Skip to content

Commit

Permalink
Support for setters, deleters and logging
Browse files Browse the repository at this point in the history
  • Loading branch information
xolox committed Jun 1, 2016
1 parent 093f06c commit bfbc105
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 32 deletions.
6 changes: 6 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,12 @@ to the computed value:
>>> print(instance.environment_based)
some-default-value

Support for setters and deleters
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

All of the custom property classes support setters and deleters just like
Python's ``property`` decorator does.

The `PropertyManager` class
~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
118 changes: 92 additions & 26 deletions property_manager/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@

# External dependencies.
from humanfriendly import coerce_boolean, compact, concatenate, format, pluralize
from verboselogs import VerboseLogger

try:
# Check if `basestring' is defined (Python 2).
Expand All @@ -86,7 +87,7 @@
# Alias basestring to str in Python 3.
basestring = str

__version__ = '1.5'
__version__ = '1.6'
"""Semi-standard module versioning."""

SPHINX_ACTIVE = 'sphinx' in sys.modules
Expand Down Expand Up @@ -160,6 +161,9 @@
:func:`delattr()`.
""")

# Initialize a logger for this module.
logger = VerboseLogger(__name__)


def set_property(obj, name, value):
"""
Expand All @@ -174,6 +178,7 @@ def set_property(obj, name, value):
is intentional: :func:`set_property()` is meant to be used by extensions of
the `property-manager` project and by user defined setter methods.
"""
logger.spam("Setting value of %s property to %r ..", format_property(obj, name), value)
obj.__dict__[name] = value


Expand All @@ -189,9 +194,21 @@ def clear_property(obj, name):
is intentional: :func:`clear_property()` is meant to be used by extensions
of the `property-manager` project and by user defined deleter methods.
"""
logger.spam("Clearing value of %s property ..", format_property(obj, name))
obj.__dict__.pop(name, None)


def format_property(obj, name):
"""
Format an object property's dotted name.
:param obj: The object that owns the property.
:param name: The name of the property (a string).
:returns: The dotted path (a string).
"""
return "%s.%s" % (obj.__class__.__name__, name)


class PropertyManager(object):

"""
Expand Down Expand Up @@ -509,32 +526,52 @@ def customized_test_property(self):
# Positional arguments construct instances.
return super(custom_property, cls).__new__(cls, *args)

def __init__(self, func):
def __init__(self, *args, **kw):
"""
Initialize a :class:`custom_property` object.
:param func: The function that's called to compute the property's
value. The :class:`custom_property` instance inherits the
values of :attr:`~object.__doc__`, :attr:`~object.__module__`
and :attr:`~object.__name__` from the function.
:raises: :exc:`~exceptions.ValueError` when the first positional
argument is not callable (e.g. a function).
:param args: Any positional arguments are passed on to the initializer
of the :class:`property` class.
:param kw: Any keyword arguments are passed on to the initializer of
the :class:`property` class.
Automatically calls :func:`inject_usage_notes()` during initialization
(only if :data:`USAGE_NOTES_ENABLED` is :data:`True`).
"""
if not callable(func):
msg = "Expected to decorate callable, got %r instead!"
raise ValueError(msg % type(func).__name__)
else:
super(custom_property, self).__init__(func)
self.__doc__ = func.__doc__
self.__module__ = func.__module__
self.__name__ = func.__name__
self.func = func
# It's not documented so I went to try it out and apparently the
# property class initializer performs absolutely no argument
# validation. The first argument doesn't have to be a callable,
# in fact none of the arguments are even mandatory?! :-P
super(custom_property, self).__init__(*args, **kw)
# Explicit is better than implicit so I'll just go ahead and check
# whether the value(s) given by the user make sense :-).
self.ensure_callable('fget')
# We only check the 'fset' and 'fdel' values when they are not None
# because both of these arguments are supposed to be optional :-).
for name in 'fset', 'fdel':
if getattr(self, name) is not None:
self.ensure_callable(name)
# Copy some important magic members from the decorated method.
for name in '__doc__', '__module__', '__name__':
value = getattr(self.fget, name, None)
if value is not None:
setattr(self, name, value)
# Inject usage notes when running under Sphinx.
if USAGE_NOTES_ENABLED:
self.inject_usage_notes()

def ensure_callable(self, role):
"""
Ensure that a decorated value is in fact callable.
:param role: The value's role (one of 'fget', 'fset' or 'fdel').
:raises: :exc:`exceptions.ValueError` when the value isn't callable.
"""
value = getattr(self, role)
if not callable(value):
msg = "Invalid '%s' value! (expected callable, got %r instead)"
raise ValueError(msg % (role, value))

def inject_usage_notes(self):
"""
Inject the property's semantics into its documentation.
Expand Down Expand Up @@ -593,23 +630,29 @@ def __get__(self, obj, type=None):
# Called to get the attribute of the class.
return self
else:
# Calculate the property's dotted name only once.
dotted_name = format_property(obj, self.__name__)
# Called to get the attribute of an instance.
if self.writable or self.cached:
# Check if a value has been assigned or cached.
value = obj.__dict__.get(self.__name__, NOTHING)
if value is not NOTHING:
logger.spam("%s reporting assigned or cached value (%r) ..", dotted_name, value)
return value
# Check if the property has an environment variable. We do this
# after checking for an assigned value so that the `writable' and
# `environment_variable' options can be used together.
if self.environment_variable:
value = os.environ.get(self.environment_variable, NOTHING)
if value is not NOTHING:
logger.spam("%s reporting value from environment variable (%r) ..", dotted_name, value)
return value
# Compute the property's value.
value = self.func(obj)
value = super(custom_property, self).__get__(obj, type)
logger.spam("%s reporting computed value (%r) ..", dotted_name, value)
if self.cached:
# Cache the computed value.
logger.spam("%s caching computed value ..", dotted_name)
set_property(obj, self.__name__, value)
return value

