Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add rename function option to return activity ID from uploaded activity #89

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"]`:

jat255 marked this conversation as resolved.
Show resolved Hide resolved
> [!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
Expand Down
107 changes: 103 additions & 4 deletions garth/http.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import base64
import json
import os
from time import sleep
jat255 marked this conversation as resolved.
Show resolved Hide resolved
from typing import IO, Any, Dict, Tuple
from urllib.parse import urljoin

Expand Down Expand Up @@ -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",
jat255 marked this conversation as resolved.
Show resolved Hide resolved
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)
Expand Down