Skip to content

Add training status support#130

Merged
matin merged 8 commits intomainfrom
add-training-status
Jan 8, 2026
Merged

Add training status support#130
matin merged 8 commits intomainfrom
add-training-status

Conversation

@matin
Copy link
Copy Markdown
Owner

@matin matin commented Jun 11, 2025

Summary

  • Add DailyTrainingStatus, WeeklyTrainingStatus, MonthlyTrainingStatus classes
  • Custom parsing logic for complex nested API responses
  • Comprehensive test coverage with VCR cassettes
  • Documentation in README with usage examples

API Endpoints Added

  • Daily: /mobile-gateway/usersummary/trainingstatus/latest/{date}
  • Weekly: /mobile-gateway/usersummary/trainingstatus/weekly/{start}/{end}
  • Monthly: /mobile-gateway/usersummary/trainingstatus/monthly/{start}/{end}

Implementation Details

  • Follows existing patterns in the stats module for consistency
  • Takes advantage of current base classes to keep code DRY
  • Custom parsing logic handles the complex nested response structure
  • All fields are properly typed with Pydantic dataclasses

Test Coverage

  • 5 comprehensive tests covering daily, weekly, monthly endpoints
  • Tests include pagination scenarios and edge cases
  • VCR cassettes recorded for reliable test execution

Closes #129

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Introduced three new training status classes—DailyTrainingStatus, WeeklyTrainingStatus, and MonthlyTrainingStatus—providing access to detailed training metrics including workload data, trends, and acute/chronic workload ratios.
  • Documentation

    • Added comprehensive usage examples and field documentation to the README for all new training status functionality.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Jun 11, 2025

Walkthrough

This PR adds support for daily, weekly, and monthly training status data retrieval from the Garmin API by introducing three new dataclasses, enhancing the Stats base class with customizable response parsing through a _parse_response hook, and providing comprehensive test coverage.

Changes

Cohort / File(s) Summary
Training Status Implementation
src/garth/stats/training_status/daily.py, src/garth/stats/training_status/weekly.py, src/garth/stats/training_status/monthly.py
Adds three new dataclasses inheriting from Stats, each with custom _parse_response methods to extract and flatten nested API response structures. Daily uses "days" period type with 28-day page size; weekly and monthly implement pagination-aware parsing with device data flattening.
Base Stats Enhancement
src/garth/stats/_base.py
Introduces _period_type class variable for flexible period specification and _parse_response abstract hook for subclass customization. The list method now delegates response parsing to _parse_response instead of inline dict filtering, enabling specialized handling per status type.
Module Exports
src/garth/__init__.py, src/garth/stats/__init__.py, src/garth/stats/training_status/__init__.py
Exposes DailyTrainingStatus, WeeklyTrainingStatus, and MonthlyTrainingStatus through package hierarchy, making them available as public API.
Testing
tests/stats/test_training_status.py
Comprehensive test suite covering list retrieval, date validation, pagination behavior, error handling for malformed responses, and edge cases using pytest-vcr.
Documentation
README.md
Adds "Training Status" section with usage examples and sample JSON representations for all three status types.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • cli #113: Modifies Stats base class return typing in src/garth/stats/_base.py, which will need coordination with the new _parse_response and _period_type additions in this PR.
🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.33% 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 'Add training status support' clearly and concisely summarizes the main change—adding support for three new training status classes (Daily, Weekly, Monthly) with their corresponding API endpoints.
Linked Issues check ✅ Passed The PR fully implements all requirements from issue #129: DailyTrainingStatus for latest endpoint, WeeklyTrainingStatus and MonthlyTrainingStatus for weekly/monthly ranges, with correct endpoint paths and comprehensive tests.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing training status support: new status classes, base class enhancements, module exports, comprehensive tests, and README documentation. No unrelated changes detected.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch add-training-status

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.

@codecov
Copy link
Copy Markdown

codecov bot commented Jun 11, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (8028441) to head (f951fe3).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##              main      #130    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files           53        58     +5     
  Lines         2309      2599   +290     
==========================================
+ Hits          2309      2599   +290     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🔭 Outside diff range comments (1)
tests/stats/test_training_status.py (1)

58-66: ⚠️ Potential issue

Assert that the list is empty and add missing newline.

The test should explicitly verify that no data is returned for the old date. Additionally, the file is missing a newline at the end.

 def test_monthly_training_status_no_data(authed_client: Client):
     end = date(2020, 1, 1)  # Date far in the past with no data
     monthly_training_status = MonthlyTrainingStatus.list(
         end, 1, client=authed_client
     )
     # Should return empty list if no data
-    assert isinstance(monthly_training_status, list)
+    assert monthly_training_status == []
+
🧹 Nitpick comments (8)
src/garth/sso.py (1)

80-86: Verify Python ≥ 3.9 requirement & drop the dict() wrapper

The | union operator only works on Python 3.9+. Double-check that pyproject.toml / setup.cfg already advertises a python_requires >= "3.9" (or bump it) so users on 3.8 don’t get a syntax error.

While you’re here, the extra dict() layer is unnecessary and slightly hurts readability. You can inline a literal:

-    SIGNIN_PARAMS = SSO_EMBED_PARAMS | dict(
-        gauthHost=SSO_EMBED,
-        service=SSO_EMBED,
-        source=SSO_EMBED,
-        redirectAfterAccountLoginUrl=SSO_EMBED,
-        redirectAfterAccountCreationUrl=SSO_EMBED,
-    )
+    SIGNIN_PARAMS = SSO_EMBED_PARAMS | {
+        "gauthHost": SSO_EMBED,
+        "service": SSO_EMBED,
+        "source": SSO_EMBED,
+        "redirectAfterAccountLoginUrl": SSO_EMBED,
+        "redirectAfterAccountCreationUrl": SSO_EMBED,
+    }
🧰 Tools
🪛 Pylint (3.3.7)

[refactor] 80-86: Consider using '{"gauthHost": SSO_EMBED, "service": SSO_EMBED, "source": SSO_EMBED, ... }' instead of a call to 'dict'.

(R1735)

src/garth/__init__.py (1)

31-34: (Nit) keep __all__ alphabetised

Pure style, but alphabetising __all__ simplifies diff noise later.

Not blocking.

Also applies to: 40-40

README.md (1)

621-734: Docs example is great – minor parameter inconsistency

