May 13, 2025
#6: Python Modern Features Mastery: Part 2 - Gotchas, Documentation, and Type Systems

Photo by Chris Ried on Unsplash
Introduction
Welcome to Part 2 of our Python Modern Features series! Having covered essential syntax enhancements in Part 1, we now dive into more sophisticated features that separate intermediate developers from Python experts. These features focus on defensive programming, documentation best practices, and Python's powerful type system.
The features we'll explore today are particularly important for maintaining large codebases and preventing subtle bugs that can plague Python applications. From understanding the dangers of mutable default arguments to harnessing the power of descriptors, these concepts will elevate your Python programming to a professional level.
Each feature in this part comes with practical warnings, best practices, and real-world scenarios where they prove invaluable. By the end of this article, you'll understand not just how to use these features, but when to apply them for maximum benefit.
Table of Contents
- Default Arguments: Gotchas and Solutions
- Descriptors: Python's Property System
- Dictionary .get() Method: Safe Value Access
- Docstring Tests: Executable Documentation
1. Default Arguments: The Mutable Trap
Understanding the Gotcha
One of the most common pitfalls in Python involves using mutable objects as default arguments. This seemingly innocent practice can lead to mysterious bugs that are difficult to track down.
The problem occurs because default arguments are evaluated once when the function is defined, not each time it's called. If the default is a mutable object, all function calls share the same object.
The Problem Demonstrated
1# DANGEROUS: Mutable default argument
2def add_item(item, lst=[]):
3 lst.append(item)
4 return lst
5
6# First call - looks fine
7result1 = add_item(1)
8print(result1) # [1]
9
10# Second call - surprise!
11result2 = add_item(2)
12print(result2) # [1, 2] - not [2] as expected!
13
14# Third call - even more surprising
15result3 = add_item(3)
16print(result3) # [1, 2, 3] - all previous items!
17
18# All results point to the same list
19print(result1 is result2 is result3) # True
Safe Patterns for Default Arguments
1# SAFE: Use None as sentinel value
2def add_item_safe(item, lst=None):
3 if lst is None:
4 lst = []
5 lst.append(item)
6 return lst
7
8# Now each call gets a fresh list
9result1 = add_item_safe(1)
10result2 = add_item_safe(2)
11print(result1) # [1]
12print(result2) # [2] - independent lists!
13
14# Alternative pattern with copy
15def append_to_list(item, template=None):
16 if template is None:
17 template = []
18 result = template.copy() # Create a copy
19 result.append(item)
20 return result
21
22# Using with existing list
23existing = [1, 2, 3]
24new_list = append_to_list(4, existing)
25print(existing) # [1, 2, 3] - unchanged
26print(new_list) # [1, 2, 3, 4]
More Complex Scenarios
1# Gotcha with dictionaries
2class ConfigManager:
3 def __init__(self, settings={}): # DANGEROUS!
4 self.settings = settings
5
6 def update_setting(self, key, value):
7 self.settings[key] = value
8
9# All instances share the same dict!
10config1 = ConfigManager()
11config2 = ConfigManager()
12
13config1.update_setting('debug', True)
14print(config2.settings) # {'debug': True} - unexpected!
15
16# CORRECT version
17class ConfigManagerSafe:
18 def __init__(self, settings=None):
19 self.settings = settings if settings is not None else {}
20
21 def update_setting(self, key, value):
22 self.settings[key] = value
23
24# Each instance gets its own dict
25config3 = ConfigManagerSafe()
26config4 = ConfigManagerSafe()
27config3.update_setting('debug', True)
28print(config4.settings) # {} - as expected
Best Practices for Default Arguments
- Use immutable defaults: Numbers, strings, tuples,
None
,True
/False
- Use
None
as sentinel: For cases where you need a mutable default - Document behavior: Clearly state when defaults are mutated
- Consider factory functions: For complex default objects
2. Descriptors: Python's Property Protocol
Understanding Descriptors
Descriptors are a powerful mechanism for customizing attribute access in Python. They're the foundation for properties, methods, staticmethods, and classmethods. Understanding descriptors gives you deep insight into how Python's object model works.
A descriptor is an object with any of these methods: __get__()
, __set__()
, or __delete__()
. When an attribute is accessed on an object, Python checks if that attribute is a descriptor and calls the appropriate method.
Basic Descriptor Implementation
1# Simple descriptor that logs access
2class LoggedAccess:
3 def __init__(self, initial_value=None):
4 self.value = initial_value
5 self.name = None # Will be set by __set_name__
6
7 def __set_name__(self, owner, name):
8 self.name = name
9
10 def __get__(self, instance, owner):
11 if instance is None:
12 return self
13 print(f"Getting {self.name} value: {self.value}")
14 return self.value
15
16 def __set__(self, instance, value):
17 print(f"Setting {self.name} to: {value}")
18 self.value = value
19
20 def __delete__(self, instance):
21 print(f"Deleting {self.name}")
22 self.value = None
23
24class MyClass:
25 attribute = LoggedAccess(10)
26
27obj = MyClass()
28print(obj.attribute) # Getting attribute value: 10
29obj.attribute = 20 # Setting attribute to: 20
30print(obj.attribute) # Getting attribute value: 20
31del obj.attribute # Deleting attribute
Type-Safe Descriptors
1# Type-enforcing descriptor
2class TypedProperty:
3 def __init__(self, expected_type, default=None):
4 self.expected_type = expected_type
5 self.default = default
6 self.name = None
7
8 def __set_name__(self, owner, name):
9 self.name = name
10 self.private_name = f"_{name}"
11
12 def __get__(self, instance, owner):
13 if instance is None:
14 return self
15 return getattr(instance, self.private_name, self.default)
16
17 def __set__(self, instance, value):
18 if not isinstance(value, self.expected_type):
19 raise TypeError(f"{self.name} must be {self.expected_type.__name__}")
20 setattr(instance, self.private_name, value)
21
22class Person:
23 name = TypedProperty(str)
24 age = TypedProperty(int, 0)
25 height = TypedProperty((int, float))
26
27person = Person()
28person.name = "Alice" # OK
29person.age = 30 # OK
30person.height = 5.8 # OK
31
32# These will raise TypeError
33try:
34 person.age = "30"
35except TypeError as e:
36 print(e) # age must be int
3. Dictionary .get() Method: Safe Value Access
The Problem with Direct Key Access
Direct dictionary key access using square brackets (dict[key]
) raises a KeyError
if the key doesn't exist. The .get()
method provides a safer alternative that returns a default value instead of raising an exception.
Basic .get() Usage
1# Direct access - risky
2config = {"database": "postgres", "debug": True}
3
4# This raises KeyError
5try:
6 port = config["port"]
7except KeyError:
8 port = 5432 # Default value
9
10# Using .get() - safer
11port = config.get("port", 5432) # Returns 5432 if "port" doesn't exist
12debug = config.get("debug") # Returns True if exists, None if not
13host = config.get("host") # Returns None by default
14
15print(f"Port: {port}, Debug: {debug}, Host: {host}")
16# Output: Port: 5432, Debug: True, Host: None
Advanced .get() Patterns
1# Configuration management with .get()
2def load_config():
3 user_config = {
4 "server": {
5 "host": "localhost",
6 "port": 8080
7 },
8 "features": {
9 "auth": True
10 }
11 }
12
13 # Nested dictionary access
14 host = user_config.get("server", {}).get("host", "0.0.0.0")
15 port = user_config.get("server", {}).get("port", 80)
16 auth_enabled = user_config.get("features", {}).get("auth", False)
17 log_level = user_config.get("logging", {}).get("level", "INFO")
18
19 return {
20 "host": host,
21 "port": port,
22 "auth_enabled": auth_enabled,
23 "log_level": log_level
24 }
25
26config = load_config()
27print(config)
28# Output: {'host': 'localhost', 'port': 8080, 'auth_enabled': True, 'log_level': 'INFO'}
4. Docstring Tests: Executable Documentation
Introduction to Doctests
Doctests allow you to embed tests directly in your documentation strings, ensuring that your examples stay synchronized with your code. They serve as both documentation and automatic tests, making them incredibly valuable for maintaining code quality.
The doctest
module parses docstrings looking for text that looks like interactive Python sessions and executes those snippets to verify they work as documented.
Basic Doctest Syntax
1def add(a, b):
2 """
3 Add two numbers together.
4
5 >>> add(2, 3)
6 5
7 >>> add(-1, 1)
8 0
9 >>> add(0.1, 0.2) # doctest: +ELLIPSIS
10 0.3...
11 """
12 return a + b
13
14def factorial(n):
15 """
16 Calculate the factorial of a number.
17
18 >>> factorial(5)
19 120
20 >>> factorial(0)
21 1
22 >>> factorial(1)
23 1
24 >>> factorial(-1)
25 Traceback (most recent call last):
26 ...
27 ValueError: Factorial not defined for negative numbers
28 """
29 if n < 0:
30 raise ValueError("Factorial not defined for negative numbers")
31 if n <= 1:
32 return 1
33 return n * factorial(n - 1)
34
35# Run doctests
36if __name__ == "__main__":
37 import doctest
38 doctest.testmod(verbose=True)
Advanced Doctest Features
1def divide(a, b):
2 """
3 Divide two numbers.
4
5 >>> divide(10, 2)
6 5.0
7 >>> divide(7, 3) # doctest: +ELLIPSIS
8 2.333...
9 >>> divide(5, 0)
10 Traceback (most recent call last):
11 ...
12 ZeroDivisionError: division by zero
13 """
14 return a / b
15
16def parse_number_list(text):
17 """
18 Parse a comma-separated list of numbers.
19
20 >>> parse_number_list("1,2,3")
21 [1, 2, 3]
22 >>> parse_number_list("1.5, 2.7, 3.9") # Handles floats
23 [1.5, 2.7, 3.9]
24 >>> parse_number_list("") # Empty string
25 []
26 >>> parse_number_list("1,2,invalid,4") # doctest: +IGNORE_EXCEPTION_DETAIL
27 Traceback (most recent call last):
28 ...
29 ValueError: Invalid number: invalid
30 """
31 if not text.strip():
32 return []
33
34 result = []
35 for item in text.split(','):
36 item = item.strip()
37 try:
38 # Try int first, then float
39 if '.' in item:
40 result.append(float(item))
41 else:
42 result.append(int(item))
43 except ValueError:
44 raise ValueError(f"Invalid number: {item}")
45 return result
Best Practices for Doctests
- Keep examples simple: Focus on demonstrating the main functionality
- Test edge cases: Include boundary conditions and error cases
- Use directives wisely: Apply appropriate flags for floating-point comparisons
- Maintain consistency: Ensure examples reflect actual behavior
- Document side effects: Show state changes when relevant
Conclusion
The four features covered in Part 2—default argument gotchas, descriptors, dictionary .get()
method, and docstring tests—represent crucial skills for writing robust, maintainable Python code. These features help prevent common bugs, enable elegant APIs, and ensure your code is both documented and tested.
Understanding default argument pitfalls prevents subtle bugs that can plague applications for months. Descriptors unlock Python's object model, enabling you to create intuitive APIs with validation and transformation. The dictionary .get()
method provides safe access patterns that make your code more resilient. Finally, docstring tests ensure your documentation stays synchronized with your implementation.
In Part 3, we'll explore the remaining features: Ellipsis slicing syntax, Enumeration, For/else constructs, and Function as iter() argument. These features will round out your understanding of Python's more sophisticated capabilities.
Key Takeaways
- Always use
None
as sentinel: Avoid mutable default arguments - Leverage descriptors: For reusable property logic across classes
- Embrace
.get()
method: For safe dictionary access in production code - Document with examples: Doctests make your code self-verifying
Stay tuned for Part 3, where we'll complete our journey through Python's modern features!