Skip to content
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ A service to read files on Diamond's filesystem from a BlueAPI container. Curren
Here is a minimal example to read a file from the centrally hosted service after installing this package

```python
from daq_config_server import ConfigClient
from daq_config_server.client import ConfigClient

config_client = ConfigClient("https://daq-config.diamond.ac.uk")

Expand Down
70 changes: 69 additions & 1 deletion docs/how-to/config-server-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ The server is centrally hosted on argus and is accessible anywhere within the Di
This library provides a python client to easily make requests from Bluesky code. The client can use caching to prevent needlessly making time-consuming requests on data which won't have changed. You can choose the maximum number of items it can hold as well as the lifetime of an item upon instantiation.

```python
from daq_config_server import ConfigClient
from daq_config_server.client import ConfigClient

config_client = ConfigClient("https://daq-config.diamond.ac.uk", cache_size = 10, cache_lifetime_s = 3600)
```
Expand Down Expand Up @@ -64,3 +64,71 @@ whitelist:
# Reading sensitive information

If you need to read a file which contains sensitive information, or `dls-dasc` doesn't have the permissions to read your file, you should encrypt this file as a [sealed secret](https://github.com/bitnami-labs/sealed-secrets) on your beamline cluster, and mount this in your BlueAPI service.

# Mocking the Config Client (for tests and offline development)

The ConfigClient can be configured to run in a fully offline mode for unit tests and local development. In this mode, no HTTP requests are made. Instead, file reads are intercepted and optionally transformed using mock converters.

This allows you to simulate server-side conversion behaviour (e.g. JSON → dict, table → JSON, or custom Pydantic models) without requiring a running config service.

## Enabling mock mode
```python
config_client = ConfigClient()
config_client.configure_mock()
```
Once enabled, all file access goes through the mock layer instead of the real server.

### Mock converters

Mock converters allow you to simulate the server-side converter_map behaviour locally.

A converter is a function with the signature:

```python
Callable[[str], ConfigModel | str | bytes | dict[str, Any]]
```

You register converters keyed by Path:

```python
from pathlib import Path

config_client.setup_mock(
{
Path("/tmp/my_file.txt"): my_converter_function
}
)
```
Example: converting a table to JSON

You can simulate a file containing a table-like format (e.g. whitespace-separated columns) and convert it into JSON.

### Example file content
```
key value
x 1
y test
```
### Converter function
```python
import json

def table_to_json(contents: str) -> str:
lines = contents.strip().splitlines()
headers = lines[0].split()

result = {}
for line in lines[1:]:
key, value = line.split()
# attempt numeric conversion
if value.isdigit():
value = int(value)
result[key] = value

return json.dumps(result)
```
### Test setup
```python
file_path = Path("/path/to/data.txt")
client.setup_mock({file_path: table_to_json})
```
1 change: 1 addition & 0 deletions docs/reference/current_and_planned_features.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- Provide a client module for users to easily communicate with the server, with caching.
- Have this service hosted on diamond's central argus cluster - with url `https://daq-config-server.diamond.ac.uk`
- Provide server-side json and pydantic model formatting for commonly used configuration files - eg `beamline_parameters.txt` should be returned as a dictionary or pydantic model.
- Supports mock behaviour so in test environments that rely on ConfigClient as a dependency can still use a local file system for tests.


## Future features
Expand Down
3 changes: 1 addition & 2 deletions src/daq_config_server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,5 @@
"""

from ._version import __version__
from .app.client import ConfigClient

__all__ = ["__version__", "ConfigClient"]
__all__ = ["__version__"]
17 changes: 2 additions & 15 deletions src/daq_config_server/app/_routes.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import json
import os
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
from typing import Any

from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse, Response
from starlette import status

from daq_config_server.app.constants import EndPoints, ValidAcceptHeaders
from daq_config_server.models.base_model import ConfigModel

from ._file_converter_map import get_converter
Expand Down Expand Up @@ -38,20 +37,8 @@ def get_converted_file_contents(file_path: Path) -> dict[str, Any]:
router = APIRouter()


class ValidAcceptHeaders(StrEnum):
JSON = "application/json"
PLAIN_TEXT = "text/plain"
RAW_BYTES = "application/octet-stream"


@dataclass(frozen=True)
class ENDPOINTS:
CONFIG = "/config"
HEALTH = "/healthz"


@router.get(
ENDPOINTS.CONFIG + "/{file_path:path}",
EndPoints.CONFIG + "/{file_path:path}",
responses={
200: {
"description": "Returns JSON, plain text, or binary file.",
Expand Down
12 changes: 12 additions & 0 deletions src/daq_config_server/app/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from enum import StrEnum


class ValidAcceptHeaders(StrEnum):
JSON = "application/json"
PLAIN_TEXT = "text/plain"
RAW_BYTES = "application/octet-stream"


class EndPoints(StrEnum):
CONFIG = "/config"
HEALTH = "/healthz"
3 changes: 3 additions & 0 deletions src/daq_config_server/client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from ._client import ConfigClient

__all__ = ["ConfigClient"]
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
import logging
import operator
from collections.abc import Callable
from logging import Logger, getLogger
from pathlib import Path
from threading import RLock
from typing import Any, TypeVar, get_origin, overload

import requests
from cachetools import TTLCache, cachedmethod
from pydantic import TypeAdapter
from requests import Response
from requests.exceptions import HTTPError

from daq_config_server.app.constants import EndPoints, ValidAcceptHeaders
from daq_config_server.models.base_model import ConfigModel

from ._routes import ENDPOINTS, ValidAcceptHeaders

LOGGER = logging.getLogger(__name__)
from ._server_response import (
MockPathToConverterDict,
MockServerResponse,
RealServerResponse,
ResponseType,
ServerResponse,
)

TModel = TypeVar("TModel", bound=ConfigModel)
TNonModel = TypeVar("TNonModel", str, bytes, dict[str, Any])
Expand All @@ -41,8 +43,13 @@ def _get_mime_type(


class ConfigClient:
"""Client to communicate with a deployed config service with a configurable cache
and logger"""
"""Client for retrieving configuration data from a config service with
support for caching, flexible return types, and pluggable backends.

