diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index d99a58ce4c..5c51bbe1e0 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -13,7 +13,7 @@ from pathlib import Path from appdirs import user_data_dir from pype.version import __version__ from pype.lib import PypeSettingsRegistry -from igniter.tools import load_environments +from .tools import load_environments class BootstrapRepos: @@ -26,7 +26,14 @@ class BootstrapRepos: """ - def __init__(self): + def __init__(self, progress_callback: Callable = None): + """Constructor. + + Args: + progress_callback (callable): Optional callback method to report + progress. + + """ # vendor and app used to construct user data dir self._vendor = "pypeclub" self._app = "pype" @@ -34,6 +41,14 @@ class BootstrapRepos: self.data_dir = Path(user_data_dir(self._app, self._vendor)) self.registry = PypeSettingsRegistry() + # dummy progress reporter + def empty_progress(x: int): + return x + + if not progress_callback: + progress_callback = empty_progress + self._progress_callback = progress_callback + if getattr(sys, "frozen", False): self.live_repo_dir = Path(sys.executable).parent / "repos" else: @@ -44,25 +59,44 @@ class BootstrapRepos: """Get version of local Pype.""" return __version__ - def install_live_repos(self, progress_callback=None) -> Union[Path, None]: - """Copy zip created from local repositories to user data dir. + @staticmethod + def get_version(repo_dir: Path) -> Union[str, None]: + """Get version of Pype in given directory. Args: - progress_callback (callable): Optional callback method to report - progress. + repo_dir (Path): Path to Pype repo. + + Returns: + str: version string. + None: if Pype is not found. + + """ + # try to find version + version_file = Path(repo_dir) / "pype" / "version.py" + if not version_file.exists(): + return None + + version = {} + with version_file.open("r") as fp: + exec(fp.read(), version) + + return version['__version__'] + + def install_live_repos(self, repo_dir: Path = None) -> Union[Path, None]: + """Copy zip created from Pype repositories to user data dir. + + Args: + repo_dir (Path, optional): Path to Pype repository. + Returns: Path: path of installed repository file. """ - # dummy progress reporter - def empty_progress(x: int): - return x - - if not progress_callback: - progress_callback = empty_progress - - # create zip from repositories - local_version = self.get_local_version() + if not repo_dir: + version = self.get_local_version() + repo_dir = self.live_repo_dir + else: + version = self.get_version(repo_dir) # create destination directory try: @@ -71,12 +105,10 @@ class BootstrapRepos: self._log.error("directory already exists") with tempfile.TemporaryDirectory() as temp_dir: temp_zip = \ - Path(temp_dir) / f"pype-repositories-v{local_version}.zip" + Path(temp_dir) / f"pype-repositories-v{version}.zip" self._log.info(f"creating zip: {temp_zip}") - BootstrapRepos._create_pype_zip( - temp_zip, self.live_repo_dir, - progress_callback=progress_callback) + self._create_pype_zip(temp_zip, repo_dir) if not os.path.exists(temp_zip): self._log.error("make archive failed.") return None @@ -98,10 +130,10 @@ class BootstrapRepos: return None return self.data_dir / temp_zip.name - @staticmethod def _create_pype_zip( + self, zip_path: Path, include_dir: Path, - progress_callback: Callable, include_pype: bool = True) -> None: + include_pype: bool = True) -> None: """Pack repositories and Pype into zip. We are using :mod:`zipfile` instead :meth:`shutil.make_archive` @@ -113,10 +145,7 @@ class BootstrapRepos: Args: zip_path (str): path to zip file. - include_dir: repo directories to include. - progress_callback (Callable(progress: int): callback to - report progress back to UI progress bar. It takes progress - percents as argument. + include_dir (Path): repo directories to include. include_pype (bool): add Pype module itself. """ @@ -130,22 +159,22 @@ class BootstrapRepos: else: repo_inc = 98.0 / float(repo_files) progress = 0 - with ZipFile(zip_path, "w") as zip: - for root, _, files in os.walk(include_dir): + with ZipFile(zip_path, "w") as zip_file: + for root, _, files in os.walk(include_dir.as_posix()): for file in files: - zip.write( + zip_file.write( os.path.relpath(os.path.join(root, file), os.path.join(include_dir, '..')), os.path.relpath(os.path.join(root, file), os.path.join(include_dir)) ) progress += repo_inc - progress_callback(int(progress)) + self._progress_callback(int(progress)) # add pype itself if include_pype: for root, _, files in os.walk("pype"): for file in files: - zip.write( + zip_file.write( os.path.relpath(os.path.join(root, file), os.path.join('pype', '..')), os.path.join( @@ -154,9 +183,9 @@ class BootstrapRepos: os.path.join('pype', '..'))) ) progress += pype_inc - progress_callback(int(progress)) - zip.testzip() - progress_callback(100) + self._progress_callback(int(progress)) + zip_file.testzip() + self._progress_callback(100) @staticmethod def add_paths_from_archive(archive: Path) -> None: @@ -169,7 +198,6 @@ class BootstrapRepos: archive (str): path to archive. """ - name_list = [] with ZipFile(archive, "r") as zip_file: name_list = zip_file.namelist() @@ -249,13 +277,43 @@ class BootstrapRepos: """ os.environ["AVALON_MONGO"] = mongo_url env = load_environments() - if not env.get("PYPE_ROOT"): + if not env.get("PYPE_PATH"): return None - return Path(env.get("PYPE_ROOT")) + return Path(env.get("PYPE_PATH")) - def process_entered_path(self, location: str) -> str: + def process_entered_location(self, location: str) -> Union[Path, None]: + """Process user entered location string. + + It decides if location string is mongodb url or path. + If it is mongodb url, it will connect and load ``PYPE_PATH`` from + there and use it as path to Pype. In it is _not_ mongodb url, it + is assumed we have a path, this is tested and zip file is + produced and installed using :meth:`install_live_repos`. + + Args: + location (str): User entered location. + + Returns: + Path: to Pype zip produced from this location. + None: Zipping failed. + + """ pype_path = None if location.startswith("mongodb"): pype_path = self._get_pype_from_mongo(location) + if not pype_path: + self._log.error("cannot find PYPE_PATH in settings.") + return None - return pype_path \ No newline at end of file + if not pype_path: + pype_path = Path(location) + + if not pype_path.exists(): + self._log.error(f"{pype_path} doesn't exists.") + return None + + repo_file = self.install_live_repos(pype_path) + if not repo_file.exists(): + self._log.error(f"installing zip {repo_file} failed.") + return None + return repo_file diff --git a/igniter/install_dialog.py b/igniter/install_dialog.py index 151dfa19ed..fa79eba871 100644 --- a/igniter/install_dialog.py +++ b/igniter/install_dialog.py @@ -124,6 +124,37 @@ class InstallDialog(QtWidgets.QDialog): input_layout.addWidget(self.user_input) input_layout.addWidget(self._btn_select) + # Mongo box | OK button + # -------------------------------------------------------------------- + + class MongoWidget(QtWidgets.QWidget): + def __init__(self, parent=None): + self._btn_mongo = None + super(MongoWidget, self).__init__(parent) + mongo_layout = QtWidgets.QHBoxLayout() + mongo_layout.setContentsMargins(0, 0, 0, 0) + self._mongo_input = QtWidgets.QLineEdit() + self._mongo_input.setPlaceholderText("Mongo URL") + self._mongo_input.textChanged.connect(self._mongo_changed) + self._mongo_input.setStyleSheet( + ("color: rgb(233, 233, 233);" + "background-color: rgb(64, 64, 64);" + "padding: 0.5em;" + "border: 1px solid rgb(32, 32, 32);") + ) + + mongo_layout.addWidget(self._mongo_input) + self.setLayout(mongo_layout) + + def _mongo_changed(self, mongo: str): + self._mongo_url = mongo + + def get_mongo_url(self): + return self._mongo_url + + self._mongo = MongoWidget(self) + self._mongo.hide() + # Bottom button bar # -------------------------------------------------------------------- bottom_widget = QtWidgets.QWidget() @@ -237,6 +268,7 @@ class InstallDialog(QtWidgets.QDialog): main.addWidget(self.main_label) main.addWidget(self.pype_path_label) main.addLayout(input_layout) + main.addWidget(self._mongo) main.addStretch(1) main.addWidget(self._status_label) main.addWidget(self._status_box) @@ -292,10 +324,12 @@ class InstallDialog(QtWidgets.QDialog): self._disable_buttons() self._install_thread = InstallThread(self) + self._install_thread.ask_for_mongo.connect(self._show_mongo) self._install_thread.message.connect(self._update_console) self._install_thread.progress.connect(self._update_progress) self._install_thread.finished.connect(self._enable_buttons) self._install_thread.set_path(self._path) + self._install_thread.set_mongo(self._mongo.get_mongo_url()) self._install_thread.start() def _update_progress(self, progress: int): @@ -304,10 +338,19 @@ class InstallDialog(QtWidgets.QDialog): def _on_exit_clicked(self): self.close() + def _show_mongo(self): + self._update_console("mongo showed") + def _path_changed(self, path: str) -> str: """Set path.""" self._path = path - return path + if not self._path.startswith("mongodb"): + self._mongo.setVisible(True) + else: + self._mongo.setVisible(False) + + if len(self._path) < 1: + self._mongo.setVisible(False) def _update_console(self, msg: str, error: bool = False) -> None: """Display message in console. @@ -425,6 +468,98 @@ class PathValidator(QValidator): QValidator.State.Acceptable, reason, path, pos) +class CollapsibleWidget(QtWidgets.QWidget): + + def __init__(self, parent=None, title: str = "", animation: int = 300): + self._mainLayout = QtWidgets.QGridLayout(parent) + self._toggleButton = QtWidgets.QToolButton(parent) + self._headerLine = QtWidgets.QFrame(parent) + self._toggleAnimation = QtCore.QParallelAnimationGroup(parent) + self._contentArea = QtWidgets.QScrollArea(parent) + self._animation = animation + self._title = title + super(CollapsibleWidget, self).__init__(parent) + self._initUi() + + def _initUi(self): + self._toggleButton.setStyleSheet( + """QToolButton { + border: none; + } + """) + self._toggleButton.setToolButtonStyle( + QtCore.Qt.ToolButtonTextBesideIcon) + + self._toggleButton.setArrowType(QtCore.Qt.ArrowType.RightArrow) + self._toggleButton.setText(self._title) + self._toggleButton.setCheckable(True) + self._toggleButton.setChecked(False) + + self._headerLine.setFrameShape(QtWidgets.QFrame.HLine) + self._headerLine.setFrameShadow(QtWidgets.QFrame.Sunken) + self._headerLine.setSizePolicy(QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Maximum) + + self._contentArea.setStyleSheet( + """QScrollArea { + background-color: rgb(32, 32, 32); + border: none; + } + """) + self._contentArea.setSizePolicy(QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Fixed) + self._contentArea.setMaximumHeight(0) + self._contentArea.setMinimumHeight(0) + + self._toggleAnimation.addAnimation( + QtCore.QPropertyAnimation(self, b"minimumHeight")) + self._toggleAnimation.addAnimation( + QtCore.QPropertyAnimation(self, b"maximumHeight")) + self._toggleAnimation.addAnimation( + QtCore.QPropertyAnimation(self._contentArea, b"maximumHeight")) + + self._mainLayout.setVerticalSpacing(0) + self._mainLayout.setContentsMargins(0, 0, 0, 0) + + row = 0 + + self._mainLayout.addWidget( + self._toggleButton, row, 0, 1, 1, QtCore.Qt.AlignCenter) + self._mainLayout.addWidget( + self._headerLine, row, 2, 1, 1) + row += row + self._mainLayout.addWidget(self._contentArea, row, 0, 1, 3) + self.setLayout(self._mainLayout) + + self._toggleButton.toggled.connect(self._toggle_action) + + def _toggle_action(self, collapsed:bool): + arrow = QtCore.Qt.ArrowType.DownArrow if collapsed else QtCore.Qt.ArrowType.RightArrow # noqa: E501 + direction = QtCore.QAbstractAnimation.Forward if collapsed else QtCore.QAbstractAnimation.Backward # noqa: E501 + self._toggleButton.setArrowType(arrow) + self._toggleAnimation.setDirection(direction) + self._toggleAnimation.start() + + def setContentLayout(self, content_layout: QtWidgets.QLayout): + self._contentArea.setLayout(content_layout) + collapsed_height = \ + self.sizeHint().height() - self._contentArea.maximumHeight() + content_height = self._contentArea.sizeHint().height() + + for i in range(0, self._toggleAnimation.animationCount() - 1): + sec_anim = self._toggleAnimation.animationAt(i) + sec_anim.setDuration(self._animation) + sec_anim.setStartValue(collapsed_height) + sec_anim.setEndValue(collapsed_height + content_height) + + con_anim = self._toggleAnimation.animationAt( + self._toggleAnimation.animationCount() - 1) + + con_anim.setDuration(self._animation) + con_anim.setStartValue(0) + con_anim.setEndValue(32) + + if __name__ == "__main__": app = QtWidgets.QApplication(sys.argv) d = InstallDialog() diff --git a/igniter/install_thread.py b/igniter/install_thread.py index 4e89f67062..5ef976a8a4 100644 --- a/igniter/install_thread.py +++ b/igniter/install_thread.py @@ -5,6 +5,7 @@ from Qt.QtCore import QThread, Signal from igniter.tools import load_environments from .bootstrap_repos import BootstrapRepos +from .tools import validate_mongo_connection class InstallThread(QThread): @@ -23,66 +24,53 @@ class InstallThread(QThread): """ + progress = Signal(int) + message = Signal((str, bool)) + def __init__(self, parent=None): - self.progress = Signal(int) - self.message = Signal((str, bool)) + self._mongo = None self._path = None QThread.__init__(self, parent) def run(self): self.message.emit("Installing Pype ...", False) # find local version of Pype - bs = BootstrapRepos() + bs = BootstrapRepos(progress_callback=self.set_progress) local_version = bs.get_local_version() # if user did entered nothing, we install Pype from local version. # zip content of `repos`, copy it to user data dir and append # version to it. if not self._path: + self.ask_for_mongo.emit(True) self.message.emit( f"We will use local Pype version {local_version}", False) - repo_file = bs.install_live_repos( - progress_callback=self.set_progress) + repo_file = bs.install_live_repos() if not repo_file: self.message.emit( f"!!! install failed - {repo_file}", True) return self.message.emit(f"installed as {repo_file}", False) else: - pype_path = None - # find central pype location from database - if self._path.startswith("mongodb"): - self.message.emit("determining Pype location from db...") - os.environ["AVALON_MONGO"] = self._path - env = load_environments() - if not env.get("PYPE_ROOT"): + if self._mongo: + if not validate_mongo_connection(self._mongo): self.message.emit( - "!!! cannot load path to Pype from db", True) + f"!!! invalid mongo url {self._mongo}", True) return + bs.registry.set_secure_item("avalonMongo", self._mongo) + os.environ["AVALON_MONGO"] = self._mongo - self.message.emit(f"path loaded from database ...", False) - self.message.emit(env.get("PYPE_ROOT"), False) - if not os.path.exists(env.get("PYPE_ROOT")): - self.message.emit(f"!!! path doesn't exist", True) - return - pype_path = env.get("PYPE_ROOT") - if not pype_path: - pype_path = self._path + repo_file = bs.process_entered_location(self._path) - if not os.path.exists(pype_path): - self.message.emit(f"!!! path doesn't exist", True) + if not repo_file: + self.message.emit(f"!!! Cannot install", True) return - # detect Pype in path - - - - - - - def set_path(self, path: str) -> None: self._path = path + def set_mongo(self, mongo: str) -> None: + self._mongo = mongo + def set_progress(self, progress: int): self.progress.emit(progress) diff --git a/igniter/tools.py b/igniter/tools.py index 13cee313c8..6f5907861b 100644 --- a/igniter/tools.py +++ b/igniter/tools.py @@ -11,6 +11,15 @@ from pymongo.errors import ServerSelectionTimeoutError, InvalidURI def validate_mongo_connection(cnx: str) -> (bool, str): + """Check if provided mongodb URL is valid. + + Args: + cnx (str): URL to validate. + + Returns: + (bool, str): True if ok, False if not and reason in str. + + """ parsed = urlparse(cnx) if parsed.scheme in ["mongodb", "mongodb+srv"]: # we have mongo connection string. Let's try if we can connect. @@ -25,7 +34,7 @@ def validate_mongo_connection(cnx: str) -> (bool, str): try: client = MongoClient(**mongo_args) - # client.server_info() + client.server_info() except ServerSelectionTimeoutError as e: return False, f"Cannot connect to server {cnx} - {e}" except ValueError: @@ -70,6 +79,15 @@ def validate_path_string(path: str) -> (bool, str): def load_environments() -> dict: + """Load environments from Pype. + + This will load environments from database, process them with + :mod:`acre` and return them as flattened dictionary. + + Returns; + dict of str: loaded and processed environments. + + """ try: import acre except ImportError: diff --git a/pype.py b/pype.py index 693504dc00..435c7b6467 100644 --- a/pype.py +++ b/pype.py @@ -87,10 +87,15 @@ def boot(): break if not os.getenv("AVALON_MONGO"): - print("*** No DB connection string specified.") - import igniter - igniter.run() - set_environments() + try: + avalon_mongo = bootstrap.registry.get_secure_item("avalonMongo") + except ValueError: + print("*** No DB connection string specified.") + import igniter + igniter.run() + set_environments() + else: + os.environ["AVALON_MONGO"] = avalon_mongo if getattr(sys, 'frozen', False): if not pype_versions: diff --git a/tests/igniter/test_bootstrap_repos.py b/tests/igniter/test_bootstrap_repos.py index e9d5e6ffb4..a97aa21538 100644 --- a/tests/igniter/test_bootstrap_repos.py +++ b/tests/igniter/test_bootstrap_repos.py @@ -12,7 +12,7 @@ from pype.lib import PypeSettingsRegistry @pytest.fixture def fix_bootstrap(tmp_path): bs = BootstrapRepos() - bs.live_repo_dir = os.path.abspath('repos') + bs.live_repo_dir = Path(os.path.abspath('repos')) bs.data_dir = tmp_path return bs