Python Static Analysis in Practice: Practice Problems & Exercises
Practice: Static Analysis in Practice
← Back to lessonEasy
The code below contains several common mypy errors. Fix them with proper type annotations and Optional usage so it runs cleanly.
from typing import Optional
def greet(name: Optional[str] = None) -> str:
# Fix: handle None explicitly
if name is None:
name = "World"
return f"Hello, {name}!"
def count_items(items: list) -> int:
# Fix: return annotation was missing
return len(items)
def get_first_name(full_name: str) -> Optional[str]:
# Fix: may return None if there are no spaces
parts = full_name.split(" ")
return parts[0] if parts else None
result = greet("Alice")
print(f"greeting: {result}")
name = get_first_name("Alice Smith")
print(f"name: {name}")
count = count_items([1, 2, 3])
print(f"count: {count}")Expected Output
greeting: Hello, Alice!
name: Alice
count: 3Hints
Hint 1: Mypy flags variables where the type widens unexpectedly (e.g., assigning None then reassigning).
Hint 2: Use Optional[str] for variables that start as None but get a string later.
Use typing.cast() to assert the type of values returned from a weakly-typed dict lookup, making the type checker happy without adding runtime overhead.
from typing import Any, Dict, cast
def load_config() -> Dict[str, Any]:
return {"user_id": 42, "name": "Alice", "active": True}
config = load_config()
# Without cast: config["user_id"] is Any — no int-specific operations
user_id = cast(int, config["user_id"])
name = cast(str, config["name"])
print(f"user_id: {user_id}")
print(f"user_id type: {type(user_id).__name__}")
print(f"name: {name}")
# cast has no runtime effect — it is purely a type system hint
assert user_id == config["user_id"]Expected Output
user_id: 42
user_id type: int
name: AliceHints
Hint 1: typing.cast(T, value) tells the type checker to treat value as T at the call site. It has zero runtime effect.
Hint 2: Use cast when you know more about the type than the checker can infer — e.g., after a dict lookup.
Annotate parse_value with its full return type using Union and Optional, then implement it to try int, float, and str coercion in order.
from typing import Union, Optional
def parse_value(raw: str) -> Optional[Union[int, float, str]]:
if not raw:
return None
try:
return int(raw)
except ValueError:
pass
try:
return float(raw)
except ValueError:
pass
return raw
for test in ["42", "3.14", "hello", ""]:
result = parse_value(test)
type_name = type(result).__name__
print(f"parse_value({test!r}) = {result} ({type_name})")Expected Output
parse_value('42') = 42 (int)
parse_value('3.14') = 3.14 (float)
parse_value('hello') = hello (str)
parse_value('') = None (NoneType)Hints
Hint 1: The return type is Union[int, float, str, None] — you can also write Optional[Union[int, float, str]].
Hint 2: Try int() first, then float(), then return the string as-is. Return None for empty input.
Medium
Demonstrate responsible use of # type: ignore with specific error codes. Show where it is appropriate and where a proper annotation is better.
from typing import Any
# Legacy function — returns Any, no annotations
def legacy_compute(x, y):
return x * y
def typed_wrapper(a: int, b: int) -> int:
# legacy_compute returns Any; cast would be cleaner but ignore is acceptable temporarily
result: int = legacy_compute(a, b) # type: ignore[assignment]
return result
def legacy_sum(*args):
total = 0
for arg in args:
total += arg
return total
# Annotate what we control; leave # type: ignore for what we don't
result: int = typed_wrapper(7, 6)
computed: int = legacy_compute(10, 10) # type: ignore[assignment]
legacy_total: int = legacy_sum(1, 2, 3, 4, 5) # type: ignore[assignment]
print(f"result: {result}")
print(f"computed: {computed}")
print(f"legacy_sum: {legacy_total}")Expected Output
result: 42
computed: 100
legacy_sum: 15Hints
Hint 1: Use # type: ignore[assignment] to suppress specific mypy errors on lines you cannot fix yet.
Hint 2: Prefer the most specific error code (e.g., [arg-type], [return-value]) over a blanket # type: ignore.
Simulate writing stub annotations for an untyped library function by wrapping it with @overload declarations that provide full type information to static checkers.
from typing import Union, overload
# Simulated "untyped library function"
def _untyped_compute(a, b):
return a + b
# Stub-style overloads (would live in .pyi in a real project)
@overload
def compute(a: int, b: int) -> int: ...
@overload
def compute(a: float, b: float) -> float: ...
@overload
def compute(a: str, b: str) -> str: ...
def compute(
a: Union[int, float, str],
b: Union[int, float, str],
) -> Union[int, float, str]:
return _untyped_compute(a, b)
print(f"compute(5, 3) = {compute(5, 3)}")
print(f"compute(5.0, 3.0) = {compute(5.0, 3.0)}")
print(f"compute('hello', ' world') = {compute('hello', ' world')}")Expected Output
compute(5, 3) = 8
compute(5.0, 3.0) = 8.0
compute('hello', ' world') = hello worldHints
Hint 1: A .pyi stub provides type information without running code. Simulate this by defining a typed wrapper around an untyped function.
Hint 2: The @overload stubs mirror what a .pyi would declare for an untyped library function.
Demonstrate how reveal_type() is used to debug type inference. Show how to use it safely at runtime while still benefiting from it in static analysis.
from typing import List
def reveal_type_safe(value, label: str = "") -> None:
"""Runtime-compatible reveal_type simulation."""
type_name = type(value).__name__
if label:
print(f"reveal_type({label}): {type_name}")
else:
print(f"reveal_type: {type_name}")
x: int = 42
items: List[int] = [1, 2, 3]
first = items[0] # inferred: int
doubled = [v * 2 for v in items] # inferred: List[int]
reveal_type_safe(x, "x")
reveal_type_safe(items, "items")
reveal_type_safe(first, "first")
reveal_type_safe(doubled, "doubled")
print(f"x = {x}")
print(f"items = {items}")
print(f"first = {first}")
print(f"doubled = {doubled}")Expected Output
x = 42
items = [1, 2, 3]
first = 1
doubled = [2, 4, 6]Hints
Hint 1: reveal_type(x) is a mypy/pyright special — it prints the inferred type of x. At runtime it calls print but with a type annotation message.
Hint 2: Wrap it in a try/except NameError for runtime compatibility when not running under mypy.
Use Final for constants and ClassVar for class-level counters. Demonstrate that mypy would flag reassignment of Final variables (simulate the check at runtime).
from typing import Final, ClassVar
# Module-level constants
PI: Final = 3.14159
MAX_RETRIES: Final[int] = 3
class Connection:
_instance_count: ClassVar[int] = 0
MAX_CONNECTIONS: ClassVar[Final[int]] = 10 # type: ignore[misc]
def __init__(self, host: str) -> None:
self.host = host
Connection._instance_count += 1
@classmethod
def count(cls) -> int:
return cls._instance_count
c1 = Connection("localhost")
c2 = Connection("remotehost")
print(f"PI = {PI}")
print(f"MAX_RETRIES = {MAX_RETRIES}")
print(f"instance count = {Connection.count()}")
# Simulated Final reassignment check
def check_final_reassignment() -> None:
"""Simulates what mypy would catch: reassignment of Final."""
try:
# In real code, mypy would flag this before runtime
globals_copy = dict(globals())
if "PI" in globals_copy:
raise TypeError("cannot reassign final")
except TypeError as e:
print(f"TypeError on assignment: {e}")
check_final_reassignment()Expected Output
PI = 3.14159
MAX_RETRIES = 3
instance count = 2
TypeError on assignment: cannot reassign finalHints
Hint 1: Final[T] marks a variable as constant — mypy raises an error if you reassign it.
Hint 2: ClassVar[T] distinguishes class-level variables from instance variables in type annotations.
Hard
Build a load_app_config function that loads a TypedDict-typed config, validates every field, and returns a strongly-typed object that mypy fully understands.
from typing import Any, Dict, cast, get_type_hints
try:
from typing import TypedDict
except ImportError:
from typing_extensions import TypedDict
class AppConfig(TypedDict):
env: str
workers: int
debug: bool
host: str
port: int
def load_app_config(raw: Dict[str, Any]) -> AppConfig:
hints = get_type_hints(AppConfig)
errors = []
coerced: Dict[str, Any] = {}
defaults: Dict[str, Any] = {
"env": "development",
"workers": 1,
"debug": True,
"host": "localhost",
"port": 8000,
}
for key, expected in hints.items():
if key in raw:
value = raw[key]
if not isinstance(value, expected):
try:
value = expected(value)
except (ValueError, TypeError):
errors.append(f"{key}: cannot convert {value!r} to {expected.__name__}")
continue
coerced[key] = value
elif key in defaults:
coerced[key] = defaults[key]
else:
errors.append(f"{key}: missing required field")
if errors:
raise ValueError("Config errors: " + "; ".join(errors))
return cast(AppConfig, coerced)
class ConfigView:
def __init__(self, cfg: AppConfig) -> None:
self._cfg = cfg
def __repr__(self) -> str:
c = self._cfg
return f"AppConfig(env={c['env']}, workers={c['workers']}, debug={c['debug']})"
raw_input: Dict[str, Any] = {
"env": "production",
"workers": "4", # string — should be coerced to int
"debug": False,
"host": "0.0.0.0",
"port": 80,
}
cfg = load_app_config(raw_input)
view = ConfigView(cfg)
print(f"Config loaded: {view}")
print(f"Env: {cfg['env']}")
print(f"Worker count valid: {isinstance(cfg['workers'], int)}")
print(f"Debug disabled in prod: {not cfg['debug']}")Expected Output
Config loaded: AppConfig(env=production, workers=4, debug=False)
Env: production
Worker count valid: True
Debug disabled in prod: TrueHints
Hint 1: Use TypedDict to define AppConfig. A loader function parses raw dict, validates types with get_type_hints, and casts to AppConfig.
Hint 2: Return a typed AppConfig object that lets the static checker know exact field types.
Demonstrate a three-phase gradual typing migration of a small analytics function: untyped, partially typed with Any, and fully typed.
from typing import Any, List, Dict, Optional
import inspect
# Phase 1: untyped — no annotations at all
def compute_v1(data, weights):
total = sum(x * w for x, w in zip(data, weights))
return total
# Phase 2: partially typed — parameters annotated, complex parts as Any
def compute_v2(data: List[Any], weights: List[Any]) -> Any:
total: Any = sum(x * w for x, w in zip(data, weights))
return total
# Phase 3: fully typed — complete, specific annotations
def compute_v3(data: List[float], weights: List[float]) -> float:
return sum(x * w for x, w in zip(data, weights))
def type_coverage(func) -> float:
"""Measure % of params+return that are not Any or missing."""
hints = {}
try:
import typing
hints = typing.get_type_hints(func)
except Exception:
pass
sig = inspect.signature(func)
params = [p for p in sig.parameters if p != "self"]
total = len(params) + 1 # +1 for return
if total == 0:
return 0.0
annotated = sum(
1 for name in (list(params) + ["return"])
if name in hints and hints[name] is not Any
)
return annotated / total
data = [1.0, 2.0, 3.0, 4.0, 5.0]
weights = [1.0, 1.0, 1.0, 1.0, 1.0]
print(f"Phase 1 (untyped): result = {compute_v1(data, weights):.0f}")
print(f"Phase 2 (partially typed): result = {compute_v2(data, weights):.0f}")
print(f"Phase 3 (fully typed): result = {compute_v3(data, weights):.0f}")
cov1 = type_coverage(compute_v1)
cov3 = type_coverage(compute_v3)
print(f"Type coverage improved: {cov3 > cov1}")Expected Output
Phase 1 (untyped): result = 25
Phase 2 (partially typed): result = 25
Phase 3 (fully typed): result = 25
Type coverage improved: TrueHints
Hint 1: Start with Any everywhere, then progressively narrow to specific types.
Hint 2: Track coverage as the percentage of function parameters and returns that are not Any.
Design a generic storage Protocol and two implementations. Show that mypy can reason about which concrete type is returned through a typed service layer.
from typing import TypeVar, Generic, Optional, Protocol, Dict, Any
import json
T = TypeVar("T")
class Serializable(Protocol):
def to_dict(self) -> Dict[str, Any]: ...
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Serializable": ...
class Storage(Protocol[T]):
def save(self, key: str, value: T) -> None: ...
def load(self, key: str) -> Optional[T]: ...
class MemoryStorage(Generic[T]):
def __init__(self) -> None:
self._store: Dict[str, T] = {}
def save(self, key: str, value: T) -> None:
print(f"MemoryStorage saved key={key}")
self._store[key] = value
def load(self, key: str) -> Optional[T]:
result = self._store.get(key)
print(f"MemoryStorage loaded key={key} -> {result}")
return result
class JSONStorage(Generic[T]):
def __init__(self, serializer, deserializer) -> None:
self._raw: Dict[str, str] = {}
self._serialize = serializer
self._deserialize = deserializer
def save(self, key: str, value: T) -> None:
print(f"JSONStorage saved key={key}")
self._raw[key] = json.dumps(self._serialize(value))
def load(self, key: str) -> Optional[T]:
if key not in self._raw:
return None
result = self._deserialize(json.loads(self._raw[key]))
print(f"JSONStorage loaded key={key} -> {result}")
return result
class User:
def __init__(self, id: int, name: str) -> None:
self.id = id
self.name = name
def to_dict(self) -> Dict[str, Any]:
return {"id": self.id, "name": self.name}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "User":
return cls(data["id"], data["name"])
def __repr__(self) -> str:
return f"User(id={self.id}, name={self.name!r})"
json_store: JSONStorage[User] = JSONStorage(
serializer=lambda u: u.to_dict(),
deserializer=User.from_dict,
)
mem_store: MemoryStorage[User] = MemoryStorage()
u1 = User(1, "Alice")
u2 = User(2, "Bob")
json_store.save("user:1", u1)
loaded_json = json_store.load("user:1")
mem_store.save("user:2", u2)
loaded_mem = mem_store.load("user:2")
print(f"Both backends return correct type: {isinstance(loaded_json, User) and isinstance(loaded_mem, User)}")Expected Output
JSONStorage saved key=user:1
JSONStorage loaded key=user:1 -> User(id=1, name='Alice')
MemoryStorage saved key=user:2
MemoryStorage loaded key=user:2 -> User(id=2, name='Bob')
Both backends return correct type: TrueHints
Hint 1: Define a Storage[T] Protocol with save(key: str, value: T) -> None and load(key: str) -> Optional[T].
Hint 2: Implement JSONStorage[T] and MemoryStorage[T] — both satisfy the protocol. A typed StorageService uses either backend.
Build a production-grade typed pipeline combining TypedDict, Protocol, TypeGuard, and overload — the kind that would pass mypy --strict in CI.
from typing import Dict, Any, List, Optional, Protocol, overload, Literal, Union
try:
from typing import TypedDict, TypeGuard
except ImportError:
from typing_extensions import TypedDict, TypeGuard
# --- Data shapes ---
class EventRecord(TypedDict):
event_id: str
payload: str
version: int
# --- Processing Strategy Protocol ---
class ProcessingStrategy(Protocol):
@property
def name(self) -> str: ...
def process(self, data: str) -> str: ...
# --- Concrete strategies ---
class UpperCaseStrategy:
@property
def name(self) -> str:
return "UPPERCASE"
def process(self, data: str) -> str:
return data.upper()
class ReverseStrategy:
@property
def name(self) -> str:
return "REVERSE"
def process(self, data: str) -> str:
return data[::-1]
# --- TypeGuard for input validation ---
def is_event_record(data: Dict[str, Any]) -> "TypeGuard[EventRecord]":
return (
isinstance(data.get("event_id"), str)
and isinstance(data.get("payload"), str)
and isinstance(data.get("version"), int)
)
# --- Overloaded output formatter ---
@overload
def format_output(result: str, verbose: Literal[True]) -> Dict[str, Any]: ...
@overload
def format_output(result: str, verbose: Literal[False]) -> str: ...
def format_output(result: str, verbose: bool = False) -> Union[str, Dict[str, Any]]:
if verbose:
return {"result": result, "length": len(result)}
return result
# --- Typed storage ---
_store: Dict[str, str] = {}
def store_result(key: str, value: str) -> None:
_store[key] = value
# --- Main pipeline ---
def process_event(
raw: Dict[str, Any],
strategy: ProcessingStrategy,
) -> Optional[str]:
print(f"Pipeline step 1: raw data validated")
if not is_event_record(raw):
return None
record: EventRecord = raw
print(f"Pipeline step 2: typed as EventRecord")
processed = strategy.process(record["payload"])
print(f"Pipeline step 3: processed with strategy {strategy.name}")
store_result(record["event_id"], processed)
print(f"Pipeline step 4: result stored")
return format_output(processed, verbose=False)
raw_event: Dict[str, Any] = {
"event_id": "evt-001",
"payload": "hello world",
"version": 1,
}
strategy = UpperCaseStrategy()
output = process_event(raw_event, strategy)
print(f"Output: {output}")
print(f"All type-safe: {output == 'HELLO WORLD'}")Expected Output
Pipeline step 1: raw data validated
Pipeline step 2: typed as EventRecord
Pipeline step 3: processed with strategy UPPERCASE
Pipeline step 4: result stored
Output: HELLO WORLD
All type-safe: TrueHints
Hint 1: Combine TypedDict for data shapes, Protocol for the processing strategy, TypeGuard for input validation, and @overload for the output function.
Hint 2: Chain all four techniques in a single process_event pipeline that would pass mypy --strict.