Earlier examples use .list("date", pages) whereas the new training-status snippets use .list(period=1) without a start date. If the API truly differs, maybe add a short note (“DailyTrainingStatus.list() takes only period”) to avoid confusion for readers copy-pasting from other sections.

CLAUDE.md (1)

81-90: Redundant heading sentence

The heading “### Adding New Stats Endpoints” already states the topic; the next line repeats it. Consider deleting the sentence for brevity.

-When adding new stats endpoints (like training status):
+When adding new stats endpoints (like training status):

(or just remove the duplicated phrase entirely)

🧰 Tools
🪛 LanguageTool

[uncategorized] ~83-~83: Possible missing comma found.
Context: ...ng New Stats Endpoints When adding new stats endpoints (like training status): 1. C...

(AI_HYDRA_LEO_MISSING_COMMA)

tests/stats/test_training_status.py (2)

24-33: Consider asserting the expected number of weekly results.

While the test verifies non-empty results, it would be more robust to assert the expected number of entries (e.g., assert len(weekly_training_status) == 4 if the API returns exactly 4 weeks).


48-56: Enhance pagination test to verify multi-page retrieval.

The test requests 60 weeks (exceeding the page size of 52) but only checks for non-empty results. Consider adding assertions to verify that pagination actually occurred, such as checking for a minimum number of results or verifying data spans the expected time range.

 def test_weekly_training_status_pagination(authed_client: Client):
     end = date(2025, 6, 11)
     weeks = 60
     weekly_training_status = WeeklyTrainingStatus.list(
         end, weeks, client=authed_client
     )
     assert len(weekly_training_status) > 0
+    # Verify we got data spanning multiple pages
+    # (exact count may vary based on data availability)
+    assert len(weekly_training_status) > 52  # More than one page
src/garth/stats/training_status.py (2)

66-74: Document the single-device limitation more prominently.

The code assumes a single device in multiple places (lines 66 and 82). This limitation should be documented in the class or method docstring to make it clear to users. Consider adding a TODO for future multi-device support.

Would you like me to help implement multi-device support or create an issue to track this enhancement?

Also applies to: 82-93

🧰 Tools
🪛 GitHub Check: codecov/patch

[warning] 74-74: src/garth/stats/training_status.py#L74
Added line #L74 was not covered by tests


154-159: Add missing newline at end of file.

The file is missing a newline character at the end.

 class MonthlyTrainingStatus(TrainingStatus):
     _path: ClassVar[str] = f"{BASE_PATH}/monthly/{{start}}/{{end}}"
     _data_key: ClassVar[str] = "monthlyTrainingStatus"
     _page_size: ClassVar[int] = 12
+
📜 Review details

Configuration used: .coderabbit.yaml
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c1bdee8 and a9305ce.

