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(version-check): Added check to periodically check for new releases on GitHub and inform when found #111

Merged
merged 7 commits into from
Dec 23, 2022
Merged
36 changes: 18 additions & 18 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ shellingham = "^1.5.0"
[tool.poetry.group.dev.dependencies]
pytest = "^7.2.0"
black = {extras = ["d"], version = "^22.10.0"}
ruff = "^0.0.150"
ruff = "^0.0.191"
pip-audit = "^2.4.7"
approvaltests = "^7.2.0"
pytest-mock = "^3.10.0"
Expand All @@ -37,7 +37,7 @@ algokit = "algokit.cli:algokit"

[tool.ruff]
line-length = 120
select = ["E", "F", "ANN", "U", "N", "C", "B", "A", "YTT", "M", "W", "FBT", "Q", "RUF", "I"]
select = ["E", "F", "ANN", "UP", "N", "C", "B", "A", "YTT", "W", "FBT", "Q", "RUF", "I"]
# note: remove A003 once flake8-builtins: builtins-ignorelist supported and can ignore id
ignore = ["ANN101", "C901", "A003"]
# Exclude a variety of commonly ignored directories.
Expand Down Expand Up @@ -79,7 +79,8 @@ line-length = 120
pythonpath = ["src", "tests"]

[tool.mypy]
files = "src/"
files = ["src"]
exclude = ["dist", "tests"]
python_version = "3.10"
warn_unused_ignores = true
warn_redundant_casts = true
Expand Down
10 changes: 8 additions & 2 deletions src/algokit/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import click

from algokit.cli.bootstrap import bootstrap_group
from algokit.cli.completions import completions_group
from algokit.cli.config import config_group
from algokit.cli.doctor import doctor_command
from algokit.cli.goal import goal_command
from algokit.cli.init import init_command
from algokit.cli.sandbox import sandbox_group
from algokit.core.conf import PACKAGE_NAME
from algokit.core.log_handlers import color_option, verbose_option
from algokit.core.version_prompt import do_version_prompt, skip_version_check_option


