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

feat(commands): Add update command #75

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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: 14 additions & 0 deletions devservices/commands/check_for_update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from __future__ import annotations

import json
from urllib.request import urlopen


def check_for_update(current_version: str) -> str | None:
url = "https://api.github.com/repos/getsentry/devservices/releases/latest"
with urlopen(url) as response:
if response.status == 200:
data = json.loads(response.read().decode("utf-8"))
latest_version = str(data["tag_name"])
return latest_version
return None

Check warning on line 14 in devservices/commands/check_for_update.py

View check run for this annotation

Codecov / codecov/patch

devservices/commands/check_for_update.py#L8-L14

Added lines #L8 - L14 were not covered by tests
62 changes: 62 additions & 0 deletions devservices/commands/update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from __future__ import annotations

import platform
import sys
from argparse import _SubParsersAction
from argparse import ArgumentParser
from argparse import Namespace
from importlib import metadata

from devservices.commands.check_for_update import check_for_update
from devservices.exceptions import DevservicesUpdateError
from devservices.utils.install_binary import install_binary


def is_in_virtualenv() -> bool:
return hasattr(sys, "real_prefix") or (

Check warning on line 16 in devservices/commands/update.py

View check run for this annotation

Codecov / codecov/patch

devservices/commands/update.py#L16

Added line #L16 was not covered by tests
hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix
)


def update_version(exec_path: str, latest_version: str) -> None:
system = platform.system().lower()
url = f"https://github.com/getsentry/devservices/releases/download/{latest_version}/devservices-{system}"
install_binary(
"devservices", exec_path, latest_version, url, DevservicesUpdateError
)
print(f"Devservices {latest_version} updated successfully")


def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
parser = subparsers.add_parser(

Check warning on line 31 in devservices/commands/update.py

View check run for this annotation

Codecov / codecov/patch

devservices/commands/update.py#L31

Added line #L31 was not covered by tests
"update", help="update devservices to the latest version"
)
parser.set_defaults(func=update)

Check warning on line 34 in devservices/commands/update.py

View check run for this annotation

Codecov / codecov/patch

devservices/commands/update.py#L34

Added line #L34 was not covered by tests


def update(args: Namespace) -> None:
current_version = metadata.version("devservices")
latest_version = check_for_update(current_version)

if latest_version is None:
raise DevservicesUpdateError("Failed to check for updates.")

if latest_version == current_version:
print("You're already on the latest version.")
return

print(f"A new version of devservices is available: {latest_version}")

if is_in_virtualenv():
print("You are running in a virtual environment.")
print(
"To update, please update your requirements.txt or requirements-dev.txt file with the new version."
)
print(
f"For example, update the line in requirements.txt to: devservices=={latest_version}"
)
print("Then, run: pip install --update -r requirements.txt")
return

print("Upgrading to the latest version...")
update_version(sys.executable, latest_version)
6 changes: 6 additions & 0 deletions devservices/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ class ConfigParseError(ConfigError):
pass


class DevservicesUpdateError(Exception):
"""Raised when the devservices update fails."""

pass


class DockerComposeInstallationError(Exception):
"""Raised when the Docker Compose installation fails."""

Expand Down
11 changes: 11 additions & 0 deletions devservices/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
from devservices.commands import start
from devservices.commands import status
from devservices.commands import stop
from devservices.commands import update
from devservices.commands.check_for_update import check_for_update
from devservices.utils.docker_compose import check_docker_compose_version