⛔ Files ignored due to path filters (5)
  • tests/stats/cassettes/test_daily_training_status.yaml is excluded by !tests/**/cassettes/**
  • tests/stats/cassettes/test_monthly_training_status.yaml is excluded by !tests/**/cassettes/**
  • tests/stats/cassettes/test_monthly_training_status_no_data.yaml is excluded by !tests/**/cassettes/**
  • tests/stats/cassettes/test_weekly_training_status.yaml is excluded by !tests/**/cassettes/**
  • tests/stats/cassettes/test_weekly_training_status_pagination.yaml is excluded by !tests/**/cassettes/**
📒 Files selected for processing (7)
  • CLAUDE.md (1 hunks)
  • README.md (1 hunks)
  • src/garth/__init__.py (2 hunks)
  • src/garth/sso.py (1 hunks)
  • src/garth/stats/__init__.py (2 hunks)
  • src/garth/stats/training_status.py (1 hunks)
  • tests/stats/test_training_status.py (1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
`tests/**`: - test functions shouldn't have a return type hint - it's ok to use `assert` instead of `pytest.assume()`

tests/**: - test functions shouldn't have a return type hint

  • it's ok to use assert instead of pytest.assume()
  • tests/stats/test_training_status.py
🧬 Code Graph Analysis (3)
src/garth/stats/__init__.py (1)
src/garth/stats/training_status.py (3)
  • DailyTrainingStatus (141-144)
  • MonthlyTrainingStatus (155-158)
  • WeeklyTrainingStatus (148-151)
src/garth/__init__.py (1)
src/garth/stats/training_status.py (3)
  • DailyTrainingStatus (141-144)
  • MonthlyTrainingStatus (155-158)
  • WeeklyTrainingStatus (148-151)
tests/stats/test_training_status.py (3)
src/garth/stats/training_status.py (3)
  • DailyTrainingStatus (141-144)
  • MonthlyTrainingStatus (155-158)
  • WeeklyTrainingStatus (148-151)
src/garth/http.py (1)
  • Client (19-244)
tests/conftest.py (3)
  • vcr (79-82)
  • authed_client (65-75)
  • client (21-22)
🪛 Pylint (3.3.7)
src/garth/sso.py

[refactor] 80-86: Consider using '{"gauthHost": SSO_EMBED, "service": SSO_EMBED, "source": SSO_EMBED, ... }' instead of a call to 'dict'.

(R1735)

src/garth/stats/training_status.py

[refactor] 15-15: Too many instance attributes (24/7)

(R0902)


[refactor] 46-46: Too many return statements (9/6)

(R0911)


[refactor] 46-46: Too many branches (14/12)

(R0912)

🪛 GitHub Check: codecov/patch
src/garth/stats/training_status.py

[warning] 51-51: src/garth/stats/training_status.py#L51
Added line #L51 was not covered by tests


[warning] 55-55: src/garth/stats/training_status.py#L55
Added line #L55 was not covered by tests


[warning] 59-59: src/garth/stats/training_status.py#L59
Added line #L59 was not covered by tests


[warning] 65-65: src/garth/stats/training_status.py#L65
Added line #L65 was not covered by tests


[warning] 74-74: src/garth/stats/training_status.py#L74
Added line #L74 was not covered by tests


[warning] 79-79: src/garth/stats/training_status.py#L79
Added line #L79 was not covered by tests


[warning] 111-111: src/garth/stats/training_status.py#L111
Added line #L111 was not covered by tests


[warning] 126-126: src/garth/stats/training_status.py#L126
Added line #L126 was not covered by tests

🪛 LanguageTool
CLAUDE.md

[uncategorized] ~83-~83: Possible missing comma found.
Context: ...ng New Stats Endpoints When adding new stats endpoints (like training status): 1. C...

(AI_HYDRA_LEO_MISSING_COMMA)

🔇 Additional comments (5)
src/garth/__init__.py (1)

10-16: Public exports look correct

The new training-status classes are imported and will be exposed to users, matching the additions in stats/__init__.py. LGTM.

src/garth/stats/__init__.py (1)

8-14: Exports consistent across package

DailyTrainingStatus, WeeklyTrainingStatus, and MonthlyTrainingStatus are now publicly available here and at package root. Nice consistency.

Also applies to: 22-26

tests/stats/test_training_status.py (1)

13-22: LGTM! Well-structured test for daily training status.

The test correctly validates the basic functionality of retrieving a single day's training status data.

src/garth/stats/training_status.py (2)

14-40: Appropriate design for API data model.

The large number of attributes is justified as this is a data transfer object matching the API response structure. The use of optional fields provides flexibility for partial responses.

🧰 Tools
🪛 Pylint (3.3.7)

[refactor] 15-15: Too many instance attributes (24/7)

(R0902)


96-138: Well-implemented pagination logic.

The recursive approach to handle pagination is clean and effective. The method properly handles date calculations and API responses.

🧰 Tools
🪛 GitHub Check: codecov/patch

[warning] 111-111: src/garth/stats/training_status.py#L111
Added line #L111 was not covered by tests


[warning] 126-126: src/garth/stats/training_status.py#L126
Added line #L126 was not covered by tests

Copy link
Copy Markdown
Owner Author

@matin matin left a comment

Choose a reason for hiding this comment

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

@claude mostly there, but there are some changes I commented on

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
src/garth/stats/training_status/daily.py (1)

38-42: _page_size is dead code in the daily implementation

_page_size is never referenced in DailyTrainingStatus.
Because the /latest/{end} endpoint always returns a single day, the
attribute is misleading noise.

-    _page_size: ClassVar[int] = 28

Unless you plan to add real pagination, drop the constant to avoid
confusion.

tests/stats/test_training_status.py (1)

225-233: Unreachable else block – small readability clean-up

The else after a return is redundant. Dropping it shortens the
test helper and silences the linter warning.

-    def mock_connectapi_side_effect(path):
-        # First call returns data, subsequent calls return empty
-        if hasattr(mock_connectapi_side_effect, "call_count"):
-            mock_connectapi_side_effect.call_count += 1
-            return {}
-        else:
-            mock_connectapi_side_effect.call_count = 1
-            return mock_response
+    def mock_connectapi_side_effect(path):
+        # First call returns data, subsequent calls return empty
+        if hasattr(mock_connectapi_side_effect, "call_count"):
+            mock_connectapi_side_effect.call_count += 1
+            return {}
+        mock_connectapi_side_effect.call_count = 1
+        return mock_response
🧰 Tools
🪛 Pylint (3.3.7)

[refactor] 227-232: Unnecessary "else" after "return", remove the "else" and de-indent the code inside it

(R1705)

CLAUDE.md (1)

82-85: Minor punctuation nit

Missing comma after “endpoints”. Helps the long sentence breathe.

-When adding new stats endpoints (like training status):
+When adding new stats endpoints, (like training status):
🧰 Tools
🪛 LanguageTool

[uncategorized] ~83-~83: Possible missing comma found.
Context: ...ng New Stats Endpoints When adding new stats endpoints (like training status): 1. C...

(AI_HYDRA_LEO_MISSING_COMMA)

📜 Review details

Configuration used: .coderabbit.yaml
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 859e14c and 9893c9a.

📒 Files selected for processing (6)
  • CLAUDE.md (1 hunks)
  • src/garth/stats/training_status/__init__.py (1 hunks)
  • src/garth/stats/training_status/daily.py (1 hunks)
  • src/garth/stats/training_status/monthly.py (1 hunks)
  • src/garth/stats/training_status/weekly.py (1 hunks)
  • tests/stats/test_training_status.py (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • src/garth/stats/training_status/init.py
🧰 Additional context used
📓 Path-based instructions (1)
`tests/**`: - test functions shouldn't have a return type hint - it's ok to use `assert` instead of `pytest.assume()`

tests/**: - test functions shouldn't have a return type hint

  • it's ok to use assert instead of pytest.assume()
  • tests/stats/test_training_status.py
🪛 Pylint (3.3.7)
src/garth/stats/training_status/monthly.py

[refactor] 13-13: Too many instance attributes (23/7)

(R0902)

src/garth/stats/training_status/weekly.py

[refactor] 13-13: Too many instance attributes (23/7)

(R0902)

src/garth/stats/training_status/daily.py

[refactor] 13-13: Too many instance attributes (23/7)

(R0902)

tests/stats/test_training_status.py

[refactor] 227-232: Unnecessary "else" after "return", remove the "else" and de-indent the code inside it

(R1705)

🪛 LanguageTool
CLAUDE.md

[uncategorized] ~83-~83: Possible missing comma found.
Context: ...ng New Stats Endpoints When adding new stats endpoints (like training status): 1. C...

(AI_HYDRA_LEO_MISSING_COMMA)

🔇 Additional comments (1)
src/garth/stats/training_status/weekly.py (1)

55-65: Pagination order looks reversed

page fetches the most-recent 52 weeks; remaining_page then fetches
older data and you return remaining_page + page, meaning the list is
oldest-to-newest. Confirm this is the intended order (other
Stats.list() implementations return newest-to-oldest).

@matin
Copy link
Copy Markdown
Owner Author

matin commented Jun 11, 2025

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Jun 11, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🔭 Outside diff range comments (1)
src/garth/stats/_base.py (1)

28-45: ⚠️ Potential issue

Monthly periods are computed in weeks – date ranges are wrong
The period_type fallback treats every non-daily class as weekly.
MonthlyTrainingStatus.list(..., period=6) therefore covers only 5×7 days
instead of ~5 months, and pagination subtracts _page_size weeks rather than
months.

-        period_type = "days" if "daily" in cls._path else "weeks"
+        if "daily" in cls._path:
+            period_type = "days"
+        elif "monthly" in cls._path:
+            period_type = "months"
+        else:       # weekly or other interval
+            period_type = "weeks"

When period_type == "months" you must switch from datetime.timedelta
(which has no months) to dateutil.relativedelta.relativedelta. A minimal,
drop-in patch:

-from datetime import date, timedelta
+from datetime import date, timedelta
+from dateutil.relativedelta import relativedelta
@@
-        start = end - timedelta(**{period_type: period - 1})
+        delta = relativedelta if period_type == "months" else timedelta
+        start = end - delta(**{period_type: period - 1})

and the identical change in the recursive branch (end - delta(...)).
Without this, monthly endpoints silently return inaccurate data.

♻️ Duplicate comments (2)
src/garth/stats/training_status/monthly.py (1)

57-68: Same single-device limitation as weekly parser
See the weekly parser comment – only the first device block is emitted.
Returning all devices’ monthly data would be more faithful to the API.

src/garth/stats/training_status/daily.py (1)

34-38: 🛠️ Refactor suggestion

_path ignores period, causing odd behaviour for period>1
/trainingstatus/latest/{end} can only ever return one day. When callers pass
period > 1, Stats.list repeatedly hits the same latest endpoint with
earlier end dates, which almost certainly yields empty pages and increases
API traffic.

If historical daily data is not available, restrict the public API:

-    def list(cls, end: date | str | None = None, period: int = 1, *, …)
+    def list(cls, end: date | str | None = None, *, …)

or document clearly that only period == 1 is supported.

🧹 Nitpick comments (2)
src/garth/stats/_base.py (1)

63-68: Avoid mutating the source dict while flattening "values"
stat.pop("values") alters the original object that may be reused by
callers/tests. A non-destructive merge is safer:

-        page_dirs = [{**stat, **stat.pop("values")} for stat in page_dirs]
+        page_dirs = [
+            {**{k: v for k, v in stat.items() if k != "values"}, **stat["values"]}
+            for stat in page_dirs
+        ]

This keeps page_dirs independent of the raw API payload.

tests/stats/test_training_status.py (1)

225-233: Minor style nit – else after return is redundant

if hasattr(mock_connectapi_side_effect, "call_count"):
    mock_connectapi_side_effect.call_count += 1
    return {}
else:                       # ← can be deleted
    mock_connectapi_side_effect.call_count = 1
    return mock_response

Removing the else shortens nesting and appeases pylint R1705.

🧰 Tools
🪛 Pylint (3.3.7)

[refactor] 227-232: Unnecessary "else" after "return", remove the "else" and de-indent the code inside it

(R1705)

📜 Review details

Configuration used: .coderabbit.yaml
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9893c9a and 02a3774.

📒 Files selected for processing (5)
  • src/garth/stats/_base.py (1 hunks)
  • src/garth/stats/training_status/daily.py (1 hunks)
  • src/garth/stats/training_status/monthly.py (1 hunks)
  • src/garth/stats/training_status/weekly.py (1 hunks)
  • tests/stats/test_training_status.py (1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
`tests/**`: - test functions shouldn't have a return type hint - it's ok to use `assert` instead of `pytest.assume()`

tests/**: - test functions shouldn't have a return type hint

  • it's ok to use assert instead of pytest.assume()
  • tests/stats/test_training_status.py
🧬 Code Graph Analysis (2)
src/garth/stats/training_status/weekly.py (3)
src/garth/stats/_base.py (2)
  • Stats (12-68)
  • _parse_response (57-68)
src/garth/stats/training_status/monthly.py (1)
  • _parse_response (40-70)
src/garth/stats/training_status/daily.py (1)
  • _parse_response (40-66)
src/garth/stats/_base.py (5)
src/garth/http.py (1)
  • connectapi (186-192)
src/garth/stats/training_status/monthly.py (1)
  • _parse_response (40-70)
src/garth/stats/training_status/daily.py (1)
  • _parse_response (40-66)
src/garth/stats/training_status/weekly.py (1)
  • _parse_response (40-70)
src/garth/utils.py (1)
  • camel_to_snake_dict (17-33)
🪛 Pylint (3.3.7)
src/garth/stats/training_status/weekly.py

[refactor] 9-9: Too many instance attributes (23/7)

(R0902)

src/garth/stats/training_status/daily.py

[refactor] 9-9: Too many instance attributes (23/7)

(R0902)

src/garth/stats/training_status/monthly.py

[refactor] 9-9: Too many instance attributes (23/7)

(R0902)

tests/stats/test_training_status.py

[refactor] 227-232: Unnecessary "else" after "return", remove the "else" and de-indent the code inside it

(R1705)

🔇 Additional comments (1)
src/garth/stats/training_status/monthly.py (1)

34-38: Pagination size (12) is correct but Stats.list still works in weeks
Even with _page_size = 12, the base class bug (see earlier comment) will
subtract 12 weeks, not 12 months. Ensure the base-class fix is applied, or
MonthlyTrainingStatus.list(period>12) will request an 84-day window instead
of a 1-year window.

Comment on lines +57 to +68
# Get the first device's data (assumes single device for now)
for device_data in report_data.values():
if isinstance(device_data, list):
result = []
for entry in device_data:
if isinstance(entry, dict):
# Flatten the acuteTrainingLoadDTO data
acute_load = entry.pop("acuteTrainingLoadDTO", {})
if isinstance(acute_load, dict):
entry.update(acute_load)
result.append(entry)
return result
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.

🛠️ Refactor suggestion

Only the first device’s data is returned – multi-device accounts lose information
for device_data in report_data.values(): return … exits after the first
iteration; if a user owns multiple watches, the remaining devices’ weekly
stats are dropped.

Consider aggregating all device_data lists:

-        for device_data in report_data.values():
-            if isinstance(device_data, list):
-                result = []
-                for entry in device_data:
+        result: list[dict] = []
+        for device_data in report_data.values():
+            if isinstance(device_data, list):
+                for entry in device_data:-                return result
-
-        return []
+        return result

Returning a complete set avoids silent data loss.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Get the first device's data (assumes single device for now)
for device_data in report_data.values():
if isinstance(device_data, list):
result = []
for entry in device_data:
if isinstance(entry, dict):
# Flatten the acuteTrainingLoadDTO data
acute_load = entry.pop("acuteTrainingLoadDTO", {})
if isinstance(acute_load, dict):
entry.update(acute_load)
result.append(entry)
return result
# Get all devices’ data (was only returning the first device)
result: list[dict] = []
for device_data in report_data.values():
if isinstance(device_data, list):
for entry in device_data:
if isinstance(entry, dict):
# Flatten the acuteTrainingLoadDTO data
acute_load = entry.pop("acuteTrainingLoadDTO", {})
if isinstance(acute_load, dict):
entry.update(acute_load)
result.append(entry)
return result
🤖 Prompt for AI Agents
In src/garth/stats/training_status/weekly.py around lines 57 to 68, the code
returns data from only the first device by exiting the loop early, causing loss
of data for users with multiple devices. Modify the code to aggregate the
processed entries from all devices into a single list before returning, ensuring
that data from every device in report_data.values() is included in the final
result.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (3)
src/garth/stats/training_status/weekly.py (1)

58-69: Still returns only the first device’s data – aggregate across all devices
Behaviour unchanged from the previous review: the early return drops data for users who own multiple devices. Please aggregate entries from all device_data lists before returning.

-        # Get the first device's data (assumes single device for now)
-        for device_data in report_data.values():
-            if isinstance(device_data, list):
-                result = []
-                for entry in device_data:
-                    ...
-                return result
+        result: list[dict] = []
+        for device_data in report_data.values():
+            if isinstance(device_data, list):
+                for entry in device_data:
+                    ...
+                    result.append(entry)
+        return result
src/garth/stats/training_status/monthly.py (1)

35-39: 🛠️ Refactor suggestion

⚠️ Potential issue

Period maths are still based on ISO-weeks → monthly ranges are wrong

Because Stats.list() defaults to period_type = "weeks" for every path that is not daily, calls such as MonthlyTrainingStatus.list(end=date(2025, 6, 1), period=6) will only go back 42 days instead of ~6 calendar months.
The same bug affects the recursion branch that subtracts _page_size.

Override list() here (or, better, fix the heuristic in _base.py) and switch to dateutil.relativedelta(months=…).

+from dateutil.relativedelta import relativedelta
+
     _page_size: ClassVar[int] = 12
+
+    # --- override to use calendar months instead of ISO weeks -------------
+    @classmethod
+    def list(
+        cls,
+        end: date | str | None = None,
+        period: int = 1,
+        *,
+        client: http.Client | None = None,
+    ):
+        client = client or http.client
+        end = format_end_date(end)
+
+        if period > cls._page_size:
+            page = cls.list(end, cls._page_size, client=client)
+            if not page:
+                return []
+            page = (
+                cls.list(
+                    end - relativedelta(months=cls._page_size),
+                    period - cls._page_size,
+                    client=client,
+                )
+                + page
+            )
+            return page
+
+        start = end - relativedelta(months=period - 1)
+        path = cls._path.format(start=start, end=end, period=period)
+        response = client.connectapi(path)
+        stats = [camel_to_snake_dict(s) for s in cls._parse_response(response)]
+        return [cls(**s) for s in stats]

Without this fix every consumer will silently receive truncated data.

src/garth/stats/training_status/daily.py (1)

40-67: period argument still functionally ignored – comment from previous review applies

DailyTrainingStatus relies on an endpoint that always returns a single
day’s record, so Stats.list(..., period=n) never yields n items.
Either implement iterative fetching (one call per date) or drop the
parameter from the public API to stay truthful.

(Tagging as duplicate because this was already raised on an earlier
commit.)

🧹 Nitpick comments (3)
src/garth/sso.py (1)

159-159: Minor nit – optional empty body

data = {} works, but when oauth1.mfa_token is falsy you could pass None to avoid sending an empty form body:

-    data = {"mfa_token": oauth1.mfa_token} if oauth1.mfa_token else {}
+    data = {"mfa_token": oauth1.mfa_token} if oauth1.mfa_token else None

Not critical, just tidies the request.

src/garth/stats/training_status/weekly.py (1)

40-71: Parsing logic duplicated across daily / weekly / monthly – consider a helper
_parse_response in the three training-status classes follows the same nested-dict pattern. An internal utility (e.g. _flatten_training_status(response, *, level_keys) ) would cut duplication and future maintenance cost.

src/garth/stats/training_status/monthly.py (1)

41-71: Parsing logic mirrors the weekly implementation — 👍 but consider defensive copy

The extractor correctly flattens acuteTrainingLoadDTO and copes with unexpected shapes.
Minor: entry.pop("acuteTrainingLoadDTO", {}) mutates the original API dictionary; use entry.get and merge into a new dict to avoid side-effects if the caller reuses the raw response elsewhere.

📜 Review details

Configuration used: .coderabbit.yaml
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 02a3774 and 0df1aa9.

📒 Files selected for processing (6)
  • README.md (1 hunks)
  • src/garth/sso.py (3 hunks)
  • src/garth/stats/training_status/daily.py (1 hunks)
  • src/garth/stats/training_status/monthly.py (1 hunks)
  • src/garth/stats/training_status/weekly.py (1 hunks)
  • tests/stats/test_training_status.py (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • README.md
  • tests/stats/test_training_status.py
🧰 Additional context used
🧬 Code Graph Analysis (3)
src/garth/stats/training_status/daily.py (3)
src/garth/stats/_base.py (2)
  • Stats (12-68)
  • _parse_response (57-68)
src/garth/stats/training_status/weekly.py (1)
  • _parse_response (41-71)
src/garth/stats/training_status/monthly.py (1)
  • _parse_response (41-71)
src/garth/stats/training_status/monthly.py (3)
src/garth/stats/_base.py (2)
  • Stats (12-68)
  • _parse_response (57-68)
src/garth/stats/training_status/weekly.py (1)
  • _parse_response (41-71)
src/garth/stats/training_status/daily.py (1)
  • _parse_response (41-67)
src/garth/stats/training_status/weekly.py (3)
src/garth/stats/_base.py (2)
  • Stats (12-68)
  • _parse_response (57-68)
src/garth/stats/training_status/daily.py (1)
  • _parse_response (41-67)
src/garth/stats/training_status/monthly.py (1)
  • _parse_response (41-71)
🪛 Pylint (3.3.7)
src/garth/stats/training_status/daily.py

[refactor] 10-10: Too many instance attributes (23/7)

(R0902)

src/garth/stats/training_status/monthly.py

[refactor] 10-10: Too many instance attributes (23/7)

(R0902)

src/garth/stats/training_status/weekly.py

[refactor] 10-10: Too many instance attributes (23/7)

(R0902)

🔇 Additional comments (4)
src/garth/sso.py (3)

75-79: Consistent dict-literal usage looks good

Switching from dict() to literal {} is more idiomatic and a hair faster; no behavioural change introduced.


82-88: Overriding gauthHost key is unchanged – all good

The merge correctly overrides the earlier gauthHost value with SSO_EMBED. Behaviour matches previous code.


109-114: Style-only change confirmed

The POST payload is identical to the earlier implementation; nothing else to flag.

src/garth/stats/training_status/monthly.py (1)

9-34: Large dataclass field set is expected – ignore pylint R0902

This dataclass is a straight mirror of Garmin’s payload; the high attribute count is acceptable and suppressing the pylint warning is fine.

🧰 Tools
🪛 Pylint (3.3.7)

[refactor] 10-10: Too many instance attributes (23/7)

(R0902)

Comment on lines +35 to +38
_path: ClassVar[str] = (
"/mobile-gateway/usersummary/trainingstatus/latest/{end}"
)
_page_size: ClassVar[int] = 28
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

_path lacks “daily” – date math is done in weeks, breaking multi-day queries

Stats.list() decides whether to subtract timedelta(days=…) or timedelta(weeks=…) by checking if the substring "daily" is present in cls._path.
Because this path is …/trainingstatus/latest/{end} (no “daily”), period_type is set to "weeks", so:

start = end - timedelta(weeks=period-1)   # ← wrong unit

DailyTrainingStatus.list(end, period=3) therefore fetches the wrong
dates.

Fix options:

-_path: ClassVar[str] = (
-    "/mobile-gateway/usersummary/trainingstatus/latest/{end}"
-)
+# Keep behaviour aligned with the heuristic in Stats.list()
+_path: ClassVar[str] = (
+    "/mobile-gateway/usersummary/trainingstatus/daily/{end}"
+)

or override list() in this subclass and force period_type = "days".

Either way, ensure the unit test covers period > 1.

🤖 Prompt for AI Agents
In src/garth/stats/training_status/daily.py around lines 35 to 38, the _path
string lacks the substring "daily", causing Stats.list() to use weeks instead of
days for date calculations, which breaks multi-day queries. To fix this, update
the _path to include "daily" so the period_type is correctly set to "days", or
alternatively override the list() method in this subclass to explicitly set
period_type to "days". Also, add or update unit tests to cover cases where
period > 1 to verify correct behavior.

Comment on lines +40 to +67
@classmethod
def _parse_response(cls, response):
"""Extract training data from the daily API response structure."""
if not isinstance(response, dict):
return []

data_section = response.get("mostRecentTrainingStatus", {})
if not isinstance(data_section, dict):
return []

payload = data_section.get("payload", {})
if not isinstance(payload, dict):
return []

latest_data = payload.get("latestTrainingStatusData", {})
if not isinstance(latest_data, dict):
return []

# Get the first device's data (assumes single device for now)
for device_data in latest_data.values():
if isinstance(device_data, dict):
# Flatten the acuteTrainingLoadDTO data
acute_load = device_data.pop("acuteTrainingLoadDTO", {})
if isinstance(acute_load, dict):
device_data.update(acute_load)
return [device_data]

return []
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.

🛠️ Refactor suggestion

Parser drops data from secondary devices – only the first device makes it through

_parse_response() stops after the first value in latestTrainingStatusData.
If the user owns multiple devices (common with Garmin), their additional
records are silently discarded.

Minimal change:

-        for device_data in latest_data.values():
-            if isinstance(device_data, dict):
-
-                return [device_data]
-        return []
+        result: list[dict] = []
+        for device_data in latest_data.values():
+            if isinstance(device_data, dict):
+                acute_load = device_data.pop("acuteTrainingLoadDTO", {}) or {}
+                if isinstance(acute_load, dict):
+                    device_data.update(acute_load)
+                result.append(device_data)
+        return result

This keeps the method contract (list[dict]) while preserving all device
entries.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@classmethod
def _parse_response(cls, response):
"""Extract training data from the daily API response structure."""
if not isinstance(response, dict):
return []
data_section = response.get("mostRecentTrainingStatus", {})
if not isinstance(data_section, dict):
return []
payload = data_section.get("payload", {})
if not isinstance(payload, dict):
return []
latest_data = payload.get("latestTrainingStatusData", {})
if not isinstance(latest_data, dict):
return []
# Get the first device's data (assumes single device for now)
for device_data in latest_data.values():
if isinstance(device_data, dict):
# Flatten the acuteTrainingLoadDTO data
acute_load = device_data.pop("acuteTrainingLoadDTO", {})
if isinstance(acute_load, dict):
device_data.update(acute_load)
return [device_data]
return []
@classmethod
def _parse_response(cls, response):
"""Extract training data from the daily API response structure."""
if not isinstance(response, dict):
return []
data_section = response.get("mostRecentTrainingStatus", {})
if not isinstance(data_section, dict):
return []
payload = data_section.get("payload", {})
if not isinstance(payload, dict):
return []
latest_data = payload.get("latestTrainingStatusData", {})
if not isinstance(latest_data, dict):
return []
result: list[dict] = []
for device_data in latest_data.values():
if isinstance(device_data, dict):
acute_load = device_data.pop("acuteTrainingLoadDTO", {}) or {}
if isinstance(acute_load, dict):
device_data.update(acute_load)
result.append(device_data)
return result
🤖 Prompt for AI Agents
In src/garth/stats/training_status/daily.py between lines 40 and 67, the
_parse_response method currently returns only the first device's data from
latestTrainingStatusData, discarding any additional devices. To fix this, modify
the method to iterate over all device_data entries in latestTrainingStatusData,
flatten each acuteTrainingLoadDTO into its device_data, and collect all
processed device_data dictionaries into a list that is returned at the end,
ensuring all devices' data are preserved and returned as a list of dicts.

@cyberjunky
Copy link
Copy Markdown
Contributor

cyberjunky commented Jan 3, 2026

@matin does this fetch the training plans suggested by Garmin Coach? If not do you know how to fetch those? (Suggested Training)

@matin
Copy link
Copy Markdown
Owner Author

matin commented Jan 4, 2026

@matin does this fetch the training plans suggested by Garmin Coach? If not do you know how to fetch those? (Suggested Training)

I don't believe so. I've been behind on Garth, but I'm going to be catching up this week. I'll look into Garmin Coach as well

matin and others added 8 commits January 8, 2026 15:14
- Add DailyTrainingStatus, WeeklyTrainingStatus, MonthlyTrainingStatus classes
- Custom parsing logic for complex nested API responses
- Comprehensive test coverage with VCR cassettes
- Documentation in README with usage examples
- Follows existing patterns for consistency and DRY principles

Closes #129

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
- Test error cases in _extract_training_data method
- Test error cases in list method for non-dict responses
- Test pagination when first page is empty
- Achieve 100% test coverage for training status module

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
- Split single training_status.py into separate daily/weekly/monthly modules
- Use existing Stats base class instead of duplicating functionality
- Follow body battery pattern for directory structure
- Add comprehensive error handling tests achieving 100% coverage
- Fix import paths and typing issues for the new structure

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
Remove problematic list[dict] return type annotations that cause
issues with mypy type checking in CI environments.
- Extend Stats base class with _parse_response() hook method
- Remove 101 lines of duplicated code (33% reduction) from training status
- Eliminate duplicate pagination, HTTP client, and formatting logic
- Training status classes now only implement custom response parsing
- Maintain 100% backward compatibility and test coverage
- Follow Template Method pattern for better maintainability

This refactoring demonstrates strategic use of inheritance to eliminate
code duplication while maintaining flexibility for custom API response
parsing in the training status modules.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
- Change since_date from str to date type
- Change timestamp from int to datetime type
- Add datetime type validation in tests
- Update README examples to show proper datetime objects
- Resolve CodeRabbit comments on dict() usage and test structure

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
- Add _period_type class variable to Stats base class for explicit override
- Set DailyTrainingStatus._period_type = "days" to fix date calculations
- Add test to verify period type is correctly applied
- Regenerate VCR cassettes with correct API calls
- Update README examples to use consistent parameter format
- Maintains backward compatibility with existing stats classes

Resolves CodeRabbit potential issue: daily training status was using weeks
instead of days for date math due to path not containing "daily" keyword.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
Revert the changes that replaced dict() calls with dictionary literals
in sso.py, as requested.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
@matin matin force-pushed the add-training-status branch from dab6868 to f951fe3 Compare January 8, 2026 21:18
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (4)
src/garth/stats/training_status/weekly.py (1)

35-38: Consider: Explicitly set _period_type = "weeks".

While the base class will correctly infer "weeks" from the path (line 30 in _base.py), explicitly setting _period_type = "weeks" would improve clarity and match the pattern established by DailyTrainingStatus.

📝 Suggested addition for clarity
     _path: ClassVar[str] = (
         "/mobile-gateway/usersummary/trainingstatus/weekly/{start}/{end}"
     )
     _page_size: ClassVar[int] = 52
+    _period_type: ClassVar[str] = "weeks"
tests/stats/test_training_status.py (3)

1-1: Move datetime import to top-level imports.

The datetime import on Line 26 should be moved to the top of the file alongside the date import for consistency and clarity.

♻️ Proposed refactor
-from datetime import date
+from datetime import date, datetime

And remove the inline import:

     if daily_training_status[0].timestamp is not None:
-        from datetime import datetime
-
         assert isinstance(daily_training_status[0].timestamp, datetime)

Also applies to: 26-26


215-227: Consider removing the unnecessary patch.

The format_end_date patch appears unnecessary since:

  • It returns the same value as the input
  • The test focuses on verifying the API path and _period_type
  • The mock_client already provides the needed response
♻️ Proposed refactor
     # Test single day request works
-    with patch("garth.stats._base.format_end_date") as mock_format:
-        mock_format.return_value = date(2025, 6, 11)
-
-        result = DailyTrainingStatus.list(
-            date(2025, 6, 11), 1, client=mock_client
-        )
-
-        # Should call the latest endpoint with the end date
-        expected_path = (
-            "/mobile-gateway/usersummary/trainingstatus/latest/2025-06-11"
-        )
-        mock_client.connectapi.assert_called_with(expected_path)
-        assert len(result) == 1
+    result = DailyTrainingStatus.list(
+        date(2025, 6, 11), 1, client=mock_client
+    )
+
+    # Should call the latest endpoint with the end date
+    expected_path = (
+        "/mobile-gateway/usersummary/trainingstatus/latest/2025-06-11"
+    )
+    mock_client.connectapi.assert_called_with(expected_path)
+    assert len(result) == 1

274-280: Simplify the mock side_effect implementation.

Two minor improvements:

  1. The path parameter is unused (per static analysis). Prefix it with underscore to indicate intentional non-use.
  2. The hasattr pattern for tracking call count is convoluted. Consider a simpler approach.
♻️ Proposed refactor
-    def mock_connectapi_side_effect(path):
+    call_count = 0
+    
+    def mock_connectapi_side_effect(_path):
         # First call returns data, subsequent calls return empty
-        if hasattr(mock_connectapi_side_effect, "call_count"):
-            mock_connectapi_side_effect.call_count += 1
+        nonlocal call_count
+        call_count += 1
+        if call_count > 1:
             return {}
-        mock_connectapi_side_effect.call_count = 1
         return mock_response
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between dab6868 and f951fe3.

⛔ Files ignored due to path filters (5)
  • tests/stats/cassettes/test_daily_training_status.yaml is excluded by !tests/**/cassettes/**
  • tests/stats/cassettes/test_monthly_training_status.yaml is excluded by !tests/**/cassettes/**
  • tests/stats/cassettes/test_monthly_training_status_no_data.yaml is excluded by !tests/**/cassettes/**
  • tests/stats/cassettes/test_weekly_training_status.yaml is excluded by !tests/**/cassettes/**
  • tests/stats/cassettes/test_weekly_training_status_pagination.yaml is excluded by !tests/**/cassettes/**
📒 Files selected for processing (9)
  • README.md
  • src/garth/__init__.py
  • src/garth/stats/__init__.py
  • src/garth/stats/_base.py
  • src/garth/stats/training_status/__init__.py
  • src/garth/stats/training_status/daily.py
  • src/garth/stats/training_status/monthly.py
  • src/garth/stats/training_status/weekly.py
  • tests/stats/test_training_status.py
✅ Files skipped from review due to trivial changes (1)
  • README.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/garth/stats/training_status/monthly.py
  • src/garth/init.py
🧰 Additional context used
📓 Path-based instructions (4)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Use make format to auto-format Python source files using ruff
Use make lint to lint Python source files (ruff format check, ruff check, mypy)

Files:

  • tests/stats/test_training_status.py
  • src/garth/stats/_base.py
  • src/garth/stats/training_status/daily.py
  • src/garth/stats/training_status/weekly.py
  • src/garth/stats/training_status/__init__.py
  • src/garth/stats/__init__.py
tests/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

tests/**/*.py: Use pytest with VCR cassettes for HTTP recording/playback in tests
Maintain comprehensive test coverage across all modules with separate test directories mirroring source structure

