MainModel

class poriscope.models.main_model.MainModel(app_config: Dict[str, Any])

Bases: QObject

App-shell model: owns app configuration (loaded from/saved to config.json), discovers and holds every available plugin class under poriscope/plugins/ and the user plugin folder, and persists/restores session and tab-action history.

ALLOWED_BASE_CLASSES: Dict[str, type] = {'MetaController': <class 'poriscope.utils.MetaController.MetaController'>, 'MetaDatabaseLoader': <class 'poriscope.utils.MetaDatabaseLoader.MetaDatabaseLoader'>, 'MetaDatabaseWriter': <class 'poriscope.utils.MetaDatabaseWriter.MetaDatabaseWriter'>, 'MetaEventFinder': <class 'poriscope.utils.MetaEventFinder.MetaEventFinder'>, 'MetaEventFitter': <class 'poriscope.utils.MetaEventFitter.MetaEventFitter'>, 'MetaEventLoader': <class 'poriscope.utils.MetaEventLoader.MetaEventLoader'>, 'MetaFilter': <class 'poriscope.utils.MetaFilter.MetaFilter'>, 'MetaModel': <class 'poriscope.utils.MetaModel.MetaModel'>, 'MetaReader': <class 'poriscope.utils.MetaReader.MetaReader'>, 'MetaView': <class 'poriscope.utils.MetaView.MetaView'>, 'MetaWriter': <class 'poriscope.utils.MetaWriter.MetaWriter'>}

The plugin families the app recognises, and the base class that identifies each. A file is a plugin when it subclasses one of these. A class attribute rather than rebuilt per call, since refresh_available_plugins makes discovery a repeated operation and the mapping never varies.

REPLACE_ATTEMPTS = 5

How many times a session write retries a rename Windows briefly refuses.

clear_cache() → None

Truncate the app’s log file (flushing any buffered log data first).

load_plugin(plugin_key: str, folder: str | Path, allowed_base_classes: Tuple[type, ...]) → type | None

Import one plugin file and return its class if it subclasses an allowed base.

The file <plugin_key>.py in folder is executed as a module with importlib, one plugin at a time, and the class of the same name is taken from it. The simple-plugin-loader package was considered and rejected because it loads every plugin at once. Any failure - a missing file, an import error, no class of that name, or a class outside allowed_base_classes - is logged and yields None rather than raising.

Parameters:
  • plugin_key (str) – The plugin’s name, which is both its file stem and its class name.

  • folder (Union[str, Path]) – The folder holding the plugin file.

  • allowed_base_classes (Tuple[type, ...]) – The bases a plugin class must inherit from to be accepted.

Returns:

The plugin class, or None if it could not be loaded or is not a plugin.

Return type:

Optional[type]

load_session(file_name: str | Path | None = None) → Dict[str, Any] | None

Read a saved session, or None if the file is missing, unreadable, or not a session.

A session maps each key to an entry carrying metaclass and subclass, as every writer of the history produces; anything else - a tab action history from the same folder, a config file - is refused here, before the caller resets the workspace to apply it. The caller reports a refusal to the user.

Parameters:

file_name (Optional[Union[str, Path]]) – the file to read, or None for the default session file

Returns:

the session, or None if it could not be loaded

Return type:

Optional[Dict[str, Any]]

populate_available_plugins() → Tuple[Dict[str, Dict[str, type]], Dict[str, List[str]]]

Find every plugin the app can offer, keyed by the family it belongs to.

Walks the shipped plugin tree and then the user’s plugin folder, importing each file to see what it subclasses.

Plugin names are unique across the whole app rather than per family, so the duplicate check here is keyed by name alone. Built-ins are walked first, so a user file of the same name is the one rejected - without that, it silently replaced the shipped plugin and there was no way to tell which had run.

Returns:

The plugin classes keyed by family then name, and the same names as a plain list per family.

Return type:

Tuple[Dict[str, Dict[str, type]], Dict[str, List[str]]]

refresh_available_plugins() → None

Re-scan the plugin directories and replace the cached results.

