diff --git a/allzpark/allzparkconfig.py b/allzpark/allzparkconfig.py index f5a74cc..0c459f0 100644 --- a/allzpark/allzparkconfig.py +++ b/allzpark/allzparkconfig.py @@ -103,6 +103,29 @@ def metadata_from_package(variant): }) +def protected_preferences(): + """Protect preference settings + + Prevent clueless one from touching danger settings. + + Following is a list of preference names that you may lock: + * showAllApps (bool) + * showHiddenApps (bool) + * showAllVersions (bool) + * patchWithFilter (bool) + * clearCacheTimeout (int) + * exclusionFilter (str) + + This should return a preference name and default value paired + dict. For example: {"showAllVersions": False} + + Returns: + dict + + """ + return dict() + + def themes(): """Allzpark GUI theme list provider diff --git a/allzpark/control.py b/allzpark/control.py index 56c8f58..1ecd00e 100644 --- a/allzpark/control.py +++ b/allzpark/control.py @@ -205,6 +205,7 @@ class Controller(QtCore.QObject): _State("resolving", help="Rez is busy resolving a context"), _State("loading", help="Something is taking a moment"), _State("errored", help="Something has gone wrong"), + _State("console", help="Something need you to read"), _State("launching", help="An application is launching"), _State("ready", help="Awaiting user input"), _State("noprofiles", help="Allzpark did not find any profiles at all"), @@ -232,7 +233,7 @@ def __init__(self, # Docks "profiles": model.ProfileModel(), - "packages": model.PackagesModel(self), + "packages": model.PackagesModel(), "context": model.ContextModel(), "environment": model.EnvironmentModel(), "parentenv": model.EnvironmentModel(), @@ -355,7 +356,40 @@ def environ(self, app_request): return environ def resolved_packages(self, app_request): - return self._state["rezContexts"][app_request].resolved_packages + """Return context resolved packages and versions + + If preference 'showAllVersions' is enabled, all packages' versions + will be collected, except profile. Profile version should not be + changed from Packages view, should be changed from Profile view. + + """ + all_vers = self._state.retrieve("showAllVersions", False) + app_vers = self._models["apps"].find(app_request)["versions"] + app_names = {app.name for app in self._state["rezApps"].values()} + profile_name = self._state["profileName"] + resolved = self._state["rezContexts"][app_request].resolved_packages + + packages = odict() # keep resolved order + for pkg in resolved or []: + is_profile = pkg.name == profile_name + is_app = False if is_profile else pkg.name in app_names + + if is_profile: + versions = [str(pkg.version)] + elif is_app: + versions = app_vers[:] + else: + versions = [ + str(p.version) + for p in (self.find(pkg.name) if all_vers else [pkg]) + ] + + packages[pkg.name] = { + "package": pkg, + "versions": versions, + } + + return packages # ---------------- # Events @@ -924,6 +958,7 @@ def on_apps_not_found(error, trace): active_profile = profile_versions[version_name] if profile_name: + # (TODO): this warning pops-up even profile actually exists self.warning("%s was not found" % profile_name) else: self.error("select_profile was passed an empty string") @@ -1088,14 +1123,26 @@ def _list_apps(self, profile): patch_with_filter = self._state.retrieve("patchWithFilter", False) package_filter = self._package_filter() + app_ranges = dict() + def _try_finding_latest_app(req_str): req_str = req_str.strip("~") req = rez.PackageRequest(req_str) try: - return rez.find_latest(req.name, range_=req.range) + app_vers = list(self.find(req.name, range_=req.range)) + latest = app_vers[-1] + except IndexError: + self.error("No package matched for request '%s', may have" + "been excluded by package filter.") + latest = model.BrokenPackage(req_str) + app_vers = [latest] except _missing as e_: self.error(str(e_)) - return model.BrokenPackage(req_str) + latest = model.BrokenPackage(req_str) + app_vers = [latest] + + app_ranges[req.name] = app_vers + return latest def _try_resolve_context(req, pkg_name, mode): kwargs = dict() @@ -1112,6 +1159,9 @@ def _try_resolve_context(req, pkg_name, mode): contexts = odict() with util.timing() as t: + current_app = self._state["appRequest"] or "" + current_app = current_app.split("==", 1)[0] + for app_request in apps: app_package = _try_finding_latest_app(app_request) @@ -1134,6 +1184,19 @@ def _try_resolve_context(req, pkg_name, mode): app_package.name, mode="Patch") + # To avoid application selection change on patched or + # set back to default: + # 1. update context key `app_request`, and + # 2. update startup app + if context.success: + for pkg in context.resolved_packages or []: + if pkg.name == app_package.name: + app_request = "%s==%s" % (pkg.name, pkg.version) + if pkg.name == current_app: + self._state.store("startupApplication", + app_request) + break + contexts[app_request] = context # Associate a Rez package with an app @@ -1181,25 +1244,38 @@ def _try_resolve_context(req, pkg_name, mode): self._state["rezApps"][app_request] = rez_pkg + self._state["rezContexts"] = contexts + self.debug("Resolved all contexts in %.2f seconds" % t.duration) - # Hide hidden - visible_apps = [] + visible_apps = dict() + + # * Opt-out hidden application + # * Find application versions show_hidden = self._state.retrieve("showHiddenApps") - for request, package in self._state["rezApps"].items(): - data = allzparkconfig.metadata_from_package(package) + for request, app_pkg in self._state["rezApps"].items(): + data = allzparkconfig.metadata_from_package(app_pkg) hidden = data.get("hidden", False) if hidden and not show_hidden: continue - visible_apps += [package] + app_versions = [str(v.version) for v in app_ranges[app_pkg.name]] + visible_apps[request] = { + "package": app_pkg, + "versions": app_versions, + } - self._state["rezContexts"] = contexts return visible_apps def graph(self): context = self._state["rezContexts"][self._state["appRequest"]] + if isinstance(context, model.BrokenContext): + self._state.to_console() + self._state.to_ready() + self.error("Can not graph a broken context.") + return + graph_str = context.graph(as_dot=True) tempdir = tempfile.mkdtemp() diff --git a/allzpark/delegates.py b/allzpark/delegates.py index c1c798c..b72502a 100644 --- a/allzpark/delegates.py +++ b/allzpark/delegates.py @@ -2,16 +2,32 @@ class Package(QtWidgets.QStyledItemDelegate): + + editor_created = QtCore.Signal() + editor_closed = QtCore.Signal(bool) + def __init__(self, ctrl, parent=None): super(Package, self).__init__(parent) + + def on_close_editor(*args): + self.editor_closed.emit(self._changed) + self.closeEditor.connect(on_close_editor) + + self._changed = None + self._default = None self._ctrl = ctrl def createEditor(self, parent, option, index): - if index.column() != 1: + model = index.model() + if index.column() != 1 or not model.data(index, "_hasVersions"): return editor = QtWidgets.QComboBox(parent) + def on_text_activated(text): + self._changed = text != self._default + editor.textActivated.connect(on_text_activated) + return editor def setEditorData(self, editor, index): @@ -19,12 +35,17 @@ def setEditorData(self, editor, index): options = model.data(index, "versions") default = index.data(QtCore.Qt.DisplayRole) + self._changed = False + self._default = default + editor.addItems(options) editor.setCurrentIndex(options.index(default)) + self.editor_created.emit() + def setModelData(self, editor, model, index): model = index.model() - package = model.data(index, "name") + package = model.data(index, "family") options = model.data(index, "versions") default = model.data(index, "default") version = options[editor.currentIndex()] diff --git a/allzpark/dock.py b/allzpark/dock.py index 3bea5da..72c8a15 100644 --- a/allzpark/dock.py +++ b/allzpark/dock.py @@ -343,15 +343,18 @@ def __init__(self, ctrl, parent=None): def on_argument_changed(self, arg): if arg["name"] == "useDevelopmentPackages": - self._ctrl._state.store("useDevelopmentPackages", arg.read()) + self._ctrl.state.store("useDevelopmentPackages", arg.read()) self._ctrl.reset() if arg["name"] == "useLocalizedPackages": - self._ctrl._state.store("useLocalizedPackages", arg.read()) + self._ctrl.state.store("useLocalizedPackages", arg.read()) self._ctrl.reset() if arg["name"] == "patch": - self._ctrl._state.store("patch", arg.read()) + # (TODO) This will be called twice since qargparse.String + # may emit changed signal twice. And profile model item + # will get doubled. + self._ctrl.state.store("patch", arg.read()) self._ctrl.reset() def on_resetted(self): @@ -368,12 +371,12 @@ def set_model(self, model_): model_.dataChanged.connect(self.on_model_changed) def on_model_changed(self): - model = self._widgets["view"].model() - model = model.sourceModel() + model_ = self._widgets["view"].model() + model_ = model_.sourceModel() - package_count = model.rowCount() - override_count = len([i for i in model.items if i["override"]]) - disabled_count = len([i for i in model.items if i["disabled"]]) + package_count = model_.rowCount() + override_count = len([i for i in model_.items if i["override"]]) + disabled_count = len([i for i in model_.items if i["disabled"]]) self._widgets["status"].showMessage( "%d Packages, %d Overridden, %d Disabled" % ( @@ -457,8 +460,17 @@ def on_right_click(self, position): localize_all.setEnabled(False) localize_related.setEnabled(False) + versions = model_.data(index, "versions") + if len(versions) <= 1: + edit.setEnabled(False) + default.setEnabled(False) + earliest.setEnabled(False) + latest.setEnabled(False) + def on_edit(): - self._widgets["view"].edit(index) + # avoid sending index that is not editable + version_index = model_.index(index.row(), 1) + self._widgets["view"].edit(version_index) def on_default(): package = model_.data(index, "package") @@ -466,7 +478,6 @@ def on_default(): self.message.emit("Package set to default") def on_earliest(): - versions = model_.data(index, "versions") earliest = versions[0] package = model_.data(index, "package") self._ctrl.patch("%s==%s" % (package.name, earliest)) @@ -474,7 +485,6 @@ def on_earliest(): self.message.emit("Package set to earliest") def on_latest(): - versions = model_.data(index, "versions") latest = versions[-1] package = model_.data(index, "package") self._ctrl.patch("%s==%s" % (package.name, latest)) @@ -625,16 +635,17 @@ def set_model(self, model_): self._model = model_ def on_state_appfailed(self): - self._widgets["generateGraph"].setEnabled(False) self._widgets["printCode"].setEnabled(False) def on_state_appok(self): - self._widgets["generateGraph"].setEnabled(True) self._widgets["printCode"].setEnabled(True) def on_generate_clicked(self): pixmap = self._ctrl.graph() + if pixmap is None: + return # was graphing broken context + if not pixmap: self._widgets["graphHotkeys"].setText( "GraphViz not found" @@ -1049,92 +1060,109 @@ class Preferences(AbstractDockWidget): icon = "Action_GoHome_32" - options = [ - qargparse.Info("startupProfile", help=( - "Load this profile on startup" - )), - qargparse.Info("startupApplication", help=( - "Load this application on startup" - )), - - qargparse.Separator("Appearance"), - - qargparse.Enum("theme", items=res.theme_names(), help=( - "GUI skin. May need to restart Allzpark after changed." - )), - - qargparse.Button("resetLayout", help=( - "Reset stored layout to their defaults" - )), - - qargparse.Separator("Settings"), - - qargparse.Boolean("smallIcons", enabled=False, help=( - "Draw small icons" - )), - qargparse.Boolean("allowMultipleDocks", help=( - "Allow more than one dock to exist at a time" - )), - qargparse.Boolean("showAdvancedControls", help=( - "Show developer-centric controls" - )), - qargparse.Boolean("showAllApps", help=( - "List everything from allzparkconfig:applications\n" - "not just the ones specified for a given profile." - )), - qargparse.Boolean("showHiddenApps", help=( - "Show apps with metadata['hidden'] = True" - )), - - qargparse.Boolean("patchWithFilter", help=( - "Use the current exclusion filter when patching.\n" - "This enables patching of packages outside of a filter, \n" - "such as *.beta packages, with every other package still \n" - "qualifying for that filter." - )), - qargparse.Integer("clearCacheTimeout", min=1, default=10, help=( - "Clear package repository cache at this interval, in seconds. \n\n" - - "Default 10. (Requires restart)\n\n" - - "Normally, filesystem calls like `os.listdir` are cached \n" - "so as to avoid unnecessary calls. However, whenever a new \n" - "version of a package is released, it will remain invisible \n" - "until this cache is cleared. \n\n" - - "Clearing ths cache should have a very small impact on \n" - "performance and is safe to do frequently. It has no effect \n" - "on memcached which has a much greater impact on performanc." - )), - - qargparse.String( - "exclusionFilter", - default=allzparkconfig.exclude_filter, - help="Exclude versions that match this expression"), - - qargparse.Separator("System"), - - # Provided by controller - qargparse.Info("pythonExe"), - qargparse.Info("pythonVersion"), - qargparse.Info("qtVersion"), - qargparse.Info("qtBinding"), - qargparse.Info("qtBindingVersion"), - qargparse.Info("rezLocation"), - qargparse.Info("rezVersion"), - qargparse.Info("rezConfigFile"), - qargparse.Info("memcachedURI"), - qargparse.InfoList("rezPackagesPath"), - qargparse.InfoList("rezLocalPath"), - qargparse.InfoList("rezReleasePath"), - qargparse.Info("settingsPath"), - ] - def __init__(self, window, ctrl, parent=None): super(Preferences, self).__init__("Preferences", parent) self.setAttribute(QtCore.Qt.WA_StyledBackground) self.setObjectName("Preferences") + self.options = [ + qargparse.Info("startupProfile", help=( + "Load this profile on startup" + )), + qargparse.Info("startupApplication", help=( + "Load this application on startup" + )), + + qargparse.Separator("Appearance"), + + qargparse.Enum("theme", items=res.theme_names(), help=( + "GUI skin. May need to restart Allzpark after changed." + )), + + qargparse.Button("resetLayout", help=( + "Reset stored layout to their defaults" + )), + + qargparse.Separator("Settings"), + + qargparse.Boolean("smallIcons", enabled=False, help=( + "Draw small icons" + )), + qargparse.Boolean("allowMultipleDocks", help=( + "Allow more than one dock to exist at a time" + )), + qargparse.Boolean("showAdvancedControls", help=( + "Show developer-centric controls" + )), + qargparse.Boolean("showAllApps", help=( + "List everything from allzparkconfig:applications\n" + "not just the ones specified for a given profile." + )), + qargparse.Boolean("showHiddenApps", help=( + "Show apps with metadata['hidden'] = True" + )), + qargparse.Boolean("showAllVersions", help=( + "Show all package versions.\n" + "Profile requested application version range will \n" + "still be respected, but all versions of each \n" + "dependency package will be shown." + )), + qargparse.Boolean("patchWithFilter", help=( + "Use the current exclusion filter when patching.\n" + "This enables patching of packages outside of a \n" + "filter, such as *.beta packages, with every other \n" + "package still qualifying for that filter." + )), + qargparse.Integer("clearCacheTimeout", min=1, default=10, help=( + "Clear package repository cache at this interval, in \n" + "seconds.\n\n" + + "Default 10. (Requires restart)\n\n" + + "Normally, filesystem calls like `os.listdir` are \n" + "cached so as to avoid unnecessary calls. However, \n" + "whenever a new version of a package is released, \n" + "it will remain invisible until this cache is \n" + "cleared. \n\n" + + "Clearing ths cache should have a very small impact \n" + "on performance and is safe to do frequently. It has \n" + "no effect on memcached which has a much greater \n" + "impact on performance." + )), + + qargparse.String( + "exclusionFilter", + default=allzparkconfig.exclude_filter, + help="Exclude versions that match this expression"), + + qargparse.Separator("System"), + + # Provided by controller + qargparse.Info("pythonExe"), + qargparse.Info("pythonVersion"), + qargparse.Info("qtVersion"), + qargparse.Info("qtBinding"), + qargparse.Info("qtBindingVersion"), + qargparse.Info("rezLocation"), + qargparse.Info("rezVersion"), + qargparse.Info("rezConfigFile"), + qargparse.Info("memcachedURI"), + qargparse.InfoList("rezPackagesPath"), + qargparse.InfoList("rezLocalPath"), + qargparse.InfoList("rezReleasePath"), + qargparse.Info("settingsPath"), + ] + + protected = allzparkconfig.protected_preferences() + for name, value in protected.items(): + arg = next((a for a in self.options if a["name"] == name), None) + if arg is None: + print("Unknown preference setting: %s" % name) + else: + ctrl.state.store(name, value) + arg["enabled"] = False + panels = { "central": QtWidgets.QTabWidget(), } diff --git a/allzpark/model.py b/allzpark/model.py index 1983f9b..9adef94 100644 --- a/allzpark/model.py +++ b/allzpark/model.py @@ -79,10 +79,9 @@ def reset(self, items=None): def find(self, name): return next(i for i in self.items if i["name"] == name) - def findIndex(self, name): - return self.createIndex( - self.items.index(self.find(name)), 0, QtCore.QModelIndex() - ) + def findIndex(self, name, column=0): + row = self.items.index(self.find(name)) + return self.createIndex(row, column, QtCore.QModelIndex()) def rowCount(self, parent=QtCore.QModelIndex()): if parent.isValid(): @@ -163,6 +162,71 @@ def parse_icon(root, template): return QtGui.QIcon(fname) +class AbstractPackageItem(dict): + + def __init__(self, name, package, versions, metadata): + super(AbstractPackageItem, self).__init__({ + "name": name, + "label": metadata["label"], + "icon": parse_icon(package.root, template=metadata["icon"]), + "family": package.name, + "package": package, + "version": str(package.version), + "versions": versions, + "default": str(package.version), + "context": None, + "active": True, + "_hasVersions": len(versions) > 1, + }) + + +class ApplicationItem(AbstractPackageItem): + + def __init__(self, app_request, data): + app_pkg = data["package"] + versions = data["versions"] + metadata = allzparkconfig.metadata_from_package(app_pkg) + tools = getattr(app_pkg, "tools", None) or [app_pkg.name] + + super(ApplicationItem, self).__init__(name=app_request, + package=app_pkg, + versions=versions, + metadata=metadata) + self.update({ + "hidden": metadata["hidden"], + "broken": isinstance(app_pkg, BrokenPackage), + "tool": None, # Current tool + "tools": tools, # All available tools + "detached": False, # Open in separate console or not + }) + + +class PackageItem(AbstractPackageItem): + + def __init__(self, name, data): + package = data["package"] + versions = data["versions"] + metadata = allzparkconfig.metadata_from_package(package) + relocatable = localz.is_relocatable(package) if localz else False + state = ( + "(dev)" if is_local(package) else + "(localised)" if is_localised(package) else + "" + ) + + super(PackageItem, self).__init__(name=name, + package=package, + versions=versions, + metadata=metadata) + self.update({ + "override": data["override"], + "disabled": data["disabled"], + "state": state, + "relocatable": relocatable, + "localizing": False, # in progress + }) + + class ApplicationModel(AbstractTableModel): ColumnToKey = { 0: { @@ -184,39 +248,13 @@ def __init__(self, *args, **kwargs): self._broken_icon = res.icon("Action_Stop_1_32.png") def reset(self, applications=None): - applications = applications or [] + applications = applications or dict() self.beginResetModel() self.items[:] = [] - for app in applications: - root = app.root - - data = allzparkconfig.metadata_from_package(app) - tools = getattr(app, "tools", None) or [app.name] - app_request = "%s==%s" % (app.name, app.version) - - item = { - "name": app_request, - "label": data["label"], - "version": str(app.version), - "icon": parse_icon(root, template=data["icon"]), - "package": app, - "context": None, - "active": True, - "hidden": data["hidden"], - "broken": isinstance(app, BrokenPackage), - - # Whether or not to open a separate console for this app - "detached": False, - - # Current tool - "tool": None, - - # All available tools - "tools": tools, - } - + for app_request, data in applications.items(): + item = ApplicationItem(app_request, data) self.items.append(item) self.endResetModel() @@ -251,8 +289,24 @@ def data(self, index, role): if col == 0: return self._broken_icon + if data["_hasVersions"] and col == 1: + if role == QtCore.Qt.FontRole: + font = QtGui.QFont() + font.setBold(True) + return font + return super(ApplicationModel, self).data(index, role) + def flags(self, index): + if index.column() == 1: + return ( + QtCore.Qt.ItemIsEnabled | + QtCore.Qt.ItemIsSelectable | + QtCore.Qt.ItemIsEditable + ) + + return super(ApplicationModel, self).flags(index) + class BrokenContext(object): broken_dict = {"error": "Failed context"} @@ -348,62 +402,22 @@ class PackagesModel(AbstractTableModel): "beta", ] - def __init__(self, ctrl, parent=None): + def __init__(self, parent=None): super(PackagesModel, self).__init__(parent) - - self._ctrl = ctrl self._overrides = {} self._disabled = {} def reset(self, packages=None): - packages = packages or [] + packages = packages or dict() self.beginResetModel() self.items[:] = [] - # TODO: This isn't nice. The model should - # not have to reach into the controller. - paths = self._ctrl._package_paths() - - for pkg in packages: - root = pkg.root - data = allzparkconfig.metadata_from_package(pkg) - state = ( - "(dev)" if is_local(pkg) else - "(localised)" if is_localised(pkg) else - "" - ) - relocatable = False - - version = str(pkg.version) - - # Fetch all versions of package - versions = rez.find(pkg.name, paths=paths) - versions = sorted( - [str(v.version) for v in versions], - key=util.natural_keys - ) or [version] # broken package - - if localz: - relocatable = localz.is_relocatable(pkg) - - item = { - "name": pkg.name, - "label": data["label"], - "version": version, - "default": version, - "icon": parse_icon(root, template=data["icon"]), - "package": pkg, - "override": self._overrides.get(pkg.name), - "disabled": self._disabled.get(pkg.name, False), - "context": None, - "active": True, - "versions": versions, - "state": state, - "relocatable": relocatable, - "localizing": False, # in progress - } + for name, data in packages.items(): + data["override"] = self._overrides.get(name) + data["disabled"] = self._disabled.get(name, False) + item = PackageItem(name, data) self.items.append(item) self.endResetModel() @@ -439,6 +453,12 @@ def data(self, index, role): if role == QtCore.Qt.ForegroundRole: return QtGui.QColor("darkorange") + if data["_hasVersions"] and col == 1: + if role == QtCore.Qt.FontRole: + font = QtGui.QFont() + font.setBold(True) + return font + try: return data[role] diff --git a/allzpark/view.py b/allzpark/view.py index 17f1112..2a7e7c3 100644 --- a/allzpark/view.py +++ b/allzpark/view.py @@ -11,17 +11,45 @@ from .vendor import qargparse from .version import version from . import resources as res, dock, model -from . import allzparkconfig +from . import allzparkconfig, delegates px = res.px class Applications(dock.SlimTableView): - def __init__(self, parent=None): + def __init__(self, ctrl, parent=None): super(Applications, self).__init__(parent) + delegate = delegates.Package(ctrl, self) + self.setItemDelegate(delegate) + self.setEditTriggers(self.EditKeyPressed) + self.setStretch(0) + + # Block/Unblock view selection signal on version picking + # - + # We are using combobox widget as package version delegate editor, + # when user done picking version, may clicking on any place out + # side of the combobox widget instead of pressing return button to + # trigger editing finished signal. If user is clicking on application + # view, the application changed signal will also being emitted at the + # same time while the version editor already called context patching, + # race condition happened. + # To avoid that, we need to block view selection signal when editor + # is created, and unblock it depend on version changed or not. + # If version isn't changed, unblock it once editor is closed, and + # wait for controller reset signal after patch completed if changed. + delegate.editor_created.connect(self.on_editor_created) + delegate.editor_closed.connect(self.on_editor_done) + ctrl.resetted.connect(lambda: self.on_editor_done(False)) + self._selected_app_ok = False + def on_editor_created(self): + self.selectionModel().blockSignals(True) + + def on_editor_done(self, block): + self.selectionModel().blockSignals(block) + def on_state_appfailed(self): self._selected_app_ok = False @@ -76,7 +104,7 @@ def __init__(self, ctrl, parent=None): "logo": QtWidgets.QToolButton(), "appVersion": QtWidgets.QLabel(version), - "apps": Applications(), + "apps": Applications(ctrl), "fullCommand": FullCommand(ctrl), # Error page @@ -475,6 +503,9 @@ def on_setting_changed(self, argument): "patchWithFilter"): self._ctrl.reset() + if key == "showAllVersions": + self._ctrl.select_application(self._ctrl.state["appRequest"]) + if key == "exclusionFilter": allzparkconfig.exclude_filter = value self._ctrl.reset() @@ -663,11 +694,12 @@ def on_state_changed(self, state): for widget in self._docks.values(): widget.setEnabled(False) - if state in ("pkgnotfound", "errored"): + if state in ("pkgnotfound", "errored", "console"): console = self._docks["console"] console.show() self.on_dock_toggled(console, visible=True) + if state in ("pkgnotfound", "errored"): page = self._pages["errored"] self._panels["pages"].setCurrentWidget(page) self._widgets["apps"].setEnabled(False) @@ -723,6 +755,9 @@ def on_app_clicked(self, index): app.show() self.on_dock_toggled(app, visible=True) + if index.column() == 1: + self._widgets["apps"].edit(index) + def on_app_selection_changed(self, selected, deselected): """The current app was changed diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 624d3d6..80b6f24 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -85,6 +85,7 @@ jobs: python3-pyside2.qtgui \ python3-pyside2.qtwidgets \ python3-pyside2.qtsvg + sudo pip install pyside2 sudo python -c "from PySide2 import QtCore;print(QtCore.__version__)" condition: startsWith(variables['python.version'], '3.') displayName: "Install PySide2" diff --git a/tests/test_apps.py b/tests/test_apps.py index 6fba619..60e7d95 100644 --- a/tests/test_apps.py +++ b/tests/test_apps.py @@ -31,10 +31,7 @@ def test_select_app(self): } }, }) - with self.wait_signal(self.ctrl.resetted): - self.ctrl.reset(["foo"]) - self.wait(timeout=200) - self.assertEqual(self.ctrl.state.state, "ready") + self.ctrl_reset(["foo"]) with self.wait_signal(self.ctrl.state_changed, "ready"): self.ctrl.select_profile("foo") @@ -82,10 +79,7 @@ def test_app_environ(self): } }, }) - with self.wait_signal(self.ctrl.resetted): - self.ctrl.reset(["foo"]) - self.wait(timeout=200) - self.assertEqual(self.ctrl.state.state, "ready") + self.ctrl_reset(["foo"]) with self.wait_signal(self.ctrl.state_changed, "ready"): self.ctrl.select_profile("foo") @@ -134,10 +128,7 @@ def test_app_failed_independently_1(self): "app_A": {"1": {"name": "app_A", "version": "1"}}, "app_B": {"1": {"name": "app_B", "version": "1"}}, }) - with self.wait_signal(self.ctrl.resetted): - self.ctrl.reset(["foo"]) - self.wait(timeout=200) - self.assertEqual(self.ctrl.state.state, "ready") + self.ctrl_reset(["foo"]) context_a = self.ctrl.state["rezContexts"]["app_A==1"] context_b = self.ctrl.state["rezContexts"]["app_B==1"] @@ -160,10 +151,7 @@ def test_app_failed_independently_2(self): }, "app_B": {"1": {"name": "app_B", "version": "1"}}, }) - with self.wait_signal(self.ctrl.resetted): - self.ctrl.reset(["foo"]) - self.wait(timeout=200) - self.assertEqual(self.ctrl.state.state, "ready") + self.ctrl_reset(["foo"]) context_a = self.ctrl.state["rezContexts"]["app_A==None"] context_b = self.ctrl.state["rezContexts"]["app_B==1"] @@ -193,10 +181,7 @@ def test_app_failed_independently_3(self): }, "app_B": {"1": {"name": "app_B", "version": "1"}}, }) - with self.wait_signal(self.ctrl.resetted): - self.ctrl.reset(["foo"]) - self.wait(timeout=200) - self.assertEqual(self.ctrl.state.state, "ready") + self.ctrl_reset(["foo"]) context_a = self.ctrl.state["rezContexts"]["app_A==1"] context_b = self.ctrl.state["rezContexts"]["app_B==1"] @@ -220,13 +205,120 @@ def test_app_failed_independently_4(self): "app_A": {"1": {"name": "app_A", "version": "1"}}, "app_B": {"1": {"name": "app_B", "version": "1"}}, }) - with self.wait_signal(self.ctrl.resetted): - self.ctrl.reset(["foo"]) - self.wait(timeout=200) - self.assertEqual(self.ctrl.state.state, "ready") + self.ctrl_reset(["foo"]) context_a = self.ctrl.state["rezContexts"]["app_A==2"] context_b = self.ctrl.state["rezContexts"]["app_B==1"] self.assertFalse(context_a.success) self.assertTrue(context_b.success) + + def test_app_changing_version(self): + """Test application version can be changed in view""" + util.memory_repository({ + "foo": { + "1": {"name": "foo", "version": "1", + "requires": ["~app_A", "~app_B"]} + }, + "app_A": {"1": {"name": "app_A", "version": "1"}}, + "app_B": {"1": {"name": "app_B", "version": "1"}, + "2": {"name": "app_B", "version": "2"}} + }) + self.ctrl_reset(["foo"]) + self.show_dock("app") + + apps = self.window._widgets["apps"] + + def get_version_editor(app_request): + self.select_application(app_request) + proxy = apps.model() + model = proxy.sourceModel() + index = model.findIndex(app_request, column=1) + index = proxy.mapFromSource(index) + apps.edit(index) + + return apps.indexWidget(index), apps.itemDelegate(index) + + editor, delegate = get_version_editor("app_A==1") + self.assertIsNone( + editor, "No version editing if App has only one version.") + + editor, delegate = get_version_editor("app_B==2") + self.assertIsNotNone( + editor, "Version should be editable if App has versions.") + + # for visual + editor.showPopup() + self.wait(100) + view = editor.view() + index = view.model().index(0, 0) + sel_model = view.selectionModel() + sel_model.select(index, sel_model.ClearAndSelect) + self.wait(150) + # change version + editor.setCurrentIndex(0) + delegate.commitData.emit(editor) + self.wait(200) # wait patch + + self.assertEqual("app_B==1", self.ctrl.state["appRequest"]) + + def test_app_no_version_change_if_flattened(self): + """No version edit if versions are flattened with allzparkconfig""" + + def applications_from_package(variant): + # From https://allzpark.com/gui/#multiple-application-versions + from allzpark import _rezapi as rez + + requirements = variant.requires or [] + apps = list( + str(req) + for req in requirements + if req.weak + ) + apps = [rez.PackageRequest(req.strip("~")) for req in apps] + flattened = list() + for request in apps: + flattened += rez.find( + request.name, + range_=request.range, + ) + apps = list( + "%s==%s" % (package.name, package.version) + for package in flattened + ) + return apps + + # patch config + self.patch_allzparkconfig("applications_from_package", + applications_from_package) + # start + util.memory_repository({ + "foo": { + "1": {"name": "foo", "version": "1", + "requires": ["~app_A"]} + }, + "app_A": {"1": {"name": "app_A", "version": "1"}, + "2": {"name": "app_A", "version": "2"}} + }) + self.ctrl_reset(["foo"]) + self.show_dock("app") + + apps = self.window._widgets["apps"] + + def get_version_editor(app_request): + self.select_application(app_request) + proxy = apps.model() + model = proxy.sourceModel() + index = model.findIndex(app_request, column=1) + index = proxy.mapFromSource(index) + apps.edit(index) + + return apps.indexWidget(index), apps.itemDelegate(index) + + editor, delegate = get_version_editor("app_A==1") + self.assertIsNone( + editor, "No version editing if versions are flattened.") + + editor, delegate = get_version_editor("app_A==2") + self.assertIsNone( + editor, "No version editing if versions are flattened.") diff --git a/tests/test_docks.py b/tests/test_docks.py index bc5826a..5b2cd0c 100644 --- a/tests/test_docks.py +++ b/tests/test_docks.py @@ -19,10 +19,9 @@ def test_feature_blocked_on_failed_app(self): }, "app_B": {"1": {"name": "app_B", "version": "1"}}, }) - with self.wait_signal(self.ctrl.resetted): - self.ctrl.reset(["foo"]) - self.wait(timeout=200) - self.assertEqual(self.ctrl.state.state, "ready") + self.ctrl_reset(["foo"]) + + self.set_preference("showAdvancedControls", True) context_a = self.ctrl.state["rezContexts"]["app_A==None"] context_b = self.ctrl.state["rezContexts"]["app_B==1"] @@ -30,20 +29,73 @@ def test_feature_blocked_on_failed_app(self): self.assertFalse(context_a.success) self.assertTrue(context_b.success) - self.show_advance_controls() - for app, state in {"app_A==None": False, "app_B==1": True}.items(): - self.ctrl.select_application(app) - self.wait(100) + self.select_application(app) dock = self.show_dock("environment", on_page="diagnose") self.assertEqual(dock._widgets["compute"].isEnabled(), state) - dock = self.show_dock("context", on_page="graph") - self.assertEqual(dock._widgets["generateGraph"].isEnabled(), state) - dock = self.show_dock("context", on_page="code") self.assertEqual(dock._widgets["printCode"].isEnabled(), state) dock = self.show_dock("app") self.assertEqual(dock._widgets["launchBtn"].isEnabled(), state) + + def test_version_editable_on_show_all_versions(self): + """Test version is editable when show all version enabled""" + self._test_version_editable(show_all_version=True) + + def test_version_editable_on_not_show_all_versions(self): + """Test version is not editable when show all version disabled""" + self._test_version_editable(show_all_version=False) + + def _test_version_editable(self, show_all_version): + util.memory_repository({ + "foo": { + "1": {"name": "foo", "version": "1", + "requires": ["~app_A", "~app_B"]}, + "2": {"name": "foo", "version": "2", + "requires": ["~app_A", "~app_B"]}, + }, + "app_A": {"1": {"name": "app_A", "version": "1"}}, + "app_B": {"1": {"name": "app_B", "version": "1", + "requires": ["bar"]}}, + "bar": {"1": {"name": "bar", "version": "1"}, + "2": {"name": "bar", "version": "2"}} + }) + self.ctrl_reset(["foo"]) + + self.set_preference("showAdvancedControls", True) + self.set_preference("showAllVersions", show_all_version) + self.wait(200) # wait for reset + + self.select_application("app_B==1") + + dock = self.show_dock("packages") + view = dock._widgets["view"] + proxy = view.model() + model = proxy.sourceModel() + + for pkg, state in {"foo": False, # profile can't change version here + "bar": show_all_version, + "app_B": False}.items(): + index = model.findIndex(pkg) + index = proxy.mapFromSource(index) + + rect = view.visualRect(index) + position = rect.center() + with util.patch_cursor_pos(view.mapToGlobal(position)): + dock.on_right_click(position) + menu = self.get_menu(dock) + edit_action = next((a for a in menu.actions() + if a.text() == "Edit"), None) + if edit_action is None: + self.fail("No version edit action.") + + self.assertEqual( + edit_action.isEnabled(), state, + "Package '%s' version edit state is incorrect." % pkg + ) + + self.wait(200) + menu.close() diff --git a/tests/test_launch.py b/tests/test_launch.py index 7c3a1d7..ed663d0 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -22,10 +22,7 @@ def test_launch_subprocess(self): } }, }) - with self.wait_signal(self.ctrl.resetted): - self.ctrl.reset(["foo"]) - self.wait(timeout=200) - self.assertEqual(self.ctrl.state.state, "ready") + self.ctrl_reset(["foo"]) with self.wait_signal(self.ctrl.state_changed, "ready"): self.ctrl.select_profile("foo") diff --git a/tests/test_profiles.py b/tests/test_profiles.py index a160f61..b81252d 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -93,10 +93,7 @@ def test_profile_list_apps(self): } }, }) - with self.wait_signal(self.ctrl.resetted): - self.ctrl.reset(["foo"]) - self.wait(timeout=200) - self.assertEqual(self.ctrl.state.state, "ready") + self.ctrl_reset(["foo"]) with self.wait_signal(self.ctrl.state_changed, "ready"): self.ctrl.select_profile("foo") diff --git a/tests/util.py b/tests/util.py index aa6a255..75e5e42 100644 --- a/tests/util.py +++ b/tests/util.py @@ -9,10 +9,16 @@ def memory_repository(packages): + from rezplugins.package_repository import memory from allzpark import _rezapi as rez + class MemoryVariantRes(memory.MemoryVariantResource): + def _root(self): # implement `root` to work with localz + return MEMORY_LOCATION + manager = rez.package_repository_manager repository = manager.get_repository(MEMORY_LOCATION) + repository.pool.resource_classes[MemoryVariantRes.key] = MemoryVariantRes repository.data = packages @@ -27,28 +33,58 @@ def setUp(self): app, ctrl = cli.initialize(clean=True, verbose=3) window = cli.launch(ctrl) + size = window.size() + window.resize(size.width() + 80, size.height() + 80) + self.app = app self.ctrl = ctrl self.window = window + self.patched_allzparkconfig = dict() self.wait(timeout=50) def tearDown(self): self.wait(timeout=500) self.window.close() + self.ctrl.deleteLater() + self.window.deleteLater() + self._restore_allzparkconfig() time.sleep(0.1) - def show_advance_controls(self): + def _restore_allzparkconfig(self): + from allzpark import allzparkconfig + + for name, value in self.patched_allzparkconfig.items(): + setattr(allzparkconfig, name, value) + + self.patched_allzparkconfig.clear() + + def patch_allzparkconfig(self, name, value): + from allzpark import allzparkconfig + + if name not in self.patched_allzparkconfig: + original = getattr(allzparkconfig, name) + self.patched_allzparkconfig[name] = original + + setattr(allzparkconfig, name, value) + + def set_preference(self, name, value): preferences = self.window._docks["preferences"] - arg = next(opt for opt in preferences.options - if opt["name"] == "showAdvancedControls") - arg.write(True) + arg = next((opt for opt in preferences.options + if opt["name"] == name), None) + if not arg: + self.fail("Preference doesn't have this setting: %s" % name) + + try: + arg.write(value) + except Exception as e: + self.fail("Preference '%s' set failed: %s" % (name, str(e))) def show_dock(self, name, on_page=None): dock = self.window._docks[name] dock.toggle.setChecked(True) dock.toggle.clicked.emit() - self.wait(timeout=200) + self.wait(timeout=50) if on_page is not None: tabs = dock._panels["central"] @@ -58,6 +94,23 @@ def show_dock(self, name, on_page=None): return dock + def ctrl_reset(self, profiles): + with self.wait_signal(self.ctrl.resetted): + self.ctrl.reset(profiles) + self.wait(timeout=200) + self.assertEqual(self.ctrl.state.state, "ready") + + def select_application(self, app_request): + apps = self.window._widgets["apps"] + proxy = apps.model() + model = proxy.sourceModel() + index = model.findIndex(app_request) + index = proxy.mapFromSource(index) + + sel_model = apps.selectionModel() + sel_model.select(index, sel_model.ClearAndSelect | sel_model.Rows) + self.wait(50) + def wait(self, timeout=1000): from allzpark.vendor.Qt import QtCore @@ -106,3 +159,24 @@ def on_timeout(): if not state["received"]: timer.start(timeout) loop.exec_() + + def get_menu(self, widget): + from allzpark.vendor.Qt import QtWidgets + menus = widget.findChildren(QtWidgets.QMenu, "") + menu = next((m for m in menus if m.isVisible()), None) + if menu: + return menu + else: + self.fail("This widget doesn't have menu.") + + +@contextlib.contextmanager +def patch_cursor_pos(point): + from allzpark.vendor.Qt import QtGui + + origin_pos = getattr(QtGui.QCursor, "pos") + setattr(QtGui.QCursor, "pos", lambda: point) + try: + yield + finally: + setattr(QtGui.QCursor, "pos", origin_pos)