diff --git a/MDANSE/Src/MDANSE/IO/IOUtils.py b/MDANSE/Src/MDANSE/IO/IOUtils.py index 3b3903dcdc..bfc62ef08d 100644 --- a/MDANSE/Src/MDANSE/IO/IOUtils.py +++ b/MDANSE/Src/MDANSE/IO/IOUtils.py @@ -18,26 +18,39 @@ import json import re from collections import UserDict +from collections.abc import Callable, Collection, Iterable, Iterator, Sequence from enum import Enum from functools import singledispatch -from itertools import filterfalse +from itertools import count, filterfalse, islice from pathlib import Path -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Protocol, TypeVar, overload import numpy as np from more_itertools import first_true, last, take, value_chain from MDANSE.MLogging import LOG -if TYPE_CHECKING: - from collections.abc import Callable, Iterable, Iterator, Sequence - K = TypeVar("K", str, bytes) V = TypeVar("V") MAX_FILE_COUNT = 2048 +class SupportsStr(Protocol): + """Any class which supports __str__ method""" + + def __str__(self) -> str: ... + + +class SupportsRepr(Protocol): + """Any class which supports __repr__ method""" + + def __repr__(self) -> str: ... + + +SupportsFormat = SupportsStr | SupportsRepr + + class UCDict(UserDict[K, V]): """Case insensitive dictionary where all keys are uppercase.""" @@ -382,6 +395,87 @@ def summarise_array( return ", ".join(value_chain(take(show, arr), "...", last(arr))) +@overload +def get_next_name( + template: str, + *, + exists: Collection[str] | Callable[[str], bool], + trial: Iterable[SupportsFormat] | None = ..., + max_tries: int | None = ..., + default: None = ..., + **kwargs: SupportsFormat, +) -> str | None: ... +@overload +def get_next_name( + template: str, + *, + exists: Collection[str] | Callable[[str], bool], + trial: Iterable[SupportsFormat] | None = ..., + max_tries: int | None = ..., + default: str = ..., + **kwargs: SupportsFormat, +) -> str: ... +def get_next_name( + template: str, + *, + exists: Collection[str] | Callable[[str], bool], + trial: Iterable[SupportsFormat] | None = None, + max_tries: int | None = None, + default: str | None = None, + **kwargs: SupportsFormat, +) -> str | None: + """Return the first unused name given rules for next in sequence and invalid values. + + Parameters + ---------- + template : str + Base format string to modify, must contain ``trial`` field. + exists : Collection[str] | Callable[[str], bool] + Set of existing values to skip or :ref:`Callable` determining existance. + trial : Iterable[SupportsFormat], optional + Set/Generator of trial values to use. If ``None`` defaults to :ref:`itertools.count`. + max_tries : int, optional + Number of attempts to generate, unlimited if ``None``. + default : str, optional + Default to return if ``max_tries`` reached or ``trial`` exhausted. + **kwargs : SupportsFormat + Extra substitutions to pass into template. + + Returns + ------- + str | None + Next unused name. + + Notes + ----- + The special value which is substituted is named "trial". + + Examples + -------- + >>> tpl = "hello_{trial}" + >>> get_next_name(tpl, exists=()) + 'hello_1' + >>> get_next_name(tpl, exists=lambda x: x[-1] != "6") + 'hello_6' + >>> get_next_name(tpl, exists={"hello_1", "hello_2"}) + 'hello_3' + >>> get_next_name(tpl, exists={"hello_1", "hello_2"}, max_tries=1, default="Argh!") + 'Argh!' + >>> get_next_name("{a}_hello_{trial}", exists=(), a="big") + 'big_hello_1' + """ + if trial is None: + trial = count(1) + + if isinstance(exists, Collection): + exists = exists.__contains__ + + gen = (template.format(trial=elem, **kwargs) for elem in trial) + return first_true( + islice(gen, max_tries), pred=lambda x: not exists(x), default=default + ) + + def unused_standard_output_filename( path_stem: Path, job_name: str, extra_text: str = "_result", extension: str = ".mda" ) -> Path | None: @@ -407,10 +501,10 @@ def unused_standard_output_filename( Path | None The first file name which does not exist. None if all names are taken. """ - temp_name_generator = ( - path_stem.with_name("".join((job_name, extra_text, str(number + 1)))) - for number in range(MAX_FILE_COUNT) - ) - return first_true( - temp_name_generator, pred=lambda x: not x.with_suffix(extension).exists() + name = get_next_name( + f"{path_stem / job_name}{extra_text}{{trial}}", + max_tries=MAX_FILE_COUNT, + exists=lambda x: Path(x).with_suffix(extension).exists(), ) + + return Path(name) if name else None diff --git a/MDANSE_GUI/Src/MDANSE_GUI/ElementsDatabaseEditor.py b/MDANSE_GUI/Src/MDANSE_GUI/ElementsDatabaseEditor.py index fdf3ec8ec8..7ef82d7659 100644 --- a/MDANSE_GUI/Src/MDANSE_GUI/ElementsDatabaseEditor.py +++ b/MDANSE_GUI/Src/MDANSE_GUI/ElementsDatabaseEditor.py @@ -15,6 +15,8 @@ # from __future__ import annotations +from itertools import repeat + from qtpy.QtCore import QSortFilterProxyModel, Qt, Signal, Slot from qtpy.QtGui import ( QBrush, @@ -38,7 +40,8 @@ ) from MDANSE.Chemistry import ATOMS_DATABASE -from MDANSE.Chemistry.Databases import AtomsDatabaseError +from MDANSE.Chemistry.Databases import AtomsDatabase, AtomsDatabaseError +from MDANSE.IO.IOUtils import get_next_name from MDANSE.MLogging import LOG from MDANSE_GUI.Tabs.Views.Delegates import ColourPicker from MDANSE_GUI.Widgets.GeneralWidgets import ( @@ -246,14 +249,14 @@ def __init__(self, *args, **kwargs): def contextMenuEvent(self, event): menu = QMenu(self) - Action1 = menu.addAction("New Custom Atom") - Action2 = menu.addAction("Copy Atoms") - Action3 = menu.addAction("Rename Custom Atom") - Action4 = menu.addAction("Delete Custom Atoms") - Action5 = menu.addAction("New Custom Property") - Action6 = menu.addAction("Copy Properties") - Action7 = menu.addAction("Rename Custom Property") - Action8 = menu.addAction("Delete Custom Properties") + NewAtom = menu.addAction("New Custom Atom") + CopyAtom = menu.addAction("Copy Atoms") + RenameAtom = menu.addAction("Rename Custom Atom") + DeleteAtom = menu.addAction("Delete Custom Atoms") + NewProp = menu.addAction("New Custom Property") + CopyProp = menu.addAction("Copy Properties") + RenameProp = menu.addAction("Rename Custom Property") + DeleteProp = menu.addAction("Delete Custom Properties") data_model = self.parent().data_model @@ -292,26 +295,26 @@ def contextMenuEvent(self, event): temp_model = self.model().sourceModel() if temp_model is not None: - Action1.triggered.connect(temp_model.new_line_dialog) - Action2.triggered.connect(temp_model.copy_rows) + NewAtom.triggered.connect(temp_model.new_line_dialog) + CopyAtom.triggered.connect(temp_model.copy_rows) if self.mouse_atm in custom_atms: - Action3.triggered.connect(temp_model.rename_row_dialog) + RenameAtom.triggered.connect(temp_model.rename_row_dialog) else: - Action3.setEnabled(False) + RenameAtom.setEnabled(False) if enable_delete_atms: - Action4.triggered.connect(temp_model.delete_rows) + DeleteAtom.triggered.connect(temp_model.delete_rows) else: - Action4.setEnabled(False) - Action5.triggered.connect(temp_model.new_column_dialog) - Action6.triggered.connect(temp_model.copy_columns) + DeleteAtom.setEnabled(False) + NewProp.triggered.connect(temp_model.new_column_dialog) + CopyProp.triggered.connect(temp_model.copy_columns) if self.mouse_prop in custom_props: - Action7.triggered.connect(temp_model.rename_column_dialog) + RenameProp.triggered.connect(temp_model.rename_column_dialog) else: - Action7.setEnabled(False) + RenameProp.setEnabled(False) if enable_delete_props: - Action8.triggered.connect(temp_model.delete_columns) + DeleteProp.triggered.connect(temp_model.delete_columns) else: - Action8.setEnabled(False) + DeleteProp.setEnabled(False) menu.exec_(event.globalPos()) @@ -351,7 +354,7 @@ class ElementModel(QStandardItemModel): table gets sorted. """ - def __init__(self, *args, element_database=None, **kwargs): + def __init__(self, *args, element_database: AtomsDatabase, **kwargs): super().__init__(*args, **kwargs) self.custom_header_brush = QBrush(QColor(255, 165, 0)) @@ -623,13 +626,14 @@ def copy_rows(self): for _, idx in row_idxs: atm_sym = self.verticalHeaderItem(idx).text() - atm_sym_copy = atm_sym + "(copy)" - while True: - if atm_sym_copy not in self.database.atoms: - self.database.add_atom(atm_sym_copy) - self.copy_row_in_database(atm_sym_copy, atm_sym) - break - atm_sym_copy += "(copy)" + atm_sym_copy = get_next_name( + f"{atm_sym}{{trial}}", + exists=self.database.atoms, + trial=repeat("(copy)"), + default="", + ) + self.database.add_atom(atm_sym_copy) + self.copy_row_in_database(atm_sym_copy, atm_sym) self.save_changes() @Slot(dict) diff --git a/MDANSE_GUI/Src/MDANSE_GUI/Tabs/Visualisers/InstrumentInfo.py b/MDANSE_GUI/Src/MDANSE_GUI/Tabs/Visualisers/InstrumentInfo.py index ba94de81ec..0c0b84f498 100644 --- a/MDANSE_GUI/Src/MDANSE_GUI/Tabs/Visualisers/InstrumentInfo.py +++ b/MDANSE_GUI/Src/MDANSE_GUI/Tabs/Visualisers/InstrumentInfo.py @@ -15,15 +15,13 @@ # from __future__ import annotations -from itertools import count - -from more_itertools import first_true from qtpy.QtCore import Qt, Signal, Slot from qtpy.QtGui import QStandardItem from qtpy.QtWidgets import QTextBrowser from MDANSE.Framework.QVectors.IQVectors import IQVectors from MDANSE.Framework.Units import measure +from MDANSE.IO.IOUtils import get_next_name from MDANSE.MLogging import LOG from MDANSE.MolecularDynamics.UnitCell import UnitCell from MDANSE_GUI.Widgets.ResolutionWidget import ResolutionCalculator, widget_text_map @@ -52,10 +50,15 @@ def generate_name( str Name composed of prefix, number, suffix using the lowest positive number possible. """ + if existing_names is None: return f"{prefix}{suffix}" - name_generator = (f"{prefix}{number}{suffix}" for number in count(1)) - return first_true(name_generator, pred=lambda x: x not in existing_names) + + return get_next_name( + f"{prefix}{{trial}}{suffix}", + exists=existing_names, + default="", + ) class SimpleInstrument: