Repository: siisurit/check_done (language: python)

Issue:
Clean up implementation
## Goals

 - [x] All `TODO#13` comments are addressed.
 - [x] Add to readme explanation of configuration/env variables.
 - [x] `authentication.py` is cleaned up.
 - [x] `check_done_demo` is set up for testing all the checks.
 - [x]  Any missing test coverage is  reasonably covered.
 - [x] The user can choose which issue status/board column to check.

Clean up implementation
## Goals

 - [x] All `TODO#13` comments are addressed.
 - [x] Add to readme explanation of configuration/env variables.
 - [x] `authentication.py` is cleaned up.
 - [x] `check_done_demo` is set up for testing all the checks.
 - [x]  Any missing test coverage is  reasonably covered.
 - [x] The user can choose which issue status/board column to check.

Clean up implementation
## Goals

 - [x] All `TODO#13` comments are addressed.
 - [x] Add to readme explanation of configuration/env variables.
 - [x] `authentication.py` is cleaned up.
 - [x] `check_done_demo` is set up for testing all the checks.
 - [x]  Any missing test coverage is  reasonably covered.
 - [x] The user can choose which issue status/board column to check.

Clean up implementation
## Goals

 - [x] All `TODO#13` comments are addressed.
 - [x] Add to readme explanation of configuration/env variables.
 - [x] `authentication.py` is cleaned up.
 - [x] `check_done_demo` is set up for testing all the checks.
 - [x]  Any missing test coverage is  reasonably covered.
 - [x] The user can choose which issue status/board column to check.

Clean up implementation
## Goals

 - [x] All `TODO#13` comments are addressed.
 - [x] Add to readme explanation of configuration/env variables.
 - [x] `authentication.py` is cleaned up.
 - [x] `check_done_demo` is set up for testing all the checks.
 - [x]  Any missing test coverage is  reasonably covered.
 - [x] The user can choose which issue status/board column to check.

Clean up implementation
## Goals

 - [x] All `TODO#13` comments are addressed.
 - [x] Add to readme explanation of configuration/env variables.
 - [x] `authentication.py` is cleaned up.
 - [x] `check_done_demo` is set up for testing all the checks.
 - [x]  Any missing test coverage is  reasonably covered.
 - [x] The user can choose which issue status/board column to check.

Clean up implementation
## Goals

 - [x] All `TODO#13` comments are addressed.
 - [x] Add to readme explanation of configuration/env variables.
 - [x] `authentication.py` is cleaned up.
 - [x] `check_done_demo` is set up for testing all the checks.
 - [x]  Any missing test coverage is  reasonably covered.
 - [x] The user can choose which issue status/board column to check.

Clean up implementation
## Goals

 - [x] All `TODO#13` comments are addressed.
 - [x] Add to readme explanation of configuration/env variables.
 - [x] `authentication.py` is cleaned up.
 - [x] `check_done_demo` is set up for testing all the checks.
 - [x]  Any missing test coverage is  reasonably covered.
 - [x] The user can choose which issue status/board column to check.

Clean up implementation
## Goals

 - [x] All `TODO#13` comments are addressed.
 - [x] Add to readme explanation of configuration/env variables.
 - [x] `authentication.py` is cleaned up.
 - [x] `check_done_demo` is set up for testing all the checks.
 - [x]  Any missing test coverage is  reasonably covered.
 - [x] The user can choose which issue status/board column to check.

Clean up implementation
## Goals

 - [x] All `TODO#13` comments are addressed.
 - [x] Add to readme explanation of configuration/env variables.
 - [x] `authentication.py` is cleaned up.
 - [x] `check_done_demo` is set up for testing all the checks.
 - [x]  Any missing test coverage is  reasonably covered.
 - [x] The user can choose which issue status/board column to check.

Clean up implementation
## Goals

 - [x] All `TODO#13` comments are addressed.
 - [x] Add to readme explanation of configuration/env variables.
 - [x] `authentication.py` is cleaned up.
 - [x] `check_done_demo` is set up for testing all the checks.
 - [x]  Any missing test coverage is  reasonably covered.
 - [x] The user can choose which issue status/board column to check.

Clean up implementation
## Goals

 - [x] All `TODO#13` comments are addressed.
 - [x] Add to readme explanation of configuration/env variables.
 - [x] `authentication.py` is cleaned up.
 - [x] `check_done_demo` is set up for testing all the checks.
 - [x]  Any missing test coverage is  reasonably covered.
 - [x] The user can choose which issue status/board column to check.



Discussion / hints:

Fix patch:
diff --git a/data/.check_done.yaml b/.check_done.yaml
similarity index 55%
rename from data/.check_done.yaml
rename to .check_done.yaml
index dca473f..a312cf7 100644
--- a/data/.check_done.yaml
+++ b/.check_done.yaml
@@ -1,3 +1,5 @@
 project_url: ${CHECK_DONE_GITHUB_PROJECT_URL}
+project_status_name_to_check: ${CHECK_DONE_GITHUB_PROJECT_STATUS_NAME_TO_CHECK}
+personal_access_token: ${CHECK_DONE_PERSONAL_ACCESS_TOKEN}
 check_done_github_app_id: ${CHECK_DONE_GITHUB_APP_ID}
 check_done_github_app_private_key: ${CHECK_DONE_GITHUB_APP_PRIVATE_KEY}
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index e82b0ab..383aa54 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -42,13 +42,16 @@ jobs:
           CHECK_DONE_GITHUB_APP_ID: ${{ secrets.CHECK_DONE_GITHUB_APP_ID }}
           CHECK_DONE_GITHUB_APP_PRIVATE_KEY: |
             ${{ secrets.CHECK_DONE_GITHUB_APP_PRIVATE_KEY }}
+          CHECK_DONE_GITHUB_PROJECT_STATUS_NAME_TO_CHECK: ${{ secrets.CHECK_DONE_GITHUB_PROJECT_STATUS_NAME_TO_CHECK }}
+          CHECK_DONE_PERSONAL_ACCESS_TOKEN: ${{ secrets.CHECK_DONE_PERSONAL_ACCESS_TOKEN }}
+          CHECK_DONE_USER_GITHUB_PROJECT_URL: ${{ secrets.CHECK_DONE_USER_GITHUB_PROJECT_URL }}
         run: |
-          poetry run pytest --cov=check-done --cov-branch
+          poetry run pytest --cov=check_done --cov-branch
 
   check-style:
     runs-on: ubuntu-latest
     # Disable pre-commit check on main and production to prevent
-    # pull request merges to fail with don't commit to branch".
+    # pull request merges to fail with don't commit to branch.
     if: github.ref != 'refs/heads/main'
     steps:
       - uses: actions/checkout@v3
diff --git a/.gitignore b/.gitignore
index 48c3c4e..a118541 100644
--- a/.gitignore
+++ b/.gitignore
@@ -287,4 +287,6 @@ poetry.toml
 # LSP config files
 pyrightconfig.json
 
+/.idea/ruff.xml
+
 # End of https://www.toptal.com/developers/gitignore/api/pycharm,python
diff --git a/.license-in-code b/.license-in-code
new file mode 100644
index 0000000..bf4b77b
--- /dev/null
+++ b/.license-in-code
@@ -0,0 +1,2 @@
+Copyright (C) 2024 by Siisurit e.U., Austria.
+All rights reserved. Distributed under the MIT License.
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7c80421..3f55f5c 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -2,7 +2,7 @@ exclude: "^\\.idea"
 
 repos:
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.6.8
+    rev: v0.7.4
     hooks:
       - id: ruff-format
       - id: ruff
@@ -14,7 +14,7 @@ repos:
       - id: prettier
 
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.6.0
+    rev: v5.0.0
     hooks:
       - id: fix-byte-order-marker
       - id: trailing-whitespace
@@ -31,11 +31,24 @@ repos:
       - id: end-of-file-fixer
       - id: mixed-line-ending
 
+  - repo: https://github.com/Lucas-C/pre-commit-hooks
+    rev: v1.5.5
+    hooks:
+      - id: insert-license
+        files: \.py$
+        args:
+          - --license-filepath=.license-in-code
+          - --comment-style=#
+          - --detect-license-in-X-top-lines=2
+          - --no-extra-eol
+          - --allow-past-years # Using these arguments allows for a date range
+          - --use-current-year # (original_date - current_date) to be generated.
+
   # NOTE: This should be the last check to ensure everything else is checked
   #  even for the rare case a commit should go into one of the protected
   #  branches.
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.6.0
+    rev: v5.0.0
     hooks:
       - id: no-commit-to-branch
         args: ["--branch", "main"]
diff --git a/README.md b/README.md
index 71e78f0..3d36bce 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,78 @@
 # check_done
 
