Exemple #1
0
class ProjectListItem(QObject):
    """
    The core functionality class - the wrapper around the Stm32pio class suitable for the project GUI representation
    """

    logAdded = Signal(str, int,
                      arguments=['message', 'level'
                                 ])  # send the log message to the front-end
    initialized = Signal()
    destructed = Signal()

    def __init__(self,
                 project_args: List[Any] = None,
                 project_kwargs: Mapping[str, Any] = None,
                 from_startup: bool = False,
                 parent: QObject = None):
        """
        Instance construction is split into 2 phases: the wrapper setup and inner Stm32pio class initialization. The
        latter one is taken out to the separated thread as it is, potentially, a time-consuming operation. This thread
        starts right after the main constructor so the wrapper is already built at that moment and therefore can be used
        from GUI, be referenced and so on.

        Args:
            project_args: list of positional arguments that will be passed to the Stm32pio constructor
            project_kwargs: dictionary of keyword arguments that will be passed to the Stm32pio constructor
            from_startup: mark that this project comes from the beginning of the app life (e.g. from the NV-storage) so
                it can be treated differently on the GUI side
            parent: Qt parent
        """

        super().__init__(parent=parent)

        if project_args is None:
            project_args = []
        if project_kwargs is None:
            project_kwargs = {}

        self._from_startup = from_startup

        underlying_logger = logging.getLogger('stm32pio.gui.projects')
        self.logger = stm32pio.core.log.ProjectLogger(underlying_logger,
                                                      project_id=id(self))
        self.logging_worker = LoggingWorker(project_id=id(self))
        self.logging_worker.sendLog.connect(self.logAdded)

        # QThreadPool can automatically queue new incoming tasks if a number of them are larger than maxThreadCount
        self.workers_pool = QThreadPool(parent=self)
        self.workers_pool.setMaxThreadCount(1)
        self.workers_pool.setExpiryTimeout(
            -1)  # tasks wait forever for the available spot

        self._current_action: str = 'loading'
        self._last_action_succeed: bool = True

        # These values are valid only until the Stm32pio project initialize itself (or failed to) (see init_project)
        self.project: Optional[stm32pio.core.project.Stm32pio] = None
        # Use a project path string (as it should be a first argument) as a name
        self._name = str(project_args[0]) if len(project_args) else 'Undefined'
        self._state = {
            'LOADING': True
        }  # pseudo-stage (not present in the ProjectStage enum but is used from QML)
        self._current_stage = 'LOADING'

        self.qml_ready = threading.Event(
        )  # the front and the back both should know when each other is initialized
        self.should_be_destroyed = threading.Event(
        )  # currently, is used just to "cancel" the initialization thread

        # Register some kind of the deconstruction handler (later, after the project initialization, see init_project)
        self._finalizer = None

        if 'logger' not in project_kwargs:
            project_kwargs['logger'] = self.logger

        # Start the Stm32pio part initialization right after. It can take some time so we schedule it in a dedicated
        # thread
        init_thread = threading.Thread(target=self.init_project,
                                       args=project_args,
                                       kwargs=project_kwargs)
        init_thread.start()

    def init_project(self, *args, **kwargs) -> None:
        """
        Initialize the underlying Stm32pio project.

        Args:
            *args: positional arguments of the Stm32pio constructor
            **kwargs: keyword arguments of the Stm32pio constructor
        """
        try:
            self.project = stm32pio.core.project.Stm32pio(*args, **kwargs)
        except:
            stm32pio.core.log.log_current_exception(self.logger)
            self._state = {'INIT_ERROR': True}  # pseudo-stage
            self._current_stage = 'INIT_ERROR'
        else:
            if self.project.config.get('project', 'inspect_ioc').lower() in stm32pio.core.settings.yes_options and \
               self.project.state.current_stage > stm32pio.core.state.ProjectStage.EMPTY:
                self.project.inspect_ioc_config()
        finally:
            # Register some kind of the deconstruction handler
            self._finalizer = weakref.finalize(
                self, self.at_exit, self.workers_pool, self.logging_worker,
                self.name if self.project is None else str(self.project))
            self._current_action = ''

            # Wait for the GUI to initialize (which one is earlier, actually, back or front)
            while not self.qml_ready.wait(timeout=0.050):
                # When item is located out of visible scope then QML doesn't load its visual representation so this
                # initialization thread is kept hanging. This is OK except when the app shutting down, it prevents
                # Python GC of calling weakref for finalization process. So we introduce additional flag to be able
                # to quit the thread
                if self.should_be_destroyed.is_set():
                    break

            if not self.should_be_destroyed.is_set():
                self.updateState()
                self.initialized.emit()
                self.nameChanged.emit(
                )  # in any case we should notify the GUI part about the initialization ending

    @staticmethod
    def at_exit(workers_pool: QThreadPool, logging_worker: LoggingWorker,
                name: str):
        """
        The instance deconstruction handler is meant to be used with weakref.finalize() conforming with the requirement
        to have no reference to the target object (so it doesn't contain any instance reference and also is decorated as
        'staticmethod')
        """
        # Wait forever for all the jobs to complete. Currently, we cannot abort them gracefully
        workers_pool.waitForDone(msecs=-1)
        logging_worker.stopped.set(
        )  # post the event in the logging worker to inform it...
        logging_worker.thread.wait()  # ...and wait for it to exit, too
        module_logger.debug(f"destroyed {name}")

    def deleteLater(self) -> None:
        self.destructed.emit()
        return super().deleteLater()

    @Slot()
    def qmlLoaded(self):
        """Event signaling the complete loading of the needed frontend components"""
        self.qml_ready.set()
        self.logging_worker.can_flush_log.set()

    @Property(bool)
    def fromStartup(self) -> bool:
        """Is this project is here from the beginning of the app life?"""
        return self._from_startup

    @Property('QVariant')
    def config(self) -> dict:
        """Inner project's ConfigParser config converted to the dictionary (QML JS object)"""
        # TODO: cache this? (related to live-reloaded settings...)
        return {
            section:
            {key: value
             for key, value in self.project.config.items(section)}
            if self.project is not None else {}
            for section in ['app', 'project']
        }

    nameChanged = Signal()

    @Property(str, notify=nameChanged)
    def name(self) -> str:
        """Human-readable name of the project. Will evaluate to the absolute path if it cannot be instantiated"""
        if self.project is not None:
            return self.project.path.name
        else:
            return self._name

    stateChanged = Signal()

    @Property('QVariant', notify=stateChanged)
    def state(self) -> dict:
        """
        Get the current project state in the appropriate Qt form. Update the cached 'current stage' value as a side
        effect
        """
        if type(self._state) == stm32pio.core.state.ProjectState:
            return {stage.name: value for stage, value in self._state.items()}
        else:
            return self._state

    @Slot()
    def updateState(self):
        if self.project is not None:
            self._state = self.project.state
        self.stateChanged.emit()
        self.currentStageChanged.emit()

    currentStageChanged = Signal()

    @Property(str, notify=currentStageChanged)
    def currentStage(self) -> str:
        """
        Get the current stage the project resides in.
        Note: this returns a cached value. Cache updates every time the state property got requested
        """
        if type(self._state) == stm32pio.core.state.ProjectState:
            return self._state.current_stage.name
        else:
            return self._current_stage

    @Property(str)
    def currentAction(self) -> str:
        """
        Stm32pio action (i.e. function name) that is currently executing or an empty string if there is none. It is set
        on actionStarted signal and reset on actionFinished
        """
        return self._current_action

    @Property(bool)
    def lastActionSucceed(self) -> bool:
        """Have the last action ended with a success?"""
        return self._last_action_succeed

    actionStarted = Signal(str, arguments=['action'])

    @Slot(str)
    def actionStartedSlot(self, action: str):
        """Pass the corresponding signal from the worker, perform related tasks"""
        # Currently, this property should be set BEFORE emitting the 'actionStarted' signal (because QML will query it
        # when the signal will be handled in StateMachine) (probably, should be resolved later as it is bad to be bound
        # to such a specific logic)
        self._current_action = action
        self.actionStarted.emit(action)

    actionFinished = Signal(str, bool, arguments=['action', 'success'])

    @Slot(str, bool)
    def actionFinishedSlot(self, action: str, success: bool):
        """Pass the corresponding signal from the worker, perform related tasks"""
        self._last_action_succeed = success
        if not success:
            # Clear the queue - stop further execution (cancel planned tasks if an error had happened)
            self.workers_pool.clear()
        self.actionFinished.emit(action, success)
        # Currently, this property should be reset AFTER emitting the 'actionFinished' signal (because QML will query it
        # when the signal will be handled in StateMachine) (probably, should be resolved later as it is bad to be bound
        # to such a specific logic)
        self._current_action = ''

    @Slot(str, 'QVariantList')
    def run(self, action: str, args: List[Any]):
        """
        Asynchronously perform Stm32pio actions (generate, build, etc.) (dispatch all business logic).

        Args:
            action: method name of the corresponding Stm32pio action
            args: list of positional arguments for this action
        """

        worker = Worker(getattr(self.project, action),
                        args,
                        self.logger,
                        parent=self)
        worker.started.connect(self.actionStartedSlot)
        worker.finished.connect(self.actionFinishedSlot)
        worker.finished.connect(self.updateState)
        worker.finished.connect(self.currentStageChanged)

        self.workers_pool.start(
            worker)  # will automatically place to the queue
