Source code for aglyph.context

# The MIT License (MIT)
#
# Copyright (c) 2006-2011 Matthew Zipay <mattz@ninthtest.net>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

"""The classes in this module are used to define collections of related
components (:class:`aglyph.component.Component` instances), called
"contexts" in Aglyph.

A context can be created in pure Python. This approach involves use of
the following API classes:

* :class:`aglyph.context.Context` (required)
* :class:`aglyph.component.Component` (required)
* :class:`aglyph.component.Reference` (used to indicate that a component
  depends on another component)
* :class:`aglyph.component.Strategy` (defines component assembly
  strategies)
* :class:`aglyph.component.Evaluator` (used as a partial function to
  lazily evaluate component arguments/attributes)

.. versionchanged:: 1.1.0
    The preferred approach to programmatic configuration is now
    :class:`aglyph.binder.Binder`, which is more succinct than using
    ``Context`` and ``Component`` directly.

Alternatively, a context can be defined using a declarative XML syntax
that conforms to the
:download:`aglyph-context-1.0.0 DTD <../../resources/aglyph-context-1.0.0.dtd>`
(included in the *resources/* directory of the distribution). This
approach requires only the :class:`aglyph.context.XMLContext` class,
which parses the XML document and then uses the API classes mentioned
above to populate the context.

.. note::

    An additional class is needed in *IronPython* applications that use XML
    contexts: :class:`aglyph.compat.ipyetree.XmlReaderTreeBuilder`.

"""

__author__ = "Matthew Zipay <mattz@ninthtest.net>"
__version__ = "1.0.0"

import functools
import logging
import sys
from xml.etree.ElementTree import ElementTree

from aglyph import AglyphError
from aglyph.compat import (DataType, is_python_2, RESTRICTED_BUILTINS,
                           TextType)
from aglyph.component import Component, Evaluator, Reference, Strategy

__all__ = ["Context", "XMLContext"]

_logger = logging.getLogger(__name__)


