From cced3f11799fc2b54325dcfd7538a41923e4522e Mon Sep 17 00:00:00 2001 From: Pradish Bijukchhe Date: Sat, 27 Apr 2024 02:40:52 +0545 Subject: [PATCH] feat: add basic updater module --- .github/workflows/python-publish.yml | 27 ++++++ .gitignore | 131 +++++++++++++++++++++++++++ LICENSE | 19 ++++ MANIFEST.in | 2 + README.md | 29 ++++++ pyproject.toml | 38 ++++++++ yappr/__init__.py | 3 + yappr/logger.py | 72 +++++++++++++++ yappr/py.typed | 0 yappr/updater.py | 95 +++++++++++++++++++ 10 files changed, 416 insertions(+) create mode 100644 .github/workflows/python-publish.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 yappr/__init__.py create mode 100644 yappr/logger.py create mode 100644 yappr/py.typed create mode 100644 yappr/updater.py diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..a73c869 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,27 @@ +name: Upload Python Package +on: + push: + branches: + - release +permissions: + contents: read +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07a95d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,131 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.vscode + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ +dist diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..96f1555 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2018 The Python Packaging Authority + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..c1a7121 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include LICENSE +include README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..30fd48c --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# yappr + +Yet another python package updater + +## Installation + +You can install the package via pip: + +```bash +pip install yappr +``` + +## Usage + +```python +from yappr import Updater + +u = Updater("httpx", first_update_interval=0) +u.check_for_updates_loop() + +# 04/27/2024 02:35:14 AM - INFO - Checking for updates... +# 04/27/2024 02:35:19 AM - INFO - New version found: 0.23.2 -> 0.27.0 +# 04/27/2024 02:35:19 AM - INFO - Collecting new packages... +# 04/27/2024 02:35:26 AM - INFO - Successfully downloaded new version. +``` + +## License + +This project is licensed under the terms of the MIT license. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9c9df04 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["setuptools>=61.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "yappr" +version = "1.0.0" +dependencies = [] +requires-python = ">=3" +authors = [ + { name = "Pradish Bijukchhe", email = "pradish@sandbox.com.np" }, +] +description = "Yet another python package updater" +readme = "README.md" +license = { file = "LICENSE" } +keywords = [] +classifiers = ["Programming Language :: Python :: 3"] + +[project.urls] +Homepage = "https://github.com/sandbox-pokhara/yappr" +Issues = "https://github.com/sandbox-pokhara/yappr/issues" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.package-dir] +"yappr" = "yappr" + +[tool.isort] +line_length = 79 +force_single_line = true + +[tool.black] +line-length = 79 +preview = true + +[tool.pyright] +typeCheckingMode = "strict" diff --git a/yappr/__init__.py b/yappr/__init__.py new file mode 100644 index 0000000..8d49494 --- /dev/null +++ b/yappr/__init__.py @@ -0,0 +1,3 @@ +from yappr.updater import Updater + +__all__ = ["Updater"] diff --git a/yappr/logger.py b/yappr/logger.py new file mode 100644 index 0000000..a29b062 --- /dev/null +++ b/yappr/logger.py @@ -0,0 +1,72 @@ +import logging +import sys +from logging import LogRecord +from logging import StreamHandler +from typing import Any + + +class ColorFormatter(logging.Formatter): + grey = "\x1b[38;20m" + blue = "\x1b[38;5;39m" + yellow = "\x1b[33;20m" + red = "\x1b[31;20m" + bold_red = "\x1b[31;1m" + reset = "\x1b[0m" + + colors = { + logging.DEBUG: grey, + logging.INFO: blue, + logging.WARNING: yellow, + logging.ERROR: red, + logging.CRITICAL: bold_red, + } + + def format(self, record: LogRecord): + color = self.colors.get(record.levelno, self.grey) + message = super().format(record) + return color + message + self.reset + + +# intialize logger +root = logging.getLogger("yappr") +root.setLevel(logging.DEBUG) + + +# formatter +fmt = "%(asctime)s - %(levelname)s - %(message)s" +datefmt = "%m/%d/%Y %I:%M:%S %p" +color_formatter = ColorFormatter(fmt, datefmt) + +# stream handler +stream_handler = StreamHandler(sys.stdout) +stream_handler.setFormatter(color_formatter) +stream_handler.setLevel(logging.INFO) +root.addHandler(stream_handler) + + +# --------------------------------------------------------------------------- +# Utility functions at module level. +# Basically delegate everything to the root logger. +# --------------------------------------------------------------------------- +def critical(msg: Any): + root.critical(msg) + + +def error(msg: Any): + root.error(msg) + + +def exception(msg: Any): + root.exception(msg) + + +def warning(msg: Any): + root.warning(msg) + + +def info(msg: Any): + root.info(msg) + + +def debug(msg: Any): + root.debug(msg) diff --git a/yappr/py.typed b/yappr/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/yappr/updater.py b/yappr/updater.py new file mode 100644 index 0000000..0ce2874 --- /dev/null +++ b/yappr/updater.py @@ -0,0 +1,95 @@ +import importlib.metadata +from subprocess import PIPE +from subprocess import STARTF_USESHOWWINDOW +from subprocess import STARTUPINFO +from subprocess import SW_HIDE +from subprocess import check_output +from subprocess import run +from threading import Event + +from yappr import logger + +# hide flashing console when using pythonw +startupinfo = STARTUPINFO() +startupinfo.dwFlags |= STARTF_USESHOWWINDOW +startupinfo.wShowWindow = SW_HIDE + + +class Updater: + def __init__( + self, + package_name: str, + first_update_interval: float = 10, + update_interval: float = 900, + exit_flag: Event = Event(), + ): + self.package_name = package_name + self.first_update_interval = first_update_interval + self.update_interval = update_interval + self.current_version = self.get_version() + self.exit_flag = exit_flag + + def get_version(self) -> str: + try: + return importlib.metadata.version(self.package_name) + except importlib.metadata.PackageNotFoundError: + return "" + + def update(self): + logger.info("Collecting new packages...") + run( + ["pip", "install", "--upgrade", "project-l"], + text=True, + stdout=PIPE, + stderr=PIPE, + timeout=30, + startupinfo=startupinfo, + ) + logger.info("Successfully downloaded new version.") + + def get_latest_version(self): + out = check_output( + ["pip", "index", "versions", self.package_name], + text=True, + stderr=PIPE, + timeout=30, + startupinfo=startupinfo, + ) + for line in out.split("\n"): + line = line.strip() + if line.startswith("LATEST:"): + version = line[10:].strip() + return version + return "" + + def check_for_updates(self): + try: + logger.info("Checking for updates...") + current = self.current_version + if not current: + logger.error("Could not determine the current version.") + return + latest = self.get_latest_version() + if not latest: + logger.error("Could not determine the latest version.") + return + if latest == current: + logger.info("Already upto date.") + return + logger.info(f"New version found: {current} -> {latest}") + self.update() + self.exit_flag.set() + except Exception: + logger.exception("Unhandled exception in updater.") + + def check_for_updates_loop(self): + self.exit_flag.wait(self.first_update_interval) + while True: + try: + if self.exit_flag.is_set(): + break + self.check_for_updates() + self.exit_flag.wait(self.update_interval) + except Exception: + logger.exception("Unhandled exception in updater.") + self.exit_flag.wait(self.update_interval)