]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Honor hybrid property / method docstrings
authorMike Bayer <mike_mp@zzzcomputing.com>
Sun, 10 Apr 2016 18:56:01 +0000 (14:56 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Mon, 18 Apr 2016 16:55:48 +0000 (12:55 -0400)
The docstring specified on a hybrid property or method is now honored
at the class level, allowing it to work with tools like Sphinx
autodoc.  The mechanics here necessarily involve some wrapping of
expressions to occur for hybrid properties, which may cause them
to appear differently using introspection.

Fixes: #3653
Change-Id: I02549977fe8b2a051802eed7b00cc532fbc214e3
Pull-request: https://github.com/zzzeek/sqlalchemy/pull/239

doc/build/changelog/changelog_11.rst
doc/build/changelog/migration_11.rst
lib/sqlalchemy/ext/hybrid.py
test/ext/test_hybrid.py
test/orm/test_utils.py

index dc7d5105e3b9f3386c9272feed0e241bdab99021..426b552e260acf365eb400f9bb289a15f93de944 100644 (file)
         Added a new dialect for the PyGreSQL Postgresql dialect.  Thanks
         to Christoph Zwerschke and Kaolin Imago Fire for their efforts.
 
+    .. change::
+        :tags: bug, ext
+        :tickets: 3653
+
+        The docstring specified on a hybrid property or method is now honored
+        at the class level, allowing it to work with tools like Sphinx
+        autodoc.  The mechanics here necessarily involve some wrapping of
+        expressions to occur for hybrid properties, which may cause them
+        to appear differently using introspection.
+
+        .. seealso::
+
+            :ref:`change_3653`
+
     .. change::
         :tags: bug, orm
         :tickets: 3488
index ac6cf1dc71409b0c6e99952cda4487c51b0d2a8d..be1609130a684250e1c22e0a07313657f96ac12d 100644 (file)
@@ -540,6 +540,50 @@ for an attribute being replaced.
 
 :ticket:`3630`
 
+.. _change_3653:
+
+Hybrid properties and methods now propagate the docstring
+---------------------------------------------------------
+
+A hybrid method or property will now reflect the ``__doc__`` value
+present in the original docstring::
+
+    class A(Base):
+        __tablename__ = 'a'
+        id = Column(Integer, primary_key=True)
+
+        name = Column(String)
+
+        @hybrid_property
+        def some_name(self):
+            """The name field"""
+            return self.name
+
+The above value of ``A.some_name.__doc__`` is now honored::
+
+    >>> A.some_name.__doc__
+    The name field
+
+However, to accomplish this, the mechanics of hybrid properties necessarily
+becomes more complex.  Previously, the class-level accessor for a hybrid
+would be a simple pass-thru, that is, this test would succeed::
+
+    >>> assert A.name is A.some_name
+
+With the change, the expression returned by ``A.some_name`` is wrapped inside
+of its own ``QueryableAttribute`` wrapper::
+
+    >>> A.some_name
+    <sqlalchemy.orm.attributes.hybrid_propertyProxy object at 0x7fde03888230>
+
+A lot of testing went into making sure this wrapper works correctly, including
+for elaborate schemes like that of the
+`Custom Value Object <http://techspot.zzzeek.org/2011/10/21/hybrids-and-value-agnostic-types/>`_
+recipe, however we'll be looking to see that no other regressions occur for
+users.
+
+:ticket:`3653`
+
 .. _change_3601:
 
 Session.merge resolves pending conflicts the same as persistent
index bbf386742aee09b83ada1899b1ab92e97323f5ea..18516eae3e1653e2dd612ddadeb38f9d70e5b14a 100644 (file)
@@ -687,7 +687,7 @@ class hybrid_method(interfaces.InspectionAttrInfo):
 
         """
         self.func = func
-        self.expr = expr or func
+        self.expression(expr or func)
 
     def __get__(self, instance, owner):
         if instance is None:
@@ -700,6 +700,8 @@ class hybrid_method(interfaces.InspectionAttrInfo):
         SQL-expression producing method."""
 
         self.expr = expr
+        if not self.expr.__doc__:
+            self.expr.__doc__ = self.func.__doc__
         return self
 
 
@@ -732,7 +734,7 @@ class hybrid_property(interfaces.InspectionAttrInfo):
         self.fget = fget
         self.fset = fset
         self.fdel = fdel
-        self.expr = expr or fget
+        self.expression(expr or fget)
         util.update_wrapper(self, fget)
 
     def __get__(self, instance, owner):
@@ -768,8 +770,12 @@ class hybrid_property(interfaces.InspectionAttrInfo):
         """Provide a modifying decorator that defines a SQL-expression
         producing method."""
 
-        self.expr = expr
-        return self
+        def _expr(cls):
+            return ExprComparator(expr(cls))
+        util.update_wrapper(_expr, expr)
+
+        self.expr = _expr
+        return self.comparator(_expr)
 
     def comparator(self, comparator):
         """Provide a modifying decorator that defines a custom
@@ -784,7 +790,9 @@ class hybrid_property(interfaces.InspectionAttrInfo):
             create_proxied_attribute(self)
 
         def expr(owner):
-            return proxy_attr(owner, self.__name__, self, comparator(owner))
+            return proxy_attr(
+                owner, self.__name__, self, comparator(owner),
+                doc=comparator.__doc__ or self.__doc__)
         self.expr = expr
         return self
 
@@ -808,3 +816,15 @@ class Comparator(interfaces.PropComparator):
     def adapt_to_entity(self, adapt_to_entity):
         # interesting....
         return self
+
+
+class ExprComparator(Comparator):
+
+    def __getattr__(self, key):
+        return getattr(self.expression, key)
+
+    def operate(self, op, *other, **kwargs):
+        return op(self.expression, *other, **kwargs)
+
+    def reverse_operate(self, op, other, **kwargs):
+        return op(other, self.expression, **kwargs)
index e36b8f7e9e3c244bd66f99fb4f1c1cd864ab9357..dac65dbab8dba9fd21ea6ac82f118746ddfb4be5 100644 (file)
@@ -1,11 +1,13 @@
-from sqlalchemy import func, Integer, String, ForeignKey
+from sqlalchemy import func, Integer, Numeric, String, ForeignKey
 from sqlalchemy.orm import relationship, Session, aliased
 from sqlalchemy.testing.schema import Column
 from sqlalchemy.ext.declarative import declarative_base
 from sqlalchemy.ext import hybrid
-from sqlalchemy.testing import eq_, AssertsCompiledSQL, assert_raises_message
+from sqlalchemy.testing import eq_, is_, AssertsCompiledSQL, \
+    assert_raises_message
 from sqlalchemy.testing import fixtures
 from sqlalchemy import inspect
+from decimal import Decimal
 
 
 class PropertyComparatorTest(fixtures.TestBase, AssertsCompiledSQL):
@@ -30,6 +32,7 @@ class PropertyComparatorTest(fixtures.TestBase, AssertsCompiledSQL):
 
             @hybrid.hybrid_property
             def value(self):
+                "This is a docstring"
                 return self._value - 5
 
             @value.comparator
@@ -81,8 +84,14 @@ class PropertyComparatorTest(fixtures.TestBase, AssertsCompiledSQL):
             "FROM a AS a_1 WHERE upper(a_1.value) = upper(:upper_1)"
         )
 
+    def test_docstring(self):
+        A = self._fixture()
+        eq_(A.value.__doc__, "This is a docstring")
+
+
 class PropertyExpressionTest(fixtures.TestBase, AssertsCompiledSQL):
     __dialect__ = 'default'
+
     def _fixture(self):
         Base = declarative_base()
 
@@ -93,10 +102,12 @@ class PropertyExpressionTest(fixtures.TestBase, AssertsCompiledSQL):
 
             @hybrid.hybrid_property
             def value(self):
+                "This is an instance-level docstring"
                 return int(self._value) - 5
 
             @value.expression
             def value(cls):
+                "This is a class-level docstring"
                 return func.foo(cls._value) + cls.bar_value
 
             @value.setter
@@ -159,7 +170,7 @@ class PropertyExpressionTest(fixtures.TestBase, AssertsCompiledSQL):
     def test_expression(self):
         A = self._fixture()
         self.assert_compile(
-            A.value,
+            A.value.__clause_element__(),
             "foo(a.value) + bar(a.value)"
         )
 
@@ -177,7 +188,7 @@ class PropertyExpressionTest(fixtures.TestBase, AssertsCompiledSQL):
     def test_aliased_expression(self):
         A = self._fixture()
         self.assert_compile(
-            aliased(A).value,
+            aliased(A).value.__clause_element__(),
             "foo(a_1.value) + bar(a_1.value)"
         )
 
@@ -199,8 +210,18 @@ class PropertyExpressionTest(fixtures.TestBase, AssertsCompiledSQL):
             "FROM a AS a_1 WHERE foo(a_1.value) + bar(a_1.value) = :param_1"
         )
 
+    def test_docstring(self):
+        A = self._fixture()
+        eq_(A.value.__doc__, "This is a class-level docstring")
+
+        # no docstring here since we get a literal
+        a1 = A(_value=10)
+        eq_(a1.value, 5)
+
+
 class PropertyValueTest(fixtures.TestBase, AssertsCompiledSQL):
     __dialect__ = 'default'
+
     def _fixture(self, assignable):
         Base = declarative_base()
 
@@ -238,15 +259,16 @@ class PropertyValueTest(fixtures.TestBase, AssertsCompiledSQL):
             delattr, a1, 'value'
         )
 
-
     def test_set_get(self):
         A = self._fixture(True)
         a1 = A(value=5)
         eq_(a1.value, 5)
         eq_(a1._value, 10)
 
+
 class MethodExpressionTest(fixtures.TestBase, AssertsCompiledSQL):
     __dialect__ = 'default'
+
     def _fixture(self):
         Base = declarative_base()
 
@@ -257,10 +279,21 @@ class MethodExpressionTest(fixtures.TestBase, AssertsCompiledSQL):
 
             @hybrid.hybrid_method
             def value(self, x):
+                "This is an instance-level docstring"
                 return int(self._value) + x
 
             @value.expression
             def value(cls, value):
+                "This is a class-level docstring"
+                return func.foo(cls._value, value) + value
+
+            @hybrid.hybrid_method
+            def other_value(self, x):
+                "This is an instance-level docstring"
+                return int(self._value) + x
+
+            @other_value.expression
+            def other_value(cls, value):
                 return func.foo(cls._value, value) + value
 
         return A
@@ -285,7 +318,6 @@ class MethodExpressionTest(fixtures.TestBase, AssertsCompiledSQL):
             {"some key": "some value"}
         )
 
-
     def test_aliased_expression(self):
         A = self._fixture()
         self.assert_compile(
@@ -327,3 +359,234 @@ class MethodExpressionTest(fixtures.TestBase, AssertsCompiledSQL):
             sess.query(aliased(A).value(5)),
             "SELECT foo(a_1.value, :foo_1) + :foo_2 AS anon_1 FROM a AS a_1"
         )
+
+    def test_docstring(self):
+        A = self._fixture()
+        eq_(A.value.__doc__, "This is a class-level docstring")
+        eq_(A.other_value.__doc__, "This is an instance-level docstring")
+        a1 = A(_value=10)
+
+        # a1.value is still a method, so it has a
+        # docstring
+        eq_(a1.value.__doc__, "This is an instance-level docstring")
+
+        eq_(a1.other_value.__doc__, "This is an instance-level docstring")
+
+
+class SpecialObjectTest(fixtures.TestBase, AssertsCompiledSQL):
+    """tests against hybrids that return a non-ClauseElement.
+
+    use cases derived from the example at
+    http://techspot.zzzeek.org/2011/10/21/hybrids-and-value-agnostic-types/
+
+    """
+    __dialect__ = 'default'
+
+    @classmethod
+    def setup_class(cls):
+        from sqlalchemy import literal
+
+        symbols = ('usd', 'gbp', 'cad', 'eur', 'aud')
+        currency_lookup = dict(
+            ((currency_from, currency_to), Decimal(str(rate)))
+            for currency_to, values in zip(
+                symbols,
+                [
+                    (1, 1.59009, 0.988611, 1.37979, 1.02962),
+                    (0.628895, 1, 0.621732, 0.867748, 0.647525),
+                    (1.01152, 1.6084, 1, 1.39569, 1.04148),
+                    (0.724743, 1.1524, 0.716489, 1, 0.746213),
+                    (0.971228, 1.54434, 0.960166, 1.34009, 1),
+                ])
+            for currency_from, rate in zip(symbols, values)
+        )
+
+        class Amount(object):
+            def __init__(self, amount, currency):
+                self.currency = currency
+                self.amount = amount
+
+            def __add__(self, other):
+                return Amount(
+                    self.amount +
+                    other.as_currency(self.currency).amount,
+                    self.currency
+                )
+
+            def __sub__(self, other):
+                return Amount(
+                    self.amount -
+                    other.as_currency(self.currency).amount,
+                    self.currency
+                )
+
+            def __lt__(self, other):
+                return self.amount < other.as_currency(self.currency).amount
+
+            def __gt__(self, other):
+                return self.amount > other.as_currency(self.currency).amount
+
+            def __eq__(self, other):
+                return self.amount == other.as_currency(self.currency).amount
+
+            def as_currency(self, other_currency):
+                return Amount(
+                    currency_lookup[(self.currency, other_currency)] *
+                    self.amount,
+                    other_currency
+                )
+
+            def __clause_element__(self):
+                # helper method for SQLAlchemy to interpret
+                # the Amount object as a SQL element
+                if isinstance(self.amount, (float, int, Decimal)):
+                    return literal(self.amount)
+                else:
+                    return self.amount
+
+            def __str__(self):
+                return "%2.4f %s" % (self.amount, self.currency)
+
+            def __repr__(self):
+                return "Amount(%r, %r)" % (self.amount, self.currency)
+
+        Base = declarative_base()
+
+        class BankAccount(Base):
+            __tablename__ = 'bank_account'
+            id = Column(Integer, primary_key=True)
+
+            _balance = Column('balance', Numeric)
+
+            @hybrid.hybrid_property
+            def balance(self):
+                """Return an Amount view of the current balance."""
+                return Amount(self._balance, "usd")
+
+            @balance.setter
+            def balance(self, value):
+                self._balance = value.as_currency("usd").amount
+
+        cls.Amount = Amount
+        cls.BankAccount = BankAccount
+
+    def test_instance_one(self):
+        BankAccount, Amount = self.BankAccount, self.Amount
+        account = BankAccount(balance=Amount(4000, "usd"))
+
+        # 3b. print balance in usd
+        eq_(account.balance.amount, 4000)
+
+    def test_instance_two(self):
+        BankAccount, Amount = self.BankAccount, self.Amount
+        account = BankAccount(balance=Amount(4000, "usd"))
+
+        # 3c. print balance in gbp
+        eq_(account.balance.as_currency("gbp").amount, Decimal('2515.58'))
+
+    def test_instance_three(self):
+        BankAccount, Amount = self.BankAccount, self.Amount
+        account = BankAccount(balance=Amount(4000, "usd"))
+
+        # 3d. perform currency-agnostic comparisons, math
+        is_(account.balance > Amount(500, "cad"), True)
+
+    def test_instance_four(self):
+        BankAccount, Amount = self.BankAccount, self.Amount
+        account = BankAccount(balance=Amount(4000, "usd"))
+        eq_(
+            account.balance + Amount(500, "cad") - Amount(50, "eur"),
+            Amount(Decimal("4425.316"), "usd")
+        )
+
+    def test_query_one(self):
+        BankAccount, Amount = self.BankAccount, self.Amount
+        session = Session()
+
+        query = session.query(BankAccount).\
+            filter(BankAccount.balance == Amount(10000, "cad"))
+
+        self.assert_compile(
+            query,
+            "SELECT bank_account.balance AS bank_account_balance, "
+            "bank_account.id AS bank_account_id FROM bank_account "
+            "WHERE bank_account.balance = :balance_1",
+            checkparams={'balance_1': Decimal('9886.110000')}
+        )
+
+    def test_query_two(self):
+        BankAccount, Amount = self.BankAccount, self.Amount
+        session = Session()
+
+        # alternatively we can do the calc on the DB side.
+        query = session.query(BankAccount).\
+            filter(
+                BankAccount.balance.as_currency("cad") > Amount(9999, "cad")).\
+            filter(
+                BankAccount.balance.as_currency("cad") < Amount(10001, "cad"))
+        self.assert_compile(
+            query,
+            "SELECT bank_account.balance AS bank_account_balance, "
+            "bank_account.id AS bank_account_id "
+            "FROM bank_account "
+            "WHERE :balance_1 * bank_account.balance > :param_1 "
+            "AND :balance_2 * bank_account.balance < :param_2",
+            checkparams={
+                'balance_1': Decimal('1.01152'),
+                'balance_2': Decimal('1.01152'),
+                'param_1': Decimal('9999'),
+                'param_2': Decimal('10001')}
+        )
+
+    def test_query_three(self):
+        BankAccount = self.BankAccount
+        session = Session()
+
+        query = session.query(BankAccount).\
+            filter(
+                BankAccount.balance.as_currency("cad") >
+                BankAccount.balance.as_currency("eur"))
+        self.assert_compile(
+            query,
+            "SELECT bank_account.balance AS bank_account_balance, "
+            "bank_account.id AS bank_account_id FROM bank_account "
+            "WHERE :balance_1 * bank_account.balance > "
+            ":param_1 * :balance_2 * bank_account.balance",
+            checkparams={
+                'balance_1': Decimal('1.01152'),
+                'balance_2': Decimal('0.724743'),
+                'param_1': Decimal('1.39569')}
+        )
+
+    def test_query_four(self):
+        BankAccount = self.BankAccount
+        session = Session()
+
+        # 4c. query all amounts, converting to "CAD" on the DB side
+        query = session.query(BankAccount.balance.as_currency("cad").amount)
+        self.assert_compile(
+            query,
+            "SELECT :balance_1 * bank_account.balance AS anon_1 "
+            "FROM bank_account",
+            checkparams={'balance_1': Decimal('1.01152')}
+        )
+
+    def test_query_five(self):
+        BankAccount = self.BankAccount
+        session = Session()
+
+        # 4d. average balance in EUR
+        query = session.query(func.avg(BankAccount.balance.as_currency("eur")))
+        self.assert_compile(
+            query,
+            "SELECT avg(:balance_1 * bank_account.balance) AS avg_1 "
+            "FROM bank_account",
+            checkparams={'balance_1': Decimal('0.724743')}
+        )
+
+    def test_docstring(self):
+        BankAccount = self.BankAccount
+        eq_(
+            BankAccount.balance.__doc__,
+            "Return an Amount view of the current balance.")
+
index 168cee19ca7b0a1f7a46287192e755472723c1f4..3c783c03df088e4b92bb62a4e700b5feb898a4ef 100644 (file)
@@ -159,8 +159,10 @@ class AliasedClassTest(fixtures.TestBase, AssertsCompiledSQL):
         self._fixture(Point)
         alias = aliased(Point)
 
-        eq_(str(Point.double_x), "point.x * :x_1")
-        eq_(str(alias.double_x), "point_1.x * :x_1")
+        eq_(str(Point.double_x), "Point.double_x")
+        eq_(str(alias.double_x), "AliasedClass_Point.double_x")
+        eq_(str(Point.double_x.__clause_element__()), "point.x * :x_1")
+        eq_(str(alias.double_x.__clause_element__()), "point_1.x * :x_1")
 
         sess = Session()
 
@@ -183,10 +185,22 @@ class AliasedClassTest(fixtures.TestBase, AssertsCompiledSQL):
         self._fixture(Point)
         alias = aliased(Point)
 
-        eq_(str(Point.x_alone), "Point.x")
-        eq_(str(alias.x_alone), "AliasedClass_Point.x")
+        eq_(str(Point.x_alone), "Point.x_alone")
+        eq_(str(alias.x_alone), "AliasedClass_Point.x_alone")
 
-        assert Point.x_alone is Point.x
+        # from __clause_element__() perspective, Point.x_alone
+        # and Point.x return the same thing, so that's good
+        eq_(str(Point.x.__clause_element__()), "point.x")
+        eq_(str(Point.x_alone.__clause_element__()), "point.x")
+
+        # same for the alias
+        eq_(str(alias.x + 1), "point_1.x + :x_1")
+        eq_(str(alias.x_alone + 1), "point_1.x + :x_1")
+
+        is_(
+            Point.x_alone.__clause_element__(),
+            Point.x.__clause_element__()
+        )
 
         eq_(str(alias.x_alone == alias.x), "point_1.x = point_1.x")