Files:

  • tests/stats/test_training_status.py
tests/**

⚙️ CodeRabbit configuration file

tests/**: - test functions shouldn't have a return type hint

  • it's ok to use assert instead of pytest.assume()

Files:

  • tests/stats/test_training_status.py
**/__init__.py

📄 CodeRabbit inference engine (CLAUDE.md)

Provide the main client instance as garth.client for direct API access

Files:

  • src/garth/stats/training_status/__init__.py
  • src/garth/stats/__init__.py
🧠 Learnings (1)
📚 Learning: 2026-01-08T00:25:14.763Z
Learnt from: CR
Repo: matin/garth PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-01-08T00:25:14.763Z
Learning: Applies to stats/**/*.py : Processed statistics and aggregated data should be implemented in the `stats/` module with classes for daily/weekly stats

Applied to files:

  • src/garth/stats/_base.py
  • src/garth/stats/training_status/daily.py
  • src/garth/stats/training_status/weekly.py
  • src/garth/stats/training_status/__init__.py
  • src/garth/stats/__init__.py
🧬 Code graph analysis (3)
src/garth/stats/training_status/weekly.py (3)
src/garth/stats/_base.py (2)
  • Stats (12-71)
  • _parse_response (60-71)
src/garth/stats/training_status/daily.py (1)
  • _parse_response (42-68)