The scan otherwise runs once, in the constructor, so a user who points the app at a different plugin folder sees no change until the next launch. Each plugin file is loaded fresh via importlib.util.spec_from_file_location/exec_module rather than through sys.modules, so an edited file’s new code is always picked up on the next scan - but that also means every scan hands back a new class object, never the one a previous scan produced. This does not break anything already instantiated: an instance keeps working through its own __class__ reference regardless of what this cache holds, it just does not become an instance of the freshly-scanned class - the two are distinct objects until nothing references the older one any more. Files that have since been deleted simply are not walked, and so drop out.

Callers are responsible for propagating the new lists - the controllers and the view each hold a copy taken at construction.

replace_class_names_with_classes(d: ~typing.Any, class_dict: ~typing.Mapping[str, ~typing.Any] = {'bool': <class 'bool'>, 'float': <class 'float'>, 'int': <class 'int'>, 'str': <class 'str'>}) → None

Turn the type names written into session JSON back into real types.

Only under the keys that actually hold a type. Matching on the string alone converted any setting whose value happened to read “float” into <class ‘float’>, so a plugin configured with Event Type: “float” came back corrupted. The key is what distinguishes a serialised type from a string that looks like one, because the save side only ever writes a type name in place of a type.

A name the map does not know is left exactly as written.

Only dict values are walked, matching the save side.

Parameters:
  • d (Any) – The settings tree, edited in place.

  • class_dict (Mapping[str, Any]) – The names to restore, and the types to restore them to.

replace_classes_with_class_names(d: Any) → None

Replace every type in a settings tree with its name, so it can be written as JSON.

A plugin setting carries a real type under Type - float, str and so on - and JSON cannot hold one, so it is written as its name and turned back by replace_class_names_with_classes on load.

This converts a type under any key, not only the expected ones, so that no save can fail on a value json.dump cannot serialise. It warns about the unexpected ones, because the load side will not convert those back: the asymmetry is deliberate, and the warning is what stops it being silent.

Only dict values are walked. A list nested in a dict is not visited - the branch that once claimed to do so was unreachable from every caller, and nothing needs it: Options holds plugin names, not types.

Parameters:

d (Any) – The settings tree, edited in place.

reset_app_config() → Dict[str, Any]

Restore the three stored settings to their defaults and persist them.

Only config/config.json is touched. Saved sessions, plugin history and log files are left alone.

This writes the file and updates the in-memory config, but does not apply the values to anything already running: reverting the parent folder has to reach live data plugins, and reverting the log level has to reconfigure the logger. MainController.reset_app_config routes the returned values back through the same paths a manual edit uses, so those side effects are not duplicated here.

Returns:

The defaults that were applied, so the caller can act on them.

Return type:

Dict[str, Any]

save_session(plugin_history: Dict[str, Any], save_file: str | Path | None = None) → None

Write the plugin history to disk as JSON

A write failure is logged rather than raised. Every caller is a Qt slot, and PySide6 does not tolerate an exception escaping a slot invoked from C++, so a read-only or otherwise unwritable destination would take the process down. except Exception rather than except OSError because json.dump also raises TypeError for a value it cannot serialize.

The level depends on who asked for the save. An autosave - save_file is None, which includes the app-shutdown path - logs at WARNING, which QtHandler does not raise a dialog for; a modal dialog during aboutToQuit would be its own bug. It also emits add_text_to_display, so the status panel says autosaving has stopped: the failure is rare and non-blocking, but a user whose work is no longer being persisted needs to know they are operating unprotected. A save to a path the user chose logs at ERROR instead, and therefore does raise a dialog, so that a Save Session which did not happen is not mistaken for one that did.

Parameters:
  • plugin_history (Dict[str, Any]) – the session state to persist

  • save_file (Optional[Union[str, Path]]) – path to write to, or None to use the default session file

save_tab_actions(plugin_history: Dict[str, Any], save_file: str | Path | None = None) → None

Write the tab action history to disk as JSON

Failures are handled exactly as in save_session(), and for the same reasons: logged rather than raised because the callers are Qt slots, at WARNING plus a status-panel message for an autosave, and at ERROR for a save to a path the user chose.

Parameters:
  • plugin_history (Dict[str, Any]) – the tab action history to persist

  • save_file (Optional[Union[str, Path]]) – path to write to, or None to use the default session file