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

PyPI version check #475

Merged
merged 23 commits into from
Feb 8, 2019
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
1 change: 1 addition & 0 deletions python/CHANGELOG.D/308.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Check the lastest PyPI neuromation release, suggest to upgrade if PyPI has a newer version.
1 change: 1 addition & 0 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Name | Description|
|_\-v, --verbose_|Enable verbose mode|
|_\--show-traceback_|Show python traceback on error, useful for debugging the tool.|
|_--color \[yes|no|auto]_|Color mode|
|_\--disable-pypi-version-check_|Don't periodically check PyPI to determine whether a new version of Neuromation CLI is available for download.|
|_--version_|Show the version and exit.|
|_--help_|Show this message and exit.|

Expand Down
16 changes: 15 additions & 1 deletion python/neuromation/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,23 @@ def setup_console_handler(
default="auto",
help="Color mode",
)
@click.option(
"--disable-pypi-version-check",
is_flag=True,
help="Don't periodically check PyPI to determine whether a new version of "
"Neuromation CLI is available for download.",
)
@click.version_option(
version=neuromation.__version__, message="Neuromation Platform Client %(version)s"
)
@click.pass_context
def cli(ctx: click.Context, verbose: int, show_traceback: bool, color: str) -> None:
def cli(
ctx: click.Context,
verbose: int,
show_traceback: bool,
color: str,
disable_pypi_version_check: bool,
) -> None:
# ▇ ◣
# ▇ ◥ ◣
# ◣ ◥ ▇
Expand All @@ -97,6 +109,8 @@ def cli(ctx: click.Context, verbose: int, show_traceback: bool, color: str) -> N
config = rc.ConfigFactory.load()
config.color = real_color
ctx.obj = config
if not disable_pypi_version_check:
config.pypi.warn_if_has_newer_version()
if not ctx.invoked_subcommand:
click.echo(ctx.get_help())

Expand Down
53 changes: 53 additions & 0 deletions python/neuromation/cli/rc.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import logging
import os
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any, Dict, Optional, Tuple

import aiohttp
import pkg_resources
import yaml
from yarl import URL

import neuromation
from neuromation.client import Client
from neuromation.client.users import get_token_username
from neuromation.utils import run
Expand All @@ -15,10 +18,16 @@
from .login import AuthConfig, AuthNegotiator, AuthToken


log = logging.getLogger(__name__)


class RCException(Exception):
pass


NO_VERSION = pkg_resources.parse_version("0.0.0")


def _create_default_auth_config() -> AuthConfig:
return _create_dev_auth_config()

Expand All @@ -41,12 +50,49 @@ def _create_staging_auth_config() -> AuthConfig:
)


@dataclass
class PyPIVersion:
pypi_version: Any
check_timestamp: int

def warn_if_has_newer_version(self) -> None:
current = pkg_resources.parse_version(neuromation.__version__)
if current < self.pypi_version:
update_command = "pip install --upgrade neuromation"
log.warning(
f"You are using Neuromation Platform Client version {current}, "
f"however version {self.pypi_version} is available. "
)
log.warning(
f"You should consider upgrading via the '{update_command}' command."
)
log.warning("") # tailing endline

@classmethod
def from_config(cls, data: Dict[str, Any]) -> "PyPIVersion":
try:
pypi_version = pkg_resources.parse_version(data["pypi_version"])
check_timestamp = int(data["check_timestamp"])
except (KeyError, TypeError, ValueError):
# config has invalid/missing data, ignore it
pypi_version = NO_VERSION
check_timestamp = 0
return cls(pypi_version=pypi_version, check_timestamp=check_timestamp)

def to_config(self) -> Dict[str, Any]:
return {
"pypi_version": str(self.pypi_version),
"check_timestamp": int(self.check_timestamp),
}


@dataclass
class Config:
auth_config: AuthConfig = field(default_factory=_create_default_auth_config)
url: str = API_URL
auth_token: Optional[AuthToken] = None
github_rsa_path: str = ""
pypi: PyPIVersion = field(default_factory=lambda: PyPIVersion(NO_VERSION, 0))
color: bool = field(default=False) # don't save the field in config

@property
Expand Down Expand Up @@ -135,6 +181,11 @@ def _validate_api_url(cls, url: str) -> None:
def update_github_rsa_path(cls, github_rsa_path: str) -> Config:
return cls._update_config(github_rsa_path=github_rsa_path)

@classmethod
def update_last_checked_version(cls, version: Any, timestamp: int) -> Config:
pypi = PyPIVersion(version, timestamp)
return cls._update_config(pypi=pypi)

@classmethod
def refresh_auth_token(cls, url: URL) -> Config:
nmrc_config_path = cls.get_path()
Expand Down Expand Up @@ -185,6 +236,7 @@ def save(path: Path, config: Config) -> Config:
"expiration_time": config.auth_token.expiration_time,
"refresh_token": config.auth_token.refresh_token,
}
payload["pypi"] = config.pypi.to_config()

# forbid access to other users
if path.exists():
Expand Down Expand Up @@ -253,6 +305,7 @@ def _load(path: Path) -> Config:
url=str(api_url),
auth_token=auth_token,
github_rsa_path=payload.get("github_rsa_path", ""),
pypi=PyPIVersion.from_config(payload.get("pypi")),
)


