5.4. OOP Classmethod

5.4.1. Rationale

  • Using class as namespace

  • Will pass class as a first argument

  • self is not required

5.4.2. Syntax

Dynamic methods:

>>> class MyClass:
...     def mymethod(self):
...         pass

Static methods:

>>> class MyClass:
...     @staticmethod
...     def mymethod():
...         pass

Class methods:

>>> class MyClass:
...     @classmethod
...     def mymethod(cls):
...         pass

5.4.3. Example

>>> class Astronaut:
...     def my_dynamic_method(self):
...         return 'Hello'
...
...     @staticmethod
...     def my_static_method():
...         return 'Hello'
...
...     @classmethod
...     def my_class_method(cls):
...         return 'Hello'

5.4.4. Manifestation

>>> import json
>>> from dataclasses import dataclass
>>>
>>>
>>> @dataclass
... class User:
...     firstname: str
...     lastname: str
...
...     def from_json(self, data):
...         data = json.loads(data)
...         return User(**data)
>>>
>>>
>>> DATA = '{"firstname": "Jan", "lastname": "Twardowski"}'
>>>
>>> User.from_json(DATA)
Traceback (most recent call last):
TypeError: from_json() missing 1 required positional argument: 'data'
>>>
>>> User().from_json(DATA)
Traceback (most recent call last):
TypeError: __init__() missing 2 required positional arguments: 'firstname' and 'lastname'
>>>
>>> User(None, None).from_json(DATA)
User(firstname='Jan', lastname='Twardowski')
>>> import json
>>> from dataclasses import dataclass
>>>
>>>
>>> @dataclass
... class User:
...     firstname: str
...     lastname: str
...
...     @staticmethod
...     def from_json(data):
...         data = json.loads(data)
...         return User(**data)
>>>
>>>
>>> DATA = '{"firstname": "Jan", "lastname": "Twardowski"}'
>>>
>>> User.from_json(DATA)
User(firstname='Jan', lastname='Twardowski')
>>> import json
>>> from dataclasses import dataclass
>>>
>>>
>>> class JSONMixin:
...     @staticmethod
...     def from_json(data):
...         data = json.loads(data)
...         return User(**data)
>>>
>>>
>>> @dataclass
... class User(JSONMixin):
...     firstname: str
...     lastname: str
>>>
>>>
>>> DATA = '{"firstname": "Jan", "lastname": "Twardowski"}'
>>>
>>> print(User.from_json(DATA))
User(firstname='Jan', lastname='Twardowski')
>>> import json
>>> from dataclasses import dataclass
>>>
>>>
>>> class JSONMixin:
...     def from_json(self, data):
...         data = json.loads(data)
...         return User(**data)
>>>
>>>
>>> @dataclass
... class User(JSONMixin):
...     firstname: str = None
...     lastname: str = None
>>>
>>>
>>> DATA = '{"firstname": "Jan", "lastname": "Twardowski"}'
>>>
>>> User.from_json(DATA)
Traceback (most recent call last):
TypeError: from_json() missing 1 required positional argument: 'data'
>>>
>>> User().from_json(DATA)
User(firstname='Jan', lastname='Twardowski')

Trying to use method with self:

>>> import json
>>> from dataclasses import dataclass
>>>
>>>
>>> class JSONMixin:
...     def from_json(self, data):
...         data = json.loads(data)
...         return self(**data)
>>>
>>>
>>> @dataclass
... class User(JSONMixin):
...     firstname: str = None
...     lastname: str = None
>>>
>>>
>>> DATA = '{"firstname": "Jan", "lastname": "Twardowski"}'
>>>
>>> User.from_json(DATA)
Traceback (most recent call last):
TypeError: from_json() missing 1 required positional argument: 'data'
>>>
>>> User().from_json(DATA)
Traceback (most recent call last):
TypeError: 'User' object is not callable

Trying to use method with self.__init__():