src/garth/stats/training_status/monthly.py (1)
  • _parse_response (41-71)
src/garth/stats/training_status/__init__.py (3)
src/garth/stats/training_status/daily.py (1)
  • DailyTrainingStatus (10-68)
src/garth/stats/training_status/monthly.py (1)
  • MonthlyTrainingStatus (10-71)
src/garth/stats/training_status/weekly.py (1)
  • WeeklyTrainingStatus (10-71)
src/garth/stats/__init__.py (3)
src/garth/stats/training_status/daily.py (1)
  • DailyTrainingStatus (10-68)
src/garth/stats/training_status/monthly.py (1)
  • MonthlyTrainingStatus (10-71)
src/garth/stats/training_status/weekly.py (1)
  • WeeklyTrainingStatus (10-71)
🪛 Ruff (0.14.10)
tests/stats/test_training_status.py

274-274: Unused function argument: path

(ARG001)

🔇 Additional comments (18)
src/garth/stats/_base.py (4)

17-17: LGTM! Clean extension point.

The _period_type class variable provides a clean way for subclasses to specify their period unit without changing the inference logic.


29-31: LGTM! Sensible fallback logic.

The period type determination provides good defaults while respecting subclass overrides.


49-57: LGTM! Clean delegation pattern.

The refactored list() method cleanly delegates response parsing to the _parse_response hook while maintaining the existing flow.


