Classes in python, the nuances


#
# abc is a module in python
# abstract base class (abc)
#
from abc import ABC, abstractmethod

#
# use an annotation
#
class MyBaseClass(ABC):
    @abstractmethod
    def my_abstract_method(self):
        pass

#
# Implement it
# Type error if not implemented
#
class MySubclass(MyBaseClass):
    def my_abstract_method(self):
        # Implement the abstract method
        print("Implemented abstract method in MySubclass")

#
# Call it
#
obj = MySubclass()
obj.my_abstract_method()

import threading

def initialize_class_variables(cls):
    if not hasattr(cls, 'class_variable'):
        cls.class_variable = 10
    return cls

@initialize_class_variables
class MyClass:
    class_variable = None

    def __init__(self):
        pass

# Example usage
obj = MyClass()
print(obj.class_variable)  # Output: 10
  1. class variables declared as class_variablename: type
  2. They are initialized with a function using an annotation
  3. This is ensured by python imports
  4. Python doesn't have java like static block that is guranteed to be executed only once in a multi-threaded environment.
  1. Can you write me sample code for the following
  2. A base class with its own init method, typed instance variables, and constructor args to init those variables
  3. A derived class with constructor arguments and its own distinct typed instance variables and to init them
  4. Calling the super init method
  5. An example of a private method
  6. An example of a public method
  7. I want the instance variables to be explicitly declared in the class body and not inside init. Also I want their type specification.

class MyBaseClass:
    name: str
    age: int

    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

    # Private method (starts with _)
    def _private_method(self):
        print("This is a private method")  

    def public_method(self):
        print("This is a public method")  

# How to derive
class MyDerivedClass(MyBaseClass):
    gender: str

    def __init__(self, name: str, age: int, gender: str):
        super().__init__(name, age)
        self.gender = gender

    def _private_method(self):
        super()._private_method()
        print("This is a private method in MyDerivedClass")  

    def public_method(self):
        super().public_method()
        print("This is a public method in MyDerivedClass")  

        #call private method. No self is needed when calling
        self._private_method()


base_obj = MyBaseClass("John", 30)
derived_obj = MyDerivedClass("Alice", 25, "Female")

Remember same named methods will be overridden.


class MyClass:
    def __init__(self, name):
        self._name = name

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        self._name = value

    @name.deleter
    def name(self):
        del self._name


# Create an instance of MyClass
obj = MyClass("John")

# Access the name attribute using the getter method
print(obj.name)  # Output: John

# Assign a new value to the name attribute using the setter method
obj.name = "Alice"
print(obj.name)  # Output: Alice

# Delete the name attribute using the deleter method
del obj.name
# Accessing the attribute after deletion will raise an AttributeError
  1. obj.name #call the getter
  2. obj.name = "blah" #call the setter
  3. del obj.name #call the deleter
  4. Notice how annotations differ for each of these methods above
  1. When calling another private method you have to use self._the_other_method()
  2. You don't have to pass the self as an argument
  3. Self is automatically passed as the first argument
  4. while defining a method inside a class "self" MUST be the first argument.
  5. The type checker or the editor usually tells you this while you are coding

from abc import ABC, abstractmethod

class Wizard(ABC):
    @abstractmethod
    def question(self):
        """
        Abstract method that should be implemented by subclasses.
        
        Returns:
            tuple: A pair (question, answer).
        """
        pass

# Example subclass implementing the abstract method
class ConcreteWizard(Wizard):
    def question(self):
        # Implementation of the abstract method
        return ("What is your quest?", "To seek the Holy Grail.")

# Usage
wizard = ConcreteWizard()
q, a = wizard.question()
print(f"Question: {q}\nAnswer: {a}")

class Base1:
    def method(self):
        print("Method of Base1")

class Base2:
    def method(self):
        print("Method of Base2")

class Derived(Base1, Base2):
    def another_method(self):
        print("Another method of Derived")

# Creating an instance of Derived
d = Derived()

# Calling methods: surprise
d.method()          
# This will call the method from Base1, 
due to the order in the inheritance list

d.another_method()  # Calls method defined in Derived

class ManagedFile:
    def __init__(self, filename):
        self.filename = filename

    def __enter__(self):
        self.file = open(self.filename, 'r')
        return self.file

    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.file:
            self.file.close()

# Usage
with ManagedFile('example.txt') as f:
    content = f.read()
    print(content)

from contextlib import contextmanager

@contextmanager
def managed_file(filename):
    try:
        f = open(filename, 'r')
        yield f
    finally:
        f.close()

# Usage
with managed_file('example.txt') as f:
    content = f.read()
    print(content)

class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price

    def __repr__(self):
        return f"Product({self.name!r}, {self.price!r})"

    def __str__(self):
        return f"{self.name} - ${self.price}"

# Creating an instance of Product
product = Product("Coffee Mug", 12.99)

# __repr__ is used when echoing in a console or using repr()
print(repr(product))  # Output: Product('Coffee Mug', 12.99)

# __str__ is used when printing or using str()
print(product)  # Output: Coffee Mug - $12.99

