diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..6d20996 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,69 @@ +--- +include: + - local: .gitlab-ci/versioning/gitversion.yml + - local: .gitlab-ci/git/create_tag.yml + +stages: + - build + - release + +gitversion: + extends: .versioning:gitversion + stage: .pre + tags: + - gitlab-org-docker + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # Run this job when commits are pushed or merged to the default branch + +build: + stage: build + image: python:3.9.21 + tags: + - gitlab-org-docker + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # Run this job when commits are pushed or merged to the default branch + needs: + - job: gitversion + artifacts: true + script: + - sed -i "s/^__version__ = .*/__version__ = \"${GitVersion_MajorMinorPatch}\"/" src/PyPiUpdateCheck/__init__.py + - cat src/PyPiUpdateCheck/__init__.py + - python3 -m pip install build + - python3 -m build + artifacts: + paths: + - dist/* + expire_in: 1 day + +publish: + stage: release + image: python:3.9.21 + tags: + - gitlab-org-docker + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # Run this job when commits are pushed or merged to the default branch + variables: + TWINE_USERNAME: "__token__" + TWINE_PASSWORD: $TWINE_API + needs: + - job: build + artifacts: true + script: + - python3 -m pip install twine + - python3 -m twine upload dist/* + +create_tag: + extends: .git:create_tag + stage: release + tags: + - gitlab-org-docker + variables: + VERSION: $GitVersion_SemVer + TOKEN: $GITLAB_TOKEN + needs: + - job: gitversion + artifacts: true + rules: + - if: $CI_COMMIT_TAG + when: never # Do not run this job when a tag is created manually + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # Run this job when commits are pushed or merged to the default branch diff --git a/.gitlab-ci/git/create_tag.yml b/.gitlab-ci/git/create_tag.yml new file mode 100644 index 0000000..2c1afd7 --- /dev/null +++ b/.gitlab-ci/git/create_tag.yml @@ -0,0 +1,15 @@ +--- + +.git:create_tag: + image: alpine:3.21 + variables: + GIT_STRATEGY: clone + GIT_DEPTH: 0 + GIT_LFS_SKIP_SMUDGE: 1 + VERSION: '' + TOKEN: '' # Token with push privileges + script: + - apk add git + - git remote set-url origin https://oauth2:$TOKEN@$CI_SERVER_HOST/$CI_PROJECT_PATH + - git tag $VERSION + - git push origin tag $VERSION diff --git a/.gitlab-ci/versioning/gitversion.yml b/.gitlab-ci/versioning/gitversion.yml new file mode 100644 index 0000000..dbbc149 --- /dev/null +++ b/.gitlab-ci/versioning/gitversion.yml @@ -0,0 +1,31 @@ +--- +.versioning:gitversion: + image: + name: mcr.microsoft.com/dotnet/sdk:9.0 + variables: + GIT_STRATEGY: clone + GIT_DEPTH: 0 # force a deep/non-shallow fetch need by gitversion + GIT_LFS_SKIP_SMUDGE: 1 + cache: [] # caches and before / after scripts can mess things up + script: + - | + dotnet tool install --global GitVersion.Tool --version 5.* + export PATH="$PATH:/root/.dotnet/tools" + + dotnet-gitversion -output buildserver + + # We could just collect the output file gitversion.properties (with artifacts:report:dotenv: gitversion.properties as it is already in DOTENV format, + # however it contains ~33 variables which unnecessarily consumes many of the 50 max DOTENV variables of the free GitLab version. + # Limits are higher for licensed editions, see https://docs.gitlab.com/ee/ci/yaml/artifacts_reports.html#artifactsreportsdotenv + grep 'GitVersion_LegacySemVer=' gitversion.properties >> gitversion.env + grep 'GitVersion_SemVer=' gitversion.properties >> gitversion.env + grep 'GitVersion_FullSemVer=' gitversion.properties >> gitversion.env + grep 'GitVersion_Major=' gitversion.properties >> gitversion.env + grep 'GitVersion_Minor=' gitversion.properties >> gitversion.env + grep 'GitVersion_Patch=' gitversion.properties >> gitversion.env + grep 'GitVersion_MajorMinorPatch=' gitversion.properties >> gitversion.env + grep 'GitVersion_BuildMetaData=' gitversion.properties >> gitversion.env + artifacts: + reports: + # propagates variables into the pipeline level + dotenv: gitversion.env diff --git a/.woodpecker/woodpecker_ci.yml b/.woodpecker/woodpecker_ci.yml deleted file mode 100644 index c531675..0000000 --- a/.woodpecker/woodpecker_ci.yml +++ /dev/null @@ -1,92 +0,0 @@ -steps: - - name: gitversion - depends_on: [] # nothing start emititly - when: - event: push - branch: main - image: mcr.microsoft.com/dotnet/sdk:9.0 - environment: - CI_TOKEN: - from_secret: CI_TOKEN - commands: - - git remote set-url origin https://CodeByMrFinchum:$CI_TOKEN@code.boxyfoxy.net/$CI_REPO.git - - git fetch --unshallow --tags - - apt-get update && apt-get install -y jq - - dotnet tool install --global GitVersion.Tool --version 5.* - - export PATH="$PATH:/root/.dotnet/tools" - - dotnet-gitversion -output json > version.json - - ls - - cat version.json - - | - echo "GitVersion_SemVer=$(jq -r '.SemVer' version.json)" >> gitversion.env - echo "GitVersion_LegacySemVer=$(jq -r '.LegacySemVer' version.json)" >> gitversion.env - echo "GitVersion_FullSemVer=$(jq -r '.FullSemVer' version.json)" >> gitversion.env - echo "GitVersion_Major=$(jq -r '.Major' version.json)" >> gitversion.env - echo "GitVersion_Minor=$(jq -r '.Minor' version.json)" >> gitversion.env - echo "GitVersion_Patch=$(jq -r '.Patch' version.json)" >> gitversion.env - echo "GitVersion_MajorMinorPatch=$(jq -r '.MajorMinorPatch' version.json)" >> gitversion.env - echo "GitVersion_BuildMetaData=$(jq -r '.BuildMetaData' version.json)" >> gitversion.env - - - name: tagging - depends_on: [gitversion] - when: - event: push - branch: main - image: alpine/git - environment: - CI_TOKEN: - from_secret: CI_TOKEN - commands: - - ls - - cat gitversion.env - - git config --global user.email "ci@noreply.boxyfoxy.net" - - git config --global user.name "CI Bot" - - git remote set-url origin https://CodeByMrFinchum:$${CI_TOKEN}@code.boxyfoxy.net/$${CI_REPO}.git - - . gitversion.env - - git tag $GitVersion_SemVer - - git push origin tag $GitVersion_SemVer - - - name: build - depends_on: [gitversion, tagging] - when: - event: push - branch: main - image: python:3.9.21 - commands: - - ls - - cat gitversion.env - - export $(cat gitversion.env | xargs) - - sed -i "s/^__version__ = .*/__version__ = \"$GitVersion_SemVer\"/" src/PyPiUpdater/__init__.py - - cat src/PyPiUpdater/__init__.py - - python3 -m pip install build - - python3 -m build - - - name: publish_pypi - depends_on: [gitversion, tagging, build] - when: - event: push - branch: main - image: python:3.9.21 - environment: - TWINE_PASSWORD: - from_secret: TWINE_API - TWINE_USERNAME: "__token__" - commands: - - ls - - python3 -m pip install twine - - python3 -m twine upload dist/* - - - name: publish_forgejo - depends_on: [gitversion, tagging, build] - when: - event: push - branch: main - image: python:3.9.21 - environment: - TWINE_PASSWORD: - from_secret: PKG_TOKEN - TWINE_USERNAME: "CodeByMrFinchum" - commands: - - ls - - python3 -m pip install twine - - python3 -m twine upload --verbose --repository-url https://code.boxyfoxy.net/api/packages/CodeByMrFinchum/pypi dist/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e847e5..306241d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,59 +1,5 @@ # Changelog -## 0.8-0.9: CI woodpecker (25.04.10-11) -- Changes to the pipeline no - -## 0.7.x -### 0.7.2: Removed Debugging Leftovers -- Cleaned up code used for debugging. - -### 0.7.1: Fixed Prerelease Update Detection -- Prevented prerelease versions from being listed as updates, as they must be installed manually. - -### 0.7.0: Added Function to Install Packages -- Introduced the `install_package` function, allowing packages to be installed directly through the app. - - Useful for optional dependencies that need to be installed separately. This enables installation via the UI. - ---- - -## 0.6.x -### 0.6.1: Classifier -- Added Classifier for pypi - -### 0.6.0: New Local Update Feature -- Added support for updating from a local folder containing package files. - - Scans a specified folder for available updates. - - Installs updates directly from local package files. -- **Note:** Local version handling depends on how dependencies are managed. - - Example: If a package requires **PyPiUpdater 0.6.0**, but the installed version is **0.0.1** (e.g., from a dev environment), **OptimaLab35 v0.9.1** may not install correctly. - - This is due to **pip's dependency checks**, ensuring all required versions are satisfied before installation. - ---- - -## 0.5.0: Rework (BREAKING CHANGE) -- Improved code consistency: return values are now always **lists** when containing multiple objects. -- **Simplified the package**: Removed the default waiting period for update checks. - - It is now the **developer's responsibility** to decide when to check for updates. A separate independent function may be introduced later. - - The last update check is still saved in the config and returned as a `time.time()` object. - ---- - -## 0.4.0: Rework (BREAKING CHANGE) -- The log file is now a JSON file, allowing it to store multiple package names, versions, and last update timestamps. -- Some return values are now lists. - ---- - -## 0.3.0: Rework (BREAKING CHANGE) -- Changed how program behaves - ---- - -## 0.2.1: CI/CD pipeline -- Added auto tagging and publishing - ---- - ## 0.0.1: Project Initiation - First working version - ATM terminal promt to accept or deny update diff --git a/README.md b/README.md index ceb666a..851e98d 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,3 @@ # PyPiUpdater -Developed on my [forgejo instance](https://code.boxyfoxy.net/CodeByMrFinchum), [GitLab](https://gitlab.com/CodeByMrFinchum) is used as backup. -**UNFINISHED** Still early code, functions might change drasticly - -**PyPiUpdater** is a Python library for managing updates of packages installed via `pip`. - -## Features -- Check the latest version of a package on PyPI - - Determine if an update is available -- Upgrade the package using `pip` -- Restart the Python script after updating - -## Installation - -```bash -pip install PyPiUpdater -``` - -## Usage example -Example has been removed, still very active development. +## More to come diff --git a/pip_README.md b/pip_README.md index 81c43b3..2fb85ac 100644 --- a/pip_README.md +++ b/pip_README.md @@ -1,3 +1,3 @@ Simple program to update package from PyPi with pip. -For more info see [PyPiUpdater forgejo](https://code.boxyfoxy.net/CodeByMrFinchum/PyPiUpdater) or backup repo [PyPiUpdater gitlab](https://gitlab.com/CodeByMrFinchum/PyPiUpdater#). +For more info see [PyPiUpdater gitlab](https://gitlab.com/CodeByMrFinchum/PyPiUpdater#). diff --git a/pyproject.toml b/pyproject.toml index 2aeb735..d369a09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,21 +7,17 @@ name = "PyPiUpdater" dynamic = ["version"] authors = [{ name = "Mr Finchum" }] description = "Simple program to update package from PyPi with pip." -readme = "README.md" +readme = "pip_README.md" requires-python = ">=3.8" dependencies = ["requests", "packaging"] classifiers = [ - "Development Status :: 3 - Alpha", "Programming Language :: Python :: 3", - "Topic :: Software Development :: Libraries :: Python Modules", "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", "Operating System :: OS Independent", ] [project.urls] Source = "https://gitlab.com/CodeByMrFinchum/PyPiUpdater" -Documentation = "https://gitlab.com/CodeByMrFinchum/PyPiUpdater/-/blob/main/README.md" -Changelog = "https://gitlab.com/CodeByMrFinchum/PyPiUpdater/-/blob/main/CHANGELOG.md" [tool.hatch.build.targets.wheel] packages = ["src/PyPiUpdater"] diff --git a/src/PyPiUpdater/__init__.py b/src/PyPiUpdater/__init__.py index 621ee43..136d36c 100644 --- a/src/PyPiUpdater/__init__.py +++ b/src/PyPiUpdater/__init__.py @@ -1,5 +1,4 @@ -from .single_updater import PyPiUpdater -#from .multi_updater import MultiPackageUpdater +from .pypi_updater import PyPiUpdater __all__ = ["PyPiUpdater"] diff --git a/src/PyPiUpdater/multi_updater.py b/src/PyPiUpdater/multi_updater.py deleted file mode 100644 index 71e55a9..0000000 --- a/src/PyPiUpdater/multi_updater.py +++ /dev/null @@ -1,4 +0,0 @@ - -class MultiPackageUpdater: - def __init__(self, log_path): - print("Not ready yet...") diff --git a/src/PyPiUpdater/pypi_updater.py b/src/PyPiUpdater/pypi_updater.py new file mode 100644 index 0000000..f3c9f91 --- /dev/null +++ b/src/PyPiUpdater/pypi_updater.py @@ -0,0 +1,91 @@ +import requests +from packaging import version +import subprocess +import sys + +class PyPiUpdater: + + def _restart_program(self): + """Restarts the current Python script.""" + print("Restarting the application...") + python = sys.executable + subprocess.run([python] + sys.argv) + sys.exit() # Ensure the old process exits + + def update_package(self, package_name): + """Runs pip install -U .""" + print(f"Updating {package_name}...") + subprocess.run([sys.executable, "-m", "pip", "install", "--upgrade", package_name]) + + + def check_and_update(self, package_name, local_version): + """Checks for updates and installs if needed.""" + checker = PyPiUpdateCheck() + is_newer, latest_version = checker.check_for_update(package_name, local_version) + + if is_newer is None: + print(f"Error checking for updates: {latest_version}") + elif is_newer: + print(f"New version available: {latest_version}") + choice = input("Do you want to update (y/n)\n") + if choice == "y": + self.update_package(package_name) + self._restart_program() # Restart after updating + else: + print("packge not updated") + else: + print("You are up to date!") + +class PyPiUpdateCheck: + + def _get_latest_version_from_rss(self, package_name): + """ + Fetch the RSS feed from PyPI and extract the latest version number. + Returns the latest version or None if an error occurs. + """ + rss_url = f"https://pypi.org/rss/project/{package_name.lower()}/releases.xml" + + try: + response = requests.get(rss_url) + response.raise_for_status() # Raise HTTPError for bad responses (4xx, 5xx) + + # Extract the latest version from the RSS XML + # The RSS feed is XML, so we just need to parse the version from the first entry + from xml.etree import ElementTree as ET + root = ET.fromstring(response.content) + + # The version is in the tag of the first <item> in the RSS feed + latest_version = root.find(".//item/title").text.strip() + return latest_version, None + except requests.exceptions.RequestException as e: + return None, f"Network error: {str(e)}" + except Exception as e: + return None, f"Error parsing feed: {str(e)}" + + def _compare_versions(self, local_version, online_version): + """ + Compare the local and online version strings. + Returns (True, online_version) if online is newer, (False, online_version) if same or older. + """ + try: + local_ver = version.parse(local_version) + online_ver = version.parse(online_version) + + if online_ver > local_ver: + return True, online_version # Online version is newer + else: + return False, online_version # Local version is the same or newer + except Exception as e: + return None, f"Error comparing versions: {str(e)}" + + def check_for_update(self, package_name, local_version): + """ + Check if the given package has a newer version on PyPI compared to the local version. + Returns (True, online_version), (False, online_version), or (None, error_message). + """ + online_version, error_message = self._get_latest_version_from_rss(package_name) + + if online_version is None: + return None, error_message # Error fetching or parsing feed + + return self._compare_versions(local_version, online_version) diff --git a/src/PyPiUpdater/single_updater.py b/src/PyPiUpdater/single_updater.py deleted file mode 100644 index 3e4e6d6..0000000 --- a/src/PyPiUpdater/single_updater.py +++ /dev/null @@ -1,174 +0,0 @@ -import requests -import subprocess -import sys -import os -import time -import json -import re -from packaging import version -from packaging.version import parse, Version -from xml.etree import ElementTree as ET - -class PyPiUpdater: - def __init__(self, package_name, local_version, log_path): - """ - Initialize PyPiUpdater. - - :param package_name: Name of the package on PyPI. - :param local_version: Currently installed version. - :param log_path: Path to the update log file (JSON file). - :param update_interval_seconds: Seconds to wait before update is allowed again (default: 20 hours). - """ - self.package_name = package_name - self.local_version = version.parse(local_version) - self.log_path = log_path - self.latest_version = "" - self.last_update_check = 0.1 - - def _get_latest_version(self): - """Fetch the latest stable version from PyPI RSS feed.""" - rss_url = f"https://pypi.org/rss/project/{self.package_name.lower()}/releases.xml" - - try: - response = requests.get(rss_url, timeout=5) - response.raise_for_status() - root = ET.fromstring(response.content) - - # Extract all versions from the feed - versions = [] - for item in root.findall(".//item/title"): - version_text = item.text.strip() - parsed_version = parse(version_text) - # Check if the version is stable (not a pre-release) - if isinstance(parsed_version, Version) and not parsed_version.is_prerelease: - versions.append(parsed_version) - - # Return the latest stable version - if versions: - latest_version = str(max(versions)) - self.latest_version = latest_version - return [latest_version, None] - - return [None, "No stable versions found"] - - except requests.exceptions.RequestException as e: - return [None, f"Network error: {str(e)}"] - except Exception as e: - return [None, f"Error parsing feed: {str(e)}"] - - def check_for_update(self): - """Check if an update is available.""" - latest_version, error = self._get_latest_version() - if latest_version is None: - return [None, error] - - is_newer = version.parse(latest_version) > self.local_version - self._record_update_check() # Save check timestamp & latest version - return [is_newer, latest_version] - - def update_package(self): - """Update the package using pip.""" - print(f"Updating {self.package_name}...") - try: - subprocess.run([sys.executable, "-m", "pip", "install", "--upgrade", self.package_name], check = True) - return [True, f"{self.package_name} updated successfully."] - except subprocess.CalledProcessError as e: - return [False, f"Update failed: {str(e)}"] - - def check_update_local(self, folder_path): - """ - Check if a newer version of the package is available in the local folder. - - :param folder_path: Path to the folder containing package files. - :return: (bool, latest_version) - True if newer version found, otherwise False. - """ - if not os.path.exists(folder_path): - return [None, "Folder does not exist"] - - pattern = re.compile(rf"{re.escape(self.package_name.lower())}-(\d+\.\d+\.\d+[a-zA-Z0-9]*)") - - available_versions = [] - for file in os.listdir(folder_path): - match = pattern.search(file) - if match: - found_version = match.group(1) - available_versions.append(version.parse(found_version)) - - if not available_versions: - return [None, "No valid package versions found in the folder"] - - latest_version = max(available_versions) - is_newer = latest_version > self.local_version - - return [is_newer, str(latest_version)] - - def update_from_local(self, folder_path): - """ - Install the latest package version from a local folder. - - :param folder_path: Path to the folder containing package files. - :return: (bool, message) - Success status and message. - """ - print(f"Installing {self.package_name} from {folder_path}...") - try: - subprocess.run( - [sys.executable, "-m", "pip", "install", "--no-index", "--find-links", folder_path, self.package_name, "-U"], - check=True - ) - return [True, f"{self.package_name} updated successfully from local folder."] - except subprocess.CalledProcessError as e: - return [False, f"Update from local folder failed: {str(e)}"] - - def restart_program(self): - """Restart the Python program after an update.""" - print("Restarting the application...") - python = sys.executable - subprocess.run([python] + sys.argv) - sys.exit() - - def get_last_state(self): - """Retrieve last update info for the package.""" - data = self._read_json() - if self.package_name in data: - entry = data[self.package_name] - return [entry["last_checked"], entry["last_online_version"], self.package_name] - return [None, None, self.package_name] - - def _record_update_check(self): - """Save the last update check time and online version in JSON.""" - data = self._read_json() - data[self.package_name] = { - "last_checked": time.time(), - "last_online_version": self.latest_version - } - self._write_json(data) - - def clear_all_entries(self): - """Clear all update history.""" - self._write_json({}) - - def _read_json(self): - """Read JSON log file.""" - if not os.path.exists(self.log_path): - return {} - try: - with open(self.log_path, "r") as f: - return json.load(f) - except (json.JSONDecodeError, FileNotFoundError): - return {} - - def _write_json(self, data): - """Write data to JSON log file.""" - with open(self.log_path, "w") as f: - json.dump(data, f, indent=4) - - @staticmethod - def install_package(package_name): - """Attempts to install a package via pip.""" - try: - subprocess.run([sys.executable, "-m", "pip", "install", package_name], check = True) - print("Successfull") - return [True, f"{package_name} installed successfully!"] - except subprocess.CalledProcessError as e: - print("Failed") - return [False, f"Failed to install {package_name}:\n{e.stderr}"]