Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate client to requests and add get_some_beamline_params #49

Merged
merged 6 commits into from
Aug 13, 2024
Merged
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
8 changes: 6 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
]
description = "A service to put and get your config values from"
dependencies = []
dependencies = ["urllib3", "requests"]
dynamic = ["version"]
license.file = "LICENSE"
readme = "README.md"
Expand Down Expand Up @@ -56,6 +56,10 @@ addopts = """
--tb=native -vv
--asyncio-mode=auto
"""
markers = """
uses_live_server: mark a test which uses the live config server
"""

# https://iscinumpy.gitlab.io/post/bound-version-constraints/#watch-for-warnings
filterwarnings = "error"
# Doctest python code in docs, python code in src docstrings, test functions in tests
Expand Down Expand Up @@ -86,7 +90,7 @@ allowlist_externals =
commands =
pre-commit: pre-commit run --all-files --show-diff-on-failure {posargs}
type-checking: pyright src tests {posargs}
tests: pytest --cov=daq_config_server --cov-report term --cov-report xml:cov.xml {posargs}
tests: pytest -m "not uses_live_server" --cov=daq_config_server --cov-report term --cov-report xml:cov.xml {posargs}
"""

[tool.ruff]
Expand Down
11 changes: 9 additions & 2 deletions src/daq_config_server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import uvicorn
from fastapi import FastAPI, Request, Response, status
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from redis import Redis

from .beamline_parameters import (
Expand Down Expand Up @@ -50,11 +51,17 @@ def get_beamline_parameter(param: str):
return {param: BEAMLINE_PARAMS.params.get(param)}


class ParamList(BaseModel):
param_list: list[str]


@app.get(ENDPOINTS.BL_PARAM)
def get_all_beamline_parameters():
def get_all_beamline_parameters(param_list_data: ParamList | None):
"""Get a dict of all the current beamline parameters."""
assert BEAMLINE_PARAMS is not None
return BEAMLINE_PARAMS.params
if param_list_data is None:
return BEAMLINE_PARAMS.params
return {k: BEAMLINE_PARAMS.params.get(k) for k in param_list_data.param_list}


@app.get(ENDPOINTS.FEATURE)
Expand Down
55 changes: 25 additions & 30 deletions src/daq_config_server/client.py
Original file line number Diff line number Diff line change
@@ -1,55 +1,44 @@
import json
from http.client import HTTPConnection, HTTPSConnection
from logging import Logger, getLogger
from typing import TypeVar
from typing import Any, TypeVar

from urllib3.util import parse_url
import requests

from .constants import ENDPOINTS

T = TypeVar("T")
BlParamDType = str | int | float | bool


class ConfigServer:
def __init__(self, url: str, log: Logger | None = None) -> None:
self._url = parse_url(url)
self._uri_prefix = self._url.request_uri if self._url.request_uri != "/" else ""
if self._url.scheme != "http" and self._url.scheme != "https":
raise ValueError("ConfigServer must use HTTP or HTTPS!")
self._Conn = HTTPSConnection if self._url.scheme == "https" else HTTPConnection
self._url = url.rstrip("/")
self._log = log if log else getLogger("daq_config_server.client")

def _get(
self,
endpoint: str,
item: str | None = None,
options: dict[str, str] | None = None,
data: dict[str, Any] | None = None,
):
req_item = f"/{item}" if item else ""
conn = self._Conn(self._url.host, self._url.port or self._url.scheme)
req_ops = (
f"?{''.join(f'{k}={v}&' for k,v in options.items())}"[:-1]
if options
else ""
r = requests.get(
self._url + endpoint + (f"/{item}" if item else ""), options, json=data
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should urlencode path parameters using something like urllib.parse.quote() - looks like requests does not have a convenient way to encode them and only supports query params.

Copy link
Collaborator Author

@dperl-dls dperl-dls Jul 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see how this would really help that much, urllib also doesn't support combining components of the path either? Anyway, could we make that into a new issue since this already fixes some problems? #53

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

json=data? Are we sending json in GET request body? Seems like a bad idea. What is this really doing?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, for getting a specific list of beamline params. what do you think is a better way of doing it? query params don't nicely support putting a list in there

)
complete_req = self._uri_prefix + endpoint + req_item + req_ops
conn.connect()
conn.request("GET", complete_req)
resp = conn.getresponse()
assert resp.status == 200, f"Failed to get response: {resp!r}"
body = json.loads(resp.read())
assert item in body, f"Malformed response: {body} does not contain {item}"
resp.close()
conn.close()
return body[item]
return r.json()

def get_beamline_param(self, param: str) -> str | int | float | bool | None:
return self._get(ENDPOINTS.BL_PARAM, param)
def get_beamline_param(self, param: str) -> BlParamDType | None:
return self._get(ENDPOINTS.BL_PARAM, param).get(param)

def get_some_beamline_params(self, params: list[str]) -> dict[str, BlParamDType]:
return self._get(ENDPOINTS.BL_PARAM, data={"param_list": params})

def get_all_beamline_params(self) -> dict[str, BlParamDType]:
return self._get(ENDPOINTS.BL_PARAM)

def get_feature_flag(self, flag_name: str) -> bool | None:
"""Get the specified feature flag; returns None if it does not exist. Will check
that the HTTP response is correct and raise an AssertionError if not."""
return self._get(ENDPOINTS.FEATURE, flag_name)
return self._get(ENDPOINTS.FEATURE, flag_name).get(flag_name)

def get_all_feature_flags(self) -> dict | None:
"""Get the values for all flags; returns None if it does not exist. Will check
Expand All @@ -68,7 +57,10 @@ def best_effort_get_feature_flag(
doesn't exist or if there is a connection error - in the latter case logs to
error."""
try:
return self._get(ENDPOINTS.FEATURE, flag_name)
assert (
result := self._get(ENDPOINTS.FEATURE, flag_name).get(flag_name)
) is not None
return result
except (AssertionError, OSError):
self._log.error(
"Encountered an error reading from the config service.", exc_info=True
Expand All @@ -79,7 +71,10 @@ def best_effort_get_all_feature_flags(self) -> dict[str, bool]:
"""Get all flags, returns an empty dict if there are no flags, or if there
is a connection error - in the latter case logs to error."""
try:
return self._get(ENDPOINTS.FEATURE, options={"get_values": "true"})
assert (
result := self._get(ENDPOINTS.FEATURE, options={"get_values": "true"})
) is not None
return result
except (AssertionError, OSError):
self._log.error(
"Encountered an error reading from the config service.", exc_info=True
Expand Down
54 changes: 54 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import pytest
import requests

from daq_config_server.client import ConfigServer

SERVER_ADDRESS = "https://daq-config.diamond.ac.uk/api"
USE_PANDA_FLAG = "use_panda_for_gridscan"


@pytest.fixture
def server():
return ConfigServer(SERVER_ADDRESS)


@pytest.mark.uses_live_server
class TestConfigServerClient:
def test_fetch_one_flag_from_server(self, server: ConfigServer):
assert isinstance(server.get_feature_flag(USE_PANDA_FLAG), bool)

def test_fetch_all_flags_from_server(self, server: ConfigServer):
assert isinstance(flags := server.get_feature_flag_list(), list)
assert all(isinstance(server.get_feature_flag(flag), bool) for flag in flags)

def test_best_effort_gets_real_flag(self, server: ConfigServer):
assert isinstance(current := server.get_feature_flag(USE_PANDA_FLAG), bool)
assert (
server.best_effort_get_feature_flag(USE_PANDA_FLAG, not current) is current
)

@pytest.mark.skip(reason="ONLY RUN THIS MANUALLY")
def test_fetch_and_set_flags(self, server: ConfigServer):
"""ONLY RUN THIS IF YOU ARE SURE NOTHING IS RUNNING USING THE SERVICE!!!"""
flag = USE_PANDA_FLAG
assert isinstance(initial_value := server.get_feature_flag(flag), bool)
r = requests.put(
SERVER_ADDRESS
+ f"/featureflag/{flag}?value={str(not initial_value).lower()}"
)
assert r.json()["success"] is True
assert server.get_feature_flag(flag) is not initial_value
r = requests.put(
SERVER_ADDRESS + f"/featureflag/{flag}?value={str(initial_value).lower()}"
)
assert r.json()["success"] is True
assert server.get_feature_flag(flag) is initial_value

def test_get_some_beamline_params(self, server: ConfigServer):
params_list = [
"miniap_x_ROBOT_LOAD",
"miniap_y_ROBOT_LOAD",
"miniap_z_ROBOT_LOAD",
]
params = server.get_some_beamline_params(params_list)
assert all(p in params.keys() for p in params_list)