diff --git a/PRACTICAL_APPLICATIONS.md b/PRACTICAL_APPLICATIONS.md new file mode 100644 index 000000000..2df431117 --- /dev/null +++ b/PRACTICAL_APPLICATIONS.md @@ -0,0 +1,200 @@ +# Practical Applications of Design Patterns + +## Overview + +This document demonstrates how the design patterns implemented in this repository +can be applied to real-world software systems. Each pattern is mapped to practical +use cases across different domains, helping developers understand **when** and **why** +to use each pattern — not just **how**. + +--- + +## 1. Observer Pattern (Behavioral) + +### What It Does +Allows an object (Subject) to notify multiple dependent objects (Observers) +automatically when its state changes, without tight coupling between them. + +### Real-World Applications + +| System | Subject | Observers | Trigger | +|--------|---------|-----------|---------| +| Library Management | Book | Waiting users, Email service | Book returned | +| E-Commerce | Product | Price alert subscribers | Price drops | +| Social Media | User account | Followers | New post published | +| Stock Trading | Stock price | Traders, Alert systems | Price changes | +| IoT / Smart Home | Temperature sensor | AC unit, Dashboard, Phone app | Temperature changes | + +### When to Use +- When multiple parts of your system need to react to the same event +- When you want to add new reactions without modifying the event source +- When the number of dependent objects may change at runtime + +### When NOT to Use +- When there is only one object that needs to be notified +- When the order of notification matters strictly +- When observers need to respond synchronously in a guaranteed order + +### SOLID Principles Applied +- **Open/Closed Principle**: New observers can be added without modifying the Subject +- **Dependency Inversion**: Subject depends on the Observer abstraction, not concrete classes +- **Single Responsibility**: Each observer handles its own reaction logic + +--- + +## 2. Null Object Pattern (Behavioral) + +### What It Does +Provides a default do-nothing object instead of returning None/null, +eliminating the need for null checks throughout the codebase. + +### Real-World Applications + +| System | Real Object | Null Object | Benefit | +|--------|-------------|-------------|---------| +| Library Management | RegisteredUser | GuestUser | Guest can browse without errors | +| E-Commerce | PremiumCustomer | NullCustomer | No crashes on missing accounts | +| Logging System | FileLogger | NullLogger | Disable logging without code changes | +| Payment System | CreditCardProcessor | NullProcessor | Skip payment in test mode | +| Notification System | EmailSender | NullSender | Disable notifications cleanly | + +### When to Use +- When you find yourself writing `if obj is not None` repeatedly +- When you want to provide a safe default behavior for missing objects +- When None checks are scattered across multiple modules + +### When NOT to Use +- When None/null is a meaningful and expected state +- When the absence of an object should genuinely raise an error +- When performance is critical and even empty method calls matter + +### SOLID Principles Applied +- **Liskov Substitution**: NullObject can replace the real object anywhere +- **Open/Closed Principle**: No need to modify existing code to handle null cases +- **Interface Segregation**: Null object implements the same interface + +### Code Comparison + +**Without Null Object (fragile):** +```python +customer = find_customer(id) +if customer is not None: + if customer.email is not None: + customer.send_notification("Hello") +``` + +**With Null Object (clean):** +```python +customer = find_customer(id) # Returns NullCustomer if not found +customer.send_notification("Hello") # Always safe +``` + +--- + +## 3. Singleton Pattern (Creational) + +### What It Does +Ensures a class has only one instance throughout the entire program +and provides a global access point to that instance. + +### Real-World Applications + +| System | Singleton Class | Why Only One? | +|--------|----------------|---------------| +| Any Application | DatabaseConnection | One connection pool shared everywhere | +| Any Application | AppConfig | One consistent configuration source | +| Online Exam Platform | ExamConfig | All modules read the same settings | +| ERP System | LicenseManager | One license check for the whole system | +| Game Engine | GameState | One game state shared by all systems | +| Web Server | Logger | One log file, one writer | + +### When to Use +- When exactly one instance is needed to coordinate actions across the system +- When that instance needs to be accessible from many different places +- When creating multiple instances would waste resources or cause conflicts + +### When NOT to Use +- When you need multiple independent instances +- When unit testing requires isolated instances (use dependency injection instead) +- When the singleton holds mutable state that causes hidden coupling + +### SOLID Principles Applied +- **Single Responsibility**: The singleton manages its own instantiation +- **Open/Closed Principle**: Subclasses can extend behavior while maintaining single instance + +--- + +## 4. Specification Pattern (Behavioral) + +### What It Does +Encapsulates business rules as standalone objects that can be combined +using boolean logic (AND, OR, NOT) to create complex selection criteria. + +### Real-World Applications + +| System | Specifications | Combined Rule Example | +|--------|---------------|----------------------| +| Inventory Management | LowStock, InCategory, FromSupplier | Low stock electronics from TechCorp | +| E-Commerce | PriceBelow, InStock, HasDiscount | Cheap available items on sale | +| Library Management | AvailableBook, InGenre, PublishedAfter | Available sci-fi books after 2020 | +| HR System | InDepartment, SeniorityAbove, HasCertification | Senior certified engineers | +| Banking | HighBalance, ActiveAccount, NoOverdraft | Eligible accounts for premium services | + +### When to Use +- When filtering logic is complex and changes frequently +- When the same business rules are reused across different modules +- When you need to combine rules dynamically at runtime + +### When NOT to Use +- When filtering logic is simple and unlikely to change +- When you have only one or two conditions +- When performance is critical and object creation overhead matters + +### SOLID Principles Applied +- **Single Responsibility**: Each specification encapsulates exactly one rule +- **Open/Closed Principle**: New rules are added as new classes, no existing code changes +- **Interface Segregation**: Each specification has one method: is_satisfied_by() + +--- + +## Pattern Selection Guide + +### How to Choose the Right Pattern + +Do multiple parts of your system need to react to the same event? + +└── YES → Observer Pattern +Are you checking if something is None/null in many places? + +└── YES → Null Object Pattern +Do you need exactly one shared instance of a resource? + +└── YES → Singleton Pattern +Do you have complex, changeable filtering or validation rules? + +└── YES → Specification Pattern + +--- + +## Cross-Pattern Integration + +In real-world systems, patterns work together. Here is an example of how +all four patterns could be used in a single Library Management System: + +- **Singleton**: `LibraryConfig` — one shared configuration for the entire system +- **Observer**: `Book` notifies `WaitingUser` objects when returned +- **Null Object**: `GuestUser` replaces None for unauthenticated visitors +- **Specification**: `AvailableBookSpec.and_(InGenreSpec("science"))` filters the catalog + +Each pattern solves a different problem, and together they create a clean, +maintainable, and extensible architecture. + +--- + +## References + +- Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). *Design Patterns: + Elements of Reusable Object-Oriented Software*. Addison-Wesley. +- Martin, R. C. (2003). *Agile Software Development, Principles, Patterns, + and Practices*. Prentice Hall. +- Python Patterns Repository: https://github.com/faif/python-patterns \ No newline at end of file diff --git a/patterns/behavioral/null_object.py b/patterns/behavioral/null_object.py new file mode 100644 index 000000000..9ef6165ff --- /dev/null +++ b/patterns/behavioral/null_object.py @@ -0,0 +1,135 @@ +""" +http://code.tutsplus.com/articles/null-object-design-pattern--mobile-19377 + +*TL;DR +Provides a default object that acts as a surrogate for a missing object, +avoiding the need for null checks throughout the codebase. + +*Examples in Python ecosystem: +Python's logging module uses a NullHandler to discard log records when +no other handlers are configured: https://docs.python.org/3/library/logging.handlers.html#logging.NullHandler +""" + +# null_object.py + +from __future__ import annotations +from typing import Union + + +class Customer: + """ + A real customer with actual behavior. + """ + + def __init__(self, name: str, email: str) -> None: + """ + Initialize a customer with a name and email. + + Args: + name (str): The customer's name. + email (str): The customer's email address. + """ + self.name = name + self.email = email + + def send_notification(self, message: str) -> None: + """ + Send a notification to the customer. + + Args: + message (str): The notification message. + """ + print(f"Sending '{message}' to {self.name} at {self.email}") + + def is_null(self) -> bool: + """ + Indicate that this is a real customer, not a null object. + """ + return False + + +class NullCustomer(Customer): + """ + A null object that mimics a Customer but performs no action. + Used as a safe default when a real customer is not found. + """ + + def __init__(self) -> None: + """ + Initialize the null customer with default empty values. + """ + self.name = "N/A" + self.email = "N/A" + + def send_notification(self, message: str) -> None: + """ + Override to do nothing instead of sending a notification. + + Args: + message (str): The notification message (ignored). + """ + pass + + def is_null(self) -> bool: + """ + Indicate that this is a null customer. + """ + return True + + +class CustomerRepository: + """ + A simple repository that stores and retrieves customers. + Returns a NullCustomer when the requested customer is not found. + """ + + def __init__(self) -> None: + self._customers = { + 1: Customer("Ahmed", "ahmed@example.com"), + 2: Customer("Sara", "sara@example.com"), + } + + def get_customer(self, customer_id: int) -> Union[Customer, NullCustomer]: + """ + Retrieve a customer by ID, or return a NullCustomer if not found. + + Args: + customer_id (int): The customer's unique identifier. + + Returns: + Customer or NullCustomer: The matching customer or a null object. + """ + return self._customers.get(customer_id, NullCustomer()) + + +def main(): + """ + >>> repo = CustomerRepository() + + # Retrieve an existing customer and send a notification + >>> customer = repo.get_customer(1) + >>> customer.is_null() + False + >>> customer.send_notification("Your order has shipped") + Sending 'Your order has shipped' to Ahmed at ahmed@example.com + + # Retrieve a non-existing customer - returns NullCustomer + >>> missing = repo.get_customer(999) + >>> missing.is_null() + True + + # Calling methods on NullCustomer is safe and does nothing + >>> missing.send_notification("Your order has shipped") + + # No null checks needed - the code stays clean + >>> for customer_id in [1, 2, 999]: + ... repo.get_customer(customer_id).send_notification("Hello") + Sending 'Hello' to Ahmed at ahmed@example.com + Sending 'Hello' to Sara at sara@example.com + """ + + +if __name__ == "__main__": + import doctest + + doctest.testmod() \ No newline at end of file diff --git a/patterns/behavioral/specification.py b/patterns/behavioral/specification.py index 10d226894..0166246ef 100644 --- a/patterns/behavioral/specification.py +++ b/patterns/behavioral/specification.py @@ -1,8 +1,17 @@ """ @author: Gordeev Andrey +https://en.wikipedia.org/wiki/Specification_pattern + *TL;DR -Provides recombination business logic by chaining together using boolean logic. +Encapsulates business rules as standalone objects that can be combined using +boolean logic (AND, OR, NOT). This allows complex selection criteria to be +built dynamically without hardcoding conditions throughout the codebase. + +*Examples in Python ecosystem: +Django QuerySet API uses Q objects to compose database queries with logical +operators, applying the same composition principle as the Specification pattern: +https://docs.djangoproject.com/en/stable/topics/db/queries/#complex-lookups-with-q-objects """ from abc import abstractmethod @@ -10,41 +19,75 @@ class Specification: + """ + Base interface for all specifications. + + A specification encapsulates a single business rule and can be combined + with other specifications using logical operators. + """ + def and_specification(self, candidate): + """Combine this specification with another using logical AND.""" raise NotImplementedError() def or_specification(self, candidate): + """Combine this specification with another using logical OR.""" raise NotImplementedError() def not_specification(self): + """Return the logical negation of this specification.""" raise NotImplementedError() @abstractmethod def is_satisfied_by(self, candidate): + """ + Check whether the given candidate satisfies this specification. + + Args: + candidate: The object to evaluate against the rule. + + Returns: + bool: True if the candidate satisfies the rule, otherwise False. + """ pass class CompositeSpecification(Specification): + """ + Abstract specification that provides default implementations for + combining specifications using AND, OR, and NOT. + + All concrete specifications should inherit from this class to gain + automatic support for logical composition. + """ + @abstractmethod def is_satisfied_by(self, candidate): pass def and_specification(self, candidate: "Specification") -> "AndSpecification": + """Return a new specification that is satisfied when both rules are satisfied.""" return AndSpecification(self, candidate) def or_specification(self, candidate: "Specification") -> "OrSpecification": + """Return a new specification that is satisfied when at least one rule is satisfied.""" return OrSpecification(self, candidate) def not_specification(self) -> "NotSpecification": + """Return a new specification that is satisfied when this rule is NOT satisfied.""" return NotSpecification(self) class AndSpecification(CompositeSpecification): + """ + Composite specification satisfied only when BOTH inner specifications are satisfied. + """ + def __init__(self, one: "Specification", other: "Specification") -> None: self._one: Specification = one self._other: Specification = other - def is_satisfied_by(self, candidate: Union["User", str]) -> bool: + def is_satisfied_by(self, candidate) -> bool: return bool( self._one.is_satisfied_by(candidate) and self._other.is_satisfied_by(candidate) @@ -52,11 +95,15 @@ def is_satisfied_by(self, candidate: Union["User", str]) -> bool: class OrSpecification(CompositeSpecification): + """ + Composite specification satisfied when AT LEAST ONE of the inner specifications is satisfied. + """ + def __init__(self, one: "Specification", other: "Specification") -> None: self._one: Specification = one self._other: Specification = other - def is_satisfied_by(self, candidate: Union["User", str]): + def is_satisfied_by(self, candidate) -> bool: return bool( self._one.is_satisfied_by(candidate) or self._other.is_satisfied_by(candidate) @@ -64,28 +111,86 @@ def is_satisfied_by(self, candidate: Union["User", str]): class NotSpecification(CompositeSpecification): - def __init__(self, wrapped: "Specification"): + """ + Composite specification satisfied when the wrapped specification is NOT satisfied. + """ + + def __init__(self, wrapped: "Specification") -> None: self._wrapped: Specification = wrapped - def is_satisfied_by(self, candidate: Union["User", str]): + def is_satisfied_by(self, candidate) -> bool: return bool(not self._wrapped.is_satisfied_by(candidate)) +# --------------------------------------------------------------------------- +# Original example: User / SuperUser +# --------------------------------------------------------------------------- + + class User: def __init__(self, super_user: bool = False) -> None: self.super_user = super_user class UserSpecification(CompositeSpecification): - def is_satisfied_by(self, candidate: Union["User", str]) -> bool: + """Specification satisfied when the candidate is a User instance.""" + + def is_satisfied_by(self, candidate) -> bool: return isinstance(candidate, User) class SuperUserSpecification(CompositeSpecification): - def is_satisfied_by(self, candidate: "User") -> bool: + """Specification satisfied when the candidate is a super-user.""" + + def is_satisfied_by(self, candidate) -> bool: return getattr(candidate, "super_user", False) +# --------------------------------------------------------------------------- +# Real-world example: filtering products in an e-commerce catalog +# --------------------------------------------------------------------------- + + +class Product: + """A simple product in an e-commerce catalog.""" + + def __init__(self, name: str, price: float, category: str, in_stock: bool) -> None: + self.name = name + self.price = price + self.category = category + self.in_stock = in_stock + + def __repr__(self) -> str: + return self.name + + +class PriceBelowSpecification(CompositeSpecification): + """Specification satisfied when the product's price is below a given limit.""" + + def __init__(self, limit: float) -> None: + self._limit = limit + + def is_satisfied_by(self, candidate: Product) -> bool: + return candidate.price < self._limit + + +class InCategorySpecification(CompositeSpecification): + """Specification satisfied when the product belongs to a given category.""" + + def __init__(self, category: str) -> None: + self._category = category + + def is_satisfied_by(self, candidate: Product) -> bool: + return candidate.category == self._category + + +class InStockSpecification(CompositeSpecification): + """Specification satisfied when the product is in stock.""" + + def is_satisfied_by(self, candidate: Product) -> bool: + return candidate.in_stock + + def main(): """ >>> andrey = User() @@ -101,10 +206,38 @@ def main(): (True, 'ivan') >>> root_specification.is_satisfied_by(vasiliy), 'vasiliy' (False, 'vasiliy') + + # Real-world example: filtering products in an e-commerce catalog + >>> products = [ + ... Product('Python Book', 45.0, 'books', True), + ... Product('Laptop', 1200.0, 'electronics', True), + ... Product('Headphones', 80.0, 'electronics', False), + ... Product('Notebook', 5.0, 'stationery', True), + ... ] + + # Build composable rules + >>> cheap = PriceBelowSpecification(100) + >>> electronics = InCategorySpecification('electronics') + >>> available = InStockSpecification() + + # Show cheap products that are in stock + >>> cheap_and_available = cheap.and_specification(available) + >>> [p for p in products if cheap_and_available.is_satisfied_by(p)] + [Python Book, Notebook] + + # Show electronics that are out of stock (using NOT) + >>> out_of_stock_electronics = electronics.and_specification(available.not_specification()) + >>> [p for p in products if out_of_stock_electronics.is_satisfied_by(p)] + [Headphones] + + # Show cheap items OR electronics + >>> cheap_or_electronics = cheap.or_specification(electronics) + >>> [p for p in products if cheap_or_electronics.is_satisfied_by(p)] + [Python Book, Laptop, Headphones, Notebook] """ if __name__ == "__main__": import doctest - doctest.testmod() + doctest.testmod() \ No newline at end of file diff --git a/patterns/creational/singleton.py b/patterns/creational/singleton.py new file mode 100644 index 000000000..11ee1debf --- /dev/null +++ b/patterns/creational/singleton.py @@ -0,0 +1,122 @@ +""" +https://en.wikipedia.org/wiki/Singleton_pattern + +*TL;DR +Ensures a class has only one instance and provides a global point of access to it. +Useful for shared resources such as configuration objects, connection pools, and loggers +where having multiple instances would waste resources or cause inconsistent behavior. + +*Examples in Python ecosystem: +Python's logging module returns the same logger instance for the same name, effectively +applying the Singleton pattern: https://docs.python.org/3/library/logging.html#logging.getLogger +""" + +# singleton.py + +from __future__ import annotations +from typing import Any, Dict + + +class Singleton: + """ + Classic Singleton implementation using __new__. + + Ensures that only one instance of the class exists during the lifetime + of the program. Any subsequent call to the constructor returns the + same instance that was created on the first call. + """ + + _instance: "Singleton" = None # type: ignore + + def __new__(cls, *args: Any, **kwargs: Any) -> "Singleton": + """ + Override the default object creation to return the existing instance + if one already exists, otherwise create and store a new instance. + """ + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + +class AppConfig(Singleton): + """ + Application configuration object implemented as a Singleton. + + All parts of the program that request AppConfig receive the same + instance, ensuring a single, consistent source of configuration. + """ + + _initialized: bool = False + + def __init__(self) -> None: + """ + Initialize default settings only on the first instantiation. + Subsequent instantiations leave the existing settings untouched. + """ + if self._initialized: + return + self._settings: Dict[str, Any] = { + "database_url": "localhost:5432", + "debug": False, + "language": "en", + } + self._initialized = True + + def get(self, key: str) -> Any: + """ + Retrieve a configuration value by key. + + Args: + key (str): The configuration key to look up. + + Returns: + The associated configuration value, or None if the key is missing. + """ + return self._settings.get(key) + + def set(self, key: str, value: Any) -> None: + """ + Update or add a configuration value. + + Args: + key (str): The configuration key to set. + value: The new configuration value. + """ + self._settings[key] = value + + +def main(): + """ + >>> config1 = AppConfig() + >>> config2 = AppConfig() + + # Both variables refer to the exact same instance + >>> config1 is config2 + True + + # Default settings are accessible from any reference + >>> config1.get("language") + 'en' + >>> config2.get("debug") + False + + # Changing a setting through one reference is visible through the other + >>> config1.set("language", "ar") + >>> config2.get("language") + 'ar' + + # Adding a new setting works the same way + >>> config2.set("theme", "dark") + >>> config1.get("theme") + 'dark' + + # A missing key returns None + >>> config1.get("missing_key") is None + True + """ + + +if __name__ == "__main__": + import doctest + + doctest.testmod() \ No newline at end of file diff --git a/patterns_toolkit/examples/exam_platform_with_singleton.py b/patterns_toolkit/examples/exam_platform_with_singleton.py new file mode 100644 index 000000000..9f1dfeb1b --- /dev/null +++ b/patterns_toolkit/examples/exam_platform_with_singleton.py @@ -0,0 +1,122 @@ +""" +Example: Singleton Pattern Applied to an Online Exam Platform +============================================================== +This example shows how the Singleton pattern can be used for +a centralized exam configuration that is shared across all +parts of the platform. + +This directly relates to an Online Exam Platform project, +demonstrating how design patterns integrate into real-world applications. +""" + +from __future__ import annotations +from typing import Any, Dict + + +class Singleton: + _instance = None + + def __new__(cls, *args: Any, **kwargs: Any): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + +class ExamConfig(Singleton): + """ + Centralized exam configuration. + All modules (timer, grading, question loader) share the same config. + """ + + _initialized: bool = False + + def __init__(self) -> None: + if self._initialized: + return + self._settings: Dict[str, Any] = { + "time_limit_minutes": 60, + "passing_score": 50, + "shuffle_questions": True, + "show_results_immediately": False, + "max_attempts": 3, + } + self._initialized = True + + def get(self, key: str) -> Any: + return self._settings.get(key) + + def set(self, key: str, value: Any) -> None: + self._settings[key] = value + + +class ExamTimer: + """Timer module that reads time limit from the shared config.""" + + def get_time_limit(self) -> str: + config = ExamConfig() + minutes = config.get("time_limit_minutes") + return f"Exam time limit: {minutes} minutes" + + +class ExamGrader: + """Grading module that reads passing score from the shared config.""" + + def check_result(self, score: int) -> str: + config = ExamConfig() + passing = config.get("passing_score") + if score >= passing: + return f"Score {score}/{100}: PASSED (minimum: {passing})" + return f"Score {score}/{100}: FAILED (minimum: {passing})" + + +class QuestionLoader: + """Question module that checks shuffle setting from the shared config.""" + + def load_questions(self) -> str: + config = ExamConfig() + shuffle = config.get("shuffle_questions") + if shuffle: + return "Loading questions in random order..." + return "Loading questions in original order..." + + +def main(): + """ + >>> timer = ExamTimer() + >>> grader = ExamGrader() + >>> loader = QuestionLoader() + + # All modules read from the SAME config instance + >>> timer.get_time_limit() + 'Exam time limit: 60 minutes' + + >>> grader.check_result(75) + 'Score 75/100: PASSED (minimum: 50)' + + >>> grader.check_result(30) + 'Score 30/100: FAILED (minimum: 50)' + + >>> loader.load_questions() + 'Loading questions in random order...' + + # Admin changes the config - all modules see the change immediately + >>> admin_config = ExamConfig() + >>> admin_config.set("time_limit_minutes", 90) + >>> admin_config.set("passing_score", 60) + >>> admin_config.set("shuffle_questions", False) + + >>> timer.get_time_limit() + 'Exam time limit: 90 minutes' + + >>> grader.check_result(55) + 'Score 55/100: FAILED (minimum: 60)' + + >>> loader.load_questions() + 'Loading questions in original order...' + """ + + +if __name__ == "__main__": + import doctest + + doctest.testmod() \ No newline at end of file diff --git a/patterns_toolkit/examples/inventory_with_specification.py b/patterns_toolkit/examples/inventory_with_specification.py new file mode 100644 index 000000000..b6dc53706 --- /dev/null +++ b/patterns_toolkit/examples/inventory_with_specification.py @@ -0,0 +1,175 @@ +""" +Example: Specification Pattern Applied to an Inventory Management System +========================================================================= +This example shows how the Specification pattern can be used to create +flexible, composable filters for inventory queries. + +This directly relates to an Inventory Management System project, +demonstrating how design patterns integrate into real-world applications. +""" + +from __future__ import annotations +from abc import abstractmethod +from typing import List + + +# --- Specification Base --- + + +class Specification: + @abstractmethod + def is_satisfied_by(self, candidate) -> bool: + pass + + def and_(self, other: Specification) -> AndSpec: + return AndSpec(self, other) + + def or_(self, other: Specification) -> OrSpec: + return OrSpec(self, other) + + def not_(self) -> NotSpec: + return NotSpec(self) + + +class AndSpec(Specification): + def __init__(self, one: Specification, other: Specification) -> None: + self._one = one + self._other = other + + def is_satisfied_by(self, candidate) -> bool: + return self._one.is_satisfied_by(candidate) and self._other.is_satisfied_by(candidate) + + +class OrSpec(Specification): + def __init__(self, one: Specification, other: Specification) -> None: + self._one = one + self._other = other + + def is_satisfied_by(self, candidate) -> bool: + return self._one.is_satisfied_by(candidate) or self._other.is_satisfied_by(candidate) + + +class NotSpec(Specification): + def __init__(self, wrapped: Specification) -> None: + self._wrapped = wrapped + + def is_satisfied_by(self, candidate) -> bool: + return not self._wrapped.is_satisfied_by(candidate) + + +# --- Inventory Domain --- + + +class InventoryItem: + """A product in the inventory system.""" + + def __init__( + self, name: str, category: str, quantity: int, price: float, supplier: str + ) -> None: + self.name = name + self.category = category + self.quantity = quantity + self.price = price + self.supplier = supplier + + def __repr__(self) -> str: + return f"{self.name} (qty: {self.quantity})" + + +# --- Inventory Specifications --- + + +class LowStockSpec(Specification): + """Items with quantity below a minimum threshold.""" + + def __init__(self, minimum: int = 10) -> None: + self._minimum = minimum + + def is_satisfied_by(self, candidate: InventoryItem) -> bool: + return candidate.quantity < self._minimum + + +class InCategorySpec(Specification): + """Items belonging to a specific category.""" + + def __init__(self, category: str) -> None: + self._category = category + + def is_satisfied_by(self, candidate: InventoryItem) -> bool: + return candidate.category == self._category + + +class PriceAboveSpec(Specification): + """Items priced above a certain amount.""" + + def __init__(self, amount: float) -> None: + self._amount = amount + + def is_satisfied_by(self, candidate: InventoryItem) -> bool: + return candidate.price > self._amount + + +class FromSupplierSpec(Specification): + """Items from a specific supplier.""" + + def __init__(self, supplier: str) -> None: + self._supplier = supplier + + def is_satisfied_by(self, candidate: InventoryItem) -> bool: + return candidate.supplier == self._supplier + + +class OutOfStockSpec(Specification): + """Items with zero quantity.""" + + def is_satisfied_by(self, candidate: InventoryItem) -> bool: + return candidate.quantity == 0 + + +def main(): + """ + >>> inventory = [ + ... InventoryItem("Laptop", "electronics", 25, 1200.0, "TechCorp"), + ... InventoryItem("Mouse", "electronics", 3, 25.0, "TechCorp"), + ... InventoryItem("Desk", "furniture", 8, 350.0, "OfficePlus"), + ... InventoryItem("Chair", "furniture", 0, 200.0, "OfficePlus"), + ... InventoryItem("Notebook", "stationery", 150, 3.0, "PaperWorld"), + ... InventoryItem("Pen", "stationery", 5, 1.5, "PaperWorld"), + ... ] + + # Find low stock items (below 10 units) + >>> low_stock = LowStockSpec(10) + >>> [i for i in inventory if low_stock.is_satisfied_by(i)] + [Mouse (qty: 3), Desk (qty: 8), Chair (qty: 0), Pen (qty: 5)] + + # Find out-of-stock items + >>> out_of_stock = OutOfStockSpec() + >>> [i for i in inventory if out_of_stock.is_satisfied_by(i)] + [Chair (qty: 0)] + + # Find low stock electronics (combining two rules) + >>> low_stock_electronics = low_stock.and_(InCategorySpec("electronics")) + >>> [i for i in inventory if low_stock_electronics.is_satisfied_by(i)] + [Mouse (qty: 3)] + + # Find expensive items from TechCorp + >>> expensive_techcorp = PriceAboveSpec(100).and_(FromSupplierSpec("TechCorp")) + >>> [i for i in inventory if expensive_techcorp.is_satisfied_by(i)] + [Laptop (qty: 25)] + + # Find items that need reordering: low stock OR out of stock + >>> needs_reorder = low_stock.or_(out_of_stock) + >>> [i for i in inventory if needs_reorder.is_satisfied_by(i)] + [Mouse (qty: 3), Desk (qty: 8), Chair (qty: 0), Pen (qty: 5)] + + # Find furniture that is NOT out of stock + >>> available_furniture = InCategorySpec("furniture").and_(out_of_stock.not_()) + >>> [i for i in inventory if available_furniture.is_satisfied_by(i)] + [Desk (qty: 8)] + """ + + +if __name__ == "__main__": + import doctest + + doctest.testmod() \ No newline at end of file diff --git a/patterns_toolkit/examples/library_system_with_observer.py b/patterns_toolkit/examples/library_system_with_observer.py new file mode 100644 index 000000000..0fb3ecabc --- /dev/null +++ b/patterns_toolkit/examples/library_system_with_observer.py @@ -0,0 +1,145 @@ +""" +Example: Observer Pattern Applied to a Library Management System +================================================================= +This example shows how the Observer pattern can be used to notify +users when a book they are waiting for becomes available. + +This directly relates to a Library Management System project, +demonstrating how design patterns integrate into real-world applications. +""" + +from __future__ import annotations +from typing import List + + +# --- Observer Pattern Base --- + + +class Observer: + def update(self, subject: Book) -> None: + raise NotImplementedError + + +class Subject: + def __init__(self) -> None: + self._observers: List[Observer] = [] + + def attach(self, observer: Observer) -> None: + if observer not in self._observers: + self._observers.append(observer) + + def detach(self, observer: Observer) -> None: + try: + self._observers.remove(observer) + except ValueError: + pass + + def notify(self) -> None: + for observer in self._observers: + observer.update(self) + + +# --- Library Domain --- + + +class Book(Subject): + """A book in the library catalog that notifies waiting users when returned.""" + + def __init__(self, title: str, author: str) -> None: + super().__init__() + self.title = title + self.author = author + self._available = True + + @property + def available(self) -> bool: + return self._available + + def borrow(self, borrower_name: str) -> str: + if not self._available: + return f"'{self.title}' is currently unavailable" + self._available = False + return f"'{self.title}' borrowed by {borrower_name}" + + def return_book(self) -> str: + self._available = True + self.notify() # Notify all waiting users + return f"'{self.title}' has been returned and is now available" + + +class WaitingUser(Observer): + """A library member waiting for a specific book to become available.""" + + def __init__(self, name: str) -> None: + self.name = name + self.notifications: List[str] = [] + + def update(self, subject: Book) -> None: + message = f"Dear {self.name}, '{subject.title}' by {subject.author} is now available!" + self.notifications.append(message) + print(message) + + +class EmailNotifier(Observer): + """Sends email notifications when a book becomes available.""" + + def update(self, subject: Book) -> None: + print(f"[EMAIL] Sending availability alert for '{subject.title}' to all subscribers") + + +class SMSNotifier(Observer): + """Sends SMS notifications when a book becomes available.""" + + def update(self, subject: Book) -> None: + print(f"[SMS] '{subject.title}' is back in the library") + + +def main(): + """ + >>> book = Book("Design Patterns", "Gang of Four") + + # Users register interest in the book + >>> ahmed = WaitingUser("Ahmed") + >>> sara = WaitingUser("Sara") + >>> email = EmailNotifier() + + >>> book.attach(ahmed) + >>> book.attach(sara) + >>> book.attach(email) + + # Someone borrows the book + >>> book.borrow("Omar") + "'Design Patterns' borrowed by Omar" + + # Try to borrow again while unavailable + >>> book.borrow("Ali") + "'Design Patterns' is currently unavailable" + + # Book is returned - all observers are notified automatically + >>> book.return_book() + Dear Ahmed, 'Design Patterns' by Gang of Four is now available! + Dear Sara, 'Design Patterns' by Gang of Four is now available! + [EMAIL] Sending availability alert for 'Design Patterns' to all subscribers + "'Design Patterns' has been returned and is now available" + + # Ahmed got notified + >>> ahmed.notifications + ["Dear Ahmed, 'Design Patterns' by Gang of Four is now available!"] + + # Detach Ahmed (he already borrowed the book) + >>> book.detach(ahmed) + + # Return the book again - only Sara and email get notified + >>> book.borrow("Ahmed") + "'Design Patterns' borrowed by Ahmed" + >>> book.return_book() + Dear Sara, 'Design Patterns' by Gang of Four is now available! + [EMAIL] Sending availability alert for 'Design Patterns' to all subscribers + "'Design Patterns' has been returned and is now available" + """ + + +if __name__ == "__main__": + import doctest + + doctest.testmod() \ No newline at end of file diff --git a/patterns_toolkit/reusable/null_object.py b/patterns_toolkit/reusable/null_object.py new file mode 100644 index 000000000..76251505d --- /dev/null +++ b/patterns_toolkit/reusable/null_object.py @@ -0,0 +1,33 @@ +""" +Reusable Null Object Pattern Template +======================================= +Copy this file into your project and adapt the base class +and its null counterpart to your domain. + +Usage: + 1. Define your real class with actual behavior + 2. Create a Null version that inherits from it + 3. Override methods to do nothing safely + 4. Return the Null version instead of None +""" + +from __future__ import annotations + + +class NullObjectMixin: + """ + Mixin that provides a standard is_null() check. + Add this to both your real class and null class. + """ + + def is_null(self) -> bool: + return False + + +class NullMixin: + """ + Mixin for the null version of a class. + """ + + def is_null(self) -> bool: + return True \ No newline at end of file diff --git a/patterns_toolkit/reusable/observer.py b/patterns_toolkit/reusable/observer.py new file mode 100644 index 000000000..38c101816 --- /dev/null +++ b/patterns_toolkit/reusable/observer.py @@ -0,0 +1,49 @@ +""" +Reusable Observer Pattern Template +=================================== +Copy this file into your project and customize the concrete observers +to fit your domain. The Subject base class works as-is. + +Usage: + 1. Create concrete observers by inheriting from Observer + 2. Implement the update() method in each observer + 3. Attach observers to a subject + 4. When the subject's state changes, call self.notify() +""" + +from __future__ import annotations +from typing import List + + +class Observer: + """Base observer interface. Subclass this and implement update().""" + + def update(self, subject: Subject) -> None: + raise NotImplementedError("Subclasses must implement update()") + + +class Subject: + """ + Base subject that maintains a list of observers and notifies them + automatically when state changes. + """ + + def __init__(self) -> None: + self._observers: List[Observer] = [] + + def attach(self, observer: Observer) -> None: + """Subscribe an observer to receive updates.""" + if observer not in self._observers: + self._observers.append(observer) + + def detach(self, observer: Observer) -> None: + """Unsubscribe an observer from receiving updates.""" + try: + self._observers.remove(observer) + except ValueError: + pass + + def notify(self) -> None: + """Notify all attached observers by calling their update method.""" + for observer in self._observers: + observer.update(self) \ No newline at end of file diff --git a/patterns_toolkit/reusable/singleton.py b/patterns_toolkit/reusable/singleton.py new file mode 100644 index 000000000..eca1248a4 --- /dev/null +++ b/patterns_toolkit/reusable/singleton.py @@ -0,0 +1,28 @@ +""" +Reusable Singleton Pattern Template +===================================== +Copy this file into your project and inherit from Singleton +for any class that must have only one instance. + +Usage: + 1. Inherit your class from Singleton + 2. Use _initialized flag to prevent re-initialization + 3. All calls to YourClass() return the same instance +""" + +from __future__ import annotations +from typing import Any + + +class Singleton: + """ + Base Singleton class. Any class inheriting from this will only + ever have one instance throughout the program's lifetime. + """ + + _instance = None + + def __new__(cls, *args: Any, **kwargs: Any): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance \ No newline at end of file diff --git a/patterns_toolkit/reusable/specification.py b/patterns_toolkit/reusable/specification.py new file mode 100644 index 000000000..9f78f1705 --- /dev/null +++ b/patterns_toolkit/reusable/specification.py @@ -0,0 +1,57 @@ +""" +Reusable Specification Pattern Template +========================================= +Copy this file into your project and create concrete specifications +for your business rules. Combine them freely with and_/or_/not_. + +Usage: + 1. Inherit from CompositeSpecification + 2. Implement is_satisfied_by() for each business rule + 3. Combine specifications: spec_a.and_(spec_b).or_(spec_c) +""" + +from __future__ import annotations +from abc import abstractmethod + + +class Specification: + """Base specification interface.""" + + @abstractmethod + def is_satisfied_by(self, candidate) -> bool: + pass + + def and_(self, other: Specification) -> AndSpecification: + return AndSpecification(self, other) + + def or_(self, other: Specification) -> OrSpecification: + return OrSpecification(self, other) + + def not_(self) -> NotSpecification: + return NotSpecification(self) + + +class AndSpecification(Specification): + def __init__(self, one: Specification, other: Specification) -> None: + self._one = one + self._other = other + + def is_satisfied_by(self, candidate) -> bool: + return self._one.is_satisfied_by(candidate) and self._other.is_satisfied_by(candidate) + + +class OrSpecification(Specification): + def __init__(self, one: Specification, other: Specification) -> None: + self._one = one + self._other = other + + def is_satisfied_by(self, candidate) -> bool: + return self._one.is_satisfied_by(candidate) or self._other.is_satisfied_by(candidate) + + +class NotSpecification(Specification): + def __init__(self, wrapped: Specification) -> None: + self._wrapped = wrapped + + def is_satisfied_by(self, candidate) -> bool: + return not self._wrapped.is_satisfied_by(candidate) \ No newline at end of file diff --git a/tests/behavioral/test_null_object.py b/tests/behavioral/test_null_object.py new file mode 100644 index 000000000..6101d0a39 --- /dev/null +++ b/tests/behavioral/test_null_object.py @@ -0,0 +1,88 @@ +import pytest + +from patterns.behavioral.null_object import Customer, NullCustomer, CustomerRepository + + +@pytest.fixture +def repository(): + return CustomerRepository() + + +@pytest.fixture +def real_customer(): + return Customer("Ahmed", "ahmed@example.com") + + +@pytest.fixture +def null_customer(): + return NullCustomer() + + +# --- Customer Tests --- + + +def test_customer_is_not_null(real_customer): + assert real_customer.is_null() is False + + +def test_customer_has_correct_name(real_customer): + assert real_customer.name == "Ahmed" + + +def test_customer_has_correct_email(real_customer): + assert real_customer.email == "ahmed@example.com" + + +def test_customer_send_notification(real_customer, capsys): + real_customer.send_notification("Hello") + captured = capsys.readouterr() + assert "Sending 'Hello' to Ahmed at ahmed@example.com" in captured.out + + +# --- NullCustomer Tests --- + + +def test_null_customer_is_null(null_customer): + assert null_customer.is_null() is True + + +def test_null_customer_has_default_name(null_customer): + assert null_customer.name == "N/A" + + +def test_null_customer_has_default_email(null_customer): + assert null_customer.email == "N/A" + + +def test_null_customer_send_notification_does_nothing(null_customer, capsys): + null_customer.send_notification("Hello") + captured = capsys.readouterr() + assert captured.out == "" + + +# --- Repository Tests --- + + +def test_repository_returns_real_customer(repository): + customer = repository.get_customer(1) + assert customer.is_null() is False + assert customer.name == "Ahmed" + + +def test_repository_returns_null_for_missing(repository): + customer = repository.get_customer(999) + assert customer.is_null() is True + + +def test_repository_null_customer_is_safe(repository): + customer = repository.get_customer(999) + customer.send_notification("Test") # Should not raise any exception + + +def test_loop_over_mixed_customers(repository, capsys): + for customer_id in [1, 2, 999]: + repository.get_customer(customer_id).send_notification("Hi") + captured = capsys.readouterr() + assert "Ahmed" in captured.out + assert "Sara" in captured.out + assert captured.out.count("Sending") == 2 # Only 2 real customers \ No newline at end of file diff --git a/tests/behavioral/test_specification.py b/tests/behavioral/test_specification.py new file mode 100644 index 000000000..0ba5b685f --- /dev/null +++ b/tests/behavioral/test_specification.py @@ -0,0 +1,122 @@ +import pytest + +from patterns.behavioral.specification import ( + User, + UserSpecification, + SuperUserSpecification, + Product, + PriceBelowSpecification, + InCategorySpecification, + InStockSpecification, +) + + +# --- Fixtures --- + + +@pytest.fixture +def normal_user(): + return User(super_user=False) + + +@pytest.fixture +def super_user(): + return User(super_user=True) + + +@pytest.fixture +def products(): + return [ + Product("Python Book", 45.0, "books", True), + Product("Laptop", 1200.0, "electronics", True), + Product("Headphones", 80.0, "electronics", False), + Product("Notebook", 5.0, "stationery", True), + ] + + +# --- User Specification Tests --- + + +def test_user_specification_with_user(normal_user): + spec = UserSpecification() + assert spec.is_satisfied_by(normal_user) is True + + +def test_user_specification_with_non_user(): + spec = UserSpecification() + assert spec.is_satisfied_by("not a user") is False + + +def test_super_user_specification_true(super_user): + spec = SuperUserSpecification() + assert spec.is_satisfied_by(super_user) is True + + +def test_super_user_specification_false(normal_user): + spec = SuperUserSpecification() + assert spec.is_satisfied_by(normal_user) is False + + +def test_user_and_super_user_combined(normal_user, super_user): + spec = UserSpecification().and_specification(SuperUserSpecification()) + assert spec.is_satisfied_by(normal_user) is False + assert spec.is_satisfied_by(super_user) is True + + +# --- Product Specification Tests --- + + +def test_price_below(products): + spec = PriceBelowSpecification(100) + result = [p for p in products if spec.is_satisfied_by(p)] + assert len(result) == 3 + assert all(p.price < 100 for p in result) + + +def test_in_category(products): + spec = InCategorySpecification("electronics") + result = [p for p in products if spec.is_satisfied_by(p)] + assert len(result) == 2 + assert all(p.category == "electronics" for p in result) + + +def test_in_stock(products): + spec = InStockSpecification() + result = [p for p in products if spec.is_satisfied_by(p)] + assert len(result) == 3 + assert all(p.in_stock for p in result) + + +# --- Composite Specification Tests --- + + +def test_and_specification(products): + cheap_and_available = PriceBelowSpecification(100).and_specification( + InStockSpecification() + ) + result = [p for p in products if cheap_and_available.is_satisfied_by(p)] + assert [p.name for p in result] == ["Python Book", "Notebook"] + + +def test_or_specification(products): + cheap_or_electronics = PriceBelowSpecification(100).or_specification( + InCategorySpecification("electronics") + ) + result = [p for p in products if cheap_or_electronics.is_satisfied_by(p)] + assert len(result) == 4 # All products match + + +def test_not_specification(products): + not_in_stock = InStockSpecification().not_specification() + result = [p for p in products if not_in_stock.is_satisfied_by(p)] + assert len(result) == 1 + assert result[0].name == "Headphones" + + +def test_complex_combination(products): + electronics_out_of_stock = InCategorySpecification( + "electronics" + ).and_specification(InStockSpecification().not_specification()) + result = [p for p in products if electronics_out_of_stock.is_satisfied_by(p)] + assert len(result) == 1 + assert result[0].name == "Headphones" \ No newline at end of file diff --git a/tests/creational/test_singleton.py b/tests/creational/test_singleton.py new file mode 100644 index 000000000..5c0111357 --- /dev/null +++ b/tests/creational/test_singleton.py @@ -0,0 +1,83 @@ +import pytest + +from patterns.creational.singleton import Singleton, AppConfig + + +@pytest.fixture(autouse=True) +def reset_singleton(): + """Reset singleton instances before each test to ensure test isolation.""" + Singleton._instance = None + AppConfig._instance = None + AppConfig._initialized = False + yield + Singleton._instance = None + AppConfig._instance = None + AppConfig._initialized = False + + +# --- Singleton Base Tests --- + + +def test_singleton_same_instance(): + s1 = Singleton() + s2 = Singleton() + assert s1 is s2 + + +def test_singleton_only_one_instance(): + instances = [Singleton() for _ in range(10)] + assert all(inst is instances[0] for inst in instances) + + +# --- AppConfig Tests --- + + +def test_appconfig_is_singleton(): + config1 = AppConfig() + config2 = AppConfig() + assert config1 is config2 + + +def test_appconfig_default_settings(): + config = AppConfig() + assert config.get("database_url") == "localhost:5432" + assert config.get("debug") is False + assert config.get("language") == "en" + + +def test_appconfig_set_and_get(): + config = AppConfig() + config.set("language", "ar") + assert config.get("language") == "ar" + + +def test_appconfig_shared_state(): + config1 = AppConfig() + config2 = AppConfig() + config1.set("theme", "dark") + assert config2.get("theme") == "dark" + + +def test_appconfig_missing_key_returns_none(): + config = AppConfig() + assert config.get("nonexistent_key") is None + + +def test_appconfig_initialized_only_once(): + config1 = AppConfig() + config1.set("language", "ar") + config2 = AppConfig() + assert config2.get("language") == "ar" # Not reset to "en" + + +def test_appconfig_add_new_setting(): + config = AppConfig() + config.set("max_connections", 100) + assert config.get("max_connections") == 100 + + +def test_appconfig_overwrite_setting(): + config = AppConfig() + assert config.get("debug") is False + config.set("debug", True) + assert config.get("debug") is True \ No newline at end of file