DrawableIn
protocols.py, add a classTrianglewith its owndraw(), and pass an instance torender()without changingDrawableorrender().
Structural
Typing with Protocols shows Circle and
Square passing as Drawable without
naming it. A class qualifies by having the members the
Protocol declares. Write Triangle with
a draw() that has the same signature, and leave
Drawable and render() untouched.
# The shape of exercise_1.py
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> str: ...
class Circle:
def draw(self) -> str:
...
class Square:
def draw(self) -> str:
...
class Triangle:
def draw(self) -> str:
...
def render(shape: Drawable) -> str:
...If you name Triangle’s method
paint(), as the chapter’s Blob does,
ty check rejects render(Triangle())
with an invalid-argument-type, reporting that
protocol member draw is not defined on type
Triangle. The call also fails at run time with an
AttributeError, because render() calls
shape.draw(). A protocol matches on names and
signatures, so the solution’s method is the
draw() -> str that Drawable
declares.
# exercise_1.py
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> str: ...
class Circle:
def draw(self) -> str:
return "circle"
class Square:
def draw(self) -> str:
return "square"
class Triangle:
def draw(self) -> str:
return "triangle"
def render(shape: Drawable) -> str:
return shape.draw()
print(render(Circle()))
#: circle
print(render(Square()))
#: square
print(render(Triangle()))
#: triangleTriangle doesn’t mention Drawable,
the same as Circle and Square. It
qualifies because it has a draw() -> str method,
and Drawable requires no more than that. Neither
Drawable nor render() needs to change
to accept a Triangle.
# type: ignore from area.pyIn
area.py, remove the# type: ignorecomment and runty checkon the file. Read the error, then restore the comment.
Catching
Mistakes shows the comment silencing the error on a call
that passes area() a str. Remove the
comment, run ty check on the file, and read which
argument and which parameter the diagnostic marks. Then consider
what the call does at run time without the checker.
def area(width: int, height: int) -> int:
return width * height
print(area("3", 4))That is the function and the call from the chapter’s area.py, without its
comments. Running ty check on that file with the
# type: ignore comment removed reports:
error[invalid-argument-type]: Argument to function `area` is incorrect
--> area.py:6:12
|
6 | print(area("3", 4))
| ^^^ Expected `int`, found `Literal["3"]`
info: Function defined here
--> area.py:2:5
|
2 | def area(width: int, height: int) -> int:
| ^^^^ ---------- Parameter declared here
The type checker pinpoints the mistake the chapter describes:
"3" is a str, not an int,
so it violates width: int. The call still runs
without error at runtime, because "3" * 4 is valid
string repetition. In the book, the # type: ignore
comment on the area("3", 4) line lets a
deliberately wrong example pass the book’s build. Removing the
comment restores the error.
last()In
generics.py, write a second generic function,last[T](items: list[T]) -> T, that returns the final element, and call it on both alist[int]and alist[str]the way the listing callsfirst().
Type
Parameters shows first() with one type
parameter T shared by the argument and the return
type. Give last() the same signature, index from
the other end of the list, and call it on a
list[int] and a list[str].
# The shape of exercise_3.py
def first[T](items: list[T]) -> T:
...
def last[T](items: list[T]) -> T:
...If you annotate last() the way the chapter
annotates first_any(), with a bare
list parameter and an Any return,
ty check accepts .upper() on the
result of last([10, 20, 30]). The call then fails
at run time with an AttributeError:
'int' object has no attribute 'upper'. The solution
shares one type parameter T between the argument
and the return type, so each call’s result keeps the list’s
element type.
# exercise_3.py
def first[T](items: list[T]) -> T:
return items[0]
def last[T](items: list[T]) -> T:
return items[-1]
print(last([10, 20, 30]))
#: 30
print(last(["a", "b", "c"]))
#: clast() mirrors first(): one type
parameter T, inferred from whatever
list[T] the caller passes. Calling
last() on a list[int] makes
T int for that call, and on a
list[str] makes T str,
the same as first() does. The type checker
therefore knows that last([10, 20, 30]) returns an
int and last(["a", "b", "c"]) returns
a str.
NamedTally still chains through
SelfIn
self_type.py, add a subclass ofNamedTallycalledLoudTallywhosereport()returns the message in all capitals, callingsuper().report()first. Confirm.bump().bump().report()still chains correctly on aLoudTally.
The
Self Return Type explains why
bump() declares its return type as
Self instead of a class name. Subclass
NamedTally, override report() to build
on super().report(), and chain bump()
twice before calling report() on the
LoudTally.
# The shape of exercise_4.py
from typing import Self
class Tally:
def __init__(self) -> None:
...
def bump(self) -> Self:
...
class NamedTally(Tally):
def __init__(self, name: str) -> None:
...
def report(self) -> str:
...
class LoudTally(NamedTally):
def report(self) -> str:
...# exercise_4.py
from typing import Self
class Tally:
def __init__(self) -> None:
self.count = 0
def bump(self) -> Self:
self.count += 1
return self
class NamedTally(Tally):
def __init__(self, name: str) -> None:
super().__init__()
self.name = name
def report(self) -> str:
return f"{self.name}: {self.count}"
class LoudTally(NamedTally):
def report(self) -> str:
return super().report().upper()
t = LoudTally("clicks")
print(t.bump().bump().report())
#: CLICKS: 2Keep the subclass type through the chain.
Tally declares bump() with return type
Self, which the type checker resolves to the class
of the object receiving the call. On a LoudTally,
Self means LoudTally, so
t.bump().bump() type-checks as a
LoudTally and .report() is available
on the result. The .report() call resolves to
LoudTally.report(), because Python starts method
lookup at the object’s own class. If bump()’s
return annotation is the fixed type Tally instead
of Self, the type checker rejects
.report() on the chained result, since
Tally has no report() method.
Add
reveal_type(words.top())totype_defaults.pyand runty checkon the file. Remove the= strdefault and run it again. The type checker reports no error either way. Say what that means for a bareStackannotation.
Type
Parameter Defaults gives Stack a default for
T so a bare Stack annotation still
means something. Use reveal_type() from
typing on words.top() with and without
the default, and compare what the checker reports. The question
is what the checker knows about an expression whose type
parameter nothing solves.
# The shape of exercise_5.py
from typing import reveal_type
class Stack[T = str]:
def __init__(self) -> None:
...
def push(self, item: T) -> None:
...
def top(self) -> T:
...# exercise_5.py
from typing import reveal_type
class Stack[T = str]:
def __init__(self) -> None:
self.items: list[T] = []
def push(self, item: T) -> None:
self.items.append(item)
def top(self) -> T:
return self.items[-1]
words: Stack = Stack()
words.push("beta")
reveal_type(words.top()) # ty: str
print(words.top().upper())
#: BETAWith = str in place, ty check
reports str for
reveal_type(words.top()). Without the default,
ty reports Unknown and still finds no
errors in the file. An unsolved type parameter does not fail the
check. It switches the check off for every expression built on
it. words.top().upper() passes either way. Without
the default, so does words.top().no_such_method().
A default turns a bare annotation that checks nothing into one
that checks, and that is the reason to give a default to a class
whose parameter is usually the same type.
Literal that does not admit
"purple"In
type_aliases.py, callpaint(grid, (2, 3), "purple")and runty check. Read the error, then widenColorto admit"purple"and confirm the error goes away.
In Naming
Types: The type Statement, Color
is an alias for a Literal union of strings. Call
paint() with "purple" and read how the
diagnostic names the parameter’s type. Then add the string to
the one type statement that defines the alias and
run the check again.
# The shape of exercise_6.py
from typing import Literal
type Coord = tuple[int, int]
type Grid = dict[Coord, str]
type Color = Literal[
"red", "blue", "green", "yellow", "purple"]
def paint(grid: Grid, cell: Coord, color: Color) -> None:
...# exercise_6.py
from typing import Literal
type Coord = tuple[int, int]
type Grid = dict[Coord, str]
type Color = Literal[
"red", "blue", "green", "yellow", "purple"]
def paint(grid: Grid, cell: Coord, color: Color) -> None:
grid[cell] = color
grid: Grid = {}
paint(grid, (2, 3), "purple")
print(grid)
#: {(2, 3): 'purple'}Before "purple" joins Color, the
call runs and stores the string, because a Literal
constrains nothing at run time. ty check
reports:
error[invalid-argument-type]: Argument to function `paint` is incorrect
--> type_aliases.py:12:21
|
12 | paint(grid, (2, 3), "purple")
| ^^^^^^^^ Expected `Color`, found `Literal["purple"]`
info: Function defined here
--> type_aliases.py:8:5
|
8 | def paint(grid: Grid, cell: Coord, color: Color) -> None:
| ^^^^^ ------------ Parameter declared here
The diagnostic names the alias rather than the union behind
it, so you read Color and find the four permitted
strings in the type statement. An alias makes that
trade: a short message and one place to change the allowed set,
against following the name to see what the set is.
Widen the set at its definition. Adding
"purple" to the alias removes the error at every
call. grid[cell] = color needs no change, since
Grid’s values are str and every
Color is a str.
add_square() to Sequence[Shape]In
variance.py, changeadd_square()’s parameter annotation toSequence[Shape]and uncomment the call. Explain why the type checker now accepts the call and whyshapes.append(...)no longer type-checks.
Variance
shows the checker refusing a list[Circle] where the
parameter requires a list[Shape]. Import
Sequence from collections.abc and
compare which operations it offers with those of
list. The answer to both questions follows from
whether you can write to the container.
# The shape of exercise_7.py
from collections.abc import Sequence
class Shape:
pass
class Circle(Shape):
pass
class Square(Shape):
pass
def count(shapes: Sequence[Shape]) -> int:
...
def add_square(shapes: Sequence[Shape]) -> None:
# ty: "Sequence[Shape]" has no attribute "append":
# shapes.append(Square())
...# exercise_7.py
from collections.abc import Sequence
class Shape:
pass
class Circle(Shape):
pass
class Square(Shape):
pass
def count(shapes: Sequence[Shape]) -> int:
return len(shapes)
def add_square(shapes: Sequence[Shape]) -> None:
# ty: "Sequence[Shape]" has no attribute "append":
# shapes.append(Square())
print("would add a square to", len(shapes), "shapes")
circles: list[Circle] = [Circle(), Circle()]
add_square(circles) # Now accepted
#: would add a square to 2 shapes
print(count(circles))
#: 2Widen what the parameter accepts. The type
checker accepts the call because Sequence is
covariant in its element type. A Sequence[Shape]
declares only that you can read Shapes out of it,
and every Circle you read out is a
Shape, so a list[Circle] meets that
requirement. list[Shape] refuses the same argument
because list is invariant.
Give up the write operations.
shapes.append(...) stops type-checking for the
reason the widening works. Sequence has no
append(): it is the read-only abstract shape, so
the diagnostic is unresolved-attribute rather than
an argument-type error. The type checker does not say “you may
not append a Square here.” It says the type you
declared has no such operation.
The one edit shows both sides of variance. A container you can write to is invariant, and giving up the writes makes it covariant. The practical rule follows: annotate a parameter with the weakest shape the body needs, because each capability you declare rejects the callers whose argument lacks it.
is not NoneIn
narrowing.py, replaceif text is not None:withif text:and runty check. Explain why the empty string now takes the other branch even though the type checker accepts either version.
Narrowing
shows is not None removing None from a
str | None before shout() calls
.upper(). A truthiness test narrows the same way,
so the checker accepts it. Feed the function None,
a normal string, and the empty string, and compare which values
each test sends to which branch.
# The shape of exercise_8.py
def shout(text: str | None) -> str:
...# exercise_8.py
def shout(text: str | None) -> str:
if text:
return text.upper()
return "(nothing)"
print(shout("hi"))
#: HI
print(shout(None))
#: (nothing)
print(shout("")) # The empty string is falsy
#: (nothing)Narrow out the missing value. The type
checker accepts either version, because truthiness narrows too.
None is falsy, so inside if text: the
type checker rules out None the same as
is not None does, and .upper() is safe
in both versions.
Send empty values to the fallback. The
change is in which values reach which branch.
is not None asks one question, whether the value is
missing. if text: asks a different one, whether the
value is missing or empty, and answers both with
"(nothing)". The function can no longer tell an
empty string a caller passed on purpose from no string.
Whether that matters depends on the caller. The type checker
cannot tell you, because both versions are type-correct. The
truthiness test is the same trap as if not target:
on a mutable default in Functions, and the
same answer applies. Test for the condition you mean. Use
is None when you mean “was anything supplied,” and
truthiness when an empty value belongs with the missing one.
In
narrowing_attribute.py, copyb.valinto a local variable before theifand use the local inside it. Replace theexpect()call withprint(show(box))for aBoxnamedbox, then printbox.val. Explain why theAttributeErrordisappears althoughreset()still runs.
Narrowing
shows the checker keeping a narrowing of b.val
although reset() can change that attribute. Bind
b.val to a local name before the if,
so the narrowed expression is a variable instead of an
attribute. Then consider which name reset() can
rebind.
# The shape of exercise_9.py
class Box:
def __init__(self, val: str | None) -> None:
...
def reset(self) -> None:
...
def show(b: Box) -> str:
...If you copy b.val into val and test
val but still call b.val.upper(), the
test narrows the local and leaves the attribute at
str | None. ty check then reports an
unresolved-attribute on b.val.upper(),
and the call fails at run time with an
AttributeError, because reset() has
set the attribute to None. The solution calls
upper() on the local, the name the test
narrowed.
# exercise_9.py
class Box:
def __init__(self, val: str | None) -> None:
self.val = val
def reset(self) -> None:
self.val = None
def show(b: Box) -> str:
val = b.val
if val is not None:
b.reset()
return val.upper()
return "(nothing)"
box = Box("hi")
print(show(box))
#: HI
print(box.val)
#: NoneNarrow a name no call can rebind.
reset() still runs and still sets the attribute to
None, as the second line of output shows. The call
cannot change val, though. val is a
second name for the string "hi", and
reset() rebinds b.val, not the local.
The narrowing of val to str therefore
holds through the call, and the type checker’s verdict matches
what the program does. In the chapter’s version the verdict
concerns b.val, which the call changes after the
test.