From 88abe8678b7158f353db96f874853fb8ce42271b Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Mon, 6 Jan 2025 11:10:55 -0700 Subject: [PATCH 1/3] add rename function and add option to return activity ID from upload also updated readme with examples --- README.md | 63 +++++++++++++++++++++++++++++++++++++ garth/http.py | 86 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 146 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 39d76dd..3b6952b 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,69 @@ generated from workouts are accepted without issues. } ``` +Using the `return_id` flag will make the code wait for Garmin to return +its internal identifier for the newly created activity, which can +be used for other methods such as renaming. The "internal ID" will +be contained within the resulting dictionary at +`result["detailedImportResult"]["successes"][0]["internalId"]`: + +```python +with open("12129115726_ACTIVITY.fit", "rb") as f: + uploaded = garth.client.upload(f, return_id=True) +``` + +```python +{ + 'detailedImportResult': { + 'uploadId': 212157427938, + 'uploadUuid': { + 'uuid': '6e56051d-1dd4-4f2c-b8ba-00a1a7d82eb3' + }, + 'owner': 2591602, + 'fileSize': 5289, + 'processingTime': 36, + 'creationDate': '2023-09-29 01:58:19.113 GMT', + 'ipAddress': None, + 'fileName': '12129115726_ACTIVITY.fit', + 'report': None, + 'successes': [ + { + 'internalId': 17123456789, + 'externalId': None, + 'messages': None + } + ], + 'failures': [] + } +} +``` + +## Renaming an activity + +Using the "internal activity id" from above, an activity can be renamed using the +`rename` method. This snippet shows an example of uploading an activity, fetching its +id number, and then renaming it on Garmin Connect: + +```python +import garth +from garth.exc import GarthException + +garth.resume(".garth") +try: + garth.client.username +except GarthException: + # Session is expired. You'll need to log in again + garth.login("email", "password") + garth.save(".garth") + +with open("your_fit_file.fit", "rb") as fp: + response = garth.client.upload(fp, return_id=True) + +id_num = response["detailedImportResult"]["successes"][0]["internalId"] +garth.client.rename(id_num, "A better title than 'Virtual Cycling'") +``` + + ## Stats resources ### Stress diff --git a/garth/http.py b/garth/http.py index d086d85..12a79c7 100644 --- a/garth/http.py +++ b/garth/http.py @@ -1,6 +1,7 @@ import base64 import json import os +from time import sleep from typing import IO, Any, Dict, Tuple from urllib.parse import urljoin @@ -184,18 +185,97 @@ def download(self, path: str, **kwargs) -> bytes: return resp.content def upload( - self, fp: IO[bytes], /, path: str = "/upload-service/upload" + self, + fp: IO[bytes], + /, + path: str = "/upload-service/upload", + return_id: bool = False, ) -> Dict[str, Any]: + """Upload FIT file to Garmin Connect. + + Args: + fp: open file pointer to FIT file + path: the API endpoint to use + return_id: Whether to return the Garmin internal activity ID + (default: ``False``). This requires polling to see if Garmin + Connect has finished processing the activity, so setting this + option to ``True`` may introduce some delay + + Returns: + response dictionary with single key ``"detailedImportResult"``. If + ``return_id=True``, will contain the identifier at the path + ``result["detailedImportResult"]["successes"][0]["internalId"]`` + + Raises: + ``AssertionErrror`` if the upload request returns no response + """ fname = os.path.basename(fp.name) files = {"file": (fname, fp)} - result = self.connectapi( + resp = self.request( + "POST", + "connectapi", path, - method="POST", files=files, ) + result = None if resp.status_code == 204 else resp.json() assert result is not None, "No result from upload" + + if return_id: + tries = 0 + # get internal activity ID from garmin connect, try five + # times with increasing waits (it takes some time for Garmin + # connect to return an ID) + while tries < 5: + wait = (tries + 1) * 0.5 + sleep(wait) + if "location" in resp.headers: + id_resp = self.request( + "GET", + "connectapi", + resp.headers["location"].replace( + "https://connectapi.garmin.com", + "", + ), + api=True + ) + if id_resp.status_code == 202: + continue + elif id_resp.status_code == 201: + print(id_resp.json()["detailedImportResult"]) + result["detailedImportResult"]["successes"] = \ + id_resp.json()["detailedImportResult"]["successes"] + break return result + def rename( + self, activity_id: int, new_title: str + ): + """Rename an activity on Garmin Connect. + + Args: + activity_id: the internal Garmin Connect activity id number + new_title: the new title to use for the activity + Raises: + ``AssertionErrror`` if the rename request has an unexpected status + """ + response = self.request( + "POST", + "connect", + f"/activity-service/activity/{activity_id}", + api=True, + json = { + "activityId": activity_id, + "activityName": new_title + }, + headers = { + "accept": "application/json, text/javascript, */*; q=0.01", + "di-backend": "connectapi.garmin.com", + "x-http-method-override": "PUT", + "content-type": "application/json" + } + ) + assert response.status_code == 204, "Unexpected status from rename" + def dump(self, dir_path: str): dir_path = os.path.expanduser(dir_path) os.makedirs(dir_path, exist_ok=True) From 467c804f7d8f787657aa976c541bcb0ad8781293 Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Mon, 6 Jan 2025 11:35:56 -0700 Subject: [PATCH 2/3] changes to appease our AI masters --- README.md | 11 +++++++---- garth/http.py | 22 ++++++++++++++++++---- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3b6952b..6ac2c4d 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,10 @@ be used for other methods such as renaming. The "internal ID" will be contained within the resulting dictionary at `result["detailedImportResult"]["successes"][0]["internalId"]`: +> [!NOTE] +> Since this process waits for processing on the Garmin Connect server, be aware that +> that it can make the upload process can take up to a few seconds longer than usual. + ```python with open("12129115726_ACTIVITY.fit", "rb") as f: uploaded = garth.client.upload(f, return_id=True) @@ -289,9 +293,9 @@ with open("12129115726_ACTIVITY.fit", "rb") as f: ## Renaming an activity -Using the "internal activity id" from above, an activity can be renamed using the -`rename` method. This snippet shows an example of uploading an activity, fetching its -id number, and then renaming it on Garmin Connect: +Using the "internal activity id" from above, an activity can be renamed +using the `rename` method. This snippet shows an example of uploading an +activity, fetching its id number, and then renaming it on Garmin Connect: ```python import garth @@ -312,7 +316,6 @@ id_num = response["detailedImportResult"]["successes"][0]["internalId"] garth.client.rename(id_num, "A better title than 'Virtual Cycling'") ``` - ## Stats resources ### Stress diff --git a/garth/http.py b/garth/http.py index 12a79c7..a2c2da1 100644 --- a/garth/http.py +++ b/garth/http.py @@ -207,7 +207,7 @@ def upload( ``result["detailedImportResult"]["successes"][0]["internalId"]`` Raises: - ``AssertionErrror`` if the upload request returns no response + ``GarthHTTPError`` if the upload request returns no response """ fname = os.path.basename(fp.name) files = {"file": (fname, fp)} @@ -219,6 +219,14 @@ def upload( ) result = None if resp.status_code == 204 else resp.json() assert result is not None, "No result from upload" + if result is None: + raise GarthHTTPError( + msg=( + "Upload did not have expected status code of 204 " + f"(was: {resp.status_code})" + ), + error=HTTPError(), + ) if return_id: tries = 0 @@ -241,7 +249,6 @@ def upload( if id_resp.status_code == 202: continue elif id_resp.status_code == 201: - print(id_resp.json()["detailedImportResult"]) result["detailedImportResult"]["successes"] = \ id_resp.json()["detailedImportResult"]["successes"] break @@ -256,7 +263,7 @@ def rename( activity_id: the internal Garmin Connect activity id number new_title: the new title to use for the activity Raises: - ``AssertionErrror`` if the rename request has an unexpected status + ``GarthHTTPError`` if the rename request has an unexpected status """ response = self.request( "POST", @@ -274,7 +281,14 @@ def rename( "content-type": "application/json" } ) - assert response.status_code == 204, "Unexpected status from rename" + if response.status_code != 204: + raise GarthHTTPError( + msg=( + "Rename did not have expected status code 204 " + f"(was: {response.status_code})" + ), + error=HTTPError(), + ) def dump(self, dir_path: str): dir_path = os.path.expanduser(dir_path) From 100849381eeed85005387f11ffe33d00d3a19b33 Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Mon, 6 Jan 2025 11:55:53 -0700 Subject: [PATCH 3/3] more AI --- README.md | 2 +- garth/http.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6ac2c4d..97426c3 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ be contained within the resulting dictionary at > [!NOTE] > Since this process waits for processing on the Garmin Connect server, be aware that -> that it can make the upload process can take up to a few seconds longer than usual. +> it can make the upload process can take up to a few seconds longer than usual. ```python with open("12129115726_ACTIVITY.fit", "rb") as f: diff --git a/garth/http.py b/garth/http.py index a2c2da1..6745d38 100644 --- a/garth/http.py +++ b/garth/http.py @@ -193,6 +193,12 @@ def upload( ) -> Dict[str, Any]: """Upload FIT file to Garmin Connect. + If ``return_id`` is true, the upload function will perform a retry loop + (up to five times) to poll if Garmin Connect has finished assigning an + ID number to the activity. This can add up to 7.5 seconds of waiting + for the request to finish (though typically it takes about 1.5 + seconds). + Args: fp: open file pointer to FIT file path: the API endpoint to use @@ -218,7 +224,6 @@ def upload( files=files, ) result = None if resp.status_code == 204 else resp.json() - assert result is not None, "No result from upload" if result is None: raise GarthHTTPError( msg=(