# This file is part of Bertini 2.
#
# python/bertini/config.py 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.
#
# python/bertini/config.py 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 python/bertini/config.py. If not, see <http://www.gnu.org/licenses/>.
#
# Copyright(C) Bertini2 Development Team
#
# See <http://www.gnu.org/licenses/> for a copy of the license,
# as well as COPYING. Bertini2 is provided with permitted
# additional terms in the b2/licenses/ directory.
# individual authors of this file include:
#
# silviana amethyst
# UWEC
#
"""
Reusable, low-friction ergonomics for Bertini config structs.
The C++ bindings expose each config struct (``SteppingConfig``, ``NewtonConfig``,
the endgame configs, the zero-dim configs, ...) with one writable attribute per
field, and expose each *owner* (every tracker and nag_algorithm) with a uniform,
type-list-driven config interface: ``set_config``, ``get_config`` and
``config_types`` (see ``configured_visitor.hpp``).
This module layers Pythonic conveniences on top of those, with **no per-config
and no per-algorithm code** -- everything is discovered by introspection, so a
newly added algorithm and its configs get the whole interface for free:
* configs gain ``update(**kwargs)`` (chainable, validated), ``to_dict()``,
``from_dict()``, a readable ``repr`` and value-equality;
* owners gain ``configure(**kwargs)`` and ``config_names()``, built on the
owner's own ``config_types()`` so they always reflect what the owner accepts.
"""
import copyreg
import re
# ---------------------------------------------------------------------------
# field discovery
# ---------------------------------------------------------------------------
[docs]
def writable_fields(cls):
"""The names of the writable (def_readwrite) data members of a bound class.
Discovered by walking the class for data descriptors (``__set__``), so it
requires no hand-maintained field lists and tracks the C++ struct exactly.
"""
fields = []
for klass in getattr(cls, "__mro__", (cls,)):
for name, val in vars(klass).items():
if name.startswith("_") or name in fields:
continue
descr = type(val)
if not hasattr(descr, "__set__") or not hasattr(descr, "__get__"):
continue
# a read-only python property would have __set__ on its type but no fset
if isinstance(val, property) and val.fset is None:
continue
fields.append(name)
return tuple(fields)
def _camel_to_snake(name):
s = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s).lower()
[docs]
def config_key(cls):
"""A short, snake_case keyword for a config class (drops a trailing 'Config').
e.g. SteppingConfig -> 'stepping', AMPConfig -> 'amp',
PostProcessingConfig -> 'post_processing'.
"""
name = cls.__name__
if name.endswith("Config"):
name = name[: -len("Config")]
return _camel_to_snake(name)
# ---------------------------------------------------------------------------
# config-class enhancement
# ---------------------------------------------------------------------------
def _coerced_setattr(obj, key, value):
"""setattr, retrying a string as the field's numeric type.
Strings are the blessed noise-free way to express numeric settings
(e.g. max_step_size="0.05", final_tolerance="1e-11"); plain Python floats
stay rejected for the *exact* fields by policy, since 0.05 the double is not
1/20. The catch is that the fields have different underlying types:
mpq_rational / mpfr_float fields (step sizes, factors) take a multiprecision
Float, while NumErrorT / double fields (tolerances, AMP bounds, min_step_size)
take a plain double, and integer-count fields take an int. So one string
spelling works for EVERY field: we try it as an exact Float first, then as a
plain float, then as an int, and keep the first that the field accepts.
Coercion only happens for strings (a non-string that the field rejects raises
straight through), so the float-rejection policy on the exact fields stands:
passing the double 0.05 to max_step_size is still an error.
"""
try:
setattr(obj, key, value)
return
except TypeError: # Boost.Python.ArgumentError is a TypeError
if not isinstance(value, str):
raise
from .multiprec import real_mp
last_error = None
for convert in (real_mp, float, int):
try:
converted = convert(value) # e.g. int("1e-7") is a ValueError -- skip it
except (ValueError, TypeError):
continue
try:
setattr(obj, key, converted)
return
except TypeError as e: # field rejected this representation; try the next
last_error = e
raise last_error if last_error is not None else TypeError(
"could not set {0!r} to {1!r}".format(key, value))
def _make_update(fields):
def update(self, **kwargs):
"""Set one or more fields at once; returns self so calls can chain.
Numeric fields accept strings (e.g. max_step_size="0.05"), which are
converted exactly to multiprecision values. Raises AttributeError on
an unknown/misspelled field name.
"""
for key, value in kwargs.items():
if key not in fields:
raise AttributeError(
"{0} has no config field {1!r}; valid fields: {2}".format(
type(self).__name__, key, list(fields)))
_coerced_setattr(self, key, value)
return self
return update
def _make_to_dict(fields):
def to_dict(self):
"""The config's fields as an ordinary dict of {name: value}."""
return {k: getattr(self, k) for k in fields}
return to_dict
def _make_from_dict():
def from_dict(cls, mapping):
"""Build a config from a dict (default-constructs, then update())."""
return cls().update(**dict(mapping))
return classmethod(from_dict)
def _make_repr(fields):
def __repr__(self):
parts = []
for k in fields:
try:
parts.append("{0}={1!r}".format(k, getattr(self, k)))
except Exception:
pass
return "{0}({1})".format(type(self).__name__, ", ".join(parts))
return __repr__
def _make_eq(fields):
def __eq__(self, other):
if type(self) is not type(other):
return NotImplemented
return all(getattr(self, k) == getattr(other, k) for k in fields)
return __eq__
def _reconstruct_enhanced(cls, mapping):
"""Pickle reconstructor for enhanced config/metadata classes: rebuild from a field dict."""
return cls.from_dict(mapping)
def _make_reduce():
def __reduce__(self):
# Pickle via the (to_dict / from_dict) round-trip. Works for any enhanced class; the field
# values must themselves be picklable (numbers, including multiprec.Complex/Float).
return (_reconstruct_enhanced, (type(self), self.to_dict()))
return __reduce__
def _make_copy():
# copy.copy / copy.deepcopy rebuild through the same to_dict/from_dict round-trip as pickle.
# Defining __deepcopy__ explicitly is load-bearing: the generic copy.deepcopy fallback would
# deep-copy the to_dict() mapping's *values* (the multiprecision scalars), which aliases/corrupts
# them on some interpreters (observed: Python 3.10 collapses the mpq_rational fields). The field
# values are immutable scalars re-fetched from the getters, so rebuilding from them -- without
# deep-copying the scalars at all -- is both correct and portable.
def __copy__(self):
return type(self).from_dict(self.to_dict())
def __deepcopy__(self, memo):
new = type(self).from_dict(self.to_dict())
memo[id(self)] = new
return new
return __copy__, __deepcopy__
def _enhance_config_class(cls):
"""Add update/to_dict/from_dict/repr/eq/pickle to a bound config class (idempotent)."""
if getattr(cls, "_b2_config_enhanced", False):
return cls
fields = writable_fields(cls)
cls._b2_fields = fields
cls.update = _make_update(fields)
cls.set = cls.update # `set` reads more naturally to many users; same behavior
cls.to_dict = _make_to_dict(fields)
cls.from_dict = _make_from_dict()
try:
cls.__repr__ = _make_repr(fields)
cls.__eq__ = _make_eq(fields)
# Make the class picklable (copy.copy/deepcopy too) via to_dict/from_dict, overriding the
# Boost.Python "pickling not enabled" default.
cls.__reduce__ = _make_reduce()
cls.__copy__, cls.__deepcopy__ = _make_copy()
except (TypeError, AttributeError):
# some bound types may refuse dunder assignment; the rest still apply
pass
cls._b2_config_enhanced = True
return cls
# a class is an "owner" (tracker/algorithm), not a config, if it carries the
# type-list-driven config interface from ConfiguredVisitor.
def _is_owner(cls):
return hasattr(cls, "set_config") and hasattr(cls, "config_types")
def _looks_like_config(cls):
return (isinstance(cls, type)
and not _is_owner(cls)
and not _is_bound_enum(cls)
and len(writable_fields(cls)) > 0)
def _is_bound_enum(cls):
"""True for a Boost.Python enum class.
Boost.Python enums subclass ``int``, so ``writable_fields`` would otherwise mistake int's
read-only ``real``/``imag``/``numerator``/``denominator`` descriptors (and the enum's own
``name``) for writable config fields and wrongly "enhance" the enum -- corrupting its repr and
its pickling. No genuine config struct subclasses int, so this is a safe, precise exclusion.
"""
return isinstance(cls, type) and issubclass(cls, int) and cls not in (int, bool)
# ---------------------------------------------------------------------------
# enum pickling
# ---------------------------------------------------------------------------
def _reconstruct_enum(cls, value):
"""Pickle reconstructor for a Boost.Python enum value: rebuild the member from its int value."""
return cls(value)
def _reduce_enum(member):
return (_reconstruct_enum, (type(member), int(member)))
def _enable_enum_pickling(cls):
"""Make a Boost.Python enum picklable (its built-in ``__reduce_ex__`` is broken).
Registered via ``copyreg`` so the pickler reconstructs the member from its int value; this also
lets structs that *contain* enum fields (e.g. ``SolutionMetaData.endgame_success_code``) round-trip.
"""
copyreg.pickle(cls, _reduce_enum)
return cls
def _enhance_all(module):
"""Enhance every config-like class found in a bound module; make bound enums picklable."""
for name in dir(module):
obj = getattr(module, name)
try:
if _is_bound_enum(obj):
_enable_enum_pickling(obj)
elif _looks_like_config(obj):
_enhance_config_class(obj)
except Exception:
# never let one odd member break importing the package
pass
# ---------------------------------------------------------------------------
# owner (tracker / algorithm) helpers
# ---------------------------------------------------------------------------
def _config_class_map(owner):
"""Map of {short_key: config_class} for the configs this owner accepts."""
out = {}
for cls in owner.config_types():
if cls is None:
continue
out[config_key(cls)] = cls
out[cls.__name__.lower()] = cls
return out
def _resolve_config_class(owner, key):
mapping = _config_class_map(owner)
cls = mapping.get(key) or mapping.get(str(key).lower())
if cls is None:
valid = sorted({config_key(c) for c in owner.config_types() if c is not None})
raise KeyError(
"{0} has no config {1!r}; available: {2}".format(
type(owner).__name__, key, valid))
return cls
[docs]
def config_names(self):
"""The keyword names accepted by configure() for this owner."""
return sorted({config_key(c) for c in self.config_types() if c is not None})
def _field_owners(owner):
"""Map {field_name: config_class} across all of this owner's configs, plus any collisions.
Field names are unique across an owner's configs (the RegenerationConfig slice_ rename and the
ZeroDimConfig de-template removed the only collisions), so each field routes to exactly one
config. Collisions, if ever reintroduced, are reported so update() can refuse them rather than
silently pick one.
"""
owners = {}
collisions = {}
for cls in owner.config_types():
if cls is None:
continue
for f in writable_fields(cls):
if f in owners and owners[f] is not cls:
collisions.setdefault(f, {owners[f]})
collisions[f].add(cls)
owners[f] = cls
return owners, collisions
[docs]
def update(self, **fields):
"""Set config fields on this owner by NAME, each routed to whichever config owns it.
You never name the config struct::
solver.update(final_tolerance="1e-11", # -> TolerancesConfig
max_num_crossed_path_resolve_attempts=3) # -> ZeroDimConfig
Strings work for every numeric field (converted exactly). A field that none of this owner's
configs has raises AttributeError with the valid names -- so a typo, or trying to set a tracker
field on the algorithm (or vice versa), never silently does nothing. Returns self, so calls
chain. To set a whole config at once, or to name the config explicitly, use configure().
"""
owners, collisions = _field_owners(self)
by_config = {}
for key, value in fields.items():
if key in collisions:
raise AttributeError(
"config field {0!r} is ambiguous on {1} (in {2}); set it with configure() naming "
"the config".format(key, type(self).__name__,
sorted(c.__name__ for c in collisions[key])))
cls = owners.get(key)
if cls is None:
raise AttributeError(
"{0} has no config field {1!r}; valid fields: {2}".format(
type(self).__name__, key, sorted(owners)))
by_config.setdefault(cls, {})[key] = value
# one get/update/set per touched config, not per field
for cls, kv in by_config.items():
self.set_config(self.get_config(cls).update(**kv))
return self
[docs]
def get_settings(self, as_dict=False):
"""This owner's whole configuration as a carryable dict.
By default (``as_dict=False``) the value is ``{config_name: config}`` -- each a copy of one of
the owner's configs (e.g. ``{'stepping': SteppingConfig(...), 'tolerances': TolerancesConfig(...)}``),
keyed by the short names config_names() lists. The configs are independent copies (and picklable),
so the dict is a plain Python value you can stash, tweak, and apply to other owners -- the way to
carry one set of tracking settings across a series of related solves::
settings = first_solver.get_settings()
next_solver.set_settings(settings)
Pass ``as_dict=True`` for the flat, human-readable view instead: one ``{field_name: value}`` dict
across ALL configs (field names are unique across an owner's configs, so there is no collision).
That form drops the struct layer nobody wants to poke at, and round-trips through ``set``::
flat = solver.get_settings(as_dict=True) # {'final_tolerance': ..., 'max_step_size': ..., ...}
other.set(**flat)
(The config-keyed default is kept because ``set_settings`` consumes it; use whichever fits.) See
set_settings() for applying the config-keyed form back.
"""
configs = {config_key(cls): self.get_config(cls)
for cls in self.config_types() if cls is not None}
if not as_dict:
return configs
flat = {}
for cfg in configs.values():
flat.update(cfg.to_dict())
return flat
[docs]
def set_settings(self, settings, strict=False):
"""Apply a settings dict (from get_settings()) onto this owner. Returns self.
``settings`` is ``{config_name: config}`` (or ``{config_name: {field: value}}``). By default only
the configs this owner actually has are applied and the rest are skipped -- so a bundle carried
from one solver drops cleanly onto another whose config set differs (e.g. a different precision
model, or a different algorithm stage). Pass ``strict=True`` to instead raise on any key this
owner does not have.
"""
have = set(config_names(self))
for key, value in dict(settings).items():
if key not in have:
if strict:
raise KeyError(
"{0} has no config {1!r}; available: {2}".format(
type(self).__name__, key, sorted(have)))
continue
cls = _resolve_config_class(self, key)
if isinstance(value, cls):
self.set_config(value)
elif isinstance(value, dict):
self.set_config(self.get_config(cls).update(**value))
else:
raise TypeError(
"value for {0!r} must be a {1} or a dict of fields, got {2}".format(
key, cls.__name__, type(value).__name__))
return self
def _enhance_owner_class(cls):
"""Attach configure()/config_names() to a tracker/algorithm class (idempotent)."""
if getattr(cls, "_b2_owner_enhanced", False):
return cls
cls.configure = configure
cls.config_names = config_names
cls.update = update
cls.set = update # `set` reads more naturally to many users; same behavior
cls.get_settings = get_settings
cls.set_settings = set_settings
cls._b2_owner_enhanced = True
return cls
def _enhance_owners(module):
"""Attach owner helpers to every config-owning class in a bound module."""
for name in dir(module):
obj = getattr(module, name)
try:
if isinstance(obj, type) and _is_owner(obj):
_enhance_owner_class(obj)
except Exception:
pass