@click.group(
Expand All @@ -16,12 +19,15 @@
@click.version_option(package_name=PACKAGE_NAME)
@verbose_option
@color_option
def algokit() -> None:
pass
@skip_version_check_option
def algokit(*, skip_version_check: bool) -> None:
if not skip_version_check:
do_version_prompt()


algokit.add_command(bootstrap_group)
algokit.add_command(completions_group)
algokit.add_command(config_group)
algokit.add_command(doctor_command)
algokit.add_command(goal_command)
algokit.add_command(init_command)
Expand Down
1 change: 1 addition & 0 deletions src/algokit/cli/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from pathlib import Path

import click

from algokit.core.bootstrap import bootstrap_any_including_subdirs, bootstrap_env, bootstrap_poetry
from algokit.core.questionary_extensions import _get_confirm_default_yes_prompt

Expand Down
1 change: 1 addition & 0 deletions src/algokit/cli/completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import click
import click.shell_completion
import shellingham # type: ignore

from algokit.core.atomic_write import atomic_write
from algokit.core.conf import get_app_config_dir

Expand Down
11 changes: 11 additions & 0 deletions src/algokit/cli/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import click

from algokit.core.version_prompt import version_prompt_configuration_command


@click.group("config", short_help="Configure AlgoKit options")
def config_group() -> None:
pass


config_group.add_command(version_prompt_configuration_command)
28 changes: 23 additions & 5 deletions src/algokit/cli/doctor.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
import datetime as dt
import importlib.metadata
import logging
import platform
import sys

import click
import pyclip # type: ignore
from algokit.core.conf import PACKAGE_NAME

from algokit.core.conf import get_current_package_version
from algokit.core.doctor import DoctorResult, check_dependency
from algokit.core.sandbox import (
DOCKER_COMPOSE_MINIMUM_VERSION,
DOCKER_COMPOSE_VERSION_COMMAND,
parse_docker_compose_version_output,
)
from algokit.core.version_prompt import get_latest_github_version

logger = logging.getLogger(__name__)

WARNING_COLOR = "yellow"
CRITICAL_COLOR = "red"


@click.command(
"doctor",
Expand All @@ -36,7 +40,7 @@ def doctor_command(*, copy_to_clipboard: bool) -> None:
is_windows = os_type == "Windows"
service_outputs = {
"timestamp": DoctorResult(ok=True, output=dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat()),
"AlgoKit": DoctorResult(ok=True, output=importlib.metadata.version(PACKAGE_NAME)),
"AlgoKit": _get_algokit_version_output(),
"AlgoKit Python": DoctorResult(ok=True, output=f"{sys.version} (location: {sys.prefix})"),
"OS": DoctorResult(ok=True, output=platform.platform()),
"docker": check_dependency(
Expand Down Expand Up @@ -103,7 +107,7 @@ def doctor_command(*, copy_to_clipboard: bool) -> None:
if value.ok:
color = None
else:
color = "red" if key in critical_services else "yellow"
color = CRITICAL_COLOR if key in critical_services else WARNING_COLOR
msg = click.style(f"{key}: ", bold=True) + click.style(value.output, fg=color)
for ln in value.extra_help or []:
msg += f"\n {ln}"
Expand All @@ -112,7 +116,7 @@ def doctor_command(*, copy_to_clipboard: bool) -> None:
# print end message anyway
logger.info(
"\n"
"If you are experiencing a problem with algokit, feel free to submit an issue via:\n"
"If you are experiencing a problem with AlgoKit, feel free to submit an issue via:\n"
"https://github.com/algorandfoundation/algokit-cli/issues/new\n"
"Please include this output, if you want to populate this message in your clipboard, run `algokit doctor -c`"
)
Expand All @@ -127,3 +131,17 @@ def doctor_command(*, copy_to_clipboard: bool) -> None:

if any(not value.ok for value in service_outputs.values()):
raise click.exceptions.Exit(code=1)


def _get_algokit_version_output() -> DoctorResult:
current = get_current_package_version()
try:
latest = get_latest_github_version()
except Exception as ex:
logger.warning("Failed to check latest AlgoKit release version", exc_info=ex)
latest = None
if latest is None or current == latest:
output = current
else:
output = click.style(current, fg=WARNING_COLOR) + f" (latest: {latest})"
return DoctorResult(ok=True, output=output)
1 change: 1 addition & 0 deletions src/algokit/cli/goal.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging

import click

from algokit.core import proc

logger = logging.getLogger(__name__)
Expand Down
1 change: 1 addition & 0 deletions src/algokit/cli/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import copier.vcs # type: ignore
import prompt_toolkit.document
import questionary

from algokit.core import proc
from algokit.core.click_extensions import DeferredChoice
from algokit.core.log_handlers import EXTRA_EXCLUDE_FROM_CONSOLE
Expand Down
1 change: 1 addition & 0 deletions src/algokit/cli/sandbox.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging

import click

from algokit.cli.goal import goal_command
from algokit.core import proc
from algokit.core.sandbox import (
Expand Down
1 change: 1 addition & 0 deletions src/algokit/core/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import Callable, Iterator

import click

from algokit.core import proc

ENV_TEMPLATE = ".env.template"
Expand Down
5 changes: 5 additions & 0 deletions src/algokit/core/conf.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
import platform
from importlib import metadata
from pathlib import Path

PACKAGE_NAME = "algokit"
Expand Down Expand Up @@ -36,3 +37,7 @@ def _get_relative_app_path(base_dir: str) -> Path:
result = path / PACKAGE_NAME
result.mkdir(parents=True, exist_ok=True)
return result


def get_current_package_version() -> str:
return metadata.version(PACKAGE_NAME)
1 change: 1 addition & 0 deletions src/algokit/core/proc.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from subprocess import run as subprocess_run

import click

from algokit.core.log_handlers import EXTRA_EXCLUDE_FROM_CONSOLE

logger = logging.getLogger(__name__)
Expand Down
1 change: 1 addition & 0 deletions src/algokit/core/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import Any, cast

import httpx

from algokit.core.conf import get_app_config_dir
from algokit.core.proc import RunResult, run

Expand Down
106 changes: 106 additions & 0 deletions src/algokit/core/version_prompt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import logging
import os
import re
from datetime import timedelta
from time import time

import click
import httpx

from algokit.core.conf import get_app_config_dir, get_app_state_dir, get_current_package_version

logger = logging.getLogger(__name__)

LATEST_URL = "https://api.github.com/repos/algorandfoundation/algokit-cli/releases/latest"
VERSION_CHECK_INTERVAL = timedelta(weeks=1).total_seconds()
DISABLE_CHECK_MARKER = "disable-version-prompt"


def do_version_prompt() -> None:
if _skip_version_prompt():
logger.debug("Version prompt disabled")
return

current_version = get_current_package_version()
latest_version = get_latest_version_or_cached()
if latest_version is None:
logger.debug("Could not determine latest version")
return

if current_version != latest_version:
logger.info(f"You are using AlgoKit version {current_version}, however version {latest_version} is available.")
else:
logger.debug("Current version is up to date")


def get_latest_version_or_cached() -> str | None:
version_check_path = get_app_state_dir() / "last-version-check"

try:
last_checked = os.path.getmtime(version_check_path)
version = version_check_path.read_text(encoding="utf-8")
except IOError:
logger.debug(f"{version_check_path} inaccessible")
last_checked = 0
version = None
else:
logger.debug(f"{version} found in cache {version_check_path}")

if (time() - last_checked) > VERSION_CHECK_INTERVAL:
try:
version = get_latest_github_version()
except Exception as ex:
logger.debug("Checking for latest version failed", exc_info=ex)
# update last checked time even if check failed
version_check_path.touch()
else:
version_check_path.write_text(version, encoding="utf-8")
# handle case where the first check failed, so we have an empty file
return version or None


def get_latest_github_version() -> str:
headers = {"ACCEPT": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}
# TODO: remove GH_TOKEN auth once algokit repo is public
if gh_token := os.getenv("GH_TOKEN"):
headers["Authorization"] = f"Bearer {gh_token}"

response = httpx.get(LATEST_URL, headers=headers)
response.raise_for_status()

json = response.json()
tag_name = json["tag_name"]
logger.debug(f"Latest version tag: {tag_name}")
match = re.match(r"v(\d+\.\d+\.\d+)", tag_name)
if not match:
raise ValueError(f"Unable to extract version from tag_name: {tag_name}")
return match.group(1)


def _skip_version_prompt() -> bool:
disable_marker = get_app_config_dir() / DISABLE_CHECK_MARKER
return disable_marker.exists()


skip_version_check_option = click.option(
"--skip-version-check",
is_flag=True,
show_default=False,
default=False,
help="Skip version checking and prompting.",
)


@click.command("version-prompt", short_help="Enables or disables version prompt")
@click.argument("enable", required=False, type=bool, default=None)
def version_prompt_configuration_command(*, enable: bool | None) -> None:
if enable is None:
logger.info(str(not _skip_version_prompt()))
else:
disable_marker = get_app_config_dir() / DISABLE_CHECK_MARKER
if enable:
disable_marker.unlink(missing_ok=True)
logger.info("📡 Resuming check for new versions")
else:
disable_marker.touch()
logger.info("🚫 Will stop checking for new versions")
Loading