Expand All @@ -622,10 +665,22 @@ def __set__(self, obj, value):
:raises: :exc:`~exceptions.AttributeError` if :attr:`writable` is
:data:`False`.
"""
if not self.writable:
msg = "%r object attribute %r is read-only"
raise AttributeError(msg % (obj.__class__.__name__, self.__name__))
set_property(obj, self.__name__, value)
# Calculate the property's dotted name only once.
dotted_name = format_property(obj, self.__name__)
# Evaluate the property's setter (if any).
try:
logger.spam("%s calling setter with value %r ..", dotted_name, value)
super(custom_property, self).__set__(obj, value)
except AttributeError:
logger.spam("%s setter raised attribute error, falling back.", dotted_name)
if self.writable:
# Override the computed value.
logger.spam("%s overriding computed value to %r ..", dotted_name, value)
set_property(obj, self.__name__, value)
else:
# Refuse to override the computed value.
msg = "%r object attribute %r is read-only"
raise AttributeError(msg % (obj.__class__.__name__, self.__name__))

def __delete__(self, obj):
"""
Expand All @@ -638,10 +693,21 @@ def __delete__(self, obj):
Once the property has been deleted the next read will evaluate the
decorated function to compute the value.
"""
if not self.resettable:
msg = "%r object attribute %r is read-only"
raise AttributeError(msg % (obj.__class__.__name__, self.__name__))
clear_property(obj, self.__name__)
# Calculate the property's dotted name only once.
dotted_name = format_property(obj, self.__name__)
# Evaluate the property's deleter (if any).
try:
logger.spam("%s calling deleter ..", dotted_name)
super(custom_property, self).__delete__(obj)
except AttributeError:
logger.spam("%s deleter raised attribute error, falling back.", dotted_name)
if self.resettable:
# Reset the computed or overridden value.
logger.spam("%s clearing assigned or computed value ..", dotted_name)
clear_property(obj, self.__name__)
else:
msg = "%r object attribute %r is read-only"
raise AttributeError(msg % (obj.__class__.__name__, self.__name__))


class writable_property(custom_property):
Expand Down
71 changes: 68 additions & 3 deletions property_manager/tests.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
# Tests of custom properties for Python programming.
#
# Author: Peter Odding <peter@peterodding.com>
# Last Change: May 31, 2016
# Last Change: June 1, 2016
# URL: https://property-manager.readthedocs.org

"""Automated tests for the :mod:`property_manager` module."""

# Standard library modules.
import logging
import os
import random
import sys
import unittest

# External dependencies.
import coloredlogs
from humanfriendly import format
from verboselogs import VerboseLogger

# Modules included in our package.
import property_manager
from property_manager import (
CACHED_PROPERTY_NOTE,
CUSTOM_PROPERTY_NOTE,
Expand All @@ -33,15 +38,22 @@
writable_property,
)

# Initialize a logger for this module.
logger = VerboseLogger(__name__)


class PropertyManagerTestCase(unittest.TestCase):

"""Container for the :mod:`property_manager` test suite."""

def setUp(self):
"""Automatically set USAGE_NOTES_ENABLED to True."""
import property_manager
"""Enable verbose logging and usage notes."""
property_manager.USAGE_NOTES_ENABLED = True
coloredlogs.install(level=logging.NOTSET)
# Separate the name of the test method (printed by the superclass
# and/or py.test without a newline at the end) from the first line of
# logging output that the test method is likely going to generate.
sys.stderr.write("\n")

def test_builtin_property(self):
"""
Expand Down Expand Up @@ -210,6 +222,59 @@ def customized_test_property(self):
assert p.is_cached
assert p.is_writable