Exemple #2
0
class ProjectsList(QAbstractListModel):
    """QAbstractListModel implementation"""

    ProjectRole = Qt.UserRole + 1
    goToProject = Signal(int, arguments=['indexToGo'])

    def __init__(self, projects: List[ProjectListItem] = None, parent: QObject = None):
        """
        Args:
            projects: initial list of projects
            parent: QObject to be parented to
        """
        super().__init__(parent=parent)

        self.projects = projects if projects is not None else []

        self.workers_pool = QThreadPool(parent=self)
        self.workers_pool.setMaxThreadCount(1)  # only 1 active worker at a time
        self.workers_pool.setExpiryTimeout(-1)  # tasks wait forever for the available spot

    def rowCount(self, parent=None, *args, **kwargs):
        return len(self.projects)

    def data(self, index: QModelIndex, role=None):
        if role == ProjectsList.ProjectRole or role == 0 or role is None:
            return self.projects[index.row()]

    def roleNames(self) -> Mapping[int, bytes]:
        return { ProjectsList.ProjectRole: b'project' }


    def _saveInSettings(self) -> None:
        """
        Get correct projects and save them to Settings. Intended to be run in a thread (as it blocks)
        """

        # Wait for all projects to be initialized, whether successfully or not
        while any(project.currentAction == 'loading' for project in self.projects):
            time.sleep(0.1)  # throttle the thread a little bit

        # Only correct ones (i.e. inner Stm32pio instance has been successfully constructed)
        projects_to_save = [project for project in self.projects if project.project is not None]

        settings = stm32pio.gui.settings.global_instance()
        settings.beginGroup('app')
        settings.remove('projects')  # clear the current saved list
        settings.beginWriteArray('projects')
        for idx, project in enumerate(projects_to_save):
            settings.setArrayIndex(idx)
            # This ensures that we always save paths in the pathlib-compatible format
            settings.setValue('path', str(project.project.path))
        settings.endArray()
        settings.endGroup()

        module_logger.debug(f"{len(projects_to_save)} projects have been saved to Settings")  # total amount

    def saveInSettings(self) -> None:
        """Spawn a thread to wait for all projects and save them in background"""
        self.workers_pool.start(Worker(self._saveInSettings, logger=module_logger, parent=self))


    # TODO: simplify?
    def each_project_is_duplicate_of(self, path: str) -> Iterator[bool]:
        """
        Returns generator yielding an answer to the question "Is current project is a duplicate of one represented by a
        given path?" for every project in this model, one by one.

        Logic explanation: At a given time some projects (e.g., when we add a bunch of projects, recently added ones)
        can be not instantiated yet so we cannot extract their project.path property and need to check before comparing.
        In this case, simply evaluate strings. Also, samefile will even raise, if the given path doesn't exist and
        that's exactly what we want.
        """
        for list_item in self.projects:
            try:
                yield (list_item.project is not None and list_item.project.path.samefile(pathlib.Path(path))) or \
                      path == list_item.name  # simply check strings if a path isn't available
            except OSError:
                yield False


    def addListItem(self, path: str, list_item_kwargs: Mapping[str, Any] = None) -> ProjectListItem:
        """
        Create and append to the list tail a new ProjectListItem instance. This doesn't save in QSettings, it's an up to
        the caller task (e.g. if we adding a bunch of projects, it make sense to store them once in the end).

        Args:
            path: path as a string
            list_item_kwargs: keyword arguments passed to the ProjectListItem constructor
        """

        # Shallow copy, dict makes it mutable
        list_item_kwargs = dict(list_item_kwargs if list_item_kwargs is not None else {})

        # Parent is always this model so we implicitly pass it there (unless it was explicitly set)
        if 'parent' not in list_item_kwargs or not list_item_kwargs['parent']:
            list_item_kwargs['parent'] = self

        duplicate_index = next((idx for idx, is_duplicated in enumerate(self.each_project_is_duplicate_of(path))
                                if is_duplicated), -1)
        if duplicate_index > -1:
            # Just added project is already in the list so abort the addition
            module_logger.warning(f"This project is already in the list: {path}")

            # If some parameters were provided, merge them
            proj_params = list_item_kwargs.get('project_kwargs', {}).get('parameters', {})
            if len(proj_params):
                self.projects[duplicate_index].logger.info(f"updating parameters from the CLI... {proj_params}")
                # Note: will save stm32pio.ini even if there was not one
                self.projects[duplicate_index].run('save_config', [proj_params])

            self.goToProject.emit(duplicate_index)  # jump to the existing one

            return self.projects[duplicate_index]
        else:
            # Insert given path into the constructor args (do not use dict.update() as we have list value that we also
            # want to "merge")
            if 'project_args' not in list_item_kwargs or len(list_item_kwargs['project_args']) == 0:
                list_item_kwargs['project_args'] = [path]
            else:
                list_item_kwargs['project_args'][0] = path

            # The project is ready to be appended to the model right after the main constructor (wrapper) finished.
            # The underlying Stm32pio class will be initialized soon later in the dedicated thread
            project = ProjectListItem(**list_item_kwargs)

            self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount())
            self.projects.append(project)
            self.endInsertRows()

            return project


    @Slot('QStringList')
    def addProjectsByPaths(self, paths: List[str]):
        """QUrl path (typically is sent from the QML GUI)"""
        if len(paths):
            for path_str in paths:  # convert to strings
                path_qurl = QUrl(path_str)
                if path_qurl.isEmpty():
                    module_logger.warning(f"Given path is empty: {path_str}")
                    continue
                elif path_qurl.isLocalFile():  # file://...
                    path: str = path_qurl.toLocalFile()
                elif path_qurl.isRelative():  # this means that the path string is not starting with 'file://' prefix
                    path: str = path_str  # just use a source string
                else:
                    module_logger.error(f"Incorrect path: {path_str}")
                    continue
                self.addListItem(path)
            self.saveInSettings()  # save after all
        else:
            module_logger.warning("No paths were given")


    @Slot(int)
    def removeRow(self, index: int, parent=QModelIndex()) -> bool:
        try:
            self.beginRemoveRows(parent, index, index)
            project = self.projects.pop(index)
            self.endRemoveRows()
        except:
            log_current_exception(module_logger, show_traceback=True)
            return False
        else:
            # Re-save the settings only if this project is saved in the settings
            if project.project is not None or project.fromStartup:
                self.saveInSettings()

            # It allows the project to be deconstructed (i.e. GC'ed) very soon, not at the app shutdown time
            project.deleteLater()

            return True
