diff --git a/Makefile b/Makefile index b9bbbb3..cd06c58 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,7 @@ format: .uv lint: .uv uv run ruff format --check $(sources) uv run ruff check $(sources) - uv run mypy $(sources) + uv run --group linting mypy $(sources) .PHONY: codespell ## Use Codespell to do spellchecking codespell: .pre-commit diff --git a/garth/data/__init__.py b/garth/data/__init__.py index 2200687..3928873 100644 --- a/garth/data/__init__.py +++ b/garth/data/__init__.py @@ -1,4 +1,5 @@ -__all__ = ["HRVData", "SleepData"] +__all__ = ["Activity", "HRVData", "SleepData"] +from .activity import Activity from .hrv import HRVData from .sleep import SleepData diff --git a/garth/data/activity.py b/garth/data/activity.py new file mode 100644 index 0000000..cf48077 --- /dev/null +++ b/garth/data/activity.py @@ -0,0 +1,117 @@ +from datetime import datetime, timezone + +from pydantic.dataclasses import dataclass + +from .. import http +from ..utils import camel_to_snake_dict, remove_dto_from_dict + + +@dataclass(frozen=True) +class ActivityType: + type_id: int + type_key: str + parent_type_id: int | None = None + + +@dataclass(frozen=True) +class Summary: + distance: float + duration: float + moving_duration: float + average_speed: float + max_speed: float + calories: float + average_hr: float + max_hr: float + start_time_gmt: datetime + start_time_local: datetime + start_latitude: float + start_longitude: float + elapsed_duration: float + elevation_gain: float + elevation_loss: float + max_elevation: float + min_elevation: float + average_moving_speed: float + bmr_calories: float + average_run_cadence: float + max_run_cadence: float + average_temperature: float + max_temperature: float + min_temperature: float + average_power: float + max_power: float + min_power: float + normalized_power: float + total_work: float + ground_contact_time: float + stride_length: float + vertical_oscillation: float + training_effect: float + anaerobic_training_effect: float + aerobic_training_effect_message: str + anaerobic_training_effect_message: str + vertical_ratio: float + end_latitude: float + end_longitude: float + max_vertical_speed: float + water_estimated: float + training_effect_label: str + activity_training_load: float + min_activity_lap_duration: float + moderate_intensity_minutes: float + vigorous_intensity_minutes: float + steps: int + begin_potential_stamina: float + end_potential_stamina: float + min_available_stamina: float + avg_grade_adjusted_speed: float + difference_body_battery: float + + +@dataclass(frozen=True) +class Activity: + activity_id: int + activity_name: str + activity_type: ActivityType + summary: Summary + average_running_cadence_in_steps_per_minute: float | None = None + max_running_cadence_in_steps_per_minute: float | None = None + steps: int | None = None + + def _get_localized_datetime( + self, gmt_time: datetime, local_time: datetime + ) -> datetime: + local_diff = local_time - gmt_time + local_offset = timezone(local_diff) + gmt_time = datetime.fromtimestamp( + gmt_time.timestamp() / 1000, timezone.utc + ) + return gmt_time.astimezone(local_offset) + + @property + def activity_start(self) -> datetime: + return self._get_localized_datetime( + self.summary.start_time_gmt, self.summary.start_time_local + ) + + @classmethod + def get( + cls, + id: int, + *, + client: http.Client | None = None, + ): + client = client or http.client + path = f"/activity-service/activity/{id}" + activity_data = client.connectapi(path) + assert activity_data + activity_data = camel_to_snake_dict(activity_data) + activity_data = remove_dto_from_dict(activity_data) + assert isinstance(activity_data, dict) + return cls(**activity_data) + + @classmethod + def list(cls, *args, **kwargs): + data = super().list(*args, **kwargs) + return sorted(data, key=lambda x: x.activity_start) diff --git a/garth/utils.py b/garth/utils.py index b00c62e..d30857e 100644 --- a/garth/utils.py +++ b/garth/utils.py @@ -33,6 +33,35 @@ def camel_to_snake_dict(camel_dict: Dict[str, Any]) -> Dict[str, Any]: return snake_dict +def remove_dto(key: str) -> str: + if key.endswith("_dto"): + return key[: -len("_dto")] + elif key.endswith("DTO"): + return key[: -len("DTO")] + else: + return key + + +def remove_dto_from_dict(input_dict: Dict[str, Any]) -> Dict[str, Any]: + """ + Removes `DTO` suffix from dictionary keys. Different API endpoints give + back different key names, e.g. "activityTypeDTO" instead of "activityType". + """ + output_dict: Dict[str, Any] = {} + for k, v in input_dict.items(): + new_key = remove_dto(k) + if isinstance(v, dict): + output_dict[new_key] = remove_dto_from_dict(v) + elif isinstance(v, list): + output_dict[new_key] = [ + remove_dto_from_dict(i) if isinstance(i, dict) else i + for i in v + ] + else: + output_dict[new_key] = v + return output_dict + + def format_end_date(end: Union[date, str, None]) -> date: if end is None: end = date.today() diff --git a/pyproject.toml b/pyproject.toml index e484dc9..d912889 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,13 @@ indent-style = "space" skip-magic-trailing-comma = false line-ending = "auto" +[dependency-groups] +linting = [ + "mypy>=1.13.0", + "ruff>=0.8.2", + "types-requests>=2.32.0.20241016", +] + [tool.ruff.lint.isort] known-first-party = ["garth"] combine-as-imports = true