Skip to content

Commit

Permalink
Merge pull request #893 from jondoesntgit/master
Browse files Browse the repository at this point in the history
Added basic API for Column-level attributes (issue #821)
  • Loading branch information
avalentino committed May 31, 2023
2 parents 07224cc + 0e30264 commit f8ad2f1
Show file tree
Hide file tree
Showing 5 changed files with 240 additions and 0 deletions.
4 changes: 4 additions & 0 deletions doc/source/usersguide/libref/declarative_classes.rst
Expand Up @@ -170,6 +170,10 @@ instances have the following attributes.
The *relative* position of this column with regard to its column
siblings.

.. attribute:: Col._v_col_attrs

Additional metadata information. See :ref:`AttributeSetClassDescr`.


Col factory methods
~~~~~~~~~~~~~~~~~~~
Expand Down
6 changes: 6 additions & 0 deletions doc/source/usersguide/tutorials.rst
Expand Up @@ -743,6 +743,12 @@ file written to disk.
Attributes are a useful mechanism to add persistent (meta) information to
your data.

Starting with PyTables 3.9.0, you can also set, delete, or rename attributes on individual columns. The API is designed to behave the same way as attributes on a table::

>>> table.cols.pressure.attrs['units'] = 'kPa'
>>> table.cols.energy.attrs['units'] = 'MeV'



Getting object metadata
~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
10 changes: 10 additions & 0 deletions tables/description.py
Expand Up @@ -64,6 +64,9 @@ class whenever you know the exact type you will need when writing your
pos : int
Sets the position of column in table. If unspecified, the position
will be randomly selected.
attrs : dict
Attribute metadata stored in the column (see
:ref:`AttributeSetClassDescr`).
"""

Expand Down Expand Up @@ -161,12 +164,15 @@ class NewCol(cls, atombase):
The constructor accepts the same arguments as the equivalent
`Atom` class, plus an additional ``pos`` argument for
position information, which is assigned to the `_v_pos`
attribute and an ``attrs`` argument for storing additional metadata
similar to `table.attrs`, which is assigned to the `_v_col_attrs`
attribute.
"""

def __init__(self, *args, **kwargs):
pos = kwargs.pop('pos', None)
col_attrs = kwargs.pop('attrs', {})
offset = kwargs.pop('_offset', None)
class_from_prefix = self._class_from_prefix
atombase.__init__(self, *args, **kwargs)
Expand All @@ -178,6 +184,7 @@ def __init__(self, *args, **kwargs):
self.__class__ = colclass
self._v_pos = pos
self._v_offset = offset
self._v_col_attrs = col_attrs

__eq__ = same_position(atombase.__eq__)
_is_equal_to_atom = same_position(atombase._is_equal_to_atom)
Expand All @@ -203,6 +210,9 @@ def __repr__(self):
rpar = atomrepr.rindex(')')
atomargs = atomrepr[lpar + 1:rpar]
classname = self.__class__.__name__
if self._v_col_attrs:
return (f'{classname}({atomargs}, pos={self._v_pos}'
f', attrs={self._v_col_attrs})')
return f'{classname}({atomargs}, pos={self._v_pos})'

def _get_init_args(self):
Expand Down
143 changes: 143 additions & 0 deletions tables/table.py
Expand Up @@ -7,6 +7,7 @@
import sys
import warnings
from pathlib import Path
import weakref

from time import perf_counter as clock

Expand Down Expand Up @@ -3285,6 +3286,10 @@ class Column:
The complete pathname of the associated column (the same as
Column.name if the column is not inside a nested column).
.. attribute:: attrs
Column attributes (see :ref:`ColClassDescr`).
Parameters
----------
table
Expand Down Expand Up @@ -3358,6 +3363,7 @@ def __init__(self, table, name, descr):
self.descr = descr
"""The Description (see :ref:`DescriptionClassDescr`) instance of the
parent table or nested column."""
self._v_attrs = ColumnAttributeSet(self)

def _g_update_table_location(self, table):
"""Updates the location information about the associated `table`."""
Expand Down Expand Up @@ -3695,3 +3701,140 @@ def __repr__(self):
"""A detailed string representation for this object."""

return str(self)

@lazyattr
def _v_pos(self):
return self.descr._v_colobjects[self.name]._v_pos

@lazyattr
def _v_col_attrs(self):
return self.descr._v_colobjects[self.name]._v_col_attrs

@property
def attrs(self):
return self._v_attrs


class ColumnAttributeSet:

def __init__(self, column):

self.__dict__['_v_tableattrs'] = column.table.attrs
self.__dict__['_v_fieldindex'] = column._v_pos
self.__dict__['_v_column_reference'] = weakref.ref(column)

# Check if this column has _v_col_attrs set and translate them into
# the table attribute format
for col_attr_key, col_attr_val in column._v_col_attrs.items():
self.__setitem__(col_attr_key, col_attr_val)

def issystemcolumnname(self, key):
"""Checks whether a key is a reserved attribute name, or should be passed through."""
return key in ['_v_tableattrs', '_v_fieldindex', '_v_column_reference']

def _prefix(self, string):
"""Prefixes a key with a special pattern for storing with table attributes"""
field_index = self.__dict__['_v_fieldindex']
return 'FIELD_%i_ATTR_%s' % (field_index, string)

def __getattr__(self, key):
"""Retrieves a PyTables attribute for this column"""
if not self.issystemcolumnname(key):
return getattr(self._v_tableattrs, self._prefix(key))
else:
return super().__getattr__(key)

def __setattr__(self, key, val):
"""Sets a PyTables attribute for this column"""
if not self.issystemcolumnname(key):
setattr(self._v_tableattrs, self._prefix(key), val)
else:
return super().__setattr__(key, val)

def __getitem__(self, key):
"""A dictionary-like interface for __getattr__"""
if not self.issystemcolumnname(key):
return self._v_tableattrs[self._prefix(key)]
else:
return self[key]

def __setitem__(self, key, value):
"""A dictionary-like interface for __setattr__"""
if not self.issystemcolumnname(key):
self._v_tableattrs[self._prefix(key)] = value
else:
self[key] = value

def __delattr__(self, key):
"""Deletes the attribute for this column"""
if self.issystemcolumnname(key):
raise TypeError('Deleting system attributes is prohibited')
else:
delattr(self._v_tableattrs, self._prefix(key))

def __delitem__(self, key):
"""A dictionary-like interface for __delattr__"""
if self.issystemcolumnname(key):
raise TypeError('Deleting system attributes is prohibited')
else:
del self._v_tableattrs[self._prefix(key)]

def _f_rename(self, oldattrname, newattrname):
"""Rename an attribute from oldattrname to newattrname."""

if oldattrname == newattrname:
# Do nothing
return

if self.issystemcolumnname(oldattrname):
raise TypeError('Renaming system attributes is prohibited')

# First, fetch the value of the oldattrname
attrvalue = getattr(self, oldattrname)

# Now, create the new attribute
setattr(self, newattrname, attrvalue)

# Finally, remove the old attribute
delattr(self, oldattrname)

def _f_copy(self, where):
"""Copy attributes to another column"""

# Is there a better way to do this?
if not isinstance(where, Column):
raise TypeError(f"destination object is not a column: {where!r}")

for key in self.keys():
where.attrs[key] = self[key]

def keys(self):
"""Returns the list of attributes for this column"""
col_prefix = self._prefix('')
length = len(col_prefix)
return [key[length:] for key in self._v_tableattrs._v_attrnames if key.startswith(col_prefix)]

def contains(self, key):
"""Returns whether a key is in the attribute set"""
return key in self.keys()

def __str__(self):
"""The string representation for this object."""

pathname = self._v_tableattrs._v__nodepath
classname = self._v_column_reference().__class__.__name__ # self._v_tableattrs._v_node.__class__.__name__
attrnumber = sum(1 for _ in self.keys())
columnname = self._v_column_reference().name

return f"{pathname}.cols.{columnname}._v_attrs ({classname}), {attrnumber} attributes"

def __repr__(self):
"""A detailed string representation for this object."""

# print additional info only if there are attributes to show
attrnames = self.keys()
if attrnames:
rep = [f'{attr} := {getattr(self, attr)!r}' for attr in attrnames]
return f"{self!s}:\n [" + ',\n '.join(rep) + "]"
else:
return str(self)
77 changes: 77 additions & 0 deletions tables/tests/test_tables.py
Expand Up @@ -6905,6 +6905,82 @@ def test_kwargs_obj_description_error_03(self):
description=RecordDescriptionDict)


