#
##
## SPDX-FileCopyrightText: © 2007-2024 Benedict Verhegghe <bverheg@gmail.com>
## SPDX-License-Identifier: GPL-3.0-or-later
##
## This file is part of pyFormex 3.5 (Thu Feb 8 19:11:13 CET 2024)
## pyFormex is a tool for generating, manipulating and transforming 3D
## geometrical models by sequences of mathematical operations.
## Home page: https://pyformex.org
## Project page: https://savannah.nongnu.org/projects/pyformex/
## Development: https://gitlab.com/bverheg/pyformex
## Distributed under the GNU General Public License version 3 or later.
##
## This program is free software: you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation, either version 3 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program. If not, see http://www.gnu.org/licenses/.
##
"""project.py
Functions for managing a project in pyFormex.
"""
import re
from enum import Flag
import pyformex as pf
from pyformex.path import Path
from pyformex.track import TrackedDict
from pyformex.pzffile import PzfFile
from pyformex.pyffile import PyfFile
__all__ = ['Project', 'SORTKEY', ]
[docs]class SORTKEY(Flag):
"""A class with debug items.
This class holds the defined debug items as attributes.
Each debug topic is a binary value with a single bit set (a power of 2).
Debug items can be combined with & (AND), | (OR) and ^ (XOR).
Two extra values are defined: NONE switches off all debugging, ALL
activates all debug items.
Examples
--------
>>> print(SORTKEY['HUMAN'])
SORTKEY.HUMAN
"""
NONE = 0
(HUMAN, IGNORE, NAME, TYPE) = (2**i for i in range(4))
N = NAME
H = HUMAN | NAME
I = IGNORE | NAME
HI = HUMAN | IGNORE | NAME
T = TYPENAME = NAME | TYPE
TH = HUMAN | T
TI = IGNORE | T
THI = HUMAN | IGNORE | T
[docs] def split(self):
"""Returns human, ignore, name, type"""
return tuple(bool(self & m) for m in SORTKEY)
[docs] @staticmethod
def combine(sortkey=0, **kargs):
"""Combine parameters into a SORTKEY
>>> print(SORTKEY.combine(name=True, ignore=True))
SORTKEY.I
>>> print(SORTKEY.combine(TH=True, ignore=True))
SORTKEY.THI
"""
sortkey = SORTKEY(sortkey)
for k in kargs:
if kargs[k]:
sortkey |= SORTKEY[k.upper()]
return sortkey
@staticmethod
def sanitize(val):
if val == '':
val = SORTKEY.NONE
if not isinstance(val, SORTKEY):
try:
val = SORTKEY[val.upper()]
except Exception as e:
print(e)
print(f"Invalid SORTKEY input ({val}) replaced with SORTKEY.NONE")
val = SORTKEY.NONE
return val
def create_filter(clas=None, like=None, func=None):
if isinstance(clas, str):
clas = eval(clas)
if isinstance(func, str):
func = eval(func)
if clas:
if like:
if func:
return lambda k, v: (isinstance(v, clas)
and re.fullmatch(like, k)
and func(k, v))
else:
return lambda k, v: isinstance(v, clas) and re.fullmatch(like, k)
else:
if func:
return lambda k, v: isinstance(v, clas) and func(k, v)
else:
return lambda k, v: isinstance(v, clas)
else:
if like:
if func:
return lambda k, v: re.fullmatch(like, k) and func(k, v)
else:
return lambda k, v: re.fullmatch(like, k)
else:
if func:
return func
else:
return lambda k, v: True
def configured_filters(filters):
filt = {}
for k, v in filters:
filt[k] = create_filter(**v)
return filt
[docs]class Project(TrackedDict):
"""Project: a collection of pyFormex data with persistent storage.
A pyFormex Project is a Python dict that can contain named data
of any kind, and can be saved to a file to create persistence over
different pyFormex sessions. The dict is a :class:`TrackedDict` so it
does know if anything has changed to its contents since the last save.
The :class:`Project` class is used by pyFormex for the ``pyformex.PF``
where global variables from pyFormex scripts can be saved and made
accessible to the GUI.
While projects are mostly handled through the pyFormex GUI, notably the
*File/Project* menu, the user may also create and handle his own Project
objects from a script.
Initially Projects were stored in a dedicated format (PYF), but
nowadays they can be saved in the newer PZF format as well.
Because of the way pyFormex Projects are written to file,
there may be problems when trying to read a project file that was
created with another pyFormex version. Problems may occur if the
project contains data of a class whose implementation has changed,
or whose definition has been relocated. Our policy is to provide
backward compatibility: newer versions of pyFormex will normally
read the older project formats. Saving is always done in the
newest format, and these can generally not be read back by older
program versions (unless you are prepared to do some hacking).
.. warning:: Compatibility issues.
Occasionally you may run into problems when reading back an
old project file, especially when it was created by an unreleased
(development) version of pyFormex. Because pyFormex is evolving fast,
we can not test the full compatibility with every revision
You can file a support request on the pyFormex `support tracker`_.
and we will try to add the required conversion code to
pyFormex.
The project files are mainly intended as a means to easily save lots
of data of any kind and to restore them in the same session or a later
session, to pass them to another user (with the same or later pyFormex
version), to store them over a medium time period. Occasionally opening
and saving back your project files with newer pyFormex versions may help
to avoid read-back problems over longer time.
For a problemless long time storage of Geometry type objects you may
consider to write them to a pyFormex Geometry file (.pgf) instead, since
this uses a stable ascii based format. It can however (currently) only
store obects of class Geometry or one of its subclasses.
Parameters
----------
filename: :term:`path_like`
The name of the file where the Project data will be saved.
If the file exists (and `access` is not `w`), it should be a previously
saved Project and an attempt will be made to load the data from this
file into the Project. If this fails, an error is raised.
If the file exists and `access` is `w`, it will be overwritten,
destroying any previous contents.
If no filename is specified, a temporary file will be created when
the Project is saved for the first time. The file with not be
automatically deleted. The generated name can be retrieved from the
filename attribute.
access: str, optional
The access mode of the project file: 'wr' (default), 'rw', 'w' or 'r'.
If the string contains an 'r' the data from an existing file will be
read into the Project. If the string starts with an 'r', the file should
exist. If the string contains a 'w', the data can be written back to
the file. The 'r' access mode is a read-only mode.
====== =============== ============ ===================
access File must exist File is read File can be written
====== =============== ============ ===================
r yes yes no
rw yes yes yes
wr no if it exists yes
w no no yes
====== =============== ============ ===================
convert: bool, optional
If True (default), and the file is opened for reading, an
attempt is made to open old projects in a compatibility mode, doing the
necessary conversions to new data formats. If convert is set to False,
only the latest format can be read and older formats will generate
an error.
signature: str, optional
A text that will be written in the header record of the
file. This can e.g. be used to record format version info.
compression: int (0-9)
The compression level. For large data sets, compression leads
to much smaller files. 0 is no compression, 9 is maximal compression.
The default is 4.
binary: bool
If False and no compression is used, storage is done
in an ASCII format, allowing to edit the file. Otherwise, storage
uses a binary format. Using binary=False is deprecated.
data: dict, optional
A dict-like object to initialize the Project contents. These data
may override values read from the file.
protocol: int,optional
The protocol to be used in pickling the data to file. If not specified,
the highest protocol supported by the pickle module is used.
Attributes
----------
sortkey: str
Specifies how the objects should be sorted by default.
See the **sort** parameter of :meth:`contents` for possible
values and their meaning. The default is 'h'.
display: str, optional
Specifies what the returned list will contain.
See the **display** parameter of :meth:`contents` for possible
values and their meaning.
Examples
--------
>>> d = dict(a=1,b=2,c=3,d=[1,2,3],e={'f':4,'g':5})
>>> P = Project()
>>> P.update(d)
>>> print(P)
Project contents:
* a: 1
* b: 2
* c: 3
* d: [1, 2, 3]
* e: {'f': 4, 'g': 5}
>>> from pyformex.filetools import TempFile
>>> with TempFile(suffix='.pzf') as tmp:
... P.save(filename=tmp.path)
... Q = P
... P.clear()
... print(len(P))
... P.load(tmp.path)
... P == Q
0
Path('...pzf')
True
"""
_filetypes = ('pzf', 'hdf5', 'pyf', 'pyf.gz')
_filters = {
'all': lambda k, v: True,
'hidden': lambda k, v: k.startswith('_'),
'public': lambda k, v: not k.startswith('_'),
'drawable': lambda k, v: pf.is_drawable(v),
'Geometry': lambda k, v: isinstance(v, pf.Geometry),
'Coords': lambda k, v: isinstance(v, pf.Coords),
'Formex': lambda k, v: isinstance(v, pf.Formex),
'Mesh': lambda k, v: isinstance(v, pf.Mesh),
'TriSurface': lambda k, v: isinstance(v, pf.TriSurface),
'Polygons': lambda k, v: isinstance(v, pf.Polygons),
'PolyLine': lambda k, v: isinstance(v, pf.PolyLine),
'Curve': lambda k, v: isinstance(v, pf.BezierSpline),
'NurbsCurve': lambda k, v: isinstance(v, pf.NurbsCurve),
}
_sorting = {
SORTKEY.N: lambda i: i[0],
SORTKEY.H: lambda i: pf.hsortkey(i[0]),
SORTKEY.I: lambda i: i[0].lower(),
SORTKEY.HI: lambda i: pf.hsortkey(i[0].lower()),
SORTKEY.T: lambda i: (i[1].__class__.__name__, i[0]),
SORTKEY.TH: lambda i: (i[1].__class__.__name__, pf.hsortkey(i[0])),
SORTKEY.TI: lambda i: (i[1].__class__.__name__, i[0].lower()),
SORTKEY.THI: lambda i: (i[1].__class__.__name__, pf.hsortkey(i[0].lower())),
}
_sep = ': ' # separator between name and type
_display = {
'Name': lambda i: i[0],
'Name+Type': lambda i: f"{i[0]}{Project._sep}{i[1].__class__.__name__}",
# 'Type+Name': lambda i: f"{i[1].__class__.__name__}{Project._sep}{i[0]}",
# 'Type': lambda i: i[1].__class__.__name__,
}
def __init__(self, filename=None, data={}):
"""Initialize a Project."""
if filename:
filename = Path(filename)
if filename.filetype() not in Project._filetypes:
raise ValueError(
"Project filetype should be one of "
f"{Project._filetypes}")
self.filename = filename
super().__init__()
if self.filename and self.filename.exists():
self.filename = self.load(filename)
if data:
self.update(data)
self.hits = 0
self._filtered = {}
self._filters = (
configured_filters(pf.cfg['project/filters']) | Project._filters)
self._curfilter = 'public'
self._sortkey = SORTKEY.H
self.display = 'Name+Type'
self.selection = []
self._saved = {} # stored selection for rollback
@property
def sortkey(self):
return self._sortkey
@sortkey.setter
def sortkey(self, val):
self._sortkey = SORTKEY.sanitize(val)
[docs] def contents(self, fname=None, *, clas=None, like=None, func=None,
sort=None, display=None):
"""Return a name list of objects that match the given criteria.
Parameters
----------
fname: str
The name of one of the built-in or added filters.
clas: class | tuple of classes
If provided, only instances of these class(es) are included.
like: str
A Python regular expression, see :mod:`re`. If provided, only
object names fully matching this RE are included.
func: function
A function taking a (name, object) tuple as parameter and
returning True or False. If provided, only object names
returning True will be included.
sort: str, optional
Specifies how the objects are sorted. One of:
- '': no sorting, use object insertion order,
- 'n': sort on object name (lexical: 'A2' before 'a1'),
- 'h': human sort on object name ('a9' before 'a10'),
- 'i': sort on name ignoring case ('a1' before A1'),
- 'hi': human sort ignoring case ('a9' before 'A10'),
- 'tn': first sort on type, then one name,
- 'th': first sort on type, then human sort on name,
- 'ti': first sort on type, then case ignoring sort on name,
- 'thi': first sort on type, the case ignoring human sort on name.
If not provided (or None), the Project's :attr:`sortkey` attribute
is used, which defaults to 'h'.
display: str, optional
Specifies what the returned list will contain.
If 'Name', returns only the name or the item.
If 'Name+Type', returns a string 'Name: Type' for each item.
If not provided, the Project's :attr:`display` attribute is used,
which defaults to 'Name+Type'.
Returns
-------
list of str
The name, type or name+type of the objects matching the criteria.
Examples
--------
>>> from pyformex.core import Mesh, TriSurface
>>> M = Mesh()
>>> M1 = Mesh(eltype='tri3')
>>> S = M1.toSurface()
>>> F = S.toFormex()
>>> P = Project(data={'M10':M, 'MS':S, 'MF':F, 'M2':M1, 'm3':M})
>>> P.contents()
['M2: Mesh', 'M10: Mesh', 'MF: Formex', 'MS: TriSurface', 'm3: Mesh']
>>> P.contents(clas=Mesh, display='Name')
['M2', 'M10', 'MS', 'm3']
>>> P.display = 'Name'
>>> P.contents(sort='')
['M10', 'MS', 'MF', 'M2', 'm3']
>>> P.contents(sort='h')
['M2', 'M10', 'MF', 'MS', 'm3']
>>> P.contents(sort='i')
['M10', 'M2', 'm3', 'MF', 'MS']
>>> P.contents(sort='hi')
['M2', 'm3', 'M10', 'MF', 'MS']
>>> P.contents(clas=Mesh, func=lambda k, v: hasattr(v, 'inside'))
['MS']
>>> P.contents(like='M.*', sort='th') # match anything starting with M
['MF', 'M2', 'M10', 'MS']
"""
sel = self.filtered(fname=fname, clas=clas, like=like, func=func)
if sort is None:
sort = self.sortkey
else:
sort = SORTKEY.sanitize(sort)
if display is None:
display = self.display
display = self._display.get(display, self._display['Name'])
if sort in self._sorting:
sortfunc = self._sorting[sort]
return [display(i) for i in sorted(sel.items(), key=sortfunc)]
else:
return [display(i) for i in sel.items()]
[docs] def addfilter(self, fname, clas=None, like=None, func=None):
"""Add a persistent filter"""
if pf.debugon(pf.DEBUG.PROJECT):
print(f"Add new Project filter {fname}: {clas=}, {like=}, {func=}")
if fname in self._filtered:
# Delete old filtered contents
del self._filtered[fname]
self._filters[fname] = create_filter(clas=clas, like=like, func=func)
[docs] def available_filters(self):
"""List the available filters"""
return list(self._filters.keys())
[docs] def filtered_items(self, func):
"""Return items that pass a filter function"""
return (item for item in self.items() if func(*item))
[docs] def apply_filter(self, fname):
"""Apply a named filter and remember result."""
if self.hits or fname not in self._filtered:
self._filtered[fname] = dict(self.filtered_items(self._filters[fname]))
return self._filtered[fname]
[docs] def set_filter(self, fname=None, *, clas=None, like=None, func=None):
"""Set the current Project filter
Parameters have the same meaning as in :meth:`contents`.
If any of clas, like or func is provided, fname is disregarded
and a new filter is created under the name '_custom_'.
Else, fname should be an existing filter name or None (which is
equivalent to 'all').
"""
if clas is not None or like is not None or func is not None:
fname = '_custom_'
self.addfilter(fname, clas, like, func)
if not fname: # allows None or ''
fname = 'all'
if fname not in self._filters:
raise ValueError(f"No such Project filter: {fname=}")
self._curfilter = fname
[docs] def filtered(self, fname=None, *, clas=None, like=None, func=None):
"""Set the current filter and return a dict with filtered items
Returns
-------
dict
The items from the Project that pass the filter.
"""
self.set_filter(fname, clas=clas, like=like, func=func)
return self.apply_filter(self._curfilter)
def names(self, **kargs):
kargs['display'] = 'Name'
return self.contents(**kargs)
def types(self, **kargs):
kargs['display'] = 'Type'
return self.contents(**kargs)
__repr__ = object.__repr__ # override dict.__repr__
def __str__(self):
return self.pformat()
[docs] def load(self, filename=None, **kargs):
"""Load a project from file.
The objects loaded from the file update the current project.
Parameters
----------
filename: :term:`path_like`
The name of a file to load (.pzf, .pyf, .pyf.gz). If not provided,
use the filename set in the Project. An exception is raised if
neither is specified.
kargs:
Keyword parameters to be passed to the :func:`PzfFile.load` or
:func:`PyfFile.load` function.
Returns
-------
Path
On success, the Path of the loaded file.
"""
if filename:
filename = Path(filename)
else:
filename = self.filename
if not filename:
raise ValueError("Need a filename to load")
if filename.ftype == 'pzf':
self.update(PzfFile(filename).load(**kargs))
elif filename.ftype == 'hdf5':
from pyformex.hdf5file import read_hdf5
self.update(read_hdf5(filename))
elif filename.ftype in ('pyf', 'pyf.gz'):
self.update(PyfFile(filename).load(**kargs))
else:
pf.error(f"Invalid Project file type {filename.ftype}")
return filename
[docs] def save(self, filename=None, **kargs):
"""Save the project to file.
Parameters
----------
filename: :term:`path_like`
The name of the file to save to (.pzf, .hdf5, .pyf, .pyf.gz).
If not provided, the filename set in the Project is used.
An exception is raised if neither is specified.
kargs:
Keyword parameters to be passed to the :func:`PzfFile.save` or
:func:`PyfFile.save` function.
"""
if filename:
filename = Path(filename)
else:
filename = self.filename
if not filename:
raise ValueError("Need a filename to save")
if filename.ftype == 'pzf':
PzfFile(filename).save(**self, **kargs)
elif filename.ftype == 'hdf5':
from pyformex.hdf5file import write_hdf5
write_hdf5(filename, **self)
elif filename.ftype in ('pyf', 'pyf.gz'):
PyfFile(filename).save(self, **kargs)
else:
pf.error(f"Invalid Project file type {filename.ftype}")
self.hits = 0
[docs] def close(self, clear=True):
"""Detach the project from the project file
Parameters
----------
clear: bool
If True (default) clears the filename attribute and the
contents of the project dict. If False, the life contents
is kept in memory.
"""
self.filename = None
if clear:
self.clear()
def add_type(self, name, obj):
return self._display['Name+Type'](name, obj)
def strip_type(self, name_type):
return name_type.split(self._sep)[0]
@property
def _drawable(self):
return self.apply_filter('drawable')
[docs] def pprint(self, *args, **kargs):
"""Pretty print (some) contents"""
print(self.pformat(*args, **kargs))
[docs] def rename(self, oldname, newname):
"""Rename an object.
Parameters
----------
oldname: str
The old name of the object. A KeyError is raised if it is not in
the Project.
newname: str
The new name for the object self[newname]. A ValueError is raised
if the name already exists.
Examples
--------
>>> P = Project(data={'a':0, 'b':1})
>>> P.rename('b', 'c')
>>> dict(P)
{'a': 0, 'c': 1}
>>> P.rename('b', 'd')
Traceback (most recent call last):
...
KeyError: 'b'
>>> P.rename('c', 'a')
Traceback (most recent call last):
...
ValueError: The name 'a' already exists in the Project
"""
if newname in self:
raise ValueError(
f"The name '{newname}' already exists in the Project")
self[newname] = self[oldname]
del self[oldname]
[docs] def forget(self, *names):
"""Silently remove named objects from the Project.
Parameters
----------
*names: str
One or more object names to be removed from the project.
Removal is silent: non-existing names are silently skipped.
Examples
--------
>>> P = Project(data={'a':0, 'b':1})
>>> P.forget('b', 'c')
>>> dict(P)
{'a': 0}
"""
for name in set(self) & set(names):
del self[name]
[docs] def keep(self, *names):
"""Forget everything except the specified names.
Parameters
----------
*names: str
One or more names of objects to be kept in the project.
Removal is silent: non-existing names are silently skipped.
Examples
--------
>>> P = Project(data={'a':0, 'b':1})
>>> P.keep('a', 'd')
>>> dict(P)
{'a': 0}
"""
for name in set(self) - set(names):
del self[name]
[docs] def update2(self, names, values):
"""Update the Project from a list of names and a list of values
Parameters
----------
names: list of str
A list of names to use as keys.
values: list of objects
A list of objects to store in the project under the provided
**names**.
Notes
-----
This is syntactical sugar for ``self.update(zip(names, values))``.
If the lists have different lengths, the shorter one is used.
Examples
--------
>>> P = Project(data={'a':0})
>>> P.update2(('b', 'c'), (1, 2))
>>> dict(P)
{'a': 0, 'b': 1, 'c': 2}
"""
self.update(zip(names, values, strict=True))
[docs] def remember(self, names=None):
"""Remember the current values of the variables in selection.
names: list or dict
"""
if names:
self._saved = dict((n, self[n]) for n in names)
else:
self._saved = dict(self) # a shallow copy
[docs] def rollback(self):
"""Undo the last remembered changes
Examples
--------
>>> P = Project(data=dict(a=0, b=1, c=2))
>>> P.remember(['a', 'c'])
>>> P['a'] = 5
>>> P['b'] = 6
>>> dict(P)
{'a': 5, 'b': 6, 'c': 2}
>>> P.rollback()
>>> dict(P)
{'a': 0, 'b': 6, 'c': 2}
"""
if self._saved:
self.update(self._saved)
self._saved = {}
else:
print("Nothing saved to roll back!")
# End