# Copyright 2025–2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
Module for handling save operations in the application.
This module provides infrastructure for managing save operations for various
savable objects. It includes the core classes and functions needed to
implement save functionality with support for multiple formats and
user interaction through dialog boxes.
The main components are:
- :class:`SaveSpec` for defining save specifications
- :class:`Savable` protocol for objects that can be saved
- :class:`SaveCoordinator` for coordinating save operations
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Protocol, runtime_checkable
from PySide6.QtCore import QObject
from PySide6.QtWidgets import QFileDialog, QInputDialog, QWidget
[docs]
def normalize_save_path(path: Path, selected_suffix: str) -> Path:
"""
Normalize the save path based on selected suffix.
:param path: Original path
:type path: Path
:param selected_suffix: Selected file suffix without dot
:type selected_suffix: str
:return: Normalized path
:rtype: Path
"""
if path.suffix:
# User explicitly typed an extension
if path.suffix.lstrip('.') != selected_suffix:
# Normalize to selected suffix
path = path.with_suffix(f'.{selected_suffix}')
else:
# No extension -> use selected suffix
path = path.with_suffix(f'.{selected_suffix}')
return path
[docs]
@dataclass(frozen=True)
class SaveSpec:
"""
Specification for a save operation.
Defines the parameters required for saving data in a specific format.
"""
key: str # this is what we are saving, not the format! (data, representation...)
label: str # "Image"
filter: str # 'TIFF (*.tif *.tiff);;JPEG (*.jpg)'
default_suffix: str # tif, might be overruled during normalizing
suffix_map: dict[str, str] # this map each filter to its default suffix
writer: Callable[[Path], None]
[docs]
@runtime_checkable
class Savable(Protocol):
"""
Protocol for objects that can be saved.
Defines the interface that objects must implement to be savable.
"""
[docs]
def default_file_name(self) -> str:
"""
Get the default file name for saving.
:return: Default file name
:rtype: str
"""
[docs]
def get_save_specs(self) -> list[SaveSpec]:
"""
Get available save specifications.
:return: List of save specifications
:rtype: list[:class:`SaveSpec`]
"""
[docs]
def can_save(self) -> bool:
"""
Check if the object can be saved.
:return: True if object can be saved, False otherwise
:rtype: bool
"""
[docs]
def can_save_as(self) -> bool:
"""
Check if the object can be saved as a different file.
:return: True if object can be saved as, False otherwise
:rtype: bool
"""
[docs]
def current_path(self) -> Path | str:
"""
Get the current file path.
:return: Current file path or string representation
:rtype: Path or str
"""
[docs]
class SaveCoordinator(QObject):
"""
Coordinator for managing save operations.
Handles the coordination of save operations including choosing
between multiple formats and managing user interactions.
"""
def __init__(self, parent: QWidget | None = None) -> None:
"""
Initialize the save coordinator.
:param parent: Parent QObject
:type parent: QObject or None
"""
super().__init__(parent)
[docs]
def save(self, savable: Savable, parent_widget: QWidget | None) -> None:
"""
Save the given savable object.
:param savable: Object to save
:type savable: :class:`Savable`
:param parent_widget: Parent widget for dialogs
:type parent_widget: :class:`QWidget`
"""
specs = savable.get_save_specs()
if not specs:
return
if len(specs) == 1:
self._save_with_spec(savable, specs[0])
else:
self._choose_and_save(savable, specs, parent_widget)
[docs]
def _save_with_spec(self, savable: Savable, spec: SaveSpec) -> None:
"""
Save using the specified save specification.
:param savable: Object to save
:type savable: :class:`Savable`
:param spec: Save specification to use
:type spec: :class:`SaveSpec`
"""
path = Path(savable.current_path())
spec.writer(path)
[docs]
def _choose_and_save(self, savable: Savable, specs: list[SaveSpec], parent_widget: QWidget | None) -> None:
"""
Choose format and save the object.
:param savable: Object to save
:type savable: :class:`Savable`
:param specs: List of available save specifications
:type specs: list[:class:`SaveSpec`]
:param parent_widget: Parent widget for dialogs
:type parent_widget: :class:`QWidget`
"""
spec = self._ask_user_for_format(specs, parent_widget)
if spec is not None:
self._save_with_spec(savable, spec)
[docs]
def save_as(self, savable: Savable, parent_widget: QWidget | None) -> None:
"""
Save the given savable object with a new name.
:param savable: Object to save
:type savable: :class:`Savable`
:param parent_widget: Parent widget for dialogs
:type parent_widget: :class:`QWidget`
"""
specs = savable.get_save_specs()
if not specs:
return
if len(specs) == 1:
self._save_as_with_spec(savable, specs[0], parent_widget)
else:
self._choose_and_save_as(savable, specs, parent_widget)
[docs]
def _normalize_save_path(
self,
path: Path,
selected_filter: str,
spec: SaveSpec,
) -> Path:
"""
Normalize the save path based on selected filter.
:param path: Original path
:type path: Path
:param selected_filter: Selected file filter
:type selected_filter: str
:param spec: Save specification
:type spec: :class:`SaveSpec`
:return: Normalized path
:rtype: Path
"""
selected_suffix = spec.suffix_map.get(selected_filter, spec.default_suffix)
return normalize_save_path(path=path, selected_suffix=selected_suffix)
[docs]
def _save_as_with_spec(self, savable: Savable, spec: SaveSpec, parent_widget: QWidget | None) -> None:
"""
Save as using the specified save specification.
:param savable: Object to save
:type savable: :class:`Savable`
:param spec: Save specification to use
:type spec: :class:`SaveSpec`
:param parent_widget: Parent widget for dialogs
:type parent_widget: :class:`QWidget`
"""
filename, selected_filter = QFileDialog.getSaveFileName(
parent_widget,
f'Save {spec.label}',
dir=savable.default_file_name(),
filter=spec.filter,
)
if not filename:
return
path = Path(filename)
path = self._normalize_save_path(
path=path,
selected_filter=selected_filter,
spec=spec,
)
spec.writer(path)
[docs]
def _choose_and_save_as(self, savable: Savable, specs: list[SaveSpec], parent_widget: QWidget | None) -> None:
"""
Choose format and save as the object.
:param savable: Object to save
:type savable: :class:`Savable`
:param specs: List of available save specifications
:type specs: list[:class:`SaveSpec`]
:param parent_widget: Parent widget for dialogs
:type parent_widget: :class:`QWidget`
"""
spec = self._ask_user_for_format(specs, parent_widget)
if spec is not None:
self._save_as_with_spec(savable, spec, parent_widget)