class Singleton:
    _instance = None  # Keep instance reference
    
    def __new__(cls, *args, **kwargs):
        if cls._instance is None:
            cls._instance = super(Singleton, cls).__new__(cls, *args, **kwargs)
        return cls._instance

# Test the Singleton class
obj1 = Singleton()
obj2 = Singleton()

print(obj1 is obj2)  # Output: True
  1. A single underscore _ is used for ignoring values.
  2. Also indicating private or internal variables
  3. or by convention to signify that a name is meant for internal use within modules and classes.
  4. Double underscores __ at the beginning of a name (but not at the end) enable name mangling, which helps to make an attribute or method private to its class.
  5. Double underscores at both the beginning and end of a name (dunder methods) are reserved by Python for special methods, which allow your objects to implement, override, or interact with Python's built-in behavior.

class Multiplier:
    def __init__(self, factor):
        self.factor = factor
    
    def __call__(self, x):
        return self.factor * x

# Create an instance of Multiplier that doubles the input
doubler = Multiplier(2)

# Use the instance as if it were a function
print(doubler(5))  # Output: 10
print(doubler(10))  # Output: 20
  1. The protocol for using a decorator on a class in Python involves the decorator taking the class as an argument and then returning a modified class or a callable object that replaces or enhances the original class.
  2. The core purpose of a class decorator is to modify or extend the behavior of the class in some way without altering its source code.
  3. This can include adding methods, modifying existing methods, enforcing patterns like singletons, or even replacing the class with a completely different one.
  1. The original class, possibly modified: The decorator might add attributes or methods to the class or modify its existing methods. After making these modifications, it returns the class itself.
  2. A new class: The decorator can return a new class that perhaps inherits from the original class or wraps it in some way, effectively replacing the original class with the new one.
  3. A callable object: In some cases, especially for patterns like the singleton, the decorator might return a callable object (like a function or an instance of another class) that controls the creation of instances of the original class. This callable object typically keeps track of the instance(s) of the class and ensures that the class's behavior conforms to certain criteria (e.g., only allowing a single instance to exist).

def add_greeting_method(cls):
    # Define a new method
    def greet(self):
        print(f"Hello, my name is {self.name}.")
    
    # Add the method to the class
    cls.greet = greet
    return cls

@add_greeting_method
class Person:
    def __init__(self, name):
        self.name = name

# Usage
person = Person("Alice")
person.greet()  # Output: Hello, my name is Alice.

There is also an example at the begining of this article


def singleton(cls):
    instances = {}
    def get_instance(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    return get_instance

@singleton
class Singleton:
    pass

# Usage
singleton1 = Singleton()
singleton2 = Singleton()

assert singleton1 is singleton2  # True

"""
*************************************************
* baselib related imports
*************************************************
"""
from baselib import baselog as log

from config.appconfig import AppConfig
from config import appconfig as appconfig

def _getAppConfigObject():
    log.info("Reading Application configuration file")
    return appconfig.readTOMLConfigFile()

def initAppServices(cls):
    log.ph1("Initializing App Services")
    cls.class_app_config = _getAppConfigObject()
    return cls

@initAppServices
class AppServices:
    class_app_config: AppConfig
    @staticmethod
    def config():
        return AppServices.class_app_config
    
"""
*************************************************
* Testing
*************************************************
"""
def _testConfig1():
    token = AppServices.config().api_token_name
    log.ph("Token from config", token)

def _testConfig2():
    a = AppServices.config().embedding_api
    log.ph("embedding api", a)

def test():
    _testConfig1()
    _testConfig2()

def localTest():
    log.ph1("Starting local test")
    test()
    log.ph1("End local test")

if __name__ == '__main__':
    localTest()
  1. Inside a class decorator function, any other function you call have to be defined before the class definition.
  2. Perhaps because the class initialization takes places as the file is getting interpreted.
  3. This is usually not the case in functions because they are not executed when the file is imported.

test() #This will fail

def test():
    log.info("test")

test() #This will succeed
  1. If they are outside of an init method with out a "self" reference they are "class" variables.
  2. If these variables are initialized at their declaration outside of the init method, know, they are class variables!
  3. You can, however, declare them without initialization with only type hints. When that happens they are merely documentation for their instance variables.
  4. So, confusing and error prone for beginers.
  5. However you may want to do so for documentation and take care not to initialize them.
  6. So when you do need a class variable, use a convention to show your intention by calling them "class_x" and "class_y" etc.

from dataclasses import dataclass

@dataclass
class InventoryItem:
    name: str
    unit_price: float
    quantity_on_hand: int = 0

    def total_cost(self) -> float:
        return self.unit_price * self.quantity_on_hand

# Example usage
item = InventoryItem('widget', unit_price=3.5, quantity_on_hand=20)
print(item)  # Output will be nicely formatted thanks to the auto-generated __repr__
print(item.total_cost())  # Outputs: 70.0
  1. Auto generates the init method, repr, eq etc.
  2. Declares variables nicely based on their types
  3. Allow for default values
  4. calls post init if needed
  5. further field decorators
  6. can be made immutable