59-71: LGTM! Backward-compatible default implementation.

The default _parse_response implementation correctly handles the standard stats API format and maintains backward compatibility with existing subclasses.

src/garth/stats/training_status/daily.py (3)

11-33: LGTM! Comprehensive field definitions.

The optional fields appropriately handle partial API responses. The str | int | None union for load_level_trend (line 18) accommodates API variability.


35-39: LGTM! Appropriate class configuration.

The path template, page size (28 days ≈ 4 weeks), and period type are well-suited for daily training status retrieval.


41-68: Verify: Response mutation and single-device assumption.

The implementation has two characteristics worth noting:

  1. Line 63: pop() mutates the response dict. This is safe if the response is single-use, but could cause issues if the response object is reused elsewhere.

  2. Line 59: The "assumes single device" comment indicates this returns only the first device's data, ignoring others if present.

If multi-device support or immutability is required in the future, consider:

  • Using dict unpacking without pop() for immutability
  • Returning data for all devices or adding a device filter parameter

Otherwise, the defensive type checking (lines 44-57) and structure navigation look solid.

src/garth/stats/training_status/__init__.py (1)

1-9: LGTM! Clean package structure.

The __init__.py properly exposes the three training status classes with consistent __all__ and imports.

src/garth/stats/__init__.py (2)

