Skip to main content

A simple python implementation of Specification pattern.

Project description

Sutoppu

Pypi Version Python Version CI Coverage Status Project license

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

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:

  1. Defines a contract for checking if an object satisfies a specific rule
  2. Provides operators for combining specifications using boolean logic
  3. 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 errors dictionary 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 calling is_satisfied_by()

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

  1. Fork the repository
  2. Create your feature branch: git checkout -b feature/my-feature
  3. Commit your changes: git commit -am 'Add my feature'
  4. Push to the branch: git push origin feature/my-feature
  5. 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

sutoppu-1.2.0.tar.gz (9.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sutoppu-1.2.0-py3-none-any.whl (14.5 kB view details)

Uploaded Python 3

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

Hashes for sutoppu-1.2.0.tar.gz
Algorithm Hash digest
SHA256 c38cebc3331d4a521b365fbdea9f10267120f4a056703b081cedfee995132694
MD5 86ae0161e61f86d0962ebe8076c51d18
BLAKE2b-256 cc86c8ca51b9f3a6972bfad7aa3bc2fca0eac5d7a7d96f63f17bdbcc790a688e

See more details on using hashes here.

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

Hashes for sutoppu-1.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c69a1fe1823727b80af747e89404e2df47c48f468d2b6ad55400c579609a0eb3
MD5 cbbbac44b575f608958cceec65668764
BLAKE2b-256 a04d27e6fd00738e7179d7e0fd4dc1444b5c07184e5ed7998cd127c0a13bef3f

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page