Skip to content

Commit 9dab066

Browse files
committed
refactor(toggl): pull out JsonFileCache class
1 parent 64f9335 commit 9dab066

File tree

4 files changed

+83
-98
lines changed

4 files changed

+83
-98
lines changed

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: 7 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,8 @@
1212
from compiler_admin.services.google import user_info as google_user_info
1313
import compiler_admin.services.files as files
1414

15-
# cache of previously seen project information, keyed on Toggl project name
16-
PROJECT_INFO = {}
17-
1815
# cache of previously seen user information, keyed on email
19-
USER_INFO = {}
16+
USER_INFO = files.JsonFileCache("TOGGL_USER_INFO")
2017
NOT_FOUND = "NOT FOUND"
2118

2219
# input CSV columns needed for conversion
@@ -130,29 +127,9 @@ def detailed_time_entries(self, start_date: datetime, end_date: datetime, **kwar
130127
return response
131128

132129

133-
def _get_info(obj: dict, key: str, env_key: str):
134-
"""Read key from obj, populating obj once from a file path at env_key."""
135-
if obj == {}:
136-
file_path = os.environ.get(env_key)
137-
if file_path:
138-
file_info = files.read_json(file_path)
139-
obj.update(file_info)
140-
return obj.get(key)
141-
142-
143-
def _toggl_project_info(project: str):
144-
"""Return the cached project for the given project key."""
145-
return _get_info(PROJECT_INFO, project, "TOGGL_PROJECT_INFO")
146-
147-
148-
def _toggl_user_info(email: str):
149-
"""Return the cached user for the given email."""
150-
return _get_info(USER_INFO, email, "TOGGL_USER_INFO")
151-
152-
153130
def _get_first_name(email: str) -> str:
154131
"""Get cached first name or derive from email."""
155-
user = _toggl_user_info(email)
132+
user = USER_INFO.get(email)
156133
first_name = user.get("First Name") if user else None
157134
if first_name is None:
158135
parts = email.split("@")
@@ -167,7 +144,7 @@ def _get_first_name(email: str) -> str:
167144

168145
def _get_last_name(email: str):
169146
"""Get cached last name or query from Google."""
170-
user = _toggl_user_info(email)
147+
user = USER_INFO.get(email)
171148
last_name = user.get("Last Name") if user else None
172149
if last_name is None:
173150
user = google_user_info(email)
@@ -179,7 +156,7 @@ def _get_last_name(email: str):
179156
return last_name
180157

181158

182-
def _str_timedelta(td):
159+
def _str_timedelta(td: str):
183160
"""Convert a string formatted duration (e.g. 01:30) to a timedelta."""
184161
return pd.to_timedelta(pd.to_datetime(td, format="%H:%M:%S").strftime("%H:%M:%S"))
185162

@@ -220,8 +197,9 @@ def convert_to_harvest(
220197
source["Client"] = client_name
221198
source["Task"] = "Project Consulting"
222199

223-
# get cached project name if any
224-
source["Project"] = source["Project"].apply(lambda x: _toggl_project_info(x) or x)
200+
# get cached project name if any, keyed on Toggl project name
201+
project_info = files.JsonFileCache("TOGGL_PROJECT_INFO")
202+
source["Project"] = source["Project"].apply(lambda x: project_info.get(key=x, default=x))
225203

226204
# assign First and Last name
227205
source["First name"] = source["Email"].apply(_get_first_name)

tests/services/test_files.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pytest
44

55
import compiler_admin.services.files
6-
from compiler_admin.services.files import pd, read_csv, read_json, write_csv, write_json
6+
from compiler_admin.services.files import JsonFileCache, pd, read_csv, read_json, write_csv, write_json
77

88

99
@pytest.fixture
@@ -81,3 +81,30 @@ def test_write_json(sample_data, temp_file):
8181

8282
with open(temp_file.name, "rt") as f:
8383
assert json.load(f) == sample_data
84+
85+
86+
def test_JsonFileCache(monkeypatch):
87+
with NamedTemporaryFile("w") as temp:
88+
monkeypatch.setenv("INFO_FILE", temp.name)
89+
temp.write('{"key": "value"}')
90+
temp.seek(0)
91+
92+
cache = JsonFileCache("INFO_FILE")
93+
94+
assert cache._path.exists()
95+
assert cache.get("key") == "value"
96+
assert cache["key"] == "value"
97+
assert cache.get("other") is None
98+
assert cache["other"] is None
99+
100+
cache["key"] = "other"
101+
assert cache.get("key") == "other"
102+
assert cache["key"] == "other"
103+
104+
105+
def test_JsonFileCache_no_file():
106+
cache = JsonFileCache("INFO_FILE")
107+
108+
assert cache._cache == {}
109+
assert cache._path is None
110+
assert cache.get("key") is None

tests/services/test_toggl.py

Lines changed: 23 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,7 @@
1414
files,
1515
INPUT_COLUMNS,
1616
OUTPUT_COLUMNS,
17-
PROJECT_INFO,
18-
USER_INFO,
1917
Toggl,
20-
_get_info,
21-
_toggl_project_info,
22-
_toggl_user_info,
2318
_get_first_name,
2419
_get_last_name,
2520
_str_timedelta,
@@ -35,19 +30,14 @@ def mock_environment(monkeypatch):
3530
monkeypatch.setenv("TOGGL_USER_INFO", "notebooks/data/toggl-user-info-sample.json")
3631

3732

38-
@pytest.fixture(autouse=True)
39-
def reset_USER_INFO():
40-
USER_INFO.clear()
41-
42-
4333
@pytest.fixture
4434
def spy_files(mocker):
4535
return mocker.patch.object(compiler_admin.services.toggl, "files", wraps=files)
4636

4737

48-
@pytest.fixture
49-
def mock_get_info(mocker):
50-
return mocker.patch(f"{MODULE}._get_info")
38+
@pytest.fixture(autouse=True)
39+
def mock_USER_INFO(mocker):
40+
return mocker.patch(f"{MODULE}.USER_INFO", new={})
5141

5242

5343
@pytest.fixture
@@ -163,103 +153,68 @@ def test_toggl_detailed_time_entries_dynamic_timeout(mock_requests, toggl):
163153
assert mock_requests.post.call_args.kwargs["timeout"] == 30
164154

165155

166-
def test_get_info(monkeypatch):
167-
with NamedTemporaryFile("w") as temp:
168-
monkeypatch.setenv("INFO_FILE", temp.name)
169-
temp.write('{"key": "value"}')
170-
temp.seek(0)
171-
172-
obj = {}
173-
result = _get_info(obj, "key", "INFO_FILE")
174-
175-
assert result == "value"
176-
assert obj["key"] == "value"
177-
178-
179-
def test_get_info_no_file():
180-
obj = {}
181-
result = _get_info(obj, "key", "INFO_FILE")
182-
183-
assert result is None
184-
assert "key" not in obj
185-
186-
187-
def test_toggl_project_info(mock_get_info):
188-
_toggl_project_info("project")
189-
190-
mock_get_info.assert_called_once_with(PROJECT_INFO, "project", "TOGGL_PROJECT_INFO")
191-
192-
193-
def test_toggl_user_info(mock_get_info):
194-
_toggl_user_info("user")
195-
196-
mock_get_info.assert_called_once_with(USER_INFO, "user", "TOGGL_USER_INFO")
197-
198-
199-
def test_get_first_name_matching(mock_get_info):
200-
mock_get_info.return_value = {"First Name": "User"}
156+
def test_get_first_name_matching(mock_USER_INFO):
157+
mock_USER_INFO["email"] = {"First Name": "User"}
201158

202159
result = _get_first_name("email")
203160

204161
assert result == "User"
205162

206163

207-
def test_get_first_name_calcuated_with_record(mock_get_info):
164+
def test_get_first_name_calcuated_with_record(mock_USER_INFO):
208165
email = "user@email.com"
209-
mock_get_info.return_value = {}
210-
USER_INFO[email] = {"Data": 1234}
166+
mock_USER_INFO[email] = {"Data": 1234}
211167

212168
result = _get_first_name(email)
213169

214170
assert result == "User"
215-
assert USER_INFO[email]["First Name"] == "User"
216-
assert USER_INFO[email]["Data"] == 1234
171+
assert mock_USER_INFO[email]["First Name"] == "User"
172+
assert mock_USER_INFO[email]["Data"] == 1234
217173

218174

219-
def test_get_first_name_calcuated_without_record(mock_get_info):
175+
def test_get_first_name_calcuated_without_record(mock_USER_INFO):
220176
email = "user@email.com"
221-
mock_get_info.return_value = {}
177+
mock_USER_INFO[email] = {}
222178

223179
result = _get_first_name(email)
224180

225181
assert result == "User"
226-
assert USER_INFO[email]["First Name"] == "User"
227-
assert list(USER_INFO[email].keys()) == ["First Name"]
182+
assert mock_USER_INFO[email]["First Name"] == "User"
183+
assert list(mock_USER_INFO[email].keys()) == ["First Name"]
228184

229185

230-
def test_get_last_name_matching(mock_get_info, mock_google_user_info):
231-
mock_get_info.return_value = {"Last Name": "User"}
186+
def test_get_last_name_matching(mock_USER_INFO, mock_google_user_info):
187+
mock_USER_INFO["email"] = {"Last Name": "User"}
232188

233189
result = _get_last_name("email")
234190

235191
assert result == "User"
236192
mock_google_user_info.assert_not_called()
237193

238194

239-
def test_get_last_name_lookup_with_record(mock_get_info, mock_google_user_info):
195+
def test_get_last_name_lookup_with_record(mock_USER_INFO, mock_google_user_info):
240196
email = "user@email.com"
241-
mock_get_info.return_value = {}
242-
USER_INFO[email] = {"Data": 1234}
197+
mock_USER_INFO[email] = {"Data": 1234}
243198
mock_google_user_info.return_value = {"Last Name": "User"}
244199

245200
result = _get_last_name(email)
246201

247202
assert result == "User"
248-
assert USER_INFO[email]["Last Name"] == "User"
249-
assert USER_INFO[email]["Data"] == 1234
203+
assert mock_USER_INFO[email]["Last Name"] == "User"
204+
assert mock_USER_INFO[email]["Data"] == 1234
250205
mock_google_user_info.assert_called_once_with(email)
251206

252207

253-
def test_get_last_name_lookup_without_record(mock_get_info, mock_google_user_info):
208+
def test_get_last_name_lookup_without_record(mock_USER_INFO, mock_google_user_info):
254209
email = "user@email.com"
255-
mock_get_info.return_value = {}
210+
mock_USER_INFO[email] = {}
256211
mock_google_user_info.return_value = {"Last Name": "User"}
257212

258213
result = _get_last_name(email)
259214

260215
assert result == "User"
261-
assert USER_INFO[email]["Last Name"] == "User"
262-
assert list(USER_INFO[email].keys()) == ["Last Name"]
216+
assert mock_USER_INFO[email]["Last Name"] == "User"
217+
assert list(mock_USER_INFO[email].keys()) == ["Last Name"]
263218
mock_google_user_info.assert_called_once_with(email)
264219

265220

0 commit comments

Comments
 (0)