diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b38df29 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 1168bd9..d8d0115 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -19,21 +19,16 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + run: docker build . --file Dockerfile --tag my-app:latest + + - name: Lint with flake8 (inside Docker) run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest + docker run --rm my-app:latest flake8 src tests --config=config/.flake8 + + - name: Test with pytest (inside Docker) run: | - pytest + docker run --rm my-app:latest pytest -n auto -v tests/ diff --git a/=2.0.0 b/=2.0.0 new file mode 100644 index 0000000..b688594 --- /dev/null +++ b/=2.0.0 @@ -0,0 +1,34 @@ +Collecting requests + Using cached requests-2.32.4-py3-none-any.whl.metadata (4.9 kB) +Collecting pydantic + Downloading pydantic-2.11.7-py3-none-any.whl.metadata (67 kB) +Collecting charset_normalizer<4,>=2 (from requests) + Using cached charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (35 kB) +Collecting idna<4,>=2.5 (from requests) + Using cached idna-3.10-py3-none-any.whl.metadata (10 kB) +Collecting urllib3<3,>=1.21.1 (from requests) + Downloading urllib3-2.5.0-py3-none-any.whl.metadata (6.5 kB) +Collecting certifi>=2017.4.17 (from requests) + Using cached certifi-2025.6.15-py3-none-any.whl.metadata (2.4 kB) +Collecting annotated-types>=0.6.0 (from pydantic) + Downloading annotated_types-0.7.0-py3-none-any.whl.metadata (15 kB) +Collecting pydantic-core==2.33.2 (from pydantic) + Downloading pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (6.8 kB) +Collecting typing-extensions>=4.12.2 (from pydantic) + Using cached typing_extensions-4.14.0-py3-none-any.whl.metadata (3.0 kB) +Collecting typing-inspection>=0.4.0 (from pydantic) + Downloading typing_inspection-0.4.1-py3-none-any.whl.metadata (2.6 kB) +Using cached requests-2.32.4-py3-none-any.whl (64 kB) +Using cached charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (148 kB) +Using cached idna-3.10-py3-none-any.whl (70 kB) +Downloading urllib3-2.5.0-py3-none-any.whl (129 kB) +Downloading pydantic-2.11.7-py3-none-any.whl (444 kB) +Downloading pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (2.0 MB) + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2.0/2.0 MB 67.1 MB/s eta 0:00:00 +Downloading annotated_types-0.7.0-py3-none-any.whl (13 kB) +Using cached certifi-2025.6.15-py3-none-any.whl (157 kB) +Using cached typing_extensions-4.14.0-py3-none-any.whl (43 kB) +Downloading typing_inspection-0.4.1-py3-none-any.whl (14 kB) +Installing collected packages: urllib3, typing-extensions, idna, charset_normalizer, certifi, annotated-types, typing-inspection, requests, pydantic-core, pydantic + +Successfully installed annotated-types-0.7.0 certifi-2025.6.15 charset_normalizer-3.4.2 idna-3.10 pydantic-2.11.7 pydantic-core-2.33.2 requests-2.32.4 typing-extensions-4.14.0 typing-inspection-0.4.1 urllib3-2.5.0 diff --git a/=2.25.0 b/=2.25.0 new file mode 100644 index 0000000..e69de29 diff --git a/BRANCH_CLEANUP_RECOMMENDATION.md b/BRANCH_CLEANUP_RECOMMENDATION.md new file mode 100644 index 0000000..91063c8 --- /dev/null +++ b/BRANCH_CLEANUP_RECOMMENDATION.md @@ -0,0 +1,60 @@ +# Branch Cleanup Recommendation + +## Executive Summary + +After thorough analysis of 3 unmerged branches, **all should be deleted** as they either remove valuable functionality or contain incomplete/broken implementations. + +## Detailed Analysis + +### ❌ DELETE: `copilot/fix-13` +**Status**: Tests pass but regressive changes +**Issues**: +- Removes pagination utilities (`stream_paginated_data`, demo files) +- Removes HTTP method support (POST, PUT, PATCH, DELETE) +- Removes webhook credentials from client initialization +- While it adds some tests, the cost is losing working functionality + +**Command to delete**: `git push origin --delete copilot/fix-13` + +### ❌ DELETE: `copilot/fix-b142c2c2-d3a9-4637-bbc2-0ed979d16113` +**Status**: Tests fail due to incomplete refactoring +**Issues**: +- Massive structural change moving all code to `src/` directory +- Import errors prevent tests from running +- Contains Docker/CI improvements but incomplete +- Represents abandoned refactoring effort + +**Command to delete**: `git push origin --delete copilot/fix-b142c2c2-d3a9-4637-bbc2-0ed979d16113` + +### ❌ DELETE: `feature/repo-organization-and-devops` +**Status**: Tests fail, redundant branch +**Issues**: +- Contains only 1 commit that's duplicated in fix-b142c2c2 branch +- Same import/structural issues as fix-b142c2c2 +- Redundant - serves no unique purpose + +**Command to delete**: `git push origin --delete feature/repo-organization-and-devops` + +## Current State - Keep Main Branch + +**✅ Main branch is healthy**: +- 132 tests passing +- Complete error handling with retry logic +- Pagination utilities working +- HTTP method support for webhooks +- Full API coverage + +## Recommendation + +1. **Delete all 3 branches** using the commands above +2. **Keep main as single source of truth** +3. **No merging needed** - main branch already contains the best functionality + +The main branch represents the most complete, tested, and functional version of the codebase. + +## Implementation Notes + +- All branches were tested for functionality +- Main branch has comprehensive error handling, pagination, and API coverage +- No valuable code would be lost by deleting these branches +- Future development should continue from main branch \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ec2b3f9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,67 @@ +# Base stage for installing dependencies +FROM python:3.10-slim as builder + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +# Create a non-root user and group +RUN groupadd -r appuser && useradd -r -g appuser -d /home/appuser -s /sbin/nologin -c "Docker image user" appuser +RUN mkdir /home/appuser && chown appuser:appuser /home/appuser + +# Create working directory +WORKDIR /app + +# Upgrade pip +RUN python -m pip install --upgrade pip + +# Copy requirement files +COPY requirements.txt requirements-dev.txt ./ + +# Install dependencies +# Install production dependencies first, then development dependencies +# This helps with Docker layer caching if only dev dependencies change +RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir -r requirements-dev.txt + +# Application stage +FROM python:3.10-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +# Create a non-root user and group (must match builder stage if copying files with --chown) +RUN groupadd -r appuser && useradd -r -g appuser -d /home/appuser -s /bin/bash -c "Docker image user" appuser +RUN mkdir /home/appuser && chown appuser:appuser /home/appuser + +# Create working directory +WORKDIR /app + +# Copy installed dependencies from builder stage +COPY --from=builder /usr/local/lib/python3.10/site-packages/ /usr/local/lib/python3.10/site-packages/ +COPY --from=builder /app/ /app/ + +# Copy application code and necessary files +# Ensure these paths are correct based on your project structure after reorganization +COPY src/ ./src/ +COPY tests/ ./tests/ +COPY config/ ./config/ +COPY setup.py . +COPY openapi_spec.json . +# Copy other root files if necessary, e.g., README.md, LICENSE, etc. +# COPY README.md . +# COPY LICENSE . + +# Ensure correct ownership of application files +RUN chown -R appuser:appuser /app /home/appuser + +# Switch to non-root user +USER appuser + +# Expose port if your application is a web service (e.g., example.py runs a Flask app) +# EXPOSE 5000 + +# Default command to run tests +# This makes the container runnable and immediately useful for a developer +CMD ["pytest", "tests/"] diff --git a/.flake8 b/config/.flake8 similarity index 100% rename from .flake8 rename to config/.flake8 diff --git a/.gitignore b/config/.gitignore similarity index 100% rename from .gitignore rename to config/.gitignore diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..396a7b6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +version: "3.8" + +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: oura_api_dev + # Mounts for live code reloading during development + volumes: + - ./src:/app/src + - ./tests:/app/tests + - ./openapi_spec.json:/app/openapi_spec.json + - ./setup.py:/app/setup.py + # If example.py or other root scripts are needed and are modified during dev: + # - ./example.py:/app/example.py + + # Keep the container running for development, e.g., for exec-ing into it + # Overrides the Dockerfile's CMD + # command: tail -f /dev/null + + # Or, to run tests by default when doing `docker-compose up`: + command: pytest tests/ + + # If your example.py runs a web service on port 5000: + # ports: + # - "5000:5000" + + environment: + # Add any environment variables needed for development or testing + # - MY_VARIABLE=my_value + - PYTHONUNBUFFERED=1 # Ensures print statements appear without delay + + # Standard useful settings + stdin_open: true # docker run -i + tty: true # docker run -t diff --git a/ARCHITECTURE.md b/docs/ARCHITECTURE.md similarity index 100% rename from ARCHITECTURE.md rename to docs/ARCHITECTURE.md diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..8e660c7 --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Initial project setup. diff --git a/CLAUDE.md b/docs/CLAUDE.md similarity index 100% rename from CLAUDE.md rename to docs/CLAUDE.md diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..b143911 --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing + +We welcome contributions to this project! Please follow these guidelines to ensure a smooth process. + +## Getting Started + +- Fork the repository and clone it locally. +- Create a new branch for your feature or bug fix: `git checkout -b my-feature-branch` +- Make your changes and commit them with clear, descriptive messages. +- Push your changes to your fork: `git push origin my-feature-branch` +- Open a pull request to the main repository. + +## Code Style + +- Follow PEP 8 guidelines for Python code. +- Use `black` for code formatting. +- Use `flake8` for linting. + +## Testing + +- Write unit tests for new features and bug fixes. +- Ensure all tests pass before submitting a pull request. + +## Pull Requests + +- Provide a clear description of your changes in the pull request. +- Reference any related issues in the pull request description. +- Ensure your pull request passes all CI checks. + +## Code of Conduct + +Please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms. diff --git a/ERROR_HANDLING_SUMMARY.md b/docs/ERROR_HANDLING_SUMMARY.md similarity index 100% rename from ERROR_HANDLING_SUMMARY.md rename to docs/ERROR_HANDLING_SUMMARY.md diff --git a/PROJECT_STATE.md b/docs/PROJECT_STATE.md similarity index 100% rename from PROJECT_STATE.md rename to docs/PROJECT_STATE.md diff --git a/SESSION_NOTES.md b/docs/SESSION_NOTES.md similarity index 100% rename from SESSION_NOTES.md rename to docs/SESSION_NOTES.md diff --git a/implementation_verification_report.md b/docs/implementation_verification_report.md similarity index 100% rename from implementation_verification_report.md rename to docs/implementation_verification_report.md diff --git a/oura_api_client/__pycache__/__init__.cpython-312.pyc b/oura_api_client/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..b026440 Binary files /dev/null and b/oura_api_client/__pycache__/__init__.cpython-312.pyc differ diff --git a/oura_api_client/__pycache__/exceptions.cpython-312.pyc b/oura_api_client/__pycache__/exceptions.cpython-312.pyc new file mode 100644 index 0000000..c350aeb Binary files /dev/null and b/oura_api_client/__pycache__/exceptions.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/__init__.cpython-312.pyc b/oura_api_client/api/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..8b95c06 Binary files /dev/null and b/oura_api_client/api/__pycache__/__init__.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/base.cpython-312.pyc b/oura_api_client/api/__pycache__/base.cpython-312.pyc new file mode 100644 index 0000000..66a28cd Binary files /dev/null and b/oura_api_client/api/__pycache__/base.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/client.cpython-312.pyc b/oura_api_client/api/__pycache__/client.cpython-312.pyc new file mode 100644 index 0000000..e5fd08a Binary files /dev/null and b/oura_api_client/api/__pycache__/client.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/daily_activity.cpython-312.pyc b/oura_api_client/api/__pycache__/daily_activity.cpython-312.pyc new file mode 100644 index 0000000..c840170 Binary files /dev/null and b/oura_api_client/api/__pycache__/daily_activity.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/daily_cardiovascular_age.cpython-312.pyc b/oura_api_client/api/__pycache__/daily_cardiovascular_age.cpython-312.pyc new file mode 100644 index 0000000..5aa87f4 Binary files /dev/null and b/oura_api_client/api/__pycache__/daily_cardiovascular_age.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/daily_readiness.cpython-312.pyc b/oura_api_client/api/__pycache__/daily_readiness.cpython-312.pyc new file mode 100644 index 0000000..b38803d Binary files /dev/null and b/oura_api_client/api/__pycache__/daily_readiness.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/daily_resilience.cpython-312.pyc b/oura_api_client/api/__pycache__/daily_resilience.cpython-312.pyc new file mode 100644 index 0000000..aef964c Binary files /dev/null and b/oura_api_client/api/__pycache__/daily_resilience.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/daily_sleep.cpython-312.pyc b/oura_api_client/api/__pycache__/daily_sleep.cpython-312.pyc new file mode 100644 index 0000000..49e454e Binary files /dev/null and b/oura_api_client/api/__pycache__/daily_sleep.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/daily_spo2.cpython-312.pyc b/oura_api_client/api/__pycache__/daily_spo2.cpython-312.pyc new file mode 100644 index 0000000..eb3121c Binary files /dev/null and b/oura_api_client/api/__pycache__/daily_spo2.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/daily_stress.cpython-312.pyc b/oura_api_client/api/__pycache__/daily_stress.cpython-312.pyc new file mode 100644 index 0000000..23698f2 Binary files /dev/null and b/oura_api_client/api/__pycache__/daily_stress.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/enhanced_tag.cpython-312.pyc b/oura_api_client/api/__pycache__/enhanced_tag.cpython-312.pyc new file mode 100644 index 0000000..0273503 Binary files /dev/null and b/oura_api_client/api/__pycache__/enhanced_tag.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/heartrate.cpython-312.pyc b/oura_api_client/api/__pycache__/heartrate.cpython-312.pyc new file mode 100644 index 0000000..4cf417a Binary files /dev/null and b/oura_api_client/api/__pycache__/heartrate.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/personal.cpython-312.pyc b/oura_api_client/api/__pycache__/personal.cpython-312.pyc new file mode 100644 index 0000000..edc8528 Binary files /dev/null and b/oura_api_client/api/__pycache__/personal.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/rest_mode_period.cpython-312.pyc b/oura_api_client/api/__pycache__/rest_mode_period.cpython-312.pyc new file mode 100644 index 0000000..60577a5 Binary files /dev/null and b/oura_api_client/api/__pycache__/rest_mode_period.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/ring_configuration.cpython-312.pyc b/oura_api_client/api/__pycache__/ring_configuration.cpython-312.pyc new file mode 100644 index 0000000..87f6ab7 Binary files /dev/null and b/oura_api_client/api/__pycache__/ring_configuration.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/session.cpython-312.pyc b/oura_api_client/api/__pycache__/session.cpython-312.pyc new file mode 100644 index 0000000..561af1c Binary files /dev/null and b/oura_api_client/api/__pycache__/session.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/sleep.cpython-312.pyc b/oura_api_client/api/__pycache__/sleep.cpython-312.pyc new file mode 100644 index 0000000..8ab43f9 Binary files /dev/null and b/oura_api_client/api/__pycache__/sleep.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/sleep_time.cpython-312.pyc b/oura_api_client/api/__pycache__/sleep_time.cpython-312.pyc new file mode 100644 index 0000000..ef0c0e8 Binary files /dev/null and b/oura_api_client/api/__pycache__/sleep_time.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/tag.cpython-312.pyc b/oura_api_client/api/__pycache__/tag.cpython-312.pyc new file mode 100644 index 0000000..d7a9ec3 Binary files /dev/null and b/oura_api_client/api/__pycache__/tag.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/vo2_max.cpython-312.pyc b/oura_api_client/api/__pycache__/vo2_max.cpython-312.pyc new file mode 100644 index 0000000..8803ef9 Binary files /dev/null and b/oura_api_client/api/__pycache__/vo2_max.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/webhook.cpython-312.pyc b/oura_api_client/api/__pycache__/webhook.cpython-312.pyc new file mode 100644 index 0000000..03dde48 Binary files /dev/null and b/oura_api_client/api/__pycache__/webhook.cpython-312.pyc differ diff --git a/oura_api_client/api/__pycache__/workout.cpython-312.pyc b/oura_api_client/api/__pycache__/workout.cpython-312.pyc new file mode 100644 index 0000000..4566d4b Binary files /dev/null and b/oura_api_client/api/__pycache__/workout.cpython-312.pyc differ diff --git a/oura_api_client/models/__pycache__/__init__.cpython-312.pyc b/oura_api_client/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..5f738c6 Binary files /dev/null and b/oura_api_client/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/oura_api_client/models/__pycache__/daily_activity.cpython-312.pyc b/oura_api_client/models/__pycache__/daily_activity.cpython-312.pyc new file mode 100644 index 0000000..e383909 Binary files /dev/null and b/oura_api_client/models/__pycache__/daily_activity.cpython-312.pyc differ diff --git a/oura_api_client/models/__pycache__/daily_cardiovascular_age.cpython-312.pyc b/oura_api_client/models/__pycache__/daily_cardiovascular_age.cpython-312.pyc new file mode 100644 index 0000000..2f94092 Binary files /dev/null and b/oura_api_client/models/__pycache__/daily_cardiovascular_age.cpython-312.pyc differ diff --git a/oura_api_client/models/__pycache__/daily_readiness.cpython-312.pyc b/oura_api_client/models/__pycache__/daily_readiness.cpython-312.pyc new file mode 100644 index 0000000..b07d493 Binary files /dev/null and b/oura_api_client/models/__pycache__/daily_readiness.cpython-312.pyc differ diff --git a/oura_api_client/models/__pycache__/daily_resilience.cpython-312.pyc b/oura_api_client/models/__pycache__/daily_resilience.cpython-312.pyc new file mode 100644 index 0000000..9e1f7ed Binary files /dev/null and b/oura_api_client/models/__pycache__/daily_resilience.cpython-312.pyc differ diff --git a/oura_api_client/models/__pycache__/daily_sleep.cpython-312.pyc b/oura_api_client/models/__pycache__/daily_sleep.cpython-312.pyc new file mode 100644 index 0000000..02bb782 Binary files /dev/null and b/oura_api_client/models/__pycache__/daily_sleep.cpython-312.pyc differ diff --git a/oura_api_client/models/__pycache__/daily_spo2.cpython-312.pyc b/oura_api_client/models/__pycache__/daily_spo2.cpython-312.pyc new file mode 100644 index 0000000..3550254 Binary files /dev/null and b/oura_api_client/models/__pycache__/daily_spo2.cpython-312.pyc differ diff --git a/oura_api_client/models/__pycache__/daily_stress.cpython-312.pyc b/oura_api_client/models/__pycache__/daily_stress.cpython-312.pyc new file mode 100644 index 0000000..9bd56e5 Binary files /dev/null and b/oura_api_client/models/__pycache__/daily_stress.cpython-312.pyc differ diff --git a/oura_api_client/models/__pycache__/enhanced_tag.cpython-312.pyc b/oura_api_client/models/__pycache__/enhanced_tag.cpython-312.pyc new file mode 100644 index 0000000..45e98ba Binary files /dev/null and b/oura_api_client/models/__pycache__/enhanced_tag.cpython-312.pyc differ diff --git a/oura_api_client/models/__pycache__/heartrate.cpython-312.pyc b/oura_api_client/models/__pycache__/heartrate.cpython-312.pyc new file mode 100644 index 0000000..e37c51c Binary files /dev/null and b/oura_api_client/models/__pycache__/heartrate.cpython-312.pyc differ diff --git a/oura_api_client/models/__pycache__/personal.cpython-312.pyc b/oura_api_client/models/__pycache__/personal.cpython-312.pyc new file mode 100644 index 0000000..60c4600 Binary files /dev/null and b/oura_api_client/models/__pycache__/personal.cpython-312.pyc differ diff --git a/oura_api_client/models/__pycache__/rest_mode_period.cpython-312.pyc b/oura_api_client/models/__pycache__/rest_mode_period.cpython-312.pyc new file mode 100644 index 0000000..defa20c Binary files /dev/null and b/oura_api_client/models/__pycache__/rest_mode_period.cpython-312.pyc differ diff --git a/oura_api_client/models/__pycache__/ring_configuration.cpython-312.pyc b/oura_api_client/models/__pycache__/ring_configuration.cpython-312.pyc new file mode 100644 index 0000000..e3c39df Binary files /dev/null and b/oura_api_client/models/__pycache__/ring_configuration.cpython-312.pyc differ diff --git a/oura_api_client/models/__pycache__/session.cpython-312.pyc b/oura_api_client/models/__pycache__/session.cpython-312.pyc new file mode 100644 index 0000000..f0f4253 Binary files /dev/null and b/oura_api_client/models/__pycache__/session.cpython-312.pyc differ diff --git a/oura_api_client/models/__pycache__/sleep.cpython-312.pyc b/oura_api_client/models/__pycache__/sleep.cpython-312.pyc new file mode 100644 index 0000000..9a92bfd Binary files /dev/null and b/oura_api_client/models/__pycache__/sleep.cpython-312.pyc differ diff --git a/oura_api_client/models/__pycache__/sleep_time.cpython-312.pyc b/oura_api_client/models/__pycache__/sleep_time.cpython-312.pyc new file mode 100644 index 0000000..d4f8bd5 Binary files /dev/null and b/oura_api_client/models/__pycache__/sleep_time.cpython-312.pyc differ diff --git a/oura_api_client/models/__pycache__/tag.cpython-312.pyc b/oura_api_client/models/__pycache__/tag.cpython-312.pyc new file mode 100644 index 0000000..ebee475 Binary files /dev/null and b/oura_api_client/models/__pycache__/tag.cpython-312.pyc differ diff --git a/oura_api_client/models/__pycache__/vo2_max.cpython-312.pyc b/oura_api_client/models/__pycache__/vo2_max.cpython-312.pyc new file mode 100644 index 0000000..2e13a2e Binary files /dev/null and b/oura_api_client/models/__pycache__/vo2_max.cpython-312.pyc differ diff --git a/oura_api_client/models/__pycache__/webhook.cpython-312.pyc b/oura_api_client/models/__pycache__/webhook.cpython-312.pyc new file mode 100644 index 0000000..39ed8c1 Binary files /dev/null and b/oura_api_client/models/__pycache__/webhook.cpython-312.pyc differ diff --git a/oura_api_client/models/__pycache__/workout.cpython-312.pyc b/oura_api_client/models/__pycache__/workout.cpython-312.pyc new file mode 100644 index 0000000..ce747be Binary files /dev/null and b/oura_api_client/models/__pycache__/workout.cpython-312.pyc differ diff --git a/oura_api_client/utils/__pycache__/__init__.cpython-312.pyc b/oura_api_client/utils/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..ee20cef Binary files /dev/null and b/oura_api_client/utils/__pycache__/__init__.cpython-312.pyc differ diff --git a/oura_api_client/utils/__pycache__/pagination.cpython-312.pyc b/oura_api_client/utils/__pycache__/pagination.cpython-312.pyc new file mode 100644 index 0000000..38459cf Binary files /dev/null and b/oura_api_client/utils/__pycache__/pagination.cpython-312.pyc differ diff --git a/oura_api_client/utils/__pycache__/query_params.cpython-312.pyc b/oura_api_client/utils/__pycache__/query_params.cpython-312.pyc new file mode 100644 index 0000000..8d8312c Binary files /dev/null and b/oura_api_client/utils/__pycache__/query_params.cpython-312.pyc differ diff --git a/oura_api_client/utils/__pycache__/retry.cpython-312.pyc b/oura_api_client/utils/__pycache__/retry.cpython-312.pyc new file mode 100644 index 0000000..c1cc014 Binary files /dev/null and b/oura_api_client/utils/__pycache__/retry.cpython-312.pyc differ diff --git a/requirements.txt b/requirements.txt index 33263de..0ff8607 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -requests>=2.25.0 -pydantic>=2.0.0 +requests==2.32.4 +pydantic==2.11.7 diff --git a/example.py b/src/example.py similarity index 100% rename from example.py rename to src/example.py diff --git a/oura_api_client/__init__.py b/src/oura_api_client/__init__.py similarity index 93% rename from oura_api_client/__init__.py rename to src/oura_api_client/__init__.py index 058ebe1..74ddc66 100644 --- a/oura_api_client/__init__.py +++ b/src/oura_api_client/__init__.py @@ -10,7 +10,7 @@ OuraServerError, OuraClientError, OuraConnectionError, - OuraTimeoutError + OuraTimeoutError, ) from .utils import RetryConfig @@ -27,5 +27,5 @@ "OuraClientError", "OuraConnectionError", "OuraTimeoutError", - "RetryConfig" + "RetryConfig", ] diff --git a/oura_api_client/api/__init__.py b/src/oura_api_client/api/__init__.py similarity index 100% rename from oura_api_client/api/__init__.py rename to src/oura_api_client/api/__init__.py diff --git a/oura_api_client/api/base.py b/src/oura_api_client/api/base.py similarity index 100% rename from oura_api_client/api/base.py rename to src/oura_api_client/api/base.py diff --git a/oura_api_client/api/client.py b/src/oura_api_client/api/client.py similarity index 89% rename from oura_api_client/api/client.py rename to src/oura_api_client/api/client.py index 21ca4cd..a8c6c86 100644 --- a/oura_api_client/api/client.py +++ b/src/oura_api_client/api/client.py @@ -89,13 +89,13 @@ def _make_request( OuraAPIError: If the API request fails with specific error details """ # Ensure endpoint starts with / - if not endpoint.startswith('/'): + if not endpoint.startswith("/"): endpoint = f"/{endpoint}" - + # Remove any duplicate /v2 prefix if present - if endpoint.startswith('/v2/'): + if endpoint.startswith("/v2/"): endpoint = endpoint[3:] # Remove '/v2' prefix - + url = f"{self.BASE_URL}{endpoint}" # Wrap the actual request in retry logic if enabled @@ -110,26 +110,28 @@ def _make_single_request( method: str, params: Optional[Dict[str, Any]], timeout: Optional[float], - endpoint: str + endpoint: str, ) -> Dict[str, Any]: """Make a single HTTP request without retry logic. - + Args: url: Full URL to request method: HTTP method params: Query parameters timeout: Request timeout endpoint: Original endpoint for error context - + Returns: dict: The JSON response from the API - + Raises: OuraAPIError: If the request fails """ try: if method.upper() == "GET": - response = requests.get(url, headers=self.headers, params=params, timeout=timeout) + response = requests.get( + url, headers=self.headers, params=params, timeout=timeout + ) else: raise ValueError(f"HTTP method {method} is not supported yet") @@ -140,11 +142,17 @@ def _make_single_request( return response.json() except requests.exceptions.Timeout as e: - raise OuraTimeoutError(f"Request timed out after {timeout} seconds", endpoint=endpoint) from e + raise OuraTimeoutError( + f"Request timed out after {timeout} seconds", endpoint=endpoint + ) from e except requests.exceptions.ConnectionError as e: - raise OuraConnectionError(f"Failed to connect to API: {str(e)}", endpoint=endpoint) from e + raise OuraConnectionError( + f"Failed to connect to API: {str(e)}", endpoint=endpoint + ) from e except requests.exceptions.RequestException as e: - raise create_api_error(getattr(e, 'response', None), endpoint, str(e)) from e + raise create_api_error( + getattr(e, "response", None), endpoint, str(e) + ) from e def _make_request_with_retry( self, @@ -152,30 +160,31 @@ def _make_request_with_retry( method: str, params: Optional[Dict[str, Any]], timeout: Optional[float], - endpoint: str + endpoint: str, ) -> Dict[str, Any]: """Make HTTP request with retry logic. - + Args: url: Full URL to request method: HTTP method params: Query parameters timeout: Request timeout endpoint: Original endpoint for error context - + Returns: dict: The JSON response from the API - + Raises: OuraAPIError: If all retries fail """ + @retry_with_backoff( max_retries=self.retry_config.max_retries, base_delay=self.retry_config.base_delay, max_delay=self.retry_config.max_delay, - jitter=self.retry_config.jitter + jitter=self.retry_config.jitter, ) def make_request(): return self._make_single_request(url, method, params, timeout, endpoint) - + return make_request() diff --git a/oura_api_client/api/daily_activity.py b/src/oura_api_client/api/daily_activity.py similarity index 87% rename from oura_api_client/api/daily_activity.py rename to src/oura_api_client/api/daily_activity.py index c01e17b..a1f7af9 100644 --- a/oura_api_client/api/daily_activity.py +++ b/src/oura_api_client/api/daily_activity.py @@ -1,7 +1,10 @@ from typing import Optional, Union from datetime import date from oura_api_client.api.base import BaseRouter -from oura_api_client.models.daily_activity import DailyActivityResponse, DailyActivityModel +from oura_api_client.models.daily_activity import ( + DailyActivityResponse, + DailyActivityModel, +) from oura_api_client.utils import build_query_params @@ -29,9 +32,7 @@ def get_daily_activity_documents( ) return DailyActivityResponse(**response) - def get_daily_activity_document( - self, document_id: str - ) -> DailyActivityModel: + def get_daily_activity_document(self, document_id: str) -> DailyActivityModel: """ Get a single daily activity document. diff --git a/oura_api_client/api/daily_cardiovascular_age.py b/src/oura_api_client/api/daily_cardiovascular_age.py similarity index 98% rename from oura_api_client/api/daily_cardiovascular_age.py rename to src/oura_api_client/api/daily_cardiovascular_age.py index f7f360f..c0d8ddf 100644 --- a/oura_api_client/api/daily_cardiovascular_age.py +++ b/src/oura_api_client/api/daily_cardiovascular_age.py @@ -4,7 +4,7 @@ from oura_api_client.utils import build_query_params from oura_api_client.models.daily_cardiovascular_age import ( DailyCardiovascularAgeResponse, - DailyCardiovascularAgeModel + DailyCardiovascularAgeModel, ) diff --git a/oura_api_client/api/daily_readiness.py b/src/oura_api_client/api/daily_readiness.py similarity index 92% rename from oura_api_client/api/daily_readiness.py rename to src/oura_api_client/api/daily_readiness.py index 4494ff8..4b9380b 100644 --- a/oura_api_client/api/daily_readiness.py +++ b/src/oura_api_client/api/daily_readiness.py @@ -4,7 +4,7 @@ from oura_api_client.utils import build_query_params from oura_api_client.models.daily_readiness import ( DailyReadinessResponse, - DailyReadinessModel + DailyReadinessModel, ) @@ -32,9 +32,7 @@ def get_daily_readiness_documents( ) return DailyReadinessResponse(**response) - def get_daily_readiness_document( - self, document_id: str - ) -> DailyReadinessModel: + def get_daily_readiness_document(self, document_id: str) -> DailyReadinessModel: """ Get a single daily readiness document. diff --git a/oura_api_client/api/daily_resilience.py b/src/oura_api_client/api/daily_resilience.py similarity index 92% rename from oura_api_client/api/daily_resilience.py rename to src/oura_api_client/api/daily_resilience.py index bdeb522..7b63007 100644 --- a/oura_api_client/api/daily_resilience.py +++ b/src/oura_api_client/api/daily_resilience.py @@ -4,7 +4,7 @@ from oura_api_client.utils import build_query_params from oura_api_client.models.daily_resilience import ( DailyResilienceResponse, - DailyResilienceModel + DailyResilienceModel, ) @@ -32,9 +32,7 @@ def get_daily_resilience_documents( ) return DailyResilienceResponse(**response) - def get_daily_resilience_document( - self, document_id: str - ) -> DailyResilienceModel: + def get_daily_resilience_document(self, document_id: str) -> DailyResilienceModel: """ Get a single daily resilience document. diff --git a/oura_api_client/api/daily_sleep.py b/src/oura_api_client/api/daily_sleep.py similarity index 93% rename from oura_api_client/api/daily_sleep.py rename to src/oura_api_client/api/daily_sleep.py index ce0c29c..5ef25bf 100644 --- a/oura_api_client/api/daily_sleep.py +++ b/src/oura_api_client/api/daily_sleep.py @@ -2,10 +2,7 @@ from datetime import date from oura_api_client.api.base import BaseRouter from oura_api_client.utils import build_query_params -from oura_api_client.models.daily_sleep import ( - DailySleepResponse, - DailySleepModel -) +from oura_api_client.models.daily_sleep import DailySleepResponse, DailySleepModel class DailySleep(BaseRouter): diff --git a/oura_api_client/api/daily_spo2.py b/src/oura_api_client/api/daily_spo2.py similarity index 94% rename from oura_api_client/api/daily_spo2.py rename to src/oura_api_client/api/daily_spo2.py index ea7eda1..77ec992 100644 --- a/oura_api_client/api/daily_spo2.py +++ b/src/oura_api_client/api/daily_spo2.py @@ -2,10 +2,7 @@ from datetime import date from oura_api_client.api.base import BaseRouter from oura_api_client.utils import build_query_params -from oura_api_client.models.daily_spo2 import ( - DailySpO2Response, - DailySpO2Model -) +from oura_api_client.models.daily_spo2 import DailySpO2Response, DailySpO2Model class DailySpo2(BaseRouter): # Renamed class to DailySpo2 diff --git a/oura_api_client/api/daily_stress.py b/src/oura_api_client/api/daily_stress.py similarity index 93% rename from oura_api_client/api/daily_stress.py rename to src/oura_api_client/api/daily_stress.py index e8157e2..c32739d 100644 --- a/oura_api_client/api/daily_stress.py +++ b/src/oura_api_client/api/daily_stress.py @@ -2,10 +2,7 @@ from datetime import date from oura_api_client.api.base import BaseRouter from oura_api_client.utils import build_query_params -from oura_api_client.models.daily_stress import ( - DailyStressResponse, - DailyStressModel -) +from oura_api_client.models.daily_stress import DailyStressResponse, DailyStressModel class DailyStress(BaseRouter): diff --git a/oura_api_client/api/enhanced_tag.py b/src/oura_api_client/api/enhanced_tag.py similarity index 93% rename from oura_api_client/api/enhanced_tag.py rename to src/oura_api_client/api/enhanced_tag.py index 710eb6a..4166c03 100644 --- a/oura_api_client/api/enhanced_tag.py +++ b/src/oura_api_client/api/enhanced_tag.py @@ -2,10 +2,7 @@ from datetime import date # Using date for start_date and end_date from oura_api_client.api.base import BaseRouter from oura_api_client.utils import build_query_params -from oura_api_client.models.enhanced_tag import ( - EnhancedTagResponse, - EnhancedTagModel -) +from oura_api_client.models.enhanced_tag import EnhancedTagResponse, EnhancedTagModel class EnhancedTag(BaseRouter): diff --git a/oura_api_client/api/heartrate.py b/src/oura_api_client/api/heartrate.py similarity index 91% rename from oura_api_client/api/heartrate.py rename to src/oura_api_client/api/heartrate.py index 1c74edb..41ba47c 100644 --- a/oura_api_client/api/heartrate.py +++ b/src/oura_api_client/api/heartrate.py @@ -38,9 +38,7 @@ def get_heartrate( if end_date: params["end_date"] = end_date - response = self.client._make_request( - "/usercollection/heartrate", params=params - ) + response = self.client._make_request("/usercollection/heartrate", params=params) if return_model: return HeartRateResponse.from_dict(response) diff --git a/oura_api_client/api/personal.py b/src/oura_api_client/api/personal.py similarity index 100% rename from oura_api_client/api/personal.py rename to src/oura_api_client/api/personal.py diff --git a/oura_api_client/api/rest_mode_period.py b/src/oura_api_client/api/rest_mode_period.py similarity index 92% rename from oura_api_client/api/rest_mode_period.py rename to src/oura_api_client/api/rest_mode_period.py index 78fd588..30c3e66 100644 --- a/oura_api_client/api/rest_mode_period.py +++ b/src/oura_api_client/api/rest_mode_period.py @@ -4,7 +4,7 @@ from oura_api_client.utils import build_query_params from oura_api_client.models.rest_mode_period import ( RestModePeriodResponse, - RestModePeriodModel + RestModePeriodModel, ) @@ -32,9 +32,7 @@ def get_rest_mode_period_documents( ) return RestModePeriodResponse(**response) - def get_rest_mode_period_document( - self, document_id: str - ) -> RestModePeriodModel: + def get_rest_mode_period_document(self, document_id: str) -> RestModePeriodModel: """ Get a single rest_mode_period document. diff --git a/oura_api_client/api/ring_configuration.py b/src/oura_api_client/api/ring_configuration.py similarity index 78% rename from oura_api_client/api/ring_configuration.py rename to src/oura_api_client/api/ring_configuration.py index ae767d2..8601a9b 100644 --- a/oura_api_client/api/ring_configuration.py +++ b/src/oura_api_client/api/ring_configuration.py @@ -1,10 +1,15 @@ -from typing import Optional, Union # Union is not strictly needed here but kept for consistency -from datetime import date # date is not used by ring_configuration but kept for consistency +from typing import ( + Optional, + Union, +) # Union is not strictly needed here but kept for consistency +from datetime import ( + date, +) # date is not used by ring_configuration but kept for consistency from oura_api_client.api.base import BaseRouter from oura_api_client.utils import build_query_params from oura_api_client.models.ring_configuration import ( RingConfigurationResponse, - RingConfigurationModel + RingConfigurationModel, ) @@ -14,8 +19,10 @@ def get_ring_configuration_documents( # Ring Configuration usually doesn't have start/end_date or pagination in typical REST APIs # as it often returns a single current configuration or a list of all historical ones. # However, if the API supports it (e.g. for historical configurations): - start_date: Optional[Union[str, date]] = None, # Kept for potential future use or specific API design - end_date: Optional[Union[str, date]] = None, # Kept for potential future use + start_date: Optional[ + Union[str, date] + ] = None, # Kept for potential future use or specific API design + end_date: Optional[Union[str, date]] = None, # Kept for potential future use next_token: Optional[str] = None, ) -> RingConfigurationResponse: """ @@ -35,8 +42,7 @@ def get_ring_configuration_documents( params = build_query_params(start_date, end_date, next_token) response = self.client._make_request( - "/usercollection/ring_configuration", - params=params if params else None + "/usercollection/ring_configuration", params=params if params else None ) return RingConfigurationResponse(**response) diff --git a/oura_api_client/api/session.py b/src/oura_api_client/api/session.py similarity index 81% rename from oura_api_client/api/session.py rename to src/oura_api_client/api/session.py index 1083b65..f88a7aa 100644 --- a/oura_api_client/api/session.py +++ b/src/oura_api_client/api/session.py @@ -1,5 +1,6 @@ from typing import Optional, Union from datetime import date # Using date for start_date and end_date + # as per other endpoints from oura_api_client.api.base import BaseRouter from oura_api_client.models.session import SessionResponse, SessionModel @@ -11,7 +12,7 @@ def get_session_documents( self, start_date: Optional[Union[str, date]] = None, # Changed from # start_datetime for consistency - end_date: Optional[Union[str, date]] = None, # Changed from + end_date: Optional[Union[str, date]] = None, # Changed from # end_datetime for consistency next_token: Optional[str] = None, ) -> SessionResponse: @@ -27,9 +28,7 @@ def get_session_documents( SessionResponse: Response containing session data. """ params = build_query_params(start_date, end_date, next_token) - response = self.client._make_request( - "/usercollection/session", params=params - ) + response = self.client._make_request("/usercollection/session", params=params) return SessionResponse(**response) def get_session_document(self, document_id: str) -> SessionModel: @@ -42,7 +41,5 @@ def get_session_document(self, document_id: str) -> SessionModel: Returns: SessionModel: Response containing session data. """ - response = self.client._make_request( - f"/usercollection/session/{document_id}" - ) + response = self.client._make_request(f"/usercollection/session/{document_id}") return SessionModel(**response) diff --git a/oura_api_client/api/sleep.py b/src/oura_api_client/api/sleep.py similarity index 82% rename from oura_api_client/api/sleep.py rename to src/oura_api_client/api/sleep.py index df29d52..c650e56 100644 --- a/oura_api_client/api/sleep.py +++ b/src/oura_api_client/api/sleep.py @@ -4,7 +4,7 @@ from oura_api_client.utils import build_query_params from oura_api_client.models.sleep import ( SleepResponse, - SleepModel # Updated model import + SleepModel, # Updated model import ) @@ -13,7 +13,7 @@ def get_sleep_documents( # Renamed method self, start_date: Optional[Union[str, date]] = None, # Changed # parameter name for clarity - end_date: Optional[Union[str, date]] = None, # Changed + end_date: Optional[Union[str, date]] = None, # Changed # parameter name for clarity next_token: Optional[str] = None, ) -> SleepResponse: # Updated return type @@ -30,9 +30,7 @@ def get_sleep_documents( # Renamed method """ params = build_query_params(start_date, end_date, next_token) # Corrected endpoint URL from daily_sleep to sleep - response = self.client._make_request( - "/usercollection/sleep", params=params - ) + response = self.client._make_request("/usercollection/sleep", params=params) return SleepResponse(**response) def get_sleep_document( @@ -48,7 +46,5 @@ def get_sleep_document( SleepModel: Response containing sleep data. """ # Corrected endpoint URL from daily_sleep to sleep - response = self.client._make_request( - f"/usercollection/sleep/{document_id}" - ) + response = self.client._make_request(f"/usercollection/sleep/{document_id}") return SleepModel(**response) diff --git a/oura_api_client/api/sleep_time.py b/src/oura_api_client/api/sleep_time.py similarity index 95% rename from oura_api_client/api/sleep_time.py rename to src/oura_api_client/api/sleep_time.py index f8ac430..48748b3 100644 --- a/oura_api_client/api/sleep_time.py +++ b/src/oura_api_client/api/sleep_time.py @@ -2,10 +2,7 @@ from datetime import date from oura_api_client.api.base import BaseRouter from oura_api_client.utils import build_query_params -from oura_api_client.models.sleep_time import ( - SleepTimeResponse, - SleepTimeModel -) +from oura_api_client.models.sleep_time import SleepTimeResponse, SleepTimeModel class SleepTime(BaseRouter): diff --git a/oura_api_client/api/tag.py b/src/oura_api_client/api/tag.py similarity index 84% rename from oura_api_client/api/tag.py rename to src/oura_api_client/api/tag.py index f1eeb2c..57b275f 100644 --- a/oura_api_client/api/tag.py +++ b/src/oura_api_client/api/tag.py @@ -24,9 +24,7 @@ def get_tag_documents( TagResponse: Response containing tag data. """ params = build_query_params(start_date, end_date, next_token) - response = self.client._make_request( - "/usercollection/tag", params=params - ) + response = self.client._make_request("/usercollection/tag", params=params) return TagResponse(**response) def get_tag_document(self, document_id: str) -> TagModel: @@ -39,7 +37,5 @@ def get_tag_document(self, document_id: str) -> TagModel: Returns: TagModel: Response containing tag data. """ - response = self.client._make_request( - f"/usercollection/tag/{document_id}" - ) + response = self.client._make_request(f"/usercollection/tag/{document_id}") return TagModel(**response) diff --git a/oura_api_client/api/vo2_max.py b/src/oura_api_client/api/vo2_max.py similarity index 84% rename from oura_api_client/api/vo2_max.py rename to src/oura_api_client/api/vo2_max.py index e4ec3ac..8312544 100644 --- a/oura_api_client/api/vo2_max.py +++ b/src/oura_api_client/api/vo2_max.py @@ -24,9 +24,7 @@ def get_vo2_max_documents( Vo2MaxResponse: Response containing VO2 max data. """ params = build_query_params(start_date, end_date, next_token) - response = self.client._make_request( - "/usercollection/vO2_max", params=params - ) + response = self.client._make_request("/usercollection/vO2_max", params=params) return Vo2MaxResponse(**response) def get_vo2_max_document(self, document_id: str) -> Vo2MaxModel: @@ -39,7 +37,5 @@ def get_vo2_max_document(self, document_id: str) -> Vo2MaxModel: Returns: Vo2MaxModel: Response containing VO2 max data. """ - response = self.client._make_request( - f"/usercollection/vO2_max/{document_id}" - ) + response = self.client._make_request(f"/usercollection/vO2_max/{document_id}") return Vo2MaxModel(**response) diff --git a/oura_api_client/api/webhook.py b/src/oura_api_client/api/webhook.py similarity index 88% rename from oura_api_client/api/webhook.py rename to src/oura_api_client/api/webhook.py index 943a7b4..0220874 100644 --- a/oura_api_client/api/webhook.py +++ b/src/oura_api_client/api/webhook.py @@ -6,15 +6,16 @@ WebhookSubscriptionCreateRequest, WebhookSubscriptionUpdateRequest, WebhookOperation, - ExtApiV2DataType + ExtApiV2DataType, ) class Webhook(BaseRouter): def _get_webhook_headers(self) -> dict: """Helper to construct headers for webhook requests.""" - if (not hasattr(self.client, 'client_id') or - not hasattr(self.client, 'client_secret')): + if not hasattr(self.client, "client_id") or not hasattr( + self.client, "client_secret" + ): # This is a fallback or error case. Ideally, the OuraClient # should be initialized with client_id and client_secret if # webhook management is to be used. For now, we'll raise an error @@ -46,8 +47,7 @@ def list_webhook_subscriptions(self) -> List[WebhookSubscriptionModel]: """ headers = self._get_webhook_headers() response_data = self.client._make_request( - "/webhook/subscription", - headers=headers + "/webhook/subscription", headers=headers ) # API returns a list of subscriptions directly return [WebhookSubscriptionModel(**item) for item in response_data] @@ -67,8 +67,8 @@ def create_webhook_subscription( headers = self._get_webhook_headers() # Ensure Content-Type is set for POST with JSON body, if not # handled by _make_request - if 'Content-Type' not in headers: - headers['Content-Type'] = 'application/json' + if "Content-Type" not in headers: + headers["Content-Type"] = "application/json" request_body = WebhookSubscriptionCreateRequest( callback_url=callback_url, @@ -82,7 +82,7 @@ def create_webhook_subscription( json_data=request_body.model_dump( by_alias=True ), # exclude_none=True is default for model_dump - headers=headers + headers=headers, ) return WebhookSubscriptionModel(**response_data) @@ -95,8 +95,7 @@ def get_webhook_subscription( """ headers = self._get_webhook_headers() response_data = self.client._make_request( - f"/webhook/subscription/{subscription_id}", - headers=headers + f"/webhook/subscription/{subscription_id}", headers=headers ) return WebhookSubscriptionModel(**response_data) @@ -113,8 +112,8 @@ def update_webhook_subscription( API Path: PUT /v2/webhook/subscription/{subscription_id} """ headers = self._get_webhook_headers() - if 'Content-Type' not in headers: - headers['Content-Type'] = 'application/json' + if "Content-Type" not in headers: + headers["Content-Type"] = "application/json" request_body = WebhookSubscriptionUpdateRequest( verification_token=verification_token, # Now required @@ -125,10 +124,8 @@ def update_webhook_subscription( response_data = self.client._make_request( f"/webhook/subscription/{subscription_id}", method="PUT", - json_data=request_body.model_dump( - by_alias=True, exclude_none=True - ), - headers=headers + json_data=request_body.model_dump(by_alias=True, exclude_none=True), + headers=headers, ) return WebhookSubscriptionModel(**response_data) @@ -139,9 +136,7 @@ def delete_webhook_subscription(self, subscription_id: str) -> None: """ headers = self._get_webhook_headers() self.client._make_request( - f"/webhook/subscription/{subscription_id}", - method="DELETE", - headers=headers + f"/webhook/subscription/{subscription_id}", method="DELETE", headers=headers ) return None @@ -162,7 +157,7 @@ def renew_webhook_subscription( response_data = self.client._make_request( f"/webhook/subscription/renew/{subscription_id}", method="PUT", - headers=headers + headers=headers, # No json_data for this specific renew endpoint as per typical # renew patterns, unless the spec implies a body, which it does # not for this path. diff --git a/oura_api_client/api/workout.py b/src/oura_api_client/api/workout.py similarity index 85% rename from oura_api_client/api/workout.py rename to src/oura_api_client/api/workout.py index b6c559f..05a8348 100644 --- a/oura_api_client/api/workout.py +++ b/src/oura_api_client/api/workout.py @@ -24,9 +24,7 @@ def get_workout_documents( WorkoutResponse: Response containing workout data. """ params = build_query_params(start_date, end_date, next_token) - response = self.client._make_request( - "/usercollection/workout", params=params - ) + response = self.client._make_request("/usercollection/workout", params=params) return WorkoutResponse(**response) def get_workout_document(self, document_id: str) -> WorkoutModel: @@ -39,7 +37,5 @@ def get_workout_document(self, document_id: str) -> WorkoutModel: Returns: WorkoutModel: Response containing workout data. """ - response = self.client._make_request( - f"/usercollection/workout/{document_id}" - ) + response = self.client._make_request(f"/usercollection/workout/{document_id}") return WorkoutModel(**response) diff --git a/oura_api_client/exceptions.py b/src/oura_api_client/exceptions.py similarity index 93% rename from oura_api_client/exceptions.py rename to src/oura_api_client/exceptions.py index c80aa62..82c04ba 100644 --- a/oura_api_client/exceptions.py +++ b/src/oura_api_client/exceptions.py @@ -6,16 +6,16 @@ class OuraAPIError(Exception): """Base exception class for Oura API errors.""" - + def __init__( self, message: str, status_code: Optional[int] = None, response: Optional[requests.Response] = None, - endpoint: Optional[str] = None + endpoint: Optional[str] = None, ): """Initialize OuraAPIError. - + Args: message: Error message status_code: HTTP status code if available @@ -27,7 +27,7 @@ def __init__( self.status_code = status_code self.response = response self.endpoint = endpoint - + def __str__(self) -> str: """Return string representation of the error.""" parts = [self.message] @@ -40,32 +40,35 @@ def __str__(self) -> str: class OuraAuthenticationError(OuraAPIError): """Raised when authentication fails (401 Unauthorized).""" + pass class OuraAuthorizationError(OuraAPIError): """Raised when authorization fails (403 Forbidden).""" + pass class OuraNotFoundError(OuraAPIError): """Raised when a resource is not found (404 Not Found).""" + pass class OuraRateLimitError(OuraAPIError): """Raised when rate limit is exceeded (429 Too Many Requests).""" - + def __init__( self, message: str, status_code: Optional[int] = None, response: Optional[requests.Response] = None, endpoint: Optional[str] = None, - retry_after: Optional[int] = None + retry_after: Optional[int] = None, ): """Initialize OuraRateLimitError. - + Args: message: Error message status_code: HTTP status code @@ -79,38 +82,42 @@ def __init__( class OuraServerError(OuraAPIError): """Raised when server encounters an error (5xx status codes).""" + pass class OuraClientError(OuraAPIError): """Raised when client request is invalid (4xx status codes, except specific ones).""" + pass class OuraConnectionError(OuraAPIError): """Raised when connection to API fails.""" + pass class OuraTimeoutError(OuraAPIError): """Raised when request times out.""" + pass def _extract_error_message(response: requests.Response, status_code: int) -> str: """Extract error message from response. - + Args: response: HTTP response object status_code: HTTP status code - + Returns: Error message string """ try: error_data = response.json() if isinstance(error_data, dict): - message = error_data.get('error', error_data.get('message', '')) + message = error_data.get("error", error_data.get("message", "")) if message: return message except (ValueError, KeyError): @@ -120,14 +127,14 @@ def _extract_error_message(response: requests.Response, status_code: int) -> str def _extract_retry_after(response: requests.Response) -> Optional[int]: """Extract retry-after value from response headers. - + Args: response: HTTP response object - + Returns: Retry-after value in seconds, or None """ - retry_after_header = response.headers.get('Retry-After') + retry_after_header = response.headers.get("Retry-After") if retry_after_header: try: return int(retry_after_header) @@ -139,40 +146,40 @@ def _extract_retry_after(response: requests.Response) -> Optional[int]: def create_api_error( response: requests.Response, endpoint: Optional[str] = None, - message: Optional[str] = None + message: Optional[str] = None, ) -> OuraAPIError: """Create appropriate OuraAPIError based on response status code. - + Args: response: HTTP response object endpoint: The API endpoint that failed message: Custom error message (will be auto-generated if not provided) - + Returns: Appropriate OuraAPIError subclass instance """ status_code = response.status_code - + # Get error message if not message: message = _extract_error_message(response, status_code) - + # Map status codes to exception classes error_mapping = { 401: OuraAuthenticationError, 403: OuraAuthorizationError, 404: OuraNotFoundError, } - + # Check specific status codes first if status_code in error_mapping: return error_mapping[status_code](message, status_code, response, endpoint) - + # Handle rate limit error with retry-after if status_code == 429: retry_after = _extract_retry_after(response) return OuraRateLimitError(message, status_code, response, endpoint, retry_after) - + # Handle ranges if 400 <= status_code < 500: return OuraClientError(message, status_code, response, endpoint) diff --git a/oura_api_client/models/__init__.py b/src/oura_api_client/models/__init__.py similarity index 100% rename from oura_api_client/models/__init__.py rename to src/oura_api_client/models/__init__.py diff --git a/oura_api_client/models/daily_activity.py b/src/oura_api_client/models/daily_activity.py similarity index 86% rename from oura_api_client/models/daily_activity.py rename to src/oura_api_client/models/daily_activity.py index 4bd731c..b3d62c6 100644 --- a/oura_api_client/models/daily_activity.py +++ b/src/oura_api_client/models/daily_activity.py @@ -5,6 +5,7 @@ class MetData(BaseModel): """MET (Metabolic Equivalent of Task) time series data.""" + interval: float = Field(..., description="Interval between measurements in minutes") items: List[float] = Field(..., description="MET values for each interval") timestamp: str = Field(..., description="Timestamp for the data") @@ -24,12 +25,8 @@ class DailyActivityModel(BaseModel): class_5_min: Optional[str] = Field(None, alias="class_5_min") score: Optional[int] = Field(None, alias="score") active_calories: Optional[int] = Field(None, alias="active_calories") - average_met_minutes: Optional[float] = Field( - None, alias="average_met_minutes" - ) - contributors: Optional[ActivityContributors] = Field( - None, alias="contributors" - ) + average_met_minutes: Optional[float] = Field(None, alias="average_met_minutes") + contributors: Optional[ActivityContributors] = Field(None, alias="contributors") equivalent_walking_distance: Optional[int] = Field( None, alias="equivalent_walking_distance" ) @@ -45,16 +42,12 @@ class DailyActivityModel(BaseModel): medium_activity_met_minutes: Optional[int] = Field( None, alias="medium_activity_met_minutes" ) - medium_activity_time: Optional[int] = Field( - None, alias="medium_activity_time" - ) + medium_activity_time: Optional[int] = Field(None, alias="medium_activity_time") met: Optional[MetData] = Field(None, alias="met") meters_to_target: Optional[int] = Field(None, alias="meters_to_target") non_wear_time: Optional[int] = Field(None, alias="non_wear_time") resting_time: Optional[int] = Field(None, alias="resting_time") - sedentary_met_minutes: Optional[int] = Field( - None, alias="sedentary_met_minutes" - ) + sedentary_met_minutes: Optional[int] = Field(None, alias="sedentary_met_minutes") sedentary_time: Optional[int] = Field(None, alias="sedentary_time") steps: Optional[int] = Field(None, alias="steps") target_calories: Optional[int] = Field(None, alias="target_calories") diff --git a/oura_api_client/models/daily_cardiovascular_age.py b/src/oura_api_client/models/daily_cardiovascular_age.py similarity index 100% rename from oura_api_client/models/daily_cardiovascular_age.py rename to src/oura_api_client/models/daily_cardiovascular_age.py diff --git a/oura_api_client/models/daily_readiness.py b/src/oura_api_client/models/daily_readiness.py similarity index 87% rename from oura_api_client/models/daily_readiness.py rename to src/oura_api_client/models/daily_readiness.py index 4eef602..e0523ef 100644 --- a/oura_api_client/models/daily_readiness.py +++ b/src/oura_api_client/models/daily_readiness.py @@ -7,14 +7,10 @@ class ReadinessContributors(BaseModel): activity_balance: Optional[int] = Field(None, alias="activity_balance") body_temperature: Optional[int] = Field(None, alias="body_temperature") hrv_balance: Optional[int] = Field(None, alias="hrv_balance") - previous_day_activity: Optional[int] = Field( - None, alias="previous_day_activity" - ) + previous_day_activity: Optional[int] = Field(None, alias="previous_day_activity") previous_night: Optional[int] = Field(None, alias="previous_night") recovery_index: Optional[int] = Field(None, alias="recovery_index") - resting_heart_rate: Optional[int] = Field( - None, alias="resting_heart_rate" - ) + resting_heart_rate: Optional[int] = Field(None, alias="resting_heart_rate") sleep_balance: Optional[int] = Field(None, alias="sleep_balance") @@ -37,9 +33,7 @@ class DailyReadinessModel(BaseModel): hrv_balance_data: Optional[str] = Field( None, alias="hrv_balance_data" ) # New, assuming string, adjust if different type - spo2_percentage: Optional[float] = Field( - None, alias="spo2_percentage" - ) # New + spo2_percentage: Optional[float] = Field(None, alias="spo2_percentage") # New # Fields from original DailyActivity/Sleep that might be relevant or # were missed in initial Readiness scope # Assuming these are not part of readiness based on typical Oura data diff --git a/oura_api_client/models/daily_resilience.py b/src/oura_api_client/models/daily_resilience.py similarity index 65% rename from oura_api_client/models/daily_resilience.py rename to src/oura_api_client/models/daily_resilience.py index 27dd8d6..46ccaf0 100644 --- a/oura_api_client/models/daily_resilience.py +++ b/src/oura_api_client/models/daily_resilience.py @@ -5,15 +5,9 @@ class ResilienceContributors(BaseModel): - sleep_recovery: Optional[float] = Field( - None, alias="sleep_recovery" - ) - daytime_recovery: Optional[float] = Field( - None, alias="daytime_recovery" - ) - stress: Optional[float] = Field( - None, alias="stress" - ) + sleep_recovery: Optional[float] = Field(None, alias="sleep_recovery") + daytime_recovery: Optional[float] = Field(None, alias="daytime_recovery") + stress: Optional[float] = Field(None, alias="stress") class DailyResilienceModel(BaseModel): @@ -25,9 +19,7 @@ class DailyResilienceModel(BaseModel): contributors: Optional[ResilienceContributors] = Field( None, alias="contributors" ) # Resilience contributors - level: Optional[str] = Field( - None, alias="level" - ) # Resilience level + level: Optional[str] = Field(None, alias="level") # Resilience level timestamp: datetime # Timestamp of the summary diff --git a/oura_api_client/models/daily_sleep.py b/src/oura_api_client/models/daily_sleep.py similarity index 70% rename from oura_api_client/models/daily_sleep.py rename to src/oura_api_client/models/daily_sleep.py index 038c10f..69e9c5b 100644 --- a/oura_api_client/models/daily_sleep.py +++ b/src/oura_api_client/models/daily_sleep.py @@ -10,7 +10,9 @@ class SleepContributors(BaseModel): rem_sleep: Optional[int] = Field(None, alias="rem_sleep") # REM sleep in minutes restfulness: Optional[int] = Field(None, alias="restfulness") timing: Optional[int] = Field(None, alias="timing") - total_sleep: Optional[int] = Field(None, alias="total_sleep") # Total sleep in minutes + total_sleep: Optional[int] = Field( + None, alias="total_sleep" + ) # Total sleep in minutes class DailySleepModel(BaseModel): @@ -33,18 +35,32 @@ class DailySleepModel(BaseModel): readiness_score_delta: Optional[int] = Field(None, alias="readiness_score_delta") rem_sleep_duration: Optional[int] = Field(None, alias="rem_sleep_duration") restless_periods: Optional[int] = Field(None, alias="restless_periods") - sleep_phase_5_min: Optional[str] = Field(None, alias="sleep_phase_5_min") # Deprecated + sleep_phase_5_min: Optional[str] = Field( + None, alias="sleep_phase_5_min" + ) # Deprecated time_in_bed: Optional[int] = Field(None, alias="time_in_bed") total_sleep_duration: Optional[int] = Field(None, alias="total_sleep_duration") - type: Optional[str] = Field(None, alias="type") # Enum: "deleted", "long_sleep", "main_sleep", "nap", "rest" + type: Optional[str] = Field( + None, alias="type" + ) # Enum: "deleted", "long_sleep", "main_sleep", "nap", "rest" average_hrv: Optional[float] = Field(None, alias="average_hrv") awake_time: Optional[int] = Field(None, alias="awake_time") - hr_60_second_average: Optional[List[int]] = Field(None, alias="hr_60_second_average") # New in v2.10 - hrv_4_hour_average: Optional[List[float]] = Field(None, alias="hrv_4_hour_average") # New in v2.10 - readiness: Optional[str] = Field(None, alias="readiness") # New in v2.10, but type not specified, assuming string for now + hr_60_second_average: Optional[List[int]] = Field( + None, alias="hr_60_second_average" + ) # New in v2.10 + hrv_4_hour_average: Optional[List[float]] = Field( + None, alias="hrv_4_hour_average" + ) # New in v2.10 + readiness: Optional[str] = Field( + None, alias="readiness" + ) # New in v2.10, but type not specified, assuming string for now temperature_delta: Optional[float] = Field(None, alias="temperature_delta") - temperature_deviation: Optional[float] = Field(None, alias="temperature_deviation") # Deprecated - temperature_trend_deviation: Optional[float] = Field(None, alias="temperature_trend_deviation") + temperature_deviation: Optional[float] = Field( + None, alias="temperature_deviation" + ) # Deprecated + temperature_trend_deviation: Optional[float] = Field( + None, alias="temperature_trend_deviation" + ) class DailySleepResponse(BaseModel): diff --git a/oura_api_client/models/daily_spo2.py b/src/oura_api_client/models/daily_spo2.py similarity index 65% rename from oura_api_client/models/daily_spo2.py rename to src/oura_api_client/models/daily_spo2.py index 213e6ea..1aae4f5 100644 --- a/oura_api_client/models/daily_spo2.py +++ b/src/oura_api_client/models/daily_spo2.py @@ -3,37 +3,25 @@ from datetime import date, datetime # Added datetime -class DailySpO2AggregatedValuesModel(BaseModel): # Renamed from Spo2Readings to DailySpO2AggregatedValuesModel for clarity - average: Optional[float] = Field( - None, alias="average" - ) # Percentage +class DailySpO2AggregatedValuesModel( + BaseModel +): # Renamed from Spo2Readings to DailySpO2AggregatedValuesModel for clarity + average: Optional[float] = Field(None, alias="average") # Percentage class DailySpO2Model(BaseModel): id: str day: date spo2_percentage: Optional[float] = Field( - - - - None, alias="spo2_percentage" - - - -) # Overall percentage for the day, if available + None, alias="spo2_percentage" + ) # Overall percentage for the day, if available # The above field seems redundant if aggregated_values.average is the main source # Kept for now as per some interpretations of daily summary vs. detailed readings aggregated_values: Optional[DailySpO2AggregatedValuesModel] = Field( - None, alias="aggregated_values" - ) # New nested model for clarity # Assuming timestamp might be relevant for when the daily record was created or last updated - timestamp: Optional[datetime] = Field( - - None, alias="timestamp" - - ) # Added timestamp + timestamp: Optional[datetime] = Field(None, alias="timestamp") # Added timestamp class DailySpO2Response(BaseModel): diff --git a/oura_api_client/models/daily_stress.py b/src/oura_api_client/models/daily_stress.py similarity index 92% rename from oura_api_client/models/daily_stress.py rename to src/oura_api_client/models/daily_stress.py index e1abe69..f0ed924 100644 --- a/oura_api_client/models/daily_stress.py +++ b/src/oura_api_client/models/daily_stress.py @@ -11,7 +11,7 @@ class DailyStressModel(BaseModel): ) # Duration of high stress in seconds stress_low: Optional[int] = Field( None, alias="stress_low" - ) # Duration of low stress in seconds + ) # Duration of low stress in seconds stress_medium: Optional[int] = Field( None, alias="stress_medium" ) # Duration of medium stress in seconds @@ -22,7 +22,7 @@ class DailyStressModel(BaseModel): ) # Duration of high recovery in seconds recovery_low: Optional[int] = Field( None, alias="recovery_low" - ) # Duration of low recovery in seconds + ) # Duration of low recovery in seconds recovery_medium: Optional[int] = Field( None, alias="recovery_medium" ) # Duration of medium recovery in seconds diff --git a/oura_api_client/models/enhanced_tag.py b/src/oura_api_client/models/enhanced_tag.py similarity index 78% rename from oura_api_client/models/enhanced_tag.py rename to src/oura_api_client/models/enhanced_tag.py index ce1beb9..93d3349 100644 --- a/oura_api_client/models/enhanced_tag.py +++ b/src/oura_api_client/models/enhanced_tag.py @@ -5,21 +5,13 @@ class EnhancedTagModel(BaseModel): id: str - tag_type_code: str = Field( - alias="tag_type_code" - ) # e.g., "common_cold", "period" + tag_type_code: str = Field(alias="tag_type_code") # e.g., "common_cold", "period" start_time: datetime = Field(alias="start_time") end_time: Optional[datetime] = Field(None, alias="end_time") start_day: Optional[date] = Field( - None, alias="start_day" - - ) # New based on typical usage - end_day: Optional[date] = Field( - - None, alias="end_day" - ) # New based on typical usage + end_day: Optional[date] = Field(None, alias="end_day") # New based on typical usage comment: Optional[str] = None # Based on OpenAPI spec, there might be other fields, # but these are the core ones usually associated with enhanced tags. diff --git a/oura_api_client/models/heartrate.py b/src/oura_api_client/models/heartrate.py similarity index 94% rename from oura_api_client/models/heartrate.py rename to src/oura_api_client/models/heartrate.py index ccd0fa2..3a96b39 100644 --- a/oura_api_client/models/heartrate.py +++ b/src/oura_api_client/models/heartrate.py @@ -7,7 +7,7 @@ class HeartRateSample(BaseModel): """Represents a single heart rate data point.""" - + timestamp: datetime bpm: int source: str @@ -15,13 +15,13 @@ class HeartRateSample(BaseModel): @classmethod def from_dict(cls, data: dict) -> "HeartRateSample": """Create a HeartRateSample from API response dictionary. - + Note: This method is kept for backward compatibility. Pydantic can parse directly from dict using HeartRateSample(**data) - + Args: data: Dictionary containing heart rate data - + Returns: HeartRateSample: Instantiated object """ @@ -30,20 +30,20 @@ def from_dict(cls, data: dict) -> "HeartRateSample": class HeartRateResponse(BaseModel): """Represents the full heart rate response.""" - + data: List[HeartRateSample] next_token: Optional[str] = None @classmethod def from_dict(cls, response: dict) -> "HeartRateResponse": """Create a HeartRateResponse from API response dictionary. - + Note: This method is kept for backward compatibility. Pydantic can parse directly from dict using HeartRateResponse(**response) - + Args: response: Dictionary containing API response - + Returns: HeartRateResponse: Instantiated object """ diff --git a/oura_api_client/models/personal.py b/src/oura_api_client/models/personal.py similarity index 95% rename from oura_api_client/models/personal.py rename to src/oura_api_client/models/personal.py index f00638e..80d97db 100644 --- a/oura_api_client/models/personal.py +++ b/src/oura_api_client/models/personal.py @@ -7,7 +7,7 @@ class PersonalInfo(BaseModel): """Represents personal information for a user.""" - + id: str email: str age: int @@ -19,13 +19,13 @@ class PersonalInfo(BaseModel): @classmethod def from_dict(cls, data: dict) -> "PersonalInfo": """Create a PersonalInfo object from API response dictionary. - + Note: This method is kept for backward compatibility. Pydantic can parse directly from dict using PersonalInfo(**data) - + Args: data: Dictionary containing personal info data - + Returns: PersonalInfo: Instantiated object """ diff --git a/oura_api_client/models/rest_mode_period.py b/src/oura_api_client/models/rest_mode_period.py similarity index 94% rename from oura_api_client/models/rest_mode_period.py rename to src/oura_api_client/models/rest_mode_period.py index 33b8ac6..d10d564 100644 --- a/oura_api_client/models/rest_mode_period.py +++ b/src/oura_api_client/models/rest_mode_period.py @@ -40,12 +40,8 @@ class RestModeEpisode(BaseModel): class RestModePeriodModel(BaseModel): id: str day: date - start_time: datetime = Field( - alias="start_time" - ) - end_time: Optional[datetime] = Field( - None, alias="end_time" - ) + start_time: datetime = Field(alias="start_time") + end_time: Optional[datetime] = Field(None, alias="end_time") # Rest mode specific state or tag, e.g. "on_demand_rest", "recovering_from_illness" rest_mode_state: Optional[str] = Field( None, alias="rest_mode_state" @@ -56,9 +52,7 @@ class RestModePeriodModel(BaseModel): # ) # However, the OpenAPI spec has a flat structure for RestModePeriodModel. # Adding fields from OpenAPI spec for RestModePeriod - baseline_heart_rate: Optional[int] = Field( - None, alias="baseline_heart_rate" - ) + baseline_heart_rate: Optional[int] = Field(None, alias="baseline_heart_rate") baseline_hrv: Optional[int] = Field(None, alias="baseline_hrv") baseline_skin_temperature: Optional[float] = Field( None, alias="baseline_skin_temperature" diff --git a/oura_api_client/models/ring_configuration.py b/src/oura_api_client/models/ring_configuration.py similarity index 62% rename from oura_api_client/models/ring_configuration.py rename to src/oura_api_client/models/ring_configuration.py index c888102..26bc648 100644 --- a/oura_api_client/models/ring_configuration.py +++ b/src/oura_api_client/models/ring_configuration.py @@ -1,6 +1,7 @@ from pydantic import BaseModel, Field from typing import List, Optional from datetime import datetime + # Enum-like fields will be handled with Literal from typing import Literal @@ -9,47 +10,29 @@ class RingConfigurationModel(BaseModel): id: str # Fields based on OpenAPI spec for RingConfiguration # Using Literal for fields that are described as enums - color: Optional[Literal[ - "black", - "brushed_titanium", # New - "gold", - "graphite", # New - "rose_gold", - "silver", - "stealth_black" # Typo in spec? "stealth" is common, "stealth_black" is more specific - ]] = Field(None, alias="color") - design: Optional[Literal[ - "balance", # New - "gucci", # New - "heritage", - "horizon" - ]] = Field( - - None, alias="design" - - ) + color: Optional[ + Literal[ + "black", + "brushed_titanium", # New + "gold", + "graphite", # New + "rose_gold", + "silver", + "stealth_black", # Typo in spec? "stealth" is common, "stealth_black" is more specific + ] + ] = Field(None, alias="color") + design: Optional[ + Literal["balance", "gucci", "heritage", "horizon"] # New # New + ] = Field(None, alias="design") firmware_version: Optional[str] = Field(None, alias="firmware_version") - hardware_type: Optional[Literal[ - "gen1", - "gen2", - "gen2m", - "gen3" - ]] = Field( - + hardware_type: Optional[Literal["gen1", "gen2", "gen2m", "gen3"]] = Field( None, alias="hardware_type" - ) # 'id' is already included set_up_at: Optional[datetime] = Field( - None, alias="set_up_at" - ) # Changed from setup_at for Pythonic convention - size: Optional[int] = Field( - - None, alias="size" - - ) + size: Optional[int] = Field(None, alias="size") # RingColor, RingDesign, RingHardwareType are effectively defined by Literals above # No separate models needed for them if they are just choices for a field. diff --git a/oura_api_client/models/session.py b/src/oura_api_client/models/session.py similarity index 72% rename from oura_api_client/models/session.py rename to src/oura_api_client/models/session.py index e7a7fd0..95620b9 100644 --- a/oura_api_client/models/session.py +++ b/src/oura_api_client/models/session.py @@ -9,9 +9,7 @@ class SessionModel(BaseModel): id: str day: date # Added day based on common patterns in other models - start_datetime: datetime = Field( - alias="start_datetime" - ) + start_datetime: datetime = Field(alias="start_datetime") end_datetime: datetime = Field(alias="end_datetime") type: Literal[ "breathing_exercise", @@ -24,45 +22,37 @@ class SessionModel(BaseModel): "session_other", # New type "sleep_sound", # New type "timer", # New type - "workout" + "workout", ] # Optional fields based on typical session data - mood: Optional[Literal[ - "bad", - "good", - "great", - "okay", - "poor", - "sensory_other", # New mood - "stressful", - "thankful", - "tired", - "undefined" - ]] = None + mood: Optional[ + Literal[ + "bad", + "good", + "great", + "okay", + "poor", + "sensory_other", # New mood + "stressful", + "thankful", + "tired", + "undefined", + ] + ] = None heart_rate: Optional[str] = Field( - None, alias="heart_rate" - ) # Assuming string, adjust if complex + None, alias="heart_rate" + ) # Assuming string, adjust if complex heart_rate_variability: Optional[str] = Field( - None, alias="heart_rate_variability" - ) # Assuming string - motion_count: Optional[int] = Field( - - None, alias="motion_count" - - ) + motion_count: Optional[int] = Field(None, alias="motion_count") # New fields from OpenAPI spec for Session breathing_rate: Optional[float] = Field(None, alias="breathing_rate") duration: Optional[int] = Field(None, alias="duration") energy: Optional[float] = Field(None, alias="energy") hrv_data: Optional[str] = Field(None, alias="hrv_data") # Assuming string label: Optional[str] = Field(None, alias="label") - readiness_score_delta: Optional[int] = Field( - - None, alias="readiness_score_delta" - - ) + readiness_score_delta: Optional[int] = Field(None, alias="readiness_score_delta") skin_temperature: Optional[float] = Field(None, alias="skin_temperature") sleep_score_delta: Optional[int] = Field(None, alias="sleep_score_delta") stress: Optional[float] = Field(None, alias="stress") diff --git a/oura_api_client/models/sleep.py b/src/oura_api_client/models/sleep.py similarity index 94% rename from oura_api_client/models/sleep.py rename to src/oura_api_client/models/sleep.py index dd1350b..832b68e 100644 --- a/oura_api_client/models/sleep.py +++ b/src/oura_api_client/models/sleep.py @@ -5,6 +5,7 @@ class SleepContributors(BaseModel): """Sleep contributors model for sleep data.""" + deep_sleep: Optional[int] = Field(None, alias="deep_sleep") efficiency: Optional[int] = Field(None, alias="efficiency") latency: Optional[int] = Field(None, alias="latency") @@ -16,6 +17,7 @@ class SleepContributors(BaseModel): class ReadinessContributors(BaseModel): """Readiness contributors model for sleep data.""" + activity_balance: Optional[int] = Field(None, alias="activity_balance") body_temperature: Optional[int] = Field(None, alias="body_temperature") hrv_balance: Optional[int] = Field(None, alias="hrv_balance") @@ -52,10 +54,14 @@ class SleepModel(BaseModel): score: Optional[int] = Field(None, alias="score") sleep_phase_5_min: Optional[str] = Field(None, alias="sleep_phase_5_min") sleep_score_delta: Optional[int] = Field(None, alias="sleep_score_delta") - sleep_algorithm_version: Optional[str] = Field(None, alias="sleep_algorithm_version") + sleep_algorithm_version: Optional[str] = Field( + None, alias="sleep_algorithm_version" + ) temperature_delta: Optional[float] = Field(None, alias="temperature_delta") temperature_deviation: Optional[float] = Field(None, alias="temperature_deviation") - temperature_trend_deviation: Optional[float] = Field(None, alias="temperature_trend_deviation") + temperature_trend_deviation: Optional[float] = Field( + None, alias="temperature_trend_deviation" + ) time_in_bed: Optional[int] = Field(None, alias="time_in_bed") total_sleep_duration: Optional[int] = Field(None, alias="total_sleep_duration") type: Optional[str] = Field(None, alias="type") diff --git a/oura_api_client/models/sleep_time.py b/src/oura_api_client/models/sleep_time.py similarity index 83% rename from oura_api_client/models/sleep_time.py rename to src/oura_api_client/models/sleep_time.py index 8e2a28c..1672dcf 100644 --- a/oura_api_client/models/sleep_time.py +++ b/src/oura_api_client/models/sleep_time.py @@ -8,24 +8,22 @@ class SleepTimeWindow(BaseModel): day_light_saving_time: Optional[int] = Field( - None, alias="day_light_saving_time" - ) # New + None, alias="day_light_saving_time" + ) # New end_offset: Optional[int] = Field( - None, alias="end_offset" - ) # Offset from midnight in seconds start_offset: Optional[int] = Field( - None, alias="start_offset" - ) # Offset from midnight in seconds class SleepTimeRecommendation(BaseModel): # Based on common sleep recommendation data, specific fields might vary # For now, keeping it simple. If a detailed spec is available, adjust. - recommendation: Optional[str] = None # e.g., "go_to_bed_earlier", "maintain_consistent_schedule" + recommendation: Optional[str] = ( + None # e.g., "go_to_bed_earlier", "maintain_consistent_schedule" + ) # Could also include specific time recommendations if the API provides them: # recommended_bedtime_start: Optional[datetime] = None # recommended_bedtime_end: Optional[datetime] = None @@ -41,25 +39,15 @@ class SleepTimeStatus(BaseModel): class SleepTimeModel(BaseModel): id: str # Though API doc says no ID, a unique identifier per record is standard day: date - optimal_bedtime: Optional[SleepTimeWindow] = Field( - None, alias="optimal_bedtime" - ) + optimal_bedtime: Optional[SleepTimeWindow] = Field(None, alias="optimal_bedtime") recommendation: Optional[SleepTimeRecommendation] = Field( - None, alias="recommendation" - ) # Using the new model status: Optional[SleepTimeStatus] = Field( - None, alias="status" - ) # Using the new model # Assuming timestamp might be relevant for when the record was created or last updated - timestamp: Optional[datetime] = Field( - - None, alias="timestamp" - - ) # Added timestamp + timestamp: Optional[datetime] = Field(None, alias="timestamp") # Added timestamp class SleepTimeResponse(BaseModel): diff --git a/oura_api_client/models/tag.py b/src/oura_api_client/models/tag.py similarity index 89% rename from oura_api_client/models/tag.py rename to src/oura_api_client/models/tag.py index 983b89a..9d653bb 100644 --- a/oura_api_client/models/tag.py +++ b/src/oura_api_client/models/tag.py @@ -7,7 +7,9 @@ class TagModel(BaseModel): id: str day: date text: Optional[str] = None # Optional based on OpenAPI spec - timestamp: datetime # Changed from Optional[datetime] to datetime as it's usually present + timestamp: ( + datetime # Changed from Optional[datetime] to datetime as it's usually present + ) # New fields from OpenAPI spec for Tag # Assuming 'tag_type_code' and 'start_time', 'end_time' might be part # of a more detailed spec, diff --git a/oura_api_client/models/time_series.py b/src/oura_api_client/models/time_series.py similarity index 55% rename from oura_api_client/models/time_series.py rename to src/oura_api_client/models/time_series.py index d8bbd7c..cdbce82 100644 --- a/oura_api_client/models/time_series.py +++ b/src/oura_api_client/models/time_series.py @@ -6,20 +6,29 @@ class TimeSeriesData(BaseModel): """ Time series data structure for various Oura metrics. - + This model represents time-series data with a consistent structure across different endpoints. The timestamp is automatically converted from ISO 8601 format to Unix timestamp for easier programmatic use. """ - interval: int = Field(..., description="Interval in seconds between the sampled items.") - items: List[Optional[float]] = Field(..., description="Recorded sample items. Null values indicate missing data points.") - timestamp: int = Field(..., description="Unix timestamp (seconds since epoch) when the sample recording started.") - - @field_validator('timestamp', mode='before') + + interval: int = Field( + ..., description="Interval in seconds between the sampled items." + ) + items: List[Optional[float]] = Field( + ..., + description="Recorded sample items. Null values indicate missing data points.", + ) + timestamp: int = Field( + ..., + description="Unix timestamp (seconds since epoch) when the sample recording started.", + ) + + @field_validator("timestamp", mode="before") @classmethod def parse_timestamp(cls, v): """Convert ISO 8601 timestamp string to unix timestamp.""" if isinstance(v, str): - dt = datetime.fromisoformat(v.replace('Z', '+00:00')) + dt = datetime.fromisoformat(v.replace("Z", "+00:00")) return int(dt.timestamp()) return v diff --git a/oura_api_client/models/vo2_max.py b/src/oura_api_client/models/vo2_max.py similarity index 100% rename from oura_api_client/models/vo2_max.py rename to src/oura_api_client/models/vo2_max.py diff --git a/oura_api_client/models/webhook.py b/src/oura_api_client/models/webhook.py similarity index 83% rename from oura_api_client/models/webhook.py rename to src/oura_api_client/models/webhook.py index c207241..282d4fb 100644 --- a/oura_api_client/models/webhook.py +++ b/src/oura_api_client/models/webhook.py @@ -33,63 +33,32 @@ class ExtApiV2DataType(str, Enum): class WebhookSubscriptionModel(BaseModel): - id: str = Field( - ..., description="Webhook subscription ID" - ) + id: str = Field(..., description="Webhook subscription ID") callback_url: str = Field(..., alias="callback_url") event_type: WebhookOperation = Field(..., alias="event_type") - data_type: ExtApiV2DataType = Field( - - ..., alias="data_type" - - ) + data_type: ExtApiV2DataType = Field(..., alias="data_type") # Assuming created_at and updated_at are not part of the GET response based on spec example for WebhookSubscriptionModel # If they are, they should be added back. The spec for WebhookSubscriptionModel shows: # id, callback_url, event_type, data_type, expiration_time - expiration_time: datetime = Field( - - ..., alias="expiration_time" - - ) + expiration_time: datetime = Field(..., alias="expiration_time") # verification_token is not part of the response for GET /subscription or GET /subscription/{id} class WebhookSubscriptionCreateRequest(BaseModel): # For POST request body - callback_url: str = Field( - - - - ..., alias="callback_url" - - - -) + callback_url: str = Field(..., alias="callback_url") verification_token: str = Field( - ..., alias="verification_token" - ) # Made required as per spec event_type: WebhookOperation = Field(..., alias="event_type") data_type: ExtApiV2DataType = Field(..., alias="data_type") class WebhookSubscriptionUpdateRequest(BaseModel): # For PUT request body - verification_token: str = Field( - - - - ..., alias="verification_token" - - - -) # Required + verification_token: str = Field(..., alias="verification_token") # Required callback_url: Optional[str] = Field(None, alias="callback_url") event_type: Optional[WebhookOperation] = Field(None, alias="event_type") - data_type: Optional[ExtApiV2DataType] = Field( - - None, alias="data_type" + data_type: Optional[ExtApiV2DataType] = Field(None, alias="data_type") - ) # No longer using WebhookListResponse as the API returns a direct list. # class WebhookListResponse(BaseModel): diff --git a/oura_api_client/models/workout.py b/src/oura_api_client/models/workout.py similarity index 83% rename from oura_api_client/models/workout.py rename to src/oura_api_client/models/workout.py index 37b8fb7..823cee2 100644 --- a/oura_api_client/models/workout.py +++ b/src/oura_api_client/models/workout.py @@ -1,6 +1,7 @@ from pydantic import BaseModel, Field from typing import List, Optional from datetime import date, datetime # Added date + # WorkoutIntensity and WorkoutSource are enums, but Pydantic uses Literal for this from typing import Literal @@ -14,10 +15,7 @@ class WorkoutModel(BaseModel): end_datetime: datetime = Field(alias="end_datetime") energy: Optional[float] = Field(None, alias="energy") # Kilojoules intensity: Literal[ - "easy", - "hard", - "moderate", - "restorative" # New based on common workout apps + "easy", "hard", "moderate", "restorative" # New based on common workout apps ] label: Optional[str] = None source: Literal[ @@ -27,11 +25,9 @@ class WorkoutModel(BaseModel): "health_connect", # New based on Android ecosystem "manual", "strava", # New based on common integrations - "oura_app" # New for workouts logged directly in Oura + "oura_app", # New for workouts logged directly in Oura ] - start_datetime: datetime = Field( - alias="start_datetime" - ) + start_datetime: datetime = Field(alias="start_datetime") # New fields from OpenAPI spec for Workout, if any, would be added here. # For now, using a common set of fields for workout tracking. # Example: diff --git a/oura_api_client/utils/__init__.py b/src/oura_api_client/utils/__init__.py similarity index 92% rename from oura_api_client/utils/__init__.py rename to src/oura_api_client/utils/__init__.py index 4382d17..03c6e0b 100644 --- a/oura_api_client/utils/__init__.py +++ b/src/oura_api_client/utils/__init__.py @@ -9,5 +9,5 @@ "RetryConfig", "retry_with_backoff", "should_retry", - "exponential_backoff" + "exponential_backoff", ] diff --git a/oura_api_client/utils/query_params.py b/src/oura_api_client/utils/query_params.py similarity index 96% rename from oura_api_client/utils/query_params.py rename to src/oura_api_client/utils/query_params.py index e09ac59..5974640 100644 --- a/oura_api_client/utils/query_params.py +++ b/src/oura_api_client/utils/query_params.py @@ -6,10 +6,10 @@ def convert_date_to_string(date_param: Optional[Union[str, date]]) -> Optional[str]: """Convert a date parameter to ISO format string if it's a date object. - + Args: date_param: Date parameter that can be a string, date object, or None - + Returns: ISO format date string or None """ @@ -25,18 +25,18 @@ def build_query_params( **kwargs: Any ) -> Dict[str, Any]: """Build query parameters dictionary for API requests. - + This function handles common query parameter patterns: - Converts date objects to ISO format strings - Filters out None values - Supports additional parameters via kwargs - + Args: start_date: Start date for filtering (string or date object) end_date: End date for filtering (string or date object) next_token: Token for pagination **kwargs: Additional query parameters - + Returns: Dictionary of query parameters with None values filtered out """ @@ -44,8 +44,8 @@ def build_query_params( "start_date": convert_date_to_string(start_date), "end_date": convert_date_to_string(end_date), "next_token": next_token, - **kwargs + **kwargs, } - + # Filter out None values return {k: v for k, v in params.items() if v is not None} diff --git a/oura_api_client/utils/retry.py b/src/oura_api_client/utils/retry.py similarity index 83% rename from oura_api_client/utils/retry.py rename to src/oura_api_client/utils/retry.py index 827b7ad..d513dc1 100644 --- a/oura_api_client/utils/retry.py +++ b/src/oura_api_client/utils/retry.py @@ -3,57 +3,66 @@ import time import random from typing import Callable -from ..exceptions import OuraRateLimitError, OuraServerError, OuraConnectionError, OuraTimeoutError +from ..exceptions import ( + OuraRateLimitError, + OuraServerError, + OuraConnectionError, + OuraTimeoutError, +) -def exponential_backoff(attempt: int, base_delay: float = 1.0, max_delay: float = 60.0, jitter: bool = True) -> float: +def exponential_backoff( + attempt: int, base_delay: float = 1.0, max_delay: float = 60.0, jitter: bool = True +) -> float: """Calculate exponential backoff delay. - + Args: attempt: Current attempt number (0-based) base_delay: Base delay in seconds max_delay: Maximum delay in seconds jitter: Whether to add random jitter - + Returns: Delay in seconds """ - delay = base_delay * (2 ** attempt) + delay = base_delay * (2**attempt) delay = min(delay, max_delay) - + if jitter: # Add ±25% jitter jitter_range = delay * 0.25 delay += random.uniform(-jitter_range, jitter_range) - + return max(0, delay) def should_retry(exception: Exception, attempt: int, max_retries: int) -> bool: """Determine if an exception should trigger a retry. - + Args: exception: The exception that occurred attempt: Current attempt number (0-based) max_retries: Maximum number of retries allowed - + Returns: True if should retry, False otherwise """ if attempt >= max_retries: return False - + # Retry on specific transient errors if isinstance(exception, (OuraServerError, OuraConnectionError, OuraTimeoutError)): return True - + # Retry on rate limit errors if retry_after is reasonable if isinstance(exception, OuraRateLimitError): if exception.retry_after and exception.retry_after <= 300: # Max 5 minutes return True - elif not exception.retry_after: # No retry-after header, use exponential backoff + elif ( + not exception.retry_after + ): # No retry-after header, use exponential backoff return True - + return False @@ -61,70 +70,75 @@ def retry_with_backoff( max_retries: int = 3, base_delay: float = 1.0, max_delay: float = 60.0, - jitter: bool = True + jitter: bool = True, ): """Decorator factory to add retry logic with exponential backoff. - + Args: max_retries: Maximum number of retries base_delay: Base delay for exponential backoff max_delay: Maximum delay between retries jitter: Whether to add random jitter - + Returns: Decorator function """ + def decorator(func: Callable) -> Callable: """Actual decorator that wraps the function. - + Args: func: Function to wrap - + Returns: Wrapped function with retry logic """ + def wrapper(*args, **kwargs): last_exception = None - + for attempt in range(max_retries + 1): # +1 for initial attempt try: return func(*args, **kwargs) except Exception as e: last_exception = e - + if not should_retry(e, attempt, max_retries): raise - + # Calculate delay if isinstance(e, OuraRateLimitError) and e.retry_after: delay = e.retry_after else: - delay = exponential_backoff(attempt, base_delay, max_delay, jitter) - + delay = exponential_backoff( + attempt, base_delay, max_delay, jitter + ) + # Wait before retry if delay > 0: time.sleep(delay) - + # If we get here, all retries failed raise last_exception - + return wrapper + return decorator class RetryConfig: """Configuration for retry behavior.""" - + def __init__( self, max_retries: int = 3, base_delay: float = 1.0, max_delay: float = 60.0, jitter: bool = True, - enabled: bool = True + enabled: bool = True, ): """Initialize retry configuration. - + Args: max_retries: Maximum number of retries base_delay: Base delay for exponential backoff diff --git a/oura_client.py b/src/oura_client.py similarity index 100% rename from oura_client.py rename to src/oura_client.py diff --git a/parse_openapi.py b/src/parse_openapi.py similarity index 72% rename from parse_openapi.py rename to src/parse_openapi.py index d56f84a..e5829f7 100644 --- a/parse_openapi.py +++ b/src/parse_openapi.py @@ -9,59 +9,81 @@ def _parse_method_details(method_details): "summary": method_details.get("summary"), "operationId": method_details.get("operationId"), "parameters": [], - "responses": {} + "responses": {}, } - + # Parse parameters - if "parameters" in method_details and isinstance(method_details["parameters"], list): + if "parameters" in method_details and isinstance( + method_details["parameters"], list + ): for param in method_details["parameters"]: - method_data["parameters"].append({ - "name": param.get("name"), - "in": param.get("in"), - "required": param.get("required"), - "schema": param.get("schema") - }) - + method_data["parameters"].append( + { + "name": param.get("name"), + "in": param.get("in"), + "required": param.get("required"), + "schema": param.get("schema"), + } + ) + # Parse responses if "responses" in method_details and isinstance(method_details["responses"], dict): for status_code, response_details in method_details["responses"].items(): method_data["responses"][status_code] = { "description": response_details.get("description"), - "content": response_details.get("content", {}).get("application/json", {}).get("schema") + "content": response_details.get("content", {}) + .get("application/json", {}) + .get("schema"), } - + return method_data def _parse_paths(spec): """Parse paths section of OpenAPI spec.""" paths_data = {} - + if "paths" not in spec or not isinstance(spec["paths"], dict): - print("Warning: 'paths' attribute not found or is not a dictionary in the OpenAPI spec.") + print( + "Warning: 'paths' attribute not found or is not a dictionary in the OpenAPI spec." + ) return paths_data - + for path_string, path_item in spec["paths"].items(): paths_data[path_string] = {} for method, method_details in path_item.items(): - if method.upper() in ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]: - paths_data[path_string][method.upper()] = _parse_method_details(method_details) - + if method.upper() in [ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + "OPTIONS", + "HEAD", + ]: + paths_data[path_string][method.upper()] = _parse_method_details( + method_details + ) + return paths_data def _parse_schemas(spec): """Parse component schemas section of OpenAPI spec.""" components_schemas_data = {} - - if ("components" in spec and - "schemas" in spec["components"] and - isinstance(spec["components"]["schemas"], dict)): + + if ( + "components" in spec + and "schemas" in spec["components"] + and isinstance(spec["components"]["schemas"], dict) + ): for schema_name, schema_details in spec["components"]["schemas"].items(): components_schemas_data[schema_name] = schema_details else: - print("Warning: 'components.schemas' attribute not found or is not a dictionary in the OpenAPI spec.") - + print( + "Warning: 'components.schemas' attribute not found or is not a dictionary in the OpenAPI spec." + ) + return components_schemas_data diff --git a/reference_spec.py b/src/reference_spec.py similarity index 86% rename from reference_spec.py rename to src/reference_spec.py index 6cb0a18..e80abd7 100644 --- a/reference_spec.py +++ b/src/reference_spec.py @@ -1,7 +1,7 @@ -#** Final Oura API V2 Blueprint for Python -#* This document provides a comprehensive, redesigned blueprint for the Oura API V2, engineered for a general-purpose Python library. It is the result of a critical analysis of the official OpenAPI specification and incorporates feedback to create robust, developer-friendly data models and documentation. -#* This is a developer-focused blueprint, not a formal OpenAPI spec, designed for direct translation into Pydantic models. -# +# ** Final Oura API V2 Blueprint for Python +# * This document provides a comprehensive, redesigned blueprint for the Oura API V2, engineered for a general-purpose Python library. It is the result of a critical analysis of the official OpenAPI specification and incorporates feedback to create robust, developer-friendly data models and documentation. +# * This is a developer-focused blueprint, not a formal OpenAPI spec, designed for direct translation into Pydantic models. +# # 1. Core Data ModelsThese are the foundational Pydantic-style models. They are designed to be intuitive, type-safe, and cover the entire surface of the Oura V2 API data endpoints. from datetime import date, datetime, timedelta @@ -12,8 +12,10 @@ # ENUMERATION MODELS # =================================================================== + class ScoreContributor(str, Enum): """Enumeration for factors contributing to a readiness or sleep score.""" + ACTIVITY_BALANCE = "activity_balance" BODY_TEMPERATURE = "body_temperature" HRV_BALANCE = "hrv_balance" @@ -30,8 +32,10 @@ class ScoreContributor(str, Enum): TIMING = "timing" TOTAL_SLEEP = "total_sleep" + class ActivityLevel(str, Enum): """Enumeration for activity intensity levels.""" + NON_WEAR = "non_wear" REST = "rest" INACTIVE = "inactive" @@ -39,15 +43,19 @@ class ActivityLevel(str, Enum): MEDIUM = "medium" HIGH = "high" + class SleepPhase(str, Enum): """Enumeration for sleep phases.""" + AWAKE = "awake" LIGHT = "light" DEEP = "deep" REM = "rem" + class SessionType(str, Enum): """Enumeration for session/moment types.""" + BREATHING = "breathing" MEDITATION = "meditation" NAP = "nap" @@ -55,30 +63,38 @@ class SessionType(str, Enum): REST = "rest" BODY_STATUS = "body_status" + class WorkoutSource(str, Enum): """Enumeration for the source of a workout entry.""" + AUTODETECTED = "autodetected" CONFIRMED = "confirmed" MANUAL = "manual" WORKOUT_HEART_RATE = "workout_heart_rate" + class ResilienceLevel(str, Enum): """Enumeration for long-term resilience levels.""" + LIMITED = "limited" ADEQUATE = "adequate" SOLID = "solid" STRONG = "strong" EXCEPTIONAL = "exceptional" + class RingDesign(str, Enum): """Enumeration for Oura Ring designs.""" + BALANCE = "balance" BALANCE_DIAMOND = "balance_diamond" HERITAGE = "heritage" HORIZON = "horizon" + class RingColor(str, Enum): """Enumeration for Oura Ring colors.""" + BRUSHED_SILVER = "brushed_silver" GLOSSY_BLACK = "glossy_black" GLOSSY_GOLD = "glossy_gold" @@ -90,19 +106,24 @@ class RingColor(str, Enum): STEALTH_BLACK = "stealth_black" TITANIUM = "titanium" + class RingHardwareType(str, Enum): """Enumeration for Oura Ring hardware generations.""" + GEN1 = "gen1" GEN2 = "gen2" GEN2M = "gen2m" GEN3 = "gen3" + # =================================================================== # SHARED & REUSABLE MODELS # =================================================================== + class TimeInterval: """A reusable model to represent a time interval with a start and optional end.""" + start: datetime end: Optional[datetime] = None @@ -113,24 +134,31 @@ def duration(self) -> Optional[timedelta]: return self.end - self.start return None + class TimeSeries: """A generic model for time-series data like heart rate or HRV.""" - timestamp: datetime # Start time of the first sample. - interval_seconds: int # Number of seconds between items. - items: List[Optional[float]] # The list of sampled data points. + + timestamp: datetime # Start time of the first sample. + interval_seconds: int # Number of seconds between items. + items: List[Optional[float]] # The list of sampled data points. + class HeartRateSample: """Represents a single heart rate measurement at a point in time.""" + bpm: int source: str timestamp: datetime + # =================================================================== # PRIMARY DATA MODELS # =================================================================== + class PersonalInfo: """Represents the user's core biological and demographic information.""" + id: str age: Optional[int] = None weight_kg: Optional[float] = None @@ -138,8 +166,10 @@ class PersonalInfo: biological_sex: Optional[str] = None email: Optional[str] = None + class RingConfiguration: """Represents the configuration and hardware details of a user's Oura Ring.""" + id: str color: Optional[RingColor] = None design: Optional[RingDesign] = None @@ -148,8 +178,10 @@ class RingConfiguration: size: Optional[int] = None set_up_at: Optional[datetime] = None + class DailyReadiness: """Represents the user's readiness score for a single day.""" + id: str day: date score: Optional[int] = None @@ -157,8 +189,10 @@ class DailyReadiness: temperature_deviation_celsius: Optional[float] = None temperature_trend_deviation_celsius: Optional[float] = None + class DailyActivity: """Represents the user's activity summary for a single day.""" + id: str day: date score: Optional[int] = None @@ -175,8 +209,10 @@ class DailyActivity: target_calories: Optional[int] = None target_meters: Optional[int] = None + class DailySleep: """Represents consolidated sleep data for a single sleep period.""" + id: str day: date bedtime: TimeInterval @@ -195,17 +231,21 @@ class DailySleep: average_hrv: Optional[int] = None hrv_timeseries: Optional[TimeSeries] = None heart_rate_timeseries: Optional[TimeSeries] = None - + + class EnhancedTag: """Represents a user-created tag for logging events, habits, or feelings.""" + id: str interval: TimeInterval tag_type_code: Optional[str] = None comment: Optional[str] = None custom_name: Optional[str] = None + class Workout: """Represents a single workout session.""" + id: str interval: TimeInterval activity: str @@ -215,8 +255,10 @@ class Workout: distance_meters: Optional[float] = None label: Optional[str] = None + class Session: """Represents a mindfulness, nap, or other session.""" + id: str interval: TimeInterval day: date @@ -225,39 +267,51 @@ class Session: heart_rate_timeseries: Optional[TimeSeries] = None hrv_timeseries: Optional[TimeSeries] = None + class DailySpo2: """Represents the user's blood oxygen saturation (SpO2) summary for a single day.""" + id: str day: date average_spo2_percentage: Optional[float] = None + class DailyStress: """Represents the user's stress and recovery summary for a single day.""" + id: str day: date stress_high_duration: Optional[timedelta] = None recovery_high_duration: Optional[timedelta] = None - + + class DailyResilience: """Represents the user's resilience summary for a single day.""" + id: str day: date level: Optional[ResilienceLevel] = None + class CardiovascularAge: """Represents the user's cardiovascular age assessment.""" + id: str day: date vascular_age: Optional[float] = None + class VO2Max: """Represents the user's VO2 Max assessment (maximal oxygen uptake).""" + id: str day: date vo2_max: Optional[float] = None + class HeartRateData: """Represents a collection of heart rate measurements over a time interval.""" + data: List[HeartRateSample] next_token: Optional[str] = None @@ -265,20 +319,21 @@ class HeartRateData: # 2. API Client DefinitionThis section defines the methods for a comprehensive client, with detailed documentation suitable for a high-quality library.# python # Base URL for all API calls: https://api.ouraring.com/v2/usercollection + class OuraApiClient: """A conceptual Python client for interacting with the optimized Oura API models.""" def get_personal_info(self) -> PersonalInfo: """ Retrieves the user's basic biological and demographic information. - + This data changes infrequently and is suitable for caching. Returns: PersonalInfo: An object containing the user's age, weight, height, etc. """ pass - + def get_ring_configurations(self) -> List[RingConfiguration]: """ Retrieves a list of all rings ever associated with the user's account. @@ -289,7 +344,9 @@ def get_ring_configurations(self) -> List[RingConfiguration]: """ pass - def get_daily_readiness(self, start_date: date, end_date: Optional[date] = None) -> List[DailyReadiness]: + def get_daily_readiness( + self, start_date: date, end_date: Optional[date] = None + ) -> List[DailyReadiness]: """ Retrieves daily readiness summaries for a given date range. @@ -302,7 +359,9 @@ def get_daily_readiness(self, start_date: date, end_date: Optional[date] = None) """ pass - def get_daily_activity(self, start_date: date, end_date: Optional[date] = None) -> List[DailyActivity]: + def get_daily_activity( + self, start_date: date, end_date: Optional[date] = None + ) -> List[DailyActivity]: """ Retrieves daily activity summaries for a given date range. @@ -315,13 +374,15 @@ def get_daily_activity(self, start_date: date, end_date: Optional[date] = None) """ pass - def get_daily_sleep(self, start_date: date, end_date: Optional[date] = None) -> List[DailySleep]: + def get_daily_sleep( + self, start_date: date, end_date: Optional[date] = None + ) -> List[DailySleep]: """ Retrieves comprehensive sleep data for a given date range. Note: A robust implementation of this method should intelligently query both the `/daily_sleep` and `/sleep` endpoints to construct the complete `DailySleep` model. - + Args: start_date: The first day of the query range. end_date: The last day of the query range. If None, queries for start_date only. @@ -331,7 +392,9 @@ def get_daily_sleep(self, start_date: date, end_date: Optional[date] = None) -> """ pass - def get_daily_spo2(self, start_date: date, end_date: Optional[date] = None) -> List[DailySpo2]: + def get_daily_spo2( + self, start_date: date, end_date: Optional[date] = None + ) -> List[DailySpo2]: """ Retrieves daily blood oxygen saturation (SpO2) summaries. @@ -344,7 +407,9 @@ def get_daily_spo2(self, start_date: date, end_date: Optional[date] = None) -> L """ pass - def get_daily_stress(self, start_date: date, end_date: Optional[date] = None) -> List[DailyStress]: + def get_daily_stress( + self, start_date: date, end_date: Optional[date] = None + ) -> List[DailyStress]: """ Retrieves daily stress and recovery summaries. @@ -357,7 +422,9 @@ def get_daily_stress(self, start_date: date, end_date: Optional[date] = None) -> """ pass - def get_daily_resilience(self, start_date: date, end_date: Optional[date] = None) -> List[DailyResilience]: + def get_daily_resilience( + self, start_date: date, end_date: Optional[date] = None + ) -> List[DailyResilience]: """ Retrieves daily resilience summaries. @@ -370,7 +437,9 @@ def get_daily_resilience(self, start_date: date, end_date: Optional[date] = None """ pass - def get_cardiovascular_age(self, start_date: date, end_date: Optional[date] = None) -> List[CardiovascularAge]: + def get_cardiovascular_age( + self, start_date: date, end_date: Optional[date] = None + ) -> List[CardiovascularAge]: """ Retrieves cardiovascular age assessments. @@ -383,7 +452,9 @@ def get_cardiovascular_age(self, start_date: date, end_date: Optional[date] = No """ pass - def get_vo2_max(self, start_date: date, end_date: Optional[date] = None) -> List[VO2Max]: + def get_vo2_max( + self, start_date: date, end_date: Optional[date] = None + ) -> List[VO2Max]: """ Retrieves VO2 Max assessments. @@ -396,7 +467,9 @@ def get_vo2_max(self, start_date: date, end_date: Optional[date] = None) -> List """ pass - def get_heart_rate(self, start_datetime: datetime, end_datetime: Optional[datetime] = None) -> HeartRateData: + def get_heart_rate( + self, start_datetime: datetime, end_datetime: Optional[datetime] = None + ) -> HeartRateData: """ Retrieves high-resolution, time-series heart rate data. @@ -409,7 +482,9 @@ def get_heart_rate(self, start_datetime: datetime, end_datetime: Optional[dateti """ pass - def get_workouts(self, start_date: date, end_date: Optional[date] = None) -> List[Workout]: + def get_workouts( + self, start_date: date, end_date: Optional[date] = None + ) -> List[Workout]: """ Retrieves user-logged workouts for a given date range. @@ -422,7 +497,9 @@ def get_workouts(self, start_date: date, end_date: Optional[date] = None) -> Lis """ pass - def get_sessions(self, start_date: date, end_date: Optional[date] = None) -> List[Session]: + def get_sessions( + self, start_date: date, end_date: Optional[date] = None + ) -> List[Session]: """ Retrieves mindfulness, nap, and other guided/unguided sessions. @@ -434,8 +511,10 @@ def get_sessions(self, start_date: date, end_date: Optional[date] = None) -> Lis List[Session]: A list of session objects. """ pass - - def get_enhanced_tags(self, start_date: date, end_date: Optional[date] = None) -> List[EnhancedTag]: + + def get_enhanced_tags( + self, start_date: date, end_date: Optional[date] = None + ) -> List[EnhancedTag]: """ Retrieves user-created tags for logging events, habits, or feelings. diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..a383a09 Binary files /dev/null and b/tests/__pycache__/__init__.cpython-312.pyc differ diff --git a/tests/__pycache__/test_client.cpython-312-pytest-8.4.2.pyc b/tests/__pycache__/test_client.cpython-312-pytest-8.4.2.pyc new file mode 100644 index 0000000..dfc02bd Binary files /dev/null and b/tests/__pycache__/test_client.cpython-312-pytest-8.4.2.pyc differ diff --git a/tests/__pycache__/test_error_handling.cpython-312-pytest-8.4.2.pyc b/tests/__pycache__/test_error_handling.cpython-312-pytest-8.4.2.pyc new file mode 100644 index 0000000..865893b Binary files /dev/null and b/tests/__pycache__/test_error_handling.cpython-312-pytest-8.4.2.pyc differ diff --git a/tests/__pycache__/test_pagination.cpython-312-pytest-8.4.2.pyc b/tests/__pycache__/test_pagination.cpython-312-pytest-8.4.2.pyc new file mode 100644 index 0000000..a2bc1fa Binary files /dev/null and b/tests/__pycache__/test_pagination.cpython-312-pytest-8.4.2.pyc differ diff --git a/tests/test_client.py b/tests/test_client.py index c3df0ca..30bf815 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -7,50 +7,72 @@ from oura_api_client.api.client import OuraClient from oura_api_client.models.heartrate import HeartRateResponse from oura_api_client.models.daily_activity import ( - DailyActivityResponse, DailyActivityModel, ActivityContributors + DailyActivityResponse, + DailyActivityModel, + ActivityContributors, ) from oura_api_client.models.daily_sleep import ( - DailySleepResponse, DailySleepModel, SleepContributors as DailySleepContributors + DailySleepResponse, + DailySleepModel, + SleepContributors as DailySleepContributors, ) from oura_api_client.models.daily_readiness import ( - DailyReadinessResponse, DailyReadinessModel, ReadinessContributors as DailyReadinessContributors + DailyReadinessResponse, + DailyReadinessModel, + ReadinessContributors as DailyReadinessContributors, +) +from oura_api_client.models.sleep import ( + SleepResponse, + SleepModel, + SleepContributors, + ReadinessContributors, ) -from oura_api_client.models.sleep import SleepResponse, SleepModel, SleepContributors, ReadinessContributors from oura_api_client.models.session import SessionResponse, SessionModel from oura_api_client.models.tag import TagResponse, TagModel from oura_api_client.models.workout import WorkoutResponse, WorkoutModel -from oura_api_client.models.enhanced_tag import ( - EnhancedTagResponse, EnhancedTagModel -) +from oura_api_client.models.enhanced_tag import EnhancedTagResponse, EnhancedTagModel from oura_api_client.models.daily_spo2 import ( - DailySpO2Response, DailySpO2Model, DailySpO2AggregatedValuesModel + DailySpO2Response, + DailySpO2Model, + DailySpO2AggregatedValuesModel, ) from oura_api_client.models.sleep_time import ( - SleepTimeResponse, SleepTimeModel, SleepTimeWindow, - SleepTimeRecommendation, SleepTimeStatus + SleepTimeResponse, + SleepTimeModel, + SleepTimeWindow, + SleepTimeRecommendation, + SleepTimeStatus, ) from oura_api_client.models.rest_mode_period import ( - RestModePeriodResponse, RestModePeriodModel + RestModePeriodResponse, + RestModePeriodModel, ) # Added RestModePeriod models from oura_api_client.models.daily_stress import ( - DailyStressResponse, DailyStressModel + DailyStressResponse, + DailyStressModel, ) # Added DailyStress models from oura_api_client.models.daily_resilience import ( - DailyResilienceResponse, DailyResilienceModel + DailyResilienceResponse, + DailyResilienceModel, ) # Added DailyResilience models from oura_api_client.models.daily_cardiovascular_age import ( - DailyCardiovascularAgeResponse, DailyCardiovascularAgeModel + DailyCardiovascularAgeResponse, + DailyCardiovascularAgeModel, ) # Added DailyCardiovascularAge models from oura_api_client.models.vo2_max import ( - Vo2MaxResponse, Vo2MaxModel + Vo2MaxResponse, + Vo2MaxModel, ) # Added Vo2Max models import requests from oura_api_client.exceptions import ( - OuraNotFoundError, OuraRateLimitError, - OuraClientError, OuraConnectionError + OuraNotFoundError, + OuraRateLimitError, + OuraClientError, + OuraConnectionError, ) + class TestOuraClient(unittest.TestCase): """Test the OuraClient class.""" @@ -61,9 +83,7 @@ def setUp(self): def test_initialization(self): """Test that the client initializes correctly.""" self.assertEqual(self.client.access_token, "test_token") - self.assertEqual( - self.client.headers["Authorization"], "Bearer test_token" - ) + self.assertEqual(self.client.headers["Authorization"], "Bearer test_token") self.assertIsNotNone(self.client.heartrate) self.assertIsNotNone(self.client.personal) self.assertIsNotNone(self.client.daily_activity) @@ -95,11 +115,7 @@ def test_get_heart_rate(self, mock_get): mock_response.raise_for_status.return_value = None mock_response.json.return_value = { "data": [ - { - "timestamp": "2024-03-01T12:00:00+00:00", - "bpm": 75, - "source": "test" - } + {"timestamp": "2024-03-01T12:00:00+00:00", "bpm": 75, "source": "test"} ], "next_token": None, } @@ -127,9 +143,11 @@ def test_get_heart_rate(self, mock_get): timeout=30.0, ) + if __name__ == "__main__": unittest.main() + class TestDailyActivity(unittest.TestCase): def setUp(self): @@ -149,10 +167,7 @@ def test_get_daily_activity_documents(self, mock_get): "timestamp": "2024-03-11T00:00:00+00:00", }, ] - mock_response_json = { - "data": mock_data, - "next_token": "test_next_token" - } + mock_response_json = {"data": mock_data, "next_token": "test_next_token"} # Configure the mock_get object to simulate a successful response mock_response = MagicMock() # Simulate no HTTP error @@ -168,20 +183,14 @@ def test_get_daily_activity_documents(self, mock_get): daily_activity_response = ( self.client.daily_activity.get_daily_activity_documents( - start_date=start_date, - end_date=end_date, - next_token="test_token" + start_date=start_date, end_date=end_date, next_token="test_token" ) ) self.assertIsInstance(daily_activity_response, DailyActivityResponse) self.assertEqual(len(daily_activity_response.data), 2) - self.assertIsInstance( - daily_activity_response.data[0], DailyActivityModel - ) - self.assertEqual( - daily_activity_response.next_token, "test_next_token" - ) + self.assertIsInstance(daily_activity_response.data[0], DailyActivityModel) + self.assertEqual(daily_activity_response.next_token, "test_next_token") # Use self.client.client._make_request for assertion # if client.get is not available actual_call_url = mock_get.call_args[0][0] @@ -189,7 +198,7 @@ def test_get_daily_activity_documents(self, mock_get): expected_url = f"{base_url}/usercollection/daily_activity" self.assertTrue(actual_call_url.endswith(expected_url)) - called_params = mock_get.call_args[1]['params'] + called_params = mock_get.call_args[1]["params"] expected_params = { "start_date": start_date_str, "end_date": end_date_str, @@ -223,7 +232,7 @@ def test_get_daily_activity_documents_with_string_dates(self, mock_get): expected_url = f"{base_url}/usercollection/daily_activity" self.assertTrue(actual_call_url.endswith(expected_url)) - called_params = mock_get.call_args[1]['params'] + called_params = mock_get.call_args[1]["params"] expected_params = {"start_date": start_date_str, "end_date": end_date_str} self.assertEqual(called_params, expected_params) @@ -262,7 +271,7 @@ def test_get_daily_activity_document(self, mock_get): "met": { "interval": 5, "items": [1.5, 2.0, 1.8, 2.2], - "timestamp": "2024-03-10T12:00:00+00:00" + "timestamp": "2024-03-10T12:00:00+00:00", }, "meters_to_target": 1000, "non_wear_time": 300, @@ -282,20 +291,24 @@ def test_get_daily_activity_document(self, mock_get): mock_get.return_value = mock_response document_id = "test_document_id" - daily_activity_document = self.client.daily_activity.get_daily_activity_document( - document_id=document_id + daily_activity_document = ( + self.client.daily_activity.get_daily_activity_document( + document_id=document_id + ) ) self.assertIsInstance(daily_activity_document, DailyActivityModel) self.assertEqual(daily_activity_document.id, document_id) - self.assertIsInstance(daily_activity_document.contributors, ActivityContributors) + self.assertIsInstance( + daily_activity_document.contributors, ActivityContributors + ) actual_call_url = mock_get.call_args[0][0] base_url = self.client.BASE_URL expected_url = f"{base_url}/usercollection/daily_activity/{document_id}" self.assertTrue(actual_call_url.endswith(expected_url)) - called_params = mock_get.call_args[1]['params'] + called_params = mock_get.call_args[1]["params"] self.assertEqual(called_params, None) @patch("requests.get") @@ -303,7 +316,10 @@ def test_get_daily_activity_document_error(self, mock_get): mock_get.side_effect = OuraConnectionError("API error") document_id = "test_document_id" with self.assertRaises(OuraConnectionError): - self.client.daily_activity.get_daily_activity_document(document_id=document_id) + self.client.daily_activity.get_daily_activity_document( + document_id=document_id + ) + class TestDailySleep(unittest.TestCase): def setUp(self): @@ -353,20 +369,15 @@ def test_get_daily_sleep_documents(self, mock_get): start_date = date.fromisoformat(start_date_str) end_date = date.fromisoformat(end_date_str) - daily_sleep_response = ( - self.client.daily_sleep.get_daily_sleep_documents( - start_date=start_date, - end_date=end_date, - next_token="test_sleep_token" - ) + daily_sleep_response = self.client.daily_sleep.get_daily_sleep_documents( + start_date=start_date, end_date=end_date, next_token="test_sleep_token" ) self.assertIsInstance(daily_sleep_response, DailySleepResponse) self.assertEqual(len(daily_sleep_response.data), 2) self.assertIsInstance(daily_sleep_response.data[0], DailySleepModel) self.assertIsInstance( - daily_sleep_response.data[0].contributors, - DailySleepContributors + daily_sleep_response.data[0].contributors, DailySleepContributors ) self.assertEqual(daily_sleep_response.next_token, "next_sleep_token") @@ -451,17 +462,15 @@ def test_get_daily_sleep_document(self, mock_get): self.assertIsInstance(daily_sleep_document, DailySleepModel) self.assertEqual(daily_sleep_document.id, document_id) - self.assertIsInstance( - daily_sleep_document.contributors, DailySleepContributors - ) + self.assertIsInstance(daily_sleep_document.contributors, DailySleepContributors) self.assertEqual(daily_sleep_document.score, 85) self.assertEqual( daily_sleep_document.bedtime_end, - datetime.fromisoformat("2024-03-11T07:00:00+00:00") + datetime.fromisoformat("2024-03-11T07:00:00+00:00"), ) self.assertEqual( daily_sleep_document.bedtime_start, - datetime.fromisoformat("2024-03-10T22:00:00+00:00") + datetime.fromisoformat("2024-03-10T22:00:00+00:00"), ) mock_get.assert_called_once_with( @@ -478,6 +487,7 @@ def test_get_daily_sleep_document_error(self, mock_get): with self.assertRaises(OuraConnectionError): self.client.daily_sleep.get_daily_sleep_document(document_id=document_id) + class TestDailyReadiness(unittest.TestCase): def setUp(self): @@ -516,22 +526,17 @@ def test_get_daily_readiness_documents(self, mock_get): self.client.daily_readiness.get_daily_readiness_documents( start_date=start_date, end_date=end_date, - next_token="test_readiness_token" + next_token="test_readiness_token", ) ) self.assertIsInstance(daily_readiness_response, DailyReadinessResponse) self.assertEqual(len(daily_readiness_response.data), 2) + self.assertIsInstance(daily_readiness_response.data[0], DailyReadinessModel) self.assertIsInstance( - daily_readiness_response.data[0], DailyReadinessModel - ) - self.assertIsInstance( - daily_readiness_response.data[0].contributors, - DailyReadinessContributors - ) - self.assertEqual( - daily_readiness_response.next_token, "next_readiness_token" + daily_readiness_response.data[0].contributors, DailyReadinessContributors ) + self.assertEqual(daily_readiness_response.next_token, "next_readiness_token") mock_get.assert_called_once_with( f"{self.client.BASE_URL}/usercollection/daily_readiness", @@ -620,13 +625,11 @@ def test_get_daily_readiness_document(self, mock_get): self.assertIsInstance(daily_readiness_document, DailyReadinessModel) self.assertEqual(daily_readiness_document.id, document_id) self.assertIsInstance( - daily_readiness_document.contributors, - DailyReadinessContributors + daily_readiness_document.contributors, DailyReadinessContributors ) self.assertEqual(daily_readiness_document.score, 78) self.assertEqual( - daily_readiness_document.activity_class_5_min, - "some_activity_class" + daily_readiness_document.activity_class_5_min, "some_activity_class" ) self.assertEqual(daily_readiness_document.hrv_balance_data, "some_hrv_data") self.assertEqual(daily_readiness_document.spo2_percentage, 98.5) @@ -647,6 +650,7 @@ def test_get_daily_readiness_document_error(self, mock_get): document_id=document_id ) + class TestSleep(unittest.TestCase): def setUp(self): @@ -656,17 +660,24 @@ def setUp(self): def test_get_sleep_documents(self, mock_get): # Reused from DailySleep for consistency mock_contributors_data = { - "deep_sleep": 70, "efficiency": 80, - "latency": 90, "rem_sleep": 60, - "restfulness": 75, "timing": 85, "total_sleep": 95, + "deep_sleep": 70, + "efficiency": 80, + "latency": 90, + "rem_sleep": 60, + "restfulness": 75, + "timing": 85, + "total_sleep": 95, } # Reused from DailyReadiness mock_readiness_contributors_data = { - "activity_balance": 60, "body_temperature": 70, + "activity_balance": 60, + "body_temperature": 70, "hrv_balance": 80, - "previous_day_activity": 90, "previous_night": 50, + "previous_day_activity": 90, + "previous_night": 50, "recovery_index": 65, - "resting_heart_rate": 75, "sleep_balance": 85, + "resting_heart_rate": 75, + "sleep_balance": 85, } mock_data = [ { @@ -691,10 +702,7 @@ def test_get_sleep_documents(self, mock_get): "timestamp": "2024-03-10T22:00:00+00:00", # Added timestamp for SleepModel }, ] - mock_response_json = { - "data": mock_data, - "next_token": "next_sleep_doc_token" - } + mock_response_json = {"data": mock_data, "next_token": "next_sleep_doc_token"} mock_response = MagicMock() mock_response.raise_for_status.return_value = None mock_response.json.return_value = mock_response_json @@ -706,23 +714,14 @@ def test_get_sleep_documents(self, mock_get): end_date = date.fromisoformat(end_date_str) sleep_response = self.client.sleep.get_sleep_documents( - start_date=start_date, - end_date=end_date, - next_token="test_sleep_doc_token" + start_date=start_date, end_date=end_date, next_token="test_sleep_doc_token" ) self.assertIsInstance(sleep_response, SleepResponse) self.assertEqual(len(sleep_response.data), 1) self.assertIsInstance(sleep_response.data[0], SleepModel) - self.assertIsInstance( - - sleep_response.data[0].contributors, - SleepContributors - - ) - self.assertIsInstance( - sleep_response.data[0].readiness, ReadinessContributors - ) + self.assertIsInstance(sleep_response.data[0].contributors, SleepContributors) + self.assertIsInstance(sleep_response.data[0].readiness, ReadinessContributors) self.assertEqual(sleep_response.next_token, "next_sleep_doc_token") mock_get.assert_called_once_with( @@ -739,12 +738,14 @@ def test_get_sleep_documents(self, mock_get): @patch("requests.get") def test_get_sleep_documents_with_string_dates(self, mock_get): # Simplified mock data for this test - mock_data = [{ - "id": "sleep_doc_str_date", - "day": "2024-03-10", - "contributors": {"deep_sleep": 1}, - "timestamp": "2024-03-10T22:00:00+00:00" - }] + mock_data = [ + { + "id": "sleep_doc_str_date", + "day": "2024-03-10", + "contributors": {"deep_sleep": 1}, + "timestamp": "2024-03-10T22:00:00+00:00", + } + ] mock_response_json = {"data": mock_data, "next_token": None} mock_response = MagicMock() mock_response.raise_for_status.return_value = None @@ -795,9 +796,7 @@ def test_get_sleep_document(self, mock_get): mock_get.return_value = mock_response document_id = "test_sleep_doc_single" - sleep_document = self.client.sleep.get_sleep_document( - document_id=document_id - ) + sleep_document = self.client.sleep.get_sleep_document(document_id=document_id) self.assertIsInstance(sleep_document, SleepModel) self.assertEqual(sleep_document.id, document_id) @@ -806,7 +805,7 @@ def test_get_sleep_document(self, mock_get): self.assertEqual(sleep_document.score, 88) self.assertEqual( sleep_document.bedtime_end, - datetime.fromisoformat("2024-03-11T07:30:00+00:00") + datetime.fromisoformat("2024-03-11T07:30:00+00:00"), ) mock_get.assert_called_once_with( @@ -823,6 +822,7 @@ def test_get_sleep_document_error(self, mock_get): with self.assertRaises(OuraConnectionError): self.client.sleep.get_sleep_document(document_id=document_id) + class TestSession(unittest.TestCase): def setUp(self): @@ -861,9 +861,7 @@ def test_get_session_documents(self, mock_get): end_date = date.fromisoformat(end_date_str) session_response = self.client.session.get_session_documents( - start_date=start_date, - end_date=end_date, - next_token="test_session_token" + start_date=start_date, end_date=end_date, next_token="test_session_token" ) self.assertIsInstance(session_response, SessionResponse) @@ -951,7 +949,7 @@ def test_get_session_document(self, mock_get): self.assertEqual(session_document.duration, 2700) self.assertEqual( session_document.start_datetime, - datetime.fromisoformat("2024-03-10T15:00:00+00:00") + datetime.fromisoformat("2024-03-10T15:00:00+00:00"), ) mock_get.assert_called_once_with( @@ -968,6 +966,7 @@ def test_get_session_document_error(self, mock_get): with self.assertRaises(OuraConnectionError): self.client.session.get_session_document(document_id=document_id) + class TestTag(unittest.TestCase): def setUp(self): @@ -1001,9 +1000,7 @@ def test_get_tag_documents(self, mock_get): end_date = date.fromisoformat(end_date_str) tag_response = self.client.tag.get_tag_documents( - start_date=start_date, - end_date=end_date, - next_token="test_tag_token" + start_date=start_date, end_date=end_date, next_token="test_tag_token" ) self.assertIsInstance(tag_response, TagResponse) @@ -1074,16 +1071,13 @@ def test_get_tag_document(self, mock_get): mock_get.return_value = mock_response document_id = "test_tag_single" - tag_document = self.client.tag.get_tag_document( - document_id=document_id - ) + tag_document = self.client.tag.get_tag_document(document_id=document_id) self.assertIsInstance(tag_document, TagModel) self.assertEqual(tag_document.id, document_id) self.assertEqual(tag_document.text, "Single tag test") self.assertEqual( - tag_document.timestamp, - datetime.fromisoformat("2024-03-10T11:00:00+00:00") + tag_document.timestamp, datetime.fromisoformat("2024-03-10T11:00:00+00:00") ) mock_get.assert_called_once_with( @@ -1100,6 +1094,7 @@ def test_get_tag_document_error(self, mock_get): with self.assertRaises(OuraConnectionError): self.client.tag.get_tag_document(document_id=document_id) + class TestWorkout(unittest.TestCase): def setUp(self): @@ -1141,9 +1136,7 @@ def test_get_workout_documents(self, mock_get): end_date = date.fromisoformat(end_date_str) workout_response = self.client.workout.get_workout_documents( - start_date=start_date, - end_date=end_date, - next_token="test_workout_token" + start_date=start_date, end_date=end_date, next_token="test_workout_token" ) self.assertIsInstance(workout_response, WorkoutResponse) @@ -1235,7 +1228,7 @@ def test_get_workout_document(self, mock_get): self.assertEqual(workout_document.source, "apple_health") self.assertEqual( workout_document.start_datetime, - datetime.fromisoformat("2024-03-10T12:00:00+00:00") + datetime.fromisoformat("2024-03-10T12:00:00+00:00"), ) mock_get.assert_called_once_with( @@ -1252,6 +1245,7 @@ def test_get_workout_document_error(self, mock_get): with self.assertRaises(OuraConnectionError): self.client.workout.get_workout_document(document_id=document_id) + class TestEnhancedTag(unittest.TestCase): def setUp(self): @@ -1267,19 +1261,19 @@ def test_get_enhanced_tag_documents(self, mock_get): "end_time": "2024-03-12T00:00:00+00:00", "start_day": "2024-03-10", "end_day": "2024-03-12", - "comment": "Feeling under the weather." + "comment": "Feeling under the weather.", }, { "id": "tag_2", "tag_type_code": "vacation", "start_time": "2024-03-15T00:00:00+00:00", "start_day": "2024-03-15", - "comment": "Beach time!" + "comment": "Beach time!", }, ] mock_response_json = { "data": mock_data, - "next_token": "next_enhanced_tag_token" + "next_token": "next_enhanced_tag_token", } mock_response = MagicMock() mock_response.raise_for_status.return_value = None @@ -1294,21 +1288,15 @@ def test_get_enhanced_tag_documents(self, mock_get): enhanced_tag_response = self.client.enhanced_tag.get_enhanced_tag_documents( start_date=start_date, end_date=end_date, - next_token="test_enhanced_tag_token" + next_token="test_enhanced_tag_token", ) self.assertIsInstance(enhanced_tag_response, EnhancedTagResponse) self.assertEqual(len(enhanced_tag_response.data), 2) - self.assertIsInstance( - enhanced_tag_response.data[0], EnhancedTagModel - ) - self.assertEqual( - enhanced_tag_response.next_token, "next_enhanced_tag_token" - ) + self.assertIsInstance(enhanced_tag_response.data[0], EnhancedTagModel) + self.assertEqual(enhanced_tag_response.next_token, "next_enhanced_tag_token") self.assertEqual(enhanced_tag_response.data[0].tag_type_code, "common_cold") - self.assertEqual( - enhanced_tag_response.data[1].start_day, date(2024, 3, 15) - ) + self.assertEqual(enhanced_tag_response.data[1].start_day, date(2024, 3, 15)) mock_get.assert_called_once_with( f"{self.client.BASE_URL}/usercollection/enhanced_tag", @@ -1368,7 +1356,7 @@ def test_get_enhanced_tag_document(self, mock_get): "end_time": "2024-03-10T18:00:00+00:00", "start_day": "2024-03-10", "end_day": "2024-03-10", - "comment": "Tough day at work." + "comment": "Tough day at work.", } mock_response = MagicMock() mock_response.raise_for_status.return_value = None @@ -1386,11 +1374,9 @@ def test_get_enhanced_tag_document(self, mock_get): self.assertEqual(enhanced_tag_document.comment, "Tough day at work.") self.assertEqual( enhanced_tag_document.start_time, - datetime.fromisoformat("2024-03-10T10:00:00+00:00") - ) - self.assertEqual( - enhanced_tag_document.end_day, date(2024, 3, 10) + datetime.fromisoformat("2024-03-10T10:00:00+00:00"), ) + self.assertEqual(enhanced_tag_document.end_day, date(2024, 3, 10)) mock_get.assert_called_once_with( f"{self.client.BASE_URL}/usercollection/enhanced_tag/{document_id}", @@ -1406,6 +1392,7 @@ def test_get_enhanced_tag_document_error(self, mock_get): with self.assertRaises(OuraConnectionError): self.client.enhanced_tag.get_enhanced_tag_document(document_id=document_id) + class TestDailySpo2(unittest.TestCase): def setUp(self): @@ -1419,19 +1406,16 @@ def test_get_daily_spo2_documents(self, mock_get): "day": "2024-03-10", "spo2_percentage": 97.5, "aggregated_values": {"average": 97.5}, - "timestamp": "2024-03-11T00:00:00+00:00" + "timestamp": "2024-03-11T00:00:00+00:00", }, { "id": "spo2_2", "day": "2024-03-11", "aggregated_values": {"average": 98.0}, - "timestamp": "2024-03-12T00:00:00+00:00" + "timestamp": "2024-03-12T00:00:00+00:00", }, ] - mock_response_json = { - "data": mock_data, - "next_token": "next_spo2_token" - } + mock_response_json = {"data": mock_data, "next_token": "next_spo2_token"} mock_response = MagicMock() mock_response.raise_for_status.return_value = None mock_response.json.return_value = mock_response_json @@ -1443,9 +1427,7 @@ def test_get_daily_spo2_documents(self, mock_get): end_date = date.fromisoformat(end_date_str) daily_spo2_response = self.client.daily_spo2.get_daily_spo2_documents( - start_date=start_date, - end_date=end_date, - next_token="test_spo2_token" + start_date=start_date, end_date=end_date, next_token="test_spo2_token" ) self.assertIsInstance(daily_spo2_response, DailySpO2Response) @@ -1455,7 +1437,7 @@ def test_get_daily_spo2_documents(self, mock_get): if daily_spo2_response.data[0].aggregated_values: self.assertIsInstance( daily_spo2_response.data[0].aggregated_values, - DailySpO2AggregatedValuesModel + DailySpO2AggregatedValuesModel, ) self.assertEqual(daily_spo2_response.next_token, "next_spo2_token") self.assertEqual(daily_spo2_response.data[0].spo2_percentage, 97.5) @@ -1478,7 +1460,7 @@ def test_get_daily_spo2_documents_with_string_dates(self, mock_get): "id": "spo2_str_date", "day": "2024-03-10", "aggregated_values": {"average": 96.0}, - "timestamp": "2024-03-11T00:00:00+00:00" + "timestamp": "2024-03-11T00:00:00+00:00", } ] mock_response_json = {"data": mock_data, "next_token": None} @@ -1516,7 +1498,7 @@ def test_get_daily_spo2_document(self, mock_get): "day": "2024-03-10", "spo2_percentage": 98.2, "aggregated_values": {"average": 98.2}, - "timestamp": "2024-03-11T00:00:00+00:00" + "timestamp": "2024-03-11T00:00:00+00:00", } mock_response = MagicMock() mock_response.raise_for_status.return_value = None @@ -1535,7 +1517,7 @@ def test_get_daily_spo2_document(self, mock_get): self.assertEqual(daily_spo2_document.aggregated_values.average, 98.2) self.assertEqual( daily_spo2_document.timestamp, - datetime.fromisoformat("2024-03-11T00:00:00+00:00") + datetime.fromisoformat("2024-03-11T00:00:00+00:00"), ) mock_get.assert_called_once_with( @@ -1552,6 +1534,7 @@ def test_get_daily_spo2_document_error(self, mock_get): with self.assertRaises(OuraConnectionError): self.client.daily_spo2.get_daily_spo2_document(document_id=document_id) + class TestSleepTime(unittest.TestCase): def setUp(self): @@ -1566,31 +1549,23 @@ def test_get_sleep_time_documents(self, mock_get): "optimal_bedtime": { "start_offset": -1800, "end_offset": 3600, - "day_light_saving_time": 0 + "day_light_saving_time": 0, }, "recommendation": {"recommendation": "go_to_bed_earlier"}, "status": {"status": "slightly_late"}, - "timestamp": "2024-03-10T04:00:00+00:00" + "timestamp": "2024-03-10T04:00:00+00:00", }, { "id": "st_2", "day": "2024-03-11", - # Missing day_light_saving_time to test Optional - "optimal_bedtime": { - "start_offset": -1500, - "end_offset": 3900 - }, - "recommendation": { - "recommendation": "maintain_consistent_schedule" - }, + # Missing day_light_saving_time to test Optional + "optimal_bedtime": {"start_offset": -1500, "end_offset": 3900}, + "recommendation": {"recommendation": "maintain_consistent_schedule"}, "status": {"status": "optimal"}, - "timestamp": "2024-03-11T04:00:00+00:00" + "timestamp": "2024-03-11T04:00:00+00:00", }, ] - mock_response_json = { - "data": mock_data, - "next_token": "next_sleep_time_token" - } + mock_response_json = {"data": mock_data, "next_token": "next_sleep_time_token"} mock_response = MagicMock() mock_response.raise_for_status.return_value = None mock_response.json.return_value = mock_response_json @@ -1602,9 +1577,7 @@ def test_get_sleep_time_documents(self, mock_get): end_date = date.fromisoformat(end_date_str) sleep_time_response = self.client.sleep_time.get_sleep_time_documents( - start_date=start_date, - end_date=end_date, - next_token="test_sleep_time_token" + start_date=start_date, end_date=end_date, next_token="test_sleep_time_token" ) self.assertIsInstance(sleep_time_response, SleepTimeResponse) @@ -1612,19 +1585,14 @@ def test_get_sleep_time_documents(self, mock_get): self.assertIsInstance(sleep_time_response.data[0], SleepTimeModel) if sleep_time_response.data[0].optimal_bedtime: self.assertIsInstance( - sleep_time_response.data[0].optimal_bedtime, - SleepTimeWindow + sleep_time_response.data[0].optimal_bedtime, SleepTimeWindow ) if sleep_time_response.data[0].recommendation: self.assertIsInstance( - sleep_time_response.data[0].recommendation, - SleepTimeRecommendation + sleep_time_response.data[0].recommendation, SleepTimeRecommendation ) if sleep_time_response.data[0].status: - self.assertIsInstance( - sleep_time_response.data[0].status, - SleepTimeStatus - ) + self.assertIsInstance(sleep_time_response.data[0].status, SleepTimeStatus) self.assertEqual(sleep_time_response.next_token, "next_sleep_time_token") self.assertEqual(sleep_time_response.data[0].day, date(2024, 3, 10)) @@ -1646,7 +1614,7 @@ def test_get_sleep_time_documents_with_string_dates(self, mock_get): "id": "st_str_date", "day": "2024-03-10", "optimal_bedtime": {"start_offset": -1800, "end_offset": 3600}, - "timestamp": "2024-03-10T04:00:00+00:00" + "timestamp": "2024-03-10T04:00:00+00:00", } ] mock_response_json = {"data": mock_data, "next_token": None} @@ -1685,11 +1653,11 @@ def test_get_sleep_time_document(self, mock_get): "optimal_bedtime": { "start_offset": -1800, "end_offset": 3600, - "day_light_saving_time": 0 + "day_light_saving_time": 0, }, "recommendation": {"recommendation": "go_to_bed_earlier"}, "status": {"status": "slightly_late"}, - "timestamp": "2024-03-10T04:00:00+00:00" + "timestamp": "2024-03-10T04:00:00+00:00", } mock_response = MagicMock() mock_response.raise_for_status.return_value = None @@ -1704,21 +1672,16 @@ def test_get_sleep_time_document(self, mock_get): self.assertIsInstance(sleep_time_document, SleepTimeModel) self.assertEqual(sleep_time_document.id, document_id) if sleep_time_document.optimal_bedtime: - self.assertEqual( - sleep_time_document.optimal_bedtime.start_offset, -1800 - ) + self.assertEqual(sleep_time_document.optimal_bedtime.start_offset, -1800) if sleep_time_document.recommendation: self.assertEqual( - sleep_time_document.recommendation.recommendation, - "go_to_bed_earlier" + sleep_time_document.recommendation.recommendation, "go_to_bed_earlier" ) if sleep_time_document.status: - self.assertEqual( - sleep_time_document.status.status, "slightly_late" - ) + self.assertEqual(sleep_time_document.status.status, "slightly_late") self.assertEqual( sleep_time_document.timestamp, - datetime.fromisoformat("2024-03-10T04:00:00+00:00") + datetime.fromisoformat("2024-03-10T04:00:00+00:00"), ) mock_get.assert_called_once_with( @@ -1733,11 +1696,14 @@ def test_get_sleep_time_document_error(self, mock_get): # As per the implementation note, this endpoint might not exist. # If it doesn't, the API would return a 404, which _make_request would # raise as an HTTPError (a subclass of RequestException). - mock_get.side_effect = requests.exceptions.ConnectionError("API error or Not Found") + mock_get.side_effect = requests.exceptions.ConnectionError( + "API error or Not Found" + ) document_id = "test_st_single_error" with self.assertRaises(OuraConnectionError): self.client.sleep_time.get_sleep_time_document(document_id=document_id) + class TestRestModePeriod(unittest.TestCase): def setUp(self): @@ -1775,21 +1741,15 @@ def test_get_rest_mode_period_documents(self, mock_get): rest_mode_response = ( self.client.rest_mode_period.get_rest_mode_period_documents( - start_date=start_date, - end_date=end_date, - next_token="test_rmp_token" + start_date=start_date, end_date=end_date, next_token="test_rmp_token" ) ) self.assertIsInstance(rest_mode_response, RestModePeriodResponse) self.assertEqual(len(rest_mode_response.data), 2) - self.assertIsInstance( - rest_mode_response.data[0], RestModePeriodModel - ) + self.assertIsInstance(rest_mode_response.data[0], RestModePeriodModel) self.assertEqual(rest_mode_response.next_token, "next_rmp_token") - self.assertEqual( - rest_mode_response.data[0].rest_mode_state, "on_demand_rest" - ) + self.assertEqual(rest_mode_response.data[0].rest_mode_state, "on_demand_rest") mock_get.assert_called_once_with( f"{self.client.BASE_URL}/usercollection/rest_mode_period", @@ -1864,13 +1824,10 @@ def test_get_rest_mode_period_document(self, mock_get): self.assertIsInstance(rmp_document, RestModePeriodModel) self.assertEqual(rmp_document.id, document_id) - self.assertEqual( - rmp_document.rest_mode_state, "recovering_from_illness" - ) + self.assertEqual(rmp_document.rest_mode_state, "recovering_from_illness") self.assertEqual(rmp_document.baseline_hrv, 48) self.assertEqual( - rmp_document.start_time, - datetime.fromisoformat("2024-03-10T10:00:00+00:00") + rmp_document.start_time, datetime.fromisoformat("2024-03-10T10:00:00+00:00") ) mock_get.assert_called_once_with( @@ -1889,6 +1846,7 @@ def test_get_rest_mode_period_document_error(self, mock_get): document_id=document_id ) + class TestDailyStress(unittest.TestCase): def setUp(self): @@ -1920,9 +1878,7 @@ def test_get_daily_stress_documents_start_date(self, mock_get): mock_response.json.return_value = mock_response_data mock_get.return_value = mock_response - self.client.daily_stress.get_daily_stress_documents( - start_date="2024-01-01" - ) + self.client.daily_stress.get_daily_stress_documents(start_date="2024-01-01") mock_get.assert_called_once_with( f"{self.base_url}/usercollection/daily_stress", headers=self.client.headers, @@ -1972,9 +1928,7 @@ def test_get_daily_stress_documents_next_token(self, mock_get): mock_response.json.return_value = mock_response_data mock_get.return_value = mock_response - self.client.daily_stress.get_daily_stress_documents( - next_token="some_token" - ) + self.client.daily_stress.get_daily_stress_documents(next_token="some_token") mock_get.assert_called_once_with( f"{self.base_url}/usercollection/daily_stress", headers=self.client.headers, @@ -1994,10 +1948,7 @@ def test_get_daily_stress_documents_success(self, mock_get): "timestamp": "2024-03-15T08:00:00Z", } ] - mock_response_json = { - "data": mock_data, - "next_token": "stress_next_token" - } + mock_response_json = {"data": mock_data, "next_token": "stress_next_token"} mock_response = MagicMock() mock_response.raise_for_status.return_value = None mock_response.json.return_value = mock_response_json @@ -2030,7 +1981,7 @@ def test_get_daily_stress_documents_api_error_400(self, mock_get): mock_response.reason = "Client Error" mock_response.json.return_value = {"error": "400 Client Error"} mock_get.return_value = mock_response - + with self.assertRaises(OuraClientError): self.client.daily_stress.get_daily_stress_documents() @@ -2043,7 +1994,7 @@ def test_get_daily_stress_documents_api_error_429(self, mock_get): mock_response.reason = "Too Many Requests" mock_response.json.return_value = {"error": "429 Client Error"} mock_get.return_value = mock_response - + with self.assertRaises(OuraRateLimitError): self.client.daily_stress.get_daily_stress_documents() @@ -2092,6 +2043,7 @@ def test_get_daily_stress_document_not_found_404(self, mock_get): with self.assertRaises(OuraNotFoundError): self.client.daily_stress.get_daily_stress_document(document_id) + class TestDailyResilience(unittest.TestCase): def setUp(self): @@ -2203,10 +2155,7 @@ def test_get_daily_resilience_documents_success(self, mock_get): "timestamp": "2024-03-18T08:00:00Z", } ] - mock_response_json = { - "data": mock_data, - "next_token": "res_next_token" - } + mock_response_json = {"data": mock_data, "next_token": "res_next_token"} mock_response = MagicMock() mock_response.raise_for_status.return_value = None mock_response.json.return_value = mock_response_json @@ -2292,9 +2241,8 @@ def test_get_daily_resilience_document_not_found_404(self, mock_get): mock_get.return_value = mock_response with self.assertRaises(OuraNotFoundError): - self.client.daily_resilience.get_daily_resilience_document( - document_id - ) + self.client.daily_resilience.get_daily_resilience_document(document_id) + class TestDailyCardiovascularAge(unittest.TestCase): def setUp(self): @@ -2405,10 +2353,7 @@ def test_get_daily_cardiovascular_age_documents_success(self, mock_get): "timestamp": "2024-03-20T08:00:00Z", } ] - mock_response_json = { - "data": mock_data, - "next_token": "cva_next_token" - } + mock_response_json = {"data": mock_data, "next_token": "cva_next_token"} mock_response = MagicMock() mock_response.raise_for_status.return_value = None mock_response.json.return_value = mock_response_json @@ -2489,6 +2434,7 @@ def test_get_daily_cardiovascular_age_document_not_found_404(self, mock_get): document_id ) + class TestVo2Max(unittest.TestCase): def setUp(self): @@ -2589,18 +2535,13 @@ def test_get_vo2_max_documents_success(self, mock_get): "vo2_max": 35.5, } ] - mock_response_json = { - "data": mock_data, - "next_token": "vo2_next_token" - } + mock_response_json = {"data": mock_data, "next_token": "vo2_next_token"} mock_response = MagicMock() mock_response.raise_for_status.return_value = None mock_response.json.return_value = mock_response_json mock_get.return_value = mock_response - response = self.client.vo2_max.get_vo2_max_documents( - start_date="2024-04-10" - ) + response = self.client.vo2_max.get_vo2_max_documents(start_date="2024-04-10") self.assertIsInstance(response, Vo2MaxResponse) self.assertEqual(len(response.data), 1) model_item = response.data[0] @@ -2646,8 +2587,7 @@ def test_get_vo2_max_document_success(self, mock_get): self.assertEqual(response.id, document_id) self.assertEqual(response.day, date(2024, 4, 11)) self.assertEqual( - response.timestamp, - datetime.fromisoformat("2024-04-11T11:00:00+00:00") + response.timestamp, datetime.fromisoformat("2024-04-11T11:00:00+00:00") ) self.assertEqual(response.vo2_max, 36.2) diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py index 947a6d5..dfea5a2 100644 --- a/tests/test_error_handling.py +++ b/tests/test_error_handling.py @@ -14,7 +14,7 @@ OuraClientError, OuraConnectionError, OuraTimeoutError, - create_api_error + create_api_error, ) from oura_api_client.utils import RetryConfig, exponential_backoff, should_retry @@ -33,10 +33,7 @@ def test_oura_api_error(self): def test_oura_rate_limit_error(self): """Test OuraRateLimitError with retry_after.""" error = OuraRateLimitError( - "Rate limit exceeded", - status_code=429, - endpoint="/test", - retry_after=60 + "Rate limit exceeded", status_code=429, endpoint="/test", retry_after=60 ) self.assertEqual(error.retry_after, 60) self.assertEqual(error.status_code, 429) @@ -47,7 +44,7 @@ def test_create_api_error_with_json_response(self): mock_response.status_code = 401 mock_response.reason = "Unauthorized" mock_response.json.return_value = {"error": "Invalid token"} - + error = create_api_error(mock_response, "/test") self.assertIsInstance(error, OuraAuthenticationError) self.assertEqual(error.message, "Invalid token") @@ -60,7 +57,7 @@ def test_create_api_error_without_json(self): mock_response.status_code = 500 mock_response.reason = "Internal Server Error" mock_response.json.side_effect = ValueError("No JSON") - + error = create_api_error(mock_response, "/test") self.assertIsInstance(error, OuraServerError) self.assertEqual(error.message, "HTTP 500: Internal Server Error") @@ -72,7 +69,7 @@ def test_create_api_error_with_retry_after(self): mock_response.reason = "Too Many Requests" mock_response.headers = {"Retry-After": "120"} mock_response.json.return_value = {"error": "Rate limit exceeded"} - + error = create_api_error(mock_response, "/test") self.assertIsInstance(error, OuraRateLimitError) self.assertEqual(error.retry_after, 120) @@ -90,14 +87,14 @@ def test_create_api_error_status_mapping(self): (502, OuraServerError), (503, OuraServerError), ] - + for status_code, expected_type in test_cases: mock_response = MagicMock() mock_response.status_code = status_code mock_response.reason = f"Status {status_code}" mock_response.json.side_effect = ValueError() mock_response.headers = {} - + error = create_api_error(mock_response) self.assertIsInstance(error, expected_type) @@ -112,17 +109,16 @@ def test_exponential_backoff(self): self.assertEqual(exponential_backoff(1, base_delay=1.0, jitter=False), 2.0) self.assertEqual(exponential_backoff(2, base_delay=1.0, jitter=False), 4.0) self.assertEqual(exponential_backoff(3, base_delay=1.0, jitter=False), 8.0) - + # Test max delay self.assertEqual( - exponential_backoff(10, base_delay=1.0, max_delay=5.0, jitter=False), - 5.0 + exponential_backoff(10, base_delay=1.0, max_delay=5.0, jitter=False), 5.0 ) - + # Test with jitter (should be within ±25% of base value) for attempt in range(5): delay = exponential_backoff(attempt, base_delay=1.0, jitter=True) - expected = 1.0 * (2 ** attempt) + expected = 1.0 * (2**attempt) self.assertGreaterEqual(delay, expected * 0.75) self.assertLessEqual(delay, expected * 1.25) @@ -132,7 +128,7 @@ def test_should_retry(self): error = OuraServerError("Server error") self.assertFalse(should_retry(error, attempt=3, max_retries=3)) self.assertTrue(should_retry(error, attempt=2, max_retries=3)) - + # Test retryable errors retryable_errors = [ OuraServerError("Server error"), @@ -140,10 +136,10 @@ def test_should_retry(self): OuraTimeoutError("Request timed out"), OuraRateLimitError("Rate limited", retry_after=60), ] - + for error in retryable_errors: self.assertTrue(should_retry(error, attempt=0, max_retries=3)) - + # Test non-retryable errors non_retryable_errors = [ OuraAuthenticationError("Invalid token"), @@ -151,10 +147,10 @@ def test_should_retry(self): OuraNotFoundError("Not found"), OuraClientError("Bad request"), ] - + for error in non_retryable_errors: self.assertFalse(should_retry(error, attempt=0, max_retries=3)) - + # Test rate limit with large retry_after error = OuraRateLimitError("Rate limited", retry_after=600) self.assertFalse(should_retry(error, attempt=0, max_retries=3)) @@ -174,7 +170,7 @@ def test_make_request_success(self, mock_get): mock_response.ok = True mock_response.json.return_value = {"data": "test"} mock_get.return_value = mock_response - + result = self.client._make_request("/test") self.assertEqual(result, {"data": "test"}) mock_get.assert_called_once() @@ -188,10 +184,10 @@ def test_make_request_http_error(self, mock_get): mock_response.reason = "Not Found" mock_response.json.return_value = {"error": "Resource not found"} mock_get.return_value = mock_response - + with self.assertRaises(OuraNotFoundError) as cm: self.client._make_request("/test") - + self.assertEqual(cm.exception.message, "Resource not found") self.assertEqual(cm.exception.status_code, 404) self.assertEqual(cm.exception.endpoint, "/test") @@ -200,10 +196,10 @@ def test_make_request_http_error(self, mock_get): def test_make_request_timeout(self, mock_get): """Test request timeout.""" mock_get.side_effect = requests.exceptions.Timeout("Timeout") - + with self.assertRaises(OuraTimeoutError) as cm: self.client._make_request("/test") - + self.assertIn("timed out", cm.exception.message) self.assertEqual(cm.exception.endpoint, "/test") @@ -211,10 +207,10 @@ def test_make_request_timeout(self, mock_get): def test_make_request_connection_error(self, mock_get): """Test connection error.""" mock_get.side_effect = requests.exceptions.ConnectionError("Connection failed") - + with self.assertRaises(OuraConnectionError) as cm: self.client._make_request("/test") - + self.assertIn("Failed to connect", cm.exception.message) self.assertEqual(cm.exception.endpoint, "/test") @@ -227,20 +223,18 @@ def test_make_request_with_retry_success(self, mock_get): mock_response_error.status_code = 500 mock_response_error.reason = "Internal Server Error" mock_response_error.json.return_value = {} - + mock_response_success = MagicMock() mock_response_success.ok = True mock_response_success.json.return_value = {"data": "success"} - + mock_get.side_effect = [mock_response_error, mock_response_success] - + # Enable retry self.client.retry_config = RetryConfig( - max_retries=3, - base_delay=0.01, # Short delay for testing - jitter=False + max_retries=3, base_delay=0.01, jitter=False # Short delay for testing ) - + result = self.client._make_request("/test") self.assertEqual(result, {"data": "success"}) self.assertEqual(mock_get.call_count, 2) @@ -255,17 +249,15 @@ def test_make_request_with_retry_exhausted(self, mock_sleep, mock_get): mock_response.reason = "Internal Server Error" mock_response.json.return_value = {"error": "Server error"} mock_get.return_value = mock_response - + # Enable retry with limited attempts self.client.retry_config = RetryConfig( - max_retries=2, - base_delay=0.01, - jitter=False + max_retries=2, base_delay=0.01, jitter=False ) - + with self.assertRaises(OuraServerError) as cm: self.client._make_request("/test") - + self.assertEqual(cm.exception.message, "Server error") self.assertEqual(mock_get.call_count, 3) # Initial + 2 retries @@ -279,18 +271,18 @@ def test_make_request_with_rate_limit_retry(self, mock_sleep, mock_get): mock_response_rate_limit.reason = "Too Many Requests" mock_response_rate_limit.headers = {"Retry-After": "2"} mock_response_rate_limit.json.return_value = {"error": "Rate limited"} - + mock_response_success = MagicMock() mock_response_success.ok = True mock_response_success.json.return_value = {"data": "success"} - + mock_get.side_effect = [mock_response_rate_limit, mock_response_success] - + self.client.retry_config = RetryConfig(max_retries=3) - + result = self.client._make_request("/test") self.assertEqual(result, {"data": "success"}) - + # Should have slept for the Retry-After duration mock_sleep.assert_called_once_with(2) @@ -303,12 +295,12 @@ def test_make_request_no_retry_on_client_error(self, mock_get): mock_response.reason = "Bad Request" mock_response.json.return_value = {"error": "Invalid parameters"} mock_get.return_value = mock_response - + self.client.retry_config = RetryConfig(max_retries=3) - + with self.assertRaises(OuraClientError) as cm: self.client._make_request("/test") - + self.assertEqual(cm.exception.message, "Invalid parameters") self.assertEqual(mock_get.call_count, 1) # No retries @@ -319,7 +311,7 @@ def test_make_request_endpoint_normalization(self): mock_response.ok = True mock_response.json.return_value = {} mock_get.return_value = mock_response - + # Test various endpoint formats test_cases = [ ("/test", "https://api.ouraring.com/v2/test"), @@ -327,7 +319,7 @@ def test_make_request_endpoint_normalization(self): ("/v2/test", "https://api.ouraring.com/v2/test"), ("v2/test", "https://api.ouraring.com/v2/test"), ] - + for endpoint, expected_url in test_cases: mock_get.reset_mock() self.client._make_request(endpoint) @@ -337,7 +329,7 @@ def test_make_request_endpoint_normalization(self): def test_retry_config_disabled(self): """Test that retry can be disabled.""" self.client.retry_config = RetryConfig(enabled=False) - + with patch("requests.get") as mock_get: mock_response = MagicMock() mock_response.ok = False @@ -345,10 +337,10 @@ def test_retry_config_disabled(self): mock_response.reason = "Server Error" mock_response.json.return_value = {} mock_get.return_value = mock_response - + with self.assertRaises(OuraServerError): self.client._make_request("/test") - + # Should not retry when disabled self.assertEqual(mock_get.call_count, 1) diff --git a/wednesday_font_vector_with_holes.txt b/wednesday_font_vector_with_holes.txt deleted file mode 100644 index a595077..0000000 --- a/wednesday_font_vector_with_holes.txt +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file