Expand Down
14 changes: 13 additions & 1 deletion python/neuromation/cli/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import re
import shlex
from functools import wraps
Expand All @@ -19,16 +20,27 @@
from neuromation.client import Volume
from neuromation.utils import run

from .version_utils import VersionChecker


_T = TypeVar("_T")

DEPRECATED_HELP_NOTICE = " " + click.style("(DEPRECATED)", fg="red")


async def _run_async_function(
func: Callable[..., Awaitable[_T]], *args: Any, **kwargs: Any
) -> _T:
loop = asyncio.get_event_loop()
version_checker = VersionChecker()
loop.create_task(version_checker.run())
return await func(*args, **kwargs)


def run_async(callback: Callable[..., Awaitable[_T]]) -> Callable[..., _T]:
@wraps(callback)
def wrapper(*args: Any, **kwargs: Any) -> _T:
return run(callback(*args, **kwargs))
return run(_run_async_function(callback, *args, **kwargs))

return wrapper

Expand Down
72 changes: 72 additions & 0 deletions python/neuromation/cli/version_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import asyncio
import logging
import time
import types
from typing import Any, Callable, Dict, Optional, Type

import aiohttp
import pkg_resources

from neuromation.cli.rc import NO_VERSION, ConfigFactory


log = logging.getLogger(__name__)


class VersionChecker:
def __init__(
self,
connector: Optional[aiohttp.TCPConnector] = None,
timer: Callable[[], float] = time.time,
) -> None:
if connector is None:
connector = aiohttp.TCPConnector()
self._session = aiohttp.ClientSession(connector=connector)
self._timer = timer

async def close(self) -> None:
await self._session.close()

async def __aenter__(self) -> "VersionChecker":
return self

async def __aexit__(
self,
exc_type: Optional[Type[BaseException]],
exc_value: Optional[BaseException],
exc_tb: Optional[types.TracebackType],
) -> None:
await self.close()

async def run(self) -> None:
try:
async with self:
await self.update_latest_version()
except asyncio.CancelledError:
raise
except aiohttp.ClientConnectionError:
log.debug("IO error on fetching data from PyPI", exc_info=True)
except Exception: # pragma: no cover
log.exception("Error on fetching data from PyPI")

async def update_latest_version(self) -> None:
pypi_version = await self._fetch_pypi()
ConfigFactory.update_last_checked_version(pypi_version, int(self._timer()))

async def _fetch_pypi(self) -> Any:
async with self._session.get("https://pypi.org/pypi/neuromation/json") as resp:
if resp.status != 200:
log.debug("%s status on fetching PyPI", resp.status)
return NO_VERSION
data = await resp.json()
return self._get_max_version(data)

def _get_max_version(self, pypi_response: Dict[str, Any]) -> Any:
try:
ret = [
pkg_resources.parse_version(version)
for version in pypi_response["releases"].keys()
]
return max(ver for ver in ret if not ver.is_prerelease) # type: ignore
except (KeyError, ValueError):
return NO_VERSION
1 change: 1 addition & 0 deletions python/requirements/ci.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ markdown-toc==1.2.4
pytest-testmon==0.9.14
towncrier==18.6.0
asynctest==0.12.2
trustme==0.4.0
29 changes: 29 additions & 0 deletions python/tests/cli/test_rc.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import logging
from pathlib import Path
from textwrap import dedent

import pkg_resources
import pytest
from jose import jwt
from yarl import URL
Expand Down Expand Up @@ -39,6 +41,9 @@ def test_create(nmrc):
success_redirect_url: https://platform.neuromation.io
token_url: https://dev-neuromation.auth0.com/oauth/token
github_rsa_path: ''
pypi:
check_timestamp: 0
pypi_version: 0.0.0
url: https://platform.dev.neuromation.io/api/v1
"""
)
Expand Down Expand Up @@ -132,6 +137,15 @@ def test_factory_update_token_no_identity(self):
with pytest.raises(ValueError):
rc.ConfigFactory.update_auth_token(token=no_identity)

def test_factory_update_last_checked_version(self):
config = rc.ConfigFactory.load()
assert config.pypi.pypi_version == pkg_resources.parse_version("0.0.0")
newer_version = pkg_resources.parse_version("1.2.3b4")
rc.ConfigFactory.update_last_checked_version(newer_version, 1234)
config2 = rc.ConfigFactory.load()
assert config2.pypi.pypi_version == newer_version
assert config2.pypi.check_timestamp == 1234

def test_factory_forget_token(self, monkeypatch, nmrc):
def home():
return nmrc.parent
Expand Down Expand Up @@ -238,3 +252,18 @@ def test_unregistered():
config = rc.Config()
with pytest.raises(rc.RCException):
config._check_registered()


def test_warn_in_has_newer_version_no_upgrade(caplog):
config = rc.Config()
with caplog.at_level(logging.WARNING):
config.pypi.warn_if_has_newer_version()
assert not caplog.records


def test_warn_in_has_newer_version_need_upgrade(caplog):
config = rc.Config()
config.pypi.pypi_version = pkg_resources.parse_version("100.500")
with caplog.at_level(logging.WARNING):
config.pypi.warn_if_has_newer_version()
assert " version 100.500 is available." in caplog.records[0].message
Loading