Exemple #3
0
class ProjectsList(QAbstractListModel):
    """
    QAbstractListModel implementation - describe basic operations and delegate all main functionality to the
    ProjectListItem
    """

    goToProject = Signal(int, arguments=['indexToGo'])

    def __init__(self,
                 projects: List[ProjectListItem] = None,
                 parent: QObject = None):
        """
        Args:
            projects: initial list of projects
            parent: QObject to be parented to
        """
        super().__init__(parent=parent)

        self.projects = projects if projects is not None else []

        self.workers_pool = QThreadPool(parent=self)
        self.workers_pool.setMaxThreadCount(
            1)  # only 1 active worker at a time
        self.workers_pool.setExpiryTimeout(
            -1)  # tasks wait forever for the available spot

    @Slot(int, result=ProjectListItem)
    def get(self, index: int):
        """
        Expose the ProjectListItem to the GUI QML side. You should firstly register the returning type using
        qmlRegisterType or similar
        """
        if index in range(len(self.projects)):
            return self.projects[index]

    def rowCount(self, parent=None, *args, **kwargs):
        return len(self.projects)

    def data(self, index: QModelIndex, role=None):
        if role == Qt.DisplayRole or role is None:
            return self.projects[index.row()]

    def _saveInSettings(self) -> None:
        """
        Get correct projects and save them to Settings. Intended to be run in a thread
        """

        # Wait for all projects to be loaded (project.init_project is finished), whether successful or not
        while not all(project.name != 'Loading...'
                      for project in self.projects):
            pass

        settings.beginGroup('app')
        settings.remove('projects')  # clear the current saved list

        settings.beginWriteArray('projects')
        # Only correct ones (inner Stm32pio instance has been successfully constructed)
        projects_to_save = [
            project for project in self.projects if project.project is not None
        ]
        for idx, project in enumerate(projects_to_save):
            settings.setArrayIndex(idx)
            # This ensures that we always save paths in pathlib form
            settings.setValue('path', str(project.project.path))
        settings.endArray()

        settings.endGroup()
        module_logger.info(
            f"{len(projects_to_save)} projects have been saved to Settings"
        )  # total amount

    def saveInSettings(self) -> None:
        """Spawn a thread to wait for all projects and save them in background"""
        w = Worker(self._saveInSettings, logger=module_logger)
        self.workers_pool.start(w)

    def each_project_is_duplicate_of(self, path: str) -> Iterator[bool]:
        """
        Returns generator yielding an answer to the question "Is current project is a duplicate of one represented by a
        given path?" for every project in this model, one by one.

        Logic explanation: At a given time some projects (e.g., when we add a bunch of projects, recently added ones)
        can be not instantiated yet so we cannot extract their project.path property and need to check before comparing.
        In this case, simply evaluate strings. Also, samefile will even raise, if the given path doesn't exist.
        """
        for list_item in self.projects:
            try:
                yield (list_item.project is not None and list_item.project.path.samefile(pathlib.Path(path))) or \
                      path == list_item.name  # simply check strings if a path isn't available
            except OSError:
                yield False

    def addListItem(self,
                    path: str,
                    list_item_kwargs: Mapping[str, Any] = None,
                    go_to_this: bool = False) -> None:
        """
        Create and append to the list tail a new ProjectListItem instance. This doesn't save in QSettings, it's an up to
        the caller task (e.g. if we adding a bunch of projects, it make sense to store them once in the end).

        Args:
            path: path as string
            list_item_kwargs: keyword arguments passed to the ProjectListItem constructor
            go_to_this: should we jump to the new project in GUI
        """

        if list_item_kwargs is not None:
            list_item_kwargs = dict(
                list_item_kwargs)  # shallow copy, dict makes it mutable
        else:
            list_item_kwargs = {}

        duplicate_index = next((idx for idx, is_duplicated in enumerate(
            self.each_project_is_duplicate_of(path)) if is_duplicated), -1)
        if duplicate_index > -1:
            # Just added project is already in the list so abort the addition
            module_logger.warning(
                f"This project is already in the list: {path}")

            # If some parameters were provided, merge them
            proj_params = list_item_kwargs.get('project_kwargs',
                                               {}).get('parameters', {})
            if len(proj_params):
                self.projects[duplicate_index].logger.info(
                    f"updating parameters from the CLI... {proj_params}")
                self.projects[duplicate_index].run('save_config',
                                                   [proj_params])

            self.goToProject.emit(duplicate_index)  # jump to the existing one
            return

        # Insert given path into the constructor args (do not use dict.update() as we have list value that we also want
        # to "merge")
        if len(list_item_kwargs) == 0:
            list_item_kwargs = {'project_args': [path]}
        elif 'project_args' not in list_item_kwargs or len(
                list_item_kwargs['project_args']) == 0:
            list_item_kwargs['project_args'] = [path]
        else:
            list_item_kwargs['project_args'][0] = path

        self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount())

        # The project is ready to be appended to the model right after the main constructor (wrapper) finished. The
        # underlying Stm32pio class will be initialized soon later in the dedicated thread
        project = ProjectListItem(**list_item_kwargs)
        self.projects.append(project)

        self.endInsertRows()

        if go_to_this:
            self.goToProject.emit(len(self.projects) - 1)

    @Slot('QStringList')
    def addProjectsByPaths(self, paths: List[str]):
        """QUrl path (typically is sent from the QML GUI)"""
        if len(paths) == 0:
            module_logger.warning("No paths were given")
            return
        else:
            for path_str in paths:  # convert to strings
                path_qurl = QUrl(path_str)
                if path_qurl.isEmpty():
                    module_logger.warning(f"Given path is empty: {path_str}")
                    continue
                elif path_qurl.isLocalFile():  # file://...
                    path: str = path_qurl.toLocalFile()
                elif path_qurl.isRelative(
                ):  # this means that the path string is not starting with 'file://' prefix
                    path: str = path_str  # just use a source string
                else:
                    module_logger.error(f"Incorrect path: {path_str}")
                    continue
                self.addListItem(path, list_item_kwargs={'parent': self})
            self.saveInSettings()

    @Slot(int)
    def removeProject(self, index: int):
        """
        Remove the project residing on the index both from the runtime list and QSettings
        """
        if index not in range(len(self.projects)):
            return

        self.beginRemoveRows(QModelIndex(), index, index)
        project = self.projects.pop(index)
        self.endRemoveRows()

        if project.project is not None:
            # Re-save the settings only if this project was correct and therefore is saved in the settings
            self.saveInSettings()

        # It allows the project to be deconstructed (i.e. GC'ed) very soon, not at the app shutdown time
        project.deleteLater()