[docs]class Context(dict): """A mapping of component IDs to :class:`aglyph.componnent.Component` objects. """ _logger = logging.getLogger("%s.Context" % __name__) def __init__(self, context_id): """*context_id* is a string that uniquely identifies this context. """ super(Context, self).__init__() self._context_id = context_id self._logger.info("initialized %s", self) @property
[docs] def context_id(self): """a read-only property for the context ID""" return self._context_id
[docs] def add(self, component): """Add *component* to this context. *component* is an :class:`aglyph.component.Component`. :raises aglyph.AglyphError: if *component.component_id* is already contained in this context """ if (component.component_id in self): raise AglyphError("component with ID %r is already defined in %s" % (component.component_id, self)) self[component.component_id] = component
[docs] def add_or_replace(self, component): """Add *component* to this context, **replacing** any component with the same ``component_id`` that already exists. *component* is an :class:`aglyph.component.Component`. :returns: the component that was replaced :rtype: :class:`aglyph.component.Component` (or ``None`` if no replacement was made) """ replaced_component = self.get(component.component_id) if ((replaced_component is not None) and self._logger.isEnabledFor(logging.WARNING)): self._logger.warning("%s is replacing %s in %s", component, replaced_component, self) self[component.component_id] = component return replaced_component
[docs] def remove(self, component_id): """Remove a component from this context. *component* is an :class:`aglyph.component.Component`. :returns: the component that was removed :rtype: :class:`aglyph.component.Component` (or ``None`` if *component.component_id* was not in this context) """ component = self.get(component_id) if (component is not None): del self[component_id] return component
def __repr__(self): return "%s:%s<%r>" % (self.__class__.__module__, self.__class__.__name__, self._context_id)
[docs]class XMLContext(Context): """Populate a new context by parsing an XML document. The XML document must conform to the :download:`aglyph-context-1.0.0 DTD <../../resources/aglyph-context-1.0.0.dtd>` (included in the *resources/* directory of the distribution). """ _logger = logging.getLogger("%s.XMLContext" % __name__) def __init__(self, source, parser=None, default_encoding=sys.getdefaultencoding()): """*source* is a filename or stream from which XML data is read. *parser*, if specified, is an *ElementTree* parser; it is passed as the second argument to :func:`xml.etree.ElementTree.ElementTree.parse`. In most cases it is unnecessary to specify a value for *parser* (as the default works fine on **most** Python implementations - see the warning below). *default_encoding* is the character set used to encode ``<bytes>`` or ``<str>`` (under Python 2) element content when an **@encoding** attribute is *not* specified on those elements. It defaults to ``sys.getdefaultencoding()``. **This is not related to the document encoding!** .. warning:: *IronPython* developers **must** specify a *parser* because the default Python XML parser (expat) is not available in *IronPython*. Please see :doc:`aglyph.compat.ipyetree`. .. note:: Aglyph uses a non-validating XML parser by default, so DTD conformance is not enforced at runtime. It is recommended that XML contexts be validated at least once (manually) during testing. """ self._logger.info("parsing context from %r", source) # alias the correct _parse_str method based on Python version if (is_python_2): self._parse_str = self.__parse_str_as_data else: # assume 3 self._parse_str = self.__parse_str_as_text self._logger.info("default encoding is %r", default_encoding) self._default_encoding = default_encoding root = ElementTree().parse(source, parser) if (root.tag != "context"): raise AglyphError("expected root <context>, not <%s>" % root.tag) super(XMLContext, self).__init__(root.attrib["id"]) # Element.iter is not available in Python 2.5, 2.6, and 3.1, but IS # available in 2.7 and 3.2 (!?) - just use getiterator for component_element in root.getiterator("component"): component = self._parse_component(component_element) self._process_component(component, component_element) # this will raise AglyphError if the component.component_id has # already been added self.add(component) @property
[docs] def default_encoding(self): """a read-only property for the default encoding **This is not related to the document encoding!** """ return self._default_encoding
def _parse_component(self, component_element): """Create an :class:`aglyph.component.Component` from *component_element*. *component_element* is an :class:`xml.etree.ElementTree.Element` representing a ``<component>`` element. """ component_id = component_element.attrib["id"] self._logger.debug("parsing component[@id=%r]", component_id) # if the dotted-name is not specified explicitly, then the component ID # is assumed to represent a dotted-name dotted_name = component_element.get("dotted-name", component_id) strategy = component_element.get("strategy", Strategy.PROTOTYPE) # Component will reject unrecognized strategy return Component(component_id, dotted_name, strategy) def _process_component(self, component, component_element): """Parse the child elements of *component_element* to populate the *component* initialization arguments and attributes. *component* is an :class:`aglyph.component.Component`. *component_element* is an :class:`xml.etree.ElementTree.Element` representing a ``<component>`` element. """ init_element = component_element.find("init") if (init_element is not None): for (keyword, value) in self._parse_init(init_element): if (keyword is None): component.init_args.append(value) else: component.init_keywords[keyword] = value attributes_element = component_element.find("attributes") if (attributes_element is not None): for (name, value) in self._parse_attributes(attributes_element): component.attributes[name] = value if (self._logger.isEnabledFor(logging.DEBUG)): self._logger.debug("%d args, %r keywords, %r attributes for %s" % (len(component.init_args), list(component.init_keywords.keys()), list(component.attributes.keys()), component)) def _parse_init(self, init_element): """Yield initialization arguments (positional and keyword) parsed from *init_element*. *init_element* is an :class:`xml.etree.ElementTree.Element` representing an ``<init>`` element. """ # Element.iter is not available in Python 2.5, 2.6, and 3.1, but IS # available in 2.7 and 3.2 (!?) - just use getiterator for arg_element in init_element.getiterator("arg"): value = self._unserialize_element_value(arg_element) if ("keyword" not in arg_element.attrib): yield (None, value) else: keyword = arg_element.attrib["keyword"] yield (keyword, value) def _parse_attributes(self, attributes_element): """Yield attributes (fields, setter methods, or properties) parsed from *attributes_element*. *attributes_element* is an :class:`xml.etree.ElementTree.Element` representing an ``<attributes>`` element. """ # Element.iter is not available in Python 2.5, 2.6, and 3.1, but IS # available in 2.7 and 3.2 (!?) - just use getiterator for attribute_element in attributes_element.getiterator("attribute"): name = attribute_element.attrib["name"] value = self._unserialize_element_value(attribute_element) yield (name, value) def _unserialize_element_value(self, element): """Return the value, :class:`aglyph.component.Reference`, :class:`aglpyh.component.Evaluator`, or :func:`functools.partial` object that is the result of processing *element*. *element* is an :class:`xml.etree.ElementTree.Element` representing a "value container" element (i.e. ``<arg>``, ``<attribute>``, ``<key>``, or ``<value>``). """ if ("reference" in element.attrib): return Reference(element.attrib["reference"]) if (len(element) != 1): raise AglyphError("<%s> must contain exactly one child element!!" % element.tag) return self._process_element(list(element)[0]) def _process_element(self, element): """Create a usable Python object from *element*. *element* is an :class:`xml.etree.ElementTree.Element` representing a "value" element (e.g. ``<int>``, ``<reference>``). This method will return one of the following types: * a built-in object (e.g. an ``int``, a ``str``) * a built-in constant (e.g. ``True``, ``False``, ``None``) * an :class:`aglyph.component.Reference` * an :class:`aglyph.component.Evaluator` * a :func:`functools.partial` """ parse = getattr(self, "_parse_%s" % element.tag) return parse(element) def _parse_true(self, true_element): """Return the built-in constant ``True``. *true_element* is an :class:`xml.etree.ElementTree.Element` representing a ``<true/>`` element. """ return True def _parse_false(self, false_element): """Return the built-in constant ``False``. *false_element* is an :class:`xml.etree.ElementTree.Element` representing a ``<false/>`` element. """ return False def _parse_none(self, none_element): """Return the built-in constant ``None``. *none_element* is an :class:`xml.etree.ElementTree.Element` representing a ``<none/>`` element. """ return None def _parse_bytes(self, bytes_element): """Return a built-in :func:`str` (Python 2) or :class:`bytes` (Python 3) object. *bytes_element* is an :class:`xml.etree.ElementTree.Element` representing a ``<bytes>`` element. If the **bytes/@encoding** attribute has been set, the text of the ``<bytes>`` element is encoded using the specified character set; otherwise, the text of the ``<bytes>`` element is encoded using the XML document's encoding (defaulting to UTF-8). .. note:: Whitespace in the ``<bytes>`` element's text is preserved. If *bytes_element* is an empty element, ``str()`` (Python 2) or ``bytes()`` (Python 3) is returned. :raises UnicodeEncodeError: if the text cannot be encoded according to the specified character set """ if (bytes_element.text is not None): encoding = bytes_element.get("encoding", self._default_encoding) # .encode() will return the appropriate type return bytes_element.text.encode(encoding) else: return DataType() def __parse_str_as_data(self, str_element): """Return a built-in ``str`` (Python 2 encoded byte data) object. .. note:: This method is aliased as ``_parse_str(self, str_element)`` when Aglyph is running under Python 2. *str_element* is an :class:`xml.etree.ElementTree.Element` representing a ``<str>`` element. If the **str/@encoding** attribute has been set, the text of the ``<str>`` element is encoded using the specified character set; otherwise, the text of the ``<str>`` element is encoded using the XML document's encoding (defaulting to UTF-8). .. note:: Whitespace in the ``<str>`` element's text is preserved. If *str_element* is an empty element, ``str()`` is returned. :raises UnicodeEncodeError: if the text cannot be encoded according to the specified character set """ if (str_element.text is not None): encoding = str_element.get("encoding", self._default_encoding) return str_element.text.encode(encoding) else: return str() def __parse_str_as_text(self, str_element): """Return a built-in ``str`` (Python 3 Unicode text) object. .. note:: This method is aliased as ``_parse_str(self, str_element)`` when Aglyph is running under Python 3. *str_element* is an :class:`xml.etree.ElementTree.Element` representing a ``<str>`` element. The text of the ``<str>`` element (which is already a Unicode string) is returned unchanged. .. note:: Whitespace in the ``<str>`` element's text is preserved. If *str_element* is an empty element, ``str()`` is returned. """ if (str_element.text is not None): encoding = str_element.get("encoding") if (encoding is not None): self._logger.warning("ignoring str/@encoding attribute (%r)", encoding) return str_element.text else: return str() def _parse_unicode(self, unicode_element): """Return a built-in ``unicode`` (Python 2) or ``str`` (Python 3) object. *unicode_element* is an :class:`xml.etree.ElementTree.Element` representing a ``<unicode>`` element. The text of the ``<unicode>`` element (which is already a Unicode string) is returned unchanged. .. note:: Whitespace in the ``<unicode>`` element's text is preserved. If *unicode_element* is an empty element, ``unicode()`` (Python 2) or ``str()`` (Python 3) is returned. """ if (unicode_element.text is not None): return unicode_element.text else: return TextType() def _parse_int(self, int_element): """Return a built-in :func:`int` object. *int_element* is an :class:`xml.etree.ElementTree.Element` representing an ``<int>`` element. The text of the ``<int>`` element is passed as the first argument to the built-in function :func:`int`. The **int/@base** attribute, if specified, is passed as the second argument to the built-in function :func:`int`. If *int_element* is an empty element, ``int()`` is returned. """ if (int_element.text is not None): base = int(int_element.get("base", "10")) return int(int_element.text, base) else: return int() def _parse_float(self, float_element): """Return a built-in :func:`float` object. *float_element* is an :class:`xml.etree.ElementTree.Element` representing a ``<float>`` element. The text of the ``<float>`` element is passed as the argument to the built-in function :func:`float`. If *float_element* is an empty element, ``float()`` is returned. """ if (float_element.text is not None): return float(float_element.text) else: return float() def _parse_list(self, list_element): """Return an :class:`aglyph.component.Evaluator` that produces a built-in :func:`list` object when called. *list_element* is an :class:`xml.etree.ElementTree.Element` representing a ``<list>`` element. """ items = [self._process_element(child_element) for child_element in list_element] return Evaluator(list, items) def _parse_tuple(self, tuple_element): """Return either an :class:`aglyph.component.Evaluator` that produces a built-in :func:`tuple` object when called, or an empty :func:`tuple` object. *tuple_element* is an :class:`xml.etree.ElementTree.Element` representing a ``<tuple>`` element. If *tuple_element* is an empty element, ``tuple()`` is returned. """ children = list(tuple_element) if (len(children)): items = [self._process_element(child_element) for child_element in children] return Evaluator(tuple, items) else: # a tuple is immutable, so there's no sense in paying the overhead # of evaluation for an empty tuple return tuple() def _parse_dict(self, dict_element): """Return an :class:`aglyph.component.Evaluator` that produces a built-in :func:`dict` object when called. *dict_element* is an :class:`xml.etree.ElementTree.Element` representing a ``<dict>`` element. """ # a list of 2-tuples, (key, value), used to initialize a dictionary items = [] # Element.iter is not available in Python 2.5, 2.6, and 3.1, but IS # available in 2.7 and 3.2 (!?) - just use getiterator for item_element in dict_element.getiterator("item"): key_element = item_element.find("key") if (key_element is None): raise AglyphError("item/key is required") value_element = item_element.find("value") if (value_element is None): raise AglyphError("item/value is required") items.append((self._unserialize_element_value(key_element), self._unserialize_element_value(value_element))) return Evaluator(dict, items) def _parse_reference(self, reference_element): """Return an :class:`aglyph.component.Reference`. *reference_element* is an :class:`xml.etree.ElementTree.Element` representing a ``<reference>`` element. The **reference/@id** attribute is required, and will be used as the value to create an :class:`aglyph.component.Reference`. """ component_id = reference_element.attrib["id"] return Reference(component_id)
[docs] def _parse_eval(self, eval_element): """Return a :func:`functools.partial` that will evaluate an expression when called, using the built-in :func:`eval` function. *eval_element* is an :class:`xml.etree.ElementTree.Element` representing an ``<eval>`` element. The environment for :func:`eval` is a restricted subset of :mod:`builtins`, providing access to the following built-ins **only** (and subject to availability based on Python version): .. hlist:: :columns: 5 * ``ArithmeticError`` * ``AssertionError`` * ``AttributeError`` * ``BaseException`` * ``BufferError`` * ``BytesWarning`` * ``DeprecationWarning`` * ``EOFError`` * ``Ellipsis`` * ``EnvironmentError`` * ``Exception`` * ``False`` * ``FloatingPointError`` * ``FutureWarning`` * ``GeneratorExit`` * ``IOError`` * ``ImportError`` * ``ImportWarning`` * ``IndentationError`` * ``IndexError`` * ``KeyError`` * ``KeyboardInterrupt`` * ``LookupError`` * ``MemoryError`` * ``NameError`` * ``None`` * ``NotImplemented`` * ``NotImplementedError`` * ``OSError`` * ``OverflowError`` * ``PendingDeprecationWarning`` * ``ReferenceError`` * ``ResourceWarning`` * ``RuntimeError`` * ``RuntimeWarning`` * ``StandardError`` * ``StopIteration`` * ``SyntaxError`` * ``SyntaxWarning`` * ``SystemError`` * ``TabError`` * ``True`` * ``TypeError`` * ``UnboundLocalError`` * ``UnicodeDecodeError`` * ``UnicodeEncodeError`` * ``UnicodeError`` * ``UnicodeTranslateError`` * ``UnicodeWarning`` * ``UserWarning`` * ``ValueError`` * ``Warning`` * ``ZeroDivisionError`` * ``__debug__`` * ``abs`` * ``all`` * ``any`` * ``apply`` * ``ascii`` * ``basestring`` * ``bin`` * ``bool`` * ``buffer`` * ``bytearray`` * ``bytes`` * ``callable`` * ``chr`` * ``cmp`` * ``coerce`` * ``complex`` * ``dict`` * ``dir`` * ``divmod`` * ``enumerate`` * ``filter`` * ``float`` * ``format`` * ``frozenset`` * ``getattr`` * ``hasattr`` * ``hash`` * ``hex`` * ``id`` * ``int`` * ``intern`` * ``isinstance`` * ``issubclass`` * ``iter`` * ``len`` * ``list`` * ``long`` * ``map`` * ``max`` * ``memoryview`` * ``min`` * ``next`` * ``object`` * ``oct`` * ``ord`` * ``pow`` * ``range`` * ``reduce`` * ``repr`` * ``reversed`` * ``round`` * ``set`` * ``slice`` * ``sorted`` * ``str`` * ``sum`` * ``tuple`` * ``type`` * ``unichr`` * ``unicode`` * ``xrange`` * ``zip`` """ if (eval_element.text is None): raise AglyphError("<eval> cannot be an empty element") # the environment for an eval expression is a restricted subset of # __builtins__. return functools.partial(eval, eval_element.text, {"__builtins__": RESTRICTED_BUILTINS})