This client abstracts access to configuration files stored either on:
- a remote configuration server (production mode), or
- a local mock filesystem (test mode)
"""

def __init__(
self,
Expand All @@ -58,22 +65,38 @@ def __init__(
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._log = log or getLogger("daq_config_server.client")
self._cache: TTLCache[tuple[str, str, Path], Response] = TTLCache(
maxsize=cache_size, ttl=cache_lifetime_s
)
self._lock = RLock()
self._server: ServerResponse = RealServerResponse(self._url, self._log)

def configure_mock(self, converters: MockPathToConverterDict | None = None) -> None:
"""Switch the client into mock mode using a local filesystem backend.

This replaces the real HTTP server implementation with a mock
server that reads configuration data directly from local files.

Optional converters can be provided to simulate server-side parsing
or transformation logic on a per-file basis.

Args:
converters:
Optional mapping of file paths to converter functions.
Each function receives raw file contents as a string and
returns a transformed object (e.g. dict, ConfigModel, etc.).
"""
self._server = MockServerResponse(converters)

@cachedmethod(
cache=operator.attrgetter("_cache"), lock=operator.attrgetter("_lock")
)
def _cached_get(
self,
endpoint: str,
accept_header: ValidAcceptHeaders,
file_path: Path,
) -> Response:
self, endpoint: str, accept_header: ValidAcceptHeaders, file_path: Path
) -> ResponseType:
"""
Get data from the config server and cache it.

Expand All @@ -87,51 +110,37 @@ def _cached_get(
"""

request_url = self._url + endpoint + (f"/{file_path}")
r = requests.get(request_url, headers={"Accept": accept_header})
# Intercept http exceptions from server so that the client
# can include the response `detail` sent by the server
try:
r.raise_for_status()
except requests.exceptions.HTTPError as err:
try:
error_detail = r.json().get("detail")
self._log.error(error_detail)
raise HTTPError(error_detail) from err
except ValueError:
self._log.error("Response raised HTTP error but no details provided")
raise HTTPError from err

r = self._server.get_response(endpoint, accept_header, file_path)
self._log.debug(f"Cache set for {request_url}.")
return r

def _get(
self,
endpoint: str,
accept_header: ValidAcceptHeaders,
file_path: Path,
accept_header: ValidAcceptHeaders,
reset_cached_result: bool = False,
):
"""
Get data from the config server with cache management and use
the content-type response header to format the return value.
If data parsing fails, return the response contents in bytes
"""

cache_key = (endpoint, accept_header, file_path)
if reset_cached_result:
with self._lock:
if cache_key in self._cache:
del self._cache[cache_key]

r = self._cached_get(*cache_key)

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

if content_type != accept_header:
self._log.warning(
f"Server failed to parse the file as requested. Requested "
f"{accept_header} but response came as content-type {content_type}"
)

try:
match content_type:
case ValidAcceptHeaders.JSON:
Expand Down Expand Up @@ -192,30 +201,22 @@ def get_file_contents(
force_parser: Optionally provide a function to convert the contents of a
config file to the desired return type. This overides whatever converter
is specified for that file in the FILE_TO_CONVERTER_MAP, and can be used
if the config file isn't in the FILE_TO_CONVERTER_MAP at all. This
should only be used for testing or when waiting on a release that will
add the file to the FILE_TO_CONVERTER_MAP.
if the config file isn't in the FILE_TO_CONVERTER_MAP at all.
Returns:
The file contents, in the format specified.
"""
file_path = Path(file_path)

if force_parser:
LOGGER.warning(
"The force_parser argument should only be used for testing or "
"as a temporary measure. Add your file and parser to the "
"FILE_TO_CONVERTER_MAP. See "
"https://github.com/DiamondLightSource/daq-config-server/blob/main/docs/how-to/config-server-guide.md#file-converters"
)
# force accept header to string so conversion is done client side
accept_header = _get_mime_type(str)
else:
accept_header = _get_mime_type(desired_return_type)

result = self._get(
ENDPOINTS.CONFIG,
accept_header,
file_path,
EndPoints.CONFIG,
accept_header=accept_header,
file_path=file_path,
reset_cached_result=reset_cached_result,
)
if force_parser:
Expand Down
Loading
Loading