Skip to content

Commit d924944

Browse files
committed
add the full implementation
Signed-off-by: zethson <lukas.heumos@posteo.net>
1 parent 9d3612c commit d924944

File tree

7 files changed

+269
-11
lines changed

7 files changed

+269
-11
lines changed

.flake8

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[flake8]
2-
select = B,B9,C,D,DAR,E,F,N,RST,S,W
3-
ignore = E203,E501,RST201,RST203,RST301,W503,D100
2+
select = B,B9,C,D,DAR,E,F,N,RST,W
3+
ignore = E203,E501,RST201,RST203,RST301,W503,D100,B950
44
max-line-length = 120
5-
max-complexity = 10
5+
max-complexity = 15
66
docstring-convention = google
77
per-file-ignores = tests/*:S101

README.rst

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ pypi-latest
3535
Features
3636
--------
3737

38-
* TODO
38+
* Check whether the locally installed version of a Python package is the most recent version on PyPI
39+
* Prompt to update to the latest version if required
3940

4041

4142
Installation
@@ -51,7 +52,7 @@ You can install *pypi-latest* via pip_ from PyPI_:
5152
Usage
5253
-----
5354

54-
Please see the `Command-line Reference <Usage_>`_ for details.
55+
Please see the `Usage Reference <Usage_>`_ for details.
5556

5657

5758
Credits

docs/usage.rst

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
Usage
22
=====
33

4-
.. click:: pypi_latest.__main__:main
5-
:prog: pypi-latest
6-
:nested: full
4+
Import the PypiLatest class as follows:
5+
6+
.. code:: python
7+
8+
from pypi_latest import PypiLatest
9+
10+
.. automodule:: pypi_latest
11+
:members:

poetry.lock

Lines changed: 47 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pypi_latest/__init__.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,111 @@
33
__author__ = "Lukas Heumos"
44
__email__ = "lukas.heumos@posteo.net"
55
__version__ = "0.1.0"
6+
7+
import json
8+
import logging
9+
import sys
10+
import urllib.request
11+
from logging import Logger
12+
from subprocess import PIPE, Popen, check_call
13+
from urllib.error import HTTPError, URLError
14+
15+
from pkg_resources import parse_version
16+
from rich import print
17+
18+
from pypi_latest.questionary import custom_questionary
19+
20+
log: Logger = logging.getLogger(__name__)
21+
22+
23+
class PypiLatest:
24+
"""Responsible for checking for newer versions and upgrading it if required."""
25+
26+
def __init__(self, package_name: str, latest_local_version: str):
27+
"""Constructor for PypiLatest."""
28+
self.package_name = package_name
29+
self.latest_local_version = latest_local_version
30+
31+
def check_upgrade(self) -> None:
32+
"""Checks whether the locally installed version of the package is the latest.
33+
34+
If not it prompts whether to upgrade and runs the upgrade command if desired.
35+
"""
36+
if not PypiLatest.check_latest(self):
37+
if custom_questionary(function="confirm", question="Do you want to upgrade?", default="y"):
38+
PypiLatest.upgrade(self)
39+
40+
def check_latest(self) -> bool:
41+
"""Checks whether the locally installed version of the package is the latest available on PyPi.
42+
43+
Returns:
44+
True if locally version is the latest or PyPI is inaccessible, False otherwise
45+
"""
46+
sliced_local_version = (
47+
self.latest_local_version[:-9]
48+
if self.latest_local_version.endswith("-SNAPSHOT")
49+
else self.latest_local_version
50+
)
51+
log.debug(f"Latest local {self.package_name} version is: {self.latest_local_version}.")
52+
log.debug(f"Checking whether a new {self.package_name} version exists on PyPI.")
53+
try:
54+
# Retrieve info on latest version
55+
# Adding nosec (bandit) here, since we have a hardcoded https request
56+
# It is impossible to access file:// or ftp://
57+
# See: https://stackoverflow.com/questions/48779202/audit-url-open-for-permitted-schemes-allowing-use-of-file-or-custom-schemes
58+
req = urllib.request.Request(f"https://pypi.org/pypi/{self.package_name}/json") # nosec
59+
with urllib.request.urlopen(req, timeout=1) as response: # nosec
60+
contents = response.read()
61+
data = json.loads(contents)
62+
latest_pypi_version = data["info"]["version"]
63+
except (HTTPError, TimeoutError, URLError):
64+
print(
65+
f"[bold red]Unable to contact PyPI to check for the latest {self.package_name} version. "
66+
"Do you have an internet connection?"
67+
)
68+
# Returning true by default, since this is not a serious issue
69+
return True
70+
71+
if parse_version(sliced_local_version) > parse_version(latest_pypi_version):
72+
print(
73+
f"[bold yellow]Installed version {self.latest_local_version} of {self.package_name} is newer than the latest release {latest_pypi_version}!"
74+
f" You are running a nightly version and features may break!"
75+
)
76+
elif parse_version(sliced_local_version) == parse_version(latest_pypi_version):
77+
return True
78+
else:
79+
print(
80+
f"[bold red]Installed version {self.latest_local_version} of {self.package_name} is outdated. Newest version is {latest_pypi_version}!"
81+
)
82+
return False
83+
84+
return False
85+
86+
def upgrade(self) -> None:
87+
"""Calls pip as a subprocess with the --upgrade flag to upgrade the package to the latest version."""
88+
log.debug(f"Attempting to upgrade {self.package_name} via pip install --upgrade {self.package_name} .")
89+
if not PypiLatest.is_pip_accessible():
90+
sys.exit(1)
91+
try:
92+
check_call([sys.executable, "-m", "pip", "install", "--upgrade", self.package_name])
93+
except Exception as e:
94+
print(f"[bold red]Unable to upgrade {self.package_name}")
95+
print(f"[bold red]Exception: {e}")
96+
97+
@classmethod
98+
def is_pip_accessible(cls) -> bool:
99+
"""Verifies that pip is accessible and in the PATH.
100+
101+
Returns:
102+
True if accessible, False if not
103+
"""
104+
log.debug("Verifying that pip is accessible.")
105+
pip_installed = Popen(["pip", "--version"], stdout=PIPE, stderr=PIPE, universal_newlines=True)
106+
(git_installed_stdout, git_installed_stderr) = pip_installed.communicate()
107+
if pip_installed.returncode != 0:
108+
log.debug("Pip was not accessible! Attempted to test via pip --version .")
109+
print("[bold red]Unable to find 'pip' in the PATH. Is it installed?")
110+
print("[bold red]Run command was [green]'pip --version '")
111+
return False
112+
113+
return True

pypi_latest/questionary.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import logging
2+
import os
3+
import sys
4+
from logging import Logger
5+
from typing import List, Optional, Union
6+
7+
import questionary
8+
from prompt_toolkit.styles import Style # type: ignore
9+
from rich.console import Console
10+
11+
12+
def force_terminal_in_github_action() -> Console:
13+
"""Check, whether the GITHUB_ACTIONS environment variable is set or not.
14+
15+
If it is set, the process runs in a workflow file and we need to tell rich, in order to get colored output as well.
16+
17+
Returns:
18+
Rich Console object
19+
"""
20+
if "GITHUB_ACTIONS" in os.environ:
21+
return Console(file=sys.stderr, force_terminal=True)
22+
else:
23+
return Console(file=sys.stderr)
24+
25+
26+
log: Logger = logging.getLogger(__name__)
27+
28+
ehrapy_style = Style(
29+
[
30+
("qmark", "fg:#0000FF bold"), # token in front of the question
31+
("question", "bold"), # question text
32+
("answer", "fg:#008000 bold"), # submitted answer text behind the question
33+
("pointer", "fg:#0000FF bold"), # pointer used in select and checkbox prompts
34+
("highlighted", "fg:#0000FF bold"), # pointed-at choice in select and checkbox prompts
35+
("selected", "fg:#008000"), # style for a selected item of a checkbox
36+
("separator", "fg:#cc5454"), # separator in lists
37+
("instruction", ""), # user instructions for select, rawselect, checkbox
38+
("text", ""), # plain text
39+
("disabled", "fg:#FF0000 italic"), # disabled choices for select and checkbox prompts
40+
]
41+
)
42+
43+
# the console used for printing with rich
44+
console = force_terminal_in_github_action()
45+
46+
47+
def custom_questionary(
48+
function: str,
49+
question: str,
50+
choices: Optional[List[str]] = None,
51+
default: Optional[str] = None,
52+
) -> Union[str, bool]:
53+
"""Custom selection based on Questionary. Handles keyboard interrupts and default values.
54+
55+
Args:
56+
function: The function of questionary to call (e.g. select or text).
57+
See https://github.com/tmbo/questionary for all available functions.
58+
question: List of all possible choices.
59+
choices: The question to prompt for. Should not include default values or colons.
60+
default: A set default value, which will be chosen if the user does not enter anything.
61+
62+
Returns:
63+
The chosen answer.
64+
"""
65+
answer: Optional[str] = ""
66+
try:
67+
if function == "select":
68+
if default not in choices: # type: ignore
69+
log.debug(f"Default value {default} is not in the set of choices!")
70+
answer = getattr(questionary, function)(f"{question}: ", choices=choices, style=ehrapy_style).unsafe_ask()
71+
elif function == "password":
72+
while not answer or answer == "":
73+
answer = getattr(questionary, function)(f"{question}: ", style=ehrapy_style).unsafe_ask()
74+
elif function == "text":
75+
if not default:
76+
log.debug(
77+
"Tried to utilize default value in questionary prompt, but is None! Please set a default value."
78+
)
79+
default = ""
80+
answer = getattr(questionary, function)(f"{question} [{default}]: ", style=ehrapy_style).unsafe_ask()
81+
elif function == "confirm":
82+
default_value_bool = True if default == "Yes" or default == "yes" else False
83+
answer = getattr(questionary, function)(
84+
f"{question} [{default}]: ", style=ehrapy_style, default=default_value_bool
85+
).unsafe_ask()
86+
else:
87+
log.debug(f"Unsupported questionary function {function} used!")
88+
89+
except KeyboardInterrupt:
90+
console.print("[bold red] Aborted!")
91+
sys.exit(1)
92+
if answer is None or answer == "":
93+
answer = default
94+
95+
log.debug(f"User was asked the question: ||{question}|| as: {function}")
96+
log.debug(f"User selected {answer}")
97+
98+
return answer # type: ignore

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@ classifiers = [
2020

2121

2222
[tool.poetry.dependencies]
23-
python = "^3.6.1"
23+
python = ">=3.6.1,<3.10"
2424
click = "^8.0.0"
2525
rich = "^10.4.0"
2626
PyYAML = "^5.4.1"
27+
questionary = "^1.9.0"
2728

2829
[tool.poetry.dev-dependencies]
2930
pytest = "^6.2.3"

0 commit comments

Comments
 (0)