diff --git a/doc/build/changelog/unreleased_13/4767.rst b/doc/build/changelog/unreleased_13/4767.rst new file mode 100644 index 0000000000..04608ade97 --- /dev/null +++ b/doc/build/changelog/unreleased_13/4767.rst @@ -0,0 +1,11 @@ +.. change:: + :tags: bug, orm + :tickets: 4767 + + Fixed bug where a synonym created against a mapped attribute that does not + exist yet, as is the case when it refers to backref before mappers are + configured, would raise recursion errors when trying to test for attributes + on it which ultimately don't exist (as occurs when the classes are run + through Sphinx autodoc), as the unconfigured state of the synonym would put + it into an attribute not found loop. + diff --git a/lib/sqlalchemy/orm/attributes.py b/lib/sqlalchemy/orm/attributes.py index 9d4a0d0823..a415a15fa4 100644 --- a/lib/sqlalchemy/orm/attributes.py +++ b/lib/sqlalchemy/orm/attributes.py @@ -365,23 +365,34 @@ def create_proxied_attribute(descriptor): def __getattr__(self, attribute): """Delegate __getattr__ to the original descriptor and/or comparator.""" - try: return getattr(descriptor, attribute) except AttributeError: + if attribute == "comparator": + raise AttributeError("comparator") try: - return getattr(self.comparator, attribute) + # comparator itself might be unreachable + comparator = self.comparator except AttributeError: raise AttributeError( - "Neither %r object nor %r object associated with %s " - "has an attribute %r" - % ( - type(descriptor).__name__, - type(self.comparator).__name__, - self, - attribute, - ) + "Neither %r object nor unconfigured comparator " + "object associated with %s has an attribute %r" + % (type(descriptor).__name__, self, attribute) ) + else: + try: + return getattr(comparator, attribute) + except AttributeError: + raise AttributeError( + "Neither %r object nor %r object " + "associated with %s has an attribute %r" + % ( + type(descriptor).__name__, + type(comparator).__name__, + self, + attribute, + ) + ) Proxy.__name__ = type(descriptor).__name__ + "Proxy" diff --git a/test/orm/test_mapper.py b/test/orm/test_mapper.py index 6103bfa2fb..08f3eca2b8 100644 --- a/test/orm/test_mapper.py +++ b/test/orm/test_mapper.py @@ -1535,6 +1535,32 @@ class MapperTest(_fixtures.FixtureTest, AssertsCompiledSQL): eq_(attributes.instance_state(u1).attrs.x.history, ([5], (), ())) eq_(attributes.instance_state(u1).attrs.y.history, ([5], (), ())) + def test_synonym_nonexistent_attr(self): + # test [ticket:4767]. + # synonym points to non-existent attrbute that hasn't been mapped yet. + users = self.tables.users + + class User(object): + def _x(self): + return self.id + + x = property(_x) + + m = mapper( + User, + users, + properties={"x": synonym("some_attr", descriptor=User.x)}, + ) + + # object gracefully handles this condition + assert not hasattr(User.x, "__name__") + assert not hasattr(User.x, "comparator") + + m.add_property("some_attr", column_property(users.c.name)) + + assert not hasattr(User.x, "__name__") + assert hasattr(User.x, "comparator") + def test_synonym_of_non_property_raises(self): from sqlalchemy.ext.associationproxy import association_proxy