Skip to content

Commit

Permalink
Split out download and save to reduce delete
Browse files Browse the repository at this point in the history
  • Loading branch information
Cadair committed Jul 8, 2024
1 parent 1e8be03 commit 9c12d56
Show file tree
Hide file tree
Showing 2 changed files with 100 additions and 69 deletions.
74 changes: 40 additions & 34 deletions dkist/net/attrs_values.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import datetime as dt
import importlib.resources
from pathlib import Path
from urllib.error import URLError

import platformdirs

Expand Down Expand Up @@ -38,6 +37,12 @@
}


class UserCacheMissing(Exception):
"""
An exception for when we have deleted the user cache.
"""


def _get_file_age(path: Path) -> dt.timedelta:
last_modified = dt.datetime.fromtimestamp(path.stat().st_mtime)
now = dt.datetime.now()
Expand Down Expand Up @@ -66,15 +71,19 @@ def _get_cached_json() -> list[Path, bool]:
return return_file, update_needed


def _fetch_values_to_file(filepath: Path, *, timeout: int = 1):
def _fetch_values(timeout: int = 1) -> bytes:
"""
Make a request for new values.
This is a separate function mostly for mocking it in the tests.
"""
data = urllib.request.urlopen(
net_conf.dataset_endpoint + net_conf.dataset_search_values_path, timeout=timeout
)
with open(filepath, "wb") as f:
f.write(data.read())
return data.read()


def attempt_local_update(*, timeout: int = 1, user_file: Path = None, silence_errors: bool = True) -> bool:
def attempt_local_update(*, timeout: int = 1, user_file: Path = None, silence_net_errors: bool = True) -> bool:
"""
Attempt to update the local data copy of the values.
Expand All @@ -87,8 +96,8 @@ def attempt_local_update(*, timeout: int = 1, user_file: Path = None, silence_er
user_file
The file to save the updated attrs JSON to. If `None` platformdirs will
be used to get the user data path.
silence_errors
If `True` catch all errors in this function.
silence_net_errors
If `True` catch all errors caused by downloading new values in this function.
Returns
-------
Expand All @@ -98,42 +107,35 @@ def attempt_local_update(*, timeout: int = 1, user_file: Path = None, silence_er
if user_file is None:
user_file = platformdirs.user_data_path("dkist") / "api_search_values.json"
user_file = Path(user_file)
user_file.parent.mkdir(exist_ok=True, parents=True)
if not user_file.exists():
user_file.parent.mkdir(exist_ok=True, parents=True)

log.info(f"Fetching updated search values for the DKIST client to {user_file}")

success = False
try:
_fetch_values_to_file(user_file, timeout=timeout)
success = True
except URLError as urlerr:
# If the new file isn't downloaded just because of no internet access then keep the old one
log.warning("Unable to attempt download. Previous attrs file will be kept but may be outdated.")

return success
data = _fetch_values(timeout)
except Exception as err:
log.error("Failed to download new attrs values.")
log.error("Failed to download new dkist attrs values. attr values for dkist may be outdated.")
log.debug(str(err))
# If an error has occurred then remove the local file so it isn't
# corrupted or invalid.
user_file.unlink(missing_ok=True)
if not silence_errors:
if not silence_net_errors:
raise
return False

return success

# Test that the file we just saved can be parsed as json
try:
# Save the data
with open(user_file, "wb") as f:
f.write(data)

# Test that the file we just saved can be parsed as json
with open(user_file) as f:
json.load(f)
except Exception:
log.error("Downloaded file is not valid JSON.")
user_file.unlink(missing_ok=True)
if not silence_errors:
raise
success = False

return success
return True

except Exception as err:
log.error("Downloaded file could not be saved or is not valid JSON, removing cached file.")
user_file.unlink(missing_ok=True)
raise UserCacheMissing from err


def get_search_attrs_values(*, allow_update: bool = True, timeout: int = 1) -> dict:
Expand All @@ -158,11 +160,15 @@ def get_search_attrs_values(*, allow_update: bool = True, timeout: int = 1) -> d
"""
local_path, update_needed = _get_cached_json()
if allow_update and update_needed:
attempt_local_update(timeout=timeout)
try:
attempt_local_update(timeout=timeout)
except UserCacheMissing:
# if we have deleted the user cache we must use the file shipped with the package
local_path = importlib.resources.files(dkist.data) / "api_search_values.json"

if not update_needed:
log.debug("No update to attr values needed.")
log.debug("Using attr values from %s", local_path)
log.debug("No update to dkist attr values needed.")
log.debug("Using dkist attr values from %s", local_path)

with open(local_path) as f:
search_values = json.load(f)
Expand Down
95 changes: 60 additions & 35 deletions dkist/net/tests/test_attrs_values.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
import datetime
import importlib
from platform import system
from urllib.error import URLError

import platformdirs
import pytest

from sunpy.net import attrs as a