>>> import json
>>> from dataclasses import dataclass
>>>
>>>
>>> class JSONMixin:
...     def from_json(self, data):
...         data = json.loads(data)
...         self.__init__(**data)
...         return self
>>>
>>>
>>> @dataclass
... class User(JSONMixin):
...     firstname: str = None
...     lastname: str = None
>>>
>>>
>>> DATA = '{"firstname": "Jan", "lastname": "Twardowski"}'
>>>
>>> User.from_json(DATA)
Traceback (most recent call last):
TypeError: from_json() missing 1 required positional argument: 'data'
>>>
>>> User().from_json(DATA)
User(firstname='Jan', lastname='Twardowski')

Trying to use methods self.__new__() and self.__init__():

>>> import json
>>> from dataclasses import dataclass
>>>
>>>
>>> class JSONMixin:
...     def from_json(self, data):
...         data = json.loads(data)
...         instance = object.__new__(type(self))
...         instance.__init__(**data)
...         return instance
>>>
>>>
>>> @dataclass
... class User(JSONMixin):
...     firstname: str = None
...     lastname: str = None
>>>
>>>
>>> DATA = '{"firstname": "Jan", "lastname": "Twardowski"}'
>>>
>>> User.from_json(DATA)
Traceback (most recent call last):
TypeError: from_json() missing 1 required positional argument: 'data'
>>>
>>> User().from_json(DATA)
User(firstname='Jan', lastname='Twardowski')
>>> import json
>>> from dataclasses import dataclass
>>>
>>>
>>> class JSONMixin:
...     @classmethod
...     def from_json(cls, data):
...         data = json.loads(data)
...         return cls(**data)
>>>
>>> @dataclass
... class User(JSONMixin):
...     firstname: str
...     lastname: str
>>>
>>>
>>> DATA = '{"firstname": "Jan", "lastname": "Twardowski"}'
>>>
>>> User.from_json(DATA)
User(firstname='Jan', lastname='Twardowski')

5.4.5. Use Cases - JSONMixin

>>> import json
>>> from dataclasses import dataclass
>>>
>>>
>>> class JSONMixin:
...     @classmethod
...     def from_json(cls, data):
...         data = json.loads(data)
...         return cls(**data)
>>>
>>> @dataclass
... class Guest(JSONMixin):
...     firstname: str
...     lastname: str
>>>
>>> @dataclass
... class Admin(JSONMixin):
...     firstname: str
...     lastname: str
>>>
>>>
>>> DATA = '{"firstname": "Jan", "lastname": "Twardowski"}'
>>>
>>> Guest.from_json(DATA)
Guest(firstname='Jan', lastname='Twardowski')
>>>
>>> Admin.from_json(DATA)
Admin(firstname='Jan', lastname='Twardowski')

5.4.6. Use Case - Interplanetary time

>>> # myapp/time.py
>>> class AbstractTime:
...     tzname: str
...     tzcode: str
...
...     def __init__(self, date, time):
...         ...
...
...     @classmethod
...     def parse(cls, text):
...         result = {'date': ..., 'time': ...}
...         return cls(**result)
>>>
>>> class MartianTime(AbstractTime):
...     tzname = 'Coordinated Mars Time'
...     tzcode = 'MTC'
>>>
>>> class LunarTime(AbstractTime):
...     tzname = 'Lunar Standard Time'
...     tzcode = 'LST'
>>>
>>> class EarthTime(AbstractTime):
...     tzname = 'Universal Time Coordinated'
...     tzcode = 'UTC'
>>> # myapp/settings.py
>>> from myapp.time import *  
>>>
>>> time = MartianTime
>>> # myapp/usage.py
>>> from myapp.settings import time  
>>>
>>> UTC = '1969-07-21T02:53:07Z'
>>>
>>> dt = time.parse(UTC)
>>> print(dt.tzname)
Coordinated Mars Time

5.4.7. Assignments