sentry_environment = (
Expand Down Expand Up @@ -58,6 +60,7 @@ def main() -> None:
list_services.add_parser(subparsers)
status.add_parser(subparsers)
logs.add_parser(subparsers)
update.add_parser(subparsers)

args = parser.parse_args()

Expand All @@ -68,6 +71,14 @@ def main() -> None:
else:
parser.print_help()

if args.command != "update":
Copy link
Member Author

Choose a reason for hiding this comment

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

I can see a major downside to this:

If user is not connected to internet, this will error out and cause spam. I think it is ok to leave it as is and put a workaround in later though

Copy link
Member Author

Choose a reason for hiding this comment

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

Ultimately, it is far more useful to notify users to update if available

newest_version = check_for_update(metadata.version("devservices"))
if newest_version != metadata.version("devservices"):
print(
f"\n\033[93mWARNING: A new version of devservices is available: {newest_version}\033[0m"
)
print("To update, run: \033[1mdevservices update\033[0m")


if __name__ == "__main__":
main()
69 changes: 17 additions & 52 deletions devservices/utils/docker_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,8 @@
import os
import platform
import re
import shutil
import subprocess
import tempfile
import time
from typing import cast
from urllib.request import urlretrieve

from packaging import version

Expand All @@ -22,6 +18,7 @@
from devservices.exceptions import DockerComposeInstallationError
from devservices.utils.dependencies import install_dependencies
from devservices.utils.dependencies import verify_local_dependencies
from devservices.utils.install_binary import install_binary
from devservices.utils.services import Service


Expand Down Expand Up @@ -60,62 +57,30 @@ def install_docker_compose() -> None:
if not arch:
raise DockerComposeInstallationError(f"Unsupported architecture: {machine}")

binary_name = "docker-compose"

# Determine the download URL based on the platform
if system == "Linux":
binary_name = f"docker-compose-linux-{arch}"
binary_name_with_extension = f"docker-compose-linux-{arch}"
elif system == "Darwin":
binary_name = f"docker-compose-darwin-{arch}"
binary_name_with_extension = f"docker-compose-darwin-{arch}"
else:
raise DockerComposeInstallationError(f"Unsupported operating system: {system}")

url = f"https://github.com/docker/compose/releases/download/v{MINIMUM_DOCKER_COMPOSE_VERSION}/{binary_name}"

# Create a temporary directory
with tempfile.TemporaryDirectory() as temp_dir:
temp_file = os.path.join(temp_dir, "docker-compose")
url = f"https://github.com/docker/compose/releases/download/v{MINIMUM_DOCKER_COMPOSE_VERSION}/{binary_name_with_extension}"
destination = os.path.join(DOCKER_USER_PLUGIN_DIR, binary_name)

# Download the Docker Compose binary with retries
max_retries = 3
retry_delay_seconds = 1
print(
f"Downloading Docker Compose {MINIMUM_DOCKER_COMPOSE_VERSION} from {url}..."
)
for attempt in range(max_retries):
try:
urlretrieve(url, temp_file)
break
except Exception as e:
if attempt < max_retries - 1:
print(
f"Download failed. Retrying in {retry_delay_seconds} seconds... (Attempt {attempt + 1}/{max_retries})"
)
time.sleep(retry_delay_seconds)
else:
raise DockerComposeInstallationError(
f"Failed to download Docker Compose after {max_retries} attempts: {e}"
)

# Make the binary executable
try:
os.chmod(temp_file, 0o755)
except Exception as e:
raise DockerComposeInstallationError(
f"Failed to set executable permissions: {e}"
)

destination = os.path.join(DOCKER_USER_PLUGIN_DIR, "docker-compose")
os.makedirs(DOCKER_USER_PLUGIN_DIR, exist_ok=True)

try:
shutil.move(temp_file, destination)
except Exception as e:
raise DockerComposeInstallationError(
f"Failed to move Docker Compose binary to {destination}: {e}"
)
install_binary(
binary_name,
destination,
MINIMUM_DOCKER_COMPOSE_VERSION,
url,
DockerComposeInstallationError,
)

print(
f"Docker Compose {MINIMUM_DOCKER_COMPOSE_VERSION} installed successfully to {destination}"
)
print(
f"Docker Compose {MINIMUM_DOCKER_COMPOSE_VERSION} installed successfully to {destination}"
)

# Verify the installation
try:
Expand Down
50 changes: 50 additions & 0 deletions devservices/utils/install_binary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from __future__ import annotations

import os
import shutil
import tempfile
import time
from urllib.request import urlretrieve


def install_binary(
binary_name: str,
exec_path: str,
version: str,
url: str,
exception_type: type[Exception],
) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
temp_file = os.path.join(temp_dir, binary_name)

# Download the binary with retries
max_retries = 3
retry_delay_seconds = 1
print(f"Downloading {binary_name} {version} from {url}...")
for attempt in range(max_retries):
try:
urlretrieve(url, temp_file)
break
except Exception as e:
if attempt < max_retries - 1:
print(
f"Download failed. Retrying in {retry_delay_seconds} seconds... (Attempt {attempt + 1}/{max_retries - 1})"
)
time.sleep(retry_delay_seconds)
else:
raise exception_type(
f"Failed to download {binary_name} after {max_retries} attempts: {e}"
)

# Make the binary executable
try:
os.chmod(temp_file, 0o755)
except Exception as e:
raise exception_type(f"Failed to set executable permissions: {e}")

try:
shutil.move(temp_file, exec_path)
except Exception as e:
raise exception_type(
f"Failed to move {binary_name} binary to {exec_path}: {e}"
)
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
--index-url https://pypi.devinfra.sentry.io/simple
pyyaml==6.0.1
packaging==24.0
sentry-devenv==1.8.0
sentry-sdk==2.14.0
80 changes: 80 additions & 0 deletions tests/commands/test_update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from __future__ import annotations

from argparse import Namespace
from unittest import mock

import pytest

from devservices.commands.update import update
from devservices.exceptions import DevservicesUpdateError


@mock.patch("devservices.commands.update.metadata.version", return_value="0.0.1")
@mock.patch("devservices.commands.update.check_for_update", return_value="1.0.0")
@mock.patch("devservices.commands.update.is_in_virtualenv", return_value=True)
def test_update_in_virtualenv(
mock_metadata_version: mock.Mock,
mock_check_for_update: mock.Mock,
mock_is_in_virtualenv: mock.Mock,
capsys: pytest.CaptureFixture[str],
) -> None:
update(Namespace())
captured = capsys.readouterr()
assert "You are running in a virtual environment." in captured.out
assert (
"To update, please update your requirements.txt or requirements-dev.txt file with the new version."
in captured.out
)
assert (
"For example, update the line in requirements.txt to: devservices==1.0.0"
in captured.out
)
assert "Then, run: pip install --update -r requirements.txt" in captured.out


@mock.patch("devservices.commands.update.metadata.version", return_value="0.0.1")
@mock.patch("devservices.commands.update.check_for_update", return_value=None)
@mock.patch("devservices.commands.update.is_in_virtualenv", return_value=False)
@mock.patch("devservices.commands.update.install_binary")
def test_update_check_for_update_error(
mock_metadata_version: mock.Mock,
mock_check_for_update: mock.Mock,
mock_is_in_virtualenv: mock.Mock,
mock_install_binary: mock.Mock,
capsys: pytest.CaptureFixture[str],
) -> None:
with pytest.raises(DevservicesUpdateError, match="Failed to check for updates."):
update(Namespace())


@mock.patch("devservices.commands.update.metadata.version", return_value="1.0.0")
@mock.patch("devservices.commands.update.check_for_update", return_value="1.0.0")
@mock.patch("devservices.commands.update.is_in_virtualenv", return_value=False)
@mock.patch("devservices.commands.update.install_binary")
def test_update_already_on_latest_version(
mock_metadata_version: mock.Mock,
mock_check_for_update: mock.Mock,
mock_is_in_virtualenv: mock.Mock,
mock_install_binary: mock.Mock,
capsys: pytest.CaptureFixture[str],
) -> None:
update(Namespace())
captured = capsys.readouterr()
assert "You're already on the latest version." in captured.out


@mock.patch("devservices.commands.update.metadata.version", return_value="0.0.1")
@mock.patch("devservices.commands.update.check_for_update", return_value="1.0.0")
@mock.patch("devservices.commands.update.is_in_virtualenv", return_value=False)
@mock.patch("devservices.commands.update.install_binary")
def test_update_success(
mock_metadata_version: mock.Mock,
mock_check_for_update: mock.Mock,
mock_is_in_virtualenv: mock.Mock,
mock_install_binary: mock.Mock,
capsys: pytest.CaptureFixture[str],
) -> None:
update(Namespace())
captured = capsys.readouterr()
assert "A new version of devservices is available: 1.0.0" in captured.out
assert "Devservices 1.0.0 updated successfully" in captured.out
Loading