From cf59b2e51155fe8f42f2008bd569ea72b827c738 Mon Sep 17 00:00:00 2001 From: Bradley Ayers Date: Mon, 4 Apr 2011 09:09:26 +1000 Subject: [PATCH] * Updated documentation * Renamed Columns to BoundColumns, and Rows to BoundRows * Added some Accessor tests --- django_tables/__init__.py | 2 +- django_tables/columns.py | 403 +++++++++------ django_tables/rows.py | 31 +- django_tables/tables.py | 94 ++-- .../templates/django_tables/basic_table.html | 2 +- .../templates/django_tables/table.html | 4 +- django_tables/utils.py | 159 ++++-- docs/conf.py | 4 +- docs/index.rst | 457 +++++++++++------- setup.py | 2 +- tests/utils.py | 17 +- 11 files changed, 732 insertions(+), 443 deletions(-) diff --git a/django_tables/__init__.py b/django_tables/__init__.py index 1b1fad5..3437774 100644 --- a/django_tables/__init__.py +++ b/django_tables/__init__.py @@ -1,2 +1,2 @@ -from .tables import * +from .tables import Table from .columns import * diff --git a/django_tables/columns.py b/django_tables/columns.py index 05514be..24001b5 100644 --- a/django_tables/columns.py +++ b/django_tables/columns.py @@ -14,83 +14,42 @@ class Column(object): :class:`Column` objects control the way a column (including the cells that fall within it) are rendered. + :param verbose_name: A pretty human readable version of the column name. + Typically this is used in the header cells in the HTML output. + + :type accessor: :class:`basestring` or :class:`~.utils.Accessor` + :param accessor: An accessor that describes how to extract values for this + column from the :term:`table data`. + + :param default: The default value for the column. This can be a value or a + callable object [1]_. If an object in the data provides :const:`None` + for a column, the default will be used instead. + + The default value may affect ordering, depending on the type of + data the table is using. The only case where ordering is not + affected ing when a :class:`QuerySet` is used as the table data + (since sorting is performed by the database). + + .. [1] The provided callable object must not expect to receive any + arguments. + + :type visible: :class:`bool` + :param visible: If :const:`False`, this column will not be in HTML from + output generators (e.g. :meth:`as_html` or ``{% render_table %}``). + + When a field is not visible, it is removed from the table's + :attr:`~Column.columns` iterable. + + :type sortable: :class:`bool` + :param sortable: If :const:`False`, this column will not be allowed to + influence row ordering/sorting. + """ #: Tracks each time a Column instance is created. Used to retain order. creation_counter = 0 def __init__(self, verbose_name=None, accessor=None, default=None, visible=True, sortable=None): - """Initialise a :class:`Column` object. - - :param verbose_name: - A pretty human readable version of the column name. Typically this - is used in the header cells in the HTML output. - - :param accessor: - A string or callable that specifies the attribute to access when - retrieving the value for a cell in this column from the data-set. - Multiple lookups can be achieved by providing a dot separated list - of lookups, e.g. ``"user.first_name"``. The functionality is - identical to that of Django's template variable syntax, e.g. ``{{ - user.first_name }}`` - - A callable should be used if the dot separated syntax is not - capable of describing the lookup properly. The callable will be - passed a single item from the data (if the table is using - :class:`QuerySet` data, this would be a :class:`Model` instance), - and is expected to return the correct value for the column. - - Consider the following: - - .. code-block:: python - - >>> import django_tables as tables - >>> data = [ - ... {'dot.separated.key': 1}, - ... {'dot.separated.key': 2}, - ... ] - ... - >>> class SlightlyComplexTable(tables.Table): - >>> dot_seperated_key = tables.Column(accessor=lambda x: x['dot.separated.key']) - ... - >>> table = SlightlyComplexTable(data) - >>> for row in table.rows: - >>> print row['dot_seperated_key'] - ... - 1 - 2 - - This would **not** have worked: - - .. code-block:: python - - dot_seperated_key = tables.Column(accessor='dot.separated.key') - - :param default: - The default value for the column. This can be a value or a callable - object [1]_. If an object in the data provides :const:`None` for a - column, the default will be used instead. - - The default value may affect ordering, depending on the type of - data the table is using. The only case where ordering is not - affected ing when a :class:`QuerySet` is used as the table data - (since sorting is performed by the database). - - .. [1] The provided callable object must not expect to receive any - arguments. - - :param visible: - If :const:`False`, this column will not be in HTML from output - generators (e.g. :meth:`as_html` or ``{% render_table %}``). - - When a field is not visible, it is removed from the table's - :attr:`~Column.columns` iterable. - - :param sortable: - If :const:`False`, this column will not be allowed to be used in - ordering the table. - - """ if not (accessor is None or isinstance(accessor, basestring) or callable(accessor)): raise TypeError('accessor must be a string or callable, not %s' % @@ -109,7 +68,8 @@ class Column(object): @property def default(self): - """The default value for cells in this column. + """ + The default value for cells in this column. The default value passed into ``Column.default`` property may be a callable, this function handles access. @@ -117,9 +77,30 @@ class Column(object): """ return self._default() if callable(self._default) else self._default + @property + def header(self): + """ + The value used for the column heading (e.g. inside the ```` tag). + + By default this equivalent to the column's :attr:`verbose_name`. + + .. note:: + + This property typically isn't accessed directly when a table is + rendered. Instead, :attr:`.BoundColumn.header` is accessed which + in turn accesses this property. This allows the header to fallback + to the column name (it's only available on a :class:`.BoundColumn` + object hence accessing that first) when this property doesn't + return something useful. + + """ + return self.verbose_name + def render(self, value, **kwargs): - """Returns a cell's content. - This method can be overridden by ``render_FOO`` methods on the table or + """ + Returns the content for a specific cell. + + This method can be overridden by :meth:`render_FOO` methods on the table or by subclassing :class:`Column`. """ @@ -127,18 +108,36 @@ class Column(object): class CheckBoxColumn(Column): - """A subclass of Column that renders its column data as a checkbox""" - def __init__(self, attrs=None, **extra): - """ - :param attrs: a dict of HTML element attributes to be added to the - ```` + """ + A subclass of :class:`.Column` that renders as a checkbox form input. - """ + This column allows a user to *select* a set of rows. The selection + information can then be used to apply some operation (e.g. "delete") onto + the set of objects that correspond to the selected rows. + + The value that is extracted from the :term:`table data` for this column is + used as the value for the checkbox, i.e. ```` + + By default this column is not sortable. + + .. note:: The "apply some operation onto the selection" functionality is + not implemented in this column, and requires manually implemention. + + :param attrs: + a :class:`dict` of HTML attributes that are added to the rendered + ```` tag + + """ + def __init__(self, attrs=None, **extra): params = {'sortable': False} params.update(extra) super(CheckBoxColumn, self).__init__(**params) self.attrs = attrs or {} - self.verbose_name = mark_safe('') + + @property + def header(self): + return mark_safe('') def render(self, value, bound_column, **kwargs): attrs = AttributeDict({ @@ -147,19 +146,59 @@ class CheckBoxColumn(Column): 'value': value }) attrs.update(self.attrs) - return mark_safe('' % AttributeDict(attrs).as_html()) + return mark_safe('' % attrs.as_html()) class LinkColumn(Column): + """ + A subclass of :class:`.Column` that renders the cell value as a hyperlink. + + It's common to have the primary value in a row hyperlinked to page + dedicated to that record. + + The first arguments are identical to that of + :func:`django.core.urlresolvers.reverse` and allow a URL to be + described. The last argument ``attrs`` allows custom HTML attributes to + be added to the ```` tag. + + :param viewname: See :func:`django.core.urlresolvers.reverse`. + :param urlconf: See :func:`django.core.urlresolvers.reverse`. + :param args: See :func:`django.core.urlresolvers.reverse`. ** + :param kwargs: See :func:`django.core.urlresolvers.reverse`. ** + :param current_app: See :func:`django.core.urlresolvers.reverse`. + + :param attrs: + a :class:`dict` of HTML attributes that are added to the rendered + ```` tag + + ** In order to create a link to a URL that relies on information in the + current row, :class:`.Accessor` objects can be used in the ``args`` or + ``kwargs`` arguments. The accessor will be resolved using the row's record + before ``reverse()`` is called. + + Example: + + .. code-block:: python + + # models.py + class Person(models.Model): + name = models.CharField(max_length=200) + + # urls.py + urlpatterns = patterns('', + url('people/(\d+)/', views.people_detail, name='people_detail') + ) + + # tables.py + from django_tables.utils import A # alias for Accessor + + class PeopleTable(tables.Table): + name = tables.LinkColumn('people_detail', args=[A('pk')]) + + """ def __init__(self, viewname, urlconf=None, args=None, kwargs=None, current_app=None, attrs=None, **extra): - """ - The first arguments are identical to that of - :func:`django.core.urlresolvers.reverse` and allow a URL to be - described. The last argument ``attrs`` allows custom HTML attributes to - be added to the ```` tag. - """ super(LinkColumn, self).__init__(**extra) self.viewname = viewname self.urlconf = urlconf @@ -201,6 +240,28 @@ class LinkColumn(Column): class TemplateColumn(Column): + """ + A subclass of :class:`.Column` that renders some template code to use as + the cell value. + + :type template_code: :class:`basestring` object + :param template_code: the template code to render + + A :class:`django.templates.Template` object is created from the + *template_code* and rendered with a context containing only a ``record`` + variable. This variable is the record for the table row being rendered. + + Example: + + .. code-block:: python + + class SimpleTable(tables.Table): + name1 = tables.TemplateColumn('{{ record.name }}') + name2 = tables.Column() + + Both columns will have the same output. + + """ def __init__(self, template_code=None, **extra): super(TemplateColumn, self).__init__(**extra) self.template_code = template_code @@ -220,16 +281,26 @@ class BoundColumn(object): For convenience, all :class:`Column` properties are available from this class. - """ - def __init__(self, table, column, name): - """Initialise a :class:`BoundColumn` object where: - * *table* - a :class:`Table` object in which this column exists - * *column* - a :class:`Column` object - * *name* – the variable name used when the column was added to the - :class:`Table` subclass + :type table: :class:`Table` object + :param table: the table in which this column exists - """ + :type column: :class:`Column` object + :param column: the type of column + + :type name: :class:`basestring` object + :param name: the variable name of the column used to when defining the + :class:`Table`. Example: + + .. code-block:: python + + class SimpleTable(tables.Table): + age = tables.Column() + + `age` is the name. + + """ + def __init__(self, table, column, name): self._table = table self._column = column self._name = name @@ -237,25 +308,16 @@ class BoundColumn(object): def __unicode__(self): return self.verbose_name - @property - def table(self): - """Returns the :class:`Table` object that this column is part of.""" - return self._table - @property def column(self): """Returns the :class:`Column` object for this column.""" return self._column - @property - def name(self): - """Returns the string used to identify this column.""" - return self._name - @property def accessor(self): - """Returns the string used to access data for this column out of the - data source. + """ + Returns the string used to access data for this column out of the data + source. """ return self.column.accessor or A(self.name) @@ -265,9 +327,39 @@ class BoundColumn(object): """Returns the default value for this column.""" return self.column.default + @property + def header(self): + """ + Return the value that should be used in the header cell for this + column. + + """ + return self.verbose_name + + @property + def name(self): + """Returns the string used to identify this column.""" + return self._name + + @property + def order_by(self): + """ + If this column is sorted, return the associated :class:`.OrderBy` + instance, otherwise :const:`None`. + + """ + try: + return self.table.order_by[self.name] + except IndexError: + return None + @property def sortable(self): - """Returns a ``bool`` depending on whether this column is sortable.""" + """ + Return a :class:`bool` depending on whether this column is + sortable. + + """ if self.column.sortable is not None: return self.column.sortable elif self.table._meta.sortable is not None: @@ -275,72 +367,78 @@ class BoundColumn(object): else: return True # the default value + @property + def table(self): + """Return the :class:`Table` object that this column is part of.""" + return self._table + @property def verbose_name(self): - """Returns the verbose name for this column.""" + """ + Return the verbose name for this column, or fallback to prettified + column name. + + """ return (self.column.verbose_name or capfirst(force_unicode(self.name.replace('_', ' ')))) @property def visible(self): - """Returns a ``bool`` depending on whether this column is visible.""" - return self.column.visible - - @property - def order_by(self): - """If this column is sorted, return the associated OrderBy instance. - Otherwise return a None. + """ + Returns a :class:`bool` depending on whether this column is visible. """ - try: - return self.table.order_by[self.name] - except IndexError: - return None + return self.column.visible -class Columns(object): - """Container for spawning BoundColumns. +class BoundColumns(object): + """ + Container for spawning BoundColumns. - This is bound to a table and provides its ``columns`` property. It - provides access to those columns in different ways (iterator, - item-based, filtered and unfiltered etc), stuff that would not be - possible with a simple iterator in the table class. + This is bound to a table and provides its :attr:`.Table.columns` property. + It provides access to those columns in different ways (iterator, + item-based, filtered and unfiltered etc), stuff that would not be possible + with a simple iterator in the table class. - A :class:`Columns` object is a container for holding :class:`BoundColumn` - objects. It provides methods that make accessing columns easier than if - they were stored in a ``list`` or ``dict``. :class:`Columns` has a similar - API to a ``dict`` (it actually uses a :class:`SortedDict` interally). + A :class:`BoundColumns` object is a container for holding + :class:`BoundColumn` objects. It provides methods that make accessing + columns easier than if they were stored in a :class:`list` or + :class:`dict`. :class:`Columns` has a similar API to a :class:`dict` (it + actually uses a :class:`SortedDict` interally). At the moment you'll only come across this class when you access a - :attr:`Table.columns` property. + :attr:`.Table.columns` property. + + :type table: :class:`.Table` object + :param table: the table containing the columns """ def __init__(self, table): - """Initialise a :class:`Columns` object. - - *table* must be a :class:`Table` object. - - """ self.table = table # ``self._columns`` attribute stores the bound columns (columns that # have a real name, ) self._columns = SortedDict() def _spawn_columns(self): - # (re)build the "_columns" cache of BoundColumn objects (note that - # ``base_columns`` might have changed since last time); creating - # BoundColumn instances can be costly, so we reuse existing ones. - new_columns = SortedDict() + """ + (re)build the "_bound_columns" cache of :class:`.BoundColumn` objects + (note that :attr:`.base_columns` might have changed since last time); + creating :class:`.BoundColumn` instances can be costly, so we reuse + existing ones. + + """ + columns = SortedDict() for name, column in self.table.base_columns.items(): if name in self._columns: - new_columns[name] = self._columns[name] + columns[name] = self._columns[name] else: - new_columns[name] = BoundColumn(self.table, column, name) - self._columns = new_columns + columns[name] = BoundColumn(self.table, column, name) + self._columns = columns def all(self): - """Iterate through all :class:`BoundColumn` objects, regardless of - visiblity or sortability. + """ + Return an iterator that exposes all :class:`.BoundColumn` objects, + regardless of visiblity or sortability. """ self._spawn_columns() @@ -348,8 +446,9 @@ class Columns(object): yield column def items(self): - """Return an iterator of ``(name, column)`` pairs (where *column* is a - :class:`BoundColumn` object). + """ + Return an iterator of ``(name, column)`` pairs (where ``column`` is a + :class:`.BoundColumn` object). """ self._spawn_columns() @@ -363,7 +462,8 @@ class Columns(object): yield r def sortable(self): - """Same as :meth:`all` but only returns sortable :class:`BoundColumn` + """ + Same as :meth:`.BoundColumns.all` but only returns sortable :class:`BoundColumn` objects. This is useful in templates, where iterating over the full @@ -377,8 +477,9 @@ class Columns(object): yield column def visible(self): - """Same as :meth:`sortable` but only returns visible - :class:`BoundColumn` objects. + """ + Same as :meth:`.sortable` but only returns visible + :class:`.BoundColumn` objects. This is geared towards table rendering. diff --git a/django_tables/rows.py b/django_tables/rows.py index d7e9e4a..6fab202 100644 --- a/django_tables/rows.py +++ b/django_tables/rows.py @@ -133,30 +133,35 @@ class BoundRow(object): return item in self -class Rows(object): - """Container for spawning BoundRows. +class BoundRows(object): + """ + Container for spawning :class:`.BoundRow` objects. + + The :attr:`.tables.Table.rows` attribute is a :class:`.BoundRows` object. + It provides functionality that would not be possible with a simple iterator + in the table class. - This is bound to a table and provides it's ``rows`` property. It - provides functionality that would not be possible with a simple - iterator in the table class. """ def __init__(self, table): - """Initialise a :class:`Rows` object. *table* is the :class:`Table` - object in which the rows exist. + """ + Initialise a :class:`Rows` object. *table* is the :class:`Table` object + in which the rows exist. """ self.table = table def all(self): - """Return an iterable for all :class:`BoundRow` objects in the table. + """ + Return an iterable for all :class:`BoundRow` objects in the table. """ - for row in self.table.data: - yield BoundRow(self.table, row) + for record in self.table.data: + yield BoundRow(self.table, record) def page(self): - """If the table is paginated, return an iterable of :class:`BoundRow` - objects that appear on the current page, otherwise return None. + """ + If the table is paginated, return an iterable of :class:`.BoundRow` + objects that appear on the current page, otherwise :const:`None`. """ if not hasattr(self.table, 'page'): @@ -164,7 +169,7 @@ class Rows(object): return iter(self.table.page.object_list) def __iter__(self): - """Convience method for all()""" + """Convience method for :meth:`.BoundRows.all`""" return self.all() def __len__(self): diff --git a/django_tables/tables.py b/django_tables/tables.py index 3e8afdf..cba5d5f 100644 --- a/django_tables/tables.py +++ b/django_tables/tables.py @@ -7,17 +7,19 @@ from django.template.loader import get_template from django.template import Context from django.utils.encoding import StrAndUnicode from .utils import OrderBy, OrderByTuple, Accessor, AttributeDict -from .columns import Column -from .rows import Rows, BoundRow -from .columns import Columns +from .rows import BoundRows, BoundRow +from .columns import BoundColumns, Column -__all__ = ('Table',) QUERYSET_ACCESSOR_SEPARATOR = '__' class TableData(object): - """Exposes a consistent API for a table data. It currently supports a - :class:`QuerySet` or a ``list`` of ``dict``s. + """ + Exposes a consistent API for :term:`table data`. It currently supports a + :class:`QuerySet`, or a :class:`list` of :class:`dict` objects. + + This class is used by :class:.Table` to wrap any + input table data. """ def __init__(self, data, table): @@ -45,7 +47,13 @@ class TableData(object): else len(self.list)) def order_by(self, order_by): - """Order the data based on column names in the table.""" + """ + Order the data based on column names in the table. + + :param order_by: the ordering to apply + :type order_by: an :class:`~.utils.OrderByTuple` object + + """ # translate order_by to something suitable for this data order_by = self._translate_order_by(order_by) if hasattr(self, 'queryset'): @@ -70,7 +78,8 @@ class TableData(object): return OrderByTuple(translated) def _populate_missing_values(self, data): - """Populates self._data with missing values based on the default value + """ + Populates self._data with missing values based on the default value for each column. It will create new items in the dataset (not modify existing ones). @@ -108,7 +117,8 @@ class TableData(object): class DeclarativeColumnsMetaclass(type): - """Metaclass that converts Column attributes on the class to a dictionary + """ + Metaclass that converts Column attributes on the class to a dictionary called ``base_columns``, taking into account parent class ``base_columns`` as well. @@ -147,20 +157,17 @@ class DeclarativeColumnsMetaclass(type): class TableOptions(object): - """Options for a :term:`table`. - - The following parameters are extracted via attribute access from the - *object* parameter. - - :param sortable: - bool determining if the table supports sorting. - :param order_by: - tuple describing the fields used to order the contents. - :param attrs: - HTML attributes added to the ```` tag. - + """ + Extracts and exposes options for a :class:`.Table` from a ``class Meta`` + when the table is defined. """ def __init__(self, options=None): + """ + + :param options: options for a table + :type options: :class:`Meta` on a :class:`.Table` + + """ super(TableOptions, self).__init__() self.sortable = getattr(options, 'sortable', None) order_by = getattr(options, 'order_by', ()) @@ -171,7 +178,21 @@ class TableOptions(object): class Table(StrAndUnicode): - """A collection of columns, plus their associated data rows.""" + """A collection of columns, plus their associated data rows. + + :type data: :class:`list` or :class:`QuerySet` + :param data: + The :term:`table data`. + + :param: :class:`tuple`-like or :class:`basestring` + :param order_by: + The description of how the table should be ordered. This allows the + :attr:`.Table.Meta.order_by` option to be overridden. + + .. note:: + Unlike a :class:`Form`, tables are always bound to data. + + """ __metaclass__ = DeclarativeColumnsMetaclass # this value is not the same as None. it means 'use the default sort @@ -181,29 +202,8 @@ class Table(StrAndUnicode): TableDataClass = TableData def __init__(self, data, order_by=DefaultOrder): - """Create a new table instance with the iterable ``data``. - - :param order_by: - If specified, it must be a sequence containing the names of columns - in the order that they should be ordered (much the same as - :method:`QuerySet.order_by`) - - If not specified, the table will fall back to the - :attr:`Meta.order_by` setting. - - Note that unlike a ``Form``, tables are always bound to data. Also - unlike a form, the ``columns`` attribute is read-only and returns - ``BoundColumn`` wrappers, similar to the ``BoundField``s you get - when iterating over a form. This is because the table iterator - already yields rows, and we need an attribute via which to expose - the (visible) set of (bound) columns - ``Table.columns`` is simply - the perfect fit for this. Instead, ``base_colums`` is copied to - table instances, so modifying that will not touch the class-wide - column list. - - """ - self._rows = Rows(self) # bound rows - self._columns = Columns(self) # bound columns + self._rows = BoundRows(self) # bound rows + self._columns = BoundColumns(self) # bound columns self._data = self.TableDataClass(data=data, table=self) # None is a valid order, so we must use DefaultOrder as a flag @@ -271,9 +271,7 @@ class Table(StrAndUnicode): """The attributes that should be applied to the ``
`` tag when rendering HTML. - ``attrs`` is an :class:`AttributeDict` object which allows the - attributes to be rendered to HTML element style syntax via the - :meth:`~AttributeDict.as_html` method. + :returns: :class:`~.utils.AttributeDict` object. """ return self._meta.attrs diff --git a/django_tables/templates/django_tables/basic_table.html b/django_tables/templates/django_tables/basic_table.html index 323ce3e..a564e7b 100644 --- a/django_tables/templates/django_tables/basic_table.html +++ b/django_tables/templates/django_tables/basic_table.html @@ -3,7 +3,7 @@ {% for column in table.columns %} - + {% endfor %} diff --git a/django_tables/templates/django_tables/table.html b/django_tables/templates/django_tables/table.html index 7212876..9ecf79a 100644 --- a/django_tables/templates/django_tables/table.html +++ b/django_tables/templates/django_tables/table.html @@ -1,12 +1,12 @@ -{% load django_tables %} {% spaceless %} +{% load django_tables %} {% for column in table.columns %} {% if column.sortable %} {% with column.order_by as ob %} - + {% endwith %} {% else %} diff --git a/django_tables/utils.py b/django_tables/utils.py index f327df2..405f447 100644 --- a/django_tables/utils.py +++ b/django_tables/utils.py @@ -10,22 +10,26 @@ __all__ = ('BaseTable', 'options') class OrderBy(str): - """A single element in an :class:`OrderByTuple`. This class is essentially - just a :class:`str` with some extra properties. + """A single item in an :class:`.OrderByTuple` object. This class is + essentially just a :class:`str` with some extra properties. """ @property def bare(self): - """Return the bare or naked version. That is, remove a ``-`` prefix if - it exists and return the result. + """ + Return the :term:`bare ` form. + + :rtype: :class:`.OrderBy` object """ return OrderBy(self[1:]) if self[:1] == '-' else self @property def opposite(self): - """Return the an :class:`OrderBy` object with the opposite sort - influence. e.g. + """ + Return an :class:`.OrderBy` object with an opposite sort influence. + + Example: .. code-block:: python @@ -33,40 +37,49 @@ class OrderBy(str): >>> order_by.opposite '-name' + :rtype: :class:`.OrderBy` object + """ return OrderBy(self[1:]) if self.is_descending else OrderBy('-' + self) @property def is_descending(self): - """Return :const:`True` if this object induces *descending* ordering.""" + """ + Return :const:`True` if this object induces *descending* ordering + + :rtype: :class:`bool` + + """ return self.startswith('-') @property def is_ascending(self): - """Return :const:`True` if this object induces *ascending* ordering.""" + """ + Return :const:`True` if this object induces *ascending* ordering. + + :returns: :class:`bool` + + """ return not self.is_descending class OrderByTuple(tuple, StrAndUnicode): - """Stores ordering instructions (as :class:`OrderBy` objects). The - :attr:`Table.order_by` property is always converted into an - :class:`OrderByTuplw` objectUsed to render output in a format we understand - as input (see :meth:`~OrderByTuple.__unicode__`) - especially useful in - templates. + """Stores ordering as (as :class:`.OrderBy` objects). The + :attr:`django_tables.tables.Table.order_by` property is always converted + to an :class:`.OrderByTuple` object. + + This class is essentially just a :class:`tuple` with some useful extras. - It's quite easy to create one of these. Pass in an iterable, and it will - automatically convert each element into an :class:`OrderBy` object. e.g. + Example: .. code-block:: python - >>> ordering = ('name', '-age') - >>> order_by_tuple = OrderByTuple(ordering) - >>> age = order_by_tuple['age'] - >>> age + >>> x = OrderByTuple(('name', '-age')) + >>> x['age'] '-age' - >>> age.is_descending + >>> x['age'].is_descending True - >>> age.opposite + >>> x['age'].opposite 'age' """ @@ -83,18 +96,23 @@ class OrderByTuple(tuple, StrAndUnicode): return ','.join(self) def __contains__(self, name): - """Determine whether a column is part of this order (i.e. descending - prefix agnostic). e.g. + """ + Determine if a column has an influence on ordering. + + Example: .. code-block:: python - >>> ordering = ('name', '-age') - >>> order_by_tuple = OrderByTuple(ordering) - >>> 'age' in order_by_tuple + >>> ordering = + >>> x = OrderByTuple(('name', '-age')) + >>> 'age' in x True - >>> '-age' in order_by_tuple + >>> '-age' in x True + :param name: The name of a column. (optionally prefixed) + :returns: :class:`bool` + """ for o in self: if o == name or o.bare == name: @@ -102,20 +120,24 @@ class OrderByTuple(tuple, StrAndUnicode): return False def __getitem__(self, index): - """Allows an :class:`OrderBy` object to be extracted using - :class:`dict`-style indexing in addition to standard 0-based integer - indexing. The :class:`dict`-style is prefix agnostic in the same way as - :meth:`~OrderByTuple.__contains__`. + """ + Allows an :class:`.OrderBy` object to be extracted via named or integer + based indexing. + + When using named based indexing, it's fine to used a prefixed named. .. code-block:: python - >>> ordering = ('name', '-age') - >>> order_by_tuple = OrderByTuple(ordering) - >>> order_by_tuple['age'] + >>> x = OrderByTuple(('name', '-age')) + >>> x[0] + 'name' + >>> x['age'] '-age' - >>> order_by_tuple['-age'] + >>> x['-age'] '-age' + :rtype: :class:`.OrderBy` object + """ if isinstance(index, basestring): for ob in self: @@ -126,8 +148,12 @@ class OrderByTuple(tuple, StrAndUnicode): @property def cmp(self): - """Return a function suitable for sorting a list. This is used for - non-:class:`QuerySet` data sources. + """ + Return a function for use with :meth:`list.sort()` that implements this + object's ordering. This is used to sort non-:class:`QuerySet` based + :term:`table data`. + + :rtype: function """ def _cmp(a, b): @@ -146,12 +172,46 @@ class OrderByTuple(tuple, StrAndUnicode): class Accessor(str): + """ + A string describing a path from one object to another via attribute/index + accesses. For convenience, the class has an alias ``A`` to allow for more concise code. + + Relations are separated by a ``.`` character. + + """ SEPARATOR = '.' def resolve(self, context): - # Try to resolve relationships spanning attributes. This is - # basically a copy/paste from django/template/base.py in - # Variable._resolve_lookup() + """ + Return an object described by the accessor by traversing the attributes + of *context*. + + Example: + + .. code-block:: python + + >>> x = Accessor('__len__`') + >>> x.resolve('brad') + 4 + >>> x = Accessor('0.upper') + >>> x.resolve('brad') + 'B' + + :type context: :class:`object` + :param context: The root/first object to traverse. + :returns: target object + :raises: TypeError, AttributeError, KeyError, ValueError + + :meth:`~.Accessor.resolve` attempts lookups in the following order: + + - dictionary (e.g. ``obj[related]``) + - attribute (e.g. ``obj.related``) + - list-index lookup (e.g. ``obj[int(related)]``) + + Callable objects are called, and their result is used, before + proceeding with the resolving. + + """ current = context for bit in self.bits: try: # dictionary lookup @@ -190,8 +250,25 @@ class AttributeDict(dict): """A wrapper around :class:`dict` that knows how to render itself as HTML style tag attributes. + The returned string is marked safe, so it can be used safely in a template. + See :meth:`.as_html` for a usage example. + """ def as_html(self): - """Render as HTML style tag attributes.""" + """ + Render to HTML tag attributes. + + Example: + + .. code-block:: python + + >>> from django_tables.utils import AttributeDict + >>> attrs = AttributeDict({'class': 'mytable', 'id': 'someid'}) + >>> attrs.as_html() + 'class="mytable" id="someid"' + + :rtype: :class:`~django.utils.safestring.SafeUnicode` object + + """ return mark_safe(' '.join(['%s="%s"' % (k, escape(v)) for k, v in self.iteritems()])) diff --git a/docs/conf.py b/docs/conf.py index 37726b7..36f0aab 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ project = u'django-tables' # built documents. # # The short X.Y version. -version = '0.4.0.beta' +version = '0.4.0' # The full version, including alpha/beta/rc tags. -release = '0.4.0.beta' +release = '0.4.0.beta2' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/index.rst b/docs/index.rst index 25a4401..37f2c06 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,8 +1,8 @@ .. default-domain:: py -===================================================== +=============================================== django-tables - An app for creating HTML tables -===================================================== +=============================================== django-tables simplifies the task of turning sets of datainto HTML tables. It has native support for pagination and sorting. It does for HTML tables what @@ -11,243 +11,240 @@ has native support for pagination and sorting. It does for HTML tables what Quick start guide ================= -1. Download and install the package. -2. Install the tables framework by adding ``'django_tables'`` to your +1. Download and install from https://github.com/bradleyayers/django-tables. + Grab a ``.tar.gz`` of the latest tag, and run ``pip install ``. +2. Hook the app into your Django project by adding ``'django_tables'`` to your ``INSTALLED_APPS`` setting. -3. Ensure that ``'django.core.context_processors.request'`` is in your - ``TEMPLATE_CONTEXT_PROCESSORS`` setting. -4. Write table classes for the types of tables you want to display. -5. Create an instance of a table in a view, provide it your data, and pass it - to a template for display. -6. Use ``{{ table.as_html }}``, the - :ref:`template tag `, or your own - custom template code to display the table. +3. Write a subclass of :class:`~django_tables.tables.Table` that describes the + structure of your table. +4. Create an instance of your table in a :term:`view`, provide it with + :term:`table data`, and pass it to a :term:`template` for display. +5. Use ``{{ table.as_html }}``, the + :ref:`template tag `, or your own + :ref:`custom template ` to display the table. -Tables -====== +Slow start guide +================ -For each type of table you want to display, you will need to create a subclass -of ``django_tables.Table`` that describes the structure of the table. - -In this example we are going to take some data describing three countries and -turn it into a HTML table. We start by creating our data: +We're going to take some data that describes three countries and +turn it into an HTML table. This is the data we'll be using: .. code-block:: python - >>> countries = [ - ... {'name': 'Australia', 'population': 21, 'tz': 'UTC +10', 'visits': 1}, - ... {'name': 'Germany', 'population', 81, 'tz': 'UTC +1', 'visits': 2}, - ... {'name': 'Mexico', 'population': 107, 'tz': 'UTC -6', 'visits': 0}, - ... ] + countries = [ + {'name': 'Australia', 'population': 21, 'tz': 'UTC +10', 'visits': 1}, + {'name': 'Germany', 'population', 81, 'tz': 'UTC +1', 'visits': 2}, + {'name': 'Mexico', 'population': 107, 'tz': 'UTC -6', 'visits': 0}, + ] + -Next we subclass ``django_tables.Table`` to create a table that describes our -data. The API should look very familiar since it's based on Django's -database model API: +The first step is to subclass :class:`~django_tables.tables.Table` and describe +the table structure. This is done by creating a column for each attribute in +the :term:`table data`. .. code-block:: python - >>> import django_tables as tables - >>> class CountryTable(tables.Table): - ... name = tables.Column() - ... population = tables.Column() - ... tz = tables.Column(verbose_name='Time Zone') - ... visits = tables.Column() + import django_tables as tables + class CountryTable(tables.Table): + name = tables.Column() + population = tables.Column() + tz = tables.Column(verbose_name='Time Zone') + visits = tables.Column() -Providing data --------------- -To use the table, simply create an instance of the table class and pass in your -data. e.g. following on from the above example: +Now that we've defined our table, it's ready for use. We simply create an +instance of it, and pass in our table data. .. code-block:: python - >>> table = CountryTable(countries) + table = CountryTable(countries) -Tables have support for any iterable data that contains objects with -attributes that can be accessed as property or dictionary syntax: +Now we add it to our template context and render it to HTML. Typically you'd +write a view that would look something like: .. code-block:: python - >>> table = SomeTable([{'a': 1, 'b': 2}, {'a': 4, 'b': 8}]) # valid - >>> table = SomeTable(SomeModel.objects.all()) # also valid - -Each item in the data corresponds to one row in the table. By default, the -table uses column names as the keys (or attributes) for extracting cell values -from the data. This can be changed by using the :attr:`~Column.accessor` -argument. + def home(request): + table = CountryTable(countries) + return render_to_response('home.html', {'table': table}, + context_instances=RequestContext(request)) - -Displaying a table ------------------- - -There are two ways to display a table, the easiest way is to use the table's -own ``as_html`` method: +In your template, the easiest way to :term:`render` the table is via the +:meth:`~django_tables.tables.Table.as_html` method: .. code-block:: django {{ table.as_html }} -Which will render something like: +…which will render something like: -+--------------+------------+---------+ -| Country Name | Population | Tz | -+==============+============+=========+ -| Australia | 21 | UTC +10 | -+--------------+------------+---------+ -| Germany | 81 | UTC +1 | -+--------------+------------+---------+ -| Mexico | 107 | UTC -6 | -+--------------+------------+---------+ ++--------------+------------+---------+--------+ +| Country Name | Population | Tz | Visit | ++==============+============+=========+========+ +| Australia | 21 | UTC +10 | 1 | ++--------------+------------+---------+--------+ +| Germany | 81 | UTC +1 | 2 | ++--------------+------------+---------+--------+ +| Mexico | 107 | UTC -6 | 0 | ++--------------+------------+---------+--------+ -The downside of this approach is that pagination and sorting will not be -available. These features require the use of the ``{% render_table %}`` -template tag: +This approach is easy, but it's not fully featured. For slightly more effort, +you can render a table with sortable columns. For this, you must use the +template tag. .. code-block:: django {% load django_tables %} {% render_table table %} -See :ref:`template_tags` for more information. +See :ref:`template-tags.render_table` for more information. +The table will be rendered, but chances are it will still look quite ugly. An +easy way to make it pretty is to use the built-in *paleblue* theme. For this to +work, you must add a CSS class to the ``
{{ column }}{{ column.header }}
{{ column.verbose_name }}{{ column.header }}{{ column.verbose_name }}
`` tag. This can be achieved by +adding a ``class Meta:`` to the table class and defining a ``attrs`` variable. -Ordering --------- +.. code-block:: python -Controlling the order that the rows are displayed (sorting) is simple, just use -the :attr:`~Table.order_by` property or pass it in when initialising the -instance: + import django_tables as tables -.. code-block:: python + class CountryTable(tables.Table): + name = tables.Column() + population = tables.Column() + tz = tables.Column(verbose_name='Time Zone') + visits = tables.Column() - >>> # order_by argument when creating table instances - >>> table = CountryTable(countries, order_by='name, -population') - >>> table = CountryTable(countries, order_by=('name', '-population')) - >>> # order_by property on table instances - >>> table = CountryTable(countries) - >>> table.order_by = 'name, -population' - >>> table.order_by = ('name', '-population') + class Meta: + attrs = {'class': 'paleblue'} +The last thing to do is to include the stylesheet in the template. -Customising the output -====================== +.. code-block:: html -There are a number of options available for changing the way the table is -rendered. Each approach provides balance of ease-of-use and control (the more -control you want, the less easy it is to use). + -CSS ---- +Save your template and reload the page in your browser. + + +.. _table-data: + +Table data +========== -If you want to affect the appearance of the table using CSS, you probably want -to add a ``class`` or ``id`` attribute to the ``
`` element. This can be -achieved by specifying an ``attrs`` variable in the table's ``Meta`` class. +The data used to populate a table is called :term:`table data`. To provide a +table with data, pass it in as the first argument when instantiating a table. .. code-block:: python - >>> import django_tables as tables - >>> class SimpleTable(tables.Table): - ... id = tables.Column() - ... age = tables.Column() - ... - ... class Meta: - ... attrs = {'class': 'mytable'} - ... - >>> table = SimpleTable() - >>> table.as_html() - '
...' + table = CountryTable(countries) # valid + table = CountryTable(Country.objects.all()) # also valid + +Each item in the :term:`table data` is called a :term:`record` and is used to +populate a single row in the table. By default, the table uses column names +as :term:`accessors ` to retrieve individual cell values. This can +be changed via the :attr:`~django_tables.columns.Column.accessor` argument. + +Any iterable can be used as table data, and there's builtin support for +:class:`QuerySet` objects (to ensure they're handled effeciently). -The :attr:`Table.attrs` property actually returns an :class:`AttributeDict` -object. These objects are identical to :class:`dict`, but have an -:meth:`AttributeDict.as_html` method that returns a HTML tag attribute string. + +.. _ordering: + +Ordering +======== + +Changing the table ordering is easy. When creating a +:class:`~django_tables.tables.Table` object include an `order_by` parameter +with a tuple that describes the way the ordering should be applied. .. code-block:: python - >>> from django_tables.utils import AttributeDict - >>> attrs = AttributeDict({'class': 'mytable', 'id': 'someid'}) - >>> attrs.as_html() - 'class="mytable" id="someid"' + table = CountryTable(countries, order_by=('name', '-population')) + table = CountryTable(countries, order_by='name,-population') # equivalant + +Alternatively, the :attr:`~django_tables.tables.Table.order_by` attribute can +by modified. + + table = CountryTable(countries) + table.order_by = ('name', '-population') + table.order_by = 'name,-population' # equivalant -The returned string is marked safe, so it can be used safely in a template. -Column formatter ----------------- +.. _custom-rendering: -Using a formatter is a quick way to adjust the way values are displayed in a -column. A limitation of this approach is that you *only* have access to a -single attribute of the data source. +Custom rendering +================ -To use a formatter, simply provide the :attr:`~Column.formatter` argument to a -:class:`Column` when you define the :class:`Table`: +Various options are available for changing the way the table is :term:`rendered +`. Each approach has a different balance of ease-of-use and +flexibility. + +CSS +--- + +In order to use CSS to style a table, you'll probably want to add a +``class`` or ``id`` attribute to the ``
`` element. ``django-tables`` has +a hook that allows abitrary attributes to be added to the ``
`` tag. .. code-block:: python >>> import django_tables as tables >>> class SimpleTable(tables.Table): - ... id = tables.Column(formatter=lambda x: '#%d' % x) - ... age = tables.Column(formatter=lambda x: '%d years old' % x) + ... id = tables.Column() + ... age = tables.Column() ... - >>> table = SimpleTable([{'age': 31, 'id': 10}, {'age': 34, 'id': 11}]) - >>> row = table.rows[0] - >>> for cell in row: - ... print cell + ... class Meta: + ... attrs = {'class': 'mytable'} ... - #10 - 31 years old - -As you can see, the only the value of the column is available to the formatter. -This means that **it's impossible create a formatter that incorporates other -values of the record**, e.g. a column with an ```` that uses -:func:`reverse` with the record's ``pk``. + >>> table = SimpleTable() + >>> table.as_html() + '
...' -If formatters aren't powerful enough, you'll need to either :ref:`create a -Column subclass `, or to use the -:ref:`Table.render_FOO method `. +Inspired by Django's ORM, the ``class Meta:`` allows you to define extra +characteristics of a table. See :class:`Table.Meta` for details. .. _table.render_foo: -:meth:`Table.render_FOO` Method -------------------------------- +:meth:`Table.render_FOO` Methods +-------------------------------- + +If you want to adjust the way table cells in a particular column are rendered, +you can implement a ``render_FOO`` method. ``FOO`` is replaced with the +:term:`name ` of the column. This approach provides a lot of control, but is only suitable if you intend to customise the rendering for a single table (otherwise you'll end up having to copy & paste the method to every table you want to modify – which violates DRY). -The example below has a number of different techniques in use: - -* :meth:`Column.render` (accessible via :attr:`BoundColumn.column`) applies the - *formatter* if it's been provided. The effect of this behaviour can be seen - below in the output for the ``id`` column. Square brackets (from the - *formatter*) have been applied *after* the angled brackets (from the - :meth:`~Table.render_FOO`). -* Completely abitrary values can be returned by :meth:`render_FOO` methods, as - shown in :meth:`~SimpleTable.render_row_number` (a :attr:`_counter` attribute - is added to the :class:`SimpleTable` object to keep track of the row number). +For convenience, a bunch of commonly used/useful values are passed to +``render_FOO`` functions, when writing the signature, accept the arguments +you're interested in, and collect the rest in a ``**kwargs`` argument. - This is possible because :meth:`render_FOO` methods override the default - behaviour of retrieving a value from the data-source. +:param value: the value for the cell retrieved from the :term:`table data` +:param record: the entire record for the row from :term:`table data` +:param column: the :class:`.Column` object +:param bound_column: the :class:`.BoundColumn` object +:param bound_row: the :class:`.BoundRow` object +:param table: alias for ``self`` .. code-block:: python >>> import django_tables as tables >>> class SimpleTable(tables.Table): ... row_number = tables.Column() - ... id = tables.Column(formatter=lambda x: '[%s]' % x) - ... age = tables.Column(formatter=lambda x: '%d years old' % x) + ... id = tables.Column() + ... age = tables.Column() ... - ... def render_row_number(self, bound_column, bound_row): + ... def render_row_number(self, **kwargs): ... value = getattr(self, '_counter', 0) ... self._counter = value + 1 ... return 'Row %d' % value ... - ... def render_id(self, bound_column, bound_row): - ... value = bound_column.column.render(table=self, - ... bound_column=bound_column, - ... bound_row=bound_row) + ... def render_id(self, value, **kwargs): ... return '<%s>' % value ... >>> table = SimpleTable([{'age': 31, 'id': 10}, {'age': 34, 'id': 11}]) @@ -255,14 +252,11 @@ The example below has a number of different techniques in use: ... print cell ... Row 0 - <[10]> - 31 years old + <10> + 31 -The :meth:`Column.render` method is what actually performs the lookup into a -record to retrieve the column value. In the example above, the -:meth:`render_row_number` never called :meth:`Column.render` and as a result -there was not attempt to access the data source to retrieve a value. +.. _custom-template: Custom Template --------------- @@ -362,15 +356,15 @@ rendered). This can be achieved by using the :func:`mark_safe` function. Template tags ============= -.. _template_tags.render_table: +.. _template-tags.render_table: render_table ------------ -If you want to render a table that provides support for sorting and pagination, -you must use the ``{% render_table %}`` template tag. In this example ``table`` -is an instance of a :class:`django_tables.Table` that has been put into the -template context: +Renders a :class:`~django_tables.tables.Table` object to HTML and includes as +many features as possible. + +Sample usage: .. code-block:: django @@ -378,7 +372,7 @@ template context: {% render_table table %} -.. _template_tags.set_url_param: +.. _template-tags.set_url_param: set_url_param ------------- @@ -392,9 +386,8 @@ This is very useful if you want the give your users the ability to interact with your table (e.g. change the ordering), because you will need to create urls with the appropriate queries. -Let's assume we have the query-string -``?search=pirates&sort=name&page=5`` and we want to update the ``sort`` -parameter: +Let's assume we have the querystring ``?search=pirates&sort=name&page=5`` and +we want to update the ``sort`` parameter: .. code-block:: django @@ -432,11 +425,57 @@ which can be iterated over: API Reference ============= +:class:`Accessor` Objects: +-------------------------- + +.. autoclass:: django_tables.utils.Accessor + :members: + + :class:`Table` Objects: ------------------------- +----------------------- .. autoclass:: django_tables.tables.Table - :members: + + +:class:`Table.Meta` Objects: +---------------------------- + +.. class:: Table.Meta + + .. attribute:: attrs + + Allows custom HTML attributes to be specified which will be added to + the ``
`` tag of any table rendered via + :meth:`~django_tables.tables.Table.as_html` or the + :ref:`template-tags.render_table` template tag. + + Default: ``{}`` + + :type: :class:`dict` + + .. attribute:: sortable + + Does the table support ordering? + + Default: :const:`True` + + :type: :class:`bool` + + .. attribute:: order_by + + The default ordering. e.g. ``('name', '-age')`` + + Default: ``()`` + + :type: :class:`tuple` + + +:class:`TableData` Objects: +------------------------------ + +.. autoclass:: django_tables.tables.TableData + :members: __init__, order_by, __getitem__, __len__ :class:`TableOptions` Objects: @@ -450,14 +489,34 @@ API Reference ------------------------ .. autoclass:: django_tables.columns.Column - :members: __init__, default, render -:class:`Columns` Objects ------------------------- +:class:`CheckBoxColumn` Objects: +-------------------------------- -.. autoclass:: django_tables.columns.Columns - :members: __init__, all, items, names, sortable, visible, __iter__, +.. autoclass:: django_tables.columns.CheckBoxColumn + :members: + + +:class:`LinkColumn` Objects: +---------------------------- + +.. autoclass:: django_tables.columns.LinkColumn + :members: + + +:class:`TemplateColumn` Objects: +-------------------------------- + +.. autoclass:: django_tables.columns.TemplateColumn + :members: + + +:class:`BoundColumns` Objects +----------------------------- + +.. autoclass:: django_tables.columns.BoundColumns + :members: all, items, names, sortable, visible, __iter__, __contains__, __len__, __getitem__ @@ -465,15 +524,14 @@ API Reference ---------------------------- .. autoclass:: django_tables.columns.BoundColumn - :members: __init__, table, column, name, accessor, default, formatter, - sortable, verbose_name, visible + :members: -:class:`Rows` Objects ---------------------- +:class:`BoundRows` Objects +-------------------------- -.. autoclass:: django_tables.rows.Rows - :members: __init__, all, page, __iter__, __len__, count, __getitem__ +.. autoclass:: django_tables.rows.BoundRows + :members: __init__, all, page, __iter__, __len__, count :class:`BoundRow` Objects @@ -501,7 +559,7 @@ API Reference ----------------------------- .. autoclass:: django_tables.utils.OrderByTuple - :members: __contains__, __getitem__, __unicode__ + :members: __unicode__, __contains__, __getitem__, cmp Glossary @@ -509,6 +567,41 @@ Glossary .. glossary:: + accessor + Refers to an :class:`~django_tables.utils.Accessor` object + + bare orderby + The non-prefixed form of an :class:`~django_tables.utils.OrderBy` + object. Typically the bare form is just the ascending form. + + Example: ``age`` is the bare form of ``-age`` + + column name + The name given to a column. In the follow example, the *column name* is + ``age``. + + .. code-block:: python + + class SimpleTable(tables.Table): + age = tables.Column() + table The traditional concept of a table. i.e. a grid of rows and columns containing data. + + view + A Django view. + + record + A single Python object used as the data for a single row. + + render + The act of serialising a :class:`~django_tables.tables.Table` into + HTML. + + template + A Django template. + + table data + An interable of :term:`records ` that + :class:`~django_tables.tables.Table` uses to populate its rows. diff --git a/setup.py b/setup.py index f69c18b..0f96d61 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages setup( name='django-tables', - version='0.4.0.beta', + version='0.4.0.beta2', description='Table framework for Django', author='Bradley Ayers', diff --git a/tests/utils.py b/tests/utils.py index f2d7f3b..48b6fe9 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,5 +1,5 @@ # -*- coding: utf8 -*- -from django_tables.utils import OrderByTuple, OrderBy +from django_tables.utils import OrderByTuple, OrderBy, Accessor from attest import Tests, Assert @@ -33,3 +33,18 @@ def orderby(): Assert('b') == b.opposite Assert(True) == b.is_descending Assert(False) == b.is_ascending + + +@utils.test +def accessor(): + x = Accessor('0') + Assert('B') == x.resolve('Brad') + + x = Accessor('1') + Assert('r') == x.resolve('Brad') + + x = Accessor('2.upper') + Assert('A') == x.resolve('Brad') + + x = Accessor('2.upper.__len__') + Assert(1) == x.resolve('Brad') -- 2.26.2