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
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
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()
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
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