Search code examples
pythonobjectoop

Prevent (or work with) altering class attribute accessed from getter, bypassing setter, in Python


I'm trying to create a Python class, and one of the attributes is a dictionary of dictionaries. I construct it using getters and setters because there are certain checks that need to be done to ensure that data types are correct, etc. The intended use is to use a function to add the entries so that it can go through the checks, but what I discovered is that you can directly access the dictionary attribute and add a key to it, thereby bypassing the built-in checks of the function.

My question is:

  1. Is there a way to prevent the behavior of Object.dict_attribute[key] = some_dictionary that bypasses the checks and the intended Object.add_item(some_dictionary) function?
  2. If not, is there a way to trigger the Object.add_item(some_dictionary) function when Object.dict_attribute[key] = some_dictionary is called, possibly using magic methods?

Below is a toy example I made that should reproduce the behavior that I described. This was run and tested with Python 3.10.2.

# Bookcase can hold books, but the total needs to be 
# under a certain weight per shelf. 
class Bookcase:
    def __init__(self, num_shelves: int = 1, 
                 weight_limit: float = 50.0, books: dict = None) -> 'Bookcase': 
        # Create our shelves. 
        self.shelves = num_shelves
        self.weight_limit = weight_limit

        # Add the books if they are defined. 
        if books is not None:
            self.books = books

    ### PRIVATE FUNCTIONS ###
    # Check that a book is well-defined. 
    def _check_book(book: dict) -> bool: 
        return((type(book) == dict and
                len(set(book.keys()).intersection(set(["weight", "shelf"]))) == 2))
    
    # If a book is good, try adding to shelf. 
    def _add_book_to_shelf(self, book: dict) -> None: 
        
        # Check defined keys. 
        if not Bookcase._check_book(book):
            raise Exception("Each book should have a target shelf and a weight.")

        target = book['shelf']

        # If good, add it to the shelf, making sure that we do not exceed the weight. 
        if self._shelves[target]['weight'] + book['weight'] <= self.weight_limit:
            self._shelves[target]['weight'] += book['weight']
            self._shelves[target]['books'].append(book)
        else:
            raise Exception(f"Cannot add book to shelf {target}, doing so will exceed weight.")

        return()

    ### PROPERTIES ###
    @property
    def shelves(self) -> dict:
        return(self._shelves)
    
    # Initialize empty shelves. These shelves will contain book keys and a total weight. 
    @shelves.setter
    def shelves(self, num_shelves: int) -> None: 
        self._shelves = {}
        if type(num_shelves) != int:
            raise Exception("Shelves needs to be an integer.")
        for i in range(num_shelves):
            self._shelves[i] = {"books": [], "weight": 0}
        return()
    
    @property
    def weight_limit(self) -> dict:
        return(self._limit)
    
    @weight_limit.setter
    def weight_limit(self, limit: int) -> None: 
        self._limit = limit
        return()

    @property 
    def books(self) -> dict:
        ret_list: list = []
        for key in self._shelves:
            ret_list += self._shelves
        return(ret_list)
    
    # Books is a list of dictionaries with keys "name", "shelf", "weight".
    @books.setter
    def books(self, books: list[dict]) -> None: 
        # Empty our shelves and get rid of pre-existing books. 
        self.shelves = len(self.shelves.keys())

        # Each book should contain a name (key), a target shelf, and a weight. 
        for book in books:
            self._add_book_to_shelf(book)

        return()

    ##### NOTE ERROR HERE #####
    # The user should use this function to add a book to the shelf. 
    # We need to go through this function so that the checks are done. 
    def add_book(self, book: dict) -> None: 
        self._add_book_to_shelf(book)
        return()

if __name__ == "__main__":
    
    # Initialize a list of books. 
    books = [
        {'name': 'Hungry Caterpiller', 'shelf': 0, 'weight': 0.5}, 
        {'name': 'To Kill a Mockingbird', 'shelf': 0, 'weight': 1.0},
        {'name': '1984', 'shelf': 1, 'weight': 1.0}
    ]

    # Make our bookcase with our starting books. 
    library = Bookcase(num_shelves = 2, weight_limit = 20, books = books)

    # Let's define a big book. 
    big_book = {'name': 'Complete Tolkien Works', 'shelf': 1, 'weight': 200}
    
    # Add another book. This should fail. 
    ###
    ### UNCOMMENT FOR ERROR
    ###
    #library.add_book(big_book) # -> Results in appropriate error. 

    # Let's bypass that silly function, because why not? 
    library.shelves[1]['books'].append(big_book)

    # Let's check our shelves now. 
    for shelf in library.shelves.keys():
        print(shelf, library.shelves[shelf])

    # It worked, but we don't want it to!

Here is another way to look at the problem.

# This calls the getter, which returns the attribute.
#           |   This gets the 'book' key of the returned value. 
#           |         |    Why does this, then, save it in memory
#           |         |    if it was accessed by a getter?
#           |         |        |
#           V         V        V
library.shelves[1]['books'].append(big_book)

Thanks for any help!


Solution

  • Technical Note


    Before anything else a technical recommendation: If you're really worried about others (developers) accessing attributes or methods that should not be public accessible I would strongly recommend change from Python to other language like: Go, Rust, Java, etc. You will have a much better time enforcing public and private policies.

    Issue


    Code is allowing anyone to by-pass constraint check routines.

    Suggestion


    The simplest suggestion here is to build an specialized list to hold books.

    First step is to inherit the base list:

    class BookList(list):
    

    Second step is to provide a way to BookList to know what's the maximum weight of a shelve:

    class BookList(list):
        def __init__(self, weight_limit):
            self.shelve = weight_limit
            super().__init__()
    

    Third step is to make sure we cannot append more than we (shelve) handles:

    class BookList(list):
        def __init__(self, weight_limit):
            self.weight_limit = weight_limit
            super().__init__()
        def append(self, book):
            # Recover the current list of book
            curr_weight = sum([b['weight'] for b in self])
            new_weight = curr_weight + book['weight']
            if new_weight > self.weight_limit:
                raise Exception("book weight pass the max weight limit")
            super().append(book)
    

    We can run some tests:

    max_weight = 5
    books = BookList(max_weight)
    books.append({'weight': 3})
    books.append({'weight': 2})
    books.append({'weight': 1})  # raises exception
    

    If might wanna look at this really good example on what other methods you would want to override when inheriting list

    Personal Take


    As far as I can understand the weight limit is only tied to the Bookcase which makes the shelves pretty much useless on that perspective. If that's correct I would probably drop the shelves layers.

    If that's not the case then I would presume each shelve is supposed to have their own weight limit definition.

    Let's say we have a single Shelve has a limit of 5 units of weight. If a Bookcase is composed of 10 Shelves the max weight limit would be 50 units of weight. On this scenario it makes a lot more sense abstracting Shelves:

    class ShelfCannotHoldBook(Exception):
        def __init__(self, book, *args: object) -> None:
            self.book = book
            super().__init__(*args)
    
    class AllBookcaseShelvesAreFull(Exception):
        pass
    
    
    class Book(object):
        def __init__(self, weight):
            self.weight = weight
    
    class Shelf(list):
        def __init__(self, bookcase, max_weight):
            self.bookcase = bookcase
            self.max_weight = max_weight
            super().__init__()
    
        def append(self, new_book):
            curr_shelf_weight = sum([b.weight for b in self])
            new_shelf_weight = curr_shelf_weight + new_book.weight
            if new_shelf_weight > self.max_weight:
                raise ShelfCannotHoldBook(new_book)
            super().append(new_book)
    
    class Bookcase():
        def __init__ (self, shelves, shelf_weight):
            """
            Params:
                shelves (int) - how many shelves we want
                shelf_weight (int) - maximum weight for a single shelf
            """
            self.max_weight = shelves * shelf_weight
            self.shelves = [Shelf(self, shelf_weight) for _ in range(shelves)]
        
        def add_book(self, new_book):
            for shelf in self.shelves:
                try:
                    shelf.append(new_book)
                    return
                except ShelfCannotHoldBook as ex:
                    # we want to try other shelves
                    continue
            raise AllBookcaseShelvesAreFull
    

    You can run some tests:

    # Executing happy path
    case = Bookcase(2, 2)  # Two shelves each able to hold 2 weight units
    case.add_book(Book(1))
    case.add_book(Book(1))
    case.add_book(Book(2))
    try:
        case.add_book(Book(1))
    except Exception as ex:
        assert isinstance(ex, AllBookcaseShelvesAreFull)
    
    # Trying to by-pass
    case = Bookcase(2, 2)  # Two shelves each able to hold 2 weight units
    case.shelves[0].append(Book(1))
    case.shelves[0].append(Book(1))
    try:
        case.shelves[0].append(Book(1))
    except Exception as ex:
        assert isinstance(ex, ShelfCannotHoldBook)
    
    case.shelves[1].append(Book(2))
    try:
        case.shelves[1].append(Book(2))
    except Exception as ex:
        assert isinstance(ex, ShelfCannotHoldBook)