from importlib import import_module from .parameter import Parameter from .. import __version__ from ..errors import ParameterError import logging log = logging.getLogger(__name__) class ParametricObject(object): """ Parametric objects may be defined like so: .. doctest:: >>> from cqparts.params import ( ... ParametricObject, ... PositiveFloat, IntRange, ... ) >>> class Foo(ParametricObject): ... x = PositiveFloat(5) ... i = IntRange(1, 10, 3) # between 1 and 10, defaults to 3 ... blah = 100 >>> a = Foo(i=8) >>> (a.x, a.i) (5.0, 8) >>> a = Foo(i=11) # raises exception # doctest: +SKIP ParameterError: value of 11 outside the range {1, 10} >>> a = Foo(z=1) # raises exception # doctest: +SKIP ParameterError: does not accept parameter(s): z >>> a = Foo(x='123', i='2') >>> (a.x, a.i) (123.0, 2) >>> a = Foo(blah=200) # raises exception, parameters must be Parameter types # doctest: +SKIP ParameterError: does not accept any of the parameters: blah >>> a = Foo(x=None) # a.x is None, a.i=3 >>> (a.x, a.i) (None, 3) Internally to the object, parameters may be accessed simply with self.x, self.i These will always return the type defined """ def __init__(self, **kwargs): # get all available parameters (recurse through inherited classes) params = self.class_params(hidden=True) # parameters explicitly defined during intantiation defined_params = set(kwargs.keys()) # only accept a subset of params invalid_params = defined_params - set(params.keys()) if invalid_params: raise ParameterError("{cls} does not accept parameter(s): {keys}".format( cls=repr(type(self)), keys=', '.join(sorted(invalid_params)), )) # Cast parameters into this instance for (name, param) in params.items(): value = param.default if name in kwargs: value = param.cast(kwargs[name]) setattr(self, name, value) self.initialize_parameters() @classmethod def class_param_names(cls, hidden=True): """ Return the names of all class parameters. :param hidden: if ``False``, excludes parameters with a ``_`` prefix. :type hidden: :class:`bool` :return: set of parameter names :rtype: :class:`set` """ param_names = set( k for (k, v) in cls.__dict__.items() if isinstance(v, Parameter) ) for parent in cls.__bases__: if hasattr(parent, 'class_param_names'): param_names |= parent.class_param_names(hidden=hidden) if not hidden: param_names = set(n for n in param_names if not n.startswith('_')) return param_names @classmethod def class_params(cls, hidden=True): """ Gets all class parameters, and their :class:`Parameter` instances. :return: dict of the form: ``{: , ... }`` :rtype: :class:`dict` .. note:: The :class:`Parameter` instances returned do not have a value, only a default value. To get a list of an **instance's** parameters and values, use :meth:`params` instead. """ param_names = cls.class_param_names(hidden=hidden) return dict( (name, getattr(cls, name)) for name in param_names ) def params(self, hidden=True): """ Gets all instance parameters, and their *cast* values. :return: dict of the form: ``{: , ... }`` :rtype: :class:`dict` """ param_names = self.class_param_names(hidden=hidden) return dict( (name, getattr(self, name)) for name in param_names ) def __repr__(self): # Returns string of the form: # params = self.params(hidden=False) return "<{cls}: {params}>".format( cls=type(self).__name__, params=", ".join( "%s=%r" % (k, v) for (k, v) in sorted(params.items(), key=lambda x: x[0]) # sort by name ), ) def initialize_parameters(self): """ A palce to set default parameters more intelegently than just a simple default value (does nothing by default) :return: ``None`` Executed just prior to exiting the :meth:`__init__` function. When overriding, strongly consider calling :meth:`super`. """ pass # Serialize / Deserialize def serialize(self): """ Encode a :class:`ParametricObject` instance to an object that can be encoded by the :mod:`json` module. :return: a dict of the format: :rtype: :class:`dict` :: { 'lib': { # library information 'name': 'cqparts', 'version': '0.1.0', }, 'class': { # importable class 'module': 'yourpartslib.submodule', # module containing class 'name': 'AwesomeThing', # class being serialized }, 'params': { # serialized parameters of AwesomeThing 'x': 10, 'y': 20, } } value of ``params`` key comes from :meth:`serialize_parameters` .. important:: Serialize pulls the class name from the classes ``__name__`` parameter. This must be the same name of the object holding the class data, or the instance cannot be re-instantiated by :meth:`deserialize`. **Examples (good / bad)** .. doctest:: >>> from cqparts.params import ParametricObject, Int >>> # GOOD Example >>> class A(ParametricObject): ... x = Int(10) >>> A().serialize()['class']['name'] 'A' >>> # BAD Example >>> B = type('Foo', (ParametricObject,), {'x': Int(10)}) >>> B().serialize()['class']['name'] # doctest: +SKIP 'Foo' In the second example, the classes import name is expected to be ``B``. But instead, the *name* ``Foo`` is recorded. This missmatch will be irreconcilable when attempting to :meth:`deserialize`. """ return { # Encode library information (future-proofing) 'lib': { 'name': 'cqparts', 'version': __version__, }, # class & name record, for automated import when decoding 'class': { 'module': type(self).__module__, 'name': type(self).__name__, }, 'params': self.serialize_parameters(), } def serialize_parameters(self): """ Get the parameter data in its serialized form. Data is serialized by each parameter's :meth:`Parameter.serialize` implementation. :return: serialized parameter data in the form: ``{: , ...}`` :rtype: :class:`dict` """ # Get parameter data class_params = self.class_params() instance_params = self.params() # Serialize each parameter serialized = {} for name in class_params.keys(): param = class_params[name] value = instance_params[name] serialized[name] = param.serialize(value) return serialized @staticmethod def deserialize(data): """ Create instance from serial data """ # Import module & get class try: module = import_module(data.get('class').get('module')) cls = getattr(module, data.get('class').get('name')) except ImportError: raise ImportError("No module named: %r" % data.get('class').get('module')) except AttributeError: raise ImportError("module %r does not contain class %r" % ( data.get('class').get('module'), data.get('class').get('name') )) # Deserialize parameters class_params = cls.class_params(hidden=True) params = dict( (name, class_params[name].deserialize(value)) for (name, value) in data.get('params').items() ) # Instantiate new instance return cls(**params)