Skip to content

feat(typed): add optional Pydantic namespace wrapper (#354)#359

Merged
cyberjunky merged 2 commits into
cyberjunky:masterfrom
sorlen008:feat/typed-namespace-models
May 2, 2026
Merged

feat(typed): add optional Pydantic namespace wrapper (#354)#359
cyberjunky merged 2 commits into
cyberjunky:masterfrom
sorlen008:feat/typed-namespace-models

Conversation

@sorlen008

@sorlen008 sorlen008 commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

Closes #354 (once merged).

Implements Option D from the issue discussion: a lazy g.typed namespace that wraps a small set of high-value read endpoints with Pydantic response models, while leaving the 132 existing methods entirely untouched.

g = Garmin(email, password)
raw = g.get_stats(date)           # dict[str, Any] — unchanged
stats = g.typed.get_stats(date)   # DailyStats (Pydantic model)
print(stats.total_steps, stats.resting_heart_rate)

Opening as draft so you can veto any of the design choices below before I polish / land.

Decisions I made (please flag any you'd change)

Decision What I did Why
Validation failure behavior Raise GarminConnectResponseValidationError with the unvalidated response preserved as .raw Loud by default; callers can try/except and still access the data. The alternative (silent fallback to dict) was the one open UX question you hadn't weighed in on — happy to flip to silent-fallback if you'd prefer.
Scope of first PR 7 methods / 6 models (stats, sleep, hrv, body battery, readiness, activities) vs the 10–15 you suggested Keeps the initial diff reviewable. Easy to add more in follow-up PRs once the pattern is blessed.
extra='allow' + all fields Optional Yes Tolerates schema drift (new fields on new firmware) and partial responses (privacy-protected accounts, missing device data) without 500'ing on real users.
[typed] extra, not core dep Kept pydantic optional — g.typed lazy-imports the module and raises a clear install hint if pydantic is missing Matches your preference expressed in the issue. Zero footprint change for existing users.
Experimental marker Module + class docstrings mark it experimental Want room to reshape the surface if real usage exposes problems before we commit to stability.
populate_by_name=True Yes Lets users construct models from snake_case or camelCase in tests / custom flows. Doesn't affect runtime API parsing.
Training readiness typed as list[TrainingReadiness] Yes, even though Garmin.get_training_readiness is annotated dict[str, Any] The live endpoint returns a list of snapshots — the existing annotation is stale. Not fixing that in this PR, just reflecting real shape in the typed layer.

Scope

Method Typed return
get_stats, get_user_summary DailyStats
get_sleep_data SleepData (nested DailySleepDTO)
get_hrv_data HrvData | None
get_body_battery list[BodyBatteryEntry]
get_training_readiness list[TrainingReadiness]
get_activities_by_date list[Activity]

Tests

16 new mocked tests in tests/test_typed.py. All 107 mock-based tests still pass.

Covered scenarios:

  • accessor returns a cached TypedGarmin
  • each wrapper delegates to the raw method and validates the result
  • extra fields land in model_extra (no validation failure)
  • missing fields default to None
  • structural validation failures raise GarminConnectResponseValidationError with .raw
  • list endpoints with non-list responses return []
  • get_hrv_data None passes through unchanged

Out of scope (follow-ups)

  • README — intentionally not touching it in this PR; happy to follow up with a "Typed responses (experimental)" section once the design is blessed.
  • More models — stress, respiration, SpO2, training status, max metrics, devices, weigh-ins all candidates for a second pass.
  • Fix the stale dict[str, Any] annotation on get_training_readiness — separate PR, doesn't need to block this.
  • Testing the pydantic-missing ImportError path — marked pragma: no cover; happy to add an integration test if you want.

Verification

  • pdm run ruff check garminconnect/typed.py tests/test_typed.py — clean
  • pdm run mypy garminconnect/typed.py — clean
  • black -l 88 --target-version py313 — clean
  • isort --profile black — clean
  • pytest tests/test_typed.py — 16 passed
  • Full mock-based suite (test_garmin_unit, test_retry_decorator, test_workout_constants, test_typed) — 107 passed, 0 regressions

Let me know if the direction is off and I'll rework.

Summary by CodeRabbit

  • New Features

    • Added an optional typed response validation layer, accessible via a new .typed accessor (requires installation of the optional "typed" extra). Provides structured, validated results for stats, sleep, HRV, body battery, training readiness, and activity endpoints; validation errors preserve original responses for debugging.
  • Tests

    • New test suite covering the typed accessor, model validation, defensive handling of unexpected responses, and error preservation.
  • Chore

    • Optional extra renamed to "typed" for the typed validation feature.

Adds ``g.typed`` — a lazy namespace accessor that wraps a curated set of
high-value read endpoints with Pydantic response models, while leaving the
132 existing methods entirely unchanged.

Design follows Option D from the issue discussion:

    g = Garmin(email, password)
    raw = g.get_stats(date)           # dict[str, Any] — unchanged
    stats = g.typed.get_stats(date)   # DailyStats (Pydantic model)

Scope of the first cut (7 methods, 6 response models):

    - get_stats / get_user_summary -> DailyStats
    - get_sleep_data               -> SleepData (with nested DailySleepDTO)
    - get_hrv_data                 -> HrvData | None
    - get_body_battery             -> list[BodyBatteryEntry]
    - get_training_readiness       -> list[TrainingReadiness]
    - get_activities_by_date       -> list[Activity]

Design notes:

- Lazy import. ``g.typed`` is a ``@functools.cached_property`` that imports
  ``garminconnect.typed`` on first access, so pydantic stays optional. If
  pydantic is missing, the import raises a clear install hint.
- Kept in a new ``[typed]`` extra (pydantic>=2.0.0) rather than promoted to
  a core dep, so the default ``pip install garminconnect`` footprint doesn't
  change.
- ``extra='allow'`` + all fields ``Optional`` — models tolerate Garmin schema
  drift (new firmware / subscription tiers add fields) and partial responses
  (privacy-protected accounts, missing device data). Unknown fields land in
  ``model_extra`` for callers who want them.
- Validation failures raise ``GarminConnectResponseValidationError`` with the
  unvalidated response preserved on ``.raw`` so users can fall back without
  losing data.
- ``get_training_readiness`` wrapper reflects the real list-shaped response
  (the existing ``dict[str, Any]`` annotation on the raw method is wrong but
  out of scope here).

Marked **experimental** in module and class docstrings — model shapes may
change between minor releases while the pattern stabilises.

Tests (16 mocked, all green):

- accessor returns cached ``TypedGarmin``
- each wrapper calls through to the raw method and validates the response
- extra fields tolerated (preserved in ``model_extra``)
- missing fields default to ``None``
- validation errors raise ``GarminConnectResponseValidationError`` with
  ``.raw`` populated
- list endpoints with non-list responses return ``[]``
- ``get_hrv_data`` ``None`` passes through unchanged

Lint clean under ruff + mypy + black + isort.
@coderabbitai

coderabbitai Bot commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

Pull request was closed or merged during review

Walkthrough

Adds an optional Pydantic-backed typed API layer: new garminconnect/typed.py with response models and a TypedGarmin wrapper, a cached Garmin.typed accessor that lazily constructs the wrapper, renames the optional extra to typed in pyproject, and includes tests exercising validation, normalization, and error preservation.

Changes

Typed API Layer

Layer / File(s) Summary
Optional Dependency
pyproject.toml
Replaces the workout extra with a typed extra, both listing pydantic>=2.0.0.
Module-level Gate & Docs
garminconnect/typed.py
Module docstring added and conditional import gate that raises ImportError with install instructions when pydantic is absent.
Shared Model Config
garminconnect/typed.py
Defines _COMMON_CONFIG and _BaseResponse applying extra="allow" and populate_by_name=True to response models.
Response Models
garminconnect/typed.py
Adds multiple Pydantic models (DailyStats, sleep models, HrvData/HrvSummary/HrvBaseline, BodyBatteryEntry, TrainingReadiness, ActivityType, Activity, etc.) with JSON alias mappings.
Validation Error Type
garminconnect/typed.py
Introduces GarminConnectResponseValidationError that preserves the raw payload (.raw) and the underlying Pydantic error (.pydantic_error).
Typed Wrapper Implementation
garminconnect/typed.py
Implements TypedGarmin that wraps a Garmin instance, validates raw responses via model_validate, normalizes return shapes (HRV None, body battery/training readiness/activities → [] when non-list), and raises GarminConnectResponseValidationError on validation failure.
Garmin Integration
garminconnect/__init__.py
Adds TYPE_CHECKING-guarded import of TypedGarmin for typing and a @functools.cached_property def typed(self) -> TypedGarmin that lazily imports .typed and returns TypedGarmin(self).
Exports
garminconnect/typed.py
Populates __all__ with the models, error class, and TypedGarmin.
Tests
tests/test_typed.py
New pytest suite: fixture garmin, sample payloads, tests that g.typed is cached and wraps the same client, happy-path model returns, normalization behaviors, forwarding of optional args, tolerance for extra fields, defaults for missing fields, and validation-error preservation of raw payload.

Sequence Diagram

sequenceDiagram
    actor User
    participant GarminClient as Garmin Client
    participant TypedWrapper as TypedGarmin Wrapper
    participant Pydantic as Pydantic Validator
    participant Model as Response Model

    User->>GarminClient: access g.typed (first time)
    GarminClient->>TypedWrapper: lazily import & construct TypedGarmin(self)
    TypedWrapper-->>User: cached TypedGarmin instance

    User->>TypedWrapper: .get_stats(date)
    TypedWrapper->>GarminClient: .get_stats(date)
    GarminClient-->>TypedWrapper: raw dict response
    TypedWrapper->>Pydantic: validate(DailyStats, raw)
    alt Validation Success
        Pydantic->>Model: instantiate DailyStats
        Model-->>TypedWrapper: typed model
        TypedWrapper-->>User: DailyStats
    else Validation Failure
        Pydantic-->>TypedWrapper: ValidationError
        TypedWrapper->>TypedWrapper: raise GarminConnectResponseValidationError(raw, pydantic_error)
        TypedWrapper-->>User: exception with .raw preserved
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding an optional Pydantic namespace wrapper for typed responses.
Linked Issues check ✅ Passed The PR successfully implements the proposal from #354 by providing optional Pydantic response models via a lazy g.typed wrapper (Option D), covering 7 core methods with proper validation and optional dependency handling.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing the optional typed namespace feature: new typed.py module, models, wrapper class, dependency update, and comprehensive tests.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 6/8 reviews remaining, refill in 8 minutes and 2 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@cyberjunky
cyberjunky marked this pull request as ready for review May 2, 2026 08:52
Copilot AI review requested due to automatic review settings May 2, 2026 08:52
@cyberjunky

Copy link
Copy Markdown
Owner

Thanks a lot!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pyproject.toml`:
- Around line 80-82: The tests import garminconnect.typed (see
tests/test_typed.py) which requires pydantic but pydantic is only listed under
the "typed" extra in pyproject.toml; update pyproject.toml to add
"pydantic>=2.0.0" to the testing/dev/test dependencies group so running pytest
with only the testing extras installed will not fail during collection—ensure
the exact pydantic version constraint used in the "typed" extra is duplicated in
the testing dependencies.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 628650ac-72c4-4d24-8752-89bfa3187c63

📥 Commits

Reviewing files that changed from the base of the PR and between a95a5c3 and ba2ad04.

📒 Files selected for processing (4)
  • garminconnect/__init__.py
  • garminconnect/typed.py
  • pyproject.toml
  • tests/test_typed.py

Comment thread pyproject.toml
Comment on lines +80 to +82
typed = [
"pydantic>=2.0.0",
]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

python - <<'PY'
import tomllib, pathlib
cfg = tomllib.loads(pathlib.Path("pyproject.toml").read_text())
proj = cfg["project"]["optional-dependencies"]
dev = cfg.get("tool", {}).get("pdm", {}).get("dev-dependencies", {})
print("project.optional-dependencies.testing =", proj.get("testing", []))
print("project.optional-dependencies.typed   =", proj.get("typed", []))
print("tool.pdm.dev-dependencies.testing     =", dev.get("testing", []))
print("testing has pydantic:", any("pydantic" in x for x in proj.get("testing", [])))
print("dev-testing has pydantic:", any("pydantic" in x for x in dev.get("testing", [])))
PY

rg -n "from garminconnect\.typed import|import garminconnect\.typed" tests
rg -n -C2 --iglob "*.yml" "pdm install|pip install" .github/workflows || true

Repository: cyberjunky/python-garminconnect

Length of output: 1186


🏁 Script executed:

# Check what garminconnect/typed.py imports and requires
head -30 garminconnect/typed.py

# Check test_typed.py for any conditional imports or skip decorators
head -50 tests/test_typed.py

# Check if there are any pytest markers or conditions on test_typed.py
grep -n "pytest.mark\|skipif\|skip\|importorskip" tests/test_typed.py || echo "No skip markers found"

Repository: cyberjunky/python-garminconnect

Length of output: 2803


🏁 Script executed:

# Check if garminconnect/typed.py imports pydantic
grep -n "^from pydantic\|^import pydantic" garminconnect/typed.py

# Check if there are any class definitions using Pydantic BaseModel
grep -n "class.*BaseModel\|class.*ValidationError" garminconnect/typed.py | head -10

Repository: cyberjunky/python-garminconnect

Length of output: 406


🏁 Script executed:

# Check the actual imports in garminconnect/typed.py
head -100 garminconnect/typed.py | grep -n "from pydantic\|^import pydantic"

Repository: cyberjunky/python-garminconnect

Length of output: 204


🏁 Script executed:

# Check context around pydantic imports to confirm they're unconditional
sed -n '30,45p' garminconnect/typed.py

Repository: cyberjunky/python-garminconnect

Length of output: 577


Add pydantic to testing dependencies for improved developer experience.

tests/test_typed.py imports garminconnect.typed at module level (line 30), which requires pydantic. Currently, pydantic is only in the typed extra—not in testing deps. This means test collection fails if an environment installs only testing dependencies without the typed extra. While the error message is clear, adding pydantic to testing deps allows test suites to run with just the testing group installed, improving local developer experience and simplifying dependency management.

Proposed fix
 [project.optional-dependencies]
 testing = [
     "coverage",
     "pytest",
     "pytest-vcr>=1.0.2",
     "vcrpy>=7.0.0",
+    "pydantic>=2.0.0",
 ]
@@
 [tool.pdm.dev-dependencies]
 testing = [
     "coverage",
     "pytest",
     "pytest-vcr>=1.0.2",
     "vcrpy>=7.0.0",
+    "pydantic>=2.0.0",
 ]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyproject.toml` around lines 80 - 82, The tests import garminconnect.typed
(see tests/test_typed.py) which requires pydantic but pydantic is only listed
under the "typed" extra in pyproject.toml; update pyproject.toml to add
"pydantic>=2.0.0" to the testing/dev/test dependencies group so running pytest
with only the testing extras installed will not fail during collection—ensure
the exact pydantic version constraint used in the "typed" extra is duplicated in
the testing dependencies.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an optional g.typed namespace that exposes a small set of high-value Garmin read endpoints as Pydantic-validated response models, while keeping the existing dict/list-returning API unchanged.

Changes:

  • Introduces garminconnect.typed with Pydantic models + a TypedGarmin wrapper and a dedicated validation error that preserves the raw payload.
  • Adds Garmin.typed as a lazy, cached accessor to the typed wrapper (keeping Pydantic as an optional dependency).
  • Adds a mocked test suite validating delegation, schema tolerance, and validation-failure behavior for the typed endpoints.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
garminconnect/typed.py New Pydantic response models + TypedGarmin wrapper and validation error type.
garminconnect/__init__.py Adds Garmin.typed cached_property with lazy import to keep Pydantic optional.
pyproject.toml Adds a new typed extra depending on pydantic>=2.0.0.
tests/test_typed.py New unit tests for the typed wrapper behavior and validation policy.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread garminconnect/typed.py Outdated
Comment thread pyproject.toml
Comment on lines +80 to +82
typed = [
"pydantic>=2.0.0",
]

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@cyberjunky
cyberjunky merged commit be815ea into cyberjunky:master May 2, 2026
1 check was pending
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Proposal]: Optional Pydantic response models for typed API access

3 participants