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

Issue:
ASP-2810 Create auth helpers in Jobbergate-core
#### What

* A package called "jobbergate-core" is published on pypi
* The package contains methods to fetch and refresh access tokens
* The source code is a part of the main Jobbergate git project
* Documentation is provided on how to use it


#### Why
As a simulation engineer working on automation, I want a python package that can help me secure an API key to make Jobbergate API requests.

`Task`: https://jira.scania.com/browse/ASP-2810

---

#### Peer Review
Please follow the upstream omnivector documentation concerning
[peer-review guidelines](https://github.com/omnivector-solutions/Documentation/blob/main/Contributing/pr_review_standards.md#peer-review).



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/prepare_release.yaml b/.github/workflows/prepare_release.yaml
index a8b7daf6..aad1e39c 100644
--- a/.github/workflows/prepare_release.yaml
+++ b/.github/workflows/prepare_release.yaml
@@ -16,27 +16,27 @@ on:
         type: choice
         description: Bump version rule for Poetry
         options:
-        - patch
-        - minor
-        - major
-        - prepatch
-        - preminor
-        - premajor
-        - prerelease
+          - patch
+          - minor
+          - major
+          - prepatch
+          - preminor
+          - premajor
+          - prerelease
 
 jobs:
   bump-version:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
 
-      - uses: actions/setup-python@v2
+      - uses: actions/setup-python@v4
         with:
           python-version: 3.8
 
-      - uses: Gr1N/setup-poetry@v7
+      - uses: Gr1N/setup-poetry@v8
         with:
-          poetry-version: 1.1.14
+          poetry-version: 1.5.1
 
       - id: api-package
         name: Bump version on the API
@@ -54,6 +54,14 @@ jobs:
           echo "Poetry version is: $(poetry version --short)"
           echo "::set-output name=version::$(poetry version --short)"
 
+      - id: core-package
+        name: Bump version on the Core
+        working-directory: jobbergate-core
+        run: |
+          ../release-scripts/prepare-release.sh ${{ github.event.inputs.bump_rule }}
+          echo "Poetry version is: $(poetry version --short)"
+          echo "::set-output name=version::$(poetry version --short)"
+
       - id: docs
         name: Bump version on the Documentation
         working-directory: jobbergate-docs
@@ -63,7 +71,7 @@ jobs:
           echo "::set-output name=version::$(poetry version --short)"
 
       - name: Fail if poetry packages version don't match
-        if: ${{ steps.api-package.outputs.version != steps.cli-package.outputs.version != steps.docs.outputs.version}}
+        if: ${{ steps.api-package.outputs.version != steps.core-package.outputs.version != steps.cli-package.outputs.version != steps.docs.outputs.version}}
         run: echo "Poetry packages version don't match!"
 
       - uses: peter-evans/create-pull-request@v4
diff --git a/.github/workflows/publish-to-ecr.yaml b/.github/workflows/publish-to-ecr.yaml
index 9a6a8b57..3f02483c 100644
--- a/.github/workflows/publish-to-ecr.yaml
+++ b/.github/workflows/publish-to-ecr.yaml
@@ -5,19 +5,18 @@ on:
     inputs:
       repository:
         description: Repository environment name
-        default: 'jobbergate-api-staging'
+        default: "jobbergate-api-staging"
         required: true
       region:
         description: AWS region to which upload the package
         required: true
-        default: 'us-west-2'
+        default: "us-west-2"
 
 jobs:
   publish:
     name: Publish to ECR
     runs-on: ubuntu-latest
     steps:
-
       - name: Fail if ref is not a tag
         if: github.ref_type != 'tag'
         run: |
@@ -27,13 +26,13 @@ jobs:
 
       - uses: actions/checkout@v3
 
-      - uses: actions/setup-python@v3
+      - uses: actions/setup-python@v4
         with:
           python-version: 3.8
 
-      - uses: Gr1N/setup-poetry@v7
+      - uses: Gr1N/setup-poetry@v8
         with:
-          poetry-version: 1.1.14
+          poetry-version: 1.5.1
 
       - id: poetry-package-version
         name: Get version of project from poetry
@@ -52,9 +51,9 @@ jobs:
       - name: Setup AWS credentials
         uses: aws-actions/configure-aws-credentials@v1
         with:
-          aws-access-key-id    : ${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY_ID }}
-          aws-region           : ${{ github.event.inputs.region }}
+          aws-region: ${{ github.event.inputs.region }}
 
       - name: Login to Amazon ECR
         id: login-ecr
diff --git a/.github/workflows/publish_docs.yaml b/.github/workflows/publish_docs.yaml
index 5fcf1da9..1065eb9e 100644
--- a/.github/workflows/publish_docs.yaml
+++ b/.github/workflows/publish_docs.yaml
@@ -1,11 +1,11 @@
-name: 'Publish Docs'
+name: "Publish Docs"
 
 on:
   push:
     tags:
-      - '[0-9]+.[0-9]+.[0-9]+'
-      - '[0-9]+.[0-9]+.[0-9]+-alpha.[0-9]+'
-      - '[0-9]+.[0-9]+.[0-9]+-beta.[0-9]+'
+      - "[0-9]+.[0-9]+.[0-9]+"
+      - "[0-9]+.[0-9]+.[0-9]+-alpha.[0-9]+"
+      - "[0-9]+.[0-9]+.[0-9]+-beta.[0-9]+"
 
 jobs:
   publish:
@@ -16,7 +16,7 @@ jobs:
         python-version: [3.8]
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
 
       - name: Configure AWS credentials
         uses: aws-actions/configure-aws-credentials@v1
@@ -25,13 +25,13 @@ jobs:
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY_ID }}
           aws-region: us-west-2
 
-      - uses: actions/setup-python@v2
+      - uses: actions/setup-python@v4
         with:
           python-version: ${{ matrix.python-version }}
 
-      - uses: Gr1N/setup-poetry@v7
+      - uses: Gr1N/setup-poetry@v8
         with:
-          poetry-version: 1.1.14
+          poetry-version: 1.5.1
 
       - name: Build docs
         working-directory: ./jobbergate-docs
diff --git a/.github/workflows/publish_on_tag.yaml b/.github/workflows/publish_on_tag.yaml
index b760f3c7..573379b0 100644
--- a/.github/workflows/publish_on_tag.yaml
+++ b/.github/workflows/publish_on_tag.yaml
@@ -8,26 +8,27 @@ name: Publish on Tag
 on:
   push:
     tags:
-      - '[0-9]+.[0-9]+.[0-9]+'
-      - '[0-9]+.[0-9]+.[0-9]+-alpha.[0-9]+'
-      - '[0-9]+.[0-9]+.[0-9]+-beta.[0-9]+'
+      - "[0-9]+.[0-9]+.[0-9]+"
+      - "[0-9]+.[0-9]+.[0-9]+-alpha.[0-9]+"
+      - "[0-9]+.[0-9]+.[0-9]+-beta.[0-9]+"
 
 jobs:
   build-publish:
     runs-on: ubuntu-latest
     strategy:
-      matrix: { sub_project: ['jobbergate-api', 'jobbergate-cli']}
+      matrix:
+        { sub_project: ["jobbergate-api", "jobbergate-cli", "jobbergate-core"] }
       fail-fast: false
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
 
-      - uses: actions/setup-python@v2
+      - uses: actions/setup-python@v4
         with:
           python-version: 3.8
-  
-      - uses: Gr1N/setup-poetry@v7
+
+      - uses: Gr1N/setup-poetry@v8
         with:
-          poetry-version: 1.1.14
+          poetry-version: 1.5.1
 
       - id: poetry-package-version
         name: Get project's version
diff --git a/.github/workflows/tag_on_merged_pull_request.yaml b/.github/workflows/tag_on_merged_pull_request.yaml
index f08811d9..400d6f70 100644
--- a/.github/workflows/tag_on_merged_pull_request.yaml
+++ b/.github/workflows/tag_on_merged_pull_request.yaml
@@ -8,7 +8,7 @@ name: Create tag when released is accepted
 
 on:
   pull_request:
-    branches: 
+    branches:
       - main
     types: [closed]
 
@@ -17,7 +17,7 @@ jobs:
     if: (github.event.pull_request.merged && startsWith(github.head_ref, 'release/'))
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
         with:
           token: ${{ secrets.TOKEN }}
 
diff --git a/jobbergate-api/Dockerfile b/jobbergate-api/Dockerfile
index 189987b7..705f9ded 100644
--- a/jobbergate-api/Dockerfile
+++ b/jobbergate-api/Dockerfile
@@ -3,7 +3,7 @@ FROM python:3.10-slim-buster
 RUN apt update && apt install -y curl libpq-dev gcc
 
 RUN curl -sSL https://install.python-poetry.org | \
-    POETRY_HOME=/opt/poetry POETRY_VERSION=1.4.2 python && \
+    POETRY_HOME=/opt/poetry POETRY_VERSION=1.5.1 python && \
     cd /usr/local/bin && \
     ln -s /opt/poetry/bin/poetry && \
     poetry config virtualenvs.create false
diff --git a/jobbergate-api/alembic/versions/20230801_071440--3bc095975b53_initial_migration_for_jobbergate_4.py b/jobbergate-api/alembic/versions/20230801_071440--3bc095975b53_initial_migration_for_jobbergate_4.py
index 4b135320..d000c9cc 100644
--- a/jobbergate-api/alembic/versions/20230801_071440--3bc095975b53_initial_migration_for_jobbergate_4.py
+++ b/jobbergate-api/alembic/versions/20230801_071440--3bc095975b53_initial_migration_for_jobbergate_4.py
@@ -34,6 +34,7 @@ def upgrade():
         sa.Column("owner_email", sa.String(), nullable=False),
         sa.Column("name", sa.String(), nullable=False),
         sa.Column("description", sa.String(), nullable=True),
+        sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.false()),
         sa.PrimaryKeyConstraint("id"),
     )
     op.create_index(
@@ -64,6 +65,7 @@ def upgrade():
         sa.Column("owner_email", sa.String(), nullable=False),
         sa.Column("name", sa.String(), nullable=False),
         sa.Column("description", sa.String(), nullable=True),
+        sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.false()),
         sa.ForeignKeyConstraint(["parent_template_id"], ["job_script_templates.id"], ondelete="SET NULL"),
         sa.PrimaryKeyConstraint("id"),
     )
@@ -129,6 +131,7 @@ def upgrade():
         sa.Column("owner_email", sa.String(), nullable=False),
         sa.Column("name", sa.String(), nullable=False),
         sa.Column("description", sa.String(), nullable=True),
+        sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.false()),
         sa.ForeignKeyConstraint(
             ["job_script_id"],
             ["job_scripts.id"],
diff --git a/jobbergate-api/jobbergate_api/apps/dependecies.py b/jobbergate-api/jobbergate_api/apps/dependecies.py
index 8b24603a..41ebf7ed 100644
--- a/jobbergate-api/jobbergate_api/apps/dependecies.py
+++ b/jobbergate-api/jobbergate_api/apps/dependecies.py
@@ -60,6 +60,9 @@ def secure_services(
     *scopes: str,
     permission_mode: PermissionMode = PermissionMode.ALL,
     services: list[DatabaseBoundService] | None = None,
+    ensure_email: bool = False,
+    ensure_organization: bool = False,
+    ensure_client_id: bool = False,
 ):
     """
     Dependency to bind database services to a secure session.
@@ -68,7 +71,15 @@ def secure_services(
         services = []
 
     async def dependency(
-        secure_session: SecureSession = Depends(secure_session(*scopes, permission_mode=permission_mode)),
+        secure_session: SecureSession = Depends(
+            secure_session(
+                *scopes,
+                permission_mode=permission_mode,
+                ensure_email=ensure_email,
+                ensure_organization=ensure_organization,
+                ensure_client_id=ensure_client_id,
+            )
+        ),
     ) -> AsyncIterator[SecureSession]:
         """
         Bind each service to the secure session and then return the session.
diff --git a/jobbergate-api/jobbergate_api/apps/job_script_templates/models.py b/jobbergate-api/jobbergate_api/apps/job_script_templates/models.py
index 977fc453..9ec31b00 100644
--- a/jobbergate-api/jobbergate_api/apps/job_script_templates/models.py
+++ b/jobbergate-api/jobbergate_api/apps/job_script_templates/models.py
@@ -6,21 +6,27 @@
 
 from sqlalchemy import Enum, ForeignKey, Integer, String, text
 from sqlalchemy.dialects.postgresql import JSONB
-from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.orm import Mapped, mapped_column, relationship, selectinload
+from sqlalchemy.sql.expression import Select
 
 from jobbergate_api.apps.constants import FileType
 from jobbergate_api.apps.models import Base, CrudMixin, FileMixin
+from jobbergate_api.safe_types import JobScript
 
 
 class JobScriptTemplate(CrudMixin, Base):
     """
     Job script template table definition.
 
+    Notice all relationships are lazy="raise" to prevent n+1 implicit queries.
+    This means that the relationships must be explicitly eager loaded using
+    helper functions in the class.
+
     Attributes:
         identifier: The identifier of the job script template.
         template_vars: The template variables of the job script template.
 
-    See Mixin class definitions for other columns
+    See Mixin class definitions for other columns.
     """
 
     identifier: Mapped[Optional[str]] = mapped_column(String, unique=True, index=True)
@@ -35,18 +41,25 @@ class JobScriptTemplate(CrudMixin, Base):
     template_files: Mapped[list["JobScriptTemplateFile"]] = relationship(
         "JobScriptTemplateFile",
         back_populates="parent",
-        lazy="selectin",
+        lazy="raise",
         uselist=True,
         cascade="all, delete-orphan",
     )
     workflow_files: Mapped[list["WorkflowFile"]] = relationship(
         "WorkflowFile",
         back_populates="parent",
-        lazy="selectin",
+        lazy="raise",
         uselist=True,
         cascade="all, delete-orphan",
     )
 
+    scripts: Mapped[list[JobScript]] = relationship(
+        "JobScript",
+        back_populates="template",
+        lazy="raise",
+        uselist=True,
+    )
+
     @classmethod
     def searchable_fields(cls):
         """
@@ -61,6 +74,13 @@ def sortable_fields(cls):
         """
         return {cls.identifier, *super().sortable_fields()}
 
+    @classmethod
+    def include_files(cls, query: Select) -> Select:
+        """
+        Include custom options on a query to eager load files.
+        """
+        return query.options(selectinload(cls.template_files), selectinload(cls.workflow_files))
+
 
 class JobScriptTemplateFile(FileMixin, Base):
     """
diff --git a/jobbergate-api/jobbergate_api/apps/job_script_templates/routers.py b/jobbergate-api/jobbergate_api/apps/job_script_templates/routers.py
index 6366874a..e74486b1 100644
--- a/jobbergate-api/jobbergate_api/apps/job_script_templates/routers.py
+++ b/jobbergate-api/jobbergate_api/apps/job_script_templates/routers.py
@@ -15,11 +15,12 @@
 from jobbergate_api.apps.job_script_templates.models import JobScriptTemplateFile, WorkflowFile
 from jobbergate_api.apps.job_script_templates.schemas import (
     JobTemplateCreateRequest,
-    JobTemplateResponse,
+    JobTemplateDetailedView,
+    JobTemplateListView,
     JobTemplateUpdateRequest,
     RunTimeConfig,
-    TemplateFileResponse,
-    WorkflowFileResponse,
+    TemplateFileDetailedView,
+    WorkflowFileDetailedView,
 )
 from jobbergate_api.apps.job_script_templates.services import (
     crud_service,
@@ -27,6 +28,7 @@
     workflow_file_service,
 )
 from jobbergate_api.apps.permissions import Permissions
+from jobbergate_api.apps.schemas import ListParams
 from jobbergate_api.storage import SecureSession, secure_session
 
 router = APIRouter(prefix="/job-script-templates", tags=["Job Script Templates"])
@@ -35,7 +37,7 @@
 @router.post(
     "",
     status_code=status.HTTP_201_CREATED,
-    response_model=JobTemplateResponse,
+    response_model=JobTemplateDetailedView,
     description="Endpoint for job script template creation",
 )
 async def job_script_template_create(
@@ -69,27 +71,23 @@ async def job_script_template_create(
 @router.get(
     "/{id_or_identifier}",
     description="Endpoint to return a job script template by its id or identifier",
-    response_model=JobTemplateResponse,
+    response_model=JobTemplateDetailedView,
     dependencies=[Depends(secure_services(Permissions.JOB_TEMPLATES_VIEW, services=[crud_service]))],
 )
 async def job_script_template_get(id_or_identifier: int | str = Path()):
     """Get a job script template by id or identifier."""
     logger.info(f"Getting job script template with {id_or_identifier=}")
-
-    return await crud_service.get(id_or_identifier)
+    return await crud_service.get(id_or_identifier, include_files=True)
 
 
 @router.get(
     "",
     description="Endpoint to return a list of job script templates",
-    response_model=Page[JobTemplateResponse],
+    response_model=Page[JobTemplateListView],
 )
 async def job_script_template_get_list(
-    user_only: bool = Query(False),
+    list_params: ListParams = Depends(),
     include_null_identifier: bool = Query(False),
-    search: str | None = Query(None),
-    sort_field: str | None = Query(None),
-    sort_ascending: bool = Query(True),
     secure_session: SecureSession = Depends(
         secure_services(Permissions.JOB_TEMPLATES_VIEW, services=[crud_service])
     ),
@@ -97,12 +95,14 @@ async def job_script_template_get_list(
     """Get a list of job script templates."""
     logger.debug("Preparing to list job script templates")
 
+    list_kwargs = list_params.dict(exclude_unset=True, exclude={"user_only", "include_parent"})
+
+    if list_params.user_only:
+        list_kwargs["owner_email"] = secure_session.identity_payload.email
+
     return await crud_service.paginated_list(
+        **list_kwargs,
         include_null_identifier=include_null_identifier,
-        user_email=secure_session.identity_payload.email if user_only else None,
-        search=search,
-        sort_field=sort_field,
-        sort_ascending=sort_ascending,
     )
 
 
@@ -110,15 +110,20 @@ async def job_script_template_get_list(
     "/{id_or_identifier}",
     status_code=status.HTTP_200_OK,
     description="Endpoint to update a job script template by id or identifier",
-    response_model=JobTemplateResponse,
-    dependencies=[Depends(secure_services(Permissions.JOB_TEMPLATES_EDIT, services=[crud_service]))],
+    response_model=JobTemplateDetailedView,
 )
 async def job_script_template_update(
     update_request: JobTemplateUpdateRequest,
     id_or_identifier: int | str = Path(),
+    secure_session: SecureSession = Depends(
+        secure_services(Permissions.JOB_TEMPLATES_EDIT, services=[crud_service], ensure_email=True)
+    ),
 ):
     """Update a job script template by id or identifier."""
     logger.info(f"Updating job script template {id_or_identifier=} with {update_request=}")
+    await crud_service.get(
+        id_or_identifier, ensure_attributes={"owner_email": secure_session.identity_payload.email}
+    )
     return await crud_service.update(id_or_identifier, **update_request.dict(exclude_unset=True))
 
 
@@ -126,13 +131,18 @@ async def job_script_template_update(
     "/{id_or_identifier}",
     status_code=status.HTTP_204_NO_CONTENT,
     description="Endpoint to delete a job script template by id or identifier",
-    dependencies=[Depends(secure_services(Permissions.JOB_TEMPLATES_EDIT, services=[crud_service]))],
 )
 async def job_script_template_delete(
     id_or_identifier: int | str = Path(),
+    secure_session: SecureSession = Depends(
+        secure_services(Permissions.JOB_TEMPLATES_EDIT, services=[crud_service], ensure_email=True)
+    ),
 ):
     """Delete a job script template by id or identifier."""
     logger.info(f"Deleting job script template with {id_or_identifier=}")
+    await crud_service.get(
+        id_or_identifier, ensure_attributes={"owner_email": secure_session.identity_payload.email}
+    )
     await crud_service.delete(id_or_identifier)
     return FastAPIResponse(status_code=status.HTTP_204_NO_CONTENT)
 
@@ -171,18 +181,18 @@ async def job_script_template_get_file(
     "/{id_or_identifier}/upload/template/{file_type}",
     status_code=status.HTTP_200_OK,
     description="Endpoint to upload a file to a job script template by id or identifier",
-    response_model=TemplateFileResponse,
-    dependencies=[
-        Depends(
-            secure_services(Permissions.JOB_TEMPLATES_EDIT, services=[crud_service, template_file_service])
-        ),
-        Depends(file_services(template_file_service)),
-    ],
+    response_model=TemplateFileDetailedView,
+    dependencies=[Depends(file_services(template_file_service))],
 )
 async def job_script_template_upload_file(
     id_or_identifier: int | str = Path(),
     file_type: FileType = Path(),
     upload_file: UploadFile = File(..., description="File to upload"),
+    secure_session: SecureSession = Depends(
+        secure_services(
+            Permissions.JOB_TEMPLATES_EDIT, services=[crud_service, template_file_service], ensure_email=True
+        )
+    ),
 ):
     """Upload a file to a job script template by id or identifier."""
     if upload_file.filename is None:
@@ -192,7 +202,10 @@ async def job_script_template_upload_file(
         )
 
     logger.debug(f"Uploading file {upload_file.filename} to job script template {id_or_identifier=}")
-    job_script_template = await crud_service.get(id_or_identifier)
+    job_script_template = await crud_service.get(
+        id_or_identifier, ensure_attributes={"owner_email": secure_session.identity_payload.email}
+    )
+
     return await template_file_service.upsert(
         parent_id=job_script_template.id,
         filename=upload_file.filename,
@@ -205,19 +218,21 @@ async def job_script_template_upload_file(
     "/{id_or_identifier}/upload/template/{file_name:path}",
     status_code=status.HTTP_200_OK,
     description="Endpoint to delete a file to a job script template by id or identifier",
-    dependencies=[
-        Depends(
-            secure_services(Permissions.JOB_TEMPLATES_EDIT, services=[crud_service, template_file_service])
-        ),
-        Depends(file_services(template_file_service)),
-    ],
+    dependencies=[Depends(file_services(template_file_service))],
 )
 async def job_script_template_delete_file(
     id_or_identifier: int | str = Path(),
     file_name: str = Path(),
+    secure_session: SecureSession = Depends(
+        secure_services(
+            Permissions.JOB_TEMPLATES_EDIT, services=[crud_service, template_file_service], ensure_email=True
+        )
+    ),
 ):
     """Delete a file from a job script template by id or identifier."""
-    job_script_template = await crud_service.get(id_or_identifier)
+    job_script_template = await crud_service.get(
+        id_or_identifier, ensure_attributes={"owner_email": secure_session.identity_payload.email}
+    )
     job_script_template_file = await template_file_service.get(job_script_template.id, file_name)
     await template_file_service.delete(job_script_template_file)
 
@@ -253,22 +268,24 @@ async def job_script_workflow_get_file(id_or_identifier: int | str = Path()):
     "/{id_or_identifier}/upload/workflow",
     status_code=status.HTTP_200_OK,
     description="Endpoint to upload a file to a job script template by id or identifier",
-    response_model=WorkflowFileResponse,
-    dependencies=[
-        Depends(
-            secure_services(Permissions.JOB_TEMPLATES_EDIT, services=[crud_service, workflow_file_service])
-        ),
-        Depends(file_services(workflow_file_service)),
-    ],
+    response_model=WorkflowFileDetailedView,
+    dependencies=[Depends(file_services(workflow_file_service))],
 )
 async def job_script_workflow_upload_file(
     id_or_identifier: int | str = Path(),
     runtime_config: RunTimeConfig = Body(),
     upload_file: UploadFile = File(..., description="File to upload"),
+    secure_session: SecureSession = Depends(
+        secure_services(
+            Permissions.JOB_TEMPLATES_EDIT, services=[crud_service, workflow_file_service], ensure_email=True
+        )
+    ),
 ):
     """Upload a file to a job script workflow by id or identifier."""
     logger.debug(f"Uploading workflow file to job script template {id_or_identifier=}: {runtime_config}")
-    job_script_template = await crud_service.get(id_or_identifier)
+    job_script_template = await crud_service.get(
+        id_or_identifier, ensure_attributes={"owner_email": secure_session.identity_payload.email}
+    )
     return await workflow_file_service.upsert(
         parent_id=job_script_template.id,
         filename=WORKFLOW_FILE_NAME,
@@ -281,16 +298,21 @@ async def job_script_workflow_upload_file(
     "/{id_or_identifier}/upload/workflow",
     status_code=status.HTTP_200_OK,
     description="Endpoint to delete a workflow file from a job script template by id or identifier",
-    dependencies=[
-        Depends(
-            secure_services(Permissions.JOB_TEMPLATES_EDIT, services=[crud_service, workflow_file_service])
-        ),
-        Depends(file_services(workflow_file_service)),
-    ],
+    dependencies=[Depends(file_services(workflow_file_service))],
 )
-async def job_script_workflow_delete_file(id_or_identifier: int | str = Path()):
+async def job_script_workflow_delete_file(
+    id_or_identifier: int | str = Path(),
+    secure_session: SecureSession = Depends(
+        secure_services(
+            Permissions.JOB_TEMPLATES_EDIT, services=[crud_service, workflow_file_service], ensure_email=True
+        )
+    ),
+):
     """Delete a workflow file from a job script template by id or identifier."""
-    job_script_template = await crud_service.get(id_or_identifier)
+    logger.debug(f"Deleting workflow file from job script template {id_or_identifier=}")
+    job_script_template = await crud_service.get(
+        id_or_identifier, ensure_attributes={"owner_email": secure_session.identity_payload.email}
+    )
     workflow_file = await workflow_file_service.get(job_script_template.id, WORKFLOW_FILE_NAME)
     await workflow_file_service.delete(workflow_file)
 
diff --git a/jobbergate-api/jobbergate_api/apps/job_script_templates/schemas.py b/jobbergate-api/jobbergate_api/apps/job_script_templates/schemas.py
index d6f12e0c..7579b7b4 100644
--- a/jobbergate-api/jobbergate_api/apps/job_script_templates/schemas.py
+++ b/jobbergate-api/jobbergate_api/apps/job_script_templates/schemas.py
@@ -68,6 +68,10 @@
     workflow_file=MetaField(
         description="The workflow file attached to a job script template",
     ),
+    is_archived=MetaField(
+        description="Indicates if the job script template has been archived.",
+        example=False,
+    ),
 )
 
 
@@ -104,29 +108,28 @@ class JobTemplateCreateRequest(BaseModel):
     """Schema for the request to create a job template."""
 
     name: str
-    identifier: Optional[str]
-    description: Optional[str]
-    template_vars: Optional[dict[str, Any]]
+    identifier: str | None
+    description: str | None
+    template_vars: dict[str, Any] | None
 
     class Config:
-        orm_mode = True
         schema_extra = job_template_meta_mapper
 
 
 class JobTemplateUpdateRequest(BaseModel):
     """Schema for the request to update a job template."""
 
-    name: Optional[str]
-    identifier: Optional[str]
-    description: Optional[str]
-    template_vars: Optional[dict[str, Any]]
+    name: str | None
+    identifier: str | None
+    description: str | None
+    template_vars: dict[str, Any] | None
+    is_archived: bool | None
 
     class Config:
-        orm_mode = True
         schema_extra = job_template_meta_mapper
 
 
-class TemplateFileResponse(BaseModel):
+class TemplateFileDetailedView(BaseModel):
     """Schema for the response to get a template file."""
 
     parent_id: int
@@ -140,7 +143,7 @@ class Config:
         schema_extra = job_template_meta_mapper
 
 
-class WorkflowFileResponse(BaseModel):
+class WorkflowFileDetailedView(BaseModel):
     """Schema for the response to get a workflow file."""
 
     parent_id: int
@@ -154,15 +157,24 @@ class Config:
         schema_extra = job_template_meta_mapper
 
 
-class JobTemplateResponse(TableResource):
-    """Schema for the request to create a job template."""
+class JobTemplateListView(TableResource):
+    """Schema for the response to get a list of entries."""
 
     identifier: Optional[str]
-    template_vars: Optional[dict[str, Any]] = {}
-
-    template_files: list[TemplateFileResponse] = []
-    workflow_files: list[WorkflowFileResponse] = []
 
     class Config:
-        orm_mode = True
         schema_extra = job_template_meta_mapper
+
+
+class JobTemplateDetailedView(JobTemplateListView):
+    """
+    Schema for the request to an entry.
+
+    Notice the files default to None, as they are not always requested, to differentiate between
+    an empty list when they are requested, but no file is found.
+    """
+
+    template_vars: dict[str, Any] | None
+
+    template_files: list[TemplateFileDetailedView] | None
+    workflow_files: list[WorkflowFileDetailedView] | None
diff --git a/jobbergate-api/jobbergate_api/apps/job_script_templates/services.py b/jobbergate-api/jobbergate_api/apps/job_script_templates/services.py
index 0e1a8b5d..bac7e1f1 100644
--- a/jobbergate-api/jobbergate_api/apps/job_script_templates/services.py
+++ b/jobbergate-api/jobbergate_api/apps/job_script_templates/services.py
@@ -20,18 +20,22 @@ class JobScriptTemplateService(CrudService):
     def build_list_query(
         self,
         sort_ascending: bool = True,
-        user_email: str | None = None,
         search: str | None = None,
         sort_field: str | None = None,
+        include_archived: bool = True,
+        include_files: bool = False,
+        include_parent: bool = False,
         include_null_identifier: bool = True,
         **additional_filters,
     ) -> Select:
         """List all job_script_templates."""
         query: Select = super().build_list_query(
             sort_ascending=sort_ascending,
-            user_email=user_email,
             search=search,
             sort_field=sort_field,
+            include_archived=include_archived,
+            include_files=include_files,
+            include_parent=include_parent,
             **additional_filters,
         )
         if not include_null_identifier:
diff --git a/jobbergate-api/jobbergate_api/apps/job_scripts/models.py b/jobbergate-api/jobbergate_api/apps/job_scripts/models.py
index b83c01f7..107a2679 100644
--- a/jobbergate-api/jobbergate_api/apps/job_scripts/models.py
+++ b/jobbergate-api/jobbergate_api/apps/job_scripts/models.py
@@ -5,21 +5,27 @@
 from __future__ import annotations
 
 from sqlalchemy import Enum, ForeignKey, Integer
-from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.orm import Mapped, mapped_column, relationship, selectinload
+from sqlalchemy.sql.expression import Select
 
 from jobbergate_api.apps.constants import FileType
+from jobbergate_api.apps.job_script_templates.models import JobScriptTemplate as JobScriptTemplateModel
 from jobbergate_api.apps.models import Base, CrudMixin, FileMixin
-from jobbergate_api.safe_types import JobSubmission
+from jobbergate_api.safe_types import JobScriptTemplate, JobSubmission
 
 
 class JobScript(CrudMixin, Base):
     """
     Job script table definition.
 
+    Notice all relationships are lazy="raise" to prevent n+1 implicit queries.
+    This means that the relationships must be explicitly eager loaded using
+    helper functions in the class.
+
     Attributes:
         parent_template_id: The id of the parent template.
 
-    See Mixin class definitions for other columns
+    See Mixin class definitions for other columns.
     """
 
     parent_template_id: Mapped[int] = mapped_column(
@@ -31,15 +37,20 @@ class JobScript(CrudMixin, Base):
     files: Mapped[list[JobScriptFile]] = relationship(
         "JobScriptFile",
         back_populates="parent",
-        lazy="selectin",
+        lazy="raise",
         uselist=True,
         cascade="all, delete-orphan",
     )
 
+    template: Mapped[JobScriptTemplate] = relationship(
+        "JobScriptTemplate",
+        back_populates="scripts",
+        lazy="raise",
+    )
     submissions: Mapped[list[JobSubmission]] = relationship(
         "JobSubmission",
         back_populates="job_script",
-        lazy="selectin",
+        lazy="raise",
         uselist=True,
     )
 
@@ -57,6 +68,24 @@ def sortable_fields(cls):
         """
         return {cls.parent_template_id, *super().sortable_fields()}
 
+    @classmethod
+    def include_files(cls, query: Select) -> Select:
+        """
+        Include custom options on a query to eager load files.
+        """
+        return query.options(selectinload(cls.files))
+
+    @classmethod
+    def include_parent(cls, query: Select) -> Select:
+        """
+        Include custom options on a query to eager load parent data.
+        """
+        return query.options(
+            selectinload(cls.template).load_only(
+                JobScriptTemplateModel.id, JobScriptTemplateModel.identifier, JobScriptTemplateModel.name
+            )
+        )
+
 
 class JobScriptFile(FileMixin, Base):
     """
diff --git a/jobbergate-api/jobbergate_api/apps/job_scripts/routers.py b/jobbergate-api/jobbergate_api/apps/job_scripts/routers.py
index e992d173..351ecbfd 100644
--- a/jobbergate-api/jobbergate_api/apps/job_scripts/routers.py
+++ b/jobbergate-api/jobbergate_api/apps/job_scripts/routers.py
@@ -1,5 +1,5 @@
 """Router for the Job Script Template resource."""
-from typing import Any, cast
+from typing import cast
 
 from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, Path, Query
 from fastapi import Response as FastAPIResponse
@@ -17,13 +17,15 @@
 from jobbergate_api.apps.job_scripts.models import JobScriptFile
 from jobbergate_api.apps.job_scripts.schemas import (
     JobScriptCreateRequest,
-    JobScriptResponse,
+    JobScriptDetailedView,
+    JobScriptListView,
     JobScriptUpdateRequest,
     RenderFromTemplateRequest,
 )
 from jobbergate_api.apps.job_scripts.services import crud_service, file_service
 from jobbergate_api.apps.job_scripts.tools import inject_sbatch_params
 from jobbergate_api.apps.permissions import Permissions
+from jobbergate_api.apps.schemas import ListParams
 from jobbergate_api.storage import SecureSession, secure_session
 
 router = APIRouter(prefix="/job-scripts", tags=["Job Scripts"])
@@ -32,7 +34,7 @@
 @router.post(
     "",
     status_code=status.HTTP_201_CREATED,
-    response_model=JobScriptResponse,
+    response_model=JobScriptDetailedView,
     description="Endpoint for creating a stand alone job script. Use file upload to add files.",
 )
 async def job_script_create(
@@ -61,7 +63,7 @@ async def job_script_create(
 @router.post(
     "/render-from-template/{id_or_identifier}",
     status_code=status.HTTP_201_CREATED,
-    response_model=JobScriptResponse,
+    response_model=JobScriptDetailedView,
     description="Endpoint for job script creation",
     dependencies=[Depends(file_services(file_service, template_file_service))],
 )
@@ -85,7 +87,9 @@ async def job_script_create_from_template(
             detail="The token payload does not contain an email",
         )
 
-    base_template = cast(JobScriptTemplate, await template_crud_service.get(id_or_identifier))
+    base_template = cast(
+        JobScriptTemplate, await template_crud_service.get(id_or_identifier, include_files=True)
+    )
 
     required_map = render_request.template_output_name_mapping
     entrypoint_key = list(required_map.values())[0]
@@ -137,32 +141,28 @@ async def job_script_create_from_template(
             file_type=file_type,
         )
 
-    await secure_session.session.refresh(job_script)
-    return job_script
+    return await crud_service.get(job_script.id, include_files=True)
 
 
 @router.get(
     "/{id}",
     description="Endpoint to return a job script by its id",
-    response_model=JobScriptResponse,
+    response_model=JobScriptDetailedView,
     dependencies=[Depends(secure_services(Permissions.JOB_SCRIPTS_VIEW, services=[crud_service]))],
 )
 async def job_script_get(id: int = Path()):
     """Get a job script by id."""
     logger.info(f"Getting job script {id=}")
-    return await crud_service.get(id)
+    return await crud_service.get(id, include_files=True)
 
 
 @router.get(
     "",
     description="Endpoint to return a list of job scripts",
-    response_model=Page[JobScriptResponse],
+    response_model=Page[JobScriptListView],
 )
 async def job_script_get_list(
-    user_only: bool = Query(False),
-    search: str | None = Query(None),
-    sort_field: str | None = Query(None),
-    sort_ascending: bool = Query(True),
+    list_params: ListParams = Depends(),
     from_job_script_template_id: int
     | None = Query(
         None,
@@ -175,16 +175,12 @@ async def job_script_get_list(
     """Get a list of job scripts."""
     logger.debug("Preparing to list job scripts")
 
-    list_kwargs: dict[str, Any] = dict(
-        search=search,
-        sort_field=sort_field,
-        sort_ascending=sort_ascending,
-    )
+    list_kwargs = list_params.dict(exclude_unset=True, exclude={"user_only"})
 
-    if user_only:
-        list_kwargs["owner_email"] = secure_session.identity_payload.email
-    if from_job_script_template_id:
+    if from_job_script_template_id is not None:
         list_kwargs["parent_template_id"] = from_job_script_template_id
+    if list_params.user_only:
+        list_kwargs["owner_email"] = secure_session.identity_payload.email
 
     return await crud_service.paginated_list(**list_kwargs)
 
@@ -193,15 +189,18 @@ async def job_script_get_list(
     "/{id}",
     status_code=status.HTTP_200_OK,
     description="Endpoint to update a job script by id",
-    response_model=JobScriptResponse,
-    dependencies=[Depends(secure_services(Permissions.JOB_SCRIPTS_EDIT, services=[crud_service]))],
+    response_model=JobScriptListView,
 )
 async def job_script_update(
     update_params: JobScriptUpdateRequest,
     id: int = Path(),
+    secure_session: SecureSession = Depends(
+        secure_services(Permissions.JOB_SCRIPTS_EDIT, services=[crud_service], ensure_email=True)
+    ),
 ):
     """Update a job script template by id or identifier."""
     logger.info(f"Updating job script {id=} with {update_params=}")
+    await crud_service.get(id, ensure_attributes={"owner_email": secure_session.identity_payload.email})
     return await crud_service.update(id, **update_params.dict(exclude_unset=True))
 
 
@@ -209,13 +208,16 @@ async def job_script_update(
     "/{id}",
     status_code=status.HTTP_204_NO_CONTENT,
     description="Endpoint to delete a job script by id",
-    dependencies=[Depends(secure_services(Permissions.JOB_SCRIPTS_EDIT, services=[crud_service]))],
 )
 async def job_script_delete(
     id: int = Path(...),
+    secure_session: SecureSession = Depends(
+        secure_services(Permissions.JOB_SCRIPTS_EDIT, services=[crud_service], ensure_email=True)
+    ),
 ):
     """Delete a job script template by id or identifier."""
     logger.info(f"Deleting job script {id=}")
+    await crud_service.get(id, ensure_attributes={"owner_email": secure_session.identity_payload.email})
     await crud_service.delete(id)
     return FastAPIResponse(status_code=status.HTTP_204_NO_CONTENT)
 
@@ -251,15 +253,17 @@ async def job_script_get_file(
     "/{id}/upload/{file_type}",
     status_code=status.HTTP_200_OK,
     description="Endpoint to upload a file to a job script file",
-    dependencies=[
-        Depends(secure_services(Permissions.JOB_SCRIPTS_EDIT, services=[crud_service, file_service])),
-        Depends(file_services(file_service)),
-    ],
+    dependencies=[Depends(file_services(file_service))],
 )
 async def job_script_upload_file(
     id: int = Path(...),
     file_type: FileType = Path(...),
     upload_file: UploadFile = File(..., description="File to upload"),
+    secure_session: SecureSession = Depends(
+        secure_services(
+            Permissions.JOB_SCRIPTS_EDIT, services=[crud_service, file_service], ensure_email=True
+        )
+    ),
 ):
     """Upload a file to a job script."""
     if upload_file.filename is None:
@@ -269,7 +273,11 @@ async def job_script_upload_file(
         )
 
     logger.debug(f"Uploading file {upload_file.filename} to job script {id=}")
-    job_script = await crud_service.get(id)
+
+    job_script = await crud_service.get(
+        id, ensure_attributes={"owner_email": secure_session.identity_payload.email}
+    )
+
     await file_service.upsert(
         parent_id=job_script.id,
         filename=upload_file.filename,
@@ -282,17 +290,21 @@ async def job_script_upload_file(
     "/{id}/upload/{file_name}",
     status_code=status.HTTP_200_OK,
     description="Endpoint to delete a file from a job script",
-    dependencies=[
-        Depends(secure_services(Permissions.JOB_SCRIPTS_EDIT, services=[crud_service, file_service])),
-        Depends(file_services(file_service)),
-    ],
+    dependencies=[Depends(file_services(file_service))],
 )
 async def job_script_delete_file(
     id: int = Path(...),
     file_name: str = Path(...),
+    secure_session: SecureSession = Depends(
+        secure_services(
+            Permissions.JOB_SCRIPTS_EDIT, services=[crud_service, file_service], ensure_email=True
+        )
+    ),
 ):
     """Delete a file from a job script template by id or identifier."""
-    job_script = await crud_service.get(id)
+    job_script = await crud_service.get(
+        id, ensure_attributes={"owner_email": secure_session.identity_payload.email}
+    )
     job_script_file = await file_service.get(job_script.id, file_name)
     await file_service.delete(job_script_file)
 
diff --git a/jobbergate-api/jobbergate_api/apps/job_scripts/schemas.py b/jobbergate-api/jobbergate_api/apps/job_scripts/schemas.py
index 20a6732c..4d0300ab 100644
--- a/jobbergate-api/jobbergate_api/apps/job_scripts/schemas.py
+++ b/jobbergate-api/jobbergate_api/apps/job_scripts/schemas.py
@@ -8,6 +8,8 @@
 from pydantic import BaseModel
 
 from jobbergate_api.apps.constants import FileType
+from jobbergate_api.apps.job_script_templates.schemas import JobTemplateListView
+from jobbergate_api.apps.schemas import TableResource
 from jobbergate_api.meta_mapper import MetaField, MetaMapper
 
 job_script_meta_mapper = MetaMapper(
@@ -68,6 +70,10 @@
         description="The type of the file",
         example=FileType.ENTRYPOINT.value,
     ),
+    is_archived=MetaField(
+        description="Indicates if the job script has been archived.",
+        example=False,
+    ),
 )
 
 
@@ -99,11 +105,13 @@ class JobScriptUpdateRequest(JobScriptCreateRequest):
     Request model for updating JobScript instances.
     """
 
+    is_archived: bool | None
+
     class Config:
         schema_extra = job_script_meta_mapper
 
 
-class JobScriptFile(BaseModel):
+class JobScriptFileDetailedView(BaseModel):
     """Model for the job_script_files field of the JobScript resource."""
 
     parent_id: int
@@ -117,18 +125,17 @@ class Config:
         schema_extra = job_script_meta_mapper
 
 
-class JobScriptResponse(BaseModel):
+class JobScriptListView(TableResource):
     """Model to match database for the JobScript resource."""
 
-    id: int
-    name: str
-    owner_email: str
-    files: list[JobScriptFile]
-    description: str
-    created_at: datetime
-    updated_at: datetime
     parent_template_id: int | None
+    template: JobTemplateListView | None
 
     class Config:
-        orm_mode = True
         schema_extra = job_script_meta_mapper
+
+
+class JobScriptDetailedView(JobScriptListView):
+    """Model to match database for the JobScript resource."""
+
+    files: list[JobScriptFileDetailedView] | None
diff --git a/jobbergate-api/jobbergate_api/apps/job_submissions/models.py b/jobbergate-api/jobbergate_api/apps/job_submissions/models.py
index 0423c766..7d15e997 100644
--- a/jobbergate-api/jobbergate_api/apps/job_submissions/models.py
+++ b/jobbergate-api/jobbergate_api/apps/job_submissions/models.py
@@ -8,8 +8,10 @@
 
 from sqlalchemy import Enum, ForeignKey, Integer, String, text
 from sqlalchemy.dialects.postgresql import JSONB
-from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.orm import Mapped, mapped_column, relationship, selectinload
+from sqlalchemy.sql.expression import Select
 
+from jobbergate_api.apps.job_scripts.models import JobScript as JobScriptModel
 from jobbergate_api.apps.job_submissions.constants import JobSubmissionStatus
 from jobbergate_api.apps.models import Base, CrudMixin
 from jobbergate_api.safe_types import JobScript
@@ -19,6 +21,10 @@ class JobSubmission(CrudMixin, Base):
     """
     Job submission table definition.
 
+    Notice all relationships are lazy="raise" to prevent n+1 implicit queries.
+    This means that the relationships must be explicitly eager loaded using
+    helper functions in the class.
+
     Attributes:
         job_script_id: Id number of the job scrip this submissions is based on.
         execution_directory: The directory where the job is executed.
@@ -50,8 +56,7 @@ class JobSubmission(CrudMixin, Base):
     job_script: Mapped[JobScript] = relationship(
         "JobScript",
         back_populates="submissions",
-        lazy="select",
-        innerjoin=True,
+        lazy="raise",
     )
 
     @classmethod
@@ -73,3 +78,17 @@ def sortable_fields(cls):
             cls.status,
             *super().sortable_fields(),
         }
+
+    @classmethod
+    def include_files(cls, query: Select) -> Select:
+        """
+        Include custom options on a query to eager load files.
+        """
+        return query.options(selectinload(cls.job_script).options(selectinload(JobScriptModel.files)))
+
+    @classmethod
+    def include_parent(cls, query: Select) -> Select:
+        """
+        Include custom options on a query to eager load parent data.
+        """
+        return query.options(selectinload(cls.job_script).load_only(JobScriptModel.id, JobScriptModel.name))
diff --git a/jobbergate-api/jobbergate_api/apps/job_submissions/routers.py b/jobbergate-api/jobbergate_api/apps/job_submissions/routers.py
index 8e36e75c..1ebcf611 100644
--- a/jobbergate-api/jobbergate_api/apps/job_submissions/routers.py
+++ b/jobbergate-api/jobbergate_api/apps/job_submissions/routers.py
@@ -1,8 +1,6 @@
 """
 Router for the JobSubmission resource.
 """
-from typing import Any
-
 from fastapi import APIRouter, Depends, HTTPException, Path, Query
 from fastapi import Response as FastAPIResponse
 from fastapi import status
@@ -19,12 +17,14 @@
     ActiveJobSubmission,
     JobSubmissionAgentUpdateRequest,
     JobSubmissionCreateRequest,
-    JobSubmissionResponse,
+    JobSubmissionDetailedView,
+    JobSubmissionListView,
     JobSubmissionUpdateRequest,
     PendingJobSubmission,
 )
 from jobbergate_api.apps.job_submissions.services import crud_service
 from jobbergate_api.apps.permissions import Permissions
+from jobbergate_api.apps.schemas import ListParams
 from jobbergate_api.email_notification import notify_submission_rejected
 from jobbergate_api.storage import SecureSession
 
@@ -35,7 +35,7 @@
     "",
     status_code=status.HTTP_201_CREATED,
     description="Endpoint for job_submission creation",
-    response_model=JobSubmissionResponse,
+    response_model=JobSubmissionDetailedView,
     dependencies=[Depends(file_services(script_file_service))],
 )
 async def job_submission_create(
@@ -70,7 +70,7 @@ async def job_submission_create(
         )
     create_request.client_id = client_id
 
-    base_job_script = await script_crud_service.get(create_request.job_script_id)
+    base_job_script = await script_crud_service.get(create_request.job_script_id, include_files=True)
 
     job_script_files = [f for f in base_job_script.files if f.file_type == FileType.ENTRYPOINT]
 
@@ -98,7 +98,7 @@ async def job_submission_create(
 @router.get(
     "/{id}",
     description="Endpoint to get a job_submission",
-    response_model=JobSubmissionResponse,
+    response_model=JobSubmissionDetailedView,
     dependencies=[Depends(secure_services(Permissions.JOB_SUBMISSIONS_VIEW, services=[crud_service]))],
 )
 async def job_submission_get(id: int = Path(...)):
@@ -110,10 +110,10 @@ async def job_submission_get(id: int = Path(...)):
 @router.get(
     "",
     description="Endpoint to list job_submissions",
-    response_model=Page[JobSubmissionResponse],
+    response_model=Page[JobSubmissionListView],
 )
 async def job_submission_get_list(
-    user_only: bool | None = Query(False),
+    list_params: ListParams = Depends(),
     slurm_job_ids: str
     | None = Query(
         None,
@@ -124,9 +124,6 @@ async def job_submission_get_list(
         None,
         description="Limit results to those with matching status",
     ),
-    search: str | None = Query(None),
-    sort_field: str | None = Query(None),
-    sort_ascending: bool = Query(True),
     from_job_script_id: int
     | None = Query(
         None,
@@ -139,13 +136,9 @@ async def job_submission_get_list(
     """List job_submissions for the authenticated user."""
     logger.debug("Fetching job submissions")
 
-    list_kwargs: dict[str, Any] = dict(
-        search=search,
-        sort_field=sort_field,
-        sort_ascending=sort_ascending,
-    )
+    list_kwargs = list_params.dict(exclude_unset=True, exclude={"user_only", "include_archived"})
 
-    if user_only:
+    if list_params.user_only:
         list_kwargs["owner_email"] = secure_session.identity_payload.email
     if submit_status:
         list_kwargs["status"] = submit_status
@@ -160,22 +153,25 @@ async def job_submission_get_list(
                 detail="Invalid slurm_job_ids param. Must be a comma-separated list of integers",
             )
 
-    return await crud_service.paginated_list(**list_kwargs)
+    return await crud_service.paginated_list(
+        **list_kwargs,
+    )
 
 
 @router.delete(
     "/{id}",
     status_code=status.HTTP_204_NO_CONTENT,
     description="Endpoint to delete job submission",
-    dependencies=[
-        Depends(secure_services(Permissions.JOB_SUBMISSIONS_EDIT, services=[crud_service])),
-    ],
 )
 async def job_submission_delete(
     id: int = Path(..., description="id of the job submission to delete"),
+    secure_session: SecureSession = Depends(
+        secure_services(Permissions.JOB_SUBMISSIONS_EDIT, services=[crud_service], ensure_email=True)
+    ),
 ):
     """Delete job_submission given its id."""
     logger.info(f"Deleting job submission {id=}")
+    await crud_service.get(id, ensure_attributes={"owner_email": secure_session.identity_payload.email})
     await crud_service.delete(id)
     return FastAPIResponse(status_code=status.HTTP_204_NO_CONTENT)
 
@@ -184,17 +180,18 @@ async def job_submission_delete(
     "/{id}",
     status_code=status.HTTP_200_OK,
     description="Endpoint to update a job_submission given the id",
-    response_model=JobSubmissionResponse,
-    dependencies=[
-        Depends(secure_services(Permissions.JOB_SUBMISSIONS_EDIT, services=[crud_service])),
-    ],
+    response_model=JobSubmissionDetailedView,
 )
 async def job_submission_update(
     update_params: JobSubmissionUpdateRequest,
     id: int = Path(),
+    secure_session: SecureSession = Depends(
+        secure_services(Permissions.JOB_SUBMISSIONS_EDIT, services=[crud_service], ensure_email=True)
+    ),
 ):
     """Update a job_submission given its id."""
     logger.debug(f"Updating {id=} with {update_params=}")
+    await crud_service.get(id, ensure_attributes={"owner_email": secure_session.identity_payload.email})
     return await crud_service.update(id, **update_params.dict(exclude_unset=True))
 
 
@@ -203,14 +200,18 @@ async def job_submission_update(
     "/agent/{id}",
     status_code=200,
     description="Endpoint for an agent to update the status of a job_submission",
-    response_model=JobSubmissionResponse,
+    response_model=JobSubmissionDetailedView,
     tags=["Agent"],
 )
 async def job_submission_agent_update(
     update_params: JobSubmissionAgentUpdateRequest,
     id: int = Path(),
     secure_session: SecureSession = Depends(
-        secure_services(Permissions.JOB_SUBMISSIONS_EDIT, services=[crud_service])
+        secure_services(
+            Permissions.JOB_SUBMISSIONS_EDIT,
+            services=[crud_service],
+            ensure_client_id=True,
+        )
     ),
 ):
     """
@@ -220,20 +221,12 @@ async def job_submission_agent_update(
     """
     logger.debug(f"Agent is requesting to update {id=}")
 
-    client_id = secure_session.identity_payload.client_id
-    if client_id is None:
-        logger.error("Access token does not contain a client_id")
-        raise HTTPException(
-            status_code=status.HTTP_400_BAD_REQUEST,
-            detail="Access token does not contain a `client_id`. Cannot update job_submission",
-        )
-
     logger.info(
         f"Setting status to: {update_params.status} "
         f"for job_submission: {id} "
-        f"on client_id: {client_id}"
+        f"on client_id: {secure_session.identity_payload.client_id}"
     )
-
+    await crud_service.get(id, ensure_attributes={"client_id": secure_session.identity_payload.client_id})
     job_submission = await crud_service.update(id, **update_params.dict(exclude_unset=True))
 
     if update_params.report_message and update_params.status == JobSubmissionStatus.REJECTED:
@@ -259,7 +252,7 @@ async def job_submissions_agent_pending(
 
     client_id = secure_session.identity_payload.client_id
     if client_id is None:
-        message = "Access token does not contain a `client_id`. Cannot fetch pending submissions"
+        message = "Access token does not contain a client_id. Cannot fetch pending submissions"
         logger.warning(message)
         raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
 
@@ -268,7 +261,8 @@ async def job_submissions_agent_pending(
     return await crud_service.paginated_list(
         status=JobSubmissionStatus.CREATED,
         client_id=client_id,
-        eager_join=True,
+        include_files=True,
+        include_parent=True,
     )
 
 
@@ -291,7 +285,7 @@ async def job_submissions_agent_active(
         logger.error("Access token does not contain a client_id")
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,
-            detail="Access token does not contain a `client_id`. Cannot fetch pending submissions",
+            detail="Access token does not contain a client_id. Cannot fetch pending submissions",
         )
 
     logger.info(f"Fetching active job_submissions for client_id: {client_id}")
diff --git a/jobbergate-api/jobbergate_api/apps/job_submissions/schemas.py b/jobbergate-api/jobbergate_api/apps/job_submissions/schemas.py
index b3616990..176aefd8 100644
--- a/jobbergate-api/jobbergate_api/apps/job_submissions/schemas.py
+++ b/jobbergate-api/jobbergate_api/apps/job_submissions/schemas.py
@@ -1,14 +1,14 @@
 """
 JobSubmission resource schema.
 """
-from datetime import datetime
 from pathlib import Path
 from typing import Dict, List, Literal, Optional
 
 from pydantic import BaseModel, Extra, Field
 
-from jobbergate_api.apps.job_scripts.schemas import JobScriptResponse
+from jobbergate_api.apps.job_scripts.schemas import JobScriptListView
 from jobbergate_api.apps.job_submissions.constants import JobSubmissionStatus
+from jobbergate_api.apps.schemas import TableResource
 from jobbergate_api.meta_mapper import MetaField, MetaMapper
 
 job_submission_meta_mapper = MetaMapper(
@@ -316,32 +316,32 @@ class Config:
         schema_extra = job_submission_meta_mapper
 
 
-class JobSubmissionResponse(BaseModel):
+class JobSubmissionListView(TableResource):
     """
-    Complete model to match the database for the JobSubmission resource.
+    Partial model to match the database for the JobSubmission resource.
     """
 
-    id: int
-    created_at: Optional[datetime]
-    updated_at: Optional[datetime]
-    name: str
-    description: Optional[str]
-    owner_email: str
     job_script_id: Optional[int]
-    execution_directory: Optional[Path]
     slurm_job_id: Optional[int]
     client_id: str
     status: JobSubmissionStatus
-    report_message: Optional[str]
-    execution_parameters: Optional[JobProperties]
 
-    job_script: Optional[JobScriptResponse]
+    job_script: Optional[JobScriptListView]
 
     class Config:
-        orm_mode = True
         schema_extra = job_submission_meta_mapper
 
 
+class JobSubmissionDetailedView(JobSubmissionListView):
+    """
+    Complete model to match the database for the JobSubmission resource.
+    """
+
+    execution_directory: Optional[Path]
+    report_message: Optional[str]
+    execution_parameters: Optional[JobProperties]
+
+
 class PendingJobSubmission(BaseModel):
     """
     Specialized model for the cluster-agent to pull pending job_submissions.
@@ -354,7 +354,7 @@ class PendingJobSubmission(BaseModel):
     owner_email: str
     execution_directory: Optional[Path]
     execution_parameters: dict = Field(default_factory=dict)
-    job_script: Optional[JobScriptResponse]
+    job_script: Optional[JobScriptListView]
 
     class Config:
         orm_mode = True
diff --git a/jobbergate-api/jobbergate_api/apps/job_submissions/services.py b/jobbergate-api/jobbergate_api/apps/job_submissions/services.py
index 5c6b0440..f2cf876a 100644
--- a/jobbergate-api/jobbergate_api/apps/job_submissions/services.py
+++ b/jobbergate-api/jobbergate_api/apps/job_submissions/services.py
@@ -1,6 +1,5 @@
 """Services for the job_submissions resource, including module specific business logic."""
 
-from sqlalchemy.orm import joinedload
 from sqlalchemy.sql.expression import Select
 
 from jobbergate_api.apps.job_submissions.models import JobSubmission
@@ -15,10 +14,11 @@ class JobSubmissionService(CrudService):
     def build_list_query(
         self,
         sort_ascending: bool = True,
-        user_email: str | None = None,
         search: str | None = None,
         sort_field: str | None = None,
-        eager_join: bool = False,
+        include_archived: bool = True,
+        include_files: bool = False,
+        include_parent: bool = False,
         filter_slurm_job_ids: list[int] | None = None,
         **additional_filters,
     ) -> Select:
@@ -27,15 +27,15 @@ def build_list_query(
         """
         query: Select = super().build_list_query(
             sort_ascending=sort_ascending,
-            user_email=user_email,
             search=search,
             sort_field=sort_field,
+            include_archived=include_archived,
+            include_files=include_files,
+            include_parent=include_parent,
             **additional_filters,
         )
         if filter_slurm_job_ids:
             query = query.where(JobSubmission.slurm_job_id.in_(filter_slurm_job_ids))
-        if eager_join:
-            query.options(joinedload(JobSubmission.job_script))
         return query
 
 
diff --git a/jobbergate-api/jobbergate_api/apps/models.py b/jobbergate-api/jobbergate_api/apps/models.py
index 68d91204..7ccdaec4 100644
--- a/jobbergate-api/jobbergate_api/apps/models.py
+++ b/jobbergate-api/jobbergate_api/apps/models.py
@@ -4,11 +4,12 @@
 
 from inflection import tableize
 from snick import conjoin
-from sqlalchemy import DateTime, Integer, String
+from sqlalchemy import Boolean, DateTime, Integer, String
 from sqlalchemy.ext.hybrid import hybrid_property
 from sqlalchemy.inspection import inspect
 from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column
 from sqlalchemy.sql import functions
+from sqlalchemy.sql.expression import Select
 
 
 class Base(DeclarativeBase):
@@ -106,7 +107,18 @@ class NameMixin:
     description: Mapped[str | None] = mapped_column(String, default="")
 
 
-class CrudMixin(CommonMixin, IdMixin, TimestampMixin, OwnerMixin, NameMixin):
+class ArchiveMixin:
+    """
+    Add is_archived column to a table.
+
+    Attributes:
+        is_archived: Specify is a row is considered archived, hidden it by default when listing rows.
+    """
+
+    is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+
+
+class CrudMixin(CommonMixin, IdMixin, TimestampMixin, OwnerMixin, NameMixin, ArchiveMixin):
     """
     Add needed columns and declared attributes for all models that support a CrudService.
     """
@@ -135,6 +147,28 @@ def sortable_fields(cls):
             cls.updated_at,
         }
 
+    @classmethod
+    def include_files(cls, query: Select) -> Select:
+        """
+        Include custom options on a query to eager load files.
+
+        This should be overridden by derived classes.
+        """
+        raise NotImplementedError(
+            f"Derived classes should override include_files. {cls.__tablename__} does not include it."
+        )
+
+    @classmethod
+    def include_parent(cls, query: Select) -> Select:
+        """
+        Include custom options on a query to eager load parent data.
+
+        This should be overridden by derived classes.
+        """
+        raise NotImplementedError(
+            f"Derived classes should override include_parent. {cls.__tablename__} does not include it."
+        )
+
 
 class FileMixin(CommonMixin, TimestampMixin):
     """
diff --git a/jobbergate-api/jobbergate_api/apps/schemas.py b/jobbergate-api/jobbergate_api/apps/schemas.py
index 6eef0c0f..0d240624 100644
--- a/jobbergate-api/jobbergate_api/apps/schemas.py
+++ b/jobbergate-api/jobbergate_api/apps/schemas.py
@@ -1,8 +1,45 @@
 """Define app-wide, reusable pydantic schemas."""
 
 from datetime import datetime
+from typing import Any
 
+import sqlalchemy
 from pydantic import BaseModel
+from pydantic.utils import GetterDict
+
+
+class IgnoreLazyGetterDict(GetterDict):
+    """
+    A custom GetterDict to avoid triggering lazy-loads when accessing attributes.
+
+    In this way, only explicitly joined relationships will be loaded and included in the response.
+
+    References:
+        https://github.com/tiangolo/fastapi/discussions/5942
+    """
+
+    def __getitem__(self, key: str) -> Any:
+        """
+        Customize __getitem__ to avoid triggering lazy-loads when accessing attributes.
+        """
+        if self._is_lazy_loaded(key):
+            raise KeyError(f"The attribute '{key}' is not loaded.")
+        super().__getitem__(key)
+
+    def get(self, key: Any, default: Any = None) -> Any:
+        """
+        Get an attribute value from the object, or return a default value if the attribute does not exist.
+        """
+        if self._is_relationship(key) and self._is_lazy_loaded(key):
+            return default
+        return getattr(self._obj, key, default)
+
+    def _is_lazy_loaded(self, key: Any) -> bool:
+        return key in sqlalchemy.orm.attributes.instance_state(self._obj).unloaded  # type: ignore
+
+    def _is_relationship(self, key: Any):
+        relationship_keys = [r.key for r in sqlalchemy.inspect(self._obj.__class__).relationships]
+        return key in relationship_keys
 
 
 class TableResource(BaseModel):
@@ -15,4 +52,22 @@ class TableResource(BaseModel):
     owner_email: str
     created_at: datetime
     updated_at: datetime
+    is_archived: bool
     description: str | None
+
+    class Config:
+        orm_mode = True
+        getter_dict = IgnoreLazyGetterDict
+
+
+class ListParams(BaseModel):
+    """
+    Describe the shared parameters for a list request.
+    """
+
+    sort_ascending: bool = True
+    user_only: bool = False
+    search: str | None
+    sort_field: str | None
+    include_archived: bool = False
+    include_parent: bool = False
diff --git a/jobbergate-api/jobbergate_api/apps/services.py b/jobbergate-api/jobbergate_api/apps/services.py
index e66ceb24..e104540a 100644
--- a/jobbergate-api/jobbergate_api/apps/services.py
+++ b/jobbergate-api/jobbergate_api/apps/services.py
@@ -7,24 +7,25 @@
 from typing import Any, Generic, Protocol, TypeVar
 
 from botocore.response import StreamingBody
-from buzz import enforce_defined, handle_errors, require_condition
+from buzz import check_expressions, enforce_defined, handle_errors, require_condition
 from fastapi import HTTPException, UploadFile, status
 from fastapi_pagination import Page
 from fastapi_pagination.ext.sqlalchemy import paginate
 from jinja2 import Template
-from sqlalchemy import delete, func, select, update
+from loguru import logger
+from sqlalchemy import delete, func, not_, select, update
 from sqlalchemy.engine import Result
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import Mapped
 from sqlalchemy.sql.expression import Select
 
 from jobbergate_api.safe_types import Bucket
-from jobbergate_api.storage import search_clause, sort_clause
+from jobbergate_api.storage import render_sql, search_clause, sort_clause
 
 
 class ServiceError(HTTPException):
     """
-    Make HTTPException more friendly by chaning the default behavior so that the first arg is a message.
+    Make HTTPException more friendly by changing the default behavior so that the first arg is a message.
 
     Also needed to play nice with py-buzz methods.
     """
@@ -102,6 +103,7 @@ class CrudModelProto(Protocol):
 
     id: Mapped[int]
     owner_email: Mapped[str]
+    is_archived: Mapped[bool]
 
     def __init__(self, **kwargs):
         """
@@ -129,6 +131,20 @@ def sortable_fields(cls) -> set[str]:
         """
         ...
 
+    @classmethod
+    def include_files(cls, query: Select) -> Select:
+        """
+        Declare that the protocol has a method to include files in a query.
+        """
+        ...
+
+    @classmethod
+    def include_parent(cls, query: Select) -> Select:
+        """
+        Declare that the protocol has a method to include details about the parent entry in a query.
+        """
+        ...
+
 
 CrudModel = TypeVar("CrudModel", bound=CrudModelProto)
 
@@ -165,21 +181,36 @@ async def count(self) -> int:
         result: Result = await self.session.execute(select(func.count(self.model_type.id)))
         return result.scalar_one()
 
-    async def get(self, locator: Any) -> CrudModel:
+    async def get(
+        self,
+        locator: Any,
+        include_files: bool = False,
+        include_parent: bool = False,
+        ensure_attributes: dict[str, Any] | None = None,
+    ) -> CrudModel:
         """
         Get a row by locator.
 
         In almost all cases, the locator will just be an ``id`` value.
+
+        Key value pairs can be provided as ``ensure_attributes`` to assert that the
+        key fields have the specified values. This is useful to assert email ownership
+        of a row before modifying it, besides any other attribute.
         """
         query = select(self.model_type).where(self.locate_where_clause(locator))
+        if include_parent:
+            query = self.model_type.include_parent(query)
+        if include_files:
+            query = self.model_type.include_files(query)
         result: Result = await self.session.execute(query)
         instance: CrudModel = enforce_defined(
-            result.scalar_one_or_none(),
-            f"{self.model_type.__tablename__} row not found by {locator}",
+            result.unique().scalar_one_or_none(),  # type: ignore
+            f"{self.name} row not found by {locator}",
             raise_exc_class=ServiceError,
             raise_kwargs=dict(status_code=status.HTTP_404_NOT_FOUND),
         )
-        await self.session.refresh(instance)
+        if ensure_attributes:
+            self.ensure_attribute(instance, **ensure_attributes)
         return instance
 
     async def delete(self, locator: Any) -> None:
@@ -193,7 +224,7 @@ async def delete(self, locator: Any) -> None:
         deleted = list(result.scalars())
         require_condition(
             len(deleted) == 1,
-            f"{self.model_type.__tablename__} row not found by {locator}",
+            f"{self.name} row not found by {locator=}",
             raise_exc_class=ServiceError,
             raise_kwargs=dict(status_code=status.HTTP_404_NOT_FOUND),
         )
@@ -214,7 +245,7 @@ async def update(self, locator: Any, **incoming_data) -> CrudModel:
         result: Result = await self.session.execute(query)
         return enforce_defined(
             result.scalar_one_or_none(),
-            f"{self.model_type.__tablename__} row not found by {locator}",
+            f"{self.name} row not found by {locator=}",
             raise_exc_class=ServiceError,
             raise_kwargs=dict(status_code=status.HTTP_404_NOT_FOUND),
         )
@@ -232,9 +263,11 @@ def locate_where_clause(self, locator: Any) -> Any:
     def build_list_query(
         self,
         sort_ascending: bool = True,
-        user_email: str | None = None,
         search: str | None = None,
         sort_field: str | None = None,
+        include_archived: bool = True,
+        include_files: bool = False,
+        include_parent: bool = False,
         **additional_filters,
     ) -> Select:
         """
@@ -245,13 +278,13 @@ def build_list_query(
         """
         query = select(self.model_type)
         for key, value in additional_filters.items():
-            query = query.where(getattr(self.model_type, key) == value)
-        if user_email:
-            query = query.where(self.model_type.owner_email == user_email)
+            query = query.where(self.model_type.__table__.c[key] == value)  # type: ignore
+        if not include_archived:
+            query = query.where(not_(self.model_type.is_archived))
         if search:
             require_condition(
                 hasattr(self.model_type, "searchable_fields"),
-                f"{self.model_type.__tablename__} does not support search",
+                f"{self.name} does not support search",
                 raise_exc_class=ServiceError,
                 raise_kwargs=dict(status_code=status.HTTP_405_METHOD_NOT_ALLOWED),
             )
@@ -259,11 +292,16 @@ def build_list_query(
         if sort_field:
             require_condition(
                 hasattr(self.model_type, "sortable_fields"),
-                f"{self.model_type.__tablename__} does not support sort",
+                f"{self.name} does not support sort",
                 raise_exc_class=ServiceError,
                 raise_kwargs=dict(status_code=status.HTTP_405_METHOD_NOT_ALLOWED),
             )
             query = query.order_by(sort_clause(sort_field, self.model_type.sortable_fields(), sort_ascending))
+        if include_parent:
+            query = self.model_type.include_parent(query)
+        if include_files:
+            query = self.model_type.include_files(query)
+        logger.trace(f"Query: {render_sql(self.session, query)}")
         return query
 
     async def paginated_list(self, **filter_kwargs) -> Page[CrudModel]:
@@ -281,7 +319,31 @@ async def list(self, **filter_kwargs) -> list[CrudModel]:
         For details on the supported filters, see the ``build_list_query()`` method.
         """
         result: Result = await self.session.execute(self.build_list_query(**filter_kwargs))
-        return list(result.scalars())
+        return list(result.unique().scalars())  # type: ignore
+
+    def ensure_attribute(self, instance: CrudModel, **attributes) -> None:
+        """
+        Ensure that a model instance has the specified values on key attributes.
+
+        Raises HTTPException if the instance does not have the specified values.
+        """
+        with check_expressions(
+            main_message="Request not allowed on {} by id={} due to mismatch on attribute(s)".format(
+                self.name, instance.id
+            ),
+            raise_exc_class=ServiceError,
+            raise_kwargs=dict(status_code=status.HTTP_403_FORBIDDEN),
+        ) as check:
+            for attr_name, expected_value in attributes.items():
+                actual_value = getattr(instance, attr_name)
+                check(actual_value == expected_value, message=attr_name)
+
+    @property
+    def name(self):
+        """
+        Helper property to recover the name of the table.
+        """
+        return self.model_type.__tablename__
 
 
 class BucketBoundService:
diff --git a/jobbergate-api/jobbergate_api/safe_types.py b/jobbergate-api/jobbergate_api/safe_types.py
index 8a9e5e15..22c4746c 100644
--- a/jobbergate-api/jobbergate_api/safe_types.py
+++ b/jobbergate-api/jobbergate_api/safe_types.py
@@ -18,9 +18,11 @@
 if TYPE_CHECKING:
     from mypy_boto3_s3.service_resource import Bucket
 
+    from jobbergate_api.apps.job_script_templates.models import JobScriptTemplate
     from jobbergate_api.apps.job_scripts.models import JobScript
     from jobbergate_api.apps.job_submissions.models import JobSubmission
 else:
     Bucket = Any
+    JobScriptTemplate = Any
     JobScript = Any
     JobSubmission = Any
diff --git a/jobbergate-api/jobbergate_api/security.py b/jobbergate-api/jobbergate_api/security.py
index 060a4123..e28706cd 100644
--- a/jobbergate-api/jobbergate_api/security.py
+++ b/jobbergate-api/jobbergate_api/security.py
@@ -6,7 +6,8 @@
 from armasec import Armasec, TokenPayload
 from armasec.schemas import DomainConfig
 from armasec.token_security import PermissionMode
-from fastapi import Depends
+from buzz import check_expressions
+from fastapi import Depends, HTTPException, status
 from loguru import logger
 from pydantic import EmailStr, root_validator
 
@@ -91,7 +92,13 @@ def extract_organization(cls, values):
         return {**values, "organization_id": next(iter(organization_dict))}
 
 
-def lockdown_with_identity(*scopes: str, permission_mode: PermissionMode = PermissionMode.ALL):
+def lockdown_with_identity(
+    *scopes: str,
+    permission_mode: PermissionMode = PermissionMode.ALL,
+    ensure_email: bool = False,
+    ensure_organization: bool = False,
+    ensure_client_id: bool = False,
+):
     """
     Provide a wrapper to be used with dependency injection to extract identity on a secured route.
     """
@@ -102,6 +109,22 @@ def dependency(
         """
         Provide an injectable function to lockdown a route and extract the identity payload.
         """
+        identity_payload = IdentityPayload.parse_obj(token_payload)
+
+        with check_expressions(
+            main_message="Access token does not contain",
+            raise_exc_class=HTTPException,
+            exc_builder=lambda exc_class, msg: exc_class(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail=msg,
+            ),
+        ) as check:
+            for ensure, name in zip(
+                (ensure_email, ensure_organization, ensure_client_id), ("email", "organization", "client_id")
+            ):
+                if ensure:
+                    check(getattr(identity_payload, name, None) is not None, message=name)
+
         return IdentityPayload.parse_obj(token_payload)
 
     return dependency
diff --git a/jobbergate-api/jobbergate_api/storage.py b/jobbergate-api/jobbergate_api/storage.py
index 65e56a74..bb3b66db 100644
--- a/jobbergate-api/jobbergate_api/storage.py
+++ b/jobbergate-api/jobbergate_api/storage.py
@@ -120,7 +120,13 @@ class SecureSession:
     session: AsyncSession
 
 
-def secure_session(*scopes: str, permission_mode: PermissionMode = PermissionMode.ALL):
+def secure_session(
+    *scopes: str,
+    permission_mode: PermissionMode = PermissionMode.ALL,
+    ensure_email: bool = False,
+    ensure_organization: bool = False,
+    ensure_client_id: bool = False,
+):
     """
     Provide an injectable for FastAPI that checks permissions and returns a database session for this request.
 
@@ -136,7 +142,13 @@ def secure_session(*scopes: str, permission_mode: PermissionMode = PermissionMod
 
     async def dependency(
         identity_payload: IdentityPayload = fastapi.Depends(
-            lockdown_with_identity(*scopes, permission_mode=permission_mode)
+            lockdown_with_identity(
+                *scopes,
+                permission_mode=permission_mode,
+                ensure_email=ensure_email,
+                ensure_organization=ensure_organization,
+                ensure_client_id=ensure_client_id,
+            )
         )
     ) -> typing.AsyncIterator[SecureSession]:
         is_test = settings.DEPLOY_ENV.lower() == "test"
diff --git a/jobbergate-api/pyproject.toml b/jobbergate-api/pyproject.toml
index f4ee8904..94039b16 100644
--- a/jobbergate-api/pyproject.toml
+++ b/jobbergate-api/pyproject.toml
@@ -32,29 +32,30 @@ fastapi = "^0.95.0"
 fastapi-pagination = "^0.12.4"
 greenlet = "^2.0.1"
 httpx = "^0.23.0"
+inflection = "^0.5.1"
 Jinja2 = "^3.0.3"
 loguru = "^0.6.0"
 nest-asyncio = "^1.5.6"
 passlib = "^1.7.2"
 py-buzz = "^3.2.1"
-pydantic = {extras = ["email"], version = "^1.10.5"}
+pydantic = { extras = ["email"], version = "^1.10.5" }
 python-dotenv = "^0.20.0"
 python-multipart = "^0.0.5"
 PyYAML = "^6.0"
 sendgrid = "^6.9.7"
 sentry-sdk = "^1.5.0"
+snick = "^1.3.0"
+sqlalchemy = { extras = ["mypy"], version = "^2.0.19" }
 starlette = "^0.26.1"
 toml = "^0.10.2"
 typer = "^0.4.0"
 uvicorn = "^0.19.0"
 yarl = "^1.7.2"
-inflection = "^0.5.1"
-snick = "^1.3.0"
-sqlalchemy = {extras = ["mypy"], version = "^2.0.19"}
 
 [tool.poetry.group.dev.dependencies]
 asgi-lifespan = "^1.0.1"
 black = "^23"
+coverage = { extras = ["toml"], version = "^7.2.7" }
 flake8-docstrings = "^1.6.0"
 ipython = "^7.31.1"
 isort = "^5.9.3"
@@ -73,9 +74,8 @@ pytest-random-order = "^1.0.4"
 python-status = "^1.0"
 requests = "^2.28.0"
 respx = "^0.20.1"
-coverage = {extras = ["toml"], version = "^7.2.7"}
+types-aioboto3 = { extras = ["essential"], version = "^11.2.0" }
 types-toml = "^0.10.8.7"
-types-aioboto3 = {extras = ["essential"], version = "^11.2.0"}
 
 [tool.poetry.scripts]
 dev-tools = "dev_tools:app"
@@ -102,9 +102,9 @@ addopts = ["--random-order", "--cov=jobbergate_api"]
 testpaths = ["tests"]
 asyncio_mode = "auto"
 env = [
-    "DEPLOY_ENV = TEST",  # Enforces that test database env vars will be used
-    "ARMASEC_DOMAIN = armasec.dev",  # Must align with the rs256_domain fixture in armasec's pytest extension
-    "ARMASEC_DEBUG = False",  # Set this to True to debug armasec issues by seeing verbose logging
+    "DEPLOY_ENV = TEST",                         # Enforces that test database env vars will be used
+    "ARMASEC_DOMAIN = armasec.dev",              # Must align with the rs256_domain fixture in armasec's pytest extension
+    "ARMASEC_DEBUG = False",                     # Set this to True to debug armasec issues by seeing verbose logging
     "SENDGRID_FROM_EMAIL = info@pytesting.com",
     "SENDGRID_API_KEY = test-api-key",
     "AWS_ACCESS_KEY_ID = compose-s3-key",
@@ -112,27 +112,18 @@ env = [
 ]
 
 [tool.coverage.run]
-omit = [
-    "jobbergate_api/main.py",
-    "jobbergate_api/safe_types.py",
-]
-concurrency = [
-    "greenlet",
-    "thread",
-]
+omit = ["jobbergate_api/main.py", "jobbergate_api/safe_types.py"]
+concurrency = ["greenlet", "thread"]
 
 [tool.coverage.report]
-    fail_under = 95
-    show_missing = true
+fail_under = 90
+show_missing = true
 
 [tool.coverage.html]
-    show_contexts = true
+show_contexts = true
 
 [tool.mypy]
-plugins = [
-    "pydantic.mypy",
-    "sqlalchemy.ext.mypy.plugin",
-]
+plugins = ["pydantic.mypy", "sqlalchemy.ext.mypy.plugin"]
 follow_imports = "silent"
 ignore_missing_imports = true
 
diff --git a/jobbergate-cli/jobbergate_cli/requests.py b/jobbergate-cli/jobbergate_cli/requests.py
index d7215449..051d64fa 100644
--- a/jobbergate-cli/jobbergate_cli/requests.py
+++ b/jobbergate-cli/jobbergate_cli/requests.py
@@ -162,6 +162,19 @@ def make_request(
                 subject=abort_subject,
                 log_message=f"Could not delete due to foreign-key constraint: {response.text}",
             )
+        elif method in ("PATCH", "PUT", "DELETE") and response.status_code == 403 and "does not own" in response.text:
+            details = response.json()["detail"]
+            raise Abort(
+                dedent(
+                    f"""
+                     {abort_message}:
+                     [red]You do not own this resource.[/red]
+                     Please contact the owner if you need it to be modified.
+                     """,
+                ),
+                subject=abort_subject,
+                log_message=f"Resource could not be modified by non-owner: {response.text}",
+            )
         else:
             raise Abort(
                 unwrap(
diff --git a/jobbergate-core/CHANGELOG.rst b/jobbergate-core/CHANGELOG.rst
new file mode 100644
index 00000000..a6e2c87f
--- /dev/null
+++ b/jobbergate-core/CHANGELOG.rst
@@ -0,0 +1,11 @@
+============
+ Change Log
+============
+
+This file keeps track of all notable changes to jobbergate-core
+
+Unreleased
+----------
+- Started the project jobbergate-core
+- Added a class used to handle tokens in Jobbergate
+- Added a class used to handle authentication in Jobbergate
diff --git a/jobbergate-core/LICENSE b/jobbergate-core/LICENSE
new file mode 100644
index 00000000..cb887987
--- /dev/null
+++ b/jobbergate-core/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020-2023 Omnivector Solutions
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/jobbergate-core/Makefile b/jobbergate-core/Makefile
new file mode 100644
index 00000000..4a9e6813
--- /dev/null
+++ b/jobbergate-core/Makefile
@@ -0,0 +1,50 @@
+.ONESHELL:
+.DEFAULT_GOAL:=help
+SHELL:=/bin/bash
+PACKAGE_NAME:=jobbergate_core
+
+.PHONY: install
+install: ## Install the Python dependencies
+	poetry install
+
+.PHONY: test
+test: install ## Run the suite of unit tests
+	poetry run pytest
+
+.PHONY: lint
+lint: install ## Lint the code using black, isort and flake8
+	poetry run black --check ${PACKAGE_NAME} tests
+	poetry run isort --check ${PACKAGE_NAME} tests
+	poetry run pflake8 ${PACKAGE_NAME} tests
+
+.PHONY: mypy
+mypy: install ## Run static typer checks
+	poetry run mypy ${PACKAGE_NAME} --pretty
+
+.PHONY: qa
+qa: test lint mypy ## Run the full suite of quality assurance, i.e. test, lint and static type checker
+	echo "All quality checks pass!"
+
+.PHONY: format
+format: install ## Format the code using black and isort
+	poetry run black ${PACKAGE_NAME} tests
+	poetry run isort ${PACKAGE_NAME} tests
+
+.PHONY: clean
+clean: ## Remove cache files and similars
+	@find . -iname '*.pyc' -delete
+	@find . -iname '*.pyo' -delete
+	@find . -iname '*~' -delete
+	@find . -iname '*.swp' -delete
+	@find . -iname '__pycache__' -delete
+	@rm -rf .mypy_cache
+	@rm -rf .pytest_cache
+	@find . -name '*.egg' -print0|xargs -0 rm -rf --
+	@rm -rf .eggs/
+	@rm -fr build/
+	@rm -fr dist/
+	@rm -fr *.egg-info
+
+.PHONY: help
+help: ## Display target comments in 'make help'
+	grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
diff --git a/jobbergate-core/README.rst b/jobbergate-core/README.rst
new file mode 100644
index 00000000..6fe6666d
--- /dev/null
+++ b/jobbergate-core/README.rst
@@ -0,0 +1,15 @@
+=================
+ Jobbergate Core
+=================
+
+Jobbergate-core is a sub-project that contains the key components and logic that is shared among all other sub-projects (CLI, API, and Agent). Additionally, jobbergate-core exists to support custom automation built on top of Jobbergate.
+
+License
+-------
+* `MIT <LICENSE>`_
+
+
+Copyright
+---------
+* Copyright (c) 2023 OmniVector Solutions <info@omnivector.solutions>
+
diff --git a/jobbergate-core/jobbergate_core/__init__.py b/jobbergate-core/jobbergate_core/__init__.py
new file mode 100644
index 00000000..74c86277
--- /dev/null
+++ b/jobbergate-core/jobbergate_core/__init__.py
@@ -0,0 +1,13 @@
+"""
+Jobbergate-core contains key components that are shared among sub-projects.
+"""
+from jobbergate_core.auth import AuthenticationError, JobbergateAuthHandler, TokenError
+from jobbergate_core.version import __version__
+
+
+__all__ = [
+    "__version__",
+    "AuthenticationError",
+    "JobbergateAuthHandler",
+    "TokenError",
+]
diff --git a/jobbergate-core/jobbergate_core/auth/__init__.py b/jobbergate-core/jobbergate_core/auth/__init__.py
new file mode 100644
index 00000000..7042001a
--- /dev/null
+++ b/jobbergate-core/jobbergate_core/auth/__init__.py
@@ -0,0 +1,15 @@
+"""
+Utilities for handling auth in Jobbergate.
+"""
+from jobbergate_core.auth.exceptions import AuthenticationError, TokenError
+from jobbergate_core.auth.handler import JobbergateAuthHandler
+from jobbergate_core.auth.token import Token, TokenType
+
+
+__all__ = [
+    "AuthenticationError",
+    "JobbergateAuthHandler",
+    "Token",
+    "TokenError",
+    "TokenType",
+]
diff --git a/jobbergate-core/jobbergate_core/auth/exceptions.py b/jobbergate-core/jobbergate_core/auth/exceptions.py
new file mode 100644
index 00000000..92287202
--- /dev/null
+++ b/jobbergate-core/jobbergate_core/auth/exceptions.py
@@ -0,0 +1,17 @@
+from buzz import Buzz
+
+
+class AuthenticationError(Buzz):
+    """
+    Base exception for errors related to authentication on Jobbergate.
+    """
+
+    pass
+
+
+class TokenError(AuthenticationError):
+    """
+    Exception for errors related to tokens on Jobbergate.
+    """
+
+    pass
diff --git a/jobbergate-core/jobbergate_core/auth/handler.py b/jobbergate-core/jobbergate_core/auth/handler.py
new file mode 100644
index 00000000..86227a0e
--- /dev/null
+++ b/jobbergate-core/jobbergate_core/auth/handler.py
@@ -0,0 +1,300 @@
+"""
+Utilities for handling authentication in the Jobbergate system.
+"""
+import time
+from collections import namedtuple
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Dict
+
+import httpx
+from loguru import logger
+
+from jobbergate_core.auth.exceptions import AuthenticationError, TokenError
+from jobbergate_core.auth.token import Token, TokenType
+
+
+_LoginInformation = namedtuple(
+    "_LoginInformation",
+    ["verification_url", "wait_interval", "device_code", "expires_at"],
+)
+
+
+@dataclass
+class JobbergateAuthHandler:
+    """
+    High-level class used to manage authentication to requests to the Jobbergate-API
+
+    After an instance of this class is created, it can be used to authenticate requests
+    from both `requests`_  and `httpx`_ packages by passing it to the ``auth``
+    parameter on the request (see examples below).
+
+    It just works out of the box. Behind the scenes, this procedure calls the
+    :meth:`JobbergateAuthHandler.acquire_access` method to load the available tokens from the cache directory,
+    it tries to refresh them if they are expired, or provides an URL to the user to login on the system.
+
+    Notice all steps above are also available individually as public methods, allowing a fine control for
+    advanced users.
+
+    .. _requests: https://requests.readthedocs.io/en/latest/
+    .. _httpx: https://www.python-httpx.org/
+
+    Arguments:
+        cache_directory: Directory to be used for the caching tokens.
+        login_domain: Domain used for the login.
+        login_audience: Audience of the login.
+        login_client_id: Client ID used for login.
+
+    Note:
+        These values depend on the identity provider used for authentication. Consult your system
+        administrator or contact Omnivector support <support@omnivector.solutions> for further assistance.
+
+    Note:
+        This class can interoperate with the tokens generated by the ``jobbergate-cli`` package, as long
+        as they are stored in the same cache directory.
+
+    Examples:
+
+        The following example shows how to use the :meth:`JobbergateAuthHandler`
+        class to authenticate a request:
+
+        >>> from pathlib import Path
+        >>> import requests
+        >>> from jobbergate_core import JobbergateAuthHandler
+        >>> jobbergate_auth = JobbergateAuthHandler(
+        ...     cache_directory=Path("."),
+        ...     login_domain="http://keycloak.local:8080/realms/jobbergate-local",
+        ...     login_audience="https://local.omnivector.solutions",
+        ...     login_client_id="cli",
+        ... )
+        >>> jobbergate_base_url = "http://localhost:8000/jobbergate"
+        >>> response = requests.get(
+        ...     f"{jobbergate_base_url}/applications",
+        ...     auth=jobbergate_auth # this is the important part
+        )
+        Login Here: http://keycloak.local:8080/realms/jobbergate-local/device?user_code=LMVJ-XOLG
+        >>> response.raise_for_status()
+        >>> print(f"response = {response.json()}")
+    """
+
+    cache_directory: Path
+    login_domain: str
+    login_audience: str
+    login_client_id: str = "default"
+    _access_token: Token = field(init=False, repr=False)
+    _refresh_token: Token = field(init=False, repr=False)
+
+    def __post_init__(self):
+        self._access_token = Token(cache_directory=self.cache_directory, label=TokenType.ACCESS)
+        self._refresh_token = Token(cache_directory=self.cache_directory, label=TokenType.REFRESH)
+
+    def __call__(self, request):
+        """
+        This internal method allows the integration with the ``requests`` library.
+
+        It is called automatically when the instance is passed to the ``auth`` parameter,
+        see the examples in the class docstring.
+
+        It adds the ``Authorization`` header to the request with the access token.
+        """
+        logger.debug("Authenticating request")
+        request.headers["Authorization"] = self.acquire_access()
+        return request
+
+    def acquire_access(self) -> str:
+        """
+        High-level method to acquire a valid access token.
+
+        This method will attempt, in order:
+
+        * Use the internal access token from the instance
+        * Load the tokens from the cache directory (see :meth:`JobbergateAuthHandler.load_from_cache`)
+        * If the access token is unavailable or expired, refresh both tokens
+          using the refresh token grant type (see :meth:`JobbergateAuthHandler.refresh_tokens`)
+        * If the refresh token is unavailable or expired, login to generate both tokens
+          (see :meth:`JobbergateAuthHandler.login`)
+
+        Returns:
+            The bearer access token.
+
+        Raises:
+            AuthenticationError: If all of the steps above fail to acquire a valid access token.
+        """
+        logger.debug("Acquiring access token")
+
+        for procedure_name in (None, "load_from_cache", "refresh_tokens", "login"):
+            if procedure_name:
+                procedure = getattr(self, procedure_name)
+                try:
+                    procedure()
+                except TokenError:
+                    logger.debug("{} failed, moving to the next procedure", procedure_name)
+            if self._access_token.is_valid():
+                return self._access_token.bearer_token
+        raise AuthenticationError("Unable to acquire the access token")
+
+    def load_from_cache(self) -> None:
+        """
+        Load the tokens that are available at the cache directory.
+        """
+        logger.debug("Loading tokens from cache directory: {}", self.cache_directory.as_posix())
+        self._access_token = self._access_token.load_from_cache()
+        self._refresh_token = self._refresh_token.load_from_cache()
+
+    def save_to_cache(self) -> None:
+        """
+        Save the tokens to the cache directory.
+
+        Note:
+            This method will create the cache directory if it does not exist.
+        """
+        logger.debug(
+            "Saving tokens to cache directory: {}",
+            self.cache_directory.as_posix(),
+        )
+        self.cache_directory.mkdir(parents=True, exist_ok=True)
+        self._access_token.save_to_cache()
+        self._refresh_token.save_to_cache()
+
+    def logout(self) -> None:
+        """
+        Logout from Jobbergate by clearing the loaded tokens and their cache on the disk.
+        """
+        logger.debug("Logging out from Jobbergate")
+        self._access_token = self._access_token.replace(content="")
+        self._access_token.clear_cache()
+        self._refresh_token = self._refresh_token.replace(content="")
+        self._refresh_token.clear_cache()
+
+    def login(self) -> None:
+        """
+        Login to Jobbergate.
+
+        An URL will be printed to the console, the user must open it in a browser and provide
+        their access credentials.
+
+        After the login is completed, the tokens will be saved to the cache directory.
+        """
+        logger.debug("Preparing to login to Jobbergate")
+        login_info = self._get_login_information()
+        response = self._wait_for_login_confirmation(login_info)
+        self._process_tokens_from_response(response)
+        logger.success("Login completed")
+
+    def _wait_for_login_confirmation(self, login_info: _LoginInformation) -> httpx.Response:
+        print(f"Login Here: {login_info.verification_url}")
+        while True:
+            AuthenticationError.require_condition(
+                login_info.expires_at > time.time(), "Login expired, please try again"
+            )
+            response = self._get_login_confirmation(login_info)
+            try:
+                response.raise_for_status()
+                break
+            except httpx.HTTPStatusError:
+                logger.debug(
+                    "    Login not completed yet, waiting {} seconds",
+                    login_info.wait_interval,
+                )
+                time.sleep(login_info.wait_interval)
+        logger.debug("Preparing to login to Jobbergate")
+        return response
+
+    def _get_login_confirmation(self, login_info: _LoginInformation) -> httpx.Response:
+        response = httpx.post(
+            f"{self.login_domain}/protocol/openid-connect/token",
+            data=dict(
+                grant_type="urn:ietf:params:oauth:grant-type:device_code",
+                device_code=login_info.device_code,
+                client_id=self.login_client_id,
+            ),
+        )
+
+        return response
+
+    def _get_login_information(self) -> _LoginInformation:
+        with AuthenticationError.handle_errors(
+            "Unexpected error while fetching the tokens",
+        ):
+            response = httpx.post(
+                f"{self.login_domain}/protocol/openid-connect/auth/device",
+                data=dict(
+                    client_id=self.login_client_id,
+                    grant_type="client_credentials",
+                    audience=self.login_audience,
+                ),
+            )
+            response.raise_for_status()
+
+        device_code_data = response.json()
+        with AuthenticationError.handle_errors(
+            f"Error processing the request data after fetching the token, {device_code_data=}",
+        ):
+            verification_url = device_code_data["verification_uri_complete"]
+            wait_interval = device_code_data["interval"]
+            device_code = device_code_data["device_code"]
+            expires_at = time.time() + device_code_data["expires_in"]
+        return _LoginInformation(
+            verification_url,
+            wait_interval,
+            device_code,
+            expires_at,
+        )
+
+    def refresh_tokens(self) -> None:
+        """
+        Refresh the tokens.
+
+        After the refresh operation is completed, the tokens will be saved to the cache directory.
+
+        Raises:
+            AuthenticationError: If the refresh token is missing or expired.
+        """
+        logger.debug("Preparing to refresh the tokens")
+
+        if not self._refresh_token.content:
+            raise TokenError("The refresh is unavailable, please login again")
+        if self._refresh_token.is_expired():
+            raise TokenError("Refresh token is expired, please login again")
+
+        response = self._get_refresh_token()
+        self._process_tokens_from_response(response)
+
+        logger.success("Tokens refreshed successfully")
+
+    def _get_refresh_token(self):
+        with AuthenticationError.handle_errors(
+            "Unexpected error while refreshing the tokens",
+        ):
+            response = httpx.post(
+                f"{self.login_domain}/protocol/openid-connect/token",
+                data=dict(
+                    client_id=self.login_client_id,
+                    audience=self.login_audience,
+                    grant_type="refresh_token",
+                    refresh_token=self._refresh_token.content,
+                ),
+            )
+            response.raise_for_status()
+        return response
+
+    def _process_tokens_from_response(self, response):
+        response_data = response.json()
+
+        tokens_content = {t: response_data.get(f"{t}_token") for t in TokenType}
+        AuthenticationError.require_condition(
+            all(tokens_content.values()), "Not all tokens were included in the response"
+        )
+        self._update_tokens(tokens_content)
+        self.save_to_cache()
+
+    def _update_tokens(self, tokens_content: Dict[TokenType, str]):
+        """
+        Update the tokens with the new content.
+        """
+        access_token = tokens_content.get(TokenType.ACCESS, "")
+        if access_token:
+            self._access_token = self._access_token.replace(content=access_token)
+        refresh_token = tokens_content.get(TokenType.REFRESH, "")
+        if refresh_token:
+            self._refresh_token = self._refresh_token.replace(content=refresh_token)
diff --git a/jobbergate-core/jobbergate_core/auth/token.py b/jobbergate-core/jobbergate_core/auth/token.py
new file mode 100644
index 00000000..ce4c215b
--- /dev/null
+++ b/jobbergate-core/jobbergate_core/auth/token.py
@@ -0,0 +1,180 @@
+"""
+Utilities for handling tokens on Jobbergate.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass, field, replace
+from enum import Enum
+from pathlib import Path
+from typing import Any, Dict
+
+import pendulum
+from jose.jwt import decode
+from loguru import logger
+
+from jobbergate_core.auth.exceptions import TokenError
+
+
+class TokenType(str, Enum):
+    """
+    The types of tokens available in the system are ``access`` and ``refresh``.
+    """
+
+    ACCESS = "access"
+    REFRESH = "refresh"
+
+
+@dataclass(frozen=True)
+class Token:
+    """
+    Low-level class used to handling tokens.
+
+    Arguments:
+        cache_directory: The directory used for cache.
+        label: The type of token.
+        content: The content of the token (default is ``""``).
+
+    Attributes:
+        file_path: The path to the file associated with the token.
+          It is computed  as ``<cache_directory>/<label>.token``.
+        data: Metadata decoded from the token's content are available in this dictionary.
+          Expiration date and permissions are some examples of data that can be found.
+    """
+
+    cache_directory: Path
+    label: TokenType
+    content: str = ""
+    file_path: Path = field(init=False, hash=False, repr=False)
+    data: Dict[str, Any] = field(
+        default_factory=dict,
+        init=False,
+        hash=False,
+        repr=False,
+    )
+
+    def __post_init__(self):
+        """
+        Post init method.
+        """
+        TokenError.require_condition(isinstance(self.content, str), "Token content is not a string.")
+
+        # There is one point to keep in mind when using frozen=True
+        # see https://docs.python.org/3/library/dataclasses.html#frozen-instances
+        object.__setattr__(
+            self,
+            "file_path",
+            self.cache_directory / f"{self.label}.token",
+        )
+        if self.content:
+            data = self._get_metadata()
+            object.__setattr__(self, "data", data)
+
+    def _get_metadata(self) -> Dict[str, Any]:
+        """
+        Extract the data from the token.
+        """
+        logger.debug(f"Getting data from {self.label} token")
+
+        with TokenError.handle_errors("Unable to extract data from the token"):
+            data = decode(
+                token=self.content,
+                key="",
+                options=dict(
+                    verify_signature=False,
+                    verify_aud=False,
+                    verify_exp=False,
+                ),
+            )
+
+        return data
+
+    def load_from_cache(self) -> Token:
+        """
+        Load the token from the cache directory.
+
+        Args:
+            cache_directory: The path to the cache directory.
+            label: The type of token.
+
+        Returns:
+            A new token with the content replaced.
+        """
+        file_path = self.cache_directory / f"{self.label}.token"
+        logger.debug(f"Loading token from {file_path.as_posix()}")
+
+        TokenError.require_condition(file_path.exists(), "Token file was not found")
+
+        with TokenError.handle_errors("Unknown error while loading the token"):
+            content = file_path.read_text().strip()
+
+        return self.replace(content=content)
+
+    def save_to_cache(self) -> None:
+        """
+        Save the token to the cache file associated with it.
+
+        Raises:
+            TokenError: If the parent directory does not exist.
+            TokenError: If there is an unknown error while saving the token.
+        """
+        if not self.content:
+            return
+        logger.debug(f"Saving token to {self.file_path}")
+        TokenError.require_condition(self.file_path.parent.exists(), "Parent directory does not exist")
+
+        with TokenError.handle_errors("Unknown error while saving the token"):
+            self.file_path.write_text(self.content.strip())
+            self.file_path.chmod(0o600)
+
+    def clear_cache(self) -> None:
+        """
+        Clear the token from cache by removing the file associated with it.
+        """
+        logger.debug(f"Clearing cached token from {self.file_path}")
+        if self.file_path.exists():
+            self.file_path.unlink()
+
+    def is_expired(self) -> bool:
+        """
+        Check if the token is expired.
+
+        Returns:
+            True if the token is expired, False otherwise.
+
+        Raises:
+            TokenError: If the expiration date is not found.
+        """
+        logger.debug(f"Checking if {self.label} token has expired")
+
+        TokenError.require_condition("exp" in self.data, "The expiration date was not found")
+        token_expiration = self.data["exp"]
+
+        current_time_UTC = pendulum.now().int_timestamp
+        is_expired = token_expiration <= current_time_UTC
+        logger.debug(f"    Token is expired: {is_expired}")
+
+        return is_expired
+
+    def is_valid(self) -> bool:
+        """
+        Verify if the token is valid, i.e., has content and is not expired.
+        """
+        return bool(self.content) and self.is_expired() is False
+
+    def replace(self, **changes) -> Token:
+        """
+        Create a new instance of the token with the changes applied.
+
+        Keyword Arguments:
+            content: The content of the token.
+            cache_directory: The directory containing the cache.
+            label: The type of token.
+        """
+        return replace(self, **changes)
+
+    @property
+    def bearer_token(self) -> str:
+        """
+        Return the token with the ``Bearer`` prefix.
+        """
+        return f"Bearer {self.content}"
diff --git a/jobbergate-core/jobbergate_core/version.py b/jobbergate-core/jobbergate_core/version.py
new file mode 100644
index 00000000..4dbe381a
--- /dev/null
+++ b/jobbergate-core/jobbergate_core/version.py
@@ -0,0 +1,7 @@
+"""
+Provide the version of the package.
+"""
+from importlib import metadata
+
+
+__version__ = metadata.version(__package__ or __name__)
diff --git a/jobbergate-core/poetry.lock b/jobbergate-core/poetry.lock
new file mode 100644
index 00000000..d9c897d9
--- /dev/null
+++ b/jobbergate-core/poetry.lock
@@ -0,0 +1,1093 @@
+[[package]]
+name = "anyio"
+version = "3.6.2"
+description = "High level compatibility layer for multiple asynchronous event loop implementations"
+category = "main"
+optional = false
+python-versions = ">=3.6.2"
+
+[package.dependencies]
+idna = ">=2.8"
+sniffio = ">=1.1"
+
+[package.extras]
+doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"]
+test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"]
+trio = ["trio (>=0.16,<0.22)"]
+
+[[package]]
+name = "attrs"
+version = "22.2.0"
+description = "Classes Without Boilerplate"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+cov = ["attrs", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"]
+dev = ["attrs"]
+docs = ["furo", "sphinx", "myst-parser", "zope.interface", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"]
+tests = ["attrs", "zope.interface"]
+tests-no-zope = ["hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist", "cloudpickle", "mypy (>=0.971,<0.990)", "pytest-mypy-plugins"]
+tests_no_zope = ["hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist", "cloudpickle", "mypy (>=0.971,<0.990)", "pytest-mypy-plugins"]
+
+[[package]]
+name = "black"
+version = "23.1.0"
+description = "The uncompromising code formatter."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+click = ">=8.0.0"
+mypy-extensions = ">=0.4.3"
+packaging = ">=22.0"
+pathspec = ">=0.9.0"
+platformdirs = ">=2"
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
+typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}
+
+[package.extras]
+colorama = ["colorama (>=0.4.3)"]
+d = ["aiohttp (>=3.7.4)"]
+jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
+uvloop = ["uvloop (>=0.15.2)"]
+
+[[package]]
+name = "certifi"
+version = "2022.12.7"
+description = "Python package for providing Mozilla's CA Bundle."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "charset-normalizer"
+version = "3.1.0"
+description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+category = "main"
+optional = false
+python-versions = ">=3.7.0"
+
+[[package]]
+name = "click"
+version = "8.1.3"
+description = "Composable command line interface toolkit"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+category = "main"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+
+[[package]]
+name = "coverage"
+version = "7.2.2"
+description = "Code coverage measurement for Python"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
+
+[package.extras]
+toml = ["tomli"]
+
+[[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]
+six = ">=1.9.0"
+
+[package.extras]
+gmpy = ["gmpy"]
+gmpy2 = ["gmpy2"]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.1.1"
+description = "Backport of PEP 654 (exception groups)"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.extras]
+test = ["pytest (>=6)"]
+
+[[package]]
+name = "flake8"
+version = "3.9.2"
+description = "the modular source code checker: pep8 pyflakes and co"
+category = "dev"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+
+[package.dependencies]
+mccabe = ">=0.6.0,<0.7.0"
+pycodestyle = ">=2.7.0,<2.8.0"
+pyflakes = ">=2.3.0,<2.4.0"
+
+[[package]]
+name = "freezegun"
+version = "1.2.2"
+description = "Let your Python tests travel through time"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+python-dateutil = ">=2.7"
+
+[[package]]
+name = "h11"
+version = "0.12.0"
+description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "httpcore"
+version = "0.14.7"
+description = "A minimal low-level HTTP client."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+anyio = ">=3.0.0,<4.0.0"
+certifi = "*"
+h11 = ">=0.11,<0.13"
+sniffio = ">=1.0.0,<2.0.0"
+
+[package.extras]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (>=1.0.0,<2.0.0)"]
+
+[[package]]
+name = "httpx"
+version = "0.22.0"
+description = "The next generation HTTP client."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+certifi = "*"
+charset-normalizer = "*"
+httpcore = ">=0.14.5,<0.15.0"
+rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]}
+sniffio = "*"
+
+[package.extras]
+brotli = ["brotlicffi", "brotli"]
+cli = ["click (>=8.0.0,<9.0.0)", "rich (>=10.0.0,<11.0.0)", "pygments (>=2.0.0,<3.0.0)"]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (>=1.0.0,<2.0.0)"]
+
+[[package]]
+name = "idna"
+version = "3.4"
+description = "Internationalized Domain Names in Applications (IDNA)"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+description = "brain-dead simple config-ini parsing"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[[package]]
+name = "isort"
+version = "5.12.0"
+description = "A Python utility / library to sort Python imports."
+category = "dev"
+optional = false
+python-versions = ">=3.8.0"
+
+[package.extras]
+colors = ["colorama (>=0.4.3)"]
+requirements-deprecated-finder = ["pip-api", "pipreqs"]
+pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"]
+plugins = ["setuptools"]
+
+[[package]]
+name = "loguru"
+version = "0.6.0"
+description = "Python logging made (stupidly) simple"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[package.dependencies]
+colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""}
+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 = "mccabe"
+version = "0.6.1"
+description = "McCabe checker, plugin for flake8"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "mypy"
+version = "0.931"
+description = "Optional static typing for Python"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+mypy-extensions = ">=0.4.3"
+tomli = ">=1.1.0"
+typing-extensions = ">=3.10"
+
+[package.extras]
+dmypy = ["psutil (>=4.0)"]
+python2 = ["typed-ast (>=1.4.0,<2)"]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.0.0"
+description = "Type system extensions for programs checked with the mypy type checker."
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "packaging"
+version = "23.0"
+description = "Core utilities for Python packages"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[[package]]
+name = "pathspec"
+version = "0.11.1"
+description = "Utility library for gitignore style pattern matching of file paths."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[[package]]
+name = "pendulum"
+version = "2.1.2"
+description = "Python datetimes made easy"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.dependencies]
+python-dateutil = ">=2.6,<3.0"
+pytzdata = ">=2020.1"
+
+[[package]]
+name = "platformdirs"
+version = "3.2.0"
+description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.extras]
+docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)", "sphinx (>=6.1.3)"]
+test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest (>=7.2.2)"]
+
+[[package]]
+name = "pluggy"
+version = "1.0.0"
+description = "plugin and hook calling mechanisms for python"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
+[[package]]
+name = "py-buzz"
+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.2,<4.0.0"
+
+[[package]]
+name = "pyasn1"
+version = "0.4.8"
+description = "ASN.1 types and codecs"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "pycodestyle"
+version = "2.7.0"
+description = "Python style guide checker"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "pydantic"
+version = "1.10.7"
+description = "Data validation and settings management using python type hints"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+typing-extensions = ">=4.2.0"
+
+[package.extras]
+dotenv = ["python-dotenv (>=0.10.4)"]
+email = ["email-validator (>=1.0.3)"]
+
+[[package]]
+name = "pyflakes"
+version = "2.3.1"
+description = "passive checker of Python programs"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "pyproject-flake8"
+version = "3.9.2"
+description = "pyproject-flake8 (`pflake8`), a monkey patching wrapper to connect flake8 with pyproject.toml configuration"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+flake8 = "3.9.2"
+tomli = {version = "*", markers = "python_version < \"3.11\""}
+
+[[package]]
+name = "pytest"
+version = "7.2.2"
+description = "pytest: simple powerful testing with Python"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+attrs = ">=19.2.0"
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=0.12,<2.0"
+tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
+
+[package.extras]
+testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
+
+[[package]]
+name = "pytest-cov"
+version = "3.0.0"
+description = "Pytest plugin for measuring coverage."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+coverage = {version = ">=5.2.1", extras = ["toml"]}
+pytest = ">=4.6"
+
+[package.extras]
+testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"]
+
+[[package]]
+name = "pytest-env"
+version = "0.6.2"
+description = "py.test plugin that allows you to add environment variables."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+pytest = ">=2.6.0"
+
+[[package]]
+name = "pytest-freezegun"
+version = "0.4.2"
+description = "Wrap tests with fixtures in freeze_time"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+freezegun = ">0.3"
+pytest = ">=3.0.0"
+
+[[package]]
+name = "pytest-mock"
+version = "3.10.0"
+description = "Thin-wrapper around the mock package for easier use with pytest"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+pytest = ">=5.0"
+
+[package.extras]
+dev = ["pre-commit", "tox", "pytest-asyncio"]
+
+[[package]]
+name = "pytest-random-order"
+version = "1.1.0"
+description = "Randomise the order in which pytest tests are run with some control over the randomness"
+category = "dev"
+optional = false
+python-versions = ">=3.5.0"
+
+[package.dependencies]
+pytest = ">=3.0.0"
+
+[[package]]
+name = "python-dateutil"
+version = "2.8.2"
+description = "Extensions to the standard Python datetime module"
+category = "main"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+
+[package.dependencies]
+six = ">=1.5"
+
+[[package]]
+name = "python-jose"
+version = "3.3.0"
+description = "JOSE implementation in Python"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+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 = "pytzdata"
+version = "2020.1"
+description = "The Olson timezone database for Python."
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "requests"
+version = "2.28.2"
+description = "Python HTTP for Humans."
+category = "dev"
+optional = false
+python-versions = ">=3.7, <4"
+
+[package.dependencies]
+certifi = ">=2017.4.17"
+charset-normalizer = ">=2,<4"
+idna = ">=2.5,<4"
+urllib3 = ">=1.21.1,<1.27"
+
+[package.extras]
+socks = ["PySocks (>=1.5.6,!=1.5.7)"]
+use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"]
+
+[[package]]
+name = "requests-mock"
+version = "1.10.0"
+description = "Mock out responses from the requests package"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+requests = ">=2.3,<3"
+six = "*"
+
+[package.extras]
+fixture = ["fixtures"]
+test = ["fixtures", "mock", "purl", "pytest", "sphinx", "testrepository (>=0.0.18)", "testtools", "requests-futures"]
+
+[[package]]
+name = "respx"
+version = "0.19.3"
+description = "A utility for mocking out the Python HTTPX and HTTP Core libraries."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+httpx = ">=0.21.0"
+
+[[package]]
+name = "rfc3986"
+version = "1.5.0"
+description = "Validating URI References per RFC 3986"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+idna = {version = "*", optional = true, markers = "extra == \"idna2008\""}
+
+[package.extras]
+idna2008 = ["idna"]
+
+[[package]]
+name = "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 = "six"
+version = "1.16.0"
+description = "Python 2 and 3 compatibility utilities"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
+name = "sniffio"
+version = "1.3.0"
+description = "Sniff out which async library your code is running under"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[[package]]
+name = "toml"
+version = "0.10.2"
+description = "Python Library for Tom's Obvious, Minimal Language"
+category = "dev"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
+name = "tomli"
+version = "2.0.1"
+description = "A lil' TOML parser"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[[package]]
+name = "typing-extensions"
+version = "4.5.0"
+description = "Backported and Experimental Type Hints for Python 3.7+"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[[package]]
+name = "urllib3"
+version = "1.26.15"
+description = "HTTP library with thread-safe connection pooling, file post, and more."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
+
+[package.extras]
+brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"]
+secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"]
+socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
+
+[[package]]
+name = "win32-setctime"
+version = "1.1.0"
+description = "A small Python utility to set file creation time on Windows"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[package.extras]
+dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"]
+
+[metadata]
+lock-version = "1.1"
+python-versions = "^3.8"
+content-hash = "56f2c73647e11bb8effb4f3907e66a0fa1114f9280e653bd01418088e3dcd18d"
+
+[metadata.files]
+anyio = [
+    {file = "anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"},
+    {file = "anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"},
+]
+attrs = [
+    {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"},
+    {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"},
+]
+black = [
+    {file = "black-23.1.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:b6a92a41ee34b883b359998f0c8e6eb8e99803aa8bf3123bf2b2e6fec505a221"},
+    {file = "black-23.1.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:57c18c5165c1dbe291d5306e53fb3988122890e57bd9b3dcb75f967f13411a26"},
+    {file = "black-23.1.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:9880d7d419bb7e709b37e28deb5e68a49227713b623c72b2b931028ea65f619b"},
+    {file = "black-23.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6663f91b6feca5d06f2ccd49a10f254f9298cc1f7f49c46e498a0771b507104"},
+    {file = "black-23.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:9afd3f493666a0cd8f8df9a0200c6359ac53940cbde049dcb1a7eb6ee2dd7074"},
+    {file = "black-23.1.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:bfffba28dc52a58f04492181392ee380e95262af14ee01d4bc7bb1b1c6ca8d27"},
+    {file = "black-23.1.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c1c476bc7b7d021321e7d93dc2cbd78ce103b84d5a4cf97ed535fbc0d6660648"},
+    {file = "black-23.1.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:382998821f58e5c8238d3166c492139573325287820963d2f7de4d518bd76958"},
+    {file = "black-23.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bf649fda611c8550ca9d7592b69f0637218c2369b7744694c5e4902873b2f3a"},
+    {file = "black-23.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:121ca7f10b4a01fd99951234abdbd97728e1240be89fde18480ffac16503d481"},
+    {file = "black-23.1.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:a8471939da5e824b891b25751955be52ee7f8a30a916d570a5ba8e0f2eb2ecad"},
+    {file = "black-23.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8178318cb74f98bc571eef19068f6ab5613b3e59d4f47771582f04e175570ed8"},
+    {file = "black-23.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:a436e7881d33acaf2536c46a454bb964a50eff59b21b51c6ccf5a40601fbef24"},
+    {file = "black-23.1.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:a59db0a2094d2259c554676403fa2fac3473ccf1354c1c63eccf7ae65aac8ab6"},
+    {file = "black-23.1.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:0052dba51dec07ed029ed61b18183942043e00008ec65d5028814afaab9a22fd"},
+    {file = "black-23.1.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:49f7b39e30f326a34b5c9a4213213a6b221d7ae9d58ec70df1c4a307cf2a1580"},
+    {file = "black-23.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:162e37d49e93bd6eb6f1afc3e17a3d23a823042530c37c3c42eeeaf026f38468"},
+    {file = "black-23.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b70eb40a78dfac24842458476135f9b99ab952dd3f2dab738c1881a9b38b753"},
+    {file = "black-23.1.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:a29650759a6a0944e7cca036674655c2f0f63806ddecc45ed40b7b8aa314b651"},
+    {file = "black-23.1.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:bb460c8561c8c1bec7824ecbc3ce085eb50005883a6203dcfb0122e95797ee06"},
+    {file = "black-23.1.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:c91dfc2c2a4e50df0026f88d2215e166616e0c80e86004d0003ece0488db2739"},
+    {file = "black-23.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a951cc83ab535d248c89f300eccbd625e80ab880fbcfb5ac8afb5f01a258ac9"},
+    {file = "black-23.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:0680d4380db3719ebcfb2613f34e86c8e6d15ffeabcf8ec59355c5e7b85bb555"},
+    {file = "black-23.1.0-py3-none-any.whl", hash = "sha256:7a0f701d314cfa0896b9001df70a530eb2472babb76086344e688829efd97d32"},
+    {file = "black-23.1.0.tar.gz", hash = "sha256:b0bd97bea8903f5a2ba7219257a44e3f1f9d00073d6cc1add68f0beec69692ac"},
+]
+certifi = [
+    {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"},
+    {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"},
+]
+charset-normalizer = [
+    {file = "charset-normalizer-3.1.0.tar.gz", hash = "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-win32.whl", hash = "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-win32.whl", hash = "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-win32.whl", hash = "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-win32.whl", hash = "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b"},
+    {file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"},
+]
+click = [
+    {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
+    {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
+]
+colorama = [
+    {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+    {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+coverage = [
+    {file = "coverage-7.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c90e73bdecb7b0d1cea65a08cb41e9d672ac6d7995603d6465ed4914b98b9ad7"},
+    {file = "coverage-7.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e2926b8abedf750c2ecf5035c07515770944acf02e1c46ab08f6348d24c5f94d"},
+    {file = "coverage-7.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57b77b9099f172804e695a40ebaa374f79e4fb8b92f3e167f66facbf92e8e7f5"},
+    {file = "coverage-7.2.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:efe1c0adad110bf0ad7fb59f833880e489a61e39d699d37249bdf42f80590169"},
+    {file = "coverage-7.2.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2199988e0bc8325d941b209f4fd1c6fa007024b1442c5576f1a32ca2e48941e6"},
+    {file = "coverage-7.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:81f63e0fb74effd5be736cfe07d710307cc0a3ccb8f4741f7f053c057615a137"},
+    {file = "coverage-7.2.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:186e0fc9cf497365036d51d4d2ab76113fb74f729bd25da0975daab2e107fd90"},
+    {file = "coverage-7.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:420f94a35e3e00a2b43ad5740f935358e24478354ce41c99407cddd283be00d2"},
+    {file = "coverage-7.2.2-cp310-cp310-win32.whl", hash = "sha256:38004671848b5745bb05d4d621526fca30cee164db42a1f185615f39dc997292"},
+    {file = "coverage-7.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:0ce383d5f56d0729d2dd40e53fe3afeb8f2237244b0975e1427bfb2cf0d32bab"},
+    {file = "coverage-7.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3eb55b7b26389dd4f8ae911ba9bc8c027411163839dea4c8b8be54c4ee9ae10b"},
+    {file = "coverage-7.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d2b96123a453a2d7f3995ddb9f28d01fd112319a7a4d5ca99796a7ff43f02af5"},
+    {file = "coverage-7.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:299bc75cb2a41e6741b5e470b8c9fb78d931edbd0cd009c58e5c84de57c06731"},
+    {file = "coverage-7.2.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e1df45c23d4230e3d56d04414f9057eba501f78db60d4eeecfcb940501b08fd"},
+    {file = "coverage-7.2.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:006ed5582e9cbc8115d2e22d6d2144a0725db542f654d9d4fda86793832f873d"},
+    {file = "coverage-7.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d683d230b5774816e7d784d7ed8444f2a40e7a450e5720d58af593cb0b94a212"},
+    {file = "coverage-7.2.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8efb48fa743d1c1a65ee8787b5b552681610f06c40a40b7ef94a5b517d885c54"},
+    {file = "coverage-7.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4c752d5264053a7cf2fe81c9e14f8a4fb261370a7bb344c2a011836a96fb3f57"},
+    {file = "coverage-7.2.2-cp311-cp311-win32.whl", hash = "sha256:55272f33da9a5d7cccd3774aeca7a01e500a614eaea2a77091e9be000ecd401d"},
+    {file = "coverage-7.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:92ebc1619650409da324d001b3a36f14f63644c7f0a588e331f3b0f67491f512"},
+    {file = "coverage-7.2.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5afdad4cc4cc199fdf3e18088812edcf8f4c5a3c8e6cb69127513ad4cb7471a9"},
+    {file = "coverage-7.2.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0484d9dd1e6f481b24070c87561c8d7151bdd8b044c93ac99faafd01f695c78e"},
+    {file = "coverage-7.2.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d530191aa9c66ab4f190be8ac8cc7cfd8f4f3217da379606f3dd4e3d83feba69"},
+    {file = "coverage-7.2.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ac0f522c3b6109c4b764ffec71bf04ebc0523e926ca7cbe6c5ac88f84faced0"},
+    {file = "coverage-7.2.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ba279aae162b20444881fc3ed4e4f934c1cf8620f3dab3b531480cf602c76b7f"},
+    {file = "coverage-7.2.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:53d0fd4c17175aded9c633e319360d41a1f3c6e352ba94edcb0fa5167e2bad67"},
+    {file = "coverage-7.2.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c99cb7c26a3039a8a4ee3ca1efdde471e61b4837108847fb7d5be7789ed8fd9"},
+    {file = "coverage-7.2.2-cp37-cp37m-win32.whl", hash = "sha256:5cc0783844c84af2522e3a99b9b761a979a3ef10fb87fc4048d1ee174e18a7d8"},
+    {file = "coverage-7.2.2-cp37-cp37m-win_amd64.whl", hash = "sha256:817295f06eacdc8623dc4df7d8b49cea65925030d4e1e2a7c7218380c0072c25"},
+    {file = "coverage-7.2.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6146910231ece63facfc5984234ad1b06a36cecc9fd0c028e59ac7c9b18c38c6"},
+    {file = "coverage-7.2.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:387fb46cb8e53ba7304d80aadca5dca84a2fbf6fe3faf6951d8cf2d46485d1e5"},
+    {file = "coverage-7.2.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:046936ab032a2810dcaafd39cc4ef6dd295df1a7cbead08fe996d4765fca9fe4"},
+    {file = "coverage-7.2.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e627dee428a176ffb13697a2c4318d3f60b2ccdde3acdc9b3f304206ec130ccd"},
+    {file = "coverage-7.2.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fa54fb483decc45f94011898727802309a109d89446a3c76387d016057d2c84"},
+    {file = "coverage-7.2.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3668291b50b69a0c1ef9f462c7df2c235da3c4073f49543b01e7eb1dee7dd540"},
+    {file = "coverage-7.2.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7c20b731211261dc9739bbe080c579a1835b0c2d9b274e5fcd903c3a7821cf88"},
+    {file = "coverage-7.2.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5764e1f7471cb8f64b8cda0554f3d4c4085ae4b417bfeab236799863703e5de2"},
+    {file = "coverage-7.2.2-cp38-cp38-win32.whl", hash = "sha256:4f01911c010122f49a3e9bdc730eccc66f9b72bd410a3a9d3cb8448bb50d65d3"},
+    {file = "coverage-7.2.2-cp38-cp38-win_amd64.whl", hash = "sha256:c448b5c9e3df5448a362208b8d4b9ed85305528313fca1b479f14f9fe0d873b8"},
+    {file = "coverage-7.2.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bfe7085783cda55e53510482fa7b5efc761fad1abe4d653b32710eb548ebdd2d"},
+    {file = "coverage-7.2.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9d22e94e6dc86de981b1b684b342bec5e331401599ce652900ec59db52940005"},
+    {file = "coverage-7.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:507e4720791977934bba016101579b8c500fb21c5fa3cd4cf256477331ddd988"},
+    {file = "coverage-7.2.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bc4803779f0e4b06a2361f666e76f5c2e3715e8e379889d02251ec911befd149"},
+    {file = "coverage-7.2.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db8c2c5ace167fd25ab5dd732714c51d4633f58bac21fb0ff63b0349f62755a8"},
+    {file = "coverage-7.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4f68ee32d7c4164f1e2c8797535a6d0a3733355f5861e0f667e37df2d4b07140"},
+    {file = "coverage-7.2.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d52f0a114b6a58305b11a5cdecd42b2e7f1ec77eb20e2b33969d702feafdd016"},
+    {file = "coverage-7.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:797aad79e7b6182cb49c08cc5d2f7aa7b2128133b0926060d0a8889ac43843be"},
+    {file = "coverage-7.2.2-cp39-cp39-win32.whl", hash = "sha256:db45eec1dfccdadb179b0f9ca616872c6f700d23945ecc8f21bb105d74b1c5fc"},
+    {file = "coverage-7.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:8dbe2647bf58d2c5a6c5bcc685f23b5f371909a5624e9f5cd51436d6a9f6c6ef"},
+    {file = "coverage-7.2.2-pp37.pp38.pp39-none-any.whl", hash = "sha256:872d6ce1f5be73f05bea4df498c140b9e7ee5418bfa2cc8204e7f9b817caa968"},
+    {file = "coverage-7.2.2.tar.gz", hash = "sha256:36dd42da34fe94ed98c39887b86db9d06777b1c8f860520e21126a75507024f2"},
+]
+ecdsa = [
+    {file = "ecdsa-0.18.0-py2.py3-none-any.whl", hash = "sha256:80600258e7ed2f16b9aa1d7c295bd70194109ad5a30fdee0eaeefef1d4c559dd"},
+    {file = "ecdsa-0.18.0.tar.gz", hash = "sha256:190348041559e21b22a1d65cee485282ca11a6f81d503fddb84d5017e9ed1e49"},
+]
+exceptiongroup = [
+    {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"},
+    {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"},
+]
+flake8 = [
+    {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"},
+    {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"},
+]
+freezegun = [
+    {file = "freezegun-1.2.2-py3-none-any.whl", hash = "sha256:ea1b963b993cb9ea195adbd893a48d573fda951b0da64f60883d7e988b606c9f"},
+    {file = "freezegun-1.2.2.tar.gz", hash = "sha256:cd22d1ba06941384410cd967d8a99d5ae2442f57dfafeff2fda5de8dc5c05446"},
+]
+h11 = [
+    {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
+    {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"},
+]
+httpcore = [
+    {file = "httpcore-0.14.7-py3-none-any.whl", hash = "sha256:47d772f754359e56dd9d892d9593b6f9870a37aeb8ba51e9a88b09b3d68cfade"},
+    {file = "httpcore-0.14.7.tar.gz", hash = "sha256:7503ec1c0f559066e7e39bc4003fd2ce023d01cf51793e3c173b864eb456ead1"},
+]
+httpx = [
+    {file = "httpx-0.22.0-py3-none-any.whl", hash = "sha256:e35e83d1d2b9b2a609ef367cc4c1e66fd80b750348b20cc9e19d1952fc2ca3f6"},
+    {file = "httpx-0.22.0.tar.gz", hash = "sha256:d8e778f76d9bbd46af49e7f062467e3157a5a3d2ae4876a4bbfd8a51ed9c9cb4"},
+]
+idna = [
+    {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"},
+    {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"},
+]
+iniconfig = [
+    {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
+    {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
+]
+isort = [
+    {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"},
+    {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"},
+]
+loguru = [
+    {file = "loguru-0.6.0-py3-none-any.whl", hash = "sha256:4e2414d534a2ab57573365b3e6d0234dfb1d84b68b7f3b948e6fb743860a77c3"},
+    {file = "loguru-0.6.0.tar.gz", hash = "sha256:066bd06758d0a513e9836fd9c6b5a75bfb3fd36841f4b996bc60b547a309d41c"},
+]
+mccabe = [
+    {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
+    {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
+]
+mypy = [
+    {file = "mypy-0.931-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3c5b42d0815e15518b1f0990cff7a705805961613e701db60387e6fb663fe78a"},
+    {file = "mypy-0.931-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c89702cac5b302f0c5d33b172d2b55b5df2bede3344a2fbed99ff96bddb2cf00"},
+    {file = "mypy-0.931-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:300717a07ad09525401a508ef5d105e6b56646f7942eb92715a1c8d610149714"},
+    {file = "mypy-0.931-cp310-cp310-win_amd64.whl", hash = "sha256:7b3f6f557ba4afc7f2ce6d3215d5db279bcf120b3cfd0add20a5d4f4abdae5bc"},
+    {file = "mypy-0.931-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:1bf752559797c897cdd2c65f7b60c2b6969ffe458417b8d947b8340cc9cec08d"},
+    {file = "mypy-0.931-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4365c60266b95a3f216a3047f1d8e3f895da6c7402e9e1ddfab96393122cc58d"},
+    {file = "mypy-0.931-cp36-cp36m-win_amd64.whl", hash = "sha256:1b65714dc296a7991000b6ee59a35b3f550e0073411ac9d3202f6516621ba66c"},
+    {file = "mypy-0.931-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e839191b8da5b4e5d805f940537efcaa13ea5dd98418f06dc585d2891d228cf0"},
+    {file = "mypy-0.931-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:50c7346a46dc76a4ed88f3277d4959de8a2bd0a0fa47fa87a4cde36fe247ac05"},
+    {file = "mypy-0.931-cp37-cp37m-win_amd64.whl", hash = "sha256:d8f1ff62f7a879c9fe5917b3f9eb93a79b78aad47b533911b853a757223f72e7"},
+    {file = "mypy-0.931-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f9fe20d0872b26c4bba1c1be02c5340de1019530302cf2dcc85c7f9fc3252ae0"},
+    {file = "mypy-0.931-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1b06268df7eb53a8feea99cbfff77a6e2b205e70bf31743e786678ef87ee8069"},
+    {file = "mypy-0.931-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8c11003aaeaf7cc2d0f1bc101c1cc9454ec4cc9cb825aef3cafff8a5fdf4c799"},
+    {file = "mypy-0.931-cp38-cp38-win_amd64.whl", hash = "sha256:d9d2b84b2007cea426e327d2483238f040c49405a6bf4074f605f0156c91a47a"},
+    {file = "mypy-0.931-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ff3bf387c14c805ab1388185dd22d6b210824e164d4bb324b195ff34e322d166"},
+    {file = "mypy-0.931-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5b56154f8c09427bae082b32275a21f500b24d93c88d69a5e82f3978018a0266"},
+    {file = "mypy-0.931-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8ca7f8c4b1584d63c9a0f827c37ba7a47226c19a23a753d52e5b5eddb201afcd"},
+    {file = "mypy-0.931-cp39-cp39-win_amd64.whl", hash = "sha256:74f7eccbfd436abe9c352ad9fb65872cc0f1f0a868e9d9c44db0893440f0c697"},
+    {file = "mypy-0.931-py3-none-any.whl", hash = "sha256:1171f2e0859cfff2d366da2c7092b06130f232c636a3f7301e3feb8b41f6377d"},
+    {file = "mypy-0.931.tar.gz", hash = "sha256:0038b21890867793581e4cb0d810829f5fd4441aa75796b53033af3aa30430ce"},
+]
+mypy-extensions = [
+    {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
+    {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
+]
+packaging = [
+    {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"},
+    {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"},
+]
+pathspec = [
+    {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"},
+    {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"},
+]
+pendulum = [
+    {file = "pendulum-2.1.2-cp27-cp27m-macosx_10_15_x86_64.whl", hash = "sha256:b6c352f4bd32dff1ea7066bd31ad0f71f8d8100b9ff709fb343f3b86cee43efe"},
+    {file = "pendulum-2.1.2-cp27-cp27m-win_amd64.whl", hash = "sha256:318f72f62e8e23cd6660dbafe1e346950281a9aed144b5c596b2ddabc1d19739"},
+    {file = "pendulum-2.1.2-cp35-cp35m-macosx_10_15_x86_64.whl", hash = "sha256:0731f0c661a3cb779d398803655494893c9f581f6488048b3fb629c2342b5394"},
+    {file = "pendulum-2.1.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:3481fad1dc3f6f6738bd575a951d3c15d4b4ce7c82dce37cf8ac1483fde6e8b0"},
+    {file = "pendulum-2.1.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9702069c694306297ed362ce7e3c1ef8404ac8ede39f9b28b7c1a7ad8c3959e3"},
+    {file = "pendulum-2.1.2-cp35-cp35m-win_amd64.whl", hash = "sha256:fb53ffa0085002ddd43b6ca61a7b34f2d4d7c3ed66f931fe599e1a531b42af9b"},
+    {file = "pendulum-2.1.2-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:c501749fdd3d6f9e726086bf0cd4437281ed47e7bca132ddb522f86a1645d360"},
+    {file = "pendulum-2.1.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:c807a578a532eeb226150d5006f156632df2cc8c5693d778324b43ff8c515dd0"},
+    {file = "pendulum-2.1.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2d1619a721df661e506eff8db8614016f0720ac171fe80dda1333ee44e684087"},
+    {file = "pendulum-2.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:f888f2d2909a414680a29ae74d0592758f2b9fcdee3549887779cd4055e975db"},
+    {file = "pendulum-2.1.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:e95d329384717c7bf627bf27e204bc3b15c8238fa8d9d9781d93712776c14002"},
+    {file = "pendulum-2.1.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:4c9c689747f39d0d02a9f94fcee737b34a5773803a64a5fdb046ee9cac7442c5"},
+    {file = "pendulum-2.1.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1245cd0075a3c6d889f581f6325dd8404aca5884dea7223a5566c38aab94642b"},
+    {file = "pendulum-2.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:db0a40d8bcd27b4fb46676e8eb3c732c67a5a5e6bfab8927028224fbced0b40b"},
+    {file = "pendulum-2.1.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:f5e236e7730cab1644e1b87aca3d2ff3e375a608542e90fe25685dae46310116"},
+    {file = "pendulum-2.1.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:de42ea3e2943171a9e95141f2eecf972480636e8e484ccffaf1e833929e9e052"},
+    {file = "pendulum-2.1.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7c5ec650cb4bec4c63a89a0242cc8c3cebcec92fcfe937c417ba18277d8560be"},
+    {file = "pendulum-2.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:33fb61601083f3eb1d15edeb45274f73c63b3c44a8524703dc143f4212bf3269"},
+    {file = "pendulum-2.1.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:29c40a6f2942376185728c9a0347d7c0f07905638c83007e1d262781f1e6953a"},
+    {file = "pendulum-2.1.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:94b1fc947bfe38579b28e1cccb36f7e28a15e841f30384b5ad6c5e31055c85d7"},
+    {file = "pendulum-2.1.2.tar.gz", hash = "sha256:b06a0ca1bfe41c990bbf0c029f0b6501a7f2ec4e38bfec730712015e8860f207"},
+]
+platformdirs = [
+    {file = "platformdirs-3.2.0-py3-none-any.whl", hash = "sha256:ebe11c0d7a805086e99506aa331612429a72ca7cd52a1f0d277dc4adc20cb10e"},
+    {file = "platformdirs-3.2.0.tar.gz", hash = "sha256:d5b638ca397f25f979350ff789db335903d7ea010ab28903f57b27e1b16c2b08"},
+]
+pluggy = [
+    {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
+    {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
+]
+py-buzz = [
+    {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.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"},
+    {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"},
+]
+pydantic = [
+    {file = "pydantic-1.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e79e999e539872e903767c417c897e729e015872040e56b96e67968c3b918b2d"},
+    {file = "pydantic-1.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:01aea3a42c13f2602b7ecbbea484a98169fb568ebd9e247593ea05f01b884b2e"},
+    {file = "pydantic-1.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:516f1ed9bc2406a0467dd777afc636c7091d71f214d5e413d64fef45174cfc7a"},
+    {file = "pydantic-1.10.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae150a63564929c675d7f2303008d88426a0add46efd76c3fc797cd71cb1b46f"},
+    {file = "pydantic-1.10.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ecbbc51391248116c0a055899e6c3e7ffbb11fb5e2a4cd6f2d0b93272118a209"},
+    {file = "pydantic-1.10.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f4a2b50e2b03d5776e7f21af73e2070e1b5c0d0df255a827e7c632962f8315af"},
+    {file = "pydantic-1.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:a7cd2251439988b413cb0a985c4ed82b6c6aac382dbaff53ae03c4b23a70e80a"},
+    {file = "pydantic-1.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:68792151e174a4aa9e9fc1b4e653e65a354a2fa0fed169f7b3d09902ad2cb6f1"},
+    {file = "pydantic-1.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe2507b8ef209da71b6fb5f4e597b50c5a34b78d7e857c4f8f3115effaef5fe"},
+    {file = "pydantic-1.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10a86d8c8db68086f1e30a530f7d5f83eb0685e632e411dbbcf2d5c0150e8dcd"},
+    {file = "pydantic-1.10.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d75ae19d2a3dbb146b6f324031c24f8a3f52ff5d6a9f22f0683694b3afcb16fb"},
+    {file = "pydantic-1.10.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:464855a7ff7f2cc2cf537ecc421291b9132aa9c79aef44e917ad711b4a93163b"},
+    {file = "pydantic-1.10.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:193924c563fae6ddcb71d3f06fa153866423ac1b793a47936656e806b64e24ca"},
+    {file = "pydantic-1.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:b4a849d10f211389502059c33332e91327bc154acc1845f375a99eca3afa802d"},
+    {file = "pydantic-1.10.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cc1dde4e50a5fc1336ee0581c1612215bc64ed6d28d2c7c6f25d2fe3e7c3e918"},
+    {file = "pydantic-1.10.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0cfe895a504c060e5d36b287ee696e2fdad02d89e0d895f83037245218a87fe"},
+    {file = "pydantic-1.10.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:670bb4683ad1e48b0ecb06f0cfe2178dcf74ff27921cdf1606e527d2617a81ee"},
+    {file = "pydantic-1.10.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:950ce33857841f9a337ce07ddf46bc84e1c4946d2a3bba18f8280297157a3fd1"},
+    {file = "pydantic-1.10.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c15582f9055fbc1bfe50266a19771bbbef33dd28c45e78afbe1996fd70966c2a"},
+    {file = "pydantic-1.10.7-cp37-cp37m-win_amd64.whl", hash = "sha256:82dffb306dd20bd5268fd6379bc4bfe75242a9c2b79fec58e1041fbbdb1f7914"},
+    {file = "pydantic-1.10.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8c7f51861d73e8b9ddcb9916ae7ac39fb52761d9ea0df41128e81e2ba42886cd"},
+    {file = "pydantic-1.10.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6434b49c0b03a51021ade5c4daa7d70c98f7a79e95b551201fff682fc1661245"},
+    {file = "pydantic-1.10.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d34ab766fa056df49013bb6e79921a0265204c071984e75a09cbceacbbdd5d"},
+    {file = "pydantic-1.10.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:701daea9ffe9d26f97b52f1d157e0d4121644f0fcf80b443248434958fd03dc3"},
+    {file = "pydantic-1.10.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cf135c46099ff3f919d2150a948ce94b9ce545598ef2c6c7bf55dca98a304b52"},
+    {file = "pydantic-1.10.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0f85904f73161817b80781cc150f8b906d521fa11e3cdabae19a581c3606209"},
+    {file = "pydantic-1.10.7-cp38-cp38-win_amd64.whl", hash = "sha256:9f6f0fd68d73257ad6685419478c5aece46432f4bdd8d32c7345f1986496171e"},
+    {file = "pydantic-1.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c230c0d8a322276d6e7b88c3f7ce885f9ed16e0910354510e0bae84d54991143"},
+    {file = "pydantic-1.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:976cae77ba6a49d80f461fd8bba183ff7ba79f44aa5cfa82f1346b5626542f8e"},
+    {file = "pydantic-1.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d45fc99d64af9aaf7e308054a0067fdcd87ffe974f2442312372dfa66e1001d"},
+    {file = "pydantic-1.10.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d2a5ebb48958754d386195fe9e9c5106f11275867051bf017a8059410e9abf1f"},
+    {file = "pydantic-1.10.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:abfb7d4a7cd5cc4e1d1887c43503a7c5dd608eadf8bc615413fc498d3e4645cd"},
+    {file = "pydantic-1.10.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:80b1fab4deb08a8292d15e43a6edccdffa5377a36a4597bb545b93e79c5ff0a5"},
+    {file = "pydantic-1.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:d71e69699498b020ea198468e2480a2f1e7433e32a3a99760058c6520e2bea7e"},
+    {file = "pydantic-1.10.7-py3-none-any.whl", hash = "sha256:0cd181f1d0b1d00e2b705f1bf1ac7799a2d938cce3376b8007df62b29be3c2c6"},
+    {file = "pydantic-1.10.7.tar.gz", hash = "sha256:cfc83c0678b6ba51b0532bea66860617c4cd4251ecf76e9846fa5a9f3454e97e"},
+]
+pyflakes = [
+    {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"},
+    {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"},
+]
+pyproject-flake8 = [
+    {file = "pyproject-flake8-3.9.2.tar.gz", hash = "sha256:ba090f638d81cf235bfda094637223244411d47ce25d4e96b08b48fde70823d0"},
+    {file = "pyproject_flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:896b8e2c4c0e33002a86c16d3d29ba4ed4c796b4dd68e41df0cc1f9c3554b144"},
+]
+pytest = [
+    {file = "pytest-7.2.2-py3-none-any.whl", hash = "sha256:130328f552dcfac0b1cec75c12e3f005619dc5f874f0a06e8ff7263f0ee6225e"},
+    {file = "pytest-7.2.2.tar.gz", hash = "sha256:c99ab0c73aceb050f68929bc93af19ab6db0558791c6a0715723abe9d0ade9d4"},
+]
+pytest-cov = [
+    {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"},
+    {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"},
+]
+pytest-env = [
+    {file = "pytest-env-0.6.2.tar.gz", hash = "sha256:7e94956aef7f2764f3c147d216ce066bf6c42948bb9e293169b1b1c880a580c2"},
+]
+pytest-freezegun = [
+    {file = "pytest-freezegun-0.4.2.zip", hash = "sha256:19c82d5633751bf3ec92caa481fb5cffaac1787bd485f0df6436fd6242176949"},
+    {file = "pytest_freezegun-0.4.2-py2.py3-none-any.whl", hash = "sha256:5318a6bfb8ba4b709c8471c94d0033113877b3ee02da5bfcd917c1889cde99a7"},
+]
+pytest-mock = [
+    {file = "pytest-mock-3.10.0.tar.gz", hash = "sha256:fbbdb085ef7c252a326fd8cdcac0aa3b1333d8811f131bdcc701002e1be7ed4f"},
+    {file = "pytest_mock-3.10.0-py3-none-any.whl", hash = "sha256:f4c973eeae0282963eb293eb173ce91b091a79c1334455acfac9ddee8a1c784b"},
+]
+pytest-random-order = [
+    {file = "pytest-random-order-1.1.0.tar.gz", hash = "sha256:dbe6debb9353a7af984cc9eddbeb3577dd4dbbcc1529a79e3d21f68ed9b45605"},
+    {file = "pytest_random_order-1.1.0-py3-none-any.whl", hash = "sha256:6cb1e59ab0f798bb0c3488c11ae0c70d7d3340306a466d28b28ccd8ef8c20b7e"},
+]
+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-jose = [
+    {file = "python-jose-3.3.0.tar.gz", hash = "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a"},
+    {file = "python_jose-3.3.0-py2.py3-none-any.whl", hash = "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a"},
+]
+pytzdata = [
+    {file = "pytzdata-2020.1-py2.py3-none-any.whl", hash = "sha256:e1e14750bcf95016381e4d472bad004eef710f2d6417240904070b3d6654485f"},
+    {file = "pytzdata-2020.1.tar.gz", hash = "sha256:3efa13b335a00a8de1d345ae41ec78dd11c9f8807f522d39850f2dd828681540"},
+]
+requests = [
+    {file = "requests-2.28.2-py3-none-any.whl", hash = "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa"},
+    {file = "requests-2.28.2.tar.gz", hash = "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf"},
+]
+requests-mock = [
+    {file = "requests-mock-1.10.0.tar.gz", hash = "sha256:59c9c32419a9fb1ae83ec242d98e889c45bd7d7a65d48375cc243ec08441658b"},
+    {file = "requests_mock-1.10.0-py2.py3-none-any.whl", hash = "sha256:2fdbb637ad17ee15c06f33d31169e71bf9fe2bdb7bc9da26185be0dd8d842699"},
+]
+respx = [
+    {file = "respx-0.19.3-py2.py3-none-any.whl", hash = "sha256:ea3049468bfcf95c7827436230e05b85a037050dc569dbeaecdaa08fa0de5750"},
+    {file = "respx-0.19.3.tar.gz", hash = "sha256:6dab3a19dfb0ea07cef996198c12bf8a34e6fc3573a24b184d714f765e8b5e57"},
+]
+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"},
+]
+six = [
+    {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+    {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
+]
+sniffio = [
+    {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"},
+    {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"},
+]
+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"},
+]
+typing-extensions = [
+    {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"},
+    {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"},
+]
+urllib3 = [
+    {file = "urllib3-1.26.15-py2.py3-none-any.whl", hash = "sha256:aa751d169e23c7479ce47a0cb0da579e3ede798f994f5816a74e4f4500dcea42"},
+    {file = "urllib3-1.26.15.tar.gz", hash = "sha256:8a388717b9476f934a21484e8c8e61875ab60644d29b9b39e11e4b9dc1c6b305"},
+]
+win32-setctime = [
+    {file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"},
+    {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"},
+]
diff --git a/jobbergate-core/pyproject.toml b/jobbergate-core/pyproject.toml
new file mode 100644
index 00000000..a8b134ab
--- /dev/null
+++ b/jobbergate-core/pyproject.toml
@@ -0,0 +1,78 @@
+[tool.poetry]
+name = "jobbergate-core"
+version = "3.4.3"
+description = "Jobbergate Core"
+authors = ["Omnivector Solutions <info@omnivector.solutions>"]
+license = "MIT"
+readme = "README.rst"
+repository = "https://github.com/omnivector-solutions/jobbergate"
+classifiers = [
+    "License :: OSI Approved :: MIT License",
+    "Operating System :: OS Independent",
+    "Programming Language :: Python :: 3",
+    "Development Status :: 5 - Production/Stable",
+    "Topic :: Scientific/Engineering",
+]
+
+[tool.poetry.urls]
+"Bug Tracker" = "https://github.com/omnivector-solutions/jobbergate/issues"
+"Changelog" = "https://github.com/omnivector-solutions/jobbergate/blob/main/jobbergate-core/CHANGELOG.rst"
+
+[tool.poetry.dependencies]
+python = "^3.8"
+httpx = "^0.22.0"
+loguru = "^0.6.0"
+pendulum = "^2.1.2"
+py-buzz = "^3.1.0"
+pydantic = "^1.8.2"
+python-jose = "^3.3.0"
+
+[tool.poetry.dev-dependencies]
+black = "^23.1"
+isort = "^5.9.3"
+mypy = "^0.931"
+pyproject-flake8 = "^3.8"
+pytest-cov = "^3.0.0"
+pytest-env = "^0.6.2"
+pytest-freezegun = "^0.4.2"
+pytest-mock = "^3.6.0"
+pytest-random-order = "^1.0.4"
+requests-mock = "^1.10"
+respx = "^0.19.2"
+toml = "^0.10.2"
+
+[tool.pytest.ini_options]
+minversion = "6.0"
+addopts = [
+    "--random-order",
+    "--cov=jobbergate_core",
+    "--cov-report=term-missing",
+    "--cov-fail-under=85",
+]
+
+[tool.black]
+line-length = 120
+
+[tool.isort]
+line_length = 120
+lines_after_imports = "2"
+profile = "black"
+multi_line_output = 3
+include_trailing_comma = true
+
+[tool.flake8]
+max-line-length = 120
+max-complexity = 40
+
+[[tool.mypy.overrides]]
+module = [
+    "dataclasses",
+    "toml",
+    "requests",
+    "jose.*",
+]
+ignore_missing_imports = true
+
+[build-system]
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"
diff --git a/jobbergate-docs/CHANGELOG.rst b/jobbergate-docs/CHANGELOG.rst
index 4993f480..f0a9f534 100644
--- a/jobbergate-docs/CHANGELOG.rst
+++ b/jobbergate-docs/CHANGELOG.rst
@@ -6,6 +6,7 @@ This file keeps track of all notable changes to jobbergate-docs
 
 Unreleased
 ----------
+- Added new page describing jobbergate-core and its components
 
 3.4.3 -- 2023-01-30
 -------------------
diff --git a/jobbergate-docs/Makefile b/jobbergate-docs/Makefile
index 14210b95..38957c09 100644
--- a/jobbergate-docs/Makefile
+++ b/jobbergate-docs/Makefile
@@ -27,6 +27,10 @@ format: install
 docs: install
 	poetry run sphinx-build src/ build/
 
+.PHONY: dev
+dev: install
+	poetry run sphinx-autobuild src/ build/ --watch ../jobbergate-core/jobbergate_core
+
 .PHONY: clean
 clean:
 	@find . -iname '*.pyc' -delete
diff --git a/jobbergate-docs/poetry.lock b/jobbergate-docs/poetry.lock
index dbe1838f..cdf79490 100644
--- a/jobbergate-docs/poetry.lock
+++ b/jobbergate-docs/poetry.lock
@@ -1,29 +1,46 @@
 [[package]]
 name = "alabaster"
-version = "0.7.12"
+version = "0.7.13"
 description = "A configurable sidebar-enabled Sphinx theme"
 category = "main"
 optional = false
-python-versions = "*"
+python-versions = ">=3.6"
+
+[[package]]
+name = "anyio"
+version = "3.6.2"
+description = "High level compatibility layer for multiple asynchronous event loop implementations"
+category = "main"
+optional = false
+python-versions = ">=3.6.2"
+
+[package.dependencies]
+idna = ">=2.8"
+sniffio = ">=1.1"
+
+[package.extras]
+doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
+test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"]
+trio = ["trio (>=0.16,<0.22)"]
 
 [[package]]
 name = "babel"
-version = "2.9.1"
+version = "2.11.0"
 description = "Internationalization utilities"
 category = "main"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=3.6"
 
 [package.dependencies]
 pytz = ">=2015.7"
 
 [[package]]
 name = "beautifulsoup4"
-version = "4.10.0"
+version = "4.11.2"
 description = "Screen-scraping library"
 category = "main"
 optional = false
-python-versions = ">3.0.0"
+python-versions = ">=3.6.0"
 
 [package.dependencies]
 soupsieve = ">1.2"
@@ -34,7 +51,7 @@ lxml = ["lxml"]
 
 [[package]]
 name = "black"
-version = "21.9b0"
+version = "21.12b0"
 description = "The uncompromising code formatter."
 category = "dev"
 optional = false
@@ -45,98 +62,127 @@ click = ">=7.1.2"
 mypy-extensions = ">=0.4.3"
 pathspec = ">=0.9.0,<1"
 platformdirs = ">=2"
-regex = ">=2020.1.8"
 tomli = ">=0.2.6,<2.0.0"
 typing-extensions = [
     {version = ">=3.10.0.0", markers = "python_version < \"3.10\""},
-    {version = "!=3.10.0.1", markers = "python_version >= \"3.10\""},
+    {version = ">=3.10.0.0,<3.10.0.1 || >3.10.0.1", markers = "python_version >= \"3.10\""},
 ]
 
 [package.extras]
 colorama = ["colorama (>=0.4.3)"]
-d = ["aiohttp (>=3.6.0)", "aiohttp-cors (>=0.4.0)"]
+d = ["aiohttp (>=3.7.4)"]
 jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
-python2 = ["typed-ast (>=1.4.2)"]
+python2 = ["typed-ast (>=1.4.3)"]
 uvloop = ["uvloop (>=0.15.2)"]
 
 [[package]]
 name = "certifi"
-version = "2021.10.8"
+version = "2022.12.7"
 description = "Python package for providing Mozilla's CA Bundle."
 category = "main"
 optional = false
-python-versions = "*"
+python-versions = ">=3.6"
 
 [[package]]
 name = "charset-normalizer"
-version = "2.0.7"
+version = "3.0.1"
 description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
 category = "main"
 optional = false
-python-versions = ">=3.5.0"
-
-[package.extras]
-unicode_backport = ["unicodedata2"]
+python-versions = "*"
 
 [[package]]
 name = "click"
-version = "7.1.2"
+version = "8.1.3"
 description = "Composable command line interface toolkit"
-category = "main"
+category = "dev"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+python-versions = ">=3.7"
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
 
 [[package]]
 name = "colorama"
-version = "0.4.4"
+version = "0.4.6"
 description = "Cross-platform colored terminal text."
 category = "main"
 optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+
+[[package]]
+name = "docutils"
+version = "0.16"
+description = "Docutils -- Python Documentation Utilities"
+category = "main"
+optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
 [[package]]
-name = "deprecated"
-version = "1.2.13"
-description = "Python @deprecated decorator to deprecate old python classes, functions or methods."
-category = "dev"
+name = "ecdsa"
+version = "0.18.0"
+description = "ECDSA cryptographic signature library (pure python)"
+category = "main"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+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 = "docutils"
-version = "0.16"
-description = "Docutils -- Python Documentation Utilities"
+name = "h11"
+version = "0.12.0"
+description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
 category = "main"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+python-versions = ">=3.6"
 
 [[package]]
-name = "flask"
-version = "2.0.2"
-description = "A simple framework for building complex web applications."
-category = "dev"
+name = "httpcore"
+version = "0.14.7"
+description = "A minimal low-level HTTP client."
+category = "main"
 optional = false
 python-versions = ">=3.6"
 
 [package.dependencies]
-click = ">=7.1.2"
-itsdangerous = ">=2.0"
-Jinja2 = ">=3.0"
-Werkzeug = ">=2.0"
+anyio = ">=3.0.0,<4.0.0"
+certifi = "*"
+h11 = ">=0.11,<0.13"
+sniffio = ">=1.0.0,<2.0.0"
 
 [package.extras]
-async = ["asgiref (>=3.2)"]
-dotenv = ["python-dotenv"]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (>=1.0.0,<2.0.0)"]
+
+[[package]]
+name = "httpx"
+version = "0.22.0"
+description = "The next generation HTTP client."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+certifi = "*"
+charset-normalizer = "*"
+httpcore = ">=0.14.5,<0.15.0"
+rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]}
+sniffio = "*"
+
+[package.extras]
+brotli = ["brotli", "brotlicffi"]
+cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10.0.0,<11.0.0)"]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (>=1.0.0,<2.0.0)"]
 
 [[package]]
 name = "idna"
-version = "3.3"
+version = "3.4"
 description = "Internationalized Domain Names in Applications (IDNA)"
 category = "main"
 optional = false
@@ -144,49 +190,49 @@ python-versions = ">=3.5"
 
 [[package]]
 name = "imagesize"
-version = "1.2.0"
+version = "1.4.1"
 description = "Getting image size from png/jpeg/jpeg2000/gif file"
 category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
 [[package]]
-name = "inflection"
-version = "0.5.1"
-description = "A port of Ruby on Rails inflector to Python"
-category = "dev"
+name = "importlib-metadata"
+version = "6.0.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 = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+perf = ["ipython"]
+testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"]
 
 [[package]]
 name = "isort"
-version = "5.9.3"
+version = "5.12.0"
 description = "A Python utility / library to sort Python imports."
 category = "dev"
 optional = false
-python-versions = ">=3.6.1,<4.0"
+python-versions = ">=3.8.0"
 
 [package.extras]
-pipfile_deprecated_finder = ["pipreqs", "requirementslib"]
-requirements_deprecated_finder = ["pipreqs", "pip-api"]
-colors = ["colorama (>=0.4.3,<0.5.0)"]
+colors = ["colorama (>=0.4.3)"]
+pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"]
 plugins = ["setuptools"]
-
-[[package]]
-name = "itsdangerous"
-version = "2.0.1"
-description = "Safely pass data to untrusted environments and back."
-category = "dev"
-optional = false
-python-versions = ">=3.6"
+requirements-deprecated-finder = ["pip-api", "pipreqs"]
 
 [[package]]
 name = "jinja2"
-version = "3.0.2"
+version = "3.1.2"
 description = "A very fast and expressive template engine."
 category = "main"
 optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
 
 [package.dependencies]
 MarkupSafe = ">=2.0"
@@ -194,6 +240,27 @@ MarkupSafe = ">=2.0"
 [package.extras]
 i18n = ["Babel (>=2.7)"]
 
+[[package]]
+name = "jobbergate-core"
+version = "3.4.3"
+description = "Jobbergate Core"
+category = "main"
+optional = false
+python-versions = "^3.6.2"
+develop = true
+
+[package.dependencies]
+httpx = "^0.22.0"
+loguru = "^0.6.0"
+pendulum = "^2.1.2"
+py-buzz = "^3.1.0"
+pydantic = "^1.8.2"
+python-jose = "^3.3.0"
+
+[package.source]
+type = "directory"
+url = "../jobbergate-core"
+
 [[package]]
 name = "livereload"
 version = "2.6.3"
@@ -206,68 +273,122 @@ python-versions = "*"
 six = "*"
 tornado = {version = "*", markers = "python_version > \"2.7\""}
 
+[[package]]
+name = "loguru"
+version = "0.6.0"
+description = "Python logging made (stupidly) simple"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[package.dependencies]
+colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""}
+win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
+
+[package.extras]
+dev = ["Sphinx (>=4.1.1)", "black (>=19.10b0)", "colorama (>=0.3.4)", "docutils (==0.16)", "flake8 (>=3.7.7)", "isort (>=5.1.1)", "pytest (>=4.6.2)", "pytest-cov (>=2.7.1)", "sphinx-autobuild (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)", "tox (>=3.9.0)"]
+
 [[package]]
 name = "markupsafe"
-version = "2.0.1"
+version = "2.1.2"
 description = "Safely add untrusted strings to HTML/XML markup."
 category = "main"
 optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
 
 [[package]]
 name = "mypy-extensions"
-version = "0.4.3"
-description = "Experimental type system extensions for programs checked with the mypy typechecker."
+version = "1.0.0"
+description = "Type system extensions for programs checked with the mypy type checker."
 category = "dev"
 optional = false
-python-versions = "*"
+python-versions = ">=3.5"
 
 [[package]]
 name = "packaging"
-version = "21.0"
+version = "23.0"
 description = "Core utilities for Python packages"
 category = "main"
 optional = false
-python-versions = ">=3.6"
-
-[package.dependencies]
-pyparsing = ">=2.0.2"
+python-versions = ">=3.7"
 
 [[package]]
 name = "pathspec"
-version = "0.9.0"
+version = "0.11.0"
 description = "Utility library for gitignore style pattern matching of file paths."
 category = "dev"
 optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+python-versions = ">=3.7"
+
+[[package]]
+name = "pendulum"
+version = "2.1.2"
+description = "Python datetimes made easy"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.dependencies]
+python-dateutil = ">=2.6,<3.0"
+pytzdata = ">=2020.1"
 
 [[package]]
 name = "platformdirs"
-version = "2.4.0"
-description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+version = "3.0.0"
+description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
 category = "dev"
 optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
 
 [package.extras]
-docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"]
-test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"]
+docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"]
+test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"]
+
+[[package]]
+name = "pockets"
+version = "0.9.1"
+description = "A collection of helpful Python tools!"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+six = ">=1.5.2"
 
 [[package]]
 name = "py-buzz"
-version = "2.1.3"
-description = "\"That's not flying, it's falling with style: Exceptions with extras\""
-category = "dev"
+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.2,<4.0.0"
+
+[[package]]
+name = "pyasn1"
+version = "0.4.8"
+description = "ASN.1 types and codecs"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "pydantic"
+version = "1.10.5"
+description = "Data validation and settings management using python type hints"
+category = "main"
 optional = false
-python-versions = ">=3.6,<4.0"
+python-versions = ">=3.7"
 
 [package.dependencies]
-deprecated = ">=1.2,<2.0"
-inflection = ">=0.5.1,<0.6.0"
+typing-extensions = ">=4.2.0"
+
+[package.extras]
+dotenv = ["python-dotenv (>=0.10.4)"]
+email = ["email-validator (>=1.0.3)"]
 
 [[package]]
 name = "pydata-sphinx-theme"
-version = "0.6.3"
+version = "0.7.2"
 description = "Bootstrap-based Sphinx theme from the PyData community"
 category = "main"
 optional = false
@@ -275,40 +396,69 @@ python-versions = ">=3.5"
 
 [package.dependencies]
 beautifulsoup4 = "*"
-docutils = "<0.17"
+docutils = "!=0.17.0"
 sphinx = "*"
 
 [package.extras]
-coverage = ["pytest-cov", "codecov", "sphinx", "numpydoc", "recommonmark", "pandas", "pytest", "pytest-regressions", "beautifulsoup4", "jupyter-sphinx", "plotly", "numpy", "xarray", "docutils (==0.16)"]
-test = ["sphinx", "numpydoc", "recommonmark", "pandas", "pytest", "pytest-regressions", "beautifulsoup4", "jupyter-sphinx", "plotly", "numpy", "xarray", "docutils (==0.16)"]
+coverage = ["beautifulsoup4", "codecov", "docutils (==0.16)", "jupyter-sphinx", "numpy", "numpydoc", "pandas", "plotly", "pytest", "pytest-cov", "pytest-regressions", "recommonmark", "sphinx", "sphinx-sitemap", "xarray"]
+test = ["beautifulsoup4", "docutils (==0.16)", "jupyter-sphinx", "numpy", "numpydoc", "pandas", "plotly", "pytest", "pytest-regressions", "recommonmark", "sphinx", "sphinx-sitemap", "xarray"]
 
 [[package]]
 name = "pygments"
-version = "2.10.0"
+version = "2.14.0"
 description = "Pygments is a syntax highlighting package written in Python."
 category = "main"
 optional = false
-python-versions = ">=3.5"
+python-versions = ">=3.6"
+
+[package.extras]
+plugins = ["importlib-metadata"]
 
 [[package]]
-name = "pyparsing"
-version = "3.0.3"
-description = "Python parsing module"
+name = "python-dateutil"
+version = "2.8.2"
+description = "Extensions to the standard Python datetime module"
 category = "main"
 optional = false
-python-versions = ">=3.6"
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+
+[package.dependencies]
+six = ">=1.5"
+
+[[package]]
+name = "python-jose"
+version = "3.3.0"
+description = "JOSE implementation in Python"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+ecdsa = "!=0.15"
+pyasn1 = "*"
+rsa = "*"
 
 [package.extras]
-diagrams = ["jinja2", "railroad-diagrams"]
+cryptography = ["cryptography (>=3.4.0)"]
+pycrypto = ["pyasn1", "pycrypto (>=2.6.0,<2.7.0)"]
+pycryptodome = ["pyasn1", "pycryptodome (>=3.3.1,<4.0.0)"]
 
 [[package]]
 name = "pytz"
-version = "2021.3"
+version = "2022.7.1"
 description = "World timezone definitions, modern and historical"
 category = "main"
 optional = false
 python-versions = "*"
 
+[[package]]
+name = "pytzdata"
+version = "2020.1"
+description = "The Olson timezone database for Python."
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
 [[package]]
 name = "pyyaml"
 version = "6.0"
@@ -317,31 +467,48 @@ category = "main"
 optional = false
 python-versions = ">=3.6"
 
-[[package]]
-name = "regex"
-version = "2021.10.23"
-description = "Alternative regular expression module, to replace re."
-category = "dev"
-optional = false
-python-versions = "*"
-
 [[package]]
 name = "requests"
-version = "2.26.0"
+version = "2.28.2"
 description = "Python HTTP for Humans."
 category = "main"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
+python-versions = ">=3.7, <4"
 
 [package.dependencies]
 certifi = ">=2017.4.17"
-charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""}
-idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""}
+charset-normalizer = ">=2,<4"
+idna = ">=2.5,<4"
 urllib3 = ">=1.21.1,<1.27"
 
 [package.extras]
-socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
-use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
+socks = ["PySocks (>=1.5.6,!=1.5.7)"]
+use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
+
+[[package]]
+name = "rfc3986"
+version = "1.5.0"
+description = "Validating URI References per RFC 3986"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+idna = {version = "*", optional = true, markers = "extra == \"idna2008\""}
+
+[package.extras]
+idna2008 = ["idna"]
+
+[[package]]
+name = "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 = "six"
@@ -351,9 +518,17 @@ category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
 
+[[package]]
+name = "sniffio"
+version = "1.3.0"
+description = "Sniff out which async library your code is running under"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
 [[package]]
 name = "snowballstemmer"
-version = "2.1.0"
+version = "2.2.0"
 description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms."
 category = "main"
 optional = false
@@ -361,15 +536,15 @@ python-versions = "*"
 
 [[package]]
 name = "soupsieve"
-version = "2.2.1"
+version = "2.4"
 description = "A modern CSS selector implementation for Beautiful Soup."
 category = "main"
 optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
 
 [[package]]
 name = "sphinx"
-version = "4.2.0"
+version = "4.5.0"
 description = "Python documentation generator"
 category = "main"
 optional = false
@@ -381,6 +556,7 @@ babel = ">=1.3"
 colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""}
 docutils = ">=0.14,<0.18"
 imagesize = "*"
+importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""}
 Jinja2 = ">=2.3"
 packaging = "*"
 Pygments = ">=2.0"
@@ -395,57 +571,71 @@ sphinxcontrib-serializinghtml = ">=1.1.5"
 
 [package.extras]
 docs = ["sphinxcontrib-websupport"]
-lint = ["flake8 (>=3.5.0)", "isort", "mypy (>=0.900)", "docutils-stubs", "types-typed-ast", "types-pkg-resources", "types-requests"]
-test = ["pytest", "pytest-cov", "html5lib", "cython", "typed-ast"]
+lint = ["docutils-stubs", "flake8 (>=3.5.0)", "isort", "mypy (>=0.931)", "types-requests", "types-typed-ast"]
+test = ["cython", "html5lib", "pytest", "pytest-cov", "typed-ast"]
+
+[[package]]
+name = "sphinx-autobuild"
+version = "2021.3.14"
+description = "Rebuild Sphinx documentation on changes, with live-reload in the browser."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+colorama = "*"
+livereload = "*"
+sphinx = "*"
+
+[package.extras]
+test = ["pytest", "pytest-cov"]
 
 [[package]]
 name = "sphinx-book-theme"
-version = "0.1.6"
-description = "Jupyter Book: Create an online book with Jupyter Notebooks"
+version = "0.1.10"
+description = "A clean book theme for scientific explanations and documentation with Sphinx"
 category = "main"
 optional = false
 python-versions = ">=3.6"
 
 [package.dependencies]
 beautifulsoup4 = ">=4.6.1,<5"
-click = ">=7.1,<8.0"
 docutils = ">=0.15,<0.17"
-pydata-sphinx-theme = ">=0.6.0,<0.7.0"
+pydata-sphinx-theme = ">=0.7.2,<0.8.0"
 pyyaml = "*"
 sphinx = ">=3,<5"
 
 [package.extras]
-code_style = ["pre-commit (>=2.7.0,<2.8.0)"]
+code-style = ["pre-commit (>=2.7.0,<2.8.0)"]
 live-dev = ["sphinx-autobuild", "web-compile (>=0.2.1,<0.3.0)"]
-sphinx = ["ablog (>=0.10.13,<0.11.0)", "ipywidgets", "folium", "numpy", "matplotlib", "myst-nb (>=0.13,<1.0)", "nbclient", "pandas", "plotly", "sphinx (>=4.0,<5.0)", "sphinx-design", "sphinx-copybutton", "sphinx-togglebutton (>=0.2.1)", "sphinx-thebe", "sphinxcontrib-bibtex (>=2.2,<3.0)", "sphinxext-opengraph"]
+sphinx = ["ablog (>=0.10.13,<0.11.0)", "folium", "ipywidgets", "matplotlib", "myst-nb (>=0.13,<1.0)", "nbclient", "numpy", "pandas", "plotly", "sphinx (>=4.0,<5.0)", "sphinx-copybutton", "sphinx-design", "sphinx-thebe", "sphinx-togglebutton (>=0.2.1)", "sphinxcontrib-bibtex (>=2.2,<3.0)", "sphinxext-opengraph"]
 testing = ["coverage", "myst-nb (>=0.13,<1.0)", "pytest (>=6.0.1,<6.1.0)", "pytest-cov", "pytest-regressions (>=2.0.1,<2.1.0)", "sphinx-thebe"]
 
 [[package]]
-name = "sphinx-view"
-version = "0.3.1"
-description = "View your rendered Sphinx or ReStructuredText documents in a browser"
-category = "dev"
+name = "sphinx-copybutton"
+version = "0.5.1"
+description = "Add a copy button to each of your code cells."
+category = "main"
 optional = false
-python-versions = ">=3.7,<4.0"
+python-versions = ">=3.7"
 
 [package.dependencies]
-Flask = ">=2.0.1,<3.0.0"
-inflection = ">=0.5.1,<0.6.0"
-livereload = ">=2.6.3,<3.0.0"
-py-buzz = ">=2.1.3,<3.0.0"
-Sphinx = ">=4.2.0,<5.0.0"
-typer = ">=0.4.0,<0.5.0"
+sphinx = ">=1.8"
+
+[package.extras]
+code-style = ["pre-commit (==2.12.1)"]
+rtd = ["ipython", "myst-nb", "sphinx", "sphinx-book-theme", "sphinx-examples"]
 
 [[package]]
 name = "sphinxcontrib-applehelp"
-version = "1.0.2"
-description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books"
+version = "1.0.4"
+description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books"
 category = "main"
 optional = false
-python-versions = ">=3.5"
+python-versions = ">=3.8"
 
 [package.extras]
-lint = ["flake8", "mypy", "docutils-stubs"]
+lint = ["docutils-stubs", "flake8", "mypy"]
 test = ["pytest"]
 
 [[package]]
@@ -457,24 +647,24 @@ optional = false
 python-versions = ">=3.5"
 
 [package.extras]
-lint = ["flake8", "mypy", "docutils-stubs"]
+lint = ["docutils-stubs", "flake8", "mypy"]
 test = ["pytest"]
 
 [[package]]
 name = "sphinxcontrib-htmlhelp"
-version = "2.0.0"
+version = "2.0.1"
 description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files"
 category = "main"
 optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.8"
 
 [package.extras]
-lint = ["flake8", "mypy", "docutils-stubs"]
-test = ["pytest", "html5lib"]
+lint = ["docutils-stubs", "flake8", "mypy"]
+test = ["html5lib", "pytest"]
 
 [[package]]
 name = "sphinxcontrib-httpdomain"
-version = "1.8.0"
+version = "1.8.1"
 description = "Sphinx domain for documenting HTTP APIs"
 category = "main"
 optional = false
@@ -493,7 +683,19 @@ optional = false
 python-versions = ">=3.5"
 
 [package.extras]
-test = ["pytest", "flake8", "mypy"]
+test = ["flake8", "mypy", "pytest"]
+
+[[package]]
+name = "sphinxcontrib-napoleon"
+version = "0.7"
+description = "Sphinx \"napoleon\" extension."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+pockets = ">=0.3"
+six = ">=1.5.2"
 
 [[package]]
 name = "sphinxcontrib-qthelp"
@@ -504,7 +706,7 @@ optional = false
 python-versions = ">=3.5"
 
 [package.extras]
-lint = ["flake8", "mypy", "docutils-stubs"]
+lint = ["docutils-stubs", "flake8", "mypy"]
 test = ["pytest"]
 
 [[package]]
@@ -516,7 +718,7 @@ optional = false
 python-versions = ">=3.5"
 
 [package.extras]
-lint = ["flake8", "mypy", "docutils-stubs"]
+lint = ["docutils-stubs", "flake8", "mypy"]
 test = ["pytest"]
 
 [[package]]
@@ -529,7 +731,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
 
 [[package]]
 name = "tomli"
-version = "1.2.2"
+version = "1.2.3"
 description = "A lil' TOML parser"
 category = "dev"
 optional = false
@@ -537,217 +739,397 @@ python-versions = ">=3.6"
 
 [[package]]
 name = "tornado"
-version = "6.1"
+version = "6.2"
 description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
 category = "dev"
 optional = false
-python-versions = ">= 3.5"
-
-[[package]]
-name = "typer"
-version = "0.4.0"
-description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
-category = "dev"
-optional = false
-python-versions = ">=3.6"
-
-[package.dependencies]
-click = ">=7.1.1,<9.0.0"
-
-[package.extras]
-all = ["colorama (>=0.4.3,<0.5.0)", "shellingham (>=1.3.0,<2.0.0)"]
-dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)"]
-doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=5.4.0,<6.0.0)", "markdown-include (>=0.5.1,<0.6.0)"]
-test = ["shellingham (>=1.3.0,<2.0.0)", "pytest (>=4.4.0,<5.4.0)", "pytest-cov (>=2.10.0,<3.0.0)", "coverage (>=5.2,<6.0)", "pytest-xdist (>=1.32.0,<2.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "mypy (==0.910)", "black (>=19.10b0,<20.0b0)", "isort (>=5.0.6,<6.0.0)"]
+python-versions = ">= 3.7"
 
 [[package]]
 name = "typing-extensions"
-version = "3.10.0.2"
-description = "Backported and Experimental Type Hints for Python 3.5+"
-category = "dev"
+version = "4.5.0"
+description = "Backported and Experimental Type Hints for Python 3.7+"
+category = "main"
 optional = false
-python-versions = "*"
+python-versions = ">=3.7"
 
 [[package]]
 name = "urllib3"
-version = "1.26.7"
+version = "1.26.14"
 description = "HTTP library with thread-safe connection pooling, file post, and more."
 category = "main"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
 
 [package.extras]
-brotli = ["brotlipy (>=0.6.0)"]
-secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
+brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"]
+secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"]
 socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
 
 [[package]]
-name = "werkzeug"
-version = "2.0.2"
-description = "The comprehensive WSGI web application library."
-category = "dev"
+name = "win32-setctime"
+version = "1.1.0"
+description = "A small Python utility to set file creation time on Windows"
+category = "main"
 optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.5"
 
 [package.extras]
-watchdog = ["watchdog"]
+dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"]
 
 [[package]]
-name = "wrapt"
-version = "1.13.2"
-description = "Module for decorators, wrappers and monkey patching."
-category = "dev"
+name = "zipp"
+version = "3.15.0"
+description = "Backport of pathlib-compatible object wrapper for zip files"
+category = "main"
 optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+python-versions = ">=3.7"
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
 
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.8"
-content-hash = "c1bce9febe02aee985cf421a5612af166f86fc0e9a67c18604e74050b1d238be"
+content-hash = "dafa0a0293526aa5cc33c8125b078dda2ba60a024ce38be605cb60da0b0a2767"
 
 [metadata.files]
 alabaster = [
-    {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"},
-    {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"},
+    {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"},
+    {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"},
+]
+anyio = [
+    {file = "anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"},
+    {file = "anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"},
 ]
 babel = [
-    {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"},
-    {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"},
+    {file = "Babel-2.11.0-py3-none-any.whl", hash = "sha256:1ad3eca1c885218f6dce2ab67291178944f810a10a9b5f3cb8382a5a232b64fe"},
+    {file = "Babel-2.11.0.tar.gz", hash = "sha256:5ef4b3226b0180dedded4229651c8b0e1a3a6a2837d45a073272f313e4cf97f6"},
 ]
 beautifulsoup4 = [
-    {file = "beautifulsoup4-4.10.0-py3-none-any.whl", hash = "sha256:9a315ce70049920ea4572a4055bc4bd700c940521d36fc858205ad4fcde149bf"},
-    {file = "beautifulsoup4-4.10.0.tar.gz", hash = "sha256:c23ad23c521d818955a4151a67d81580319d4bf548d3d49f4223ae041ff98891"},
+    {file = "beautifulsoup4-4.11.2-py3-none-any.whl", hash = "sha256:0e79446b10b3ecb499c1556f7e228a53e64a2bfcebd455f370d8927cb5b59e39"},
+    {file = "beautifulsoup4-4.11.2.tar.gz", hash = "sha256:bc4bdda6717de5a2987436fb8d72f45dc90dd856bdfd512a1314ce90349a0106"},
 ]
 black = [
-    {file = "black-21.9b0-py3-none-any.whl", hash = "sha256:380f1b5da05e5a1429225676655dddb96f5ae8c75bdf91e53d798871b902a115"},
-    {file = "black-21.9b0.tar.gz", hash = "sha256:7de4cfc7eb6b710de325712d40125689101d21d25283eed7e9998722cf10eb91"},
+    {file = "black-21.12b0-py3-none-any.whl", hash = "sha256:a615e69ae185e08fdd73e4715e260e2479c861b5740057fde6e8b4e3b7dd589f"},
+    {file = "black-21.12b0.tar.gz", hash = "sha256:77b80f693a569e2e527958459634f18df9b0ba2625ba4e0c2d5da5be42e6f2b3"},
 ]
 certifi = [
-    {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"},
-    {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"},
+    {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"},
+    {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"},
 ]
 charset-normalizer = [
-    {file = "charset-normalizer-2.0.7.tar.gz", hash = "sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0"},
-    {file = "charset_normalizer-2.0.7-py3-none-any.whl", hash = "sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b"},
+    {file = "charset-normalizer-3.0.1.tar.gz", hash = "sha256:ebea339af930f8ca5d7a699b921106c6e29c617fe9606fa7baa043c1cdae326f"},
+    {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88600c72ef7587fe1708fd242b385b6ed4b8904976d5da0893e31df8b3480cb6"},
+    {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c75ffc45f25324e68ab238cb4b5c0a38cd1c3d7f1fb1f72b5541de469e2247db"},
+    {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:db72b07027db150f468fbada4d85b3b2729a3db39178abf5c543b784c1254539"},
+    {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62595ab75873d50d57323a91dd03e6966eb79c41fa834b7a1661ed043b2d404d"},
+    {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff6f3db31555657f3163b15a6b7c6938d08df7adbfc9dd13d9d19edad678f1e8"},
+    {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:772b87914ff1152b92a197ef4ea40efe27a378606c39446ded52c8f80f79702e"},
+    {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70990b9c51340e4044cfc394a81f614f3f90d41397104d226f21e66de668730d"},
+    {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:292d5e8ba896bbfd6334b096e34bffb56161c81408d6d036a7dfa6929cff8783"},
+    {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2edb64ee7bf1ed524a1da60cdcd2e1f6e2b4f66ef7c077680739f1641f62f555"},
+    {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:31a9ddf4718d10ae04d9b18801bd776693487cbb57d74cc3458a7673f6f34639"},
+    {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:44ba614de5361b3e5278e1241fda3dc1838deed864b50a10d7ce92983797fa76"},
+    {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:12db3b2c533c23ab812c2b25934f60383361f8a376ae272665f8e48b88e8e1c6"},
+    {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c512accbd6ff0270939b9ac214b84fb5ada5f0409c44298361b2f5e13f9aed9e"},
+    {file = "charset_normalizer-3.0.1-cp310-cp310-win32.whl", hash = "sha256:502218f52498a36d6bf5ea77081844017bf7982cdbe521ad85e64cabee1b608b"},
+    {file = "charset_normalizer-3.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:601f36512f9e28f029d9481bdaf8e89e5148ac5d89cffd3b05cd533eeb423b59"},
+    {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0298eafff88c99982a4cf66ba2efa1128e4ddaca0b05eec4c456bbc7db691d8d"},
+    {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a8d0fc946c784ff7f7c3742310cc8a57c5c6dc31631269876a88b809dbeff3d3"},
+    {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:87701167f2a5c930b403e9756fab1d31d4d4da52856143b609e30a1ce7160f3c"},
+    {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e76c0f23218b8f46c4d87018ca2e441535aed3632ca134b10239dfb6dadd6b"},
+    {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c0a590235ccd933d9892c627dec5bc7511ce6ad6c1011fdf5b11363022746c1"},
+    {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c7fe7afa480e3e82eed58e0ca89f751cd14d767638e2550c77a92a9e749c317"},
+    {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79909e27e8e4fcc9db4addea88aa63f6423ebb171db091fb4373e3312cb6d603"},
+    {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7b6a045b814cf0c47f3623d21ebd88b3e8cf216a14790b455ea7ff0135d18"},
+    {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:72966d1b297c741541ca8cf1223ff262a6febe52481af742036a0b296e35fa5a"},
+    {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:f9d0c5c045a3ca9bedfc35dca8526798eb91a07aa7a2c0fee134c6c6f321cbd7"},
+    {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5995f0164fa7df59db4746112fec3f49c461dd6b31b841873443bdb077c13cfc"},
+    {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4a8fcf28c05c1f6d7e177a9a46a1c52798bfe2ad80681d275b10dcf317deaf0b"},
+    {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:761e8904c07ad053d285670f36dd94e1b6ab7f16ce62b9805c475b7aa1cffde6"},
+    {file = "charset_normalizer-3.0.1-cp311-cp311-win32.whl", hash = "sha256:71140351489970dfe5e60fc621ada3e0f41104a5eddaca47a7acb3c1b851d6d3"},
+    {file = "charset_normalizer-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9ab77acb98eba3fd2a85cd160851816bfce6871d944d885febf012713f06659c"},
+    {file = "charset_normalizer-3.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:84c3990934bae40ea69a82034912ffe5a62c60bbf6ec5bc9691419641d7d5c9a"},
+    {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74292fc76c905c0ef095fe11e188a32ebd03bc38f3f3e9bcb85e4e6db177b7ea"},
+    {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c95a03c79bbe30eec3ec2b7f076074f4281526724c8685a42872974ef4d36b72"},
+    {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c39b0e3eac288fedc2b43055cfc2ca7a60362d0e5e87a637beac5d801ef478"},
+    {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df2c707231459e8a4028eabcd3cfc827befd635b3ef72eada84ab13b52e1574d"},
+    {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93ad6d87ac18e2a90b0fe89df7c65263b9a99a0eb98f0a3d2e079f12a0735837"},
+    {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:59e5686dd847347e55dffcc191a96622f016bc0ad89105e24c14e0d6305acbc6"},
+    {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:cd6056167405314a4dc3c173943f11249fa0f1b204f8b51ed4bde1a9cd1834dc"},
+    {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:083c8d17153ecb403e5e1eb76a7ef4babfc2c48d58899c98fcaa04833e7a2f9a"},
+    {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:f5057856d21e7586765171eac8b9fc3f7d44ef39425f85dbcccb13b3ebea806c"},
+    {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:7eb33a30d75562222b64f569c642ff3dc6689e09adda43a082208397f016c39a"},
+    {file = "charset_normalizer-3.0.1-cp36-cp36m-win32.whl", hash = "sha256:95dea361dd73757c6f1c0a1480ac499952c16ac83f7f5f4f84f0658a01b8ef41"},
+    {file = "charset_normalizer-3.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:eaa379fcd227ca235d04152ca6704c7cb55564116f8bc52545ff357628e10602"},
+    {file = "charset_normalizer-3.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3e45867f1f2ab0711d60c6c71746ac53537f1684baa699f4f668d4c6f6ce8e14"},
+    {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cadaeaba78750d58d3cc6ac4d1fd867da6fc73c88156b7a3212a3cd4819d679d"},
+    {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:911d8a40b2bef5b8bbae2e36a0b103f142ac53557ab421dc16ac4aafee6f53dc"},
+    {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:503e65837c71b875ecdd733877d852adbc465bd82c768a067badd953bf1bc5a3"},
+    {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a60332922359f920193b1d4826953c507a877b523b2395ad7bc716ddd386d866"},
+    {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:16a8663d6e281208d78806dbe14ee9903715361cf81f6d4309944e4d1e59ac5b"},
+    {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a16418ecf1329f71df119e8a65f3aa68004a3f9383821edcb20f0702934d8087"},
+    {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9d9153257a3f70d5f69edf2325357251ed20f772b12e593f3b3377b5f78e7ef8"},
+    {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:02a51034802cbf38db3f89c66fb5d2ec57e6fe7ef2f4a44d070a593c3688667b"},
+    {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:2e396d70bc4ef5325b72b593a72c8979999aa52fb8bcf03f701c1b03e1166918"},
+    {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:11b53acf2411c3b09e6af37e4b9005cba376c872503c8f28218c7243582df45d"},
+    {file = "charset_normalizer-3.0.1-cp37-cp37m-win32.whl", hash = "sha256:0bf2dae5291758b6f84cf923bfaa285632816007db0330002fa1de38bfcb7154"},
+    {file = "charset_normalizer-3.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:2c03cc56021a4bd59be889c2b9257dae13bf55041a3372d3295416f86b295fb5"},
+    {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:024e606be3ed92216e2b6952ed859d86b4cfa52cd5bc5f050e7dc28f9b43ec42"},
+    {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4b0d02d7102dd0f997580b51edc4cebcf2ab6397a7edf89f1c73b586c614272c"},
+    {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:358a7c4cb8ba9b46c453b1dd8d9e431452d5249072e4f56cfda3149f6ab1405e"},
+    {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81d6741ab457d14fdedc215516665050f3822d3e56508921cc7239f8c8e66a58"},
+    {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8b8af03d2e37866d023ad0ddea594edefc31e827fee64f8de5611a1dbc373174"},
+    {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cf4e8ad252f7c38dd1f676b46514f92dc0ebeb0db5552f5f403509705e24753"},
+    {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e696f0dd336161fca9adbb846875d40752e6eba585843c768935ba5c9960722b"},
+    {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c22d3fe05ce11d3671297dc8973267daa0f938b93ec716e12e0f6dee81591dc1"},
+    {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:109487860ef6a328f3eec66f2bf78b0b72400280d8f8ea05f69c51644ba6521a"},
+    {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:37f8febc8ec50c14f3ec9637505f28e58d4f66752207ea177c1d67df25da5aed"},
+    {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:f97e83fa6c25693c7a35de154681fcc257c1c41b38beb0304b9c4d2d9e164479"},
+    {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a152f5f33d64a6be73f1d30c9cc82dfc73cec6477ec268e7c6e4c7d23c2d2291"},
+    {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:39049da0ffb96c8cbb65cbf5c5f3ca3168990adf3551bd1dee10c48fce8ae820"},
+    {file = "charset_normalizer-3.0.1-cp38-cp38-win32.whl", hash = "sha256:4457ea6774b5611f4bed5eaa5df55f70abde42364d498c5134b7ef4c6958e20e"},
+    {file = "charset_normalizer-3.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:e62164b50f84e20601c1ff8eb55620d2ad25fb81b59e3cd776a1902527a788af"},
+    {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8eade758719add78ec36dc13201483f8e9b5d940329285edcd5f70c0a9edbd7f"},
+    {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8499ca8f4502af841f68135133d8258f7b32a53a1d594aa98cc52013fff55678"},
+    {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3fc1c4a2ffd64890aebdb3f97e1278b0cc72579a08ca4de8cd2c04799a3a22be"},
+    {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00d3ffdaafe92a5dc603cb9bd5111aaa36dfa187c8285c543be562e61b755f6b"},
+    {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2ac1b08635a8cd4e0cbeaf6f5e922085908d48eb05d44c5ae9eabab148512ca"},
+    {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6f45710b4459401609ebebdbcfb34515da4fc2aa886f95107f556ac69a9147e"},
+    {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ae1de54a77dc0d6d5fcf623290af4266412a7c4be0b1ff7444394f03f5c54e3"},
+    {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b590df687e3c5ee0deef9fc8c547d81986d9a1b56073d82de008744452d6541"},
+    {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab5de034a886f616a5668aa5d098af2b5385ed70142090e2a31bcbd0af0fdb3d"},
+    {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9cb3032517f1627cc012dbc80a8ec976ae76d93ea2b5feaa9d2a5b8882597579"},
+    {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:608862a7bf6957f2333fc54ab4399e405baad0163dc9f8d99cb236816db169d4"},
+    {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0f438ae3532723fb6ead77e7c604be7c8374094ef4ee2c5e03a3a17f1fca256c"},
+    {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:356541bf4381fa35856dafa6a965916e54bed415ad8a24ee6de6e37deccf2786"},
+    {file = "charset_normalizer-3.0.1-cp39-cp39-win32.whl", hash = "sha256:39cf9ed17fe3b1bc81f33c9ceb6ce67683ee7526e65fde1447c772afc54a1bb8"},
+    {file = "charset_normalizer-3.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:0a11e971ed097d24c534c037d298ad32c6ce81a45736d31e0ff0ad37ab437d59"},
+    {file = "charset_normalizer-3.0.1-py3-none-any.whl", hash = "sha256:7e189e2e1d3ed2f4aebabd2d5b0f931e883676e51c7624826e0a4e5fe8a0bf24"},
 ]
 click = [
-    {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
-    {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
+    {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
+    {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
 ]
 colorama = [
-    {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
-    {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
-]
-deprecated = [
-    {file = "Deprecated-1.2.13-py2.py3-none-any.whl", hash = "sha256:64756e3e14c8c5eea9795d93c524551432a0be75629f8f29e67ab8caf076c76d"},
-    {file = "Deprecated-1.2.13.tar.gz", hash = "sha256:43ac5335da90c31c24ba028af536a91d41d53f9e6901ddb021bcc572ce44e38d"},
+    {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+    {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
 ]
 docutils = [
     {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"},
     {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"},
 ]
-flask = [
-    {file = "Flask-2.0.2-py3-none-any.whl", hash = "sha256:cb90f62f1d8e4dc4621f52106613488b5ba826b2e1e10a33eac92f723093ab6a"},
-    {file = "Flask-2.0.2.tar.gz", hash = "sha256:7b2fb8e934ddd50731893bdcdb00fc8c0315916f9fcd50d22c7cc1a95ab634e2"},
+ecdsa = [
+    {file = "ecdsa-0.18.0-py2.py3-none-any.whl", hash = "sha256:80600258e7ed2f16b9aa1d7c295bd70194109ad5a30fdee0eaeefef1d4c559dd"},
+    {file = "ecdsa-0.18.0.tar.gz", hash = "sha256:190348041559e21b22a1d65cee485282ca11a6f81d503fddb84d5017e9ed1e49"},
+]
+h11 = [
+    {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
+    {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"},
+]
+httpcore = [
+    {file = "httpcore-0.14.7-py3-none-any.whl", hash = "sha256:47d772f754359e56dd9d892d9593b6f9870a37aeb8ba51e9a88b09b3d68cfade"},
+    {file = "httpcore-0.14.7.tar.gz", hash = "sha256:7503ec1c0f559066e7e39bc4003fd2ce023d01cf51793e3c173b864eb456ead1"},
+]
+httpx = [
+    {file = "httpx-0.22.0-py3-none-any.whl", hash = "sha256:e35e83d1d2b9b2a609ef367cc4c1e66fd80b750348b20cc9e19d1952fc2ca3f6"},
+    {file = "httpx-0.22.0.tar.gz", hash = "sha256:d8e778f76d9bbd46af49e7f062467e3157a5a3d2ae4876a4bbfd8a51ed9c9cb4"},
 ]
 idna = [
-    {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
-    {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
+    {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"},
+    {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"},
 ]
 imagesize = [
-    {file = "imagesize-1.2.0-py2.py3-none-any.whl", hash = "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1"},
-    {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"},
+    {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"},
+    {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"},
 ]
-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 = [
+    {file = "importlib_metadata-6.0.0-py3-none-any.whl", hash = "sha256:7efb448ec9a5e313a57655d35aa54cd3e01b7e1fbcf72dce1bf06119420f5bad"},
+    {file = "importlib_metadata-6.0.0.tar.gz", hash = "sha256:e354bedeb60efa6affdcc8ae121b73544a7aa74156d047311948f6d711cd378d"},
 ]
 isort = [
-    {file = "isort-5.9.3-py3-none-any.whl", hash = "sha256:e17d6e2b81095c9db0a03a8025a957f334d6ea30b26f9ec70805411e5c7c81f2"},
-    {file = "isort-5.9.3.tar.gz", hash = "sha256:9c2ea1e62d871267b78307fe511c0838ba0da28698c5732d54e2790bf3ba9899"},
-]
-itsdangerous = [
-    {file = "itsdangerous-2.0.1-py3-none-any.whl", hash = "sha256:5174094b9637652bdb841a3029700391451bd092ba3db90600dea710ba28e97c"},
-    {file = "itsdangerous-2.0.1.tar.gz", hash = "sha256:9e724d68fc22902a1435351f84c3fb8623f303fffcc566a4cb952df8c572cff0"},
+    {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"},
+    {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"},
 ]
 jinja2 = [
-    {file = "Jinja2-3.0.2-py3-none-any.whl", hash = "sha256:8569982d3f0889eed11dd620c706d39b60c36d6d25843961f33f77fb6bc6b20c"},
-    {file = "Jinja2-3.0.2.tar.gz", hash = "sha256:827a0e32839ab1600d4eb1c4c33ec5a8edfbc5cb42dafa13b81f182f97784b45"},
+    {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"},
+    {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"},
 ]
+jobbergate-core = []
 livereload = [
+    {file = "livereload-2.6.3-py2.py3-none-any.whl", hash = "sha256:ad4ac6f53b2d62bb6ce1a5e6e96f1f00976a32348afedcb4b6d68df2a1d346e4"},
     {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"},
 ]
+loguru = [
+    {file = "loguru-0.6.0-py3-none-any.whl", hash = "sha256:4e2414d534a2ab57573365b3e6d0234dfb1d84b68b7f3b948e6fb743860a77c3"},
+    {file = "loguru-0.6.0.tar.gz", hash = "sha256:066bd06758d0a513e9836fd9c6b5a75bfb3fd36841f4b996bc60b547a309d41c"},
+]
 markupsafe = [
-    {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"},
-    {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"},
-    {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"},
-    {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"},
-    {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"},
-    {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"},
-    {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"},
-    {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"},
-    {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"},
-    {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"},
-    {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"},
-    {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"},
-    {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"},
-    {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"},
-    {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"},
-    {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"},
-    {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"},
-    {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"},
-    {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"},
-    {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"},
-    {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"},
-    {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"},
-    {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"},
-    {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"},
-    {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"},
-    {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"},
-    {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"},
-    {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"},
-    {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"},
-    {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"},
-    {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"},
-    {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"},
-    {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"},
-    {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"},
+    {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7"},
+    {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036"},
+    {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1"},
+    {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323"},
+    {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601"},
+    {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1"},
+    {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff"},
+    {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65"},
+    {file = "MarkupSafe-2.1.2-cp310-cp310-win32.whl", hash = "sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603"},
+    {file = "MarkupSafe-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156"},
+    {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013"},
+    {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a"},
+    {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd"},
+    {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6"},
+    {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d"},
+    {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1"},
+    {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc"},
+    {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0"},
+    {file = "MarkupSafe-2.1.2-cp311-cp311-win32.whl", hash = "sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625"},
+    {file = "MarkupSafe-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3"},
+    {file = "MarkupSafe-2.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a6e40afa7f45939ca356f348c8e23048e02cb109ced1eb8420961b2f40fb373a"},
+    {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf877ab4ed6e302ec1d04952ca358b381a882fbd9d1b07cccbfd61783561f98a"},
+    {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63ba06c9941e46fa389d389644e2d8225e0e3e5ebcc4ff1ea8506dce646f8c8a"},
+    {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1cd098434e83e656abf198f103a8207a8187c0fc110306691a2e94a78d0abb2"},
+    {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619"},
+    {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a6f2fcca746e8d5910e18782f976489939d54a91f9411c32051b4aab2bd7c513"},
+    {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0b462104ba25f1ac006fdab8b6a01ebbfbce9ed37fd37fd4acd70c67c973e460"},
+    {file = "MarkupSafe-2.1.2-cp37-cp37m-win32.whl", hash = "sha256:7668b52e102d0ed87cb082380a7e2e1e78737ddecdde129acadb0eccc5423859"},
+    {file = "MarkupSafe-2.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6d6607f98fcf17e534162f0709aaad3ab7a96032723d8ac8750ffe17ae5a0666"},
+    {file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a806db027852538d2ad7555b203300173dd1b77ba116de92da9afbc3a3be3eed"},
+    {file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a4abaec6ca3ad8660690236d11bfe28dfd707778e2442b45addd2f086d6ef094"},
+    {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f03a532d7dee1bed20bc4884194a16160a2de9ffc6354b3878ec9682bb623c54"},
+    {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cf06cdc1dda95223e9d2d3c58d3b178aa5dacb35ee7e3bbac10e4e1faacb419"},
+    {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22731d79ed2eb25059ae3df1dfc9cb1546691cc41f4e3130fe6bfbc3ecbbecfa"},
+    {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f8ffb705ffcf5ddd0e80b65ddf7bed7ee4f5a441ea7d3419e861a12eaf41af58"},
+    {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8db032bf0ce9022a8e41a22598eefc802314e81b879ae093f36ce9ddf39ab1ba"},
+    {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2298c859cfc5463f1b64bd55cb3e602528db6fa0f3cfd568d3605c50678f8f03"},
+    {file = "MarkupSafe-2.1.2-cp38-cp38-win32.whl", hash = "sha256:50c42830a633fa0cf9e7d27664637532791bfc31c731a87b202d2d8ac40c3ea2"},
+    {file = "MarkupSafe-2.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:bb06feb762bade6bf3c8b844462274db0c76acc95c52abe8dbed28ae3d44a147"},
+    {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f"},
+    {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd"},
+    {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f"},
+    {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4"},
+    {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2"},
+    {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65"},
+    {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c"},
+    {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3"},
+    {file = "MarkupSafe-2.1.2-cp39-cp39-win32.whl", hash = "sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7"},
+    {file = "MarkupSafe-2.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed"},
+    {file = "MarkupSafe-2.1.2.tar.gz", hash = "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d"},
 ]
 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"},
+    {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
+    {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
 ]
 packaging = [
-    {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"},
-    {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"},
+    {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"},
+    {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"},
 ]
 pathspec = [
-    {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
-    {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
+    {file = "pathspec-0.11.0-py3-none-any.whl", hash = "sha256:3a66eb970cbac598f9e5ccb5b2cf58930cd8e3ed86d393d541eaf2d8b1705229"},
+    {file = "pathspec-0.11.0.tar.gz", hash = "sha256:64d338d4e0914e91c1792321e6907b5a593f1ab1851de7fc269557a21b30ebbc"},
+]
+pendulum = [
+    {file = "pendulum-2.1.2-cp27-cp27m-macosx_10_15_x86_64.whl", hash = "sha256:b6c352f4bd32dff1ea7066bd31ad0f71f8d8100b9ff709fb343f3b86cee43efe"},
+    {file = "pendulum-2.1.2-cp27-cp27m-win_amd64.whl", hash = "sha256:318f72f62e8e23cd6660dbafe1e346950281a9aed144b5c596b2ddabc1d19739"},
+    {file = "pendulum-2.1.2-cp35-cp35m-macosx_10_15_x86_64.whl", hash = "sha256:0731f0c661a3cb779d398803655494893c9f581f6488048b3fb629c2342b5394"},
+    {file = "pendulum-2.1.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:3481fad1dc3f6f6738bd575a951d3c15d4b4ce7c82dce37cf8ac1483fde6e8b0"},
+    {file = "pendulum-2.1.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9702069c694306297ed362ce7e3c1ef8404ac8ede39f9b28b7c1a7ad8c3959e3"},
+    {file = "pendulum-2.1.2-cp35-cp35m-win_amd64.whl", hash = "sha256:fb53ffa0085002ddd43b6ca61a7b34f2d4d7c3ed66f931fe599e1a531b42af9b"},
+    {file = "pendulum-2.1.2-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:c501749fdd3d6f9e726086bf0cd4437281ed47e7bca132ddb522f86a1645d360"},
+    {file = "pendulum-2.1.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:c807a578a532eeb226150d5006f156632df2cc8c5693d778324b43ff8c515dd0"},
+    {file = "pendulum-2.1.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2d1619a721df661e506eff8db8614016f0720ac171fe80dda1333ee44e684087"},
+    {file = "pendulum-2.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:f888f2d2909a414680a29ae74d0592758f2b9fcdee3549887779cd4055e975db"},
+    {file = "pendulum-2.1.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:e95d329384717c7bf627bf27e204bc3b15c8238fa8d9d9781d93712776c14002"},
+    {file = "pendulum-2.1.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:4c9c689747f39d0d02a9f94fcee737b34a5773803a64a5fdb046ee9cac7442c5"},
+    {file = "pendulum-2.1.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1245cd0075a3c6d889f581f6325dd8404aca5884dea7223a5566c38aab94642b"},
+    {file = "pendulum-2.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:db0a40d8bcd27b4fb46676e8eb3c732c67a5a5e6bfab8927028224fbced0b40b"},
+    {file = "pendulum-2.1.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:f5e236e7730cab1644e1b87aca3d2ff3e375a608542e90fe25685dae46310116"},
+    {file = "pendulum-2.1.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:de42ea3e2943171a9e95141f2eecf972480636e8e484ccffaf1e833929e9e052"},
+    {file = "pendulum-2.1.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7c5ec650cb4bec4c63a89a0242cc8c3cebcec92fcfe937c417ba18277d8560be"},
+    {file = "pendulum-2.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:33fb61601083f3eb1d15edeb45274f73c63b3c44a8524703dc143f4212bf3269"},
+    {file = "pendulum-2.1.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:29c40a6f2942376185728c9a0347d7c0f07905638c83007e1d262781f1e6953a"},
+    {file = "pendulum-2.1.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:94b1fc947bfe38579b28e1cccb36f7e28a15e841f30384b5ad6c5e31055c85d7"},
+    {file = "pendulum-2.1.2.tar.gz", hash = "sha256:b06a0ca1bfe41c990bbf0c029f0b6501a7f2ec4e38bfec730712015e8860f207"},
 ]
 platformdirs = [
-    {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"},
-    {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"},
+    {file = "platformdirs-3.0.0-py3-none-any.whl", hash = "sha256:b1d5eb14f221506f50d6604a561f4c5786d9e80355219694a1b244bcd96f4567"},
+    {file = "platformdirs-3.0.0.tar.gz", hash = "sha256:8a1228abb1ef82d788f74139988b137e78692984ec7b08eaa6c65f1723af28f9"},
+]
+pockets = [
+    {file = "pockets-0.9.1-py2.py3-none-any.whl", hash = "sha256:68597934193c08a08eb2bf6a1d85593f627c22f9b065cc727a4f03f669d96d86"},
+    {file = "pockets-0.9.1.tar.gz", hash = "sha256:9320f1a3c6f7a9133fe3b571f283bcf3353cd70249025ae8d618e40e9f7e92b3"},
 ]
 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.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"},
+    {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"},
+]
+pydantic = [
+    {file = "pydantic-1.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5920824fe1e21cbb3e38cf0f3dd24857c8959801d1031ce1fac1d50857a03bfb"},
+    {file = "pydantic-1.10.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3bb99cf9655b377db1a9e47fa4479e3330ea96f4123c6c8200e482704bf1eda2"},
+    {file = "pydantic-1.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2185a3b3d98ab4506a3f6707569802d2d92c3a7ba3a9a35683a7709ea6c2aaa2"},
+    {file = "pydantic-1.10.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f582cac9d11c227c652d3ce8ee223d94eb06f4228b52a8adaafa9fa62e73d5c9"},
+    {file = "pydantic-1.10.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c9e5b778b6842f135902e2d82624008c6a79710207e28e86966cd136c621bfee"},
+    {file = "pydantic-1.10.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:72ef3783be8cbdef6bca034606a5de3862be6b72415dc5cb1fb8ddbac110049a"},
+    {file = "pydantic-1.10.5-cp310-cp310-win_amd64.whl", hash = "sha256:45edea10b75d3da43cfda12f3792833a3fa70b6eee4db1ed6aed528cef17c74e"},
+    {file = "pydantic-1.10.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:63200cd8af1af2c07964546b7bc8f217e8bda9d0a2ef0ee0c797b36353914984"},
+    {file = "pydantic-1.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:305d0376c516b0dfa1dbefeae8c21042b57b496892d721905a6ec6b79494a66d"},
+    {file = "pydantic-1.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fd326aff5d6c36f05735c7c9b3d5b0e933b4ca52ad0b6e4b38038d82703d35b"},
+    {file = "pydantic-1.10.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6bb0452d7b8516178c969d305d9630a3c9b8cf16fcf4713261c9ebd465af0d73"},
+    {file = "pydantic-1.10.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9a9d9155e2a9f38b2eb9374c88f02fd4d6851ae17b65ee786a87d032f87008f8"},
+    {file = "pydantic-1.10.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f836444b4c5ece128b23ec36a446c9ab7f9b0f7981d0d27e13a7c366ee163f8a"},
+    {file = "pydantic-1.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:8481dca324e1c7b715ce091a698b181054d22072e848b6fc7895cd86f79b4449"},
+    {file = "pydantic-1.10.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:87f831e81ea0589cd18257f84386bf30154c5f4bed373b7b75e5cb0b5d53ea87"},
+    {file = "pydantic-1.10.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ce1612e98c6326f10888df951a26ec1a577d8df49ddcaea87773bfbe23ba5cc"},
+    {file = "pydantic-1.10.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58e41dd1e977531ac6073b11baac8c013f3cd8706a01d3dc74e86955be8b2c0c"},
+    {file = "pydantic-1.10.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:6a4b0aab29061262065bbdede617ef99cc5914d1bf0ddc8bcd8e3d7928d85bd6"},
+    {file = "pydantic-1.10.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:36e44a4de37b8aecffa81c081dbfe42c4d2bf9f6dff34d03dce157ec65eb0f15"},
+    {file = "pydantic-1.10.5-cp37-cp37m-win_amd64.whl", hash = "sha256:261f357f0aecda005934e413dfd7aa4077004a174dafe414a8325e6098a8e419"},
+    {file = "pydantic-1.10.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b429f7c457aebb7fbe7cd69c418d1cd7c6fdc4d3c8697f45af78b8d5a7955760"},
+    {file = "pydantic-1.10.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:663d2dd78596c5fa3eb996bc3f34b8c2a592648ad10008f98d1348be7ae212fb"},
+    {file = "pydantic-1.10.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51782fd81f09edcf265823c3bf43ff36d00db246eca39ee765ef58dc8421a642"},
+    {file = "pydantic-1.10.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c428c0f64a86661fb4873495c4fac430ec7a7cef2b8c1c28f3d1a7277f9ea5ab"},
+    {file = "pydantic-1.10.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:76c930ad0746c70f0368c4596020b736ab65b473c1f9b3872310a835d852eb19"},
+    {file = "pydantic-1.10.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3257bd714de9db2102b742570a56bf7978e90441193acac109b1f500290f5718"},
+    {file = "pydantic-1.10.5-cp38-cp38-win_amd64.whl", hash = "sha256:f5bee6c523d13944a1fdc6f0525bc86dbbd94372f17b83fa6331aabacc8fd08e"},
+    {file = "pydantic-1.10.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:532e97c35719f137ee5405bd3eeddc5c06eb91a032bc755a44e34a712420daf3"},
+    {file = "pydantic-1.10.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ca9075ab3de9e48b75fa8ccb897c34ccc1519177ad8841d99f7fd74cf43be5bf"},
+    {file = "pydantic-1.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd46a0e6296346c477e59a954da57beaf9c538da37b9df482e50f836e4a7d4bb"},
+    {file = "pydantic-1.10.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3353072625ea2a9a6c81ad01b91e5c07fa70deb06368c71307529abf70d23325"},
+    {file = "pydantic-1.10.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:3f9d9b2be177c3cb6027cd67fbf323586417868c06c3c85d0d101703136e6b31"},
+    {file = "pydantic-1.10.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b473d00ccd5c2061fd896ac127b7755baad233f8d996ea288af14ae09f8e0d1e"},
+    {file = "pydantic-1.10.5-cp39-cp39-win_amd64.whl", hash = "sha256:5f3bc8f103b56a8c88021d481410874b1f13edf6e838da607dcb57ecff9b4594"},
+    {file = "pydantic-1.10.5-py3-none-any.whl", hash = "sha256:7c5b94d598c90f2f46b3a983ffb46ab806a67099d118ae0da7ef21a2a4033b28"},
+    {file = "pydantic-1.10.5.tar.gz", hash = "sha256:9e337ac83686645a46db0e825acceea8e02fca4062483f40e9ae178e8bd1103a"},
 ]
 pydata-sphinx-theme = [
-    {file = "pydata-sphinx-theme-0.6.3.tar.gz", hash = "sha256:32e0580ef985734d652eec2bf25e0995a330a9d4f76deaa353571ce8e180ab14"},
-    {file = "pydata_sphinx_theme-0.6.3-py3-none-any.whl", hash = "sha256:f0fee20dc33fa5efa6b9be57368be760d236d8b9c1486b14ad1d17b7e7e0db04"},
+    {file = "pydata-sphinx-theme-0.7.2.tar.gz", hash = "sha256:671df35fcdd290eafbd23d0595e6d359dbe90b2e64e6c3f4dc88276eed4a065e"},
+    {file = "pydata_sphinx_theme-0.7.2-py3-none-any.whl", hash = "sha256:bc1abc45e103b254c6c7a8f2ddabbaf8aa1f0817d85cae65dd163dd554c52700"},
 ]
 pygments = [
-    {file = "Pygments-2.10.0-py3-none-any.whl", hash = "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380"},
-    {file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"},
+    {file = "Pygments-2.14.0-py3-none-any.whl", hash = "sha256:fa7bd7bd2771287c0de303af8bfdfc731f51bd2c6a47ab69d117138893b82717"},
+    {file = "Pygments-2.14.0.tar.gz", hash = "sha256:b3ed06a9e8ac9a9aae5a6f5dbe78a8a58655d17b43b93c078f094ddc476ae297"},
 ]
-pyparsing = [
-    {file = "pyparsing-3.0.3-py3-none-any.whl", hash = "sha256:f8d3fe9fc404576c5164f0f0c4e382c96b85265e023c409c43d48f65da9d60d0"},
-    {file = "pyparsing-3.0.3.tar.gz", hash = "sha256:9e3511118010f112a4b4b435ae50e1eaa610cda191acb9e421d60cf5fde83455"},
+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-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"},
 ]
 pytz = [
-    {file = "pytz-2021.3-py2.py3-none-any.whl", hash = "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c"},
-    {file = "pytz-2021.3.tar.gz", hash = "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326"},
+    {file = "pytz-2022.7.1-py2.py3-none-any.whl", hash = "sha256:78f4f37d8198e0627c5f1143240bb0206b8691d8d7ac6d78fee88b78733f8c4a"},
+    {file = "pytz-2022.7.1.tar.gz", hash = "sha256:01a0681c4b9684a28304615eba55d1ab31ae00bf68ec157ec3708a8182dbbcd0"},
+]
+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"},
@@ -757,6 +1139,13 @@ pyyaml = [
     {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-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"},
+    {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"},
+    {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"},
+    {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"},
+    {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"},
+    {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"},
+    {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"},
     {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"},
@@ -784,92 +1173,74 @@ pyyaml = [
     {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
     {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
 ]
-regex = [
-    {file = "regex-2021.10.23-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:45b65d6a275a478ac2cbd7fdbf7cc93c1982d613de4574b56fd6972ceadb8395"},
-    {file = "regex-2021.10.23-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74d071dbe4b53c602edd87a7476ab23015a991374ddb228d941929ad7c8c922e"},
-    {file = "regex-2021.10.23-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:34d870f9f27f2161709054d73646fc9aca49480617a65533fc2b4611c518e455"},
-    {file = "regex-2021.10.23-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fb698037c35109d3c2e30f2beb499e5ebae6e4bb8ff2e60c50b9a805a716f79"},
-    {file = "regex-2021.10.23-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cb46b542133999580ffb691baf67410306833ee1e4f58ed06b6a7aaf4e046952"},
-    {file = "regex-2021.10.23-cp310-cp310-win32.whl", hash = "sha256:5e9c9e0ce92f27cef79e28e877c6b6988c48b16942258f3bc55d39b5f911df4f"},
-    {file = "regex-2021.10.23-cp310-cp310-win_amd64.whl", hash = "sha256:ab7c5684ff3538b67df3f93d66bd3369b749087871ae3786e70ef39e601345b0"},
-    {file = "regex-2021.10.23-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:de557502c3bec8e634246588a94e82f1ee1b9dfcfdc453267c4fb652ff531570"},
-    {file = "regex-2021.10.23-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee684f139c91e69fe09b8e83d18b4d63bf87d9440c1eb2eeb52ee851883b1b29"},
-    {file = "regex-2021.10.23-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5095a411c8479e715784a0c9236568ae72509450ee2226b649083730f3fadfc6"},
-    {file = "regex-2021.10.23-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b568809dca44cb75c8ebb260844ea98252c8c88396f9d203f5094e50a70355f"},
-    {file = "regex-2021.10.23-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:eb672217f7bd640411cfc69756ce721d00ae600814708d35c930930f18e8029f"},
-    {file = "regex-2021.10.23-cp36-cp36m-win32.whl", hash = "sha256:a7a986c45d1099a5de766a15de7bee3840b1e0e1a344430926af08e5297cf666"},
-    {file = "regex-2021.10.23-cp36-cp36m-win_amd64.whl", hash = "sha256:6d7722136c6ed75caf84e1788df36397efdc5dbadab95e59c2bba82d4d808a4c"},
-    {file = "regex-2021.10.23-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9f665677e46c5a4d288ece12fdedf4f4204a422bb28ff05f0e6b08b7447796d1"},
-    {file = "regex-2021.10.23-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:450dc27483548214314640c89a0f275dbc557968ed088da40bde7ef8fb52829e"},
-    {file = "regex-2021.10.23-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:129472cd06062fb13e7b4670a102951a3e655e9b91634432cfbdb7810af9d710"},
-    {file = "regex-2021.10.23-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a940ca7e7189d23da2bfbb38973832813eab6bd83f3bf89a977668c2f813deae"},
-    {file = "regex-2021.10.23-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:530fc2bbb3dc1ebb17f70f7b234f90a1dd43b1b489ea38cea7be95fb21cdb5c7"},
-    {file = "regex-2021.10.23-cp37-cp37m-win32.whl", hash = "sha256:ded0c4a3eee56b57fcb2315e40812b173cafe79d2f992d50015f4387445737fa"},
-    {file = "regex-2021.10.23-cp37-cp37m-win_amd64.whl", hash = "sha256:391703a2abf8013d95bae39145d26b4e21531ab82e22f26cd3a181ee2644c234"},
-    {file = "regex-2021.10.23-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be04739a27be55631069b348dda0c81d8ea9822b5da10b8019b789e42d1fe452"},
-    {file = "regex-2021.10.23-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13ec99df95003f56edcd307db44f06fbeb708c4ccdcf940478067dd62353181e"},
-    {file = "regex-2021.10.23-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8d1cdcda6bd16268316d5db1038965acf948f2a6f43acc2e0b1641ceab443623"},
-    {file = "regex-2021.10.23-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c186691a7995ef1db61205e00545bf161fb7b59cdb8c1201c89b333141c438a"},
-    {file = "regex-2021.10.23-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2b20f544cbbeffe171911f6ce90388ad36fe3fad26b7c7a35d4762817e9ea69c"},
-    {file = "regex-2021.10.23-cp38-cp38-win32.whl", hash = "sha256:c0938ddd60cc04e8f1faf7a14a166ac939aac703745bfcd8e8f20322a7373019"},
-    {file = "regex-2021.10.23-cp38-cp38-win_amd64.whl", hash = "sha256:56f0c81c44638dfd0e2367df1a331b4ddf2e771366c4b9c5d9a473de75e3e1c7"},
-    {file = "regex-2021.10.23-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:80bb5d2e92b2258188e7dcae5b188c7bf868eafdf800ea6edd0fbfc029984a88"},
-    {file = "regex-2021.10.23-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1dae12321b31059a1a72aaa0e6ba30156fe7e633355e445451e4021b8e122b6"},
-    {file = "regex-2021.10.23-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1f2b59c28afc53973d22e7bc18428721ee8ca6079becf1b36571c42627321c65"},
-    {file = "regex-2021.10.23-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d134757a37d8640f3c0abb41f5e68b7cf66c644f54ef1cb0573b7ea1c63e1509"},
-    {file = "regex-2021.10.23-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0dcc0e71118be8c69252c207630faf13ca5e1b8583d57012aae191e7d6d28b84"},
-    {file = "regex-2021.10.23-cp39-cp39-win32.whl", hash = "sha256:a30513828180264294953cecd942202dfda64e85195ae36c265daf4052af0464"},
-    {file = "regex-2021.10.23-cp39-cp39-win_amd64.whl", hash = "sha256:0f7552429dd39f70057ac5d0e897e5bfe211629652399a21671e53f2a9693a4e"},
-    {file = "regex-2021.10.23.tar.gz", hash = "sha256:f3f9a91d3cc5e5b0ddf1043c0ae5fa4852f18a1c0050318baf5fc7930ecc1f9c"},
-]
 requests = [
-    {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"},
-    {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"},
+    {file = "requests-2.28.2-py3-none-any.whl", hash = "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa"},
+    {file = "requests-2.28.2.tar.gz", hash = "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf"},
+]
+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"},
 ]
 six = [
     {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
     {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
 ]
+sniffio = [
+    {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"},
+    {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"},
+]
 snowballstemmer = [
-    {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"},
-    {file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"},
+    {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"},
+    {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"},
 ]
 soupsieve = [
-    {file = "soupsieve-2.2.1-py3-none-any.whl", hash = "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"},
-    {file = "soupsieve-2.2.1.tar.gz", hash = "sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc"},
+    {file = "soupsieve-2.4-py3-none-any.whl", hash = "sha256:49e5368c2cda80ee7e84da9dbe3e110b70a4575f196efb74e51b94549d921955"},
+    {file = "soupsieve-2.4.tar.gz", hash = "sha256:e28dba9ca6c7c00173e34e4ba57448f0688bb681b7c5e8bf4971daafc093d69a"},
 ]
 sphinx = [
-    {file = "Sphinx-4.2.0-py3-none-any.whl", hash = "sha256:98a535c62a4fcfcc362528592f69b26f7caec587d32cd55688db580be0287ae0"},
-    {file = "Sphinx-4.2.0.tar.gz", hash = "sha256:94078db9184491e15bce0a56d9186e0aec95f16ac20b12d00e06d4e36f1058a6"},
+    {file = "Sphinx-4.5.0-py3-none-any.whl", hash = "sha256:ebf612653238bcc8f4359627a9b7ce44ede6fdd75d9d30f68255c7383d3a6226"},
+    {file = "Sphinx-4.5.0.tar.gz", hash = "sha256:7bf8ca9637a4ee15af412d1a1d9689fec70523a68ca9bb9127c2f3eeb344e2e6"},
+]
+sphinx-autobuild = [
+    {file = "sphinx-autobuild-2021.3.14.tar.gz", hash = "sha256:de1ca3b66e271d2b5b5140c35034c89e47f263f2cd5db302c9217065f7443f05"},
+    {file = "sphinx_autobuild-2021.3.14-py3-none-any.whl", hash = "sha256:8fe8cbfdb75db04475232f05187c776f46f6e9e04cacf1e49ce81bdac649ccac"},
 ]
 sphinx-book-theme = [
-    {file = "sphinx-book-theme-0.1.6.tar.gz", hash = "sha256:a9aa7e7e6eff879b998677a184d11a5835b6552732e7404776a2dfed942671f9"},
-    {file = "sphinx_book_theme-0.1.6-py3-none-any.whl", hash = "sha256:b7613cb38ea3613354ac4f68db48a38001746e76b9e6860d559ca37e25ce233f"},
+    {file = "sphinx-book-theme-0.1.10.tar.gz", hash = "sha256:c74d7deb36e6fea4bcb3a979d162d9fd80e93b7ea4da78ebf8286da4b898582a"},
+    {file = "sphinx_book_theme-0.1.10-py3-none-any.whl", hash = "sha256:1722b60c8ac87aa53758f4403912972cb28d0c253196446339859124e92f686f"},
 ]
-sphinx-view = [
-    {file = "sphinx-view-0.3.1.tar.gz", hash = "sha256:1c28d1b9829e00832c4bb195d6a1f16df795d0fdcd041012a5359934efcb4fd8"},
-    {file = "sphinx_view-0.3.1-py3-none-any.whl", hash = "sha256:202d24afa35559266a5d1be08fde040f68f299f65f6a6ea128dc20dd263ef2cc"},
+sphinx-copybutton = [
+    {file = "sphinx-copybutton-0.5.1.tar.gz", hash = "sha256:366251e28a6f6041514bfb5439425210418d6c750e98d3a695b73e56866a677a"},
+    {file = "sphinx_copybutton-0.5.1-py3-none-any.whl", hash = "sha256:0842851b5955087a7ec7fc870b622cb168618ad408dee42692e9a5c97d071da8"},
 ]
 sphinxcontrib-applehelp = [
-    {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"},
-    {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"},
+    {file = "sphinxcontrib-applehelp-1.0.4.tar.gz", hash = "sha256:828f867945bbe39817c210a1abfd1bc4895c8b73fcaade56d45357a348a07d7e"},
+    {file = "sphinxcontrib_applehelp-1.0.4-py3-none-any.whl", hash = "sha256:29d341f67fb0f6f586b23ad80e072c8e6ad0b48417db2bde114a4c9746feb228"},
 ]
 sphinxcontrib-devhelp = [
     {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"},
     {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"},
 ]
 sphinxcontrib-htmlhelp = [
-    {file = "sphinxcontrib-htmlhelp-2.0.0.tar.gz", hash = "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2"},
-    {file = "sphinxcontrib_htmlhelp-2.0.0-py2.py3-none-any.whl", hash = "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07"},
+    {file = "sphinxcontrib-htmlhelp-2.0.1.tar.gz", hash = "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff"},
+    {file = "sphinxcontrib_htmlhelp-2.0.1-py3-none-any.whl", hash = "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903"},
 ]
 sphinxcontrib-httpdomain = [
-    {file = "sphinxcontrib-httpdomain-1.8.0.tar.gz", hash = "sha256:2059cfabd0cca8fcc3455cc8ffad92f0915a7d3bb03bfddba078a6a0f35beec5"},
-    {file = "sphinxcontrib_httpdomain-1.8.0-py2.py3-none-any.whl", hash = "sha256:a3396d6350728d574f52458b400f0ac848f8b6913bd41fed95d391d3ffbbade3"},
+    {file = "sphinxcontrib-httpdomain-1.8.1.tar.gz", hash = "sha256:6c2dfe6ca282d75f66df333869bb0ce7331c01b475db6809ff9d107b7cdfe04b"},
+    {file = "sphinxcontrib_httpdomain-1.8.1-py2.py3-none-any.whl", hash = "sha256:21eefe1270e4d9de8d717cc89ee92cc4871b8736774393bafc5e38a6bb77b1d5"},
 ]
 sphinxcontrib-jsmath = [
     {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"},
     {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"},
 ]
+sphinxcontrib-napoleon = [
+    {file = "sphinxcontrib-napoleon-0.7.tar.gz", hash = "sha256:407382beed396e9f2d7f3043fad6afda95719204a1e1a231ac865f40abcbfcf8"},
+    {file = "sphinxcontrib_napoleon-0.7-py2.py3-none-any.whl", hash = "sha256:711e41a3974bdf110a484aec4c1a556799eb0b3f3b897521a018ad7e2db13fef"},
+]
 sphinxcontrib-qthelp = [
     {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"},
     {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"},
@@ -883,112 +1254,35 @@ toml = [
     {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
 ]
 tomli = [
-    {file = "tomli-1.2.2-py3-none-any.whl", hash = "sha256:f04066f68f5554911363063a30b108d2b5a5b1a010aa8b6132af78489fe3aade"},
-    {file = "tomli-1.2.2.tar.gz", hash = "sha256:c6ce0015eb38820eaf32b5db832dbc26deb3dd427bd5f6556cf0acac2c214fee"},
+    {file = "tomli-1.2.3-py3-none-any.whl", hash = "sha256:e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c"},
+    {file = "tomli-1.2.3.tar.gz", hash = "sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f"},
 ]
 tornado = [
-    {file = "tornado-6.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:d371e811d6b156d82aa5f9a4e08b58debf97c302a35714f6f45e35139c332e32"},
-    {file = "tornado-6.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:0d321a39c36e5f2c4ff12b4ed58d41390460f798422c4504e09eb5678e09998c"},
-    {file = "tornado-6.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9de9e5188a782be6b1ce866e8a51bc76a0fbaa0e16613823fc38e4fc2556ad05"},
-    {file = "tornado-6.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:61b32d06ae8a036a6607805e6720ef00a3c98207038444ba7fd3d169cd998910"},
-    {file = "tornado-6.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:3e63498f680547ed24d2c71e6497f24bca791aca2fe116dbc2bd0ac7f191691b"},
-    {file = "tornado-6.1-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:6c77c9937962577a6a76917845d06af6ab9197702a42e1346d8ae2e76b5e3675"},
-    {file = "tornado-6.1-cp35-cp35m-win32.whl", hash = "sha256:6286efab1ed6e74b7028327365cf7346b1d777d63ab30e21a0f4d5b275fc17d5"},
-    {file = "tornado-6.1-cp35-cp35m-win_amd64.whl", hash = "sha256:fa2ba70284fa42c2a5ecb35e322e68823288a4251f9ba9cc77be04ae15eada68"},
-    {file = "tornado-6.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0a00ff4561e2929a2c37ce706cb8233b7907e0cdc22eab98888aca5dd3775feb"},
-    {file = "tornado-6.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:748290bf9112b581c525e6e6d3820621ff020ed95af6f17fedef416b27ed564c"},
-    {file = "tornado-6.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e385b637ac3acaae8022e7e47dfa7b83d3620e432e3ecb9a3f7f58f150e50921"},
-    {file = "tornado-6.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:25ad220258349a12ae87ede08a7b04aca51237721f63b1808d39bdb4b2164558"},
-    {file = "tornado-6.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:65d98939f1a2e74b58839f8c4dab3b6b3c1ce84972ae712be02845e65391ac7c"},
-    {file = "tornado-6.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:e519d64089b0876c7b467274468709dadf11e41d65f63bba207e04217f47c085"},
-    {file = "tornado-6.1-cp36-cp36m-win32.whl", hash = "sha256:b87936fd2c317b6ee08a5741ea06b9d11a6074ef4cc42e031bc6403f82a32575"},
-    {file = "tornado-6.1-cp36-cp36m-win_amd64.whl", hash = "sha256:cc0ee35043162abbf717b7df924597ade8e5395e7b66d18270116f8745ceb795"},
-    {file = "tornado-6.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7250a3fa399f08ec9cb3f7b1b987955d17e044f1ade821b32e5f435130250d7f"},
-    {file = "tornado-6.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ed3ad863b1b40cd1d4bd21e7498329ccaece75db5a5bf58cd3c9f130843e7102"},
-    {file = "tornado-6.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:dcef026f608f678c118779cd6591c8af6e9b4155c44e0d1bc0c87c036fb8c8c4"},
-    {file = "tornado-6.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:70dec29e8ac485dbf57481baee40781c63e381bebea080991893cd297742b8fd"},
-    {file = "tornado-6.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:d3f7594930c423fd9f5d1a76bee85a2c36fd8b4b16921cae7e965f22575e9c01"},
-    {file = "tornado-6.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:3447475585bae2e77ecb832fc0300c3695516a47d46cefa0528181a34c5b9d3d"},
-    {file = "tornado-6.1-cp37-cp37m-win32.whl", hash = "sha256:e7229e60ac41a1202444497ddde70a48d33909e484f96eb0da9baf8dc68541df"},
-    {file = "tornado-6.1-cp37-cp37m-win_amd64.whl", hash = "sha256:cb5ec8eead331e3bb4ce8066cf06d2dfef1bfb1b2a73082dfe8a161301b76e37"},
-    {file = "tornado-6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:20241b3cb4f425e971cb0a8e4ffc9b0a861530ae3c52f2b0434e6c1b57e9fd95"},
-    {file = "tornado-6.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:c77da1263aa361938476f04c4b6c8916001b90b2c2fdd92d8d535e1af48fba5a"},
-    {file = "tornado-6.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:fba85b6cd9c39be262fcd23865652920832b61583de2a2ca907dbd8e8a8c81e5"},
-    {file = "tornado-6.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:1e8225a1070cd8eec59a996c43229fe8f95689cb16e552d130b9793cb570a288"},
-    {file = "tornado-6.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d14d30e7f46a0476efb0deb5b61343b1526f73ebb5ed84f23dc794bdb88f9d9f"},
-    {file = "tornado-6.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8f959b26f2634a091bb42241c3ed8d3cedb506e7c27b8dd5c7b9f745318ddbb6"},
-    {file = "tornado-6.1-cp38-cp38-win32.whl", hash = "sha256:34ca2dac9e4d7afb0bed4677512e36a52f09caa6fded70b4e3e1c89dbd92c326"},
-    {file = "tornado-6.1-cp38-cp38-win_amd64.whl", hash = "sha256:6196a5c39286cc37c024cd78834fb9345e464525d8991c21e908cc046d1cc02c"},
-    {file = "tornado-6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f0ba29bafd8e7e22920567ce0d232c26d4d47c8b5cf4ed7b562b5db39fa199c5"},
-    {file = "tornado-6.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:33892118b165401f291070100d6d09359ca74addda679b60390b09f8ef325ffe"},
-    {file = "tornado-6.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7da13da6f985aab7f6f28debab00c67ff9cbacd588e8477034c0652ac141feea"},
-    {file = "tornado-6.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:e0791ac58d91ac58f694d8d2957884df8e4e2f6687cdf367ef7eb7497f79eaa2"},
-    {file = "tornado-6.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:66324e4e1beede9ac79e60f88de548da58b1f8ab4b2f1354d8375774f997e6c0"},
-    {file = "tornado-6.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:a48900ecea1cbb71b8c71c620dee15b62f85f7c14189bdeee54966fbd9a0c5bd"},
-    {file = "tornado-6.1-cp39-cp39-win32.whl", hash = "sha256:d3d20ea5782ba63ed13bc2b8c291a053c8d807a8fa927d941bd718468f7b950c"},
-    {file = "tornado-6.1-cp39-cp39-win_amd64.whl", hash = "sha256:548430be2740e327b3fe0201abe471f314741efcb0067ec4f2d7dcfb4825f3e4"},
-    {file = "tornado-6.1.tar.gz", hash = "sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791"},
-]
-typer = [
-    {file = "typer-0.4.0-py3-none-any.whl", hash = "sha256:d81169725140423d072df464cad1ff25ee154ef381aaf5b8225352ea187ca338"},
-    {file = "typer-0.4.0.tar.gz", hash = "sha256:63c3aeab0549750ffe40da79a1b524f60e08a2cbc3126c520ebf2eeaf507f5dd"},
+    {file = "tornado-6.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:20f638fd8cc85f3cbae3c732326e96addff0a15e22d80f049e00121651e82e72"},
+    {file = "tornado-6.2-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:87dcafae3e884462f90c90ecc200defe5e580a7fbbb4365eda7c7c1eb809ebc9"},
+    {file = "tornado-6.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba09ef14ca9893954244fd872798b4ccb2367c165946ce2dd7376aebdde8e3ac"},
+    {file = "tornado-6.2-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8150f721c101abdef99073bf66d3903e292d851bee51910839831caba341a75"},
+    {file = "tornado-6.2-cp37-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3a2f5999215a3a06a4fc218026cd84c61b8b2b40ac5296a6db1f1451ef04c1e"},
+    {file = "tornado-6.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:5f8c52d219d4995388119af7ccaa0bcec289535747620116a58d830e7c25d8a8"},
+    {file = "tornado-6.2-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:6fdfabffd8dfcb6cf887428849d30cf19a3ea34c2c248461e1f7d718ad30b66b"},
+    {file = "tornado-6.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:1d54d13ab8414ed44de07efecb97d4ef7c39f7438cf5e976ccd356bebb1b5fca"},
+    {file = "tornado-6.2-cp37-abi3-win32.whl", hash = "sha256:5c87076709343557ef8032934ce5f637dbb552efa7b21d08e89ae7619ed0eb23"},
+    {file = "tornado-6.2-cp37-abi3-win_amd64.whl", hash = "sha256:e5f923aa6a47e133d1cf87d60700889d7eae68988704e20c75fb2d65677a8e4b"},
+    {file = "tornado-6.2.tar.gz", hash = "sha256:9b630419bde84ec666bfd7ea0a4cb2a8a651c2d5cccdbdd1972a0c859dfc3c13"},
 ]
 typing-extensions = [
-    {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"},
-    {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"},
-    {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"},
+    {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"},
+    {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"},
 ]
 urllib3 = [
-    {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"},
-    {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"},
-]
-werkzeug = [
-    {file = "Werkzeug-2.0.2-py3-none-any.whl", hash = "sha256:63d3dc1cf60e7b7e35e97fa9861f7397283b75d765afcaefd993d6046899de8f"},
-    {file = "Werkzeug-2.0.2.tar.gz", hash = "sha256:aa2bb6fc8dee8d6c504c0ac1e7f5f7dc5810a9903e793b6f715a9f015bdadb9a"},
-]
-wrapt = [
-    {file = "wrapt-1.13.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3de7b4d3066cc610054e7aa2c005645e308df2f92be730aae3a47d42e910566a"},
-    {file = "wrapt-1.13.2-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:8164069f775c698d15582bf6320a4f308c50d048c1c10cf7d7a341feaccf5df7"},
-    {file = "wrapt-1.13.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9adee1891253670575028279de8365c3a02d3489a74a66d774c321472939a0b1"},
-    {file = "wrapt-1.13.2-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:a70d876c9aba12d3bd7f8f1b05b419322c6789beb717044eea2c8690d35cb91b"},
-    {file = "wrapt-1.13.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:3f87042623530bcffea038f824b63084180513c21e2e977291a9a7e65a66f13b"},
-    {file = "wrapt-1.13.2-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:e634136f700a21e1fcead0c137f433dde928979538c14907640607d43537d468"},
-    {file = "wrapt-1.13.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:3e33c138d1e3620b1e0cc6fd21e46c266393ed5dae0d595b7ed5a6b73ed57aa0"},
-    {file = "wrapt-1.13.2-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:283e402e5357e104ac1e3fba5791220648e9af6fb14ad7d9cc059091af2b31d2"},
-    {file = "wrapt-1.13.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:ccb34ce599cab7f36a4c90318697ead18312c67a9a76327b3f4f902af8f68ea1"},
-    {file = "wrapt-1.13.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:fbad5ba74c46517e6488149514b2e2348d40df88cd6b52a83855b7a8bf04723f"},
-    {file = "wrapt-1.13.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:724ed2bc9c91a2b9026e5adce310fa60c6e7c8760b03391445730b9789b9d108"},
-    {file = "wrapt-1.13.2-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:83f2793ec6f3ef513ad8d5b9586f5ee6081cad132e6eae2ecb7eac1cc3decae0"},
-    {file = "wrapt-1.13.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:0473d1558b93e314e84313cc611f6c86be779369f9d3734302bf185a4d2625b1"},
-    {file = "wrapt-1.13.2-cp35-cp35m-win32.whl", hash = "sha256:15eee0e6fd07f48af2f66d0e6f2ff1916ffe9732d464d5e2390695296872cad9"},
-    {file = "wrapt-1.13.2-cp35-cp35m-win_amd64.whl", hash = "sha256:bc85d17d90201afd88e3d25421da805e4e135012b5d1f149e4de2981394b2a52"},
-    {file = "wrapt-1.13.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c6ee5f8734820c21b9b8bf705e99faba87f21566d20626568eeb0d62cbeaf23c"},
-    {file = "wrapt-1.13.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:53c6706a1bcfb6436f1625511b95b812798a6d2ccc51359cd791e33722b5ea32"},
-    {file = "wrapt-1.13.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fbe6aebc9559fed7ea27de51c2bf5c25ba2a4156cf0017556f72883f2496ee9a"},
-    {file = "wrapt-1.13.2-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:0582180566e7a13030f896c2f1ac6a56134ab5f3c3f4c5538086f758b1caf3f2"},
-    {file = "wrapt-1.13.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:bff0a59387a0a2951cb869251257b6553663329a1b5525b5226cab8c88dcbe7e"},
-    {file = "wrapt-1.13.2-cp36-cp36m-win32.whl", hash = "sha256:df3eae297a5f1594d1feb790338120f717dac1fa7d6feed7b411f87e0f2401c7"},
-    {file = "wrapt-1.13.2-cp36-cp36m-win_amd64.whl", hash = "sha256:1eb657ed84f4d3e6ad648483c8a80a0cf0a78922ef94caa87d327e2e1ad49b48"},
-    {file = "wrapt-1.13.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0cdedf681db878416c05e1831ec69691b0e6577ac7dca9d4f815632e3549580"},
-    {file = "wrapt-1.13.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:87ee3c73bdfb4367b26c57259995935501829f00c7b3eed373e2ad19ec21e4e4"},
-    {file = "wrapt-1.13.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:3e0d16eedc242d01a6f8cf0623e9cdc3b869329da3f97a15961d8864111d8cf0"},
-    {file = "wrapt-1.13.2-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:8318088860968c07e741537030b1abdd8908ee2c71fbe4facdaade624a09e006"},
-    {file = "wrapt-1.13.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:d90520616fce71c05dedeac3a0fe9991605f0acacd276e5f821842e454485a70"},
-    {file = "wrapt-1.13.2-cp37-cp37m-win32.whl", hash = "sha256:22142afab65daffc95863d78effcbd31c19a8003eca73de59f321ee77f73cadb"},
-    {file = "wrapt-1.13.2-cp37-cp37m-win_amd64.whl", hash = "sha256:d0d717e10f952df7ea41200c507cc7e24458f4c45b56c36ad418d2e79dacd1d4"},
-    {file = "wrapt-1.13.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:593cb049ce1c391e0288523b30426c4430b26e74c7e6f6e2844bd99ac7ecc831"},
-    {file = "wrapt-1.13.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:8860c8011a6961a651b1b9f46fdbc589ab63b0a50d645f7d92659618a3655867"},
-    {file = "wrapt-1.13.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:ada5e29e59e2feb710589ca1c79fd989b1dd94d27079dc1d199ec954a6ecc724"},
-    {file = "wrapt-1.13.2-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:fdede980273aeca591ad354608778365a3a310e0ecdd7a3587b38bc5be9b1808"},
-    {file = "wrapt-1.13.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:af9480de8e63c5f959a092047aaf3d7077422ded84695b3398f5d49254af3e90"},
-    {file = "wrapt-1.13.2-cp38-cp38-win32.whl", hash = "sha256:c65e623ea7556e39c4f0818200a046cbba7575a6b570ff36122c276fdd30ab0a"},
-    {file = "wrapt-1.13.2-cp38-cp38-win_amd64.whl", hash = "sha256:b20703356cae1799080d0ad15085dc3213c1ac3f45e95afb9f12769b98231528"},
-    {file = "wrapt-1.13.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1c5c4cf188b5643a97e87e2110bbd4f5bc491d54a5b90633837b34d5df6a03fe"},
-    {file = "wrapt-1.13.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:82223f72eba6f63eafca87a0f614495ae5aa0126fe54947e2b8c023969e9f2d7"},
-    {file = "wrapt-1.13.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:81a4cf257263b299263472d669692785f9c647e7dca01c18286b8f116dbf6b38"},
-    {file = "wrapt-1.13.2-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:728e2d9b7a99dd955d3426f237b940fc74017c4a39b125fec913f575619ddfe9"},
-    {file = "wrapt-1.13.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:7574de567dcd4858a2ffdf403088d6df8738b0e1eabea220553abf7c9048f59e"},
-    {file = "wrapt-1.13.2-cp39-cp39-win32.whl", hash = "sha256:c7ac2c7a8e34bd06710605b21dd1f3576764443d68e069d2afba9b116014d072"},
-    {file = "wrapt-1.13.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e6d1a8eeef415d7fb29fe017de0e48f45e45efd2d1bfda28fc50b7b330859ef"},
-    {file = "wrapt-1.13.2.tar.gz", hash = "sha256:dca56cc5963a5fd7c2aa8607017753f534ee514e09103a6c55d2db70b50e7447"},
+    {file = "urllib3-1.26.14-py2.py3-none-any.whl", hash = "sha256:75edcdc2f7d85b137124a6c3c9fc3933cdeaa12ecb9a6a959f22797a0feca7e1"},
+    {file = "urllib3-1.26.14.tar.gz", hash = "sha256:076907bf8fd355cde77728471316625a4d2f7e713c125f51953bb5b3eecf4f72"},
+]
+win32-setctime = [
+    {file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"},
+    {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"},
+]
+zipp = [
+    {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"},
+    {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"},
 ]
diff --git a/jobbergate-docs/pyproject.toml b/jobbergate-docs/pyproject.toml
index af1fa32a..cb812178 100644
--- a/jobbergate-docs/pyproject.toml
+++ b/jobbergate-docs/pyproject.toml
@@ -12,9 +12,12 @@ python = "^3.8"
 sphinxcontrib-httpdomain = "^1.8.0"
 sphinx-book-theme = "^0.1.6"
 toml = "^0.10.2"
+jobbergate-core = {path = "../jobbergate-core", develop = true }
+sphinxcontrib-napoleon = "^0.7"
+sphinx-copybutton = "^0.5"
 
 [tool.poetry.dev-dependencies]
-sphinx-view = "^0.3.1"
+sphinx-autobuild = "^2021.3.14"
 black = "^21.9b0"
 isort = "^5.9.3"
 
diff --git a/jobbergate-docs/src/conf.py b/jobbergate-docs/src/conf.py
index 6c2cb733..cc0b1f73 100644
--- a/jobbergate-docs/src/conf.py
+++ b/jobbergate-docs/src/conf.py
@@ -30,11 +30,18 @@
 pygments_style = "rainbow_dash"
 exclude_patterns = []
 extensions = [
-    "sphinx.ext.githubpages",
+    "sphinx_copybutton",
+    "sphinx.ext.autodoc",
     "sphinx.ext.autosectionlabel",
+    "sphinx.ext.githubpages",
+    "sphinx.ext.intersphinx",
+    "sphinx.ext.napoleon",
     "sphinxcontrib.httpdomain",
 ]
 
+intersphinx_mapping = {
+    "python": ("https://docs.python.org/3", None,),
+}
 
 # -- Options for HTML output -------------------------------------------------
 
diff --git a/jobbergate-docs/src/core.rst b/jobbergate-docs/src/core.rst
new file mode 100644
index 00000000..7526670e
--- /dev/null
+++ b/jobbergate-docs/src/core.rst
@@ -0,0 +1,26 @@
+=================
+ Jobbergate Core
+=================
+
+This is a new sub-project on the Jobbergate system that contains the key components and logic to be shared among all others sub-projects (CLI, API, and Agent), and that can be exposed to the end-users to leverage their custom workflows.
+
+Authentication
+--------------
+
+.. automodule:: jobbergate_core.auth.handler
+   :members:
+   :show-inheritance:
+
+Tokens
+^^^^^^
+
+.. automodule:: jobbergate_core.auth.token
+   :members:
+   :show-inheritance:
+
+Exceptions
+^^^^^^^^^^
+
+.. automodule:: jobbergate_core.auth.exceptions
+   :members:
+   :show-inheritance:
\ No newline at end of file
diff --git a/jobbergate-docs/src/index.rst b/jobbergate-docs/src/index.rst
index 403eafe4..e241a3b8 100644
--- a/jobbergate-docs/src/index.rst
+++ b/jobbergate-docs/src/index.rst
@@ -25,5 +25,6 @@ Table of Contents
    Resources <resources>
    Tutorial <tutorial>
    Developer Guide <developer_guide/index>
+   Jobbergate-Core <core>
    Attribution <attribution>
    Contact Us <contact>
diff --git a/release-scripts/check-prereqs.sh b/release-scripts/check-prereqs.sh
index d894c8bf..b81621f2 100755
--- a/release-scripts/check-prereqs.sh
+++ b/release-scripts/check-prereqs.sh
@@ -2,7 +2,7 @@
 
 BASE_BRANCH="main"
 SUBPROJECT=${PWD##*/}
-ALL_SUBPROJECTS="jobbergate-api jobbergate-cli jobbergate-docs"
+ALL_SUBPROJECTS="jobbergate-api jobbergate-cli jobbergate-core jobbergate-docs"
 
 function bail {
     echo "!!! $1 -- Aborting !!!"


Test patch:
diff --git a/.github/workflows/test_on_push.yaml b/.github/workflows/test_on_push.yaml
index 96646d38..6aa6b921 100644
--- a/.github/workflows/test_on_push.yaml
+++ b/.github/workflows/test_on_push.yaml
@@ -7,6 +7,28 @@ on:
   pull_request:
 
 jobs:
+  core-tests:
+    name: "jobbergate-core tests"
+    runs-on: "ubuntu-20.04"
+    steps:
+      - uses: actions/checkout@v3
+      - uses: Gr1N/setup-poetry@v8
+        with:
+          poetry-version: 1.5.1
+      - uses: actions/setup-python@v4
+        with:
+          python-version: "3.8"
+          architecture: "x64"
+          cache: "poetry"
+          cache-dependency-path: |
+            jobbergate-core/poetry.lock
+            jobbergate-core/pyproject.toml
+      - name: "run quality control checks"
+        working-directory: jobbergate-core
+        run: |
+          poetry env use "3.8"
+          make qa
+
   api-tests:
     name: "jobbergate-api tests"
     runs-on: "ubuntu-20.04"
@@ -21,8 +43,9 @@ jobs:
     runs-on: "ubuntu-20.04"
     steps:
       - uses: actions/checkout@v3
-      - name: Install poetry
-        run: pipx install poetry==1.4.2
+      - uses: Gr1N/setup-poetry@v8
+        with:
+          poetry-version: 1.5.1
       - uses: actions/setup-python@v4
         with:
           python-version: "3.6"
diff --git a/jobbergate-api/tests/apps/conftest.py b/jobbergate-api/tests/apps/conftest.py
index dbc41947..038bb2ed 100644
--- a/jobbergate-api/tests/apps/conftest.py
+++ b/jobbergate-api/tests/apps/conftest.py
@@ -11,7 +11,12 @@ def base_data(tester_email):
     """
     Provide a fixture that supplies test application data.
     """
-    return {"name": "test_name", "description": "test_description", "owner_email": tester_email}
+    return {
+        "name": "test_name",
+        "description": "test_description",
+        "owner_email": tester_email,
+        "is_archived": False,
+    }
 
 
 @fixture
diff --git a/jobbergate-api/tests/apps/job_script_templates/test_routers.py b/jobbergate-api/tests/apps/job_script_templates/test_routers.py
index 6bbd9a66..803a89d2 100644
--- a/jobbergate-api/tests/apps/job_script_templates/test_routers.py
+++ b/jobbergate-api/tests/apps/job_script_templates/test_routers.py
@@ -5,7 +5,7 @@
 from fastapi import HTTPException, status
 
 from jobbergate_api.apps.job_script_templates.constants import WORKFLOW_FILE_NAME
-from jobbergate_api.apps.job_script_templates.schemas import JobTemplateResponse
+from jobbergate_api.apps.job_script_templates.schemas import JobTemplateDetailedView, JobTemplateListView
 from jobbergate_api.apps.job_script_templates.services import (
     crud_service,
     template_file_service,
@@ -43,8 +43,8 @@ async def test_create_job_template__success(
     assert response_data["identifier"] == payload["identifier"]
     assert response_data["template_vars"] == payload["template_vars"]
     assert response_data["owner_email"] == tester_email
-    assert response_data["template_files"] == []
-    assert response_data["workflow_files"] == []
+    assert response_data["template_files"] is None
+    assert response_data["workflow_files"] is None
 
     # Make sure that the crud service has no bound session after the request is complete
     with pytest.raises(HTTPException) as exc_info:
@@ -187,6 +187,35 @@ async def test_update_job_template__fail_unauthorized(client):
     assert response.status_code == 401
 
 
+async def test_update_job_template__forbidden(
+    client,
+    fill_job_template_data,
+    inject_security_header,
+    tester_email,
+    synth_session,
+):
+    with crud_service.bound_session(synth_session):
+        instance = await crud_service.create(**fill_job_template_data())
+
+    owner_email = tester_email
+    requester_email = "another_" + owner_email
+
+    payload = dict(
+        name="new-name",
+        identifier="new-identifier",
+        description="new-description",
+        template_vars={"new": "value"},
+    )
+
+    inject_security_header(requester_email, Permissions.JOB_TEMPLATES_EDIT)
+    response = await client.put(
+        f"jobbergate/job-script-templates/{instance.id}",
+        json=payload,
+    )
+
+    assert response.status_code == 403
+
+
 @pytest.mark.parametrize("identification_field", ("id", "identifier"))
 async def test_get_job_template__success(
     identification_field,
@@ -252,6 +281,34 @@ async def test_delete_job_template__fail_not_found(
     assert response.status_code == 404
 
 
+@pytest.mark.parametrize("identification_field", ("id", "identifier"))
+async def test_delete_job_template__forbidden(
+    identification_field,
+    client,
+    tester_email,
+    inject_security_header,
+    fill_job_template_data,
+    synth_session,
+):
+    owner_email = tester_email
+    requester_email = "another_" + owner_email
+
+    payload = fill_job_template_data(
+        identifier=f"delete-template-forbidden-{identification_field}",
+        template_vars={"foo": "bar"},
+    )
+    with crud_service.bound_session(synth_session):
+        instance = await crud_service.create(**payload)
+
+    inject_security_header(requester_email, Permissions.JOB_TEMPLATES_EDIT)
+    identification = getattr(instance, identification_field)
+    response = await client.delete(f"jobbergate/job-script-templates/{identification}")
+    assert response.status_code == 403
+
+    with crud_service.bound_session(synth_session):
+        assert (await crud_service.count()) == 1
+
+
 class TestListJobTemplates:
     """Test the list endpoint."""
 
@@ -281,6 +338,12 @@ async def job_templates_list(self, synth_session, fill_all_job_template_data):
                 "template_vars": {"foo-1": "bar-4"},
                 "owner_email": "test-test@pytest.com",
             },
+            {
+                "name": "name-5",
+                "description": "desc-5",
+                "template_vars": {"foo-1": "bar-5"},
+                "is_archived": True,
+            },
         )
         with crud_service.bound_session(synth_session):
             for item in data:
@@ -295,7 +358,10 @@ async def test_list_job_templates__all_success(
         job_templates_list,
     ):
         inject_security_header(tester_email, Permissions.JOB_TEMPLATES_VIEW)
-        response = await client.get("jobbergate/job-script-templates?include_null_identifier=True")
+        response = await client.get(
+            "jobbergate/job-script-templates",
+            params=dict(include_null_identifier=True, include_archived=True),
+        )
         assert response.status_code == 200, f"List failed: {response.text}"
 
         response_data = response.json()
@@ -308,10 +374,27 @@ async def test_list_job_templates__all_success(
             assert response_item.get("identifier") == expected_item.get("identifier")
             assert response_item["name"] == expected_item["name"]
             assert response_item["description"] == expected_item["description"]
-            assert response_item["template_vars"] == expected_item["template_vars"]
             assert response_item["owner_email"] == expected_item["owner_email"]
-            assert response_item["template_files"] == []
-            assert response_item["workflow_files"] == []
+            assert response_item["is_archived"] == expected_item["is_archived"]
+
+    async def test_list_job_templates__ignore_archived(
+        self,
+        client,
+        tester_email,
+        inject_security_header,
+        job_templates_list,
+    ):
+        inject_security_header(tester_email, Permissions.JOB_TEMPLATES_VIEW)
+        response = await client.get("jobbergate/job-script-templates?include_null_identifier=True")
+        assert response.status_code == 200, f"List failed: {response.text}"
+
+        response_data = response.json()
+
+        expected_names = {i["name"] for i in job_templates_list if i["is_archived"] is False}
+        response_names = {i["name"] for i in response_data["items"]}
+
+        assert response_data["total"] == len(expected_names)
+        assert expected_names == response_names
 
     async def test_list_job_templates__user_only(
         self,
@@ -322,16 +405,16 @@ async def test_list_job_templates__user_only(
     ):
         inject_security_header(tester_email, Permissions.JOB_TEMPLATES_VIEW)
         response = await client.get(
-            "jobbergate/job-script-templates?user_only=True&include_null_identifier=True"
+            "jobbergate/job-script-templates?user_only=True&include_null_identifier=True&include_archived=True"
         )
         assert response.status_code == 200, f"List failed: {response.text}"
 
         response_data = response.json()
-        assert response_data["total"] == 3
 
         expected_names = {i["name"] for i in job_templates_list if i["owner_email"] == tester_email}
         response_names = {i["name"] for i in response_data["items"]}
 
+        assert response_data["total"] == len(expected_names)
         assert expected_names == response_names
 
 
@@ -340,7 +423,7 @@ class TestJobTemplateFiles:
     async def job_template_data(self, fill_job_template_data, synth_session):
         with crud_service.bound_session(synth_session):
             raw_db_data = await crud_service.create(**fill_job_template_data())
-        yield JobTemplateResponse.from_orm(raw_db_data)
+        yield JobTemplateDetailedView.from_orm(raw_db_data)
 
     async def test_create__success(
         self,
@@ -391,6 +474,31 @@ async def test_create__success(
         assert template_file["filename"] == dummy_file_path.name
         assert template_file["file_type"] == file_type
 
+    async def test_create__fail_forbidden(
+        self,
+        client,
+        tester_email,
+        inject_security_header,
+        job_template_data,
+        dummy_template,
+        make_dummy_file,
+    ):
+        parent_id = job_template_data.id
+        file_type = "ENTRYPOINT"
+        dummy_file_path = make_dummy_file("test_template.py.j2", content=dummy_template)
+
+        owner_email = tester_email
+        requester_email = "another_" + owner_email
+
+        inject_security_header(requester_email, Permissions.JOB_TEMPLATES_EDIT)
+        with open(dummy_file_path, mode="rb") as template_file:
+            response = await client.put(
+                f"jobbergate/job-script-templates/{parent_id}/upload/template/{file_type}",
+                files={"upload_file": (dummy_file_path.name, template_file, "text/plain")},
+            )
+
+        assert response.status_code == status.HTTP_403_FORBIDDEN
+
     async def test_get__success(
         self,
         client,
@@ -441,19 +549,47 @@ async def test_delete__success(
         response = await client.delete(
             f"jobbergate/job-script-templates/{parent_id}/upload/template/test_template.py.j2"
         )
-        assert response.status_code == status.HTTP_200_OK, "Delete failed: {response.text}"
+        assert response.status_code == status.HTTP_200_OK, f"Delete failed: {response.text}"
 
         s3_object = await synth_bucket.Object(f"job_script_template_files/{parent_id}/test_template.py.j2")
         with pytest.raises(synth_bucket.meta.client.exceptions.NoSuchKey):
             await s3_object.get()
 
+    async def test_delete__fail_forbidden(
+        self,
+        client,
+        tester_email,
+        inject_security_header,
+        job_template_data,
+        synth_session,
+        synth_bucket,
+    ):
+        parent_id = job_template_data.id
+        with template_file_service.bound_bucket(synth_bucket):
+            with template_file_service.bound_session(synth_session):
+                await template_file_service.upsert(
+                    parent_id=parent_id,
+                    filename="test_template.py.j2",
+                    upload_content="dummy file data",
+                    file_type="ENTRYPOINT",
+                )
+
+        owner_email = tester_email
+        requester_email = "another_" + owner_email
+
+        inject_security_header(requester_email, Permissions.JOB_TEMPLATES_EDIT)
+        response = await client.delete(
+            f"jobbergate/job-script-templates/{parent_id}/upload/template/test_template.py.j2"
+        )
+        assert response.status_code == status.HTTP_403_FORBIDDEN
+
 
 class TestJobTemplateWorkflowFile:
     @pytest.fixture(scope="function")
     async def job_template_data(self, fill_job_template_data, synth_session):
         with crud_service.bound_session(synth_session):
             raw_db_data = await crud_service.create(**fill_job_template_data())
-        yield JobTemplateResponse.from_orm(raw_db_data)
+        yield JobTemplateListView.from_orm(raw_db_data)
 
     async def test_create__success(
         self,
@@ -505,6 +641,33 @@ async def test_create__success(
         assert workflow_file["filename"] == WORKFLOW_FILE_NAME
         assert workflow_file["runtime_config"] == runtime_config
 
+    async def test_create__fail_forbidden(
+        self,
+        client,
+        tester_email,
+        inject_security_header,
+        job_template_data,
+        dummy_application_source_file,
+        make_dummy_file,
+    ):
+        parent_id = job_template_data.id
+        dummy_file_path = make_dummy_file("test_template.py.j2", content=dummy_application_source_file)
+        runtime_config = {"foo": "bar"}
+
+        owner_email = tester_email
+        requester_email = "another_" + owner_email
+
+        inject_security_header(requester_email, Permissions.JOB_TEMPLATES_EDIT)
+        with open(dummy_file_path, mode="rb") as workflow_file:
+            response = await client.put(
+                f"jobbergate/job-script-templates/{parent_id}/upload/workflow",
+                files={"upload_file": (dummy_file_path.name, workflow_file, "text/plain")},
+                data={"runtime_config": json.dumps(runtime_config)},
+            )
+
+        # First, check the response from the upload endpoint
+        assert response.status_code == status.HTTP_403_FORBIDDEN
+
     async def test_get__success(
         self, client, tester_email, inject_security_header, job_template_data, synth_bucket, synth_session
     ):
diff --git a/jobbergate-api/tests/apps/job_script_templates/test_services.py b/jobbergate-api/tests/apps/job_script_templates/test_services.py
index 1ddcb9c4..5c9709eb 100644
--- a/jobbergate-api/tests/apps/job_script_templates/test_services.py
+++ b/jobbergate-api/tests/apps/job_script_templates/test_services.py
@@ -3,7 +3,7 @@
 
 import pytest
 from fastapi import HTTPException
-
+from sqlalchemy import inspect
 from jobbergate_api.apps.constants import FileType
 from jobbergate_api.apps.job_script_templates.constants import WORKFLOW_FILE_NAME
 from jobbergate_api.apps.job_script_templates.services import (
@@ -186,3 +186,123 @@ async def test_upsert(self, template_test_data):
             await crud_service.get(template_instance.id)
         assert exc_info.value.status_code == 404
         assert await workflow_file_service.find_children(template_instance.id) == []
+
+
+class TestIntegration:
+    @pytest.fixture(autouse=True)
+    async def setup(self, synth_session, synth_bucket):
+        """
+        Ensure that the services are bound for each method in this test class.
+        """
+        with (
+            crud_service.bound_session(synth_session),
+            workflow_file_service.bound_session(synth_session),
+            workflow_file_service.bound_bucket(synth_bucket),
+            template_file_service.bound_session(synth_session),
+            template_file_service.bound_bucket(synth_bucket),
+        ):
+            yield
+
+    async def test_get_template_includes_all_files(self, template_test_data):
+        template_instance = await crud_service.create(**template_test_data)
+
+        workflow_file = await workflow_file_service.upsert(
+            template_instance.id,
+            WORKFLOW_FILE_NAME,
+            "test file content",
+            runtime_config=dict(foo="bar"),
+        )
+
+        template_file = await template_file_service.upsert(
+            template_instance.id,
+            "test.txt",
+            "test file content",
+            file_type=FileType.ENTRYPOINT,
+        )
+
+        result = await crud_service.get(template_instance.id, include_files=True)
+
+        assert {"workflow_files", "template_files"} not in inspect(result).unloaded
+
+        assert result.workflow_files == [workflow_file]
+        assert result.template_files == [template_file]
+
+    async def test_list_template_includes_all_files(self, template_test_data, synth_session):
+        template_instance = await crud_service.create(**template_test_data)
+
+        workflow_file = await workflow_file_service.upsert(
+            template_instance.id,
+            WORKFLOW_FILE_NAME,
+            "test file content",
+            runtime_config=dict(foo="bar"),
+        )
+
+        template_file = await template_file_service.upsert(
+            template_instance.id,
+            "test.txt",
+            "test file content",
+            file_type=FileType.ENTRYPOINT,
+        )
+
+        await synth_session.refresh(template_instance)
+
+        actual_result = await crud_service.list(include_files=True)
+
+        assert actual_result == [template_instance]
+        assert actual_result[0].workflow_files == [workflow_file]
+        assert actual_result[0].template_files == [template_file]
+
+    async def test_update_template_includes_no_files(self, template_test_data):
+        template_instance = await crud_service.create(**template_test_data)
+
+        workflow_file = await workflow_file_service.upsert(
+            template_instance.id,
+            WORKFLOW_FILE_NAME,
+            "test file content",
+            runtime_config=dict(foo="bar"),
+        )
+
+        template_file = await template_file_service.upsert(
+            template_instance.id,
+            "test.txt",
+            "test file content",
+            file_type=FileType.ENTRYPOINT,
+        )
+
+        result = await crud_service.update(template_instance.id, name="new-name")
+
+        actual_unloaded = inspect(result).unloaded
+        expected_unloaded = {"workflow_files", "scripts", "template_files"}
+
+        assert actual_unloaded == expected_unloaded
+
+    async def test_delete_cascades_to_files(self, template_test_data):
+        template_instance = await crud_service.create(**template_test_data)
+
+        workflow_file = await workflow_file_service.upsert(
+            template_instance.id,
+            WORKFLOW_FILE_NAME,
+            "test file content",
+            runtime_config=dict(foo="bar"),
+        )
+
+        template_file = await template_file_service.upsert(
+            template_instance.id,
+            "test.txt",
+            "test file content",
+            file_type=FileType.ENTRYPOINT,
+        )
+
+        await crud_service.delete(template_instance.id)
+
+        with pytest.raises(HTTPException) as exc_info:
+            await crud_service.get(template_instance.id)
+        assert exc_info.value.status_code == 404
+
+        with pytest.raises(HTTPException) as exc_info:
+            await workflow_file_service.get(workflow_file.parent_id, workflow_file.filename)
+        assert exc_info.value.status_code == 404
+
+        with pytest.raises(HTTPException) as exc_info:
+            await template_file_service.get(template_file.parent_id, template_file.filename)
+        assert exc_info.value.status_code == 404
diff --git a/jobbergate-api/tests/apps/job_scripts/test_routers.py b/jobbergate-api/tests/apps/job_scripts/test_routers.py
index f85959d8..98847201 100644
--- a/jobbergate-api/tests/apps/job_scripts/test_routers.py
+++ b/jobbergate-api/tests/apps/job_scripts/test_routers.py
@@ -1,10 +1,9 @@
 """Tests for the /job-scripts/ endpoint."""
 import pytest
 from fastapi import HTTPException, status
-
+from loguru import logger
 from jobbergate_api.apps.job_script_templates.services import crud_service as template_crud_service
 from jobbergate_api.apps.job_script_templates.services import template_file_service
-from jobbergate_api.apps.job_scripts.schemas import JobScriptResponse
 from jobbergate_api.apps.job_scripts.services import crud_service, file_service
 from jobbergate_api.apps.permissions import Permissions
 
@@ -28,7 +27,7 @@ async def test_create_stand_alone_job_script(
     assert response_data["name"] == payload["name"]
     assert response_data["description"] == payload["description"]
     assert response_data["owner_email"] == tester_email
-    assert response_data["files"] == []
+    assert response_data["files"] is None
     assert response_data["parent_template_id"] is None
 
     created_id = response_data["id"]
@@ -324,6 +323,12 @@ async def job_scripts_list(self, fill_all_job_script_data, synth_session):
                 "description": "desc-4",
                 "owner_email": "test-test@pytest.com",
             },
+            {
+                "name": "name-5",
+                "description": "desc-5",
+                "owner_email": "test-test@pytest.com",
+                "is_archived": True,
+            },
         )
         with crud_service.bound_session(synth_session):
             for item in data:
@@ -336,22 +341,42 @@ async def test_list_job_scripts__all_success(
         tester_email,
         inject_security_header,
         job_scripts_list,
+    ):
+        inject_security_header(tester_email, Permissions.JOB_SCRIPTS_VIEW)
+        response = await client.get("jobbergate/job-scripts?include_archived=True")
+        assert response.status_code == 200, f"Get failed: {response.text}"
+
+        response_data = response.json()
+        assert response_data["total"] == len(job_scripts_list)
+        assert response_data["page"] == 1
+        assert response_data["size"] == 50
+        assert response_data["pages"] == 1
+
+        for response_item, expected_item in zip(response_data["items"], job_scripts_list):
+            assert response_item["name"] == expected_item["name"]
+            assert response_item["description"] == expected_item["description"]
+            assert response_item["owner_email"] == expected_item["owner_email"]
+            assert response_item["is_archived"] == expected_item["is_archived"]
+            assert response_item["template"] is None
+
+    async def test_list_job_scripts__ignore_archived(
+        self,
+        client,
+        tester_email,
+        inject_security_header,
+        job_scripts_list,
     ):
         inject_security_header(tester_email, Permissions.JOB_SCRIPTS_VIEW)
         response = await client.get("jobbergate/job-scripts")
         assert response.status_code == 200, f"Get failed: {response.text}"
 
-        actual_data = response.json()
-        assert actual_data["total"] == len(job_scripts_list)
-        assert actual_data["page"] == 1
-        assert actual_data["size"] == 50
-        assert actual_data["pages"] == 1
+        response_data = response.json()
 
-        for actual_item, expected_item in zip(actual_data["items"], job_scripts_list):
-            assert actual_item["name"] == expected_item["name"]
-            assert actual_item["description"] == expected_item["description"]
-            assert actual_item["owner_email"] == expected_item["owner_email"]
-            assert actual_item["files"] == []
+        expected_names = {i["name"] for i in job_scripts_list if i["is_archived"] is False}
+        response_names = {i["name"] for i in response_data["items"]}
+
+        assert response_data["total"] == len(expected_names)
+        assert expected_names == response_names
 
     async def test_list_job_scripts__user_only(
         self,
@@ -361,16 +386,105 @@ async def test_list_job_scripts__user_only(
         job_scripts_list,
     ):
         inject_security_header(tester_email, Permissions.JOB_SCRIPTS_VIEW)
-        response = await client.get("jobbergate/job-scripts?user_only=True")
+        response = await client.get("jobbergate/job-scripts?user_only=True&include_archived=True")
         assert response.status_code == 200, f"Get failed: {response.text}"
 
-        actual_data = response.json()
-        assert actual_data["total"] == 3
+        response_data = response.json()
 
         expected_names = {i["name"] for i in job_scripts_list if i["owner_email"] == tester_email}
-        actual_names = {i["name"] for i in actual_data["items"]}
+        actual_names = {i["name"] for i in response_data["items"]}
+
+        assert response_data["total"] == len(expected_names)
         assert expected_names == actual_names
 
+    async def test_list_job_scripts__include_parent(
+        self,
+        client,
+        tester_email,
+        synth_session,
+        inject_security_header,
+        fill_job_template_data,
+        fill_all_job_script_data,
+        synth_bucket,
+        job_script_data_as_string,
+    ):
+        with template_crud_service.bound_session(synth_session):
+            base_template = await template_crud_service.create(**fill_job_template_data())
+
+        data = fill_all_job_script_data(
+            {"name": "name-1", "parent_template_id": base_template.id},
+            {"name": "name-2"},
+        )
+        with crud_service.bound_session(synth_session):
+            for item in data:
+                job_script_data = await crud_service.create(**item)
+
+                id = job_script_data.id
+                file_type = "ENTRYPOINT"
+                job_script_filename = "entrypoint.py"
+
+                with file_service.bound_session(synth_session):
+                    with file_service.bound_bucket(synth_bucket):
+                        await file_service.upsert(
+                            parent_id=id,
+                            filename=job_script_filename,
+                            upload_content=job_script_data_as_string,
+                            file_type=file_type,
+                        )
+
+        inject_security_header(tester_email, Permissions.JOB_SCRIPTS_VIEW)
+        response = await client.get("jobbergate/job-scripts", params={"include_parent": True})
+        assert response.status_code == 200, f"Get failed: {response.text}"
+
+        response_data = response.json()
+
+        expected_names = {i["name"] for i in data}
+        actual_names = {i["name"] for i in response_data["items"]}
+
+        assert response_data["total"] == len(expected_names)
+        assert expected_names == actual_names
+
+        assert response_data["items"][0]["template"] is not None
+        assert response_data["items"][0]["template"]["name"] == base_template.name
+        assert response_data["items"][1]["template"] is None
+
+    async def test_list_job_scripts__not_include_parent(
+        self,
+        client,
+        tester_email,
+        synth_session,
+        inject_security_header,
+        fill_job_template_data,
+        fill_all_job_script_data,
+    ):
+        with template_crud_service.bound_session(synth_session):
+            base_template = await template_crud_service.create(**fill_job_template_data())
+
+        data = fill_all_job_script_data(
+            {"name": "name-1", "parent_template_id": base_template.id},
+            {"name": "name-2"},
+        )
+        with crud_service.bound_session(synth_session):
+            for item in data:
+                await crud_service.create(**item)
+
+        inject_security_header(tester_email, Permissions.JOB_SCRIPTS_VIEW)
+        response = await client.get("jobbergate/job-scripts", params={"include_parent": False})
+        assert response.status_code == 200, f"Get failed: {response.text}"
+
+        response_data = response.json()
+
+        logger.info(f"{response_data=}")
+
+        expected_names = {i["name"] for i in data}
+        actual_names = {i["name"] for i in response_data["items"]}
+
+        assert response_data["total"] == len(expected_names)
+        assert expected_names == actual_names
+
+        assert response_data["items"][0]["template"] is None
+        assert response_data["items"][1]["template"] is None
+
 
 class TestJobScriptFiles:
     @pytest.fixture(scope="function")
@@ -416,6 +530,31 @@ async def test_create__success(
                 file_content = await file_service.get_file_content(job_script_file)
                 assert file_content.decode() == job_script_data_as_string
 
+    async def test_create__fail_forbidden(
+        self,
+        client,
+        tester_email,
+        inject_security_header,
+        job_script_data,
+        job_script_data_as_string,
+        make_dummy_file,
+    ):
+        id = job_script_data.id
+        file_type = "ENTRYPOINT"
+        dummy_file_path = make_dummy_file("test_template.py", content=job_script_data_as_string)
+
+        owner_email = tester_email
+        requester_email = "another_" + owner_email
+
+        inject_security_header(requester_email, Permissions.JOB_SCRIPTS_EDIT)
+        with open(dummy_file_path, mode="rb") as file:
+            response = await client.put(
+                f"jobbergate/job-scripts/{id}/upload/{file_type}",
+                files={"upload_file": (dummy_file_path.name, file, "text/plain")},
+            )
+
+        assert response.status_code == status.HTTP_403_FORBIDDEN
+
     async def test_get__success(
         self,
         client,
@@ -475,3 +614,33 @@ async def test_delete__success(
         s3_object = await synth_bucket.Object(upserted_instance.file_key)
         with pytest.raises(synth_bucket.meta.client.exceptions.NoSuchKey):
             await s3_object.get()
+
+    async def test_delete__fail_forbidden(
+        self,
+        client,
+        tester_email,
+        inject_security_header,
+        job_script_data,
+        job_script_data_as_string,
+        synth_session,
+        synth_bucket,
+    ):
+        parent_id = job_script_data.id
+        file_type = "ENTRYPOINT"
+        job_script_filename = "entrypoint.py"
+
+        with file_service.bound_session(synth_session):
+            with file_service.bound_bucket(synth_bucket):
+                await file_service.upsert(
+                    parent_id=parent_id,
+                    filename=job_script_filename,
+                    upload_content=job_script_data_as_string,
+                    file_type=file_type,
+                )
+
+        owner_email = tester_email
+        requester_email = "another_" + owner_email
+
+        inject_security_header(requester_email, Permissions.JOB_SCRIPTS_EDIT)
+        response = await client.delete(f"jobbergate/job-scripts/{parent_id}/upload/{job_script_filename}")
+        assert response.status_code == status.HTTP_403_FORBIDDEN
diff --git a/jobbergate-api/tests/apps/job_scripts/test_services.py b/jobbergate-api/tests/apps/job_scripts/test_services.py
new file mode 100644
index 00000000..0d6395a6
--- /dev/null
+++ b/jobbergate-api/tests/apps/job_scripts/test_services.py
@@ -0,0 +1,134 @@
+"""Database models for the job scripts resource."""
+from typing import Any
+
+import pytest
+from fastapi import HTTPException
+from sqlalchemy import inspect
+from jobbergate_api.apps.constants import FileType
+from jobbergate_api.apps.job_scripts.services import (
+    crud_service,
+    file_service,
+)
+from jobbergate_api.apps.job_script_templates.services import crud_service as template_crud_service
+
+
+@pytest.fixture
+def script_test_data() -> dict[str, Any]:
+    """Return a dictionary with dummy values."""
+    return dict(
+        name="test-name",
+        description="test-description",
+        owner_email="owner_email@test.com",
+    )
+
+
+class TestIntegration:
+    @pytest.fixture(autouse=True)
+    async def setup(self, synth_session, synth_bucket):
+        """
+        Ensure that the services are bound for each method in this test class.
+        """
+        with (
+            crud_service.bound_session(synth_session),
+            file_service.bound_session(synth_session),
+            file_service.bound_bucket(synth_bucket),
+        ):
+            yield
+
+    async def test_get_includes_all_files(self, script_test_data):
+        script_instance = await crud_service.create(**script_test_data)
+
+        script_file = await file_service.upsert(
+            script_instance.id,
+            "test.txt",
+            "test file content",
+            file_type=FileType.ENTRYPOINT,
+        )
+
+        result = await crud_service.get(script_instance.id, include_files=True)
+
+        assert "files" not in inspect(result).unloaded
+
+        assert result.files == [script_file]
+
+    async def test_get_includes_parent(self, script_test_data, synth_session):
+        with template_crud_service.bound_session(synth_session):
+            template_instance = await template_crud_service.create(
+                name="test-name", identifier="test-identifier", owner_email=script_test_data["owner_email"]
+            )
+
+        script_instance = await crud_service.create(
+            **script_test_data, parent_template_id=template_instance.id
+        )
+
+        actual_result = await crud_service.get(script_instance.id, include_parent=True)
+
+        assert actual_result.template == template_instance
+
+    async def test_get_not_include_parent(self, script_test_data, synth_session):
+        with template_crud_service.bound_session(synth_session):
+            template_instance = await template_crud_service.create(
+                name="test-name", identifier="test-identifier", owner_email=script_test_data["owner_email"]
+            )
+
+        script_instance = await crud_service.create(
+            **script_test_data, parent_template_id=template_instance.id
+        )
+
+        result = await crud_service.get(script_instance.id, include_parent=False)
+
+        assert "template" in inspect(result).unloaded
+
+    async def test_list_includes_all_files(self, script_test_data, synth_session):
+        script_instance = await crud_service.create(**script_test_data)
+
+        script_file = await file_service.upsert(
+            script_instance.id,
+            "test.txt",
+            "test file content",
+            file_type=FileType.ENTRYPOINT,
+        )
+
+        await synth_session.refresh(script_instance)
+
+        actual_result = await crud_service.list(include_files=True)
+
+        assert actual_result == [script_instance]
+        assert actual_result[0].files == [script_file]
+
+    async def test_update_includes_no_files(self, script_test_data):
+        script_instance = await crud_service.create(**script_test_data)
+
+        script_file = await file_service.upsert(
+            script_instance.id,
+            "test.txt",
+            "test file content",
+            file_type=FileType.ENTRYPOINT,
+        )
+
+        result = await crud_service.update(script_instance.id, name="new-name")
+
+        actual_unloaded = inspect(result).unloaded
+        expected_unloaded = {"template", "files", "submissions"}
+
+        assert actual_unloaded == expected_unloaded
+
+    async def test_delete_cascades_to_files(self, script_test_data):
+        script_instance = await crud_service.create(**script_test_data)
+
+        script_file = await file_service.upsert(
+            script_instance.id,
+            "test.txt",
+            "test file content",
+            file_type=FileType.ENTRYPOINT,
+        )
+
+        await crud_service.delete(script_instance.id)
+
+        with pytest.raises(HTTPException) as exc_info:
+            await crud_service.get(script_instance.id)
+        assert exc_info.value.status_code == 404
+
+        with pytest.raises(HTTPException) as exc_info:
+            await file_service.get(script_file.parent_id, script_file.filename)
+        assert exc_info.value.status_code == 404
diff --git a/jobbergate-api/tests/apps/job_submissions/test_routers.py b/jobbergate-api/tests/apps/job_submissions/test_routers.py
index a9895fc3..8d2ae558 100644
--- a/jobbergate-api/tests/apps/job_submissions/test_routers.py
+++ b/jobbergate-api/tests/apps/job_submissions/test_routers.py
@@ -636,6 +636,29 @@ async def test_get_job_submissions__user_only(
     assert unpack_response(response, key="name", sort=True) == ["sub1", "sub3"]
 
 
+async def test_get_job_submissions__include_archived(
+    client,
+    fill_all_job_submission_data,
+    inject_security_header,
+    synth_session,
+    unpack_response,
+):
+    """
+    Test that the is archived field makes no effect when listing job_submissions.
+
+    The archive feature is shared across all tables, but it is not planned to work on job submissions yet,
+    ir order to keep feature parity with version 3.x. It may be implemented in the future.
+    """
+    all_create_data = fill_all_job_submission_data({"is_archived": False}, {"is_archived": True})
+    with crud_service.bound_session(synth_session):
+        for create_data in all_create_data:
+            await crud_service.create(**create_data)
+
+    inject_security_header("owner1@org.com", Permissions.JOB_SUBMISSIONS_VIEW)
+    response = await client.get("/jobbergate/job-submissions", params=dict(user_only=False))
+    assert unpack_response(response, key="is_archived", sort=True) == [False, True]
+
+
 async def test_get_job_submissions__from_job_script_id(
     client,
     fill_job_script_data,
@@ -1101,6 +1124,44 @@ async def test_update_job_submission_bad_permission(
         assert instance.name == "old name"
 
 
+async def test_update_job_submission_forbidden(
+    client,
+    fill_job_script_data,
+    fill_job_submission_data,
+    tester_email,
+    inject_security_header,
+    synth_session,
+):
+    """
+    Test that it is not possible to update a job_submission from another person.
+    """
+    with scripts_crud_service.bound_session(synth_session):
+        base_job_script = await scripts_crud_service.create(**fill_job_script_data())
+
+    inserted_job_script_id = base_job_script.id
+
+    with crud_service.bound_session(synth_session):
+        inserted_submission = await crud_service.create(
+            job_script_id=inserted_job_script_id, **fill_job_submission_data(name="old name")
+        )
+    inserted_job_submission_id = inserted_submission.id
+
+    owner_email = tester_email
+    requester_email = "another_" + owner_email
+
+    inject_security_header(requester_email, "INVALID_PERMISSION")
+    response = await client.put(
+        f"/jobbergate/job-submissions/{inserted_job_submission_id}",
+        json=dict(name="new name"),
+    )
+
+    assert response.status_code == status.HTTP_403_FORBIDDEN
+
+    with crud_service.bound_session(synth_session):
+        instance = await crud_service.get(inserted_job_submission_id)
+        assert instance.name == "old name"
+
+
 async def test_delete_job_submission(
     client,
     fill_job_script_data,
@@ -1181,6 +1242,37 @@ async def test_delete_job_submission_bad_permission(
     assert response.status_code == status.HTTP_403_FORBIDDEN
 
 
+async def test_delete_job_submission_forbidden(
+    client,
+    tester_email,
+    fill_job_script_data,
+    fill_job_submission_data,
+    inject_security_header,
+    synth_session,
+):
+    """
+    Test that it is not possible to delete a job_submission from another person.
+    """
+    with scripts_crud_service.bound_session(synth_session):
+        base_job_script = await scripts_crud_service.create(**fill_job_script_data())
+
+    inserted_job_script_id = base_job_script.id
+
+    with crud_service.bound_session(synth_session):
+        inserted_submission = await crud_service.create(
+            job_script_id=inserted_job_script_id, **fill_job_submission_data()
+        )
+    inserted_job_submission_id = inserted_submission.id
+
+    owner_email = tester_email
+    requester_email = "another_" + owner_email
+
+    inject_security_header(requester_email, "INVALID_PERMISSION")
+    response = await client.delete(f"/jobbergate/job-submissions/{inserted_job_submission_id}")
+
+    assert response.status_code == status.HTTP_403_FORBIDDEN
+
+
 async def test_job_submissions_agent_pending__success(
     client,
     job_script_data_as_string,
@@ -1299,7 +1391,7 @@ async def test_job_submissions_agent_pending__returns_400_if_token_does_not_carr
     )
     response = await client.get("/jobbergate/job-submissions/agent/pending")
     assert response.status_code == status.HTTP_400_BAD_REQUEST
-    assert "token does not contain a `client_id`" in response.text
+    assert "Access token does not contain a client_id" in response.text
 
 
 async def test_job_submissions_agent_update__success(
@@ -1324,7 +1416,7 @@ async def test_job_submissions_agent_update__success(
 
     with crud_service.bound_session(synth_session):
         inserted_submission = await crud_service.create(
-            job_script_id=inserted_job_script_id, **fill_job_submission_data()
+            job_script_id=inserted_job_script_id, **fill_job_submission_data(client_id="dummy-client")
         )
     inserted_job_submission_id = inserted_submission.id
 
@@ -1370,7 +1462,7 @@ async def test_job_submissions_agent_update__job_rejected(
 
     with crud_service.bound_session(synth_session):
         inserted_submission = await crud_service.create(
-            job_script_id=inserted_job_script_id, **fill_job_submission_data()
+            job_script_id=inserted_job_script_id, **fill_job_submission_data(client_id="dummy-client")
         )
     inserted_job_submission_id = inserted_submission.id
 
@@ -1415,7 +1507,44 @@ async def test_job_submissions_agent_update__returns_400_if_token_does_not_carry
         json=dict(status=JobSubmissionStatus.SUBMITTED, slurm_job_id=111),
     )
     assert response.status_code == status.HTTP_400_BAD_REQUEST
-    assert "token does not contain a `client_id`" in response.text
+    assert "Checked expressions failed: Access token does not contain\\n  1: client_id" in response.text
+
+
+async def test_job_submissions_agent_update__returns_403_if_client_id_differs(
+    fill_job_script_data,
+    fill_job_submission_data,
+    client,
+    inject_security_header,
+    synth_session,
+):
+    """
+    Test PUT /job-submissions/agent/{job_submission_id} returns 403 if client_id does not match the database.
+    """
+    original_client_id = "dummy-client"
+    requester_client_id = "another_" + original_client_id
+
+    with scripts_crud_service.bound_session(synth_session):
+        base_job_script = await scripts_crud_service.create(**fill_job_script_data())
+
+    inserted_job_script_id = base_job_script.id
+
+    with crud_service.bound_session(synth_session):
+        inserted_submission = await crud_service.create(
+            job_script_id=inserted_job_script_id, **fill_job_submission_data(client_id=original_client_id)
+        )
+    inserted_job_submission_id = inserted_submission.id
+
+    payload = dict(
+        status=JobSubmissionStatus.REJECTED.value,
+        report_message="Job rejected by the system due to test-test",
+    )
+
+    inject_security_header("who@cares.com", Permissions.JOB_SUBMISSIONS_EDIT, client_id=requester_client_id)
+    response = await client.put(
+        f"/jobbergate/job-submissions/agent/{inserted_job_submission_id}", json=payload
+    )
+
+    assert response.status_code == status.HTTP_403_FORBIDDEN
 
 
 async def test_job_submissions_agent_active__success(
@@ -1494,4 +1623,4 @@ async def test_job_submissions_agent_active__returns_400_if_token_does_not_carry
     )
     response = await client.get("/jobbergate/job-submissions/agent/active")
     assert response.status_code == status.HTTP_400_BAD_REQUEST
-    assert "token does not contain a `client_id`" in response.text
+    assert "Access token does not contain a client_id" in response.text
diff --git a/jobbergate-api/tests/apps/job_submissions/test_services.py b/jobbergate-api/tests/apps/job_submissions/test_services.py
new file mode 100644
index 00000000..b314769e
--- /dev/null
+++ b/jobbergate-api/tests/apps/job_submissions/test_services.py
@@ -0,0 +1,103 @@
+"""Database models for the job scripts resource."""
+from typing import Any
+
+import pytest
+from fastapi import HTTPException
+from sqlalchemy import inspect
+from jobbergate_api.apps.constants import FileType
+from jobbergate_api.apps.job_submissions.services import crud_service
+from jobbergate_api.apps.job_scripts.services import crud_service as scripts_crud_service
+from jobbergate_api.apps.job_scripts.services import file_service
+
+
+class TestIntegration:
+    @pytest.fixture(autouse=True)
+    async def setup(self, synth_session, synth_bucket):
+        """
+        Ensure that the services are bound for each method in this test class.
+        """
+        with (
+            crud_service.bound_session(synth_session),
+            scripts_crud_service.bound_session(synth_session),
+            file_service.bound_session(synth_session),
+            file_service.bound_bucket(synth_bucket),
+        ):
+            yield
+
+    async def test_get_includes_all_files(self, fill_job_script_data, fill_job_submission_data):
+        script_instance = await scripts_crud_service.create(**fill_job_script_data())
+        submission_instance = await crud_service.create(
+            **fill_job_submission_data(), job_script_id=script_instance.id
+        )
+        script_file = await file_service.upsert(
+            script_instance.id,
+            "test.txt",
+            "test file content",
+            file_type=FileType.ENTRYPOINT,
+        )
+
+        result = await crud_service.get(submission_instance.id, include_files=True)
+
+        assert "job_script" not in inspect(result).unloaded
+        assert "files" not in inspect(result.job_script).unloaded
+
+        assert result.job_script.files == [script_file]
+
+    async def test_get_includes_parent(self, fill_job_script_data, fill_job_submission_data):
+        script_instance = await scripts_crud_service.create(**fill_job_script_data())
+        submission_instance = await crud_service.create(
+            **fill_job_submission_data(), job_script_id=script_instance.id
+        )
+
+        result = await crud_service.get(submission_instance.id, include_parent=True)
+
+        assert "job_script" not in inspect(result).unloaded
+        assert "files" in inspect(result.job_script).unloaded
+
+        assert result.job_script == script_instance
+
+    async def test_get_not_include_parent(self, fill_job_script_data, fill_job_submission_data):
+        script_instance = await scripts_crud_service.create(**fill_job_script_data())
+        submission_instance = await crud_service.create(
+            **fill_job_submission_data(), job_script_id=script_instance.id
+        )
+
+        result = await crud_service.get(submission_instance.id, include_parent=False)
+
+        assert "job_script" in inspect(result).unloaded
+
+    async def test_list_includes_all_files(self, fill_job_script_data, fill_job_submission_data):
+        script_instance = await scripts_crud_service.create(**fill_job_script_data())
+        submission_instance = await crud_service.create(
+            **fill_job_submission_data(), job_script_id=script_instance.id
+        )
+        script_file = await file_service.upsert(
+            script_instance.id,
+            "test.txt",
+            "test file content",
+            file_type=FileType.ENTRYPOINT,
+        )
+
+        actual_result = await crud_service.list(include_files=True)
+
+        assert actual_result == [submission_instance]
+        assert actual_result[0].job_script.files == [script_file]
+
+    async def test_update_includes_no_files(self, fill_job_script_data, fill_job_submission_data):
+        script_instance = await scripts_crud_service.create(**fill_job_script_data())
+        submission_instance = await crud_service.create(
+            **fill_job_submission_data(), job_script_id=script_instance.id
+        )
+        script_file = await file_service.upsert(
+            script_instance.id,
+            "test.txt",
+            "test file content",
+            file_type=FileType.ENTRYPOINT,
+        )
+
+        result = await crud_service.update(submission_instance.id, name="new-name")
+
+        actual_unloaded = inspect(result).unloaded
+        expected_unloaded = {"job_script"}
+
+        assert actual_unloaded == expected_unloaded
diff --git a/jobbergate-api/tests/apps/test_services.py b/jobbergate-api/tests/apps/test_services.py
index 99f56c36..faa0625c 100644
--- a/jobbergate-api/tests/apps/test_services.py
+++ b/jobbergate-api/tests/apps/test_services.py
@@ -53,6 +53,13 @@ def _helper(page=1, size=10):
     return _helper
 
 
+def test_property__name(dummy_crud_service):
+    """
+    Test the name property.
+    """
+    assert dummy_crud_service.name == "dummy_cruds"
+
+
 class TestCrudService:
     """
     Group tests for the CrudService.
@@ -123,6 +130,29 @@ async def test_get__raises_exception_if_not_found(
             await dummy_crud_service.get(0)
         assert exc_info.value.status_code == 404
 
+    async def test_get__enforces_attributes(
+        self,
+        dummy_crud_service,
+        tester_email,
+    ):
+        """
+        Test that the ``get()`` calls ``ensure_attribute()`` with the given attributes.
+
+        Different scenarios are tested directly on the ``ensure_attribute()`` method.
+        """
+        test_name = "test-name"
+        created_instance = await dummy_crud_service.create(
+            name=test_name,
+            owner_email=tester_email,
+        )
+        with mock.patch.object(dummy_crud_service, "ensure_attribute") as ensure_attribute:
+            fetched_instance = await dummy_crud_service.get(
+                created_instance.id, ensure_attributes=dict(owner_email=tester_email, name=test_name)
+            )
+        ensure_attribute.assert_called_once_with(created_instance, owner_email=tester_email, name=test_name)
+
+        assert created_instance == fetched_instance
+
     async def test_delete__success_by_id(
         self,
         dummy_crud_service,
@@ -214,6 +244,52 @@ async def test_list__unfiltered(
         all_fetched_instances = await dummy_crud_service.list()
         assert ["one", "two", "three"] == [i.name for i in all_fetched_instances]
 
+    async def test_list__include_archived(
+        self,
+        dummy_crud_service,
+    ):
+        """
+        Test that the ``list()`` method returns all instances of the served model when including archived.
+
+        Notice this is the default behavior.
+        """
+        await dummy_crud_service.create(
+            name="one",
+            description="the first",
+            owner_email="1@test.com",
+            is_archived=False,
+        )
+        await dummy_crud_service.create(
+            name="two",
+            description="second",
+            owner_email="2@test.com",
+            is_archived=True,
+        )
+        all_fetched_instances = await dummy_crud_service.list()
+        assert ["one", "two"] == [i.name for i in all_fetched_instances]
+
+    async def test_list__not_include_archived(
+        self,
+        dummy_crud_service,
+    ):
+        """
+        Test that the ``list()`` method don not include archived instances.
+        """
+        await dummy_crud_service.create(
+            name="one",
+            description="the first",
+            owner_email="1@test.com",
+            is_archived=False,
+        )
+        await dummy_crud_service.create(
+            name="two",
+            description="second",
+            owner_email="2@test.com",
+            is_archived=True,
+        )
+        all_fetched_instances = await dummy_crud_service.list(include_archived=False)
+        assert ["one"] == [i.name for i in all_fetched_instances]
+
     async def test_list__with_search(
         self,
         dummy_crud_service,
@@ -281,20 +357,11 @@ async def test_list__limited_by_owner(
         """
         Test that the ``list()`` restricts instances of the served model to those owned by the user.
         """
-        await dummy_crud_service.create(
-            name="one",
-            owner_email="user1@test.com",
-        )
-        await dummy_crud_service.create(
-            name="two",
-            owner_email="user2@test.com",
-        )
-        await dummy_crud_service.create(
-            name="three",
-            owner_email="user1@test.com",
-        )
+        await dummy_crud_service.create(name="one", owner_email="user1@test.com")
+        await dummy_crud_service.create(name="two", owner_email="user2@test.com")
+        await dummy_crud_service.create(name="three", owner_email="user1@test.com")
 
-        all_fetched_instances = await dummy_crud_service.list(user_email="user1@test.com")
+        all_fetched_instances = await dummy_crud_service.list(owner_email="user1@test.com")
         assert ["one", "three"] == [i.name for i in all_fetched_instances]
 
     async def test_paginated_list(
@@ -342,6 +409,78 @@ async def test_paginated_list(
         assert page.total == 3
         assert ["two"] == [i.name for i in page.items]
 
+    async def test_get_ensure_ownership__success(
+        self,
+        dummy_crud_service,
+        tester_email,
+    ):
+        """
+        Test that the ``ensure_attribute()`` works when the entry is found and the emails match.
+        """
+        created_instance = await dummy_crud_service.create(
+            name="test-name",
+            description="test-description",
+            owner_email=tester_email,
+        )
+        dummy_crud_service.ensure_attribute(created_instance, owner_email=tester_email)
+
+    async def test_get_ensure_attribute_multiple_arguments(
+        self,
+        dummy_crud_service,
+        tester_email,
+    ):
+        """
+        Test that the ``ensure_attribute()`` method works with multiple arguments.
+        """
+        name = "test-name"
+        created_instance = await dummy_crud_service.create(
+            name=name,
+            description="test-description",
+            owner_email=tester_email,
+        )
+        dummy_crud_service.ensure_attribute(created_instance, owner_email=tester_email, name=name)
+
+    async def test_get_ensure_ownership__bad_request(
+        self,
+        dummy_crud_service,
+        tester_email,
+    ):
+        """
+        Test that the ``ensure_attribute()`` raises AttributeError when a column is not found.
+
+        This is a sanity check, error handling may be improved in the future.
+        """
+        created_instance = await dummy_crud_service.create(
+            name="test-name",
+            description="test-description",
+            owner_email=tester_email,
+        )
+        with pytest.raises(AttributeError):
+            dummy_crud_service.ensure_attribute(created_instance, not_a_column="any-value")
+
+    async def test_get_ensure_ownership__forbidden(
+        self,
+        dummy_crud_service,
+        tester_email,
+    ):
+        """
+        Test that the ``ensure_attribute()`` returns 403 when emails do not match.
+
+        Also makes sure the context manager does not delete the entry.
+        """
+        owner_email = tester_email
+        requester_email = "another_" + tester_email
+
+        created_instance = await dummy_crud_service.create(
+            name="test-name",
+            description="test-description",
+            owner_email=owner_email,
+        )
+
+        with pytest.raises(HTTPException) as exc_info:
+            dummy_crud_service.ensure_attribute(created_instance, owner_email=requester_email)
+        assert exc_info.value.status_code == 403
+
 
 class DummyFile(FileMixin, Base):
     """
diff --git a/jobbergate-cli/tests/test_requests.py b/jobbergate-cli/tests/test_requests.py
index 23d31398..ed59ada9 100644
--- a/jobbergate-cli/tests/test_requests.py
+++ b/jobbergate-cli/tests/test_requests.py
@@ -146,6 +146,39 @@ def test_make_request__raises_Abort_if_client_request_raises_exception(respx_moc
     assert err_info.value.original_error == original_error
 
 
+def test_make_request__raises_Abort_with_ownership_message_for_403_for_non_owners(respx_mock, dummy_client):
+    """
+    Validate that the ``make_request()`` function will raise an Abort if the ``expected_status`` arg is set and it
+    does not match the status code of the response. Further verify that message is attached telling the user that
+    they may not modify a resource they do not own.
+    """
+    client = dummy_client()
+    req_path = "/fake-path"
+
+    respx_mock.delete(f"{DEFAULT_DOMAIN}{req_path}").mock(
+        return_value=httpx.Response(
+            httpx.codes.FORBIDDEN,
+            json=dict(detail="This jabroni does not own this whingding"),
+        ),
+    )
+
+    with pytest.raises(Abort, match="You do not own this resource") as err_info:
+        make_request(
+            client,
+            req_path,
+            "DELETE",
+            expected_status=204,
+            abort_message="There was a big problem",
+            abort_subject="BIG PROBLEM",
+            support=True,
+        )
+    assert err_info.value.subject == "BIG PROBLEM"
+    assert err_info.value.support is False
+    assert "Resource could not be modified by non-owner" in err_info.value.log_message
+    assert "This jabroni does not own this whingding" in err_info.value.log_message
+    assert err_info.value.original_error is None
+
+
 def test_make_request__raises_Abort_when_expected_status_is_not_None_and_response_status_does_not_match_it(
     respx_mock, dummy_client
 ):
diff --git a/jobbergate-core/tests/__init__.py b/jobbergate-core/tests/__init__.py
new file mode 100644
index 00000000..8611862a
--- /dev/null
+++ b/jobbergate-core/tests/__init__.py
@@ -0,0 +1,3 @@
+"""
+Provide unit tests for this package.
+"""
diff --git a/jobbergate-core/tests/auth/__init__.py b/jobbergate-core/tests/auth/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/jobbergate-core/tests/auth/conftest.py b/jobbergate-core/tests/auth/conftest.py
new file mode 100644
index 00000000..8f823599
--- /dev/null
+++ b/jobbergate-core/tests/auth/conftest.py
@@ -0,0 +1,60 @@
+import pendulum
+import pytest
+from jose.jwt import encode
+
+from jobbergate_core.auth.token import Token, TokenType
+
+
+@pytest.fixture(scope="session")
+def time_now():
+    """
+    Return a pendulum instance with a fixed time.
+    """
+    time_now = pendulum.datetime(2020, 1, 1, tz="UTC")
+    pendulum.set_test_now(time_now)
+
+    yield time_now
+
+    pendulum.set_test_now(None)
+
+
+@pytest.fixture(scope="session")
+def jwt_token(time_now):
+    """
+    This fixture will create a JWT token using the jose package.
+    """
+
+    def helper(expiration_time=time_now, **kwargs):
+        payload = {
+            "exp": expiration_time.int_timestamp,
+            **kwargs,
+        }
+        jwt_token = encode(payload, "fake-secret", algorithm="HS256")
+
+        return jwt_token
+
+    return helper
+
+
+@pytest.fixture
+def valid_token(tmp_path, jwt_token):
+    """
+    Return a valid token.
+    """
+    return Token(
+        content=jwt_token(exp=pendulum.tomorrow().int_timestamp),
+        cache_directory=tmp_path,
+        label=TokenType.ACCESS,
+    )
+
+
+@pytest.fixture
+def expired_token(tmp_path, jwt_token):
+    """
+    Return an expired token.
+    """
+    return Token(
+        content=jwt_token(exp=pendulum.yesterday().int_timestamp),
+        cache_directory=tmp_path,
+        label=TokenType.ACCESS,
+    )
diff --git a/jobbergate-core/tests/auth/test_handler.py b/jobbergate-core/tests/auth/test_handler.py
new file mode 100644
index 00000000..4b5c9d8e
--- /dev/null
+++ b/jobbergate-core/tests/auth/test_handler.py
@@ -0,0 +1,395 @@
+"""
+Test the utilities for handling auth in Jobbergate.
+"""
+from unittest import mock
+
+import httpx
+import pytest
+import requests
+
+from jobbergate_core.auth import JobbergateAuthHandler
+from jobbergate_core.auth.exceptions import AuthenticationError
+from jobbergate_core.auth.token import TokenError, TokenType
+
+
+DUMMY_LOGIN_DOMAIN = "http://keycloak.local:8080/realms/jobbergate-local"
+DUMMY_LOGIN_AUDIENCE = "https://local.omnivector.solutions"
+DUMMY_LOGIN_CLIENT_ID = "cli"
+
+
+@pytest.fixture
+def dummy_jobbergate_auth(tmp_path):
+    """
+    Return a dummy JobbergateAuthHandler object.
+    """
+    return JobbergateAuthHandler(
+        cache_directory=tmp_path,
+        login_domain=DUMMY_LOGIN_DOMAIN,
+        login_audience=DUMMY_LOGIN_AUDIENCE,
+        login_client_id=DUMMY_LOGIN_CLIENT_ID,
+    )
+
+
+def test_auth_base_case(tmp_path, dummy_jobbergate_auth):
+    """
+    Test that the JobbergateAuthHandler class can be instantiated.
+    """
+    assert dummy_jobbergate_auth.cache_directory == tmp_path
+    assert dummy_jobbergate_auth.login_domain == DUMMY_LOGIN_DOMAIN
+    assert dummy_jobbergate_auth.login_audience == DUMMY_LOGIN_AUDIENCE
+    assert dummy_jobbergate_auth.login_client_id == DUMMY_LOGIN_CLIENT_ID
+
+    assert dummy_jobbergate_auth._access_token.content == ""
+    assert dummy_jobbergate_auth._access_token.cache_directory == tmp_path
+    assert dummy_jobbergate_auth._access_token.label == TokenType.ACCESS
+
+    assert dummy_jobbergate_auth._refresh_token.content == ""
+    assert dummy_jobbergate_auth._refresh_token.cache_directory == tmp_path
+    assert dummy_jobbergate_auth._refresh_token.label == TokenType.REFRESH
+
+
+def test_insert_token_in_request_header(respx_mock, dummy_jobbergate_auth, valid_token):
+    """
+    Test that the JobbergateAuthHandler class inserts the token in the header (performed by __call__).
+    """
+    dummy_headers = {"test-header": "test-value", "foo": "bar"}
+    expected_headers = {**dummy_headers, "Authorization": valid_token.bearer_token}
+
+    test_endpoint = "http://test.com"
+    respx_mock.get(test_endpoint).mock()
+    request = requests.Request("GET", test_endpoint, auth=dummy_jobbergate_auth, headers=dummy_headers)
+
+    assert request.headers == dummy_headers
+
+    with mock.patch.object(
+        dummy_jobbergate_auth,
+        attribute="acquire_access",
+        new=lambda *args, **kwargs: valid_token.bearer_token,
+    ):
+        prepared_request = request.prepare()
+
+    assert prepared_request.headers == expected_headers
+
+
+@pytest.mark.parametrize("procedure", (None, "load_from_cache", "refresh_tokens", "login"))
+def test_acquire_access(procedure, dummy_jobbergate_auth, valid_token):
+    """
+    Test that the acquire_tokens method works as expected.
+
+    The acquire_access method should try in this order until a valid access token is found:
+        * Get the access token stored in the instance
+        * Load the tokens from the cache directory
+        * Refresh both tokens using the refresh token.
+        * Login to fetch both tokens
+
+    This test covers all possible scenarios, making sure that the correct functions are
+    called according to the tokens available in the class.
+    """
+    expected_order_of_procedures = ("load_from_cache", "refresh_tokens", "login")
+
+    def inject_valid_access_token(*args, **kwargs):
+        dummy_jobbergate_auth._access_token = valid_token
+
+    mock_params = {key: mock.DEFAULT for key in expected_order_of_procedures}
+    if procedure is None:
+        dummy_jobbergate_auth._access_token = valid_token
+    else:
+        mock_params[procedure] = inject_valid_access_token
+
+    with mock.patch.multiple(dummy_jobbergate_auth, **mock_params) as mocked_jobbergate_auth:
+        dummy_jobbergate_auth.acquire_access()
+
+    actual_call_counter = {key: mocked.call_count for key, mocked in mocked_jobbergate_auth.items()}
+    expected_call_counter = {e: 0 for e in expected_order_of_procedures}
+    if procedure is not None:
+        actual_call_counter[procedure] = 1
+        for e in expected_order_of_procedures:
+            expected_call_counter[e] = 1
+            if e == procedure:
+                break
+
+    assert actual_call_counter == expected_call_counter
+
+
+class TestJobbergateAuthHandlerLoadFromCache:
+    """
+    Test the load_from_cache method on JobbergateAuthHandler class.
+    """
+
+    def test_no_tokens_found(self, dummy_jobbergate_auth):
+        """
+        Test that the function works as expected.
+
+        If no tokens are found in cache, the tokens dictionary should stay empty.
+        """
+        with pytest.raises(TokenError, match="Token file was not found"):
+            dummy_jobbergate_auth.load_from_cache()
+
+    def test_tokens_found__replace_loaded(self, dummy_jobbergate_auth, valid_token, expired_token):
+        """
+        Test that the function works as expected.
+
+        If a token is already loaded in the class, it should be replaced.
+        """
+        cached_tokens = {
+            TokenType.ACCESS: expired_token.replace(label=TokenType.ACCESS),
+            TokenType.REFRESH: expired_token.replace(label=TokenType.REFRESH),
+        }
+        for token in cached_tokens.values():
+            token.save_to_cache()
+
+        original_content = valid_token.content
+        expected_content = expired_token.content
+
+        dummy_jobbergate_auth.access_token = original_content
+        dummy_jobbergate_auth.refresh_token = original_content
+
+        dummy_jobbergate_auth.load_from_cache()
+
+        assert dummy_jobbergate_auth._access_token.content == expected_content
+        assert dummy_jobbergate_auth._refresh_token.content == expected_content
+
+
+def test_save_to_cache(dummy_jobbergate_auth, valid_token, expired_token):
+    """
+    Test that the save_to_cache function works as expected.
+
+    The save_to_cache function should:
+        * Create the cache directory if it does not exist
+        * Save the tokens to the cache directory
+    """
+    new_cache_directory = dummy_jobbergate_auth.cache_directory / "new_cache"
+
+    dummy_jobbergate_auth.cache_directory = new_cache_directory
+
+    access_token = dummy_jobbergate_auth._access_token.replace(
+        content=valid_token.content, cache_directory=new_cache_directory
+    )
+    dummy_jobbergate_auth._access_token = access_token
+    refresh_token = dummy_jobbergate_auth._refresh_token.replace(
+        content=expired_token.content, cache_directory=new_cache_directory
+    )
+    dummy_jobbergate_auth._refresh_token = refresh_token
+
+    assert new_cache_directory.exists() is False
+
+    dummy_jobbergate_auth.save_to_cache()
+
+    assert new_cache_directory.exists() is True
+    assert access_token.file_path.read_text() == access_token.content
+    assert refresh_token.file_path.read_text() == refresh_token.content
+
+
+class TestJobbergateAuthHandlerRefreshTokens:
+    """
+    Test the refresh_tokens method on JobbergateAuthHandler class.
+    """
+
+    def test_refresh_tokens__success(
+        self,
+        respx_mock,
+        jwt_token,
+        dummy_jobbergate_auth,
+        expired_token,
+        valid_token,
+    ):
+        """
+        Test that the function works as expected.
+        """
+        dummy_jobbergate_auth._access_token = dummy_jobbergate_auth._access_token.replace(
+            content=expired_token.content,
+        )
+        dummy_jobbergate_auth._refresh_token = dummy_jobbergate_auth._refresh_token.replace(
+            content=valid_token.content,
+        )
+
+        refreshed_access_token_content = jwt_token(custom_data="refreshed_access_token")
+        refreshed_refresh_token_content = jwt_token(custom_data="refreshed_refresh_token")
+
+        endpoint = f"{dummy_jobbergate_auth.login_domain}/protocol/openid-connect/token"
+        respx_mock.post(endpoint).mock(
+            return_value=httpx.Response(
+                httpx.codes.OK,
+                json=dict(
+                    access_token=refreshed_access_token_content,
+                    refresh_token=refreshed_refresh_token_content,
+                ),
+            ),
+        )
+
+        dummy_jobbergate_auth.refresh_tokens()
+
+        assert dummy_jobbergate_auth._access_token.content == refreshed_access_token_content
+        assert dummy_jobbergate_auth._refresh_token.content == refreshed_refresh_token_content
+
+    def test_refresh_tokens__failure_no_refresh_token(self, dummy_jobbergate_auth):
+        """
+        Test that the function raises an exception if there is no refresh token.
+        """
+        assert dummy_jobbergate_auth._refresh_token.content == ""
+        with pytest.raises(
+            AuthenticationError,
+            match="The refresh is unavailable",
+        ):
+            dummy_jobbergate_auth.refresh_tokens()
+
+    def test_refresh_tokens__failure_expired_refresh_token(self, dummy_jobbergate_auth, expired_token):
+        """
+        Test that the function raises an exception if the refresh token is expired.
+        """
+        dummy_jobbergate_auth._refresh_token = dummy_jobbergate_auth._refresh_token.replace(
+            content=expired_token.content,
+        )
+        with pytest.raises(AuthenticationError, match="Refresh token is expired"):
+            dummy_jobbergate_auth.refresh_tokens()
+
+    def test_refresh_tokens__failure_by_missing_data(
+        self,
+        respx_mock,
+        jwt_token,
+        dummy_jobbergate_auth,
+        valid_token,
+    ):
+        """
+        Test that the function raises an exception if the response is missing data.
+        """
+        dummy_jobbergate_auth._refresh_token = dummy_jobbergate_auth._refresh_token.replace(
+            content=valid_token.content,
+        )
+
+        refreshed_access_token_content = jwt_token(custom_data="refreshed_access_token")
+
+        endpoint = f"{dummy_jobbergate_auth.login_domain}/protocol/openid-connect/token"
+        respx_mock.post(endpoint).mock(
+            return_value=httpx.Response(
+                httpx.codes.OK,
+                json=dict(access_token=refreshed_access_token_content),
+            ),  # note that the refresh token is missing
+        )
+        with pytest.raises(
+            AuthenticationError,
+            match="Not all tokens were included in the response",
+        ):
+            dummy_jobbergate_auth.refresh_tokens()
+
+    def test_refresh_tokens__request_failure(
+        self,
+        respx_mock,
+        dummy_jobbergate_auth,
+        expired_token,
+        valid_token,
+    ):
+        """
+        Test that the function raises an exception if the tokens are not refreshed.
+        """
+        dummy_jobbergate_auth._refresh_token = dummy_jobbergate_auth._refresh_token.replace(
+            content=valid_token.content,
+        )
+
+        endpoint = f"{dummy_jobbergate_auth.login_domain}/protocol/openid-connect/token"
+        respx_mock.post(endpoint).mock(
+            return_value=httpx.Response(httpx.codes.BAD_REQUEST),
+        )
+
+        with pytest.raises(
+            AuthenticationError,
+            match="Unexpected error while refreshing the tokens",
+        ):
+            dummy_jobbergate_auth.refresh_tokens()
+
+
+def test_logout_success(dummy_jobbergate_auth, valid_token):
+    """
+    Test that the logout function works as expected.
+    """
+    dummy_jobbergate_auth._access_token = dummy_jobbergate_auth._access_token.replace(
+        content=valid_token.content,
+    )
+    dummy_jobbergate_auth._refresh_token = dummy_jobbergate_auth._refresh_token.replace(
+        content=valid_token.content,
+    )
+
+    access_token_path = dummy_jobbergate_auth._access_token.file_path
+    refresh_token_path = dummy_jobbergate_auth._refresh_token.file_path
+
+    dummy_jobbergate_auth.save_to_cache()
+
+    assert access_token_path.exists() is True
+    assert refresh_token_path.exists() is True
+
+    dummy_jobbergate_auth.logout()
+
+    assert dummy_jobbergate_auth._access_token.content == ""
+    assert dummy_jobbergate_auth._refresh_token.content == ""
+
+    assert access_token_path.exists() is False
+    assert refresh_token_path.exists() is False
+
+
+class TestJobbergateAuthHandlerLogin:
+    """
+    Test the login method on JobbergateAuthHandler class.
+    """
+
+    def test_login__success(self, respx_mock, dummy_jobbergate_auth, valid_token):
+        """
+        Test that the function works as expected.
+        """
+        assert dummy_jobbergate_auth._access_token.content == ""
+        assert dummy_jobbergate_auth._refresh_token.content == ""
+
+        endpoint = f"{dummy_jobbergate_auth.login_domain}/protocol/openid-connect/auth/device"
+        respx_mock.post(endpoint).mock(
+            return_value=httpx.Response(
+                httpx.codes.OK,
+                json=dict(
+                    device_code="dummy-code",
+                    verification_uri_complete="https://dummy-uri.com",
+                    interval=1,
+                    expires_in=0.5,
+                ),
+            ),
+        )
+
+        endpoint = f"{dummy_jobbergate_auth.login_domain}/protocol/openid-connect/token"
+        respx_mock.post(endpoint).mock(
+            return_value=httpx.Response(
+                httpx.codes.OK,
+                json=dict(
+                    access_token=valid_token.content,
+                    refresh_token=valid_token.content,
+                ),
+            ),
+        )
+
+        dummy_jobbergate_auth.login()
+
+        assert dummy_jobbergate_auth._access_token.content == valid_token.content
+        assert dummy_jobbergate_auth._refresh_token.content == valid_token.content
+
+    def test_login__raises_timeout(self, respx_mock, dummy_jobbergate_auth):
+        """
+        Test that the function raises an exception if the process times out.
+        """
+        endpoint = f"{dummy_jobbergate_auth.login_domain}/protocol/openid-connect/auth/device"
+        respx_mock.post(endpoint).mock(
+            return_value=httpx.Response(
+                httpx.codes.OK,
+                json=dict(
+                    device_code="dummy-code",
+                    verification_uri_complete="https://dummy-uri.com",
+                    interval=1,
+                    expires_in=1e-8,  # very small value to force timeout
+                ),
+            ),
+        )
+
+        endpoint = f"{dummy_jobbergate_auth.login_domain}/protocol/openid-connect/token"
+        respx_mock.post(endpoint).mock(
+            return_value=httpx.Response(
+                httpx.codes.BAD_REQUEST,
+            ),
+        )
+
+        with pytest.raises(AuthenticationError, match="Login expired, please try again"):
+            dummy_jobbergate_auth.login()
diff --git a/jobbergate-core/tests/auth/test_token.py b/jobbergate-core/tests/auth/test_token.py
new file mode 100644
index 00000000..b42108a1
--- /dev/null
+++ b/jobbergate-core/tests/auth/test_token.py
@@ -0,0 +1,231 @@
+"""
+Test the utilities for handling auth in Jobbergate.
+"""
+import pytest
+
+from jobbergate_core.auth.token import Token, TokenError, TokenType
+
+
+class TestToken:
+    def test_base_case(self, jwt_token, tmp_path, time_now):
+        """
+        Test that the Token class can be instantiated.
+        """
+
+        token_data = {
+            "test": "test",
+            "foo": "bar",
+            "email": "good@email.com",
+            "name": "John Doe",
+            "exp": time_now.int_timestamp,
+        }
+        token_content = jwt_token(**token_data)
+
+        token = Token(
+            content=token_content,
+            cache_directory=tmp_path,
+            label=TokenType.ACCESS,
+        )
+
+        assert token.content == token_content
+        assert token.cache_directory == tmp_path
+        assert token.label == "access"
+        assert token.file_path == tmp_path / "access.token"
+        assert token.data == token_data
+
+    def test_init_empty_token(self, tmp_path):
+        """
+        Test that an empty token can be initiated.
+        """
+
+        token = Token(
+            cache_directory=tmp_path,
+            label=TokenType.ACCESS,
+        )
+
+        assert token.content == ""
+        assert token.cache_directory == tmp_path
+        assert token.label == "access"
+        assert token.file_path == tmp_path / "access.token"
+        assert token.data == {}
+
+    def test_save_to_cache__success(self, tmp_path, jwt_token):
+        """
+        Test that the save_to_cache function works as expected.
+        """
+
+        token_content = jwt_token()
+
+        token = Token(
+            content=token_content,
+            cache_directory=tmp_path,
+            label=TokenType.ACCESS,
+        )
+
+        assert token.file_path.exists() is False
+
+        token.save_to_cache()
+
+        assert token.file_path.read_text() == token_content
+
+    def test_save_to_cache__validation_error(self, tmp_path, jwt_token):
+        """
+        Test that save_to_cache raises an exception when the path is invalid.
+        """
+        token_directory = tmp_path / "unexistent_directory"
+        token_content = jwt_token()
+
+        token = Token(
+            content=token_content,
+            cache_directory=token_directory,
+            label=TokenType.ACCESS,
+        )
+
+        with pytest.raises(TokenError, match="Parent directory does not exist"):
+            token.save_to_cache()
+
+    def test_load_from_cache__success(self, tmp_path, jwt_token, time_now):
+        """
+        Test that the load_from_cache function works as expected.
+        """
+
+        token_data = {
+            "test": "test",
+            "foo": "bar",
+            "email": "good@email.com",
+            "name": "John Doe",
+            "exp": time_now.int_timestamp,
+        }
+        token_label = TokenType.ACCESS
+        token_path = tmp_path / "access.token"
+        token_content = jwt_token(**token_data)
+        token_path.write_text(token_content)
+
+        token = Token(cache_directory=tmp_path, label=token_label)
+
+        assert token.content == ""
+
+        token = token.load_from_cache()
+
+        assert token.content == token_content
+        assert token.cache_directory == tmp_path
+        assert token.label == token_label
+        assert token.file_path == token_path
+        assert token.data == token_data
+
+    def test_load_from_cache__validation_error(self, tmp_path):
+        """
+        Test that the load_from_cache function raises an error when the file is not found.
+        """
+
+        token_path = tmp_path / "access.token"
+
+        assert token_path.exists() is False
+
+        with pytest.raises(TokenError, match="Token file was not found"):
+            Token(cache_directory=tmp_path, label=TokenType.ACCESS).load_from_cache()
+
+    def test_clear_cache__success(self, tmp_path, jwt_token):
+        """
+        Test that the clear_cache function works as expected.
+        """
+
+        token_path = tmp_path / "access.token"
+        token_content = jwt_token()
+        token_path.write_text(token_content)
+
+        token = Token(
+            content=token_content,
+            cache_directory=tmp_path,
+            label=TokenType.ACCESS,
+        )
+
+        assert token_path.is_file() is True
+        token.clear_cache()
+        assert token_path.is_file() is False
+
+    def test_clear_cache__file_not_found(self, tmp_path, jwt_token):
+        """
+        Test that the clear_cache raises no error if the file does no exist.
+        """
+
+        token_path = tmp_path / "access.token"
+        token_content = jwt_token()
+
+        token = Token(
+            content=token_content,
+            cache_directory=tmp_path,
+            label=TokenType.ACCESS,
+        )
+
+        assert token_path.is_file() is False
+        token.clear_cache()
+        assert token_path.is_file() is False
+
+    @pytest.mark.parametrize("test_content", ["some-dummy-text", None])
+    def test_validate_content__invalid_token(self, test_content, tmp_path):
+        """
+        Test that an error is raised when the token content is invalid.
+        """
+        with pytest.raises(TokenError):
+            Token(
+                content=test_content,
+                cache_directory=tmp_path,
+                label=TokenType.ACCESS,
+            )
+
+    @pytest.mark.parametrize(
+        "time_delta, is_expired",
+        [(-1, True), (0, True), (1, False)],
+    )
+    def test_is_expired__success(self, time_delta, is_expired, tmp_path, jwt_token, time_now):
+        """
+        Test that the is_expired function works as expected.
+        """
+        expiration_date = time_now.int_timestamp + time_delta
+
+        token = Token(
+            content=jwt_token(exp=expiration_date),
+            cache_directory=tmp_path,
+            label=TokenType.ACCESS,
+        )
+
+        assert token.is_expired() == is_expired
+
+    @pytest.mark.parametrize(
+        "content, is_expired, is_valid",
+        [
+            (True, False, True),
+            (True, True, False),
+            (False, True, False),
+            (False, False, False),
+        ],
+    )
+    def test_is_valid(self, content, is_expired, is_valid, valid_token, expired_token, tmp_path):
+        """
+        Test that a token is valid for a given combination of content and expiration state.
+        """
+        token_content = ""
+        if content:
+            token_content = expired_token.content if is_expired else valid_token.content
+        token = Token(
+            cache_directory=tmp_path,
+            label=TokenType.ACCESS,
+            content=token_content,
+        )
+
+        assert token.is_valid() == is_valid
+
+    def test_bearer_token(self, tmp_path, jwt_token):
+        """
+        Test that the bearer_token property works as expected.
+        """
+        token_content = jwt_token()
+
+        token = Token(
+            content=token_content,
+            cache_directory=tmp_path,
+            label=TokenType.ACCESS,
+        )
+
+        assert token.bearer_token == f"Bearer {token_content}"
diff --git a/jobbergate-core/tests/test_version.py b/jobbergate-core/tests/test_version.py
new file mode 100644
index 00000000..96b59c69
--- /dev/null
+++ b/jobbergate-core/tests/test_version.py
@@ -0,0 +1,20 @@
+"""
+Test the module version.
+"""
+import toml
+
+from jobbergate_core import __version__
+
+
+def test_version_matches_poetry():
+    """
+    Test that the version matches the version in pyproject.toml.
+
+    It is necessary to replace alpha and beta by a and b when running
+    prereleases on poetry 1.1.*, this can be removed for poetry 1.2.*,
+    since the version scheme is already normalized.
+    """
+    poetry_version = toml.load("pyproject.toml")["tool"]["poetry"]["version"]
+    poetry_version = poetry_version.replace("-alpha.", "a").replace("-beta.", "b")
+
+    assert __version__ == poetry_version