Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Is it possible to decorate a class attribute

How to collect information about the class attributes while the class is being constructed?

In Java, what I am asking for is possible.

In Python, that doesn't seem to be the case. Correct me if I am wrong!

I am constructing a sqlalchemy object that is defined declaratively.

class Foo(BASE):
   id = Column(Integer, primaryKey=True, nullable=False)
   text = Column(String(100))

I would like to define the class like this:

class Foo(declarative_base()):
   @persisted_property(id=true)
   id = Column(Integer, primaryKey=True, nullable=False)

   @persisted_property(mutable=True)
   text = Column(String(100))

   @persisted_property(set_once=True)
   created_by = Column(Integer)

   @classmethod
   def builder(cls, existing=None):
       return Builder(cls, existing)

The persisted_property class/function/? purpose is to collect the class attributes. With that knowledge, these things would happen:

  1. a builder() classmethod would be added to the class Foo that returns a generated FooBuilder. The FooBuilder would have these methods: set_text()->FooBuilder, set_created_by()->FooBuilder, build()->Foo

  2. (ideally) any attempt to directly alter a Foo object would be blocked. (how to let sqlalchemy work?)

Example behavior:

  1. Foo.builder().set_text("Foo text").set_created_by(1).build()
  2. Foo.builder(existing_foo).set_text("Foo text").set_created_by(1).build() : would raise Exception since existing_foo already has a value for created_by

Notes:

  1. Adding a class level decorator separates the attribute definition from the decoration and feels ... wrong
  2. Class level decorations happen after sqlalchemy does it magic. (this could be good or bad)

Alternatives? suggestions?

like image 881
Pat Avatar asked Oct 15 '25 12:10

Pat


1 Answers

The @callable decorator syntax is indeed exclusive to def function and class class statements. However, it is just syntactic sugar.

The syntax

@name(arguments)
def functionname(...):
    # ...

is translated to:

def functionname(...):
    # ...
functionname = name(arguments)(functionname)

that is, the callable object produced by @[decorator] is called and the result is assigned back to the function name (or class name, if applied to a class statement).

You can always call the decorator directly, and assign the return value:

id = persisted_property(id=true)(Column(Integer, primaryKey=True, nullable=False))

However, decorators do not have access to the namespace in which the objects are constructed! The body of a class statement is executed as if it were a function (albeit with different scoping rules) and the resulting local namespace is taken to produce the class attributes. A decorator is just another function call in this context, and the local namespace of the class body is not meant to be available.

Next, I'd not even begin to build your builder pattern. That's a Java pattern where class privacy and immutability are enforced to the detriment of dynamic language patterns. Python is not Java, don't try to turn it into Java. For example, you can't really make instances of Python classes immutable, that's simply not something a dynamic language lets you do. Moreover, the builder pattern is a solution to a problem that doesn’t really exist in Python, where you can build up your arguments to construct a class up front in, say, a dictionary you then apply dynamically to a class call, whereas Java has no such dynamic call support.

And you don't need to use a decorator pattern to mark your schema attributes anyway. You should instead rely on SQLAlchemy's own introspection support:

from sqlalchemy.inspection import inspect

class Builder:
    def __init__(self, cls, existing=None, **attrs):
        self.cls = cls
        if existing is not None:
            assert isinstance(existing, cls)
            existing_attrs = {n: s.value for n, s in inspect(existing).attrs.items()}
            # keyword arguments override existing attribute values
            attrs = {**existing_attrs, **attrs}
        self.attrs = attrs
    def _create_attr_setter(self, attrname):
        # create a bound attribute setter for the given attribute name
        def attr_setter(self, value):
            if attrname in self.attrs:
                raise ValueError(f"{attrname} already has a value set")
            return type(self)(self.cls, **self.attrs, **{attrname: value})
        attr_setter.__name__ = f'set_{attrname}'
        return attr_setter.__get__(self, type(self))
    def __getattr__(self, name):
        if name.startswith('set_'):
            attrname = name[4:]
            mapper = inspect(self.cls)
            # valid SQLAlchemy descriptor name on the class?
            if attrname in mapper.attrs:
                return self._create_attr_setter(attrname)
        raise AttributeError(name)
    def build(self):
        return self.cls(**self.attrs)

class BuilderMixin:
    @classmethod
    def builder(cls, existing=None):
        return Builder(cls, existing)

and then just use BuilderMixin as a mixin class:

>>> from sqlalchemy.ext.declarative import declarative_base
>>> from sqlalchemy import Column, Integer, String
>>> Base = declarative_base()
>>> class Foo(Base, BuilderMixin):
...     __tablename__ = 'foo'
...     id = Column(Integer, primary_key=True, nullable=False)
...     text = Column(String(100))
...     created_by = Column(Integer)
...
>>> Foo.builder().set_text('Demonstration text').set_created_by(1).build()
<__main__.Foo object at 0x10f8314a8>
>>> _.text, _.created_by
('Demonstration text', 1)

You can attach additional information to columns in the info dictionary:

text = Column(String(100), info={'mutable': True})

which your builder code could then access via the mapper (e.g. mapper.attrs['text'].info.get('mutable', False)).

But again, rather than recreate the Java builder pattern, just construct the attrs dictionary directly, and at most encode mutability rules by using a hybrid property or with ORM events.

like image 120
Martijn Pieters Avatar answered Oct 18 '25 01:10

Martijn Pieters



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!