diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4f3c01d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.venv +data +.env +tests +__pycache__ +*.py[cod] diff --git a/.env.example b/.env.example index ff63920..ec249f4 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,8 @@ -GARMIN_USERNAME= -GARMIN_PASSWORD= +WITHINGS_CLIENT_ID= +WITHINGS_CLIENT_SECRET= +WITHINGS_REDIRECT_URI= +SYNC_INTERVAL=1h +TZ=UTC +LOG_LEVEL=INFO +PUID=1000 +PGID=1000 diff --git a/.github/workflows/build-publish.yml b/.github/workflows/build-publish.yml deleted file mode 100644 index b80a5c2..0000000 --- a/.github/workflows/build-publish.yml +++ /dev/null @@ -1,97 +0,0 @@ ---- - -name: Build and Publish Release to PyPI & GitHub - -on: push - -jobs: - build: - name: Build distribution 📦 - runs-on: ubuntu-latest - steps: - - name: Checkout repository 🛒 - uses: actions/checkout@v4 - - name: Update version in pyproject.toml ⬆️ - if: startsWith(github.ref, 'refs/tags/') - run: | - VERSION=${GITHUB_REF#refs/tags/} - sed -i "s/version = \"1.0.0.dev1\"/version = \"$VERSION\"/" pyproject.toml - - name: Set up Python 3.12 🐍 - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Install and configure Poetry 📜 - uses: snok/install-poetry@v1 - with: - virtualenvs-create: false - - name: Build & Package project 👷 - run: poetry build - - name: Store the distribution packages - uses: actions/upload-artifact@v4 - with: - name: python-package-distributions - path: dist/ - - publish-to-pypi: - name: Publish Python 🐍 distribution 📦 to PyPI - if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes - needs: - - build - runs-on: ubuntu-latest - environment: - name: pypi - url: https://pypi.org/p/withings-sync - permissions: - id-token: write # IMPORTANT: mandatory for trusted publishing - steps: - - name: Download all the dists - uses: actions/download-artifact@v4 - with: - name: python-package-distributions - path: dist/ - - name: Publish distribution 📦 to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} # LEAVING THIS HERE UNTIL TRUSTED PUBLISHING IS IN PLACE - - publish-to-github-release: - name: Upload the Python 🐍 distribution to GitHub Release - if: startsWith(github.ref, 'refs/tags/') # only upload to GitHub Release on tag pushes - needs: - - build - runs-on: ubuntu-latest - steps: - - name: Download all the dists - uses: actions/download-artifact@v4 - with: - name: python-package-distributions - path: dist/ - - name: Release - uses: fnkr/github-action-ghr@v1 - env: - GHR_PATH: dist/ - GHR_REPLACE: true - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - publish-to-testpypi: - name: Publish Python 🐍 distribution 📦 to TestPyPI - needs: - - build - runs-on: ubuntu-latest - environment: - name: testpypi - url: https://test.pypi.org/p/withings-sync - permissions: - id-token: write # IMPORTANT: mandatory for trusted publishing - steps: - - name: Download all the dists - uses: actions/download-artifact@v4 - with: - name: python-package-distributions - path: dist/ - - name: Publish distribution 📦 to TestPyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - repository-url: https://test.pypi.org/legacy/ - skip-existing: true diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 5badd11..752e62f 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -25,12 +25,6 @@ jobs: - name: Checkout repository 🛒 uses: actions/checkout@v6 - - name: Update version in pyproject.toml ⬆️ - if: startsWith(github.ref, 'refs/tags/') - run: | - VERSION=${GITHUB_REF#refs/tags/} - sed -i "s/version = \"1.0.0.dev1\"/version = \"$VERSION\"/" pyproject.toml - - name: Set up QEMU 👷 uses: docker/setup-qemu-action@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d1dd342 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Publish Python package + +on: + push: + tags: + - "v*" + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Set version from tag + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + python - <<'PY' + import os + import re + from pathlib import Path + + version = os.environ["RELEASE_TAG"].removeprefix("v") + if not re.fullmatch(r"\d+\.\d+\.\d+(?:[a-zA-Z0-9.-]+)?", version): + raise SystemExit("Release tag must contain a valid package version.") + path = Path("pyproject.toml") + content, count = re.subn( + r'(?m)^version = "[^"]+"$', + f'version = "{version}"', + path.read_text(), + count=1, + ) + if count != 1: + raise SystemExit("Could not update the project version.") + path.write_text(content) + PY + - run: python -m pip install build + - run: python -m build + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..839b5c4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,17 @@ +name: Test and build + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - run: pip install -e ".[test]" + - run: pytest + - run: docker compose build diff --git a/.gitignore b/.gitignore index 79900a9..59d9eb0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ *.egg-info/ dist/ .idea -*.json .env +data/ +.venv/ diff --git a/Dockerfile b/Dockerfile index 3b459ab..44e4ebe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,41 +1,22 @@ -FROM python:3.12-alpine +FROM python:3.12-slim -ARG PROJECT="withings-sync" -ARG PACKAGE="withings_sync" -ARG USER_UID=1000 -ARG USER_GID=$USER_UID +ARG PUID=1000 +ARG PGID=1000 -RUN apk --no-cache add supercronic +RUN apt-get update && apt-get install -y --no-install-recommends tzdata && \ + rm -rf /var/lib/apt/lists/* && \ + groupadd --gid "${PGID}" app && \ + useradd --uid "${PUID}" --gid app --create-home --shell /usr/sbin/nologin app && \ + mkdir -p /app /data && chown app:app /app /data -RUN if getent passwd ${USER_UID} >/dev/null; then \ - deluser $(getent passwd ${USER_UID} | cut -d: -f1); fi && \ - if getent group ${USER_GID} >/dev/null; then \ - delgroup $(getent group ${USER_GID} | cut -d: -f1); fi -RUN addgroup --system --gid ${USER_GID} ${PROJECT} && \ - adduser --system --disabled-password --home /home/${PROJECT} \ - --uid ${USER_UID} --ingroup ${PROJECT} ${PROJECT} +WORKDIR /app +COPY pyproject.toml README.md ./ +COPY withings_sync ./withings_sync +RUN pip install --no-cache-dir . -ENV PROJECT_DIR="/home/${PROJECT}" +ENV DATA_DIR=/data \ + PYTHONUNBUFFERED=1 -USER $PROJECT -WORKDIR $PROJECT_DIR - -ENV PATH="${PROJECT_DIR}/.poetry/bin:${PATH}" \ - PATH="${PROJECT_DIR}/.local/bin:${PATH}" \ - PIP_ROOT_USER_ACTION=ignore \ - PIP_DISABLE_PIP_VERSION_CHECK=on \ - POETRY_HOME="${PROJECT_DIR}/.poetry" \ - POETRY_NO_INTERACTION=1 \ - POETRY_VIRTUALENVS_IN_PROJECT=1 \ - POETRY_VIRTUALENVS_CREATE=1 \ - POETRY_CACHE_DIR="/tmp/poetry_cache" - -RUN pip install poetry - -COPY --chown=$PROJECT:$PROJECT pyproject.toml poetry.lock README.md $PROJECT_DIR/ -RUN poetry install --without dev --no-root && rm -rf $POETRY_CACHE_DIR - -COPY --chown=$PROJECT:$PROJECT $PACKAGE ./$PACKAGE/ -RUN poetry install --without dev - -ENTRYPOINT ["poetry", "run", "withings-sync"] +USER app +ENTRYPOINT ["withings-sync"] +CMD [] diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 695153d..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include withings_sync/config/*.json \ No newline at end of file diff --git a/README.md b/README.md index c20ec58..5c2b750 100644 --- a/README.md +++ b/README.md @@ -1,634 +1,58 @@ -> [!CAUTION] -> This Release introduces breaking changes that users need to be aware of before upgrading or using it. -> These changes were made to enhance security and compatibility but may require modifications to your existing setup. -> -> - The container now runs without root privileges. -> - Dependencies, virtual envs, packaging is now done by Poetry. -> - This fork requires a recent version of Python, currently capped at >= python 3.12. -> -> Make sure to go over the updated readme and test these new changes thoroughly for your environment. -> Chances are quite high you will have to make changes to make this work again. +# Withings → Garmin Connect -# withings-sync +Sync Withings scale weight and body composition (fat %, hydration %, muscle, bone, and BMI) to Garmin Connect. Runs as one non-root Docker Compose service; credentials and sync state live in `./data`. -A tool for synchronisation of the Withings API to: +## Ubuntu quickstart -- Garmin Connect -- Trainer Road -- raw JSON output +1. Install Docker Engine and the Docker Compose plugin using Docker's [Ubuntu installation guide](https://docs.docker.com/engine/install/ubuntu/). +2. Create a Withings developer application at [developer.withings.com](https://developer.withings.com/). Set its callback/redirect URL to the exact URL you will enter as `WITHINGS_REDIRECT_URI`, and enable the `user.metrics` scope. You need your app's client ID and client secret; do not use credentials from another app. +3. Prepare configuration and the writable data directory: -## 1. Installation Instructions -### 1.1 Installation of withings-sync with pip -> This method installs the package asuming you have a working python and pip installation. It relies on an external scheduler (e.g., cron on the host operating system) to manage job execution. -
- Expand to show installation steps. + ```sh + cp .env.example .env + sed -i "s/^PUID=.*/PUID=$(id -u)/; s/^PGID=.*/PGID=$(id -g)/" .env + chmod 600 .env + mkdir -p data + sudo chown "$(id -u):$(id -g)" data + ``` - 1. installing the package: + Set `WITHINGS_CLIENT_ID`, `WITHINGS_CLIENT_SECRET`, `WITHINGS_REDIRECT_URI` in `.env`. The default scheduler interval is `1h`; `SYNC_INTERVAL` also accepts seconds, minutes, and days (for example `30m`). If you change `PUID`/`PGID`, make `data/` writable by that UID/GID and rebuild. +4. Build the image and authorize Withings: - ```bash - $ pip install withings-sync - ``` + ```sh + docker compose build + docker compose run --rm withings-sync auth withings + ``` - 2. obtaining Withings authorization: + Open the printed URL, approve access, then paste the complete redirect URL (or just its `code`) into the prompt. The code is exchanged immediately. Withings codes are short-lived, so paste the redirect as soon as authorization completes. +5. Authenticate Garmin. The command prompts for your email/password and, when required, an MFA code. Passwords are used for this login only and are not saved: - When running for a very first time, you need to obtain Withings authorization: - ```bash - $ withings-sync - 2024-12-01 01:29:02,601 - withings - ERROR - Can\'t read config file /home/youruser/.withings_user.json - 2024-12-01 01:29:02,602 - withings - WARNING - User interaction needed to get Authentification Code from Withings! - 2024-12-01 01:29:02,603 - withings - WARNING - - 2024-12-01 01:29:02,603 - withings - WARNING - Open the following URL in your web browser and copy back the token. You will have *30 seconds* before the token expires. HURRY UP! - 2024-12-01 01:29:02,603 - withings - WARNING - (This is one-time activity) - 2024-12-01 01:29:02,604 - withings - WARNING - - 2024-12-01 01:29:02,604 - withings - INFO - https://account.withings.com/oauth2_user/authorize2?response_type=code&client_id=183e03e1f363110b3551f96765c98c10e8f1aa647a37067a1cb64bbbaf491626&state=OK&scope=user.metrics&redirect_uri=https://jaroslawhartman.github.io/withings-sync/contrib/withings.html& - 2024-12-01 01:29:02,604 - withings - INFO - + ```sh + docker compose run --rm withings-sync auth garmin + ``` - Token : + If you already have an authenticator code ready, it can be supplied with `--mfa-code CODE`; otherwise, enter it at the prompt. The saved Garmin token is reused and refreshed without asking for your password. +6. Start the hourly sync service and follow its logs: - 2024-12-01 01:31:07,832 - withings - INFO - Get Access Token - 2024-12-01 01:31:08,313 - withings - INFO - Refresh Access Token - 2024-12-01 01:31:08,771 - root - INFO - Fetching measurements from 2024-12-01 00:00 to 2024-12-01 23:59 - 2024-12-01 01:31:09,406 - withings - INFO - Get Measurements - 2024-12-01 01:31:09,856 - root - ERROR - No measurements to upload for date or period specified - ``` - You need to visit the URL listed by the script and then - copy Authentification Code back to the prompt. + ```sh + docker compose up -d + docker compose logs -f + ``` - This is one-time activity and it will not be needed to repeat. +## Commands - 3. running the application: - - Subsequent runs will use the saved access tokens in `~/.withings_user.json` - ```bash - $ withings-sync - 2024-12-01 01:37:41,500 - withings - INFO - Refresh Access Token - 2024-12-01 01:37:41,954 - root - INFO - Fetching measurements from 2024-12-01 00:00 to 2024-12-01 23:59 - 2024-12-01 01:37:42,563 - withings - INFO - Get Measurements - 2024-12-01 01:37:43,069 - root - ERROR - No measurements to upload for date or period specified - ``` -
- -### 1.2 Installation of withings-sync with docker compose (not using cron) -> This method follows a default approach of utilizing a single container to run one job at a time, then exiting upon completion. It relies on an external scheduler (e.g., cron on the host operating system) to manage job execution. -
- Expand to show installation steps. - - 1. create the following file/directory structure: - - ```bash - . # STACK_PATH - ./.env # .env file containing your variables - ./docker-compose.yml # docker-compose file - ./config/withings-sync/ # config directory for withings-sync (will be created automatically) - ``` - - 2. contents of an example `.env` file: - - ```bash - TZ=Europe/Kyiv - STACK_PATH=/home/your_user/your_stack_name - GARMIN_USERNAME="your.name@domain.ext" - GARMIN_PASSWORD="YourPasswordHere" - ``` - - 3. contents of an example `docker-compose.yml` file (simplified with --config-folder): - - ```yaml - services: - withings-sync: - image: ghcr.io/jaroslawhartman/withings-sync:latest - container_name: withings-sync - stdin_open: true # docker run -i - tty: true # docker run -t - environment: - - TZ=${TZ:?err} - - GARMIN_USERNAME=${GARMIN_USERNAME:?err} - - GARMIN_PASSWORD=${GARMIN_PASSWORD:?err} - volumes: - - /etc/localtime:/etc/localtime:ro - - ${STACK_PATH:?err}/config/withings-sync:/config - command: ["--config-folder", "/config"] - restart: unless-stopped - ``` - - **Note**: Using `--config-folder` simplifies the setup by: - - Storing both Withings and Garmin session files in one directory - - No need to mount individual files - - Easier backup and migration of configuration - - 4. obtaining Withings authorization: - - ```bash - $ docker compose pull - [+] Pulling 13/13 - ✔ withings-sync Pulled 56.0s - ✔ cb8611c9fe51 Pull complete 4.2s - ✔ 52e189a1282f Pull complete 6.4s - ✔ 95e68cb0cebc Pull complete 19.0s - ✔ c3ba8bc06a4d Pull complete 19.3s - ✔ fc2b9c85008a Pull complete 21.6s - ✔ 0376fca350d9 Pull complete 21.7s - ✔ 4f4fb700ef54 Pull complete 21.9s - ✔ c749d618f51d Pull complete 43.0s - ✔ 86d00088bd8d Pull complete 43.2s - ✔ 98dec7b84387 Pull complete 52.8s - ✔ 8825309bd8c2 Pull complete 53.1s - ✔ 7747652082d6 Pull complete 53.3s - ``` - - First start to ensure the script can start successfully: - - ```bash - $ docker compose run -it --remove-orphans --entrypoint "poetry run withings-sync --config-folder /config" withings-sync - [+] Creating 1/1 - ✔ Network stack_default Created 0.5s - 2024-12-01 01:29:02,601 - withings - ERROR - Can\'t read config file /home/youruser/.withings_user.json - 2024-12-01 01:29:02,602 - withings - WARNING - User interaction needed to get Authentification Code from Withings! - 2024-12-01 01:29:02,603 - withings - WARNING - - 2024-12-01 01:29:02,603 - withings - WARNING - Open the following URL in your web browser and copy back the token. You will have *30 seconds* before the token expires. HURRY UP! - 2024-12-01 01:29:02,603 - withings - WARNING - (This is one-time activity) - 2024-12-01 01:29:02,604 - withings - WARNING - - 2024-12-01 01:29:02,604 - withings - INFO - https://account.withings.com/oauth2_user/authorize2?response_type=code&client_id=183e03e1f363110b3551f96765c98c10e8f1aa647a37067a1cb64bbbaf491626&state=OK&scope=user.metrics&redirect_uri=https://jaroslawhartman.github.io/withings-sync/contrib/withings.html& - 2024-12-01 01:29:02,604 - withings - INFO - - - Token : - - 2024-12-01 01:31:07,832 - withings - INFO - Get Access Token - 2024-12-01 01:31:08,313 - withings - INFO - Refresh Access Token - 2024-12-01 01:31:08,771 - root - INFO - Fetching measurements from 2024-12-01 00:00 to 2024-12-01 23:59 - 2024-12-01 01:31:09,406 - withings - INFO - Get Measurements - 2024-12-01 01:31:09,856 - root - ERROR - No measurements to upload for date or period specified - ``` - You need to visit the URL listed by the script and then - copy Authentification Code back to the prompt. - - This is one-time activity and it will not be needed to repeat. - - 5. running the container: - - Subsequent runs will use the saved access tokens in `~/.withings_user.json` - - ```bash - $ docker compose run -it --remove-orphans withings-sync 0.5s - [+] Creating 1/1 - ✔ Container stack-withings-sync-run-3f24bc7ec7f9 Removed - 2024-12-01 01:37:41,500 - withings - INFO - Refresh Access Token - 2024-12-01 01:37:41,954 - root - INFO - Fetching measurements from 2024-12-01 00:00 to 2024-12-01 23:59 - 2024-12-01 01:37:42,563 - withings - INFO - Get Measurements - 2024-12-01 01:37:43,069 - root - ERROR - No measurements to upload for date or period specified - ``` - - 6. updating to a newer version: - - ```bash - $ docker compose pull - $ docker compose run -it --remove-orphans withings-sync - ``` - - This will ensure that both Withings and Garmin session files persist across container restarts in the `/config` directory. -
- -### 1.3 Installation of withings-sync with docker compose (using supercronic) -> This method leverages the included supercronic package for scheduling jobs directly within the container. This eliminates the need for an external scheduler, allowing the container to manage job execution independently. -
- Expand to show installation steps. - - 1. create the following file/directory structure: - - > Make sure to create the directories (`mkdir`) & files (`touch`) upfront or docker will create them as root. - ```bash - . # STACK_PATH - ./.env # .env file containing your variables - ./docker-compose.yml # docker-compose file - ./config/withings-sync/ # config directory for withings-sync (will be created automatically) - ./config/withings-sync/entrypoint.sh # entrypoint.sh file containing your cmd & arguments - ``` - - 2. contents of an example `.env` file: - - ```bash - TZ=Europe/Kyiv - STACK_PATH=/home/youruser/withings-sync - GARMIN_USERNAME="your.name@domain.ext" - GARMIN_PASSWORD="YourPasswordHere" - ``` - - 3. contents of an example `entrypoint.sh` file (updated for --config-folder): - - ```bash - #!/bin/sh - echo "$(( $RANDOM % 59 +0 )) */3 * * * * * poetry run withings-sync --config-folder /config --features BLOOD_PRESSURE" > /home/withings-sync/cronjob - supercronic -debug -passthrough-logs /home/withings-sync/cronjob - ``` - - 4. contents of an example `docker-compose.yml` file (simplified with --config-folder): - - ```yaml - services: - withings-sync: - image: ghcr.io/jaroslawhartman/withings-sync:latest - container_name: withings-sync - stdin_open: true # docker run -i - tty: true # docker run -t - environment: - - TZ=${TZ:?err} - - GARMIN_USERNAME=${GARMIN_USERNAME:?err} - - GARMIN_PASSWORD=${GARMIN_PASSWORD:?err} - volumes: - - /etc/localtime:/etc/localtime:ro - - ${STACK_PATH:?err}/config/withings-sync:/config - - ${STACK_PATH:?err}/config/withings-sync/entrypoint.sh:/home/withings-sync/entrypoint.sh - entrypoint: "sh /home/withings-sync/entrypoint.sh" - restart: unless-stopped - ``` - - **Note**: Using `--config-folder` simplifies the setup by storing both Withings and Garmin session files in the `/config` directory, eliminating the need for multiple volume mounts. - - 5. obtaining Withings authorization: - - ```bash - $ docker compose pull - [+] Pulling 13/13 - ✔ withings-sync Pulled 56.0s - ✔ cb8611c9fe51 Pull complete 4.2s - ✔ 52e189a1282f Pull complete 6.4s - ✔ 95e68cb0cebc Pull complete 19.0s - ✔ c3ba8bc06a4d Pull complete 19.3s - ✔ fc2b9c85008a Pull complete 21.6s - ✔ 0376fca350d9 Pull complete 21.7s - ✔ 4f4fb700ef54 Pull complete 21.9s - ✔ c749d618f51d Pull complete 43.0s - ✔ 86d00088bd8d Pull complete 43.2s - ✔ 98dec7b84387 Pull complete 52.8s - ✔ 8825309bd8c2 Pull complete 53.1s - ✔ 7747652082d6 Pull complete 53.3s - ``` - - First start to ensure the container can start successfully: - - ```bash - $ docker compose run -it --remove-orphans --entrypoint "poetry run withings-sync --config-folder /config" withings-sync - [+] Creating 1/1 - ✔ Network stack_default Created 0.5s - 2024-12-01 01:29:02,601 - withings - ERROR - Can\'t read config file /home/youruser/.withings_user.json - 2024-12-01 01:29:02,602 - withings - WARNING - User interaction needed to get Authentification Code from Withings! - 2024-12-01 01:29:02,603 - withings - WARNING - - 2024-12-01 01:29:02,603 - withings - WARNING - Open the following URL in your web browser and copy back the token. You will have *30 seconds* before the token expires. HURRY UP! - 2024-12-01 01:29:02,603 - withings - WARNING - (This is one-time activity) - 2024-12-01 01:29:02,604 - withings - WARNING - - 2024-12-01 01:29:02,604 - withings - INFO - https://account.withings.com/oauth2_user/authorize2?response_type=code&client_id=183e03e1f363110b3551f96765c98c10e8f1aa647a37067a1cb64bbbaf491626&state=OK&scope=user.metrics&redirect_uri=https://jaroslawhartman.github.io/withings-sync/contrib/withings.html& - 2024-12-01 01:29:02,604 - withings - INFO - - - Token : - - 2024-12-01 01:31:07,832 - withings - INFO - Get Access Token - 2024-12-01 01:31:08,313 - withings - INFO - Refresh Access Token - 2024-12-01 01:31:08,771 - root - INFO - Fetching measurements from 2024-12-01 00:00 to 2024-12-01 23:59 - 2024-12-01 01:31:09,406 - withings - INFO - Get Measurements - 2024-12-01 01:31:09,856 - root - ERROR - No measurements to upload for date or period specified - ``` - - 6. running the container: - - And for subsequent runs we start docker compose and let the container run in the background. - Subsequent runs will use the saved access tokens in `~/.withings_user.json` - - ```bash - $ docker compose up -d --remove-orphans - [+] Running 1/1 - ✔ Container withings-sync Started 1.5s - ``` - - 7. logging: - - ```bash - $ docker compose logs withings-sync - withings-sync | WARN[2024-12-24T09:23:55+01:00] process reaping disabled, not pid 1 - withings-sync | INFO[2024-12-24T09:23:55+01:00] read crontab: /home/withings-sync/cronjob - withings-sync | DEBU[2024-12-24T09:23:55+01:00] try parse (7 fields): '53 */3 * * * poetry run' - withings-sync | DEBU[2024-12-24T09:23:55+01:00] failed to parse (7 fields): '53 */3 * * * poetry run': failed: syntax error in day-of-week field: 'poetry' - withings-sync | DEBU[2024-12-24T09:23:55+01:00] try parse (6 fields): '53 */3 * * * poetry' - withings-sync | DEBU[2024-12-24T09:23:55+01:00] failed to parse (6 fields): '53 */3 * * * poetry': failed: syntax error in year field: 'poetry' - withings-sync | DEBU[2024-12-24T09:23:55+01:00] try parse (5 fields): '53 */3 * * *' - withings-sync | DEBU[2024-12-24T09:23:55+01:00] job will run next at 2024-12-24 09:53:00 +0100 CET job.command="poetry run withings-sync --features BLOOD_PRESSURE" job.position=0 job.schedule="53 */3 * * *" - withings-sync | INFO[2024-12-24T09:53:00+01:00] starting iteration=0 job.command="poetry run withings-sync --features BLOOD_PRESSURE" job.position=0 job.schedule="53 */3 * * *" - withings-sync | 2024-12-24 09:53:29,177 - withings - INFO - Refresh Access Token - withings-sync | 2024-12-24 09:53:29,380 - root - INFO - Fetching measurements from 2024-12-22 18:52 to 2024-12-24 23:59 - withings-sync | 2024-12-24 09:53:29,662 - withings - INFO - Get Measurements - withings-sync | 2024-12-24 09:53:29,866 - root - INFO - 2024-12-24 08:08:57 This Withings metric contains no weight data or blood pressure. Not syncing... - withings-sync | 2024-12-24 09:53:29,868 - root - INFO - 2024-12-24 08:08:57 This Withings metric contains no weight data or blood pressure. Not syncing... - withings-sync | 2024-12-24 09:53:29,870 - root - INFO - 2024-12-24 08:08:57 This Withings metric contains no weight data or blood pressure. Not syncing... - withings-sync | 2024-12-24 09:53:29,878 - root - INFO - No blood pressure data to sync for FIT file - withings-sync | 2024-12-24 09:53:29,880 - root - INFO - No TrainerRoad username or a new measurement - skipping sync - withings-sync | 2024-12-24 09:53:33,665 - root - INFO - Fit file with weight information uploaded to Garmin Connect - withings-sync | 2024-12-24 09:53:33,666 - withings - INFO - Saving Last Sync - withings-sync | INFO[2024-12-24T09:53:34+01:00] job succeeded iteration=0 job.command="poetry run withings-sync --features BLOOD_PRESSURE" job.position=0 job.schedule="53 */3 * * *" - withings-sync | DEBU[2024-12-24T09:53:34+01:00] job will run next at 2024-12-24 12:53:00 +0100 CET job.command="poetry run withings-sync --features BLOOD_PRESSURE" job.position=0 job.schedule="53 */3 * * *" - ``` - - 8. updating to a newer version: - - ```bash - $ docker compose pull - $ docker compose down - $ docker compose up -d --remove-orphans - $ docker image prune -f - ``` - - This will ensure that both Withings and Garmin session files persist across container restarts in the `/config` directory. -
- -### 1.4 Installation of withings-sync with docker (not compose) -> This method follows a default approach of utilizing a single container to run one job at a time, then exiting upon completion. It relies on an external scheduler (e.g., cron on the host operating system) to manage job execution. -
- Expand to show installation steps. - -```bash -$ docker pull ghcr.io/jaroslawhartman/withings-sync:latest +```sh +docker compose run --rm withings-sync status +docker compose run --rm withings-sync sync --once +docker compose run --rm withings-sync sync --since 2026-01-01 ``` -First start to ensure the script can start successfully (using --config-folder for simplified session management): - -Obtaining Withings authorisation: - -```bash -# Create a local config directory -mkdir -p ./config - -# Run with config folder mounted -$ docker run -v ./config:/config --interactive --tty --name withings-sync ghcr.io/jaroslawhartman/withings-sync:latest --config-folder /config --garmin-username= --garmin-password= - -Can't read config file /config/.withings_user.json -User interaction needed to get Authentification Code from Withings! - -Open the following URL in your web browser and copy back the token. You will have *30 seconds* before the token expires. HURRY UP! -(This is one-time activity) +`sync --since` performs a one-time backfill starting on the given UTC date. Sync state records successfully uploaded Withings measurement IDs, so reruns skip them. The service automatically retries at the next interval after a scheduled failure. -https://account.withings.com/oauth2_user/authorize2?response_type=code&client_id=183e03e1f363110b3551f96765c98c10e8f1aa647a37067a1cb64bbbaf491626&state=OK&scope=user.metrics&redirect_uri=https://jaroslawhartman.github.io/withings-sync/contrib/withings.html& +If Garmin credentials truly expire or are revoked, logs report the re-auth command: -Token : -Withings: Get Access Token -Withings: Refresh Access Token -Withings: Get Measurements - Measurements received -JaHa.WAW.PL -Garmin Connect User Name: JaHa.WAW.PL -Fit file uploaded to Garmin Connect +```sh +docker compose run --rm withings-sync auth garmin ``` -And for subsequent runs (both session files persist in ./config): - -```bash -$ docker start -i withings-sync -Withings: Refresh Access Token -Withings: Get Measurements - Measurements received -JaHa.WAW.PL -Garmin Connect User Name: JaHa.WAW.PL -Fit file uploaded to Garmin Connect -``` - -**Note**: Using `--config-folder /config` stores both Withings and Garmin session files in the mounted directory, making them persist across container restarts. -
- -## 2. Usage Instructions - -``` -usage: withings-sync [-h] [--version] [--garmin-username GARMIN_USERNAME] [--garmin-password GARMIN_PASSWORD] [--trainerroad-username TRAINERROAD_USERNAME] [--trainerroad-password TRAINERROAD_PASSWORD] [--fromdate DATE] [--todate DATE] [--to-fit] [--to-json] - [--output BASENAME] [--no-upload] [--features BLOOD_PRESSURE [BLOOD_PRESSURE ...]] [--verbose | --silent] [--dump-raw] [--config-folder PATH] - -A tool for synchronisation of Withings (ex. Nokia Health Body) to Garmin Connect and Trainer Road or to provide a json string. - -options: - -h, --help show this help message and exit - --version, -V show program's version number and exit - --garmin-username GARMIN_USERNAME, --gu GARMIN_USERNAME - Username to log in to Garmin Connect. - --garmin-password GARMIN_PASSWORD, --gp GARMIN_PASSWORD - Password to log in to Garmin Connect. - --trainerroad-username TRAINERROAD_USERNAME, --tu TRAINERROAD_USERNAME - Username to log in to TrainerRoad. - --trainerroad-password TRAINERROAD_PASSWORD, --tp TRAINERROAD_PASSWORD - Password to log in to TrainerRoad. - --fromdate DATE, -f DATE - Date to start syncing from. Ex: 2023-12-20 - --todate DATE, -t DATE - Date for the last sync. Ex: 2023-12-30 - --to-fit, -F Write output file in FIT format. - --to-json, -J Write output file in JSON format. - --output BASENAME, -o BASENAME - Write downloaded measurements to file. - --no-upload Won't upload to Garmin Connect or TrainerRoad. - --features BLOOD_PRESSURE [BLOOD_PRESSURE ...] - Enable Features like BLOOD_PRESSURE. - --verbose, -v Run verbosely. - --silent, -s Run silently (suppress INFO messages). - --dump-raw, -R Dump the raw Withings API JSON for the selected date range to file. If --output is provided, the file will be BASENAME.withings_raw.json. Otherwise, a default filename with the date range will be used. - --config-folder PATH, -c PATH - Path to config folder for session files (if not specified, uses legacy paths in home directory). -``` - -## 3. Providing credentials -### 3.1 Providing credentials via environment variables - -You can use the following environment variables for providing the Garmin and/or Trainerroad credentials: - -- `GARMIN_USERNAME` -- `GARMIN_PASSWORD` -- `TRAINERROAD_USERNAME` -- `TRAINERROAD_PASSWORD` - -The CLI also uses python-dotenv to populate the variables above. Therefore setting the environment variables -has the same effect as placing the variables in a `.env` file in the working directory. - -### 3.2 Providing credentials via secrets files - -You can also populate the following 'secrets' files to provide the Garmin and/or Trainerroad credentials: - -- `/run/secrets/garmin_username` -- `/run/secrets/garmin_password` -- `/run/secrets/trainerroad_username` -- `/run/secrets/trainerroad_password` - -Secrets are useful in an orchestrated container context — see the [Docker Swarm](https://docs.docker.com/engine/swarm/secrets/) or [Rancher](https://rancher.com/docs/rancher/v1.6/en/cattle/secrets/) docs for more information on how to securely inject secrets into a container. - -### 3.3 Order of priority for credentials - -In the case of credentials being available via multiple means (e.g. [environment variables](#providing-credentials-via-environment-variables) and [secrets files](#providing-credentials-via-secrets-files)), the order of resolution for determining which credentials to use is as follows, with later methods overriding credentials supplied by an earlier method: - -1. Read secrets file(s) -2. Read environment variable(s), variables set explicitly take precedence over values from a `.env` file. -3. Use command invocation argument(s) - -## 4. Tips - -### 4.1 Garmin SSO errors - -Some users reported errors raised by the Garmin SSO login: - -``` -withings_sync.garmin.APIException: SSO error 401 -``` - -or - -``` -withings_sync.garmin.APIException: SSO error 403 -``` - -These errors are raised if a user tries to login too frequently. -E.g. by running the script every 10 minutes. - -**We recommend to run the script around 8-10 times per day (every 2-3 hours).** - -See also: https://github.com/jaroslawhartman/withings-sync/issues/31 - -### 4.2 Garmin auth - -You can configure the location of the garmin session file with the variabe `GARMIN_SESSION`. - -Note: If you specify both `--config-folder` and the `GARMIN_SESSION` environment variable, the `--config-folder` option takes precedence. - -### 4.3 Config folder - -By default, withings-sync stores session files in your home directory: -- `~/.withings_user.json` for Withings authentication -- `~/.garmin_session` for Garmin authentication - -You can use the `--config-folder` or `-c` argument to store all session files in a custom folder: - -```bash -# Store session files in ~/.withings-sync -withings-sync --config-folder ~/.withings-sync - -# Store session files in a custom location -withings-sync --config-folder /path/to/my/config -``` - -When using a custom config folder: -- The folder will be created automatically if it doesn't exist -- If you have existing session files in the legacy locations, the script will show you where to copy them from -- This is useful for Docker installations or for keeping your home directory clean - -### 4.4 Run a periodic Kubernetes job - -1. Create the secret using kubectl in the command line. -``` -export GARMIN_USERNAME="user@username.com" -export GARMIN_PASSWORD="superPassword" -export TRAINERROAD_USERNAME: "name@name.com" -export TRAINERROAD_PASSWORD: "superPassword" -kubectl create secret generic withings-secret --from-literal=GARMIN_USERNAME=$GARMIN_USERNAME --from-literal=GARMIN_PASSWORD=$GARMIN_PASSWORD ---from-literal=TRAINERROAD_USERNAME=$TRAINERROAD_USERNAME --from-literal=TRAINERROAD_PASSWORD=$TRAINERROAD_PASSWORD -``` - -2. Create the PVC. -``` -kubectl apply -f k8s-pvc.yaml -``` - -3. Run the bootstrap pod, which attaches to the PVC for storing credentials. -``` -kubectl apply -f k8s-bootstrap.yaml -``` -The bootstrap pod stays on indefinitely to allow time for you to exec in, and -generate the credentials. - -4. Exec into the bootstrap pod, generate credentials and store them in the PVC. -``` -kubectl exec -it bootstrap-withings-sync -- sh -``` -From _within_ the bootstrap pod: -``` -# Create a config directory in the PVC -mkdir -p /data/config - -# Run with config folder to store all session files -poetry run withings-sync --config-folder /data/config --fromdate= -``` -It is important that this run includes a date that has a record, as a record is required for the program to attempt an upload to garmin in order to create the session files for garmin. -The command above will allow entering the withings token and the MFA code for garmin. -After successful auth, the credentials will be automatically stored in `/data/config/`: -- `/data/config/.withings_user.json` -- `/data/config/.garmin_session` - -5. Create the cron job. Update the cron job command to use the config folder: -``` -kubectl apply -f k8s-job.yaml -``` - -Note: Update your k8s-job.yaml to include `--config-folder /data/config` in the container command. - -## 5 For advanced users - registering own Withings application -> Instead of using the provided Withings application tokens you can register your own app with Withings and use that one instead. -
- If you are not sure you need this, you most likely won't. - -The script has been registered as a Withings application and got assigned `Client ID` and `Consumer Secret`. If you wish to create your own application - feel free! - - -* First you need a Withings account. [Sign up here](https://account.withings.com/connectionuser/account_create). -* Then you need a Withings developer app registered. [Create your app here](https://account.withings.com/partner/add_oauth2). - -Note, registering it is quite cumbersome, as you need to have a callback URL and an Icon. Anyway, when done, you should have the following identifiers: - -| Identfier | Example | -|-----------------|--------------------------------------------------------------------| -| Client ID | `183e03.................765c98c10e8f1aa647a37067a1......baf491626` | -| Consumer Secret | `a75d65.................4c16719ef7bd69fa7c5d3fd0ea......ed48f1765` | -| Callback URI | `https://jhartman.pl/withings/notify` | - -Configure them in `config/withings_app.json`, for example: - -``` -{ - "callback_url": "https://wieloryb.uk.to/withings/withings.html", - "client_id": "183e0******0b3551f96765c98c1******b64bbbaf491626", - "consumer_secret": "a75d65******1df1514c16719ef7bd69fa7*****2e2b0ed48f1765" -} -``` - -For the callback URL you will need to setup a webserver hosting `contrib/withings.html`. - -To do this in a Docker installation, you can use the environment variable `WITHINGS_APP` to point to a mounted `withings_app.json` - -Example docker-compose: -``` - withings-sync: - container_name: withings-sync - image: ghcr.io/jaroslawhartman/withings-sync:latest - volumes: - - "withings-sync:/root" - - "/etc/localtime:/etc/localtime:ro" - environment: - WITHINGS_APP: /home/withings-sync/withings_app.json -(...) -``` -You can then add the app-config in `withings-sync/withings_app.json` - -
- -## 6. Release - -Release works via the GitHub [Draft a new Release](https://github.com/jaroslawhartman/withings-sync/releases/new) -function. -The `version` key in `pyproject.toml` will be bumped automatically (Version will be written to pyproject.toml file). - -### Docker Image - -Container images are created automagically by GitHub Action and published -to [ghcr](https://github.com/jaroslawhartman/withings-sync/pkgs/container/withings-sync). - -### Pypi & GitHub - -Will be conducted automatically within the Github-Release cycle. -This needs the permission on the [pypi-project](https://pypi.org/project/withings-sync/). -The python packages are added to the GitHub releases by a GitHub Action. - -## 7. References - -* SSO authorization derived from https://github.com/cpfair/tapiriik -* TrainerRoad API from https://github.com/stuwilkins/python-trainerroad - -## 8. Credits / Authors - -* Based on [withings-garmin](https://github.com/ikasamah/withings-garmin) by Masayuki Hamasaki, improved to support SSO authorization in Garmin Connect 2. -* Based on [withings-garmin-v2](https://github.com/jaroslawhartman/withings-garmin-v2) by Jarek Hartman, improved Python 3 compatability, code-style and setuptools packaging, Kubernetes and Docker support. +For Withings revocation or a missing refresh token, repeat `auth withings`. Keep `./data` private and backed up; it contains long-lived account tokens. `./data` is the only persistent volume. diff --git a/contrib/icon.png b/contrib/icon.png deleted file mode 100644 index c9eb8b3..0000000 Binary files a/contrib/icon.png and /dev/null differ diff --git a/contrib/k8s-bootstrap.yaml b/contrib/k8s-bootstrap.yaml deleted file mode 100644 index ebe3442..0000000 --- a/contrib/k8s-bootstrap.yaml +++ /dev/null @@ -1,51 +0,0 @@ -apiVersion: v1 -kind: Pod -metadata: - name: bootstrap-withings-sync - labels: - app.kubernetes.io/name: bootstrap-withings-sync -spec: - restartPolicy: Never - securityContext: - fsGroup: 1000 - initContainers: - - name: prep-pvc - image: busybox:1.36 - command: - - sh - - -lc - - | - set -eux - chown -R 1000:1000 /data - chmod 755 /data - ls -la /data - - volumeMounts: - - name: creds - mountPath: /data - - containers: - - name: bootstrap-withings-sync - image: ghcr.io/jaroslawhartman/withings-sync:latest - command: ["sh","-lc","sleep infinity"] - securityContext: - runAsUser: 1000 - runAsGroup: 1000 - envFrom: - - secretRef: - name: withings-secret - resources: - requests: - cpu: "200m" - memory: "512Mi" - limits: - cpu: "200m" - memory: "512Mi" - volumeMounts: - - name: creds - mountPath: /data - - volumes: - - name: creds - persistentVolumeClaim: - claimName: withings-oauth-cache diff --git a/contrib/k8s-deployment.yaml b/contrib/k8s-deployment.yaml deleted file mode 100644 index e0c38c9..0000000 --- a/contrib/k8s-deployment.yaml +++ /dev/null @@ -1,112 +0,0 @@ -# Deploy withings-sync as a Kubernetes deployment -# Please also see contrib/k8s-job.yaml for a Kubernetes CronJob -# -# To install: -# - kubectl create namespace FOO -# - kubectl -n FOO create -f (this file) -# -# To check logs: -# - kubectl -n FOO logs -l app=withings-sync -# -# Because the app is running without STDIN, it will not pause to wait -# for you to enter the oauth token. Instead it will throw an error -# EOFError: EOF when reading a line -# and continue to sleep. To set up for the first time, run something -# like this manually, and enter the oauth token provided after you -# follow the link given: -# -# - kubectl -n FOO exec -ti $( kubectl -n FOO get pods | awk '$3=="Running" {print $1; exit}' ) -- withings-sync - - ---- -# Secret for credentials. By defining this as stringData: -# instead of data: we can enter details here in plain text and -# it gets base64 encoded when added to the system. -# -# Leave unused variables defined, but empty "" -# -apiVersion: v1 -kind: Secret -metadata: - name: credentials -type: Opaque -stringData: - # NOT base64 - GARMIN_USERNAME: "someone@example.com" - GARMIN_PASSWORD: "password-goes-here" - TRAINERROAD_PASSWORD: "" - TRAINERROAD_USERNAME: "" - ---- -# PVC for storing Withings OAuth tokens. Different -# storage methods have different minimum sizes. -# -# If you don't have a default storage class, you must -# specify it -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: withings-oauth-cache -spec: - accessModes: - - ReadWriteOnce - # uncomment, and define if needed - # storageClassName: class - resources: - requests: - storage: 1Mi - ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: withings-sync -spec: - replicas: 1 - revisionHistoryLimit: 3 - selector: - matchLabels: - app: withings-sync - template: - metadata: - labels: - app: withings-sync - spec: - containers: - - name: withings-sync - image: ghcr.io/jaroslawhartman/withings-sync:master - imagePullPolicy: IfNotPresent - - # override $HOME to put our oauth file in a known place - env: - - name: HOME - value: /data - - # read usernames/passwords from the secret we defined - envFrom: - - secretRef: - name: credentials - - volumeMounts: - - mountPath: /data - name: oauth-cache - - # run every 15 minutes, synched to clock time - command: - - sh - - -c - - while true; do date; withings-sync; echo; sleep 10000; done - - # Never run this container as root - securityContext: - fsGroup: 1234 - runAsGroup: 1234 - runAsUser: 1234 - runAsNonRoot: True - - volumes: - - name: oauth-cache - persistentVolumeClaim: - claimName: withings-oauth-cache - - diff --git a/contrib/k8s-job.yaml b/contrib/k8s-job.yaml deleted file mode 100644 index c914fc4..0000000 --- a/contrib/k8s-job.yaml +++ /dev/null @@ -1,48 +0,0 @@ -apiVersion: batch/v1 -kind: CronJob -metadata: - name: withings-garmin-sync -spec: - schedule: '0 */3 * * *' - timeZone: "Europe/London" # NeedS k8s >= 1.27. - successfulJobsHistoryLimit: 3 - concurrencyPolicy: Allow - failedJobsHistoryLimit: 1 - jobTemplate: - spec: - template: - spec: - restartPolicy: Never - containers: - - name: withings-garmin-sync - image: ghcr.io/jaroslawhartman/withings-sync:latest - imagePullPolicy: IfNotPresent - - volumeMounts: - - mountPath: /data - name: oauth-cache - - # Run the container as user 1000 'withings-sync' - securityContext: - runAsGroup: 1000 - runAsUser: 1000 - - command: - - sh - - -c - - | - set -eux - ln -sf /data/.withings_user.json /home/withings-sync/.withings_user.json - ln -sf /data/.garmin_session /home/withings-sync/.garmin_session - ls -la /home/withings-sync/ - poetry run withings-sync --features BLOOD_PRESSURE - - envFrom: - - secretRef: - name: withings-secret - - volumes: - - name: oauth-cache - persistentVolumeClaim: - claimName: withings-oauth-cache - diff --git a/contrib/k8s-pvc.yaml b/contrib/k8s-pvc.yaml deleted file mode 100644 index 777cabb..0000000 --- a/contrib/k8s-pvc.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# PVC for storing Withings OAuth tokens. Different -# storage methods have different minimum sizes. -# -# If you don't have a default storage class, you must -# specify it -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: withings-oauth-cache -spec: - accessModes: - - ReadWriteOnce - # uncomment, and define if needed - # storageClassName: class - resources: - requests: - storage: 1Mi - volumeMode: Filesystem diff --git a/contrib/withings.html b/contrib/withings.html deleted file mode 100644 index c9cd5f3..0000000 --- a/contrib/withings.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - Withings Authorization Token - - -

Your Withings Authorization Token

- - -

You must copy it back to the script during next 30 seconds, otherwise it will get expired.

- - - - diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..752ed9a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +services: + withings-sync: + build: + context: . + args: + PUID: ${PUID:-1000} + PGID: ${PGID:-1000} + restart: unless-stopped + env_file: + - path: .env + required: false + environment: + DATA_DIR: /data + SYNC_INTERVAL: ${SYNC_INTERVAL:-1h} + TZ: ${TZ:-UTC} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + volumes: + - ./data:/data + healthcheck: + test: ["CMD", "withings-sync", "status", "--healthcheck"] + interval: 5m + timeout: 15s + start_period: 1m + retries: 3 diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 070e105..0000000 --- a/poetry.lock +++ /dev/null @@ -1,1280 +0,0 @@ -# This file is automatically @generated by Poetry 2.3.2 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" -groups = ["main"] -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 = "astroid" -version = "3.3.6" -description = "An abstract syntax tree for Python with inference support." -optional = false -python-versions = ">=3.9.0" -groups = ["dev"] -files = [ - {file = "astroid-3.3.6-py3-none-any.whl", hash = "sha256:db676dc4f3ae6bfe31cda227dc60e03438378d7a896aec57422c95634e8d722f"}, - {file = "astroid-3.3.6.tar.gz", hash = "sha256:6aaea045f938c735ead292204afdb977a36e989522b7833ef6fea94de743f442"}, -] - -[[package]] -name = "black" -version = "24.10.0" -description = "The uncompromising code formatter." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, - {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, - {file = "black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f"}, - {file = "black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"}, - {file = "black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad"}, - {file = "black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50"}, - {file = "black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392"}, - {file = "black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175"}, - {file = "black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3"}, - {file = "black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65"}, - {file = "black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f"}, - {file = "black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8"}, - {file = "black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981"}, - {file = "black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b"}, - {file = "black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2"}, - {file = "black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b"}, - {file = "black-24.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd"}, - {file = "black-24.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f"}, - {file = "black-24.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800"}, - {file = "black-24.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7"}, - {file = "black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d"}, - {file = "black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875"}, -] - -[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.10)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - -[[package]] -name = "certifi" -version = "2024.12.14" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56"}, - {file = "certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.0" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7.0" -groups = ["main"] -files = [ - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, - {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, - {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, -] - -[[package]] -name = "click" -version = "8.1.7" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -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" -groups = ["dev"] -markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" -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 = "dill" -version = "0.3.9" -description = "serialize all of Python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "dill-0.3.9-py3-none-any.whl", hash = "sha256:468dff3b89520b474c0397703366b7b95eebe6303f108adf9b19da1f702be87a"}, - {file = "dill-0.3.9.tar.gz", hash = "sha256:81aa267dddf68cbfe8029c42ca9ec6a4ab3b22371d1c450abc54422577b4512c"}, -] - -[package.extras] -graph = ["objgraph (>=1.7.2)"] -profile = ["gprof2dot (>=2022.7.29)"] - -[[package]] -name = "executing" -version = "2.2.1" -description = "Get the currently executing AST node of a frame, and other information" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017"}, - {file = "executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4"}, -] - -[package.extras] -tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich ; python_version >= \"3.11\""] - -[[package]] -name = "garth" -version = "0.7.11" -description = "Garmin SSO auth + Connect client" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "garth-0.7.11-py3-none-any.whl", hash = "sha256:ce121fe2479e396968cb59de734ca3ff2edd2eac5aa88a36f30c4e906be0a105"}, - {file = "garth-0.7.11.tar.gz", hash = "sha256:c41f5c75b2a9a68f942c4c286ad6b7440f70db2d172c4d494add0cfcf3dad67f"}, -] - -[package.dependencies] -logfire = ">=2.11,<5.0" -pydantic = ">=2.0.0,<3.0.0" -pydantic-settings = ">=2.0.0,<3.0.0" -requests = ">=2.0.0,<3.0.0" -requests-oauthlib = ">=1.3.1,<3.0.0" - -[package.extras] -docs = ["zensical"] - -[[package]] -name = "googleapis-common-protos" -version = "1.73.0" -description = "Common protobufs used in Google APIs" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8"}, - {file = "googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a"}, -] - -[package.dependencies] -protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" - -[package.extras] -grpc = ["grpcio (>=1.44.0,<2.0.0)"] - -[[package]] -name = "idna" -version = "3.10" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "importlib-metadata" -version = "8.7.1" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, - {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, -] - -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=3.4)"] -perf = ["ipython"] -test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] - -[[package]] -name = "importlib-resources" -version = "6.4.5" -description = "Read resources from Python packages" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "importlib_resources-6.4.5-py3-none-any.whl", hash = "sha256:ac29d5f956f01d5e4bb63102a5a19957f1b9175e45649977264a1416783bb717"}, - {file = "importlib_resources-6.4.5.tar.gz", hash = "sha256:980862a1d16c9e147a59603677fa2aa5fd82b87f223b6cb870695bcfce830065"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["jaraco.test (>=5.4)", "pytest (>=6,!=8.1.*)", "zipp (>=3.17)"] -type = ["pytest-mypy"] - -[[package]] -name = "isort" -version = "5.13.2" -description = "A Python utility / library to sort Python imports." -optional = false -python-versions = ">=3.8.0" -groups = ["dev"] -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 = "logfire" -version = "4.29.0" -description = "The best Python observability tool! 🪵🔥" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "logfire-4.29.0-py3-none-any.whl", hash = "sha256:8dd7fdf6bed21459b8893eaa290d61977b9ebcc901844e365ddee868b5d8bca8"}, - {file = "logfire-4.29.0.tar.gz", hash = "sha256:18a306a0b5744aee8ad0a8f5d6b3a47a6d8951c340eaecc42dc5d0224f4bdca0"}, -] - -[package.dependencies] -executing = ">=2.0.1" -opentelemetry-exporter-otlp-proto-http = ">=1.39.0,<1.40.0" -opentelemetry-instrumentation = ">=0.41b0" -opentelemetry-sdk = ">=1.39.0,<1.40.0" -protobuf = ">=4.23.4" -rich = ">=13.4.2" -typing-extensions = ">=4.1.0" - -[package.extras] -aiohttp = ["opentelemetry-instrumentation-aiohttp-client (>=0.42b0)"] -aiohttp-client = ["opentelemetry-instrumentation-aiohttp-client (>=0.42b0)"] -aiohttp-server = ["opentelemetry-instrumentation-aiohttp-server (>=0.55b0)"] -asgi = ["opentelemetry-instrumentation-asgi (>=0.42b0)"] -asyncpg = ["opentelemetry-instrumentation-asyncpg (>=0.42b0)"] -aws-lambda = ["opentelemetry-instrumentation-aws-lambda (>=0.42b0)"] -celery = ["opentelemetry-instrumentation-celery (>=0.42b0)"] -datasets = ["httpx (>=0.27.2)", "pydantic (>=2)", "pydantic-evals (>=1.0.0) ; python_version >= \"3.10\""] -django = ["opentelemetry-instrumentation-asgi (>=0.42b0)", "opentelemetry-instrumentation-django (>=0.42b0)"] -dspy = ["openinference-instrumentation-dspy (>=0)"] -fastapi = ["opentelemetry-instrumentation-fastapi (>=0.42b0)"] -flask = ["opentelemetry-instrumentation-flask (>=0.42b0)"] -google-genai = ["opentelemetry-instrumentation-google-genai (>=0.4b0)"] -httpx = ["opentelemetry-instrumentation-httpx (>=0.42b0)"] -litellm = ["openinference-instrumentation-litellm (>=0)"] -mysql = ["opentelemetry-instrumentation-mysql (>=0.42b0)"] -psycopg = ["opentelemetry-instrumentation-psycopg (>=0.42b0)", "packaging"] -psycopg2 = ["opentelemetry-instrumentation-psycopg2 (>=0.42b0)", "packaging"] -pymongo = ["opentelemetry-instrumentation-pymongo (>=0.42b0)"] -redis = ["opentelemetry-instrumentation-redis (>=0.42b0)"] -requests = ["opentelemetry-instrumentation-requests (>=0.42b0)"] -sqlalchemy = ["opentelemetry-instrumentation-sqlalchemy (>=0.42b0)"] -sqlite3 = ["opentelemetry-instrumentation-sqlite3 (>=0.42b0)"] -starlette = ["opentelemetry-instrumentation-starlette (>=0.42b0)"] -system-metrics = ["opentelemetry-instrumentation-system-metrics (>=0.42b0)"] -variables = ["pydantic (>=2)"] -wsgi = ["opentelemetry-instrumentation-wsgi (>=0.42b0)"] - -[[package]] -name = "lxml" -version = "5.3.0" -description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "lxml-5.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:dd36439be765e2dde7660212b5275641edbc813e7b24668831a5c8ac91180656"}, - {file = "lxml-5.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ae5fe5c4b525aa82b8076c1a59d642c17b6e8739ecf852522c6321852178119d"}, - {file = "lxml-5.3.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:501d0d7e26b4d261fca8132854d845e4988097611ba2531408ec91cf3fd9d20a"}, - {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb66442c2546446944437df74379e9cf9e9db353e61301d1a0e26482f43f0dd8"}, - {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e41506fec7a7f9405b14aa2d5c8abbb4dbbd09d88f9496958b6d00cb4d45330"}, - {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f7d4a670107d75dfe5ad080bed6c341d18c4442f9378c9f58e5851e86eb79965"}, - {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41ce1f1e2c7755abfc7e759dc34d7d05fd221723ff822947132dc934d122fe22"}, - {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:44264ecae91b30e5633013fb66f6ddd05c006d3e0e884f75ce0b4755b3e3847b"}, - {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:3c174dc350d3ec52deb77f2faf05c439331d6ed5e702fc247ccb4e6b62d884b7"}, - {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:2dfab5fa6a28a0b60a20638dc48e6343c02ea9933e3279ccb132f555a62323d8"}, - {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b1c8c20847b9f34e98080da785bb2336ea982e7f913eed5809e5a3c872900f32"}, - {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2c86bf781b12ba417f64f3422cfc302523ac9cd1d8ae8c0f92a1c66e56ef2e86"}, - {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c162b216070f280fa7da844531169be0baf9ccb17263cf5a8bf876fcd3117fa5"}, - {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:36aef61a1678cb778097b4a6eeae96a69875d51d1e8f4d4b491ab3cfb54b5a03"}, - {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f65e5120863c2b266dbcc927b306c5b78e502c71edf3295dfcb9501ec96e5fc7"}, - {file = "lxml-5.3.0-cp310-cp310-win32.whl", hash = "sha256:ef0c1fe22171dd7c7c27147f2e9c3e86f8bdf473fed75f16b0c2e84a5030ce80"}, - {file = "lxml-5.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:052d99051e77a4f3e8482c65014cf6372e61b0a6f4fe9edb98503bb5364cfee3"}, - {file = "lxml-5.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74bcb423462233bc5d6066e4e98b0264e7c1bed7541fff2f4e34fe6b21563c8b"}, - {file = "lxml-5.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a3d819eb6f9b8677f57f9664265d0a10dd6551d227afb4af2b9cd7bdc2ccbf18"}, - {file = "lxml-5.3.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b8f5db71b28b8c404956ddf79575ea77aa8b1538e8b2ef9ec877945b3f46442"}, - {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c3406b63232fc7e9b8783ab0b765d7c59e7c59ff96759d8ef9632fca27c7ee4"}, - {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ecdd78ab768f844c7a1d4a03595038c166b609f6395e25af9b0f3f26ae1230f"}, - {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168f2dfcfdedf611eb285efac1516c8454c8c99caf271dccda8943576b67552e"}, - {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa617107a410245b8660028a7483b68e7914304a6d4882b5ff3d2d3eb5948d8c"}, - {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:69959bd3167b993e6e710b99051265654133a98f20cec1d9b493b931942e9c16"}, - {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:bd96517ef76c8654446fc3db9242d019a1bb5fe8b751ba414765d59f99210b79"}, - {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ab6dd83b970dc97c2d10bc71aa925b84788c7c05de30241b9e96f9b6d9ea3080"}, - {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eec1bb8cdbba2925bedc887bc0609a80e599c75b12d87ae42ac23fd199445654"}, - {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6a7095eeec6f89111d03dabfe5883a1fd54da319c94e0fb104ee8f23616b572d"}, - {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f651ebd0b21ec65dfca93aa629610a0dbc13dbc13554f19b0113da2e61a4763"}, - {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f422a209d2455c56849442ae42f25dbaaba1c6c3f501d58761c619c7836642ec"}, - {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:62f7fdb0d1ed2065451f086519865b4c90aa19aed51081979ecd05a21eb4d1be"}, - {file = "lxml-5.3.0-cp311-cp311-win32.whl", hash = "sha256:c6379f35350b655fd817cd0d6cbeef7f265f3ae5fedb1caae2eb442bbeae9ab9"}, - {file = "lxml-5.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:9c52100e2c2dbb0649b90467935c4b0de5528833c76a35ea1a2691ec9f1ee7a1"}, - {file = "lxml-5.3.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:e99f5507401436fdcc85036a2e7dc2e28d962550afe1cbfc07c40e454256a859"}, - {file = "lxml-5.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:384aacddf2e5813a36495233b64cb96b1949da72bef933918ba5c84e06af8f0e"}, - {file = "lxml-5.3.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:874a216bf6afaf97c263b56371434e47e2c652d215788396f60477540298218f"}, - {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65ab5685d56914b9a2a34d67dd5488b83213d680b0c5d10b47f81da5a16b0b0e"}, - {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aac0bbd3e8dd2d9c45ceb82249e8bdd3ac99131a32b4d35c8af3cc9db1657179"}, - {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b369d3db3c22ed14c75ccd5af429086f166a19627e84a8fdade3f8f31426e52a"}, - {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24037349665434f375645fa9d1f5304800cec574d0310f618490c871fd902b3"}, - {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:62d172f358f33a26d6b41b28c170c63886742f5b6772a42b59b4f0fa10526cb1"}, - {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:c1f794c02903c2824fccce5b20c339a1a14b114e83b306ff11b597c5f71a1c8d"}, - {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:5d6a6972b93c426ace71e0be9a6f4b2cfae9b1baed2eed2006076a746692288c"}, - {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:3879cc6ce938ff4eb4900d901ed63555c778731a96365e53fadb36437a131a99"}, - {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:74068c601baff6ff021c70f0935b0c7bc528baa8ea210c202e03757c68c5a4ff"}, - {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ecd4ad8453ac17bc7ba3868371bffb46f628161ad0eefbd0a855d2c8c32dd81a"}, - {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7e2f58095acc211eb9d8b5771bf04df9ff37d6b87618d1cbf85f92399c98dae8"}, - {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e63601ad5cd8f860aa99d109889b5ac34de571c7ee902d6812d5d9ddcc77fa7d"}, - {file = "lxml-5.3.0-cp312-cp312-win32.whl", hash = "sha256:17e8d968d04a37c50ad9c456a286b525d78c4a1c15dd53aa46c1d8e06bf6fa30"}, - {file = "lxml-5.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:c1a69e58a6bb2de65902051d57fde951febad631a20a64572677a1052690482f"}, - {file = "lxml-5.3.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c72e9563347c7395910de6a3100a4840a75a6f60e05af5e58566868d5eb2d6a"}, - {file = "lxml-5.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e92ce66cd919d18d14b3856906a61d3f6b6a8500e0794142338da644260595cd"}, - {file = "lxml-5.3.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d04f064bebdfef9240478f7a779e8c5dc32b8b7b0b2fc6a62e39b928d428e51"}, - {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c2fb570d7823c2bbaf8b419ba6e5662137f8166e364a8b2b91051a1fb40ab8b"}, - {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c120f43553ec759f8de1fee2f4794452b0946773299d44c36bfe18e83caf002"}, - {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:562e7494778a69086f0312ec9689f6b6ac1c6b65670ed7d0267e49f57ffa08c4"}, - {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:423b121f7e6fa514ba0c7918e56955a1d4470ed35faa03e3d9f0e3baa4c7e492"}, - {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c00f323cc00576df6165cc9d21a4c21285fa6b9989c5c39830c3903dc4303ef3"}, - {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:1fdc9fae8dd4c763e8a31e7630afef517eab9f5d5d31a278df087f307bf601f4"}, - {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:658f2aa69d31e09699705949b5fc4719cbecbd4a97f9656a232e7d6c7be1a367"}, - {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1473427aff3d66a3fa2199004c3e601e6c4500ab86696edffdbc84954c72d832"}, - {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a87de7dd873bf9a792bf1e58b1c3887b9264036629a5bf2d2e6579fe8e73edff"}, - {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0d7b36afa46c97875303a94e8f3ad932bf78bace9e18e603f2085b652422edcd"}, - {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cf120cce539453ae086eacc0130a324e7026113510efa83ab42ef3fcfccac7fb"}, - {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:df5c7333167b9674aa8ae1d4008fa4bc17a313cc490b2cca27838bbdcc6bb15b"}, - {file = "lxml-5.3.0-cp313-cp313-win32.whl", hash = "sha256:c802e1c2ed9f0c06a65bc4ed0189d000ada8049312cfeab6ca635e39c9608957"}, - {file = "lxml-5.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:406246b96d552e0503e17a1006fd27edac678b3fcc9f1be71a2f94b4ff61528d"}, - {file = "lxml-5.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8f0de2d390af441fe8b2c12626d103540b5d850d585b18fcada58d972b74a74e"}, - {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1afe0a8c353746e610bd9031a630a95bcfb1a720684c3f2b36c4710a0a96528f"}, - {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56b9861a71575f5795bde89256e7467ece3d339c9b43141dbdd54544566b3b94"}, - {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:9fb81d2824dff4f2e297a276297e9031f46d2682cafc484f49de182aa5e5df99"}, - {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2c226a06ecb8cdef28845ae976da407917542c5e6e75dcac7cc33eb04aaeb237"}, - {file = "lxml-5.3.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:7d3d1ca42870cdb6d0d29939630dbe48fa511c203724820fc0fd507b2fb46577"}, - {file = "lxml-5.3.0-cp36-cp36m-win32.whl", hash = "sha256:094cb601ba9f55296774c2d57ad68730daa0b13dc260e1f941b4d13678239e70"}, - {file = "lxml-5.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:eafa2c8658f4e560b098fe9fc54539f86528651f61849b22111a9b107d18910c"}, - {file = "lxml-5.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cb83f8a875b3d9b458cada4f880fa498646874ba4011dc974e071a0a84a1b033"}, - {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25f1b69d41656b05885aa185f5fdf822cb01a586d1b32739633679699f220391"}, - {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23e0553b8055600b3bf4a00b255ec5c92e1e4aebf8c2c09334f8368e8bd174d6"}, - {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ada35dd21dc6c039259596b358caab6b13f4db4d4a7f8665764d616daf9cc1d"}, - {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:81b4e48da4c69313192d8c8d4311e5d818b8be1afe68ee20f6385d0e96fc9512"}, - {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:2bc9fd5ca4729af796f9f59cd8ff160fe06a474da40aca03fcc79655ddee1a8b"}, - {file = "lxml-5.3.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:07da23d7ee08577760f0a71d67a861019103e4812c87e2fab26b039054594cc5"}, - {file = "lxml-5.3.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:ea2e2f6f801696ad7de8aec061044d6c8c0dd4037608c7cab38a9a4d316bfb11"}, - {file = "lxml-5.3.0-cp37-cp37m-win32.whl", hash = "sha256:5c54afdcbb0182d06836cc3d1be921e540be3ebdf8b8a51ee3ef987537455f84"}, - {file = "lxml-5.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:f2901429da1e645ce548bf9171784c0f74f0718c3f6150ce166be39e4dd66c3e"}, - {file = "lxml-5.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c56a1d43b2f9ee4786e4658c7903f05da35b923fb53c11025712562d5cc02753"}, - {file = "lxml-5.3.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ee8c39582d2652dcd516d1b879451500f8db3fe3607ce45d7c5957ab2596040"}, - {file = "lxml-5.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdf3a3059611f7585a78ee10399a15566356116a4288380921a4b598d807a22"}, - {file = "lxml-5.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:146173654d79eb1fc97498b4280c1d3e1e5d58c398fa530905c9ea50ea849b22"}, - {file = "lxml-5.3.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:0a7056921edbdd7560746f4221dca89bb7a3fe457d3d74267995253f46343f15"}, - {file = "lxml-5.3.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:9e4b47ac0f5e749cfc618efdf4726269441014ae1d5583e047b452a32e221920"}, - {file = "lxml-5.3.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f914c03e6a31deb632e2daa881fe198461f4d06e57ac3d0e05bbcab8eae01945"}, - {file = "lxml-5.3.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:213261f168c5e1d9b7535a67e68b1f59f92398dd17a56d934550837143f79c42"}, - {file = "lxml-5.3.0-cp38-cp38-win32.whl", hash = "sha256:218c1b2e17a710e363855594230f44060e2025b05c80d1f0661258142b2add2e"}, - {file = "lxml-5.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:315f9542011b2c4e1d280e4a20ddcca1761993dda3afc7a73b01235f8641e903"}, - {file = "lxml-5.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1ffc23010330c2ab67fac02781df60998ca8fe759e8efde6f8b756a20599c5de"}, - {file = "lxml-5.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2b3778cb38212f52fac9fe913017deea2fdf4eb1a4f8e4cfc6b009a13a6d3fcc"}, - {file = "lxml-5.3.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b0c7a688944891086ba192e21c5229dea54382f4836a209ff8d0a660fac06be"}, - {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:747a3d3e98e24597981ca0be0fd922aebd471fa99d0043a3842d00cdcad7ad6a"}, - {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86a6b24b19eaebc448dc56b87c4865527855145d851f9fc3891673ff97950540"}, - {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b11a5d918a6216e521c715b02749240fb07ae5a1fefd4b7bf12f833bc8b4fe70"}, - {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68b87753c784d6acb8a25b05cb526c3406913c9d988d51f80adecc2b0775d6aa"}, - {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:109fa6fede314cc50eed29e6e56c540075e63d922455346f11e4d7a036d2b8cf"}, - {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:02ced472497b8362c8e902ade23e3300479f4f43e45f4105c85ef43b8db85229"}, - {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:6b038cc86b285e4f9fea2ba5ee76e89f21ed1ea898e287dc277a25884f3a7dfe"}, - {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:7437237c6a66b7ca341e868cda48be24b8701862757426852c9b3186de1da8a2"}, - {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7f41026c1d64043a36fda21d64c5026762d53a77043e73e94b71f0521939cc71"}, - {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:482c2f67761868f0108b1743098640fbb2a28a8e15bf3f47ada9fa59d9fe08c3"}, - {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1483fd3358963cc5c1c9b122c80606a3a79ee0875bcac0204149fa09d6ff2727"}, - {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2dec2d1130a9cda5b904696cec33b2cfb451304ba9081eeda7f90f724097300a"}, - {file = "lxml-5.3.0-cp39-cp39-win32.whl", hash = "sha256:a0eabd0a81625049c5df745209dc7fcef6e2aea7793e5f003ba363610aa0a3ff"}, - {file = "lxml-5.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:89e043f1d9d341c52bf2af6d02e6adde62e0a46e6755d5eb60dc6e4f0b8aeca2"}, - {file = "lxml-5.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7b1cd427cb0d5f7393c31b7496419da594fe600e6fdc4b105a54f82405e6626c"}, - {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51806cfe0279e06ed8500ce19479d757db42a30fd509940b1701be9c86a5ff9a"}, - {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee70d08fd60c9565ba8190f41a46a54096afa0eeb8f76bd66f2c25d3b1b83005"}, - {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8dc2c0395bea8254d8daebc76dcf8eb3a95ec2a46fa6fae5eaccee366bfe02ce"}, - {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:6ba0d3dcac281aad8a0e5b14c7ed6f9fa89c8612b47939fc94f80b16e2e9bc83"}, - {file = "lxml-5.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:6e91cf736959057f7aac7adfc83481e03615a8e8dd5758aa1d95ea69e8931dba"}, - {file = "lxml-5.3.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:94d6c3782907b5e40e21cadf94b13b0842ac421192f26b84c45f13f3c9d5dc27"}, - {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c300306673aa0f3ed5ed9372b21867690a17dba38c68c44b287437c362ce486b"}, - {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d9b952e07aed35fe2e1a7ad26e929595412db48535921c5013edc8aa4a35ce"}, - {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:01220dca0d066d1349bd6a1726856a78f7929f3878f7e2ee83c296c69495309e"}, - {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2d9b8d9177afaef80c53c0a9e30fa252ff3036fb1c6494d427c066a4ce6a282f"}, - {file = "lxml-5.3.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:20094fc3f21ea0a8669dc4c61ed7fa8263bd37d97d93b90f28fc613371e7a875"}, - {file = "lxml-5.3.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ace2c2326a319a0bb8a8b0e5b570c764962e95818de9f259ce814ee666603f19"}, - {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92e67a0be1639c251d21e35fe74df6bcc40cba445c2cda7c4a967656733249e2"}, - {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd5350b55f9fecddc51385463a4f67a5da829bc741e38cf689f38ec9023f54ab"}, - {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:4c1fefd7e3d00921c44dc9ca80a775af49698bbfd92ea84498e56acffd4c5469"}, - {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:71a8dd38fbd2f2319136d4ae855a7078c69c9a38ae06e0c17c73fd70fc6caad8"}, - {file = "lxml-5.3.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:97acf1e1fd66ab53dacd2c35b319d7e548380c2e9e8c54525c6e76d21b1ae3b1"}, - {file = "lxml-5.3.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:68934b242c51eb02907c5b81d138cb977b2129a0a75a8f8b60b01cb8586c7b21"}, - {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b710bc2b8292966b23a6a0121f7a6c51d45d2347edcc75f016ac123b8054d3f2"}, - {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18feb4b93302091b1541221196a2155aa296c363fd233814fa11e181adebc52f"}, - {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3eb44520c4724c2e1a57c0af33a379eee41792595023f367ba3952a2d96c2aab"}, - {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:609251a0ca4770e5a8768ff902aa02bf636339c5a93f9349b48eb1f606f7f3e9"}, - {file = "lxml-5.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:516f491c834eb320d6c843156440fe7fc0d50b33e44387fcec5b02f0bc118a4c"}, - {file = "lxml-5.3.0.tar.gz", hash = "sha256:4e109ca30d1edec1ac60cdbe341905dc3b8f55b16855e03a54aaf59e51ec8c6f"}, -] - -[package.extras] -cssselect = ["cssselect (>=0.7)"] -html-clean = ["lxml-html-clean"] -html5 = ["html5lib"] -htmlsoup = ["BeautifulSoup4"] -source = ["Cython (>=3.0.11)"] - -[[package]] -name = "markdown-it-py" -version = "4.0.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, - {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins (>=0.5.0)"] -profiling = ["gprof2dot"] -rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] - -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] - -[[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" -groups = ["dev"] -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 = "oauthlib" -version = "3.2.2" -description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"}, - {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"}, -] - -[package.extras] -rsa = ["cryptography (>=3.0.0)"] -signals = ["blinker (>=1.4.0)"] -signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] - -[[package]] -name = "opentelemetry-api" -version = "1.39.1" -description = "OpenTelemetry Python API" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950"}, - {file = "opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c"}, -] - -[package.dependencies] -importlib-metadata = ">=6.0,<8.8.0" -typing-extensions = ">=4.5.0" - -[[package]] -name = "opentelemetry-exporter-otlp-proto-common" -version = "1.39.1" -description = "OpenTelemetry Protobuf encoding" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde"}, - {file = "opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464"}, -] - -[package.dependencies] -opentelemetry-proto = "1.39.1" - -[[package]] -name = "opentelemetry-exporter-otlp-proto-http" -version = "1.39.1" -description = "OpenTelemetry Collector Protobuf over HTTP Exporter" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985"}, - {file = "opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb"}, -] - -[package.dependencies] -googleapis-common-protos = ">=1.52,<2.0" -opentelemetry-api = ">=1.15,<2.0" -opentelemetry-exporter-otlp-proto-common = "1.39.1" -opentelemetry-proto = "1.39.1" -opentelemetry-sdk = ">=1.39.1,<1.40.0" -requests = ">=2.7,<3.0" -typing-extensions = ">=4.5.0" - -[package.extras] -gcp-auth = ["opentelemetry-exporter-credential-provider-gcp (>=0.59b0)"] - -[[package]] -name = "opentelemetry-instrumentation" -version = "0.60b1" -description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d"}, - {file = "opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a"}, -] - -[package.dependencies] -opentelemetry-api = ">=1.4,<2.0" -opentelemetry-semantic-conventions = "0.60b1" -packaging = ">=18.0" -wrapt = ">=1.0.0,<2.0.0" - -[[package]] -name = "opentelemetry-proto" -version = "1.39.1" -description = "OpenTelemetry Python Proto" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007"}, - {file = "opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8"}, -] - -[package.dependencies] -protobuf = ">=5.0,<7.0" - -[[package]] -name = "opentelemetry-sdk" -version = "1.39.1" -description = "OpenTelemetry Python SDK" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c"}, - {file = "opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6"}, -] - -[package.dependencies] -opentelemetry-api = "1.39.1" -opentelemetry-semantic-conventions = "0.60b1" -typing-extensions = ">=4.5.0" - -[[package]] -name = "opentelemetry-semantic-conventions" -version = "0.60b1" -description = "OpenTelemetry Semantic Conventions" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb"}, - {file = "opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953"}, -] - -[package.dependencies] -opentelemetry-api = "1.39.1" -typing-extensions = ">=4.5.0" - -[[package]] -name = "packaging" -version = "24.2" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, - {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, -] - -[[package]] -name = "pathspec" -version = "0.12.1" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -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.3.6" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, - {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, -] - -[package.extras] -docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] -type = ["mypy (>=1.11.2)"] - -[[package]] -name = "protobuf" -version = "6.33.6" -description = "" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3"}, - {file = "protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326"}, - {file = "protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a"}, - {file = "protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2"}, - {file = "protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3"}, - {file = "protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593"}, - {file = "protobuf-6.33.6-cp39-cp39-win32.whl", hash = "sha256:bd56799fb262994b2c2faa1799693c95cc2e22c62f56fb43af311cae45d26f0e"}, - {file = "protobuf-6.33.6-cp39-cp39-win_amd64.whl", hash = "sha256:f443a394af5ed23672bc6c486be138628fbe5c651ccbc536873d7da23d1868cf"}, - {file = "protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901"}, - {file = "protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135"}, -] - -[[package]] -name = "pydantic" -version = "2.10.4" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pydantic-2.10.4-py3-none-any.whl", hash = "sha256:597e135ea68be3a37552fb524bc7d0d66dcf93d395acd93a00682f1efcb8ee3d"}, - {file = "pydantic-2.10.4.tar.gz", hash = "sha256:82f12e9723da6de4fe2ba888b5971157b3be7ad914267dea8f05f82b28254f06"}, -] - -[package.dependencies] -annotated-types = ">=0.6.0" -pydantic-core = "2.27.2" -typing-extensions = ">=4.12.2" - -[package.extras] -email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] - -[[package]] -name = "pydantic-core" -version = "2.27.2" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"}, - {file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"}, - {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a"}, - {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5"}, - {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c"}, - {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7"}, - {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a"}, - {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236"}, - {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962"}, - {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9"}, - {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af"}, - {file = "pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4"}, - {file = "pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31"}, - {file = "pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc"}, - {file = "pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d"}, - {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b"}, - {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474"}, - {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6"}, - {file = "pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c"}, - {file = "pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc"}, - {file = "pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4"}, - {file = "pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0"}, - {file = "pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef"}, - {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7"}, - {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934"}, - {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6"}, - {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c"}, - {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2"}, - {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4"}, - {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3"}, - {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4"}, - {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57"}, - {file = "pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc"}, - {file = "pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9"}, - {file = "pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b"}, - {file = "pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b"}, - {file = "pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154"}, - {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9"}, - {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9"}, - {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1"}, - {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a"}, - {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e"}, - {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4"}, - {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27"}, - {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee"}, - {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1"}, - {file = "pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130"}, - {file = "pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee"}, - {file = "pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b"}, - {file = "pydantic_core-2.27.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506"}, - {file = "pydantic_core-2.27.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320"}, - {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145"}, - {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1"}, - {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228"}, - {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046"}, - {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5"}, - {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a"}, - {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d"}, - {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9"}, - {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da"}, - {file = "pydantic_core-2.27.2-cp38-cp38-win32.whl", hash = "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b"}, - {file = "pydantic_core-2.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad"}, - {file = "pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993"}, - {file = "pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308"}, - {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4"}, - {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf"}, - {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76"}, - {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118"}, - {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630"}, - {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54"}, - {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f"}, - {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362"}, - {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96"}, - {file = "pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e"}, - {file = "pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35"}, - {file = "pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39"}, -] - -[package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" - -[[package]] -name = "pydantic-settings" -version = "2.13.1" -description = "Settings management using Pydantic" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237"}, - {file = "pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025"}, -] - -[package.dependencies] -pydantic = ">=2.7.0" -python-dotenv = ">=0.21.0" -typing-inspection = ">=0.4.0" - -[package.extras] -aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] -azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] -gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] -toml = ["tomli (>=2.0.1)"] -yaml = ["pyyaml (>=6.0.1)"] - -[[package]] -name = "pygments" -version = "2.19.2" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pylint" -version = "3.3.2" -description = "python code static checker" -optional = false -python-versions = ">=3.9.0" -groups = ["dev"] -files = [ - {file = "pylint-3.3.2-py3-none-any.whl", hash = "sha256:77f068c287d49b8683cd7c6e624243c74f92890f767f106ffa1ddf3c0a54cb7a"}, - {file = "pylint-3.3.2.tar.gz", hash = "sha256:9ec054ec992cd05ad30a6df1676229739a73f8feeabf3912c995d17601052b01"}, -] - -[package.dependencies] -astroid = ">=3.3.5,<=3.4.0.dev0" -colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} -dill = {version = ">=0.3.7", markers = "python_version >= \"3.12\""} -isort = ">=4.2.5,<5.13.0 || >5.13.0,<6" -mccabe = ">=0.6,<0.8" -platformdirs = ">=2.2.0" -tomlkit = ">=0.10.1" - -[package.extras] -spelling = ["pyenchant (>=3.2,<4.0)"] -testutils = ["gitpython (>3)"] - -[[package]] -name = "python-dotenv" -version = "1.0.1" -description = "Read key-value pairs from a .env file and set them as environment variables" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, - {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, -] - -[package.extras] -cli = ["click (>=5.0)"] - -[[package]] -name = "requests" -version = "2.32.3" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.8" -groups = ["main"] -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 = "requests-oauthlib" -version = "2.0.0" -description = "OAuthlib authentication support for Requests." -optional = false -python-versions = ">=3.4" -groups = ["main"] -files = [ - {file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, - {file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, -] - -[package.dependencies] -oauthlib = ">=3.0.0" -requests = ">=2.0.0" - -[package.extras] -rsa = ["oauthlib[signedtoken] (>=3.0.0)"] - -[[package]] -name = "rich" -version = "14.3.3" -description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -optional = false -python-versions = ">=3.8.0" -groups = ["main"] -files = [ - {file = "rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d"}, - {file = "rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"}, -] - -[package.dependencies] -markdown-it-py = ">=2.2.0" -pygments = ">=2.13.0,<3.0.0" - -[package.extras] -jupyter = ["ipywidgets (>=7.5.1,<9)"] - -[[package]] -name = "tomlkit" -version = "0.13.2" -description = "Style preserving TOML library" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde"}, - {file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"}, -] - -[[package]] -name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false -python-versions = ">=3.8" -groups = ["main"] -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 = "typing-inspection" -version = "0.4.2" -description = "Runtime typing introspection tools" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, - {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, -] - -[package.dependencies] -typing-extensions = ">=4.12.0" - -[[package]] -name = "urllib3" -version = "2.2.3" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, - {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, -] - -[package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "wrapt" -version = "1.17.3" -description = "Module for decorators, wrappers and monkey patching." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"}, - {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"}, - {file = "wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c"}, - {file = "wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775"}, - {file = "wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd"}, - {file = "wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05"}, - {file = "wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418"}, - {file = "wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390"}, - {file = "wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6"}, - {file = "wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18"}, - {file = "wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7"}, - {file = "wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85"}, - {file = "wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f"}, - {file = "wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311"}, - {file = "wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1"}, - {file = "wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5"}, - {file = "wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2"}, - {file = "wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89"}, - {file = "wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77"}, - {file = "wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a"}, - {file = "wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0"}, - {file = "wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba"}, - {file = "wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd"}, - {file = "wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828"}, - {file = "wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9"}, - {file = "wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396"}, - {file = "wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc"}, - {file = "wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe"}, - {file = "wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c"}, - {file = "wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6"}, - {file = "wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0"}, - {file = "wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77"}, - {file = "wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7"}, - {file = "wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277"}, - {file = "wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d"}, - {file = "wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa"}, - {file = "wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050"}, - {file = "wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8"}, - {file = "wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb"}, - {file = "wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16"}, - {file = "wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39"}, - {file = "wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235"}, - {file = "wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c"}, - {file = "wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b"}, - {file = "wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa"}, - {file = "wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7"}, - {file = "wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4"}, - {file = "wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10"}, - {file = "wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6"}, - {file = "wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58"}, - {file = "wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a"}, - {file = "wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067"}, - {file = "wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454"}, - {file = "wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e"}, - {file = "wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f"}, - {file = "wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056"}, - {file = "wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804"}, - {file = "wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977"}, - {file = "wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116"}, - {file = "wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6"}, - {file = "wrapt-1.17.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:70d86fa5197b8947a2fa70260b48e400bf2ccacdcab97bb7de47e3d1e6312225"}, - {file = "wrapt-1.17.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:df7d30371a2accfe4013e90445f6388c570f103d61019b6b7c57e0265250072a"}, - {file = "wrapt-1.17.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:caea3e9c79d5f0d2c6d9ab96111601797ea5da8e6d0723f77eabb0d4068d2b2f"}, - {file = "wrapt-1.17.3-cp38-cp38-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:758895b01d546812d1f42204bd443b8c433c44d090248bf22689df673ccafe00"}, - {file = "wrapt-1.17.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02b551d101f31694fc785e58e0720ef7d9a10c4e62c1c9358ce6f63f23e30a56"}, - {file = "wrapt-1.17.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:656873859b3b50eeebe6db8b1455e99d90c26ab058db8e427046dbc35c3140a5"}, - {file = "wrapt-1.17.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a9a2203361a6e6404f80b99234fe7fb37d1fc73487b5a78dc1aa5b97201e0f22"}, - {file = "wrapt-1.17.3-cp38-cp38-win32.whl", hash = "sha256:55cbbc356c2842f39bcc553cf695932e8b30e30e797f961860afb308e6b1bb7c"}, - {file = "wrapt-1.17.3-cp38-cp38-win_amd64.whl", hash = "sha256:ad85e269fe54d506b240d2d7b9f5f2057c2aa9a2ea5b32c66f8902f768117ed2"}, - {file = "wrapt-1.17.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:30ce38e66630599e1193798285706903110d4f057aab3168a34b7fdc85569afc"}, - {file = "wrapt-1.17.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:65d1d00fbfb3ea5f20add88bbc0f815150dbbde3b026e6c24759466c8b5a9ef9"}, - {file = "wrapt-1.17.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a7c06742645f914f26c7f1fa47b8bc4c91d222f76ee20116c43d5ef0912bba2d"}, - {file = "wrapt-1.17.3-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e18f01b0c3e4a07fe6dfdb00e29049ba17eadbc5e7609a2a3a4af83ab7d710a"}, - {file = "wrapt-1.17.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f5f51a6466667a5a356e6381d362d259125b57f059103dd9fdc8c0cf1d14139"}, - {file = "wrapt-1.17.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:59923aa12d0157f6b82d686c3fd8e1166fa8cdfb3e17b42ce3b6147ff81528df"}, - {file = "wrapt-1.17.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:46acc57b331e0b3bcb3e1ca3b421d65637915cfcd65eb783cb2f78a511193f9b"}, - {file = "wrapt-1.17.3-cp39-cp39-win32.whl", hash = "sha256:3e62d15d3cfa26e3d0788094de7b64efa75f3a53875cdbccdf78547aed547a81"}, - {file = "wrapt-1.17.3-cp39-cp39-win_amd64.whl", hash = "sha256:1f23fa283f51c890eda8e34e4937079114c74b4c81d2b2f1f1d94948f5cc3d7f"}, - {file = "wrapt-1.17.3-cp39-cp39-win_arm64.whl", hash = "sha256:24c2ed34dc222ed754247a2702b1e1e89fdbaa4016f324b4b8f1a802d4ffe87f"}, - {file = "wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22"}, - {file = "wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0"}, -] - -[[package]] -name = "zipp" -version = "3.23.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, - {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - -[metadata] -lock-version = "2.1" -python-versions = "^3.12" -content-hash = "3f8fc299a5000c9bf4a0891b28451b46a943baef79bffbc213fd0f5e917a276d" diff --git a/pyproject.toml b/pyproject.toml index 97a6b64..131c416 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,38 +1,27 @@ -[tool.poetry] +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] name = "withings-sync" -version = "1.0.0.dev1" -description = "A tool for synchronisation of Withings (ex. Nokia Health Body) to Garmin Connect and Trainer Road." -license = "MIT" -authors = ["Masayuki Hamasaki ", - "Steffen Vogel ", - "Jarek Hartman ", - ] -maintainers = ["longstone <2110765+longstone@users.noreply.github.com>", - ] -repository = "https://github.com/jaroslawhartman/withings-sync" -homepage = "https://github.com/jaroslawhartman/withings-sync" +version = "2.0.0" +description = "Reliable Withings weight and body-composition sync to Garmin Connect." readme = "README.md" -keywords = ["garmin", "withings", "sync", "api", "scale", "smarthome"] -packages = [{include = "withings_sync"}] -classifiers = ["Topic :: Utilities", - "Topic :: Home Automation", - ] - -[tool.poetry.dependencies] -python = "^3.12" -garth = ">=0.7.11" -requests = ">=2.32.3" -lxml = ">=5.3.0" -python-dotenv = ">=1.0.1" -importlib-resources = ">=6.4.5" +requires-python = ">=3.12" +license = { text = "MIT" } +dependencies = [ + "garminconnect==0.3.17", + "requests>=2.32.3,<3", +] -[tool.poetry.group.dev.dependencies] -black = ">=24.10.0" -pylint = ">=3.3.1" +[project.optional-dependencies] +test = ["pytest>=8,<9"] -[tool.poetry.scripts] +[project.scripts] withings-sync = "withings_sync.sync:main" -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" +[tool.setuptools.packages.find] +include = ["withings_sync*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/test_fit.py b/tests/test_fit.py new file mode 100644 index 0000000..6f4d294 --- /dev/null +++ b/tests/test_fit.py @@ -0,0 +1,55 @@ +import struct +from datetime import datetime, timezone + +from withings_sync.fit import FitEncoderWeight, _crc + + +def test_weight_scale_file_has_valid_header_crc_and_composition_fields(): + encoder = FitEncoderWeight() + measured_at = datetime(2026, 8, 12, 10, 30, tzinfo=timezone.utc) + encoder.write_file_info(time_created=measured_at) + encoder.write_file_creator() + encoder.write_device_info(measured_at) + encoder.write_weight_scale( + measured_at, + weight=72.35, + percent_fat=21.2, + percent_hydration=54.1, + bone_mass=3.1, + muscle_mass=33.4, + bmi=23.8, + ) + encoder.finish() + data = encoder.getvalue() + + assert data[8:12] == b".FIT" + assert struct.unpack_from("H", data, offset + 2 + )[0] + count = data[offset + 4] + offset += 5 + fields = [] + for _ in range(count): + number, size, base_type = struct.unpack_from("= {0, 1, 2, 4, 5, 13, 253} diff --git a/tests/test_garmin.py b/tests/test_garmin.py new file mode 100644 index 0000000..9256279 --- /dev/null +++ b/tests/test_garmin.py @@ -0,0 +1,32 @@ +import json + +from withings_sync.garmin import GarminClient +from withings_sync.storage import Storage + + +def test_garmin_mfa_prompt_saves_tokens_not_password(tmp_path, monkeypatch): + storage = Storage(tmp_path) + calls = {} + + class FakeTokenClient: + def dump(self, path): + with open(path, "w", encoding="utf-8") as token_file: + json.dump({"di_token": "access", "di_refresh_token": "refresh"}, token_file) + + class FakeGarmin: + def __init__(self, **kwargs): + calls["kwargs"] = kwargs + self.client = FakeTokenClient() + + def login(self, tokenstore): + calls["mfa"] = calls["kwargs"]["prompt_mfa"]() + calls["tokenstore"] = tokenstore + + monkeypatch.setattr("withings_sync.garmin.Garmin", FakeGarmin) + client = GarminClient(storage) + client.authenticate("user@example.com", "do-not-save-this", "123456") + + assert calls["mfa"] == "123456" + assert calls["tokenstore"] == str(tmp_path / "garmin_tokens.json") + assert "do-not-save-this" not in (tmp_path / "garmin_tokens.json").read_text() + assert client.status()[0] diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..1feb11a --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,85 @@ +import json +import os +from datetime import datetime, timezone + +import pytest + +from withings_sync.storage import Storage +from withings_sync.withings import TOKEN_FILE, WithingsClient, WithingsError + + +class Response: + def __init__(self, payload): + self.payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self.payload + + +class Session: + def __init__(self, responses): + self.responses = iter(responses) + self.calls = [] + + def post(self, *args, **kwargs): + self.calls.append((args, kwargs)) + return next(self.responses) + + +def test_json_state_is_atomic_private_and_readable(tmp_path): + storage = Storage(tmp_path) + storage.write_json("credentials.json", {"refresh_token": "secret"}) + + assert storage.read_json("credentials.json") == {"refresh_token": "secret"} + assert os.stat(tmp_path / "credentials.json").st_mode & 0o777 == 0o600 + assert not list(tmp_path.glob("*.tmp")) + + +def test_withings_refresh_rotates_and_persists_token_immediately(tmp_path): + storage = Storage(tmp_path) + storage.write_json( + TOKEN_FILE, + { + "access_token": "old-access", + "refresh_token": "old-refresh", + "expires_at": int(datetime.now(timezone.utc).timestamp()) - 1, + }, + ) + session = Session( + [ + Response( + { + "status": 0, + "body": { + "access_token": "new-access", + "refresh_token": "new-refresh", + "userid": 7, + "expires_in": 3600, + }, + } + ) + ] + ) + client = WithingsClient(storage, "app-id", "app-secret", "https://callback", session) + + assert client.access_token() == "new-access" + saved = storage.read_json(TOKEN_FILE) + assert saved["refresh_token"] == "new-refresh" + assert saved["expires_at"] > int(datetime.now(timezone.utc).timestamp()) + assert session.calls[0][1]["data"]["refresh_token"] == "old-refresh" + + +def test_failed_refresh_does_not_replace_rotating_refresh_token(tmp_path): + storage = Storage(tmp_path) + original = {"access_token": "old", "refresh_token": "still-needed", "expires_at": 0} + storage.write_json(TOKEN_FILE, original) + session = Session([Response({"status": 401, "error": "invalid refresh token"})]) + client = WithingsClient(storage, "id", "secret", "redirect", session) + + with pytest.raises(WithingsError, match="refresh"): + client.access_token() + assert storage.read_json(TOKEN_FILE)["refresh_token"] == "still-needed" + assert json.loads((tmp_path / TOKEN_FILE).read_text()) == original diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..838e4d0 --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,95 @@ +import base64 +from datetime import datetime, timezone +import json + +import pytest + +from withings_sync.garmin import GarminClient, token_expiry +from withings_sync.storage import Storage +from withings_sync.sync import SyncService + + +class FakeWithings: + def __init__(self, groups): + self.groups = groups + self.calls = [] + + def get_measurements(self, start, end): + self.calls.append((start, end)) + return self.groups + + def get_height(self): + return 1.75 + + +class FakeGarmin: + def __init__(self): + self.uploads = [] + + def upload(self, measurement): + self.uploads.append(measurement.source_id) + + +def test_successful_upload_state_deduplicates_repeated_sync(tmp_path): + storage = Storage(tmp_path) + withings = FakeWithings( + [ + { + "grpid": 987, + "date": 1786530600, + "measures": [{"type": 1, "value": 71000, "unit": -3}], + } + ] + ) + garmin = FakeGarmin() + service = SyncService(storage, withings, garmin) + + assert service.run_once() == 1 + assert service.run_once() == 0 + state = storage.read_json("state.json") + assert garmin.uploads == ["987"] + assert state["uploaded_measurements"]["987"] == datetime.fromtimestamp( + 1786530600, timezone.utc + ).isoformat() + + +def test_garmin_state_commits_only_after_upload_succeeds(tmp_path): + storage = Storage(tmp_path) + withings = FakeWithings( + [ + { + "grpid": 988, + "date": 1786530600, + "measures": [{"type": 1, "value": 71000, "unit": -3}], + } + ] + ) + + class FailedGarmin: + def upload(self, measurement): + raise RuntimeError("upload rejected") + + service = SyncService(storage, withings, FailedGarmin()) + with pytest.raises(RuntimeError, match="upload rejected"): + service.run_once() + + assert storage.read_json("state.json") == {} + + +def test_garmin_status_reads_saved_tokens_and_expiry(tmp_path): + storage = Storage(tmp_path) + expiry = int(datetime.now(timezone.utc).timestamp()) + 600 + payload = base64.urlsafe_b64encode( + json.dumps({"exp": expiry}).encode() + ).decode().rstrip("=") + token = f"header.{payload}.signature" + storage.write_json( + "garmin_tokens.json", + {"di_token": token, "di_refresh_token": "refresh-token"}, + ) + + connected, token_expires = GarminClient(storage).status() + + assert connected + assert token_expires == datetime.fromtimestamp(expiry, timezone.utc) + assert token_expiry(None) is None diff --git a/tests/test_withings.py b/tests/test_withings.py new file mode 100644 index 0000000..8f59d51 --- /dev/null +++ b/tests/test_withings.py @@ -0,0 +1,48 @@ +from datetime import datetime, timezone + +from withings_sync.withings import ( + WithingsClient, + parse_measurement_groups, +) + + +def test_parse_weight_composition_and_bmi(): + groups = [ + { + "grpid": 1234, + "date": 1786530600, + "measures": [ + {"type": 1, "value": 72350, "unit": -3}, + {"type": 6, "value": 2120, "unit": -2}, + {"type": 77, "value": 39000, "unit": -3}, + {"type": 76, "value": 32800, "unit": -3}, + {"type": 88, "value": 3100, "unit": -3}, + {"type": 170, "value": 8, "unit": 0}, + ], + }, + {"grpid": 1235, "date": 1786530700, "measures": [{"type": 6, "value": 20}]}, + ] + + measurements = parse_measurement_groups(groups, height_m=1.75) + item = measurements[0] + + assert item.source_id == "1234" + assert item.timestamp == datetime.fromtimestamp(1786530600, timezone.utc) + assert item.weight == 72.35 + assert item.percent_fat == 21.2 + assert item.percent_hydration == 53.9 + assert item.muscle_mass == 32.8 + assert item.bone_mass == 3.1 + assert item.visceral_fat == 8 + assert item.bmi == 23.6 + assert len(measurements) == 1 + + +def test_extract_code_accepts_code_or_validated_redirect_url(): + assert WithingsClient.extract_code(" auth-code ") == "auth-code" + assert ( + WithingsClient.extract_code( + "https://example.org/callback?code=abc&state=s1", "s1" + ) + == "abc" + ) diff --git a/withings_sync/config/withings_app.json b/withings_sync/config/withings_app.json deleted file mode 100644 index 95295d7..0000000 --- a/withings_sync/config/withings_app.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "callback_url": "https://jaroslawhartman.github.io/withings-sync/contrib/withings.html", - "client_id": "183e03e1f363110b3551f96765c98c10e8f1aa647a37067a1cb64bbbaf491626", - "consumer_secret": "a75d655c985d9e6391df1514c16719ef7bd69fa7c5d3fd0eac2e2b0ed48f1765" -} diff --git a/withings_sync/fit.py b/withings_sync/fit.py index 4c2e5f4..fa08cd2 100644 --- a/withings_sync/fit.py +++ b/withings_sync/fit.py @@ -1,316 +1,177 @@ -from io import BytesIO -from struct import pack -from struct import unpack -from datetime import datetime -import time +"""Small FIT encoder for Garmin weight-scale/body-composition records.""" +from __future__ import annotations -def _calcCRC(crc, byte): - table = [0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401, - 0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400] - # compute checksum of lower four bits of byte - tmp = table[crc & 0xF] - crc = (crc >> 4) & 0x0FFF - crc = crc ^ tmp ^ table[byte & 0xF] - # now compute checksum of upper four bits of byte - tmp = table[crc & 0xF] - crc = (crc >> 4) & 0x0FFF - crc = crc ^ tmp ^ table[(byte >> 4) & 0xF] - return crc - - -class FitBaseType(object): - """BaseType Definition - - see FIT Protocol Document(Page.20)""" - enum = {'#': 0, 'endian': 0, 'field': 0x00, 'name': 'enum', 'invalid': 0xFF, 'size': 1} - sint8 = {'#': 1, 'endian': 0, 'field': 0x01, 'name': 'sint8', 'invalid': 0x7F, 'size': 1} - uint8 = {'#': 2, 'endian': 0, 'field': 0x02, 'name': 'uint8', 'invalid': 0xFF, 'size': 1} - sint16 = {'#': 3, 'endian': 1, 'field': 0x83, 'name': 'sint16', 'invalid': 0x7FFF, 'size': 2} - uint16 = {'#': 4, 'endian': 1, 'field': 0x84, 'name': 'uint16', 'invalid': 0xFFFF, 'size': 2} - sint32 = {'#': 5, 'endian': 1, 'field': 0x85, 'name': 'sint32', 'invalid': 0x7FFFFFFF, 'size': 4} - uint32 = {'#': 6, 'endian': 1, 'field': 0x86, 'name': 'uint32', 'invalid': 0xFFFFFFFF, 'size': 4} - string = {'#': 7, 'endian': 0, 'field': 0x07, 'name': 'string', 'invalid': 0x00, 'size': 1} - float32 = {'#': 8, 'endian': 1, 'field': 0x88, 'name': 'float32', 'invalid': 0xFFFFFFFF, 'size': 2} - float64 = {'#': 9, 'endian': 1, 'field': 0x89, 'name': 'float64', 'invalid': 0xFFFFFFFFFFFFFFFF, 'size': 4} - uint8z = {'#': 10, 'endian': 0, 'field': 0x0A, 'name': 'uint8z', 'invalid': 0x00, 'size': 1} - uint16z = {'#': 11, 'endian': 1, 'field': 0x8B, 'name': 'uint16z', 'invalid': 0x0000, 'size': 2} - uint32z = {'#': 12, 'endian': 1, 'field': 0x8C, 'name': 'uint32z', 'invalid': 0x00000000, 'size': 4} - byte = {'#': 13, 'endian': 0, 'field': 0x0D, 'name': 'byte', 'invalid': 0xFF, - 'size': 1} # array of byte, field is invalid if all bytes are invalid - - @staticmethod - def get_format(basetype): - formats = { - 0: 'B', 1: 'b', 2: 'B', 3: 'h', 4: 'H', 5: 'i', 6: 'I', 7: 's', 8: 'f', - 9: 'd', 10: 'B', 11: 'H', 12: 'I', 13: 'c', - } - return formats[basetype['#']] - - @staticmethod - def pack(basetype, value): - """function to avoid DeprecationWarning""" - if basetype['#'] in (1, 2, 3, 4, 5, 6, 10, 11, 12): - value = int(value) - fmt = FitBaseType.get_format(basetype) - return pack(fmt, value) +from datetime import datetime, timezone +import struct +from typing import Any +FIT_EPOCH = 631065600 +CRC_TABLE = ( + 0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401, + 0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400, +) +UINT8 = (2, 1, 0x02, 0xFF, "B") +UINT16 = (4, 2, 0x84, 0xFFFF, "H") +UINT32 = (6, 4, 0x86, 0xFFFFFFFF, "I") +UINT32Z = (12, 4, 0x8C, 0, "I") +ENUM = (0, 1, 0x00, 0xFF, "B") -class Fit(object): - HEADER_SIZE = 12 - # not sure if this is the mesg_num - GMSG_NUMS = { - 'file_id': 0, - 'device_info': 23, - 'weight_scale': 30, - 'file_creator': 49, - 'blood_pressure': 51, - } +def _crc_byte(crc: int, byte: int) -> int: + tmp = CRC_TABLE[crc & 0x0F] + crc = (crc >> 4) & 0x0FFF + crc ^= tmp ^ CRC_TABLE[byte & 0x0F] + tmp = CRC_TABLE[crc & 0x0F] + crc = (crc >> 4) & 0x0FFF + crc ^= tmp ^ CRC_TABLE[(byte >> 4) & 0x0F] + return crc -class FitEncoder(Fit): - FILE_TYPE = 9 - LMSG_TYPE_FILE_INFO = 0 - LMSG_TYPE_FILE_CREATOR = 1 - LMSG_TYPE_DEVICE_INFO = 2 +def _crc(data: bytes) -> int: + result = 0 + for byte in data: + result = _crc_byte(result, byte) + return result - def __init__(self): - self.buf = BytesIO() - self.write_header() # create header first - self.device_info_defined = False - def __str__(self): - orig_pos = self.buf.tell() - self.buf.seek(0) - lines = [] - while True: - b = self.buf.read(16) - if not b: - break - lines.append(' '.join(['%02x' % ord(c) for c in b])) - self.buf.seek(orig_pos) - return '\n'.join(lines) +def _fit_timestamp(value: datetime | int | float) -> int: + if isinstance(value, datetime): + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + value = value.timestamp() + result = int(value) - FIT_EPOCH + if result < 0 or result >= UINT32[3]: + raise ValueError("Timestamp is outside the FIT uint32 range.") + return result - def write_header(self, header_size=Fit.HEADER_SIZE, - protocol_version=16, - profile_version=108, - data_size=0, - data_type=b'.FIT'): - self.buf.seek(0) - s = pack('BBHI4s', header_size, protocol_version, profile_version, data_size, data_type) - self.buf.write(s) - def _build_content_block(self, content): - field_defs = [] - values = [] - for num, basetype, value, scale in content: - s = pack('BBB', num, basetype['size'], basetype['field']) - field_defs.append(s) - if value is None: - # invalid value - value = basetype['invalid'] - elif scale is not None: - value *= scale - values.append(FitBaseType.pack(basetype, value)) - return (b''.join(field_defs), b''.join(values)) +class FitEncoderWeight: + """Encode a single Garmin FIT weight-scale file.""" - def write_file_info(self, serial_number=None, time_created=None, manufacturer=None, product=None, number=None): - if time_created is None: - time_created = datetime.now() + def __init__(self) -> None: + self.data = bytearray() + self._file_defined = False + self._creator_defined = False + self._device_defined = False + self._weight_defined = False - content = [ - (3, FitBaseType.uint32z, serial_number, None), - (4, FitBaseType.uint32, self.timestamp(time_created), None), - (1, FitBaseType.uint16, manufacturer, None), - (2, FitBaseType.uint16, product, None), - (5, FitBaseType.uint16, number, None), - (0, FitBaseType.enum, self.FILE_TYPE, None), # type + @staticmethod + def _pack_value(base_type: tuple[int, int, int, int, str], value: Any) -> bytes: + _, _, _, invalid, fmt = base_type + if value is None: + value = invalid + else: + value = int(round(value)) + if value < 0 or value >= invalid: + raise ValueError(f"FIT value is outside {fmt} range.") + return struct.pack("<" + fmt, value) + + def _write_message( + self, + local: int, + fields: list[tuple[int, tuple[int, int, int, int, str], Any]], + values: list[Any] | None = None, + definition: bool = True, + ) -> None: + if definition: + global_message = {0: 0, 1: 49, 2: 23, 3: 30}[local] + self.data.extend( + struct.pack(" None: + created = time_created or datetime.now(timezone.utc) + values = [ + serial_number, + _fit_timestamp(created), + manufacturer, + product, + number, + 9, ] - fields, values = self._build_content_block(content) - - # create fixed content - msg_number = self.GMSG_NUMS['file_id'] - fixed_content = pack('BBHB', 0, 0, msg_number, len(content)) # reserved, architecture(0: little endian) - - self.buf.write(b''.join([ - # definition - self.record_header(definition=True, lmsg_type=self.LMSG_TYPE_FILE_INFO), - fixed_content, - fields, - # record - self.record_header(lmsg_type=self.LMSG_TYPE_FILE_INFO), - values, - ])) - - def write_file_creator(self, software_version=None, hardware_version=None): - content = [ - (0, FitBaseType.uint16, software_version, None), - (1, FitBaseType.uint8, hardware_version, None), + fields = [ + (3, UINT32Z, values[0]), (4, UINT32, values[1]), (1, UINT16, values[2]), + (2, UINT16, values[3]), (5, UINT16, values[4]), (0, ENUM, values[5]), ] - fields, values = self._build_content_block(content) - - msg_number = self.GMSG_NUMS['file_creator'] - fixed_content = pack('BBHB', 0, 0, msg_number, len(content)) # reserved, architecture(0: little endian) - self.buf.write(b''.join([ - # definition - self.record_header(definition=True, lmsg_type=self.LMSG_TYPE_FILE_CREATOR), - fixed_content, - fields, - # record - self.record_header(lmsg_type=self.LMSG_TYPE_FILE_CREATOR), - values, - ])) - - def write_device_info(self, timestamp, serial_number=None, cum_operationg_time=None, manufacturer=None, - product=None, software_version=None, battery_voltage=None, device_index=None, - device_type=None, hardware_version=None, battery_status=None): - content = [ - (253, FitBaseType.uint32, self.timestamp(timestamp), 1), - (3, FitBaseType.uint32z, serial_number, 1), - (7, FitBaseType.uint32, cum_operationg_time, 1), - (8, FitBaseType.uint32, None, None), # unknown field(undocumented) - (2, FitBaseType.uint16, manufacturer, 1), - (4, FitBaseType.uint16, product, 1), - (5, FitBaseType.uint16, software_version, 100), - (10, FitBaseType.uint16, battery_voltage, 256), - (0, FitBaseType.uint8, device_index, 1), - (1, FitBaseType.uint8, device_type, 1), - (6, FitBaseType.uint8, hardware_version, 1), - (11, FitBaseType.uint8, battery_status, None), + self._write_message(0, fields, values, definition=not self._file_defined) + self._file_defined = True + + def write_file_creator( + self, software_version: int | None = None, hardware_version: int | None = None + ) -> None: + fields = [(0, UINT16, software_version), (1, UINT8, hardware_version)] + self._write_message(1, fields, definition=not self._creator_defined) + self._creator_defined = True + + def write_device_info(self, timestamp: datetime | int | float) -> None: + fields: list[tuple[int, tuple[int, int, int, int, str], Any]] = [ + (253, UINT32, None), (3, UINT32Z, None), (7, UINT32, None), + (8, UINT32, None), (2, UINT16, None), (4, UINT16, None), + (5, UINT16, None), (10, UINT16, None), (0, UINT8, None), + (1, UINT8, None), (6, UINT8, None), (11, UINT8, None), ] - fields, values = self._build_content_block(content) - - if not self.device_info_defined: - header = self.record_header(definition=True, lmsg_type=self.LMSG_TYPE_DEVICE_INFO) - msg_number = self.GMSG_NUMS['device_info'] - fixed_content = pack('BBHB', 0, 0, msg_number, len(content)) # reserved, architecture(0: little endian) - self.buf.write(header + fixed_content + fields) - self.device_info_defined = True - - header = self.record_header(lmsg_type=self.LMSG_TYPE_DEVICE_INFO) - self.buf.write(header + values) - - def record_header(self, definition=False, lmsg_type=0): - msg = 0 - if definition: - msg = 1 << 6 # 6th bit is a definition message - return pack('B', msg + lmsg_type) - - def crc(self): - orig_pos = self.buf.tell() - self.buf.seek(0) - - crc = 0 - while True: - b = self.buf.read(1) - if not b: - break - crc = _calcCRC(crc, unpack('b', b)[0]) - self.buf.seek(orig_pos) - return pack('H', crc) - - def finish(self): - """re-weite file-header, then append crc to end of file""" - data_size = self.get_size() - self.HEADER_SIZE - self.write_header(data_size=data_size) - crc = self.crc() - self.buf.seek(0, 2) - self.buf.write(crc) - - def get_size(self): - orig_pos = self.buf.tell() - self.buf.seek(0, 2) - size = self.buf.tell() - self.buf.seek(orig_pos) - return size - - def getvalue(self): - return self.buf.getvalue() - - def timestamp(self, t): - """the timestamp in fit protocol is seconds since - UTC 00:00 Dec 31 1989 (631065600)""" - if isinstance(t, datetime): - t = time.mktime(t.timetuple()) - return t - 631065600 - - -class FitEncoderBloodPressure(FitEncoder): - # Here might be dragons - no idea what lsmg stand for, found 14 somewhere in the deepest web - LMSG_TYPE_BLOOD_PRESSURE = 14 - - def __init__(self): - super().__init__() - self.blood_pressure_monitor_defined = False - - def write_blood_pressure(self, - timestamp, - diastolic_blood_pressure=None, - systolic_blood_pressure=None, - mean_arterial_pressure=None, - map_3_sample_mean=None, - map_morning_values=None, - map_evening_values=None, - heart_rate=None, ): - # BLOOD PRESSURE FILE MESSAGES - content = [ - (253, FitBaseType.uint32, self.timestamp(timestamp), 1), - (0, FitBaseType.uint16, systolic_blood_pressure, 1), - (1, FitBaseType.uint16, diastolic_blood_pressure, 1), - (2, FitBaseType.uint16, mean_arterial_pressure, 1), - (3, FitBaseType.uint16, map_3_sample_mean, 1), - (4, FitBaseType.uint16, map_morning_values, 1), - (5, FitBaseType.uint16, map_evening_values, 1), - (6, FitBaseType.uint8, heart_rate, 1), + values = [_fit_timestamp(timestamp)] + [None] * (len(fields) - 1) + self._write_message(2, fields, values, definition=not self._device_defined) + self._device_defined = True + + def write_weight_scale( + self, + timestamp: datetime | int | float, + weight: int | float, + percent_fat: int | float | None = None, + percent_hydration: int | float | None = None, + visceral_fat_mass: int | float | None = None, + bone_mass: int | float | None = None, + muscle_mass: int | float | None = None, + basal_met: int | float | None = None, + active_met: int | float | None = None, + physique_rating: int | float | None = None, + metabolic_age: int | float | None = None, + visceral_fat_rating: int | float | None = None, + bmi: int | float | None = None, + ) -> None: + fields = [ + (253, UINT32, _fit_timestamp(timestamp)), + (0, UINT16, _scaled(weight, 100)), + (1, UINT16, _scaled(percent_fat, 100)), + (2, UINT16, _scaled(percent_hydration, 100)), + (3, UINT16, _scaled(visceral_fat_mass, 100)), + (4, UINT16, _scaled(bone_mass, 100)), + (5, UINT16, _scaled(muscle_mass, 100)), + (7, UINT16, _scaled(basal_met, 4)), + (9, UINT16, _scaled(active_met, 4)), + (8, UINT8, physique_rating), + (10, UINT8, metabolic_age), + (11, UINT8, visceral_fat_rating), + (13, UINT16, _scaled(bmi, 10)), ] - fields, values = self._build_content_block(content) - - if not self.blood_pressure_monitor_defined: - header = self.record_header(definition=True, lmsg_type=self.LMSG_TYPE_BLOOD_PRESSURE) - msg_number = self.GMSG_NUMS['blood_pressure'] - fixed_content = pack('BBHB', 0, 0, msg_number, len(content)) # reserved, architecture(0: little endian) - self.buf.write(header + fixed_content + fields) - self.blood_pressure_monitor_defined = True + self._write_message(3, fields, definition=not self._weight_defined) + self._weight_defined = True - header = self.record_header(lmsg_type=self.LMSG_TYPE_BLOOD_PRESSURE) - self.buf.write(header + values) + def finish(self) -> None: + data_size = len(self.data) + header = struct.pack(" bytes: + return bytes(self.data) -class FitEncoderWeight(FitEncoder): - LMSG_TYPE_WEIGHT_SCALE = 3 - - def __init__(self): - super().__init__() - self.weight_scale_defined = False - - def write_weight_scale(self, timestamp, weight, percent_fat=None, percent_hydration=None, - visceral_fat_mass=None, bone_mass=None, muscle_mass=None, basal_met=None, - active_met=None, physique_rating=None, metabolic_age=None, - visceral_fat_rating=None, bmi=None): - content = [ - (253, FitBaseType.uint32, self.timestamp(timestamp), 1), - (0, FitBaseType.uint16, weight, 100), - (1, FitBaseType.uint16, percent_fat, 100), - (2, FitBaseType.uint16, percent_hydration, 100), - (3, FitBaseType.uint16, visceral_fat_mass, 100), - (4, FitBaseType.uint16, bone_mass, 100), - (5, FitBaseType.uint16, muscle_mass, 100), - (7, FitBaseType.uint16, basal_met, 4), - (9, FitBaseType.uint16, active_met, 4), - (8, FitBaseType.uint8, physique_rating, 1), - (10, FitBaseType.uint8, metabolic_age, 1), - (11, FitBaseType.uint8, visceral_fat_rating, 1), - (13, FitBaseType.uint16, bmi, 10), - ] - fields, values = self._build_content_block(content) - - if not self.weight_scale_defined: - header = self.record_header(definition=True, lmsg_type=self.LMSG_TYPE_WEIGHT_SCALE) - msg_number = self.GMSG_NUMS['weight_scale'] - fixed_content = pack('BBHB', 0, 0, msg_number, len(content)) # reserved, architecture(0: little endian) - self.buf.write(header + fixed_content + fields) - self.weight_scale_defined = True - header = self.record_header(lmsg_type=self.LMSG_TYPE_WEIGHT_SCALE) - self.buf.write(header + values) +def _scaled(value: int | float | None, factor: int) -> int | float | None: + return None if value is None else value * factor diff --git a/withings_sync/garmin.py b/withings_sync/garmin.py index 58165a2..0089cbf 100644 --- a/withings_sync/garmin.py +++ b/withings_sync/garmin.py @@ -1,118 +1,143 @@ -"""This module handles the Garmin connectivity.""" +"""Garmin Connect authentication and FIT body-composition uploads.""" -import io +from __future__ import annotations + +import base64 +from datetime import datetime, timezone +import getpass +import json import logging -import os - -import garth -# Temporary fix until Garth project merges https://github.com/matin/garth/issues/73 -garth.http.USER_AGENT = {"User-Agent": ("GCM-iOS-5.7.2.1")} - -log = logging.getLogger("garmin") - -HOME = os.getenv("HOME", ".") -GARMIN_SESSION = os.path.abspath(os.path.expanduser(os.getenv('GARMIN_SESSION', os.path.join(HOME, ".garmin_session")))) - - -class LoginFailed(Exception): - """Raised when login fails.""" - - -class APIException(Exception): - """Raised for API exceptions.""" - - -class GarminConnect: - """Main GarminConnect class.""" - - def __init__(self, config_folder=None) -> None: - self.client = garth.Client() - self.config_folder = config_folder - - if config_folder: - self.session_path = os.path.join(config_folder, ".garmin_session") - else: - self.session_path = GARMIN_SESSION - - # Log helpful message if using new config folder and file doesn't exist - if config_folder and not os.path.exists(self.session_path): - home = os.getenv("HOME", ".") - legacy_path = os.path.abspath(os.path.expanduser(os.path.join(home, ".garmin_session"))) - if os.path.exists(legacy_path): - log.info(f"Using new config folder: {self.session_path}") - log.info(f"If you want to use existing session, copy from: {legacy_path}") - - def login(self, email=None, password=None): - """Login to Garmin Connect with session persistence and MFA support.""" - log.debug("Attempting Garmin login") - - if GarminConnect.invalid_garmin_session_config(self): - raise APIException("invalid garmin session path config") - - if os.path.exists(self.session_path): - try: - log.debug("Loading existing Garmin session") - self.client.load(self.session_path) - if self.looks_like_valid_session(): - log.info(f"Successfully loaded Garmin session for user: {self.client.username}") - return - else: - log.warning("Session file exists but appears invalid or expired") - except Exception as ex: - log.warning(f"Failed to load Garmin session: {ex}") - - # Fallback to credential authentication - if not email or not password: - raise APIException( - "No valid session found and no credentials provided. " - "For MFA accounts:" - "1) Authenticate once using Garmin Connect mobile app or web interface, " - "2) Locate the .garmin_session file in your home directory, " - "3) Copy this file to the location specified by GARMIN_SESSION environment variable." - ) - - # Check write permissions BEFORE attempting authentication - session_dir = os.path.dirname(self.session_path) - if session_dir and not os.access(session_dir, os.W_OK): - log.warning(f"Cannot write to session directory: {session_dir}") - +from typing import Callable + +from garminconnect import Garmin +from garminconnect.exceptions import ( + GarminConnectAuthenticationError, + GarminConnectConnectionError, +) + +from withings_sync.fit import FitEncoderWeight +from withings_sync.storage import Storage +from withings_sync.withings import Measurement + +log = logging.getLogger(__name__) +GARMIN_TOKEN_FILE = "garmin_tokens.json" +REAUTH_MESSAGE = ( + "Garmin authentication failed. Run: " + "docker compose run --rm withings-sync auth garmin" +) + + +def token_expiry(token: str | None) -> datetime | None: + if not token: + return None + try: + payload = token.split(".")[1] + payload += "=" * (-len(payload) % 4) + claims = json.loads(base64.urlsafe_b64decode(payload)) + return datetime.fromtimestamp(int(claims["exp"]), timezone.utc) + except (IndexError, KeyError, TypeError, ValueError, json.JSONDecodeError): + return None + + +class GarminClient: + def __init__(self, storage: Storage): + self.storage = storage + self.token_path = storage.directory / GARMIN_TOKEN_FILE + self.client: Garmin | None = None + + def status(self) -> tuple[bool, datetime | None]: try: - log.info("Attempting Garmin authentication with credentials") - self.client.login(email, password) - log.info("Garmin authentication successful") - - except Exception as ex: - raise APIException( - f"Authentication failure: {ex}. " - f"For MFA accounts, credential-based login may not work. " - f"Use the session file method described in the error message above." - ) - - # Save session separately to handle dump failures distinctly + data = json.loads(self.token_path.read_text(encoding="utf-8")) + except FileNotFoundError: + return False, None + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError(f"Cannot read Garmin token file: {exc}") from exc + expiry = token_expiry(data.get("di_token")) + return bool(data.get("di_refresh_token") or data.get("di_token")), expiry + + def _login( + self, + email: str | None = None, + password: str | None = None, + prompt_mfa: Callable[[], str] | None = None, + ) -> Garmin: + self.token_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + client = Garmin(email=email, password=password, prompt_mfa=prompt_mfa) + client.login(tokenstore=str(self.token_path)) + self.client = client + self._persist() + return client + + def connect(self) -> Garmin: + if self.client: + return self.client try: - # Ensure parent directory exists before dumping session - if session_dir: - os.makedirs(session_dir, exist_ok=True) - log.debug("Session directory created/verified") - - self.client.dump(self.session_path) - log.info(f"Successfully saved Garmin session to {self.session_path}") - - except Exception as ex: - raise APIException( - f"Session save failed: {ex}. Authentication succeeded but session could not be persisted. " - f"Check GARMIN_SESSION path, permissions, and available disk space." - ) + return self._login() + except GarminConnectAuthenticationError as exc: + raise RuntimeError(f"{REAUTH_MESSAGE}. Details: {exc}") from exc + except Exception as exc: + raise RuntimeError(f"Garmin Connect is unavailable: {exc}") from exc - def looks_like_valid_session(self) -> bool: - return hasattr(self.client, "username") and self.client.username + def authenticate( + self, + email: str, + password: str, + mfa_code: str | None = None, + ) -> None: + def prompt() -> str: + return mfa_code if mfa_code else input("Garmin MFA code: ").strip() - def invalid_garmin_session_config(self) -> bool: - return not self.session_path + self._login(email=email, password=password, prompt_mfa=prompt) + self._persist() - def upload_file(self, ffile): - """Upload fit file to Garmin Connect.""" - fit_file = io.BytesIO(ffile.getvalue()) - fit_file.name = "withings.fit" - self.client.upload(fit_file) - return True + def authenticate_interactively(self, mfa_code: str | None = None) -> None: + has_tokens, expiry = self.status() + if has_tokens: + try: + self._login() + log.info( + "Existing Garmin token is valid; no password is needed%s.", + f" (access expires {expiry.isoformat()})" if expiry else "", + ) + return + except GarminConnectAuthenticationError: + log.info("Saved Garmin credentials were rejected; a new login is required.") + except GarminConnectConnectionError as exc: + raise RuntimeError(f"Garmin login could not reach Connect: {exc}") from exc + email = input("Garmin email: ").strip() + password = getpass.getpass("Garmin password: ") + self.authenticate(email, password, mfa_code) + + def _persist(self) -> None: + if not self.client: + raise RuntimeError("Garmin client has no authenticated session.") + self.client.client.dump(str(self.token_path)) + + def upload(self, measurement: Measurement) -> None: + client = self.connect() + encoder = FitEncoderWeight() + encoder.write_file_info(time_created=measurement.timestamp) + encoder.write_file_creator() + encoder.write_device_info(timestamp=measurement.timestamp) + encoder.write_weight_scale( + timestamp=measurement.timestamp, + weight=measurement.weight, + percent_fat=measurement.percent_fat, + percent_hydration=measurement.percent_hydration, + bone_mass=measurement.bone_mass, + muscle_mass=measurement.muscle_mass, + visceral_fat_rating=measurement.visceral_fat, + bmi=measurement.bmi, + ) + encoder.finish() + try: + client.client.post( + "connectapi", + "/upload-service/upload", + files={"file": ("body_composition.fit", encoder.getvalue())}, + api=True, + ) + except GarminConnectAuthenticationError as exc: + raise RuntimeError(f"{REAUTH_MESSAGE}. Details: {exc}") from exc + finally: + self._persist() diff --git a/withings_sync/storage.py b/withings_sync/storage.py new file mode 100644 index 0000000..5ef2f43 --- /dev/null +++ b/withings_sync/storage.py @@ -0,0 +1,63 @@ +"""Durable local state shared by authentication and sync commands.""" + +from __future__ import annotations + +from contextlib import contextmanager +import fcntl +import json +import os +from pathlib import Path +import tempfile +from typing import Any, Iterator + + +class Storage: + def __init__(self, directory: str | Path): + self.directory = Path(directory) + self.directory.mkdir(mode=0o700, parents=True, exist_ok=True) + + @contextmanager + def lock(self) -> Iterator[None]: + lock_path = self.directory / "withings-sync.lock" + with lock_path.open("a", encoding="utf-8") as lock_file: + os.chmod(lock_path, 0o600) + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + + def read_json(self, name: str) -> dict[str, Any]: + path = self.directory / name + try: + with path.open(encoding="utf-8") as file: + data = json.load(file) + except FileNotFoundError: + return {} + if not isinstance(data, dict): + raise ValueError(f"Invalid state file: {path}") + return data + + def write_json(self, name: str, data: dict[str, Any]) -> None: + path = self.directory / name + fd, temp_name = tempfile.mkstemp(prefix=f".{name}.", dir=self.directory) + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as file: + json.dump(data, file, indent=2, sort_keys=True) + file.write("\n") + file.flush() + os.fsync(file.fileno()) + os.replace(temp_name, path) + os.chmod(path, 0o600) + dir_fd = os.open(self.directory, os.O_RDONLY) + try: + os.fsync(dir_fd) + finally: + os.close(dir_fd) + except BaseException: + try: + os.unlink(temp_name) + except FileNotFoundError: + pass + raise diff --git a/withings_sync/sync.py b/withings_sync/sync.py index 840e90b..58bd97d 100644 --- a/withings_sync/sync.py +++ b/withings_sync/sync.py @@ -1,628 +1,180 @@ -"""This module syncs measurement data from Withings to Garmin a/o TrainerRoad.""" +"""Command-line application and scheduled Withings-to-Garmin synchronization.""" + +from __future__ import annotations import argparse -import time -import sys -import os +from datetime import datetime, time, timedelta, timezone import logging -import json -from datetime import date, datetime -from importlib.metadata import version -import dotenv - -from withings_sync.withings2 import WithingsAccount -from withings_sync.garmin import GarminConnect -from withings_sync.trainerroad import TrainerRoad -from withings_sync.fit import FitEncoderWeight, FitEncoderBloodPressure - -# Load the environment variables from a .env (dotenv) file. -# This is done prior to importing other modules such that all variables, -# also the ones accessed in those modules, can be set in the dotenv file. -dotenv.load_dotenv() - - -def load_variable(env_var, secrets_file): - """Load a variable from an environment variable or from a secrets file""" - # Try to read the value from the secrets file. Silently fail if the file - # cannot be read and use an empty value - try: - with open(secrets_file, encoding="utf-8") as secret: - value = secret.read().strip("\n") - except OSError: - value = "" - - # Load variable from environment if it exists, otherwise use the - # value read from the secrets file. - return os.getenv(env_var, value) - - -GARMIN_USERNAME = load_variable("GARMIN_USERNAME", "/run/secrets/garmin_username") -GARMIN_PASSWORD = load_variable("GARMIN_PASSWORD", "/run/secrets/garmin_password") -TRAINERROAD_USERNAME = load_variable( - "TRAINERROAD_USERNAME", "/run/secrets/trainerroad_username" -) -TRAINERROAD_PASSWORD = load_variable( - "TRAINERROAD_PASSWORD", "/run/secrets/trainerroad_password" +import os +import re +import sys +import time as clock +from typing import Any + +from withings_sync.garmin import GarminClient +from withings_sync.storage import Storage +from withings_sync.withings import ( + WithingsClient, + new_oauth_state, + parse_measurement_groups, ) +STATE_FILE = "state.json" +log = logging.getLogger("withings_sync") -def get_args(): - """get command-line arguments""" - parser = argparse.ArgumentParser( - description=( - "A tool for synchronisation of Withings " - "(ex. Nokia Health Body) to Garmin Connect" - " and Trainer Road or to provide a json string." - ) - ) - - def date_parser(date_string): - return datetime.strptime(date_string, "%Y-%m-%d") - - parser.add_argument( - "--version", "-V", action="version", version=version("withings-sync") - ) - parser.add_argument( - "--garmin-username", - "--gu", - default=GARMIN_USERNAME, - type=str, - metavar="GARMIN_USERNAME", - help="Username to log in to Garmin Connect.", - ) - parser.add_argument( - "--garmin-password", - "--gp", - default=GARMIN_PASSWORD, - type=str, - metavar="GARMIN_PASSWORD", - help="Password to log in to Garmin Connect.", - ) - - parser.add_argument( - "--trainerroad-username", - "--tu", - default=TRAINERROAD_USERNAME, - type=str, - metavar="TRAINERROAD_USERNAME", - help="Username to log in to TrainerRoad.", - ) - - parser.add_argument( - "--trainerroad-password", - "--tp", - default=TRAINERROAD_PASSWORD, - type=str, - metavar="TRAINERROAD_PASSWORD", - help="Password to log in to TrainerRoad.", - ) - - parser.add_argument( - "--fromdate", - "-f", - type=date_parser, - metavar="DATE", - help="Date to start syncing from. Ex: 2023-12-20", - ) - - parser.add_argument( - "--todate", - "-t", - type=date_parser, - default=date.today(), - metavar="DATE", - help="Date for the last sync. Ex: 2023-12-30", - ) - - parser.add_argument( - "--to-fit", - "-F", - action="store_true", - help="Write output file in FIT format.", - ) - - parser.add_argument( - "--to-json", - "-J", - action="store_true", - help="Write output file in JSON format.", - ) - - parser.add_argument( - "--output", - "-o", - type=str, - metavar="BASENAME", - help="Write downloaded measurements to file.", - ) - - parser.add_argument( - "--no-upload", - action="store_true", - help="Won't upload to Garmin Connect or TrainerRoad.", - ) - - parser.add_argument( - "--features", - nargs="+", - default=[], - metavar="BLOOD_PRESSURE", - help="Enable Features like BLOOD_PRESSURE.", - ) - - log_level_group = parser.add_mutually_exclusive_group() - log_level_group.add_argument("--verbose", "-v", action="store_true", help="Run verbosely.") - log_level_group.add_argument("--silent", "-s", action="store_true", help="Run silently (suppress INFO messages).") - - parser.add_argument( - "--dump-raw", - "-R", - action="store_true", - help=( - "Dump the raw Withings API JSON for the selected date range to file. " - "If --output is provided, the file will be BASENAME.withings_raw.json. " - "Otherwise, a default filename with the date range will be used." - ), - ) - - parser.add_argument( - "--config-folder", - "-c", - type=str, - metavar="CONFIG_FOLDER", - help="Path to config folder for session files (if not specified, uses legacy paths in home directory)", - ) - - return parser.parse_args() - - -def sync_garmin(fit_file, config_folder=None): - """Sync generated fit file to Garmin Connect""" - garmin = GarminConnect(config_folder=config_folder) - garmin.login(ARGS.garmin_username, ARGS.garmin_password) - return garmin.upload_file(fit_file) - - -def sync_trainerroad(last_weight): - """Sync measured weight to TrainerRoad""" - t_road = TrainerRoad(ARGS.trainerroad_username, ARGS.trainerroad_password) - t_road.connect() - logging.info("Current TrainerRoad weight: %s kg ", t_road.weight) - logging.info("Updating TrainerRoad weight to %s kg", last_weight) - wt = round(last_weight, 1) - t_road.weight = wt - t_road.disconnect() - - return wt - - -def generate_fitdata(syncdata): - """Generate fit data from measured data""" - logging.debug("Generating fit data...") - - weight_measurements = list(filter(lambda x: (x["type"] == "weight"), syncdata)) - blood_pressure_measurements = list( - filter(lambda x: (x["type"] == "blood_pressure"), syncdata) - ) - - fit_weight = None - fit_blood_pressure = None - - if len(weight_measurements) > 0: - fit_weight = FitEncoderWeight() - fit_weight.write_file_info() - fit_weight.write_file_creator() - - for record in weight_measurements: - fit_weight.write_device_info(timestamp=record.get("date_time")) - fit_weight.write_weight_scale( - timestamp=record.get("date_time"), - weight=record.get("weight"), - percent_fat=record.get("fat_ratio"), - percent_hydration=record.get("percent_hydration"), - bone_mass=record.get("bone_mass"), - muscle_mass=record.get("muscle_mass"), - bmi=record.get("bmi"), - ) - - fit_weight.finish() - else: - logging.info("No weight data to sync for FIT file") - - if len(blood_pressure_measurements) > 0: - fit_blood_pressure = FitEncoderBloodPressure() - fit_blood_pressure.write_file_info() - fit_blood_pressure.write_file_creator() - - for record in blood_pressure_measurements: - fit_blood_pressure.write_device_info(timestamp=record.get("date_time")) - fit_blood_pressure.write_blood_pressure( - timestamp=record.get("date_time"), - diastolic_blood_pressure=record.get("diastolic_blood_pressure"), - systolic_blood_pressure=record.get("systolic_blood_pressure"), - heart_rate=record.get("heart_pulse"), - ) - - fit_blood_pressure.finish() - else: - logging.info("No blood pressure data to sync for FIT file") - - logging.debug("Fit data generated...") - return fit_weight, fit_blood_pressure - - -def generate_jsondata(syncdata): - """Generate fit data from measured data""" - logging.debug("Generating json data...") - - json_data = {} - for record in syncdata: - sdt = str(record["date_time"]) - json_data[sdt] = {} - for dataentry in record["raw_data"]: - for k, jd in dataentry.json_dict().items(): - json_data[sdt][k] = jd - if "bmi" in record: - json_data[sdt]["BMI"] = {"Value": record["bmi"], "Unit": "kg/m^2"} - if "percent_hydration" in record: - json_data[sdt]["Percent_Hydration"] = { - "Value": record["percent_hydration"], - "Unit": "%", - } - logging.debug("Json data generated...") - return json_data - - -def prepare_syncdata(height, groups): - """Prepare measurement data to be sent""" - syncdata = [] - - last_date_time = None - last_weight = None - - sync_dict = {} - - for group in groups: - # Get extra physical measurements - dt = group.get_datetime() - # create a default group_data - group_data = { - "date_time": group.get_datetime(), - "type": "None", - "raw_data": group.get_raw_data(), - } - - if dt not in sync_dict: - sync_dict[dt] = {} - - if group.get_weight(): - group_data = { - "date_time": group.get_datetime(), - "height": height, - "weight": group.get_weight(), - "fat_ratio": group.get_fat_ratio(), - "muscle_mass": group.get_muscle_mass(), - "hydration": group.get_hydration(), - "percent_hydration": None, - "bone_mass": group.get_bone_mass(), - "pulse_wave_velocity": group.get_pulse_wave_velocity(), - "heart_pulse": group.get_heart_pulse(), - "bmi": None, - "raw_data": group.get_raw_data(), - "type": "weight", - } - elif group.get_diastolic_blood_pressure(): - group_data = { - "date_time": group.get_datetime(), - "diastolic_blood_pressure": group.get_diastolic_blood_pressure(), - "systolic_blood_pressure": group.get_systolic_blood_pressure(), - "heart_pulse": group.get_heart_pulse(), - "raw_data": group.get_raw_data(), - "type": "blood_pressure", - } - - # execute the code below, if this is not a whitelisted entry like weight and blood pressure - if group_data["type"] == "None" or ( - group_data["type"] == "blood_pressure" - and "BLOOD_PRESSURE" not in ARGS.features - ): - collected_metrics = "weight data" - if "BLOOD_PRESSURE" in ARGS.features: - collected_metrics += " or blood pressure" - elif "diastolic_blood_pressure" in group_data: - collected_metrics += ", but blood pressure (to enable sync set --features BLOOD_PRESSURE)" - - logging.info( - "%s This Withings metric contains no %s. Not syncing...", - dt, - collected_metrics, - ) - groupdata_log_raw_data(group_data) - # Do not delete existing data for this timestamp; there may be valid - # weight or blood pressure measurements in other groups with the same timestamp. - # Simply skip this non-whitelisted group. - continue - - if height and "weight" in group_data: - group_data["bmi"] = round( - group_data["weight"] / pow(group_data["height"], 2), 1 - ) - if "hydration" in group_data and group_data["hydration"]: - group_data["percent_hydration"] = round( - group_data["hydration"] * 100.0 / group_data["weight"], 2 - ) - logging.info("%s This Withings metric contains valid data. Syncing...", dt) - logging.debug("%s Detected data: ", dt) - groupdata_log_raw_data(group_data) - if "weight" in group_data: - logging.debug( - "Record: %s, type=%s\n" - "height=%s m, " - "weight=%s kg, " - "fat_ratio=%s %%, " - "muscle_mass=%s kg, " - "percent_hydration=%s %%, " - "bone_mass=%s kg, " - "bmi=%s", - group_data["date_time"], - group_data["type"], - group_data["height"], - group_data["weight"], - group_data["fat_ratio"], - group_data["muscle_mass"], - group_data["percent_hydration"], - group_data["bone_mass"], - group_data["bmi"], - ) - if "diastolic_blood_pressure" in group_data: - logging.debug( - "Record: %s, type=%s\n" - "diastolic_blood_pressure=%s mmHg, " - "systolic_blood_pressure=%s mmHg, " - "heart_pulse=%s BPM, ", - group_data["date_time"], - group_data["type"], - group_data["diastolic_blood_pressure"], - group_data["systolic_blood_pressure"], - group_data["heart_pulse"], - ) - # join groups with same timestamp - # Merge without letting a later non-weight/bp group override an existing valid record - existing = sync_dict[dt] - # merge raw_data lists for richer JSON/debug output - if "raw_data" in group_data: - existing.setdefault("raw_data", []) - existing["raw_data"].extend(group_data["raw_data"]) +def interval_seconds(value: str) -> int: + match = re.fullmatch(r"\s*(\d+)\s*([smhd]?)\s*", value.lower()) + if not match or int(match.group(1)) <= 0: + raise argparse.ArgumentTypeError( + "SYNC_INTERVAL must be a positive number of seconds or use s, m, h, or d." + ) + factor = {"": 1, "s": 1, "m": 60, "h": 3600, "d": 86400}[match.group(2)] + return int(match.group(1)) * factor - # decide resulting type: prefer weight when present - if "type" not in existing or existing["type"] == "None": - existing["type"] = group_data["type"] - elif existing["type"] == "weight" and group_data["type"] != "weight": - # keep weight; do not downgrade to another type - pass - elif existing["type"] != "weight": - # allow switching from non-weight to the new type (e.g., blood_pressure) - existing["type"] = group_data["type"] - # merge scalar fields; keep existing non-None values - for k, v in group_data.items(): - if k in ("type", "raw_data"): +def parse_since(value: str) -> datetime: + try: + return datetime.combine( + datetime.strptime(value, "%Y-%m-%d").date(), time.min, timezone.utc + ) + except ValueError as exc: + raise argparse.ArgumentTypeError("Use YYYY-MM-DD for --since.") from exc + + +class SyncService: + def __init__(self, storage: Storage, withings: WithingsClient, garmin: GarminClient): + self.storage = storage + self.withings = withings + self.garmin = garmin + + def run_once(self, since: datetime | None = None) -> int: + state: dict[str, Any] = self.storage.read_json(STATE_FILE) + last_synced = state.get("last_synced_at") + if since is None: + if last_synced: + since = datetime.fromisoformat(last_synced) - timedelta(days=1) + else: + since = datetime(2009, 1, 1, tzinfo=timezone.utc) + end = datetime.now(timezone.utc) + groups = self.withings.get_measurements(since, end) + height = self.withings.get_height() + measurements = parse_measurement_groups(groups, height) + uploaded = state.setdefault("uploaded_measurements", {}) + if not isinstance(uploaded, dict): + raise ValueError("Invalid uploaded_measurements in sync state.") + count = 0 + for measurement in measurements: + if measurement.source_id in uploaded: continue - if v is not None: - existing[k] = v - - last_measurement_type = None - - # Iterate in chronological order for determinism - for dt in sorted(sync_dict.keys()): - group_data = sync_dict[dt] - # Skip empty or non-whitelisted groups (those that never collected a valid type) - if not group_data or "type" not in group_data or group_data["type"] == "None": - logging.debug("skipping data with timestamp: %s, type: %s", dt, group_data.get("type") if group_data else "empty") - if group_data: - # Log the group_data as JSON (excluding raw_data objects) - debug_data = {k: v for k, v in group_data.items() if k != "raw_data"} - logging.debug("skipped record details: %s", json.dumps(debug_data, indent=2, default=str)) - continue - syncdata.append(group_data) - logging.debug("Processed data: ") - for k, v in group_data.items(): - logging.debug("%s=%s", k, v) - if last_date_time is None or group_data["date_time"] > last_date_time: - last_date_time = group_data["date_time"] - last_measurement_type = group_data["type"] - logging.debug("last_dt: %s last_weight: %s", last_date_time, last_weight) - - if last_measurement_type is None: - logging.error("Invalid or no data detected") - - return last_measurement_type, last_date_time, syncdata - - -def groupdata_log_raw_data(groupdata): - """Logs raw data to debug""" - for dataentry in groupdata["raw_data"]: - logging.debug("%s", dataentry) - # Detailed structure to help map unknown fields - try: - logging.debug( - " -> type_id=%s label=%s unit_str=%s unit_exp=%s raw_value=%s human_value=%s", - getattr(dataentry, "type", None), - getattr(dataentry, "type_s", None), - getattr(dataentry, "unit_s", None), - getattr(dataentry, "unit", None), - getattr(dataentry, "value", None), - round(dataentry.get_value(), 6) if hasattr(dataentry, "get_value") else None, + self.garmin.upload(measurement) + uploaded[measurement.source_id] = measurement.timestamp.isoformat() + prior = state.get("last_synced_at") + if not prior or measurement.timestamp > datetime.fromisoformat(prior): + state["last_synced_at"] = measurement.timestamp.isoformat() + self.storage.write_json(STATE_FILE, state) + count += 1 + log.info( + "Synced %s kg from %s", + measurement.weight, + measurement.timestamp.isoformat(), ) - except Exception as e: - logging.debug(" -> failed to print detailed entry: %s", e) - - -def write_withings_raw_json(filename, raw_json): - """Write raw Withings JSON to file for debugging/mapping purposes""" - logging.info("Writing Withings raw JSON to %s.", filename) - try: - with open(filename, "w", encoding="utf-8") as jf: - json.dump(raw_json, jf, indent=2, default=str) - except OSError: - logging.error("Unable to open output jsonfile! %s", filename) - - -def write_to_fitfile(filename, fit_data): - """Writes fit data to fit file""" - logging.info("Writing fitfile to %s.", filename) - try: - with open(filename, "wb") as fitfile: - fitfile.write(fit_data.getvalue()) - except OSError: - logging.error("Unable to open output fitfile! %s", filename) - - -def write_to_file_when_needed(fit_data_weigth, fit_data_blood_pressure, json_data): - """Write measurements to file when requested""" - if ARGS.output is not None: - if ARGS.to_fit: - if fit_data_weigth is not None: - write_to_fitfile(ARGS.output + ".weight.fit", fit_data_weigth) - if fit_data_blood_pressure is not None: - write_to_fitfile( - ARGS.output + ".blood_pressure.fit", fit_data_blood_pressure - ) - - if ARGS.to_json: - filename = ARGS.output + ".json" - logging.info("Writing jsonfile to %s.", filename) - try: - with open(filename, "w", encoding="utf-8") as jsonfile: - json.dump(json_data, jsonfile, indent=4) - except OSError: - logging.error("Unable to open output jsonfile!") - - -def sync(): - """Sync measurements from Withings to Garmin a/o TrainerRoad""" - # Prepare config folder - config_folder = None - if ARGS.config_folder: - config_folder = os.path.abspath(os.path.expanduser(ARGS.config_folder)) - # Create directory if it doesn't exist - os.makedirs(config_folder, exist_ok=True) - - # Withings API - withings = WithingsAccount(config_folder=config_folder) - - if not ARGS.fromdate: - startdate = withings.get_lastsync() - else: - startdate = int(time.mktime(ARGS.fromdate.timetuple())) - - enddate = int(time.mktime(ARGS.todate.timetuple())) + 86399 - logging.info( - "Fetching measurements from %s to %s", - time.strftime("%Y-%m-%d %H:%M", time.localtime(startdate)), - time.strftime("%Y-%m-%d %H:%M", time.localtime(enddate)), - ) - - height = withings.get_height() - groups = withings.get_measurements(startdate=startdate, enddate=enddate) - - # Only upload if there are measurement returned - if groups is None or len(groups) == 0: - logging.error("No measurements to upload for date or period specified") - return -1 - - last_measurement_type, last_date_time, syncdata = prepare_syncdata(height, groups) - - # dump raw Withings JSON to a file - if ARGS.dump_raw and hasattr(withings, "last_measurements_json"): - if ARGS.output: - raw_filename = ARGS.output + ".withings_raw.json" - else: - # Create a default filename with date range - start_s = time.strftime("%Y%m%d", time.localtime(startdate)) - end_s = time.strftime("%Y%m%d", time.localtime(enddate)) - raw_filename = f"withings_raw_{start_s}_{end_s}.json" - write_withings_raw_json(raw_filename, withings.last_measurements_json) - - fit_data_weight, fit_data_blood_pressure = generate_fitdata(syncdata) - json_data = generate_jsondata(syncdata) - - write_to_file_when_needed(fit_data_weight, fit_data_blood_pressure, json_data) - - if not ARGS.no_upload: - # get weight entries (in case of only blood_pressure) - only_weight_entries = list(filter(lambda x: (x["type"] == "weight"), syncdata)) - last_weight_exists = len(only_weight_entries) > 0 - # Upload to Trainer Road - if ARGS.trainerroad_username and last_weight_exists: - # sort and get last weight - last_weight_measurement = sorted( - only_weight_entries, key=lambda x: x["date_time"] - )[-1] - last_weight = last_weight_measurement["weight"] - logging.info("Trainerroad username set -- attempting to sync") - logging.info(" Last weight %s", last_weight) - logging.info(" Measured %s", last_date_time) - if sync_trainerroad(last_weight): - logging.info("TrainerRoad update done!") - else: - logging.info("No TrainerRoad username or a new measurement - skipping sync") - - # Upload to Garmin Connect - if ARGS.garmin_username and ( - fit_data_weight is not None or fit_data_blood_pressure is not None - ): - logging.debug("attempting to upload fit file...") - gar_wg_state = None - gar_bp_state = None - if fit_data_weight is not None: - gar_wg_state = sync_garmin(fit_data_weight, config_folder) - if gar_wg_state: - logging.info( - "Fit file with weight information uploaded to Garmin Connect" - ) - if fit_data_blood_pressure is not None: - gar_bp_state = sync_garmin(fit_data_blood_pressure, config_folder) - if gar_bp_state: - logging.info( - "Fit file with blood pressure information uploaded to Garmin Connect" - ) - if gar_wg_state or gar_bp_state: - # Save this sync so we don't re-download the same data again (if no range has been specified) - if not ARGS.fromdate: - withings.set_lastsync() - elif ARGS.garmin_username is None: - logging.info("No Garmin username - skipping sync") - else: - logging.info("No Garmin data selected - skipping sync") - else: - logging.info("Skipping upload") - return 0 - - -ARGS = get_args() - + log.info("Sync complete: %d new measurement(s).", count) + return count + + def status(self, healthcheck: bool = False) -> int: + withings_ok, withings_expiry = self.withings.status() + garmin_ok, garmin_expiry = self.garmin.status() + print(f"Withings: {'tokens saved' if withings_ok else 'not authenticated'}" + f"{_expiry_text(withings_expiry)}") + print(f"Garmin: {'tokens saved' if garmin_ok else 'not authenticated'}" + f"{_expiry_text(garmin_expiry)}") + state = self.storage.read_json(STATE_FILE) + print(f"Last sync: {state.get('last_synced_at', 'never')}") + return 0 if (withings_ok and garmin_ok) else (1 if healthcheck else 0) + + +def _expiry_text(expiry: datetime | None) -> str: + return f" (access token expires {expiry.isoformat()})" if expiry else " (expiry unknown)" + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Sync Withings body composition to Garmin Connect.") + subparsers = parser.add_subparsers(dest="command") + auth = subparsers.add_parser("auth", help="Authenticate a service.") + auth_commands = auth.add_subparsers(dest="service", required=True) + auth_commands.add_parser("withings", help="Authorize a personal Withings app.") + garmin_auth = auth_commands.add_parser("garmin", help="Authenticate Garmin Connect, including MFA.") + garmin_auth.add_argument("--mfa-code", help="Supply an authenticator code without an interactive MFA prompt.") + sync = subparsers.add_parser("sync", help="Run a synchronization.") + sync.add_argument("--once", action="store_true", help="Perform one sync and exit.") + sync.add_argument("--since", type=parse_since, help="Backfill from YYYY-MM-DD.") + subparsers.add_parser("status", help="Show saved token and sync status.") + subparsers.choices["status"].add_argument("--healthcheck", action="store_true") + return parser + + +def _new_service(storage: Storage) -> SyncService: + return SyncService(storage, WithingsClient(storage), GarminClient(storage)) + + +def _authenticate_withings(client: WithingsClient) -> None: + state = new_oauth_state() + print("Open this URL and authorize access to your Withings measurements:") + print(client.authorization_url(state)) + print("Paste the full redirect URL or its authorization code.") + value = input("Redirect URL or code: ") + client.authenticate(value, state) + log.info("Withings authorization saved.") + + +def _scheduler(service: SyncService, storage: Storage, interval: int) -> None: + log.info("Starting sync scheduler (interval: %s seconds).", interval) + while True: + try: + with storage.lock(): + service.run_once() + except Exception: + log.exception("Scheduled sync failed; will retry after the next interval.") + clock.sleep(interval) -def main(): - """Main""" - if ARGS.verbose: - log_level = logging.DEBUG - elif ARGS.silent: - log_level = logging.WARNING - else: - log_level = logging.INFO +def main() -> None: logging.basicConfig( - level=log_level, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - stream=sys.stdout, + level=os.getenv("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) - logging.debug("withings-sync script version %s", version("withings-sync")) - logging.debug("Script invoked with the following arguments: %s", ARGS) - - if sys.version_info < (3, 12): - print("Sorry, requires at least Python3.12.") + parser = _build_parser() + args = parser.parse_args() + storage = Storage(os.getenv("DATA_DIR", "./data")) + try: + if args.command == "auth" and args.service == "withings": + with storage.lock(): + _authenticate_withings(WithingsClient(storage)) + elif args.command == "auth" and args.service == "garmin": + with storage.lock(): + GarminClient(storage).authenticate_interactively(args.mfa_code) + log.info("Garmin authentication saved.") + elif args.command == "sync": + service = _new_service(storage) + if args.once or args.since: + with storage.lock(): + service.run_once(args.since) + else: + _scheduler(service, storage, interval_seconds(os.getenv("SYNC_INTERVAL", "1h"))) + elif args.command == "status": + if args.healthcheck: + sys.exit(_new_service(storage).status(args.healthcheck)) + with storage.lock(): + sys.exit(_new_service(storage).status()) + else: + _scheduler(_new_service(storage), storage, interval_seconds(os.getenv("SYNC_INTERVAL", "1h"))) + except KeyboardInterrupt: + log.info("Stopped.") + except Exception as exc: + log.error("%s", exc) sys.exit(1) - sync() + +if __name__ == "__main__": + main() diff --git a/withings_sync/trainerroad.py b/withings_sync/trainerroad.py deleted file mode 100644 index c40daa4..0000000 --- a/withings_sync/trainerroad.py +++ /dev/null @@ -1,211 +0,0 @@ -import requests -import json -import logging - -logger = logging.getLogger(__name__) - - -class TrainerRoad: - _ftp = 'ftp' - _weight = 'weightKg' - _units_metric = 'kmh' - _units_imperial = 'mph' - _numerical_verify = (_ftp, _weight) - _login_url = 'https://www.trainerroad.com/app/login' - _logout_url = 'https://www.trainerroad.com/app/logout' - _profile_api_url = 'https://www.trainerroad.com/app/api/profile/rider-information' - _download_tcx_url = 'http://www.trainerroad.com/cycling/rides/download' - _workouts_url = 'https://api.trainerroad.com/api/careerworkouts' - - def __init__(self, username, password): - self._username = username - self._password = password - self._session = None - - def connect(self): - self._session = requests.Session() - - data = {'Username': self._username, - 'Password': self._password} - - r = self._session.post(self._login_url, data=data, - allow_redirects=False) - - if r.status_code not in [200, 302]: - raise RuntimeError("Error loging in to TrainerRoad (Code {})" - .format(r.status_code)) - - logger.info('Logged into TrainerRoad as "{}"'.format(self._username)) - - def disconnect(self): - r = self._session.get(self._logout_url, allow_redirects=False) - if r.status_code not in [200, 302]: - raise RuntimeError("Error loging out of TrainerRoad (Code {})" - .format(r.status_code)) - - self._session = None - logger.info('Logged out of TrainerRoad as "{}"'.format(self._username)) - - def __enter__(self): - self.connect() - return self - - def __exit__(self, exc_type, exc_value, exc_traceback): - self.disconnect() - - - - def _get(self, url): - if self._session is None: - raise RuntimeError('Not Connected') - - # Add browser-like headers for API calls (camelCase JSON format) - headers = { - 'Accept': 'application/json, text/plain, */*', - 'Referer': 'https://www.trainerroad.com/app/profile/rider-information', - 'trainerroad-jsonformat': 'camel-case', - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0' - } - - r = self._session.get(url, headers=headers) - - if r.status_code != 200: - raise RuntimeError("Error getting info from TrainerRoad (Code {})" - .format(r.status_code)) - - return r - - def _post(self, url, data): - if self._session is None: - raise RuntimeError('Not Connected') - - r = self._session.post(url, data) - - if r.status_code != 200: - raise RuntimeError("Error posting info to TrainerRoad (Code {})" - .format(r.status_code)) - - return r - - def _read_profile(self): - """Read profile data from TrainerRoad JSON API""" - r = self._get(self._profile_api_url) - - if r.status_code != 200: - raise RuntimeError("Error getting profile from TrainerRoad (Code {})" - .format(r.status_code)) - - profile_data = r.json() - logger.debug("Profile API response: {}".format(json.dumps(profile_data, indent=2))) - - return profile_data - - def _write_profile(self, new_values): - """Write profile data to TrainerRoad JSON API""" - # Read current values - data = self._read_profile() - original_data = data.copy() - - logger.info("Current profile values: Weight={}, FTP={}".format( - data.get(self._weight), data.get(self._ftp))) - - # Update values with new_values - for key, value in new_values.items(): - if key == self._weight and data.get('Units') == self._units_imperial: - value = round(value/0.45359237, 1) - logger.debug("Converting Weight to lbs {}".format(value)) - - data[key] = value - - logger.info("Updating profile: Weight={}, FTP={}".format( - data.get(self._weight), data.get(self._ftp))) - - # Send PUT request with JSON data (exact browser headers) - headers = { - 'Accept': 'application/json, text/plain, */*', - 'Content-Type': 'application/json', - 'Origin': 'https://www.trainerroad.com', - 'Referer': 'https://www.trainerroad.com/app/profile/rider-information', - 'trainerroad-jsonformat': 'camel-case', - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0' - } - r = self._session.put(self._profile_api_url, json=data, headers=headers) - - logger.debug("PUT request status: {}, response: {}".format(r.status_code, r.text[:200])) - - if r.status_code not in [200, 204]: - raise RuntimeError("Error updating TrainerRoad profile (Code {})".format(r.status_code)) - - # Verify the changes - updated_data = self._read_profile() - logger.info("Profile updated successfully: Weight={}, FTP={}".format( - updated_data.get(self._weight), updated_data.get(self._ftp))) - - # Verify numerical fields - for key in self._numerical_verify: - if key in new_values: - expected = float(new_values[key]) - if key == self._weight and original_data.get('Units') == self._units_imperial: - expected = round(expected/0.45359237, 1) - actual = float(updated_data.get(key, 0)) - if abs(expected - actual) > 0.1: # Allow small rounding differences - raise RuntimeError('Failed to verify numerical key {}: expected {}, got {}' - .format(key, expected, actual)) - - return - - @property - def ftp(self): - """Get current FTP value from TrainerRoad""" - values = self._read_profile() - return values.get(self._ftp) - - @ftp.setter - def ftp(self, value): - """Set FTP value in TrainerRoad""" - self._write_profile({self._ftp: value}) - - @property - def weight(self): - """Get current weight value from TrainerRoad""" - values = self._read_profile() - return values.get(self._weight) - - @weight.setter - def weight(self, value): - """Set weight value in TrainerRoad""" - self._write_profile({self._weight: value}) - - def download_tcx(self, id): - res = self._session.get('{}/{}'.format(self._download_tcx_url, str(id))) - if res.status_code != 200: - raise RuntimeError("Unable to download (code = {})" - .format(res.status_code)) - - return res.text - - def get_workouts(self): - res = self._session.get(self._workouts_url) - if res.status_code != 200: - raise RuntimeError("Unable to download (code = {})" - .format(res.status_code)) - - data = json.loads(res.text) - logger.debug(json.dumps(data, indent=4, sort_keys=True)) - - logger.info('Recieved info on {} workouts'.format(len(data))) - - return data - - def get_workout(self, guid): - res = self._session.get(self._workout_url - + '?guid={}'.format(str(guid))) - - if res.status_code != 200: - raise RuntimeError('Unable to get workout "{}" (Code = {})' - .format(guid, res.status_code)) - - data = json.loads(res.text) - logger.debug(json.dumps(data, indent=4, sort_keys=True)) - - return data diff --git a/withings_sync/withings.py b/withings_sync/withings.py new file mode 100644 index 0000000..429ff87 --- /dev/null +++ b/withings_sync/withings.py @@ -0,0 +1,278 @@ +"""Withings OAuth and weight-measurement API.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import hashlib +import json +import math +import os +from typing import Any +from urllib.parse import parse_qs, urlencode, urlparse +import uuid + +import requests + +from withings_sync.storage import Storage + +AUTHORIZE_URL = "https://account.withings.com/oauth2_user/authorize2" +TOKEN_URL = "https://wbsapi.withings.net/v2/oauth2" +MEASURE_URL = "https://wbsapi.withings.net/measure" +TOKEN_FILE = "withings.json" + + +class WithingsError(RuntimeError): + """Raised when a Withings operation cannot complete.""" + + +@dataclass(frozen=True) +class Measurement: + source_id: str + timestamp: datetime + weight: float + percent_fat: float | None + percent_hydration: float | None + bone_mass: float | None + muscle_mass: float | None + bmi: float | None + visceral_fat: float | None = None + + +def _scaled_value(measure: dict[str, Any]) -> float | None: + value = measure.get("value") + unit = measure.get("unit", 0) + if value is None: + return None + try: + result = float(value) * (10 ** int(unit)) + if not math.isfinite(result): + raise ValueError("measurement must be finite") + return round(result, 2) + except (TypeError, ValueError, OverflowError) as exc: + raise WithingsError(f"Invalid measurement value: {measure!r}") from exc + + +def parse_measurement_groups( + groups: list[dict[str, Any]], height_m: float | None = None +) -> list[Measurement]: + result = [] + for group in groups: + try: + timestamp = datetime.fromtimestamp(int(group["date"]), timezone.utc) + measures = { + int(item["type"]): _scaled_value(item) + for item in group.get("measures", []) + } + except (KeyError, TypeError, ValueError, OverflowError) as exc: + raise WithingsError(f"Invalid Withings measurement group: {group!r}") from exc + weight = measures.get(1) + if weight is None or weight <= 0: + continue + bmi = ( + round(weight / (height_m**2), 1) + if height_m and math.isfinite(height_m) and height_m > 0 + else None + ) + hydration = measures.get(77) + result.append( + Measurement( + source_id=str( + group.get("grpid") + or hashlib.sha256( + json.dumps(group, sort_keys=True).encode("utf-8") + ).hexdigest() + ), + timestamp=timestamp, + weight=weight, + percent_fat=measures.get(6), + percent_hydration=( + round(hydration * 100 / weight, 2) if hydration is not None else None + ), + bone_mass=measures.get(88), + muscle_mass=measures.get(76), + bmi=bmi, + visceral_fat=measures.get(170), + ) + ) + return sorted(result, key=lambda measure: (measure.timestamp, measure.source_id)) + + +class WithingsClient: + def __init__( + self, + storage: Storage, + client_id: str | None = None, + client_secret: str | None = None, + redirect_uri: str | None = None, + session: requests.Session | None = None, + ): + self.storage = storage + self.client_id = client_id or os.getenv("WITHINGS_CLIENT_ID", "") + self.client_secret = client_secret or os.getenv("WITHINGS_CLIENT_SECRET", "") + self.redirect_uri = redirect_uri or os.getenv("WITHINGS_REDIRECT_URI", "") + self.session = session or requests.Session() + + def _require_app(self) -> None: + if not all((self.client_id, self.client_secret, self.redirect_uri)): + raise WithingsError( + "Set WITHINGS_CLIENT_ID, WITHINGS_CLIENT_SECRET, and " + "WITHINGS_REDIRECT_URI in .env before authenticating." + ) + + def authorization_url(self, state: str) -> str: + self._require_app() + return f"{AUTHORIZE_URL}?{urlencode({'response_type': 'code', 'client_id': self.client_id, 'state': state, 'scope': 'user.metrics', 'redirect_uri': self.redirect_uri})}" + + @staticmethod + def extract_code(value: str, expected_state: str | None = None) -> str: + value = value.strip() + if value.startswith(("http://", "https://")): + query = parse_qs(urlparse(value).query) + code = query.get("code", [""])[0] + returned_state = query.get("state", [None])[0] + if expected_state and returned_state != expected_state: + raise WithingsError("The redirect URL state did not match this login.") + else: + code = value + if not code: + raise WithingsError("No authorization code was found in the supplied value.") + return code + + def exchange_code(self, code: str) -> None: + self._require_app() + response = self.session.post( + TOKEN_URL, + data={ + "action": "requesttoken", + "grant_type": "authorization_code", + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": code, + "redirect_uri": self.redirect_uri, + }, + timeout=30, + ) + response.raise_for_status() + payload = response.json() + self._check_response(payload, "authorization code exchange") + self._save_tokens(payload["body"]) + + def _check_response(self, payload: dict[str, Any], action: str) -> None: + if payload.get("status") != 0: + raise WithingsError( + f"Withings {action} failed (status {payload.get('status')}): " + f"{payload.get('error') or payload.get('body') or 'unknown API error'}" + ) + + def _save_tokens(self, tokens: dict[str, Any]) -> None: + try: + access_token = tokens["access_token"] + refresh_token = tokens["refresh_token"] + except KeyError as exc: + raise WithingsError("Withings token response omitted a required token.") from exc + expires_in = int(tokens.get("expires_in", 10800)) + self.storage.write_json( + TOKEN_FILE, + { + "access_token": access_token, + "refresh_token": refresh_token, + "userid": tokens.get("userid"), + "expires_at": int(datetime.now(timezone.utc).timestamp()) + expires_in, + }, + ) + + def _tokens(self) -> dict[str, Any]: + return self.storage.read_json(TOKEN_FILE) + + def access_token(self) -> str: + self._require_app() + tokens = self._tokens() + if not tokens.get("refresh_token"): + raise WithingsError("Withings is not authenticated. Run: docker compose run --rm withings-sync auth withings") + if int(tokens.get("expires_at", 0)) <= int(datetime.now(timezone.utc).timestamp()) + 300: + response = self.session.post( + TOKEN_URL, + data={ + "action": "requesttoken", + "grant_type": "refresh_token", + "client_id": self.client_id, + "client_secret": self.client_secret, + "refresh_token": tokens["refresh_token"], + }, + timeout=30, + ) + response.raise_for_status() + payload = response.json() + self._check_response(payload, "token refresh") + self._save_tokens(payload["body"]) + tokens = self._tokens() + return str(tokens["access_token"]) + + def get_measurements(self, start: datetime, end: datetime) -> list[dict[str, Any]]: + token = self.access_token() + groups: list[dict[str, Any]] = [] + offset = 0 + while True: + response = self.session.post( + MEASURE_URL, + params={"action": "getmeas"}, + data={ + "access_token": token, + "category": 1, + "startdate": int(start.timestamp()), + "enddate": int(end.timestamp()), + "offset": offset, + }, + timeout=30, + ) + response.raise_for_status() + payload = response.json() + self._check_response(payload, "measurement request") + body = payload.get("body") or {} + page = body.get("measuregrps") or [] + groups.extend(page) + if not body.get("more") or not page: + break + next_offset = int(body.get("offset", offset + len(page))) + if next_offset <= offset: + raise WithingsError("Withings pagination did not advance its offset.") + offset = next_offset + return groups + + def get_height(self) -> float | None: + token = self.access_token() + response = self.session.post( + MEASURE_URL, + params={"action": "getmeas"}, + data={"access_token": token, "meastype": 4, "category": 1}, + timeout=30, + ) + response.raise_for_status() + payload = response.json() + self._check_response(payload, "height request") + groups = (payload.get("body") or {}).get("measuregrps") or [] + heights = [ + (int(group.get("date", 0)), _scaled_value(measure)) + for group in groups + for measure in group.get("measures", []) + if int(measure.get("type", -1)) == 4 and _scaled_value(measure) is not None + ] + return max(heights, default=(0, None), key=lambda item: item[0])[1] + + def authenticate(self, value: str, expected_state: str | None = None) -> None: + self.exchange_code(self.extract_code(value, expected_state)) + + def status(self) -> tuple[bool, datetime | None]: + tokens = self._tokens() + if not tokens.get("refresh_token"): + return False, None + expiry = tokens.get("expires_at") + return ( + True, + datetime.fromtimestamp(int(expiry), timezone.utc) if expiry else None, + ) + + +def new_oauth_state() -> str: + return uuid.uuid4().hex diff --git a/withings_sync/withings2.py b/withings_sync/withings2.py deleted file mode 100644 index 3f44791..0000000 --- a/withings_sync/withings2.py +++ /dev/null @@ -1,529 +0,0 @@ -"""This module takes care of the communication with Withings.""" -from datetime import date, datetime -import logging -import json -import os -import time -import importlib_resources -import requests - -log = logging.getLogger("withings") - -HOME = os.environ.get("HOME", ".") -AUTHORIZE_URL = "https://account.withings.com/oauth2_user/authorize2" -TOKEN_URL = "https://wbsapi.withings.net/v2/oauth2" -GETMEAS_URL = "https://wbsapi.withings.net/measure?action=getmeas" - - -APP_CONFIG = os.environ.get( - "WITHINGS_APP", - importlib_resources.files(__name__) / "config/withings_app.json", -) -USER_CONFIG = os.environ.get("WITHINGS_USER", HOME + "/.withings_user.json") - - -class WithingsException(Exception): - """Pass WithingsExceptions""" - - -class WithingsConfig: - """This class takes care of the Withings config file""" - - config = {} - config_file = "" - - def __init__(self, config_file): - self.config_file = config_file - self.read() - - def read(self): - """reads config file""" - try: - with open(self.config_file, encoding="utf8") as configfile: - self.config = json.load(configfile) - except (ValueError, FileNotFoundError): - log.error("Can't read config file %s", self.config_file) - self.config = {} - - def write(self): - """writes config file""" - with open(self.config_file, "w", encoding="utf8") as configfile: - json.dump(self.config, configfile, indent=4, sort_keys=True) - - -class WithingsOAuth2: - """This class takes care of the Withings OAuth2 authentication""" - - app_config = user_config = None - - def __init__(self, config_folder=None): - # Determine app config file path with fallback chain - if config_folder: - app_config_path = os.path.join(config_folder, "withings_app.json") - if os.path.exists(app_config_path): - log.info(f"Using app config from: {app_config_path}") - else: - log.warning(f"App config not found at {app_config_path}, falling back to WITHINGS_APP env var or default") - app_config_path = os.environ.get("WITHINGS_APP", APP_CONFIG) - else: - app_config_path = os.environ.get("WITHINGS_APP", APP_CONFIG) - - log.info(f"Loading app config from: {app_config_path}") - app_cfg = WithingsConfig(app_config_path) - self.app_config = app_cfg.config - - # Determine user config file path - if config_folder: - user_config_path = os.path.join(config_folder, ".withings_user.json") - # Always use the new path when config_folder is specified - # The directory will be created when needed - else: - user_config_path = USER_CONFIG - - self.user_cfg = WithingsConfig(user_config_path) - self.user_config = self.user_cfg.config - - if config_folder and not os.path.exists(user_config_path): - legacy_path = HOME + "/.withings_user.json" - if os.path.exists(legacy_path): - log.info(f"Using new config folder: {user_config_path}") - log.info(f"If you want to use existing credentials, copy from: {legacy_path}") - - if not self.user_config.get("access_token"): - if not self.user_config.get("authentification_code"): - self.user_config[ - "authentification_code" - ] = self.get_authenticationcode() - try: - self.get_accesstoken() - except Exception as e: - log.warning("Could not get access-token. Trying to renew auth_code") - self.user_config[ - "authentification_code" - ] = self.get_authenticationcode() - self.get_accesstoken() - - - self.refresh_accesstoken() - - self.user_cfg.write() - - def update_config(self): - """updates config file""" - self.user_cfg.write() - - def get_authenticationcode(self): - """get Withings authentication code""" - params = { - "response_type": "code", - "client_id": self.app_config["client_id"], - "state": "OK", - "scope": "user.metrics", - "redirect_uri": self.app_config["callback_url"], - } - - log.warning( - "User interaction needed to get Authentification " "Code from Withings!" - ) - log.warning("") - log.warning( - "Open the following URL in your web browser and copy back " - "the token. You will have *30 seconds* before the " - "token expires. HURRY UP!" - ) - log.warning("(This is one-time activity)") - log.warning("") - - url = AUTHORIZE_URL + "?" - - for key, value in params.items(): - url = url + key + "=" + value + "&" - - log.warning(url) - log.warning("") - - authentification_code = input("Token : ") - - return authentification_code - - def get_accesstoken(self): - """get Withings access token""" - log.info("Get Access Token") - - params = { - "action": "requesttoken", - "grant_type": "authorization_code", - "client_id": self.app_config["client_id"], - "client_secret": self.app_config["consumer_secret"], - "code": self.user_config["authentification_code"], - "redirect_uri": self.app_config["callback_url"], - } - - req = requests.post(TOKEN_URL, params) - resp = req.json() - - status = resp.get("status") - body = resp.get("body") - - if status != 0: - log.error("Received error code: %d", status) - log.error( - "Check here for an interpretation of this error: " - "http://developer.withings.com/api-reference#section/Response-status" - ) - log.error("") - log.error( - "If it's regarding an invalid code, try to start the" - " script again to obtain a new link." - ) - raise - - self.user_config["access_token"] = body.get("access_token") - self.user_config["refresh_token"] = body.get("refresh_token") - self.user_config["userid"] = body.get("userid") - - def refresh_accesstoken(self): - """refresh Withings access token""" - log.info("Refresh Access Token") - - params = { - "action": "requesttoken", - "grant_type": "refresh_token", - "client_id": self.app_config["client_id"], - "client_secret": self.app_config["consumer_secret"], - "refresh_token": self.user_config["refresh_token"], - } - - req = requests.post(TOKEN_URL, params) - resp = req.json() - - status = resp.get("status") - body = resp.get("body") - - if status != 0: - log.error("Received error code: %d", status) - log.error( - "Check here for an interpretation of this error: " - "http://developer.withings.com/api-reference#section/Response-status" - ) - log.error("") - log.error( - "If it's regarding an invalid code, try to start the" - " script again to obtain a new link." - ) - - self.user_config["access_token"] = body.get("access_token") - self.user_config["refresh_token"] = body.get("refresh_token") - self.user_config["userid"] = body.get("userid") - - -class WithingsAccount: - """This class gets measurements from Withings""" - - def __init__(self, config_folder=None): - self.withings = WithingsOAuth2(config_folder=config_folder) - - def get_lastsync(self): - """get last sync timestamp""" - if not self.withings.user_config.get("last_sync"): - return int(time.mktime(date.today().timetuple())) - return self.withings.user_config["last_sync"] - - def set_lastsync(self): - """set last sync timestamp""" - self.withings.user_config["last_sync"] = int(time.time()) - log.info("Saving Last Sync") - self.withings.update_config() - - def get_measurements(self, startdate, enddate): - """get Withings measurements""" - log.info("Get Measurements") - - params = { - "access_token": self.withings.user_config["access_token"], - # 'meastype': MEASTYPE_WEIGHT, - "category": 1, - "startdate": startdate, - "enddate": enddate, - } - - req = requests.post(GETMEAS_URL, params) - - measurements = req.json() - - if measurements.get("status") == 0: - log.debug("Measurements received") - return [ - WithingsMeasureGroup(g) - for g in measurements.get("body").get("measuregrps") - ] - return None - - def get_height(self): - """get height from Withings""" - height = None - height_timestamp = None - height_group = None - - log.debug("Get Height") - - params = { - "access_token": self.withings.user_config["access_token"], - "meastype": WithingsMeasure.TYPE_HEIGHT, - "category": 1, - } - - req = requests.post(GETMEAS_URL, params) - - measurements = req.json() - - if measurements.get("status") == 0: - log.debug("Height received") - - # there could be multiple height records. use the latest one - for record in measurements.get("body").get("measuregrps"): - height_group = WithingsMeasureGroup(record) - if height is not None: - if height_timestamp is not None: - if height_group.get_datetime() > height_timestamp: - height = height_group.get_height() - else: - height = height_group.get_height() - height_timestamp = height_group.get_datetime() - - return height - - -class WithingsMeasureGroup: - """This class takes care of the group measurement functions""" - - def __init__(self, measuregrp): - self._raw_data = measuregrp - self.grpid = measuregrp.get("grpid") - self.attrib = measuregrp.get("attrib") - self.date = measuregrp.get("date") - self.category = measuregrp.get("category") - self.measures = [WithingsMeasure(m) for m in measuregrp["measures"]] - - def __iter__(self): - for measure in self.measures: - yield measure - - def __len__(self): - return len(self.measures) - - def get_datetime(self): - """convenient function to get date & time""" - return datetime.fromtimestamp(self.date) - - def get_raw_data(self): - """convenient function to get raw data""" - return self.measures - - def get_weight(self): - """convenient function to get weight""" - for measure in self.measures: - if measure.type == WithingsMeasure.TYPE_WEIGHT: - return round(measure.get_value(), 2) - return None - - def get_height(self): - """convenient function to get height""" - for measure in self.measures: - if measure.type == WithingsMeasure.TYPE_HEIGHT: - return round(measure.get_value(), 2) - return None - - def get_fat_free_mass(self): - """convenient function to get fat free mass""" - for measure in self.measures: - if measure.type == WithingsMeasure.TYPE_FAT_FREE_MASS: - return round(measure.get_value(), 2) - return None - - def get_fat_ratio(self): - """convenient function to get fat ratio""" - for measure in self.measures: - if measure.type == WithingsMeasure.TYPE_FAT_RATIO: - return round(measure.get_value(), 2) - return None - - def get_fat_mass_weight(self): - """convenient function to get fat mass weight""" - for measure in self.measures: - if measure.type == WithingsMeasure.TYPE_FAT_MASS_WEIGHT: - return round(measure.get_value(), 2) - return None - - def get_diastolic_blood_pressure(self): - """convenient function to get diastolic blood pressure""" - for measure in self.measures: - if measure.type == WithingsMeasure.TYPE_DIASTOLIC_BLOOD_PRESSURE: - return round(measure.get_value(), 2) - return None - - def get_systolic_blood_pressure(self): - """convenient function to get systolic blood pressure""" - for measure in self.measures: - if measure.type == WithingsMeasure.TYPE_SYSTOLIC_BLOOD_PRESSURE: - return round(measure.get_value(), 2) - return None - - def get_heart_pulse(self): - """convenient function to get heart pulse""" - for measure in self.measures: - if measure.type == WithingsMeasure.TYPE_HEART_PULSE: - return round(measure.get_value(), 2) - return None - - def get_temperature(self): - """convenient function to get temperature""" - for measure in self.measures: - if measure.type == WithingsMeasure.TYPE_TEMPERATURE: - return round(measure.get_value(), 2) - return None - - def get_sp02(self): - """convenient function to get sp02""" - for measure in self.measures: - if measure.type == WithingsMeasure.TYPE_SP02: - return round(measure.get_value(), 2) - return None - - def get_body_temperature(self): - """convenient function to get body temperature""" - for measure in self.measures: - if measure.type == WithingsMeasure.TYPE_BODY_TEMPERATURE: - return round(measure.get_value(), 2) - return None - - def get_skin_temperature(self): - """convenient function to get skin temperature""" - for measure in self.measures: - if measure.type == WithingsMeasure.TYPE_SKIN_TEMPERATURE: - return round(measure.get_value(), 2) - return None - - def get_muscle_mass(self): - """convenient function to get muscle mass""" - for measure in self.measures: - if measure.type == WithingsMeasure.TYPE_MUSCLE_MASS: - return round(measure.get_value(), 2) - return None - - def get_hydration(self): - """convenient function to get hydration""" - for measure in self.measures: - if measure.type == WithingsMeasure.TYPE_HYDRATION: - return round(measure.get_value(), 2) - return None - - def get_bone_mass(self): - """convenient function to get bone mass""" - for measure in self.measures: - if measure.type == WithingsMeasure.TYPE_BONE_MASS: - return round(measure.get_value(), 2) - return None - - def get_pulse_wave_velocity(self): - """convenient function to get pulse wave velocity""" - for measure in self.measures: - if measure.type == WithingsMeasure.TYPE_PULSE_WAVE_VELOCITY: - return round(measure.get_value(), 2) - return None - - -class WithingsMeasure: - """This class takes care of the individual measurements""" - - TYPE_WEIGHT = 1 - TYPE_HEIGHT = 4 - TYPE_FAT_FREE_MASS = 5 - TYPE_FAT_RATIO = 6 - TYPE_FAT_MASS_WEIGHT = 8 - TYPE_DIASTOLIC_BLOOD_PRESSURE = 9 - TYPE_SYSTOLIC_BLOOD_PRESSURE = 10 - TYPE_HEART_PULSE = 11 - TYPE_TEMPERATURE = 12 - TYPE_SP02 = 54 - TYPE_BODY_TEMPERATURE = 71 - TYPE_SKIN_TEMPERATURE = 73 - TYPE_MUSCLE_MASS = 76 - TYPE_HYDRATION = 77 - TYPE_BONE_MASS = 88 - TYPE_PULSE_WAVE_VELOCITY = 91 - TYPE_VO2MAX = 123 - TYPE_QRS_INTERVAL = 135 - TYPE_PR_INTERVAL = 136 - TYPE_QT_INTERVAL = 137 - TYPE_CORRECTED_QT_INTERVAL = 138 - TYPE_ATRIAL_FIBRILLATION_PPG = 139 - TYPE_FAT_MASS_SEGMENTS = 174 - TYPE_EXTRACELLULAR_WATER = 168 - TYPE_INTRACELLULAR_WATER = 169 - TYPE_VISCERAL_FAT = 170 - TYPE_MUSCLE_MASS_SEGMENTS = 175 - TYPE_VASCULAR_AGE = 155 - TYPE_ATRIAL_FIBRILLATION = 130 - TYPE_NERVE_HEALTH_LEFT_FOOT = 158 - TYPE_NERVE_HEALTH_RIGHT_FOOT = 159 - TYPE_NERVE_HEALTH_FEET = 167 - TYPE_ELECTRODERMAL_ACTIVITY_FEET = 196 - TYPE_ELECTRODERMAL_ACTIVITY_LEFT_FOOT = 197 - TYPE_ELECTRODERMAL_ACTIVITY_RIGHT_FOOT = 198 - - withings_table = { - TYPE_WEIGHT: ["Weight", "kg"], - TYPE_HEIGHT: ["Height", "meter"], - TYPE_FAT_FREE_MASS: ["Fat Free Mass", "kg"], - TYPE_FAT_RATIO: ["Fat Ratio", "%"], - TYPE_FAT_MASS_WEIGHT: ["Fat Mass Weight", "kg"], - TYPE_DIASTOLIC_BLOOD_PRESSURE: ["Diastolic Blood Pressure", "mmHg"], - TYPE_SYSTOLIC_BLOOD_PRESSURE: ["Systolic Blood Pressure", "mmHg"], - TYPE_HEART_PULSE: ["Heart Pulse", "bpm"], - TYPE_TEMPERATURE: ["Temperature", "celsius"], - TYPE_SP02: ["SP02", "%"], - TYPE_BODY_TEMPERATURE: ["Body Temperature", "celsius"], - TYPE_SKIN_TEMPERATURE: ["Skin Temperature", "celsius"], - TYPE_MUSCLE_MASS: ["Muscle Mass", "kg"], - TYPE_HYDRATION: ["Hydration", "kg"], - TYPE_BONE_MASS: ["Bone Mass", "kg"], - TYPE_PULSE_WAVE_VELOCITY: ["Pulse Wave Velocity", "m/s"], - TYPE_VO2MAX: ["VO2 max", "ml/min/kg"], - TYPE_QRS_INTERVAL: ["QRS interval duration based on ECG signal", "ms"], - TYPE_PR_INTERVAL: ["PR interval duration based on ECG signal", "ms"], - TYPE_QT_INTERVAL: ["QT interval duration based on ECG signal", "ms"], - TYPE_CORRECTED_QT_INTERVAL: ["Corrected QT interval duration based on ECG signal", "ms"], - TYPE_ATRIAL_FIBRILLATION_PPG: ["Atrial fibrillation result from PPG", "ms"], - TYPE_FAT_MASS_SEGMENTS: ["Fat Mass for segments in mass unit", "kg"], - TYPE_EXTRACELLULAR_WATER: ["Extracellular Water", "kg"], - TYPE_INTRACELLULAR_WATER: ["Intracellular Water", "kg"], - TYPE_VISCERAL_FAT: ["Extracellular Water", "kg"], - TYPE_MUSCLE_MASS_SEGMENTS: ["Muscle Mass for segments in mass unit", "kg"], - TYPE_VASCULAR_AGE: ["Vascular age", "years"], - TYPE_ATRIAL_FIBRILLATION: ["Atrial fibrillation result", "ms"], - TYPE_NERVE_HEALTH_LEFT_FOOT: ["Nerve Health Score left foot", ""], - TYPE_NERVE_HEALTH_RIGHT_FOOT: ["Nerve Health Score right foot", ""], - TYPE_NERVE_HEALTH_FEET: ["Nerve Health Score feet", ""], - TYPE_ELECTRODERMAL_ACTIVITY_FEET: ["Electrodermal activity feet", ""], - TYPE_ELECTRODERMAL_ACTIVITY_LEFT_FOOT: ["Electrodermal activity left foot", ""], - TYPE_ELECTRODERMAL_ACTIVITY_RIGHT_FOOT: ["Electrodermal activity right foot", ""], - } - - def __init__(self, measure): - self._raw_data = measure - self.value = measure.get("value") - self.type = measure.get("type") - self.unit = measure.get("unit") - self.type_s = self.withings_table.get(self.type, ["unknown", ""])[0] - self.unit_s = self.withings_table.get(self.type, ["unknown", ""])[1] - - def __str__(self): - return f"{self.type_s}: {self.get_value()} {self.unit_s}" - - def json_dict(self): - return { f"{self.type_s.replace(' ','_')}": { "Value": round(self.get_value(), 2), "Unit": f'{self.unit_s}'}} - - def get_value(self): - """get value""" - return self.value * pow(10, self.unit)