A simple python implementation of Specification pattern.
Project description
Sutoppu
Sutoppu (ストップ - Japanese from English Stop) is a lightweight implementation of the Specification pattern for Python, enabling elegant business rule composition through boolean logic.
Table of Contents
- Introduction
- Installation
- Core Concepts
- Basic Usage
- Advanced Features
- Real-World Examples
- API Reference
- Contributing
- License
Introduction
The Specification pattern is a powerful approach for encapsulating business rules in reusable, combinable objects. This pattern is especially valuable in domain-driven design and applications with complex validation logic.
Sutoppu brings this pattern to Python with a clean, intuitive API that leverages Python's operator overloading to create natural boolean expressions for your business rules.
"In computer programming, the specification pattern is a particular software design pattern, whereby business rules can be recombined by chaining the business rules together using boolean logic. The pattern is frequently used in the context of domain-driven design." – Wikipedia
See original paper by Eric Evans and Martin Fowler for more information.
Installation
pip install sutoppu
Sutoppu is compatible with Python 3.8+ and has no external dependencies for Python 3.11+. For Python 3.8-3.10, it requires only the lightweight typing-extensions package.
Core Concepts
The foundation of Sutoppu is the Specification abstract base class, which:
- Defines a contract for checking if an object satisfies a specific rule
- Provides operators for combining specifications using boolean logic
- Includes built-in error tracking to identify which rules aren't satisfied
Each specification must implement the is_satisfied_by(candidate) method, which returns True if the candidate meets the specification's criteria or False otherwise.
Basic Usage
Here's a simple example demonstrating how to create and use specifications:
from sutoppu import Specification
# Define a domain entity
class User:
def __init__(self, username: str, email: str, age: int) -> None:
self.username = username
self.email = email
self.age = age
# Create specifications for user validation
class ValidUsername(Specification[User]):
description = "Username must be between 3 and 20 characters."
def is_satisfied_by(self, user: User) -> bool:
return 3 <= len(user.username) <= 20
class ValidEmail(Specification[User]):
description = "Email must contain @ symbol."
def is_satisfied_by(self, user: User) -> bool:
return "@" in user.email
class AdultUser(Specification[User]):
description = "User must be 18 or older."
def is_satisfied_by(self, user: User) -> bool:
return user.age >= 18
# Use the specifications
user1 = User("john_doe", "john@example.com", 25)
user2 = User("jo", "invalid-email", 17)
# Combine specifications
valid_user = ValidUsername() & ValidEmail() & AdultUser()
# Check if users are valid
print(valid_user.is_satisfied_by(user1)) # True
print(valid_user.is_satisfied_by(user2)) # False
# Check which rules failed
valid_user.is_satisfied_by(user2)
print(valid_user.errors)
# {
# 'ValidUsername': 'Username must be between 3 and 20 characters.',
# 'ValidEmail': 'Email must contain @ symbol.',
# 'AdultUser': 'User must be 18 or older.'
# }
Advanced Features
Combining Specifications
Sutoppu overloads Python's bitwise operators to create a natural, expressive syntax for combining specifications:
&(AND): Both specifications must be satisfied|(OR): At least one specification must be satisfied~(NOT): The specification must not be satisfied
These operators can be chained to create complex rule compositions:
# User must be an adult with valid credentials, OR an approved minor
valid_account = (ValidUsername() & ValidEmail() & AdultUser()) | ApprovedMinor()
# User must have valid credentials but must NOT be blacklisted
active_account = (ValidUsername() & ValidEmail()) & ~Blacklisted()
Call Syntax
For a more concise syntax, specifications can be called directly as functions:
adult_user = AdultUser()
# These are equivalent:
result1 = adult_user.is_satisfied_by(user)
result2 = adult_user(user)
Error Reporting
Sutoppu automatically tracks which specifications fail during validation. After checking a candidate, the errors dictionary provides detailed feedback on each failed rule:
complex_spec = SpecA() & (SpecB() | SpecC()) & ~SpecD()
complex_spec.is_satisfied_by(candidate)
if complex_spec.errors:
for spec_name, description in complex_spec.errors.items():
print(f"Failed rule: {spec_name} - {description}")
Key features of error reporting:
- The
errorsdictionary is reset before each validation - Keys are specification class names
- Values are the descriptions defined in the specifications
- Negated specifications that fail show "Expected condition to NOT satisfy: [original description]" as description
Real-World Examples
Product Eligibility for Promotion
from sutoppu import Specification
from datetime import datetime, timedelta
from typing import Set, Literal
# Define allowed category types for better type checking
CategoryType = Literal["electronics", "home", "fashion", "books", "toys", "sports"]
class Product:
def __init__(
self,
sku: str,
category: CategoryType,
price: float,
created_at: datetime,
stock: int,
) -> None:
self.sku = sku
self.category = category
self.price = price
self.created_at = created_at
self.stock = stock
class InPromotionCategory(Specification[Product]):
description = "Product must be in eligible promotion category."
PROMO_CATEGORIES: Set[CategoryType] = {"electronics", "home", "fashion"}
def is_satisfied_by(self, product: Product) -> bool:
return product.category in self.PROMO_CATEGORIES
class PriceThreshold(Specification[Product]):
description = "Product must cost at least $50."
def is_satisfied_by(self, product: Product) -> bool:
return product.price >= 50.0
class NewArrival(Specification[Product]):
description = "Product must be added within the last 30 days."
def is_satisfied_by(self, product: Product) -> bool:
days_since_added = (datetime.now() - product.created_at).days
return days_since_added <= 30
class InStock(Specification[Product]):
description = "Product must be in stock."
def is_satisfied_by(self, product: Product) -> bool:
return product.stock > 0
# Combine specifications for promotion eligibility
promotion_eligible = (
InPromotionCategory() &
PriceThreshold() &
(NewArrival() | ~InStock()) # New arrivals or out-of-stock products
)
# Example products
eligible_product = Product(
sku="ELEC123",
category="electronics",
price=199.99,
created_at=datetime.now() - timedelta(days=5), # 5 days ago
stock=10
)
ineligible_product = Product(
sku="BOOK789",
category="books",
price=14.99,
created_at=datetime.now() - timedelta(days=60), # 60 days ago
stock=20
)
# Check eligibility for both products
is_eligible = promotion_eligible.is_satisfied_by(eligible_product)
print(f"Electronics product eligible for promotion: {is_eligible}")
# Electronics product eligible for promotion: True
is_ineligible = promotion_eligible.is_satisfied_by(ineligible_product)
print(f"Book eligible for promotion: {is_ineligible}")
# Book eligible for promotion: False
# Display failure reasons for the ineligible product
print("Failure reasons:", promotion_eligible.errors)
# Failure reasons:: {
# 'InPromotionCategory': 'Product must be in eligible promotion category.',
# 'PriceThreshold': 'Product must cost at least $50.',
# 'NewArrival': 'Product must be added within the last 30 days.',
# 'InStock': 'Expected condition to NOT satisfy: Product must be in stock.'
# }
User Permission System
from sutoppu import Specification
from typing import List, Set, Literal, Union
# Define domain types
RoleType = Literal["admin", "user", "manager", "auditor"]
DepartmentType = Literal["IT", "HR", "Finance", "Marketing", "Operations"]
class User:
def __init__(
self,
roles: Set[RoleType],
department: DepartmentType,
access_level: int,
two_factor_enabled: bool,
) -> None:
self.roles = roles
self.department = department
self.access_level = access_level
self.two_factor_enabled = two_factor_enabled
class AdminRole(Specification[User]):
description = "User must have admin role."
def is_satisfied_by(self, user: User) -> bool:
return "admin" in user.roles
class ITDepartment(Specification[User]):
description = "User must be in IT department."
def is_satisfied_by(self, user: User) -> bool:
return user.department == "IT"
class SeniorAccessLevel(Specification[User]):
description = "User must have senior access level."
SENIOR_THRESHOLD: int = 7
def is_satisfied_by(self, user: User) -> bool:
return user.access_level >= self.SENIOR_THRESHOLD
class TwoFactorEnabled(Specification[User]):
description = "User must have 2FA enabled."
def is_satisfied_by(self, user: User) -> bool:
return user.two_factor_enabled
# Define sensitive data access rule
can_access_sensitive_data = (
(AdminRole() | (ITDepartment() & SeniorAccessLevel())) &
TwoFactorEnabled()
)
# Example check with a regular user
regular_user = User(
roles={"user"},
department="Finance",
access_level=6,
two_factor_enabled=True
)
# Check permission
has_access = can_access_sensitive_data.is_satisfied_by(regular_user)
print(f"Regular user can access sensitive data: {has_access}")
# Regular user can access sensitive data: False
# Check which rules failed
print("Failed rules:", can_access_sensitive_data.errors)
# Failed rules: {
# 'AdminRole': 'User must have admin role.',
# 'ITDepartment': 'User must be in IT department.',
# 'SeniorAccessLevel': 'User must have senior access level.'
# }
API Reference
Specification[T]
Abstract base class for creating specifications. Type parameter T defines the type of objects being checked.
Attributes:
description: Class attribute for describing the rule (default: "No description provided.")errors: Dictionary of failed specifications, with class names as keys and descriptions as values
Methods:
is_satisfied_by(candidate: T) -> bool: Abstract method that must be implemented by concrete specifications__and__(other: Specification[T]) -> Specification[T]: Combine with another specification using AND logic__or__(other: Specification[T]) -> Specification[T]: Combine with another specification using OR logic__invert__() -> Specification[T]: Negate the specification (NOT logic)__call__(candidate: T) -> bool: Shorthand for callingis_satisfied_by()
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch:
git checkout -b feature/my-feature - Commit your changes:
git commit -am 'Add my feature' - Push to the branch:
git push origin feature/my-feature - Submit a pull request
License
This project is licensed under the MIT License - see the LICENSE file for details.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sutoppu-1.2.0.tar.gz.
File metadata
- Download URL: sutoppu-1.2.0.tar.gz
- Upload date:
- Size: 9.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: poetry/1.8.3 CPython/3.11.8 Darwin/24.4.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c38cebc3331d4a521b365fbdea9f10267120f4a056703b081cedfee995132694
|
|
| MD5 |
86ae0161e61f86d0962ebe8076c51d18
|
|
| BLAKE2b-256 |
cc86c8ca51b9f3a6972bfad7aa3bc2fca0eac5d7a7d96f63f17bdbcc790a688e
|
File details
Details for the file sutoppu-1.2.0-py3-none-any.whl.
File metadata
- Download URL: sutoppu-1.2.0-py3-none-any.whl
- Upload date:
- Size: 14.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: poetry/1.8.3 CPython/3.11.8 Darwin/24.4.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c69a1fe1823727b80af747e89404e2df47c48f468d2b6ad55400c579609a0eb3
|
|
| MD5 |
cbbbac44b575f608958cceec65668764
|
|
| BLAKE2b-256 |
a04d27e6fd00738e7179d7e0fd4dc1444b5c07184e5ed7998cd127c0a13bef3f
|