May 13, 2025

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

Python syntax

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

  1. Default Arguments: Gotchas and Solutions
  2. Descriptors: Python's Property System
  3. Dictionary .get() Method: Safe Value Access
  4. 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

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

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

Stay tuned for Part 3, where we'll complete our journey through Python's modern features!

Crafted with ❤️, straight from Toronto.

Copyright © 2025, all rights resereved