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)