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 291ec3b..3e847e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,59 @@ # Changelog -## 0.3.0: Rework BREAKING +## 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 91f7b25..ceb666a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ # 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`. @@ -16,37 +18,4 @@ pip install PyPiUpdater ``` ## Usage example -**NOTE:** Example only work for client application, GUI apps such as OptimaLab35 with QT need also to close the window and should not use the restart function. - -**Now the example by ChatGPT:** - -Here's a short example code snippet for your `PyPiUpdater` package to include in your README: - -```python -# Example usage of PyPiUpdater - -from PyPiUpdater import PyPiUpdater - -# Initialize the updater with the package name, current version, and log file path -updater = PyPiUpdater(package_name="OptimaLab35", local_version="0.7.0", log_path="update_log.txt") - -# Check if an update is available (optionally forcing the check, otherwise only checked every 20 hours(default set with update_interval_seconds = int seconds)) -is_newer, latest_version = updater.check_for_update(force=False) - -if is_newer: - print(f"Update available! Latest version: {latest_version}") - # Update the package using pip - success, message = updater.update_package() - print(message) - if success: - # Restart the program after update - updater.restart_program() -else: - print("No update available or checked too recently.") -``` - -### Explanation: -- The example shows how to initialize the `PyPiUpdater` class with the package name, current version, and the path to a log file. -- It checks for an update by calling `check_for_update()`. -- If an update is available, it proceeds with updating the package using `update_package()` and restarts the program with `restart_program()`. -- If no update is available, or it was checked too recently, it prints an appropriate message. +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 0456616..2aeb735 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,13 +11,17 @@ 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 880df45..0000000 --- a/src/PyPiUpdater/pypi_updater.py +++ /dev/null @@ -1,98 +0,0 @@ -import requests -import subprocess -import sys -import os -import time -from packaging import version -from xml.etree import ElementTree as ET - -class PyPiUpdater: - def __init__(self, package_name, local_version, log_path, update_interval_seconds=20 * 3600): - """ - 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 (txt 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.update_interval = update_interval_seconds - - def _get_latest_version(self): - """Fetch the latest 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) - - 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 check_for_update(self, force = False): - """Check if an update is available.""" - - if not force and not self.should_check_for_update(): - return None, "Checked too recently" - - latest_version, error = self._get_latest_version() - if latest_version is None: - return None, error - - is_newer = version.parse(latest_version) > self.local_version - if is_newer: - self.record_update_check() # Save the check timestamp only if successful - 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 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 last_update_check(self): - """Retrieve the last update check timestamp from a text file.""" - if not os.path.exists(self.log_path): - return 0 # If no log, assume a long time passed - - try: - with open(self.log_path, "r") as f: - last_check = float(f.readline().strip()) # Read first line as timestamp - return last_check - except Exception: - return 0 # Handle read errors gracefully - - def last_update_date_string(self): - time_float = self.last_update_check() - local_time = time.localtime(time_float) - time_string = f"{local_time.tm_mday:02d}/{local_time.tm_mon:02d} {local_time.tm_hour:02d}:{local_time.tm_min:02d}" - return time_string - - def record_update_check(self): - """Save the current time as the last update check timestamp.""" - with open(self.log_path, "w") as f: - f.write(f"{time.time()}") - - def should_check_for_update(self): - """Returns True if enough time has passed since the last check.""" - last_check = self.last_update_check() - elapsed_time = time.time() - last_check - return elapsed_time >= self.update_interval 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}"]