Exemple #4
0
class ProjectListItem(QObject):
    """
    The core functionality class - GUI representation of the Stm32pio project
    """

    nameChanged = Signal()  # properties notifiers
    stateChanged = Signal()
    stageChanged = Signal()

    logAdded = Signal(str, int,
                      arguments=['message', 'level'
                                 ])  # send the log message to the front-end
    actionDone = Signal(str, bool,
                        arguments=['action', 'success'
                                   ])  # emit when the action has executed

    def __init__(self,
                 project_args: list = None,
                 project_kwargs: dict = None,
                 parent: QObject = None):
        super().__init__(parent=parent)

        if project_args is None:
            project_args = []
        if project_kwargs is None:
            project_kwargs = {}

        self.logger = logging.getLogger(f"{stm32pio.lib.__name__}.{id(self)}")
        self.logger.setLevel(
            logging.DEBUG if settings.get('verbose') else logging.INFO)
        self.logging_worker = LoggingWorker(self.logger)
        self.logging_worker.sendLog.connect(self.logAdded)

        # QThreadPool can automatically queue new incoming tasks if a number of them are larger than maxThreadCount
        self.workers_pool = QThreadPool()
        self.workers_pool.setMaxThreadCount(1)
        self.workers_pool.setExpiryTimeout(
            -1)  # tasks forever wait for the available spot

        # These values are valid till the Stm32pio project does not initialize itself (or failed to)
        self.project = None
        self._name = 'Loading...'
        self._state = {'LOADING': True}
        self._current_stage = 'Loading...'

        self.qml_ready = threading.Event(
        )  # the front and the back both should know when each other is initialized

        self._finalizer = weakref.finalize(
            self, self.at_exit)  # register some kind of deconstruction handler

        if project_args is not None:
            if 'instance_options' not in project_kwargs:
                project_kwargs['instance_options'] = {'logger': self.logger}
            elif 'logger' not in project_kwargs['instance_options']:
                project_kwargs['instance_options']['logger'] = self.logger

            # Start the Stm32pio part initialization right after. It can take some time so we schedule it in a dedicated
            # thread
            self.init_thread = threading.Thread(target=self.init_project,
                                                args=project_args,
                                                kwargs=project_kwargs)
            self.init_thread.start()

    def init_project(self, *args, **kwargs) -> None:
        """
        Initialize the underlying Stm32pio project.

        Args:
            *args: positional arguments of the Stm32pio constructor
            **kwargs: keyword arguments of the Stm32pio constructor
        """
        try:
            self.project = stm32pio.lib.Stm32pio(*args, **kwargs)
        except Exception as e:
            # Error during the initialization
            self.logger.exception(e,
                                  exc_info=self.logger.isEnabledFor(
                                      logging.DEBUG))
            if len(args):
                self._name = args[
                    0]  # use project path string (probably) as a name
            else:
                self._name = 'No name'
            self._state = {'INIT_ERROR': True}
            self._current_stage = 'Initializing error'
        else:
            self._name = 'Project'  # successful initialization. These values should not be used anymore
            self._state = {}
            self._current_stage = 'Initialized'
        finally:
            self.qml_ready.wait()  # wait for the GUI to initialized
            self.nameChanged.emit(
            )  # in any case we should notify the GUI part about the initialization ending
            self.stageChanged.emit()
            self.stateChanged.emit()

    def at_exit(self):
        module_logger.info(f"destroy {self.project}")
        self.workers_pool.waitForDone(
            msecs=-1
        )  # wait for all jobs to complete. Currently, we cannot abort them gracefully
        self.logging_worker.stopped.set(
        )  # post the event in the logging worker to inform it...
        self.logging_worker.thread.wait()  # ...and wait for it to exit

    @Property(str, notify=nameChanged)
    def name(self):
        if self.project is not None:
            return self.project.path.name
        else:
            return self._name

    @Property('QVariant', notify=stateChanged)
    def state(self):
        if self.project is not None:
            # Convert to normal dict (JavaScript object) and exclude UNDEFINED key
            return {
                stage.name: value
                for stage, value in self.project.state.items()
                if stage != stm32pio.lib.ProjectStage.UNDEFINED
            }
        else:
            return self._state

    @Property(str, notify=stageChanged)
    def current_stage(self):
        if self.project is not None:
            return str(self.project.state.current_stage)
        else:
            return self._current_stage

    @Slot()
    def qmlLoaded(self):
        """
        Event signaling the complete loading of needed frontend components.
        """
        self.qml_ready.set()
        self.logging_worker.can_flush_log.set()

    @Slot(str, 'QVariantList')
    def run(self, action: str, args: list):
        """
        Asynchronously perform Stm32pio actions (generate, build, etc.) (dispatch all business logic).

        Args:
            action: method name of the corresponding Stm32pio action
            args: list of positional arguments for the action
        """

        worker = ProjectActionWorker(getattr(self.project, action), args,
                                     self.logger)
        worker.actionDone.connect(self.stateChanged)
        worker.actionDone.connect(self.stageChanged)
        worker.actionDone.connect(self.actionDone)

        self.workers_pool.start(
            worker)  # will automatically place to the queue