class TestCreateTableColumnAttrs(common.TempFileMixin, common.PyTablesTestCase):
"""
Testing the attachment of column attributes (metadata) during table layout
creation using an `IsDescription` subclass.
"""
where = '/'
name = 'table'
freq_attrs = {'val': 13.3, 'unit': 'Hz', 'description': 'Ref. freq'}
labels_attrs = {'nbits': 10}

def test_col_attr_01(self):
"""
Tests if the set column attrs set via `IsDescription` subclass are
availalbe in the table.
"""
class TableEntry(tb.IsDescription):
# Adding column attrs at description level
freq = tb.Float32Col(attrs=self.freq_attrs)
labels = tb.StringCol(itemsize=2, attrs=self.labels_attrs)

self.h5file.create_table(self.where, self.name, TableEntry)

self._reopen()

table = self.h5file.get_node(self.where, self.name)
# for k, v in self.freq_attrs.items():
# # self.assertTrue(table.cols.freq.attrs.contains(k))
# self.assertTrue(table.cols.freq.attrs[k] == self.freq_attrs[k])
for k, v in self.labels_attrs.items():
# self.assertTrue(table.cols.labels.attrs.contains(k))
self.assertTrue(table.cols.labels.attrs[k] == self.labels_attrs[k])

def test_col_attr_02(self):
"""
Tests if the `ColumnAttributeSet` works for adding and changing attrs
per column in the existing table.
"""
class TableEntry(tb.IsDescription):
# Not adding attrs
freq = tb.Float32Col()
labels = tb.StringCol(itemsize=2)

