Skip to content

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

Draft
sorlen008 wants to merge 1 commit intocyberjunky:masterfrom
sorlen008:feat/typed-namespace-models
Draft

feat(typed): add optional Pydantic namespace wrapper (#354)#359
sorlen008 wants to merge 1 commit intocyberjunky:masterfrom
sorlen008:feat/typed-namespace-models

Conversation

@sorlen008
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.

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
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 21, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fa163f9c-3a3a-4533-8bb5-f5fc430ca46c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ 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

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

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

1 participant