Skip to content
This repository has been archived by the owner on Jun 17, 2023. It is now read-only.

Commit

Permalink
Rebase against master once more
Browse files Browse the repository at this point in the history
  • Loading branch information
hynek committed Oct 26, 2014
1 parent 53df083 commit 7632a18
Show file tree
Hide file tree
Showing 4 changed files with 163 additions and 89 deletions.
14 changes: 13 additions & 1 deletion benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
from characteristic import attributes


class Artisanal(object):
def __init__(self, a, b, c):
self.a = a
self.b = b
self.c = c


@attributes(["a", "b", "c"])
class NoDefaults(object):
pass
Expand All @@ -17,6 +24,10 @@ class Defaults(object):
pass


def bench_artisanal():
Artisanal(a=1, b=2, c=3)


def bench_no_defaults():
NoDefaults(a=1, b=2, c=3)

Expand All @@ -33,7 +44,8 @@ def bench_both():
if __name__ == "__main__":
import timeit

for func in ["bench_no_defaults", "bench_defaults", "bench_both"]:
for func in ["bench_no_defaults", "bench_defaults", "bench_both",
"bench_artisanal"]:
print(
func + ": ",
timeit.timeit(func + "()",
Expand Down
143 changes: 92 additions & 51 deletions characteristic.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from __future__ import absolute_import, division, print_function

import hashlib
import linecache
import sys
import warnings

Expand All @@ -24,6 +26,14 @@
"with_repr",
]

# I'm sorry. :(
if sys.version_info[0] == 2:
def exec_(code, locals_, globals_):
exec("exec code in locals_, globals_")
else: # pragma: no cover
def exec_(code, locals_, globals_):
exec(code, locals_, globals_)


class _Nothing(object):
"""
Expand Down Expand Up @@ -121,7 +131,7 @@ class Attribute(object):
__slots__ = [
"name", "exclude_from_cmp", "exclude_from_init", "exclude_from_repr",
"exclude_from_immutable", "default_value", "default_factory",
"instance_of", "init_aliaser", "_default", "_kw_name",
"instance_of", "init_aliaser", "_kw_name",
]

def __init__(self,
Expand Down Expand Up @@ -151,11 +161,6 @@ def __init__(self,

self.default_value = default_value
self.default_factory = default_factory
if default_value is not NOTHING:
self._default = default_value
elif default_factory is None:
self._default = NOTHING

self.instance_of = instance_of

self.init_aliaser = init_aliaser
Expand All @@ -181,15 +186,6 @@ def __eq__(self, other):
def __ne__(self, other):
return not self == other

def __getattr__(self, name):
"""
If no value has been set to _default, we need to call a factory.
"""
if name == "_default" and self.default_factory:
return self.default_factory()
else:
raise AttributeError

def __repr__(self):
return (
"<Attribute(name={name!r}, exclude_from_cmp={exclude_from_cmp!r}, "
Expand Down Expand Up @@ -400,49 +396,38 @@ def with_init(attrs, **kw):
:param defaults: Default values if attributes are omitted on instantiation.
:type defaults: ``dict`` or ``None``
"""
def characteristic_init(self, *args, **kw):
"""
Attribute initializer automatically created by characteristic.
attrs = [attr
for attr in _ensure_attributes(attrs,
defaults=kw.get("defaults",
NOTHING))
if attr.exclude_from_init is False]

The original `__init__` method is renamed to `__original_init__` and
is called at the end with the initialized attributes removed from the
keyword arguments.
"""
for a in attrs:
v = kw.pop(a._kw_name, NOTHING)
if v is NOTHING:
# Since ``a._default`` could be a property that calls
# a factory, we make this a separate step.
v = a._default
if v is NOTHING:
raise ValueError(
"Missing keyword value for '{0}'.".format(a._kw_name)
)
if (
a.instance_of is not None
and not isinstance(v, a.instance_of)
):
raise TypeError(
"Attribute '{0}' must be an instance of '{1}'."
.format(a.name, a.instance_of.__name__)
)
self.__characteristic_setattr__(a.name, v)
self.__original_init__(*args, **kw)
# We cache the generated init methods for the same kinds of attributes.
sha1 = hashlib.sha1()
sha1.update(repr(attrs).encode("utf-8"))
unique_filename = "<characteristic generated init {0}>".format(
sha1.hexdigest()
)

script = _attrs_to_script(attrs)
locs = {}
bytecode = compile(script, unique_filename, "exec")
exec_(bytecode, {"NOTHING": NOTHING, "attrs": attrs}, locs)
init = locs["characteristic_init"]

def wrap(cl):
cl.__original_init__ = cl.__init__
cl.__init__ = characteristic_init
# Sidestep immutability sentry completely if possible..
cl.__characteristic_setattr__ = getattr(
cl, "__original_setattr__", cl.__setattr__
# In order of debuggers like PDB being able to step through the code,
# we add a fake linecache entry.
linecache.cache[unique_filename] = (
len(script),
None,
script.splitlines(True),
unique_filename
)
cl.__init__ = init
return cl

attrs = [attr
for attr in _ensure_attributes(attrs,
defaults=kw.get("defaults",
NOTHING))
if attr.exclude_from_init is False]
return wrap


Expand Down Expand Up @@ -589,3 +574,59 @@ def wrap(cl):
cl = with_init(attrs)(cl)
return cl
return wrap


def _attrs_to_script(attrs):
"""
Return a valid Python script of a initializer for `attrs`.
"""
lines = []
for i, a in enumerate(attrs):
# attrs is passed into the the exec later to enable default_value
# and default_factory. To find it, enumerate and 'i' are used.
lines.append(
"self.{a.name} = kw.pop('{a._kw_name}', {default})"
.format(
a=a,
# Save a lookup for the common case of no default value.
default="attrs[{i}].default_value".format(i=i)
if a.default_value is not NOTHING else "NOTHING"
)
)
if a.default_value is NOTHING:
# Can't use pop + big try/except because Python 2.6:
# http://bugs.python.org/issue10221
lines.append("if self.{a.name} is NOTHING:".format(a=a))
if a.default_factory is None:
lines.append(
" raise ValueError(\"Missing keyword value for "
"'{a._kw_name}'.\")".format(a=a),
)
else:
lines.append(
" self.{a.name} = attrs[{i}].default_factory()"
.format(a=a, i=i)
)
if a.instance_of:
lines.append(
"if not isinstance(self.{a.name}, attrs[{i}].instance_of):\n"
.format(a=a, i=i)
)
lines.append(
" raise TypeError(\"Attribute '{a.name}' must be an"
" instance of '{type_name}'.\")"
.format(a=a, type_name=a.instance_of.__name__)
)

return """\
def characteristic_init(self, *args, **kw):
'''
Attribute initializer automatically created by characteristic.
The original `__init__` method is renamed to `__original_init__` and
is called at the end with the initialized attributes removed from the
keyword arguments.
'''
{setters}
self.__original_init__(*args, **kw)
""".format(setters="\n ".join(lines))
2 changes: 2 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ Changes:

- Attributes set by :func:`characteristic.attributes` are now stored on the class as well.
[`20 <https://github.com/hynek/characteristic/pull/20>`_]
- ``__init__`` methods that are created by :func:`characteristic.with_init` are now generated on the fly and optimized for each class.
[`9 <https://github.com/hynek/characteristic/pull/9>`_]


----
Expand Down
93 changes: 56 additions & 37 deletions test_characteristic.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import absolute_import, division, print_function

import linecache
import sys
import warnings

Expand Down Expand Up @@ -28,22 +29,23 @@ def test_init_simple(self):
"""
a = Attribute("foo")
assert "foo" == a.name
assert NOTHING is a._default
assert NOTHING is a.default_value

def test_init_default_factory(self):
"""
Instantiating with default_factory creates a proper descriptor for
_default.
"""
a = Attribute("foo", default_factory=list)
assert list() == a._default
assert NOTHING is a.default_value
assert list() == a.default_factory()

def test_init_default_value(self):
"""
Instantiating with default_value initializes default properly.
"""
a = Attribute("foo", default_value="bar")
assert "bar" == a._default
assert "bar" == a.default_value

def test_ambiguous_defaults(self):
"""
Expand Down Expand Up @@ -439,29 +441,6 @@ class C(object):
o2 = C()
assert o1.a is not o2.a

def test_optimizes(self):
"""
Uses __original_setattr__ if possible.
"""
@immutable(["a"])
@with_init(["a"])
class C(object):
pass

c = C(a=42)
assert c.__original_setattr__ == c.__characteristic_setattr__

def test_setattr(self):
"""
Uses setattr by default.
"""
@with_init(["a"])
class C(object):
pass

c = C(a=42)
assert c.__setattr__ == c.__characteristic_setattr__

def test_underscores(self):
"""
with_init takes keyword aliasing into account.
Expand Down Expand Up @@ -532,6 +511,57 @@ class C(object):
) == w[0].message.args[0]
assert issubclass(w[0].category, DeprecationWarning)

def test_linecache(self):
"""
The created init method is added to the linecache so PDB shows it
properly.
"""
attrs = [Attribute("a")]

@with_init(attrs)
class C(object):
pass

assert tuple == type(linecache.cache[C.__init__.__code__.co_filename])

def test_linecache_attrs_unique(self):
"""
If the attributes are the same, only one linecache entry is created.
Since the key within the cache is the filename, this effectively means
that the filenames must be equal if the attributes are equal.
"""
attrs = [Attribute("a")]

@with_init(attrs[:])
class C1(object):
pass

@with_init(attrs[:])
class C2(object):
pass

assert (
C1.__init__.__code__.co_filename
== C2.__init__.__code__.co_filename
)

def test_linecache_different_attrs(self):
"""
Different Attributes have different generated filenames.
"""
@with_init([Attribute("a")])
class C1(object):
pass

@with_init([Attribute("b")])
class C2(object):
pass

assert (
C1.__init__.__code__.co_filename
!= C2.__init__.__code__.co_filename
)


class TestAttributes(object):
def test_leaves_init_alone(self):
Expand Down Expand Up @@ -641,17 +671,6 @@ class C(object):

assert C.characteristic_attributes == attrs

def test_optimizes(self):
"""
Uses correct order such that with_init can us __original_setattr__.
"""
@attributes(["a"], apply_immutable=True)
class C(object):
__slots__ = ["a"]

c = C(a=42)
assert c.__original_setattr__ == c.__characteristic_setattr__

def test_private(self):
"""
Integration test for name mangling/aliasing.
Expand Down

0 comments on commit 7632a18

Please sign in to comment.