Code 5.18. Solution
"""
* Assignment: OOP Classmethod Time
* Complexity: easy
* Lines of code: 5 lines
* Time: 8 min

English:
    1. Define class `Timezone` with:
       a. Field `when: datetime`
       b. Field `tzname: str`
       c. Method `convert()` taking class and `datetime` as arguments
    2. Method `convert()` returns instance of a class, which was given
       as an argument with field set `when: datetime`
    3. Run doctests - all must succeed

Polish:
    1. Zdefiniuj klasę `Timezone` z:
       a. polem `when: datetime`
       b. polem `tzname: str`
       c. Metodą `convert()` przyjmującą klasę oraz `datetime` jako argumenty
    2. Metoda `convert()` zwraca instancję klasy, którą dostała jako argument
       z ustawionym polem `when: datetime`
    3. Uruchom doctesty - wszystkie muszą się powieść

Hints:

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from inspect import isclass

    >>> assert isclass(Timezone)
    >>> assert isclass(CET)
    >>> assert isclass(CEST)

    >>> dt = datetime(1969, 7, 21, 2, 56, 15)

    >>> cet = CET.convert(dt)
    >>> assert cet.tzname == 'Central European Time'
    >>> assert cet.when == datetime(1969, 7, 21, 2, 56, 15)

    >>> cest = CEST.convert(dt)
    >>> assert cest.tzname == 'Central European Summer Time'
    >>> assert cest.when == datetime(1969, 7, 21, 2, 56, 15)
"""
from datetime import datetime


class Timezone:
    tzname: str


class CET(Timezone):
    tzname = 'Central European Time'


class CEST(Timezone):
    tzname = 'Central European Summer Time'


Code 5.19. Solution
"""
* Assignment: OOP Classmethod CSV
* Complexity: easy
* Lines of code: 4 lines
* Time: 13 min

English:
    1. To class `CSVMixin` add methods:
        a. `to_csv(self) -> str`
        b. `from_csv(self, text: str) -> Union['Astronaut', 'Cosmonaut']`
    2. `CSVMixin.to_csv()` should return attribute values separated with coma
    3. `CSVMixin.from_csv()` should return instance of a class on which it was called
    4. Use `@classmethod` decorator in proper place
    5. Run doctests - all must succeed

Polish:
    1. Do klasy `CSVMixin` dodaj metody:
        a. `to_csv(self) -> str`
        b. `from_csv(self, text: str) -> Union['Astronaut', 'Cosmonaut']`
    2. `CSVMixin.to_csv()` powinna zwracać wartości atrybutów klasy rozdzielone po przecinku
    3. `CSVMixin.from_csv()` powinna zwracać instancje klasy na której została wywołana
    4. Użyj dekoratora `@classmethod` w odpowiednim miejscu
    5. Uruchom doctesty - wszystkie muszą się powieść

Hints:
    * `CSVMixin.to_csv()` should add newline `\n` at the end of line
    * `CSVMixin.from_csv()` should remove newline `\n` at the end of line

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from dataclasses import dataclass

    >>> @dataclass
    ... class Astronaut(CSVMixin):
    ...     firstname: str
    ...     lastname: str
    ...
    >>> @dataclass
    ... class Cosmonaut(CSVMixin):
    ...     firstname: str
    ...     lastname: str

    >>> mark = Astronaut('Mark', 'Watney')
    >>> jan = Cosmonaut('Jan', 'Twardowski')
    >>> mark.to_csv()
    'Mark,Watney\\n'
    >>> jan.to_csv()
    'Jan,Twardowski\\n'

    >>> with open('_temporary.txt', mode='wt') as file:
    ...     data = mark.to_csv() + jan.to_csv()
    ...     file.writelines(data)

    >>> result = []
    >>> with open('_temporary.txt', mode='rt') as file:
    ...     lines = file.readlines()
    ...     result += [Astronaut.from_csv(lines[0])]
    ...     result += [Cosmonaut.from_csv(lines[1])]

    >>> result  # doctest: +NORMALIZE_WHITESPACE
    [Astronaut(firstname='Mark', lastname='Watney'),
     Cosmonaut(firstname='Jan', lastname='Twardowski')]
    >>> from os import remove
    >>> remove('_temporary.txt')
"""

from typing import Union


class CSVMixin:
    def to_csv(self) -> str:
        ...

    def from_csv(cls, line: str) -> Union['Astronaut', 'Cosmonaut']:
        ...