Skip to content

Commit

Permalink
ref(update): Adding caching to checking for latest version
Browse files Browse the repository at this point in the history
  • Loading branch information
IanWoodard committed Dec 12, 2024
1 parent 0f26f0b commit 3749bdb
Show file tree
Hide file tree
Showing 7 changed files with 286 additions and 17 deletions.
14 changes: 0 additions & 14 deletions devservices/commands/check_for_update.py

This file was deleted.

2 changes: 1 addition & 1 deletion devservices/commands/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
from argparse import Namespace
from importlib import metadata

from devservices.commands.check_for_update import check_for_update
from devservices.constants import DEVSERVICES_DOWNLOAD_URL
from devservices.exceptions import BinaryInstallError
from devservices.exceptions import DevservicesUpdateError
from devservices.utils.check_for_update import check_for_update
from devservices.utils.console import Console
from devservices.utils.install_binary import install_binary

Expand Down
12 changes: 11 additions & 1 deletion devservices/constants.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import os
from datetime import timedelta

MINIMUM_DOCKER_COMPOSE_VERSION = "2.29.7"
DEVSERVICES_DIR_NAME = "devservices"
Expand All @@ -11,8 +12,8 @@
DEVSERVICES_LOCAL_DIR = os.path.expanduser("~/.local/share/sentry-devservices")
DEVSERVICES_DEPENDENCIES_CACHE_DIR = os.path.join(DEVSERVICES_CACHE_DIR, "dependencies")
DEVSERVICES_DEPENDENCIES_CACHE_DIR_KEY = "DEVSERVICES_DEPENDENCIES_CACHE_DIR"
DEVSERVICES_ORCHESTRATOR_LABEL = "orchestrator=devservices"
STATE_DB_FILE = os.path.join(DEVSERVICES_LOCAL_DIR, "state")
DEVSERVICES_ORCHESTRATOR_LABEL = "orchestrator=devservices"
DOCKER_COMPOSE_COMMAND_LENGTH = 7

DEPENDENCY_CONFIG_VERSION = "v1"
Expand All @@ -22,9 +23,18 @@
"core.sparseCheckout": "true",
}

DEVSERVICES_RELEASES_URL = (
"https://api.github.com/repos/getsentry/devservices/releases/latest"
)
DOCKER_COMPOSE_DOWNLOAD_URL = "https://github.com/docker/compose/releases/download"
DEVSERVICES_DOWNLOAD_URL = "https://github.com/getsentry/devservices/releases/download"
BINARY_PERMISSIONS = 0o755
MAX_LOG_LINES = "100"
LOGGER_NAME = "devservices"
DOCKER_NETWORK_NAME = "devservices"

# Latest Version Cache
DEVSERVICES_LATEST_VERSION_CACHE_FILE = os.path.join(
DEVSERVICES_CACHE_DIR, "latest_version.json"
)
DEVSERVICES_LATEST_VERSION_CACHE_TTL = timedelta(minutes=15)
2 changes: 1 addition & 1 deletion devservices/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@
from devservices.commands import status
from devservices.commands import up
from devservices.commands import update
from devservices.commands.check_for_update import check_for_update
from devservices.constants import LOGGER_NAME
from devservices.exceptions import DockerComposeInstallationError
from devservices.exceptions import DockerDaemonNotRunningError
from devservices.utils.check_for_update import check_for_update
from devservices.utils.console import Console
from devservices.utils.docker_compose import check_docker_compose_version

Expand Down
75 changes: 75 additions & 0 deletions devservices/utils/check_for_update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from __future__ import annotations

import json
import os
from datetime import datetime
from urllib.request import urlopen

from devservices.constants import DEVSERVICES_CACHE_DIR
from devservices.constants import DEVSERVICES_LATEST_VERSION_CACHE_FILE
from devservices.constants import DEVSERVICES_LATEST_VERSION_CACHE_TTL
from devservices.constants import DEVSERVICES_RELEASES_URL


def _delete_cached_version() -> None:
os.remove(DEVSERVICES_LATEST_VERSION_CACHE_FILE)


def _get_cached_version() -> str | None:
if not os.path.exists(DEVSERVICES_LATEST_VERSION_CACHE_FILE):
return None
try:
with open(DEVSERVICES_LATEST_VERSION_CACHE_FILE, "r", encoding="utf-8") as f:
cached_data = json.load(f)
except (OSError, json.JSONDecodeError):
_delete_cached_version()
return None

timestamp = cached_data["timestamp"]
cached_latest_version = cached_data["latest_version"]

