diff --git a/README.md b/README.md index 39d76dd..97426c3 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,72 @@ 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"]`: + +> [!NOTE] +> Since this process waits for processing on the Garmin Connect server, be aware 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) +``` + +```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..6745d38 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,116 @@ 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. + + 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 + 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: + ``GarthHTTPError`` 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, ) - assert result is not None, "No result from upload" + result = None if resp.status_code == 204 else resp.json() + 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 + # 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: + 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: + ``GarthHTTPError`` 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" + } + ) + 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) os.makedirs(dir_path, exist_ok=True)