Methods
In Lesson 2, James wrote class Note with __init__ and self. The object holds data, but it does not do anything yet. James looks at the class. "Now I want it to do things."
Emma nods. "Functions that live inside a class are called methods. They have access to self, which means they can read the object's data, change it, and enforce rules about how it changes. That is the difference between a function floating beside an object and a method that belongs to it."
When you add a method to a class, you are defining a new interface. Specify the method signature with types, write a test that calls it, prompt AI to implement, verify with pytest. Same cycle.
A method is a function defined inside a class. It always takes self as its first parameter, which gives it access to the object's data. When you call note.summarize(), Python automatically passes the note object as self.
Instance methods receive self implicitly. The method signature defines the interface; type annotations on parameters and return values serve as documentation and enable static analysis. Methods that modify state conventionally return None; methods that query state return a value. This convention makes the caller's intent clear.
Three Kinds of Methods
Methods fall into three categories based on what they do with instance state:
| Kind | What it does | Return type | Example |
|---|---|---|---|
| Reader | Computes a value from fields | A value (str, int, bool) | .summarize() -> str |
| Mutator | Changes fields, optionally with validation | None | .add_tag(tag: str) -> None |
| Query | Checks a condition on fields | bool | .has_tag(tag: str) -> bool |
All three use self to access the object's data. The difference is whether they change it.
Building the Note Class with Methods
Start with the Note class from Lesson 2 and add three methods. Create a file called note_methods.py:
class Note:
def __init__(
self,
title: str,
body: str,
author: str = "Anonymous",
is_draft: bool = True,
tags: list[str] | None = None,
) -> None:
self.title: str = title
self.body: str = body
self.author: str = author
self.is_draft: bool = is_draft
self.tags: list[str] = tags if tags is not None else []
def summarize(self) -> str:
"""Return a one-line summary of the note."""
word_count: int = len(self.body.split())
return f"{self.title} ({word_count} words)"
def add_tag(self, tag: str) -> None:
"""Add a tag if it is not already present."""
if tag in self.tags:
raise ValueError(f"Tag '{tag}' already exists")
self.tags.append(tag)
def has_tag(self, tag: str) -> bool:
"""Check whether this note has a specific tag."""
return tag in self.tags
Notice: word_count is no longer a field. It is computed from self.body every time summarize() is called. This eliminates the synchronization problem from Lesson 1: the word count always matches the current body.
Add these lines at the bottom and run the file:
note = Note("Python Basics", "Variables store values in memory")
print(note.summarize())
note.add_tag("python")
note.add_tag("beginner")
print(note.tags)
print(note.has_tag("python"))
print(note.has_tag("advanced"))
Output:
Python Basics (5 words)
['python', 'beginner']
True
False
The reader method (.summarize()) computes a value without changing anything. The mutator method (.add_tag()) changes self.tags. The query method (.has_tag()) checks a condition without changing anything.
Validation: Methods That Enforce Rules
The add_tag method does not blindly append. It checks for duplicates first:
def add_tag(self, tag: str) -> None:
if tag in self.tags:
raise ValueError(f"Tag '{tag}' already exists")
self.tags.append(tag)
Try adding the same tag twice:
note = Note("Test", "Body text")
note.add_tag("python")
note.add_tag("python") # What happens?
Output:
Traceback (most recent call last):
File "note_methods.py", line 35, in <module>
note.add_tag("python")
File "note_methods.py", line 22, in add_tag
raise ValueError(f"Tag '{tag}' already exists")
ValueError: Tag 'python' already exists
This is why the dataclass ceiling matters. A bare list field lets anyone append anything. A method controls how the list changes. The rule (no duplicates) lives inside the object, not scattered across the codebase.
Methods vs Standalone Functions
In Phases 1 through 4, you wrote standalone functions that took Note as a parameter:
# Standalone function (Phase 4 style)
def summarize_note(note: Note) -> str:
word_count: int = len(note.body.split())
return f"{note.title} ({word_count} words)"
# Method (Phase 5 style)
class Note:
def summarize(self) -> str:
word_count: int = len(self.body.split())
return f"{self.title} ({word_count} words)"
Both work. The difference:
| Aspect | Standalone function | Method |
|---|---|---|
| Call syntax | summarize_note(note) | note.summarize() |
| Access to data | Through the note parameter | Through self |
| Where it lives | Anywhere in the codebase | Inside the class definition |
| Who "owns" the behavior | The module | The object |
Methods bind behavior to the object. When you read note.summarize(), you know immediately: this is something a Note can do. When you read summarize_note(note), you have to find the function definition to know what it does.
Testing Methods: Create, Call, Assert
Testing a method follows the pattern you already know from TDG, adapted for objects:
# test_note.py
from note_methods import Note
def test_summarize_counts_words() -> None:
note = Note("Test", "one two three")
result: str = note.summarize()
assert result == "Test (3 words)"
def test_add_tag_appends() -> None:
note = Note("Test", "body")
note.add_tag("python")
assert note.tags == ["python"]
def test_add_tag_rejects_duplicate() -> None:
note = Note("Test", "body")
note.add_tag("python")
try:
note.add_tag("python")
assert False, "Should have raised ValueError"
except ValueError:
pass # Expected
def test_has_tag_returns_true_when_present() -> None:
note = Note("Test", "body", tags=["python"])
assert note.has_tag("python") is True
def test_has_tag_returns_false_when_absent() -> None:
note = Note("Test", "body")
assert note.has_tag("python") is False
Run the tests:
uv run pytest test_note.py -v
Output:
test_note.py::test_summarize_counts_words PASSED
test_note.py::test_add_tag_appends PASSED
test_note.py::test_add_tag_rejects_duplicate PASSED
test_note.py::test_has_tag_returns_true_when_present PASSED
test_note.py::test_has_tag_returns_false_when_absent PASSED
The pattern: create an instance with known state, call a method, assert the state changed (or the return value is correct). Each test isolates one behavior.
PRIMM-AI+ Practice: Predict the Validation
Predict [AI-FREE]
Press Shift+Tab to enter Plan Mode before predicting.
Read this code. Without running it, predict the output of each print statement. Write your predictions and a confidence score from 1 to 5.
note = Note("Ideas", "Some thoughts on Python")
note.add_tag("python")
note.add_tag("ideas")
print(note.has_tag("python"))
print(note.has_tag("PYTHON"))
print(len(note.tags))
try:
note.add_tag("python")
print("Added successfully")
except ValueError as e:
print(f"Caught: {e}")
Check your predictions
True
False
2
Caught: Tag 'python' already exists
.has_tag("PYTHON") returns False because the check is case-sensitive: "PYTHON" is not the same string as "python". The duplicate check in .add_tag("python") raises ValueError because "python" is already in the list.
If you predicted True for "PYTHON", that is a common assumption. Tags are strings, and string comparison in Python is case-sensitive by default.
Run
Press Shift+Tab to exit Plan Mode.
Add the code above to note_methods.py and run it. Confirm the output matches your prediction. If you got the case-sensitivity wrong, that is a valuable insight: methods inherit the behavior of the data types they use.
Investigate
In Claude Code, type:
/investigate @note_methods.py
Ask: "Should has_tag and add_tag be case-insensitive? What would change in the implementation, and what new test cases would I need?" Compare the AI's suggestions to your own ideas.
Modify
Add a remove_tag(self, tag: str) -> None method that:
- Raises
ValueErrorif the tag is not present - Removes the tag if it is present
Write two test cases for it: one for successful removal, one for the error case. Run uv run pytest test_note.py -v and verify both pass.
Make [Mastery Gate]
Write a TodoItem class with:
__init__takingdescription: strandpriority: int(1-5, default 3)- A reader method
.display() -> strthat returns"[P3] Buy groceries" - A mutator method
.escalate() -> Nonethat increases priority by 1, raisingValueErrorif already at 5 - A query method
.is_urgent() -> boolthat returnsTrueif priority is 4 or 5
Write tests for each method, including the edge case where .escalate() is called on a priority-5 item. All tests must pass.
Try With AI
If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.
Prompt 1: Add a Method to Note
Here is my Note class with summarize, add_tag, and has_tag:
[paste your note_methods.py]
Add a publish method that:
- Sets is_draft to False
- Raises ValueError if the note is already published
- Returns None
Include type annotations and a docstring. Also write
three test cases for it.
Review the AI's implementation. Does it check self.is_draft before changing it? Does it raise ValueError with a clear message? Run the tests yourself to verify.
What you're learning: You are specifying a method interface (name, parameters, behavior, error case) and evaluating whether the AI's implementation matches your specification. This is the TDG cycle applied to methods.
Prompt 2: Refactor Functions to Methods
I have these standalone functions that operate on Note objects:
def word_count(note: Note) -> int:
return len(note.body.split())
def is_long(note: Note, threshold: int = 100) -> bool:
return len(note.body.split()) > threshold
Convert them into methods on the Note class. Show me the
before and after, and explain what changes when a function
becomes a method.
Compare the AI's refactoring to the pattern in this lesson. The key change: note parameter becomes self, and the call syntax changes from word_count(note) to note.word_count().
What you're learning: You are seeing how standalone functions transform into methods, reinforcing the relationship between self and the external parameter they replace.
Prompt 3: Design Methods for Your Domain
I work in [describe your professional domain]. I have a
class called [name from Lesson 2 Prompt 3]. Design three
methods for it:
1. A reader method that computes something from the fields
2. A mutator method that changes state with validation
3. A query method that checks a condition
Include type annotations, docstrings, and one test case
per method.
Evaluate the AI's method designs. Do the mutator methods validate before changing state? Do the query methods return bool? Are the test cases following the create-call-assert pattern?
What you're learning: You are applying the three method categories (reader, mutator, query) to your own domain and evaluating whether the AI's implementation follows the patterns from this lesson.
James looks at his Note class with its three methods and five tests, all passing green. "So a method is just a function with self?"
"Structurally, yes," Emma says. "But the difference is ownership. When add_tag lives inside Note, the validation rule lives with the data it protects. When add_tag is a standalone function somewhere in the codebase, someone can modify note.tags directly and bypass the check entirely."
"Like buttons on a machine," James says. "In the warehouse, every sorting machine has a control panel. Press 'add item' and it checks the weight limit before accepting. Press 'status' and it tells you the current load. You do not reach inside the machine and move items by hand."
Emma laughs. "Your button analogy is actually better than my usual explanation. I normally talk about message passing and encapsulation, but 'press the button, do not reach inside the machine' is more intuitive."
"So the methods are the buttons," James says. "And self is the machine knowing its own state when a button is pressed."
"Exactly. And now your Note has buttons: .summarize() reads the label, .add_tag() attaches a tag with a duplicate check, .has_tag() checks whether a tag exists. The machine knows its own state. The next lesson gives it more sophisticated buttons: methods that coordinate multiple fields and properties that compute values on the fly."