try:
cached_time = datetime.fromisoformat(timestamp)
except ValueError:
_delete_cached_version()
return None

Check warning on line 35 in devservices/utils/check_for_update.py

View check run for this annotation

Codecov / codecov/patch

devservices/utils/check_for_update.py#L33-L35

Added lines #L33 - L35 were not covered by tests

if (
isinstance(cached_latest_version, str)
and datetime.now() - cached_time < DEVSERVICES_LATEST_VERSION_CACHE_TTL
):
return cached_latest_version

# If the cache file exists but is stale or has an invalid version,
# remove it.
_delete_cached_version()
return None


def _set_cached_version(latest_version: str) -> None:
with open(DEVSERVICES_LATEST_VERSION_CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(
{
"latest_version": latest_version,
"timestamp": datetime.now().isoformat(),
},
f,
)


def check_for_update() -> str | None:
os.makedirs(DEVSERVICES_CACHE_DIR, exist_ok=True)

cached_version = _get_cached_version()
if cached_version is not None:
return cached_version

with urlopen(DEVSERVICES_RELEASES_URL) as response:
if response.status == 200:
data = json.loads(response.read())
latest_version = str(data["tag_name"])

_set_cached_version(latest_version)

return latest_version
return None

Check warning on line 75 in devservices/utils/check_for_update.py

View check run for this annotation

Codecov / codecov/patch

devservices/utils/check_for_update.py#L75

Added line #L75 was not covered by tests
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ types-PyYAML==6.0.11
setuptools==70.0.0
build==0.8.0
wheel==0.42.0
freezegun==1.2.2
197 changes: 197 additions & 0 deletions tests/utils/test_check_for_update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
from __future__ import annotations

import json
from datetime import datetime
from datetime import timedelta
from pathlib import Path
from unittest import mock

from freezegun import freeze_time

from devservices.constants import DEVSERVICES_RELEASES_URL
from devservices.utils.check_for_update import check_for_update


@mock.patch("devservices.utils.check_for_update.urlopen")
def test_check_for_update_not_cached(mock_urlopen: mock.Mock, tmp_path: Path) -> None:
mock_response = mock.mock_open(read_data=b'{"tag_name": "1.0.0"}').return_value
mock_response.status = 200
mock_urlopen.side_effect = [mock_response]
with (
mock.patch(
"devservices.utils.check_for_update.DEVSERVICES_CACHE_DIR",
str(tmp_path / "cache"),
),
mock.patch(
"devservices.utils.check_for_update.DEVSERVICES_LATEST_VERSION_CACHE_FILE",
str(tmp_path / "cache" / "latest_version.json"),
),
):
assert check_for_update() == "1.0.0"
mock_urlopen.assert_called_once_with(DEVSERVICES_RELEASES_URL)


@mock.patch("devservices.utils.check_for_update.urlopen")
def test_check_for_update_cached_fresh(mock_urlopen: mock.Mock, tmp_path: Path) -> None:
cache_dir = tmp_path / "cache"
cached_file = cache_dir / "latest_version.json"
with (
freeze_time("2024-05-14 05:43:21"),
mock.patch(
"devservices.utils.check_for_update.DEVSERVICES_CACHE_DIR",
str(cache_dir),
),
mock.patch(
"devservices.utils.check_for_update.DEVSERVICES_LATEST_VERSION_CACHE_FILE",
str(cached_file),
),
):
fourteen_minutes_ago = datetime.now() - timedelta(minutes=14)
cache_dir.mkdir()
cached_file.write_text(
f"""{{
"latest_version": "1.0.0",
"timestamp": "{fourteen_minutes_ago.isoformat()}"
}}"""
)
assert check_for_update() == "1.0.0"
mock_urlopen.assert_not_called()


@mock.patch("devservices.utils.check_for_update.urlopen")
def test_check_for_update_cached_stale_without_update(
mock_urlopen: mock.Mock, tmp_path: Path
) -> None:
mock_response = mock.mock_open(read_data=b'{"tag_name": "1.0.0"}').return_value
mock_response.status = 200
mock_urlopen.side_effect = [mock_response]
cache_dir = tmp_path / "cache"
cached_file = cache_dir / "latest_version.json"
with (
freeze_time("2024-05-14 05:43:21"),
mock.patch(
"devservices.utils.check_for_update.DEVSERVICES_CACHE_DIR",
str(cache_dir),
),
mock.patch(
"devservices.utils.check_for_update.DEVSERVICES_LATEST_VERSION_CACHE_FILE",
str(cached_file),
),
):
sixteen_minutes_ago = datetime.now() - timedelta(minutes=16)
cache_dir.mkdir()
cached_file.write_text(
f"""{{
"latest_version": "1.0.0",
"timestamp": "{sixteen_minutes_ago.isoformat()}"
}}"""
)
assert check_for_update() == "1.0.0"
mock_urlopen.assert_called_once_with(DEVSERVICES_RELEASES_URL)

with cached_file.open("r") as f:
cached_data = json.load(f)
cached_data["latest_version"] = "1.0.0"
cached_data["timestamp"] = datetime.now().isoformat()


@mock.patch("devservices.utils.check_for_update.urlopen")
def test_check_for_update_cached_stale_with_update(
mock_urlopen: mock.Mock, tmp_path: Path
) -> None:
mock_response = mock.mock_open(read_data=b'{"tag_name": "1.0.1"}').return_value
mock_response.status = 200
mock_urlopen.side_effect = [mock_response]
cache_dir = tmp_path / "cache"
cached_file = cache_dir / "latest_version.json"
with (
freeze_time("2024-05-14 05:43:21"),
mock.patch(
"devservices.utils.check_for_update.DEVSERVICES_CACHE_DIR",
str(cache_dir),
),
mock.patch(
"devservices.utils.check_for_update.DEVSERVICES_LATEST_VERSION_CACHE_FILE",
str(cached_file),
),
):
sixteen_minutes_ago = datetime.now() - timedelta(minutes=16)
cache_dir.mkdir()
cached_file.write_text(
f"""{{
"latest_version": "1.0.0",
"timestamp": "{sixteen_minutes_ago.isoformat()}"
}}"""
)
assert check_for_update() == "1.0.1"
mock_urlopen.assert_called_once_with(DEVSERVICES_RELEASES_URL)

with cached_file.open("r") as f:
cached_data = json.load(f)
cached_data["latest_version"] = "1.0.1"
cached_data["timestamp"] = datetime.now().isoformat()


@mock.patch("devservices.utils.check_for_update.urlopen")
def test_check_for_update_invalid_cached_value(
mock_urlopen: mock.Mock, tmp_path: Path
) -> None:
mock_response = mock.mock_open(read_data=b'{"tag_name": "1.0.0"}').return_value
mock_response.status = 200
mock_urlopen.side_effect = [mock_response]
cache_dir = tmp_path / "cache"
cached_file = cache_dir / "latest_version.json"
with (
freeze_time("2024-05-14 05:43:21"),
mock.patch(
"devservices.utils.check_for_update.DEVSERVICES_CACHE_DIR",
str(cache_dir),
),
mock.patch(
"devservices.utils.check_for_update.DEVSERVICES_LATEST_VERSION_CACHE_FILE",
str(cached_file),
),
):
cache_dir.mkdir()
cached_file.write_text("invalid json")
assert check_for_update() == "1.0.0"
mock_urlopen.assert_called_once_with(DEVSERVICES_RELEASES_URL)

with cached_file.open("r") as f:
cached_data = json.load(f)
cached_data["latest_version"] = "1.0.0"
cached_data["timestamp"] = datetime.now().isoformat()


@mock.patch("devservices.utils.check_for_update.urlopen")
def test_check_for_update_invalid_date(mock_urlopen: mock.Mock, tmp_path: Path) -> None:
mock_response = mock.mock_open(read_data=b'{"tag_name": "1.0.0"}').return_value
mock_response.status = 200
mock_urlopen.side_effect = [mock_response]
cache_dir = tmp_path / "cache"
cached_file = cache_dir / "latest_version.json"
with (
freeze_time("2024-05-14 05:43:21"),
mock.patch(
"devservices.utils.check_for_update.DEVSERVICES_CACHE_DIR",
str(cache_dir),
),
mock.patch(
"devservices.utils.check_for_update.DEVSERVICES_LATEST_VERSION_CACHE_FILE",
str(cached_file),
),
):
cache_dir.mkdir()
cached_file.write_text(
"""{{
"latest_version": "1.0.0",
"timestamp": "invalid"
}}"""
)
assert check_for_update() == "1.0.0"
mock_urlopen.assert_called_once_with(DEVSERVICES_RELEASES_URL)

with cached_file.open("r") as f:
cached_data = json.load(f)
cached_data["latest_version"] = "1.0.0"
cached_data["timestamp"] = datetime.now().isoformat()

0 comments on commit 3749bdb

Please sign in to comment.