Building a Python library means packaging reusable code so others can install it, import it, and rely on it. This article walks through creating a small but complete library that demonstrates the advanced Python features covered throughout this section: descriptors for validated attributes, metaclasses for class registration, type hints for API clarity, and proper project structure for distribution.
The library we will build is a simple validation framework called validable. It lets users define data classes with typed, validated fields, and it automatically registers every model class for serialization. It is small enough to understand in one sitting but demonstrates real patterns used in production libraries.
Project structure and pyproject.toml
A modern Python library starts with a clean project layout. The package code lives in a directory named after the package. A pyproject.toml file declares metadata, dependencies, and build configuration.
validable/
pyproject.toml
src/
validable/
__init__.py
models.py
fields.py
errors.py
tests/
test_models.pyThe init file exports the public API names that users should import. Everything the user needs should come directly from the top-level package name without navigating into internal submodules.
from validable import Model, Field, ValidationErrorKeeping the public API small and predictable is the most important design decision. Every name you export is a commitment.
Custom exceptions
Libraries should define their own exception hierarchy. This lets callers catch library-specific errors without accidentally catching unrelated exceptions.
class ValidableError(Exception):
pass
class ValidationError(ValidableError):
def __init__(self, field, value, reason):
self.field = field
self.value = value
self.reason = reason
super().__init__(f"{field}: {reason} (got {value!r})")
class RegistryError(ValidableError):
passCallers can catch ValidableError for any library error or ValidationError specifically for field validation failures. Custom exceptions carry structured data about the error, not just a message string.
Descriptors for validated fields
The Field descriptor, covered in the article on Python descriptors, handles type checking and validation at the attribute level. Each Field instance manages storage for its attribute across all model instances.
class Field:
def __init__(self, field_type, validators=None):
self.field_type = field_type
self.validators = validators or []
self.storage = {}
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
return self.storage.get(id(instance))
def __set__(self, instance, value):
if not isinstance(value, self.field_type):
raise ValidationError(
self.name, value,
f"expected {self.field_type.__name__}"
)
for validator in self.validators:
validator(self.name, value)
self.storage[id(instance)] = valueThe descriptor validates type on every assignment. Additional validators can enforce domain-specific rules like positivity or string length. Storage per instance uses the id-based dictionary pattern.
Metaclass for model registration
A metaclass, covered in the article on Python metaclasses, automatically registers every class that inherits from Model. This enables discovery without explicit registration calls.
class ModelMeta(type):
registry = {}
def __new__(mcs, name, bases, namespace):
cls = super().__new__(mcs, name, bases, namespace)
if name != "Model":
mcs.registry[name] = cls
return cls
class Model(metaclass=ModelMeta):
passAny class that inherits from Model is automatically added to the registry. Users never call a register function. The library discovers models at import time.
Putting it together: the user experience
With the infrastructure in place, the user-facing API is clean and declarative. Users define models by subclassing Model and declaring Fields as class attributes.
class User(Model):
name = Field(str)
age = Field(int)
user = User()
user.name = "Maya"
user.age = 30
print(user.name, user.age)The fields enforce types automatically on every attribute assignment. Trying to assign a string value to the age field raises a ValidationError with a clear and descriptive error message.
Maya 30Assigning a value of the wrong type shows what the descriptor actually does on a bad assignment, instead of only describing it.
try:
user.age = "thirty"
except ValidationError as e:
print(e)The descriptor's set method catches the type mismatch before the bad value ever reaches storage, and the exception reports exactly which field failed and what it received.
age: expected int (got 'thirty')The registry contains every model class defined in the application. This enables serialization, form generation, and schema introspection without additional configuration.
print(list(ModelMeta.registry.keys()))Each model appears in the registry by its class name. This enables discovery by other parts of the application without any explicit registration code or decorators on the model classes.
['User']What makes this a real library
This small library demonstrates the pattern behind larger frameworks. The public API is three names: Model, Field, and ValidationError. Each serves a clear purpose and the user never touches internal details.
The descriptors encapsulate attribute storage and validation. The metaclass handles class registration transparently. Type hints throughout the code let IDEs and type checkers provide autocompletion and catch mistakes.
The same architecture scales to real libraries. SQLAlchemy uses descriptors for column definitions and a metaclass for model registration, and Django uses descriptors for field management and a metaclass for model setup. Understanding the patterns in this small library prepares you to read and contribute to those larger codebases.
The article on writing Pythonic code covers the style and design principles that make a library feel natural to Python developers.
Rune AI
Key Insights
- Structure projects with a clear package layout, pyproject.toml, and explicit public API via all.
- Use descriptors and properties to create intuitive attribute interfaces for your library users.
- Define custom exception hierarchies so callers can catch specific error conditions.
- Add type hints to function signatures and class attributes for tooling support.
- Keep the public API small; every name you export is a commitment you must maintain.
Frequently Asked Questions
How should I structure a Python library project?
How do I design a clean public API for my library?
Conclusion
Building a Python library pulls together every advanced feature covered in this section. The object model ensures consistent behaviour. Descriptors and properties provide clean attribute interfaces. Metaclasses handle class validation and registration. Type hints document the API for both humans and tools. The result is a library that feels like it belongs in Python, with an API that is easy to learn and hard to misuse.
More in this topic
How to Test Python Code
Learn what testing means in Python, the core ideas behind automated tests, and the built-in and third-party tools that make testing practical.
Write Your First Python Unit Test
Write and run your first Python unit test step by step, using unittest to check a small function and understand the test output.
The Factory Pattern in Python
Learn how to use the factory pattern in Python to encapsulate object creation, making your code more flexible and easier to extend.