11"""
22@author: Gordeev Andrey <gordeev.and.and@gmail.com>
33
4+ https://en.wikipedia.org/wiki/Specification_pattern
5+
46*TL;DR
5- Provides recombination business logic by chaining together using boolean logic.
7+ Encapsulates business rules as standalone objects that can be combined using
8+ boolean logic (AND, OR, NOT). This allows complex selection criteria to be
9+ built dynamically without hardcoding conditions throughout the codebase.
10+
11+ *Examples in Python ecosystem:
12+ Django QuerySet API uses Q objects to compose database queries with logical
13+ operators, applying the same composition principle as the Specification pattern:
14+ https://docs.djangoproject.com/en/stable/topics/db/queries/#complex-lookups-with-q-objects
615"""
716
817from abc import abstractmethod
918from typing import Union
1019
1120
1221class Specification :
22+ """
23+ Base interface for all specifications.
24+
25+ A specification encapsulates a single business rule and can be combined
26+ with other specifications using logical operators.
27+ """
28+
1329 def and_specification (self , candidate ):
30+ """Combine this specification with another using logical AND."""
1431 raise NotImplementedError ()
1532
1633 def or_specification (self , candidate ):
34+ """Combine this specification with another using logical OR."""
1735 raise NotImplementedError ()
1836
1937 def not_specification (self ):
38+ """Return the logical negation of this specification."""
2039 raise NotImplementedError ()
2140
2241 @abstractmethod
2342 def is_satisfied_by (self , candidate ):
43+ """
44+ Check whether the given candidate satisfies this specification.
45+
46+ Args:
47+ candidate: The object to evaluate against the rule.
48+
49+ Returns:
50+ bool: True if the candidate satisfies the rule, otherwise False.
51+ """
2452 pass
2553
2654
2755class CompositeSpecification (Specification ):
56+ """
57+ Abstract specification that provides default implementations for
58+ combining specifications using AND, OR, and NOT.
59+
60+ All concrete specifications should inherit from this class to gain
61+ automatic support for logical composition.
62+ """
63+
2864 @abstractmethod
2965 def is_satisfied_by (self , candidate ):
3066 pass
3167
3268 def and_specification (self , candidate : "Specification" ) -> "AndSpecification" :
69+ """Return a new specification that is satisfied when both rules are satisfied."""
3370 return AndSpecification (self , candidate )
3471
3572 def or_specification (self , candidate : "Specification" ) -> "OrSpecification" :
73+ """Return a new specification that is satisfied when at least one rule is satisfied."""
3674 return OrSpecification (self , candidate )
3775
3876 def not_specification (self ) -> "NotSpecification" :
77+ """Return a new specification that is satisfied when this rule is NOT satisfied."""
3978 return NotSpecification (self )
4079
4180
4281class AndSpecification (CompositeSpecification ):
82+ """
83+ Composite specification satisfied only when BOTH inner specifications are satisfied.
84+ """
85+
4386 def __init__ (self , one : "Specification" , other : "Specification" ) -> None :
4487 self ._one : Specification = one
4588 self ._other : Specification = other
4689
47- def is_satisfied_by (self , candidate : Union [ "User" , str ] ) -> bool :
90+ def is_satisfied_by (self , candidate ) -> bool :
4891 return bool (
4992 self ._one .is_satisfied_by (candidate )
5093 and self ._other .is_satisfied_by (candidate )
5194 )
5295
5396
5497class OrSpecification (CompositeSpecification ):
98+ """
99+ Composite specification satisfied when AT LEAST ONE of the inner specifications is satisfied.
100+ """
101+
55102 def __init__ (self , one : "Specification" , other : "Specification" ) -> None :
56103 self ._one : Specification = one
57104 self ._other : Specification = other
58105
59- def is_satisfied_by (self , candidate : Union [ "User" , str ]) :
106+ def is_satisfied_by (self , candidate ) -> bool :
60107 return bool (
61108 self ._one .is_satisfied_by (candidate )
62109 or self ._other .is_satisfied_by (candidate )
63110 )
64111
65112
66113class NotSpecification (CompositeSpecification ):
67- def __init__ (self , wrapped : "Specification" ):
114+ """
115+ Composite specification satisfied when the wrapped specification is NOT satisfied.
116+ """
117+
118+ def __init__ (self , wrapped : "Specification" ) -> None :
68119 self ._wrapped : Specification = wrapped
69120
70- def is_satisfied_by (self , candidate : Union [ "User" , str ]) :
121+ def is_satisfied_by (self , candidate ) -> bool :
71122 return bool (not self ._wrapped .is_satisfied_by (candidate ))
72123
73124
125+ # ---------------------------------------------------------------------------
126+ # Original example: User / SuperUser
127+ # ---------------------------------------------------------------------------
128+
129+
74130class User :
75131 def __init__ (self , super_user : bool = False ) -> None :
76132 self .super_user = super_user
77133
78134
79135class UserSpecification (CompositeSpecification ):
80- def is_satisfied_by (self , candidate : Union ["User" , str ]) -> bool :
136+ """Specification satisfied when the candidate is a User instance."""
137+
138+ def is_satisfied_by (self , candidate ) -> bool :
81139 return isinstance (candidate , User )
82140
83141
84142class SuperUserSpecification (CompositeSpecification ):
85- def is_satisfied_by (self , candidate : "User" ) -> bool :
143+ """Specification satisfied when the candidate is a super-user."""
144+
145+ def is_satisfied_by (self , candidate ) -> bool :
86146 return getattr (candidate , "super_user" , False )
87147
88148
149+ # ---------------------------------------------------------------------------
150+ # Real-world example: filtering products in an e-commerce catalog
151+ # ---------------------------------------------------------------------------
152+
153+
154+ class Product :
155+ """A simple product in an e-commerce catalog."""
156+
157+ def __init__ (self , name : str , price : float , category : str , in_stock : bool ) -> None :
158+ self .name = name
159+ self .price = price
160+ self .category = category
161+ self .in_stock = in_stock
162+
163+ def __repr__ (self ) -> str :
164+ return self .name
165+
166+
167+ class PriceBelowSpecification (CompositeSpecification ):
168+ """Specification satisfied when the product's price is below a given limit."""
169+
170+ def __init__ (self , limit : float ) -> None :
171+ self ._limit = limit
172+
173+ def is_satisfied_by (self , candidate : Product ) -> bool :
174+ return candidate .price < self ._limit
175+
176+
177+ class InCategorySpecification (CompositeSpecification ):
178+ """Specification satisfied when the product belongs to a given category."""
179+
180+ def __init__ (self , category : str ) -> None :
181+ self ._category = category
182+
183+ def is_satisfied_by (self , candidate : Product ) -> bool :
184+ return candidate .category == self ._category
185+
186+
187+ class InStockSpecification (CompositeSpecification ):
188+ """Specification satisfied when the product is in stock."""
189+
190+ def is_satisfied_by (self , candidate : Product ) -> bool :
191+ return candidate .in_stock
192+
193+
89194def main ():
90195 """
91196 >>> andrey = User()
@@ -101,10 +206,38 @@ def main():
101206 (True, 'ivan')
102207 >>> root_specification.is_satisfied_by(vasiliy), 'vasiliy'
103208 (False, 'vasiliy')
209+
210+ # Real-world example: filtering products in an e-commerce catalog
211+ >>> products = [
212+ ... Product('Python Book', 45.0, 'books', True),
213+ ... Product('Laptop', 1200.0, 'electronics', True),
214+ ... Product('Headphones', 80.0, 'electronics', False),
215+ ... Product('Notebook', 5.0, 'stationery', True),
216+ ... ]
217+
218+ # Build composable rules
219+ >>> cheap = PriceBelowSpecification(100)
220+ >>> electronics = InCategorySpecification('electronics')
221+ >>> available = InStockSpecification()
222+
223+ # Show cheap products that are in stock
224+ >>> cheap_and_available = cheap.and_specification(available)
225+ >>> [p for p in products if cheap_and_available.is_satisfied_by(p)]
226+ [Python Book, Notebook]
227+
228+ # Show electronics that are out of stock (using NOT)
229+ >>> out_of_stock_electronics = electronics.and_specification(available.not_specification())
230+ >>> [p for p in products if out_of_stock_electronics.is_satisfied_by(p)]
231+ [Headphones]
232+
233+ # Show cheap items OR electronics
234+ >>> cheap_or_electronics = cheap.or_specification(electronics)
235+ >>> [p for p in products if cheap_or_electronics.is_satisfied_by(p)]
236+ [Python Book, Laptop, Headphones, Notebook]
104237 """
105238
106239
107240if __name__ == "__main__" :
108241 import doctest
109242
110- doctest .testmod ()
243+ doctest .testmod ()
0 commit comments