Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 105 additions & 11 deletions MDANSE/Src/MDANSE/IO/IOUtils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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:
Expand All @@ -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
62 changes: 33 additions & 29 deletions MDANSE_GUI/Src/MDANSE_GUI/ElementsDatabaseEditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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())

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 8 additions & 5 deletions MDANSE_GUI/Src/MDANSE_GUI/Tabs/Visualisers/InstrumentInfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading