Dynamically creating read-only attributes

Question:

I’m currently trying to create a class that inherits from set, but allows calling for subsets with attributes.

Since I want to create this class such that I can use it in any context, I want to have it be able to create attributes from any given string. I have succeeded in this, but I want the subsets to only be changed via a method, such that it also adds them to the ‘main’ set. Otherwise, someone could add items to a subset without adding them to the main.

Right now someone could simply set the attributes after they are created. Is there any way to create read-only attributes dynamically?

This is what I have so far:

class SetWithSubset(set):
    """
    Set should only be initialized with arguments if they should not be
    in a subset. Read Only.
    """
    def create_subset(self, name, subset = None):
        if type(subset) != set:
            subset = set()
        self.update(subset)
        setattr(self, name, subset)

    def add_to_subset(self, name, element):
        getattr(self, name).add(element)
        self.add(element)

I have read things about changing __setattr__, but if I change that to raise an exception, it also raises the error when the method tries to change it.

Edit: There was an unrelated problem in the code which I changed

Asked By: JohnMcSwag

||

Answers:

Limited (im)mutability

As I am sure you are aware already, there isn’t really any way to make something truly immutable (or "read-only") in Python. All you can do, is make it more difficult (or more involved) to change attributes of objects. So no approach will guarantee you fool-proof read-only attributes.

If you want, you can actually meaningfully override the __setattr__ method like this:

class SetWithSubset(set):
    def __setattr__(self, name, items):
        if hasattr(self, name):
            raise AttributeError("Already exists")
        items = set(items)
        super().__setattr__(name, items)
        self.update(items)

s = SetWithSubset([10, 20])
s.foo = [1, 2]
print(s)            # SetWithSubset({1, 10, 2, 20})
print(s.foo)        # {1, 2}
try:
    s.foo = [3, 4]
except AttributeError as e:
    print(repr(e))  # AttributeError('Already exists')

But your approach has a major drawback IMHO. Dynamically setting attributes (via the setattr function) makes the true interface of the object more opaque to any static type checker. This includes your friendly IDE engine. So you can say goodbye to many helpful auto-suggestions or warnings, when you try to access attributes that actually exist or don’t exist on the given object.

Additionally, while the "main" set does not allow re-setting existing subsets, nothing prevents you from simply mutating them, if they are simply accessible as attributes of your main set, without this having any effect on the main set:

# ... continued from above
s.foo.add(5)
print(s)      # SetWithSubset({1, 10, 2, 20})
print(s.foo)  # {1, 2, 5}

Allow me to suggest a slight shift in paradigm and offer a different implementation that may accomplish what you seem to be aiming at.

Inherit from the ABC

The first change I would suggest is inheriting from the abstract base class collections.abc.MutableSet instead of builtins.set. This is just good practice when emulating built-in types and gives you more explicit control over how the core methods of your class behave. All you need to do, is implement the five abstract methods listed here and you get all the rest of the expected set-methods (except update for some reason) automatically mixed-in.

Internally you would still keep a regular old set as the container for all the items as a protected attribute. Thus you should also define your own __init__ method.

Subsets in a dictionary

Secondly, instead of dynamically assigning attributes to an instance of your class, keep a dedicated internal dictionary mapping subset names to subset instances. If the user tries to create a new subset with a name that already exists as a key in that dictionary, you can raise an error. But internally you can still easily mutate that dictionary as you deem necessary.

Keep a parent reference

You can link nested sets by allowing a parent set to be defined upon instantiation. Then every method that mutates the set can explicitly call the same method with the same arguments on its parent set. If your nested sets are all instances of your custom class, this allows theoretically unlimited nesting of subsets.

If you are careful about ensuring a set is always mutated in tandem with its parent, you can be sure that a parent will always essentially reflect the union of its subsets.

This is rather easy in terms of adding items to a subset, but may become a bit trickier with removing items because even though one subset may want to discard an item, the parent may have another subset that still holds on to the same item. How you want to deal with this is really up to you and your desired outcome. You may e.g. simply disallow discarding elements or you may on the other hand propagate removal to all subsets as well as the parent.

Make it subscriptable

If you want, you can additionally define the __getitem__ and __setitem__ methods on your class as the mechanism for accessing existing or creating new subsets respectively. That way you’ll partially expose the interface of the internal dictionary and create a sort of hybrid between a mutable set and a (sort-of-not-really mutable) Mapping.