table = self.h5file.create_table(self.where, self.name, TableEntry)
for k, v in self.freq_attrs.items():
table.cols.freq.attrs[k] = v
for k, v in self.labels_attrs.items():
table.cols.labels.attrs[k] = v

self._reopen()

table = self.h5file.get_node(self.where, self.name)
for k, v in self.freq_attrs.items():
self.assertTrue(table.cols.freq.attrs.contains(k))
self.assertTrue(table.cols.freq.attrs[k] == self.freq_attrs[k])
for k, v in self.labels_attrs.items():
self.assertTrue(table.cols.labels.attrs.contains(k))
self.assertTrue(table.cols.labels.attrs[k] == self.labels_attrs[k])

def test_col_attr_03(self):
"""
Similar test as *_02 but using the .name access.
"""
class TableEntry(tb.IsDescription):
col1 = tb.Float32Col()

table = self.h5file.create_table(self.where, self.name, TableEntry)
table.cols.col1.attrs.val = 1
table.cols.col1.attrs.unit = 'N'

self._reopen()

table = self.h5file.get_node(self.where, self.name)
self.assertTrue(table.cols.col1.attrs.val == 1)
self.assertTrue(table.cols.col1.attrs.unit == 'N')


def suite():
theSuite = common.unittest.TestSuite()
niter = 1
Expand Down Expand Up @@ -7020,6 +7096,7 @@ def suite():
theSuite.addTest(common.unittest.makeSuite(AccessClosedTestCase))
theSuite.addTest(common.unittest.makeSuite(ColumnIterationTestCase))
theSuite.addTest(common.unittest.makeSuite(TestCreateTableArgs))
theSuite.addTest(common.unittest.makeSuite(TestCreateTableColumnAttrs))

if common.heavy:
theSuite.addTest(
Expand Down

0 comments on commit f8ad2f1

Please sign in to comment.