Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
77179b1
Remove unused features
olliesilvester Apr 28, 2025
886268e
Remove CI for GUI
olliesilvester Apr 28, 2025
4f86289
Update helmchart/values.yaml
olliesilvester Apr 28, 2025
c3f4124
Changes to CI
olliesilvester Apr 28, 2025
dd94bdd
remove paths from ci
olliesilvester Apr 28, 2025
2184ace
update readme
olliesilvester May 2, 2025
d392aae
Update helmchart
olliesilvester May 2, 2025
4069f66
Merge remote-tracking branch 'origin/get_cI_working' into 64_remove_u…
olliesilvester May 2, 2025
da4fb1d
create main get_configuration endpoint and add tests
olliesilvester May 8, 2025
c21a08f
Improve CI
olliesilvester May 8, 2025
6aaca84
Improve codecov
olliesilvester May 8, 2025
576e4f2
Merge branch 'main' into 65_create_main_endpoint
olliesilvester May 8, 2025
963cd9f
Response from review
olliesilvester May 19, 2025
2f1fe49
Small tidy on build bash scri[t
olliesilvester May 20, 2025
b7ffc13
add cache for client get
Relm-Arrowny May 20, 2025
c8c1b4c
add cachetools
Relm-Arrowny May 20, 2025
dd7ea82
catch miss cache
Relm-Arrowny May 20, 2025
f32e464
add error handling on responds
Relm-Arrowny May 21, 2025
d9cd16b
remove unused variables
olliesilvester May 21, 2025
52e9a3f
remvoe try and add lib to dev
Relm-Arrowny May 21, 2025
7cb94ff
Merge remote-tracking branch 'remotes/origin/65_create_main_endpoint'…
Relm-Arrowny May 21, 2025
7bce3af
correct test to raise the correct exception
Relm-Arrowny May 21, 2025
bc5a2ff
make cache size and lifetime customisables
Relm-Arrowny May 21, 2025
b6ad4de
correct docstring
Relm-Arrowny May 21, 2025
d605301
remove pop cache
Relm-Arrowny May 21, 2025
0f02426
make use of cachedmethod.
Relm-Arrowny May 22, 2025
0c18eac
rename response to data
Relm-Arrowny May 22, 2025
aed0cf4
Update src/daq_config_server/__main__.py
olliesilvester May 22, 2025
8a2d57d
Remove unused beamline variable
olliesilvester May 22, 2025
a26a4e8
Merge branch '65_create_main_endpoint' into 66-implement-caching-on-g…
olliesilvester May 22, 2025
b04eebe
fix docString
Relm-Arrowny May 22, 2025
a237f9e
Merge remote-tracking branch 'origin/main' into 66-implement-caching-…
Relm-Arrowny May 22, 2025
3320488
Merge branch '66-implement-caching-on-get_configuration' of github.co…
Relm-Arrowny May 22, 2025
3560c9f
correct typo
Relm-Arrowny May 22, 2025
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
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ readme = "README.md"
requires-python = ">=3.11"

[project.optional-dependencies]
server = ["fastapi", "uvicorn", "redis", "hiredis"]
server = ["fastapi", "uvicorn", "redis", "hiredis", "cachetools"]
dev = [
"copier",
"httpx",
Expand All @@ -31,6 +31,9 @@ dev = [
"ruff",
"tox-direct",
"types-mock",
"cachetools",
"fastapi",
"uvicorn",
]

[project.scripts]
Expand Down
95 changes: 85 additions & 10 deletions src/daq_config_server/client.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,101 @@
import operator
from logging import Logger, getLogger
from typing import Any

import requests
from cachetools import TTLCache, cachedmethod

from .constants import ENDPOINTS


class ConfigServer:
def __init__(self, url: str, log: Logger | None = None) -> None:
def __init__(
self,
url: str,
log: Logger | None = None,
cache_size: int = 10,
cache_lifetime_s: int = 3600,
) -> None:
"""
Initialize the ConfigServer client.

Args:
url: Base URL of the config server.
log: Optional logger instance.
cache_size: Size of the cache (maximum number of items can be stored).
cache_lifetime_s: Lifetime of the cache (in seconds).
"""
self._url = url.rstrip("/")
self._log = log if log else getLogger("daq_config_server.client")
self._cache = TTLCache(maxsize=cache_size, ttl=cache_lifetime_s)

def _get(
self,
endpoint: str,
item: str | None = None,
):
r = requests.get(self._url + endpoint + (f"/{item}" if item else ""))
return r.json()

def read_unformatted_file(self, file_path: str) -> Any:
# After https://github.com/DiamondLightSource/daq-config-server/issues/67, we
# can get specific formats, and then have better typing on
# return values
return self._get(ENDPOINTS.CONFIG, file_path)
reset_cached_result: bool = False,
) -> Any:
"""
Get data from the config server with cache management.
If a cached response doesn't already exist, makes a request to
the config server.
If reset_cached_result is true, remove the cache entry for that request and
make a new request

Args:
endpoint: API endpoint.
item: Optional item identifier.
reset_cached_result: Whether to reset cache.

Returns:
The response data.
"""

if (endpoint, item) in self._cache and reset_cached_result:
del self._cache[(endpoint, item)]
return self._cached_get(endpoint, item)

@cachedmethod(cache=operator.attrgetter("_cache"))
def _cached_get(
self,
endpoint: str,
item: str | None = None,
) -> Any:
"""
Get data from the config server and cache it.

Args:
endpoint: API endpoint.
item: Optional item identifier.

Returns:
The response data.
"""
url = self._url + endpoint + (f"/{item}" if item else "")

try:
r = requests.get(url)
r.raise_for_status()
data = r.json()
self._log.debug(f"Cache set for {endpoint}/{item}.")
return data
except requests.exceptions.HTTPError as e:
self._log.error(f"HTTP error: {e}")
raise

def read_unformatted_file(
self, file_path: str, reset_cached_result: bool = False
) -> Any:
"""
Read an unformatted file from the config server.

Args:
file_path: Path to the file.
reset_cached_result: Whether to reset cache.

Returns:
The file content.
"""
return self._get(
ENDPOINTS.CONFIG, file_path, reset_cached_result=reset_cached_result
)
105 changes: 101 additions & 4 deletions tests/unit_tests/test_client.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,115 @@
from time import sleep
from unittest.mock import MagicMock, patch

import pytest
import requests
from fastapi import status
from httpx import Response

from daq_config_server.client import ConfigServer
from daq_config_server.constants import ENDPOINTS


# More useful tests for the client are in tests/system_tests
def make_mock_response(json_value, status_code=200, raise_exc=None):
mock_response = MagicMock()
mock_response.json.return_value = json_value
mock_response.status_code = status_code
if raise_exc:
mock_response.raise_for_status.side_effect = raise_exc
else:
mock_response.raise_for_status.return_value = None
return mock_response


@patch("daq_config_server.client.requests.get")
def test_read_unformatted_file(mock_request: MagicMock):
mock_request.return_value = Response(status_code=status.HTTP_200_OK, json="test")
"""Test that read_unformatted_file calls the correct endpoint and
returns the expected result."""
mock_request.return_value = make_mock_response({"key": "value"}, status.HTTP_200_OK)
file_path = "test"
url = "url"
server = ConfigServer(url)
server.read_unformatted_file(file_path)
result = server.read_unformatted_file(file_path)
assert result == {"key": "value"}
mock_request.assert_called_once_with(url + ENDPOINTS.CONFIG + "/" + file_path)


@patch("daq_config_server.client.requests.get")
def test_read_unformatted_file_reading_cache(mock_request: MagicMock):
"""Test cache behavior for read_unformatted_file."""
mock_request.side_effect = [
make_mock_response("1st_read", status.HTTP_200_OK),
make_mock_response("2nd_read", status.HTTP_200_OK),
make_mock_response("3rd_read", status.HTTP_200_OK),
]
file_path = "test"
url = "url"
server = ConfigServer(url)
assert server.read_unformatted_file(file_path) == "1st_read"
assert server.read_unformatted_file(file_path) == "1st_read" # Cached
assert (
server.read_unformatted_file(file_path, reset_cached_result=True) == "2nd_read"
)
assert server.read_unformatted_file(file_path) == "2nd_read" # Cached
assert (
server.read_unformatted_file(file_path, reset_cached_result=True) == "3rd_read"
)


@patch("daq_config_server.client.requests.get")
def test_read_unformatted_file_reading_reset_cached_result_true_without_cache(
mock_request: MagicMock,
):
"""Test repeated reset_cached_result=False disables cache for each call."""
mock_request.side_effect = [
make_mock_response("1st_read", status.HTTP_200_OK),
]
file_path = "test"
url = "url"
server = ConfigServer(url)
assert (
server.read_unformatted_file(file_path, reset_cached_result=True) == "1st_read"
)


@patch("daq_config_server.client.requests.get")
def test_read_unformatted_file_reading_not_OK(mock_request: MagicMock):
"""Test that a non-200 response raises a RequestException."""
mock_request.return_value = make_mock_response(
"1st_read", status.HTTP_204_NO_CONTENT, raise_exc=requests.exceptions.HTTPError
)
file_path = "test"
url = "url"
server = ConfigServer(url)
with pytest.raises(requests.exceptions.HTTPError):
server.read_unformatted_file(file_path)


@patch("daq_config_server.client.requests.get")
def test_read_unformatted_file_reading_cache_custom_size(mock_request: MagicMock):
mock_request.side_effect = [
make_mock_response("1st_read", status.HTTP_200_OK),
make_mock_response("2nd_read", status.HTTP_200_OK),
make_mock_response("3rd_read", status.HTTP_200_OK),
]
file_path = "test"
url = "url"
server = ConfigServer(url=url, cache_size=1)
assert server.read_unformatted_file(file_path) == "1st_read"
assert server.read_unformatted_file(file_path + "1") == "2nd_read"
assert server.read_unformatted_file(file_path) == "3rd_read"


@patch("daq_config_server.client.requests.get")
def test_read_unformatted_file_cache_custom_lifetime(mock_request: MagicMock):
mock_request.side_effect = [
make_mock_response("1st_read", status.HTTP_200_OK),
make_mock_response("2nd_read", status.HTTP_200_OK),
make_mock_response("3rd_read", status.HTTP_200_OK),
]
file_path = "test"
url = "url"
server = ConfigServer(url=url, cache_lifetime_s=0.1) # type: ignore
assert server.read_unformatted_file(file_path) == "1st_read"
assert server.read_unformatted_file(file_path) == "1st_read"
sleep(0.1)
assert server.read_unformatted_file(file_path) == "2nd_read"
Loading