Skip to content

Commit

Permalink
ref(update): Adding caching to checking for latest version (#179)
Browse files Browse the repository at this point in the history
* ref(update): Adding caching to checking for latest version

* Adding more test coverage

* Switching to a simpler caching approach

* Using constant in test
  • Loading branch information
IanWoodard authored Dec 13, 2024
1 parent 1a86f18 commit 2af25a6
Show file tree
Hide file tree
Showing 7 changed files with 243 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.txt"
)
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
59 changes: 59 additions & 0 deletions devservices/utils/check_for_update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from __future__ import annotations

import json
import os
from datetime import datetime
from datetime import timedelta
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:
if os.path.exists(DEVSERVICES_LATEST_VERSION_CACHE_FILE):
os.remove(DEVSERVICES_LATEST_VERSION_CACHE_FILE)


def _get_cache_age() -> timedelta:
if os.path.exists(DEVSERVICES_LATEST_VERSION_CACHE_FILE):
file_modification_time = datetime.fromtimestamp(
os.path.getmtime(DEVSERVICES_LATEST_VERSION_CACHE_FILE)
)
return datetime.now() - file_modification_time
return timedelta.max


def _get_cached_version() -> str | None:
cache_age = _get_cache_age()
if cache_age < DEVSERVICES_LATEST_VERSION_CACHE_TTL:
with open(DEVSERVICES_LATEST_VERSION_CACHE_FILE, "r", encoding="utf-8") as f:
return f.read()
else:
_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:
f.write(latest_version)


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
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
170 changes: 170 additions & 0 deletions tests/utils/test_check_for_update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
from __future__ import annotations

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_LATEST_VERSION_CACHE_TTL
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]
cache_dir = tmp_path / "cache"
cached_file = cache_dir / "latest_version.txt"
with (
freeze_time("2024-05-14 05:43:21"),
mock.patch(
"devservices.utils.check_for_update.os.path.getmtime",
return_value=datetime.now().timestamp(),
),
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),
),
):
assert check_for_update() == "1.0.0"
mock_urlopen.assert_called_once_with(DEVSERVICES_RELEASES_URL)

with cached_file.open("r") as f:
cached_version = f.read()
assert cached_version == "1.0.0"


@mock.patch("devservices.utils.check_for_update.urlopen")
def test_check_for_update_no_cache_not_ok(
mock_urlopen: mock.Mock, tmp_path: Path
) -> None:
mock_response = mock.mock_open().return_value
mock_response.status = 500
mock_urlopen.side_effect = [mock_response]
cache_dir = tmp_path / "cache"
cached_file = cache_dir / "latest_version.txt"
with (
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),
),
):
assert check_for_update() is None
mock_urlopen.assert_called_once_with(DEVSERVICES_RELEASES_URL)

assert not cached_file.exists()


@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.txt"
with (
freeze_time("2024-05-14 05:43:21"),
mock.patch(
"devservices.utils.check_for_update.os.path.getmtime",
return_value=(
datetime.now()
- DEVSERVICES_LATEST_VERSION_CACHE_TTL
+ timedelta(minutes=1)
).timestamp(),
),
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("1.0.0")
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.txt"
with (
freeze_time("2024-05-14 05:43:21"),
mock.patch(
"devservices.utils.check_for_update.os.path.getmtime",
return_value=(
datetime.now() - DEVSERVICES_LATEST_VERSION_CACHE_TTL
).timestamp(),
),
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("1.0.0")
assert check_for_update() == "1.0.0"
mock_urlopen.assert_called_once_with(DEVSERVICES_RELEASES_URL)

with cached_file.open("r") as f:
cached_version = f.read()
assert cached_version == "1.0.0"


@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.txt"
with (
freeze_time("2024-05-14 05:43:21"),
mock.patch(
"devservices.utils.check_for_update.os.path.getmtime",
return_value=(
datetime.now()
- DEVSERVICES_LATEST_VERSION_CACHE_TTL
- timedelta(minutes=1)
).timestamp(),
),
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("1.0.0")
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 = f.read()
assert cached_data == "1.0.1"

0 comments on commit 2af25a6

Please sign in to comment.