Exemple #5
0
class ProjectListItem(QObject):
    """
    The core functionality class - the wrapper around the Stm32pio class suitable for the project GUI representation
    """

    logAdded = Signal(str, int,
                      arguments=['message', 'level'
                                 ])  # send the log message to the front-end
    initialized = Signal(ProjectID, arguments=['project_id'])

    def __init__(self,
                 project_args: List[any] = None,
                 project_kwargs: Mapping[str, Any] = None,
                 from_startup: bool = False,
                 parent: QObject = None):
        """
        Instance construction is split into 2 phases: the wrapper setup and inner Stm32pio class initialization. The
        latter one is taken out to the separated thread as it is, potentially, a time-consuming operation. This thread
        starts right after the main constructor so the wrapper is already built at that moment and therefore can be used
        from GUI, be referenced and so on.

        Args:
            project_args: list of positional arguments that will be passed to the Stm32pio constructor
            project_kwargs: dictionary of keyword arguments that will be passed to the Stm32pio constructor
            from_startup: mark that this project comes from the beginning of the app life (e.g. from the NV-storage) so
                it can be treated differently on the GUI side
            parent: Qt parent
        """

        super().__init__(parent=parent)

        if project_args is None:
            project_args = []
        if project_kwargs is None:
            project_kwargs = {}

        self._from_startup = from_startup

        underlying_logger = logging.getLogger('stm32pio.gui.projects')
        self.logger = stm32pio.core.util.ProjectLoggerAdapter(
            underlying_logger, {'project_id': id(self)})
        self.logging_worker = LoggingWorker(project_id=id(self))
        self.logging_worker.sendLog.connect(self.logAdded)

        # QThreadPool can automatically queue new incoming tasks if a number of them are larger than maxThreadCount
        self.workers_pool = QThreadPool(parent=self)
        self.workers_pool.setMaxThreadCount(1)
        self.workers_pool.setExpiryTimeout(
            -1)  # tasks wait forever for the available spot

        self._current_action: str = ''
        self._last_action_succeed: bool = True

        # These values are valid only until the Stm32pio project initialize itself (or failed to) (see init_project)
        self.project = None
        self._name = 'Loading...'
        self._state = {
            'LOADING': True
        }  # pseudo-stage (not present in the ProjectStage enum but is used from QML)
        self._current_stage = 'Loading...'

        self.qml_ready = threading.Event(
        )  # the front and the back both should know when each other is initialized

        # Register some kind of the deconstruction handler (later, after the project initialization, see init_project)
        self._finalizer = None

        if 'instance_options' not in project_kwargs:
            project_kwargs['instance_options'] = {'logger': self.logger}
        elif 'logger' not in project_kwargs['instance_options']:
            project_kwargs['instance_options']['logger'] = self.logger

        # Start the Stm32pio part initialization right after. It can take some time so we schedule it in a dedicated
        # thread
        init_thread = threading.Thread(target=self.init_project,
                                       args=project_args,
                                       kwargs=project_kwargs)
        init_thread.start()

    def init_project(self, *args, **kwargs) -> None:
        """
        Initialize the underlying Stm32pio project.

        Args:
            *args: positional arguments of the Stm32pio constructor
            **kwargs: keyword arguments of the Stm32pio constructor
        """
        try:
            # time.sleep(2.0)
            # raise Exception('blabla')
            self.project = stm32pio.core.lib.Stm32pio(*args, **kwargs)
        except Exception:
            stm32pio.core.util.log_current_exception(self.logger)
            if len(args):
                self._name = args[
                    0]  # use a project path string (as it should be a first argument) as a name
            else:
                self._name = 'Undefined'
            self._state = {'INIT_ERROR': True}  # pseudo-stage
            self._current_stage = 'Initializing error'
        else:
            # Successful initialization. These values should not be used anymore but we "reset" them anyway
            self._name = 'Project'
            self._state = {}
            self._current_stage = 'Initialized'
        finally:
            # Register some kind of the deconstruction handler
            self._finalizer = weakref.finalize(
                self, self.at_exit, self.workers_pool, self.logging_worker,
                self.name if self.project is None else str(self.project))
            self.qml_ready.wait(
            )  # wait for the GUI to initialize (which one is earlier, actually, back or front)

            # TODO: causing
            # RuntimeWarning: libshiboken: Overflow: Value 4595188736 exceeds limits of type  [signed] "i" (4bytes).
            # OverflowError
            self.initialized.emit(id(self))

            self.nameChanged.emit(
            )  # in any case we should notify the GUI part about the initialization ending
            self.stageChanged.emit()
            self.stateChanged.emit()

    @staticmethod
    def at_exit(workers_pool: QThreadPool, logging_worker: LoggingWorker,
                name: str):
        """
        The instance deconstruction handler is meant to be used with weakref.finalize() conforming with the requirement
        to have no reference to the target object (so it doesn't contain any instance reference and also is decorated as
        'staticmethod')
        """
        # Wait forever for all the jobs to complete. Currently, we cannot abort them gracefully
        workers_pool.waitForDone(msecs=-1)
        logging_worker.stopped.set(
        )  # post the event in the logging worker to inform it...
        logging_worker.thread.wait()  # ...and wait for it to exit, too
        module_logger.info(f"destroyed {name}")

    @Property(bool)
    def fromStartup(self) -> bool:
        """Is this project is here from the beginning of the app life?"""
        return self._from_startup

    @Property('QVariant')
    def config(self) -> dict:
        """Inner project's ConfigParser config converted to the dictionary (QML JS object)"""
        return {
            section:
            {key: value
             for key, value in self.project.config.items(section)}
            if self.project is not None else {}
            for section in ['app', 'project']
        }

    nameChanged = Signal()

    @Property(str, notify=nameChanged)
    def name(self) -> str:
        """Human-readable name of the project. Will evaluate to the absolute path if it cannot be instantiated"""
        if self.project is not None:
            return self.project.path.name
        else:
            return self._name

    stateChanged = Signal()

    @Property('QVariant', notify=stateChanged)
    def state(self) -> dict:
        """
        Get the current project state in the appropriate Qt form. Update the cached 'current stage' value as a side
        effect
        """
        if self.project is not None:
            state = self.project.state

            # Side-effect: caching the current stage at the same time to avoid the flooding of calls to the 'state'
            # getter (many IO operations). Requests to 'state' and 'stage' are usually goes together so there is no need
            # to necessarily keeps them separated
            self._current_stage = str(state.current_stage)

            state.pop(stm32pio.core.lib.ProjectStage.UNDEFINED
                      )  # exclude UNDEFINED key
            # Convert to {string: boolean} dict (will be translated into the JavaScript object)
            return {stage.name: value for stage, value in state.items()}
        else:
            return self._state

    stageChanged = Signal()

    @Property(str, notify=stageChanged)
    def currentStage(self) -> str:
        """
        Get the current stage the project resides in.
        Note: this returns a cached value. Cache updates every time the state property got requested
        """
        return self._current_stage

    @Property(str)
    def currentAction(self) -> str:
        """
        Stm32pio action (i.e. function name) that is currently executing or an empty string if there is none. It is set
        on actionStarted signal and reset on actionFinished
        """
        return self._current_action

    @Property(bool)
    def lastActionSucceed(self) -> bool:
        """Have the last action ended with a success?"""
        return self._last_action_succeed

    actionStarted = Signal(str, arguments=['action'])

    @Slot(str)
    def actionStartedSlot(self, action: str):
        """Pass the corresponding signal from the worker, perform related tasks"""
        # Currently, this property should be set BEFORE emitting the 'actionStarted' signal (because QML will query it
        # when the signal will be handled in StateMachine) (probably, should be resolved later as it is bad to be bound
        # to such a specific logic)
        self._current_action = action
        self.actionStarted.emit(action)

    actionFinished = Signal(str, bool, arguments=['action', 'success'])

    @Slot(str, bool)
    def actionFinishedSlot(self, action: str, success: bool):
        """Pass the corresponding signal from the worker, perform related tasks"""
        self._last_action_succeed = success
        if not success:
            # Clear the queue - stop further execution (cancel planned tasks if an error had happened)
            self.workers_pool.clear()
        self.actionFinished.emit(action, success)
        # Currently, this property should be reset AFTER emitting the 'actionFinished' signal (because QML will query it
        # when the signal will be handled in StateMachine) (probably, should be resolved later as it is bad to be bound
        # to such a specific logic)
        self._current_action = ''

    @Slot()
    def qmlLoaded(self):
        """Event signaling the complete loading of the needed frontend components"""
        self.qml_ready.set()
        self.logging_worker.can_flush_log.set()

    @Slot(str, 'QVariantList')
    def run(self, action: str, args: List[Any]):
        """
        Asynchronously perform Stm32pio actions (generate, build, etc.) (dispatch all business logic).

        Args:
            action: method name of the corresponding Stm32pio action
            args: list of positional arguments for this action
        """

        worker = Worker(getattr(self.project, action),
                        args,
                        self.logger,
                        parent=self)
        worker.started.connect(self.actionStartedSlot)
        worker.finished.connect(self.actionFinishedSlot)
        worker.finished.connect(self.stateChanged)
        worker.finished.connect(self.stageChanged)

        self.workers_pool.start(
            worker)  # will automatically place to the queue