diff --git a/.github/workflows/release_beta_to_dev.yaml b/.github/workflows/release_beta_to_dev.yaml new file mode 100644 index 000000000..642c5d189 --- /dev/null +++ b/.github/workflows/release_beta_to_dev.yaml @@ -0,0 +1,41 @@ +name: release_beta_to_dev +on: + push: + branches: [ development ] + pull_request: + branches: [ development ] + +jobs: + Release: + runs-on: ubuntu-latest + env: + ACTIONS_ALLOW_UNSECURE_COMMANDS: true + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + steps: + - name: Checkout source code + uses: actions/checkout@v2 + with: + fetch-depth: 0 + ref: development + + - name: Setup NodeJS + uses: actions/setup-node@v2 + with: + node-version: '15.x' + - run: npm install -D release-it + - run: npm install -D @release-it/bumper + + - id: latest_release + uses: pozetroninc/github-action-get-latest-release@master + with: + repository: ${{ github.repository }} + excludes: draft + + - name: Define LAST_VERSION environment variable + run: | + echo "LAST_VERSION=${{steps.latest_release.outputs.release}}" >> $GITHUB_ENV + + - name: Update version and create release + uses: TheRealWaldo/release-it@v0.2.1 + with: + json-opts: '{"preRelease": true, "increment": "prepatch", "preReleaseId": "beta"}' \ No newline at end of file diff --git a/.github/workflows/release_major_and_merge.yaml b/.github/workflows/release_major_and_merge.yaml new file mode 100644 index 000000000..4563a50ee --- /dev/null +++ b/.github/workflows/release_major_and_merge.yaml @@ -0,0 +1,59 @@ +name: release_major_and_merge +on: + workflow_dispatch + +jobs: + Release: + runs-on: ubuntu-latest + env: + ACTIONS_ALLOW_UNSECURE_COMMANDS: true + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + steps: + - name: Validate branch + if: ${{ github.ref != 'refs/heads/development' }} + run: | + echo This action can only be run on development branch, not ${{ github.ref }} + exit 1 + + - name: Checkout source code + uses: actions/checkout@v2 + with: + fetch-depth: 0 + ref: development + + - name: Setup NodeJS + uses: actions/setup-node@v2 + with: + node-version: '15.x' + - run: npm install -D release-it + - run: npm install -D @release-it/bumper + - run: npm install -D auto-changelog + + - id: latest_release + uses: pozetroninc/github-action-get-latest-release@master + with: + repository: ${{ github.repository }} + excludes: prerelease, draft + + - name: Define LAST_VERSION environment variable + run: | + echo "LAST_VERSION=${{steps.latest_release.outputs.release}}" >> $GITHUB_ENV + + - name: Update version and create release + uses: TheRealWaldo/release-it@v0.2.1 + with: + json-opts: '{"increment": "major"}' + Merge: + needs: Release + runs-on: ubuntu-latest + steps: + - name: Checkout source code + uses: actions/checkout@v2 + + - name: Merge development -> master + uses: devmasx/merge-branch@v1.3.1 + with: + type: now + from_branch: development + target_branch: master + github_token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release_minor_and_merge.yaml b/.github/workflows/release_minor_and_merge.yaml new file mode 100644 index 000000000..c47fe3e4b --- /dev/null +++ b/.github/workflows/release_minor_and_merge.yaml @@ -0,0 +1,59 @@ +name: release_minor_and_merge +on: + workflow_dispatch + +jobs: + Release: + runs-on: ubuntu-latest + env: + ACTIONS_ALLOW_UNSECURE_COMMANDS: true + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + steps: + - name: Validate branch + if: ${{ github.ref != 'refs/heads/development' }} + run: | + echo This action can only be run on development branch, not ${{ github.ref }} + exit 1 + + - name: Checkout source code + uses: actions/checkout@v2 + with: + fetch-depth: 0 + ref: development + + - name: Setup NodeJS + uses: actions/setup-node@v2 + with: + node-version: '15.x' + - run: npm install -D release-it + - run: npm install -D @release-it/bumper + - run: npm install -D auto-changelog + + - id: latest_release + uses: pozetroninc/github-action-get-latest-release@master + with: + repository: ${{ github.repository }} + excludes: prerelease, draft + + - name: Define LAST_VERSION environment variable + run: | + echo "LAST_VERSION=${{steps.latest_release.outputs.release}}" >> $GITHUB_ENV + + - name: Update version and create release + uses: TheRealWaldo/release-it@v0.2.1 + with: + json-opts: '{"increment": "minor"}' + Merge: + needs: Release + runs-on: ubuntu-latest + steps: + - name: Checkout source code + uses: actions/checkout@v2 + + - name: Merge development -> master + uses: devmasx/merge-branch@v1.3.1 + with: + type: now + from_branch: development + target_branch: master + github_token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release_patch_and_merge.yaml b/.github/workflows/release_patch_and_merge.yaml new file mode 100644 index 000000000..0f31f3c36 --- /dev/null +++ b/.github/workflows/release_patch_and_merge.yaml @@ -0,0 +1,59 @@ +name: release_patch_and_merge +on: + workflow_dispatch + +jobs: + Release: + runs-on: ubuntu-latest + env: + ACTIONS_ALLOW_UNSECURE_COMMANDS: true + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + steps: + - name: Validate branch + if: ${{ github.ref != 'refs/heads/development' }} + run: | + echo This action can only be run on development branch, not ${{ github.ref }} + exit 1 + + - name: Checkout source code + uses: actions/checkout@v2 + with: + fetch-depth: 0 + ref: development + + - name: Setup NodeJS + uses: actions/setup-node@v2 + with: + node-version: '15.x' + - run: npm install -D release-it + - run: npm install -D @release-it/bumper + - run: npm install -D auto-changelog + + - id: latest_release + uses: pozetroninc/github-action-get-latest-release@master + with: + repository: ${{ github.repository }} + excludes: prerelease, draft + + - name: Define LAST_VERSION environment variable + run: | + echo "LAST_VERSION=${{steps.latest_release.outputs.release}}" >> $GITHUB_ENV + + - name: Update version and create release + uses: TheRealWaldo/release-it@v0.2.1 + with: + json-opts: '{"increment": "patch"}' + Merge: + needs: Release + runs-on: ubuntu-latest + steps: + - name: Checkout source code + uses: actions/checkout@v2 + + - name: Merge development -> master + uses: devmasx/merge-branch@v1.3.1 + with: + type: now + from_branch: development + target_branch: master + github_token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.release-it.json b/.release-it.json new file mode 100644 index 000000000..00fcfac9f --- /dev/null +++ b/.release-it.json @@ -0,0 +1,21 @@ +{ + "github": { + "release": true, + "releaseName": "v${version}", + "releaseNotes": "echo \"From newest to oldest:\" && git log --pretty=format:\"- %s [%h](${repo.protocol}://${repo.host}/${repo.owner}/${repo.project}/commit/%H)\" --no-merges --grep \"^Release\" --invert-grep $LAST_VERSION..HEAD" + }, + "npm": { + "publish": false, + "ignoreVersion": true + }, + "plugins": { + "@release-it/bumper": { + "in": { + "file": "VERSION" + }, + "out": { + "file": "VERSION" + } + } + } +} \ No newline at end of file diff --git a/VERSION b/VERSION new file mode 100644 index 000000000..f514a2f0b --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.9.1 \ No newline at end of file diff --git a/bazarr/check_update.py b/bazarr/check_update.py index 06c454d22..451004211 100644 --- a/bazarr/check_update.py +++ b/bazarr/check_update.py @@ -4,124 +4,19 @@ import os import logging import json import requests -import tarfile +import semver +from zipfile import ZipFile from get_args import args from config import settings -from database import database - -if not args.no_update and not args.release_update: - import git - -current_working_directory = os.path.dirname(os.path.dirname(__file__)) - - -def gitconfig(): - g = git.Repo.init(current_working_directory) - config_read = g.config_reader() - config_write = g.config_writer() - - try: - username = config_read.get_value("user", "name") - except: - logging.debug('BAZARR Settings git username') - config_write.set_value("user", "name", "Bazarr") - - try: - email = config_read.get_value("user", "email") - except: - logging.debug('BAZARR Settings git email') - config_write.set_value("user", "email", "bazarr@fake.email") - - config_write.release() - - -def check_and_apply_update(): - check_releases() - if not args.release_update: - gitconfig() - branch = settings.general.branch - g = git.cmd.Git(current_working_directory) - g.fetch('origin') - result = g.diff('--shortstat', 'origin/' + branch) - if len(result) == 0: - logging.info('BAZARR No new version of Bazarr available.') - else: - g.reset('--hard', 'HEAD') - g.checkout(branch) - g.reset('--hard', 'origin/' + branch) - g.pull() - logging.info('BAZARR Updated to latest version. Restart required. ' + result) - updated() - else: - url = 'https://api.github.com/repos/morpheus65535/bazarr/releases/latest' - release = request_json(url, timeout=20, whitelist_status_code=404, validator=lambda x: type(x) == list) - - if release is None: - logging.warning('BAZARR Could not get releases from GitHub.') - return - else: - latest_release = release['tag_name'] - - if ('v' + os.environ["BAZARR_VERSION"]) != latest_release: - update_from_source(tar_download_url=release['tarball_url']) - else: - logging.info('BAZARR is up to date') - - -def update_from_source(tar_download_url): - update_dir = os.path.join(os.path.dirname(__file__), '..', 'update') - - logging.info('BAZARR Downloading update from: ' + tar_download_url) - data = request_content(tar_download_url) - - if not data: - logging.error("BAZARR Unable to retrieve new version from '%s', can't update", tar_download_url) - return - - download_name = settings.general.branch + '-github' - tar_download_path = os.path.join(os.path.dirname(__file__), '..', download_name) - - # Save tar to disk - with open(tar_download_path, 'wb') as f: - f.write(data) - - # Extract the tar to update folder - logging.info('BAZARR Extracting file: ' + tar_download_path) - tar = tarfile.open(tar_download_path) - tar.extractall(update_dir) - tar.close() - - # Delete the tar.gz - logging.info('BAZARR Deleting file: ' + tar_download_path) - os.remove(tar_download_path) - - # Find update dir name - update_dir_contents = [x for x in os.listdir(update_dir) if os.path.isdir(os.path.join(update_dir, x))] - if len(update_dir_contents) != 1: - logging.error("BAZARR Invalid update data, update failed: " + str(update_dir_contents)) - return - - content_dir = os.path.join(update_dir, update_dir_contents[0]) - - # walk temp folder and move files to main folder - for dirname, dirnames, filenames in os.walk(content_dir): - dirname = dirname[len(content_dir) + 1:] - for curfile in filenames: - old_path = os.path.join(content_dir, dirname, curfile) - new_path = os.path.join(os.path.dirname(__file__), '..', dirname, curfile) - - if os.path.isfile(new_path): - os.remove(new_path) - os.renames(old_path, new_path) - updated() def check_releases(): releases = [] url_releases = 'https://api.github.com/repos/morpheus65535/Bazarr/releases' try: - r = requests.get(url_releases, timeout=15) + logging.debug('BAZARR getting releases from Github: {}'.format(url_releases)) + r = requests.get(url_releases, allow_redirects=True) r.raise_for_status() except requests.exceptions.HTTPError as errh: logging.exception("Error trying to get releases from Github. Http error.") @@ -136,156 +31,108 @@ def check_releases(): releases.append({'name': release['name'], 'body': release['body'], 'date': release['published_at'], - 'prerelease': release['prerelease']}) + 'prerelease': release['prerelease'], + 'download_link': release['zipball_url']}) with open(os.path.join(args.config_dir, 'config', 'releases.txt'), 'w') as f: json.dump(releases, f) + logging.debug('BAZARR saved {} releases to releases.txt'.format(len(r.json()))) -class FakeLock(object): - """ - If no locking or request throttling is needed, use this - """ - - def __enter__(self): - """ - Do nothing on enter - """ - pass - - def __exit__(self, type, value, traceback): - """ - Do nothing on exit - """ - pass - - -fake_lock = FakeLock() +def check_if_new_update(): + if settings.general.branch == 'master': + use_prerelease = False + elif settings.general.branch == 'development': + use_prerelease = True + else: + logging.error('BAZARR unknown branch provided to updater: {}'.format(settings.general.branch)) + return + logging.debug('BAZARR updater is using {} branch'.format(settings.general.branch)) + check_releases() -def request_content(url, **kwargs): - """ - Wrapper for `request_response', which will return the raw content. - """ - - response = request_response(url, **kwargs) - - if response is not None: - return response.content + with open(os.path.join(args.config_dir, 'config', 'releases.txt'), 'r') as f: + data = json.load(f) + if not args.no_update: + if use_prerelease: + release = next((item for item in data), None) + else: + release = next((item for item in data if not item["prerelease"]), None) + if release: + logging.debug('BAZARR last release available is {}'.format(release['name'])) -def request_response(url, method="get", auto_raise=True, - whitelist_status_code=None, lock=fake_lock, **kwargs): - """ - Convenient wrapper for `requests.get', which will capture the exceptions - and log them. On success, the Response object is returned. In case of a - exception, None is returned. + try: + semver.parse(os.environ["BAZARR_VERSION"]) + semver.parse(release['name'].lstrip('v')) + except ValueError: + new_version = True + else: + new_version = True if semver.compare(release['name'].lstrip('v'), os.environ["BAZARR_VERSION"]) > 0 \ + else False - Additionally, there is support for rate limiting. To use this feature, - supply a tuple of (lock, request_limit). The lock is used to make sure no - other request with the same lock is executed. The request limit is the - minimal time between two requests (and so 1/request_limit is the number of - requests per seconds). - """ - - # Convert whitelist_status_code to a list if needed - if whitelist_status_code and type(whitelist_status_code) != list: - whitelist_status_code = [whitelist_status_code] - - # Disable verification of SSL certificates if requested. Note: this could - # pose a security issue! - kwargs["verify"] = True - - # Map method to the request.XXX method. This is a simple hack, but it - # allows requests to apply more magic per method. See lib/requests/api.py. - request_method = getattr(requests, method.lower()) - - try: - # Request URL and wait for response - with lock: - logging.debug( - "BAZARR Requesting URL via %s method: %s", method.upper(), url) - response = request_method(url, **kwargs) - - # If status code != OK, then raise exception, except if the status code - # is white listed. - if whitelist_status_code and auto_raise: - if response.status_code not in whitelist_status_code: - try: - response.raise_for_status() - except: - logging.debug( - "BAZARR Response status code %d is not white " - "listed, raised exception", response.status_code) - raise - elif auto_raise: - response.raise_for_status() - - return response - except requests.exceptions.SSLError as e: - if kwargs["verify"]: - logging.error( - "BAZARR Unable to connect to remote host because of a SSL error. " - "It is likely that your system cannot verify the validity" - "of the certificate. The remote certificate is either " - "self-signed, or the remote server uses SNI. See the wiki for " - "more information on this topic.") - else: - logging.error( - "BAZARR SSL error raised during connection, with certificate " - "verification turned off: %s", e) - except requests.ConnectionError: - logging.error( - "BAZARR Unable to connect to remote host. Check if the remote " - "host is up and running.") - except requests.Timeout: - logging.error( - "BAZARR Request timed out. The remote host did not respond timely.") - except requests.HTTPError as e: - if e.response is not None: - if e.response.status_code >= 500: - cause = "remote server error" - elif e.response.status_code >= 400: - cause = "local client error" + # skip update process if latest release is v0.9.1.1 which is the latest pre-semver compatible release + if new_version and release['name'] != 'v0.9.1.1': + logging.debug('BAZARR newer release available and will be downloaded: {}'.format(release['name'])) + download_release(url=release['download_link']) else: - # I don't think we will end up here, but for completeness - cause = "unknown" - - logging.error( - "BAZARR Request raise HTTP error with status code %d (%s).", - e.response.status_code, cause) + logging.debug('BAZARR no newer release have been found') else: - logging.error("BAZARR Request raised HTTP error.") - except requests.RequestException as e: - logging.error("BAZARR Request raised exception: %s", e) - + logging.debug('BAZARR no release found') + else: + logging.debug('BAZARR --no_update have been used as an argument') -def request_json(url, **kwargs): - """ - Wrapper for `request_response', which will decode the response as JSON - object and return the result, if no exceptions are raised. - As an option, a validator callback can be given, which should return True - if the result is valid. - """ - - validator = kwargs.pop("validator", None) - response = request_response(url, **kwargs) - - if response is not None: +def download_release(url): + r = None + update_dir = os.path.join(args.config_dir, 'update') + try: + os.makedirs(update_dir, exist_ok=True) + except Exception as e: + logging.debug('BAZARR unable to create update directory {}'.format(update_dir)) + else: + logging.debug('BAZARR downloading release from Github: {}'.format(url)) + r = requests.get(url, allow_redirects=True) + if r: try: - result = response.json() - - if validator and not validator(result): - logging.error("BAZARR JSON validation result failed") + with open(os.path.join(update_dir, 'bazarr.zip'), 'wb') as f: + f.write(r.content) + except Exception as e: + logging.exception('BAZARR unable to download new release and save it to disk') + else: + apply_update() + + +def apply_update(): + is_updated = False + update_dir = os.path.join(args.config_dir, 'update') + bazarr_zip = os.path.join(update_dir, 'bazarr.zip') + bazarr_dir = os.path.dirname(os.path.dirname(__file__)) + if os.path.isdir(update_dir): + if os.path.isfile(bazarr_zip): + logging.debug('BAZARR is trying to unzip this release to {0}: {1}'.format(bazarr_dir, bazarr_zip)) + try: + with ZipFile(bazarr_zip, 'r') as archive: + zip_root_directory = archive.namelist()[0] + for file in archive.namelist(): + if file.startswith(zip_root_directory) and file != zip_root_directory and not \ + file.endswith('bazarr.py'): + file_path = os.path.join(bazarr_dir, file[len(zip_root_directory):]) + parent_dir = os.path.dirname(file_path) + os.makedirs(parent_dir, exist_ok=True) + if not os.path.isdir(file_path): + with open(file_path, 'wb+') as f: + f.write(archive.read(file)) + except Exception as e: + logging.exception('BAZARR unable to unzip release') else: - return result - except ValueError: - logging.error("BAZARR Response returned invalid JSON data") - + is_updated = True + finally: + logging.debug('BAZARR now deleting release archive') + os.remove(bazarr_zip) + else: + return -def updated(restart=True): - if settings.general.getboolean('update_restart') and restart: + if is_updated: + logging.debug('BAZARR new release have been installed, now we restart') from server import webserver webserver.restart() - else: - database.execute("UPDATE system SET updated='1'") diff --git a/bazarr/config.py b/bazarr/config.py index 9e20b1925..db5e30849 100644 --- a/bazarr/config.py +++ b/bazarr/config.py @@ -49,7 +49,6 @@ defaults = { 'chmod': '0640', 'subfolder': 'current', 'subfolder_custom': '', - 'update_restart': 'True', 'upgrade_subs': 'True', 'upgrade_frequency': '12', 'days_to_upgrade_subs': '7', diff --git a/bazarr/init.py b/bazarr/init.py index f503b4170..4ef90ffdb 100644 --- a/bazarr/init.py +++ b/bazarr/init.py @@ -135,6 +135,7 @@ if settings.analytics.visitor: # Clean unused settings from config.ini with open(os.path.normpath(os.path.join(args.config_dir, 'config', 'config.ini')), 'w+') as handle: settings.remove_option('general', 'throtteled_providers') + settings.remove_option('general', 'update_restart') settings.write(handle) diff --git a/bazarr/main.py b/bazarr/main.py index 2f63b1473..d81b8df88 100644 --- a/bazarr/main.py +++ b/bazarr/main.py @@ -1,9 +1,14 @@ # coding=utf-8 -bazarr_version = '0.9.1.1' - import os +bazarr_version = '' + +version_file = os.path.join(os.path.dirname(__file__), '..', 'VERSION') +if os.path.isfile(version_file): + with open(version_file, 'r') as f: + bazarr_version = f.read() + os.environ["BAZARR_VERSION"] = bazarr_version import gc @@ -31,21 +36,19 @@ from get_series import * from get_episodes import * from get_movies import * -from check_update import check_and_apply_update, check_releases +from check_update import apply_update, check_if_new_update, check_releases from server import app, webserver from functools import wraps -# Check and install update on startup when running on Windows from installer -if args.release_update: - check_and_apply_update() -# If not, update releases cache instead. -else: - check_releases() +# Install downloaded update +if bazarr_version != '': + apply_update() +check_releases() configure_proxy_func() -# Reset restart required warning on start -database.execute("UPDATE system SET configured='0', updated='0'") +# Reset the updated once Bazarr have been restarted after an update +database.execute("UPDATE system SET updated='0'") # Load languages in database load_language_in_db() @@ -126,7 +129,13 @@ def login_page(): @app.context_processor def template_variable_processor(): - return dict(settings=settings, args=args) + updated = None + try: + updated = database.execute("SELECT updated FROM system", only_one=True)['updated'] + except: + pass + finally: + return dict(settings=settings, args=args, updated=updated) def api_authorize(): @@ -360,8 +369,8 @@ def settingsscheduler(): @app.route('/check_update') @login_required def check_update(): - if not args.no_update: - check_and_apply_update() + if not args.no_update and bazarr_version != '': + check_if_new_update() return '', 200 diff --git a/bazarr/scheduler.py b/bazarr/scheduler.py index acbd4dba7..c24635f38 100644 --- a/bazarr/scheduler.py +++ b/bazarr/scheduler.py @@ -9,7 +9,7 @@ from get_subtitle import wanted_search_missing_subtitles_series, wanted_search_m from utils import cache_maintenance from get_args import args if not args.no_update: - from check_update import check_and_apply_update, check_releases + from check_update import check_if_new_update, check_releases else: from check_update import check_releases from apscheduler.schedulers.background import BackgroundScheduler @@ -201,18 +201,16 @@ class Scheduler: id='update_all_movies', name='Update all Movie Subtitles from disk', replace_existing=True) def __update_bazarr_task(self): - if not args.no_update: - task_name = 'Update Bazarr from source on Github' - if args.release_update: - task_name = 'Update Bazarr from release on Github' + if not args.no_update and os.environ["BAZARR_VERSION"] != '': + task_name = 'Update Bazarr' if settings.general.getboolean('auto_update'): self.aps_scheduler.add_job( - check_and_apply_update, IntervalTrigger(hours=6), max_instances=1, coalesce=True, + check_if_new_update, IntervalTrigger(hours=6), max_instances=1, coalesce=True, misfire_grace_time=15, id='update_bazarr', name=task_name, replace_existing=True) else: self.aps_scheduler.add_job( - check_and_apply_update, CronTrigger(year='2100'), hour=4, id='update_bazarr', name=task_name, + check_if_new_update, CronTrigger(year='2100'), hour=4, id='update_bazarr', name=task_name, replace_existing=True) self.aps_scheduler.add_job( check_releases, IntervalTrigger(hours=3), max_instances=1, coalesce=True, misfire_grace_time=15, diff --git a/changelog.hbs b/changelog.hbs new file mode 100644 index 000000000..eb0156c46 --- /dev/null +++ b/changelog.hbs @@ -0,0 +1,6 @@ +From newest to oldest: +{{#each releases}} + {{#each commits}} + - {{subject}}{{#if href}} [`{{shorthash}}`]({{href}}){{/if}} + {{/each}} +{{/each}} \ No newline at end of file diff --git a/libs/semver.py b/libs/semver.py new file mode 100644 index 000000000..ce8816afb --- /dev/null +++ b/libs/semver.py @@ -0,0 +1,1259 @@ +"""Python helper for Semantic Versioning (http://semver.org/)""" +from __future__ import print_function + +import argparse +import collections +from functools import wraps, partial +import inspect +import re +import sys +import warnings + + +PY2 = sys.version_info[0] == 2 +PY3 = sys.version_info[0] == 3 + + +__version__ = "2.13.0" +__author__ = "Kostiantyn Rybnikov" +__author_email__ = "k-bx@k-bx.com" +__maintainer__ = ["Sebastien Celles", "Tom Schraitle"] +__maintainer_email__ = "s.celles@gmail.com" + +#: Our public interface +__all__ = ( + # + # Module level function: + "bump_build", + "bump_major", + "bump_minor", + "bump_patch", + "bump_prerelease", + "compare", + "deprecated", + "finalize_version", + "format_version", + "match", + "max_ver", + "min_ver", + "parse", + "parse_version_info", + "replace", + # + # CLI interface + "cmd_bump", + "cmd_check", + "cmd_compare", + "createparser", + "main", + "process", + # + # Constants and classes + "SEMVER_SPEC_VERSION", + "VersionInfo", +) + +#: Contains the implemented semver.org version of the spec +SEMVER_SPEC_VERSION = "2.0.0" + + +if not hasattr(__builtins__, "cmp"): + + def cmp(a, b): + """Return negative if ab.""" + return (a > b) - (a < b) + + +if PY3: # pragma: no cover + string_types = str, bytes + text_type = str + binary_type = bytes + + def b(s): + return s.encode("latin-1") + + def u(s): + return s + + +else: # pragma: no cover + string_types = unicode, str + text_type = unicode + binary_type = str + + def b(s): + return s + + # Workaround for standalone backslash + def u(s): + return unicode(s.replace(r"\\", r"\\\\"), "unicode_escape") + + +def ensure_str(s, encoding="utf-8", errors="strict"): + # Taken from six project + """ + Coerce *s* to `str`. + + For Python 2: + - `unicode` -> encoded to `str` + - `str` -> `str` + + For Python 3: + - `str` -> `str` + - `bytes` -> decoded to `str` + """ + if not isinstance(s, (text_type, binary_type)): + raise TypeError("not expecting type '%s'" % type(s)) + if PY2 and isinstance(s, text_type): + s = s.encode(encoding, errors) + elif PY3 and isinstance(s, binary_type): + s = s.decode(encoding, errors) + return s + + +def deprecated(func=None, replace=None, version=None, category=DeprecationWarning): + """ + Decorates a function to output a deprecation warning. + + :param func: the function to decorate (or None) + :param str replace: the function to replace (use the full qualified + name like ``semver.VersionInfo.bump_major``. + :param str version: the first version when this function was deprecated. + :param category: allow you to specify the deprecation warning class + of your choice. By default, it's :class:`DeprecationWarning`, but + you can choose :class:`PendingDeprecationWarning` or a custom class. + """ + + if func is None: + return partial(deprecated, replace=replace, version=version, category=category) + + @wraps(func) + def wrapper(*args, **kwargs): + msg = ["Function '{m}.{f}' is deprecated."] + + if version: + msg.append("Deprecated since version {v}. ") + msg.append("This function will be removed in semver 3.") + if replace: + msg.append("Use {r!r} instead.") + else: + msg.append("Use the respective 'semver.VersionInfo.{r}' instead.") + + # hasattr is needed for Python2 compatibility: + f = func.__qualname__ if hasattr(func, "__qualname__") else func.__name__ + r = replace or f + + frame = inspect.currentframe().f_back + + msg = " ".join(msg) + warnings.warn_explicit( + msg.format(m=func.__module__, f=f, r=r, v=version), + category=category, + filename=inspect.getfile(frame.f_code), + lineno=frame.f_lineno, + ) + # As recommended in the Python documentation + # https://docs.python.org/3/library/inspect.html#the-interpreter-stack + # better remove the interpreter stack: + del frame + return func(*args, **kwargs) + + return wrapper + + +@deprecated(version="2.10.0") +def parse(version): + """ + Parse version to major, minor, patch, pre-release, build parts. + + .. deprecated:: 2.10.0 + Use :func:`semver.VersionInfo.parse` instead. + + :param version: version string + :return: dictionary with the keys 'build', 'major', 'minor', 'patch', + and 'prerelease'. The prerelease or build keys can be None + if not provided + :rtype: dict + + >>> ver = semver.parse('3.4.5-pre.2+build.4') + >>> ver['major'] + 3 + >>> ver['minor'] + 4 + >>> ver['patch'] + 5 + >>> ver['prerelease'] + 'pre.2' + >>> ver['build'] + 'build.4' + """ + return VersionInfo.parse(version).to_dict() + + +def comparator(operator): + """Wrap a VersionInfo binary op method in a type-check.""" + + @wraps(operator) + def wrapper(self, other): + comparable_types = (VersionInfo, dict, tuple, list, text_type, binary_type) + if not isinstance(other, comparable_types): + raise TypeError( + "other type %r must be in %r" % (type(other), comparable_types) + ) + return operator(self, other) + + return wrapper + + +class VersionInfo(object): + """ + A semver compatible version class. + + :param int major: version when you make incompatible API changes. + :param int minor: version when you add functionality in + a backwards-compatible manner. + :param int patch: version when you make backwards-compatible bug fixes. + :param str prerelease: an optional prerelease string + :param str build: an optional build string + """ + + __slots__ = ("_major", "_minor", "_patch", "_prerelease", "_build") + #: Regex for number in a prerelease + _LAST_NUMBER = re.compile(r"(?:[^\d]*(\d+)[^\d]*)+") + #: Regex for a semver version + _REGEX = re.compile( + r""" + ^ + (?P0|[1-9]\d*) + \. + (?P0|[1-9]\d*) + \. + (?P0|[1-9]\d*) + (?:-(?P + (?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*) + (?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))* + ))? + (?:\+(?P + [0-9a-zA-Z-]+ + (?:\.[0-9a-zA-Z-]+)* + ))? + $ + """, + re.VERBOSE, + ) + + def __init__(self, major, minor=0, patch=0, prerelease=None, build=None): + # Build a dictionary of the arguments except prerelease and build + version_parts = { + "major": major, + "minor": minor, + "patch": patch, + } + + for name, value in version_parts.items(): + value = int(value) + version_parts[name] = value + if value < 0: + raise ValueError( + "{!r} is negative. A version can only be positive.".format(name) + ) + + self._major = version_parts["major"] + self._minor = version_parts["minor"] + self._patch = version_parts["patch"] + self._prerelease = None if prerelease is None else str(prerelease) + self._build = None if build is None else str(build) + + @property + def major(self): + """The major part of a version (read-only).""" + return self._major + + @major.setter + def major(self, value): + raise AttributeError("attribute 'major' is readonly") + + @property + def minor(self): + """The minor part of a version (read-only).""" + return self._minor + + @minor.setter + def minor(self, value): + raise AttributeError("attribute 'minor' is readonly") + + @property + def patch(self): + """The patch part of a version (read-only).""" + return self._patch + + @patch.setter + def patch(self, value): + raise AttributeError("attribute 'patch' is readonly") + + @property + def prerelease(self): + """The prerelease part of a version (read-only).""" + return self._prerelease + + @prerelease.setter + def prerelease(self, value): + raise AttributeError("attribute 'prerelease' is readonly") + + @property + def build(self): + """The build part of a version (read-only).""" + return self._build + + @build.setter + def build(self, value): + raise AttributeError("attribute 'build' is readonly") + + def to_tuple(self): + """ + Convert the VersionInfo object to a tuple. + + .. versionadded:: 2.10.0 + Renamed ``VersionInfo._astuple`` to ``VersionInfo.to_tuple`` to + make this function available in the public API. + + :return: a tuple with all the parts + :rtype: tuple + + >>> semver.VersionInfo(5, 3, 1).to_tuple() + (5, 3, 1, None, None) + """ + return (self.major, self.minor, self.patch, self.prerelease, self.build) + + def to_dict(self): + """ + Convert the VersionInfo object to an OrderedDict. + + .. versionadded:: 2.10.0 + Renamed ``VersionInfo._asdict`` to ``VersionInfo.to_dict`` to + make this function available in the public API. + + :return: an OrderedDict with the keys in the order ``major``, ``minor``, + ``patch``, ``prerelease``, and ``build``. + :rtype: :class:`collections.OrderedDict` + + >>> semver.VersionInfo(3, 2, 1).to_dict() + OrderedDict([('major', 3), ('minor', 2), ('patch', 1), \ +('prerelease', None), ('build', None)]) + """ + return collections.OrderedDict( + ( + ("major", self.major), + ("minor", self.minor), + ("patch", self.patch), + ("prerelease", self.prerelease), + ("build", self.build), + ) + ) + + # For compatibility reasons: + @deprecated(replace="semver.VersionInfo.to_tuple", version="2.10.0") + def _astuple(self): + return self.to_tuple() # pragma: no cover + + _astuple.__doc__ = to_tuple.__doc__ + + @deprecated(replace="semver.VersionInfo.to_dict", version="2.10.0") + def _asdict(self): + return self.to_dict() # pragma: no cover + + _asdict.__doc__ = to_dict.__doc__ + + def __iter__(self): + """Implement iter(self).""" + # As long as we support Py2.7, we can't use the "yield from" syntax + for v in self.to_tuple(): + yield v + + @staticmethod + def _increment_string(string): + """ + Look for the last sequence of number(s) in a string and increment. + + :param str string: the string to search for. + :return: the incremented string + + Source: + http://code.activestate.com/recipes/442460-increment-numbers-in-a-string/#c1 + """ + match = VersionInfo._LAST_NUMBER.search(string) + if match: + next_ = str(int(match.group(1)) + 1) + start, end = match.span(1) + string = string[: max(end - len(next_), start)] + next_ + string[end:] + return string + + def bump_major(self): + """ + Raise the major part of the version, return a new object but leave self + untouched. + + :return: new object with the raised major part + :rtype: :class:`VersionInfo` + + >>> ver = semver.VersionInfo.parse("3.4.5") + >>> ver.bump_major() + VersionInfo(major=4, minor=0, patch=0, prerelease=None, build=None) + """ + cls = type(self) + return cls(self._major + 1) + + def bump_minor(self): + """ + Raise the minor part of the version, return a new object but leave self + untouched. + + :return: new object with the raised minor part + :rtype: :class:`VersionInfo` + + >>> ver = semver.VersionInfo.parse("3.4.5") + >>> ver.bump_minor() + VersionInfo(major=3, minor=5, patch=0, prerelease=None, build=None) + """ + cls = type(self) + return cls(self._major, self._minor + 1) + + def bump_patch(self): + """ + Raise the patch part of the version, return a new object but leave self + untouched. + + :return: new object with the raised patch part + :rtype: :class:`VersionInfo` + + >>> ver = semver.VersionInfo.parse("3.4.5") + >>> ver.bump_patch() + VersionInfo(major=3, minor=4, patch=6, prerelease=None, build=None) + """ + cls = type(self) + return cls(self._major, self._minor, self._patch + 1) + + def bump_prerelease(self, token="rc"): + """ + Raise the prerelease part of the version, return a new object but leave + self untouched. + + :param token: defaults to 'rc' + :return: new object with the raised prerelease part + :rtype: :class:`VersionInfo` + + >>> ver = semver.VersionInfo.parse("3.4.5-rc.1") + >>> ver.bump_prerelease() + VersionInfo(major=3, minor=4, patch=5, prerelease='rc.2', \ +build=None) + """ + cls = type(self) + prerelease = cls._increment_string(self._prerelease or (token or "rc") + ".0") + return cls(self._major, self._minor, self._patch, prerelease) + + def bump_build(self, token="build"): + """ + Raise the build part of the version, return a new object but leave self + untouched. + + :param token: defaults to 'build' + :return: new object with the raised build part + :rtype: :class:`VersionInfo` + + >>> ver = semver.VersionInfo.parse("3.4.5-rc.1+build.9") + >>> ver.bump_build() + VersionInfo(major=3, minor=4, patch=5, prerelease='rc.1', \ +build='build.10') + """ + cls = type(self) + build = cls._increment_string(self._build or (token or "build") + ".0") + return cls(self._major, self._minor, self._patch, self._prerelease, build) + + def compare(self, other): + """ + Compare self with other. + + :param other: the second version (can be string, a dict, tuple/list, or + a VersionInfo instance) + :return: The return value is negative if ver1 < ver2, + zero if ver1 == ver2 and strictly positive if ver1 > ver2 + :rtype: int + + >>> semver.VersionInfo.parse("1.0.0").compare("2.0.0") + -1 + >>> semver.VersionInfo.parse("2.0.0").compare("1.0.0") + 1 + >>> semver.VersionInfo.parse("2.0.0").compare("2.0.0") + 0 + >>> semver.VersionInfo.parse("2.0.0").compare(dict(major=2, minor=0, patch=0)) + 0 + """ + cls = type(self) + if isinstance(other, string_types): + other = cls.parse(other) + elif isinstance(other, dict): + other = cls(**other) + elif isinstance(other, (tuple, list)): + other = cls(*other) + elif not isinstance(other, cls): + raise TypeError( + "Expected str or {} instance, but got {}".format( + cls.__name__, type(other) + ) + ) + + v1 = self.to_tuple()[:3] + v2 = other.to_tuple()[:3] + x = cmp(v1, v2) + if x: + return x + + rc1, rc2 = self.prerelease, other.prerelease + rccmp = _nat_cmp(rc1, rc2) + + if not rccmp: + return 0 + if not rc1: + return 1 + elif not rc2: + return -1 + + return rccmp + + def next_version(self, part, prerelease_token="rc"): + """ + Determines next version, preserving natural order. + + .. versionadded:: 2.10.0 + + This function is taking prereleases into account. + The "major", "minor", and "patch" raises the respective parts like + the ``bump_*`` functions. The real difference is using the + "preprelease" part. It gives you the next patch version of the prerelease, + for example: + + >>> str(semver.VersionInfo.parse("0.1.4").next_version("prerelease")) + '0.1.5-rc.1' + + :param part: One of "major", "minor", "patch", or "prerelease" + :param prerelease_token: prefix string of prerelease, defaults to 'rc' + :return: new object with the appropriate part raised + :rtype: :class:`VersionInfo` + """ + validparts = { + "major", + "minor", + "patch", + "prerelease", + # "build", # currently not used + } + if part not in validparts: + raise ValueError( + "Invalid part. Expected one of {validparts}, but got {part!r}".format( + validparts=validparts, part=part + ) + ) + version = self + if (version.prerelease or version.build) and ( + part == "patch" + or (part == "minor" and version.patch == 0) + or (part == "major" and version.minor == version.patch == 0) + ): + return version.replace(prerelease=None, build=None) + + if part in ("major", "minor", "patch"): + return getattr(version, "bump_" + part)() + + if not version.prerelease: + version = version.bump_patch() + return version.bump_prerelease(prerelease_token) + + @comparator + def __eq__(self, other): + return self.compare(other) == 0 + + @comparator + def __ne__(self, other): + return self.compare(other) != 0 + + @comparator + def __lt__(self, other): + return self.compare(other) < 0 + + @comparator + def __le__(self, other): + return self.compare(other) <= 0 + + @comparator + def __gt__(self, other): + return self.compare(other) > 0 + + @comparator + def __ge__(self, other): + return self.compare(other) >= 0 + + def __getitem__(self, index): + """ + self.__getitem__(index) <==> self[index] + + Implement getitem. If the part requested is undefined, or a part of the + range requested is undefined, it will throw an index error. + Negative indices are not supported + + :param Union[int, slice] index: a positive integer indicating the + offset or a :func:`slice` object + :raises: IndexError, if index is beyond the range or a part is None + :return: the requested part of the version at position index + + >>> ver = semver.VersionInfo.parse("3.4.5") + >>> ver[0], ver[1], ver[2] + (3, 4, 5) + """ + if isinstance(index, int): + index = slice(index, index + 1) + + if ( + isinstance(index, slice) + and (index.start is not None and index.start < 0) + or (index.stop is not None and index.stop < 0) + ): + raise IndexError("Version index cannot be negative") + + part = tuple(filter(lambda p: p is not None, self.to_tuple()[index])) + + if len(part) == 1: + part = part[0] + elif not part: + raise IndexError("Version part undefined") + return part + + def __repr__(self): + s = ", ".join("%s=%r" % (key, val) for key, val in self.to_dict().items()) + return "%s(%s)" % (type(self).__name__, s) + + def __str__(self): + """str(self)""" + version = "%d.%d.%d" % (self.major, self.minor, self.patch) + if self.prerelease: + version += "-%s" % self.prerelease + if self.build: + version += "+%s" % self.build + return version + + def __hash__(self): + return hash(self.to_tuple()[:4]) + + def finalize_version(self): + """ + Remove any prerelease and build metadata from the version. + + :return: a new instance with the finalized version string + :rtype: :class:`VersionInfo` + + >>> str(semver.VersionInfo.parse('1.2.3-rc.5').finalize_version()) + '1.2.3' + """ + cls = type(self) + return cls(self.major, self.minor, self.patch) + + def match(self, match_expr): + """ + Compare self to match a match expression. + + :param str match_expr: operator and version; valid operators are + < smaller than + > greater than + >= greator or equal than + <= smaller or equal than + == equal + != not equal + :return: True if the expression matches the version, otherwise False + :rtype: bool + + >>> semver.VersionInfo.parse("2.0.0").match(">=1.0.0") + True + >>> semver.VersionInfo.parse("1.0.0").match(">1.0.0") + False + """ + prefix = match_expr[:2] + if prefix in (">=", "<=", "==", "!="): + match_version = match_expr[2:] + elif prefix and prefix[0] in (">", "<"): + prefix = prefix[0] + match_version = match_expr[1:] + else: + raise ValueError( + "match_expr parameter should be in format , " + "where is one of " + "['<', '>', '==', '<=', '>=', '!=']. " + "You provided: %r" % match_expr + ) + + possibilities_dict = { + ">": (1,), + "<": (-1,), + "==": (0,), + "!=": (-1, 1), + ">=": (0, 1), + "<=": (-1, 0), + } + + possibilities = possibilities_dict[prefix] + cmp_res = self.compare(match_version) + + return cmp_res in possibilities + + @classmethod + def parse(cls, version): + """ + Parse version string to a VersionInfo instance. + + :param version: version string + :return: a :class:`VersionInfo` instance + :raises: :class:`ValueError` + :rtype: :class:`VersionInfo` + + .. versionchanged:: 2.11.0 + Changed method from static to classmethod to + allow subclasses. + + >>> semver.VersionInfo.parse('3.4.5-pre.2+build.4') + VersionInfo(major=3, minor=4, patch=5, \ +prerelease='pre.2', build='build.4') + """ + match = cls._REGEX.match(ensure_str(version)) + if match is None: + raise ValueError("%s is not valid SemVer string" % version) + + version_parts = match.groupdict() + + version_parts["major"] = int(version_parts["major"]) + version_parts["minor"] = int(version_parts["minor"]) + version_parts["patch"] = int(version_parts["patch"]) + + return cls(**version_parts) + + def replace(self, **parts): + """ + Replace one or more parts of a version and return a new + :class:`VersionInfo` object, but leave self untouched + + .. versionadded:: 2.9.0 + Added :func:`VersionInfo.replace` + + :param dict parts: the parts to be updated. Valid keys are: + ``major``, ``minor``, ``patch``, ``prerelease``, or ``build`` + :return: the new :class:`VersionInfo` object with the changed + parts + :raises: :class:`TypeError`, if ``parts`` contains invalid keys + """ + version = self.to_dict() + version.update(parts) + try: + return VersionInfo(**version) + except TypeError: + unknownkeys = set(parts) - set(self.to_dict()) + error = "replace() got %d unexpected keyword " "argument(s): %s" % ( + len(unknownkeys), + ", ".join(unknownkeys), + ) + raise TypeError(error) + + @classmethod + def isvalid(cls, version): + """ + Check if the string is a valid semver version. + + .. versionadded:: 2.9.1 + + :param str version: the version string to check + :return: True if the version string is a valid semver version, False + otherwise. + :rtype: bool + """ + try: + cls.parse(version) + return True + except ValueError: + return False + + +@deprecated(replace="semver.VersionInfo.parse", version="2.10.0") +def parse_version_info(version): + """ + Parse version string to a VersionInfo instance. + + .. deprecated:: 2.10.0 + Use :func:`semver.VersionInfo.parse` instead. + + .. versionadded:: 2.7.2 + Added :func:`semver.parse_version_info` + + :param version: version string + :return: a :class:`VersionInfo` instance + :rtype: :class:`VersionInfo` + + >>> version_info = semver.VersionInfo.parse("3.4.5-pre.2+build.4") + >>> version_info.major + 3 + >>> version_info.minor + 4 + >>> version_info.patch + 5 + >>> version_info.prerelease + 'pre.2' + >>> version_info.build + 'build.4' + """ + return VersionInfo.parse(version) + + +def _nat_cmp(a, b): + def convert(text): + return int(text) if re.match("^[0-9]+$", text) else text + + def split_key(key): + return [convert(c) for c in key.split(".")] + + def cmp_prerelease_tag(a, b): + if isinstance(a, int) and isinstance(b, int): + return cmp(a, b) + elif isinstance(a, int): + return -1 + elif isinstance(b, int): + return 1 + else: + return cmp(a, b) + + a, b = a or "", b or "" + a_parts, b_parts = split_key(a), split_key(b) + for sub_a, sub_b in zip(a_parts, b_parts): + cmp_result = cmp_prerelease_tag(sub_a, sub_b) + if cmp_result != 0: + return cmp_result + else: + return cmp(len(a), len(b)) + + +@deprecated(version="2.10.0") +def compare(ver1, ver2): + """ + Compare two versions strings. + + :param ver1: version string 1 + :param ver2: version string 2 + :return: The return value is negative if ver1 < ver2, + zero if ver1 == ver2 and strictly positive if ver1 > ver2 + :rtype: int + + >>> semver.compare("1.0.0", "2.0.0") + -1 + >>> semver.compare("2.0.0", "1.0.0") + 1 + >>> semver.compare("2.0.0", "2.0.0") + 0 + """ + v1 = VersionInfo.parse(ver1) + return v1.compare(ver2) + + +@deprecated(version="2.10.0") +def match(version, match_expr): + """ + Compare two versions strings through a comparison. + + :param str version: a version string + :param str match_expr: operator and version; valid operators are + < smaller than + > greater than + >= greator or equal than + <= smaller or equal than + == equal + != not equal + :return: True if the expression matches the version, otherwise False + :rtype: bool + + >>> semver.match("2.0.0", ">=1.0.0") + True + >>> semver.match("1.0.0", ">1.0.0") + False + """ + ver = VersionInfo.parse(version) + return ver.match(match_expr) + + +@deprecated(replace="max", version="2.10.2") +def max_ver(ver1, ver2): + """ + Returns the greater version of two versions strings. + + :param ver1: version string 1 + :param ver2: version string 2 + :return: the greater version of the two + :rtype: :class:`VersionInfo` + + >>> semver.max_ver("1.0.0", "2.0.0") + '2.0.0' + """ + if isinstance(ver1, string_types): + ver1 = VersionInfo.parse(ver1) + elif not isinstance(ver1, VersionInfo): + raise TypeError() + cmp_res = ver1.compare(ver2) + if cmp_res >= 0: + return str(ver1) + else: + return ver2 + + +@deprecated(replace="min", version="2.10.2") +def min_ver(ver1, ver2): + """ + Returns the smaller version of two versions strings. + + :param ver1: version string 1 + :param ver2: version string 2 + :return: the smaller version of the two + :rtype: :class:`VersionInfo` + + >>> semver.min_ver("1.0.0", "2.0.0") + '1.0.0' + """ + ver1 = VersionInfo.parse(ver1) + cmp_res = ver1.compare(ver2) + if cmp_res <= 0: + return str(ver1) + else: + return ver2 + + +@deprecated(replace="str(versionobject)", version="2.10.0") +def format_version(major, minor, patch, prerelease=None, build=None): + """ + Format a version string according to the Semantic Versioning specification. + + .. deprecated:: 2.10.0 + Use ``str(VersionInfo(VERSION)`` instead. + + :param int major: the required major part of a version + :param int minor: the required minor part of a version + :param int patch: the required patch part of a version + :param str prerelease: the optional prerelease part of a version + :param str build: the optional build part of a version + :return: the formatted string + :rtype: str + + >>> semver.format_version(3, 4, 5, 'pre.2', 'build.4') + '3.4.5-pre.2+build.4' + """ + return str(VersionInfo(major, minor, patch, prerelease, build)) + + +@deprecated(version="2.10.0") +def bump_major(version): + """ + Raise the major part of the version string. + + .. deprecated:: 2.10.0 + Use :func:`semver.VersionInfo.bump_major` instead. + + :param: version string + :return: the raised version string + :rtype: str + + >>> semver.bump_major("3.4.5") + '4.0.0' + """ + return str(VersionInfo.parse(version).bump_major()) + + +@deprecated(version="2.10.0") +def bump_minor(version): + """ + Raise the minor part of the version string. + + .. deprecated:: 2.10.0 + Use :func:`semver.VersionInfo.bump_minor` instead. + + :param: version string + :return: the raised version string + :rtype: str + + >>> semver.bump_minor("3.4.5") + '3.5.0' + """ + return str(VersionInfo.parse(version).bump_minor()) + + +@deprecated(version="2.10.0") +def bump_patch(version): + """ + Raise the patch part of the version string. + + .. deprecated:: 2.10.0 + Use :func:`semver.VersionInfo.bump_patch` instead. + + :param: version string + :return: the raised version string + :rtype: str + + >>> semver.bump_patch("3.4.5") + '3.4.6' + """ + return str(VersionInfo.parse(version).bump_patch()) + + +@deprecated(version="2.10.0") +def bump_prerelease(version, token="rc"): + """ + Raise the prerelease part of the version string. + + .. deprecated:: 2.10.0 + Use :func:`semver.VersionInfo.bump_prerelease` instead. + + :param version: version string + :param token: defaults to 'rc' + :return: the raised version string + :rtype: str + + >>> semver.bump_prerelease('3.4.5', 'dev') + '3.4.5-dev.1' + """ + return str(VersionInfo.parse(version).bump_prerelease(token)) + + +@deprecated(version="2.10.0") +def bump_build(version, token="build"): + """ + Raise the build part of the version string. + + .. deprecated:: 2.10.0 + Use :func:`semver.VersionInfo.bump_build` instead. + + :param version: version string + :param token: defaults to 'build' + :return: the raised version string + :rtype: str + + >>> semver.bump_build('3.4.5-rc.1+build.9') + '3.4.5-rc.1+build.10' + """ + return str(VersionInfo.parse(version).bump_build(token)) + + +@deprecated(version="2.10.0") +def finalize_version(version): + """ + Remove any prerelease and build metadata from the version string. + + .. deprecated:: 2.10.0 + Use :func:`semver.VersionInfo.finalize_version` instead. + + .. versionadded:: 2.7.9 + Added :func:`finalize_version` + + :param version: version string + :return: the finalized version string + :rtype: str + + >>> semver.finalize_version('1.2.3-rc.5') + '1.2.3' + """ + verinfo = VersionInfo.parse(version) + return str(verinfo.finalize_version()) + + +@deprecated(version="2.10.0") +def replace(version, **parts): + """ + Replace one or more parts of a version and return the new string. + + .. deprecated:: 2.10.0 + Use :func:`semver.VersionInfo.replace` instead. + + .. versionadded:: 2.9.0 + Added :func:`replace` + + :param str version: the version string to replace + :param dict parts: the parts to be updated. Valid keys are: + ``major``, ``minor``, ``patch``, ``prerelease``, or ``build`` + :return: the replaced version string + :raises: TypeError, if ``parts`` contains invalid keys + :rtype: str + + >>> import semver + >>> semver.replace("1.2.3", major=2, patch=10) + '2.2.10' + """ + return str(VersionInfo.parse(version).replace(**parts)) + + +# ---- CLI +def cmd_bump(args): + """ + Subcommand: Bumps a version. + + Synopsis: bump + can be major, minor, patch, prerelease, or build + + :param args: The parsed arguments + :type args: :class:`argparse.Namespace` + :return: the new, bumped version + """ + maptable = { + "major": "bump_major", + "minor": "bump_minor", + "patch": "bump_patch", + "prerelease": "bump_prerelease", + "build": "bump_build", + } + if args.bump is None: + # When bump is called without arguments, + # print the help and exit + args.parser.parse_args(["bump", "-h"]) + + ver = VersionInfo.parse(args.version) + # get the respective method and call it + func = getattr(ver, maptable[args.bump]) + return str(func()) + + +def cmd_check(args): + """ + Subcommand: Checks if a string is a valid semver version. + + Synopsis: check + + :param args: The parsed arguments + :type args: :class:`argparse.Namespace` + """ + if VersionInfo.isvalid(args.version): + return None + raise ValueError("Invalid version %r" % args.version) + + +def cmd_compare(args): + """ + Subcommand: Compare two versions + + Synopsis: compare + + :param args: The parsed arguments + :type args: :class:`argparse.Namespace` + """ + return str(compare(args.version1, args.version2)) + + +def cmd_nextver(args): + """ + Subcommand: Determines the next version, taking prereleases into account. + + Synopsis: nextver + + :param args: The parsed arguments + :type args: :class:`argparse.Namespace` + """ + version = VersionInfo.parse(args.version) + return str(version.next_version(args.part)) + + +def createparser(): + """ + Create an :class:`argparse.ArgumentParser` instance. + + :return: parser instance + :rtype: :class:`argparse.ArgumentParser` + """ + parser = argparse.ArgumentParser(prog=__package__, description=__doc__) + + parser.add_argument( + "--version", action="version", version="%(prog)s " + __version__ + ) + + s = parser.add_subparsers() + # create compare subcommand + parser_compare = s.add_parser("compare", help="Compare two versions") + parser_compare.set_defaults(func=cmd_compare) + parser_compare.add_argument("version1", help="First version") + parser_compare.add_argument("version2", help="Second version") + + # create bump subcommand + parser_bump = s.add_parser("bump", help="Bumps a version") + parser_bump.set_defaults(func=cmd_bump) + sb = parser_bump.add_subparsers(title="Bump commands", dest="bump") + + # Create subparsers for the bump subparser: + for p in ( + sb.add_parser("major", help="Bump the major part of the version"), + sb.add_parser("minor", help="Bump the minor part of the version"), + sb.add_parser("patch", help="Bump the patch part of the version"), + sb.add_parser("prerelease", help="Bump the prerelease part of the version"), + sb.add_parser("build", help="Bump the build part of the version"), + ): + p.add_argument("version", help="Version to raise") + + # Create the check subcommand + parser_check = s.add_parser( + "check", help="Checks if a string is a valid semver version" + ) + parser_check.set_defaults(func=cmd_check) + parser_check.add_argument("version", help="Version to check") + + # Create the nextver subcommand + parser_nextver = s.add_parser( + "nextver", help="Determines the next version, taking prereleases into account." + ) + parser_nextver.set_defaults(func=cmd_nextver) + parser_nextver.add_argument("version", help="Version to raise") + parser_nextver.add_argument( + "part", help="One of 'major', 'minor', 'patch', or 'prerelease'" + ) + return parser + + +def process(args): + """ + Process the input from the CLI. + + :param args: The parsed arguments + :type args: :class:`argparse.Namespace` + :param parser: the parser instance + :type parser: :class:`argparse.ArgumentParser` + :return: result of the selected action + :rtype: str + """ + if not hasattr(args, "func"): + args.parser.print_help() + raise SystemExit() + + # Call the respective function object: + return args.func(args) + + +def main(cliargs=None): + """ + Entry point for the application script. + + :param list cliargs: Arguments to parse or None (=use :class:`sys.argv`) + :return: error code + :rtype: int + """ + try: + parser = createparser() + args = parser.parse_args(args=cliargs) + # Save parser instance: + args.parser = parser + result = process(args) + if result is not None: + print(result) + return 0 + + except (ValueError, TypeError) as err: + print("ERROR", err, file=sys.stderr) + return 2 + + +if __name__ == "__main__": + import doctest + + doctest.testmod() diff --git a/views/_main.html b/views/_main.html index 948e3171a..5cba5c3f7 100644 --- a/views/_main.html +++ b/views/_main.html @@ -84,6 +84,11 @@ margin-bottom: 0.5em; } + #sidebar-nav-notif { + position: absolute; + bottom: 0; + } + {% endblock head %} @@ -174,7 +179,7 @@ data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> @@ -487,7 +506,7 @@ events.close(); }); - $('#restart').on('click', function () { + $('.restart_action').on('click', function () { $('#loader_button').prop("hidden", true); $('#loader_text').text("Bazarr is restarting, please wait..."); $('#reconnect_overlay').show(); diff --git a/views/settingsgeneral.html b/views/settingsgeneral.html index c0dec5b57..49adf4391 100644 --- a/views/settingsgeneral.html +++ b/views/settingsgeneral.html @@ -269,18 +269,6 @@ -
-
- Restart After Update -
-
- - -
-
@@ -324,7 +312,7 @@ $('#save_button').prop('disabled', true).css('cursor', 'not-allowed'); // Hide update_div if args.no-update - {% if args.no_update or args.release_update %} + {% if args.no_update %} $('#update_div').hide() {% endif %} @@ -357,7 +345,6 @@ $('#settings-general-debug').prop('checked', {{'true' if settings.general.getboolean('debug') else 'false'}}); $('#settings-analytics-enabled').prop('checked', {{'true' if settings.analytics.getboolean('enabled') else 'false'}}); $('#settings-general-auto_update').prop('checked', {{'true' if settings.general.getboolean('auto_update') else 'false'}}); - $('#settings-general-update_restart').prop('checked', {{'true' if settings.general.getboolean('update_restart') else 'false'}}); $('#save_button').on('click', function() { var formdata = new FormData(document.getElementById("settings_form"));