import dkist.data
from dkist.net.attrs_values import (_fetch_values_to_file, _get_cached_json,
from dkist.net.attrs_values import (UserCacheMissing, _fetch_values, _get_cached_json,
attempt_local_update, get_search_attrs_values)

PACKAGE_FILE = importlib.resources.files(dkist.data) / "api_search_values.json"
Expand Down Expand Up @@ -79,31 +80,25 @@ def test_get_cached_json_local_not_quite_out_of_date(tmp_homedir, values_in_home


@pytest.mark.remote_data
def test_fetch_values_to_file(tmp_path):
json_file = tmp_path / "api_search_values.json"

assert json_file.exists() is False
_fetch_values_to_file(json_file)
assert json_file.exists() is True
def test_fetch_values_to_file():
data = _fetch_values()
assert isinstance(data, bytes)

# Check we can load the file as json and it looks very roughly like what we
# would expect from the API response
with open(json_file) as f:
data = json.load(f)
assert "parameterValues" in data.keys()
assert isinstance(data["parameterValues"], list)
jdata = json.loads(data)
assert "parameterValues" in jdata.keys()
assert isinstance(jdata["parameterValues"], list)


def _local_fetch_values(user_file, *, timeout):
user_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(PACKAGE_FILE, user_file)
def _local_fetch_values(timeout):
with open(PACKAGE_FILE, "rb") as fobj:
return fobj.read()


def test_attempt_local_update(mocker, tmp_path, caplog_dkist):
json_file = tmp_path / "api_search_values.json"
mocker.patch("dkist.net.attrs_values._fetch_values_to_file",
mocker.patch("dkist.net.attrs_values._fetch_values",
new_callable=lambda: _local_fetch_values)
success = attempt_local_update(user_file=json_file, silence_errors=False)
success = attempt_local_update(user_file=json_file, silence_net_errors=False)
assert success

assert caplog_dkist.record_tuples == [
Expand All @@ -116,39 +111,50 @@ def raise_error(*args, **kwargs):


def test_attempt_local_update_error_download(mocker, caplog_dkist, tmp_homedir, user_file):
mocker.patch("dkist.net.attrs_values._fetch_values_to_file",
mocker.patch("dkist.net.attrs_values._fetch_values",
side_effect=raise_error)
success = attempt_local_update(silence_errors=True)
success = attempt_local_update(silence_net_errors=True)
assert not success

assert caplog_dkist.record_tuples == [
("dkist", logging.INFO, f"Fetching updated search values for the DKIST client to {user_file}"),
("dkist", logging.ERROR, "Failed to download new attrs values."),
("dkist", logging.ERROR, "Failed to download new dkist attrs values. attr values for dkist may be outdated."),
]

with pytest.raises(ValueError, match="This is a value error"):
success = attempt_local_update(silence_errors=False)
success = attempt_local_update(silence_net_errors=False)


def _definately_not_json(user_file, *, timeout):
with open(user_file, "w") as f:
f.write("This is not json")
def _definately_not_json(timeout):
return b"alskdjalskdjaslkdj!!"


def test_attempt_local_update_fail_invalid_download(mocker, tmp_path, caplog_dkist):
def test_attempt_local_update_fail_invalid_json(mocker, user_file, tmp_path, caplog_dkist):
# test that the file is removed after
json_file = tmp_path / "api_search_values.json"
mocker.patch("dkist.net.attrs_values._fetch_values_to_file",
mocker.patch("dkist.net.attrs_values._fetch_values",
new_callable=lambda: _definately_not_json)
success = attempt_local_update(user_file=json_file, silence_errors=True)
assert not success
with pytest.raises(UserCacheMissing):
success = attempt_local_update(user_file=json_file)

assert caplog_dkist.record_tuples == [
("dkist", logging.INFO, f"Fetching updated search values for the DKIST client to {json_file}"),
("dkist", logging.ERROR, "Downloaded file is not valid JSON."),
]
assert json_file.exists()


def test_get_search_attrs_values_fail_invalid_download(mocker, user_file, values_in_home, tmp_path, caplog_dkist):
"""
Given: An existing cache file
When: JSON is invalid
Then: File is removed, and attr values are still loaded
"""
mocker.patch("dkist.net.attrs_values._fetch_values",
new_callable=lambda: _definately_not_json)
ten_ago = (datetime.datetime.now() - datetime.timedelta(days=10)).timestamp()
os.utime(user_file, (ten_ago, ten_ago))

attr_values = get_search_attrs_values()
assert not user_file.exists()

with pytest.raises(json.JSONDecodeError):
success = attempt_local_update(user_file=json_file, silence_errors=False)
assert {a.Instrument, a.dkist.HeaderVersion, a.dkist.WorkflowName}.issubset(attr_values.keys())


@pytest.mark.parametrize(("user_file", "update_needed", "allow_update", "should_update"), [
Expand All @@ -172,3 +178,22 @@ def test_get_search_attrs_values(mocker, caplog_dkist, values_in_home, user_file
assert isinstance(attr_values, dict)
# Test that some known attrs are in the result
assert {a.Instrument, a.dkist.HeaderVersion, a.dkist.WorkflowName}.issubset(attr_values.keys())


def _fetch_values_urlerror(*args):
raise URLError("it hates you")


def test_failed_download(mocker, caplog_dkist, user_file, values_in_home):
mock = mocker.patch("dkist.net.attrs_values._fetch_values",
new_callable=lambda: _fetch_values_urlerror)

ten_ago = (datetime.datetime.now() - datetime.timedelta(days=10)).timestamp()
os.utime(user_file, (ten_ago, ten_ago))

attr_values = get_search_attrs_values(allow_update=True)

assert caplog_dkist.record_tuples == [
("dkist", logging.INFO, f"Fetching updated search values for the DKIST client to {user_file}"),
("dkist", logging.ERROR, "Failed to download new dkist attrs values. attr values for dkist may be outdated."),
]

0 comments on commit 9c12d56

Please sign in to comment.