diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index d93834d..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,69 +0,0 @@ ---- -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/PyPiUpdater/__init__.py - - cat src/PyPiUpdater/__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 deleted file mode 100644 index 2c1afd7..0000000 --- a/.gitlab-ci/git/create_tag.yml +++ /dev/null @@ -1,15 +0,0 @@ ---- - -.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 deleted file mode 100644 index dbbc149..0000000 --- a/.gitlab-ci/versioning/gitversion.yml +++ /dev/null @@ -1,31 +0,0 @@ ---- -.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 new file mode 100644 index 0000000..c531675 --- /dev/null +++ b/.woodpecker/woodpecker_ci.yml @@ -0,0 +1,92 @@ +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 b451e28..3e847e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,59 @@ # Changelog -## 0.1.1: CI/CD pipeline +## 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 851e98d..ceb666a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,21 @@ # PyPiUpdater +Developed on my [forgejo instance](https://code.boxyfoxy.net/CodeByMrFinchum), [GitLab](https://gitlab.com/CodeByMrFinchum) is used as backup. -## More to come +**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. diff --git a/pip_README.md b/pip_README.md index 2fb85ac..81c43b3 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 gitlab](https://gitlab.com/CodeByMrFinchum/PyPiUpdater#). +For more info see [PyPiUpdater forgejo](https://code.boxyfoxy.net/CodeByMrFinchum/PyPiUpdater) or backup repo [PyPiUpdater gitlab](https://gitlab.com/CodeByMrFinchum/PyPiUpdater#). diff --git a/pyproject.toml b/pyproject.toml index d369a09..2aeb735 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,17 +7,21 @@ name = "PyPiUpdater" dynamic = ["version"] authors = [{ name = "Mr Finchum" }] description = "Simple program to update package from PyPi with pip." -readme = "pip_README.md" +readme = "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 136d36c..621ee43 100644 --- a/src/PyPiUpdater/__init__.py +++ b/src/PyPiUpdater/__init__.py @@ -1,4 +1,5 @@ -from .pypi_updater import PyPiUpdater +from .single_updater import PyPiUpdater +#from .multi_updater import MultiPackageUpdater __all__ = ["PyPiUpdater"] diff --git a/src/PyPiUpdater/multi_updater.py b/src/PyPiUpdater/multi_updater.py new file mode 100644 index 0000000..71e55a9 --- /dev/null +++ b/src/PyPiUpdater/multi_updater.py @@ -0,0 +1,4 @@ + +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 deleted file mode 100644 index f3c9f91..0000000 --- a/src/PyPiUpdater/pypi_updater.py +++ /dev/null @@ -1,91 +0,0 @@ -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 new file mode 100644 index 0000000..3e4e6d6 --- /dev/null +++ b/src/PyPiUpdater/single_updater.py @@ -0,0 +1,174 @@ +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}"]