8-13: LGTM! Maintains alphabetical ordering.

The training status classes are correctly added to __all__ and maintain the existing alphabetical grouping pattern.


22-26: LGTM! Clean import statement.

The multi-line import correctly brings in all three training status classes from the submodule.

src/garth/stats/training_status/weekly.py (2)

11-33: Note: Field definitions duplicated across status classes.

The fields are identical to DailyTrainingStatus and MonthlyTrainingStatus. This is acceptable as they represent the same API data structure, but consider extracting to a mixin or base class if these evolve independently.


40-71: Same concerns as DailyTrainingStatus: mutation and single-device assumption.

This implementation shares the same characteristics noted in daily.py:

  1. Line 65: pop() mutates response dicts (safe if single-use).
  2. Line 58: Only processes the first device's data.

Additionally, there's significant code duplication between WeeklyTrainingStatus._parse_response and MonthlyTrainingStatus._parse_response (they differ only in the response key: "weeklyTrainingStatus" vs "monthlyTrainingStatus").

Consider refactoring the shared parsing logic (lines 59-69) into a helper method to reduce duplication, potentially in a future PR.

♻️ Example refactor to reduce duplication

Create a shared helper in the module:

def _extract_device_entries(report_data: dict) -> list[dict]:
    """Extract and flatten device entries from reportData."""
    for device_data in report_data.values():
        if isinstance(device_data, list):
            result = []
            for entry in device_data:
                if isinstance(entry, dict):
                    acute_load = entry.pop("acuteTrainingLoadDTO", {})
                    if isinstance(acute_load, dict):
                        entry.update(acute_load)
                    result.append(entry)
            return result
    return []

Then both weekly and monthly could use:

return _extract_device_entries(report_data)
tests/stats/test_training_status.py (6)

13-28: LGTM!

The test thoroughly validates daily training status retrieval, including field types and nullable fields.


31-52: LGTM!

Both weekly and monthly training status tests are well-structured and validate the expected data structure.


55-72: LGTM!

Good coverage of pagination scenarios and edge cases with no data.


75-143: LGTM!

Comprehensive error handling tests for all three training status types. The coverage of malformed API responses is excellent.


145-186: LGTM!

Good coverage of error cases in the list method with appropriate mocking.


282-288: LGTM!

The pagination edge case test correctly verifies behavior when data is returned on the first page and subsequent pages are empty.

@matin matin merged commit a8f8583 into main Jan 8, 2026
26 checks passed
@matin matin deleted the add-training-status branch January 8, 2026 21:23
This was referenced Jan 8, 2026
@coderabbitai coderabbitai bot mentioned this pull request Mar 18, 2026
3 tasks
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.

add training status

2 participants