Skip to content

Commit daf933e

Browse files
Merge formatting features with caching
2 parents ad6285f + 4791b34 commit daf933e

File tree

6 files changed

+134
-56
lines changed

6 files changed

+134
-56
lines changed

Dockerfile

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
2727
COPY --from=build /venv/ /venv/
2828
COPY tests/test_data/beamline_parameters.txt tests/test_data/beamline_parameters.txt
2929
ENV PATH=/venv/bin:$PATH
30-
ARG BEAMLINE="dev"
31-
ENV BEAMLINE=${BEAMLINE}
3230

3331
# change this entrypoint if it is not the same as the repo
3432
CMD daq-config-server

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ readme = "README.md"
1717
requires-python = ">=3.11"
1818

1919
[project.optional-dependencies]
20-
server = ["fastapi", "uvicorn", "redis", "hiredis"]
20+
server = ["fastapi", "uvicorn", "redis", "hiredis", "cachetools"]
2121
dev = [
2222
"copier",
2323
"httpx",
@@ -31,6 +31,9 @@ dev = [
3131
"ruff",
3232
"tox-direct",
3333
"types-mock",
34+
"cachetools",
35+
"fastapi",
36+
"uvicorn",
3437
]
3538

3639
[project.scripts]

src/daq_config_server/__main__.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,10 @@ def check_server_dependencies():
1414
import uvicorn # noqa
1515
from fastapi import FastAPI # noqa
1616

17-
server_dependencies_exist = True
17+
return True
1818

1919
except ImportError:
20-
server_dependencies_exist = False
21-
22-
return server_dependencies_exist
20+
return False
2321

2422

2523
def main():

src/daq_config_server/client.py

Lines changed: 77 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1+
import operator
12
from enum import StrEnum
23
from logging import Logger, getLogger
3-
from typing import Any, TypeVar
4+
from typing import Any
45

56
import requests
7+
from cachetools import TTLCache, cachedmethod
8+
from requests import Response
69

710
from daq_config_server.app import ValidAcceptHeaders
811

912
from .constants import ENDPOINTS
1013

11-
T = TypeVar("T")
12-
BlParamDType = str | int | float | bool
13-
1414

1515
class RequestedResponseFormats(StrEnum):
1616
DICT = ValidAcceptHeaders.JSON # Convert to dict using json.loads()
@@ -19,26 +19,77 @@ class RequestedResponseFormats(StrEnum):
1919

2020

2121
class ConfigServer:
22-
def __init__(self, url: str, log: Logger | None = None) -> None:
22+
def __init__(
23+
self,
24+
url: str,
25+
log: Logger | None = None,
26+
cache_size: int = 10,
27+
cache_lifetime_s: int = 3600,
28+
) -> None:
29+
"""
30+
Initialize the ConfigServer client.
31+
32+
Args:
33+
url: Base URL of the config server.
34+
log: Optional logger instance.
35+
cache_size: Size of the cache (maximum number of items can be stored).
36+
cache_lifetime_s: Lifetime of the cache (in seconds).
37+
"""
2338
self._url = url.rstrip("/")
2439
self._log = log if log else getLogger("daq_config_server.client")
40+
self._cache = TTLCache(maxsize=cache_size, ttl=cache_lifetime_s)
41+
42+
@cachedmethod(cache=operator.attrgetter("_cache"))
43+
def _cached_get(
44+
self,
45+
endpoint: str,
46+
accept_header: str,
47+
item: str,
48+
) -> Response:
49+
"""
50+
Get data from the config server and cache it.
51+
52+
Args:
53+
endpoint: API endpoint.
54+
item: item identifier - the filepath.
55+
56+
Returns:
57+
The response data.
58+
"""
59+
60+
try:
61+
r = requests.get(
62+
self._url + endpoint + (f"/{item}"), headers={"Accept": accept_header}
63+
)
64+
r.raise_for_status()
65+
self._log.debug(f"Cache set for {endpoint}/{item}.")
66+
return r
67+
except requests.exceptions.HTTPError as e:
68+
self._log.error(f"HTTP error: {e}")
69+
raise
2570

2671
def _get(
2772
self,
2873
endpoint: str,
29-
headers: dict,
30-
item: str | None = None,
74+
accept_header: str,
75+
item: str,
76+
reset_cached_result=False,
3177
):
32-
r = requests.get(
33-
self._url + endpoint + (f"/{item}" if item else ""), headers=headers
34-
)
78+
"""
79+
Get data from the config server with cache management and use
80+
the content-type response header to format the return value.
81+
If data parsing fails, return the response contents in bytes
82+
"""
83+
if (endpoint, accept_header, item) in self._cache and reset_cached_result:
84+
del self._cache[(endpoint, accept_header, item)]
85+
r = self._cached_get(endpoint, accept_header, item)
3586

3687
content_type = r.headers["content-type"].split(";")[0].strip()
3788

38-
if content_type != headers["Accept"]:
89+
if content_type != accept_header:
3990
self._log.warning(
4091
f"Server failed to parse the file as requested. Requested \
41-
{headers['Accept']} but response came as content-type {content_type}"
92+
{accept_header} but response came as content-type {content_type}"
4293
)
4394

4495
try:
@@ -64,17 +115,25 @@ def get_file_contents(
64115
requested_response_format: RequestedResponseFormats = (
65116
RequestedResponseFormats.DECODED_STRING
66117
),
118+
reset_cached_result: bool = False,
67119
) -> Any:
68120
"""
69-
Get an file contents from the config server in the format specified.
70-
71-
If data parsing fails, the return type will be bytes
121+
Get contents of a file from the config server in the format specified.
122+
If data parsing fails, the return type will be bytes. Optionally try to use
123+
cached result.
72124
73125
Args:
74126
file_path: Path to the file.
75127
requested_response_format: Specify how to parse the response.
128+
reset_cached_result: Whether to reset cache for specific request.
76129
Returns:
77-
The file contents, in the type specified.
130+
The file contents, in the format specified.
78131
"""
79-
headers = {"Accept": requested_response_format}
80-
return self._get(ENDPOINTS.CONFIG, headers, file_path)
132+
133+
accept_header = requested_response_format
134+
return self._get(
135+
ENDPOINTS.CONFIG,
136+
accept_header,
137+
file_path,
138+
reset_cached_result=reset_cached_result,
139+
)

tests/system_tests/test_client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99

1010
"""For now, these tests require locally hosting the config server
1111
12-
While in the python environment, run
12+
While in the python environment, run from the terminal:
1313
14-
python -m daq_config_server --dev
14+
daq-config-server
1515
1616
before running the tests
1717
"""

tests/unit_tests/test_client.py

Lines changed: 49 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,45 @@
11
from unittest.mock import MagicMock, patch
22

3+
import pytest
4+
import requests
35
from fastapi import status
46
from httpx import Response
7+
from requests import RequestException
58

69
from daq_config_server.app import ValidAcceptHeaders
710
from daq_config_server.client import ConfigServer, RequestedResponseFormats
811
from daq_config_server.constants import ENDPOINTS
912

1013

14+
def make_test_response(
15+
content: str,
16+
status_code: int = 200,
17+
raise_exc: type[RequestException] | None = None,
18+
json_value: str | None = None,
19+
content_type=ValidAcceptHeaders.PLAIN_TEXT,
20+
):
21+
r = Response(
22+
json=json_value,
23+
status_code=status_code,
24+
headers={"content-type": content_type},
25+
content=content,
26+
)
27+
r.raise_for_status = MagicMock()
28+
29+
if raise_exc:
30+
r.raise_for_status.side_effect = raise_exc
31+
else:
32+
r.raise_for_status.return_value = None
33+
return r
34+
35+
1136
@patch("daq_config_server.client.requests.get")
1237
def test_get_file_contents_default_header(mock_request: MagicMock):
13-
content_type = ValidAcceptHeaders.PLAIN_TEXT
1438
mock_request.return_value = Response(
1539
status_code=status.HTTP_200_OK,
1640
content="test",
17-
headers={
18-
"content-type": content_type,
19-
},
2041
)
42+
mock_request.return_value = make_test_response("test")
2143
file_path = "test"
2244
url = "url"
2345
server = ConfigServer(url)
@@ -34,13 +56,8 @@ def test_get_file_contents_warns_and_gives_bytes_on_invalid_json(
3456
):
3557
content_type = ValidAcceptHeaders.JSON
3658
bad_json = "bad_dict}"
37-
mock_request.return_value = Response(
38-
status_code=status.HTTP_200_OK,
39-
json="test",
40-
headers={
41-
"content-type": content_type,
42-
},
43-
content=bad_json,
59+
mock_request.return_value = make_test_response(
60+
bad_json, content_type=content_type, json_value=bad_json
4461
)
4562
file_path = "test"
4663
url = "url"
@@ -59,28 +76,31 @@ def test_get_file_contents_warns_and_gives_bytes_on_invalid_json(
5976

6077

6178
@patch("daq_config_server.client.requests.get")
62-
def test_logger_warning_if_content_type_doesnt_match_requested_type(
79+
def test_read_unformatted_file_reading_reset_cached_result_true_without_cache(
6380
mock_request: MagicMock,
6481
):
65-
headers = {"Accept": RequestedResponseFormats.DICT}
66-
content_type = ValidAcceptHeaders.PLAIN_TEXT
67-
text = "text"
68-
mock_request.return_value = Response(
69-
status_code=status.HTTP_200_OK,
70-
headers={
71-
"content-type": content_type,
72-
},
73-
content=text,
74-
)
82+
"""Test reset_cached_result=False and reset_cached_result=True."""
83+
mock_request.side_effect = [
84+
make_test_response("1st_read"),
85+
make_test_response("2nd_read"),
86+
make_test_response("3rd_read"),
87+
]
7588
file_path = "test"
7689
url = "url"
7790
server = ConfigServer(url)
78-
server._log.warning = MagicMock()
79-
server.get_file_contents(
80-
file_path, requested_response_format=RequestedResponseFormats.DICT
81-
)
91+
assert server.get_file_contents(file_path, reset_cached_result=True) == "1st_read"
92+
assert server.get_file_contents(file_path, reset_cached_result=True) == "2nd_read"
93+
assert server.get_file_contents(file_path, reset_cached_result=False) == "2nd_read"
94+
8295

83-
server._log.warning.assert_called_once_with(
84-
f"Server failed to parse the file as requested. Requested \
85-
{headers['Accept']} but response came as content-type {content_type}"
96+
@patch("daq_config_server.client.requests.get")
97+
def test_read_unformatted_file_reading_not_OK(mock_request: MagicMock):
98+
"""Test that a non-200 response raises a RequestException."""
99+
mock_request.return_value = make_test_response(
100+
"1st_read", status.HTTP_204_NO_CONTENT, raise_exc=requests.exceptions.HTTPError
86101
)
102+
file_path = "test"
103+
url = "url"
104+
server = ConfigServer(url)
105+
with pytest.raises(requests.exceptions.HTTPError):
106+
server.get_file_contents(file_path)

0 commit comments

Comments
 (0)