diff --git a/.flake8 b/.flake8
index 9705b0356a1934a128a512b20e40ec680472b87c..f5883816b62a531fb55b5fd3db72470b6ab57e49 100644
--- a/.flake8
+++ b/.flake8
@@ -2,5 +2,5 @@
 max-line-length = 99
 max-doc-length = 99
 docstring-convention=google
-exclude = python, tests/*, __init__.py, src/aura_engine/client/*
+exclude = python, tests/*, __init__.py, src/aura_engine_api/*, src/aura_steering_api/*, src/aura_tank_api/*
 ignore = E121,E123,E126,E203,E226,E24,E704,W503,N802,D105,D107,D200,D202,D212,D417
diff --git a/.gitignore b/.gitignore
index 3caf830408aa39cf3592ce5bb469cc16c12e2953..2bb85a609a5dacda35c3c8d062a5448cce2bdcf8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@ docker.env
 /.vscode/tags
 /.local
 /.cache
+/.build
 /.bash_history
 /config/engine.ini
 /config/systemd/dev/
diff --git a/.openapi-client-steering.yml b/.openapi-client-steering.yml
new file mode 100644
index 0000000000000000000000000000000000000000..2122f947ec5e50a432ecd6060c183f234602ca6f
--- /dev/null
+++ b/.openapi-client-steering.yml
@@ -0,0 +1,2 @@
+project_name_override: .build
+package_name_override: aura_steering_api
\ No newline at end of file
diff --git a/.openapi-client-tank.yml b/.openapi-client-tank.yml
new file mode 100644
index 0000000000000000000000000000000000000000..56571b0cf2baaa9648f13a681763ed47841b2579
--- /dev/null
+++ b/.openapi-client-tank.yml
@@ -0,0 +1,2 @@
+project_name_override: .build
+package_name_override: aura_tank_api
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 366aa4064f738c17c78c561470f920357d2b3a3d..5fe97aa1571db04d8f7f531bb93bc2af2a6e6a55 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -12,4 +12,13 @@
     ],
     "python.linting.enabled": true,
     "python.linting.flake8Enabled": true,
+    "python.testing.unittestArgs": [
+        "-v",
+        "-s",
+        "./tests",
+        "-p",
+        "test_*.py"
+    ],
+    "python.testing.pytestEnabled": false,
+    "python.testing.unittestEnabled": true,
 }
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9ba4589624d504505dc29f4a02004e983d40182c..9530e37201b520a0c4f6823b202afaa617b78947 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Added
 
-- ...
+- API responses from Steering and Tank are now cached in `cache_dir`
 
 ### Changed
 
diff --git a/Makefile b/Makefile
index 012da2f6a4abc6f45a60b43a0816c5e39121f668..c8525d456bfcfb7202f0b1714c7f31d8387326ad 100644
--- a/Makefile
+++ b/Makefile
@@ -5,6 +5,7 @@ help::
 	@echo "$(APP_NAME) targets:"
 	@echo "    init.app        - init application environment"
 	@echo "    init.dev        - init development environment"
+	@echo "    api             - build models for API requests/responses"
 	@echo "    lint            - verify code style"
 	@echo "    spell           - check spelling of text"
 	@echo "    format          - apply automatic formatting"
@@ -50,12 +51,26 @@ DOCKER_RUN = @docker run \
 init.app:: pyproject.toml
 	poetry install
 	cp -n config/sample.engine.ini config/engine.ini
+	mkdir -p .cache
 
 init.dev:: pyproject.toml
 	poetry install --with dev
 	poetry run pre-commit autoupdate
 	poetry run pre-commit install
 	cp -n config/sample.engine.ini config/engine.ini
+	mkdir -p .cache
+
+api::
+	rm -rf .build
+	poetry run openapi-python-client generate --path schemas/openapi-tank.json --config .openapi-client-tank.yml
+	cp -r .build/aura_tank_api/models src/aura_tank_api
+	cp .build/aura_tank_api/py.typed src/aura_tank_api
+	cp .build/aura_tank_api/types.py src/aura_tank_api
+	rm -rf .build
+	poetry run openapi-python-client generate --path schemas/openapi-steering.json --config .openapi-client-steering.yml
+	cp -r .build/aura_steering_api/models src/aura_steering_api
+	cp .build/aura_steering_api/py.typed src/aura_steering_api
+	cp .build/aura_steering_api/types.py src/aura_steering_api
 
 lint::
 	poetry run python3 -m flake8 .
diff --git a/config/sample.engine.docker.ini b/config/sample.engine.docker.ini
index 74f6bec3027d04c3ef8be3ea56d76e89943a16db..5527aab963269a573f83cec63ef0fb82355d2303 100644
--- a/config/sample.engine.docker.ini
+++ b/config/sample.engine.docker.ini
@@ -5,6 +5,8 @@
 [general]
 # Path to the engine-core socket directory relative to the engine project root
 socket_dir="/srv/socket"
+# Directory to store temporary data
+cache_dir="/tmp"
 # Directory where the log file resides
 log_dir="logs"
 # Possible values: debug, info, warning, error, critical
diff --git a/config/sample.engine.ini b/config/sample.engine.ini
index 7e4f4cd88c5c1963fa45b31e055c4e746f9a61f9..dce26d656d741d13505d43723cf47f9c53e0769d 100644
--- a/config/sample.engine.ini
+++ b/config/sample.engine.ini
@@ -5,6 +5,8 @@
 [general]
 # Path to the engine-core socket directory relative to the engine project root
 socket_dir="../engine-core/socket"
+# Directory to store temporary data
+cache_dir="./.cache"
 # Directory where the log file resides
 log_dir="logs"
 # Possible values: debug, info, warning, error, critical
diff --git a/docs/developer-guide.md b/docs/developer-guide.md
index b123ce80a878c8c629231433fd62dc8b67402b1a..925900624b3c17f658e369b0eed60bd55f594fe1 100644
--- a/docs/developer-guide.md
+++ b/docs/developer-guide.md
@@ -80,6 +80,18 @@ Test cases are located in `./tests` are executed by running:
 
 You can find the AURA API definition at https://api.aura.radio
 
+Engine utilizes the Steering API and Tank API to query scheduling and playlist data.
+
+The OpenAPI specification for these APIs are located in `schemas` and used for the client models.
+
+To generate the API client models run
+
+```bash
+make api
+```
+
+Find the generated models in the packages `aura_steering_api` and `aura_tank_api`.
+
 ## Scheduler
 
 Scheduling is split into multiple phases. Below you see a timeline with one timeslot planned at a certain
diff --git a/poetry.lock b/poetry.lock
index 0c8dccd73bb234df186c7bf55c2bafa64e3ab5ab..6598fce4f2a4ce4d3cd0b22c7e7c5939ca2f6c66 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,33 +1,103 @@
 # This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand.
 
+[[package]]
+name = "anyio"
+version = "3.6.2"
+description = "High level compatibility layer for multiple asynchronous event loop implementations"
+category = "main"
+optional = false
+python-versions = ">=3.6.2"
+files = [
+    {file = "anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"},
+    {file = "anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"},
+]
+
+[package.dependencies]
+idna = ">=2.8"
+sniffio = ">=1.1"
+
+[package.extras]
+doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
+test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"]
+trio = ["trio (>=0.16,<0.22)"]
+
+[[package]]
+name = "attrs"
+version = "23.1.0"
+description = "Classes Without Boilerplate"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"},
+    {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"},
+]
+
+[package.extras]
+cov = ["attrs[tests]", "coverage[toml] (>=5.3)"]
+dev = ["attrs[docs,tests]", "pre-commit"]
+docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"]
+tests = ["attrs[tests-no-zope]", "zope-interface"]
+tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+
+[[package]]
+name = "autoflake"
+version = "1.7.8"
+description = "Removes unused imports and unused variables"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "autoflake-1.7.8-py3-none-any.whl", hash = "sha256:46373ef69b6714f5064c923bb28bd797c4f8a9497f557d87fc36665c6d956b39"},
+    {file = "autoflake-1.7.8.tar.gz", hash = "sha256:e7e46372dee46fa1c97acf310d99d922b63d369718a270809d7c278d34a194cf"},
+]
+
+[package.dependencies]
+pyflakes = ">=1.1.0,<3"
+tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
+
 [[package]]
 name = "black"
-version = "22.12.0"
+version = "23.3.0"
 description = "The uncompromising code formatter."
-category = "dev"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "black-22.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eedd20838bd5d75b80c9f5487dbcb06836a43833a37846cf1d8c1cc01cef59d"},
-    {file = "black-22.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:159a46a4947f73387b4d83e87ea006dbb2337eab6c879620a3ba52699b1f4351"},
-    {file = "black-22.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d30b212bffeb1e252b31dd269dfae69dd17e06d92b87ad26e23890f3efea366f"},
-    {file = "black-22.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:7412e75863aa5c5411886804678b7d083c7c28421210180d67dfd8cf1221e1f4"},
-    {file = "black-22.12.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c116eed0efb9ff870ded8b62fe9f28dd61ef6e9ddd28d83d7d264a38417dcee2"},
-    {file = "black-22.12.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1f58cbe16dfe8c12b7434e50ff889fa479072096d79f0a7f25e4ab8e94cd8350"},
-    {file = "black-22.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d86c9f3db9b1bf6761244bc0b3572a546f5fe37917a044e02f3166d5aafa7d"},
-    {file = "black-22.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:82d9fe8fee3401e02e79767016b4907820a7dc28d70d137eb397b92ef3cc5bfc"},
-    {file = "black-22.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101c69b23df9b44247bd88e1d7e90154336ac4992502d4197bdac35dd7ee3320"},
-    {file = "black-22.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:559c7a1ba9a006226f09e4916060982fd27334ae1998e7a38b3f33a37f7a2148"},
-    {file = "black-22.12.0-py3-none-any.whl", hash = "sha256:436cc9167dd28040ad90d3b404aec22cedf24a6e4d7de221bec2730ec0c97bcf"},
-    {file = "black-22.12.0.tar.gz", hash = "sha256:229351e5a18ca30f447bf724d007f890f97e13af070bb6ad4c0a441cd7596a2f"},
+    {file = "black-23.3.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:0945e13506be58bf7db93ee5853243eb368ace1c08a24c65ce108986eac65915"},
+    {file = "black-23.3.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:67de8d0c209eb5b330cce2469503de11bca4085880d62f1628bd9972cc3366b9"},
+    {file = "black-23.3.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:7c3eb7cea23904399866c55826b31c1f55bbcd3890ce22ff70466b907b6775c2"},
+    {file = "black-23.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32daa9783106c28815d05b724238e30718f34155653d4d6e125dc7daec8e260c"},
+    {file = "black-23.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:35d1381d7a22cc5b2be2f72c7dfdae4072a3336060635718cc7e1ede24221d6c"},
+    {file = "black-23.3.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:a8a968125d0a6a404842fa1bf0b349a568634f856aa08ffaff40ae0dfa52e7c6"},
+    {file = "black-23.3.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c7ab5790333c448903c4b721b59c0d80b11fe5e9803d8703e84dcb8da56fec1b"},
+    {file = "black-23.3.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:a6f6886c9869d4daae2d1715ce34a19bbc4b95006d20ed785ca00fa03cba312d"},
+    {file = "black-23.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f3c333ea1dd6771b2d3777482429864f8e258899f6ff05826c3a4fcc5ce3f70"},
+    {file = "black-23.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:11c410f71b876f961d1de77b9699ad19f939094c3a677323f43d7a29855fe326"},
+    {file = "black-23.3.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:1d06691f1eb8de91cd1b322f21e3bfc9efe0c7ca1f0e1eb1db44ea367dff656b"},
+    {file = "black-23.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50cb33cac881766a5cd9913e10ff75b1e8eb71babf4c7104f2e9c52da1fb7de2"},
+    {file = "black-23.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e114420bf26b90d4b9daa597351337762b63039752bdf72bf361364c1aa05925"},
+    {file = "black-23.3.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:48f9d345675bb7fbc3dd85821b12487e1b9a75242028adad0333ce36ed2a6d27"},
+    {file = "black-23.3.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:714290490c18fb0126baa0fca0a54ee795f7502b44177e1ce7624ba1c00f2331"},
+    {file = "black-23.3.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:064101748afa12ad2291c2b91c960be28b817c0c7eaa35bec09cc63aa56493c5"},
+    {file = "black-23.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:562bd3a70495facf56814293149e51aa1be9931567474993c7942ff7d3533961"},
+    {file = "black-23.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:e198cf27888ad6f4ff331ca1c48ffc038848ea9f031a3b40ba36aced7e22f2c8"},
+    {file = "black-23.3.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:3238f2aacf827d18d26db07524e44741233ae09a584273aa059066d644ca7b30"},
+    {file = "black-23.3.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:f0bd2f4a58d6666500542b26354978218a9babcdc972722f4bf90779524515f3"},
+    {file = "black-23.3.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:92c543f6854c28a3c7f39f4d9b7694f9a6eb9d3c5e2ece488c327b6e7ea9b266"},
+    {file = "black-23.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a150542a204124ed00683f0db1f5cf1c2aaaa9cc3495b7a3b5976fb136090ab"},
+    {file = "black-23.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:6b39abdfb402002b8a7d030ccc85cf5afff64ee90fa4c5aebc531e3ad0175ddb"},
+    {file = "black-23.3.0-py3-none-any.whl", hash = "sha256:ec751418022185b0c1bb7d7736e6933d40bbb14c14a0abcf9123d1b159f98dd4"},
+    {file = "black-23.3.0.tar.gz", hash = "sha256:1c7b8d606e728a41ea1ccbd7264677e494e87cf630e399262ced92d4a8dac940"},
 ]
 
 [package.dependencies]
 click = ">=8.0.0"
 mypy-extensions = ">=0.4.3"
+packaging = ">=22.0"
 pathspec = ">=0.9.0"
 platformdirs = ">=2"
-tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""}
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
 
 [package.extras]
 colorama = ["colorama (>=0.4.3)"]
@@ -37,14 +107,14 @@ uvloop = ["uvloop (>=0.15.2)"]
 
 [[package]]
 name = "certifi"
-version = "2022.12.7"
+version = "2023.5.7"
 description = "Python package for providing Mozilla's CA Bundle."
 category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
-    {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"},
-    {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"},
+    {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"},
+    {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"},
 ]
 
 [[package]]
@@ -61,100 +131,87 @@ files = [
 
 [[package]]
 name = "charset-normalizer"
-version = "3.0.1"
+version = "3.1.0"
 description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
 category = "main"
 optional = false
-python-versions = "*"
+python-versions = ">=3.7.0"
 files = [
-    {file = "charset-normalizer-3.0.1.tar.gz", hash = "sha256:ebea339af930f8ca5d7a699b921106c6e29c617fe9606fa7baa043c1cdae326f"},
-    {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88600c72ef7587fe1708fd242b385b6ed4b8904976d5da0893e31df8b3480cb6"},
-    {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c75ffc45f25324e68ab238cb4b5c0a38cd1c3d7f1fb1f72b5541de469e2247db"},
-    {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:db72b07027db150f468fbada4d85b3b2729a3db39178abf5c543b784c1254539"},
-    {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62595ab75873d50d57323a91dd03e6966eb79c41fa834b7a1661ed043b2d404d"},
-    {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff6f3db31555657f3163b15a6b7c6938d08df7adbfc9dd13d9d19edad678f1e8"},
-    {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:772b87914ff1152b92a197ef4ea40efe27a378606c39446ded52c8f80f79702e"},
-    {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70990b9c51340e4044cfc394a81f614f3f90d41397104d226f21e66de668730d"},
-    {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:292d5e8ba896bbfd6334b096e34bffb56161c81408d6d036a7dfa6929cff8783"},
-    {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2edb64ee7bf1ed524a1da60cdcd2e1f6e2b4f66ef7c077680739f1641f62f555"},
-    {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:31a9ddf4718d10ae04d9b18801bd776693487cbb57d74cc3458a7673f6f34639"},
-    {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:44ba614de5361b3e5278e1241fda3dc1838deed864b50a10d7ce92983797fa76"},
-    {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:12db3b2c533c23ab812c2b25934f60383361f8a376ae272665f8e48b88e8e1c6"},
-    {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c512accbd6ff0270939b9ac214b84fb5ada5f0409c44298361b2f5e13f9aed9e"},
-    {file = "charset_normalizer-3.0.1-cp310-cp310-win32.whl", hash = "sha256:502218f52498a36d6bf5ea77081844017bf7982cdbe521ad85e64cabee1b608b"},
-    {file = "charset_normalizer-3.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:601f36512f9e28f029d9481bdaf8e89e5148ac5d89cffd3b05cd533eeb423b59"},
-    {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0298eafff88c99982a4cf66ba2efa1128e4ddaca0b05eec4c456bbc7db691d8d"},
-    {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a8d0fc946c784ff7f7c3742310cc8a57c5c6dc31631269876a88b809dbeff3d3"},
-    {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:87701167f2a5c930b403e9756fab1d31d4d4da52856143b609e30a1ce7160f3c"},
-    {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e76c0f23218b8f46c4d87018ca2e441535aed3632ca134b10239dfb6dadd6b"},
-    {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c0a590235ccd933d9892c627dec5bc7511ce6ad6c1011fdf5b11363022746c1"},
-    {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c7fe7afa480e3e82eed58e0ca89f751cd14d767638e2550c77a92a9e749c317"},
-    {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79909e27e8e4fcc9db4addea88aa63f6423ebb171db091fb4373e3312cb6d603"},
-    {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7b6a045b814cf0c47f3623d21ebd88b3e8cf216a14790b455ea7ff0135d18"},
-    {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:72966d1b297c741541ca8cf1223ff262a6febe52481af742036a0b296e35fa5a"},
-    {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:f9d0c5c045a3ca9bedfc35dca8526798eb91a07aa7a2c0fee134c6c6f321cbd7"},
-    {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5995f0164fa7df59db4746112fec3f49c461dd6b31b841873443bdb077c13cfc"},
-    {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4a8fcf28c05c1f6d7e177a9a46a1c52798bfe2ad80681d275b10dcf317deaf0b"},
-    {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:761e8904c07ad053d285670f36dd94e1b6ab7f16ce62b9805c475b7aa1cffde6"},
-    {file = "charset_normalizer-3.0.1-cp311-cp311-win32.whl", hash = "sha256:71140351489970dfe5e60fc621ada3e0f41104a5eddaca47a7acb3c1b851d6d3"},
-    {file = "charset_normalizer-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9ab77acb98eba3fd2a85cd160851816bfce6871d944d885febf012713f06659c"},
-    {file = "charset_normalizer-3.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:84c3990934bae40ea69a82034912ffe5a62c60bbf6ec5bc9691419641d7d5c9a"},
-    {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74292fc76c905c0ef095fe11e188a32ebd03bc38f3f3e9bcb85e4e6db177b7ea"},
-    {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c95a03c79bbe30eec3ec2b7f076074f4281526724c8685a42872974ef4d36b72"},
-    {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c39b0e3eac288fedc2b43055cfc2ca7a60362d0e5e87a637beac5d801ef478"},
-    {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df2c707231459e8a4028eabcd3cfc827befd635b3ef72eada84ab13b52e1574d"},
-    {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93ad6d87ac18e2a90b0fe89df7c65263b9a99a0eb98f0a3d2e079f12a0735837"},
-    {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:59e5686dd847347e55dffcc191a96622f016bc0ad89105e24c14e0d6305acbc6"},
-    {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:cd6056167405314a4dc3c173943f11249fa0f1b204f8b51ed4bde1a9cd1834dc"},
-    {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:083c8d17153ecb403e5e1eb76a7ef4babfc2c48d58899c98fcaa04833e7a2f9a"},
-    {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:f5057856d21e7586765171eac8b9fc3f7d44ef39425f85dbcccb13b3ebea806c"},
-    {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:7eb33a30d75562222b64f569c642ff3dc6689e09adda43a082208397f016c39a"},
-    {file = "charset_normalizer-3.0.1-cp36-cp36m-win32.whl", hash = "sha256:95dea361dd73757c6f1c0a1480ac499952c16ac83f7f5f4f84f0658a01b8ef41"},
-    {file = "charset_normalizer-3.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:eaa379fcd227ca235d04152ca6704c7cb55564116f8bc52545ff357628e10602"},
-    {file = "charset_normalizer-3.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3e45867f1f2ab0711d60c6c71746ac53537f1684baa699f4f668d4c6f6ce8e14"},
-    {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cadaeaba78750d58d3cc6ac4d1fd867da6fc73c88156b7a3212a3cd4819d679d"},
-    {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:911d8a40b2bef5b8bbae2e36a0b103f142ac53557ab421dc16ac4aafee6f53dc"},
-    {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:503e65837c71b875ecdd733877d852adbc465bd82c768a067badd953bf1bc5a3"},
-    {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a60332922359f920193b1d4826953c507a877b523b2395ad7bc716ddd386d866"},
-    {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:16a8663d6e281208d78806dbe14ee9903715361cf81f6d4309944e4d1e59ac5b"},
-    {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a16418ecf1329f71df119e8a65f3aa68004a3f9383821edcb20f0702934d8087"},
-    {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9d9153257a3f70d5f69edf2325357251ed20f772b12e593f3b3377b5f78e7ef8"},
-    {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:02a51034802cbf38db3f89c66fb5d2ec57e6fe7ef2f4a44d070a593c3688667b"},
-    {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:2e396d70bc4ef5325b72b593a72c8979999aa52fb8bcf03f701c1b03e1166918"},
-    {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:11b53acf2411c3b09e6af37e4b9005cba376c872503c8f28218c7243582df45d"},
-    {file = "charset_normalizer-3.0.1-cp37-cp37m-win32.whl", hash = "sha256:0bf2dae5291758b6f84cf923bfaa285632816007db0330002fa1de38bfcb7154"},
-    {file = "charset_normalizer-3.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:2c03cc56021a4bd59be889c2b9257dae13bf55041a3372d3295416f86b295fb5"},
-    {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:024e606be3ed92216e2b6952ed859d86b4cfa52cd5bc5f050e7dc28f9b43ec42"},
-    {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4b0d02d7102dd0f997580b51edc4cebcf2ab6397a7edf89f1c73b586c614272c"},
-    {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:358a7c4cb8ba9b46c453b1dd8d9e431452d5249072e4f56cfda3149f6ab1405e"},
-    {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81d6741ab457d14fdedc215516665050f3822d3e56508921cc7239f8c8e66a58"},
-    {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8b8af03d2e37866d023ad0ddea594edefc31e827fee64f8de5611a1dbc373174"},
-    {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cf4e8ad252f7c38dd1f676b46514f92dc0ebeb0db5552f5f403509705e24753"},
-    {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e696f0dd336161fca9adbb846875d40752e6eba585843c768935ba5c9960722b"},
-    {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c22d3fe05ce11d3671297dc8973267daa0f938b93ec716e12e0f6dee81591dc1"},
-    {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:109487860ef6a328f3eec66f2bf78b0b72400280d8f8ea05f69c51644ba6521a"},
-    {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:37f8febc8ec50c14f3ec9637505f28e58d4f66752207ea177c1d67df25da5aed"},
-    {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:f97e83fa6c25693c7a35de154681fcc257c1c41b38beb0304b9c4d2d9e164479"},
-    {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a152f5f33d64a6be73f1d30c9cc82dfc73cec6477ec268e7c6e4c7d23c2d2291"},
-    {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:39049da0ffb96c8cbb65cbf5c5f3ca3168990adf3551bd1dee10c48fce8ae820"},
-    {file = "charset_normalizer-3.0.1-cp38-cp38-win32.whl", hash = "sha256:4457ea6774b5611f4bed5eaa5df55f70abde42364d498c5134b7ef4c6958e20e"},
-    {file = "charset_normalizer-3.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:e62164b50f84e20601c1ff8eb55620d2ad25fb81b59e3cd776a1902527a788af"},
-    {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8eade758719add78ec36dc13201483f8e9b5d940329285edcd5f70c0a9edbd7f"},
-    {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8499ca8f4502af841f68135133d8258f7b32a53a1d594aa98cc52013fff55678"},
-    {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3fc1c4a2ffd64890aebdb3f97e1278b0cc72579a08ca4de8cd2c04799a3a22be"},
-    {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00d3ffdaafe92a5dc603cb9bd5111aaa36dfa187c8285c543be562e61b755f6b"},
-    {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2ac1b08635a8cd4e0cbeaf6f5e922085908d48eb05d44c5ae9eabab148512ca"},
-    {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6f45710b4459401609ebebdbcfb34515da4fc2aa886f95107f556ac69a9147e"},
-    {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ae1de54a77dc0d6d5fcf623290af4266412a7c4be0b1ff7444394f03f5c54e3"},
-    {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b590df687e3c5ee0deef9fc8c547d81986d9a1b56073d82de008744452d6541"},
-    {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab5de034a886f616a5668aa5d098af2b5385ed70142090e2a31bcbd0af0fdb3d"},
-    {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9cb3032517f1627cc012dbc80a8ec976ae76d93ea2b5feaa9d2a5b8882597579"},
-    {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:608862a7bf6957f2333fc54ab4399e405baad0163dc9f8d99cb236816db169d4"},
-    {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0f438ae3532723fb6ead77e7c604be7c8374094ef4ee2c5e03a3a17f1fca256c"},
-    {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:356541bf4381fa35856dafa6a965916e54bed415ad8a24ee6de6e37deccf2786"},
-    {file = "charset_normalizer-3.0.1-cp39-cp39-win32.whl", hash = "sha256:39cf9ed17fe3b1bc81f33c9ceb6ce67683ee7526e65fde1447c772afc54a1bb8"},
-    {file = "charset_normalizer-3.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:0a11e971ed097d24c534c037d298ad32c6ce81a45736d31e0ff0ad37ab437d59"},
-    {file = "charset_normalizer-3.0.1-py3-none-any.whl", hash = "sha256:7e189e2e1d3ed2f4aebabd2d5b0f931e883676e51c7624826e0a4e5fe8a0bf24"},
+    {file = "charset-normalizer-3.1.0.tar.gz", hash = "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-win32.whl", hash = "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-win32.whl", hash = "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-win32.whl", hash = "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-win32.whl", hash = "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b"},
+    {file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"},
 ]
 
 [[package]]
@@ -174,20 +231,21 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""}
 
 [[package]]
 name = "codespell"
-version = "2.2.2"
+version = "2.2.4"
 description = "Codespell"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "codespell-2.2.2-py3-none-any.whl", hash = "sha256:87dfcd9bdc9b3cb8b067b37f0af22044d7a84e28174adfc8eaa203056b7f9ecc"},
-    {file = "codespell-2.2.2.tar.gz", hash = "sha256:c4d00c02b5a2a55661f00d5b4b3b5a710fa803ced9a9d7e45438268b099c319c"},
+    {file = "codespell-2.2.4-py3-none-any.whl", hash = "sha256:7d984b8130108e6f82524b7d09f8b7bf2fb1e398c5d4b37d9e2bd310145b3e29"},
+    {file = "codespell-2.2.4.tar.gz", hash = "sha256:0b4620473c257d9cde1ff8998b26b2bb209a35c2b7489f5dc3436024298ce83a"},
 ]
 
 [package.extras]
-dev = ["check-manifest", "flake8", "pytest", "pytest-cov", "pytest-dependency", "tomli"]
+dev = ["Pygments", "build", "chardet", "flake8", "flake8-pyproject", "pytest", "pytest-cov", "pytest-dependency", "tomli"]
 hard-encoding-detection = ["chardet"]
 toml = ["tomli"]
+types = ["chardet (>=5.1.0)", "mypy", "pytest", "pytest-cov", "pytest-dependency"]
 
 [[package]]
 name = "colorama"
@@ -294,19 +352,19 @@ files = [
 
 [[package]]
 name = "filelock"
-version = "3.9.0"
+version = "3.12.0"
 description = "A platform independent file lock."
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "filelock-3.9.0-py3-none-any.whl", hash = "sha256:f58d535af89bb9ad5cd4df046f741f8553a418c01a7856bf0d173bbc9f6bd16d"},
-    {file = "filelock-3.9.0.tar.gz", hash = "sha256:7b319f24340b51f55a2bf7a12ac0755a9b03e718311dac567a0f4f7fabd2f5de"},
+    {file = "filelock-3.12.0-py3-none-any.whl", hash = "sha256:ad98852315c2ab702aeb628412cbf7e95b7ce8c3bf9565670b4eaecf1db370a9"},
+    {file = "filelock-3.12.0.tar.gz", hash = "sha256:fc03ae43288c013d2ea83c8597001b1129db351aad9c57fe2409327916b8e718"},
 ]
 
 [package.extras]
-docs = ["furo (>=2022.12.7)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"]
-testing = ["covdefaults (>=2.2.2)", "coverage (>=7.0.1)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-timeout (>=2.1)"]
+docs = ["furo (>=2023.3.27)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"]
+testing = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"]
 
 [[package]]
 name = "flake8"
@@ -453,6 +511,18 @@ files = [
 docs = ["Sphinx", "docutils (<0.18)"]
 test = ["objgraph", "psutil"]
 
+[[package]]
+name = "h11"
+version = "0.14.0"
+description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
+    {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
+]
+
 [[package]]
 name = "http-parser"
 version = "0.9.0"
@@ -465,16 +535,62 @@ files = [
     {file = "http_parser-0.9.0-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:fc025894367cff34fcfc7386980b146b9498e9324cf3dc8992b39609e6158401"},
 ]
 
+[[package]]
+name = "httpcore"
+version = "0.17.0"
+description = "A minimal low-level HTTP client."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "httpcore-0.17.0-py3-none-any.whl", hash = "sha256:0fdfea45e94f0c9fd96eab9286077f9ff788dd186635ae61b312693e4d943599"},
+    {file = "httpcore-0.17.0.tar.gz", hash = "sha256:cc045a3241afbf60ce056202301b4d8b6af08845e3294055eb26b09913ef903c"},
+]
+
+[package.dependencies]
+anyio = ">=3.0,<5.0"
+certifi = "*"
+h11 = ">=0.13,<0.15"
+sniffio = ">=1.0.0,<2.0.0"
+
+[package.extras]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (>=1.0.0,<2.0.0)"]
+
+[[package]]
+name = "httpx"
+version = "0.24.0"
+description = "The next generation HTTP client."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "httpx-0.24.0-py3-none-any.whl", hash = "sha256:447556b50c1921c351ea54b4fe79d91b724ed2b027462ab9a329465d147d5a4e"},
+    {file = "httpx-0.24.0.tar.gz", hash = "sha256:507d676fc3e26110d41df7d35ebd8b3b8585052450f4097401c9be59d928c63e"},
+]
+
+[package.dependencies]
+certifi = "*"
+httpcore = ">=0.15.0,<0.18.0"
+idna = "*"
+sniffio = "*"
+
+[package.extras]
+brotli = ["brotli", "brotlicffi"]
+cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<14)"]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (>=1.0.0,<2.0.0)"]
+
 [[package]]
 name = "identify"
-version = "2.5.17"
+version = "2.5.24"
 description = "File identification library for Python"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "identify-2.5.17-py2.py3-none-any.whl", hash = "sha256:7d526dd1283555aafcc91539acc061d8f6f59adb0a7bba462735b0a318bff7ed"},
-    {file = "identify-2.5.17.tar.gz", hash = "sha256:93cc61a861052de9d4c541a7acb7e3dcc9c11b398a2144f6e52ae5285f5f4f06"},
+    {file = "identify-2.5.24-py2.py3-none-any.whl", hash = "sha256:986dbfb38b1140e763e413e6feb44cd731faf72d1909543178aa79b0e258265d"},
+    {file = "identify-2.5.24.tar.gz", hash = "sha256:0aac67d5b4812498056d28a9a512a483f5085cc28640b02b258a59dac34301d4"},
 ]
 
 [package.extras]
@@ -496,7 +612,7 @@ files = [
 name = "isort"
 version = "5.12.0"
 description = "A Python utility / library to sort Python imports."
-category = "dev"
+category = "main"
 optional = false
 python-versions = ">=3.8.0"
 files = [
@@ -616,7 +732,7 @@ files = [
 name = "mypy-extensions"
 version = "1.0.0"
 description = "Type system extensions for programs checked with the mypy type checker."
-category = "dev"
+category = "main"
 optional = false
 python-versions = ">=3.5"
 files = [
@@ -626,46 +742,84 @@ files = [
 
 [[package]]
 name = "nodeenv"
-version = "1.7.0"
+version = "1.8.0"
 description = "Node.js virtual environment builder"
 category = "dev"
 optional = false
 python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*"
 files = [
-    {file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"},
-    {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"},
+    {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"},
+    {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"},
 ]
 
 [package.dependencies]
 setuptools = "*"
 
+[[package]]
+name = "openapi-python-client"
+version = "0.14.0"
+description = "Generate modern Python clients from OpenAPI"
+category = "main"
+optional = false
+python-versions = ">=3.8,<4.0"
+files = [
+    {file = "openapi_python_client-0.14.0-py3-none-any.whl", hash = "sha256:9148af6b9ee3375f26585147d18ed94f9a3185e74cda04f3289cc8c5149cf9c1"},
+    {file = "openapi_python_client-0.14.0.tar.gz", hash = "sha256:47f986f43494a203521ea01e05f7712f61d4be41cdbbd90c26e15cbba1bb90bb"},
+]
+
+[package.dependencies]
+attrs = ">=21.3.0"
+autoflake = ">=1.4,<3.0.0"
+black = ">=23"
+colorama = {version = ">=0.4.3,<0.5.0", markers = "sys_platform == \"win32\""}
+httpx = ">=0.15.4,<0.25.0"
+isort = ">=5.0.5,<6.0.0"
+jinja2 = ">=3.0.0,<4.0.0"
+pydantic = ">=1.6.1,<2.0.0"
+python-dateutil = ">=2.8.1,<3.0.0"
+PyYAML = ">=6.0,<7.0"
+shellingham = ">=1.3.2,<2.0.0"
+typer = ">=0.6,<0.8.0"
+
+[[package]]
+name = "packaging"
+version = "23.1"
+description = "Core utilities for Python packages"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"},
+    {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"},
+]
+
 [[package]]
 name = "pathspec"
-version = "0.11.0"
+version = "0.11.1"
 description = "Utility library for gitignore style pattern matching of file paths."
-category = "dev"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "pathspec-0.11.0-py3-none-any.whl", hash = "sha256:3a66eb970cbac598f9e5ccb5b2cf58930cd8e3ed86d393d541eaf2d8b1705229"},
-    {file = "pathspec-0.11.0.tar.gz", hash = "sha256:64d338d4e0914e91c1792321e6907b5a593f1ab1851de7fc269557a21b30ebbc"},
+    {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"},
+    {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"},
 ]
 
 [[package]]
 name = "platformdirs"
-version = "2.6.2"
+version = "3.5.1"
 description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
-category = "dev"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "platformdirs-2.6.2-py3-none-any.whl", hash = "sha256:83c8f6d04389165de7c9b6f0c682439697887bca0aa2f1c87ef1826be3584490"},
-    {file = "platformdirs-2.6.2.tar.gz", hash = "sha256:e1fea1fe471b9ff8332e229df3cb7de4f53eeea4998d3b6bfff542115e998bd2"},
+    {file = "platformdirs-3.5.1-py3-none-any.whl", hash = "sha256:e2378146f1964972c03c085bb5662ae80b2b8c06226c54b2ff4aa9483e8a13a5"},
+    {file = "platformdirs-3.5.1.tar.gz", hash = "sha256:412dae91f52a6f84830f39a8078cecd0e866cb72294a5c66808e74d5e88d251f"},
 ]
 
 [package.extras]
-docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"]
-test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"]
+docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.2.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"]
+test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"]
 
 [[package]]
 name = "pre-commit"
@@ -688,83 +842,74 @@ virtualenv = ">=20.10.0"
 
 [[package]]
 name = "psycopg2-binary"
-version = "2.9.5"
+version = "2.9.6"
 description = "psycopg2 - Python-PostgreSQL Database Adapter"
 category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
-    {file = "psycopg2-binary-2.9.5.tar.gz", hash = "sha256:33e632d0885b95a8b97165899006c40e9ecdc634a529dca7b991eb7de4ece41c"},
-    {file = "psycopg2_binary-2.9.5-cp310-cp310-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:0775d6252ccb22b15da3b5d7adbbf8cfe284916b14b6dc0ff503a23edb01ee85"},
-    {file = "psycopg2_binary-2.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ec46ed947801652c9643e0b1dc334cfb2781232e375ba97312c2fc256597632"},
-    {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3520d7af1ebc838cc6084a3281145d5cd5bdd43fdef139e6db5af01b92596cb7"},
-    {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cbc554ba47ecca8cd3396ddaca85e1ecfe3e48dd57dc5e415e59551affe568e"},
-    {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:5d28ecdf191db558d0c07d0f16524ee9d67896edf2b7990eea800abeb23ebd61"},
-    {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_24_ppc64le.whl", hash = "sha256:b9c33d4aef08dfecbd1736ceab8b7b3c4358bf10a0121483e5cd60d3d308cc64"},
-    {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:05b3d479425e047c848b9782cd7aac9c6727ce23181eb9647baf64ffdfc3da41"},
-    {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:1e491e6489a6cb1d079df8eaa15957c277fdedb102b6a68cfbf40c4994412fd0"},
-    {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:9e32cedc389bcb76d9f24ea8a012b3cb8385ee362ea437e1d012ffaed106c17d"},
-    {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:46850a640df62ae940e34a163f72e26aca1f88e2da79148e1862faaac985c302"},
-    {file = "psycopg2_binary-2.9.5-cp310-cp310-win32.whl", hash = "sha256:3d790f84201c3698d1bfb404c917f36e40531577a6dda02e45ba29b64d539867"},
-    {file = "psycopg2_binary-2.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:1764546ffeaed4f9428707be61d68972eb5ede81239b46a45843e0071104d0dd"},
-    {file = "psycopg2_binary-2.9.5-cp311-cp311-macosx_10_9_universal2.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:426c2ae999135d64e6a18849a7d1ad0e1bd007277e4a8f4752eaa40a96b550ff"},
-    {file = "psycopg2_binary-2.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7cf1d44e710ca3a9ce952bda2855830fe9f9017ed6259e01fcd71ea6287565f5"},
-    {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:024030b13bdcbd53d8a93891a2cf07719715724fc9fee40243f3bd78b4264b8f"},
-    {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcda1c84a1c533c528356da5490d464a139b6e84eb77cc0b432e38c5c6dd7882"},
-    {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_24_aarch64.whl", hash = "sha256:2ef892cabdccefe577088a79580301f09f2a713eb239f4f9f62b2b29cafb0577"},
-    {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_24_ppc64le.whl", hash = "sha256:af0516e1711995cb08dc19bbd05bec7dbdebf4185f68870595156718d237df3e"},
-    {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e72c91bda9880f097c8aa3601a2c0de6c708763ba8128006151f496ca9065935"},
-    {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e67b3c26e9b6d37b370c83aa790bbc121775c57bfb096c2e77eacca25fd0233b"},
-    {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5fc447058d083b8c6ac076fc26b446d44f0145308465d745fba93a28c14c9e32"},
-    {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d892bfa1d023c3781a3cab8dd5af76b626c483484d782e8bd047c180db590e4c"},
-    {file = "psycopg2_binary-2.9.5-cp311-cp311-win32.whl", hash = "sha256:2abccab84d057723d2ca8f99ff7b619285d40da6814d50366f61f0fc385c3903"},
-    {file = "psycopg2_binary-2.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:bef7e3f9dc6f0c13afdd671008534be5744e0e682fb851584c8c3a025ec09720"},
-    {file = "psycopg2_binary-2.9.5-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:6e63814ec71db9bdb42905c925639f319c80e7909fb76c3b84edc79dadef8d60"},
-    {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:212757ffcecb3e1a5338d4e6761bf9c04f750e7d027117e74aa3cd8a75bb6fbd"},
-    {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f8a9bcab7b6db2e3dbf65b214dfc795b4c6b3bb3af922901b6a67f7cb47d5f8"},
-    {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_24_aarch64.whl", hash = "sha256:56b2957a145f816726b109ee3d4e6822c23f919a7d91af5a94593723ed667835"},
-    {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_24_ppc64le.whl", hash = "sha256:f95b8aca2703d6a30249f83f4fe6a9abf2e627aa892a5caaab2267d56be7ab69"},
-    {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:70831e03bd53702c941da1a1ad36c17d825a24fbb26857b40913d58df82ec18b"},
-    {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:dbc332beaf8492b5731229a881807cd7b91b50dbbbaf7fe2faf46942eda64a24"},
-    {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:2d964eb24c8b021623df1c93c626671420c6efadbdb8655cb2bd5e0c6fa422ba"},
-    {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:95076399ec3b27a8f7fa1cc9a83417b1c920d55cf7a97f718a94efbb96c7f503"},
-    {file = "psycopg2_binary-2.9.5-cp36-cp36m-win32.whl", hash = "sha256:3fc33295cfccad697a97a76dec3f1e94ad848b7b163c3228c1636977966b51e2"},
-    {file = "psycopg2_binary-2.9.5-cp36-cp36m-win_amd64.whl", hash = "sha256:02551647542f2bf89073d129c73c05a25c372fc0a49aa50e0de65c3c143d8bd0"},
-    {file = "psycopg2_binary-2.9.5-cp37-cp37m-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:63e318dbe52709ed10d516a356f22a635e07a2e34c68145484ed96a19b0c4c68"},
-    {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7e518a0911c50f60313cb9e74a169a65b5d293770db4770ebf004245f24b5c5"},
-    {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9d38a4656e4e715d637abdf7296e98d6267df0cc0a8e9a016f8ba07e4aa3eeb"},
-    {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:68d81a2fe184030aa0c5c11e518292e15d342a667184d91e30644c9d533e53e1"},
-    {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:7ee3095d02d6f38bd7d9a5358fcc9ea78fcdb7176921528dd709cc63f40184f5"},
-    {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:46512486be6fbceef51d7660dec017394ba3e170299d1dc30928cbedebbf103a"},
-    {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b911dfb727e247340d36ae20c4b9259e4a64013ab9888ccb3cbba69b77fd9636"},
-    {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:422e3d43b47ac20141bc84b3d342eead8d8099a62881a501e97d15f6addabfe9"},
-    {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c5682a45df7d9642eff590abc73157c887a68f016df0a8ad722dcc0f888f56d7"},
-    {file = "psycopg2_binary-2.9.5-cp37-cp37m-win32.whl", hash = "sha256:b8104f709590fff72af801e916817560dbe1698028cd0afe5a52d75ceb1fce5f"},
-    {file = "psycopg2_binary-2.9.5-cp37-cp37m-win_amd64.whl", hash = "sha256:7b3751857da3e224f5629400736a7b11e940b5da5f95fa631d86219a1beaafec"},
-    {file = "psycopg2_binary-2.9.5-cp38-cp38-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:043a9fd45a03858ff72364b4b75090679bd875ee44df9c0613dc862ca6b98460"},
-    {file = "psycopg2_binary-2.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9ffdc51001136b699f9563b1c74cc1f8c07f66ef7219beb6417a4c8aaa896c28"},
-    {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c15ba5982c177bc4b23a7940c7e4394197e2d6a424a2d282e7c236b66da6d896"},
-    {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc85b3777068ed30aff8242be2813038a929f2084f69e43ef869daddae50f6ee"},
-    {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:215d6bf7e66732a514f47614f828d8c0aaac9a648c46a831955cb103473c7147"},
-    {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:7d07f552d1e412f4b4e64ce386d4c777a41da3b33f7098b6219012ba534fb2c2"},
-    {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a0adef094c49f242122bb145c3c8af442070dc0e4312db17e49058c1702606d4"},
-    {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:00475004e5ed3e3bf5e056d66e5dcdf41a0dc62efcd57997acd9135c40a08a50"},
-    {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:7d88db096fa19d94f433420eaaf9f3c45382da2dd014b93e4bf3215639047c16"},
-    {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:902844f9c4fb19b17dfa84d9e2ca053d4a4ba265723d62ea5c9c26b38e0aa1e6"},
-    {file = "psycopg2_binary-2.9.5-cp38-cp38-win32.whl", hash = "sha256:4e7904d1920c0c89105c0517dc7e3f5c20fb4e56ba9cdef13048db76947f1d79"},
-    {file = "psycopg2_binary-2.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:a36a0e791805aa136e9cbd0ffa040d09adec8610453ee8a753f23481a0057af5"},
-    {file = "psycopg2_binary-2.9.5-cp39-cp39-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:25382c7d174c679ce6927c16b6fbb68b10e56ee44b1acb40671e02d29f2fce7c"},
-    {file = "psycopg2_binary-2.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9c38d3869238e9d3409239bc05bc27d6b7c99c2a460ea337d2814b35fb4fea1b"},
-    {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5c6527c8efa5226a9e787507652dd5ba97b62d29b53c371a85cd13f957fe4d42"},
-    {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e59137cdb970249ae60be2a49774c6dfb015bd0403f05af1fe61862e9626642d"},
-    {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:d4c7b3a31502184e856df1f7bbb2c3735a05a8ce0ade34c5277e1577738a5c91"},
-    {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:b9a794cef1d9c1772b94a72eec6da144c18e18041d294a9ab47669bc77a80c1d"},
-    {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c5254cbd4f4855e11cebf678c1a848a3042d455a22a4ce61349c36aafd4c2267"},
-    {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c5e65c6ac0ae4bf5bef1667029f81010b6017795dcb817ba5c7b8a8d61fab76f"},
-    {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:74eddec4537ab1f701a1647214734bc52cee2794df748f6ae5908e00771f180a"},
-    {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:01ad49d68dd8c5362e4bfb4158f2896dc6e0c02e87b8a3770fc003459f1a4425"},
-    {file = "psycopg2_binary-2.9.5-cp39-cp39-win32.whl", hash = "sha256:937880290775033a743f4836aa253087b85e62784b63fd099ee725d567a48aa1"},
-    {file = "psycopg2_binary-2.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:484405b883630f3e74ed32041a87456c5e0e63a8e3429aa93e8714c366d62bd1"},
+    {file = "psycopg2-binary-2.9.6.tar.gz", hash = "sha256:1f64dcfb8f6e0c014c7f55e51c9759f024f70ea572fbdef123f85318c297947c"},
+    {file = "psycopg2_binary-2.9.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d26e0342183c762de3276cca7a530d574d4e25121ca7d6e4a98e4f05cb8e4df7"},
+    {file = "psycopg2_binary-2.9.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c48d8f2db17f27d41fb0e2ecd703ea41984ee19362cbce52c097963b3a1b4365"},
+    {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe9dc0a884a8848075e576c1de0290d85a533a9f6e9c4e564f19adf8f6e54a7"},
+    {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a76e027f87753f9bd1ab5f7c9cb8c7628d1077ef927f5e2446477153a602f2c"},
+    {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6460c7a99fc939b849431f1e73e013d54aa54293f30f1109019c56a0b2b2ec2f"},
+    {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae102a98c547ee2288637af07393dd33f440c25e5cd79556b04e3fca13325e5f"},
+    {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9972aad21f965599ed0106f65334230ce826e5ae69fda7cbd688d24fa922415e"},
+    {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7a40c00dbe17c0af5bdd55aafd6ff6679f94a9be9513a4c7e071baf3d7d22a70"},
+    {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:cacbdc5839bdff804dfebc058fe25684cae322987f7a38b0168bc1b2df703fb1"},
+    {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7f0438fa20fb6c7e202863e0d5ab02c246d35efb1d164e052f2f3bfe2b152bd0"},
+    {file = "psycopg2_binary-2.9.6-cp310-cp310-win32.whl", hash = "sha256:b6c8288bb8a84b47e07013bb4850f50538aa913d487579e1921724631d02ea1b"},
+    {file = "psycopg2_binary-2.9.6-cp310-cp310-win_amd64.whl", hash = "sha256:61b047a0537bbc3afae10f134dc6393823882eb263088c271331602b672e52e9"},
+    {file = "psycopg2_binary-2.9.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:964b4dfb7c1c1965ac4c1978b0f755cc4bd698e8aa2b7667c575fb5f04ebe06b"},
+    {file = "psycopg2_binary-2.9.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afe64e9b8ea66866a771996f6ff14447e8082ea26e675a295ad3bdbffdd72afb"},
+    {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15e2ee79e7cf29582ef770de7dab3d286431b01c3bb598f8e05e09601b890081"},
+    {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dfa74c903a3c1f0d9b1c7e7b53ed2d929a4910e272add6700c38f365a6002820"},
+    {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b83456c2d4979e08ff56180a76429263ea254c3f6552cd14ada95cff1dec9bb8"},
+    {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0645376d399bfd64da57148694d78e1f431b1e1ee1054872a5713125681cf1be"},
+    {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e99e34c82309dd78959ba3c1590975b5d3c862d6f279f843d47d26ff89d7d7e1"},
+    {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4ea29fc3ad9d91162c52b578f211ff1c931d8a38e1f58e684c45aa470adf19e2"},
+    {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:4ac30da8b4f57187dbf449294d23b808f8f53cad6b1fc3623fa8a6c11d176dd0"},
+    {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e78e6e2a00c223e164c417628572a90093c031ed724492c763721c2e0bc2a8df"},
+    {file = "psycopg2_binary-2.9.6-cp311-cp311-win32.whl", hash = "sha256:1876843d8e31c89c399e31b97d4b9725a3575bb9c2af92038464231ec40f9edb"},
+    {file = "psycopg2_binary-2.9.6-cp311-cp311-win_amd64.whl", hash = "sha256:b4b24f75d16a89cc6b4cdff0eb6a910a966ecd476d1e73f7ce5985ff1328e9a6"},
+    {file = "psycopg2_binary-2.9.6-cp36-cp36m-win32.whl", hash = "sha256:498807b927ca2510baea1b05cc91d7da4718a0f53cb766c154c417a39f1820a0"},
+    {file = "psycopg2_binary-2.9.6-cp36-cp36m-win_amd64.whl", hash = "sha256:0d236c2825fa656a2d98bbb0e52370a2e852e5a0ec45fc4f402977313329174d"},
+    {file = "psycopg2_binary-2.9.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:34b9ccdf210cbbb1303c7c4db2905fa0319391bd5904d32689e6dd5c963d2ea8"},
+    {file = "psycopg2_binary-2.9.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84d2222e61f313c4848ff05353653bf5f5cf6ce34df540e4274516880d9c3763"},
+    {file = "psycopg2_binary-2.9.6-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30637a20623e2a2eacc420059be11527f4458ef54352d870b8181a4c3020ae6b"},
+    {file = "psycopg2_binary-2.9.6-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8122cfc7cae0da9a3077216528b8bb3629c43b25053284cc868744bfe71eb141"},
+    {file = "psycopg2_binary-2.9.6-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38601cbbfe600362c43714482f43b7c110b20cb0f8172422c616b09b85a750c5"},
+    {file = "psycopg2_binary-2.9.6-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c7e62ab8b332147a7593a385d4f368874d5fe4ad4e341770d4983442d89603e3"},
+    {file = "psycopg2_binary-2.9.6-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2ab652e729ff4ad76d400df2624d223d6e265ef81bb8aa17fbd63607878ecbee"},
+    {file = "psycopg2_binary-2.9.6-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:c83a74b68270028dc8ee74d38ecfaf9c90eed23c8959fca95bd703d25b82c88e"},
+    {file = "psycopg2_binary-2.9.6-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d4e6036decf4b72d6425d5b29bbd3e8f0ff1059cda7ac7b96d6ac5ed34ffbacd"},
+    {file = "psycopg2_binary-2.9.6-cp37-cp37m-win32.whl", hash = "sha256:a8c28fd40a4226b4a84bdf2d2b5b37d2c7bd49486b5adcc200e8c7ec991dfa7e"},
+    {file = "psycopg2_binary-2.9.6-cp37-cp37m-win_amd64.whl", hash = "sha256:51537e3d299be0db9137b321dfb6a5022caaab275775680e0c3d281feefaca6b"},
+    {file = "psycopg2_binary-2.9.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cf4499e0a83b7b7edcb8dabecbd8501d0d3a5ef66457200f77bde3d210d5debb"},
+    {file = "psycopg2_binary-2.9.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7e13a5a2c01151f1208d5207e42f33ba86d561b7a89fca67c700b9486a06d0e2"},
+    {file = "psycopg2_binary-2.9.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e0f754d27fddcfd74006455b6e04e6705d6c31a612ec69ddc040a5468e44b4e"},
+    {file = "psycopg2_binary-2.9.6-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d57c3fd55d9058645d26ae37d76e61156a27722097229d32a9e73ed54819982a"},
+    {file = "psycopg2_binary-2.9.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:71f14375d6f73b62800530b581aed3ada394039877818b2d5f7fc77e3bb6894d"},
+    {file = "psycopg2_binary-2.9.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:441cc2f8869a4f0f4bb408475e5ae0ee1f3b55b33f350406150277f7f35384fc"},
+    {file = "psycopg2_binary-2.9.6-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:65bee1e49fa6f9cf327ce0e01c4c10f39165ee76d35c846ade7cb0ec6683e303"},
+    {file = "psycopg2_binary-2.9.6-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:af335bac6b666cc6aea16f11d486c3b794029d9df029967f9938a4bed59b6a19"},
+    {file = "psycopg2_binary-2.9.6-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:cfec476887aa231b8548ece2e06d28edc87c1397ebd83922299af2e051cf2827"},
+    {file = "psycopg2_binary-2.9.6-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:65c07febd1936d63bfde78948b76cd4c2a411572a44ac50719ead41947d0f26b"},
+    {file = "psycopg2_binary-2.9.6-cp38-cp38-win32.whl", hash = "sha256:4dfb4be774c4436a4526d0c554af0cc2e02082c38303852a36f6456ece7b3503"},
+    {file = "psycopg2_binary-2.9.6-cp38-cp38-win_amd64.whl", hash = "sha256:02c6e3cf3439e213e4ee930308dc122d6fb4d4bea9aef4a12535fbd605d1a2fe"},
+    {file = "psycopg2_binary-2.9.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e9182eb20f41417ea1dd8e8f7888c4d7c6e805f8a7c98c1081778a3da2bee3e4"},
+    {file = "psycopg2_binary-2.9.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8a6979cf527e2603d349a91060f428bcb135aea2be3201dff794813256c274f1"},
+    {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8338a271cb71d8da40b023a35d9c1e919eba6cbd8fa20a54b748a332c355d896"},
+    {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3ed340d2b858d6e6fb5083f87c09996506af483227735de6964a6100b4e6a54"},
+    {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f81e65376e52f03422e1fb475c9514185669943798ed019ac50410fb4c4df232"},
+    {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfb13af3c5dd3a9588000910178de17010ebcccd37b4f9794b00595e3a8ddad3"},
+    {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4c727b597c6444a16e9119386b59388f8a424223302d0c06c676ec8b4bc1f963"},
+    {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:4d67fbdaf177da06374473ef6f7ed8cc0a9dc640b01abfe9e8a2ccb1b1402c1f"},
+    {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0892ef645c2fabb0c75ec32d79f4252542d0caec1d5d949630e7d242ca4681a3"},
+    {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:02c0f3757a4300cf379eb49f543fb7ac527fb00144d39246ee40e1df684ab514"},
+    {file = "psycopg2_binary-2.9.6-cp39-cp39-win32.whl", hash = "sha256:c3dba7dab16709a33a847e5cd756767271697041fbe3fe97c215b1fc1f5c9848"},
+    {file = "psycopg2_binary-2.9.6-cp39-cp39-win_amd64.whl", hash = "sha256:f6a88f384335bb27812293fdb11ac6aee2ca3f51d3c7820fe03de0a304ab6249"},
 ]
 
 [[package]]
@@ -779,6 +924,59 @@ files = [
     {file = "pycodestyle-2.9.1.tar.gz", hash = "sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785"},
 ]
 
+[[package]]
+name = "pydantic"
+version = "1.10.7"
+description = "Data validation and settings management using python type hints"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "pydantic-1.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e79e999e539872e903767c417c897e729e015872040e56b96e67968c3b918b2d"},
+    {file = "pydantic-1.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:01aea3a42c13f2602b7ecbbea484a98169fb568ebd9e247593ea05f01b884b2e"},
+    {file = "pydantic-1.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:516f1ed9bc2406a0467dd777afc636c7091d71f214d5e413d64fef45174cfc7a"},
+    {file = "pydantic-1.10.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae150a63564929c675d7f2303008d88426a0add46efd76c3fc797cd71cb1b46f"},
+    {file = "pydantic-1.10.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ecbbc51391248116c0a055899e6c3e7ffbb11fb5e2a4cd6f2d0b93272118a209"},
+    {file = "pydantic-1.10.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f4a2b50e2b03d5776e7f21af73e2070e1b5c0d0df255a827e7c632962f8315af"},
+    {file = "pydantic-1.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:a7cd2251439988b413cb0a985c4ed82b6c6aac382dbaff53ae03c4b23a70e80a"},
+    {file = "pydantic-1.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:68792151e174a4aa9e9fc1b4e653e65a354a2fa0fed169f7b3d09902ad2cb6f1"},
+    {file = "pydantic-1.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe2507b8ef209da71b6fb5f4e597b50c5a34b78d7e857c4f8f3115effaef5fe"},
+    {file = "pydantic-1.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10a86d8c8db68086f1e30a530f7d5f83eb0685e632e411dbbcf2d5c0150e8dcd"},
+    {file = "pydantic-1.10.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d75ae19d2a3dbb146b6f324031c24f8a3f52ff5d6a9f22f0683694b3afcb16fb"},
+    {file = "pydantic-1.10.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:464855a7ff7f2cc2cf537ecc421291b9132aa9c79aef44e917ad711b4a93163b"},
+    {file = "pydantic-1.10.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:193924c563fae6ddcb71d3f06fa153866423ac1b793a47936656e806b64e24ca"},
+    {file = "pydantic-1.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:b4a849d10f211389502059c33332e91327bc154acc1845f375a99eca3afa802d"},
+    {file = "pydantic-1.10.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cc1dde4e50a5fc1336ee0581c1612215bc64ed6d28d2c7c6f25d2fe3e7c3e918"},
+    {file = "pydantic-1.10.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0cfe895a504c060e5d36b287ee696e2fdad02d89e0d895f83037245218a87fe"},
+    {file = "pydantic-1.10.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:670bb4683ad1e48b0ecb06f0cfe2178dcf74ff27921cdf1606e527d2617a81ee"},
+    {file = "pydantic-1.10.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:950ce33857841f9a337ce07ddf46bc84e1c4946d2a3bba18f8280297157a3fd1"},
+    {file = "pydantic-1.10.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c15582f9055fbc1bfe50266a19771bbbef33dd28c45e78afbe1996fd70966c2a"},
+    {file = "pydantic-1.10.7-cp37-cp37m-win_amd64.whl", hash = "sha256:82dffb306dd20bd5268fd6379bc4bfe75242a9c2b79fec58e1041fbbdb1f7914"},
+    {file = "pydantic-1.10.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8c7f51861d73e8b9ddcb9916ae7ac39fb52761d9ea0df41128e81e2ba42886cd"},
+    {file = "pydantic-1.10.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6434b49c0b03a51021ade5c4daa7d70c98f7a79e95b551201fff682fc1661245"},
+    {file = "pydantic-1.10.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d34ab766fa056df49013bb6e79921a0265204c071984e75a09cbceacbbdd5d"},
+    {file = "pydantic-1.10.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:701daea9ffe9d26f97b52f1d157e0d4121644f0fcf80b443248434958fd03dc3"},
+    {file = "pydantic-1.10.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cf135c46099ff3f919d2150a948ce94b9ce545598ef2c6c7bf55dca98a304b52"},
+    {file = "pydantic-1.10.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0f85904f73161817b80781cc150f8b906d521fa11e3cdabae19a581c3606209"},
+    {file = "pydantic-1.10.7-cp38-cp38-win_amd64.whl", hash = "sha256:9f6f0fd68d73257ad6685419478c5aece46432f4bdd8d32c7345f1986496171e"},
+    {file = "pydantic-1.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c230c0d8a322276d6e7b88c3f7ce885f9ed16e0910354510e0bae84d54991143"},
+    {file = "pydantic-1.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:976cae77ba6a49d80f461fd8bba183ff7ba79f44aa5cfa82f1346b5626542f8e"},
+    {file = "pydantic-1.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d45fc99d64af9aaf7e308054a0067fdcd87ffe974f2442312372dfa66e1001d"},
+    {file = "pydantic-1.10.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d2a5ebb48958754d386195fe9e9c5106f11275867051bf017a8059410e9abf1f"},
+    {file = "pydantic-1.10.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:abfb7d4a7cd5cc4e1d1887c43503a7c5dd608eadf8bc615413fc498d3e4645cd"},
+    {file = "pydantic-1.10.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:80b1fab4deb08a8292d15e43a6edccdffa5377a36a4597bb545b93e79c5ff0a5"},
+    {file = "pydantic-1.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:d71e69699498b020ea198468e2480a2f1e7433e32a3a99760058c6520e2bea7e"},
+    {file = "pydantic-1.10.7-py3-none-any.whl", hash = "sha256:0cd181f1d0b1d00e2b705f1bf1ac7799a2d938cce3376b8007df62b29be3c2c6"},
+    {file = "pydantic-1.10.7.tar.gz", hash = "sha256:cfc83c0678b6ba51b0532bea66860617c4cd4251ecf76e9846fa5a9f3454e97e"},
+]
+
+[package.dependencies]
+typing-extensions = ">=4.2.0"
+
+[package.extras]
+dotenv = ["python-dotenv (>=0.10.4)"]
+email = ["email-validator (>=1.0.3)"]
+
 [[package]]
 name = "pydocstyle"
 version = "6.3.0"
@@ -801,7 +999,7 @@ toml = ["tomli (>=1.2.3)"]
 name = "pyflakes"
 version = "2.5.0"
 description = "passive checker of Python programs"
-category = "dev"
+category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -809,11 +1007,26 @@ files = [
     {file = "pyflakes-2.5.0.tar.gz", hash = "sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3"},
 ]
 
+[[package]]
+name = "python-dateutil"
+version = "2.8.2"
+description = "Extensions to the standard Python datetime module"
+category = "main"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+files = [
+    {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
+    {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
+]
+
+[package.dependencies]
+six = ">=1.5"
+
 [[package]]
 name = "pyyaml"
 version = "6.0"
 description = "YAML parser and emitter for Python"
-category = "dev"
+category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -861,21 +1074,21 @@ files = [
 
 [[package]]
 name = "requests"
-version = "2.28.2"
+version = "2.30.0"
 description = "Python HTTP for Humans."
 category = "main"
 optional = false
-python-versions = ">=3.7, <4"
+python-versions = ">=3.7"
 files = [
-    {file = "requests-2.28.2-py3-none-any.whl", hash = "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa"},
-    {file = "requests-2.28.2.tar.gz", hash = "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf"},
+    {file = "requests-2.30.0-py3-none-any.whl", hash = "sha256:10e94cc4f3121ee6da529d358cdaeaff2f1c409cd377dbc72b825852f2f7e294"},
+    {file = "requests-2.30.0.tar.gz", hash = "sha256:239d7d4458afcb28a692cdd298d87542235f4ca8d36d03a15bfc128a6559a2f4"},
 ]
 
 [package.dependencies]
 certifi = ">=2017.4.17"
 charset-normalizer = ">=2,<4"
 idna = ">=2.5,<4"
-urllib3 = ">=1.21.1,<1.27"
+urllib3 = ">=1.21.1,<3"
 
 [package.extras]
 socks = ["PySocks (>=1.5.6,!=1.5.7)"]
@@ -883,14 +1096,14 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
 
 [[package]]
 name = "setuptools"
-version = "67.1.0"
+version = "67.7.2"
 description = "Easily download, build, install, upgrade, and uninstall Python packages"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "setuptools-67.1.0-py3-none-any.whl", hash = "sha256:a7687c12b444eaac951ea87a9627c4f904ac757e7abdc5aac32833234af90378"},
-    {file = "setuptools-67.1.0.tar.gz", hash = "sha256:e261cdf010c11a41cb5cb5f1bf3338a7433832029f559a6a7614bd42a967c300"},
+    {file = "setuptools-67.7.2-py3-none-any.whl", hash = "sha256:23aaf86b85ca52ceb801d32703f12d77517b2556af839621c641fca11287952b"},
+    {file = "setuptools-67.7.2.tar.gz", hash = "sha256:f104fa03692a2602fa0fec6c6a9e63b6c8a968de13e17c026957dd1f53d80990"},
 ]
 
 [package.extras]
@@ -898,6 +1111,42 @@ docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-g
 testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
 testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
 
+[[package]]
+name = "shellingham"
+version = "1.5.0.post1"
+description = "Tool to Detect Surrounding Shell"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "shellingham-1.5.0.post1-py2.py3-none-any.whl", hash = "sha256:368bf8c00754fd4f55afb7bbb86e272df77e4dc76ac29dbcbb81a59e9fc15744"},
+    {file = "shellingham-1.5.0.post1.tar.gz", hash = "sha256:823bc5fb5c34d60f285b624e7264f4dda254bc803a3774a147bf99c0e3004a28"},
+]
+
+[[package]]
+name = "six"
+version = "1.16.0"
+description = "Python 2 and 3 compatibility utilities"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+files = [
+    {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+    {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.0"
+description = "Sniff out which async library your code is running under"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"},
+    {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"},
+]
+
 [[package]]
 name = "snowballstemmer"
 version = "2.2.0"
@@ -912,53 +1161,53 @@ files = [
 
 [[package]]
 name = "sqlalchemy"
-version = "1.4.46"
+version = "1.4.48"
 description = "Database Abstraction Library"
 category = "main"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
 files = [
-    {file = "SQLAlchemy-1.4.46-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:7001f16a9a8e06488c3c7154827c48455d1c1507d7228d43e781afbc8ceccf6d"},
-    {file = "SQLAlchemy-1.4.46-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c7a46639ba058d320c9f53a81db38119a74b8a7a1884df44d09fbe807d028aaf"},
-    {file = "SQLAlchemy-1.4.46-cp27-cp27m-win32.whl", hash = "sha256:c04144a24103135ea0315d459431ac196fe96f55d3213bfd6d39d0247775c854"},
-    {file = "SQLAlchemy-1.4.46-cp27-cp27m-win_amd64.whl", hash = "sha256:7b81b1030c42b003fc10ddd17825571603117f848814a344d305262d370e7c34"},
-    {file = "SQLAlchemy-1.4.46-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:939f9a018d2ad04036746e15d119c0428b1e557470361aa798e6e7d7f5875be0"},
-    {file = "SQLAlchemy-1.4.46-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:b7f4b6aa6e87991ec7ce0e769689a977776db6704947e562102431474799a857"},
-    {file = "SQLAlchemy-1.4.46-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dbf17ac9a61e7a3f1c7ca47237aac93cabd7f08ad92ac5b96d6f8dea4287fc1"},
-    {file = "SQLAlchemy-1.4.46-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7f8267682eb41a0584cf66d8a697fef64b53281d01c93a503e1344197f2e01fe"},
-    {file = "SQLAlchemy-1.4.46-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64cb0ad8a190bc22d2112001cfecdec45baffdf41871de777239da6a28ed74b6"},
-    {file = "SQLAlchemy-1.4.46-cp310-cp310-win32.whl", hash = "sha256:5f752676fc126edc1c4af0ec2e4d2adca48ddfae5de46bb40adbd3f903eb2120"},
-    {file = "SQLAlchemy-1.4.46-cp310-cp310-win_amd64.whl", hash = "sha256:31de1e2c45e67a5ec1ecca6ec26aefc299dd5151e355eb5199cd9516b57340be"},
-    {file = "SQLAlchemy-1.4.46-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d68e1762997bfebf9e5cf2a9fd0bcf9ca2fdd8136ce7b24bbd3bbfa4328f3e4a"},
-    {file = "SQLAlchemy-1.4.46-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d112b0f3c1bc5ff70554a97344625ef621c1bfe02a73c5d97cac91f8cd7a41e"},
-    {file = "SQLAlchemy-1.4.46-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69fac0a7054d86b997af12dc23f581cf0b25fb1c7d1fed43257dee3af32d3d6d"},
-    {file = "SQLAlchemy-1.4.46-cp311-cp311-win32.whl", hash = "sha256:887865924c3d6e9a473dc82b70977395301533b3030d0f020c38fd9eba5419f2"},
-    {file = "SQLAlchemy-1.4.46-cp311-cp311-win_amd64.whl", hash = "sha256:984ee13543a346324319a1fb72b698e521506f6f22dc37d7752a329e9cd00a32"},
-    {file = "SQLAlchemy-1.4.46-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:9167d4227b56591a4cc5524f1b79ccd7ea994f36e4c648ab42ca995d28ebbb96"},
-    {file = "SQLAlchemy-1.4.46-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d61e9ecc849d8d44d7f80894ecff4abe347136e9d926560b818f6243409f3c86"},
-    {file = "SQLAlchemy-1.4.46-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3ec187acf85984263299a3f15c34a6c0671f83565d86d10f43ace49881a82718"},
-    {file = "SQLAlchemy-1.4.46-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9883f5fae4fd8e3f875adc2add69f8b945625811689a6c65866a35ee9c0aea23"},
-    {file = "SQLAlchemy-1.4.46-cp36-cp36m-win32.whl", hash = "sha256:535377e9b10aff5a045e3d9ada8a62d02058b422c0504ebdcf07930599890eb0"},
-    {file = "SQLAlchemy-1.4.46-cp36-cp36m-win_amd64.whl", hash = "sha256:18cafdb27834fa03569d29f571df7115812a0e59fd6a3a03ccb0d33678ec8420"},
-    {file = "SQLAlchemy-1.4.46-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:a1ad90c97029cc3ab4ffd57443a20fac21d2ec3c89532b084b073b3feb5abff3"},
-    {file = "SQLAlchemy-1.4.46-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4847f4b1d822754e35707db913396a29d874ee77b9c3c3ef3f04d5a9a6209618"},
-    {file = "SQLAlchemy-1.4.46-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c5a99282848b6cae0056b85da17392a26b2d39178394fc25700bcf967e06e97a"},
-    {file = "SQLAlchemy-1.4.46-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4b1cc7835b39835c75cf7c20c926b42e97d074147c902a9ebb7cf2c840dc4e2"},
-    {file = "SQLAlchemy-1.4.46-cp37-cp37m-win32.whl", hash = "sha256:c522e496f9b9b70296a7675272ec21937ccfc15da664b74b9f58d98a641ce1b6"},
-    {file = "SQLAlchemy-1.4.46-cp37-cp37m-win_amd64.whl", hash = "sha256:ae067ab639fa499f67ded52f5bc8e084f045d10b5ac7bb928ae4ca2b6c0429a5"},
-    {file = "SQLAlchemy-1.4.46-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:e3c1808008124850115a3f7e793a975cfa5c8a26ceeeb9ff9cbb4485cac556df"},
-    {file = "SQLAlchemy-1.4.46-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4d164df3d83d204c69f840da30b292ac7dc54285096c6171245b8d7807185aa"},
-    {file = "SQLAlchemy-1.4.46-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b33ffbdbbf5446cf36cd4cc530c9d9905d3c2fe56ed09e25c22c850cdb9fac92"},
-    {file = "SQLAlchemy-1.4.46-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d94682732d1a0def5672471ba42a29ff5e21bb0aae0afa00bb10796fc1e28dd"},
-    {file = "SQLAlchemy-1.4.46-cp38-cp38-win32.whl", hash = "sha256:f8cb80fe8d14307e4124f6fad64dfd87ab749c9d275f82b8b4ec84c84ecebdbe"},
-    {file = "SQLAlchemy-1.4.46-cp38-cp38-win_amd64.whl", hash = "sha256:07e48cbcdda6b8bc7a59d6728bd3f5f574ffe03f2c9fb384239f3789c2d95c2e"},
-    {file = "SQLAlchemy-1.4.46-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:1b1e5e96e2789d89f023d080bee432e2fef64d95857969e70d3cadec80bd26f0"},
-    {file = "SQLAlchemy-1.4.46-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3714e5b33226131ac0da60d18995a102a17dddd42368b7bdd206737297823ad"},
-    {file = "SQLAlchemy-1.4.46-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:955162ad1a931fe416eded6bb144ba891ccbf9b2e49dc7ded39274dd9c5affc5"},
-    {file = "SQLAlchemy-1.4.46-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6e4cb5c63f705c9d546a054c60d326cbde7421421e2d2565ce3e2eee4e1a01f"},
-    {file = "SQLAlchemy-1.4.46-cp39-cp39-win32.whl", hash = "sha256:51e1ba2884c6a2b8e19109dc08c71c49530006c1084156ecadfaadf5f9b8b053"},
-    {file = "SQLAlchemy-1.4.46-cp39-cp39-win_amd64.whl", hash = "sha256:315676344e3558f1f80d02535f410e80ea4e8fddba31ec78fe390eff5fb8f466"},
-    {file = "SQLAlchemy-1.4.46.tar.gz", hash = "sha256:6913b8247d8a292ef8315162a51931e2b40ce91681f1b6f18f697045200c4a30"},
+    {file = "SQLAlchemy-1.4.48-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:4bac3aa3c3d8bc7408097e6fe8bf983caa6e9491c5d2e2488cfcfd8106f13b6a"},
+    {file = "SQLAlchemy-1.4.48-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:dbcae0e528d755f4522cad5842f0942e54b578d79f21a692c44d91352ea6d64e"},
+    {file = "SQLAlchemy-1.4.48-cp27-cp27m-win32.whl", hash = "sha256:cbbe8b8bffb199b225d2fe3804421b7b43a0d49983f81dc654d0431d2f855543"},
+    {file = "SQLAlchemy-1.4.48-cp27-cp27m-win_amd64.whl", hash = "sha256:627e04a5d54bd50628fc8734d5fc6df2a1aa5962f219c44aad50b00a6cdcf965"},
+    {file = "SQLAlchemy-1.4.48-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9af1db7a287ef86e0f5cd990b38da6bd9328de739d17e8864f1817710da2d217"},
+    {file = "SQLAlchemy-1.4.48-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ce7915eecc9c14a93b73f4e1c9d779ca43e955b43ddf1e21df154184f39748e5"},
+    {file = "SQLAlchemy-1.4.48-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5381ddd09a99638f429f4cbe1b71b025bed318f6a7b23e11d65f3eed5e181c33"},
+    {file = "SQLAlchemy-1.4.48-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:87609f6d4e81a941a17e61a4c19fee57f795e96f834c4f0a30cee725fc3f81d9"},
+    {file = "SQLAlchemy-1.4.48-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb0808ad34167f394fea21bd4587fc62f3bd81bba232a1e7fbdfa17e6cfa7cd7"},
+    {file = "SQLAlchemy-1.4.48-cp310-cp310-win32.whl", hash = "sha256:d53cd8bc582da5c1c8c86b6acc4ef42e20985c57d0ebc906445989df566c5603"},
+    {file = "SQLAlchemy-1.4.48-cp310-cp310-win_amd64.whl", hash = "sha256:4355e5915844afdc5cf22ec29fba1010166e35dd94a21305f49020022167556b"},
+    {file = "SQLAlchemy-1.4.48-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:066c2b0413e8cb980e6d46bf9d35ca83be81c20af688fedaef01450b06e4aa5e"},
+    {file = "SQLAlchemy-1.4.48-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c99bf13e07140601d111a7c6f1fc1519914dd4e5228315bbda255e08412f61a4"},
+    {file = "SQLAlchemy-1.4.48-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ee26276f12614d47cc07bc85490a70f559cba965fb178b1c45d46ffa8d73fda"},
+    {file = "SQLAlchemy-1.4.48-cp311-cp311-win32.whl", hash = "sha256:49c312bcff4728bffc6fb5e5318b8020ed5c8b958a06800f91859fe9633ca20e"},
+    {file = "SQLAlchemy-1.4.48-cp311-cp311-win_amd64.whl", hash = "sha256:cef2e2abc06eab187a533ec3e1067a71d7bbec69e582401afdf6d8cad4ba3515"},
+    {file = "SQLAlchemy-1.4.48-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:3509159e050bd6d24189ec7af373359f07aed690db91909c131e5068176c5a5d"},
+    {file = "SQLAlchemy-1.4.48-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fc2ab4d9f6d9218a5caa4121bdcf1125303482a1cdcfcdbd8567be8518969c0"},
+    {file = "SQLAlchemy-1.4.48-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e1ddbbcef9bcedaa370c03771ebec7e39e3944782bef49e69430383c376a250b"},
+    {file = "SQLAlchemy-1.4.48-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f82d8efea1ca92b24f51d3aea1a82897ed2409868a0af04247c8c1e4fef5890"},
+    {file = "SQLAlchemy-1.4.48-cp36-cp36m-win32.whl", hash = "sha256:e3e98d4907805b07743b583a99ecc58bf8807ecb6985576d82d5e8ae103b5272"},
+    {file = "SQLAlchemy-1.4.48-cp36-cp36m-win_amd64.whl", hash = "sha256:25887b4f716e085a1c5162f130b852f84e18d2633942c8ca40dfb8519367c14f"},
+    {file = "SQLAlchemy-1.4.48-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:0817c181271b0ce5df1aa20949f0a9e2426830fed5ecdcc8db449618f12c2730"},
+    {file = "SQLAlchemy-1.4.48-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe1dd2562313dd9fe1778ed56739ad5d9aae10f9f43d9f4cf81d65b0c85168bb"},
+    {file = "SQLAlchemy-1.4.48-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:68413aead943883b341b2b77acd7a7fe2377c34d82e64d1840860247cec7ff7c"},
+    {file = "SQLAlchemy-1.4.48-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbde5642104ac6e95f96e8ad6d18d9382aa20672008cf26068fe36f3004491df"},
+    {file = "SQLAlchemy-1.4.48-cp37-cp37m-win32.whl", hash = "sha256:11c6b1de720f816c22d6ad3bbfa2f026f89c7b78a5c4ffafb220e0183956a92a"},
+    {file = "SQLAlchemy-1.4.48-cp37-cp37m-win_amd64.whl", hash = "sha256:eb5464ee8d4bb6549d368b578e9529d3c43265007193597ddca71c1bae6174e6"},
+    {file = "SQLAlchemy-1.4.48-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:92e6133cf337c42bfee03ca08c62ba0f2d9695618c8abc14a564f47503157be9"},
+    {file = "SQLAlchemy-1.4.48-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44d29a3fc6d9c45962476b470a81983dd8add6ad26fdbfae6d463b509d5adcda"},
+    {file = "SQLAlchemy-1.4.48-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:005e942b451cad5285015481ae4e557ff4154dde327840ba91b9ac379be3b6ce"},
+    {file = "SQLAlchemy-1.4.48-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c8cfe951ed074ba5e708ed29c45397a95c4143255b0d022c7c8331a75ae61f3"},
+    {file = "SQLAlchemy-1.4.48-cp38-cp38-win32.whl", hash = "sha256:2b9af65cc58726129d8414fc1a1a650dcdd594ba12e9c97909f1f57d48e393d3"},
+    {file = "SQLAlchemy-1.4.48-cp38-cp38-win_amd64.whl", hash = "sha256:2b562e9d1e59be7833edf28b0968f156683d57cabd2137d8121806f38a9d58f4"},
+    {file = "SQLAlchemy-1.4.48-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:a1fc046756cf2a37d7277c93278566ddf8be135c6a58397b4c940abf837011f4"},
+    {file = "SQLAlchemy-1.4.48-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d9b55252d2ca42a09bcd10a697fa041e696def9dfab0b78c0aaea1485551a08"},
+    {file = "SQLAlchemy-1.4.48-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6dab89874e72a9ab5462997846d4c760cdb957958be27b03b49cf0de5e5c327c"},
+    {file = "SQLAlchemy-1.4.48-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fd8b5ee5a3acc4371f820934b36f8109ce604ee73cc668c724abb054cebcb6e"},
+    {file = "SQLAlchemy-1.4.48-cp39-cp39-win32.whl", hash = "sha256:eee09350fd538e29cfe3a496ec6f148504d2da40dbf52adefb0d2f8e4d38ccc4"},
+    {file = "SQLAlchemy-1.4.48-cp39-cp39-win_amd64.whl", hash = "sha256:7ad2b0f6520ed5038e795cc2852eb5c1f20fa6831d73301ced4aafbe3a10e1f6"},
+    {file = "SQLAlchemy-1.4.48.tar.gz", hash = "sha256:b47bc287096d989a0838ce96f7d8e966914a24da877ed41a7531d44b55cdb8df"},
 ]
 
 [package.dependencies]
@@ -989,7 +1238,7 @@ sqlcipher = ["sqlcipher3-binary"]
 name = "tomli"
 version = "2.0.1"
 description = "A lil' TOML parser"
-category = "dev"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -997,22 +1246,56 @@ files = [
     {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
 ]
 
+[[package]]
+name = "typer"
+version = "0.7.0"
+description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "typer-0.7.0-py3-none-any.whl", hash = "sha256:b5e704f4e48ec263de1c0b3a2387cd405a13767d2f907f44c1a08cbad96f606d"},
+    {file = "typer-0.7.0.tar.gz", hash = "sha256:ff797846578a9f2a201b53442aedeb543319466870fbe1c701eab66dd7681165"},
+]
+
+[package.dependencies]
+click = ">=7.1.1,<9.0.0"
+
+[package.extras]
+all = ["colorama (>=0.4.3,<0.5.0)", "rich (>=10.11.0,<13.0.0)", "shellingham (>=1.3.0,<2.0.0)"]
+dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "pre-commit (>=2.17.0,<3.0.0)"]
+doc = ["cairosvg (>=2.5.2,<3.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pillow (>=9.3.0,<10.0.0)"]
+test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "pytest (>=4.4.0,<8.0.0)", "pytest-cov (>=2.10.0,<5.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<4.0.0)", "rich (>=10.11.0,<13.0.0)", "shellingham (>=1.3.0,<2.0.0)"]
+
+[[package]]
+name = "typing-extensions"
+version = "4.5.0"
+description = "Backported and Experimental Type Hints for Python 3.7+"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"},
+    {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"},
+]
+
 [[package]]
 name = "urllib3"
-version = "1.26.14"
+version = "2.0.2"
 description = "HTTP library with thread-safe connection pooling, file post, and more."
 category = "main"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
+python-versions = ">=3.7"
 files = [
-    {file = "urllib3-1.26.14-py2.py3-none-any.whl", hash = "sha256:75edcdc2f7d85b137124a6c3c9fc3933cdeaa12ecb9a6a959f22797a0feca7e1"},
-    {file = "urllib3-1.26.14.tar.gz", hash = "sha256:076907bf8fd355cde77728471316625a4d2f7e713c125f51953bb5b3eecf4f72"},
+    {file = "urllib3-2.0.2-py3-none-any.whl", hash = "sha256:d055c2f9d38dc53c808f6fdc8eab7360b6fdbbde02340ed25cfbcd817c62469e"},
+    {file = "urllib3-2.0.2.tar.gz", hash = "sha256:61717a1095d7e155cdb737ac7bb2f4324a858a1e2e6466f6d03ff630ca68d3cc"},
 ]
 
 [package.extras]
-brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"]
-secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"]
-socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
+brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
+secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"]
+socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
+zstd = ["zstandard (>=0.18.0)"]
 
 [[package]]
 name = "validators"
@@ -1033,24 +1316,24 @@ test = ["flake8 (>=2.4.0)", "isort (>=4.2.2)", "pytest (>=2.2.3)"]
 
 [[package]]
 name = "virtualenv"
-version = "20.17.1"
+version = "20.23.0"
 description = "Virtual Python Environment builder"
 category = "dev"
 optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
 files = [
-    {file = "virtualenv-20.17.1-py3-none-any.whl", hash = "sha256:ce3b1684d6e1a20a3e5ed36795a97dfc6af29bc3970ca8dab93e11ac6094b3c4"},
-    {file = "virtualenv-20.17.1.tar.gz", hash = "sha256:f8b927684efc6f1cc206c9db297a570ab9ad0e51c16fa9e45487d36d1905c058"},
+    {file = "virtualenv-20.23.0-py3-none-any.whl", hash = "sha256:6abec7670e5802a528357fdc75b26b9f57d5d92f29c5462ba0fbe45feacc685e"},
+    {file = "virtualenv-20.23.0.tar.gz", hash = "sha256:a85caa554ced0c0afbd0d638e7e2d7b5f92d23478d05d17a76daeac8f279f924"},
 ]
 
 [package.dependencies]
 distlib = ">=0.3.6,<1"
-filelock = ">=3.4.1,<4"
-platformdirs = ">=2.4,<3"
+filelock = ">=3.11,<4"
+platformdirs = ">=3.2,<4"
 
 [package.extras]
-docs = ["proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-argparse (>=0.3.2)", "sphinx-rtd-theme (>=1)", "towncrier (>=22.8)"]
-testing = ["coverage (>=6.2)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=21.3)", "pytest (>=7.0.1)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.6.1)", "pytest-randomly (>=3.10.3)", "pytest-timeout (>=2.1)"]
+docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"]
+test = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.3.1)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=67.7.1)", "time-machine (>=2.9)"]
 
 [[package]]
 name = "werkzeug"
@@ -1070,4 +1353,4 @@ watchdog = ["watchdog"]
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.10"
-content-hash = "f50336fb52be804527a228de362a475f0b7072949bfad32185a23ff4147c28ec"
+content-hash = "3466a53b0fed48bab6d63b7598d2aa4c12a907f9148e937beebda728383b0e78"
diff --git a/pyproject.toml b/pyproject.toml
index 4ed2258e230d08972094760e9e0b6f34a8f63704..6427733b1e68370e56dac7c442c4eaec5db23b94 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,10 +43,11 @@ Flask-SQLAlchemy = "2.4.3"
 itsdangerous = "2.0.1"
 http-parser = "^0.9.0"
 psycopg2-binary = "^2.9.5"
+openapi-python-client = "^0.14.0"
 
 [tool.poetry.group.dev.dependencies]
 codespell = "^2.2.1"
-black = "^22.8.0"
+black = "^23.3.0"
 flake8 = "^5.0.4"
 flake8-docstrings = "^1.6.0"
 validators = "^0.20.0"
@@ -56,10 +57,13 @@ coverage = {extras = ["toml"], version = "^7.2.5"}
 
 [tool.coverage.run]
 source = ["src"]
-omit = []
+omit = ["types.py"]
 [tool.coverage.report]
 # TODO Increase after we have more test cases
-fail_under = 25
+fail_under = 40
+exclude_lines = [
+  "if __name__ == .__main__.:",
+]
 
 [build-system]
 requires = ["poetry>=1.3"]
diff --git a/schemas/openapi-steering.json b/schemas/openapi-steering.json
new file mode 100644
index 0000000000000000000000000000000000000000..345e57754eafecb37382c45b3132c98decd62c71
--- /dev/null
+++ b/schemas/openapi-steering.json
@@ -0,0 +1,92 @@
+{
+    "openapi": "3.0.3",
+    "info": {
+        "title": "AURA Steering API",
+        "version": "1.0.0",
+        "description": "Programme/schedule management for Aura"
+    },
+    "paths": {},
+    "components": {
+        "schemas": {
+            "Timeslot": {
+                "type": "object",
+                "properties": {
+                    "id": {
+                        "type": "integer"
+                    },
+                    "start": {
+                        "type": "string",
+                        "format": "date-time"
+                    },
+                    "end": {
+                        "type": "string",
+                        "format": "date-time"
+                    },
+                    "title": {
+                        "type": "integer"
+                    },
+                    "schedule_id": {
+                        "type": "integer"
+                    },
+                    "is_repetition": {
+                        "type": "boolean",
+                        "nullable": true
+                    },
+                    "playlist_id": {
+                        "type": "integer",
+                        "nullable": true
+                    },
+                    "schedule_default_playlist_id": {
+                        "type": "integer",
+                        "nullable": true
+                    },
+                    "show_default_playlist_id": {
+                        "type": "integer",
+                        "nullable": true
+                    },
+                    "show_id": {
+                        "type": "integer"
+                    },
+                    "show_name": {
+                        "type": "string"
+                    },
+                    "show_hosts": {
+                        "type": "string"
+                    },
+                    "show_type": {
+                        "type": "string",
+                        "nullable": true
+                    },
+                    "show_categories": {
+                        "type": "string",
+                        "nullable": true
+                    },
+                    "show_topics": {
+                        "type": "string",
+                        "nullable": true
+                    },
+                    "show_musicfocus": {
+                        "type": "string",
+                        "nullable": true
+                    },
+                    "show_languages": {
+                        "type": "string",
+                        "nullable": true
+                    },
+                    "show_fundingcategory": {
+                        "type": "string",
+                        "nullable": true
+                    },
+                    "memo": {
+                        "type": "string",
+                        "nullable": true
+                    },
+                    "className": {
+                        "type": "string",
+                        "nullable": true
+                    }
+                }
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/schemas/openapi-tank.json b/schemas/openapi-tank.json
new file mode 100644
index 0000000000000000000000000000000000000000..b19876a1b8afed2c750bcf2bc5c9bfab9b91ec0e
--- /dev/null
+++ b/schemas/openapi-tank.json
@@ -0,0 +1,145 @@
+{
+    "openapi": "3.0.1",
+    "info": {
+        "contact": {
+            "name": "aura.radio",
+            "url": "https://aura.radio"
+        },
+        "description": "Import & Playlist Daemon",
+        "license": {
+            "name": "AGPLv3",
+            "url": "https://www.gnu.org/licenses/agpl-3.0"
+        },
+        "title": "AURA Tank API",
+        "version": "1.0"
+    },
+    "servers": [
+        {
+            "url": "/"
+        }
+    ],
+    "paths": {},
+    "components": {
+        "schemas": {
+            "File": {
+                "properties": {
+                    "created": {
+                        "type": "string"
+                    },
+                    "duration": {
+                        "type": "integer"
+                    },
+                    "id": {
+                        "type": "integer"
+                    },
+                    "metadata": {
+                        "$ref": "#/components/schemas/FileMetadata"
+                    },
+                    "show": {
+                        "type": "string"
+                    },
+                    "size": {
+                        "type": "integer"
+                    },
+                    "source": {
+                        "$ref": "#/components/schemas/FileSource"
+                    },
+                    "updated": {
+                        "type": "string"
+                    }
+                },
+                "type": "object"
+            },
+            "FileMetadata": {
+                "properties": {
+                    "album": {
+                        "type": "string"
+                    },
+                    "artist": {
+                        "description": "actually a full-text index would be nice here...",
+                        "type": "string"
+                    },
+                    "isrc": {
+                        "type": "string"
+                    },
+                    "organization": {
+                        "type": "string"
+                    },
+                    "title": {
+                        "type": "string"
+                    }
+                },
+                "type": "object"
+            },
+            "FileSource": {
+                "properties": {
+                    "hash": {
+                        "type": "string"
+                    },
+                    "import": {
+                        "$ref": "#/components/schemas/Import"
+                    },
+                    "uri": {
+                        "type": "string"
+                    }
+                },
+                "type": "object"
+            },
+            "Import": {
+                "properties": {
+                    "error": {
+                        "type": "string"
+                    },
+                    "state": {
+                        "type": "string"
+                    }
+                },
+                "type": "object"
+            },
+            "Playlist": {
+                "properties": {
+                    "created": {
+                        "type": "string"
+                    },
+                    "description": {
+                        "type": "string"
+                    },
+                    "entries": {
+                        "items": {
+                            "$ref": "#/components/schemas/PlaylistEntry"
+                        },
+                        "type": "array"
+                    },
+                    "id": {
+                        "type": "integer"
+                    },
+                    "playout-mode": {
+                        "type": "string"
+                    },
+                    "show": {
+                        "type": "string"
+                    },
+                    "updated": {
+                        "type": "string"
+                    }
+                },
+                "type": "object"
+            },
+            "PlaylistEntry": {
+                "properties": {
+                    "duration": {
+                        "type": "integer"
+                    },
+                    "file": {
+                        "$ref": "#/components/schemas/File"
+                    },
+                    "uri": {
+                        "type": "string"
+                    }
+                },
+                "type": "object"
+            }
+        },
+        "x-original-swagger-version": "2.0"
+    }
+}
\ No newline at end of file
diff --git a/src/aura_engine/app.py b/src/aura_engine/app.py
index 4e7ef020c1898c84575693924d18788bd4fe0980..c5fdec9033e384f2c5b2671e0ca21fef57c64834 100755
--- a/src/aura_engine/app.py
+++ b/src/aura_engine/app.py
@@ -67,13 +67,13 @@ class EngineRunner:
         self.config = config
         AuraLogger(self.config)
         self.logger = logging.getLogger("engine")
+        self.engine = Engine()
 
     def run(self):
         """
         Start Engine Core.
         """
-
-        self.engine = Engine()
+        self.engine.start()
 
     def recreate_db(self):
         """
diff --git a/src/aura_engine/base/api.py b/src/aura_engine/base/api.py
index 2815b39eb54b97f5f5e5b65cebba2a387d9b4571..e1f3fbe94f1eddff30f2a6f26e2f115d46361d0a 100644
--- a/src/aura_engine/base/api.py
+++ b/src/aura_engine/base/api.py
@@ -42,7 +42,7 @@ frequent basis:
 
 Basic GET usage:
 
-   >>> from api import SimpleApi
+   >>> from api import SimpleRestApi
    >>> r = api.get("https://aura.radio/foo.json")
    >>> r.response.status_code
    200
@@ -55,6 +55,9 @@ Basic GET usage:
 
 import json
 import logging
+import os
+from pathlib import Path
+from urllib.parse import urlparse
 
 import requests
 
@@ -62,14 +65,14 @@ from aura_engine.base.lang import DotDict
 from aura_engine.base.utils import SimpleUtil as SU
 
 
-class SimpleApi:
+class SimpleRestApi:
     """
     Simple wrapper on `requests` to deal with REST APIs.
 
     Use it for services which do not want to deal with exception
     handling but with results only.
 
-    SimpleApi has implicit logging of invalid states at logs
+    SimpleRestApi has implicit logging of invalid states at logs
     to the `engine` logger by default.
     """
 
@@ -116,7 +119,10 @@ class SimpleApi:
                     response_dict = response
                     response = response_dict.response
                 if int(response.status_code) >= 300:
-                    error = f"{response.status_code} | Error {msg_template}: {response.reason}"
+                    reason = "-"
+                    if hasattr(response, "reason"):
+                        reason = response.reason
+                    error = f"{response.status_code} | Error {msg_template}: {reason}"
                     args[0].logger.error(SU.red(error))
             except requests.exceptions.ConnectionError as e:
                 exc = e
@@ -158,7 +164,7 @@ class SimpleApi:
             if value is None:
                 del data[key]
             elif isinstance(value, dict):
-                SimpleApi.clean_dictionary(self, value)
+                SimpleRestApi.clean_dictionary(self, value)
         return data
 
     def serialize_json(self, data: dict, clean_data=True) -> str:
@@ -192,8 +198,9 @@ class SimpleApi:
         json_data = None
         try:
             json_data = response.json()
-        except Exception as e:
-            self.logger.error(f"Invalid JSON: {response.content}", e)
+        except Exception:
+            self.logger.error(f"Invalid JSON: {response.content}")
+            return None
         return json_data
 
     @exception_handler
@@ -214,9 +221,9 @@ class SimpleApi:
         """
         json_data = None
         if not headers:
-            headers = SimpleApi.default_headers
+            headers = SimpleRestApi.default_headers
         response = requests.get(url, headers=headers)
-        if headers.get("content-type") == SimpleApi.CONTENT_JSON:
+        if headers.get("content-type") == SimpleRestApi.CONTENT_JSON:
             json_data = self.deserialize_json(response)
         return DotDict({"response": response, "json": json_data})
 
@@ -228,7 +235,7 @@ class SimpleApi:
         Args:
             url (str): The URL of the request
             data (dict): Data payload for request body
-            headers (dict, optional): Optional headers, defaults to `SimpleApi.default_headers`
+            headers (dict, optional): Optional headers, defaults to `SimpleRestApi.default_headers`
 
         Returns:
             {
@@ -239,9 +246,9 @@ class SimpleApi:
 
         """
         if not headers:
-            headers = SimpleApi.default_headers
+            headers = SimpleRestApi.default_headers
         body: str = self.serialize_json(data)
-        return requests.put(url, data=body, headers=headers)
+        return requests.post(url, data=body, headers=headers)
 
     @exception_handler
     def put(self, url: str, data: dict, headers: dict = None) -> requests.Response:
@@ -251,7 +258,7 @@ class SimpleApi:
         Args:
             url (str): The URL of the request
             data (dict): Data payload for request body
-            headers (dict, optional): Optional headers, defaults to `SimpleApi.default_headers`
+            headers (dict, optional): Optional headers, defaults to `SimpleRestApi.default_headers`
 
         Returns:
             {
@@ -262,11 +269,106 @@ class SimpleApi:
 
         """
         if not headers:
-            headers = SimpleApi.default_headers
+            headers = SimpleRestApi.default_headers
         body: str = self.serialize_json(data)
         return requests.put(url, data=body, headers=headers)
 
 
+class SimpleCachedRestApi:
+    """
+    Wrapper to cache GET responses based on the simple REST API.
+
+    It uses a network-first strategy:
+
+      1. Query the requested API endpoint
+      2. Store the result in a JSON file
+      3. Return the result as a JSON object
+
+    If the API endpoint is not available at step 1.) the cached JSON from the
+    most recent, previously succeesful request is returned.
+
+    """
+
+    cache_location: str
+    simple_api: SimpleRestApi
+
+    logger = None
+
+    def __init__(self, simple_api: SimpleRestApi, cache_location: str, logger_name="engine"):
+        if cache_location[-1] != "/":
+            cache_location += "/"
+        cache_location += "api/"
+        os.makedirs(cache_location, exist_ok=True)
+        self.simple_api = simple_api
+        self.cache_location = cache_location
+        self.logger = logging.getLogger(logger_name)
+
+    def get(self, url: str, headers: dict = None) -> requests.Response:
+        """
+        GET from an URL while also storing the result in the local cache.
+
+        Args:
+            url (str): The URL of the request
+
+        Returns:
+            {
+                "response": requests.Response,
+                "error": str,
+                "exception": Exception
+            }
+
+        """
+        filename = self.build_filename(url)
+        cache_filepath = self.cache_location + filename
+        result = self.simple_api.get(url, headers)
+
+        if result and result.json and result.response.status_code == 200:
+            with open(cache_filepath, "w") as file:
+                json.dump(result.json, file)
+                file.close()
+        else:
+            json_data = None
+            try:
+                file = open(cache_filepath, "r")
+                json_data = json.load(file)
+                file.close()
+            except FileNotFoundError:
+                pass
+
+            if json_data:
+                result = {
+                    "response": DotDict({"status_code": 304, "error": "Not Modified"}),
+                    "json": json_data,
+                }
+            else:
+                result = {
+                    "response": DotDict({"status_code": 404, "error": "Not Found in local cache"}),
+                    "json": None,
+                }
+
+        return DotDict(result)
+
+    def build_filename(self, url: str) -> str:
+        """
+        Build a valid file name based on the URI parts of an URL.
+
+        Args:
+            url (str): The URL to build the filename from
+
+        Returns:
+            str: File name representing an URL
+        """
+        parts = urlparse(url)
+        dirs = parts.path.strip("/").split("/")
+        return "-".join(dirs) + ".json"
+
+    def prune_cache_dir(self):
+        """
+        Delete everything in the API cache directory.
+        """
+        [f.unlink() for f in Path(self.cache_location).iterdir() if f.is_file()]
+
+
 class LiquidsoapUtil:
     """
     Utilities specific to Liquidsoap.
diff --git a/src/aura_engine/engine.py b/src/aura_engine/engine.py
index 5ad059269e0155469eac98fe089ed2b6cb590c92..20c29439f6a49a07b9123945ddf8957ed57f3d66 100644
--- a/src/aura_engine/engine.py
+++ b/src/aura_engine/engine.py
@@ -68,7 +68,6 @@ class Engine:
         self.logger = logging.getLogger("engine")
         self.config = AuraConfig.config()
         Engine.engine_time_offset = float(self.config.get("engine_latency_offset"))
-        self.start()
 
     def start(self):
         """
@@ -125,7 +124,7 @@ class Engine:
         state = DotDict(LU.json_to_dict(state))
 
         def dispatch_fallback_event():
-            timeslot = self.scheduler.programme.get_current_timeslot()
+            timeslot = self.scheduler.timetable.get_current_timeslot()
             fallback_show_name = self.config.get("fallback_show_name")
             self.event_dispatcher.on_fallback_active(timeslot, fallback_show_name)
 
diff --git a/src/aura_engine/plugins/clock.py b/src/aura_engine/plugins/clock.py
index 0398276e913cb855b0e9b52b04678190a54b90be..02a810054eb82f598fd9713cd93c4d7380b4d888 100644
--- a/src/aura_engine/plugins/clock.py
+++ b/src/aura_engine/plugins/clock.py
@@ -24,7 +24,7 @@ Update the clock information stored in Engine API.
 import logging
 from datetime import datetime, timedelta
 
-from aura_engine.base.api import SimpleApi
+from aura_engine.base.api import SimpleRestApi
 from aura_engine.base.config import AuraConfig
 from aura_engine.base.lang import DotDict
 from aura_engine.resources import ResourceUtil
@@ -46,7 +46,7 @@ class ClockInfoHandler:
         """
         self.logger = logging.getLogger("engine")
         self.config = AuraConfig.config()
-        self.api = SimpleApi()
+        self.api = SimpleRestApi()
         self.engine = engine
 
         if not self.engine:
@@ -74,7 +74,7 @@ class ClockInfoHandler:
 
         # Interpolate timeslot-less slot
         # TODO start time to be calculated based on previous timeslot (future station logic)
-        upcoming_timeslots = self.engine.scheduler.programme.get_next_timeslots()
+        upcoming_timeslots = self.engine.scheduler.timetable.get_next_timeslots()
         virtual_start_time = datetime.now()
         virtual_end_time = virtual_start_time + timedelta(hours=1)
         if len(upcoming_timeslots) > 0:
@@ -108,8 +108,8 @@ class ClockInfoHandler:
         """
         active_playlist_type, active_playlist = self.engine.scheduler.get_active_playlist()
         active_type_id = active_playlist_type.get("id")
-        active_timeslot = self.engine.scheduler.programme.get_current_timeslot()
-        upcoming_timeslots = self.engine.scheduler.programme.get_next_timeslots()
+        active_timeslot = self.engine.scheduler.timetable.get_current_timeslot()
+        upcoming_timeslots = self.engine.scheduler.timetable.get_next_timeslots()
         self.post_clock_info(active_type_id, active_playlist, active_timeslot, upcoming_timeslots)
 
     def post_clock_info(self, active_type, active_playlist, active_timeslot, upcoming_timeslots):
diff --git a/src/aura_engine/resources.py b/src/aura_engine/resources.py
index baa57d19203450415437338f94e9c7f77703b611..cd804362e7b75305299c3b6bca7c0fe59c6ecf7a 100644
--- a/src/aura_engine/resources.py
+++ b/src/aura_engine/resources.py
@@ -62,7 +62,7 @@ class ResourceMapping:
         """
         Retrieve a `ChannelType` for the given `ResourceType`.
 
-        Only default mappings can be evaluatated. Custom variations
+        Only default mappings can be evaluated. Custom variations
         like fallback channels are not respected.
         """
         return self.resource_mapping.get(resource_type)
diff --git a/src/aura_engine/scheduling/api.py b/src/aura_engine/scheduling/api.py
index ac4f012b82caf63341de797c4ce01671b4482b96..950f66cd49e096b238009765d4103504be9e0251 100644
--- a/src/aura_engine/scheduling/api.py
+++ b/src/aura_engine/scheduling/api.py
@@ -25,7 +25,7 @@ import logging
 import queue
 import threading
 
-from aura_engine.base.api import SimpleApi
+from aura_engine.base.api import SimpleCachedRestApi, SimpleRestApi
 from aura_engine.base.config import AuraConfig
 from aura_engine.base.lang import private
 from aura_engine.base.utils import SimpleUtil as SU
@@ -68,7 +68,8 @@ class ApiFetcher(threading.Thread):
         """
         self.config = AuraConfig.config()
         self.logger = logging.getLogger("engine")
-        self.api = SimpleApi()
+        cache_location = self.config.get("cache_dir")
+        self.api = SimpleCachedRestApi(SimpleRestApi(), cache_location)
         self.url_api_timeslots = self.config.get("api_steering_calendar")
         self.url_api_playlist = self.config.get("api_tank_playlist")
         self.queue = queue.Queue()
@@ -132,7 +133,7 @@ class ApiFetcher(threading.Thread):
     #
 
     @private
-    def get_current_timeslots(self):
+    def get_current_timeslots(self) -> list:
         """
         Fetch timeslot data from Steering.
 
@@ -150,9 +151,6 @@ class ApiFetcher(threading.Thread):
         self.logger.debug("Fetch timeslots from Steering API...")
         result = self.api.get(url)
         timeslots = result.json
-        if not timeslots:
-            return None
-
         timeslots = self.filter_timeslots(timeslots)
 
         for t in timeslots:
@@ -190,7 +188,7 @@ class ApiFetcher(threading.Thread):
         return timeslots
 
     @private
-    def fetch_playlist(self, playlist_id: int, fetched_playlists):
+    def fetch_playlist(self, playlist_id: int, fetched_playlists: dict):
         """
         Fetch a playlist from Tank.
 
@@ -224,7 +222,7 @@ class ApiFetcher(threading.Thread):
         return playlist
 
     @private
-    def filter_timeslots(self, timeslots):
+    def filter_timeslots(self, timeslots: list) -> list:
         """
         Remove all timeslots which are not relevant for further processing.
 
@@ -234,6 +232,9 @@ class ApiFetcher(threading.Thread):
             timeslots (dict): The timeslots to be filtered
         @private
         """
+        if not timeslots:
+            return []
+
         count_before = len(timeslots)
         timeslots = TimeslotFilter.filter_24h(timeslots)
         timeslots = TimeslotFilter.filter_past(timeslots)
diff --git a/src/aura_engine/scheduling/scheduler.py b/src/aura_engine/scheduling/scheduler.py
index 0fabb86fc8c60ee848bf5bf62fbc74d75501bea0..92a902ab8b2584f2b6fb11a56c17ab9457e56d90 100644
--- a/src/aura_engine/scheduling/scheduler.py
+++ b/src/aura_engine/scheduling/scheduler.py
@@ -33,7 +33,7 @@ from aura_engine.engine import Engine, Player
 from aura_engine.resources import ResourceClass, ResourceUtil
 from aura_engine.scheduling.models import AuraDatabaseModel
 from aura_engine.scheduling.programme import ProgrammeService
-from aura_engine.scheduling.utils import TimeslotRenderer
+from aura_engine.scheduling.utils import TimetableRenderer
 
 
 class NoActiveTimeslotException(Exception):
@@ -45,10 +45,12 @@ class NoActiveTimeslotException(Exception):
 
 
 class AuraScheduler(threading.Thread):
-    """The programme scheduler.
+    """
+    The Scheduler.
 
-    - Retrieves data from Steering and Tank
-    - Executes engine actions in an automated fashion
+    The programme scheduler has two main duties:
+        - Retrieve data from Steering and Tank
+        - Execute engine actions in an automated, timed fashion
 
     """
 
@@ -56,8 +58,8 @@ class AuraScheduler(threading.Thread):
     logger = None
     engine: Engine = None
     exit_event: threading.Event = None
-    timeslot_renderer: TimeslotRenderer = None
-    programme: ProgrammeService = None
+    timetable_renderer: TimetableRenderer = None
+    timetable: ProgrammeService = None
     message_timer = []
     is_initialized: bool = None
     is_engine_ready: bool = None
@@ -65,8 +67,8 @@ class AuraScheduler(threading.Thread):
     def __init__(self, engine):
         self.config = AuraConfig.config()
         self.logger = logging.getLogger("engine")
-        self.programme = ProgrammeService()
-        self.timeslot_renderer = TimeslotRenderer(self)
+        self.timetable = ProgrammeService()
+        self.timetable_renderer = TimetableRenderer(self)
         self.engine = engine
         self.engine.scheduler = self
         self.is_soundsytem_init = False
@@ -89,7 +91,7 @@ class AuraScheduler(threading.Thread):
             1. `self.fetch_new_programme()` periodically from the API depending on the
                 `fetching_frequency` defined in the engine configuration.
             2. Loads the latest programme from the database and sets the instance state
-                `self.programme` with current timeslots.
+                `self.timetable` with current timeslots.
             3. Queues all timeslots of the programme, if the soundssystem is ready to accept
                 commands.
 
@@ -104,7 +106,7 @@ class AuraScheduler(threading.Thread):
                 self.logger.info(SU.cyan(msg))
 
                 # Load some stuff from the API in any case
-                self.programme.refresh()
+                self.timetable.refresh()
 
                 # Queue only when the engine is ready to play
                 if self.is_initialized:
@@ -128,7 +130,7 @@ class AuraScheduler(threading.Thread):
         Called when the engine has finished booting and is ready to play.
         """
         self.is_initialized = True
-        self.logger.info(self.timeslot_renderer.get_ascii_timeslots())
+        self.logger.info(self.timetable_renderer.get_ascii_timeslots())
 
         try:
             self.play_active_entry()
@@ -158,32 +160,32 @@ class AuraScheduler(threading.Thread):
     #   METHODS
     #
 
-    def get_programme(self):
+    def get_timetable(self):
         """
-        Return the current programme.
+        Return the current timetable.
         """
-        return self.programme
+        return self.timetable
 
     def play_active_entry(self):
-        """Play currently active playlist entry, as per programme.
+        """Play currently active playlist entry, as per timetable.
 
         Plays the entry scheduled for the very current moment and forwards to the scheduled
         position in time. Usually called when the Engine boots.
 
         Raises:
-            (NoActiveTimeslotException): If there's no timeslot in the programme, within the
+            (NoActiveTimeslotException): If there's no timeslot in the timetable, within the
                 scheduling window
 
         """
         sleep_offset = 10
-        active_timeslot = self.programme.get_current_timeslot()
+        active_timeslot = self.timetable.get_current_timeslot()
 
         # Schedule any available fallback playlist
         if active_timeslot:
             # Create command timer to indicate the start of the timeslot
             TimeslotCommand(self.engine, active_timeslot)
 
-        active_entry = self.programme.get_current_entry()
+        active_entry = self.timetable.get_current_entry()
         if not active_entry:
             raise NoActiveTimeslotException
 
@@ -240,7 +242,7 @@ class AuraScheduler(threading.Thread):
             (Dict, Playlist): A dictionary holding the playlist type and the resolved playlist
 
         """
-        timeslot = self.programme.get_current_timeslot()
+        timeslot = self.timetable.get_current_timeslot()
         if timeslot:
             return self.resolve_playlist(timeslot)
         return (-1, None)
@@ -256,7 +258,7 @@ class AuraScheduler(threading.Thread):
             (Dict, Playlist): A dictionary holding the playlist type and the resolved playlist
 
         """
-        playlist_type, playlist = self.programme.get_current_playlist(timeslot)
+        playlist_type, playlist = self.timetable.get_current_playlist(timeslot)
         return (playlist_type, playlist)
 
     def queue_programme(self):
@@ -267,7 +269,7 @@ class AuraScheduler(threading.Thread):
         enable the individual tracks of playlists.
         """
         # Get a clean set of the timeslots within the scheduling window
-        timeslots = self.programme.get_next_timeslots()
+        timeslots = self.timetable.get_next_timeslots()
         timeslots = self.filter_scheduling_window(timeslots)
 
         # Queue the timeslots, their playlists and entries
@@ -276,7 +278,7 @@ class AuraScheduler(threading.Thread):
                 # Create command timer to indicate the start of the timeslot
                 TimeslotCommand(self.engine, next_timeslot)
 
-                playlist_type, playlist = self.programme.get_current_playlist(next_timeslot)
+                playlist_type, playlist = self.timetable.get_current_playlist(next_timeslot)
                 if playlist:
                     self.queue_playlist_entries(next_timeslot, playlist.entries, False, True)
 
@@ -288,14 +290,14 @@ class AuraScheduler(threading.Thread):
 
         Don't use this method in any other scenario, as it doesn't respect the scheduling window.
         """
-        current_timeslot = self.programme.get_current_timeslot()
+        current_timeslot = self.timetable.get_current_timeslot()
 
         # Queue the (rest of the) currently playing timeslot upon startup
         if current_timeslot:
-            playlist_type, current_playlist = self.programme.get_current_playlist(current_timeslot)
+            playlist_type, current_playlist = self.timetable.get_current_playlist(current_timeslot)
 
             if current_playlist:
-                active_entry = self.programme.get_current_entry()
+                active_entry = self.timetable.get_current_entry()
                 if active_entry:
                     # Queue open entries for current playlist
                     rest_of_playlist = active_entry.get_next_entries(True)
@@ -415,7 +417,7 @@ class AuraScheduler(threading.Thread):
         Call this method when thread is stopped or a signal to terminate is received.
         """
         self.logger.info(SU.yellow("[Scheduler] Shutting down..."))
-        self.programme.terminate()
+        self.timetable.terminate()
         self.exit_event.set()
 
 
@@ -489,8 +491,8 @@ class TimeslotCommand(EngineExecutor):
         self.engine.event_dispatcher.on_timeslot_end(timeslot)
 
         def has_direct_successor():
-            programme = self.engine.scheduler.programme
-            next_timeslot = programme.get_next_timeslots(1)
+            timetable = self.engine.scheduler.timetable
+            next_timeslot = timetable.get_next_timeslots(1)
             if next_timeslot:
                 next_timeslot = next_timeslot[0]
                 if next_timeslot.timeslot_start > timeslot.timeslot_end:
@@ -572,4 +574,4 @@ class PlayCommand(EngineExecutor):
                 time.sleep(2)
 
         self.engine.player.play(entries[0], Player.TransitionType.FADE)
-        self.logger.info(self.engine.scheduler.timeslot_renderer.get_ascii_timeslots())
+        self.logger.info(self.engine.scheduler.timetable_renderer.get_ascii_timeslots())
diff --git a/src/aura_engine/scheduling/utils.py b/src/aura_engine/scheduling/utils.py
index c396fe0ceefa355d866299a304353cdaa7992021..bb1f29b333856c068ed72560106c58669bf769d3 100644
--- a/src/aura_engine/scheduling/utils.py
+++ b/src/aura_engine/scheduling/utils.py
@@ -45,7 +45,7 @@ class TimeslotFilter:
     """
 
     @staticmethod
-    def filter_invalid(timeslots) -> bool:
+    def filter_invalid(timeslots: list) -> bool:
         """
         Remove invalid timeslots.
 
@@ -62,8 +62,9 @@ class TimeslotFilter:
         return items
 
     @staticmethod
-    def filter_24h(timeslots):
-        """Filter timeslot of the last 24 hours.
+    def filter_24h(timeslots: list):
+        """
+        Filter timeslot of the last 24 hours.
 
         Remove entries 24h in the future and 12 hours in the past.
         Note: This might influence resuming (in case of a crash)
@@ -85,8 +86,9 @@ class TimeslotFilter:
         return items
 
     @staticmethod
-    def filter_past(timeslots):
-        """Filter old timeslots.
+    def filter_past(timeslots: list):
+        """
+        Filter old timeslots.
 
         Remove all timeslot dictionaries from the past, except the one which is
         currently playing.
@@ -111,7 +113,7 @@ class TimeslotFilter:
 
 class M3UPlaylistProcessor:
     """
-    Renders a M3U Playlist as a engine compatible playlist dictionary.
+    Render a M3U Playlist as a engine compatible playlist dictionary.
     """
 
     config = None
@@ -174,9 +176,9 @@ class M3UPlaylistProcessor:
         return entries
 
 
-class TimeslotRenderer:
+class TimetableRenderer:
     """
-    Displays current and next timeslots in ASCII for maintenance and debugging.
+    Display current and next timeslots in ASCII for maintenance and debugging.
     """
 
     logger = None
@@ -186,7 +188,7 @@ class TimeslotRenderer:
     def __init__(self, scheduler):
         self.logger = logging.getLogger("engine")
         self.scheduler = scheduler
-        self.programme = scheduler.get_programme()
+        self.programme = scheduler.get_timetable()
 
     def get_ascii_timeslots(self):
         """
@@ -340,7 +342,7 @@ class TimeslotRenderer:
 
     def preprocess_entries(self, entries, cut_oos):
         """
-        Analyse and marks entries which are going to be cut or excluded.
+        Analyse and mark entries which are going to be cut or excluded.
 
         Args:
             entries ([PlaylistEntry]): The playlist entries to be scheduled for playout
diff --git a/src/aura_steering_api/models/__init__.py b/src/aura_steering_api/models/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..718e2f1c38fc0c456177e2546f0f16fea8dd573c
--- /dev/null
+++ b/src/aura_steering_api/models/__init__.py
@@ -0,0 +1,5 @@
+""" Contains all the data models used in inputs/outputs """
+
+from .timeslot import Timeslot
+
+__all__ = ("Timeslot",)
diff --git a/src/aura_steering_api/models/timeslot.py b/src/aura_steering_api/models/timeslot.py
new file mode 100644
index 0000000000000000000000000000000000000000..5da8d5f45a82205abf3bcd1c5a575173cc45b98a
--- /dev/null
+++ b/src/aura_steering_api/models/timeslot.py
@@ -0,0 +1,227 @@
+import datetime
+from typing import Any, Dict, List, Type, TypeVar, Union
+
+import attr
+from dateutil.parser import isoparse
+
+from ..types import UNSET, Unset
+
+T = TypeVar("T", bound="Timeslot")
+
+
+@attr.s(auto_attribs=True)
+class Timeslot:
+    """
+    Attributes:
+        id (Union[Unset, int]):
+        start (Union[Unset, datetime.datetime]):
+        end (Union[Unset, datetime.datetime]):
+        title (Union[Unset, int]):
+        schedule_id (Union[Unset, int]):
+        is_repetition (Union[Unset, None, bool]):
+        playlist_id (Union[Unset, None, int]):
+        schedule_default_playlist_id (Union[Unset, None, int]):
+        show_default_playlist_id (Union[Unset, None, int]):
+        show_id (Union[Unset, int]):
+        show_name (Union[Unset, str]):
+        show_hosts (Union[Unset, str]):
+        show_type (Union[Unset, None, str]):
+        show_categories (Union[Unset, None, str]):
+        show_topics (Union[Unset, None, str]):
+        show_musicfocus (Union[Unset, None, str]):
+        show_languages (Union[Unset, None, str]):
+        show_fundingcategory (Union[Unset, None, str]):
+        memo (Union[Unset, None, str]):
+        class_name (Union[Unset, None, str]):
+    """
+
+    id: Union[Unset, int] = UNSET
+    start: Union[Unset, datetime.datetime] = UNSET
+    end: Union[Unset, datetime.datetime] = UNSET
+    title: Union[Unset, int] = UNSET
+    schedule_id: Union[Unset, int] = UNSET
+    is_repetition: Union[Unset, None, bool] = UNSET
+    playlist_id: Union[Unset, None, int] = UNSET
+    schedule_default_playlist_id: Union[Unset, None, int] = UNSET
+    show_default_playlist_id: Union[Unset, None, int] = UNSET
+    show_id: Union[Unset, int] = UNSET
+    show_name: Union[Unset, str] = UNSET
+    show_hosts: Union[Unset, str] = UNSET
+    show_type: Union[Unset, None, str] = UNSET
+    show_categories: Union[Unset, None, str] = UNSET
+    show_topics: Union[Unset, None, str] = UNSET
+    show_musicfocus: Union[Unset, None, str] = UNSET
+    show_languages: Union[Unset, None, str] = UNSET
+    show_fundingcategory: Union[Unset, None, str] = UNSET
+    memo: Union[Unset, None, str] = UNSET
+    class_name: Union[Unset, None, str] = UNSET
+    additional_properties: Dict[str, Any] = attr.ib(init=False, factory=dict)
+
+    def to_dict(self) -> Dict[str, Any]:
+        id = self.id
+        start: Union[Unset, str] = UNSET
+        if not isinstance(self.start, Unset):
+            start = self.start.isoformat()
+
+        end: Union[Unset, str] = UNSET
+        if not isinstance(self.end, Unset):
+            end = self.end.isoformat()
+
+        title = self.title
+        schedule_id = self.schedule_id
+        is_repetition = self.is_repetition
+        playlist_id = self.playlist_id
+        schedule_default_playlist_id = self.schedule_default_playlist_id
+        show_default_playlist_id = self.show_default_playlist_id
+        show_id = self.show_id
+        show_name = self.show_name
+        show_hosts = self.show_hosts
+        show_type = self.show_type
+        show_categories = self.show_categories
+        show_topics = self.show_topics
+        show_musicfocus = self.show_musicfocus
+        show_languages = self.show_languages
+        show_fundingcategory = self.show_fundingcategory
+        memo = self.memo
+        class_name = self.class_name
+
+        field_dict: Dict[str, Any] = {}
+        field_dict.update(self.additional_properties)
+        field_dict.update({})
+        if id is not UNSET:
+            field_dict["id"] = id
+        if start is not UNSET:
+            field_dict["start"] = start
+        if end is not UNSET:
+            field_dict["end"] = end
+        if title is not UNSET:
+            field_dict["title"] = title
+        if schedule_id is not UNSET:
+            field_dict["schedule_id"] = schedule_id
+        if is_repetition is not UNSET:
+            field_dict["is_repetition"] = is_repetition
+        if playlist_id is not UNSET:
+            field_dict["playlist_id"] = playlist_id
+        if schedule_default_playlist_id is not UNSET:
+            field_dict["schedule_default_playlist_id"] = schedule_default_playlist_id
+        if show_default_playlist_id is not UNSET:
+            field_dict["show_default_playlist_id"] = show_default_playlist_id
+        if show_id is not UNSET:
+            field_dict["show_id"] = show_id
+        if show_name is not UNSET:
+            field_dict["show_name"] = show_name
+        if show_hosts is not UNSET:
+            field_dict["show_hosts"] = show_hosts
+        if show_type is not UNSET:
+            field_dict["show_type"] = show_type
+        if show_categories is not UNSET:
+            field_dict["show_categories"] = show_categories
+        if show_topics is not UNSET:
+            field_dict["show_topics"] = show_topics
+        if show_musicfocus is not UNSET:
+            field_dict["show_musicfocus"] = show_musicfocus
+        if show_languages is not UNSET:
+            field_dict["show_languages"] = show_languages
+        if show_fundingcategory is not UNSET:
+            field_dict["show_fundingcategory"] = show_fundingcategory
+        if memo is not UNSET:
+            field_dict["memo"] = memo
+        if class_name is not UNSET:
+            field_dict["className"] = class_name
+
+        return field_dict
+
+    @classmethod
+    def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
+        d = src_dict.copy()
+        id = d.pop("id", UNSET)
+
+        _start = d.pop("start", UNSET)
+        start: Union[Unset, datetime.datetime]
+        if isinstance(_start, Unset):
+            start = UNSET
+        else:
+            start = isoparse(_start)
+
+        _end = d.pop("end", UNSET)
+        end: Union[Unset, datetime.datetime]
+        if isinstance(_end, Unset):
+            end = UNSET
+        else:
+            end = isoparse(_end)
+
+        title = d.pop("title", UNSET)
+
+        schedule_id = d.pop("schedule_id", UNSET)
+
+        is_repetition = d.pop("is_repetition", UNSET)
+
+        playlist_id = d.pop("playlist_id", UNSET)
+
+        schedule_default_playlist_id = d.pop("schedule_default_playlist_id", UNSET)
+
+        show_default_playlist_id = d.pop("show_default_playlist_id", UNSET)
+
+        show_id = d.pop("show_id", UNSET)
+
+        show_name = d.pop("show_name", UNSET)
+
+        show_hosts = d.pop("show_hosts", UNSET)
+
+        show_type = d.pop("show_type", UNSET)
+
+        show_categories = d.pop("show_categories", UNSET)
+
+        show_topics = d.pop("show_topics", UNSET)
+
+        show_musicfocus = d.pop("show_musicfocus", UNSET)
+
+        show_languages = d.pop("show_languages", UNSET)
+
+        show_fundingcategory = d.pop("show_fundingcategory", UNSET)
+
+        memo = d.pop("memo", UNSET)
+
+        class_name = d.pop("className", UNSET)
+
+        timeslot = cls(
+            id=id,
+            start=start,
+            end=end,
+            title=title,
+            schedule_id=schedule_id,
+            is_repetition=is_repetition,
+            playlist_id=playlist_id,
+            schedule_default_playlist_id=schedule_default_playlist_id,
+            show_default_playlist_id=show_default_playlist_id,
+            show_id=show_id,
+            show_name=show_name,
+            show_hosts=show_hosts,
+            show_type=show_type,
+            show_categories=show_categories,
+            show_topics=show_topics,
+            show_musicfocus=show_musicfocus,
+            show_languages=show_languages,
+            show_fundingcategory=show_fundingcategory,
+            memo=memo,
+            class_name=class_name,
+        )
+
+        timeslot.additional_properties = d
+        return timeslot
+
+    @property
+    def additional_keys(self) -> List[str]:
+        return list(self.additional_properties.keys())
+
+    def __getitem__(self, key: str) -> Any:
+        return self.additional_properties[key]
+
+    def __setitem__(self, key: str, value: Any) -> None:
+        self.additional_properties[key] = value
+
+    def __delitem__(self, key: str) -> None:
+        del self.additional_properties[key]
+
+    def __contains__(self, key: str) -> bool:
+        return key in self.additional_properties
diff --git a/src/aura_steering_api/py.typed b/src/aura_steering_api/py.typed
new file mode 100644
index 0000000000000000000000000000000000000000..1aad32711f3d86135c552ae38665f4dcc73f3ce2
--- /dev/null
+++ b/src/aura_steering_api/py.typed
@@ -0,0 +1 @@
+# Marker file for PEP 561
\ No newline at end of file
diff --git a/src/aura_steering_api/types.py b/src/aura_steering_api/types.py
new file mode 100644
index 0000000000000000000000000000000000000000..599eeb9f5eef454afb0f9db35e08ef57de14c3a7
--- /dev/null
+++ b/src/aura_steering_api/types.py
@@ -0,0 +1,44 @@
+""" Contains some shared types for properties """
+from http import HTTPStatus
+from typing import BinaryIO, Generic, Literal, MutableMapping, Optional, Tuple, TypeVar
+
+import attr
+
+
+class Unset:
+    def __bool__(self) -> Literal[False]:
+        return False
+
+
+UNSET: Unset = Unset()
+
+FileJsonType = Tuple[Optional[str], BinaryIO, Optional[str]]
+
+
+@attr.s(auto_attribs=True)
+class File:
+    """Contains information for file uploads"""
+
+    payload: BinaryIO
+    file_name: Optional[str] = None
+    mime_type: Optional[str] = None
+
+    def to_tuple(self) -> FileJsonType:
+        """Return a tuple representation that httpx will accept for multipart/form-data"""
+        return self.file_name, self.payload, self.mime_type
+
+
+T = TypeVar("T")
+
+
+@attr.s(auto_attribs=True)
+class Response(Generic[T]):
+    """A response from an endpoint"""
+
+    status_code: HTTPStatus
+    content: bytes
+    headers: MutableMapping[str, str]
+    parsed: Optional[T]
+
+
+__all__ = ["File", "Response", "FileJsonType"]
diff --git a/src/aura_tank_api/__init__.py b/src/aura_tank_api/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/src/aura_tank_api/models/__init__.py b/src/aura_tank_api/models/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..d29566926351ce07f8880624e467dd73e05121fd
--- /dev/null
+++ b/src/aura_tank_api/models/__init__.py
@@ -0,0 +1,17 @@
+""" Contains all the data models used in inputs/outputs """
+
+from .file import File
+from .file_metadata import FileMetadata
+from .file_source import FileSource
+from .import_ import Import
+from .playlist import Playlist
+from .playlist_entry import PlaylistEntry
+
+__all__ = (
+    "File",
+    "FileMetadata",
+    "FileSource",
+    "Import",
+    "Playlist",
+    "PlaylistEntry",
+)
diff --git a/src/aura_tank_api/models/file.py b/src/aura_tank_api/models/file.py
new file mode 100644
index 0000000000000000000000000000000000000000..b408d50d2110fd027d0c1edd8337887a3b60579c
--- /dev/null
+++ b/src/aura_tank_api/models/file.py
@@ -0,0 +1,137 @@
+from typing import TYPE_CHECKING, Any, Dict, List, Type, TypeVar, Union
+
+import attr
+
+from ..types import UNSET, Unset
+
+if TYPE_CHECKING:
+    from ..models.file_metadata import FileMetadata
+    from ..models.file_source import FileSource
+
+
+T = TypeVar("T", bound="File")
+
+
+@attr.s(auto_attribs=True)
+class File:
+    """
+    Attributes:
+        created (Union[Unset, str]):
+        duration (Union[Unset, int]):
+        id (Union[Unset, int]):
+        metadata (Union[Unset, FileMetadata]):
+        show (Union[Unset, str]):
+        size (Union[Unset, int]):
+        source (Union[Unset, FileSource]):
+        updated (Union[Unset, str]):
+    """
+
+    created: Union[Unset, str] = UNSET
+    duration: Union[Unset, int] = UNSET
+    id: Union[Unset, int] = UNSET
+    metadata: Union[Unset, "FileMetadata"] = UNSET
+    show: Union[Unset, str] = UNSET
+    size: Union[Unset, int] = UNSET
+    source: Union[Unset, "FileSource"] = UNSET
+    updated: Union[Unset, str] = UNSET
+    additional_properties: Dict[str, Any] = attr.ib(init=False, factory=dict)
+
+    def to_dict(self) -> Dict[str, Any]:
+        created = self.created
+        duration = self.duration
+        id = self.id
+        metadata: Union[Unset, Dict[str, Any]] = UNSET
+        if not isinstance(self.metadata, Unset):
+            metadata = self.metadata.to_dict()
+
+        show = self.show
+        size = self.size
+        source: Union[Unset, Dict[str, Any]] = UNSET
+        if not isinstance(self.source, Unset):
+            source = self.source.to_dict()
+
+        updated = self.updated
+
+        field_dict: Dict[str, Any] = {}
+        field_dict.update(self.additional_properties)
+        field_dict.update({})
+        if created is not UNSET:
+            field_dict["created"] = created
+        if duration is not UNSET:
+            field_dict["duration"] = duration
+        if id is not UNSET:
+            field_dict["id"] = id
+        if metadata is not UNSET:
+            field_dict["metadata"] = metadata
+        if show is not UNSET:
+            field_dict["show"] = show
+        if size is not UNSET:
+            field_dict["size"] = size
+        if source is not UNSET:
+            field_dict["source"] = source
+        if updated is not UNSET:
+            field_dict["updated"] = updated
+
+        return field_dict
+
+    @classmethod
+    def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
+        from ..models.file_metadata import FileMetadata
+        from ..models.file_source import FileSource
+
+        d = src_dict.copy()
+        created = d.pop("created", UNSET)
+
+        duration = d.pop("duration", UNSET)
+
+        id = d.pop("id", UNSET)
+
+        _metadata = d.pop("metadata", UNSET)
+        metadata: Union[Unset, FileMetadata]
+        if isinstance(_metadata, Unset):
+            metadata = UNSET
+        else:
+            metadata = FileMetadata.from_dict(_metadata)
+
+        show = d.pop("show", UNSET)
+
+        size = d.pop("size", UNSET)
+
+        _source = d.pop("source", UNSET)
+        source: Union[Unset, FileSource]
+        if isinstance(_source, Unset):
+            source = UNSET
+        else:
+            source = FileSource.from_dict(_source)
+
+        updated = d.pop("updated", UNSET)
+
+        file = cls(
+            created=created,
+            duration=duration,
+            id=id,
+            metadata=metadata,
+            show=show,
+            size=size,
+            source=source,
+            updated=updated,
+        )
+
+        file.additional_properties = d
+        return file
+
+    @property
+    def additional_keys(self) -> List[str]:
+        return list(self.additional_properties.keys())
+
+    def __getitem__(self, key: str) -> Any:
+        return self.additional_properties[key]
+
+    def __setitem__(self, key: str, value: Any) -> None:
+        self.additional_properties[key] = value
+
+    def __delitem__(self, key: str) -> None:
+        del self.additional_properties[key]
+
+    def __contains__(self, key: str) -> bool:
+        return key in self.additional_properties
diff --git a/src/aura_tank_api/models/file_metadata.py b/src/aura_tank_api/models/file_metadata.py
new file mode 100644
index 0000000000000000000000000000000000000000..4bd06f46e60ea3795c7162ec4082dabb716406e6
--- /dev/null
+++ b/src/aura_tank_api/models/file_metadata.py
@@ -0,0 +1,89 @@
+from typing import Any, Dict, List, Type, TypeVar, Union
+
+import attr
+
+from ..types import UNSET, Unset
+
+T = TypeVar("T", bound="FileMetadata")
+
+
+@attr.s(auto_attribs=True)
+class FileMetadata:
+    """
+    Attributes:
+        album (Union[Unset, str]):
+        artist (Union[Unset, str]): actually a full-text index would be nice here...
+        isrc (Union[Unset, str]):
+        organization (Union[Unset, str]):
+        title (Union[Unset, str]):
+    """
+
+    album: Union[Unset, str] = UNSET
+    artist: Union[Unset, str] = UNSET
+    isrc: Union[Unset, str] = UNSET
+    organization: Union[Unset, str] = UNSET
+    title: Union[Unset, str] = UNSET
+    additional_properties: Dict[str, Any] = attr.ib(init=False, factory=dict)
+
+    def to_dict(self) -> Dict[str, Any]:
+        album = self.album
+        artist = self.artist
+        isrc = self.isrc
+        organization = self.organization
+        title = self.title
+
+        field_dict: Dict[str, Any] = {}
+        field_dict.update(self.additional_properties)
+        field_dict.update({})
+        if album is not UNSET:
+            field_dict["album"] = album
+        if artist is not UNSET:
+            field_dict["artist"] = artist
+        if isrc is not UNSET:
+            field_dict["isrc"] = isrc
+        if organization is not UNSET:
+            field_dict["organization"] = organization
+        if title is not UNSET:
+            field_dict["title"] = title
+
+        return field_dict
+
+    @classmethod
+    def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
+        d = src_dict.copy()
+        album = d.pop("album", UNSET)
+
+        artist = d.pop("artist", UNSET)
+
+        isrc = d.pop("isrc", UNSET)
+
+        organization = d.pop("organization", UNSET)
+
+        title = d.pop("title", UNSET)
+
+        file_metadata = cls(
+            album=album,
+            artist=artist,
+            isrc=isrc,
+            organization=organization,
+            title=title,
+        )
+
+        file_metadata.additional_properties = d
+        return file_metadata
+
+    @property
+    def additional_keys(self) -> List[str]:
+        return list(self.additional_properties.keys())
+
+    def __getitem__(self, key: str) -> Any:
+        return self.additional_properties[key]
+
+    def __setitem__(self, key: str, value: Any) -> None:
+        self.additional_properties[key] = value
+
+    def __delitem__(self, key: str) -> None:
+        del self.additional_properties[key]
+
+    def __contains__(self, key: str) -> bool:
+        return key in self.additional_properties
diff --git a/src/aura_tank_api/models/file_source.py b/src/aura_tank_api/models/file_source.py
new file mode 100644
index 0000000000000000000000000000000000000000..97032b69b93018a1eaacae2d3a6a17370420cb33
--- /dev/null
+++ b/src/aura_tank_api/models/file_source.py
@@ -0,0 +1,87 @@
+from typing import TYPE_CHECKING, Any, Dict, List, Type, TypeVar, Union
+
+import attr
+
+from ..types import UNSET, Unset
+
+if TYPE_CHECKING:
+    from ..models.import_ import Import
+
+
+T = TypeVar("T", bound="FileSource")
+
+
+@attr.s(auto_attribs=True)
+class FileSource:
+    """
+    Attributes:
+        hash_ (Union[Unset, str]):
+        import_ (Union[Unset, Import]):
+        uri (Union[Unset, str]):
+    """
+
+    hash_: Union[Unset, str] = UNSET
+    import_: Union[Unset, "Import"] = UNSET
+    uri: Union[Unset, str] = UNSET
+    additional_properties: Dict[str, Any] = attr.ib(init=False, factory=dict)
+
+    def to_dict(self) -> Dict[str, Any]:
+        hash_ = self.hash_
+        import_: Union[Unset, Dict[str, Any]] = UNSET
+        if not isinstance(self.import_, Unset):
+            import_ = self.import_.to_dict()
+
+        uri = self.uri
+
+        field_dict: Dict[str, Any] = {}
+        field_dict.update(self.additional_properties)
+        field_dict.update({})
+        if hash_ is not UNSET:
+            field_dict["hash"] = hash_
+        if import_ is not UNSET:
+            field_dict["import"] = import_
+        if uri is not UNSET:
+            field_dict["uri"] = uri
+
+        return field_dict
+
+    @classmethod
+    def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
+        from ..models.import_ import Import
+
+        d = src_dict.copy()
+        hash_ = d.pop("hash", UNSET)
+
+        _import_ = d.pop("import", UNSET)
+        import_: Union[Unset, Import]
+        if isinstance(_import_, Unset):
+            import_ = UNSET
+        else:
+            import_ = Import.from_dict(_import_)
+
+        uri = d.pop("uri", UNSET)
+
+        file_source = cls(
+            hash_=hash_,
+            import_=import_,
+            uri=uri,
+        )
+
+        file_source.additional_properties = d
+        return file_source
+
+    @property
+    def additional_keys(self) -> List[str]:
+        return list(self.additional_properties.keys())
+
+    def __getitem__(self, key: str) -> Any:
+        return self.additional_properties[key]
+
+    def __setitem__(self, key: str, value: Any) -> None:
+        self.additional_properties[key] = value
+
+    def __delitem__(self, key: str) -> None:
+        del self.additional_properties[key]
+
+    def __contains__(self, key: str) -> bool:
+        return key in self.additional_properties
diff --git a/src/aura_tank_api/models/import_.py b/src/aura_tank_api/models/import_.py
new file mode 100644
index 0000000000000000000000000000000000000000..7490f941558e2528ff244ee43566f94be3b06370
--- /dev/null
+++ b/src/aura_tank_api/models/import_.py
@@ -0,0 +1,65 @@
+from typing import Any, Dict, List, Type, TypeVar, Union
+
+import attr
+
+from ..types import UNSET, Unset
+
+T = TypeVar("T", bound="Import")
+
+
+@attr.s(auto_attribs=True)
+class Import:
+    """
+    Attributes:
+        error (Union[Unset, str]):
+        state (Union[Unset, str]):
+    """
+
+    error: Union[Unset, str] = UNSET
+    state: Union[Unset, str] = UNSET
+    additional_properties: Dict[str, Any] = attr.ib(init=False, factory=dict)
+
+    def to_dict(self) -> Dict[str, Any]:
+        error = self.error
+        state = self.state
+
+        field_dict: Dict[str, Any] = {}
+        field_dict.update(self.additional_properties)
+        field_dict.update({})
+        if error is not UNSET:
+            field_dict["error"] = error
+        if state is not UNSET:
+            field_dict["state"] = state
+
+        return field_dict
+
+    @classmethod
+    def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
+        d = src_dict.copy()
+        error = d.pop("error", UNSET)
+
+        state = d.pop("state", UNSET)
+
+        import_ = cls(
+            error=error,
+            state=state,
+        )
+
+        import_.additional_properties = d
+        return import_
+
+    @property
+    def additional_keys(self) -> List[str]:
+        return list(self.additional_properties.keys())
+
+    def __getitem__(self, key: str) -> Any:
+        return self.additional_properties[key]
+
+    def __setitem__(self, key: str, value: Any) -> None:
+        self.additional_properties[key] = value
+
+    def __delitem__(self, key: str) -> None:
+        del self.additional_properties[key]
+
+    def __contains__(self, key: str) -> bool:
+        return key in self.additional_properties
diff --git a/src/aura_tank_api/models/playlist.py b/src/aura_tank_api/models/playlist.py
new file mode 100644
index 0000000000000000000000000000000000000000..426ed62b51e51f5b19fb25576027478d8de65cbf
--- /dev/null
+++ b/src/aura_tank_api/models/playlist.py
@@ -0,0 +1,123 @@
+from typing import TYPE_CHECKING, Any, Dict, List, Type, TypeVar, Union
+
+import attr
+
+from ..types import UNSET, Unset
+
+if TYPE_CHECKING:
+    from ..models.playlist_entry import PlaylistEntry
+
+
+T = TypeVar("T", bound="Playlist")
+
+
+@attr.s(auto_attribs=True)
+class Playlist:
+    """
+    Attributes:
+        created (Union[Unset, str]):
+        description (Union[Unset, str]):
+        entries (Union[Unset, List['PlaylistEntry']]):
+        id (Union[Unset, int]):
+        playout_mode (Union[Unset, str]):
+        show (Union[Unset, str]):
+        updated (Union[Unset, str]):
+    """
+
+    created: Union[Unset, str] = UNSET
+    description: Union[Unset, str] = UNSET
+    entries: Union[Unset, List["PlaylistEntry"]] = UNSET
+    id: Union[Unset, int] = UNSET
+    playout_mode: Union[Unset, str] = UNSET
+    show: Union[Unset, str] = UNSET
+    updated: Union[Unset, str] = UNSET
+    additional_properties: Dict[str, Any] = attr.ib(init=False, factory=dict)
+
+    def to_dict(self) -> Dict[str, Any]:
+        created = self.created
+        description = self.description
+        entries: Union[Unset, List[Dict[str, Any]]] = UNSET
+        if not isinstance(self.entries, Unset):
+            entries = []
+            for entries_item_data in self.entries:
+                entries_item = entries_item_data.to_dict()
+
+                entries.append(entries_item)
+
+        id = self.id
+        playout_mode = self.playout_mode
+        show = self.show
+        updated = self.updated
+
+        field_dict: Dict[str, Any] = {}
+        field_dict.update(self.additional_properties)
+        field_dict.update({})
+        if created is not UNSET:
+            field_dict["created"] = created
+        if description is not UNSET:
+            field_dict["description"] = description
+        if entries is not UNSET:
+            field_dict["entries"] = entries
+        if id is not UNSET:
+            field_dict["id"] = id
+        if playout_mode is not UNSET:
+            field_dict["playout-mode"] = playout_mode
+        if show is not UNSET:
+            field_dict["show"] = show
+        if updated is not UNSET:
+            field_dict["updated"] = updated
+
+        return field_dict
+
+    @classmethod
+    def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
+        from ..models.playlist_entry import PlaylistEntry
+
+        d = src_dict.copy()
+        created = d.pop("created", UNSET)
+
+        description = d.pop("description", UNSET)
+
+        entries = []
+        _entries = d.pop("entries", UNSET)
+        for entries_item_data in _entries or []:
+            entries_item = PlaylistEntry.from_dict(entries_item_data)
+
+            entries.append(entries_item)
+
+        id = d.pop("id", UNSET)
+
+        playout_mode = d.pop("playout-mode", UNSET)
+
+        show = d.pop("show", UNSET)
+
+        updated = d.pop("updated", UNSET)
+
+        playlist = cls(
+            created=created,
+            description=description,
+            entries=entries,
+            id=id,
+            playout_mode=playout_mode,
+            show=show,
+            updated=updated,
+        )
+
+        playlist.additional_properties = d
+        return playlist
+
+    @property
+    def additional_keys(self) -> List[str]:
+        return list(self.additional_properties.keys())
+
+    def __getitem__(self, key: str) -> Any:
+        return self.additional_properties[key]
+
+    def __setitem__(self, key: str, value: Any) -> None:
+        self.additional_properties[key] = value
+
+    def __delitem__(self, key: str) -> None:
+        del self.additional_properties[key]
+
+    def __contains__(self, key: str) -> bool:
+        return key in self.additional_properties
diff --git a/src/aura_tank_api/models/playlist_entry.py b/src/aura_tank_api/models/playlist_entry.py
new file mode 100644
index 0000000000000000000000000000000000000000..c575c632048a06c3101227b0592c05f3e72fb34d
--- /dev/null
+++ b/src/aura_tank_api/models/playlist_entry.py
@@ -0,0 +1,87 @@
+from typing import TYPE_CHECKING, Any, Dict, List, Type, TypeVar, Union
+
+import attr
+
+from ..types import UNSET, Unset
+
+if TYPE_CHECKING:
+    from ..models.file import File
+
+
+T = TypeVar("T", bound="PlaylistEntry")
+
+
+@attr.s(auto_attribs=True)
+class PlaylistEntry:
+    """
+    Attributes:
+        duration (Union[Unset, int]):
+        file (Union[Unset, File]):
+        uri (Union[Unset, str]):
+    """
+
+    duration: Union[Unset, int] = UNSET
+    file: Union[Unset, "File"] = UNSET
+    uri: Union[Unset, str] = UNSET
+    additional_properties: Dict[str, Any] = attr.ib(init=False, factory=dict)
+
+    def to_dict(self) -> Dict[str, Any]:
+        duration = self.duration
+        file: Union[Unset, Dict[str, Any]] = UNSET
+        if not isinstance(self.file, Unset):
+            file = self.file.to_dict()
+
+        uri = self.uri
+
+        field_dict: Dict[str, Any] = {}
+        field_dict.update(self.additional_properties)
+        field_dict.update({})
+        if duration is not UNSET:
+            field_dict["duration"] = duration
+        if file is not UNSET:
+            field_dict["file"] = file
+        if uri is not UNSET:
+            field_dict["uri"] = uri
+
+        return field_dict
+
+    @classmethod
+    def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
+        from ..models.file import File
+
+        d = src_dict.copy()
+        duration = d.pop("duration", UNSET)
+
+        _file = d.pop("file", UNSET)
+        file: Union[Unset, File]
+        if isinstance(_file, Unset):
+            file = UNSET
+        else:
+            file = File.from_dict(_file)
+
+        uri = d.pop("uri", UNSET)
+
+        playlist_entry = cls(
+            duration=duration,
+            file=file,
+            uri=uri,
+        )
+
+        playlist_entry.additional_properties = d
+        return playlist_entry
+
+    @property
+    def additional_keys(self) -> List[str]:
+        return list(self.additional_properties.keys())
+
+    def __getitem__(self, key: str) -> Any:
+        return self.additional_properties[key]
+
+    def __setitem__(self, key: str, value: Any) -> None:
+        self.additional_properties[key] = value
+
+    def __delitem__(self, key: str) -> None:
+        del self.additional_properties[key]
+
+    def __contains__(self, key: str) -> bool:
+        return key in self.additional_properties
diff --git a/src/aura_tank_api/py.typed b/src/aura_tank_api/py.typed
new file mode 100644
index 0000000000000000000000000000000000000000..1aad32711f3d86135c552ae38665f4dcc73f3ce2
--- /dev/null
+++ b/src/aura_tank_api/py.typed
@@ -0,0 +1 @@
+# Marker file for PEP 561
\ No newline at end of file
diff --git a/src/aura_tank_api/types.py b/src/aura_tank_api/types.py
new file mode 100644
index 0000000000000000000000000000000000000000..599eeb9f5eef454afb0f9db35e08ef57de14c3a7
--- /dev/null
+++ b/src/aura_tank_api/types.py
@@ -0,0 +1,44 @@
+""" Contains some shared types for properties """
+from http import HTTPStatus
+from typing import BinaryIO, Generic, Literal, MutableMapping, Optional, Tuple, TypeVar
+
+import attr
+
+
+class Unset:
+    def __bool__(self) -> Literal[False]:
+        return False
+
+
+UNSET: Unset = Unset()
+
+FileJsonType = Tuple[Optional[str], BinaryIO, Optional[str]]
+
+
+@attr.s(auto_attribs=True)
+class File:
+    """Contains information for file uploads"""
+
+    payload: BinaryIO
+    file_name: Optional[str] = None
+    mime_type: Optional[str] = None
+
+    def to_tuple(self) -> FileJsonType:
+        """Return a tuple representation that httpx will accept for multipart/form-data"""
+        return self.file_name, self.payload, self.mime_type
+
+
+T = TypeVar("T")
+
+
+@attr.s(auto_attribs=True)
+class Response(Generic[T]):
+    """A response from an endpoint"""
+
+    status_code: HTTPStatus
+    content: bytes
+    headers: MutableMapping[str, str]
+    parsed: Optional[T]
+
+
+__all__ = ["File", "Response", "FileJsonType"]
diff --git a/tests/json/steering-api-v1-playout.json b/tests/json/steering-api-v1-playout.json
new file mode 100644
index 0000000000000000000000000000000000000000..e6ba8cf3d92eddb860625605ee8f715198edb0e0
--- /dev/null
+++ b/tests/json/steering-api-v1-playout.json
@@ -0,0 +1,68 @@
+[
+    {
+       "id":17,
+       "start":"2023-05-16T10:06:00",
+       "end":"2023-05-16T10:10:00",
+       "title":"Pepi's Polka",
+       "schedule_id":17,
+       "is_repetition":false,
+       "playlist_id":1,
+       "schedule_default_playlist_id":null,
+       "show_default_playlist_id":null,
+       "show_id":1,
+       "show_name":"Musikprogramm",
+       "show_hosts":"Musikredaktion",
+       "show_type":"Unmoderiertes Musikprogramm",
+       "show_categories":"",
+       "show_topics":"",
+       "show_musicfocus":"",
+       "show_languages":"",
+       "show_fundingcategory":"Standard",
+       "memo":"",
+       "className":"default"
+    },
+    {
+       "id":18,
+       "start":"2023-05-16T13:00:00",
+       "end":"2023-05-16T13:05:00",
+       "title":"Wurlitzer",
+       "schedule_id":18,
+       "is_repetition":false,
+       "playlist_id":1,
+       "schedule_default_playlist_id":1,
+       "show_default_playlist_id":1,
+       "show_id":1,
+       "show_name":"Musikprogramm",
+       "show_hosts":"Musikredaktion",
+       "show_type":"Unmoderiertes Musikprogramm",
+       "show_categories":"",
+       "show_topics":"",
+       "show_musicfocus":"",
+       "show_languages":"",
+       "show_fundingcategory":"Standard",
+       "memo":"",
+       "className":"default"
+    },
+    {
+      "id":19,
+      "start":"2023-05-16T15:00:00",
+      "end":"2023-05-16T15:05:00",
+      "title":"Pepi's Polka II",
+      "schedule_id":19,
+      "is_repetition":false,
+      "playlist_id":1,
+      "schedule_default_playlist_id":1,
+      "show_default_playlist_id":1,
+      "show_id":1,
+      "show_name":"Musikprogramm",
+      "show_hosts":"Musikredaktion",
+      "show_type":"Unmoderiertes Musikprogramm",
+      "show_categories":"",
+      "show_topics":"",
+      "show_musicfocus":"",
+      "show_languages":"",
+      "show_fundingcategory":"Standard",
+      "memo":"",
+      "className":"default"
+   }
+ ]
\ No newline at end of file
diff --git a/tests/json/tank-api-v1-playlists-1.json b/tests/json/tank-api-v1-playlists-1.json
new file mode 100644
index 0000000000000000000000000000000000000000..8833a994cc937470d7a6163161abc60e8b899015
--- /dev/null
+++ b/tests/json/tank-api-v1-playlists-1.json
@@ -0,0 +1,34 @@
+{
+    "id":1,
+    "created":"2023-02-28T15:25:38.684803+01:00",
+    "updated":"2023-02-28T15:25:38.684803+01:00",
+    "description":"test",
+    "playout-mode":"linear",
+    "show":"musikprogramm",
+    "entries":[
+       {
+          "uri":"file://musikprogramm/2",
+          "duration":199040000000,
+          "file":{
+             "id":2,
+             "created":"2023-02-28T14:42:09.540485+01:00",
+             "updated":"2023-02-28T14:42:17.564099+01:00",
+             "show":"musikprogramm",
+             "source":{
+                "uri":"upload://some-audio-file.flac",
+                "hash":"sha256:b4e1922bad633ff0e11f55611f04cb3807d15d70bb09969d2b324373af47b574",
+                "import":{
+                   "state":"done"
+                }
+             },
+             "metadata":{
+                "artist":"Test Artist",
+                "title":"Test Track Title",
+                "album":"Test Album"
+             },
+             "size":36496517,
+             "duration":199040000000
+          }
+       }
+    ]
+ }
\ No newline at end of file
diff --git a/tests/test_app.py b/tests/test_app.py
new file mode 100644
index 0000000000000000000000000000000000000000..a864216d82b79b6fb19ea964e97c1db2ceb668d0
--- /dev/null
+++ b/tests/test_app.py
@@ -0,0 +1,73 @@
+#
+# Aura Engine (https://gitlab.servus.at/aura/engine)
+#
+# Copyright (C) 2017-now() - The Aura Engine Team.
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import unittest
+
+# from aura_engine.app import EngineRunner
+from aura_engine.base.config import AuraConfig
+
+# from unittest import mock
+
+
+class TestApp(unittest.TestCase):
+    """
+    Testing the Configuration.
+    """
+
+    config = None
+
+    #
+    # Mock
+    #
+
+    def mocked_engine_start(*args, **kwargs):
+        print(args, kwargs)
+        print("Mocked start finished")
+
+    #
+    # Setup
+    #
+
+    def setUp(self):
+        self.config = AuraConfig()
+
+    #
+    # Test Cases
+    #
+
+    # FIXME For some unknown reason, this Mock leaks into other test cases
+    # When this test case is enabled, these tests fail:
+    # - tests.test_engine_executor - test_parent_child_executors_in_order
+    # - tests.test_engine_executor - test_parent_child_executors_with_child_before
+    # - tests.test_engine_executor - test_timer_store_replacement_after_parent_execution
+    #
+    # @mock.patch("aura_engine.engine.Engine.start", side_effect=mocked_engine_start)
+    # def test_run_app(self, engine_start_mock):
+    #     runner = EngineRunner()
+    #     self.assertIsNotNone(runner)
+    #     runner.run()
+
+    #     try:
+    #         runner.exit_gracefully(signum=1, frame=1)
+    #     except:
+    #         pass
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_liquidsoap_util.py b/tests/test_liquidsoap_util.py
new file mode 100644
index 0000000000000000000000000000000000000000..ca8dd8f3e35aacd6c7070ab09b2f3fcee30594bf
--- /dev/null
+++ b/tests/test_liquidsoap_util.py
@@ -0,0 +1,65 @@
+#
+# Aura Engine (https://gitlab.servus.at/aura/engine)
+#
+# Copyright (C) 2017-now() - The Aura Engine Team.
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import unittest
+from unittest import mock
+
+import requests
+
+from aura_engine.base.api import LiquidsoapUtil
+from aura_engine.base.config import AuraConfig
+
+
+class TestLiquidsoapUtil(unittest.TestCase):
+    """
+    Testing the Configuration.
+    """
+
+    config = None
+    api = None
+
+    #
+    # Setup
+    #
+
+    def setUp(self):
+        pass
+
+    #
+    # Tests
+    #
+
+    def test_json_to_dict(self):
+        print(self._testMethodName)
+
+        json_str = '+{-"key"-:-"value"-}+'
+        json_dict = LiquidsoapUtil.json_to_dict(json_str)
+
+        # Check if config is available
+        self.assertIsNotNone(json_dict)
+        self.assertEqual("value", json_dict.get("key"))
+
+    def test_annotate_uri(self):
+        print(self._testMethodName)
+
+        uri = "/some/uri"
+        meta = {"cue": 1, "volume": 100}
+        uri = LiquidsoapUtil.annotate_uri(uri, meta)
+
+        self.assertEqual('annotate:cue="1",volume="100":/some/uri', uri)
diff --git a/tests/test_scheduling_api_fetcher.py b/tests/test_scheduling_api_fetcher.py
new file mode 100644
index 0000000000000000000000000000000000000000..a47a12179d055819a3d3a7ce75ff4942ce045d55
--- /dev/null
+++ b/tests/test_scheduling_api_fetcher.py
@@ -0,0 +1,133 @@
+#
+# Aura Engine (https://gitlab.servus.at/aura/engine)
+#
+# Copyright (C) 2017-now() - The Aura Engine Team.
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import json
+import os
+import unittest
+from datetime import datetime, timedelta
+from unittest import mock
+
+from aura_engine.base.config import AuraConfig
+from aura_engine.scheduling.api import ApiFetcher
+
+
+class TestSchedulingApiFetcher(unittest.TestCase):
+    """
+    Testing the Configuration.
+    """
+
+    config = None
+    api_fetcher = None
+    mocked_steering_json = None
+    mocked_tank_json = None
+
+    #
+    # Mock
+    #
+
+    # Mock `requests.get` in `SimpleRestApi`
+    def mocked_requests_get(*args, **kwargs):
+        class MockResponse:
+            def __init__(self, json_data, status_code):
+                self.json_data = json_data
+                self.status_code = status_code
+
+            def json(self):
+                return self.json_data
+
+        print(f"Calling mocked 'requests.get' with '{args[0]}'")
+        if "/api/v1/playout" in args[0]:
+            return MockResponse(TestSchedulingApiFetcher.mocked_steering_json, 200)
+        elif "/api/v1/playlists/1" in args[0]:
+            return MockResponse(TestSchedulingApiFetcher.mocked_tank_json, 200)
+
+        return MockResponse(None, 404)
+
+    #
+    # Setup
+    #
+
+    def setUp(self):
+        self.config = AuraConfig()
+        self.api_fetcher = ApiFetcher()
+
+        with open("./tests/json/steering-api-v1-playout.json", "r") as file:
+            TestSchedulingApiFetcher.mocked_steering_json = json.load(file)
+        with open("./tests/json/tank-api-v1-playlists-1.json", "r") as file:
+            TestSchedulingApiFetcher.mocked_tank_json = json.load(file)
+
+        # Update dates that they are in the future
+        # Format e.g. "start":"2023-05-16T10:06:00"
+        now = datetime.now()
+        hour: int = -1
+
+        for timeslot in TestSchedulingApiFetcher.mocked_steering_json:
+            start = (now + timedelta(hours=hour)).strftime("%Y-%m-%dT%H:%M:%S")
+            end = (now + timedelta(hours=hour + 1)).strftime("%Y-%m-%dT%H:%M:%S")
+            timeslot["start"] = start
+            timeslot["end"] = end
+            hour += 1
+
+    #
+    # Test Cases
+    #
+
+    @mock.patch("aura_engine.base.api.requests.get", side_effect=mocked_requests_get)
+    def test_fetch_data(self, mock_get):
+        print(self._testMethodName)
+
+        self.api_fetcher.start()
+        response = self.api_fetcher.get_fetched_data()
+        # print("[test_fetch_data] response: " + str(response))
+
+        # Test Timetable
+        self.assertEqual(list, type(response))
+        self.assertEqual(2, len(response))
+
+        # Test Timeslot 1
+        ts1 = response[0]
+        self.assertEqual(1, ts1["show_id"])
+        self.assertEqual("Musikprogramm", ts1["show_name"])
+        self.assertEqual("Wurlitzer", ts1["title"])
+        self.assertEqual(1, ts1["playlist_id"])
+        self.assertEqual(1, ts1["playlist"]["id"])
+        self.assertEqual("musikprogramm", ts1["playlist"]["show"])
+        self.assertEqual("test", ts1["playlist"]["description"])
+        self.assertEqual("linear", ts1["playlist"]["playout-mode"])
+
+        # Test Timeslot 2
+        ts2 = response[1]
+        self.assertEqual(1, ts2["show_id"])
+        self.assertEqual("Musikprogramm", ts2["show_name"])
+        self.assertEqual("Pepi's Polka II", ts2["title"])
+        self.assertEqual(1, ts2["playlist_id"])
+        self.assertEqual(1, ts2["playlist"]["id"])
+        self.assertEqual("musikprogramm", ts2["playlist"]["show"])
+        self.assertEqual("test", ts2["playlist"]["description"])
+        self.assertEqual("linear", ts2["playlist"]["playout-mode"])
+        self.assertEqual("musikprogramm", ts2["default_schedule_playlist"]["show"])
+        self.assertEqual("test", ts2["default_schedule_playlist"]["description"])
+        self.assertEqual("linear", ts2["default_schedule_playlist"]["playout-mode"])
+        self.assertEqual("musikprogramm", ts2["default_show_playlist"]["show"])
+        self.assertEqual("test", ts2["default_show_playlist"]["description"])
+        self.assertEqual("linear", ts2["default_show_playlist"]["playout-mode"])
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_scheduling_api_steering.py b/tests/test_scheduling_api_steering.py
new file mode 100644
index 0000000000000000000000000000000000000000..c97eb60ad8cf06f48a0650bcbaa706a8901dad5b
--- /dev/null
+++ b/tests/test_scheduling_api_steering.py
@@ -0,0 +1,84 @@
+#
+# Aura Engine (https://code.aura.radio/)
+#
+# Copyright (C) 2017-now() - The Aura Engine Team.
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import json
+import os
+import unittest
+from datetime import datetime
+
+import validators
+
+from aura_engine.base.config import AuraConfig
+from aura_steering_api.models.timeslot import Timeslot
+from aura_steering_api.types import UNSET
+
+
+class TestApiSteering(unittest.TestCase):
+    """
+    Testing the Configuration.
+    """
+
+    config: AuraConfig
+    mocked_steering_json = None
+
+    def setUp(self):
+        self.config = AuraConfig()
+        with open("./tests/json/steering-api-v1-playout.json", "r") as file:
+            self.mocked_steering_json = json.load(file)
+
+    def test_api_steering_timeslot_model(self):
+        print(self._testMethodName)
+        print("Steering JSON: " + str(self.mocked_steering_json))
+
+        # FIXME Steering doesn't provide JSON as per a valid JSON schema.
+        # Hence we need to split the array manually
+        timetable = []
+        timeslot = Timeslot()
+        for entry in self.mocked_steering_json:
+            t = timeslot.from_dict(entry)
+            self.assertIsNotNone(t.to_dict())
+            timetable.append(t)
+
+        self.assertIsNotNone(timetable)
+        self.assertEqual(3, len(timetable))
+
+        t1 = timetable[0]
+        self.assertEqual(17, t1.id)
+        self.assertEqual(datetime.strptime("2023-05-16T10:06:00", "%Y-%m-%dT%H:%M:%S"), t1.start)
+        self.assertEqual(datetime.strptime("2023-05-16T10:10:00", "%Y-%m-%dT%H:%M:%S"), t1.end)
+        self.assertEqual("Pepi's Polka", t1.title)
+        self.assertEqual(17, t1.schedule_id)
+        self.assertEqual(False, t1.is_repetition)
+        self.assertEqual(1, t1.playlist_id)
+        self.assertEqual(None, t1.schedule_default_playlist_id)
+        self.assertEqual(None, t1.show_default_playlist_id)
+        self.assertEqual(1, t1.show_id)
+        self.assertEqual("Musikprogramm", t1.show_name)
+        self.assertEqual("Musikredaktion", t1.show_hosts)
+        self.assertEqual("Unmoderiertes Musikprogramm", t1.show_type)
+        self.assertEqual("", t1.show_categories)
+        self.assertEqual("", t1.show_topics)
+        self.assertEqual("", t1.show_musicfocus)
+        self.assertEqual("", t1.show_languages)
+        self.assertEqual("Standard", t1.show_fundingcategory)
+        self.assertEqual("", t1.memo)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_scheduling_api_tank.py b/tests/test_scheduling_api_tank.py
new file mode 100644
index 0000000000000000000000000000000000000000..a5873136fe22840bfd5c367f83a6d88f8cc0f549
--- /dev/null
+++ b/tests/test_scheduling_api_tank.py
@@ -0,0 +1,79 @@
+#
+# Aura Engine (https://code.aura.radio/)
+#
+# Copyright (C) 2017-now() - The Aura Engine Team.
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import json
+import os
+import unittest
+
+import validators
+
+from aura_engine.base.config import AuraConfig
+from aura_tank_api.models.playlist import Playlist
+from aura_tank_api.types import UNSET
+
+
+class TestApiTank(unittest.TestCase):
+    """
+    Testing the Configuration.
+    """
+
+    config: AuraConfig
+    mocked_tank_json = None
+
+    def setUp(self):
+        self.config = AuraConfig()
+        with open("./tests/json/tank-api-v1-playlists-1.json", "r") as file:
+            self.mocked_tank_json = json.load(file)
+
+    def test_api_tank_model(self):
+        print(self._testMethodName)
+
+        print("Tank JSON: " + str(self.mocked_tank_json))
+        playlist = Playlist()
+        self.assertEqual(UNSET, playlist.id)
+        playlist = playlist.from_dict(self.mocked_tank_json)
+        self.assertIsNotNone(playlist.to_dict())
+
+        self.assertEqual(1, playlist.id)
+        self.assertEqual("musikprogramm", playlist.show)
+        self.assertEqual("linear", playlist.playout_mode)
+        self.assertEqual("test", playlist.description)
+        self.assertEqual("2023-02-28T15:25:38.684803+01:00", playlist.created)
+        self.assertEqual("2023-02-28T15:25:38.684803+01:00", playlist.updated)
+
+        entries = playlist.entries
+        self.assertIsNotNone(1, playlist.entries)
+        self.assertEqual(1, len(entries))
+        self.assertEqual("file://musikprogramm/2", entries[0].uri)
+        self.assertEqual(199040000000, entries[0].duration)
+
+        file = playlist.entries[0].file
+        self.assertEqual(2, file.id)
+        self.assertEqual(
+            "sha256:b4e1922bad633ff0e11f55611f04cb3807d15d70bb09969d2b324373af47b574",
+            file.source.hash_,
+        )
+        self.assertEqual("upload://some-audio-file.flac", file.source.uri)
+        self.assertEqual("Test Artist", file.metadata.artist)
+        self.assertEqual("Test Track Title", file.metadata.title)
+        self.assertEqual("Test Album", file.metadata.album)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_simple_api.py b/tests/test_simple_api.py
index 7060324e8528727a0775b828cfc77da509c34eb0..12aa24c45c4c7816fafd0d682a416803e94a036c 100644
--- a/tests/test_simple_api.py
+++ b/tests/test_simple_api.py
@@ -17,9 +17,13 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 
+import json
 import unittest
+from unittest import mock
 
-from aura_engine.base.api import SimpleApi
+import requests
+
+from aura_engine.base.api import SimpleRestApi
 from aura_engine.base.config import AuraConfig
 
 
@@ -31,9 +35,67 @@ class TestApi(unittest.TestCase):
     config = None
     api = None
 
+    #
+    # Mock
+    #
+
+    # Mock `requests.get` in `SimpleRestApi`
+    def mocked_requests_get(*args, **kwargs):
+        class MockResponse:
+            def __init__(self, json_data, status_code):
+                self.json_data = json_data
+                self.status_code = status_code
+
+            def json(self):
+                return self.json_data
+
+        print(f"Calling mocked 'requests.get' with '{args[0]}'")
+        if args[0] == "http://aura.test.available":
+            return MockResponse({"foo": "bar"}, 200)
+        if args[0] == "http://aura.test.available/bad-request":
+            return MockResponse({}, 400)
+        if args[0] == "http://aura.test.available/connection-error":
+            raise requests.exceptions.ConnectionError
+        if args[0] == "http://aura.test.available/timeout":
+            raise requests.exceptions.Timeout
+        if args[0] == "http://aura.test.available/exception":
+            raise Exception
+        if args[0] == "http://aura.test.available/not-found":
+            return MockResponse({}, 404)
+        if args[0] == "https://some.website.no.api":
+            return MockResponse({}, 405)
+
+        return MockResponse(None, 404)
+
+    # Mock `requests.put` in `SimpleRestApi`
+    def mocked_requests_put(*args, **kwargs):
+        class MockResponse:
+            def __init__(self, json_data, status_code):
+                self.json_data = json_data
+                self.status_code = status_code
+
+            def json(self):
+                return self.json_data
+
+        print(f"Calling mocked 'requests.put' with '{args[0]}'")
+        if args[0] == "http://aura.test.available/bad-request":
+            return MockResponse({}, 400)
+        if args[0] == "http://aura.test.available/not-found":
+            return MockResponse({}, 404)
+
+        return MockResponse(None, 404)
+
+    #
+    # Setup
+    #
+
     def setUp(self):
         self.config = AuraConfig()
-        self.api = SimpleApi()
+        self.api = SimpleRestApi()
+
+    #
+    # Tests
+    #
 
     def test_config(self):
         print(self._testMethodName)
@@ -41,6 +103,16 @@ class TestApi(unittest.TestCase):
         # Check if config is available
         self.assertIsNotNone(self.config.ini_path)
 
+    def test_clean_dict(self):
+        print(self._testMethodName)
+
+        data = {"foo": {"bar": None}, "foo2": None}
+        self.assertEqual(2, len(data.keys()))
+        data = self.api.clean_dictionary(data)
+
+        self.assertEqual(1, len(data.keys()))
+        self.assertEqual(["foo"], list(data.keys()))
+
     def test_serialize_json(self):
         print(self._testMethodName)
 
@@ -61,35 +133,82 @@ class TestApi(unittest.TestCase):
         except ValueError:
             self.assertTrue("Value not found in dict")
 
-    def test_get(self):
+    @mock.patch("aura_engine.base.api.requests.get", side_effect=mocked_requests_get)
+    def test_get(self, mock_get):
         print(self._testMethodName)
-        result = self.api.get("https://o94.at/")
+        result = self.api.get("http://aura.test.available")
 
         # Success
-        # print(result.response.status_code)
         self.assertEqual(200, result.response.status_code)
+        self.assertEqual("bar", result.json["foo"])
+
+    @mock.patch("aura_engine.base.api.requests.get", side_effect=mocked_requests_get)
+    def test_get_bad_request(self, mock_get):
+        print(self._testMethodName)
+        result = self.api.get("http://aura.test.available/bad-request")
+
+        # Bad Request
+        self.assertEqual(400, result.response.status_code)
+
+    @mock.patch("aura_engine.base.api.requests.get", side_effect=mocked_requests_get)
+    def test_get_connection_error(self, mock_get):
+        print(self._testMethodName)
+        result = self.api.get("http://aura.test.available/connection-error")
+
+        # Bad Request
+        self.assertEqual(400, result.response.status_code)
+
+    @mock.patch("aura_engine.base.api.requests.post")
+    def test_post_success(self, mock_post):
+        mock_post.return_value.status_code = 201
+        mock_post.return_value.json.return_value = "mock response"
 
-    def test_post(self):
         print(self._testMethodName)
         data = {"foo": "bar"}
 
-        # Not allowed
-        result = self.api.post("https://o94.at/", data=data)
-        print(result)
-        self.assertEqual(405, result.response.status_code)
+        result = self.api.post("http://aura.test.available/api", data=data)
+
+        mock_post.assert_called_once_with(
+            "http://aura.test.available/api",
+            data=json.dumps({"foo": "bar"}, indent=4, sort_keys=True, default=str),
+            headers={"content-type": "application/json"},
+        )
+
+        # print(result)
+        self.assertEqual(201, result.response.status_code)
+
+    @mock.patch("aura_engine.base.api.requests.put")
+    def test_put_success(self, mock_post):
+        mock_post.return_value.status_code = 200
+        mock_post.return_value.json.return_value = "mock response"
+
+        print(self._testMethodName)
+        data = {"foo": "bar"}
+
+        result = self.api.put("http://aura.test.available/api", data=data)
+
+        mock_post.assert_called_once_with(
+            "http://aura.test.available/api",
+            data=json.dumps({"foo": "bar"}, indent=4, sort_keys=True, default=str),
+            headers={"content-type": "application/json"},
+        )
+
+        # print(result)
+        self.assertEqual(200, result.response.status_code)
 
-    def test_put(self):
+    @mock.patch("aura_engine.base.api.requests.put", side_effect=mocked_requests_put)
+    def test_put(self, mock_put):
         print(self._testMethodName)
         data = {"foo": "bar"}
 
         # Bad request: Invalid URL
-        result = self.api.put("http:/0.0.0.0/invalid-service", data=data)
-        print(result)
+        result = self.api.put("http://aura.test.available/bad-request", data=data)
+        # print(result)
         self.assertEqual(400, result.response.status_code)
 
         # Not found
-        result = self.api.put("https://o94.at/invalid-service", data=data)
-        print(result)
+        result = self.api.put("http://aura.test.available/not-found", data=data)
+        # print(result)
         self.assertEqual(404, result.response.status_code)
 
 
diff --git a/tests/test_simple_api_cached.py b/tests/test_simple_api_cached.py
new file mode 100644
index 0000000000000000000000000000000000000000..e1168b550c9d6082e31eb29eafcd941ba92b51f3
--- /dev/null
+++ b/tests/test_simple_api_cached.py
@@ -0,0 +1,151 @@
+#
+# Aura Engine (https://gitlab.servus.at/aura/engine)
+#
+# Copyright (C) 2017-now() - The Aura Engine Team.
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import os
+import unittest
+from unittest import mock
+
+from aura_engine.base.api import SimpleCachedRestApi, SimpleRestApi
+from aura_engine.base.config import AuraConfig
+
+
+class TestCachedApi(unittest.TestCase):
+    """
+    Testing the Configuration.
+    """
+
+    config = None
+    api = None
+
+    #
+    # Mock
+    #
+
+    # Mock `requests.get` in `SimpleRestApi`
+    def mocked_requests_get(*args, **kwargs):
+        class MockResponse:
+            def __init__(self, json_data, status_code):
+                self.json_data = json_data
+                self.status_code = status_code
+
+            def json(self):
+                return self.json_data
+
+        print(f"Calling mocked 'requests.get' with '{args[0]}'")
+        if args[0] == "http://aura.test.available/dummy-api/v1/playout":
+            return MockResponse({"foo": "bar"}, 200)
+        elif args[0] == "http://aura.test.404/dummy-api/v1/playout":
+            return MockResponse(None, 404)
+        elif args[0] == "http://aura.test.not-json/dummy-api/v1/not-json":
+            return MockResponse("{-that's-definitely-not-json}", 200)
+
+        return MockResponse(None, 404)
+
+    #
+    # Setup
+    #
+
+    def setUp(self):
+        self.config = AuraConfig()
+        cache_location = self.config.get("cache_dir")
+        self.api = SimpleCachedRestApi(SimpleRestApi(), cache_location)
+
+    #
+    # Test Cases
+    #
+
+    def test_config(self):
+        print(self._testMethodName)
+
+        # Check if config is available
+        self.assertIsNotNone(self.config.ini_path)
+
+    def test_build_filename(self):
+        url = "https://dashboard.aura.radio/steering/api/v1/playout"
+        filename = self.api.build_filename(url)
+
+        # Success
+        expected = "steering-api-v1-playout.json"
+        self.assertEqual(expected, filename)
+
+    def test_prune_cache_dir(self):
+        dir = self.api.cache_location
+        f = dir + "dummy-file"
+        with open(f, "a"):
+            os.utime(f, None)
+
+        count = len(os.listdir(dir))
+        self.assertNotEqual(0, count)
+        self.api.prune_cache_dir()
+        count = len(os.listdir(dir))
+        self.assertEqual(0, count)
+
+    @mock.patch("aura_engine.base.api.requests.get", side_effect=mocked_requests_get)
+    def test_get_from_network(self, mock_get):
+        print(self._testMethodName)
+        self.api.prune_cache_dir()
+        url = "http://aura.test.available/dummy-api/v1/playout"
+        result = self.api.get(url)
+
+        # 200 - Success
+        self.assertEqual(200, result.response.status_code)
+        self.assertEqual("bar", result.json.get("foo"))
+
+    @mock.patch("aura_engine.base.api.requests.get", side_effect=mocked_requests_get)
+    def test_get_from_cache(self, mock_get):
+        print(self._testMethodName)
+        self.api.prune_cache_dir()
+        # Ensure a cached response is created
+        self.api.get("http://aura.test.available/dummy-api/v1/playout")
+        # Read the same endpoint from an invalid to domain, enforcing a local cache response
+        result = self.api.get("http://aura.test.404/dummy-api/v1/playout")
+
+        # Read from local cache: 304 - Not Modified
+        self.assertEqual(304, result.response.status_code)
+
+    @mock.patch("aura_engine.base.api.requests.get", side_effect=mocked_requests_get)
+    def test_get_not_existing_from_cache(self, mock_get):
+        print(self._testMethodName)
+        self.api.prune_cache_dir()
+        # Read the same endpoint from an invalid to domain, enforcing a local cache response
+        result = self.api.get("http://aura.test.404/dummy-api/v1/404")
+
+        # Read from local cache: 404 - Not Found
+        self.assertEqual(404, result.response.status_code)
+
+    @mock.patch("aura_engine.base.api.requests.get", side_effect=mocked_requests_get)
+    def test_get_from_cache_with_invalid_json(self, mock_get):
+        print(self._testMethodName)
+        self.api.prune_cache_dir()
+        # Get response with invalid JSON data
+        result = self.api.get("http://aura.test.not-json/dummy-api/v1/not-json")
+        self.assertEqual(200, result.response.status_code)
+        # self.assertEqual(None, result.json)
+        self.assertEqual("{-that's-definitely-not-json}", result.json)
+
+        # Read the same endpoint from an invalid to domain, enforcing a local cache response
+        result = self.api.get("http://aura.test.404/dummy-api/v1/not-json")
+
+        # Read from local cache: 304 - Not Found
+        self.assertEqual(304, result.response.status_code)
+        self.assertEqual("{-that's-definitely-not-json}", result.json)
+
+
+if __name__ == "__main__":
+    unittest.main()