diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..9d33724
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
+
+version: 2
+updates:
+ - package-ecosystem: "pip" # See documentation for possible values
+ directory: "/" # Location of package manifests
+ schedule:
+ interval: "weekly"
\ No newline at end of file
diff --git a/.github/workflows/poetry-ci.yml b/.github/workflows/poetry-ci.yml
new file mode 100644
index 0000000..3f690ef
--- /dev/null
+++ b/.github/workflows/poetry-ci.yml
@@ -0,0 +1,48 @@
+name: Poetry CI
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: [ 3.12 ]
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install poetry
+ run: |
+ curl -sSL https://install.python-poetry.org | python3 -
+ echo "${HOME}/.local/bin" >> $GITHUB_PATH
+
+ - name: Set up cache
+ uses: actions/cache@v4
+ with:
+ path: |
+ .venv
+ ~/.cache/pypoetry
+ key: poetry-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
+
+ - name: Install dependencies
+ run: |
+ poetry config virtualenvs.in-project true
+ poetry install --no-interaction --no-root
+
+ - name: Run unit tests
+ run: poetry run pytest -m unit
+
+ - name: Run mypy
+ run: poetry run mypy .
diff --git a/.github/workflows/poetry-publish.yml b/.github/workflows/poetry-publish.yml
new file mode 100644
index 0000000..df82eae
--- /dev/null
+++ b/.github/workflows/poetry-publish.yml
@@ -0,0 +1,37 @@
+name: Poetry Publish
+
+on:
+ release:
+ types: [ created ]
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: 3.12
+
+ - name: Install Poetry
+ run: |
+ curl -sSL https://install.python-poetry.org | python3 -
+ echo "${HOME}/.local/bin" >> $GITHUB_PATH
+
+ - name: Install dependencies
+ run: |
+ poetry config virtualenvs.in-project true
+ poetry install --no-interaction --no-root
+
+ - name: Build the package
+ run: |
+ poetry build
+
+ - name: Publish to PyPI
+ env:
+ TOKEN: ${{ secrets.PYPI_TOKEN }}
+ run: |
+ poetry publish --username __token__ --password $TOKEN --no-interaction --build
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
new file mode 100644
index 0000000..105ce2d
--- /dev/null
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..61b710d
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..2448764
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/public-transit-client.iml b/.idea/public-transit-client.iml
new file mode 100644
index 0000000..2c80e12
--- /dev/null
+++ b/.idea/public-transit-client.iml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 9f981ad..0676370 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,66 @@
-# public-transit-client
-Client to access the public transit service.
+# Public Transit Client
+
+Client to access the [Public Transit Service](https://github.com/naviqore/public-transit-service) API endpoints.
+
+It is designed to interact seamlessly with the service, offering easy-to-use methods to query
+transit information and connections.
+
+## Installation
+
+To install the package, you can use pip:
+
+```sh
+pip install public-transit-client
+```
+
+## Usage
+
+Here's a basic example of how to use the client:
+
+```python
+from public_transit_client.client import PublicTransitClient
+
+client = PublicTransitClient("http://localhost:8080")
+response = client.get_stop("NANAA")
+print(response)
+```
+
+See the integration tests for more examples.
+
+## Testing
+
+This project uses pytest for both unit and integration testing. The tests are organized into separate folders to ensure
+clarity and separation of concerns.
+
+### Unit Tests
+
+Unit tests are designed to test individual components in isolation. To run the unit tests, simply execute:
+
+```sh
+poetry run pytest -m unit
+```
+
+### Integration Tests
+
+Integration tests ensure that the components work together as expected in a more realistic environment. These tests
+require the service to be running, usually within a Docker container.
+
+**Step 1: Start the Docker Compose Environment**
+
+First, start the necessary services using Docker Compose:
+
+```sh
+docker compose up -d
+```
+
+**Step 2: Run Integration Tests**
+
+Once the services are up and running, execute the integration tests with:
+
+```sh
+poetry run pytest -m integration
+```
+
+## License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..2f79604
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,17 @@
+version: '3.8'
+
+services:
+ public-transit-service:
+ image: ghcr.io/naviqore/public-transit-service:latest
+ container_name: public-transit-service
+ ports:
+ - "8080:8080"
+ environment:
+ - GTFS_STATIC_URI=https://developers.google.com/static/transit/gtfs/examples/sample-feed.zip
+ healthcheck:
+ test: [ "CMD-SHELL", "curl -f http://localhost:8080/actuator/health | grep '\"status\":\"UP\"' || exit 1" ]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 30s
+ restart: unless-stopped
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..6f78776
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,582 @@
+# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+description = "Reusable constraint types to use with typing.Annotated"
+optional = false
+python-versions = ">=3.8"
+files = [
+ { file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53" },
+ { file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" },
+]
+
+[[package]]
+name = "black"
+version = "24.8.0"
+description = "The uncompromising code formatter."
+optional = false
+python-versions = ">=3.8"
+files = [
+ { file = "black-24.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6" },
+ { file = "black-24.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb" },
+ { file = "black-24.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42" },
+ { file = "black-24.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a" },
+ { file = "black-24.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1" },
+ { file = "black-24.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af" },
+ { file = "black-24.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4" },
+ { file = "black-24.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af" },
+ { file = "black-24.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368" },
+ { file = "black-24.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed" },
+ { file = "black-24.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018" },
+ { file = "black-24.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2" },
+ { file = "black-24.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd" },
+ { file = "black-24.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2" },
+ { file = "black-24.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e" },
+ { file = "black-24.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920" },
+ { file = "black-24.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c" },
+ { file = "black-24.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e" },
+ { file = "black-24.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47" },
+ { file = "black-24.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb" },
+ { file = "black-24.8.0-py3-none-any.whl", hash = "sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed" },
+ { file = "black-24.8.0.tar.gz", hash = "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f" },
+]
+
+[package.dependencies]
+click = ">=8.0.0"
+mypy-extensions = ">=0.4.3"
+packaging = ">=22.0"
+pathspec = ">=0.9.0"
+platformdirs = ">=2"
+
+[package.extras]
+colorama = ["colorama (>=0.4.3)"]
+d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"]
+jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
+uvloop = ["uvloop (>=0.15.2)"]
+
+[[package]]
+name = "certifi"
+version = "2024.7.4"
+description = "Python package for providing Mozilla's CA Bundle."
+optional = false
+python-versions = ">=3.6"
+files = [
+ { file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90" },
+ { file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.3.2"
+description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+optional = false
+python-versions = ">=3.7.0"
+files = [
+ { file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5" },
+ { file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3" },
+ { file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027" },
+ { file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03" },
+ { file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d" },
+ { file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e" },
+ { file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6" },
+ { file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5" },
+ { file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537" },
+ { file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c" },
+ { file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12" },
+ { file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f" },
+ { file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269" },
+ { file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519" },
+ { file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73" },
+ { file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09" },
+ { file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db" },
+ { file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96" },
+ { file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e" },
+ { file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f" },
+ { file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574" },
+ { file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4" },
+ { file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8" },
+ { file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc" },
+ { file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae" },
+ { file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887" },
+ { file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae" },
+ { file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce" },
+ { file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f" },
+ { file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab" },
+ { file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77" },
+ { file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8" },
+ { file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b" },
+ { file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6" },
+ { file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a" },
+ { file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389" },
+ { file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa" },
+ { file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b" },
+ { file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed" },
+ { file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26" },
+ { file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d" },
+ { file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068" },
+ { file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143" },
+ { file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4" },
+ { file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7" },
+ { file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001" },
+ { file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c" },
+ { file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5" },
+ { file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985" },
+ { file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6" },
+ { file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714" },
+ { file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786" },
+ { file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5" },
+ { file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c" },
+ { file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8" },
+ { file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711" },
+ { file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811" },
+ { file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4" },
+ { file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99" },
+ { file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a" },
+ { file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac" },
+ { file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a" },
+ { file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33" },
+ { file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238" },
+ { file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a" },
+ { file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2" },
+ { file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8" },
+ { file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898" },
+ { file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99" },
+ { file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d" },
+ { file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04" },
+ { file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087" },
+ { file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25" },
+ { file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b" },
+ { file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4" },
+ { file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d" },
+ { file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0" },
+ { file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269" },
+ { file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c" },
+ { file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519" },
+ { file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796" },
+ { file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185" },
+ { file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c" },
+ { file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458" },
+ { file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2" },
+ { file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8" },
+ { file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" },
+ { file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f" },
+ { file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d" },
+ { file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc" },
+]
+
+[[package]]
+name = "click"
+version = "8.1.7"
+description = "Composable command line interface toolkit"
+optional = false
+python-versions = ">=3.7"
+files = [
+ { file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28" },
+ { file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" },
+]
+
+[package.dependencies]
+colorama = { version = "*", markers = "platform_system == \"Windows\"" }
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+ { file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" },
+ { file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" },
+]
+
+[[package]]
+name = "geographiclib"
+version = "2.0"
+description = "The geodesic routines from GeographicLib"
+optional = false
+python-versions = ">=3.7"
+files = [
+ { file = "geographiclib-2.0-py3-none-any.whl", hash = "sha256:6b7225248e45ff7edcee32becc4e0a1504c606ac5ee163a5656d482e0cd38734" },
+ { file = "geographiclib-2.0.tar.gz", hash = "sha256:f7f41c85dc3e1c2d3d935ec86660dc3b2c848c83e17f9a9e51ba9d5146a15859" },
+]
+
+[[package]]
+name = "geopy"
+version = "2.4.1"
+description = "Python Geocoding Toolbox"
+optional = false
+python-versions = ">=3.7"
+files = [
+ { file = "geopy-2.4.1-py3-none-any.whl", hash = "sha256:ae8b4bc5c1131820f4d75fce9d4aaaca0c85189b3aa5d64c3dcaf5e3b7b882a7" },
+ { file = "geopy-2.4.1.tar.gz", hash = "sha256:50283d8e7ad07d89be5cb027338c6365a32044df3ae2556ad3f52f4840b3d0d1" },
+]
+
+[package.dependencies]
+geographiclib = ">=1.52,<3"
+
+[package.extras]
+aiohttp = ["aiohttp"]
+dev = ["coverage", "flake8 (>=5.0,<5.1)", "isort (>=5.10.0,<5.11.0)", "pytest (>=3.10)", "pytest-asyncio (>=0.17)", "readme-renderer", "sphinx (<=4.3.2)", "sphinx-issues", "sphinx-rtd-theme (>=0.5.0)"]
+dev-docs = ["readme-renderer", "sphinx (<=4.3.2)", "sphinx-issues", "sphinx-rtd-theme (>=0.5.0)"]
+dev-lint = ["flake8 (>=5.0,<5.1)", "isort (>=5.10.0,<5.11.0)"]
+dev-test = ["coverage", "pytest (>=3.10)", "pytest-asyncio (>=0.17)", "sphinx (<=4.3.2)"]
+requests = ["requests (>=2.16.2)", "urllib3 (>=1.24.2)"]
+timezone = ["pytz"]
+
+[[package]]
+name = "idna"
+version = "3.7"
+description = "Internationalized Domain Names in Applications (IDNA)"
+optional = false
+python-versions = ">=3.5"
+files = [
+ { file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0" },
+ { file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+description = "brain-dead simple config-ini parsing"
+optional = false
+python-versions = ">=3.7"
+files = [
+ { file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" },
+ { file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3" },
+]
+
+[[package]]
+name = "isort"
+version = "5.13.2"
+description = "A Python utility / library to sort Python imports."
+optional = false
+python-versions = ">=3.8.0"
+files = [
+ { file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6" },
+ { file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109" },
+]
+
+[package.extras]
+colors = ["colorama (>=0.4.6)"]
+
+[[package]]
+name = "mypy"
+version = "1.11.1"
+description = "Optional static typing for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ { file = "mypy-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a32fc80b63de4b5b3e65f4be82b4cfa362a46702672aa6a0f443b4689af7008c" },
+ { file = "mypy-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c1952f5ea8a5a959b05ed5f16452fddadbaae48b5d39235ab4c3fc444d5fd411" },
+ { file = "mypy-1.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1e30dc3bfa4e157e53c1d17a0dad20f89dc433393e7702b813c10e200843b03" },
+ { file = "mypy-1.11.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2c63350af88f43a66d3dfeeeb8d77af34a4f07d760b9eb3a8697f0386c7590b4" },
+ { file = "mypy-1.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:a831671bad47186603872a3abc19634f3011d7f83b083762c942442d51c58d58" },
+ { file = "mypy-1.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7b6343d338390bb946d449677726edf60102a1c96079b4f002dedff375953fc5" },
+ { file = "mypy-1.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4fe9f4e5e521b458d8feb52547f4bade7ef8c93238dfb5bbc790d9ff2d770ca" },
+ { file = "mypy-1.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886c9dbecc87b9516eff294541bf7f3655722bf22bb898ee06985cd7269898de" },
+ { file = "mypy-1.11.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fca4a60e1dd9fd0193ae0067eaeeb962f2d79e0d9f0f66223a0682f26ffcc809" },
+ { file = "mypy-1.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:0bd53faf56de9643336aeea1c925012837432b5faf1701ccca7fde70166ccf72" },
+ { file = "mypy-1.11.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f39918a50f74dc5969807dcfaecafa804fa7f90c9d60506835036cc1bc891dc8" },
+ { file = "mypy-1.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0bc71d1fb27a428139dd78621953effe0d208aed9857cb08d002280b0422003a" },
+ { file = "mypy-1.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b868d3bcff720dd7217c383474008ddabaf048fad8d78ed948bb4b624870a417" },
+ { file = "mypy-1.11.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a707ec1527ffcdd1c784d0924bf5cb15cd7f22683b919668a04d2b9c34549d2e" },
+ { file = "mypy-1.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:64f4a90e3ea07f590c5bcf9029035cf0efeae5ba8be511a8caada1a4893f5525" },
+ { file = "mypy-1.11.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:749fd3213916f1751fff995fccf20c6195cae941dc968f3aaadf9bb4e430e5a2" },
+ { file = "mypy-1.11.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b639dce63a0b19085213ec5fdd8cffd1d81988f47a2dec7100e93564f3e8fb3b" },
+ { file = "mypy-1.11.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c956b49c5d865394d62941b109728c5c596a415e9c5b2be663dd26a1ff07bc0" },
+ { file = "mypy-1.11.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45df906e8b6804ef4b666af29a87ad9f5921aad091c79cc38e12198e220beabd" },
+ { file = "mypy-1.11.1-cp38-cp38-win_amd64.whl", hash = "sha256:d44be7551689d9d47b7abc27c71257adfdb53f03880841a5db15ddb22dc63edb" },
+ { file = "mypy-1.11.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2684d3f693073ab89d76da8e3921883019ea8a3ec20fa5d8ecca6a2db4c54bbe" },
+ { file = "mypy-1.11.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79c07eb282cb457473add5052b63925e5cc97dfab9812ee65a7c7ab5e3cb551c" },
+ { file = "mypy-1.11.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11965c2f571ded6239977b14deebd3f4c3abd9a92398712d6da3a772974fad69" },
+ { file = "mypy-1.11.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a2b43895a0f8154df6519706d9bca8280cda52d3d9d1514b2d9c3e26792a0b74" },
+ { file = "mypy-1.11.1-cp39-cp39-win_amd64.whl", hash = "sha256:1a81cf05975fd61aec5ae16501a091cfb9f605dc3e3c878c0da32f250b74760b" },
+ { file = "mypy-1.11.1-py3-none-any.whl", hash = "sha256:0624bdb940255d2dd24e829d99a13cfeb72e4e9031f9492148f410ed30bcab54" },
+ { file = "mypy-1.11.1.tar.gz", hash = "sha256:f404a0b069709f18bbdb702eb3dcfe51910602995de00bd39cea3050b5772d08" },
+]
+
+[package.dependencies]
+mypy-extensions = ">=1.0.0"
+typing-extensions = ">=4.6.0"
+
+[package.extras]
+dmypy = ["psutil (>=4.0)"]
+install-types = ["pip"]
+mypyc = ["setuptools (>=50)"]
+reports = ["lxml"]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.0.0"
+description = "Type system extensions for programs checked with the mypy type checker."
+optional = false
+python-versions = ">=3.5"
+files = [
+ { file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d" },
+ { file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" },
+]
+
+[[package]]
+name = "packaging"
+version = "24.1"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.8"
+files = [
+ { file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" },
+ { file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002" },
+]
+
+[[package]]
+name = "pathspec"
+version = "0.12.1"
+description = "Utility library for gitignore style pattern matching of file paths."
+optional = false
+python-versions = ">=3.8"
+files = [
+ { file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08" },
+ { file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.2.2"
+description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
+optional = false
+python-versions = ">=3.8"
+files = [
+ { file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee" },
+ { file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3" },
+]
+
+[package.extras]
+docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
+test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"]
+type = ["mypy (>=1.8)"]
+
+[[package]]
+name = "pluggy"
+version = "1.5.0"
+description = "plugin and hook calling mechanisms for python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ { file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669" },
+ { file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1" },
+]
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
+[[package]]
+name = "pydantic"
+version = "2.8.2"
+description = "Data validation using Python type hints"
+optional = false
+python-versions = ">=3.8"
+files = [
+ { file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8" },
+ { file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a" },
+]
+
+[package.dependencies]
+annotated-types = ">=0.4.0"
+pydantic-core = "2.20.1"
+typing-extensions = [
+ { version = ">=4.12.2", markers = "python_version >= \"3.13\"" },
+ { version = ">=4.6.1", markers = "python_version < \"3.13\"" },
+]
+
+[package.extras]
+email = ["email-validator (>=2.0.0)"]
+
+[[package]]
+name = "pydantic-core"
+version = "2.20.1"
+description = "Core functionality for Pydantic validation and serialization"
+optional = false
+python-versions = ">=3.8"
+files = [
+ { file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3" },
+ { file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6" },
+ { file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a" },
+ { file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3" },
+ { file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1" },
+ { file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953" },
+ { file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98" },
+ { file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a" },
+ { file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a" },
+ { file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840" },
+ { file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250" },
+ { file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c" },
+ { file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312" },
+ { file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88" },
+ { file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc" },
+ { file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43" },
+ { file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6" },
+ { file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121" },
+ { file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1" },
+ { file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b" },
+ { file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27" },
+ { file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b" },
+ { file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a" },
+ { file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2" },
+ { file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231" },
+ { file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9" },
+ { file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f" },
+ { file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52" },
+ { file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237" },
+ { file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe" },
+ { file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e" },
+ { file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24" },
+ { file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1" },
+ { file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd" },
+ { file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688" },
+ { file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d" },
+ { file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686" },
+ { file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a" },
+ { file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b" },
+ { file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19" },
+ { file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac" },
+ { file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703" },
+ { file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c" },
+ { file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83" },
+ { file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203" },
+ { file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0" },
+ { file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e" },
+ { file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20" },
+ { file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91" },
+ { file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b" },
+ { file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a" },
+ { file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f" },
+ { file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad" },
+ { file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c" },
+ { file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598" },
+ { file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd" },
+ { file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa" },
+ { file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987" },
+ { file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a" },
+ { file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434" },
+ { file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c" },
+ { file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6" },
+ { file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2" },
+ { file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a" },
+ { file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611" },
+ { file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b" },
+ { file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006" },
+ { file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1" },
+ { file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09" },
+ { file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab" },
+ { file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2" },
+ { file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669" },
+ { file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906" },
+ { file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94" },
+ { file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f" },
+ { file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482" },
+ { file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6" },
+ { file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc" },
+ { file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99" },
+ { file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6" },
+ { file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331" },
+ { file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad" },
+ { file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1" },
+ { file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86" },
+ { file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e" },
+ { file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0" },
+ { file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a" },
+ { file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7" },
+ { file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4" },
+]
+
+[package.dependencies]
+typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
+
+[[package]]
+name = "pytest"
+version = "8.3.2"
+description = "pytest: simple powerful testing with Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ { file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5" },
+ { file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce" },
+]
+
+[package.dependencies]
+colorama = { version = "*", markers = "sys_platform == \"win32\"" }
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=1.5,<2"
+
+[package.extras]
+dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
+
+[[package]]
+name = "requests"
+version = "2.32.3"
+description = "Python HTTP for Humans."
+optional = false
+python-versions = ">=3.8"
+files = [
+ { file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" },
+ { file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760" },
+]
+
+[package.dependencies]
+certifi = ">=2017.4.17"
+charset-normalizer = ">=2,<4"
+idna = ">=2.5,<4"
+urllib3 = ">=1.21.1,<3"
+
+[package.extras]
+socks = ["PySocks (>=1.5.6,!=1.5.7)"]
+use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
+
+[[package]]
+name = "types-requests"
+version = "2.32.0.20240712"
+description = "Typing stubs for requests"
+optional = false
+python-versions = ">=3.8"
+files = [
+ { file = "types-requests-2.32.0.20240712.tar.gz", hash = "sha256:90c079ff05e549f6bf50e02e910210b98b8ff1ebdd18e19c873cd237737c1358" },
+ { file = "types_requests-2.32.0.20240712-py3-none-any.whl", hash = "sha256:f754283e152c752e46e70942fa2a146b5bc70393522257bb85bd1ef7e019dcc3" },
+]
+
+[package.dependencies]
+urllib3 = ">=2"
+
+[[package]]
+name = "typing-extensions"
+version = "4.12.2"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+optional = false
+python-versions = ">=3.8"
+files = [
+ { file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d" },
+ { file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.2.2"
+description = "HTTP library with thread-safe connection pooling, file post, and more."
+optional = false
+python-versions = ">=3.8"
+files = [
+ { file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472" },
+ { file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168" },
+]
+
+[package.extras]
+brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
+h2 = ["h2 (>=4,<5)"]
+socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
+zstd = ["zstandard (>=0.18.0)"]
+
+[metadata]
+lock-version = "2.0"
+python-versions = "^3.12"
+content-hash = "e2cf20ffcf55bb60eafa0794b14f79340fbb7a2b65d7c978959f45c05f410e68"
diff --git a/public_transit_client/__init__.py b/public_transit_client/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/public_transit_client/client.py b/public_transit_client/client.py
new file mode 100644
index 0000000..a01b74b
--- /dev/null
+++ b/public_transit_client/client.py
@@ -0,0 +1,282 @@
+import logging
+from datetime import datetime
+
+import requests
+from requests import Response
+
+from public_transit_client.model import (
+ APIError,
+ Connection,
+ Coordinate,
+ Departure,
+ DistanceToStop,
+ SearchType,
+ Stop,
+ StopConnection,
+ TimeType,
+)
+
+LOG = logging.getLogger(__name__)
+
+
+class PublicTransitClientException(Exception):
+ """Exception raised for errors in the Public Transit Client."""
+
+ def __init__(self, api_error: APIError):
+ """Initialize the PublicTransitClientException with an APIError.
+
+ Args:
+ api_error (APIError): The error object returned by the API.
+ """
+ self.api_error = api_error
+ super().__init__(f"API Error {api_error.status}: {api_error.message}")
+
+
+class PublicTransitClient:
+ """A client to interact with the public transit API."""
+
+ def __init__(self, host: str):
+ """Initialize the PublicTransitClient with the API host URL.
+
+ Args:
+ host (str): The base URL of the public transit API.
+ """
+ self.host = host
+
+ def _send_get_request(self, endpoint: str, params: dict[str, str] | None = None):
+ """Sends a GET request to the API and handles the response."""
+ url = f"{self.host}{endpoint}"
+ LOG.debug(f"Sending GET request to {url} with params {params}")
+ response = requests.get(url, params=params)
+ return self._handle_response(response)
+
+ @staticmethod
+ def _handle_response(response: Response):
+ """Handles the response from the API."""
+ if response.status_code == 200:
+ return response.json()
+ else:
+ try:
+ error_details = APIError(**response.json())
+ LOG.error(f"API error occurred: {error_details}")
+ raise PublicTransitClientException(error_details)
+ except ValueError:
+ LOG.error(f"Non-JSON response received: {response.text}")
+ response.raise_for_status()
+
+ def search_stops(
+ self, query: str, limit: int = 10, search_type: SearchType = SearchType.CONTAINS
+ ) -> list[Stop]:
+ """Search for stops by a query string.
+
+ Args:
+ query (str): The search query for the stop name.
+ limit (int, optional): The maximum number of stops to return. Defaults to 10.
+ search_type (SearchType, optional): The type of search to perform (EXACT, CONTAINS, etc.). Defaults to CONTAINS.
+
+ Returns:
+ list[Stop]: A list of Stop objects that match the query.
+ """
+ params = {"query": query, "limit": str(limit), "searchType": search_type.name}
+ data = self._send_get_request("/schedule/stops/autocomplete", params)
+ return [Stop(**stop) for stop in data]
+
+ def nearest_stops(
+ self, coordinate: Coordinate, limit: int = 10, max_distance: int = 1000
+ ) -> list[DistanceToStop]:
+ """Find the nearest stops to a given coordinate.
+
+ Args:
+ coordinate (Coordinate): The geographical coordinate to search from.
+ limit (int, optional): The maximum number of stops to return. Defaults to 10.
+ max_distance (int, optional): The maximum distance (in meters) from the coordinate to search. Defaults to 1000.
+
+ Returns:
+ list[DistanceToStop]: A list of DistanceToStop objects representing nearby stops.
+ """
+ params = {
+ "latitude": str(coordinate.latitude),
+ "longitude": str(coordinate.longitude),
+ "limit": str(limit),
+ "maxDistance": str(max_distance),
+ }
+ data = self._send_get_request("/schedule/stops/nearest", params)
+ return [DistanceToStop(**stop) for stop in data]
+
+ def get_stop(self, stop_id: str) -> Stop | None:
+ """Retrieve details of a specific stop by its ID.
+
+ Args:
+ stop_id (str): The unique identifier of the stop.
+
+ Returns:
+ Stop | None: A Stop object if found, otherwise None.
+ """
+ data = self._send_get_request(f"/schedule/stops/{stop_id}")
+ return Stop(**data) if data else None
+
+ def get_next_departures(
+ self,
+ stop: str | Stop,
+ departure: datetime | None = None,
+ limit: int = 10,
+ until: datetime | None = None,
+ ) -> list[Departure]:
+ """Retrieve the next departures from a specific stop.
+
+ Args:
+ stop (str | Stop): The stop ID or Stop object to get departures from.
+ departure (datetime, optional): The starting time for the departures search. Defaults to None (=now).
+ limit (int, optional): The maximum number of departures to return. Defaults to 10.
+ until (datetime, optional): The end time for the departures search. Defaults to None.
+
+ Returns:
+ list[Departure]: A list of Departure objects.
+ """
+ stop_id = stop.id if isinstance(stop, Stop) else stop
+ params = {"limit": str(limit)}
+ if departure:
+ params["departureDateTime"] = departure.strftime("%Y-%m-%dT%H:%M:%S")
+ if until:
+ params["untilDateTime"] = until.strftime("%Y-%m-%dT%H:%M:%S")
+
+ data = self._send_get_request(f"/schedule/stops/{stop_id}/departures", params)
+ return [Departure(**dep) for dep in data]
+
+ def get_connections(
+ self,
+ source: Stop | Coordinate | str | tuple[float, float],
+ target: str | Stop | Coordinate | tuple[float, float],
+ time: datetime | None = None,
+ time_type: TimeType = TimeType.DEPARTURE,
+ max_walking_duration: int | None = None,
+ max_transfer_number: int | None = None,
+ max_travel_time: int | None = None,
+ min_transfer_time: int | None = None,
+ ) -> list[Connection]:
+ """Retrieve a list of possible connections between two stops and or locations.
+
+ Args:
+ source (Stop | Coordinate | str | tuple[float, float]): The starting Stop object, Coordinate object, Stop ID or
+ Coordinates tuple.
+ target (Stop | Coordinate | str | tuple[float, float]): The destination Stop object, Coordinate object,
+ Stop ID or Coordinates tuple.
+ time (datetime, optional): The time for the connection search. Defaults to None (=now).
+ time_type (TimeType, optional): Whether the time is for departure or arrival. Defaults to DEPARTURE.
+ max_walking_duration (int, optional): Maximum walking duration in minutes. Defaults to None.
+ max_transfer_number (int, optional): Maximum number of transfers allowed. Defaults to None.
+ max_travel_time (int, optional): Maximum travel time in minutes. Defaults to None.
+ min_transfer_time (int, optional): Minimum transfer time in minutes. Defaults to None.
+
+ Returns:
+ list[Connection]: A list of Connection objects representing the possible routes.
+ """
+ params = self._build_params_dict(
+ source,
+ target,
+ time=time,
+ time_type=time_type,
+ max_walking_duration=max_walking_duration,
+ max_transfer_number=max_transfer_number,
+ max_travel_time=max_travel_time,
+ min_transfer_time=min_transfer_time,
+ )
+ data = self._send_get_request("/routing/connections", params)
+ return [Connection(**conn) for conn in data]
+
+ def get_isolines(
+ self,
+ source: Stop | Coordinate | str | tuple[float, float],
+ time: datetime | None = None,
+ time_type: TimeType = TimeType.DEPARTURE,
+ max_walking_duration: int | None = None,
+ max_transfer_number: int | None = None,
+ max_travel_time: int | None = None,
+ min_transfer_time: int | None = None,
+ return_connections: bool = False,
+ ) -> list[StopConnection]:
+ """Retrieve isolines (areas reachable within a certain time) from a specific stop / location.
+
+ Args:
+ source (Stop | Coordinate | str | tuple[float, float]): The starting Stop object, Coordinate object, Stop ID or
+ Coordinates tuple.
+ time (datetime, optional): The time for the isoline calculation. Defaults to None (=now).
+ time_type (TimeType, optional): Whether the time is for departure or arrival. Defaults to DEPARTURE.
+ max_walking_duration (int, optional): Maximum walking duration in minutes. Defaults to None.
+ max_transfer_number (int, optional): Maximum number of transfers allowed. Defaults to None.
+ max_travel_time (int, optional): Maximum travel time in minutes. Defaults to None.
+ min_transfer_time (int, optional): Minimum transfer time in minutes. Defaults to None.
+ return_connections (bool, optional): Whether to return detailed connections. Defaults to False.
+
+ Returns:
+ list[StopConnection]: A list of StopConnection objects representing the reachable areas.
+ """
+ params = self._build_params_dict(
+ source,
+ time=time,
+ time_type=time_type,
+ max_walking_duration=max_walking_duration,
+ max_transfer_number=max_transfer_number,
+ max_travel_time=max_travel_time,
+ min_transfer_time=min_transfer_time,
+ )
+
+ if return_connections:
+ params["returnConnections"] = "true"
+
+ data = self._send_get_request("/routing/isolines", params)
+ return [StopConnection(**stop_conn) for stop_conn in data]
+
+ @staticmethod
+ def _build_params_dict(
+ source: Stop | Coordinate | str | tuple[float, float],
+ target: str | Stop | Coordinate | tuple[float, float] | None = None,
+ time: datetime | None = None,
+ time_type: TimeType | None = None,
+ max_walking_duration: int | None = None,
+ max_transfer_number: int | None = None,
+ max_travel_time: int | None = None,
+ min_transfer_time: int | None = None,
+ ) -> dict[str, str]:
+
+ if isinstance(source, Stop):
+ source = source.id
+ elif isinstance(source, Coordinate):
+ source = source.to_tuple()
+
+ if isinstance(target, Stop):
+ target = target.id
+ elif isinstance(target, Coordinate):
+ target = target.to_tuple()
+
+ params: dict[str, str] = {
+ "dateTime": (datetime.now() if time is None else time).strftime(
+ "%Y-%m-%dT%H:%M:%S"
+ ),
+ }
+
+ if source is isinstance(source, tuple):
+ params["sourceLatitude"] = str(source[0])
+ params["sourceLongitude"] = str(source[1])
+ elif isinstance(source, str):
+ params["sourceStopId"] = source
+
+ if target is not None:
+ if target is isinstance(target, tuple):
+ params["targetLatitude"] = str(target[0])
+ params["targetLongitude"] = str(target[1])
+ elif isinstance(target, str):
+ params["targetStopId"] = target
+
+ if time_type:
+ params["timeType"] = time_type.value
+ if max_walking_duration is not None:
+ params["maxWalkingDuration"] = str(max_walking_duration)
+ if max_transfer_number is not None:
+ params["maxTransferNumber"] = str(max_transfer_number)
+ if max_travel_time is not None:
+ params["maxTravelTime"] = str(max_travel_time)
+ if min_transfer_time is not None:
+ params["minTransferTime"] = str(min_transfer_time)
+
+ return params
diff --git a/public_transit_client/model.py b/public_transit_client/model.py
new file mode 100644
index 0000000..4c3a958
--- /dev/null
+++ b/public_transit_client/model.py
@@ -0,0 +1,529 @@
+from datetime import datetime
+from enum import Enum
+from itertools import pairwise
+
+from geopy import distance # type: ignore
+from pydantic import BaseModel, Field, field_validator
+
+
+class SearchType(Enum):
+ """Enum for specifying the type of search."""
+
+ EXACT = "EXACT"
+ CONTAINS = "CONTAINS"
+ STARTS_WITH = "STARTS_WITH"
+ ENDS_WITH = "ENDS_WITH"
+
+
+class LegType(Enum):
+ """Enum for specifying the type of leg in a journey."""
+
+ WALK = "WALK"
+ ROUTE = "ROUTE"
+
+
+class TimeType(Enum):
+ """Enum for specifying the type of time defined in a connection query (departure or arrival)."""
+
+ DEPARTURE = "DEPARTURE"
+ ARRIVAL = "ARRIVAL"
+
+
+class APIError(BaseModel):
+ """Model representing an API error.
+
+ Attributes:
+ timestamp (datetime): The time when the error occurred.
+ status (int): The HTTP status code of the error.
+ error (str): The error message.
+ path (str): The path where the error occurred.
+ message (str): A detailed message about the error.
+ """
+
+ timestamp: datetime
+ status: int
+ error: str
+ path: str
+ message: str
+
+
+class Coordinate(BaseModel):
+ """Model representing geographical coordinates.
+
+ Attributes:
+ latitude (float): The latitude of the coordinate.
+ longitude (float): The longitude of the coordinate.
+ """
+
+ latitude: float
+ longitude: float
+
+ def distance_to(self, other: "Coordinate") -> float:
+ """Calculate the distance to another Coordinate.
+
+ Args:
+ other (Coordinate): The other coordinate to calculate distance to.
+
+ Returns:
+ float: The distance in meters.
+ """
+ return float(
+ distance.distance(
+ (self.latitude, self.longitude),
+ (other.latitude, other.longitude),
+ ).meters
+ )
+
+ def to_tuple(self) -> tuple[float, float]:
+ """Convert the Coordinate to a tuple.
+
+ Returns:
+ tuple[float, float]: The coordinate as a (latitude, longitude) tuple.
+ """
+ return self.latitude, self.longitude
+
+
+class Stop(BaseModel):
+ """Model representing a public transport stop.
+
+ Attributes:
+ id (str): The unique identifier of the stop.
+ name (str): The name of the stop.
+ coordinate (Coordinate): The geographical coordinate of the stop.
+ """
+
+ id: str
+ name: str
+ coordinate: Coordinate = Field(alias="coordinates")
+
+
+class Route(BaseModel):
+ """Model representing a public transport route.
+
+ Attributes:
+ id (str): The unique identifier of the route.
+ name (str): The name of the route.
+ short_name (str): The short name of the route.
+ transport_mode (str): The mode of transport (e.g., bus, train).
+ """
+
+ id: str
+ name: str
+ short_name: str = Field(alias="shortName")
+ transport_mode: str = Field(alias="transportMode")
+
+
+class StopTime(BaseModel):
+ """Model representing a stop time for a particular route.
+
+ Attributes:
+ stop (Stop): The stop associated with the stop time.
+ arrival_time (datetime): The arrival time at the stop.
+ departure_time (datetime): The departure time from the stop.
+ """
+
+ stop: Stop
+ arrival_time: datetime = Field(alias="arrivalTime")
+ departure_time: datetime = Field(alias="departureTime")
+
+
+class Trip(BaseModel):
+ """Model representing a public transport trip.
+
+ Attributes:
+ head_sign (str): The head sign of the trip.
+ route (Route): The route associated with the trip.
+ stop_times (list[StopTime]): A list of stop times for the trip.
+ """
+
+ head_sign: str = Field(alias="headSign")
+ route: Route
+ stop_times: list[StopTime] = Field(alias="stopTimes")
+
+ @staticmethod
+ @field_validator("stop_times", mode="before")
+ def _set_stop_times_not_none(v: list[StopTime] | None) -> list[StopTime]:
+ return v or []
+
+
+class Departure(BaseModel):
+ """Model representing a departure event.
+
+ Attributes:
+ stop_time (StopTime): The stop time associated with the departure.
+ trip (Trip): The trip associated with the departure.
+ """
+
+ stop_time: StopTime = Field(alias="stopTime")
+ trip: Trip
+
+
+class Leg(BaseModel):
+ """Model representing a leg of a journey.
+
+ Attributes:
+ from_coordinate (Coordinate): The starting coordinate of the leg.
+ from_stop (Stop | None): The starting stop of the leg (if applicable).
+ to_coordinate (Coordinate): The ending coordinate of the leg.
+ to_stop (Stop | None): The ending stop of the leg (if applicable).
+ type (LegType): The type of the leg (WALK or ROUTE).
+ departure_time (datetime): The departure time for the leg.
+ arrival_time (datetime): The arrival time for the leg.
+ trip (Trip | None): The trip associated with the leg (if applicable).
+ """
+
+ from_coordinate: Coordinate = Field(alias="from")
+ from_stop: Stop | None = Field(alias="fromStop", default=None)
+ to_coordinate: Coordinate = Field(alias="to")
+ to_stop: Stop | None = Field(alias="toStop", default=None)
+ type: LegType
+ departure_time: datetime = Field(alias="departureTime")
+ arrival_time: datetime = Field(alias="arrivalTime")
+ trip: Trip | None = None
+
+ @property
+ def duration(self) -> int:
+ """Calculate the duration of the leg in seconds.
+
+ Returns:
+ int: The duration in seconds.
+ """
+ return (self.arrival_time - self.departure_time).seconds
+
+ @property
+ def distance(self) -> float:
+ """Calculate the distance of the leg.
+
+ Returns:
+ float: The distance in meters.
+ """
+ return self.from_coordinate.distance_to(self.to_coordinate)
+
+ @property
+ def is_walk(self) -> bool:
+ """Check if the leg is a walking leg.
+
+ Returns:
+ bool: True if the leg is a walking leg, False otherwise.
+ """
+ return self.type == LegType.WALK
+
+ @property
+ def is_route(self) -> bool:
+ """Check if the leg is a route leg.
+
+ Returns:
+ bool: True if the leg is a route leg, False otherwise.
+ """
+ return self.type == LegType.ROUTE
+
+ @property
+ def num_stops(self) -> int:
+ """Calculate the number of stops between the starting and ending stops of the leg.
+
+ Returns:
+ int: The number of stops.
+
+ Raises:
+ ValueError: If from_stop or to_stop is not found in the trip.
+ """
+ if self.trip is None or not self.is_route:
+ return 0
+
+ # get index of from_stop and to_stop
+ from_stop_index = None
+ to_stop_index = None
+ for i, stop_time in enumerate(self.trip.stop_times):
+ if stop_time.stop == self.from_stop:
+ from_stop_index = i
+ if stop_time.stop == self.to_stop:
+ to_stop_index = i
+ break
+
+ if from_stop_index is None or to_stop_index is None:
+ raise ValueError("from_stop or to_stop not found in trip")
+ return to_stop_index - from_stop_index
+
+
+class Connection(BaseModel):
+ """Model representing a journey connection consisting of multiple legs.
+
+ Attributes:
+ legs (list[Leg]): A list of legs that make up the connection.
+ """
+
+ legs: list[Leg]
+
+ @staticmethod
+ @field_validator("legs")
+ def _legs_not_empty(v: list[Leg]) -> list[Leg]:
+ if not v:
+ raise ValueError("legs must not be empty")
+ return v
+
+ @property
+ def first_leg(self) -> Leg:
+ """Get the first leg of the connection.
+
+ Returns:
+ Leg: The first leg.
+ """
+ return self.legs[0]
+
+ @property
+ def last_leg(self) -> Leg:
+ """Get the last leg of the connection.
+
+ Returns:
+ Leg: The last leg.
+ """
+ return self.legs[-1]
+
+ @property
+ def first_route_leg(self) -> Leg | None:
+ """Get the first route leg of the connection.
+
+ Returns:
+ Leg | None: The first route leg, or None if no route legs exist.
+ """
+ for leg in self.legs:
+ if leg.is_route:
+ return leg
+
+ return None
+
+ @property
+ def last_route_leg(self) -> Leg | None:
+ """Get the last route leg of the connection.
+
+ Returns:
+ Leg | None: The last route leg, or None if no route legs exist.
+ """
+ for leg in reversed(self.legs):
+ if leg.is_route:
+ return leg
+
+ return None
+
+ @property
+ def first_stop(self) -> Stop | None:
+ """Get the first stop of the connection.
+
+ Returns:
+ Stop | None: The first stop, or None if no stop is passed.
+ """
+ first_route_leg = self.first_route_leg
+ return first_route_leg.from_stop if first_route_leg else None
+
+ @property
+ def last_stop(self) -> Stop | None:
+ """Get the last stop of the connection.
+
+ Returns:
+ Stop | None: The last stop, or None if no stop is passed.
+ """
+ last_route_leg = self.last_route_leg
+ return last_route_leg.to_stop if last_route_leg else None
+
+ @property
+ def departure_time(self) -> datetime:
+ """Get the departure time of the connection.
+
+ Returns:
+ datetime: The departure time.
+ """
+ return self.first_leg.departure_time
+
+ @property
+ def arrival_time(self) -> datetime:
+ """Get the arrival time of the connection.
+
+ Returns:
+ datetime: The arrival time.
+ """
+ return self.last_leg.arrival_time
+
+ @property
+ def from_coordinate(self) -> Coordinate:
+ """Get the starting coordinate of the connection.
+
+ Returns:
+ Coordinate: The starting coordinate.
+ """
+ return self.first_leg.from_coordinate
+
+ @property
+ def to_coordinate(self) -> Coordinate:
+ """Get the ending coordinate of the connection.
+
+ Returns:
+ Coordinate: The ending coordinate.
+ """
+ return self.last_leg.to_coordinate
+
+ @property
+ def from_stop(self) -> Stop | None:
+ """Get the starting stop of the connection.
+
+ Returns:
+ Stop | None: The starting stop, or None if connection does not start at stop.
+ """
+ return self.first_leg.from_stop
+
+ @property
+ def to_stop(self) -> Stop | None:
+ """Get the ending stop of the connection.
+
+ Returns:
+ Stop | None: The ending stop, or None if connection does not end at stop.
+ """
+ return self.last_leg.to_stop
+
+ @property
+ def duration(self) -> int:
+ """Calculate the duration of the connection in seconds.
+
+ Returns:
+ int: The duration in seconds.
+ """
+ return (self.arrival_time - self.departure_time).seconds
+
+ @property
+ def travel_duration(self) -> int:
+ """Calculate the travel duration of the connection in seconds.
+
+ The travel duration is the sum of the duration of all legs, exluding any waiting time.
+
+ Returns:
+ int: The travel duration in seconds.
+ """
+ return sum(leg.duration for leg in self.legs)
+
+ @property
+ def travel_distance(self) -> float:
+ """Calculate the travel distance of the connection.
+
+ The travel distance is the sum of the distance of all legs.
+
+ Returns:
+ float: The travel distance in meters.
+ """
+ return sum(leg.distance for leg in self.legs)
+
+ @property
+ def bee_line_distance(self) -> float:
+ """Calculate the bee line distance of the connection.
+
+ The bee line distance is the distance between the starting and ending coordinates.
+
+ Returns:
+ float: The bee line distance in meters.
+ """
+ return self.from_coordinate.distance_to(self.to_coordinate)
+
+ @property
+ def walk_distance(self) -> float:
+ """Calculate the walking distance of the connection.
+
+ Returns:
+ float: The total walking distance in meters.
+ """
+ return sum(leg.distance for leg in self.legs if leg.is_walk)
+
+ @property
+ def route_distance(self) -> float:
+ """Calculate the route distance of the connection.
+
+ Returns:
+ float: The total distance travelled on route legs in meters.
+ """
+ return sum(leg.distance for leg in self.legs if leg.is_route)
+
+ @property
+ def walk_duration(self) -> int:
+ """Calculate the walking duration of the connection in seconds.
+
+ Returns:
+ int: The total walking duration in seconds.
+ """
+ return sum(leg.duration for leg in self.legs if leg.is_walk)
+
+ @property
+ def route_duration(self) -> int:
+ """Calculate the route duration of the connection in seconds.
+
+ Returns:
+ int: The total duration travelled on route legs in seconds.
+ """
+ return sum(leg.duration for leg in self.legs if leg.is_route)
+
+ @property
+ def num_transfers(self) -> int:
+ """Calculate the number of transfers in the connection.
+
+ A transfer is counted when switching from a route leg to another route leg.
+
+ Returns:
+ int: The number of transfers.
+ """
+ return sum(1 for leg in self.legs if leg.is_route) - 1
+
+ @property
+ def num_same_station_transfers(self) -> int:
+ """Calculate the number of same station transfers in the connection.
+
+ A same station transfer is counted when switching from a route leg to another route leg at the same stop.
+ With no intermediate walking legs between the route legs.
+
+ Returns:
+ int: The number of same station transfers.
+ """
+ return sum(
+ 1
+ for prev_leg, current_leg in pairwise(self.legs)
+ if prev_leg.is_route and current_leg.is_route
+ )
+
+ @property
+ def num_stops(self) -> int:
+ """Calculate the number of stops in the connection.
+
+ Returns:
+ int: The number of stops.
+ """
+ return sum(leg.num_stops for leg in self.legs if leg.is_route)
+
+ @property
+ def multi_date(self) -> bool:
+ """Check if the connection spans multiple dates.
+
+ Returns:
+ bool: True if the connection spans multiple dates, False otherwise.
+ """
+ return self.departure_time.date() != self.arrival_time.date()
+
+
+class StopConnection(BaseModel):
+ """Model representing a connection to a stop.
+
+ Attributes:
+ stop (Stop): The stop associated with the connection.
+ connecting_leg (Leg): The leg connecting to the stop.
+ connection (Connection | None): The connection to the stop.
+ """
+
+ stop: Stop
+ connecting_leg: Leg = Field(alias="connectingLeg")
+ connection: Connection | None = None
+
+
+class DistanceToStop(BaseModel):
+ """Model representing the distance to a stop.
+
+ Attributes:
+ stop (Stop): The stop.
+ distance (float): The distance to the stop in meters.
+ """
+
+ stop: Stop
+ distance: float
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..62a3301
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,23 @@
+[tool.poetry]
+name = "public-transit-client"
+version = "0.1.0"
+description = "Client to access the public transit service API endpoints."
+authors = ["Lukas Connolly ", "Merlin Unterfinger "]
+readme = "README.md"
+
+[tool.poetry.dependencies]
+python = "^3.12"
+requests = "^2.32.3"
+pydantic = "^2.8.2"
+geopy = "^2.4.1"
+
+[tool.poetry.group.dev.dependencies]
+pytest = "^8.3.2"
+mypy = "^1.11.1"
+black = "^24.8.0"
+isort = "^5.13.2"
+types-requests = "^2.32.0.20240712"
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..5fb8765
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,4 @@
+[pytest]
+markers =
+ unit: mark a test as a unit test.
+ integration: mark a test as an integration test.
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/integration/test_integration_routing.py b/tests/integration/test_integration_routing.py
new file mode 100644
index 0000000..38f4b03
--- /dev/null
+++ b/tests/integration/test_integration_routing.py
@@ -0,0 +1,128 @@
+from datetime import datetime
+
+import pytest
+
+from public_transit_client.client import (
+ PublicTransitClient,
+ PublicTransitClientException,
+)
+from public_transit_client.model import Connection, Coordinate, StopConnection, TimeType
+
+HOST = "http://localhost:8080"
+
+
+@pytest.fixture(scope="module")
+def client():
+ return PublicTransitClient(host=HOST)
+
+
+@pytest.mark.integration
+def test_get_connections(client):
+ from_stop = "NANAA"
+ to_stop = "BULLFROG"
+ departure_time = datetime(2008, 6, 1)
+ connections = client.get_connections(
+ source=from_stop,
+ target=to_stop,
+ time=departure_time,
+ time_type=TimeType.DEPARTURE,
+ )
+
+ assert isinstance(connections, list)
+ assert len(connections) > 0
+ assert all(isinstance(connection, Connection) for connection in connections)
+ assert connections[0].from_stop.id == from_stop
+ assert connections[0].to_stop.id == to_stop
+
+
+@pytest.mark.integration
+def test_get_connections_invalid_stop(client):
+ from_stop = "INVALID_STOP"
+ to_stop = "BULLFROG"
+ departure_time = datetime(2008, 6, 1)
+
+ with pytest.raises(PublicTransitClientException) as exc_info:
+ client.get_connections(
+ source=from_stop,
+ target=to_stop,
+ time=departure_time,
+ time_type=TimeType.DEPARTURE,
+ )
+
+ assert exc_info.value.api_error.status == 404
+ assert "'INVALID_STOP' was not found" in exc_info.value.api_error.message
+
+
+@pytest.mark.integration
+def test_get_connections_negative_walking_duration(client):
+ from_stop = "NANAA"
+ to_stop = "BULLFROG"
+ departure_time = datetime(2008, 6, 1)
+
+ with pytest.raises(PublicTransitClientException) as exc_info:
+ client.get_connections(
+ source=from_stop,
+ target=to_stop,
+ time=departure_time,
+ time_type=TimeType.DEPARTURE,
+ max_walking_duration=-10,
+ )
+
+ assert exc_info.value.api_error.status == 400
+ assert (
+ "Max walking duration must be greater than or equal to 0"
+ in exc_info.value.api_error.message
+ )
+
+
+@pytest.mark.integration
+def test_get_isolines(client):
+ from_stop = "NANAA"
+ departure_time = datetime(2008, 6, 1)
+ isolines = client.get_isolines(
+ source=from_stop,
+ time=departure_time,
+ time_type=TimeType.DEPARTURE,
+ max_walking_duration=10,
+ max_transfer_number=1,
+ return_connections=True,
+ )
+
+ assert isinstance(isolines, list)
+ assert len(isolines) > 0
+ assert all(
+ isinstance(stop_connection, StopConnection) for stop_connection in isolines
+ )
+
+
+@pytest.mark.integration
+def test_get_isolines_invalid_stop(client):
+ from_stop = "INVALID_STOP"
+ departure_time = datetime(2008, 6, 1)
+
+ with pytest.raises(PublicTransitClientException) as exc_info:
+ client.get_isolines(
+ source=from_stop,
+ time=departure_time,
+ time_type=TimeType.DEPARTURE,
+ max_walking_duration=10,
+ max_transfer_number=1,
+ return_connections=True,
+ )
+
+ assert exc_info.value.api_error.status == 404
+ assert "'INVALID_STOP' was not found" in exc_info.value.api_error.message
+
+
+@pytest.mark.integration
+def test_nearest_stops_invalid_coordinate(client):
+ invalid_coordinate = Coordinate(latitude=999.0, longitude=999.0)
+
+ with pytest.raises(PublicTransitClientException) as exc_info:
+ client.nearest_stops(coordinate=invalid_coordinate, limit=5, max_distance=500)
+
+ assert exc_info.value.api_error.status == 400
+ assert (
+ "Latitude must be between -90 and 90 degrees"
+ in exc_info.value.api_error.message
+ )
diff --git a/tests/integration/test_integration_schedule.py b/tests/integration/test_integration_schedule.py
new file mode 100644
index 0000000..25d057b
--- /dev/null
+++ b/tests/integration/test_integration_schedule.py
@@ -0,0 +1,66 @@
+import pytest
+
+from public_transit_client.client import (
+ PublicTransitClient,
+ PublicTransitClientException,
+)
+from public_transit_client.model import Coordinate, SearchType, Stop
+
+HOST = "http://localhost:8080"
+
+
+@pytest.fixture(scope="module")
+def client():
+ return PublicTransitClient(host=HOST)
+
+
+@pytest.mark.integration
+def test_search_stops(client):
+ stops = client.search_stops(query="e", limit=5, search_type=SearchType.CONTAINS)
+
+ assert isinstance(stops, list)
+ assert len(stops) > 0
+ assert all(isinstance(stop, Stop) for stop in stops)
+
+
+@pytest.mark.integration
+def test_nearest_stops(client):
+ coordinate = Coordinate(latitude=36, longitude=-116)
+ stops = client.nearest_stops(coordinate=coordinate, limit=3, max_distance=100000)
+
+ assert isinstance(stops, list)
+ assert len(stops) > 0
+ assert all(stop.distance >= 0 for stop in stops)
+ assert all(isinstance(stop.stop, Stop) for stop in stops)
+
+
+@pytest.mark.integration
+def test_nearest_stops_invalid_limit(client):
+ coordinate = Coordinate(latitude=36, longitude=-116)
+
+ with pytest.raises(PublicTransitClientException) as exc_info:
+ client.nearest_stops(coordinate=coordinate, limit=-1, max_distance=100000)
+
+ assert exc_info.value.api_error.status == 400
+ assert "Limit must be greater than 0" in exc_info.value.api_error.message
+
+
+@pytest.mark.integration
+def test_get_stop(client):
+ stop_id = "NANAA"
+ stop = client.get_stop(stop_id=stop_id)
+
+ assert stop is not None
+ assert isinstance(stop, Stop)
+ assert stop.id == stop_id
+
+
+@pytest.mark.integration
+def test_get_stop_invalid_stop_id(client):
+ invalid_stop_id = "INVALID_STOP_ID"
+
+ with pytest.raises(PublicTransitClientException) as exc_info:
+ client.get_stop(stop_id=invalid_stop_id)
+
+ assert exc_info.value.api_error.status == 404
+ assert "'INVALID_STOP_ID' was not found" in exc_info.value.api_error.message
diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py
new file mode 100644
index 0000000..ee999fe
--- /dev/null
+++ b/tests/unit/test_client.py
@@ -0,0 +1,237 @@
+from datetime import datetime
+from unittest.mock import Mock, patch
+
+import pytest
+from requests import Response
+
+from public_transit_client.client import (
+ PublicTransitClient,
+ PublicTransitClientException,
+)
+from public_transit_client.model import (
+ Connection,
+ Coordinate,
+ Departure,
+ SearchType,
+ Stop,
+ StopConnection,
+)
+
+
+@pytest.fixture(scope="module")
+def client():
+ return PublicTransitClient(host="http://fakehost")
+
+
+@pytest.mark.unit
+def mock_response(status=200, json_data=None):
+ mock_resp = Mock(spec=Response)
+ mock_resp.status_code = status
+ mock_resp.json.return_value = json_data
+ return mock_resp
+
+
+@pytest.mark.unit
+def test_send_get_request_success(client):
+ with patch("requests.get") as mock_get:
+ mock_get.return_value = mock_response(status=200, json_data={"key": "value"})
+
+ response = client._send_get_request("/fake_endpoint")
+
+ assert response == {"key": "value"}
+ mock_get.assert_called_once_with("http://fakehost/fake_endpoint", params=None)
+
+
+@pytest.mark.unit
+def test_send_get_request_error(client):
+ with patch("requests.get") as mock_get:
+ mock_get.return_value = mock_response(
+ status=404,
+ json_data={
+ "timestamp": "2024-08-18T17:34:03.820509687",
+ "status": 404,
+ "error": "Not Found",
+ "path": "/schedule/stops/NOT_EXISTING",
+ "message": "The requested stop with ID 'NOT_EXISTING' was not found.",
+ },
+ )
+
+ with pytest.raises(PublicTransitClientException) as exc_info:
+ client._send_get_request("/fake_endpoint")
+
+ assert (
+ "API Error 404: The requested stop with ID 'NOT_EXISTING' was not found."
+ in str(exc_info.value)
+ )
+ mock_get.assert_called_once_with("http://fakehost/fake_endpoint", params=None)
+
+
+@pytest.mark.unit
+def test_search_stops(client):
+ with patch("requests.get") as mock_get:
+ mock_get.return_value = mock_response(
+ status=200,
+ json_data=[
+ {
+ "id": "1",
+ "name": "Stop 1",
+ "coordinates": {"latitude": 36.0, "longitude": -116.0},
+ }
+ ],
+ )
+
+ stops = client.search_stops(query="e", limit=5, search_type=SearchType.CONTAINS)
+
+ assert isinstance(stops, list)
+ assert len(stops) == 1
+ assert stops[0].id == "1"
+ assert isinstance(stops[0], Stop)
+
+
+@pytest.mark.unit
+def test_nearest_stops(client):
+ with patch("requests.get") as mock_get:
+ mock_get.return_value = mock_response(
+ status=200,
+ json_data=[
+ {
+ "stop": {
+ "id": "1",
+ "name": "Stop 1",
+ "coordinates": {"latitude": 36.0, "longitude": -116.0},
+ },
+ "distance": 500,
+ }
+ ],
+ )
+
+ coordinate = Coordinate(latitude=36, longitude=-116)
+ stops = client.nearest_stops(
+ coordinate=coordinate, limit=3, max_distance=100000
+ )
+
+ assert isinstance(stops, list)
+ assert len(stops) == 1
+ assert stops[0].distance == 500
+ assert isinstance(stops[0].stop, Stop)
+
+
+@pytest.mark.unit
+def test_get_stop(client):
+ with patch("requests.get") as mock_get:
+ mock_get.return_value = mock_response(
+ status=200,
+ json_data={
+ "id": "NANAA",
+ "name": "Stop NANAA",
+ "coordinates": {"latitude": 36.0, "longitude": -116.0},
+ },
+ )
+
+ stop = client.get_stop(stop_id="NANAA")
+
+ assert stop is not None
+ assert stop.id == "NANAA"
+ assert isinstance(stop, Stop)
+
+
+@pytest.mark.unit
+def test_get_next_departures(client):
+ with patch("requests.get") as mock_get:
+ mock_get.return_value = mock_response(
+ status=200,
+ json_data=[
+ {
+ "stopTime": {
+ "stop": {
+ "id": "1",
+ "name": "Stop 1",
+ "coordinates": {"latitude": 36.0, "longitude": -116.0},
+ },
+ "arrivalTime": "2024-08-18T17:34:03",
+ "departureTime": "2024-08-18T17:45:00",
+ },
+ "trip": {
+ "headSign": "Head Sign",
+ "route": {
+ "id": "1",
+ "name": "Route 1",
+ "shortName": "R1",
+ "transportMode": "BUS",
+ },
+ "stopTimes": [],
+ },
+ }
+ ],
+ )
+
+ departures = client.get_next_departures(
+ stop="NANAA", departure=datetime(2024, 8, 18, 17, 0)
+ )
+
+ assert isinstance(departures, list)
+ assert len(departures) == 1
+ assert isinstance(departures[0], Departure)
+
+
+@pytest.mark.unit
+def test_get_connections(client):
+ with patch("requests.get") as mock_get:
+ mock_get.return_value = mock_response(
+ status=200,
+ json_data=[
+ {
+ "legs": [
+ {
+ "from": {"latitude": 36.0, "longitude": -116.0},
+ "to": {"latitude": 37.0, "longitude": -117.0},
+ "type": "ROUTE",
+ "departureTime": "2024-08-18T17:34:03",
+ "arrivalTime": "2024-08-18T18:00:00",
+ }
+ ]
+ }
+ ],
+ )
+
+ connections = client.get_connections(
+ source="NANAA", target="BULLFROG", time=datetime(2024, 8, 18, 17, 0)
+ )
+
+ assert isinstance(connections, list)
+ assert len(connections) == 1
+ assert isinstance(connections[0], Connection)
+
+
+@pytest.mark.unit
+def test_get_isolines(client):
+ with patch("requests.get") as mock_get:
+ mock_get.return_value = mock_response(
+ status=200,
+ json_data=[
+ {
+ "stop": {
+ "id": "1",
+ "name": "Stop 1",
+ "coordinates": {"latitude": 36.0, "longitude": -116.0},
+ },
+ "connectingLeg": {
+ "from": {"latitude": 36.0, "longitude": -116.0},
+ "to": {"latitude": 37.0, "longitude": -117.0},
+ "type": "ROUTE",
+ "departureTime": "2024-08-18T17:34:03",
+ "arrivalTime": "2024-08-18T18:00:00",
+ },
+ }
+ ],
+ )
+
+ isolines = client.get_isolines(
+ source="NANAA",
+ time=datetime(2024, 8, 18, 17, 0),
+ return_connections=True,
+ )
+
+ assert isinstance(isolines, list)
+ assert len(isolines) == 1
+ assert isinstance(isolines[0], StopConnection)