def test_setters(self):
"""Test that custom properties support setters."""
class SetterTest(object):

@custom_property
def setter_test_property(self):
return getattr(self, 'whatever_you_want_goes_here', 42)

@setter_test_property.setter
def setter_test_property(self, value):
if value < 0:
raise ValueError
self.whatever_you_want_goes_here = value

with PropertyInspector(SetterTest, 'setter_test_property') as p:
# This is basically just testing the lazy property.
assert p.is_recognizable
assert p.value == 42
# Test that the setter is being called by verifying
# that it raises a value error on invalid arguments.
self.assertRaises(ValueError, setattr, p, 'value', -5)
# Test that valid values are actually set.
p.value = 13
assert p.value == 13

def test_deleters(self):
"""Test that custom properties support deleters."""
class DeleterTest(object):

@custom_property
def deleter_test_property(self):
return getattr(self, 'whatever_you_want_goes_here', 42)

@deleter_test_property.setter
def deleter_test_property(self, value):
self.whatever_you_want_goes_here = value

@deleter_test_property.deleter
def deleter_test_property(self):
delattr(self, 'whatever_you_want_goes_here')

with PropertyInspector(DeleterTest, 'deleter_test_property') as p:
# This is basically just testing the custom property.
assert p.is_recognizable
assert p.value == 42
# Make sure we can set a new value.
p.value = 13
assert p.value == 13
# Make sure we can delete the value.
p.delete()
# Here we expect the computed value.
assert p.value == 42

def test_cache_invalidation(self):
"""Test that :func:`.PropertyManager.clear_cached_properties()` correctly clears cached property values."""
class CacheInvalidationTest(PropertyManager):
Expand Down
2 changes: 1 addition & 1 deletion pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@
# how pytest test discovery works).

[pytest]
addopts = --capture=no --exitfirst
addopts = --verbose
python_files = property_manager/tests.py
8 changes: 6 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"""Setup script for the `property-manager` package."""

# Author: Peter Odding <peter@peterodding.com>
# Last Change: May 31, 2016
# Last Change: June 1, 2016
# URL: https://property-manager.readthedocs.org

# Standard library modules.
Expand Down Expand Up @@ -43,7 +43,11 @@
author_email='peter@peterodding.com',
packages=find_packages(),
install_requires=[
'humanfriendly >= 1.44.7'
'humanfriendly >= 1.44.7',
'verboselogs >= 1.1',
],
tests_require=[
'coloredlogs >= 5.0',
],
test_suite='property_manager.tests',
classifiers=[
Expand Down

0 comments on commit bfbc105

Please sign in to comment.