# 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.
"""
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.
One family of fields is stored as an exact RATIONAL rather than as a float -- a sample
ladder's ratio, a homotopy's times -- and those read only the fraction spelling, so
``_exact_to_rational`` is among the candidates: it takes ``'1/2'`` and ``'0.5'`` alike and
makes the rational each one denotes. An int is coerced for the same reason (a rational
field refuses a bare Python int).
Coercion only happens for exact spellings (a float that the field rejects raises straight
through, with a message saying why), so the float-rejection policy on the exact fields
stands: passing the double 0.05 to max_step_size is still an error.
"""
from fractions import Fraction
try:
setattr(obj, key, value)
return
except TypeError as first_error: # Boost.Python.ArgumentError is a TypeError
if isinstance(value, float):
raise TypeError(
"{0!r} does not take the Python float {1!r}: it stores an exact value, and "
"0.1 the double is not one tenth. Pass an exact spelling instead -- a string "
"('1/10' or '0.1'), an int, or a fractions.Fraction.".format(key, value)
) from first_error
if not isinstance(value, (str, int, Fraction)):
raise
from .multiprec import real_mp
from ._coefficients import _exact_to_rational
# A string could be any of the field types, so try them in turn; an int or a Fraction only
# ever reaches here because the field wanted an exact rational.
candidates = (real_mp, _exact_to_rational, float, int) if isinstance(value, str) \
else (_exact_to_rational,)
last_error = None
for convert in candidates:
try:
# int("1e-7") is a ValueError, real_mp("1/2") a RuntimeError out of the mpfr string
# parser -- neither says anything about the field, so keep going rather than let an
# incidental parse failure escape as the diagnosis
converted = convert(value)
except (ValueError, TypeError, RuntimeError):
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 _did_you_mean(name, candidates):
"""A `` Did you mean 'x'?`` clause for a misspelling, or empty when nothing is close.
A settings call names fields as keywords, so a typo is silent at the call site and only the
error message can catch it. Listing every valid name says what is available but not what
the user probably meant, and these lists run to dozens of names. Cutoff 0.6, stdlib
difflib: close enough to catch a transposition or a missing underscore, far enough not to
guess wildly.
"""
from difflib import get_close_matches
close = get_close_matches(str(name), [str(c) for c in candidates], n=3, cutoff=0.6)
if not close:
return ""
if len(close) == 1:
return " Did you mean {0!r}?".format(close[0])
return " Did you mean one of {0}?".format(", ".join(repr(c) for c in close))
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}.{2} Valid fields: {3}".format(
type(self).__name__, key, _did_you_mean(key, fields), 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_holders(owner):
"""The objects whose configs count as this owner's, nearest first.
A solver's settings are not all kept on the solver. The tracker holds stepping, Newton and
precision; the endgame holds the endgame, security and flavour configs. Both are reached
through live references (``get_tracker`` / ``get_endgame`` hand back the solver's own
members), so writing through them writes into the solver -- which is what lets one flat
``solver.update(...)`` reach every setting the solve actually uses (b2#364).
One level deep, deliberately: an endgame's own ``get_tracker()`` is the same tracker the
solver would hand back, so recursing would add nothing but a second route to it. Order is
what breaks ties: the owner's own configs come first and win (see ``_field_owners``).
"""
holders = [owner]
for getter in ("get_tracker", "get_endgame"):
fetch = getattr(owner, getter, None)
if fetch is None:
continue
try:
sub = fetch()
except Exception: # an owner that cannot produce one simply has none
continue
if sub is not None and _is_owner(sub):
holders.append(sub)
return holders
def _config_class_map(owner):
"""Map of {short_key: (holder, config_class)} over this owner and its config holders."""
out = {}
for holder in _config_holders(owner):
for cls in holder.config_types():
if cls is None:
continue
for key in (config_key(cls), cls.__name__.lower()):
out.setdefault(key, (holder, cls)) # nearer holder wins
return out
def _resolve_config_class(owner, key):
"""Find the (holder, config class) a config name refers to."""
mapping = _config_class_map(owner)
found = mapping.get(key) or mapping.get(str(key).lower())
if found is None:
names = config_names(owner)
raise KeyError(
"{0} has no config {1!r}.{2} Available: {3}".format(
type(owner).__name__, key, _did_you_mean(key, names), names))
return found
[docs]
def config_names(self):
"""The keyword names accepted by configure() for this owner, its tracker and its endgame."""
return sorted({config_key(c)
for holder in _config_holders(self)
for c in holder.config_types() if c is not None})
def _field_owners(owner):
"""Map {field_name: (holder, config_class)} over this owner's configs, plus any collisions.
Covers the configs of the owner and of the objects it keeps its settings in (see
``_config_holders``), so one flat call reaches a tracker's or an endgame's fields too.
Two names for one field is resolved by distance, not refused: exactly one field name is
shared across the library's config structs -- ``final_tolerance``, on both TolerancesConfig
and EndgameConfig -- and on a solver the solver's own must keep winning, because
``solver.update(final_tolerance=...)`` is long-documented and reaches the endgame anyway
through the solver's own push (b2#392). A shadowed field stays reachable by naming its
config: ``solver.configure(endgame={'final_tolerance': ...})``.
A collision WITHIN one holder is still a collision, and update() refuses it rather than
silently picking one.
"""
owners = {}
collisions = {}
for holder in _config_holders(owner):
local = {}
for cls in holder.config_types():
if cls is None:
continue
for f in writable_fields(cls):
if f in local and local[f] is not cls:
collisions.setdefault(f, {local[f]})
collisions[f].add(cls)
local[f] = cls
for f, cls in local.items():
owners.setdefault(f, (holder, cls)) # nearer holder wins
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])))
found = owners.get(key)
if found is None:
raise AttributeError(
"{0} has no config field {1!r}.{2} Valid fields: {3}".format(
type(self).__name__, key, _did_you_mean(key, owners), sorted(owners)))
holder, cls = found
by_config.setdefault((id(holder), cls), (holder, cls, {}))[2][key] = value
# one get/update/set per touched config, not per field
for holder, cls, kv in by_config.values():
holder.set_config(holder.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 = {}
for holder in _config_holders(self):
for cls in holder.config_types():
if cls is not None:
configs.setdefault(config_key(cls), holder.get_config(cls))
if not as_dict:
return configs
flat = {}
# nearest holder first, and the flat view must agree with update() about who owns a
# shadowed name, so an earlier config is not overwritten by a later one
for cfg in configs.values():
for field, value in cfg.to_dict().items():
flat.setdefault(field, value)
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}.{2} Available: {3}".format(
type(self).__name__, key, _did_you_mean(key, have), sorted(have)))
continue
holder, cls = _resolve_config_class(self, key)
if isinstance(value, cls):
holder.set_config(value)
elif isinstance(value, dict):
holder.set_config(holder.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 _accept_config_names(cls):
"""Let get_config()/set_config() take a config's short NAME as well as its class.
``config_names()`` advertises names such as ``'tolerances'``; before this, ``get_config`` took
only the class and refused those very strings with a KeyError, so two adjacent methods on the
same object did not compose (issue #392). A name resolves through the same table
``configure()`` uses; the class form is unchanged.
"""
native_get = cls.get_config
native_set = cls.set_config
def _holder_of(self, config_class):
"""Which of this object's config holders keeps the given config class, if any."""
for holder in _config_holders(self):
if any(c is config_class for c in holder.config_types()):
return holder
return None
def get_config(self, config):
"""The named config, by class (``TolerancesConfig``) or by short name (``'tolerances'``,
as ``config_names()`` lists them).
Either may belong to this object's tracker or endgame rather than to the object itself
(``solver.get_config('stepping')``, ``solver.get_config(EndgameConfig)``); it is fetched
from whichever holds it, so every name ``config_names()`` lists can be asked for."""
if isinstance(config, str):
holder, config = _resolve_config_class(self, config)
else:
holder = _holder_of(self, config)
if holder is not None and holder is not self:
return holder.get_config(config)
return native_get(self, config)
def set_config(self, config):
"""Store a configuration struct, on this object or on whichever of its tracker and
endgame owns that config's type."""
holder = _holder_of(self, type(config))
if holder is not None and holder is not self:
return holder.set_config(config)
return native_set(self, config)
get_config.__doc__ = (native_get.__doc__ or "").rstrip() + "\n\n" + get_config.__doc__
set_config.__doc__ = (native_set.__doc__ or "").rstrip() + "\n\n" + set_config.__doc__
cls.get_config = get_config
cls.set_config = set_config
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
if hasattr(cls, "get_config"):
_accept_config_names(cls)
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