In From data to objects we defined a Task class and assigned attributes to it from outside, and we saw that this fixed nothing: we could still create a task without completed and with its priority written as "High". The only thing that changed was the name of the error, from KeyError to AttributeError. This lesson brings the missing piece, and it is the heart of the whole module. That piece is the constructor, __init__: a block that Python runs automatically when each instance is created, and which is therefore the only point in the program that every task ever to exist must pass through. That is where the essential data is demanded and its values validated. Alongside it come methods, which let us gather up the loose functions that have been wandering around easytask.py since module 4 and store them where they belong: inside the task itself. By the end of the lesson, EasyTask will be v0.15 and will work with objects.

Contents

  1. __init__: the constructor
  2. self, properly explained
  3. Validating inside the constructor
  4. Methods that read the state
  5. Methods that change the state
  6. __str__ versus __repr__
  7. Comparing objects with __eq__ (and a note on __len__)
  8. Class and method docstrings
  9. @property: computed values without duplicated state
  10. @staticmethod and @classmethod
  11. @dataclass: the shortcut for classes that only hold data
  12. EasyTask v0.15: the Task class
  13. Common mistakes and tips
  14. Exercises
  15. Conclusion

  1. __init__: the constructor

The constructor is a special method called __init__ (two underscores on each side) that Python runs immediately after creating an instance. Its job is to leave the object ready to use: to give a value to every one of its attributes.

class Task:
    """A task at Alba Studio."""
    def __init__(self, title, assignee, priority="medium", days=1):
        self.title = title
        self.assignee = assignee
        self.priority = priority
        self.days = days
        self.completed = False        # every task is born pending

poster = Task("Book fair poster", "Luis", "high", 3)
print(poster.title, poster.priority, poster.completed)   # ... high False
Task("Vidal quote")
# TypeError: __init__() missing 1 required positional argument: 'assignee'

Several new things happen at once, and they are worth reading slowly:

  • __init__ is never called by hand. You write Task("Book fair...", "Luis", "high", 3) and Python creates the object and passes those arguments to __init__ for you. It is the same mechanism by which list("abc") or int("42") build objects.
  • Parameters without a default value are the mandatory data. title and assignee have none, so it is impossible to create a task without them: the error fires in the right place at the right moment, as the last line of the example shows. The ones that do have a default work exactly as in functions (04-02): priority="medium" and days=1 let you write Task("Review sketches", "Nuria"), and they must come after the mandatory ones.
  • completed is not a parameter: it is fixed inside. No task is born completed, so there is no sense in asking about it at creation time. Some attributes are received and some are fixed or computed, and all of them leave the constructor with a value. Before, the guarantee that a task had its five fields depended on every programmer remembering; now the guarantee belongs to the language: if the object exists, its attributes exist, and the AttributeError halfway through the listing is no longer possible.

  1. self, properly explained

self is the word that confuses people most at first, and there is really no mystery to it: self is the object being worked on. When you write Task("Book fair poster", "Luis"), Python does two things: it manufactures an empty object and calls __init__ passing that object as the first argument. Inside the method, that first parameter is called self by convention, and self.title = title means "store the received value in this object".

What you write What Python actually runs
poster = Task("Poster", "Luis") creates the object and calls Task.__init__(object, "Poster", "Luis")
poster.complete() calls Task.complete(poster)
poster.reassign("Nuria") calls Task.reassign(poster, "Nuria")

Two rules come out of that and must be memorised: self is always the first parameter of every instance method, even if it receives nothing else; and self is not passed when calling, Python supplies it from the object on the left of the dot. That is why poster.complete() is written without arguments even though its definition is def complete(self). Two classic mistakes, with their exact message so you recognise them:

class Task:
    def __init__(title, assignee):            # WRONG: self is missing
        ...
Task("Poster", "Luis")
# TypeError: __init__() takes 2 positional arguments but 3 were given

The message looks absurd —"you gave 3 and it accepts 2"— until you remember that Python adds the object as the first argument. Without self, the numbers never add up. The second mistake produces no message at all, and that is why it is worse: writing title = title inside the constructor instead of self.title = title. That assigns to a local variable which dies when the method ends (it is module 4's scope in action); the object is left without a title and the failure will show up later and somewhere else. The rule is simple: anything that must outlive the call is stored in self.. Finally, self is not a keyword —you could call it me and it would work—, but do not do it: everybody writes self.

  1. Validating inside the constructor

Having all five attributes is not enough: their values must be correct too. And since the constructor is the compulsory step for every task, it is the natural place to check.

PRIORITIES = ("high", "medium", "low")
TEAM = ("marta", "luis", "nuria")

class Task:
    def __init__(self, title, assignee, priority="medium", days=1):     # v0.15
        self.title = title.strip()                        # drop stray spaces
        clean = assignee.strip().lower()
        self.assignee = clean.capitalize() if clean in TEAM else "Marta"
        priority = priority.strip().lower()               # 'High' -> 'high'
        self.priority = priority if priority in PRIORITIES else "medium"
        self.days = days if 1 <= days <= 365 else 1
        self.completed = False

t = Task("  Sole Bakery logo  ", "NURIA", "High", 5)
print(f"[{t.title}] {t.assignee} {t.priority} {t.days}d")
# [Sole Bakery logo] Nuria high 5d

Look at what just happened: "High", the value that in module 6 was going to blow up PRIORITY_ORDER, can no longer get into the program. The class normalises it at the single point every task passes through, and it does the same with the spaces in the title and with "NURIA". There are three possible strategies when faced with an invalid value:

Strategy What it does When it suits
Normalise Fixes what is fixable: "High""high", strips spaces Differences of format, not of content
Safe value Replaces the unrecoverable with a default Doubtful data that should not stop the program
Warn and reject Prevents the object from being created and reports the failure The right thing in a serious program

For now we use the first two deliberately. The third —raising an error with raise and catching it with try/except— is the content of Debugging and error handling; when you study it you will come back to this class and replace the safe values with explicit errors, without changing the structure. In the meantime there is a danger to watch: a silent safe value hides the problem. If somebody writes Task("Poster", "Pete") and the task ends up assigned to Marta without a word, the error exists but nobody sees it; at the very least you should warn on screen with a print(f"Warning: '{assignee}' is not on the team; assigned to Marta.") in that branch, and that is what the final version in section 12 does.

  1. Methods that read the state

A method is a function defined inside the class, with self as its first parameter, which therefore reaches the object's attributes directly. There are two families of them, and telling them apart helps you design well: the ones that query the state without touching it and the ones that modify it. We start with the former, which gather up logic that was until now scattered across loose functions (the class also has an attribute self.done = 0, the days already invested):

    def is_overdue(self):
        """Say whether more days have been spent than estimated."""
        return not self.completed and self.done > self.days

    def urgency(self):
        """Return the real urgency: 'none', 'critical' or the priority."""
        if self.completed:
            return "none"
        return "critical" if self.is_overdue() else self.priority

poster = Task("Book fair poster", "Luis", "high", 3)
poster.done = 5                                 # 5 days spent out of 3 planned
print(poster.is_overdue(), poster.urgency())    # True critical

Three observations that hold for every method you write. They do not receive the task as a parameter: we used to write is_overdue(task) and now the task is self and arrives on its own, so poster.urgency() says who and what without ambiguity. A method can call another one on the same object, always through self, as urgency does with self.is_overdue(); without the self., Python would look for a global function of that name and not find it. And these methods do not print: they return a value, which is the separation between input/output and logic from 04-04 applied inside classes; the caller decides whether to print it, add it up or use it to filter.

  1. Methods that change the state

The other family modifies the object's attributes. This is where mark_completed and change_priority, which since module 4 have been receiving a dictionary and modifying it in place, finally find their home:

    def complete(self):
        """Mark the task as finished. Return False if it already was."""
        if self.completed:
            return False
        self.completed = True
        self.done = max(self.done, self.days)
        return True

    def reassign(self, person):
        """Change the assignee if the person belongs to the team."""
        clean = person.strip().lower()
        if clean not in TEAM:
            return False
        self.assignee = clean.capitalize()
        return True

    def advance(self, days_worked):
        """Add days of work already invested in the task."""
        self.done += max(0, days_worked)

poster = Task("Book fair poster", "Luis", "high", 3)
print(poster.reassign("nuria"), poster.reassign("Pete"))   # True False
print(poster.complete(), poster.complete())   # True False: the second changes nothing

Two design decisions worth copying. They return True/False to signal whether the change happened, so the caller warns the user without the method printing anything: if not poster.reassign(name): print("That person is not on the team."). And the method protects the invariant: reassign does not accept an assignee outside TEAM, just as the constructor did. That is the general rule of encapsulation: if a piece of data has rules, every path that modifies it must respect them, and that is why it is changed through methods and not by writing poster.assignee = "Pete" from outside.

  1. __str__ versus __repr__

You already saw that print(poster) shows something unreadable: <__main__.Task object at 0x7f8b1c0d5e50>. It is fixed with two special methods:

    def __str__(self):                           # text for the end user
        mark = "[X]" if self.completed else "[ ]"
        return f"{mark} {self.title:<28}{self.assignee:<8}{self.priority:<6}{self.days}d"

    def __repr__(self):                          # text for the programmer
        return f"Task({self.title!r}, {self.assignee!r}, {self.priority!r}, {self.days})"

poster = Task("Book fair poster", "Luis", "high", 3)
print(poster)          # [ ] Book fair poster            Luis    high  3d
print(repr(poster))    # Task('Book fair poster', 'Luis', 'high', 3)
print([poster])        # [Task('Book fair poster', 'Luis', 'high', 3)]
__str__ __repr__
Who it is for The user of the program The programmer
Used by print(obj), str(obj), f-strings The console, repr(obj), objects inside lists
Goal To read well To be precise and unambiguous
If you define only one print works, but lists stay ugly print uses it too, as a stand-in

The last row is the practical advice: if you are only going to write one, write __repr__, because Python uses it as a substitute when __str__ is missing and it is also the one you see while debugging. The most common surprise is print([poster]): when printing a list, Python does not use the __str__ of its elements but their __repr__, so a list of tasks without __repr__ still shows memory addresses. The !r in the f-string, by the way, is the shortcut for applying repr() to a value: that is why the strings come out in quotes.

  1. Comparing objects with __eq__ (and a note on __len__)

By default, two different objects are never equal, even if they hold the same data. It is consistent with what we saw in 05-01 about is and ==, but it is almost never what we want. By defining __eq__ we decide ourselves what it means for two tasks to be the same:

    def __eq__(self, other):
        """Two tasks are the same if title and assignee match."""
        if not isinstance(other, Task):
            return NotImplemented          # comparing with anything else is not our job
        return (self.title.lower() == other.title.lower()
                and self.assignee == other.assignee)

a = Task("Book fair poster", "Luis", "high", 3)
b = Task("book fair poster", "Luis", "low", 9)
print(a == b, a == "Poster")    # True False (without __eq__, the first would be False)
print(a in [b])                 # True: the 'in' operator uses ==

The interesting part is the last line: by defining __eq__, operators and functions you already know start working with your objects. in, .count(), .index() and .remove() on lists use == internally, so they now locate tasks by content. Returning NotImplemented when the other object is not a Task is the polite way of saying "I do not know how to compare this"; Python then answers False. In the same family is __len__, which defines what len(obj) returns. On a single task it makes no sense —the length of what?—, but in the Agenda class of Collections of objects it will be obvious: len(agenda) should give the number of tasks. These methods with underscores are called special methods or dunder (from double underscore), and their point is that they connect your classes with the syntax of the language.

  1. Class and method docstrings

Just like functions, classes and their methods carry a docstring: a piece of text between triple quotes on the first line of the body. You have seen it in every example in this lesson —"""Mark the task as finished. Return False if it already was."""—, with one difference: a class docstring usually spans several lines, because as well as saying what the object represents it describes what it guarantees, as in """A task at the studio. Born pending, validated and with every field set.""".

They are consulted with help(Task) or Task.complete.__doc__, and the editor shows them as you type. The minimum rule: the class explains what it represents and what it guarantees; each method, what it does and what it returns. The full guide is in Documentation and comments.

  1. @property: computed values without duplicated state

Imagine you want to know how many days of work a task has left. The temptation is to store it as one more attribute, self.remaining_days = days, and that is where the problem starts: every time somebody calls advance() or complete() you have to remember to recompute it, and the day you forget, the object will show a false figure. That is duplicated state, one of the most silent sources of error there is. The solution is not to store it but to compute it on demand. And so that it reads like an attribute rather than a method, we use the @property decorator:

    @property
    def remaining_days(self):
        """Days of work left; zero if the task is already completed."""
        return 0 if self.completed else max(0, self.days - self.done)

poster = Task("Book fair poster", "Luis", "high", 3)
print(poster.remaining_days)     # 3   <- no parentheses: it looks like an attribute
poster.advance(2)
print(poster.remaining_days)     # 1   <- always consistent: recomputed
poster.complete()
print(poster.remaining_days)     # 0   <- and still consistent

The difference from a stored attribute lies in consistency: both are read the same way, with obj.x and no parentheses, but the attribute has to be updated by hand in every method that affects it, whereas the @property is computed on the spot and therefore never lies. In exchange it pays a small computation per read, which outside enormous loops is irrelevant. Decorators will already ring a bell from @lru_cache (06-03): they are functions that wrap another one to modify its behaviour. The practical rule: if a value can be deduced from other attributes, make it a @property; if it is independent data, store it. And note that a read-only @property does not accept assignment —poster.remaining_days = 5 raises AttributeError—, which is a protection, not an inconvenience.

  1. @staticmethod and @classmethod

Not every method needs a concrete object. A @staticmethod is an ordinary function that lives inside the class out of thematic affinity: it receives no self and touches no object. It is useful for domain utilities, such as checking a value before creating anything:

    @staticmethod
    def valid_priority(text):
        """Say whether a piece of text is an acceptable priority."""
        return text.strip().lower() in PRIORITIES

print(Task.valid_priority("HIGH"))    # True: from the class, with no instance

A @classmethod receives the class (cls) as its first parameter instead of the instance, and its most valuable use is the alternative constructor: another way of manufacturing objects from data in a different format. Exactly what we need for the JSON of 05-05, where each task is stored as a dictionary:

    @classmethod
    def from_dictionary(cls, data):
        """Create a Task from a dictionary read out of the JSON file."""
        task = cls(data["title"], data["assignee"],
                   data.get("priority", "medium"), data.get("days", 1))
        task.done = data.get("done", 0)
        task.completed = data.get("completed", False)
        return task

d = {"title": "Vidal quote", "assignee": "Marta", "priority": "High"}
print(Task.from_dictionary(d))   # [ ] Vidal quote                 Marta   high  1d

Notice the detail that makes it robust: data.get("priority", "medium") tolerates the key being missing and the constructor normalises the "High" that came from the file. In other words, the dirty dictionaries of module 6 go in, and clean tasks come out. The reverse operation —turning the object into a dictionary so it can be saved— is to_dict(), and it arrives in 07-03 alongside the Agenda class, because json.dump cannot write objects.

  1. @dataclass: the shortcut for classes that only hold data

When a class does nothing but group data together, writing __init__, __repr__ and __eq__ by hand is pure paperwork. The standard library's dataclasses module generates them for you. The same Client, before and after:

class Client:                                    # BEFORE: all by hand
    def __init__(self, name, contact, active=True):
        self.name, self.contact, self.active = name, contact, active
    def __repr__(self): ...                      # the f-string with the three fields
    def __eq__(self, other): ...                 # isinstance + field-by-field comparison

from dataclasses import dataclass                # AFTER: the same, generated

@dataclass
class Client:
    """A client of the studio."""
    name: str
    contact: str
    active: bool = True

sole = Client("Sole Bakery", "[email protected]")
print(sole, sole == Client("Sole Bakery", "[email protected]"))
# Client(name='Sole Bakery', contact='[email protected]', active=True) True

The decorator reads the name: str lines —type annotations, which Python does not check but which serve as documentation and help the editor— and generates __init__, __repr__ and __eq__. When to use which: if the class only groups data, @dataclass; if there is validation, normalisation, invariants or behaviour of its own, an ordinary class with a hand-written __init__. Our Task validates, normalises and decides, so it stays an ordinary class; Client, which only carries data, is a textbook @dataclass.

  1. EasyTask v0.15: the Task class

Let us apply the lot. The loose functions that worked on dictionaries become methods, and the agenda becomes a list of Task objects.

# easytask.py - Alba Studio / Version 0.15: the task is an object
FIELDS = ("title", "assignee", "priority", "days", "done", "completed")
# --- The rest of the constants and I/O functions: unchanged since v0.14 ---
class Task:
    """A task at the studio. Born pending, validated and with every field set."""
    # __init__ as in section 3, plus the warning for an invalid assignee and
    #     self.done = 0; remaining_days (@property), is_overdue, urgency,
    #     complete, reassign, change_priority, advance, from_dictionary,
    #     __str__, __repr__ and __eq__ exactly as written in this lesson

    def sort_key(self):
        """Listing criterion: priority, then days, then title."""
        return (PRIORITY_ORDER[self.priority], self.days, self.title)

def register_task(agenda):
    """Ask for a new task and add it to the agenda."""
    agenda.append(Task(ask_text("Title        : "),
                       ask_option("Assignee     : ", TEAM),
                       ask_option("Priority     : ", PRIORITIES),
                       ask_integer("Days (1-365) : ", 1, 365)))

def show_list(agenda):
    """Show the agenda sorted by priority and days."""
    for number, task in enumerate(sorted(agenda, key=Task.sort_key), start=1):
        print(f"{number:>2}. {task}")           # this is where __str__ acts

Two new details. In the print inside the loop, {task} invokes __str__ automatically: the function no longer knows anything about the internal structure of a task. And key=Task.sort_key works because a method reached from the class is an ordinary function that receives the object as its first argument, so sorted will pass each task as self; key=lambda t: t.sort_key() would work too. Before and after, line by line:

In v0.14 (dictionaries) In v0.15 (objects)
task["title"] task.title
show_card(task) print(task), thanks to __str__
sort_key(task) / mark_completed(task) task.sort_key() / task.complete()
Validation spread through register_task Centralised in __init__
A possible KeyError in any listing Impossible: if the object exists, it has its fields

One loose end is obvious: save_tasks no longer works. json.dump knows how to write dictionaries, but not Task objects, and it complains with TypeError: Object of type Task is not JSON serializable. The solution —a to_dict() method returning the equivalent dictionary, with from_dictionary for the way back— arrives in the next lesson.

Common Mistakes and Tips

  • Forgetting self in the method definition, with the message takes 1 positional argument but 2 were given. Remember that Python adds the object: if the method receives n arguments when called, its definition needs n + 1 parameters. And forgetting the self. when assigning inside the constructor gives no error at all: it creates a local variable that is lost, so if an attribute "disappears", check this first.
  • Putting a mutable as a parameter's default value, def __init__(self, notes=[]). It is the same mistake from module 4 and here it is worse, because that list is shared by every instance. Use notes=None and inside self.notes = notes if notes else []. Nor is it ever necessary to call __init__ by hand (poster.__init__(...)): write Task(...).
  • Defining only __str__ and expecting lists to look good. print([task]) uses __repr__: define both, or at least __repr__. And do not modify attributes from outside, bypassing the methods (task.priority = "High"), because it breaks exactly the guarantee the class provides: if a piece of data has rules, go through the method.
  • Tip: put the methods in order. First __init__, then the @property blocks, then the methods that query, then the ones that modify, and the special ones last. A tidy class can be read at a glance.

Exercises

Exercise 1: The Client class

Write a Client class whose constructor receives name, contact and credit_days=30; which normalises the name (no stray spaces and with the first letter of each word capitalised), forces credit_days to be between 0 and 90 (otherwise 30) and sets invoices = []. Add an invoice(amount) method that appends the amount to the list if it is positive and returns True/False, and a @property total_invoiced. Try it out with Solé Bakery.

Exercise 2: A method from a loose function

This function worked with dictionaries in v0.14. Turn it into a summary() method of Task without changing its behaviour and explain in writing what improves: return f"{task['title']} ({task['assignee']}): {status}, {task['days']} days", where status is "completed" or "pending" depending on task["completed"].

Exercise 3: @property versus stored attribute

Add to Task a @property called percentage that returns progress as a percentage (days done over days estimated, capped at 100, and 100 if it is completed). Then answer in writing: what would happen if instead of a @property you stored self.percentage in the constructor? Write the sequence of calls that would demonstrate the failure.

Solutions

Solution 1.

class Client:
    """A client of the studio, with its credit and its invoices."""
    def __init__(self, name, contact, credit_days=30):
        self.name = name.strip().title()
        self.contact = contact.strip().lower()
        self.credit_days = credit_days if 0 <= credit_days <= 90 else 30
        self.invoices = []             # mutable: always created in the constructor

    @property
    def total_invoiced(self):
        """Sum of every invoice issued to the client."""
        return sum(self.invoices)

    def invoice(self, amount):
        """Add an invoice if the amount is positive."""
        if amount <= 0:
            return False
        self.invoices.append(amount)
        return True

sole = Client(" sole bakery ", "[email protected]", 120)
sole.invoice(450.0)
print(sole.name, sole.credit_days, sole.invoice(-10), sole.total_invoiced)
# Sole Bakery 30 False 450.0   <- name normalised, safe credit, rejection

The key is self.invoices = [] inside the constructor: if it were in the body of the class it would be a shared class attribute and one client's invoices would show up in the others (07-01, section 7). And total_invoiced is a @property because it is a sum deducible from invoices: storing it would force you to update it on every invoice.

Solution 2.

    def summary(self):
        """Return a descriptive line for the task."""
        status = "completed" if self.completed else "pending"
        return f"{self.title} ({self.assignee}): {status}, {self.days} days"

Three concrete improvements. It lives next to the data: if tomorrow the task gains a field, the summary is three lines away and not somewhere else in the file. It cannot receive something that is not a task: task_summary({"title": "x"}) blew up with KeyError, whereas task.summary() only exists if task is a Task. And it reads better at the point of use: print(poster.summary()) says whose summary it is without having to look at any function's signature.

Solution 3.

    @property
    def percentage(self):                        # progress, between 0 and 100
        if self.completed:
            return 100
        return min(100, round(self.done / self.days * 100))

# If instead of the @property you stored self.percentage = 0 in the constructor:
t = Task("Book fair poster", "Luis", "high", 4)
t.advance(2)                 # half of it is done
print(t.percentage)          # 0  <- A LIE: nobody updated the attribute

advance modifies done, but the percentage attribute keeps the value the constructor gave it. That is duplicated state: the same fact —how much progress has been made— stored in two places that can disagree. With the @property, the percentage does not exist until it is asked for and therefore cannot be out of date. The same trap would appear with complete(), which also changes done without touching percentage.

Conclusion

The constructor __init__ is the compulsory step for every object of a class, and that is why it is where we guarantee that every task is born complete: parameters without a default value are the essential data, those with one cover the optional part, and the attributes that are not received —such as completed— are fixed inside. That is also where we validate: normalising "High" to "high" and checking the assignee against TEAM eliminates module 6's KeyError at the root, and when you study raise and try/except in 08-02 you will replace the safe values with explicit errors. self is not magic: it is the object itself, which Python passes as the first argument based on what sits to the left of the dot, and anything that must outlive the call is stored in self.. Methods gather up the logic that was lying loose, distinguishing the ones that query the state (is_overdue, urgency, sort_key) from the ones that modify it (complete, reassign, change_priority), which return True/False instead of printing. Special methods connect the class with the language: __str__ for the user, __repr__ for the programmer —and it is the one lists use—, __eq__ so that == and in compare content. @property turns any value deducible from the others into a read-only attribute and removes duplicated state; @staticmethod houses object-free utilities and @classmethod provides alternative constructors such as Task.from_dictionary(d), the entry point for the JSON data. And @dataclass saves the manual work in classes that only group data.

EasyTask is now v0.15: the agenda is a list of Task objects, the listing is printed with {task} and seven loose functions have moved inside the class. But look at what has been left dangling. The agenda is still a bare list: any part of the program can append whatever it likes to it —including a stray dictionary or a number—, the operations that handle it (search, filter, sort, summarise) are still spread around the file, and save_tasks is literally broken because json.dump cannot write objects. In Collections of objects we will take today's same leap, but one level up: enter the Agenda class, which contains the tasks and offers add, find, filter and summary; you will learn to iterate, sort and index objects, you will see why a collection must protect its internal list, and you will recover persistence with to_dict/from_dict so we can keep saving to the same JSON file from 05-05.

© Copyright 2026. All rights reserved