Source code for project

#
##
##  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 pformat(self, header='Project contents:\n', keys=None): """Pretty print (some) contents""" if keys is None: keys = sorted(self.keys()) s = header for k in keys: s += f"* {k}: {pf.utils.indentTail(str(self[k]))}\n" return s
[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