That way creating a subset is as easy as plain dict assignment via subscript (the [] brackets) and you can e.g. raise an error if a subset with the given name/key already exists. And adding items to a subset becomes as easy as dict access and then calling the desired method on the returned subset (add, update or what have you).

Make it generic and annotate it properly

Using the Python’s type annotation capabilities as consistently as possible is good style anyway and it serves a very obvious practical purpose. The more static type checkers know about the objects you are dealing with, the better they can help you by providing useful auto-suggestions, warnings, inspection etc.. Luckily there is not much to do here, since the ABC is generic already, you just need to consistently type-hint all relevant methods with a type variable of your choice.

Example implementation

from __future__ import annotations
from collections.abc import Iterable, Iterator, MutableSet
from typing import Optional, TypeVar


T = TypeVar("T")


class SetWithSubsets(MutableSet[T]):
    def __init__(
        self,
        items: Iterable[T] = (),
        parent: Optional[SetWithSubsets[T]] = None,
    ) -> None:
        """If provided, updates the parent with it sown initial items"""
        self._items = set(items)
        self._subsets: dict[str, SetWithSubsets[T]] = {}
        self._parent = parent
        if self._parent is not None:
            self._parent.update(items)

    def __repr__(self) -> str:
        """Not required, just obviously useful to have"""
        return repr(self._items)

    def __contains__(self, item: object) -> bool:
        return item in self._items

    def __iter__(self) -> Iterator[T]:
        return iter(self._items)

    def __len__(self) -> int:
        return len(self._items)

    def add(self, item: T) -> None:
        self._items.add(item)
        if self._parent is not None:
            self._parent.add(item)

    def discard(self, item: T) -> None:
        self._items.discard(item)
        # You may or may not want to propagate this call.
        # the parent may have another subset containing `item`.
        # You can also e.g. discard the item form a parent and _all_ subsets.

    def update(self, *items: Iterable[T]) -> None:
        """Not automatically mixed-in by the ABC"""
        self._items.update(*items)
        if self._parent is not None:
            self._parent.update(*items)

    def __setitem__(self, subset_name: str, items: Iterable[T]) -> None:
        """Alternative to regular method for creating a subset"""
        if subset_name in self._subsets:
            raise KeyError(f"Subset named {subset_name} already exists!")
        self._subsets[subset_name] = SetWithSubsets(items, parent=self)

    def __getitem__(self, subset_name: str) -> SetWithSubsets[T]:
        """Alternative to regular method for mutating a subset"""
        return self._subsets[subset_name]

Demo

def demo() -> None:
    s = SetWithSubsets([10, 20])
    # Create new subset named "foo" with initial values:
    s["foo"] = (1, 2, 3)
    print(s)                      # {1, 2, 3, 10, 20}
    print(s["foo"])               # {1, 2, 3}
    print(type(s["foo"]))         # <class '__main__.SetWithSubsets'>
    try:
        s["foo"] = {4, 5}
    except KeyError as e:
        print(repr(e))            # KeyError('Subset named foo already exists!')
    # Create two new subsets:
    s["bar"] = {4, 5}
    s["baz"] = [6]
    print(s)                      # {1, 2, 3, 4, 5, 6, 10, 20}
    print(s["bar"])               # {4, 5}
    # Add items to subsets:
    s["bar"].add(7)
    s["baz"].update([8, 9])
    print(s)                      # {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 20}
    print(s["bar"])               # {4, 5, 7}
    print(s["baz"])               # {8, 9, 6}


if __name__ == '__main__':
    demo()

Variations

Obviously, you can use any reasonable combination of the suggestions and approaches I laid out above. If you don’t like the dict-like access and instead want regular methods for creating/getting subsets, you can easily adjust the methods accordingly.

If you don’t care that type checkers and IDEs may be confused or complain, you can of course also define your own __setattr__ and __getattr__ in a similar fashion as the methods shown above.

Also, instead of raising an error when someone tries to call the "setter" method (whatever it may be in the end), you may consider implementing some mechanism that mutates that set or discards it (and its elements from the parent) and replaces it.

There are countless options. Hopefully some of this is useful.

Answered By: Daniil Fajnberg