diff --git a/.versioneer-lookup b/.versioneer-lookup index 5a20c7e4..ed486827 100644 --- a/.versioneer-lookup +++ b/.versioneer-lookup @@ -7,10 +7,10 @@ # The file is processed from top to bottom, the first matching line wins. If or are left out, # the lookup table does not apply to the matched branches -# master, prerelease and rc shall not use the lookup table, only tags +# master and rc shall not use the lookup table, only tags master +rc/.* prerelease -rc # neither should disconnected checkouts, e.g. 'git checkout ' HEAD diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 427d2b44..733c9241 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -299,14 +299,9 @@ There are three main branches in OctoPrint: * `master`: The master branch always contains the current stable release. It is *only* updated on new releases. Will have a version number following - the scheme `x.y.z` (e.g. `1.2.9`) or - if it's absolutely necessary to - add a commit after release to this branch - `x.y.z.post` + the scheme `..` (e.g. `1.2.9`) or - if it's absolutely necessary to + add a commit after release to this branch - `...post` (e.g. `1.2.9.post1`). - * `prerelease`: This branch is only used during the short period where a - future release has "graduated" from the `maintenance` branch and is already - tagged, but still marked on Github as a pre-release. This is mostly used for - update testing just before new releases. Version number follows the scheme - `x.y.z` (e.g. `1.2.9`), just like the `master` branch. * `maintenance`: Improvements and fixes of the current release that make up the next release go here. More or less continously updated. You can consider this a preview of the next release version. It should be very stable at all @@ -314,7 +309,7 @@ There are three main branches in OctoPrint: next stable release, so if you want to help out development, running the `maintenance` branch and reporting back anything you find is a very good way to do that. Will usually have a version number following the scheme - `x.y.z+1.dev.` for an OctoPrint version of `x.y.z` + `...dev` for an OctoPrint version of `..` (e.g. `1.2.10.dev12`). * `devel`: Ongoing development of new features that will go into the next bigger release (MINOR version number increases) will happen on this branch. Usually @@ -322,8 +317,16 @@ There are three main branches in OctoPrint: temporarily. Can be considered the "bleeding edge". All PRs should target *this* branch. Important improvements and fixes from PRs here are backported to `maintenance` as needed. Will usually have a version number following the - scheme `x.y+1.0.dev` for an OctoPrint version - of `x.y.z` (e.g. `1.3.0.dev123`). + scheme `..0.dev` for a current OctoPrint version + of `..` (e.g. `1.3.0.dev123`). + * `rc/maintenance`: This branch is reserved for future releases that have graduated from + the `maintenance` branch and are now being pushed on the "Maintenance" + pre release channel for further testing. Version number follows the scheme + `..rc` (e.g. `1.2.9rc1`). + * `rc/devel`: This branch is reserved for future releases that have graduated from + the `devel` branch and are now being pushed on the "Devel" pre release channel + for further testing. Version number follows the scheme `..0rc` (e.g. `1.3.0rc1`) + for a current stable OctoPrint version of `..`. Additionally, from time to time you might see other branches pop up in the repository. Those usually have one of the following prefixes: @@ -334,9 +337,6 @@ Those usually have one of the following prefixes: `maintenance` and `devel` branches. * `dev/...` or `feature/...`: New functionality under development that is to be merged into the `devel` branch. - * `rc`: A branch similar in nature to the `prerelease` branch, only that it will be - used to provide current release candidates of the next stable version to be derived - from the `devel` branch. There is also the `gh-pages` branch, which holds OctoPrint's web page, and a couple of older development branches that are slowly being migrated or deleted. diff --git a/src/octoprint/plugins/softwareupdate/__init__.py b/src/octoprint/plugins/softwareupdate/__init__.py index b2a06dac..3177d495 100644 --- a/src/octoprint/plugins/softwareupdate/__init__.py +++ b/src/octoprint/plugins/softwareupdate/__init__.py @@ -20,7 +20,7 @@ from . import version_checks, updaters, exceptions, util, cli from octoprint.server.util.flask import restricted_access -from octoprint.server import admin_permission, VERSION, REVISION +from octoprint.server import admin_permission, VERSION, REVISION, BRANCH from octoprint.util import dict_merge import octoprint.settings @@ -155,14 +155,18 @@ class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin, #~~ SettingsPlugin API def get_settings_defaults(self): + update_script = os.path.join(self._basefolder, "scripts", "update-octoprint.py") return { "checks": { "octoprint": { "type": "github_release", "user": "foosel", "repo": "OctoPrint", - "update_script": "{{python}} \"{update_script}\" --python=\"{{python}}\" \"{{folder}}\" {{target}}".format(update_script=os.path.join(self._basefolder, "scripts", "update-octoprint.py")), - "restart": "octoprint" + "update_script": "{{python}} \"{update_script}\" --branch={{branch}} --force={{force}} \"{{folder}}\" {{target}}".format(update_script=update_script), + "restart": "octoprint", + "stable_branch": dict(branch="master", name="Stable"), + "prerelease_branches": [dict(branch="rc/maintenance", name="Maintenance RCs"), + dict(branch="rc/devel", name="Devel RCs")] }, }, "pip_command": None, @@ -176,19 +180,47 @@ class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin, if "checks" in data: del data["checks"] + if "check_providers" in data: + del data["check_providers"] + checks = self._get_configured_checks() if "octoprint" in checks: data["octoprint_checkout_folder"] = self._get_octoprint_checkout_folder(checks=checks) data["octoprint_type"] = checks["octoprint"].get("type", None) + + try: + data["octoprint_method"] = self._get_update_method("octoprint", checks["octoprint"]) + except exceptions.UnknownUpdateType: + data["octoprint_method"] = "unknown" + + stable_branch = None + prerelease_branches = [] + branch_mappings = [] + if "stable_branch" in checks["octoprint"]: + branch_mappings.append(checks["octoprint"]["stable_branch"]) + stable_branch = checks["octoprint"]["stable_branch"]["branch"] + if "prerelease_branches" in checks["octoprint"]: + for mapping in checks["octoprint"]["prerelease_branches"]: + branch_mappings.append(mapping) + prerelease_branches.append(mapping["branch"]) + data["octoprint_branch_mappings"] = branch_mappings + + data["octoprint_release_channel"] = stable_branch + if checks["octoprint"].get("prerelease", False): + channel = checks["octoprint"].get("prerelease_channel", BRANCH) + if channel in prerelease_branches: + data["octoprint_release_channel"] = channel + else: data["octoprint_checkout_folder"] = None data["octoprint_type"] = None + data["octoprint_branch_mappings"] = [] return data def on_settings_save(self, data): for key in self.get_settings_defaults(): - if key == "checks" or key == "cache_ttl" or key == "octoprint_checkout_folder" or key == "octoprint_type": + if key in ("checks", "cache_ttl", "octoprint_checkout_folder", "octoprint_type", "octoprint_release_channel"): continue if key in data: self._settings.set([key], data[key]) @@ -203,6 +235,11 @@ class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin, update_type = check.get("type", None) checkout_folder = check.get("checkout_folder", None) update_folder = check.get("update_folder", None) + prerelease = check.get("prerelease", False) + prerelease_channel = check.get("prerelease_channel", None) + else: + update_type = checkout_folder = update_folder = prerelease_channel = None + prerelease = False defaults = dict( plugins=dict(softwareupdate=dict( @@ -210,7 +247,9 @@ class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin, octoprint=dict( type=update_type, checkout_folder=checkout_folder, - update_folder=update_folder + update_folder=update_folder, + prerelease=prerelease, + prerelease_channel=prerelease_channel ) ) )) @@ -236,6 +275,17 @@ class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin, pass self._version_cache_dirty = True + if "octoprint_release_channel" in data: + prerelease_branches = self._settings.get(["checks", "octoprint", "prerelease_branches"]) + if prerelease_branches and data["octoprint_release_channel"] in [x["branch"] for x in prerelease_branches]: + self._settings.set(["checks", "octoprint", "prerelease"], True, defaults=defaults, force=True) + self._settings.set(["checks", "octoprint", "prerelease_channel"], data["octoprint_release_channel"], defaults=defaults, force=True) + self._refresh_configured_checks = True + else: + self._settings.set(["checks", "octoprint", "prerelease"], False, defaults=defaults, force=True) + self._settings.set(["checks", "octoprint", "prerelease_channel"], None, defaults=defaults, force=True) + self._refresh_configured_checks = True + def get_settings_version(self): return 4 @@ -368,7 +418,8 @@ class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin, try: information, update_available, update_possible = self.get_current_versions(check_targets=check_targets, force=force) - return flask.jsonify(dict(status="updatePossible" if update_available and update_possible else "updateAvailable" if update_available else "current", information=information)) + return flask.jsonify(dict(status="updatePossible" if update_available and update_possible else "updateAvailable" if update_available else "current", + information=information)) except exceptions.ConfigurationInvalid as e: flask.make_response("Update not properly configured, can't proceed: %s" % e.message, 500) @@ -621,6 +672,7 @@ class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin, # one of our updates requires a restart of either type "octoprint" or "environment". Let's see if # we can actually perform that + restart_command = None if restart_type == "octoprint": restart_command = self._settings.global_get(["server", "commands", "serverRestartCommand"]) elif restart_type == "environment": @@ -729,18 +781,70 @@ class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin, result["displayName"] = check.get("displayName", gettext("OctoPrint")) result["displayVersion"] = check.get("displayVersion", "{octoprint_version}") + stable_branch = "master" + release_branches = [] + if "stable_branch" in check: + release_branches.append(check["stable_branch"]["branch"]) + stable_branch = check["stable_branch"]["branch"] + if "prerelease_branches" in check: + release_branches += [x["branch"] for x in check["prerelease_branches"]] + result["released_version"] = not release_branches or BRANCH in release_branches + if check["type"] == "github_commit": result["current"] = REVISION if REVISION else "unknown" else: result["current"] = VERSION + + if check["type"] == "github_release" and (check["prerelease"] or BRANCH != stable_branch): + # we are tracking github releases and are either also tracking prerelease OR are currently installed + # from something that is not the stable (master) branch => we need to change some parameters + + # we compare versions fully, not just the base so that we see a difference + # between RCs + stable for the same version release + result["force_base"] = False + + if check.get("update_script", None): + # if we are using the update_script, we need to set our update_branch and force + # to install the exact version we requested + + if check["prerelease"]: + # we are tracking prereleases => we want to be on the correct prerelease channel/branch + channel = check.get("prerelease_channel", None) + if channel: + # if we have a release channel, we also set our update_branch here to our release channel + # in case it's not already set + result["update_branch"] = check.get("update_branch", channel) + + # we also force our target version in the update + result["force_exact_version"] = True + + else: + # we are not tracking prereleases, but aren't on the stable branch either => switch back + # to stable branch on update + result["update_branch"] = check.get("update_branch", stable_branch) + + + if BRANCH != result.get("prerelease_channel"): + # we force python unequality check here because that will also allow us to + # downgrade on a prerelease channel change (rc/devel => rc/maintenance) + # + # we detect channel changes by comparing the current branch with the target + # branch of the release channel - unequality means we might have to handle + # a downgrade + result["release_compare"] = "python_unequal" + else: result["displayName"] = check.get("displayName", target) result["displayVersion"] = check.get("displayVersion", check.get("current", "unknown")) - if check["type"] in ("github_commit"): + if check["type"] in ("github_commit",): result["current"] = check.get("current", None) else: result["current"] = check.get("current", check.get("displayVersion", None)) + if "pip" in result: + if not "pip_command" in check and self._settings.get(["pip_command"]) is not None: + result["pip_command"] = self._settings.get(["pip_command"]) + return result def _log(self, lines, prefix=None, stream=None, strip=True): @@ -777,22 +881,45 @@ class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin, else: raise exceptions.UnknownCheckType() + def _get_update_method(self, target, check, valid_methods=None): + """ + Determines the update method for the given target and check. + + If ``valid_methods`` is provided, determine method must be contained + therein to be considered valid. + + Raises an ``UnknownUpdateType`` exception if method cannot be determined + or validated. + """ + + method = None + if "method" in check: + method = check["method"] + else: + if "update_script" in check: + method = "update_script" + elif "pip" in check: + method = "pip" + elif "python_updater" in check: + method = "python_updated" + + if method is None or (valid_methods and not method in valid_methods): + raise exceptions.UnknownUpdateType() + + return method + def _get_updater(self, target, check): """ Retrieves the updater for the given target and check configuration. Will raise an UnknownUpdateType if updater cannot be determined. """ - if "update_script" in check: - return updaters.update_script - elif "pip" in check: - if not "pip_command" in check and self._settings.get(["pip_command"]) is not None: - check["pip_command"] = self._settings.get(["pip_command"]) - return updaters.pip - elif "python_updater" in check: - return updaters.python_updater - else: - raise exceptions.UnknownUpdateType() + mapping = dict(update_script=updaters.update_script, + pip=updaters.pip, + python_updater=updaters.python_updater) + + method = self._get_update_method(target, check, valid_methods=mapping.keys()) + return mapping[method] def _get_octoprint_checkout_folder(self, checks=None): if checks is None: diff --git a/src/octoprint/plugins/softwareupdate/scripts/update-octoprint.py b/src/octoprint/plugins/softwareupdate/scripts/update-octoprint.py index 2d4b8ac6..8aade984 100644 --- a/src/octoprint/plugins/softwareupdate/scripts/update-octoprint.py +++ b/src/octoprint/plugins/softwareupdate/scripts/update-octoprint.py @@ -134,7 +134,7 @@ def _to_error(*lines): return u"".join(map(lambda x: _to_unicode(x, errors="replace"), lines)) -def update_source(git_executable, folder, target, force=False): +def _rescue_changes(git_executable, folder): print(">>> Running: git diff --shortstat") returncode, stdout, stderr = _git(["diff", "--shortstat"], folder, git_executable=git_executable) if returncode != 0: @@ -155,6 +155,13 @@ def update_source(git_executable, folder, target, force=False): for line in stdout: f.write(line) + return True + + return False + + +def update_source(git_executable, folder, target, force=False, branch=None): + if _rescue_changes(git_executable, folder): print(">>> Running: git reset --hard") returncode, stdout, stderr = _git(["reset", "--hard"], folder, git_executable=git_executable) if returncode != 0: @@ -165,6 +172,18 @@ def update_source(git_executable, folder, target, force=False): if returncode != 0: raise RuntimeError("Could not update, \"git clean -f\" failed with returcode %d: %s" % (returncode, _to_error(*stdout))) + print(">>> Running: git fetch") + returncode, stdout = _git(["fetch"], folder, git_executable=git_executable) + if returncode != 0: + raise RuntimeError("Could not update, \"git fetch\" failed with returncode %d: %s" % (returncode, stdout)) + print(stdout) + + if branch is not None and branch.strip() != "": + print(">>> Running: git checkout {}".format(branch)) + returncode, stdout = _git(["checkout", branch], folder, git_executable=git_executable) + if returncode != 0: + raise RuntimeError("Could not update, \"git checkout\" failed with returncode %d: %s" % (returncode, stdout)) + print(">>> Running: git pull") returncode, stdout, stderr = _git(["pull"], folder, git_executable=git_executable) if returncode != 0: @@ -199,18 +218,24 @@ def install_source(python_executable, folder, user=False, sudo=False): def parse_arguments(): import argparse + boolean_trues = ["true", "yes", "1"] + boolean_falses = ["false", "no", "0"] + parser = argparse.ArgumentParser(prog="update-octoprint.py") parser.add_argument("--git", action="store", type=str, dest="git_executable", help="Specify git executable to use") parser.add_argument("--python", action="store", type=str, dest="python_executable", help="Specify python executable to use") - parser.add_argument("--force", action="store_true", dest="force", - help="Set this to force the update to only the specified version (nothing newer)") + parser.add_argument("--force", action="store", type=lambda x: x in boolean_trues, + dest="force", default=False, + help="Set this to true to force the update to only the specified version (nothing newer, nothing older)") parser.add_argument("--sudo", action="store_true", dest="sudo", help="Install with sudo") parser.add_argument("--user", action="store_true", dest="user", help="Install to the user site directory instead of the general site directory") + parser.add_argument("--branch", action="store", type=str, dest="branch", default=None, + help="Specify the branch to make sure is checked out") parser.add_argument("folder", type=str, help="Specify the base folder of the OctoPrint installation to update") parser.add_argument("target", type=str, @@ -238,13 +263,12 @@ def main(): print("Python executable: {!r}".format(python_executable)) folder = args.folder - target = args.target import os if not os.access(folder, os.W_OK): raise RuntimeError("Could not update, base folder is not writable") - update_source(git_executable, folder, target, force=args.force) + update_source(git_executable, folder, args.target, force=args.force, branch=args.branch) install_source(python_executable, folder, user=args.user, sudo=args.sudo) if __name__ == "__main__": diff --git a/src/octoprint/plugins/softwareupdate/static/js/softwareupdate.js b/src/octoprint/plugins/softwareupdate/static/js/softwareupdate.js index b4feaeed..0a627986 100644 --- a/src/octoprint/plugins/softwareupdate/static/js/softwareupdate.js +++ b/src/octoprint/plugins/softwareupdate/static/js/softwareupdate.js @@ -82,14 +82,14 @@ $(function() { self.config_cacheTtl = ko.observable(); self.config_checkoutFolder = ko.observable(); self.config_checkType = ko.observable(); + self.config_updateMethod = ko.observable(); + self.config_releaseChannel = ko.observable(); self.configurationDialog = $("#settings_plugin_softwareupdate_configurationdialog"); self.confirmationDialog = $("#softwareupdate_confirmation_dialog"); - self.config_availableCheckTypes = [ - {"key": "github_release", "name": gettext("Release")}, - {"key": "git_commit", "name": gettext("Commit")} - ]; + self.config_availableCheckTypes = ko.observableArray([]); + self.config_availableReleaseChannels = ko.observableArray([]); self.reloadOverlay = $("#reloadui_overlay"); @@ -161,7 +161,8 @@ $(function() { softwareupdate: { cache_ttl: parseInt(self.config_cacheTtl()), octoprint_checkout_folder: self.config_checkoutFolder(), - octoprint_type: self.config_checkType() + octoprint_type: self.config_checkType(), + octoprint_release_channel: self.config_releaseChannel() } } }; @@ -179,9 +180,28 @@ $(function() { }; self._copyConfig = function() { + var updateMethod = self.settings.settings.plugins.softwareupdate.octoprint_method(); + + var availableCheckTypes = []; + if (updateMethod == "update_script" || updateMethod == "python") { + availableCheckTypes = [{"key": "github_release", "name": gettext("Release")}, + {"key": "git_commit", "name": gettext("Commit")}]; + } else { + availableCheckTypes = []; + } + self.config_availableCheckTypes(availableCheckTypes); + + var availableReleaseChannels = []; + _.each(self.settings.settings.plugins.softwareupdate.octoprint_branch_mappings(), function(mapping) { + availableReleaseChannels.push({"key": mapping.branch(), "name": gettext(mapping.name() || mapping.branch())}); + }); + self.config_availableReleaseChannels(availableReleaseChannels); + + self.config_updateMethod(updateMethod); self.config_cacheTtl(self.settings.settings.plugins.softwareupdate.cache_ttl()); self.config_checkoutFolder(self.settings.settings.plugins.softwareupdate.octoprint_checkout_folder()); self.config_checkType(self.settings.settings.plugins.softwareupdate.octoprint_type()); + self.config_releaseChannel(self.settings.settings.plugins.softwareupdate.octoprint_release_channel()); }; self._copyConfigBack = function() { @@ -220,7 +240,7 @@ $(function() { var octoprint = data.information["octoprint"]; if (octoprint && octoprint.hasOwnProperty("check")) { var check = octoprint.check; - if (BRANCH != "master" && check["type"] == "github_release") { + if (check["released_version"] === false && check["type"] == "github_release") { self.octoprintUnreleased(true); } else { self.octoprintUnreleased(false); @@ -228,8 +248,8 @@ $(function() { var checkoutFolder = (check["checkout_folder"] || "").trim(); var updateFolder = (check["update_folder"] || "").trim(); - var checkType = check["type"] || ""; - if ((checkType == "github_release" || checkType == "git_commit") && checkoutFolder == "" && updateFolder == "") { + var needsFolder = check["update_script"] || false; + if (needsFolder && checkoutFolder == "" && updateFolder == "") { self.octoprintUnconfigured(true); } else { self.octoprintUnconfigured(false); diff --git a/src/octoprint/plugins/softwareupdate/templates/_snippets/plugins/softwareupdate/checkoutFolder.jinja2 b/src/octoprint/plugins/softwareupdate/templates/_snippets/plugins/softwareupdate/checkoutFolder.jinja2 index 1804d11f..b45c8655 100644 --- a/src/octoprint/plugins/softwareupdate/templates/_snippets/plugins/softwareupdate/checkoutFolder.jinja2 +++ b/src/octoprint/plugins/softwareupdate/templates/_snippets/plugins/softwareupdate/checkoutFolder.jinja2 @@ -1,4 +1,4 @@ -
+
diff --git a/src/octoprint/plugins/softwareupdate/templates/_snippets/plugins/softwareupdate/releaseChannel.jinja2 b/src/octoprint/plugins/softwareupdate/templates/_snippets/plugins/softwareupdate/releaseChannel.jinja2 new file mode 100644 index 00000000..59c6720e --- /dev/null +++ b/src/octoprint/plugins/softwareupdate/templates/_snippets/plugins/softwareupdate/releaseChannel.jinja2 @@ -0,0 +1,6 @@ +
+ +
+ +
+
diff --git a/src/octoprint/plugins/softwareupdate/templates/_snippets/plugins/softwareupdate/versionTracking.jinja2 b/src/octoprint/plugins/softwareupdate/templates/_snippets/plugins/softwareupdate/versionTracking.jinja2 index 93ce4493..d53d21a6 100644 --- a/src/octoprint/plugins/softwareupdate/templates/_snippets/plugins/softwareupdate/versionTracking.jinja2 +++ b/src/octoprint/plugins/softwareupdate/templates/_snippets/plugins/softwareupdate/versionTracking.jinja2 @@ -1,4 +1,4 @@ -
+
diff --git a/src/octoprint/plugins/softwareupdate/templates/softwareupdate_settings.jinja2 b/src/octoprint/plugins/softwareupdate/templates/softwareupdate_settings.jinja2 index 0254d765..31f8b94a 100644 --- a/src/octoprint/plugins/softwareupdate/templates/softwareupdate_settings.jinja2 +++ b/src/octoprint/plugins/softwareupdate/templates/softwareupdate_settings.jinja2 @@ -76,6 +76,7 @@
{% include "_snippets/plugins/softwareupdate/checkoutFolder.jinja2" %} {% include "_snippets/plugins/softwareupdate/versionTracking.jinja2" %} + {% include "_snippets/plugins/softwareupdate/releaseChannel.jinja2" %}
diff --git a/src/octoprint/plugins/softwareupdate/updaters/update_script.py b/src/octoprint/plugins/softwareupdate/updaters/update_script.py index 62a210af..009f622a 100644 --- a/src/octoprint/plugins/softwareupdate/updaters/update_script.py +++ b/src/octoprint/plugins/softwareupdate/updaters/update_script.py @@ -57,9 +57,11 @@ def perform_update(target, check, target_version, log_cb=None): raise ConfigurationInvalid("checkout_folder and update_folder are missing for update target %s, one is needed" % target) update_script = check["update_script"] - folder = check["update_folder"] if "update_folder" in check else check["checkout_folder"] - pre_update_script = check["pre_update_script"] if "pre_update_script" in check else None - post_update_script = check["post_update_script"] if "post_update_script" in check else None + update_branch = check.get("update_branch", "") + force_exact_version = check.get("force_exact_version", False) + folder = check.get("update_folder", check["checkout_folder"]) + pre_update_script = check.get("pre_update_script", None) + post_update_script = check.get("post_update_script", None) caller = _get_caller(log_cb=log_cb) @@ -75,7 +77,12 @@ def perform_update(target, check, target_version, log_cb=None): ### update try: - update_command = update_script.format(python=sys.executable, folder=folder, target=target_version) + update_command = update_script.format(python=sys.executable, + folder=folder, + target=target_version, + branch=update_branch, + force="true" if force_exact_version else "false") + logger.debug("Target %s, running update script: %s" % (target, update_command)) caller.checked_call(update_command, cwd=folder) diff --git a/src/octoprint/plugins/softwareupdate/version_checks/github_release.py b/src/octoprint/plugins/softwareupdate/version_checks/github_release.py index 644cefa1..e950a0e6 100644 --- a/src/octoprint/plugins/softwareupdate/version_checks/github_release.py +++ b/src/octoprint/plugins/softwareupdate/version_checks/github_release.py @@ -14,7 +14,69 @@ RELEASE_URL = "https://api.github.com/repos/{user}/{repo}/releases" logger = logging.getLogger("octoprint.plugins.softwareupdate.version_checks.github_release") -def _get_latest_release(user, repo, include_prerelease=False): +def _filter_out_latest(releases, + sort_key=None, + include_prerelease=False, + prerelease_channel=None): + """ + Filters out the newest of all matching releases. + + Tests: + + >>> release_1_2_15 = dict(name="1.2.15", tag_name="1.2.15", html_url="some_url", published_at="2016-07-29T19:53:29Z", prerelease=False, draft=False, target_commitish="prerelease") + >>> release_1_2_16rc1 = dict(name="1.2.16rc1", tag_name="1.2.16rc1", html_url="some_url", published_at="2016-08-29T12:00:00Z", prerelease=True, draft=False, target_commitish="rc/maintenance") + >>> release_1_2_16rc2 = dict(name="1.2.16rc2", tag_name="1.2.16rc2", html_url="some_url", published_at="2016-08-30T12:00:00Z", prerelease=True, draft=False, target_commitish="rc/maintenance") + >>> release_1_2_17rc1 = dict(name="1.2.17rc1", tag_name="1.2.17rc1", html_url="some_url", published_at="2016-08-31T12:00:00Z", prerelease=True, draft=True, target_commitish="rc/maintenance") + >>> release_1_3_0rc1 = dict(name="1.3.0rc1", tag_name="1.3.0rc1", html_url="some_url", published_at="2016-12-12T12:00:00Z", prerelease=True, draft=False, target_commitish="rc/devel") + >>> release_1_4_0rc1 = dict(name="1.4.0rc1", tag_name="1.4.0rc1", html_url="some_url", published_at="2017-12-12T12:00:00Z", prerelease=True, draft=False, target_commitish="rc/future") + >>> releases = [release_1_2_15, release_1_2_16rc1, release_1_2_16rc2, release_1_2_17rc1, release_1_3_0rc1, release_1_4_0rc1] + >>> _filter_out_latest(releases, include_prerelease=False, prerelease_channel=None) + ('1.2.15', '1.2.15', 'some_url') + >>> _filter_out_latest(releases, include_prerelease=True, prerelease_channel="rc/maintenance") + ('1.2.16rc2', '1.2.16rc2', 'some_url') + >>> _filter_out_latest(releases, include_prerelease=True, prerelease_channel="rc/devel") + ('1.3.0rc1', '1.3.0rc1', 'some_url') + >>> _filter_out_latest(releases, include_prerelease=True, prerelease_channel=None) + ('1.4.0rc1', '1.4.0rc1', 'some_url') + >>> _filter_out_latest(releases, include_prerelease=True, prerelease_channel="rc/doesntexist") + ('1.2.15', '1.2.15', 'some_url') + >>> _filter_out_latest([release_1_2_17rc1]) + (None, None, None) + >>> _filter_out_latest([release_1_2_16rc1, release_1_2_16rc2]) + (None, None, None) + """ + + nothing = None, None, None + + if sort_key is None: + sort_key = lambda release: release.get("published_at", None) + + # filter out prereleases and drafts + filter_function = lambda rel: not rel["prerelease"] and not rel["draft"] + if include_prerelease: + if prerelease_channel: + filter_function = lambda rel: not rel["draft"] and ( + not rel["prerelease"] or rel["target_commitish"] == prerelease_channel) + else: + filter_function = lambda rel: not rel["draft"] + + releases = filter(filter_function, releases) + if not releases: + return nothing + + # sort by sort_key + releases = sorted(releases, key=sort_key) + + # latest release = last in list + latest = releases[-1] + + return latest["name"], latest["tag_name"], latest.get("html_url", None) + + +def _get_latest_release(user, repo, compare_type, + include_prerelease=False, + prerelease_channel=None, + force_base=True): nothing = None, None, None r = requests.get(RELEASE_URL.format(user=user, repo=repo)) @@ -27,36 +89,55 @@ def _get_latest_release(user, repo, include_prerelease=False): releases = r.json() # sanitize - required_fields = {"name", "tag_name", "html_url", "draft", "prerelease", "published_at"} + required_fields = {"name", "tag_name", "html_url", "draft", "prerelease", "published_at", "target_commitish"} releases = filter(lambda rel: set(rel.keys()) & required_fields == required_fields, releases) - # filter out prereleases and drafts - if include_prerelease: - releases = filter(lambda rel: not rel["draft"], releases) - else: - releases = filter(lambda rel: not rel["prerelease"] and not rel["draft"], - releases) + comparable_factory = _get_comparable_factory(compare_type, + force_base=force_base) + sort_key = lambda release: comparable_factory(_get_sanitized_version(release["tag_name"])) - if not releases: - return nothing - - # sort by date - comp = lambda a, b: cmp(a.get("published_at", None), b["published_at"]) - releases = sorted(releases, cmp=comp) - - # latest release = last in list - latest = releases[-1] - - return latest["name"], latest["tag_name"], latest.get("html_url", None) + return _filter_out_latest(releases, + sort_key=sort_key, + include_prerelease=include_prerelease, + prerelease_channel=prerelease_channel) def _get_sanitized_version(version_string): + """ + Removes "-..." prefix from version strings. + + Tests: + >>> _get_sanitized_version("1.2.15") + '1.2.15' + >>> _get_sanitized_version("1.2.15-dev12") + '1.2.15' + """ if "-" in version_string: version_string = version_string[:version_string.find("-")] return version_string +def _get_base_from_version_tuple(version_tuple): + """ + Reduces version tuple to base version. + + Tests: + + >>> _get_base_from_version_tuple(("1", "2", "15")) + ('1', '2', '15') + >>> _get_base_from_version_tuple(("1", "2", "15", "*", "dev12")) + ('1', '2', '15') + """ + + base_version = [] + for part in version_tuple: + if part.startswith("*"): + break + base_version.append(part) + return tuple(base_version) + + def _get_comparable_version_pkg_resources(version_string, force_base=True): import pkg_resources @@ -65,12 +146,7 @@ def _get_comparable_version_pkg_resources(version_string, force_base=True): if force_base: if isinstance(version, tuple): # old setuptools - base_version = [] - for part in version: - if part.startswith("*"): - break - base_version.append(part) - version = tuple(base_version) + version = _get_base_from_version_tuple(version) else: # new setuptools version = pkg_resources.parse_version(version.base_version) @@ -90,32 +166,66 @@ def _get_comparable_version_semantic(version_string, force_base=True): return version +def _get_sanitized_compare_type(compare_type, custom=None): + if not compare_type in ("python", "python_unequal", + "semantic", "semantic_unequal", + "unequal", "custom") or compare_type == "custom" and custom is None: + compare_type = "python" + return compare_type + + +def _get_comparable_factory(compare_type, force_base=True): + if compare_type in ("python", "python_unequal"): + return lambda version: _get_comparable_version_pkg_resources(version, force_base=force_base) + elif compare_type in ("semantic", "semantic_unequal"): + return lambda version: _get_comparable_version_semantic(version, force_base=force_base) + else: + return lambda version: version + + +def _get_comparator(compare_type, custom=None): + if compare_type in ("python", "semantic"): + return lambda a, b: a >= b + elif compare_type == "custom": + return custom + else: + return lambda a, b: a == b + + def _is_current(release_information, compare_type, custom=None, force_base=True): + """ + Checks if the provided release information indicates the version being the most current one. + + Tests: + + >>> _is_current(dict(remote=dict(value=None)) + True + >>> _is_current(dict(local=dict(value="1.2.15"), remote=dict(value="1.2.16"))) + False + >>> _is_current(dict(local=dict(value="1.2.16dev1"), remote=dict(value="1.2.16dev2"))) + True + >>> _is_current(dict(local=dict(value="1.2.16dev1"), remote=dict(value="1.2.16dev2")), force_base=False) + False + >>> _is_current(dict(local=dict(value="1.2.16dev3"), remote=dict(value="1.2.16dev2")), force_base=False) + True + >>> _is_current(dict(local=dict(value="1.2.16dev3"), remote=dict(value="1.2.16dev2")), force_base=False, compare_type="python_unequal") + False + + """ + if release_information["remote"]["value"] is None: return True - if not compare_type in ("python", "semantic", "unequal", "custom") or compare_type == "custom" and custom is None: - compare_type = "python" + compare_type = _get_sanitized_compare_type(compare_type, custom=custom) + comparable_factory = _get_comparable_factory(compare_type, force_base=force_base) + comparator = _get_comparator(compare_type, custom=custom) sanitized_local = _get_sanitized_version(release_information["local"]["value"]) sanitized_remote = _get_sanitized_version(release_information["remote"]["value"]) try: - if compare_type == "python": - local_version = _get_comparable_version_pkg_resources(sanitized_local, force_base=force_base) - remote_version = _get_comparable_version_pkg_resources(sanitized_remote, force_base=force_base) - return local_version >= remote_version - - elif compare_type == "semantic": - local_version = _get_comparable_version_semantic(sanitized_local, force_base=force_base) - remote_version = _get_comparable_version_semantic(sanitized_remote, force_base=force_base) - return local_version >= remote_version - - elif compare_type == "custom": - return custom(sanitized_local, sanitized_remote) - - else: - return sanitized_local == sanitized_remote + return comparator(comparable_factory(sanitized_local), + comparable_factory(sanitized_remote)) except: logger.exception("Could not check if version is current due to an error, assuming it is") return True @@ -127,12 +237,17 @@ def get_latest(target, check, custom_compare=None): current = check.get("current", None) include_prerelease = check.get("prerelease", False) + prerelease_channel = check.get("prerelease_channel", None) force_base = check.get("force_base", True) + compare_type = _get_sanitized_compare_type(check.get("release_compare", "python"), + custom=custom_compare) remote_name, remote_tag, release_notes = _get_latest_release(check["user"], check["repo"], - include_prerelease=include_prerelease) - compare_type = check["release_compare"] if "release_compare" in check else "python" + compare_type, + include_prerelease=include_prerelease, + prerelease_channel=prerelease_channel, + force_base=force_base) information =dict( local=dict(name=current, value=current),