Skip to content

Commit 2c37d80

Browse files
authored
Refactor: create a Toggl API class (#27)
2 parents a3aa5af + 5ec437c commit 2c37d80

File tree

7 files changed

+313
-253
lines changed

7 files changed

+313
-253
lines changed

compiler_admin/api/toggl.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
from base64 import b64encode
2+
from datetime import datetime
3+
4+
import requests
5+
6+
from compiler_admin import __version__
7+
8+
9+
class Toggl:
10+
"""Toggl API Client.
11+
12+
See https://engineering.toggl.com/docs/.
13+
"""
14+
15+
API_BASE_URL = "https://api.track.toggl.com"
16+
API_REPORTS_BASE_URL = "reports/api/v3"
17+
API_WORKSPACE = "workspace/{}"
18+
API_HEADERS = {"Content-Type": "application/json", "User-Agent": "compilerla/compiler-admin:{}".format(__version__)}
19+
20+
def __init__(self, api_token: str, workspace_id: int, **kwargs):
21+
self._token = api_token
22+
self.workspace_id = workspace_id
23+
24+
self.headers = dict(Toggl.API_HEADERS)
25+
self.headers.update(self._authorization_header())
26+
27+
self.session = requests.Session()
28+
self.session.headers.update(self.headers)
29+
self.timeout = int(kwargs.get("timeout", 5))
30+
31+
@property
32+
def workspace_url_fragment(self):
33+
"""The workspace portion of an API URL."""
34+
return Toggl.API_WORKSPACE.format(self.workspace_id)
35+
36+
def _authorization_header(self):
37+
"""Gets an `Authorization: Basic xyz` header using the Toggl API token.
38+
39+
See https://engineering.toggl.com/docs/authentication.
40+
"""
41+
creds = f"{self._token}:api_token"
42+
creds64 = b64encode(bytes(creds, "utf-8")).decode("utf-8")
43+
return {"Authorization": "Basic {}".format(creds64)}
44+
45+
def _make_report_url(self, endpoint: str):
46+
"""Get a fully formed URL for the Toggl Reports API v3 endpoint.
47+
48+
See https://engineering.toggl.com/docs/reports_start.
49+
"""
50+
return "/".join((Toggl.API_BASE_URL, Toggl.API_REPORTS_BASE_URL, self.workspace_url_fragment, endpoint))
51+
52+
def detailed_time_entries(self, start_date: datetime, end_date: datetime, **kwargs) -> requests.Response:
53+
"""Request a CSV report from Toggl of detailed time entries for the given date range.
54+
55+
Args:
56+
start_date (datetime): The beginning of the reporting period.
57+
58+
end_date (str): The end of the reporting period.
59+
60+
Extra `kwargs` are passed through as a POST json body.
61+
62+
By default, requests a report with the following configuration:
63+
* `billable=True`
64+
* `rounding=1` (True, but this is an int param)
65+
* `rounding_minutes=15`
66+
67+
See https://engineering.toggl.com/docs/reports/detailed_reports#post-export-detailed-report.
68+
69+
Returns:
70+
response (requests.Response): The HTTP response.
71+
"""
72+
# ensure start_date precedes end_date
73+
start_date, end_date = min(start_date, end_date), max(start_date, end_date)
74+
start = start_date.strftime("%Y-%m-%d")
75+
end = end_date.strftime("%Y-%m-%d")
76+
77+
# calculate a timeout based on the size of the reporting period in days
78+
# approximately 5 seconds per month of query size, with a minimum of 5 seconds
79+
range_days = (end_date - start_date).days
80+
current_timeout = self.timeout
81+
dynamic_timeout = int((max(30, range_days) / 30.0) * 5)
82+
self.timeout = max(current_timeout, dynamic_timeout)
83+
84+
params = dict(
85+
billable=True,
86+
start_date=start,
87+
end_date=end,
88+
rounding=1,
89+
rounding_minutes=15,
90+
)
91+
params.update(kwargs)
92+
93+
response = self.post_reports("search/time_entries.csv", **params)
94+
self.timeout = current_timeout
95+
96+
return response
97+
98+
def post_reports(self, endpoint: str, **kwargs) -> requests.Response:
99+
"""Send a POST request to the Reports v3 `endpoint`.
100+
101+
Extra `kwargs` are passed through as a POST json body.
102+
103+
Will raise for non-200 status codes.
104+
105+
See https://engineering.toggl.com/docs/reports_start.
106+
"""
107+
url = self._make_report_url(endpoint)
108+
109+
response = self.session.post(url, json=kwargs, timeout=self.timeout)
110+
response.raise_for_status()
111+
112+
return response

compiler_admin/services/files.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import json
2+
import os
3+
from pathlib import Path
24

35
import pandas as pd
46

@@ -23,3 +25,26 @@ def write_json(file_path: str, data):
2325
"""Write a python object as JSON to the given path."""
2426
with open(file_path, "w") as f:
2527
json.dump(data, f, indent=2)
28+
29+
30+
class JsonFileCache:
31+
"""Very basic in-memory cache of a JSON file."""
32+
33+
def __init__(self, env_file_path=None):
34+
self._cache = {}
35+
self._path = None
36+
37+
if env_file_path:
38+
p = os.environ.get(env_file_path)
39+
self._path = Path(p) if p else None
40+
if self._path and self._path.exists():
41+
self._cache.update(read_json(self._path))
42+
43+
def __getitem__(self, key):
44+
return self._cache.get(key)
45+
46+
def __setitem__(self, key, value):
47+
self._cache[key] = value
48+
49+
def get(self, key, default=None):
50+
return self._cache.get(key, default)

compiler_admin/services/toggl.py

Lines changed: 19 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,17 @@
1-
from base64 import b64encode
21
from datetime import datetime
32
import io
43
import os
54
import sys
65
from typing import TextIO
76

87
import pandas as pd
9-
import requests
108

11-
from compiler_admin import __version__
9+
from compiler_admin.api.toggl import Toggl
1210
from compiler_admin.services.google import user_info as google_user_info
1311
import compiler_admin.services.files as files
1412

15-
# Toggl API config
16-
API_BASE_URL = "https://api.track.toggl.com"
17-
API_REPORTS_BASE_URL = "reports/api/v3"
18-
API_WORKSPACE = "workspace/{}"
19-
20-
# cache of previously seen project information, keyed on Toggl project name
21-
PROJECT_INFO = {}
22-
2313
# cache of previously seen user information, keyed on email
24-
USER_INFO = {}
14+
USER_INFO = files.JsonFileCache("TOGGL_USER_INFO")
2515
NOT_FOUND = "NOT FOUND"
2616

2717
# input CSV columns needed for conversion
@@ -31,83 +21,9 @@
3121
OUTPUT_COLUMNS = ["Date", "Client", "Project", "Task", "Notes", "Hours", "First name", "Last name"]
3222

3323

34-
def _harvest_client_name():
35-
"""Gets the value of the HARVEST_CLIENT_NAME env var."""
36-
return os.environ.get("HARVEST_CLIENT_NAME")
37-
38-
39-
def _get_info(obj: dict, key: str, env_key: str):
40-
"""Read key from obj, populating obj once from a file path at env_key."""
41-
if obj == {}:
42-
file_path = os.environ.get(env_key)
43-
if file_path:
44-
file_info = files.read_json(file_path)
45-
obj.update(file_info)
46-
return obj.get(key)
47-
48-
49-
def _toggl_api_authorization_header():
50-
"""Gets an `Authorization: Basic xyz` header using the Toggl API token.
51-
52-
See https://engineering.toggl.com/docs/authentication.
53-
"""
54-
token = _toggl_api_token()
55-
creds = f"{token}:api_token"
56-
creds64 = b64encode(bytes(creds, "utf-8")).decode("utf-8")
57-
return {"Authorization": "Basic {}".format(creds64)}
58-
59-
60-
def _toggl_api_headers():
61-
"""Gets a dict of headers for Toggl API requests.
62-
63-
See https://engineering.toggl.com/docs/.
64-
"""
65-
headers = {"Content-Type": "application/json"}
66-
headers.update({"User-Agent": "compilerla/compiler-admin:{}".format(__version__)})
67-
headers.update(_toggl_api_authorization_header())
68-
return headers
69-
70-
71-
def _toggl_api_report_url(endpoint: str):
72-
"""Get a fully formed URL for the Toggl Reports API v3 endpoint.
73-
74-
See https://engineering.toggl.com/docs/reports_start.
75-
"""
76-
workspace_id = _toggl_workspace()
77-
return "/".join((API_BASE_URL, API_REPORTS_BASE_URL, API_WORKSPACE.format(workspace_id), endpoint))
78-
79-
80-
def _toggl_api_token():
81-
"""Gets the value of the TOGGL_API_TOKEN env var."""
82-
return os.environ.get("TOGGL_API_TOKEN")
83-
84-
85-
def _toggl_client_id():
86-
"""Gets the value of the TOGGL_CLIENT_ID env var."""
87-
client_id = os.environ.get("TOGGL_CLIENT_ID")
88-
if client_id:
89-
return int(client_id)
90-
return None
91-
92-
93-
def _toggl_project_info(project: str):
94-
"""Return the cached project for the given project key."""
95-
return _get_info(PROJECT_INFO, project, "TOGGL_PROJECT_INFO")
96-
97-
98-
def _toggl_user_info(email: str):
99-
"""Return the cached user for the given email."""
100-
return _get_info(USER_INFO, email, "TOGGL_USER_INFO")
101-
102-
103-
def _toggl_workspace():
104-
"""Gets the value of the TOGGL_WORKSPACE_ID env var."""
105-
return os.environ.get("TOGGL_WORKSPACE_ID")
106-
107-
10824
def _get_first_name(email: str) -> str:
10925
"""Get cached first name or derive from email."""
110-
user = _toggl_user_info(email)
26+
user = USER_INFO.get(email)
11127
first_name = user.get("First Name") if user else None
11228
if first_name is None:
11329
parts = email.split("@")
@@ -122,7 +38,7 @@ def _get_first_name(email: str) -> str:
12238

12339
def _get_last_name(email: str):
12440
"""Get cached last name or query from Google."""
125-
user = _toggl_user_info(email)
41+
user = USER_INFO.get(email)
12642
last_name = user.get("Last Name") if user else None
12743
if last_name is None:
12844
user = google_user_info(email)
@@ -134,7 +50,7 @@ def _get_last_name(email: str):
13450
return last_name
13551

13652

137-
def _str_timedelta(td):
53+
def _str_timedelta(td: str):
13854
"""Convert a string formatted duration (e.g. 01:30) to a timedelta."""
13955
return pd.to_timedelta(pd.to_datetime(td, format="%H:%M:%S").strftime("%H:%M:%S"))
14056

@@ -160,7 +76,7 @@ def convert_to_harvest(
16076
None. Either prints the resulting CSV data or writes to output_path.
16177
"""
16278
if client_name is None:
163-
client_name = _harvest_client_name()
79+
client_name = os.environ.get("HARVEST_CLIENT_NAME")
16480

16581
# read CSV file, parsing dates and times
16682
source = files.read_csv(source_path, usecols=INPUT_COLUMNS, parse_dates=["Start date"], cache_dates=True)
@@ -175,8 +91,9 @@ def convert_to_harvest(
17591
source["Client"] = client_name
17692
source["Task"] = "Project Consulting"
17793

178-
# get cached project name if any
179-
source["Project"] = source["Project"].apply(lambda x: _toggl_project_info(x) or x)
94+
# get cached project name if any, keyed on Toggl project name
95+
project_info = files.JsonFileCache("TOGGL_PROJECT_INFO")
96+
source["Project"] = source["Project"].apply(lambda x: project_info.get(key=x, default=x))
18097

18198
# assign First and Last name
18299
source["First name"] = source["Email"].apply(_get_first_name)
@@ -208,42 +125,20 @@ def download_time_entries(
208125
209126
Extra kwargs are passed along in the POST request body.
210127
211-
By default, requests a report with the following configuration:
212-
* `billable=True`
213-
* `client_ids=[$TOGGL_CLIENT_ID]`
214-
* `rounding=1` (True, but this is an int param)
215-
* `rounding_minutes=15`
216-
217-
See https://engineering.toggl.com/docs/reports/detailed_reports#post-export-detailed-report.
218-
219128
Returns:
220129
None. Either prints the resulting CSV data or writes to output_path.
221130
"""
222-
start = start_date.strftime("%Y-%m-%d")
223-
end = end_date.strftime("%Y-%m-%d")
224-
# calculate a timeout based on the size of the reporting period in days
225-
# approximately 5 seconds per month of query size, with a minimum of 5 seconds
226-
range_days = (end_date - start_date).days
227-
timeout = int((max(30, range_days) / 30.0) * 5)
228-
229-
if ("client_ids" not in kwargs or not kwargs["client_ids"]) and isinstance(_toggl_client_id(), int):
230-
kwargs["client_ids"] = [_toggl_client_id()]
231-
232-
params = dict(
233-
billable=True,
234-
start_date=start,
235-
end_date=end,
236-
rounding=1,
237-
rounding_minutes=15,
238-
)
239-
params.update(kwargs)
240-
241-
headers = _toggl_api_headers()
242-
url = _toggl_api_report_url("search/time_entries.csv")
243-
244-
response = requests.post(url, json=params, headers=headers, timeout=timeout)
245-
response.raise_for_status()
131+
env_client_id = os.environ.get("TOGGL_CLIENT_ID")
132+
if env_client_id:
133+
env_client_id = int(env_client_id)
134+
if ("client_ids" not in kwargs or not kwargs["client_ids"]) and isinstance(env_client_id, int):
135+
kwargs["client_ids"] = [env_client_id]
136+
137+
token = os.environ.get("TOGGL_API_TOKEN")
138+
workspace = os.environ.get("TOGGL_WORKSPACE_ID")
139+
toggl = Toggl(token, workspace)
246140

141+
response = toggl.detailed_time_entries(start_date, end_date, **kwargs)
247142
# the raw response has these initial 3 bytes:
248143
#
249144
# b"\xef\xbb\xbfUser,Email,Client..."

tests/api/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)