Skip to content

ref(update): Adding caching to checking for latest version #179

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

Merged
merged 4 commits into from
Dec 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
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"
Loading