How to prevent attribute re-access until all attributes have been accessed once (Python metaclass)?

99 views Asked by At

I have a list of attributes, say ["foo", "bar", "baz"], and I want to write a metaclass which ensures the following properties:

  • They can all be accessed once, in any order
  • They cannot be accessed again until all have been accessed once

For example, the following is how it might work:

class TrackedAttrsMeta:
  @classmethod
  def with_attributes(cls, *args):
    cls._expected_attrs = set(args)
    return cls

class MyClass(metaclass=TrackedAttrsMeta.with_attributes("foo", "bar", "baz")):
  foo: "quux"
  bar: {"blah": -1}
  baz: 1

ob = MyClass()
for _ in range(10):
  print(ob.foo) # Allowed
  print(ob.bar) # Allowed
  print(ob.foo) # Fails with an error because "baz" has not been accessed yet
  print(ob.baz) # Would be allowed if not for above
  print(ob.foo) # Would be allowed because we've accessed every attribute in the list once

The implementation of a custom __getattribute__ might look like so:

def _tracking_getattr(self, name):
  value = super().__getattribute__(name)
  remaining = list(expected_attrs - self._accessed_attrs)
  if name in self._accessed_attrs:
    raise ValueError(f"Already accessed '{name}', must access all of {remaining} before this is allowed again")
           
  self._accessed_attrs.add(name)

  # Once we've seen them all, clear out the record so we can see them again
  if self._accessed_attrs == expected_attrs:
    self._accessed_attrs = set()
2

There are 2 answers

6
ijustlovemath On BEST ANSWER

You can implement this metaclass like so, but it will only allow one set of "tracked" attributes per class. Instead of constructing the metaclass with a classmethod, you will add a _tracked_ attribute as well as the metaclass to your class constructor.

class TrackedAttrsMeta(type):
    def __new__(cls, cls_name, bases, dct):
        # Which attributes have been accessed so far
        dct["_trk_accessed_"] = set()

        # Which attributes we apply the tracking to
        tracked_attrs = dct.get("_tracked_", None)
        if tracked_attrs is None or not isinstance(tracked_attrs, list):
            raise TypeError("You must define a _tracked_ attribute on the class with the list of attributes you'd like to track")
        dct["_trk_attributes_"] = set(tracked_attrs)

        cls_instance = super().__new__(cls, cls_name, bases, dct)

        # Preserve the object's original getattr
        gtattr = cls_instance.__getattribute__

        def _tracking_getattr(cls, name):
            value = gtattr(cls, name)
            # Prevent a stackoverflow by handling these attributes specially
            if name in ["_trk_attributes_", "_trk_accessed_"]:
                return value

            if name in cls._trk_attributes_:
                remaining = list(cls._trk_attributes_ - cls._trk_accessed_)

                if name in cls._trk_accessed_:
                    raise AttributeError(f"Cannot access {name} before accessing {remaining}")

                cls._trk_accessed_.add(name)

                if cls._trk_accessed_ == cls._trk_attributes_:
                    cls._trk_accessed_.clear()

            return value

        def _allow_access(self, name):
            if name not in self._trk_attributes_:
                raise ValueError(f"Not tracking {name}")

            if name not in self._trk_accessed_:
                raise ValueError(f"Access to {name} is already allowed")

            self._trk_accessed_.remove(name)

        # Use the custom getattr
        cls_instance.__getattribute__ = _tracking_getattr

        # Add a method to skip tracking once
        cls_instance.allow_untracked_access = _allow_access

        return cls_instance

You can use it like this:

class YourClass(metaclass=TrackedAttrsMeta):
    _tracked_ = ["foo", "bar", "baz"]
    foo = "bah"
    bar = 2
    baz = {"h": 6}

# Example usage:
obj = YourClass()
print(obj.foo)  # Access foo for the first time
# print(obj.foo) # This will raise AttributeError
print(obj.bar)  # Access bar for the first time
print(obj.baz)  # Access baz for the first time
print(obj.foo)  # This is allowed since we've accessed all
print(obj.bar)  # This is allowed since we've accessed all

# The following will raise an AttributeError, as we just accessed bar
# print(obj.bar)
7
chepner On

This really cries out for a context manager, so that you can use a with statement to explicitly delimit the block in which exhaustive access needs to be enforced. (The object itself doesn't have anyway of knowing about when one iteration of the loop ends and the next begins.)

For example,

ob = MyClass()

for i in range(10):
    with ob as proxy:
        print(proxy.foo)
        print(proxy.bar)
        print(proxy.baz)

Something like

class AttributeTrackerProxy:
    def __init__(self, obj, *args):
        self.obj = obj
        self._unused = set(args)

    def __getattribute__(self, name):
        if self._unused and name not in self._unused:
            # i.e., if there are unused attributes and "name" isn't one of them...
            raise Exception(f"{name} already used; access others first") 
        self._unused.remove(name)
        return getattr(self.obj, name)


class MyClass:
    def __init__(self):
        self.foo = ...
        self.bar = ...
        self.baz = ...
        self._attrs = ["foo", "bar", "baz"]

    def __enter__(self):
        self._proxy = AttributeTrackerProxy(self._attrs)
        return self._proxy

    def __exit__(self, *args):
        # There isn't really anything to do here, unless
        # you want to raise an exception if self._proxy._unused
        # hasn't been emptied.
        pass
    

The only thing the metaclass would help with is automating the definition of the list of attribute names. (__enter__ could be defined by a mix-in class and inherited by MyClass.) I would not recommend complicating your code with a metaclass; just define _attrs manually.