-Check done issues on a GitHub project board.
+check_done is a command line tool to check that finished issues and pull requests in a GitHub project board column are really done.
+
+## Configuration
+
+To use check_done for your project, you need to configure the fields in the `yaml` file found in the `configuration` folder.
+
+### General settings
+
+#### project_url (required)
+
+The project URL as is when viewing your GitHub V2 project. E.g.: `"https://github.com/orgs/my_project_owner_name/projects/1/views/1"`
+
+#### project_status_name_to_check (optional)
+
+By default, check_done checks all issues and pull requests in the column rightmost / last column of the project board. If you left the default names when creating the GitHub project board, this will be the `"✅ Done"` column.
+
+If you want to check a different column, you can specify its name with this option. For example:
+
+```yaml
+project_status_name_to_check = "Done"
+```
+
+The name takes the first column that partially matches case sensitively. For example, `"Done"` will also match `"✅ Done"`, but not `"done"`.
+
+If no column matches, the resulting error messages will tell you the exact names of all available columns.
+
+### Authorization settings for a project belonging to a GitHub user
+
+#### personal_access_token
+
+A personal access token with the permission: `read:project`.
+
+Example:
+
+```yaml
+project_url: "https://github.com/orgs/my_username/projects/1/views/1"
+personal_access_token: "ghp_xxxxxxxxxxxxxxxxxxxxxx"
+# Since no `project_status_name_to_check` was specified, the checks will apply to the last project status/column.
+```
+
+### Authorization settings for a project belonging to a GitHub organization
+
+Follow the instructions to [Installing a GitHub App from a third party](https://docs.github.com/en/apps/using-github-apps/installing-a-github-app-from-a-third-party) using the [Siisurit's check_done app](https://github.com/apps/siisurit-s-check-done).
+
+You can also derive your own GitHub app from it with the following permissions:
+
+- `pull requests`
+- `projects`
+- `read:issues`
+
+Remember the App ID and the app private key. Then add the following settings to the configuration file:
+
+```yaml
+check_done_github_app_id = ...  # Typically a decimal number with at least 6 digits
+check_done_github_app_private_key = ""-----BEGIN RSA PRIVATE KEY..."
+```
+
+Example:
+
+```yaml
+project_url: "https://github.com/orgs/my_username/projects/1/views/1"
+check_done_github_app_id: "0123456"
+check_done_github_app_private_key: "-----BEGIN RSA PRIVATE KEY-----
+something_something
+-----END RSA PRIVATE KEY-----
+"
+project_status_name_to_check: "Done" # This will match the name of a project status/column containing "Done" like "✅ Done". The checks will then be applied to this project status/column.
+```
+
+### Using environment variables and examples
+
+You can use environment variables for the values in the configuration yaml by starting them with a `$` symbol and wrapping it with curly braces. For example:
+
+```yaml
+personal_access_token: ${MY_PERSONAL_ACCESS_TOKEN_ENVVAR}
+```
diff --git a/check_done/__init__.py b/check_done/__init__.py
index e69de29..03619e8 100644
--- a/check_done/__init__.py
+++ b/check_done/__init__.py
@@ -0,0 +1,5 @@
+# Copyright (C) 2024 by Siisurit e.U., Austria.
+# All rights reserved. Distributed under the MIT License.
+from importlib.metadata import version
+
+__version__ = version(__name__)
diff --git a/check_done/authentication.py b/check_done/authentication.py
deleted file mode 100644
index 3d8d897..0000000
--- a/check_done/authentication.py
+++ /dev/null
@@ -1,76 +0,0 @@
-import time
-
-import jwt
-import requests
-
-from check_done.common import (
-    AuthenticationError,
-    HttpBearerAuth,
-    config_info,
-)
-
-_CHECK_DONE_GITHUB_APP_ID = config_info().check_done_github_app_id
-_CHECK_DONE_GITHUB_APP_PRIVATE_KEY = config_info().check_done_github_app_private_key
-_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
-_SECONDS_PER_MINUTE = 60
-_ISSUED_AT = int(time.time())
-_EXPIRES_AT = int(time.time()) + (10 * _SECONDS_PER_MINUTE)
-
-
-def github_app_access_token(organization: str) -> str:
-    authentication = _Authentication(organization)
-    return authentication.access_token
-
-
-# TODO#13: Refactor code into functions instead of a class.
-
-
-class _Authentication:
-    def __init__(self, organization: str):
-        self.organization = organization
-        try:
-            self._jwt_token = self._generated_jwt_token()
-            self.session = requests.Session()
-            self.session.auth = HttpBearerAuth(self._jwt_token)
-            self.session.headers = {"Accept": "application/vnd.github+json"}
-            self._installation_id = self._get_installation_id()
-            self._access_token = self._get_installation_access_token()
-        except Exception as error:
-            raise AuthenticationError(f"Cannot authenticate with organization: {organization}") from error
-        finally:
-            self.session.close()
-
-    @property
-    def access_token(self):
-        return self._access_token
-
-    @staticmethod
-    def _generated_jwt_token() -> str:
-        """Generates a JWT token for authentication with GitHub."""
-        try:
-            payload = {
-                "exp": _EXPIRES_AT,
-                "iat": _ISSUED_AT,
-                "iss": _CHECK_DONE_GITHUB_APP_ID,
-            }
-            return jwt.encode(payload, _CHECK_DONE_GITHUB_APP_PRIVATE_KEY, algorithm="RS256")
-        except Exception as error:
-            raise AuthenticationError(f"Cannot generate JWT token: {error}") from error
-
-    def _get_installation_id(self) -> str:
-        """Fetches the installation ID for the organization."""
-        headers = self.session.headers
-        response = self.session.get(f"https://api.github.com/orgs/{self.organization}/installation", headers=headers)
-        if response.status_code == 200:
-            return response.json().get("id")
-        raise AuthenticationError(f"Could not retrieve installation ID: {response.status_code} - {response.text}")
-
-    def _get_installation_access_token(self) -> str:
-        """Retrieves the access token using the installation ID."""
-        headers = self.session.headers
-        response = self.session.post(
-            f"https://api.github.com/app/installations/{self._installation_id}/access_tokens", headers=headers
-        )
-        if response.status_code == 201:
-            return response.json().get("token")
-        raise AuthenticationError(f"Could not retrieve access token: {response.status_code} - {response.text}")
diff --git a/check_done/checks.py b/check_done/checks.py
deleted file mode 100644
index 627989c..0000000
--- a/check_done/checks.py
+++ /dev/null
@@ -1,77 +0,0 @@
-from html.parser import HTMLParser
-
-from check_done.done_project_items_info.done_project_items_info import done_project_items_info
-from check_done.done_project_items_info.info import ProjectItemInfo
-
-
-def check_done_project_items_for_warnings() -> list[str | None]:
-    result = []
-    done_project_items = done_project_items_info()
-    for project_item in done_project_items:
-        warning_reasons = [
-            warning_reason for check, warning_reason in CONDITION_CHECK_AND_WARNING_REASON_LIST if check(project_item)
-        ]
-        if len(warning_reasons) >= 1:
-            warning = _project_item_warning_string(project_item, warning_reasons)
-            result.append(warning)
-    return result
-
-
-def _project_item_warning_string(issue: ProjectItemInfo, reasons_for_warning: list[str]) -> str:
-    if len(reasons_for_warning) >= 3:
-        reasons_for_warning = f"{', '.join(reasons_for_warning[:-1])}, and {reasons_for_warning[-1]}"
-    elif len(reasons_for_warning) == 2:
-        reasons_for_warning = f"{', '.join(reasons_for_warning[:1])} and is {reasons_for_warning[1]}"
-    else:
-        reasons_for_warning = reasons_for_warning[0]
-    return (
-        f" Done project item is {reasons_for_warning}."
-        f" - repository: '{issue.repository.name}', project item: '#{issue.number} {issue.title}'."
-    )
-
-
-def _is_not_closed(project_item: ProjectItemInfo) -> bool:
-    return not project_item.closed
-
-
-def _is_not_assigned(project_item: ProjectItemInfo) -> bool:
-    return project_item.assignees.total_count == 0
-
-
-def _has_no_milestone(project_item: ProjectItemInfo) -> bool:
-    return project_item.milestone is None
-
-
-def has_unfinished_goals(project_item: ProjectItemInfo) -> bool:
-    class _GoalsHTMLParser(HTMLParser):
-        def __init__(self):
-            super().__init__()
-            self.is_any_goal_unchecked = False
-
-        def handle_starttag(self, tag, attrs):
-            if tag == "input":
-                attr_dict = dict(attrs)
-                is_checkbox = attr_dict.get("type") == "checkbox"
-                is_unchecked = "checked" not in attr_dict
-                if is_checkbox and is_unchecked:
-                    self.is_any_goal_unchecked = True
-
-        def has_unfinished_goals(self):
-            return self.is_any_goal_unchecked
-
-    parser = _GoalsHTMLParser()
-    parser.feed(project_item.body_html)
-    return parser.has_unfinished_goals()
-
-
-def _is_missing_linked_project_item(project_item: ProjectItemInfo) -> bool:
-    return len(project_item.linked_project_item.nodes) == 0
-
-
-CONDITION_CHECK_AND_WARNING_REASON_LIST = [
-    (_is_not_closed, "not closed"),
-    (_is_not_assigned, "missing assignee"),
-    (_has_no_milestone, "missing milestone"),
-    (has_unfinished_goals, "missing finished goals"),
-    (_is_missing_linked_project_item, "missing linked project item"),
-]
diff --git a/check_done/command.py b/check_done/command.py
index f4e3800..d75458b 100644
--- a/check_done/command.py
+++ b/check_done/command.py
@@ -1,25 +1,80 @@
+# Copyright (C) 2024 by Siisurit e.U., Austria.
+# All rights reserved. Distributed under the MIT License.
+import argparse
 import logging
 import sys
+from pathlib import Path
 
-from check_done.checks import check_done_project_items_for_warnings
+import check_done
+from check_done.config import (
+    map_from_yaml_file_path,
+    resolve_configuration_yaml_file_path_from_root_path,
+    resolve_root_repository_path,
+    validate_configuration_info_from_yaml_map,
+)
+from check_done.done_project_items_info import done_project_items_info
+from check_done.warning_checks import warnings_for_done_project_items
 
 logger = logging.getLogger(__name__)
 
+_HELP_DESCRIPTION = "Checks that finished issues and pull requests in a GitHub project board column are really done."
 
-def check_done_command():
+
+class Command:
+    """Command interface for check_done"""
+
+    def __init__(self):
+        self.args = None
+
+    @staticmethod
+    def argument_parser():
+        parser = argparse.ArgumentParser(prog="check_done", description=_HELP_DESCRIPTION)
+        parser.add_argument("--version", "-v", action="version", version="%(prog)s " + check_done.__version__)
+        # TODO#13: Is this implementation of the --root-dir argument proper? If not how to improve it?
+        parser.add_argument(
+            "--root-dir",
+            "-r",
+            type=Path,
+            default=resolve_root_repository_path(),
+            help="Specify a different root directory to check for the YAML config file.",
+        )
+        return parser
+
+    def apply_arguments(self, arguments=None):
+        parser = self.argument_parser()
+        self.args = parser.parse_args(arguments)
+
+    def execute(self):
+        configuration_yaml_path = resolve_configuration_yaml_file_path_from_root_path(self.args.root_dir)
+        yaml_map = map_from_yaml_file_path(configuration_yaml_path)
+        configuration_info = validate_configuration_info_from_yaml_map(yaml_map)
+        done_project_items = done_project_items_info(configuration_info)
+        done_project_items_count = len(done_project_items)
+        if done_project_items_count == 0:
+            logger.info("Nothing to check. Project has no items in the selected project status.")
+        else:
+            warnings = warnings_for_done_project_items(done_project_items)
+            if len(warnings) == 0:
+                logger.info(
+                    f"All project items are correct, "
+                    f"{done_project_items_count!s} checked in the selected project status. "
+                )
+            else:
+                for warning in warnings:
+                    logger.warning(warning)
+
+
+def check_done_command(arguments=None):
     result = 1
+    command = Command()
     try:
-        warnings = check_done_project_items_for_warnings()
-        if len(warnings) == 0:
-            logger.info("No warnings found.")
-            result = 0
-        else:
-            for warning in warnings:
-                logger.warning(warning)
+        command.apply_arguments(arguments)
+        command.execute()
+        result = 0
     except KeyboardInterrupt:
-        logger.exception("Interrupted as requested by user.")
+        logger.error("Interrupted as requested by user.")  # noqa: TRY400
     except Exception:
-        logger.exception("Cannot check done issues.")
+        logger.exception("Cannot check done project items.")
     return result
 
 
diff --git a/check_done/common.py b/check_done/common.py
deleted file mode 100644
index 41ba832..0000000
--- a/check_done/common.py
+++ /dev/null
@@ -1,108 +0,0 @@
-import os
-import re
-import string
-from enum import StrEnum
-from pathlib import Path
-from typing import Any
-
-import yaml
-from dotenv import load_dotenv
-from pydantic import BaseModel, field_validator
-from requests.auth import AuthBase
-
-load_dotenv()
-_ENVVAR_CHECK_DONE_GITHUB_PERSONAL_ACCESS_TOKEN = "CHECK_DONE_GITHUB_PERSONAL_ACCESS_TOKEN"
-CHECK_DONE_GITHUB_PERSONAL_ACCESS_TOKEN = os.environ.get(_ENVVAR_CHECK_DONE_GITHUB_PERSONAL_ACCESS_TOKEN)
-
-_CONFIG_PATH = Path(__file__).parent.parent / "data" / ".check_done.yaml"
-_GITHUB_ORGANIZATION_NAME_AND_PROJECT_NUMBER_URL_REGEX = re.compile(
-    r"https://github\.com/orgs/(?P<organization_name>[a-zA-Z0-9\-]+)/projects/(?P<project_number>[0-9]+).*"
-)
-_GITHUB_USER_NAME_AND_PROJECT_NUMBER_URL_REGEX = re.compile(
-    r"https://github\.com/users/(?P<user_name>[a-zA-Z0-9\-]+)/projects/(?P<project_number>[0-9]+).*"
-)
-
-
-class _ConfigInfo(BaseModel):
-    project_url: str
-    check_done_github_app_id: str
-    check_done_github_app_private_key: str
-
-    @field_validator("project_url", "check_done_github_app_id", "check_done_github_app_private_key", mode="before")
-    def value_from_env(cls, value: Any | None):
-        if isinstance(value, str):
-            stripped_value = value.strip()
-            result = (
-                resolved_environment_variables(value)
-                if stripped_value.startswith("${") and stripped_value.endswith("}")
-                else stripped_value
-            )
-        else:
-            result = value
-        return result
-
-
-def config_info() -> _ConfigInfo:
-    config_map = config_map_from_yaml_file(_CONFIG_PATH)
-    return _ConfigInfo(**config_map)
-
-
-class ProjectOwnerType(StrEnum):
-    User = "users"
-    Organization = "orgs"
-
-
-def github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(
-    url: str,
-) -> tuple[ProjectOwnerType, str, int]:
-    organization_name_and_project_number_match = _GITHUB_ORGANIZATION_NAME_AND_PROJECT_NUMBER_URL_REGEX.match(url)
-    if organization_name_and_project_number_match is None:
-        user_name_and_project_number_match = _GITHUB_USER_NAME_AND_PROJECT_NUMBER_URL_REGEX.match(url)
-        if organization_name_and_project_number_match is None and user_name_and_project_number_match is None:
-            raise ValueError(f"Cannot parse GitHub organization or user name, and project number from URL: {url}.")
-        project_owner_type = ProjectOwnerType.User
-        project_owner_name = user_name_and_project_number_match.group("user_name")
-        project_number = int(user_name_and_project_number_match.group("project_number"))
-    else:
-        project_owner_type = ProjectOwnerType.Organization
-        project_owner_name = organization_name_and_project_number_match.group("organization_name")
-        project_number = int(organization_name_and_project_number_match.group("project_number"))
-    return project_owner_type, project_owner_name, project_number
-
-
-def resolved_environment_variables(value: str, fail_on_missing_envvar=True) -> str:
-    try:
-        result = string.Template(value).substitute(os.environ)
-    except KeyError as error:
-        if fail_on_missing_envvar:
-            raise ValueError(f"Cannot resolve {value}: environment variable must be set: {error!s}") from error
-        result = ""
-    except ValueError as error:
-        raise ValueError(f"Cannot resolve {value!r}: {error}.") from error
-    return result
-
-
-def config_map_from_yaml_file(config_path: Path) -> dict:
-    try:
-        with open(config_path) as config_file:
-            result = yaml.safe_load(config_file)
-            if result is None:
-                raise ValueError(f"The check_done configuration is empty. Path: {config_path}")
-    except FileNotFoundError as error:
-        raise FileNotFoundError(f"Cannot find check_done configuration: {config_path}") from error
-    return result
-
-
-class AuthenticationError(Exception):
-    """Error raised due to failed JWT authentication process."""
-
-
-class HttpBearerAuth(AuthBase):
-    # Source:
-    # <https://stackoverflow.com/questions/29931671/making-an-api-call-in-python-with-an-api-that-requires-a-bearer-token>
-    def __init__(self, token):
-        self.token = token
-
-    def __call__(self, request):
-        request.headers["Authorization"] = "Bearer " + self.token
-        return request
diff --git a/check_done/config.py b/check_done/config.py
new file mode 100644
index 0000000..e9db487
--- /dev/null
+++ b/check_done/config.py
@@ -0,0 +1,146 @@
+# Copyright (C) 2024 by Siisurit e.U., Austria.
+# All rights reserved. Distributed under the MIT License.
+import os
+import re
+import string
+from enum import StrEnum
+from pathlib import Path
+from typing import Any
+
+import yaml
+from dotenv import load_dotenv
+from git import Repo
+from pydantic import Field, field_validator, model_validator
+from pydantic.dataclasses import dataclass
+
+load_dotenv()
+
+CONFIG_FILE_NAME = ".check_done.yaml"
+_GITHUB_ORGANIZATION_NAME_AND_PROJECT_NUMBER_URL_REGEX = re.compile(
+    r"https://github\.com/orgs/(?P<organization_name>[a-zA-Z0-9\-]+)/projects/(?P<project_number>[0-9]+).*"
+)
+_GITHUB_USER_NAME_AND_PROJECT_NUMBER_URL_REGEX = re.compile(
+    r"https://github\.com/users/(?P<user_name>[a-zA-Z0-9\-]+)/projects/(?P<project_number>[0-9]+).*"
+)
+
+
+class ProjectOwnerType(StrEnum):
+    User = "users"
+    Organization = "orgs"
+
+
+@dataclass
+class ConfigurationInfo:
+    project_url: str
+
+    # Required for user owned project
+    personal_access_token: str | None = None
+
+    # Required for organization owned project
+    check_done_github_app_id: str | None = None
+    check_done_github_app_private_key: str | None = None
+
+    # Optional
+    project_status_name_to_check: str | None = None
+
+    # Fields computed during initialization
+    is_project_owner_of_type_organization: bool = Field(init=False)
+    project_number: int = Field(init=False)
+    project_owner_name: str = Field(init=False)
+
+    @field_validator(
+        "project_url",
+        "project_status_name_to_check",
+        "personal_access_token",
+        "check_done_github_app_id",
+        "check_done_github_app_private_key",
+        mode="before",
+    )
+    def value_from_env(cls, value: Any | None):
+        stripped_value = value.strip()
+        result = (
+            resolved_environment_variables(value, fail_on_missing_envvar=False)
+            if stripped_value.startswith("${") and stripped_value.endswith("}")
+            else stripped_value
+        )
+        return result
+
+    @model_validator(mode="after")
+    def validate_authentication_and_set_project_details(self):
+        self.project_owner_name, self.project_number, self.is_project_owner_of_type_organization = (
+            github_project_owner_name_and_project_number_and_is_project_owner_of_type_organization_from_url_if_matches(
+                self.project_url
+            )
+        )
+
+        has_user_authentication = (
+            self.personal_access_token is not None and not self.is_project_owner_of_type_organization
+        )
+        has_organizational_authentication = (
+            self.check_done_github_app_id is not None
+            and self.check_done_github_app_private_key is not None
+            and self.is_project_owner_of_type_organization
+        )
+        if not has_user_authentication ^ has_organizational_authentication:
+            raise ValueError("A user or an organization authentication method must be configured.")
+        return self
+
+
+def validate_configuration_info_from_yaml_map(yaml_map: dict) -> ConfigurationInfo:
+    return ConfigurationInfo(**yaml_map)
+
+
+def github_project_owner_name_and_project_number_and_is_project_owner_of_type_organization_from_url_if_matches(
+    url: str,
+) -> tuple[str, int, bool]:
+    organization_name_and_project_number_match = _GITHUB_ORGANIZATION_NAME_AND_PROJECT_NUMBER_URL_REGEX.match(url)
+    if organization_name_and_project_number_match is None:
+        user_name_and_project_number_match = _GITHUB_USER_NAME_AND_PROJECT_NUMBER_URL_REGEX.match(url)
+        if organization_name_and_project_number_match is None and user_name_and_project_number_match is None:
+            raise ValueError(f"Cannot parse GitHub organization or user name, and project number from URL: {url}.")
+        project_owner_name = user_name_and_project_number_match.group("user_name")
+        project_number = int(user_name_and_project_number_match.group("project_number"))
+        is_project_owner_of_type_organization = False
+    else:
+        project_owner_name = organization_name_and_project_number_match.group("organization_name")
+        project_number = int(organization_name_and_project_number_match.group("project_number"))
+        is_project_owner_of_type_organization = True
+    return project_owner_name, project_number, is_project_owner_of_type_organization
+
+
+def resolved_environment_variables(value: str, fail_on_missing_envvar=True) -> str:
+    try:
+        result = string.Template(value).substitute(os.environ)
+    except KeyError as error:
+        if fail_on_missing_envvar:
+            raise ValueError(f"Cannot resolve {value}: environment variable must be set: {error!s}") from error
+        result = None
+    except ValueError as error:
+        raise ValueError(f"Cannot resolve {value!r}: {error}.") from error
+    return result
+
+
+def map_from_yaml_file_path(configuration_path: Path) -> dict:
+    try:
+        with open(configuration_path) as configuration_file:
+            result = yaml.safe_load(configuration_file)
+            if result is None:
+                raise ValueError(
+                    f"The check_done configuration yaml file is empty. Path: {configuration_path}"
+                ) from None
+    except FileNotFoundError:
+        raise FileNotFoundError(f"Cannot find check_done configuration yaml file: {configuration_path}") from None
+    return result
+
+
+def resolve_root_repository_path(path: Path | str = ".", search_parent_directories=True) -> Path:
+    root_repository = Repo(path, search_parent_directories=search_parent_directories)
+    result = Path(root_repository.git.rev_parse("--show-toplevel"))
+    return result
+
+
+def resolve_configuration_yaml_file_path_from_root_path(root_path: Path) -> Path:
+    result = Path(root_path / CONFIG_FILE_NAME)
+    if not Path.is_file(result):
+        raise FileNotFoundError(f"Configuration file missing in the specified root path: {root_path}") from None
+    return result
diff --git a/check_done/done_project_items_info.py b/check_done/done_project_items_info.py
new file mode 100644
index 0000000..24fde58
--- /dev/null
+++ b/check_done/done_project_items_info.py
@@ -0,0 +1,130 @@
+# Copyright (C) 2024 by Siisurit e.U., Austria.
+# All rights reserved. Distributed under the MIT License.
+import logging
+
+import requests
+
+from check_done.config import ConfigurationInfo
+from check_done.graphql import GraphQlQuery, HttpBearerAuth, query_infos
+from check_done.info import (
+    NodeByIdInfo,
+    ProjectItemInfo,
+    ProjectOwnerInfo,
+    ProjectV2ItemNode,
+    ProjectV2Node,
+    ProjectV2SingleSelectFieldNode,
+)
+from check_done.organization_authentication import resolve_organization_access_token
+
+_GITHUB_PROJECT_STATUS_FIELD_NAME = "Status"
+logger = logging.getLogger(__name__)
+
+
+def done_project_items_info(configuration_info: ConfigurationInfo) -> list[ProjectItemInfo]:
+    project_owner_name = configuration_info.project_owner_name
+
+    is_project_owner_of_type_organization = configuration_info.is_project_owner_of_type_organization
+    if is_project_owner_of_type_organization:
+        check_done_github_app_id = configuration_info.check_done_github_app_id
+        check_done_github_app_private_key = configuration_info.check_done_github_app_private_key
+        access_token = resolve_organization_access_token(
+            project_owner_name, check_done_github_app_id, check_done_github_app_private_key
+        )
+    else:
+        access_token = configuration_info.personal_access_token
+
+    with requests.Session() as session:
+        session.headers = {"Accept": "application/vnd.github+json"}
+        session.auth = HttpBearerAuth(access_token)
+
+        project_query_name = (
+            GraphQlQuery.ORGANIZATION_PROJECTS.name
+            if is_project_owner_of_type_organization
+            else GraphQlQuery.USER_PROJECTS.name
+        )
+        project_infos = query_infos(ProjectOwnerInfo, project_query_name, session, project_owner_name)
+
+        project_number = configuration_info.project_number
+        project_id = matching_project_id(project_infos, project_number, project_owner_name)
+        project_single_select_field_infos = query_infos(
+            NodeByIdInfo, GraphQlQuery.PROJECT_SINGLE_SELECT_FIELDS.name, session, project_owner_name, project_id
+        )
+
+        project_item_infos = query_infos(
+            NodeByIdInfo, GraphQlQuery.PROJECT_V2_ITEMS.name, session, project_owner_name, project_id
+        )
+
+    project_status_name_to_check = configuration_info.project_status_name_to_check
+    project_status_option_id = matching_project_state_option_id(
+        project_single_select_field_infos,
+        project_status_name_to_check,
+        project_number,
+        project_owner_name,
+    )
+    result = filtered_project_item_infos_by_done_status(project_item_infos, project_status_option_id)
+    return result
+
+
+def matching_project_id(project_infos: list[ProjectV2Node], project_number: int, project_owner_name: str) -> str:
+    try:
+        return next(str(project_info.id) for project_info in project_infos if project_info.number == project_number)
+    except StopIteration:
+        raise ValueError(
+            f"Cannot find a project with number '{project_number}', owned by '{project_owner_name}'."
+        ) from None
+
+
+def matching_project_state_option_id(
+    project_single_select_field_infos: list[ProjectV2SingleSelectFieldNode],
+    project_status_name_to_check: str | None,
+    project_number: int,
+    project_owner_name: str,
+) -> str:
+    try:
+        status_options = next(
+            field_info.options
+            for field_info in project_single_select_field_infos
+            if field_info.name == _GITHUB_PROJECT_STATUS_FIELD_NAME
+        )
+    except StopIteration:
+        raise ValueError(
+            f"Cannot find a project status selection field in the GitHub project with number `{project_number}` "
+            f"owned by `{project_owner_name}`."
+        ) from None
+    if project_status_name_to_check:
+        logger.info(f"Checking project items with status matching: '{project_status_name_to_check}'")
+        try:
+            result = next(
+                status_option.id
+                for status_option in status_options
+                if project_status_name_to_check in status_option.name
+            )
+        except StopIteration:
+            project_status_options_names = [
+                option.name for field_info in project_single_select_field_infos for option in field_info.options
+            ]
+            raise ValueError(
+                f"Cannot find the project status matching name {project_status_name_to_check!r} "
+                f"in the GitHub project with number '{project_number}' owned by {project_owner_name!r}. "
+                f"Available options are: {project_status_options_names!r}"
+            ) from None
+    else:
+        last_status_option = status_options[-1]
+        logger.info(f"Checking project items with the last project status selected: {last_status_option.name!r}.")
+        result = last_status_option.id
+    return result
+
+
+def filtered_project_item_infos_by_done_status(
+    project_item_infos: list[ProjectV2ItemNode],
+    project_state_option_id: str,
+) -> list[ProjectItemInfo]:
+    result = []
+    for project_item_info in project_item_infos:
+        has_project_state_option = (
+            project_item_info.field_value_by_name is not None
+            and project_item_info.field_value_by_name.option_id == project_state_option_id
+        )
+        if has_project_state_option:
+            result.append(project_item_info.content)
+    return result
diff --git a/check_done/done_project_items_info/done_project_items_info.py b/check_done/done_project_items_info/done_project_items_info.py
deleted file mode 100644
index d6e6408..0000000
--- a/check_done/done_project_items_info/done_project_items_info.py
+++ /dev/null
@@ -1,208 +0,0 @@
-import logging
-import re
-from enum import Enum
-from functools import lru_cache
-from pathlib import Path
-from typing import Any
-
-import requests
-from pydantic import BaseModel
-from requests import HTTPError, Response, Session
-
-from check_done.authentication import github_app_access_token
-from check_done.common import (
-    CHECK_DONE_GITHUB_PERSONAL_ACCESS_TOKEN,
-    HttpBearerAuth,
-    ProjectOwnerType,
-    config_info,
-    github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches,
-)
-from check_done.done_project_items_info.info import (
-    GithubContentType,
-    NodeByIdInfo,
-    PaginatedQueryInfo,
-    ProjectItemInfo,
-    ProjectOwnerInfo,
-    ProjectV2ItemNodeInfo,
-    ProjectV2NodeInfo,
-    ProjectV2SingleSelectFieldNodeInfo,
-)
-
-_GRAPHQL_ENDPOINT = "https://api.github.com/graphql"
-_PROJECT_BOARD_URL = config_info().project_url
-_PROJECT_OWNER_TYPE, _PROJECT_OWNER_NAME, PROJECT_NUMBER = (
-    github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(_PROJECT_BOARD_URL)
-)
-_IS_PROJECT_OWNER_OF_TYPE_ORGANIZATION = ProjectOwnerType.Organization == _PROJECT_OWNER_TYPE
-_ACCESS_TOKEN = (
-    github_app_access_token(_PROJECT_OWNER_NAME)
-    if _IS_PROJECT_OWNER_OF_TYPE_ORGANIZATION
-    else CHECK_DONE_GITHUB_PERSONAL_ACCESS_TOKEN
-)
-_PATH_TO_QUERIES = Path(__file__).parent.parent / "done_project_items_info" / "queries"
-_MAX_ENTRIES_PER_PAGE = 100
-_GITHUB_PROJECT_STATUS_FIELD_NAME = "Status"
-logger = logging.getLogger(__name__)
-
-
-def done_project_items_info() -> list[ProjectItemInfo]:
-    session = requests.Session()
-    session.headers = {"Accept": "application/vnd.github+json"}
-    session.auth = HttpBearerAuth(_ACCESS_TOKEN)
-
-    projects_ids_query_name = (
-        _GraphQlQuery.ORGANIZATION_PROJECTS_IDS.name
-        if _IS_PROJECT_OWNER_OF_TYPE_ORGANIZATION
-        else _GraphQlQuery.USER_PROJECTS_IDS.name
-    )
-    projects_ids_nodes_info = _query_nodes_info(ProjectOwnerInfo, projects_ids_query_name, session)
-    project_id = matching_project_id(projects_ids_nodes_info)
-
-    last_project_state_option_ids_nodes_info = _query_nodes_info(
-        NodeByIdInfo, _GraphQlQuery.PROJECT_STATE_OPTIONS_IDS.name, session, project_id
-    )
-    last_project_state_option_id = matching_last_project_state_option_id(last_project_state_option_ids_nodes_info)
-
-    project_items_nodes_info = _query_nodes_info(
-        NodeByIdInfo, _GraphQlQuery.PROJECT_V_2_ITEMS.name, session, project_id
-    )
-    result = filtered_project_item_infos_by_done_status(project_items_nodes_info, last_project_state_option_id)
-    return result
-
-
-def matching_project_id(node_infos: list[ProjectV2NodeInfo]) -> str:
-    try:
-        return next(str(project_node.id) for project_node in node_infos if project_node.number == PROJECT_NUMBER)
-    except StopIteration as error:
-        raise ValueError(
-            f"Cannot find a project with number '{PROJECT_NUMBER}', owned by '{_PROJECT_OWNER_NAME}'."
-        ) from error
-
-
-def matching_last_project_state_option_id(node_infos: list[ProjectV2SingleSelectFieldNodeInfo]) -> str:
-    try:
-        return next(
-            # TODO#13: Ponder giving the user the option to pick which project state option instead of picking
-            #  the last one.
-            #  Take into consideration that the GraphQL query allows for filtering the status options by name,
-            #  which is interesting when there are emoji's in the name like in the case of Siisurit...
-            #  To help with that, if the script can't find the name the user gave, a list of the options can be
-            #  shown to them.
-            project_state_node.options[-1].id
-            for project_state_node in node_infos
-            if project_state_node.name is not None and project_state_node.name == _GITHUB_PROJECT_STATUS_FIELD_NAME
-        )
-    except StopIteration as error:
-        raise ValueError(
-            f"Cannot find the project status selection field in the GitHub project with number `{PROJECT_NUMBER}` "
-            f"owned by `{_PROJECT_OWNER_NAME}`."
-        ) from error
-
-
-def filtered_project_item_infos_by_done_status(
-    node_infos: list[ProjectV2ItemNodeInfo], last_project_state_option_id: str
-) -> list[ProjectItemInfo]:
-    result = []
-    for node in node_infos:
-        has_last_project_state_option = node.field_value_by_name.option_id == last_project_state_option_id
-        assert node.type == GithubContentType.ISSUE or GithubContentType.PULL_REQUEST
-        if has_last_project_state_option:
-            result.append(node.content)
-    if len(result) < 1:
-        logger.warning(
-            f"No project items found with the last project status option selected in the GitHub project with number "
-            f"`{PROJECT_NUMBER}` owned by '{_PROJECT_OWNER_NAME}'"
-        )
-    return result
-
-
-@lru_cache
-def minimized_graphql(graphql_query: str) -> str:
-    single_spaced_query = re.sub(r"\s+", " ", graphql_query)
-    formatted_query = re.sub(r"\s*([{}()\[\]:,])\s*", r"\1", single_spaced_query)
-    result = formatted_query.strip()
-    return result
-
-
-def _graphql_query(item_name: str) -> str:
-    query_path = _PATH_TO_QUERIES / f"{item_name}.graphql"
-    with query_path.open(encoding="utf-8") as query_file:
-        query_text = query_file.read()
-    return minimized_graphql(query_text)
-
-
-class _GraphQlQuery(Enum):
-    ORGANIZATION_PROJECTS_IDS = _graphql_query("organization_projects_ids")
-    USER_PROJECTS_IDS = _graphql_query("user_projects_ids")
-    PROJECT_STATE_OPTIONS_IDS = _graphql_query("project_state_options_ids")
-    PROJECT_V_2_ITEMS = _graphql_query("project_v2_items")
-
-    @staticmethod
-    def query_for(name: str):
-        assert name is not None
-        assert name.upper() == name
-        return _GraphQlQuery[name].value
-
-
-def _query_nodes_info(
-    base_model: type[BaseModel], query_name: str, session: Session, project_id: str | None = None
-) -> list:
-    result = []
-    variables = {"login": _PROJECT_OWNER_NAME, "maxEntriesPerPage": _MAX_ENTRIES_PER_PAGE}
-    if project_id is not None:
-        variables["projectId"] = project_id
-    after = None
-    has_more_pages = True
-    while has_more_pages:
-        query = _GraphQlQuery.query_for(query_name)
-        if after is not None:
-            variables["after"] = after
-        json_payload_map = {
-            "variables": variables,
-            "query": query,
-        }
-        response = session.post(_GRAPHQL_ENDPOINT, json=json_payload_map)
-        response_map = checked_graphql_data_map(response)
-        response_info = base_model(**response_map)
-        paginated_query_info = get_paginated_query_info_from_response_info(response_info)
-        nodes_info = paginated_query_info.nodes
-        page_info = paginated_query_info.page_info
-        after = page_info.endCursor
-        has_more_pages = page_info.hasNextPage
-        result.extend(nodes_info)
-    return result
-
-
-def get_paginated_query_info_from_response_info(base_model: BaseModel) -> PaginatedQueryInfo:
-    if isinstance(base_model, PaginatedQueryInfo):
-        return base_model
-    for model in base_model.__fields_set__:
-        field_value = getattr(base_model, model)
-        if isinstance(field_value, BaseModel):
-            page_info = get_paginated_query_info_from_response_info(field_value)
-            if page_info is not None:
-                return page_info
-    raise ValueError(f"Could not find nodes info in {base_model}.")
-
-
-def checked_graphql_data_map(response: Response) -> dict[str, Any | None]:
-    try:
-        response.raise_for_status()
-    except HTTPError as error:
-        raise GraphQlError(error) from error
-    response_map = response.json()
-    if not isinstance(response_map, dict):
-        raise GraphQlError(f"GraphQL response must be a map but is: {response_map}.")
-    errors = response_map.get("errors")
-    if errors is not None:
-        raise GraphQlError(f'{errors[0]["message"]}; details: {errors}.')
-    result = response_map.get("data")
-    if result is None:
-        raise GraphQlError(f"GraphQL result must include data but only has: {sorted(response_map.keys())}.")
-    if not isinstance(result, dict):
-        raise GraphQlError(f"GraphQL data must be a map but is: {result}.")
-    return result
-
-
-class GraphQlError(Exception):
-    pass
diff --git a/check_done/graphql.py b/check_done/graphql.py
new file mode 100644
index 0000000..c3c9c95
--- /dev/null
+++ b/check_done/graphql.py
@@ -0,0 +1,134 @@
+# Copyright (C) 2024 by Siisurit e.U., Austria.
+# All rights reserved. Distributed under the MIT License.
+import re
+from enum import Enum
+from functools import lru_cache
+from pathlib import Path
+from typing import Any
+
+from pydantic import BaseModel
+from requests import HTTPError, Response, Session
+from requests.auth import AuthBase
+
+from check_done.info import (
+    QueryInfo,
+)
+
+GRAPHQL_ENDPOINT = "https://api.github.com/graphql"
+_MAX_ENTRIES_PER_PAGE = 100
+_PATH_TO_QUERIES = Path(__file__).parent / "queries"
+
+
+class GraphQlError(Exception):
+    pass
+
+
+class HttpBearerAuth(AuthBase):
+    # Source:
+    # <https://stackoverflow.com/questions/29931671/making-an-api-call-in-python-with-an-api-that-requires-a-bearer-token>
+    def __init__(self, token):
+        self.token = token
+
+    def __call__(self, request):
+        request.headers["Authorization"] = "Bearer " + self.token
+        return request
+
+
+# TODO#13: Is the lru_cache useful? There shouldn't be multiple identical queries due to GraphQL pagination.
+
+
+@lru_cache
+def minimized_graphql(graphql_query: str) -> str:
+    single_spaced_query = re.sub(r"\s+", " ", graphql_query)
+    formatted_query = re.sub(r"\s*([{}()\[\]:,])\s*", r"\1", single_spaced_query)
+    result = formatted_query.strip()
+    if result == "":
+        raise GraphQlError("GraphQL query is empty.")
+    return result
+
+
+def _graphql_query(item_name: str) -> str:
+    query_path = _PATH_TO_QUERIES / f"{item_name}.graphql"
+    with query_path.open(encoding="utf-8") as query_file:
+        query_text = query_file.read()
+    return minimized_graphql(query_text)
+
+
+class GraphQlQuery(Enum):
+    ORGANIZATION_PROJECTS = _graphql_query("organization_projects")
+    USER_PROJECTS = _graphql_query("user_projects")
+    PROJECT_SINGLE_SELECT_FIELDS = _graphql_query("project_single_select_fields")
+    PROJECT_V2_ITEMS = _graphql_query("project_v2_items")
+
+    @staticmethod
+    def query_for(name: str):
+        assert name is not None
+        assert name.upper() == name
+        return GraphQlQuery[name].value
+
+
+def query_infos(
+    base_model: type[BaseModel],
+    query_name: str,
+    session: Session,
+    project_owner_name: str,
+    project_id: str | None = None,
+) -> list:
+    result = []
+    variables = {"login": project_owner_name, "maxEntriesPerPage": _MAX_ENTRIES_PER_PAGE}
+    if project_id is not None:
+        variables["projectId"] = project_id
+    after = None
+    has_more_pages = True
+    while has_more_pages:
+        query = GraphQlQuery.query_for(query_name)
+        if after is not None:
+            variables["after"] = after
+        json_payload_map = {
+            "variables": variables,
+            "query": query,
+        }
+        response = session.post(GRAPHQL_ENDPOINT, json=json_payload_map)
+        response_map = checked_graphql_data_map(response)
+        response_info = base_model(**response_map)
+        query_info = query_info_from_response_info(response_info)
+        nodes_info = query_info.nodes
+        page_info = query_info.page_info
+        after = page_info.endCursor
+        has_more_pages = page_info.hasNextPage
+        result.extend(nodes_info)
+    return result
+
+
+def query_info_from_response_info(base_model: BaseModel) -> QueryInfo:
+    if isinstance(base_model, QueryInfo):
+        return base_model
+    for model_field_name in base_model.model_fields_set:
+        field_model = getattr(base_model, model_field_name)
+        if isinstance(field_model, BaseModel):
+            result = query_info_from_response_info(field_model)
+            if result is not None:
+                return result
+    raise ValueError(
+        f"Could not find fields matching the {QueryInfo.__name__} model with fields "
+        f"`{QueryInfo.model_fields.keys()}` in: {base_model}"
+    )
+
+
+def checked_graphql_data_map(response: Response) -> dict[str, Any | None]:
+    try:
+        response.raise_for_status()
+    except HTTPError as error:
+        raise GraphQlError(error) from error
+    response_map = response.json()
+    if not isinstance(response_map, dict):
+        raise GraphQlError(f"GraphQL response must be a map but is: {response_map}.")
+    errors = response_map.get("errors")
+    if errors is not None:
+        raise GraphQlError(f'{errors[0]["message"]}; details: {errors}.')
+    result = response_map.get("data")
+    if result is None:
+        raise GraphQlError(f"GraphQL result must include data but only has: {sorted(response_map.keys())}.")
+    if not isinstance(result, dict):
+        raise GraphQlError(f"GraphQL data must be a map but is: {result}.")
+    return result
diff --git a/check_done/done_project_items_info/info.py b/check_done/info.py
similarity index 71%
rename from check_done/done_project_items_info/info.py
rename to check_done/info.py
index e15ac28..195c459 100644
--- a/check_done/done_project_items_info/info.py
+++ b/check_done/info.py
@@ -1,3 +1,5 @@
+# Copyright (C) 2024 by Siisurit e.U., Austria.
+# All rights reserved. Distributed under the MIT License.
 from enum import StrEnum
 from typing import Any
 
@@ -18,10 +20,9 @@ class ProjectItemState(StrEnum):
     OPEN = "OPEN"
 
 
-class GithubContentType(StrEnum):
-    ISSUE = "ISSUE"
-    DRAFT_ISSUE = "DRAFT_ISSUE"
-    PULL_REQUEST = "PULL_REQUEST"
+class GithubProjectItemType(StrEnum):
+    issue = "Issue"
+    pull_request = "PullRequest"
 
 
 class _EmptyDict(BaseModel):
@@ -33,12 +34,14 @@ class PageInfo(BaseModel):
     hasNextPage: bool
 
 
-class PaginatedQueryInfo(BaseModel):
+class QueryInfo(BaseModel):
+    """The nested content of the query and its pagination info in a paginated GraphQL query with nodes"""
+
     nodes: list[Any]
     page_info: PageInfo = Field(alias="pageInfo")
 
     @field_validator("nodes", mode="after", check_fields=True)
-    def validate_each_node(cls, nodes: list[Any]):
+    def resolve_nodes(cls, nodes: list[Any]):
         validated_nodes = []
         for node in nodes:
             node_type = node.get("__typename")
@@ -48,12 +51,12 @@ def validate_each_node(cls, nodes: list[Any]):
         return validated_nodes
 
 
-class ProjectV2NodeInfo(BaseModel):
+class ProjectV2Node(BaseModel):
     id: str
     number: NonNegativeInt
     typename: str = Field(alias="__typename")
-    fields: PaginatedQueryInfo | None = None
-    items: PaginatedQueryInfo | None = None
+    fields: QueryInfo | None = None
+    items: QueryInfo | None = None
 
 
 class ProjectV2Options(BaseModel):
@@ -61,7 +64,7 @@ class ProjectV2Options(BaseModel):
     name: str
 
 
-class ProjectV2SingleSelectFieldNodeInfo(BaseModel):
+class ProjectV2SingleSelectFieldNode(BaseModel):
     id: str
     name: str
     typename: str = Field(alias="__typename")
@@ -85,21 +88,22 @@ class MilestoneInfo(BaseModel):
     id: str
 
 
-class LinkedProjectItemNodeInfo(BaseModel):
+class LinkedProjectItemNode(BaseModel):
     number: NonNegativeInt
     title: str
 
 
 class LinkedProjectItemInfo(BaseModel):
-    nodes: list[LinkedProjectItemNodeInfo]
+    nodes: list[LinkedProjectItemNode]
 
 
 # NOTE: For simplicity, both issues and pull requests are treated the same under a generic "project item" type.
-#  Since all their underlying properties needed for checking are essentially identical,
-#  there is no value in differentiating between them as long as the above continues to be the case.
+#  Since all their underlying properties needed for checking, are mostly identical.
 class ProjectItemInfo(BaseModel):
     """A generic type representing both issues and pull requests in a project board."""
 
+    # Shared between Issues and Pull Requests.
+    typename: GithubProjectItemType = Field(alias="__typename")
     assignees: AssigneesInfo
     body_html: str = Field(alias="bodyHTML", default=None)
     closed: bool
@@ -107,27 +111,23 @@ class ProjectItemInfo(BaseModel):
     repository: RepositoryInfo
     milestone: MilestoneInfo | None
     title: str
-    linked_project_item: LinkedProjectItemInfo = Field(
-        validation_alias=AliasChoices(
-            "closedByPullRequestsReferences", "closingIssuesReferences", "linked_project_item"
-        )
-    )
+
+    # Only set for pull requests
+    closing_issues_references: LinkedProjectItemInfo = Field(alias="closingIssuesReferences", default=None)
 
 
-class ProjectV2ItemNodeInfo(BaseModel):
-    type: GithubContentType
-    # TODO#4: When including pull requests, implement their info model
-    #  See: https://docs.github.com/en/graphql/reference/unions#projectv2itemcontent
+class ProjectV2ItemNode(BaseModel):
     content: ProjectItemInfo | _EmptyDict = None
     field_value_by_name: ProjectV2ItemProjectStatusInfo | None = Field(alias="fieldValueByName", default=None)
+    typename: str = Field(alias="__typename")
 
 
 class NodeByIdInfo(BaseModel):
-    node: ProjectV2NodeInfo
+    node: ProjectV2Node
 
 
 class _ProjectsV2Info(BaseModel):
-    projects_v2: PaginatedQueryInfo = Field(alias="projectsV2")
+    projects_v2: QueryInfo = Field(alias="projectsV2")
 
 
 class ProjectOwnerInfo(BaseModel):
@@ -147,7 +147,7 @@ class _NodeTypeName(StrEnum):
 
 
 _NODE_TYPE_NAME_TO_INFO_CLASS_MAP = {
-    _NodeTypeName.ProjectV2.value: ProjectV2NodeInfo,
-    _NodeTypeName.ProjectV2SingleSelectField.value: ProjectV2SingleSelectFieldNodeInfo,
-    _NodeTypeName.ProjectV2SingleSelectField.ProjectV2Item: ProjectV2ItemNodeInfo,
+    _NodeTypeName.ProjectV2.value: ProjectV2Node,
+    _NodeTypeName.ProjectV2SingleSelectField.value: ProjectV2SingleSelectFieldNode,
+    _NodeTypeName.ProjectV2SingleSelectField.ProjectV2Item: ProjectV2ItemNode,
 }
diff --git a/check_done/organization_authentication.py b/check_done/organization_authentication.py
new file mode 100644
index 0000000..81d0095
--- /dev/null
+++ b/check_done/organization_authentication.py
@@ -0,0 +1,75 @@
+# Copyright (C) 2024 by Siisurit e.U., Austria.
+# All rights reserved. Distributed under the MIT License.
+import time
+
+import jwt
+import requests
+from requests import Session
+
+from check_done.graphql import HttpBearerAuth
+
+_SECONDS_PER_MINUTE = 60
+_ISSUED_AT = int(time.time())
+_EXPIRES_AT = int(time.time()) + (10 * _SECONDS_PER_MINUTE)
+
+
+class AuthenticationError(Exception):
+    """Error raised due to failed JWT authentication process."""
+
+
+def resolve_organization_access_token(
+    organization_name: str, check_done_github_app_id: str, check_done_github_app_private_key: str
+) -> str:
+    """
+    Generates the necessary access token for an organization from the installed GitHub app instance in said organization
+    """
+    jwt_token = generate_jwt_token(check_done_github_app_id, check_done_github_app_private_key)
+    session = requests.Session()
+    session.headers = {"Accept": "application/vnd.github+json"}
+    session.auth = HttpBearerAuth(jwt_token)
+    try:
+        check_done_github_app_installation_id = resolve_check_done_github_app_installation_id(
+            session, organization_name
+        )
+        result = resolve_access_token_from_check_done_github_app_installation_id(
+            session, check_done_github_app_installation_id
+        )
+    except Exception as error:
+        raise AuthenticationError(
+            f"Cannot resolve organization access token from JWT authentication process: {error}"
+        ) from error
+    return result
+
+
+def generate_jwt_token(check_done_github_app_id: str, check_done_github_app_private_key: str) -> str:
+    """Generates a JWT token for authentication with GitHub."""
+    try:
+        payload = {
+            "exp": _EXPIRES_AT,
+            "iat": _ISSUED_AT,
+            "iss": check_done_github_app_id,
+        }
+        return jwt.encode(payload, check_done_github_app_private_key, algorithm="RS256")
+    except Exception as error:
+        raise AuthenticationError(f"Cannot generate JWT token: {error}") from error
+
+
+def resolve_check_done_github_app_installation_id(session: Session, organization_name: str) -> str:
+    """Fetches the installation ID for the organization."""
+    response = session.get(f"https://api.github.com/orgs/{organization_name}/installation")
+
+    if response.status_code == 200 and response.json().get("id") is not None:
+        return response.json().get("id")
+    raise AuthenticationError(
+        f"Could not retrieve installation ID: status={response.status_code} - response_text={response.text}"
+    )
+
+
+def resolve_access_token_from_check_done_github_app_installation_id(session: Session, installation_id: str) -> str:
+    """Retrieves the access token using the installation ID."""
+    response = session.post(f"https://api.github.com/app/installations/{installation_id}/access_tokens")
+    if response.status_code == 201 and response.json().get("token") is not None:
+        return response.json().get("token")
+    raise AuthenticationError(
+        f"Could not retrieve access token: status={response.status_code} - response_text={response.text}"
+    )
diff --git a/check_done/done_project_items_info/queries/organization_projects_ids.graphql b/check_done/queries/organization_projects.graphql
similarity index 92%
rename from check_done/done_project_items_info/queries/organization_projects_ids.graphql
rename to check_done/queries/organization_projects.graphql
index e4a7b6f..f4db352 100644
--- a/check_done/done_project_items_info/queries/organization_projects_ids.graphql
+++ b/check_done/queries/organization_projects.graphql
@@ -1,4 +1,4 @@
-query organizationProjectsIds(
+query organizationProjects(
   $login: String!
   $maxEntriesPerPage: Int!
   $after: String
diff --git a/check_done/done_project_items_info/queries/project_state_options_ids.graphql b/check_done/queries/project_single_select_fields.graphql
similarity index 93%
rename from check_done/done_project_items_info/queries/project_state_options_ids.graphql
rename to check_done/queries/project_single_select_fields.graphql
index d2fac12..92b037e 100644
--- a/check_done/done_project_items_info/queries/project_state_options_ids.graphql
+++ b/check_done/queries/project_single_select_fields.graphql
@@ -1,4 +1,4 @@
-query projectStateOptionsIds(
+query projectSingleSelectFields(
   $projectId: ID!
   $maxEntriesPerPage: Int!
   $after: String
diff --git a/check_done/done_project_items_info/queries/project_v2_items.graphql b/check_done/queries/project_v2_items.graphql
similarity index 89%
rename from check_done/done_project_items_info/queries/project_v2_items.graphql
rename to check_done/queries/project_v2_items.graphql
index b8fa0f2..995d8c4 100644
--- a/check_done/done_project_items_info/queries/project_v2_items.graphql
+++ b/check_done/queries/project_v2_items.graphql
@@ -16,6 +16,7 @@ query projectV2Issues(
           type
           content {
             ... on Issue {
+              __typename
               assignees {
                 totalCount
               }
@@ -24,12 +25,6 @@ query projectV2Issues(
               milestone {
                 id
               }
-              closedByPullRequestsReferences(first: 1) {
-                nodes {
-                  number
-                  title
-                }
-              }
               closed
               title
               repository {
@@ -37,6 +32,7 @@ query projectV2Issues(
               }
             }
             ... on PullRequest {
+              __typename
               assignees {
                 totalCount
               }
diff --git a/check_done/done_project_items_info/queries/user_projects_ids.graphql b/check_done/queries/user_projects.graphql
similarity index 76%
rename from check_done/done_project_items_info/queries/user_projects_ids.graphql
rename to check_done/queries/user_projects.graphql
index 9227c45..2505a19 100644
--- a/check_done/done_project_items_info/queries/user_projects_ids.graphql
+++ b/check_done/queries/user_projects.graphql
@@ -1,8 +1,4 @@
-query userProjectsIds(
-  $login: String!
-  $maxEntriesPerPage: Int!
-  $after: String
-) {
+query userProjects($login: String!, $maxEntriesPerPage: Int!, $after: String) {
   user(login: $login) {
     projectsV2(
       first: $maxEntriesPerPage
diff --git a/check_done/warning_checks.py b/check_done/warning_checks.py
new file mode 100644
index 0000000..fcb0871
--- /dev/null
+++ b/check_done/warning_checks.py
@@ -0,0 +1,95 @@
+# Copyright (C) 2024 by Siisurit e.U., Austria.
+# All rights reserved. Distributed under the MIT License.
+from html.parser import HTMLParser
+
+from check_done.info import GithubProjectItemType, ProjectItemInfo
+
+
+class _StopParsingHtml(Exception):
+    """Custom exception to stop HTML parsing."""
+
+
+class _AllTasksCheckedHtmlParser(HTMLParser):
+    def __init__(self):
+        super().__init__()
+        self.all_tasks_are_checked = True
+
+    def handle_starttag(self, tag, attrs):
+        if tag == "input":
+            attr_dict = dict(attrs)
+            is_checkbox = attr_dict.get("type") == "checkbox"
+            if is_checkbox:
+                is_checked = "checked" in attr_dict
+                if not is_checked:
+                    self.all_tasks_are_checked = False
+                    raise _StopParsingHtml
+
+
+def warnings_for_done_project_items(done_project_items: list[ProjectItemInfo]) -> list[str | None]:
+    result = []
+    for project_item in done_project_items:
+        warning_reasons = [
+            warning_reason(project_item)
+            for warning_reason in POSSIBLE_WARNINGS
+            if warning_reason(project_item) is not None
+        ]
+        if len(warning_reasons) >= 1:
+            warning = sentence_from_project_item_warning_reasons(project_item, warning_reasons)
+            result.append(warning)
+    return result
+
+
+def sentence_from_project_item_warning_reasons(project_item: ProjectItemInfo, warning_reasons: list[str]) -> str:
+    if len(warning_reasons) >= 3:
+        warning_reasons = f"{', '.join(warning_reasons[:-1])}, and {warning_reasons[-1]}"
+    elif len(warning_reasons) == 2:
+        warning_reasons = f"{', '.join(warning_reasons[:1])} and {warning_reasons[1]}"
+    else:
+        warning_reasons = warning_reasons[0]
+    return (
+        f" Project item should {warning_reasons}."
+        f" - repository: {project_item.repository.name!r} - item name: '#{project_item.number} {project_item.title}'."
+    )
+
+
+def warning_reason_if_open(project_item: ProjectItemInfo) -> str | None:
+    return "be closed" if not project_item.closed else None
+
+
+def warning_reason_if_unassigned(project_item: ProjectItemInfo) -> str | None:
+    return "be assigned" if project_item.assignees.total_count == 0 else None
+
+
+def warning_reason_if_missing_milestone(project_item: ProjectItemInfo) -> str | None:
+    return "have a milestone" if project_item.milestone is None else None
+
+
+def warning_reason_if_tasks_are_uncompleted(project_item: ProjectItemInfo) -> str | None:
+    # TODO#29 Change parsing of tasks to markdown description.
+    #  Background: This is less fragile than HTML because even if GitHub changes the names of HTML
+    #  types it will still work.
+    parser = _AllTasksCheckedHtmlParser()
+    try:
+        parser.feed(project_item.body_html)
+        parser.close()
+    except _StopParsingHtml:
+        pass  # Stop parsing HTML after first unchecked task.
+    return "have all tasks completed" if not parser.all_tasks_are_checked else None
+
+
+def warning_reason_if_missing_closing_issue_reference_in_pull_request(project_item: ProjectItemInfo) -> str | None:
+    is_missing_closing_issue_reference_in_pull_request = (
+        len(project_item.closing_issues_references.nodes) == 0
+        if project_item.typename is GithubProjectItemType.pull_request
+        else False
+    )
+    return "have a closing issue reference" if is_missing_closing_issue_reference_in_pull_request else None
+
+
+POSSIBLE_WARNINGS = [
+    warning_reason_if_open,
+    warning_reason_if_unassigned,
+    warning_reason_if_missing_milestone,
+    warning_reason_if_tasks_are_uncompleted,
+    warning_reason_if_missing_closing_issue_reference_in_pull_request,
+]
diff --git a/poetry.lock b/poetry.lock
index c6cc795..6b97e38 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -140,101 +140,116 @@ files = [
 
 [[package]]
 name = "charset-normalizer"
-version = "3.3.2"
+version = "3.4.0"
 description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
 optional = false
 python-versions = ">=3.7.0"
 files = [
-    {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"},
-    {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"},
-    {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"},
-    {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"},
-    {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"},
-    {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"},
-    {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"},
-    {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"},
-    {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"},
-    {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"},
-    {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"},
-    {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"},
-    {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"},
-    {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"},
-    {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"},
-    {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"},
-    {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"},
-    {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"},
-    {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"},
-    {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"},
-    {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"},
-    {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"},
-    {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"},
-    {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"},
-    {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"},
-    {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"},
-    {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"},
-    {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"},
-    {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"},
-    {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"},
-    {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"},
-    {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"},
-    {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"},
-    {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"},
-    {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"},
-    {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"},
-    {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"},
-    {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"},
-    {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"},
-    {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"},
-    {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"},
-    {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"},
-    {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"},
-    {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"},
-    {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"},
-    {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"},
-    {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"},
-    {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"},
-    {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"},
-    {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"},
-    {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"},
-    {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"},
-    {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"},
-    {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"},
-    {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"},
-    {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"},
-    {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"},
-    {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"},
-    {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"},
-    {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"},
-    {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"},
-    {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"},
-    {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"},
-    {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"},
-    {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"},
-    {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"},
-    {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"},
-    {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"},
-    {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"},
-    {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"},
-    {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"},
-    {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"},
-    {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"},
-    {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"},
-    {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"},
-    {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"},
-    {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"},
-    {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"},
-    {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"},
-    {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"},
-    {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"},
-    {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"},
-    {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"},
-    {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"},
-    {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"},
-    {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"},
-    {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"},
-    {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"},
-    {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"},
-    {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"},
+    {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"},
+    {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"},
+    {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"},
+    {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"},
+    {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"},
+    {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"},
+    {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"},
+    {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"},
+    {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"},
+    {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"},
+    {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"},
+    {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"},
+    {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"},
+    {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"},
+    {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"},
+    {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"},
+    {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"},
+    {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"},
+    {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"},
+    {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"},
+    {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"},
+    {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"},
+    {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"},
+    {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"},
+    {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"},
+    {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"},
+    {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"},
+    {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"},
+    {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"},
+    {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"},
+    {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"},
+    {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"},
+    {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"},
+    {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"},
+    {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"},
+    {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"},
+    {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"},
+    {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"},
+    {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"},
+    {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"},
+    {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"},
+    {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"},
+    {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"},
+    {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"},
+    {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"},
+    {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"},
+    {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"},
+    {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"},
+    {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"},
+    {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"},
+    {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"},
+    {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"},
+    {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"},
+    {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"},
+    {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"},
+    {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"},
+    {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"},
+    {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"},
+    {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"},
+    {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"},
+    {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"},
+    {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"},
+    {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"},
+    {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"},
+    {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"},
+    {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"},
+    {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"},
+    {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"},
+    {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"},
+    {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"},
+    {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"},
+    {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"},
+    {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"},
+    {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"},
+    {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"},
+    {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"},
+    {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"},
+    {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"},
+    {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"},
+    {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"},
+    {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"},
+    {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"},
+    {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"},
+    {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"},
+    {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"},
+    {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"},
+    {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"},
+    {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"},
+    {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"},
+    {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"},
+    {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"},
+    {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"},
+    {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"},
+    {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"},
+    {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"},
+    {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"},
+    {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"},
+    {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"},
+    {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"},
+    {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"},
+    {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"},
+    {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"},
+    {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"},
+    {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"},
+    {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"},
 ]
 
 [[package]]
@@ -250,83 +265,73 @@ files = [
 
 [[package]]
 name = "coverage"
-version = "7.6.1"
+version = "7.6.7"
 description = "Code coverage measurement for Python"
 optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.9"
 files = [
-    {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"},
-    {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"},
-    {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"},
-    {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"},
-    {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"},
-    {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"},
-    {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"},
-    {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"},
-    {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"},
-    {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"},
-    {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"},
-    {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"},
-    {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"},
-    {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"},
-    {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"},
-    {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"},
-    {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"},
-    {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"},
-    {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"},
-    {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"},
-    {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"},
-    {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"},
-    {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"},
-    {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"},
-    {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"},
-    {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"},
-    {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"},
-    {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"},
-    {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"},
-    {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"},
-    {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"},
-    {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"},
-    {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"},
-    {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"},
-    {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"},
-    {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"},
-    {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"},
-    {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"},
-    {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"},
-    {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"},
-    {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"},
-    {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"},
-    {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"},
-    {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"},
-    {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"},
-    {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"},
-    {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"},
-    {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"},
-    {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"},
-    {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"},
-    {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"},
-    {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"},
-    {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"},
-    {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"},
-    {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"},
-    {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"},
-    {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"},
-    {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"},
-    {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"},
-    {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"},
-    {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"},
-    {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"},
-    {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"},
-    {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"},
-    {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"},
-    {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"},
-    {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"},
-    {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"},
-    {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"},
-    {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"},
-    {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"},
-    {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"},
+    {file = "coverage-7.6.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:108bb458827765d538abcbf8288599fee07d2743357bdd9b9dad456c287e121e"},
+    {file = "coverage-7.6.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c973b2fe4dc445cb865ab369df7521df9c27bf40715c837a113edaa2aa9faf45"},
+    {file = "coverage-7.6.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c6b24007c4bcd0b19fac25763a7cac5035c735ae017e9a349b927cfc88f31c1"},
+    {file = "coverage-7.6.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:acbb8af78f8f91b3b51f58f288c0994ba63c646bc1a8a22ad072e4e7e0a49f1c"},
+    {file = "coverage-7.6.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad32a981bcdedb8d2ace03b05e4fd8dace8901eec64a532b00b15217d3677dd2"},
+    {file = "coverage-7.6.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:34d23e28ccb26236718a3a78ba72744212aa383141961dd6825f6595005c8b06"},
+    {file = "coverage-7.6.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e25bacb53a8c7325e34d45dddd2f2fbae0dbc230d0e2642e264a64e17322a777"},
+    {file = "coverage-7.6.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af05bbba896c4472a29408455fe31b3797b4d8648ed0a2ccac03e074a77e2314"},
+    {file = "coverage-7.6.7-cp310-cp310-win32.whl", hash = "sha256:796c9b107d11d2d69e1849b2dfe41730134b526a49d3acb98ca02f4985eeff7a"},
+    {file = "coverage-7.6.7-cp310-cp310-win_amd64.whl", hash = "sha256:987a8e3da7da4eed10a20491cf790589a8e5e07656b6dc22d3814c4d88faf163"},
+    {file = "coverage-7.6.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7e61b0e77ff4dddebb35a0e8bb5a68bf0f8b872407d8d9f0c726b65dfabe2469"},
+    {file = "coverage-7.6.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a5407a75ca4abc20d6252efeb238377a71ce7bda849c26c7a9bece8680a5d99"},
+    {file = "coverage-7.6.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df002e59f2d29e889c37abd0b9ee0d0e6e38c24f5f55d71ff0e09e3412a340ec"},
+    {file = "coverage-7.6.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:673184b3156cba06154825f25af33baa2671ddae6343f23175764e65a8c4c30b"},
+    {file = "coverage-7.6.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e69ad502f1a2243f739f5bd60565d14a278be58be4c137d90799f2c263e7049a"},
+    {file = "coverage-7.6.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60dcf7605c50ea72a14490d0756daffef77a5be15ed1b9fea468b1c7bda1bc3b"},
+    {file = "coverage-7.6.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9c2eb378bebb2c8f65befcb5147877fc1c9fbc640fc0aad3add759b5df79d55d"},
+    {file = "coverage-7.6.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c0317288f032221d35fa4cbc35d9f4923ff0dfd176c79c9b356e8ef8ef2dff4"},
+    {file = "coverage-7.6.7-cp311-cp311-win32.whl", hash = "sha256:951aade8297358f3618a6e0660dc74f6b52233c42089d28525749fc8267dccd2"},
+    {file = "coverage-7.6.7-cp311-cp311-win_amd64.whl", hash = "sha256:5e444b8e88339a2a67ce07d41faabb1d60d1004820cee5a2c2b54e2d8e429a0f"},
+    {file = "coverage-7.6.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f07ff574986bc3edb80e2c36391678a271d555f91fd1d332a1e0f4b5ea4b6ea9"},
+    {file = "coverage-7.6.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:49ed5ee4109258973630c1f9d099c7e72c5c36605029f3a91fe9982c6076c82b"},
+    {file = "coverage-7.6.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3e8796434a8106b3ac025fd15417315d7a58ee3e600ad4dbcfddc3f4b14342c"},
+    {file = "coverage-7.6.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3b925300484a3294d1c70f6b2b810d6526f2929de954e5b6be2bf8caa1f12c1"},
+    {file = "coverage-7.6.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c42ec2c522e3ddd683dec5cdce8e62817afb648caedad9da725001fa530d354"},
+    {file = "coverage-7.6.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0266b62cbea568bd5e93a4da364d05de422110cbed5056d69339bd5af5685433"},
+    {file = "coverage-7.6.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e5f2a0f161d126ccc7038f1f3029184dbdf8f018230af17ef6fd6a707a5b881f"},
+    {file = "coverage-7.6.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c132b5a22821f9b143f87446805e13580b67c670a548b96da945a8f6b4f2efbb"},
+    {file = "coverage-7.6.7-cp312-cp312-win32.whl", hash = "sha256:7c07de0d2a110f02af30883cd7dddbe704887617d5c27cf373362667445a4c76"},
+    {file = "coverage-7.6.7-cp312-cp312-win_amd64.whl", hash = "sha256:fd49c01e5057a451c30c9b892948976f5d38f2cbd04dc556a82743ba8e27ed8c"},
+    {file = "coverage-7.6.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46f21663e358beae6b368429ffadf14ed0a329996248a847a4322fb2e35d64d3"},
+    {file = "coverage-7.6.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40cca284c7c310d622a1677f105e8507441d1bb7c226f41978ba7c86979609ab"},
+    {file = "coverage-7.6.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77256ad2345c29fe59ae861aa11cfc74579c88d4e8dbf121cbe46b8e32aec808"},
+    {file = "coverage-7.6.7-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87ea64b9fa52bf395272e54020537990a28078478167ade6c61da7ac04dc14bc"},
+    {file = "coverage-7.6.7-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d608a7808793e3615e54e9267519351c3ae204a6d85764d8337bd95993581a8"},
+    {file = "coverage-7.6.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdd94501d65adc5c24f8a1a0eda110452ba62b3f4aeaba01e021c1ed9cb8f34a"},
+    {file = "coverage-7.6.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:82c809a62e953867cf57e0548c2b8464207f5f3a6ff0e1e961683e79b89f2c55"},
+    {file = "coverage-7.6.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bb684694e99d0b791a43e9fc0fa58efc15ec357ac48d25b619f207c41f2fd384"},
+    {file = "coverage-7.6.7-cp313-cp313-win32.whl", hash = "sha256:963e4a08cbb0af6623e61492c0ec4c0ec5c5cf74db5f6564f98248d27ee57d30"},
+    {file = "coverage-7.6.7-cp313-cp313-win_amd64.whl", hash = "sha256:14045b8bfd5909196a90da145a37f9d335a5d988a83db34e80f41e965fb7cb42"},
+    {file = "coverage-7.6.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f2c7a045eef561e9544359a0bf5784b44e55cefc7261a20e730baa9220c83413"},
+    {file = "coverage-7.6.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dd4e4a49d9c72a38d18d641135d2fb0bdf7b726ca60a103836b3d00a1182acd"},
+    {file = "coverage-7.6.7-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c95e0fa3d1547cb6f021ab72f5c23402da2358beec0a8e6d19a368bd7b0fb37"},
+    {file = "coverage-7.6.7-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f63e21ed474edd23f7501f89b53280014436e383a14b9bd77a648366c81dce7b"},
+    {file = "coverage-7.6.7-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ead9b9605c54d15be228687552916c89c9683c215370c4a44f1f217d2adcc34d"},
+    {file = "coverage-7.6.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0573f5cbf39114270842d01872952d301027d2d6e2d84013f30966313cadb529"},
+    {file = "coverage-7.6.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:e2c8e3384c12dfa19fa9a52f23eb091a8fad93b5b81a41b14c17c78e23dd1d8b"},
+    {file = "coverage-7.6.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:70a56a2ec1869e6e9fa69ef6b76b1a8a7ef709972b9cc473f9ce9d26b5997ce3"},
+    {file = "coverage-7.6.7-cp313-cp313t-win32.whl", hash = "sha256:dbba8210f5067398b2c4d96b4e64d8fb943644d5eb70be0d989067c8ca40c0f8"},
+    {file = "coverage-7.6.7-cp313-cp313t-win_amd64.whl", hash = "sha256:dfd14bcae0c94004baba5184d1c935ae0d1231b8409eb6c103a5fd75e8ecdc56"},
+    {file = "coverage-7.6.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:37a15573f988b67f7348916077c6d8ad43adb75e478d0910957394df397d2874"},
+    {file = "coverage-7.6.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b6cce5c76985f81da3769c52203ee94722cd5d5889731cd70d31fee939b74bf0"},
+    {file = "coverage-7.6.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ab9763d291a17b527ac6fd11d1a9a9c358280adb320e9c2672a97af346ac2c"},
+    {file = "coverage-7.6.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6cf96ceaa275f071f1bea3067f8fd43bec184a25a962c754024c973af871e1b7"},
+    {file = "coverage-7.6.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aee9cf6b0134d6f932d219ce253ef0e624f4fa588ee64830fcba193269e4daa3"},
+    {file = "coverage-7.6.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2bc3e45c16564cc72de09e37413262b9f99167803e5e48c6156bccdfb22c8327"},
+    {file = "coverage-7.6.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:623e6965dcf4e28a3debaa6fcf4b99ee06d27218f46d43befe4db1c70841551c"},
+    {file = "coverage-7.6.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:850cfd2d6fc26f8346f422920ac204e1d28814e32e3a58c19c91980fa74d8289"},
+    {file = "coverage-7.6.7-cp39-cp39-win32.whl", hash = "sha256:c296263093f099da4f51b3dff1eff5d4959b527d4f2f419e16508c5da9e15e8c"},
+    {file = "coverage-7.6.7-cp39-cp39-win_amd64.whl", hash = "sha256:90746521206c88bdb305a4bf3342b1b7316ab80f804d40c536fc7d329301ee13"},
+    {file = "coverage-7.6.7-pp39.pp310-none-any.whl", hash = "sha256:0ddcb70b3a3a57581b450571b31cb774f23eb9519c2aaa6176d3a84c9fc57671"},
+    {file = "coverage-7.6.7.tar.gz", hash = "sha256:d79d4826e41441c9a118ff045e4bccb9fdbdcb1d02413e7ea6eb5c87b5439d24"},
 ]
 
 [package.dependencies]
@@ -356,38 +361,38 @@ yaml = ["pyyaml (>=3.10,<7.0)"]
 
 [[package]]
 name = "cryptography"
-version = "43.0.1"
+version = "43.0.3"
 description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"},
-    {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"},
-    {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962"},
-    {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277"},
-    {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a"},
-    {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042"},
-    {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494"},
-    {file = "cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2"},
-    {file = "cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d"},
-    {file = "cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d"},
-    {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806"},
-    {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85"},
-    {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c"},
-    {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1"},
-    {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa"},
-    {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4"},
-    {file = "cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47"},
-    {file = "cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb"},
-    {file = "cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034"},
-    {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d"},
-    {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"},
-    {file = "cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84"},
-    {file = "cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365"},
-    {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96"},
-    {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172"},
-    {file = "cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2"},
-    {file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"},
+    {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"},
+    {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"},
+    {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"},
+    {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"},
+    {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"},
+    {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"},
+    {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"},
+    {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"},
+    {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"},
+    {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"},
+    {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"},
+    {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"},
+    {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"},
+    {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"},
+    {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"},
+    {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"},
+    {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"},
+    {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"},
+    {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"},
+    {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"},
+    {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"},
+    {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"},
+    {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"},
+    {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"},
+    {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"},
+    {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"},
+    {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"},
 ]
 
 [package.dependencies]
@@ -400,18 +405,18 @@ nox = ["nox"]
 pep8test = ["check-sdist", "click", "mypy", "ruff"]
 sdist = ["build"]
 ssh = ["bcrypt (>=3.1.5)"]
-test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
+test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
 test-randomorder = ["pytest-randomly"]
 
 [[package]]
 name = "distlib"
-version = "0.3.8"
+version = "0.3.9"
 description = "Distribution utilities"
 optional = false
 python-versions = "*"
 files = [
-    {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"},
-    {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"},
+    {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"},
+    {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"},
 ]
 
 [[package]]
@@ -499,13 +504,13 @@ test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit",
 
 [[package]]
 name = "identify"
-version = "2.6.1"
+version = "2.6.2"
 description = "File identification library for Python"
 optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.9"
 files = [
-    {file = "identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0"},
-    {file = "identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98"},
+    {file = "identify-2.6.2-py2.py3-none-any.whl", hash = "sha256:c097384259f49e372f4ea00a19719d95ae27dd5ff0fd77ad630aa891306b82f3"},
+    {file = "identify-2.6.2.tar.gz", hash = "sha256:fab5c716c24d7a789775228823797296a2994b075fb6080ac83a102772a98cbd"},
 ]
 
 [package.extras]
@@ -597,21 +602,25 @@ test = ["portend", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-c
 
 [[package]]
 name = "jaraco-functools"
-version = "4.0.2"
+version = "4.1.0"
 description = "Functools like those found in stdlib"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "jaraco.functools-4.0.2-py3-none-any.whl", hash = "sha256:c9d16a3ed4ccb5a889ad8e0b7a343401ee5b2a71cee6ed192d3f68bc351e94e3"},
-    {file = "jaraco_functools-4.0.2.tar.gz", hash = "sha256:3460c74cd0d32bf82b9576bbb3527c4364d5b27a21f5158a62aed6c4b42e23f5"},
+    {file = "jaraco.functools-4.1.0-py3-none-any.whl", hash = "sha256:ad159f13428bc4acbf5541ad6dec511f91573b90fba04df61dafa2a1231cf649"},
+    {file = "jaraco_functools-4.1.0.tar.gz", hash = "sha256:70f7e0e2ae076498e212562325e805204fc092d7b4c17e0e86c959e249701a9d"},
 ]
 
 [package.dependencies]
 more-itertools = "*"
 
 [package.extras]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"]
+cover = ["pytest-cov"]
 doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
-test = ["jaraco.classes", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
+enabler = ["pytest-enabler (>=2.2)"]
+test = ["jaraco.classes", "pytest (>=6,!=8.1.*)"]
+type = ["pytest-mypy"]
 
 [[package]]
 name = "jeepney"
@@ -630,13 +639,13 @@ trio = ["async_generator", "trio"]
 
 [[package]]
 name = "keyring"
-version = "25.4.1"
+version = "25.5.0"
 description = "Store and access your passwords safely."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "keyring-25.4.1-py3-none-any.whl", hash = "sha256:5426f817cf7f6f007ba5ec722b1bcad95a75b27d780343772ad76b17cb47b0bf"},
-    {file = "keyring-25.4.1.tar.gz", hash = "sha256:b07ebc55f3e8ed86ac81dd31ef14e81ace9dd9c3d4b5d77a6e9a2016d0d71a1b"},
+    {file = "keyring-25.5.0-py3-none-any.whl", hash = "sha256:e67f8ac32b04be4714b42fe84ce7dad9c40985b9ca827c592cc303e7c26d9741"},
+    {file = "keyring-25.5.0.tar.gz", hash = "sha256:4c753b3ec91717fe713c4edd522d625889d8973a349b0e582622f49766de58e6"},
 ]
 
 [package.dependencies]
@@ -741,13 +750,13 @@ files = [
 
 [[package]]
 name = "packaging"
-version = "24.1"
+version = "24.2"
 description = "Core utilities for Python packages"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"},
-    {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
+    {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
+    {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
 ]
 
 [[package]]
@@ -961,13 +970,13 @@ windows-terminal = ["colorama (>=0.4.6)"]
 
 [[package]]
 name = "pyjwt"
-version = "2.9.0"
+version = "2.10.0"
 description = "JSON Web Token implementation in Python"
 optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.9"
 files = [
-    {file = "PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"},
-    {file = "pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"},
+    {file = "PyJWT-2.10.0-py3-none-any.whl", hash = "sha256:543b77207db656de204372350926bed5a86201c4cbff159f623f79c7bb487a15"},
+    {file = "pyjwt-2.10.0.tar.gz", hash = "sha256:7628a7eb7938959ac1b26e819a1df0fd3259505627b575e4bad6d08f76db695c"},
 ]
 
 [package.extras]
@@ -1033,6 +1042,20 @@ pytest = ">=6.2.5"
 [package.extras]
 dev = ["pre-commit", "pytest-asyncio", "tox"]
 
+[[package]]
+name = "pytest-order"
+version = "1.3.0"
+description = "pytest plugin to run your tests in a specific order"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "pytest_order-1.3.0-py3-none-any.whl", hash = "sha256:2cd562a21380345dd8d5774aa5fd38b7849b6ee7397ca5f6999bbe6e89f07f6e"},
+    {file = "pytest_order-1.3.0.tar.gz", hash = "sha256:51608fec3d3ee9c0adaea94daa124a5c4c1d2bb99b00269f098f414307f23dde"},
+]
+
+[package.dependencies]
+pytest = {version = ">=6.2.4", markers = "python_version >= \"3.10\""}
+
 [[package]]
 name = "python-dotenv"
 version = "1.0.1"
@@ -1207,18 +1230,19 @@ idna2008 = ["idna"]
 
 [[package]]
 name = "rich"
-version = "13.8.1"
+version = "13.9.4"
 description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
 optional = false
-python-versions = ">=3.7.0"
+python-versions = ">=3.8.0"
 files = [
-    {file = "rich-13.8.1-py3-none-any.whl", hash = "sha256:1760a3c0848469b97b558fc61c85233e3dafb69c7a071b4d60c38099d3cd4c06"},
-    {file = "rich-13.8.1.tar.gz", hash = "sha256:8260cda28e3db6bf04d2d1ef4dbc03ba80a824c88b0e7668a0f23126a424844a"},
+    {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"},
+    {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"},
 ]
 
 [package.dependencies]
 markdown-it-py = ">=2.2.0"
 pygments = ">=2.13.0,<3.0.0"
+typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""}
 
 [package.extras]
 jupyter = ["ipywidgets (>=7.5.1,<9)"]
@@ -1278,13 +1302,13 @@ files = [
 
 [[package]]
 name = "tomli"
-version = "2.0.1"
+version = "2.1.0"
 description = "A lil' TOML parser"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 files = [
-    {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
-    {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
+    {file = "tomli-2.1.0-py3-none-any.whl", hash = "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391"},
+    {file = "tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8"},
 ]
 
 [[package]]
@@ -1339,13 +1363,13 @@ zstd = ["zstandard (>=0.18.0)"]
 
 [[package]]
 name = "virtualenv"
-version = "20.26.5"
+version = "20.27.1"
 description = "Virtual Python Environment builder"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 files = [
-    {file = "virtualenv-20.26.5-py3-none-any.whl", hash = "sha256:4f3ac17b81fba3ce3bd6f4ead2749a72da5929c01774948e243db9ba41df4ff6"},
-    {file = "virtualenv-20.26.5.tar.gz", hash = "sha256:ce489cac131aa58f4b25e321d6d186171f78e6cb13fafbf32a840cee67733ff4"},
+    {file = "virtualenv-20.27.1-py3-none-any.whl", hash = "sha256:f11f1b8a29525562925f745563bfd48b189450f61fb34c4f9cc79dd5aa32a1f4"},
+    {file = "virtualenv-20.27.1.tar.gz", hash = "sha256:142c6be10212543b32c6c45d3d3893dff89112cc588b7d0879ae5a1ec03a47ba"},
 ]
 
 [package.dependencies]
@@ -1359,13 +1383,13 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess
 
 [[package]]
 name = "zipp"
-version = "3.20.2"
+version = "3.21.0"
 description = "Backport of pathlib-compatible object wrapper for zip files"
 optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.9"
 files = [
-    {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"},
-    {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"},
+    {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"},
+    {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"},
 ]
 
 [package.extras]
@@ -1379,4 +1403,4 @@ type = ["pytest-mypy"]
 [metadata]
 lock-version = "2.0"
 python-versions = ">=3.10, <3.13"
-content-hash = "1b5eb1fc32754b40ce86331ab9750766b336b8af8408f32d95173c9b962146ad"
+content-hash = "2c42d4be443c2033aefb3755c3b2aad245a43a515afb78ddc4e6eeb3149084f8"
diff --git a/pyproject.toml b/pyproject.toml
index a6dc35a..b4456c4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -38,7 +38,9 @@ packages = [
 ]
 
 [tool.poetry.dependencies]
-python = ">=3.10, <3.13"  # NOTE We cannot use <4 because of coveralls.
+# TODO#30: Move python target to <3.14 once coveralls supports it.
+#  See https://github.com/TheKevJames/coveralls-python/pull/542
+python = ">=3.10, <3.13"
 pygments = "^2"
 chardet = "^5"
 rich = ">=9, <14"
@@ -49,6 +51,7 @@ pytest-mock = "^3.14.0"
 pydantic = "^2.9.2"
 pyjwt = "^2.9.0"
 requests-mock = "^1.12.1"
+pytest-order = "^1.3.0"
 
 [tool.poetry.dev-dependencies]
 coveralls = "^4"
@@ -80,7 +83,7 @@ exclude = [
     "htmlcov",
 ]
 line-length = 120
-target-version = "py310"
+target-version = "py312"
 
 [tool.ruff.lint]
 ignore = [


Test patch:
diff --git a/scripts/test_coverage.sh b/scripts/test_coverage.sh
new file mode 100755
index 0000000..9e8ba2f
--- /dev/null
+++ b/scripts/test_coverage.sh
@@ -0,0 +1,4 @@
+#!/bin/sh
+set -e
+poetry run pytest --cov-reset --cov=check_done tests/ --cov-report html
+echo "To view results on linux/MacOS run: firefox htmlcov/index.html &"
diff --git a/tests/__init__.py b/tests/__init__.py
index e69de29..c3eafc5 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (C) 2024 by Siisurit e.U., Austria.
+# All rights reserved. Distributed under the MIT License.
diff --git a/tests/_common.py b/tests/_common.py
new file mode 100644
index 0000000..a2e3f16
--- /dev/null
+++ b/tests/_common.py
@@ -0,0 +1,92 @@
+# Copyright (C) 2024 by Siisurit e.U., Austria.
+# All rights reserved. Distributed under the MIT License.
+import os
+
+from check_done.config import (
+    github_project_owner_name_and_project_number_and_is_project_owner_of_type_organization_from_url_if_matches,
+)
+from check_done.info import (
+    AssigneesInfo,
+    GithubProjectItemType,
+    LinkedProjectItemInfo,
+    LinkedProjectItemNode,
+    MilestoneInfo,
+    ProjectItemInfo,
+    ProjectV2ItemNode,
+    ProjectV2ItemProjectStatusInfo,
+    RepositoryInfo,
+)
+
+_ENVVAR_DEMO_CHECK_DONE_GITHUB_APP_ID = "CHECK_DONE_GITHUB_APP_ID"
+_ENVVAR_DEMO_CHECK_DONE_GITHUB_APP_PRIVATE_KEY = "CHECK_DONE_GITHUB_APP_PRIVATE_KEY"
+ENVVAR_DEMO_CHECK_DONE_GITHUB_PROJECT_STATUS_NAME_TO_CHECK = "CHECK_DONE_GITHUB_PROJECT_STATUS_NAME_TO_CHECK"
+_ENVVAR_DEMO_CHECK_DONE_GITHUB_PROJECT_URL = "CHECK_DONE_GITHUB_PROJECT_URL"
+
+DEMO_CHECK_DONE_GITHUB_APP_ID = os.environ.get(_ENVVAR_DEMO_CHECK_DONE_GITHUB_APP_ID)
+DEMO_CHECK_DONE_GITHUB_APP_PRIVATE_KEY = os.environ.get(_ENVVAR_DEMO_CHECK_DONE_GITHUB_APP_PRIVATE_KEY)
+DEMO_CHECK_DONE_GITHUB_PROJECT_STATUS_NAME_TO_CHECK = os.environ.get(
+    ENVVAR_DEMO_CHECK_DONE_GITHUB_PROJECT_STATUS_NAME_TO_CHECK
+)
+DEMO_CHECK_DONE_GITHUB_PROJECT_URL = os.environ.get(_ENVVAR_DEMO_CHECK_DONE_GITHUB_PROJECT_URL)
+
+(
+    DEMO_CHECK_DONE_PROJECT_OWNER_NAME,
+    DEMO_CHECK_DONE_PROJECT_NUMBER,
+    DEMO_CHECK_DONE_IS_PROJECT_OWNER_OF_TYPE_ORGANIZATION,
+) = github_project_owner_name_and_project_number_and_is_project_owner_of_type_organization_from_url_if_matches(
+    DEMO_CHECK_DONE_GITHUB_PROJECT_URL
+)
+
+HAS_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED = (
+    DEMO_CHECK_DONE_GITHUB_PROJECT_URL is not None
+    and DEMO_CHECK_DONE_GITHUB_APP_ID is not None
+    and DEMO_CHECK_DONE_GITHUB_APP_PRIVATE_KEY is not None
+    and DEMO_CHECK_DONE_IS_PROJECT_OWNER_OF_TYPE_ORGANIZATION
+)
+REASON_SHOULD_HAVE_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED = (
+    "To enable, setup the configuration file for an organization as described in the readme, "
+    "and set the following environment variables:"
+    f"{_ENVVAR_DEMO_CHECK_DONE_GITHUB_PROJECT_URL}"
+    f"{_ENVVAR_DEMO_CHECK_DONE_GITHUB_APP_ID}"
+    f"{_ENVVAR_DEMO_CHECK_DONE_GITHUB_APP_PRIVATE_KEY}"
+    f"{_ENVVAR_DEMO_CHECK_DONE_GITHUB_APP_PRIVATE_KEY}"
+)
+
+
+def new_fake_project_v2_item_node(status: str = "Done", option_id: str = "a1", closed: bool = True):
+    return ProjectV2ItemNode(
+        __typename="ProjectV2Item",
+        content=new_fake_project_item_info(closed=closed),
+        fieldValueByName=ProjectV2ItemProjectStatusInfo(status=status, optionId=option_id),
+    )
+
+
+def new_fake_project_item_info(
+    has_no_milestone: bool = False,
+    assignees_count: int = 1,
+    body_html: str = "",
+    closed: bool = True,
+    closing_issues_references: [] = None,
+    milestone: MilestoneInfo = None,
+    number: int = 1,
+    repository: RepositoryInfo = None,
+    title: str = "fake_title",
+    typename: GithubProjectItemType = GithubProjectItemType.pull_request,
+):
+    if closing_issues_references is None:
+        closing_issues_references = [LinkedProjectItemNode(number=1, title="fake_closing_issues_references")]
+    if milestone is None and not has_no_milestone:
+        milestone = MilestoneInfo(id="fake_milestone_id")
+    if repository is None:
+        repository = RepositoryInfo(name="fake_repository")
+    return ProjectItemInfo(
+        __typename=typename,
+        assignees=AssigneesInfo(totalCount=assignees_count),
+        bodyHTML=body_html,
+        closed=closed,
+        closingIssuesReferences=LinkedProjectItemInfo(nodes=closing_issues_references),
+        milestone=milestone,
+        number=number,
+        repository=repository,
+        title=title,
+    )
diff --git a/tests/data/test_can_query_mocked_query_infos/test_can_resolve_multi_page_query_infos_page_1.json b/tests/data/test_can_query_mocked_query_infos/test_can_resolve_multi_page_query_infos_page_1.json
new file mode 100644
index 0000000..d99b6f6
--- /dev/null
+++ b/tests/data/test_can_query_mocked_query_infos/test_can_resolve_multi_page_query_infos_page_1.json
@@ -0,0 +1,19 @@
+{
+  "data": {
+    "user": {
+      "projectsV2": {
+        "nodes": [
+          {
+            "__typename": "ProjectV2",
+            "id": "dummy_id",
+            "number": 1
+          }
+        ],
+        "pageInfo": {
+          "endCursor": "AA",
+          "hasNextPage": true
+        }
+      }
+    }
+  }
+}
diff --git a/tests/data/test_can_query_mocked_query_infos/test_can_resolve_multi_page_query_infos_page_2.json b/tests/data/test_can_query_mocked_query_infos/test_can_resolve_multi_page_query_infos_page_2.json
new file mode 100644
index 0000000..f5a1360
--- /dev/null
+++ b/tests/data/test_can_query_mocked_query_infos/test_can_resolve_multi_page_query_infos_page_2.json
@@ -0,0 +1,19 @@
+{
+  "data": {
+    "user": {
+      "projectsV2": {
+        "nodes": [
+          {
+            "__typename": "ProjectV2",
+            "id": "another_dummy_id",
+            "number": 2
+          }
+        ],
+        "pageInfo": {
+          "endCursor": "AB",
+          "hasNextPage": false
+        }
+      }
+    }
+  }
+}
diff --git a/tests/data/test_can_query_mocked_query_infos/test_can_resolve_single_page_query_infos.json b/tests/data/test_can_query_mocked_query_infos/test_can_resolve_single_page_query_infos.json
new file mode 100644
index 0000000..350930c
--- /dev/null
+++ b/tests/data/test_can_query_mocked_query_infos/test_can_resolve_single_page_query_infos.json
@@ -0,0 +1,19 @@
+{
+  "data": {
+    "user": {
+      "projectsV2": {
+        "nodes": [
+          {
+            "__typename": "ProjectV2",
+            "id": "dummy_id",
+            "number": 1
+          }
+        ],
+        "pageInfo": {
+          "endCursor": "AA",
+          "hasNextPage": false
+        }
+      }
+    }
+  }
+}
diff --git a/check_done/done_project_items_info/__init__.py b/tests/data/test_configuration_map_from_yaml/fake_empty_yaml.yaml
similarity index 100%
rename from check_done/done_project_items_info/__init__.py
rename to tests/data/test_configuration_map_from_yaml/fake_empty_yaml.yaml
diff --git a/tests/data/test_configuration_map_from_yaml/fake_invalid_yaml.txt b/tests/data/test_configuration_map_from_yaml/fake_invalid_yaml.txt
new file mode 100644
index 0000000..7e91684
--- /dev/null
+++ b/tests/data/test_configuration_map_from_yaml/fake_invalid_yaml.txt
@@ -0,0 +1,1 @@
+name: "Joe Doe
diff --git a/tests/data/test_configuration_map_from_yaml/fake_valid_yaml.yaml b/tests/data/test_configuration_map_from_yaml/fake_valid_yaml.yaml
new file mode 100644
index 0000000..6aaff89
--- /dev/null
+++ b/tests/data/test_configuration_map_from_yaml/fake_valid_yaml.yaml
@@ -0,0 +1,2 @@
+name:
+  - Joe Doe
diff --git a/tests/done_project_items_info/__init__.py b/tests/done_project_items_info/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/tests/done_project_items_info/done_project_items_info.py b/tests/done_project_items_info/done_project_items_info.py
deleted file mode 100644
index 309e0f9..0000000
--- a/tests/done_project_items_info/done_project_items_info.py
+++ /dev/null
@@ -1,268 +0,0 @@
-from unittest.mock import Mock
-
-import pytest
-from pydantic import BaseModel
-from requests import HTTPError, Response
-
-from check_done.done_project_items_info.done_project_items_info import (
-    PROJECT_NUMBER,
-    GraphQlError,
-    checked_graphql_data_map,
-    done_project_items_info,
-    filtered_project_item_infos_by_done_status,
-    get_paginated_query_info_from_response_info,
-    matching_last_project_state_option_id,
-    matching_project_id,
-    minimized_graphql,
-)
-from check_done.done_project_items_info.info import (
-    GithubContentType,
-    PageInfo,
-    PaginatedQueryInfo,
-    ProjectItemInfo,
-    ProjectV2ItemNodeInfo,
-    ProjectV2ItemProjectStatusInfo,
-    ProjectV2NodeInfo,
-    ProjectV2Options,
-    ProjectV2SingleSelectFieldNodeInfo,
-    RepositoryInfo,
-)
-
-
-@pytest.mark.parametrize(
-    "input_query, expected_output",
-    [
-        # Basic whitespace removal
-        ("{ user(id: 1) { name age } }", "{user(id:1){name age}}"),
-        # Test 2: Multiple spaces and around symbols
-        ("{    user   (  id:    1   )   {   name,   age   }   }", "{user(id:1){name,age}}"),
-        # Empty string
-        ("", ""),
-        # Already minimized string
-        ("{user(id:1){name,age}}", "{user(id:1){name,age}}"),
-        # Test 5: Newlines and tabs
-        ("{\n  user (id: 1) {\n\tname\n\tage\n  }\n}", "{user(id:1){name age}}"),
-        # Complex query with variables and fragments
-        (
-            """
-                query getUser($id: ID!) {
-                  user(id: $id) {
-                    ...userFields
-                  }
-                }
-                fragment userFields on User {
-                  name
-                  age
-                  email
-                }
-                """,
-            "query getUser($id:ID!){user(id:$id){...userFields}}fragment userFields on User{name age email}",
-        ),
-    ],
-    ids=[
-        "basic_whitespace_removal",
-        "whitespace_around_symbols",
-        "empty_string",
-        "already_minimized",
-        "newlines_and_tabs",
-        "complex_query_with_fragments",
-    ],
-)
-def test_minimized_graphql(input_query, expected_output):
-    assert minimized_graphql(input_query) == expected_output
-
-
-def _mock_response(json_data=None, status_code=200, raise_for_status=None):
-    mock_resp = Mock(spec=Response)
-    mock_resp.status_code = status_code
-    mock_resp.json.return_value = json_data
-    if raise_for_status:
-        mock_resp.raise_for_status.side_effect = raise_for_status
-    else:
-        mock_resp.raise_for_status = Mock()
-    return mock_resp
-
-
-def test_can_check_graphql_data_map():
-    mock_resp = _mock_response(json_data={"data": {"key": "value"}})
-    result = checked_graphql_data_map(mock_resp)
-    assert result == {"key": "value"}
-
-
-def test_fails_to_check_graphql_data_map_on_http_error():
-    mock_resp = _mock_response(raise_for_status=HTTPError("HTTP error"))
-
-    with pytest.raises(GraphQlError, match="HTTP error"):
-        checked_graphql_data_map(mock_resp)
-
-
-def test_fails_to_check_graphql_data_map_on_invalid_json_format():
-    mock_resp = _mock_response(json_data=["not", "a", "dict"])
-    with pytest.raises(GraphQlError, match="GraphQL response must be a map"):
-        checked_graphql_data_map(mock_resp)
-
-
-def test_fails_to_check_graphql_data_map_on_graphql_errors():
-    mock_resp = _mock_response(json_data={"errors": [{"message": "Some GraphQL error"}]})
-    with pytest.raises(GraphQlError, match="Some GraphQL error"):
-        checked_graphql_data_map(mock_resp)
-
-
-def test_fails_to_check_graphql_data_map_on_missing_data():
-    mock_resp = _mock_response(json_data={"something_else": "value"})
-    with pytest.raises(GraphQlError, match="GraphQL result must include data"):
-        checked_graphql_data_map(mock_resp)
-
-
-def test_fails_to_check_graphql_data_map_on_invalid_data_format():
-    mock_resp = _mock_response(json_data={"data": ["not", "a", "dict"]})
-    with pytest.raises(GraphQlError, match="GraphQL data must be a map"):
-        checked_graphql_data_map(mock_resp)
-
-
-class _MockedInfo(BaseModel):
-    id: int
-
-
-def test_can_get_nodes_info_from_response_info():
-    mocked_node_info = PaginatedQueryInfo(nodes=[], pageInfo=PageInfo(endCursor="a", hasNextPage=True))
-    result = get_paginated_query_info_from_response_info(mocked_node_info)
-    assert isinstance(result, PaginatedQueryInfo)
-
-
-def test_fails_to_get_nodes_info_from_node_with_no_matching_node_info_type():
-    mocked_model = _MockedInfo(id=1)
-    with pytest.raises(ValueError, match="Could not find nodes info in id=1."):
-        get_paginated_query_info_from_response_info(mocked_model)
-
-
-def test_can_get_done_project_items_info():
-    assert len(done_project_items_info()) >= 1
-
-
-def test_can_find_matching_project_id_from_node_infos():
-    _matching_project_id = "b2"
-    _mock_project_id_node_infos = [
-        ProjectV2NodeInfo(id="a1", number=1, __typename="ProjectV2"),
-        ProjectV2NodeInfo(id=_matching_project_id, number=PROJECT_NUMBER, __typename="ProjectV2"),
-    ]
-
-    _project_id = matching_project_id(_mock_project_id_node_infos)
-    assert _project_id == _matching_project_id
-
-
-def test_fails_to_find_matching_project_id_from_node_infos():
-    _mock_project_id_node_infos = [
-        ProjectV2NodeInfo(id="a1", number=1, __typename="ProjectV2"),
-    ]
-    with pytest.raises(ValueError, match=f"Cannot find a project with number '{PROJECT_NUMBER}'"):
-        matching_project_id(_mock_project_id_node_infos)
-
-
-def test_can_find_matching_last_project_state_option_id():
-    _mock_matching_last_project_state_option_id = "2b"
-    _mock_last_project_state_option_id_node_infos = [
-        ProjectV2SingleSelectFieldNodeInfo(
-            id="a1",
-            name="Status",
-            __typename="ProjectV2SingleSelectField",
-            options=[
-                ProjectV2Options(id="1a", name="In Progress"),
-                ProjectV2Options(id=_mock_matching_last_project_state_option_id, name="Finished"),
-            ],
-        )
-    ]
-    _matching_last_project_state_option_id = matching_last_project_state_option_id(
-        _mock_last_project_state_option_id_node_infos
-    )
-    assert _matching_last_project_state_option_id == _mock_matching_last_project_state_option_id
-
-
-def test_fails_to_find_matching_last_project_state_option_id():
-    _mock_last_project_state_option_id_node_infos = [
-        ProjectV2SingleSelectFieldNodeInfo(
-            id="a1", name="Milestone", __typename="ProjectV2SingleSelectField", options=[]
-        )
-    ]
-    with pytest.raises(ValueError, match="Cannot find the project status selection field "):
-        _matching_last_project_state_option_id = matching_last_project_state_option_id(
-            _mock_last_project_state_option_id_node_infos
-        )
-
-
-def test_can_get_filtered_project_item_infos_by_done_status():
-    _in_progress_project_status_id = "a1"
-    _done_project_status_id = "b2"
-    _mocked_done_issues_node_infos = [
-        ProjectV2ItemNodeInfo(
-            type=GithubContentType.ISSUE,
-            content=ProjectItemInfo(
-                number=1,
-                closed=False,
-                title="Do something",
-                repository=RepositoryInfo(name="my_repo"),
-            ),
-            fieldValueByName=ProjectV2ItemProjectStatusInfo(
-                status="In Progress", optionId=_in_progress_project_status_id
-            ),
-        ),
-        ProjectV2ItemNodeInfo(
-            type=GithubContentType.ISSUE,
-            content=ProjectItemInfo(
-                number=2,
-                closed=True,
-                title="Do something else",
-                repository=RepositoryInfo(name="my_repo"),
-            ),
-            fieldValueByName=ProjectV2ItemProjectStatusInfo(status="Done", optionId=_done_project_status_id),
-        ),
-        ProjectV2ItemNodeInfo(
-            type=GithubContentType.PULL_REQUEST,
-            content=ProjectItemInfo(
-                number=3,
-                closed=False,
-                title="#1 Do something",
-                repository=RepositoryInfo(name="my_repo"),
-            ),
-            fieldValueByName=ProjectV2ItemProjectStatusInfo(
-                status="In Progress", optionId=_in_progress_project_status_id
-            ),
-        ),
-        ProjectV2ItemNodeInfo(
-            type=GithubContentType.PULL_REQUEST,
-            content=ProjectItemInfo(
-                number=4,
-                closed=True,
-                title="#2 Do something else",
-                repository=RepositoryInfo(name="my_repo"),
-            ),
-            fieldValueByName=ProjectV2ItemProjectStatusInfo(status="Done", optionId=_done_project_status_id),
-        ),
-    ]
-    _done_issue_infos = filtered_project_item_infos_by_done_status(
-        _mocked_done_issues_node_infos, _done_project_status_id
-    )
-    assert len(_done_issue_infos) == 2
-
-
-def test_fails_to_find_any_filtered_project_item_infos_by_done_status(caplog):
-    _matching_project_id = "a1"
-    _other_project_id = "b2"
-    _mocked_done_issues_node_infos = [
-        ProjectV2ItemNodeInfo(
-            type=GithubContentType.ISSUE,
-            content=ProjectItemInfo(
-                number=1,
-                closed=False,
-                title="Do something",
-                repository=RepositoryInfo(name="my_repo"),
-            ),
-            fieldValueByName=ProjectV2ItemProjectStatusInfo(status="In Progress", optionId=_other_project_id),
-        )
-    ]
-
-    _done_issue_infos = filtered_project_item_infos_by_done_status(_mocked_done_issues_node_infos, _matching_project_id)
-    assert len(_done_issue_infos) < 1
-
-    expected_log_warning = "No project items found with the last project status option selected "
-    assert expected_log_warning in caplog.text
diff --git a/tests/test_authentication.py b/tests/test_authentication.py
deleted file mode 100644
index 356bfc7..0000000
--- a/tests/test_authentication.py
+++ /dev/null
@@ -1,66 +0,0 @@
-import pytest
-import requests_mock
-
-from check_done.authentication import AuthenticationError, github_app_access_token
-
-_DEMO_INSTALLATION_ID = "installation_id"
-_DEMO_ACCESS_TOKEN = "access_token"
-
-
-def test_github_app_access_token():
-    with requests_mock.Mocker() as mock:
-        mock.get(
-            "https://api.github.com/orgs/my_organization/installation",
-            json={"id": _DEMO_INSTALLATION_ID},
-            status_code=200,
-        )
-        mock.post(
-            f"https://api.github.com/app/installations/{_DEMO_INSTALLATION_ID}/access_tokens",
-            json={"token": _DEMO_ACCESS_TOKEN},
-            status_code=201,
-        )
-        token = github_app_access_token("my_organization")
-    assert token == _DEMO_ACCESS_TOKEN
-
-
-# TODO#13: Once the authentication.py module is finalized, implement proper test for function _generated_jwt_token()
-# def test_fails_to_generate_jwt_token():
-# original_value_private_key = check_done.authentication.GITHUB_APP_PRIVATE_KEY
-# try:
-#     invalid_private_key_value = "ë"
-#     check_done.authentication.GITHUB_APP_PRIVATE_KEY = invalid_private_key_value
-#     with pytest.raises(Exception):
-#         github_app_access_token("my_organization")
-# finally:
-#     check_done.authentication.GITHUB_APP_PRIVATE_KEY = original_value_private_key
-
-
-def test_fails_to_get_installation_id():
-    with requests_mock.Mocker() as mock:
-        mock.get(
-            "https://api.github.com/orgs/my_organization/installation",
-            json={"id": _DEMO_INSTALLATION_ID},
-            status_code=400,
-        )
-        with pytest.raises(AuthenticationError) as exception_info:
-            github_app_access_token("my_organization")
-    cause_exception = str(exception_info.value.__cause__)
-    assert cause_exception.startswith("Could not retrieve installation ID: ")
-
-
-def test_fails_to_get_access_token():
-    with requests_mock.Mocker() as mock:
-        mock.get(
-            "https://api.github.com/orgs/my_organization/installation",
-            json={"id": _DEMO_INSTALLATION_ID},
-            status_code=200,
-        )
-        mock.post(
-            f"https://api.github.com/app/installations/{_DEMO_INSTALLATION_ID}/access_tokens",
-            json={"token": _DEMO_ACCESS_TOKEN},
-            status_code=400,
-        )
-        with pytest.raises(AuthenticationError) as exception_info:
-            github_app_access_token("my_organization")
-    cause_exception = str(exception_info.value.__cause__)
-    assert cause_exception.startswith("Could not retrieve access token: ")
diff --git a/tests/test_checks.py b/tests/test_checks.py
deleted file mode 100644
index c2fdd8f..0000000
--- a/tests/test_checks.py
+++ /dev/null
@@ -1,68 +0,0 @@
-from check_done.checks import has_unfinished_goals
-from check_done.done_project_items_info.info import (
-    AssigneesInfo,
-    LinkedProjectItemInfo,
-    MilestoneInfo,
-    ProjectItemInfo,
-    RepositoryInfo,
-)
-
-
-def test_can_check_for_unfinished_goals():
-    html_with_an_unfinished_goals = """
-    <h2 dir="auto">Goals</h2>
-    <ul class="contains-task-list">
-        <li class="task-list-item">
-            <input type="checkbox" id="" disabled="" class="task-list-item-checkbox"> Test 1.
-        </li>
-        <li class="task-list-item">
-            <input type="checkbox" id="" disabled="" class="task-list-item-checkbox" checked=""> Test. 2
-        </li>
-    </ul>
-    """
-    project_item_with_unfinished_goals = ProjectItemInfo(
-        assignees=AssigneesInfo(totalCount=1),
-        bodyHTML=html_with_an_unfinished_goals,
-        closed=True,
-        number=1,
-        repository=RepositoryInfo(name="test_repo"),
-        milestone=MilestoneInfo(id="1"),
-        title="Test",
-        linked_project_item=LinkedProjectItemInfo(nodes=[]),
-    )
-    html_with_an_finished_goals = """
-    <h2 dir="auto">Goals</h2>
-    <ul class="contains-task-list">
-        <li class="task-list-item">
-            <input type="checkbox" id="" disabled="" class="task-list-item-checkbox" checked=""> Test 1.
-        </li>
-        <li class="task-list-item">
-            <input type="checkbox" id="" disabled="" class="task-list-item-checkbox" checked=""> Test. 2
-        </li>
-    </ul>
-    """
-    project_item_with_finished_goals = ProjectItemInfo(
-        assignees=AssigneesInfo(totalCount=1),
-        bodyHTML=html_with_an_finished_goals,
-        closed=True,
-        number=1,
-        repository=RepositoryInfo(name="test_repo"),
-        milestone=MilestoneInfo(id="1"),
-        title="Test",
-        linked_project_item=LinkedProjectItemInfo(nodes=[]),
-    )
-    empty_html_body = ""
-    project_item_with_empty_html_body = ProjectItemInfo(
-        assignees=AssigneesInfo(totalCount=1),
-        bodyHTML=empty_html_body,
-        closed=True,
-        number=1,
-        repository=RepositoryInfo(name="test_repo"),
-        milestone=MilestoneInfo(id="1"),
-        title="Test",
-        linked_project_item=LinkedProjectItemInfo(nodes=[]),
-    )
-
-    assert has_unfinished_goals(project_item_with_unfinished_goals) is True
-    assert has_unfinished_goals(project_item_with_finished_goals) is False
-    assert has_unfinished_goals(project_item_with_empty_html_body) is False
diff --git a/tests/test_command.py b/tests/test_command.py
index 96866d0..9550d25 100644
--- a/tests/test_command.py
+++ b/tests/test_command.py
@@ -1,2 +1,165 @@
-def test_can_test():
-    assert True
+# Copyright (C) 2024 by Siisurit e.U., Austria.
+# All rights reserved. Distributed under the MIT License.
+import logging
+import os
+import re
+import subprocess
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from check_done.command import check_done_command
+from check_done.config import resolve_root_repository_path
+from tests._common import (
+    HAS_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+    REASON_SHOULD_HAVE_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+)
+
+
+def test_can_show_help():
+    with pytest.raises(SystemExit) as error_info:
+        check_done_command(["--help"])
+    assert error_info.value.code == 0
+
+
+def test_can_show_version():
+    with pytest.raises(SystemExit) as error_info:
+        check_done_command(["--version"])
+    assert error_info.value.code == 0
+
+
+@pytest.mark.skipif(
+    not HAS_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+    reason=REASON_SHOULD_HAVE_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+)
+def test_can_set_root_dir_argument():
+    root_repository_path = str(resolve_root_repository_path())
+    exit_code = check_done_command([f"--root-dir={root_repository_path}"])
+    assert exit_code == 0
+
+
+@pytest.mark.skipif(
+    not HAS_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+    reason=REASON_SHOULD_HAVE_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+)
+def test_can_execute_check_done_command_and_get_warnings(caplog):
+    exit_code = check_done_command([])
+    assert exit_code == 0
+    check_done_warning_messages = len(caplog.messages)
+    assert check_done_warning_messages >= 1
+
+
+@pytest.mark.skipif(
+    not HAS_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+    reason=REASON_SHOULD_HAVE_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+)
+def test_can_check_done_demo_project(caplog):
+    exit_code = check_done_command([])
+    assert exit_code == 0
+
+    check_done_warning_messages = len(caplog.messages)
+    assert check_done_warning_messages >= 1
+
+    expected_warning_about_missing_assignee = r"(?=.*be assigned)(?=.*#3 Warning: Closed issue without assignee)"
+    warning_about_missing_assignee = caplog.messages[0]
+    assert re.search(expected_warning_about_missing_assignee, warning_about_missing_assignee)
+
+    expected_warning_about_open_issue_with_dene_state = r"(?=.*be closed)(?=.*#4 Warning: Open issue with Done state)"
+    warning_about_open_issue_with_dene_state = caplog.messages[1]
+    assert re.search(expected_warning_about_open_issue_with_dene_state, warning_about_open_issue_with_dene_state)
+
+    expected_warning_about_missing_milestone = (
+        r"(?=.*have a milestone)(?=.*#11 Warning: Project item without milestone)"
+    )
+    warning_about_missing_milestone = caplog.messages[2]
+    assert re.search(expected_warning_about_missing_milestone, warning_about_missing_milestone)
+
+    expected_warning_about_unfinished_goals = (
+        r"(?=.*have all tasks completed)(?=.*#8 Warning: project item with unfinished goals)"
+    )
+    warning_about_unfinished_goals = caplog.messages[3]
+    assert re.search(expected_warning_about_unfinished_goals, warning_about_unfinished_goals)
+
+    expected_warning_about_pull_request_with_missing_closing_issue_reference = (
+        r"(?=.*have a closing issue reference)(?=.*#6 Warning: Pull request is missing linked issue)"
+    )
+    warning_about_pull_request_with_missing_closing_issue_reference = caplog.messages[4]
+    assert re.search(
+        expected_warning_about_pull_request_with_missing_closing_issue_reference,
+        warning_about_pull_request_with_missing_closing_issue_reference,
+    )
+
+    expected_warning_about_open_pull_request_with_done_state = (
+        r"(?=.*be closed)(?=.*#7 Warning: Open pull request with Done state)"
+    )
+    warning_about_open_pull_request_with_done_state = caplog.messages[5]
+    assert re.search(
+        expected_warning_about_open_pull_request_with_done_state,
+        warning_about_open_pull_request_with_done_state,
+    )
+
+    ok_project_item_titles = [
+        "#2 Ok: Closed issue without PR",
+        "#10 Ok: Properly closed issue with Done state",
+        "#12 Ok: Project item with finished goals",
+        "#5 Ok: Pull Request has linked issue",
+    ]
+    assert not any(
+        ok_project_item_title in warning_message
+        for ok_project_item_title in ok_project_item_titles
+        for warning_message in caplog.messages
+    )
+
+
+@pytest.mark.skipif(
+    not HAS_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+    reason=REASON_SHOULD_HAVE_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+)
+def test_main_script():
+    path = Path(__file__).resolve().parent.parent / "check_done" / "command.py"
+    command = f"poetry run python {path!s}"
+    result = subprocess.run(command, capture_output=True, text=True, shell=True, executable="/bin/bash", check=False)
+    assert "Checking project items" in result.stderr
+
+
+def test_keyboard_interrupt_handling():
+    with patch("check_done.command.Command.execute", side_effect=KeyboardInterrupt):
+        exit_code = check_done_command([])
+        assert exit_code == 1
+
+
+def test_general_exception_handling():
+    with patch("check_done.command.Command.execute", side_effect=Exception("Fake exception")):
+        exit_code = check_done_command([])
+        assert exit_code == 1
+
+
+@pytest.mark.skipif(
+    not HAS_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+    reason=REASON_SHOULD_HAVE_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+)
+def test_can_log_info_message_if_selected_project_status_is_empty(caplog):
+    with caplog.at_level(logging.INFO):
+        envvar_name = "CHECK_DONE_GITHUB_PROJECT_STATUS_NAME_TO_CHECK"
+        original_envar_value = os.environ[envvar_name]
+        empty_project_status_name_in_check_done_demo_project = "In progress"
+        os.environ[envvar_name] = empty_project_status_name_in_check_done_demo_project
+        check_done_command([])
+        os.environ[envvar_name] = original_envar_value
+        assert "Nothing to check. Project has no items in the selected project status." in caplog.messages[1]
+
+
+@pytest.mark.skipif(
+    not HAS_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+    reason=REASON_SHOULD_HAVE_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+)
+def test_can_log_info_message_if_no_warnings_were_found_for_checked_project_items(caplog):
+    with caplog.at_level(logging.INFO):
+        envvar_name = "CHECK_DONE_GITHUB_PROJECT_STATUS_NAME_TO_CHECK"
+        original_envar_value = os.environ[envvar_name]
+        project_status_name_in_check_done_demo_project_with_only_correct_issues = "Archived"
+        os.environ[envvar_name] = project_status_name_in_check_done_demo_project_with_only_correct_issues
+        check_done_command([])
+        os.environ[envvar_name] = original_envar_value
+        assert "All project items are correct" in caplog.messages[1]
diff --git a/tests/test_common.py b/tests/test_common.py
deleted file mode 100644
index b1e186b..0000000
--- a/tests/test_common.py
+++ /dev/null
@@ -1,98 +0,0 @@
-import os
-from pathlib import Path
-
-import pytest
-import yaml
-
-from check_done.common import (
-    ProjectOwnerType,
-    config_map_from_yaml_file,
-    github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches,
-    resolved_environment_variables,
-)
-
-
-def test_can_load_valid_yaml(mocker):
-    valid_yaml = """
-    name:
-      - Joe Doe
-    """
-    mocker.patch("os.path.exists", return_value=True)
-    mock_open = mocker.mock_open(read_data=valid_yaml)
-    mocker.patch("builtins.open", mock_open)
-    path = Path("valid.yaml")
-    result = config_map_from_yaml_file(path)
-    expected_result = {"name": ["Joe Doe"]}
-    assert result == expected_result
-    mock_open.assert_called_once_with(path)
-
-
-def test_fails_to_load_configuration_file(mocker):
-    mocker.patch("os.path.exists", return_value=False)
-    path = Path("non_existent.yaml")
-    with pytest.raises(FileNotFoundError, match="Cannot find check_done configuration: "):
-        config_map_from_yaml_file(path)
-
-
-def test_fails_on_invalid_yaml_syntax(mocker):
-    invalid_yaml = """
-    name: "Joe Doe
-    """
-    mocker.patch("os.path.exists", return_value=True)
-    mocker.patch("builtins.open", mocker.mock_open(read_data=invalid_yaml))
-    path = Path("invalid.yaml")
-    with pytest.raises(yaml.YAMLError):
-        config_map_from_yaml_file(path)
-
-
-def test_fails_to_load_empty_yaml_config_file(mocker):
-    mocker.patch("builtins.open", mocker.mock_open(read_data=""))
-    mocker.patch("os.path.exists", return_value=True)
-    path = Path("empty_config.yaml")
-    with pytest.raises(ValueError, match="The check_done configuration is empty. Path: "):
-        config_map_from_yaml_file(path)
-
-
-def test_can_resolve_environment_variables():
-    envvar_name = "TEST_CAN_RESOLVE_FROM"
-    os.environ[envvar_name] = "hello"
-    assert resolved_environment_variables(f"${{{envvar_name}}}") == "hello"
-
-
-def test_fails_to_resolve_non_existent_environment_variable():
-    with pytest.raises(ValueError):
-        resolved_environment_variables("${__no_such_environment_variable__}", True)
-
-
-def test_fails_to_resolve_environment_variables_with_syntax_error():
-    with pytest.raises(ValueError):
-        resolved_environment_variables("${")
-
-
-def test_can_extract_github_project_owner_type_and_project_owner_name_and_project_number_from_url():
-    assert github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(
-        "https://github.com/orgs/example-org/projects/1"
-    ) == (ProjectOwnerType.Organization, "example-org", 1)
-    assert github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(
-        "https://github.com/orgs/example-org/projects/1/some-other-stuff"
-    ) == (ProjectOwnerType.Organization, "example-org", 1)
-    assert github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(
-        "https://github.com/users/example-username/projects/1"
-    ) == (ProjectOwnerType.User, "example-username", 1)
-    assert github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(
-        "https://github.com/users/example-username/projects/1/some-other-stuff"
-    ) == (ProjectOwnerType.User, "example-username", 1)
-
-
-def test_fails_to_extract_github_project_owner_type_and_project_owner_name_and_project_number_from_url():
-    urls = [
-        "https://github.com/orgs/",
-        "https://github.com/users/",
-        "https://github.com/orgs/example-org/projects/",
-        "https://github.com/users/example-username/projects/",
-        "",
-    ]
-    for url in urls:
-        expected_error_message = rf"Cannot parse GitHub organization or user name, and project number from URL: {url}."
-        with pytest.raises(ValueError, match=expected_error_message):
-            github_project_owner_type_and_project_owner_name_and_project_number_from_url_if_matches(url)
diff --git a/tests/test_config.py b/tests/test_config.py
new file mode 100644
index 0000000..7670ffd
--- /dev/null
+++ b/tests/test_config.py
@@ -0,0 +1,191 @@
+# Copyright (C) 2024 by Siisurit e.U., Austria.
+# All rights reserved. Distributed under the MIT License.
+import os
+from pathlib import Path
+
+import pytest
+import yaml
+
+from check_done.config import (
+    CONFIG_FILE_NAME,
+    ConfigurationInfo,
+    github_project_owner_name_and_project_number_and_is_project_owner_of_type_organization_from_url_if_matches,
+    map_from_yaml_file_path,
+    resolve_configuration_yaml_file_path_from_root_path,
+    resolve_root_repository_path,
+    resolved_environment_variables,
+)
+
+_TEST_DATA_BASE_FOLDER_FOR_MAP_FROM_YAML_FILE_TESTS = (
+    Path(__file__).parent / "data" / "test_configuration_map_from_yaml"
+)
+
+
+def test_can_resolve_map_from_valid_yaml_file_path():
+    expected_result = {"name": ["Joe Doe"]}
+    valid_yaml_path = _TEST_DATA_BASE_FOLDER_FOR_MAP_FROM_YAML_FILE_TESTS / "fake_valid_yaml.yaml"
+    result = map_from_yaml_file_path(valid_yaml_path)
+    assert result == expected_result
+
+
+def test_fails_to_resolve_map_from_missing_yaml_file_path():
+    missing_yaml_path = _TEST_DATA_BASE_FOLDER_FOR_MAP_FROM_YAML_FILE_TESTS / "some_missing_yaml.yaml"
+    with pytest.raises(FileNotFoundError, match="Cannot find check_done configuration yaml file: "):
+        map_from_yaml_file_path(missing_yaml_path)
+
+
+def test_fails_to_resolve_map_from_yaml_file_on_invalid_yaml_syntax():
+    invalid_yaml_path = _TEST_DATA_BASE_FOLDER_FOR_MAP_FROM_YAML_FILE_TESTS / "fake_invalid_yaml.txt"
+    with pytest.raises(yaml.YAMLError):
+        map_from_yaml_file_path(invalid_yaml_path)
+
+
+def test_fails_to_load_empty_yaml_config_file():
+    empty_yaml_path = _TEST_DATA_BASE_FOLDER_FOR_MAP_FROM_YAML_FILE_TESTS / "fake_empty_yaml.yaml"
+    with pytest.raises(ValueError, match="The check_done configuration yaml file is empty. Path: "):
+        map_from_yaml_file_path(empty_yaml_path)
+
+
+def test_can_resolve_environment_variables():
+    envvar_name = "TEST_CAN_RESOLVE_FROM"
+    os.environ[envvar_name] = "hello"
+    assert resolved_environment_variables(f"${{{envvar_name}}}") == "hello"
+
+
+def test_can_pass_on_missing_environment_variable():
+    envvar_name = "MISSING_ENVIRONMENT_VARIABLE"
+    assert resolved_environment_variables(f"${{{envvar_name}}}", fail_on_missing_envvar=False) is None
+
+
+def test_fails_to_resolve_non_existent_environment_variable():
+    with pytest.raises(ValueError):
+        resolved_environment_variables("${__no_such_environment_variable__}", True)
+
+
+def test_fails_to_resolve_environment_variables_with_syntax_error():
+    with pytest.raises(ValueError):
+        resolved_environment_variables("${")
+
+
+def test_can_extract_github_project_owner_name_and_project_number_and_is_project_owner_of_type_organization_from_url():
+    assert github_project_owner_name_and_project_number_and_is_project_owner_of_type_organization_from_url_if_matches(
+        "https://github.com/orgs/example-org/projects/1"
+    ) == ("example-org", 1, True)
+    assert github_project_owner_name_and_project_number_and_is_project_owner_of_type_organization_from_url_if_matches(
+        "https://github.com/orgs/example-org/projects/1/some-other-stuff"
+    ) == ("example-org", 1, True)
+    assert github_project_owner_name_and_project_number_and_is_project_owner_of_type_organization_from_url_if_matches(
+        "https://github.com/users/example-username/projects/1"
+    ) == ("example-username", 1, False)
+    assert github_project_owner_name_and_project_number_and_is_project_owner_of_type_organization_from_url_if_matches(
+        "https://github.com/users/example-username/projects/1/some-other-stuff"
+    ) == ("example-username", 1, False)
+
+
+def test_fails_to_extract_github_project_owner_name_and_project_number_and_is_project_owner_of_type_organization():
+    urls = [
+        "https://github.com/orgs/",
+        "https://github.com/users/",
+        "https://github.com/orgs/example-org/projects/",
+        "https://github.com/users/example-username/projects/",
+        "",
+    ]
+    for url in urls:
+        expected_error_message = rf"Cannot parse GitHub organization or user name, and project number from URL: {url}."
+        with pytest.raises(ValueError, match=expected_error_message):
+            github_project_owner_name_and_project_number_and_is_project_owner_of_type_organization_from_url_if_matches(
+                url
+            )
+
+
+def test_can_validate_at_least_one_type_of_authentication_is_properly_configured():
+    fake_user_project_url = "https://github.com/users/fake-username/projects/1"
+    fake_configuration_info_with_user_project = ConfigurationInfo(
+        project_url=fake_user_project_url,
+        personal_access_token="fake_personal_token",
+    )
+    assert fake_configuration_info_with_user_project.project_url == fake_user_project_url
+    assert fake_configuration_info_with_user_project.personal_access_token == "fake_personal_token"
+
+    fake_organization_project_url = "https://github.com/orgs/fake-organization/projects/1"
+    fake_configuration_info_with_organization_project = ConfigurationInfo(
+        project_url="https://github.com/orgs/fake-organization/projects/1",
+        check_done_github_app_id="fake_app_id",
+        check_done_github_app_private_key="fake_private_key",
+    )
+    assert fake_configuration_info_with_organization_project.project_url == fake_organization_project_url
+    assert fake_configuration_info_with_organization_project.check_done_github_app_id == "fake_app_id"
+    assert fake_configuration_info_with_organization_project.check_done_github_app_private_key == "fake_private_key"
+
+
+def test_fails_on_no_authentication_method_configured():
+    with pytest.raises(ValueError, match="A user or an organization authentication method must be configured."):
+        ConfigurationInfo(
+            project_url="https://github.com/users/fake-username/projects/1",
+        )
+
+
+def test_fails_on_organization_authentication_method_missing_private_key():
+    with pytest.raises(ValueError, match="A user or an organization authentication method must be configured."):
+        ConfigurationInfo(
+            project_url="https://github.com/orgs/fake-organization/projects/1",
+            check_done_github_app_id="fake_check_done_github_app_id",
+        )
+
+
+def test_fails_on_organization_authentication_method_missing_app_id():
+    with pytest.raises(ValueError, match="A user or an organization authentication method must be configured."):
+        ConfigurationInfo(
+            project_url="https://github.com/orgs/fake-organization/projects/1",
+            check_done_github_app_private_key="fake_check_done_github_app_private_key",
+        )
+
+
+def test_can_resolve_value_from_env():
+    envvar_name = "FAKE_CHECK_DONE_GITHUB_PROJECT_URL"
+    envvar_value = "https://github.com/users/fake-username/projects/1"
+    os.environ[envvar_name] = envvar_value
+    configuration_info_with_env_value = ConfigurationInfo(
+        project_url="${FAKE_CHECK_DONE_GITHUB_PROJECT_URL}",
+        personal_access_token="fake_personal_token",
+    )
+    assert configuration_info_with_env_value.project_url == envvar_value
+
+
+def test_can_resolve_project_details_from_user_project_url():
+    configuration_info = ConfigurationInfo(
+        project_url="https://github.com/users/fake-username/projects/1",
+        personal_access_token="fake_personal_token",
+    )
+    assert not configuration_info.is_project_owner_of_type_organization
+    assert configuration_info.project_number == 1
+    assert configuration_info.project_owner_name == "fake-username"
+
+
+def test_can_resolve_project_details_from_organization_project_url():
+    configuration_info = ConfigurationInfo(
+        project_url="https://github.com/orgs/fake-organization-name/projects/1/views/2",
+        check_done_github_app_id="fake_check_done_github_app_id",
+        check_done_github_app_private_key="fake_check_done_github_app_private_key",
+    )
+    assert configuration_info.is_project_owner_of_type_organization
+    assert configuration_info.project_number == 1
+    assert configuration_info.project_owner_name == "fake-organization-name"
+
+
+def test_can_resolve_root_repository_path():
+    root_repository_path = resolve_root_repository_path()
+    assert "check_done" in str(root_repository_path)
+
+
+def test_can_resolve_configuration_yaml_file_path_from_root_path():
+    root_path = resolve_root_repository_path()
+    resolved_configuration_yaml_file_path_from_root_path = resolve_configuration_yaml_file_path_from_root_path(
+        root_path
+    )
+    assert root_path / CONFIG_FILE_NAME == resolved_configuration_yaml_file_path_from_root_path
+
+
+def test_fails_to_resolve_configuration_yaml_file_path_from_root_path():
+    with pytest.raises(FileNotFoundError):
+        resolve_configuration_yaml_file_path_from_root_path(Path("dummy_root_path"))
diff --git a/tests/test_done_project_items_info.py b/tests/test_done_project_items_info.py
new file mode 100644
index 0000000..ada40b4
--- /dev/null
+++ b/tests/test_done_project_items_info.py
@@ -0,0 +1,193 @@
+# Copyright (C) 2024 by Siisurit e.U., Austria.
+# All rights reserved. Distributed under the MIT License.
+import os
+
+import pytest
+
+from check_done.config import ConfigurationInfo
+from check_done.done_project_items_info import (
+    done_project_items_info,
+    filtered_project_item_infos_by_done_status,
+    matching_project_id,
+    matching_project_state_option_id,
+)
+from check_done.info import (
+    ProjectV2Node,
+    ProjectV2Options,
+    ProjectV2SingleSelectFieldNode,
+)
+from tests._common import (
+    DEMO_CHECK_DONE_GITHUB_APP_ID,
+    DEMO_CHECK_DONE_GITHUB_APP_PRIVATE_KEY,
+    DEMO_CHECK_DONE_GITHUB_PROJECT_STATUS_NAME_TO_CHECK,
+    DEMO_CHECK_DONE_GITHUB_PROJECT_URL,
+    ENVVAR_DEMO_CHECK_DONE_GITHUB_PROJECT_STATUS_NAME_TO_CHECK,
+    HAS_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+    REASON_SHOULD_HAVE_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+    new_fake_project_v2_item_node,
+)
+
+_HAS_PROJECT_STATUS_NAME_TO_CHECK = DEMO_CHECK_DONE_GITHUB_PROJECT_STATUS_NAME_TO_CHECK is not None
+_REASON_SHOULD_HAVE_SET_ENV_PROJECT_STATUS_NAME_TO_CHECK = (
+    f"To enable, set {ENVVAR_DEMO_CHECK_DONE_GITHUB_PROJECT_STATUS_NAME_TO_CHECK}."
+)
+_ENVVAR_DEMO_CHECK_DONE_PERSONAL_ACCESS_TOKEN = "CHECK_DONE_PERSONAL_ACCESS_TOKEN"
+_DEMO_CHECK_DONE_PERSONAL_ACCESS_TOKEN = os.environ.get(_ENVVAR_DEMO_CHECK_DONE_PERSONAL_ACCESS_TOKEN)
+_ENVVAR_DEMO_CHECK_DONE_USER_GITHUB_PROJECT_URL = "CHECK_DONE_USER_GITHUB_PROJECT_URL"
+_DEMO_CHECK_DONE_USER_GITHUB_PROJECT_URL = os.environ.get(_ENVVAR_DEMO_CHECK_DONE_USER_GITHUB_PROJECT_URL)
+_REASON_SHOULD_HAVE_USER_PROJECT_CONFIGURED = (
+    f"To enable, set the environment variable {_ENVVAR_DEMO_CHECK_DONE_PERSONAL_ACCESS_TOKEN}, "
+    f"and {_ENVVAR_DEMO_CHECK_DONE_USER_GITHUB_PROJECT_URL} to a user owned project URL."
+)
+
+
+@pytest.mark.skipif(
+    not HAS_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+    reason=REASON_SHOULD_HAVE_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+)
+def test_can_resolve_done_project_items_info():
+    fake_configuration_info = ConfigurationInfo(
+        project_url=DEMO_CHECK_DONE_GITHUB_PROJECT_URL,
+        check_done_github_app_id=DEMO_CHECK_DONE_GITHUB_APP_ID,
+        check_done_github_app_private_key=DEMO_CHECK_DONE_GITHUB_APP_PRIVATE_KEY,
+    )
+    assert len(done_project_items_info(fake_configuration_info)) >= 1
+
+
+@pytest.mark.skipif(
+    not _DEMO_CHECK_DONE_USER_GITHUB_PROJECT_URL and not _DEMO_CHECK_DONE_PERSONAL_ACCESS_TOKEN,
+    reason=_REASON_SHOULD_HAVE_USER_PROJECT_CONFIGURED,
+)
+def test_can_resolve_done_project_items_info_for_user_project():
+    fake_configuration_info = ConfigurationInfo(
+        project_url=_DEMO_CHECK_DONE_USER_GITHUB_PROJECT_URL,
+        personal_access_token=_DEMO_CHECK_DONE_PERSONAL_ACCESS_TOKEN,
+    )
+    assert len(done_project_items_info(fake_configuration_info)) >= 1
+
+
+def test_can_find_matching_project_id():
+    expected_to_match_project_id = "b2"
+    project_number = 1
+    another_project_number = 256
+    fake_project_id_node_infos = [
+        ProjectV2Node(id="a1", number=another_project_number, __typename="ProjectV2"),
+        ProjectV2Node(id=expected_to_match_project_id, number=project_number, __typename="ProjectV2"),
+    ]
+
+    matched_project_id = matching_project_id(
+        fake_project_id_node_infos, project_number=project_number, project_owner_name="dummy_project_owner_name"
+    )
+    assert matched_project_id == expected_to_match_project_id
+
+
+def test_fails_to_find_matching_project_id():
+    project_number = 1
+    another_project_number = 256
+    fake_project_id_node_infos = [
+        ProjectV2Node(id="a1", number=another_project_number, __typename="ProjectV2"),
+    ]
+    with pytest.raises(ValueError, match="Cannot find a project with number "):
+        matching_project_id(fake_project_id_node_infos, project_number, "dummy_project_owner_name")
+
+
+def test_can_find_matching_project_state_option_id():
+    expected_to_math_project_status_option_id = "2b"
+    fake_matching_project_state_option_name = "Finished"
+    fake_last_project_state_option_id_node_infos = [
+        ProjectV2SingleSelectFieldNode(
+            id="a1",
+            name="Status",
+            __typename="ProjectV2SingleSelectField",
+            options=[
+                ProjectV2Options(id="1a", name="In Progress"),
+                ProjectV2Options(
+                    id=expected_to_math_project_status_option_id, name=fake_matching_project_state_option_name
+                ),
+            ],
+        )
+    ]
+    matched_project_status_option_id = matching_project_state_option_id(
+        fake_last_project_state_option_id_node_infos,
+        fake_matching_project_state_option_name,
+        1,
+        "dummy_project_owner_name",
+    )
+    assert matched_project_status_option_id == expected_to_math_project_status_option_id
+
+
+@pytest.mark.skipif(
+    not _HAS_PROJECT_STATUS_NAME_TO_CHECK,
+    reason=_REASON_SHOULD_HAVE_SET_ENV_PROJECT_STATUS_NAME_TO_CHECK,
+)
+def test_fails_to_find_matching_project_state_option_id():
+    wrongly_assumed_matching_project_state_option_name = "Backlog"
+    fake_last_project_state_option_id_node_infos = [
+        ProjectV2SingleSelectFieldNode(
+            id="a1",
+            name="Status",
+            __typename="ProjectV2SingleSelectField",
+            options=[
+                ProjectV2Options(id="1a", name="In Progress"),
+                ProjectV2Options(id="2b", name=DEMO_CHECK_DONE_GITHUB_PROJECT_STATUS_NAME_TO_CHECK),
+            ],
+        )
+    ]
+    with pytest.raises(
+        ValueError,
+        match=f"Cannot find the project status matching name '{wrongly_assumed_matching_project_state_option_name}' ",
+    ):
+        matching_project_state_option_id(
+            fake_last_project_state_option_id_node_infos,
+            wrongly_assumed_matching_project_state_option_name,
+            1,
+            "dummy_project_owner_name",
+        )
+
+
+def test_can_find_matching_last_project_state_option_id():
+    expected_to_match_project_state_option_id = "2b"
+    fake_last_project_state_option_id_node_infos = [
+        ProjectV2SingleSelectFieldNode(
+            id="a1",
+            name="Status",
+            __typename="ProjectV2SingleSelectField",
+            options=[
+                ProjectV2Options(id="1a", name="In Progress"),
+                ProjectV2Options(id=expected_to_match_project_state_option_id, name="Finished"),
+            ],
+        )
+    ]
+    matching_last_project_state_option_id = matching_project_state_option_id(
+        fake_last_project_state_option_id_node_infos,
+        None,
+        1,
+        "dummy_project_owner_name",
+    )
+    assert matching_last_project_state_option_id == expected_to_match_project_state_option_id
+
+
+def test_fails_to_find_matching_last_project_state_option_id():
+    fake_last_project_state_option_id_node_infos = [
+        ProjectV2SingleSelectFieldNode(id="a1", name="Milestone", __typename="ProjectV2SingleSelectField", options=[])
+    ]
+    with pytest.raises(ValueError, match="Cannot find a project status selection field "):
+        matching_project_state_option_id(
+            fake_last_project_state_option_id_node_infos,
+            None,
+            1,
+            "dummy_project_owner_name",
+        )
+
+
+def test_can_resolve_filtered_project_item_infos_by_done_status():
+    in_progress_project_status_id = "a1"
+    done_project_status_id = "b2"
+    fake_done_issues_node_infos = [
+        new_fake_project_v2_item_node(option_id=in_progress_project_status_id, closed=True),
+        new_fake_project_v2_item_node(option_id=in_progress_project_status_id, closed=False),
+        new_fake_project_v2_item_node(option_id=done_project_status_id, closed=True),
+        new_fake_project_v2_item_node(option_id=done_project_status_id, closed=False),
+    ]
+    done_issue_infos = filtered_project_item_infos_by_done_status(fake_done_issues_node_infos, done_project_status_id)
+    assert len(done_issue_infos) == 2
diff --git a/tests/test_graphql.py b/tests/test_graphql.py
new file mode 100644
index 0000000..28f0509
--- /dev/null
+++ b/tests/test_graphql.py
@@ -0,0 +1,191 @@
+# Copyright (C) 2024 by Siisurit e.U., Austria.
+# All rights reserved. Distributed under the MIT License.
+import json
+from pathlib import Path
+from unittest.mock import Mock
+
+import pytest
+import requests
+import requests_mock
+from pydantic import BaseModel
+from requests import HTTPError, Response
+
+from check_done.graphql import (
+    GRAPHQL_ENDPOINT,
+    GraphQlError,
+    GraphQlQuery,
+    checked_graphql_data_map,
+    minimized_graphql,
+    query_info_from_response_info,
+    query_infos,
+)
+from check_done.info import (
+    PageInfo,
+    ProjectOwnerInfo,
+    ProjectV2Node,
+    QueryInfo,
+)
+
+
+class _FakeModelWithQueryInfoField(BaseModel):
+    query_info: QueryInfo
+
+
+class _FakeModelContainingQueryInfoModel(BaseModel):
+    another_dummy_mode: _FakeModelWithQueryInfoField
+
+
+class _FakeModelInfo(BaseModel):
+    id: int
+
+
+def test_can_minimize_graphql_with_simple_white_space():
+    input_query = "{ user(id: 1) { name age } }"
+    expected_output = "{user(id:1){name age}}"
+    assert minimized_graphql(input_query) == expected_output
+
+
+def test_can_minimize_graphql_with_multiple_spaces_around_symbols():
+    input_query = "{    user   (  id:    1   )   {   name,   age   }   }"
+    expected_output = "{user(id:1){name,age}}"
+    assert minimized_graphql(input_query) == expected_output
+
+
+def test_can_minimize_graphql_with_already_minimal_string():
+    input_query = "{user(id:1){name,age}}"
+    expected_output = "{user(id:1){name,age}}"
+    assert minimized_graphql(input_query) == expected_output
+
+
+def test_can_minimize_graphql_with_newlines_and_tabs():
+    input_query = "{\n  user (id: 1) {\n\tname\n\tage\n  }\n}"
+    expected_output = "{user(id:1){name age}}"
+    assert minimized_graphql(input_query) == expected_output
+
+
+def test_can_minimize_complex_graphql_query_with_fragments_and_variables():
+    input_query = """
+                query getUser($id: ID!) {
+                  user(id: $id) {
+                    ...userFields
+                  }
+                }
+                fragment userFields on User {
+                  name
+                  age
+                  email
+                }
+                """
+    expected_output = "query getUser($id:ID!){user(id:$id){...userFields}}fragment userFields on User{name age email}"
+    assert minimized_graphql(input_query) == expected_output
+
+
+def test_fails_to_minimize_empty_graphql_query():
+    with pytest.raises(GraphQlError, match="GraphQL query is empty."):
+        minimized_graphql("")
+
+
+def test_can_check_graphql_data_map():
+    mock_response = _mock_response(json_data={"data": {"key": "value"}})
+    result = checked_graphql_data_map(mock_response)
+    assert result == {"key": "value"}
+
+
+def test_fails_to_check_graphql_data_map_on_http_error():
+    mock_response = _mock_response(raise_for_status=HTTPError("HTTP error"))
+    with pytest.raises(GraphQlError, match="HTTP error"):
+        checked_graphql_data_map(mock_response)
+
+
+def test_fails_to_check_graphql_data_map_on_invalid_json_format():
+    mock_response = _mock_response(json_data=["not", "a", "dict"])
+    with pytest.raises(GraphQlError, match="GraphQL response must be a map"):
+        checked_graphql_data_map(mock_response)
+
+
+def test_fails_to_check_graphql_data_map_on_graphql_errors():
+    mock_response = _mock_response(json_data={"errors": [{"message": "Some GraphQL error"}]})
+    with pytest.raises(GraphQlError, match="Some GraphQL error"):
+        checked_graphql_data_map(mock_response)
+
+
+def test_fails_to_check_graphql_data_map_on_missing_data():
+    mock_response = _mock_response(json_data={"something_else": "value"})
+    with pytest.raises(GraphQlError, match="GraphQL result must include data"):
+        checked_graphql_data_map(mock_response)
+
+
+def test_fails_to_check_graphql_data_map_on_invalid_data_format():
+    mock_response = _mock_response(json_data={"data": ["not", "a", "dict"]})
+    with pytest.raises(GraphQlError, match="GraphQL data must be a map"):
+        checked_graphql_data_map(mock_response)
+
+
+def test_can_resolve_query_info_from_response_info():
+    fake_query_info = QueryInfo(nodes=[], pageInfo=PageInfo(endCursor="a", hasNextPage=True))
+    query_info = query_info_from_response_info(fake_query_info)
+    assert query_info.nodes == fake_query_info.nodes
+    assert query_info.page_info.endCursor == fake_query_info.page_info.endCursor
+    assert query_info.page_info.hasNextPage
+    assert isinstance(query_info, QueryInfo)
+
+
+def test_can_resolve_nested_query_info_from_response_info():
+    fake_query_info = QueryInfo(nodes=[], pageInfo=PageInfo(endCursor="a", hasNextPage=True))
+    fake_nested_query_info = _FakeModelContainingQueryInfoModel(
+        another_dummy_mode=_FakeModelWithQueryInfoField(query_info=fake_query_info)
+    )
+    query_info = query_info_from_response_info(fake_nested_query_info)
+    assert query_info.nodes == fake_query_info.nodes
+    assert query_info.page_info.endCursor == fake_query_info.page_info.endCursor
+    assert query_info.page_info.hasNextPage
+    assert isinstance(query_info, QueryInfo)
+
+
+def test_fails_to_resolve_query_info_from_response_info_with_no_query_info():
+    fake_model = _FakeModelInfo(id=1)
+    with pytest.raises(ValueError, match="Could not find fields matching the "):
+        query_info_from_response_info(fake_model)
+
+
+def test_can_resolve_mocked_single_page_query_infos():
+    mocked_result = _mocked_query_infos_from_json_files(["test_can_resolve_single_page_query_infos"])
+    assert len(mocked_result) == 1
+    assert isinstance(mocked_result[0], ProjectV2Node)
+
+
+def test_can_resolve_multi_page_query_infos():
+    mocked_result = _mocked_query_infos_from_json_files(
+        ["test_can_resolve_multi_page_query_infos_page_1", "test_can_resolve_multi_page_query_infos_page_2"]
+    )
+    assert len(mocked_result) > 1
+    assert isinstance(mocked_result[0], ProjectV2Node)
+    assert isinstance(mocked_result[1], ProjectV2Node)
+
+
+def _mocked_query_infos_from_json_files(json_files_base_name: list[str]) -> list:
+    test_data_base_folder = Path(__file__).parent / "data" / "test_can_query_mocked_query_infos"
+    with requests_mock.Mocker() as mock:
+        response_list = []
+        for json_file_base_name in json_files_base_name:
+            json_mock_path = test_data_base_folder / (json_file_base_name + ".json")
+            with json_mock_path.open() as json_mock_info_file:
+                json_mock_data = json.load(json_mock_info_file)
+                response_list.append({"json": json_mock_data})
+        mock.post(GRAPHQL_ENDPOINT, response_list=response_list)
+        dummy_session = requests.Session()
+        result = query_infos(
+            ProjectOwnerInfo, GraphQlQuery.USER_PROJECTS.name, dummy_session, "dummy_project_owner_name"
+        )
+    return result
+
+
+def _mock_response(json_data=None, status_code=200, raise_for_status=None):
+    mock_response = Mock(spec=Response)
+    mock_response.status_code = status_code
+    mock_response.json.return_value = json_data
+    if raise_for_status:
+        mock_response.raise_for_status.side_effect = raise_for_status
+    else:
+        mock_response.raise_for_status = Mock()
+    return mock_response
diff --git a/tests/test_info.py b/tests/test_info.py
new file mode 100644
index 0000000..e081f93
--- /dev/null
+++ b/tests/test_info.py
@@ -0,0 +1,33 @@
+# Copyright (C) 2024 by Siisurit e.U., Austria.
+# All rights reserved. Distributed under the MIT License.
+from check_done.info import (
+    PageInfo,
+    ProjectV2ItemNode,
+    ProjectV2Node,
+    ProjectV2SingleSelectFieldNode,
+    QueryInfo,
+)
+
+
+def test_can_resolve_nodes():
+    fake_nodes = [
+        {"__typename": "ProjectV2", "id": "fake_id", "number": 1},
+        {
+            "__typename": "ProjectV2SingleSelectField",
+            "id": "fake_id",
+            "name": "fake_name",
+            "options": [{"id": "another_fake_id", "name": "another_fake_name"}],
+        },
+        {"__typename": "ProjectV2Item", "type": "ISSUE", "content": {}, "field_value_by_name": None},
+    ]
+    fake_page_info = PageInfo(endCursor="a", hasNextPage=False)
+    fake_query_info = QueryInfo(nodes=fake_nodes, pageInfo=fake_page_info)
+
+    fake_project_v2_node = fake_query_info.nodes[0]
+    assert isinstance(fake_project_v2_node, ProjectV2Node)
+
+    fake_project_v2_single_select_field_node = fake_query_info.nodes[1]
+    assert isinstance(fake_project_v2_single_select_field_node, ProjectV2SingleSelectFieldNode)
+
+    fake_project_v2_item_node = fake_query_info.nodes[2]
+    assert isinstance(fake_project_v2_item_node, ProjectV2ItemNode)
diff --git a/tests/test_organization_authentication.py b/tests/test_organization_authentication.py
new file mode 100644
index 0000000..00353b7
--- /dev/null
+++ b/tests/test_organization_authentication.py
@@ -0,0 +1,112 @@
+# Copyright (C) 2024 by Siisurit e.U., Austria.
+# All rights reserved. Distributed under the MIT License.
+import pytest
+import requests
+import requests_mock
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+
+from check_done.graphql import HttpBearerAuth
+from check_done.organization_authentication import (
+    AuthenticationError,
+    generate_jwt_token,
+    resolve_check_done_github_app_installation_id,
+    resolve_organization_access_token,
+)
+from tests._common import (
+    DEMO_CHECK_DONE_GITHUB_APP_ID,
+    DEMO_CHECK_DONE_GITHUB_APP_PRIVATE_KEY,
+    DEMO_CHECK_DONE_PROJECT_OWNER_NAME,
+    HAS_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+    REASON_SHOULD_HAVE_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+)
+
+_DUMMY_GITHUB_APP_ID = "dummy_github_app_id"
+_DUMMY_ORGANIZATION_NAME = "dummy_organization_name"
+_DUMMY_ACCESS_TOKEN = "dummy_access_token"
+_FAKE_PRIVATE_KEY = rsa.generate_private_key(public_exponent=65537, key_size=1024)
+_FAKE_PEM_PRIVATE_KEY = _FAKE_PRIVATE_KEY.private_bytes(
+    encoding=serialization.Encoding.PEM,
+    format=serialization.PrivateFormat.PKCS8,
+    encryption_algorithm=serialization.NoEncryption(),
+).decode("utf-8")
+
+
+@pytest.mark.skipif(
+    not HAS_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+    reason=REASON_SHOULD_HAVE_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+)
+def test_can_resolve_organization_access_token():
+    resolve_organization_access_token(
+        DEMO_CHECK_DONE_PROJECT_OWNER_NAME, DEMO_CHECK_DONE_GITHUB_APP_ID, DEMO_CHECK_DONE_GITHUB_APP_PRIVATE_KEY
+    )
+
+
+def test_can_resolve_organization_access_token_from_bad_requests():
+    with requests_mock.Mocker() as mock:
+        mock.get(
+            f"https://api.github.com/orgs/{_DUMMY_ORGANIZATION_NAME}/installation",
+            json={"id": _DUMMY_GITHUB_APP_ID},
+            status_code=200,
+        )
+        mock.post(
+            f"https://api.github.com/app/installations/{_DUMMY_GITHUB_APP_ID}/access_tokens",
+            json={"token": _DUMMY_ACCESS_TOKEN},
+            status_code=201,
+        )
+        token = resolve_organization_access_token(_DUMMY_ORGANIZATION_NAME, _DUMMY_GITHUB_APP_ID, _FAKE_PEM_PRIVATE_KEY)
+    assert token == _DUMMY_ACCESS_TOKEN
+
+
+def test_fails_to_generate_jwt_token():
+    invalid_private_key_value = ""
+    with pytest.raises(Exception, match="Cannot generate JWT token: Could not parse the provided public key."):
+        generate_jwt_token(_DUMMY_GITHUB_APP_ID, invalid_private_key_value)
+
+
+def test_can_generate_jwt_token():
+    fake_jwt_token = generate_jwt_token(_DUMMY_GITHUB_APP_ID, _FAKE_PEM_PRIVATE_KEY)
+    assert isinstance(fake_jwt_token, str)
+
+
+@pytest.mark.skipif(
+    not HAS_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+    reason=REASON_SHOULD_HAVE_DEMO_CHECK_DONE_ORGANIZATION_PROJECT_CONFIGURED,
+)
+def test_can_resolve_check_done_github_app_installation_id():
+    session = _session()
+    fake_installation_id = resolve_check_done_github_app_installation_id(session, DEMO_CHECK_DONE_PROJECT_OWNER_NAME)
+    assert isinstance(fake_installation_id, int)
+
+
+def test_fails_to_resolve_check_done_github_app_installation_id():
+    session = _session()
+    with pytest.raises(AuthenticationError, match="Could not retrieve installation ID: status=404 "):
+        resolve_check_done_github_app_installation_id(session, _DUMMY_ORGANIZATION_NAME)
+
+
+def test_fails_to_resolve_check_done_github_app_installation_id_from_bad_request():
+    with requests_mock.Mocker() as mock:
+        mock.get(f"https://api.github.com/orgs/{_DUMMY_ORGANIZATION_NAME}/installation", status_code=400)
+        with pytest.raises(AuthenticationError, match="Could not retrieve installation ID: status=400 "):
+            resolve_organization_access_token(_DUMMY_ORGANIZATION_NAME, _DUMMY_GITHUB_APP_ID, _FAKE_PEM_PRIVATE_KEY)
+
+
+def test_fails_to_resolve_access_token_from_check_done_github_app_installation_id():
+    with requests_mock.Mocker() as mock:
+        mock.get(
+            f"https://api.github.com/orgs/{_DUMMY_ORGANIZATION_NAME}/installation",
+            json={"id": _DUMMY_GITHUB_APP_ID},
+            status_code=200,
+        )
+        mock.post(f"https://api.github.com/app/installations/{_DUMMY_GITHUB_APP_ID}/access_tokens", status_code=400)
+        with pytest.raises(AuthenticationError, match="Could not retrieve access token: status=400 "):
+            resolve_organization_access_token(_DUMMY_ORGANIZATION_NAME, _DUMMY_GITHUB_APP_ID, _FAKE_PEM_PRIVATE_KEY)
+
+
+def _session():
+    jwt_token = generate_jwt_token(DEMO_CHECK_DONE_GITHUB_APP_ID, DEMO_CHECK_DONE_GITHUB_APP_PRIVATE_KEY)
+    session = requests.Session()
+    session.headers = {"Accept": "application/vnd.github+json"}
+    session.auth = HttpBearerAuth(jwt_token)
+    return session
diff --git a/tests/test_warning_checks.py b/tests/test_warning_checks.py
new file mode 100644
index 0000000..a6126cb
--- /dev/null
+++ b/tests/test_warning_checks.py
@@ -0,0 +1,149 @@
+# Copyright (C) 2024 by Siisurit e.U., Austria.
+# All rights reserved. Distributed under the MIT License.
+from check_done.info import GithubProjectItemType
+from check_done.warning_checks import (
+    sentence_from_project_item_warning_reasons,
+    warning_reason_if_missing_closing_issue_reference_in_pull_request,
+    warning_reason_if_missing_milestone,
+    warning_reason_if_open,
+    warning_reason_if_tasks_are_uncompleted,
+    warning_reason_if_unassigned,
+    warnings_for_done_project_items,
+)
+from tests._common import new_fake_project_item_info
+
+
+def test_can_resolve_warnings_for_done_project_items():
+    fake_done_project_items = [
+        new_fake_project_item_info(closed=False),
+        new_fake_project_item_info(assignees_count=0, closing_issues_references=[]),
+        new_fake_project_item_info(has_no_milestone=True, assignees_count=0, closing_issues_references=[]),
+    ]
+    fake_warnings = warnings_for_done_project_items(fake_done_project_items)
+
+    not_closed_warning = fake_warnings[0]
+    assert "should be closed." in not_closed_warning
+
+    not_assigned_and_pull_request_is_missing_closing_issue_reference_warning = fake_warnings[1]
+    assert (
+        "should be assigned and have a closing issue reference."
+        in not_assigned_and_pull_request_is_missing_closing_issue_reference_warning
+    )
+
+    not_assigned_and_missing_milestone_and_pull_request_is_missing_closing_issue_reference_warning = fake_warnings[2]
+    assert (
+        "should be assigned, have a milestone, and have a closing issue reference."
+        in not_assigned_and_missing_milestone_and_pull_request_is_missing_closing_issue_reference_warning
+    )
+
+
+def test_warnings_for_done_project_items_with_empty_list_of_done_project_items():
+    empty_fake_warnings = warnings_for_done_project_items([])
+    assert len(empty_fake_warnings) == 0
+
+
+def test_can_compose_sentence_from_project_item_warning_reasons():
+    project_item_with_one_warning_reason = new_fake_project_item_info(closing_issues_references=[])
+    single_warning_reason_string = sentence_from_project_item_warning_reasons(
+        project_item_with_one_warning_reason, ["have a closing issue reference"]
+    )
+    assert "Project item should have a closing issue reference." in single_warning_reason_string
+
+    project_item_with_two_warning_reasons = new_fake_project_item_info(assignees_count=0, closing_issues_references=[])
+    double_warning_reason_string = sentence_from_project_item_warning_reasons(
+        project_item_with_two_warning_reasons, ["be assigned", "have a closing issue reference"]
+    )
+    assert "be assigned and have a closing issue reference." in double_warning_reason_string
+
+    project_item_with_three_warning_reasons = new_fake_project_item_info(
+        has_no_milestone=True, assignees_count=0, milestone=None, closing_issues_references=[]
+    )
+    triple_warning_reason_string = sentence_from_project_item_warning_reasons(
+        project_item_with_three_warning_reasons,
+        ["be assigned", "have a milestone", "have a closing issue reference"],
+    )
+    assert "be assigned, have a milestone, and have a closing issue reference." in triple_warning_reason_string
+
+
+def test_can_return_warning_reason_if_project_item_is_open():
+    open_project_item = new_fake_project_item_info(closed=False)
+    assert warning_reason_if_open(open_project_item) is not None
+
+    closed_project_item = new_fake_project_item_info(closed=True)
+    assert warning_reason_if_open(closed_project_item) is None
+
+
+def test_can_return_warning_reason_if_project_item_is_unassigned():
+    unassigned_project_item = new_fake_project_item_info(assignees_count=0)
+    assert warning_reason_if_unassigned(unassigned_project_item) is not None
+
+    assigned_project_item = new_fake_project_item_info(assignees_count=1)
+    assert warning_reason_if_unassigned(assigned_project_item) is None
+
+
+def test_can_return_warning_reason_if_project_item_is_missing_milestone():
+    project_item_with_missing_milestone = new_fake_project_item_info(has_no_milestone=True)
+    assert warning_reason_if_missing_milestone(project_item_with_missing_milestone) is not None
+
+    project_item_with_milestone = new_fake_project_item_info(has_no_milestone=False)
+    assert warning_reason_if_missing_milestone(project_item_with_milestone) is None
+
+
+def test_can_return_warning_reason_if_project_item_has_uncompleted_tasks():
+    html_with_an_uncompleted_task = """
+    <h2 dir="auto">Goals</h2>
+    <ul class="contains-task-list">
+        <li class="task-list-item">
+            <input type="checkbox" id="" disabled="" class="task-list-item-checkbox"> Test 1.
+        </li>
+        <li class="task-list-item">
+            <input type="checkbox" id="" disabled="" class="task-list-item-checkbox" checked=""> Test. 2
+        </li>
+    </ul>
+    """
+
+    project_item_with_uncompleted_task = new_fake_project_item_info(body_html=html_with_an_uncompleted_task)
+    assert warning_reason_if_tasks_are_uncompleted(project_item_with_uncompleted_task) is not None
+
+    html_with_completed_tasks = """
+    <h2 dir="auto">Goals</h2>
+    <ul class="contains-task-list">
+        <li class="task-list-item">
+            <input type="checkbox" id="" disabled="" class="task-list-item-checkbox" checked=""> Test 1.
+        </li>
+        <li class="task-list-item">
+            <input type="checkbox" id="" disabled="" class="task-list-item-checkbox" checked=""> Test. 2
+        </li>
+    </ul>
+    """
+    project_item_with_completed_tasks = new_fake_project_item_info(body_html=html_with_completed_tasks)
+    assert warning_reason_if_tasks_are_uncompleted(project_item_with_completed_tasks) is None
+
+    project_item_with_empty_html_body = new_fake_project_item_info()
+    assert warning_reason_if_tasks_are_uncompleted(project_item_with_empty_html_body) is None
+
+
+def test_can_return_warning_reason_if_project_item_is_missing_closing_issue_reference_in_pul_request():
+    pull_request_with_missing_closing_issue_reference = new_fake_project_item_info(closing_issues_references=[])
+    assert (
+        warning_reason_if_missing_closing_issue_reference_in_pull_request(
+            pull_request_with_missing_closing_issue_reference
+        )
+        is not None
+    )
+
+    issue_is_missing_closed_by_pull_request_reference = new_fake_project_item_info(
+        typename=GithubProjectItemType.issue, closing_issues_references=[]
+    )
+    assert (
+        warning_reason_if_missing_closing_issue_reference_in_pull_request(
+            issue_is_missing_closed_by_pull_request_reference
+        )
+        is None
+    )
+
+    pull_request_with_closing_issue_reference = new_fake_project_item_info()
+    assert (
+        warning_reason_if_missing_closing_issue_reference_in_pull_request(pull_request_with_closing_issue_reference)
+        is None
+    )