diff --git a/.github/workflows/lint_pr.yml b/.github/workflows/lint_pr.yml index f18e5c2e..ca1eaddf 100644 --- a/.github/workflows/lint_pr.yml +++ b/.github/workflows/lint_pr.yml @@ -9,8 +9,8 @@ jobs: steps: - uses: actions/checkout@v3 with: - fetch-depth: 0 # To get all history for git diff commands - + fetch-depth: 0 # To get all history for git diff commands + - name: Get changed Python files id: changed-files run: | @@ -31,7 +31,7 @@ jobs: CHANGED_FILES="$CHANGED_FILES $SETUP_PY_CHANGED" fi fi - + # Check if any Python files were changed and set the output accordingly if [ -z "$CHANGED_FILES" ]; then echo "No Python files changed" @@ -40,9 +40,11 @@ jobs: else echo "Changed Python files: $CHANGED_FILES" echo "has_python_changes=true" >> $GITHUB_OUTPUT - echo "files=$CHANGED_FILES" >> $GITHUB_OUTPUT + # Use proper delimiter formatting for GitHub Actions + FILES_SINGLE_LINE=$(echo "$CHANGED_FILES" | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//') + echo "files=$FILES_SINGLE_LINE" >> $GITHUB_OUTPUT fi - + - name: PR information if: ${{ github.event_name == 'pull_request' }} run: | @@ -68,27 +70,27 @@ jobs: echo "No Python files were changed. Skipping linting." exit 0 fi - + - uses: actions/checkout@v3 with: fetch-depth: 0 - + - uses: actions/setup-python@v4 with: python-version: 3.12 - + - uses: actions/cache@v3 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('requirements-dev.txt') }} restore-keys: | ${{ runner.os }}-pip- - + - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements-dev.txt - + # Flake8 linting - name: Lint with flake8 if: ${{ matrix.tool == 'flake8' }} @@ -96,7 +98,7 @@ jobs: run: | echo "Linting files: ${{ needs.check_changes.outputs.files }}" flake8 ${{ needs.check_changes.outputs.files }} --count --show-source --statistics - + # Format checking with isort and black - name: Format check if: ${{ matrix.tool == 'format' }} @@ -106,7 +108,7 @@ jobs: isort --profile black --check ${{ needs.check_changes.outputs.files }} echo "Checking format with black for: ${{ needs.check_changes.outputs.files }}" black --check ${{ needs.check_changes.outputs.files }} - + # Type checking with mypy - name: Type check with mypy if: ${{ matrix.tool == 'mypy' }} @@ -114,7 +116,7 @@ jobs: run: | echo "Type checking: ${{ needs.check_changes.outputs.files }}" mypy --ignore-missing-imports ${{ needs.check_changes.outputs.files }} - + # Run tests with pytest - name: Run tests with pytest if: ${{ matrix.tool == 'pytest' }} @@ -122,11 +124,11 @@ jobs: run: | echo "Running pytest discovery..." python -m pytest --collect-only -v - + # First run any test files that correspond to changed files echo "Running tests for changed files..." changed_files="${{ needs.check_changes.outputs.files }}" - + # Extract module paths from changed files modules=() for file in $changed_files; do @@ -137,13 +139,13 @@ jobs: modules+=("$module_path") fi done - + # Run tests for each module for module in "${modules[@]}"; do echo "Testing module: $module" python -m pytest -xvs tests/ -k "$module" || true done - + # Then run doctests on the changed files echo "Running doctests for changed files..." for file in $changed_files; do @@ -152,13 +154,13 @@ jobs: python -m pytest --doctest-modules -v $file || true fi done - + # Check Python version compatibility - name: Check Python version compatibility if: ${{ matrix.tool == 'pyupgrade' }} id: pyupgrade run: pyupgrade --py312-plus ${{ needs.check_changes.outputs.files }} - + # Run tox - name: Run tox if: ${{ matrix.tool == 'tox' }} @@ -166,12 +168,12 @@ jobs: run: | echo "Running tox integration for changed files..." changed_files="${{ needs.check_changes.outputs.files }}" - + # Create a temporary tox configuration that extends the original one echo "[tox]" > tox_pr.ini echo "envlist = py312" >> tox_pr.ini echo "skip_missing_interpreters = true" >> tox_pr.ini - + echo "[testenv]" >> tox_pr.ini echo "setenv =" >> tox_pr.ini echo " COVERAGE_FILE = .coverage.{envname}" >> tox_pr.ini @@ -182,11 +184,11 @@ jobs: echo " coverage" >> tox_pr.ini echo " python" >> tox_pr.ini echo "commands =" >> tox_pr.ini - + # Check if we have any implementation files that changed pattern_files=0 test_files=0 - + for file in $changed_files; do if [[ $file == patterns/* ]]; then pattern_files=1 @@ -194,12 +196,12 @@ jobs: test_files=1 fi done - + # Only run targeted tests, no baseline echo " # Run specific tests for changed files" >> tox_pr.ini - + has_tests=false - + # Add coverage-focused test commands for file in $changed_files; do if [[ $file == *.py ]]; then @@ -246,18 +248,18 @@ jobs: fi fi done - + # If we didn't find any specific tests to run, mention it if [ "$has_tests" = false ]; then echo " python -c \"print('No specific tests found for changed files. Consider adding tests.')\"" >> tox_pr.ini # Add a minimal test to avoid failure, but ensure it generates coverage data echo " coverage run -m pytest -xvs --cov=patterns --cov-append -k \"not integration\" --no-header" >> tox_pr.ini fi - + # Add coverage report command echo " coverage combine" >> tox_pr.ini echo " coverage report -m" >> tox_pr.ini - + # Run tox with the custom configuration echo "Running tox with custom PR configuration..." echo "======================== TOX CONFIG ========================" @@ -272,7 +274,7 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v3 - + - name: Summarize results run: | echo "## Pull Request Lint Results" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index d272a2e1..4521242b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,8 @@ venv/ .vscode/ .python-version .coverage +.project +.pydevproject +/.pytest_cache/ build/ -dist/ \ No newline at end of file +dist/ diff --git a/patterns/behavioral/catalog.py b/patterns/behavioral/catalog.py index 763d1501..11a730c3 100644 --- a/patterns/behavioral/catalog.py +++ b/patterns/behavioral/catalog.py @@ -1,19 +1,17 @@ """ -A class that uses different static functions depending on a parameter passed in -init. Note the use of a single dictionary instead of multiple conditions +A class that uses different static functions depending on a parameter passed +during initialization. Uses a single dictionary instead of multiple conditions. """ + __author__ = "Ibrahim Diop " class Catalog: - """catalog of multiple static methods that are executed depending on an init - - parameter + """catalog of multiple static methods that are executed depending on an init parameter """ def __init__(self, param: str) -> None: - # dictionary that will be used to determine which static method is # to be executed but that will be also used to store possible param # value @@ -29,25 +27,24 @@ def __init__(self, param: str) -> None: raise ValueError(f"Invalid Value for Param: {param}") @staticmethod - def _static_method_1() -> None: - print("executed method 1!") + def _static_method_1() -> str: + return "executed method 1!" @staticmethod - def _static_method_2() -> None: - print("executed method 2!") + def _static_method_2() -> str: + return "executed method 2!" - def main_method(self) -> None: + def main_method(self) -> str: """will execute either _static_method_1 or _static_method_2 depending on self.param value """ - self._static_method_choices[self.param]() + return self._static_method_choices[self.param]() # Alternative implementation for different levels of methods class CatalogInstance: """catalog of multiple methods that are executed depending on an init - parameter """ @@ -60,29 +57,28 @@ def __init__(self, param: str) -> None: else: raise ValueError(f"Invalid Value for Param: {param}") - def _instance_method_1(self) -> None: - print(f"Value {self.x1}") + def _instance_method_1(self) -> str: + return f"Value {self.x1}" - def _instance_method_2(self) -> None: - print(f"Value {self.x2}") + def _instance_method_2(self) -> str: + return f"Value {self.x2}" _instance_method_choices = { "param_value_1": _instance_method_1, "param_value_2": _instance_method_2, } - def main_method(self) -> None: + def main_method(self) -> str: """will execute either _instance_method_1 or _instance_method_2 depending on self.param value """ - self._instance_method_choices[self.param].__get__(self)() # type: ignore + return self._instance_method_choices[self.param].__get__(self)() # type: ignore # type ignore reason: https://github.com/python/mypy/issues/10206 class CatalogClass: """catalog of multiple class methods that are executed depending on an init - parameter """ @@ -97,30 +93,29 @@ def __init__(self, param: str) -> None: raise ValueError(f"Invalid Value for Param: {param}") @classmethod - def _class_method_1(cls) -> None: - print(f"Value {cls.x1}") + def _class_method_1(cls) -> str: + return f"Value {cls.x1}" @classmethod - def _class_method_2(cls) -> None: - print(f"Value {cls.x2}") + def _class_method_2(cls) -> str: + return f"Value {cls.x2}" _class_method_choices = { "param_value_1": _class_method_1, "param_value_2": _class_method_2, } - def main_method(self): + def main_method(self) -> str: """will execute either _class_method_1 or _class_method_2 depending on self.param value """ - self._class_method_choices[self.param].__get__(None, self.__class__)() # type: ignore + return self._class_method_choices[self.param].__get__(None, self.__class__)() # type: ignore # type ignore reason: https://github.com/python/mypy/issues/10206 class CatalogStatic: """catalog of multiple static methods that are executed depending on an init - parameter """ @@ -132,25 +127,25 @@ def __init__(self, param: str) -> None: raise ValueError(f"Invalid Value for Param: {param}") @staticmethod - def _static_method_1() -> None: - print("executed method 1!") + def _static_method_1() -> str: + return "executed method 1!" @staticmethod - def _static_method_2() -> None: - print("executed method 2!") + def _static_method_2() -> str: + return "executed method 2!" _static_method_choices = { "param_value_1": _static_method_1, "param_value_2": _static_method_2, } - def main_method(self) -> None: + def main_method(self) -> str: """will execute either _static_method_1 or _static_method_2 depending on self.param value """ - self._static_method_choices[self.param].__get__(None, self.__class__)() # type: ignore + return self._static_method_choices[self.param].__get__(None, self.__class__)() # type: ignore # type ignore reason: https://github.com/python/mypy/issues/10206 @@ -158,19 +153,19 @@ def main(): """ >>> test = Catalog('param_value_2') >>> test.main_method() - executed method 2! + 'executed method 2!' >>> test = CatalogInstance('param_value_1') >>> test.main_method() - Value x1 + 'Value x1' >>> test = CatalogClass('param_value_2') >>> test.main_method() - Value x2 + 'Value x2' >>> test = CatalogStatic('param_value_1') >>> test.main_method() - executed method 1! + 'executed method 1!' """ diff --git a/patterns/behavioral/mediator.py b/patterns/behavioral/mediator.py index e4b3c34a..6a59bbb6 100644 --- a/patterns/behavioral/mediator.py +++ b/patterns/behavioral/mediator.py @@ -15,7 +15,7 @@ class ChatRoom: """Mediator class""" def display_message(self, user: User, message: str) -> None: - print(f"[{user} says]: {message}") + return f"[{user} says]: {message}" class User: @@ -26,7 +26,7 @@ def __init__(self, name: str) -> None: self.chat_room = ChatRoom() def say(self, message: str) -> None: - self.chat_room.display_message(self, message) + return self.chat_room.display_message(self, message) def __str__(self) -> str: return self.name @@ -39,11 +39,11 @@ def main(): >>> ethan = User('Ethan') >>> molly.say("Hi Team! Meeting at 3 PM today.") - [Molly says]: Hi Team! Meeting at 3 PM today. + '[Molly says]: Hi Team! Meeting at 3 PM today.' >>> mark.say("Roger that!") - [Mark says]: Roger that! + '[Mark says]: Roger that!' >>> ethan.say("Alright.") - [Ethan says]: Alright. + '[Ethan says]: Alright.' """ diff --git a/patterns/behavioral/memento.py b/patterns/behavioral/memento.py index c1bc7f0b..4d072833 100644 --- a/patterns/behavioral/memento.py +++ b/patterns/behavioral/memento.py @@ -9,10 +9,10 @@ from typing import Callable, List -def memento(obj, deep=False): +def memento(obj: Any, deep: bool = False) -> Callable: state = deepcopy(obj.__dict__) if deep else copy(obj.__dict__) - def restore(): + def restore() -> None: obj.__dict__.clear() obj.__dict__.update(state) @@ -28,15 +28,15 @@ class Transaction: deep = False states: List[Callable[[], None]] = [] - def __init__(self, deep, *targets): + def __init__(self, deep: bool, *targets: Any) -> None: self.deep = deep self.targets = targets self.commit() - def commit(self): + def commit(self) -> None: self.states = [memento(target, self.deep) for target in self.targets] - def rollback(self): + def rollback(self) -> None: for a_state in self.states: a_state() @@ -47,27 +47,40 @@ def Transactional(method): :param method: The function to be decorated. """ - def transaction(obj, *args, **kwargs): - state = memento(obj) - try: - return method(obj, *args, **kwargs) - except Exception as e: - state() - raise e + + def __init__(self, method: Callable) -> None: + self.method = method + + def __get__(self, obj: Any, T: Type) -> Callable: + """ + A decorator that makes a function transactional. + + :param method: The function to be decorated. + """ + + def transaction(*args, **kwargs): + state = memento(obj) + try: + return self.method(obj, *args, **kwargs) + except Exception as e: + state() + raise e + return transaction + class NumObj: - def __init__(self, value): + def __init__(self, value: int) -> None: self.value = value - def __repr__(self): + def __repr__(self) -> str: return f"<{self.__class__.__name__}: {self.value!r}>" - def increment(self): + def increment(self) -> None: self.value += 1 @Transactional - def do_stuff(self): + def do_stuff(self) -> None: self.value = "1111" # <- invalid value self.increment() # <- will fail and rollback diff --git a/patterns/behavioral/observer.py b/patterns/behavioral/observer.py index 03d970ad..c9184be1 100644 --- a/patterns/behavioral/observer.py +++ b/patterns/behavioral/observer.py @@ -9,34 +9,59 @@ Flask Signals: https://flask.palletsprojects.com/en/1.1.x/signals/ """ -from __future__ import annotations - -from contextlib import suppress -from typing import Protocol +# observer.py +from __future__ import annotations +from typing import List -# define a generic observer type -class Observer(Protocol): +class Observer: def update(self, subject: Subject) -> None: + """ + Receive update from the subject. + + Args: + subject (Subject): The subject instance sending the update. + """ pass class Subject: + _observers: List[Observer] + def __init__(self) -> None: - self._observers: list[Observer] = [] + """ + Initialize the subject with an empty observer list. + """ + self._observers = [] def attach(self, observer: Observer) -> None: + """ + Attach an observer to the subject. + + Args: + observer (Observer): The observer instance to attach. + """ if observer not in self._observers: self._observers.append(observer) def detach(self, observer: Observer) -> None: - with suppress(ValueError): + """ + Detach an observer from the subject. + + Args: + observer (Observer): The observer instance to detach. + """ + try: self._observers.remove(observer) + except ValueError: + pass - def notify(self, modifier: Observer | None = None) -> None: + def notify(self) -> None: + """ + Notify all attached observers by calling their update method. + """ for observer in self._observers: - if modifier != observer: - observer.update(self) + observer.update(self) class Data(Subject): diff --git a/patterns/behavioral/registry.py b/patterns/behavioral/registry.py index d44a992e..60cae019 100644 --- a/patterns/behavioral/registry.py +++ b/patterns/behavioral/registry.py @@ -2,7 +2,6 @@ class RegistryHolder(type): - REGISTRY: Dict[str, "RegistryHolder"] = {} def __new__(cls, name, bases, attrs): diff --git a/patterns/behavioral/servant.py b/patterns/behavioral/servant.py index de939a60..776c4126 100644 --- a/patterns/behavioral/servant.py +++ b/patterns/behavioral/servant.py @@ -19,8 +19,10 @@ References: - https://en.wikipedia.org/wiki/Servant_(design_pattern) """ + import math + class Position: """Representation of a 2D position with x and y coordinates.""" @@ -28,6 +30,7 @@ def __init__(self, x, y): self.x = x self.y = y + class Circle: """Representation of a circle defined by a radius and a position.""" @@ -35,6 +38,7 @@ def __init__(self, radius, position: Position): self.radius = radius self.position = position + class Rectangle: """Representation of a rectangle defined by width, height, and a position.""" @@ -65,7 +69,7 @@ def calculate_area(shape): ValueError: If the shape type is unsupported. """ if isinstance(shape, Circle): - return math.pi * shape.radius ** 2 + return math.pi * shape.radius**2 elif isinstance(shape, Rectangle): return shape.width * shape.height else: diff --git a/patterns/behavioral/specification.py b/patterns/behavioral/specification.py index 303ee513..10d22689 100644 --- a/patterns/behavioral/specification.py +++ b/patterns/behavioral/specification.py @@ -6,6 +6,7 @@ """ from abc import abstractmethod +from typing import Union class Specification: @@ -28,22 +29,22 @@ class CompositeSpecification(Specification): def is_satisfied_by(self, candidate): pass - def and_specification(self, candidate): + def and_specification(self, candidate: "Specification") -> "AndSpecification": return AndSpecification(self, candidate) - def or_specification(self, candidate): + def or_specification(self, candidate: "Specification") -> "OrSpecification": return OrSpecification(self, candidate) - def not_specification(self): + def not_specification(self) -> "NotSpecification": return NotSpecification(self) class AndSpecification(CompositeSpecification): - def __init__(self, one, other): + def __init__(self, one: "Specification", other: "Specification") -> None: self._one: Specification = one self._other: Specification = other - def is_satisfied_by(self, candidate): + def is_satisfied_by(self, candidate: Union["User", str]) -> bool: return bool( self._one.is_satisfied_by(candidate) and self._other.is_satisfied_by(candidate) @@ -51,11 +52,11 @@ def is_satisfied_by(self, candidate): class OrSpecification(CompositeSpecification): - def __init__(self, one, other): + def __init__(self, one: "Specification", other: "Specification") -> None: self._one: Specification = one self._other: Specification = other - def is_satisfied_by(self, candidate): + def is_satisfied_by(self, candidate: Union["User", str]): return bool( self._one.is_satisfied_by(candidate) or self._other.is_satisfied_by(candidate) @@ -63,25 +64,25 @@ def is_satisfied_by(self, candidate): class NotSpecification(CompositeSpecification): - def __init__(self, wrapped): + def __init__(self, wrapped: "Specification"): self._wrapped: Specification = wrapped - def is_satisfied_by(self, candidate): + def is_satisfied_by(self, candidate: Union["User", str]): return bool(not self._wrapped.is_satisfied_by(candidate)) class User: - def __init__(self, super_user=False): + def __init__(self, super_user: bool = False) -> None: self.super_user = super_user class UserSpecification(CompositeSpecification): - def is_satisfied_by(self, candidate): + def is_satisfied_by(self, candidate: Union["User", str]) -> bool: return isinstance(candidate, User) class SuperUserSpecification(CompositeSpecification): - def is_satisfied_by(self, candidate): + def is_satisfied_by(self, candidate: "User") -> bool: return getattr(candidate, "super_user", False) diff --git a/patterns/behavioral/visitor.py b/patterns/behavioral/visitor.py index 00d95248..aa10b58c 100644 --- a/patterns/behavioral/visitor.py +++ b/patterns/behavioral/visitor.py @@ -14,6 +14,7 @@ which is then being used e.g. in tools like `pyflakes`. - `Black` formatter tool implements it's own: https://github.com/ambv/black/blob/master/black.py#L718 """ +from typing import Union class Node: @@ -33,7 +34,7 @@ class C(A, B): class Visitor: - def visit(self, node, *args, **kwargs): + def visit(self, node: Union[A, C, B], *args, **kwargs) -> None: meth = None for cls in node.__class__.__mro__: meth_name = "visit_" + cls.__name__ @@ -45,10 +46,10 @@ def visit(self, node, *args, **kwargs): meth = self.generic_visit return meth(node, *args, **kwargs) - def generic_visit(self, node, *args, **kwargs): + def generic_visit(self, node: A, *args, **kwargs) -> None: print("generic_visit " + node.__class__.__name__) - def visit_B(self, node, *args, **kwargs): + def visit_B(self, node: Union[C, B], *args, **kwargs) -> None: print("visit_B " + node.__class__.__name__) @@ -58,13 +59,13 @@ def main(): >>> visitor = Visitor() >>> visitor.visit(a) - generic_visit A + 'generic_visit A' >>> visitor.visit(b) - visit_B B + 'visit_B B' >>> visitor.visit(c) - visit_B C + 'visit_B C' """ diff --git a/patterns/creational/factory.py b/patterns/creational/factory.py index 3ef2d2a8..e5372ca5 100644 --- a/patterns/creational/factory.py +++ b/patterns/creational/factory.py @@ -26,8 +26,7 @@ class Localizer(Protocol): - def localize(self, msg: str) -> str: - pass + def localize(self, msg: str) -> str: ... class GreekLocalizer: diff --git a/patterns/creational/lazy_evaluation.py b/patterns/creational/lazy_evaluation.py index b56daf0c..1f8db6bd 100644 --- a/patterns/creational/lazy_evaluation.py +++ b/patterns/creational/lazy_evaluation.py @@ -20,14 +20,15 @@ """ import functools +from typing import Callable, Type class lazy_property: - def __init__(self, function): + def __init__(self, function: Callable) -> None: self.function = function functools.update_wrapper(self, function) - def __get__(self, obj, type_): + def __get__(self, obj: "Person", type_: Type["Person"]) -> str: if obj is None: return self val = self.function(obj) @@ -35,7 +36,7 @@ def __get__(self, obj, type_): return val -def lazy_property2(fn): +def lazy_property2(fn: Callable) -> property: """ A lazy property decorator. @@ -54,19 +55,19 @@ def _lazy_property(self): class Person: - def __init__(self, name, occupation): + def __init__(self, name: str, occupation: str) -> None: self.name = name self.occupation = occupation self.call_count2 = 0 @lazy_property - def relatives(self): + def relatives(self) -> str: # Get all relatives, let's assume that it costs much time. relatives = "Many relatives." return relatives @lazy_property2 - def parents(self): + def parents(self) -> str: self.call_count2 += 1 return "Father and mother" diff --git a/patterns/creational/pool.py b/patterns/creational/pool.py index 1d70ea69..02f61791 100644 --- a/patterns/creational/pool.py +++ b/patterns/creational/pool.py @@ -27,24 +27,32 @@ *TL;DR Stores a set of initialized objects kept ready to use. """ +from queue import Queue +from types import TracebackType +from typing import Union class ObjectPool: - def __init__(self, queue, auto_get=False): + def __init__(self, queue: Queue, auto_get: bool = False) -> None: self._queue = queue self.item = self._queue.get() if auto_get else None - def __enter__(self): + def __enter__(self) -> str: if self.item is None: self.item = self._queue.get() return self.item - def __exit__(self, Type, value, traceback): + def __exit__( + self, + Type: Union[type[BaseException], None], + value: Union[BaseException, None], + traceback: Union[TracebackType, None], + ) -> None: if self.item is not None: self._queue.put(self.item) self.item = None - def __del__(self): + def __del__(self) -> None: if self.item is not None: self._queue.put(self.item) self.item = None diff --git a/patterns/other/blackboard.py b/patterns/other/blackboard.py index 58fbdb98..0269a3e7 100644 --- a/patterns/other/blackboard.py +++ b/patterns/other/blackboard.py @@ -15,6 +15,7 @@ class AbstractExpert(ABC): """Abstract class for experts in the blackboard system.""" + @abstractmethod def __init__(self, blackboard) -> None: self.blackboard = blackboard @@ -31,6 +32,7 @@ def contribute(self) -> None: class Blackboard: """The blackboard system that holds the common state.""" + def __init__(self) -> None: self.experts: list = [] self.common_state = { @@ -46,6 +48,7 @@ def add_expert(self, expert: AbstractExpert) -> None: class Controller: """The controller that manages the blackboard system.""" + def __init__(self, blackboard: Blackboard) -> None: self.blackboard = blackboard @@ -63,6 +66,7 @@ def run_loop(self): class Student(AbstractExpert): """Concrete class for a student expert.""" + def __init__(self, blackboard) -> None: super().__init__(blackboard) @@ -79,6 +83,7 @@ def contribute(self) -> None: class Scientist(AbstractExpert): """Concrete class for a scientist expert.""" + def __init__(self, blackboard) -> None: super().__init__(blackboard) diff --git a/patterns/other/graph_search.py b/patterns/other/graph_search.py index 262a6f08..6e3cdffb 100644 --- a/patterns/other/graph_search.py +++ b/patterns/other/graph_search.py @@ -1,3 +1,6 @@ +from typing import Any, Dict, List, Optional, Union + + class GraphSearch: """Graph search emulation in python, from source http://www.python.org/doc/essays/graphs/ @@ -5,10 +8,12 @@ class GraphSearch: dfs stands for Depth First Search bfs stands for Breadth First Search""" - def __init__(self, graph): + def __init__(self, graph: Dict[str, List[str]]) -> None: self.graph = graph - def find_path_dfs(self, start, end, path=None): + def find_path_dfs( + self, start: str, end: str, path: Optional[List[str]] = None + ) -> Optional[List[str]]: path = path or [] path.append(start) @@ -20,7 +25,9 @@ def find_path_dfs(self, start, end, path=None): if newpath: return newpath - def find_all_paths_dfs(self, start, end, path=None): + def find_all_paths_dfs( + self, start: str, end: str, path: Optional[List[str]] = None + ) -> List[Union[List[str], Any]]: path = path or [] path.append(start) if start == end: @@ -32,7 +39,9 @@ def find_all_paths_dfs(self, start, end, path=None): paths.extend(newpaths) return paths - def find_shortest_path_dfs(self, start, end, path=None): + def find_shortest_path_dfs( + self, start: str, end: str, path: Optional[List[str]] = None + ) -> Optional[List[str]]: path = path or [] path.append(start) @@ -47,7 +56,7 @@ def find_shortest_path_dfs(self, start, end, path=None): shortest = newpath return shortest - def find_shortest_path_bfs(self, start, end): + def find_shortest_path_bfs(self, start: str, end: str) -> Optional[List[str]]: """ Finds the shortest path between two nodes in a graph using breadth-first search. diff --git a/patterns/structural/3-tier.py b/patterns/structural/3-tier.py index ecc04243..287badaf 100644 --- a/patterns/structural/3-tier.py +++ b/patterns/structural/3-tier.py @@ -16,7 +16,6 @@ class Data: } def __get__(self, obj, klas): - print("(Fetching from Data Store)") return {"products": self.products} diff --git a/patterns/structural/bridge.py b/patterns/structural/bridge.py index feddb675..1575cb53 100644 --- a/patterns/structural/bridge.py +++ b/patterns/structural/bridge.py @@ -5,34 +5,37 @@ *TL;DR Decouples an abstraction from its implementation. """ +from typing import Union # ConcreteImplementor 1/2 class DrawingAPI1: - def draw_circle(self, x, y, radius): + def draw_circle(self, x: int, y: int, radius: float) -> None: print(f"API1.circle at {x}:{y} radius {radius}") # ConcreteImplementor 2/2 class DrawingAPI2: - def draw_circle(self, x, y, radius): + def draw_circle(self, x: int, y: int, radius: float) -> None: print(f"API2.circle at {x}:{y} radius {radius}") # Refined Abstraction class CircleShape: - def __init__(self, x, y, radius, drawing_api): + def __init__( + self, x: int, y: int, radius: int, drawing_api: Union[DrawingAPI2, DrawingAPI1] + ) -> None: self._x = x self._y = y self._radius = radius self._drawing_api = drawing_api # low-level i.e. Implementation specific - def draw(self): + def draw(self) -> None: self._drawing_api.draw_circle(self._x, self._y, self._radius) # high-level i.e. Abstraction specific - def scale(self, pct): + def scale(self, pct: float) -> None: self._radius *= pct diff --git a/patterns/structural/flyweight.py b/patterns/structural/flyweight.py index fad17a8b..68b6f43c 100644 --- a/patterns/structural/flyweight.py +++ b/patterns/structural/flyweight.py @@ -36,7 +36,7 @@ class Card: # when there are no other references to it. _pool: weakref.WeakValueDictionary = weakref.WeakValueDictionary() - def __new__(cls, value, suit): + def __new__(cls, value: str, suit: str): # If the object exists in the pool - just return it obj = cls._pool.get(value + suit) # otherwise - create new one (and add it to the pool) @@ -52,7 +52,7 @@ def __new__(cls, value, suit): # def __init__(self, value, suit): # self.value, self.suit = value, suit - def __repr__(self): + def __repr__(self) -> str: return f"" diff --git a/patterns/structural/mvc.py b/patterns/structural/mvc.py index 24b0017a..27765fb7 100644 --- a/patterns/structural/mvc.py +++ b/patterns/structural/mvc.py @@ -4,13 +4,15 @@ """ from abc import ABC, abstractmethod +from ProductModel import Price +from typing import Dict, List, Union, Any from inspect import signature from sys import argv -from typing import Any class Model(ABC): """The Model is the data layer of the application.""" + @abstractmethod def __iter__(self) -> Any: pass @@ -29,6 +31,7 @@ def item_type(self) -> str: class ProductModel(Model): """The Model is the data layer of the application.""" + class Price(float): """A polymorphic way to pass a float with a particular __str__ functionality.""" @@ -56,12 +59,15 @@ def get(self, product: str) -> dict: class View(ABC): """The View is the presentation layer of the application.""" + @abstractmethod def show_item_list(self, item_type: str, item_list: list) -> None: pass @abstractmethod - def show_item_information(self, item_type: str, item_name: str, item_info: dict) -> None: + def show_item_information( + self, item_type: str, item_name: str, item_info: dict + ) -> None: """Will look for item information by iterating over key,value pairs yielded by item_info.items()""" pass @@ -73,6 +79,7 @@ def item_not_found(self, item_type: str, item_name: str) -> None: class ConsoleView(View): """The View is the presentation layer of the application.""" + def show_item_list(self, item_type: str, item_list: list) -> None: print(item_type.upper() + " LIST:") for item in item_list: @@ -84,7 +91,9 @@ def capitalizer(string: str) -> str: """Capitalizes the first letter of a string and lowercases the rest.""" return string[0].upper() + string[1:].lower() - def show_item_information(self, item_type: str, item_name: str, item_info: dict) -> None: + def show_item_information( + self, item_type: str, item_name: str, item_info: dict + ) -> None: """Will look for item information by iterating over key,value pairs""" print(item_type.upper() + " INFORMATION:") printout = "Name: %s" % item_name @@ -99,6 +108,7 @@ def item_not_found(self, item_type: str, item_name: str) -> None: class Controller: """The Controller is the intermediary between the Model and the View.""" + def __init__(self, model_class: Model, view_class: View) -> None: self.model: Model = model_class self.view: View = view_class @@ -124,15 +134,17 @@ def show_item_information(self, item_name: str) -> None: class Router: """The Router is the entry point of the application.""" + def __init__(self): self.routes = {} def register( - self, - path: str, - controller_class: type[Controller], - model_class: type[Model], - view_class: type[View]) -> None: + self, + path: str, + controller_class: type[Controller], + model_class: type[Model], + view_class: type[View], + ) -> None: model_instance: Model = model_class() view_instance: View = view_class() self.routes[path] = controller_class(model_instance, view_instance) @@ -184,7 +196,7 @@ def main(): controller: Controller = router.resolve(argv[1]) action: str = str(argv[2]) if len(argv) > 2 else "" - args: str = ' '.join(map(str, argv[3:])) if len(argv) > 3 else "" + args: str = " ".join(map(str, argv[3:])) if len(argv) > 3 else "" if hasattr(controller, action): command = getattr(controller, action) @@ -201,4 +213,5 @@ def main(): print(f"Command {action} not found in the controller.") import doctest + doctest.testmod() diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..1194272a --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,8 @@ +flake8 +black +isort +pytest +pytest-randomly +mypy +pyupgrade +tox diff --git a/tests/behavioral/test_catalog.py b/tests/behavioral/test_catalog.py new file mode 100644 index 00000000..60933816 --- /dev/null +++ b/tests/behavioral/test_catalog.py @@ -0,0 +1,23 @@ +import pytest + +from patterns.behavioral.catalog import Catalog, CatalogClass, CatalogInstance, CatalogStatic + +def test_catalog_multiple_methods(): + test = Catalog('param_value_2') + token = test.main_method() + assert token == 'executed method 2!' + +def test_catalog_multiple_instance_methods(): + test = CatalogInstance('param_value_1') + token = test.main_method() + assert token == 'Value x1' + +def test_catalog_multiple_class_methods(): + test = CatalogClass('param_value_2') + token = test.main_method() + assert token == 'Value x2' + +def test_catalog_multiple_static_methods(): + test = CatalogStatic('param_value_1') + token = test.main_method() + assert token == 'executed method 1!' diff --git a/tests/behavioral/test_mediator.py b/tests/behavioral/test_mediator.py new file mode 100644 index 00000000..1af60e67 --- /dev/null +++ b/tests/behavioral/test_mediator.py @@ -0,0 +1,16 @@ +import pytest + +from patterns.behavioral.mediator import User + +def test_mediated_comments(): + molly = User('Molly') + mediated_comment = molly.say("Hi Team! Meeting at 3 PM today.") + assert mediated_comment == "[Molly says]: Hi Team! Meeting at 3 PM today." + + mark = User('Mark') + mediated_comment = mark.say("Roger that!") + assert mediated_comment == "[Mark says]: Roger that!" + + ethan = User('Ethan') + mediated_comment = ethan.say("Alright.") + assert mediated_comment == "[Ethan says]: Alright." diff --git a/tests/behavioral/test_memento.py b/tests/behavioral/test_memento.py new file mode 100644 index 00000000..bd307b76 --- /dev/null +++ b/tests/behavioral/test_memento.py @@ -0,0 +1,29 @@ +import pytest + +from patterns.behavioral.memento import NumObj, Transaction + +def test_object_creation(): + num_obj = NumObj(-1) + assert repr(num_obj) == '', "Object representation not as expected" + +def test_rollback_on_transaction(): + num_obj = NumObj(-1) + a_transaction = Transaction(True, num_obj) + for _i in range(3): + num_obj.increment() + a_transaction.commit() + assert num_obj.value == 2 + + for _i in range(3): + num_obj.increment() + try: + num_obj.value += 'x' # will fail + except TypeError: + a_transaction.rollback() + assert num_obj.value == 2, "Transaction did not rollback as expected" + +def test_rollback_with_transactional_annotation(): + num_obj = NumObj(2) + with pytest.raises(TypeError): + num_obj.do_stuff() + assert num_obj.value == 2 diff --git a/tests/behavioral/test_publish_subscribe.py b/tests/behavioral/test_publish_subscribe.py index c153da5b..8bb7130c 100644 --- a/tests/behavioral/test_publish_subscribe.py +++ b/tests/behavioral/test_publish_subscribe.py @@ -48,9 +48,10 @@ def test_provider_shall_update_affected_subscribers_with_published_subscription( sub2 = Subscriber("sub 2 name", pro) sub2.subscribe("sub 2 msg 1") sub2.subscribe("sub 2 msg 2") - with patch.object(sub1, "run") as mock_subscriber1_run, patch.object( - sub2, "run" - ) as mock_subscriber2_run: + with ( + patch.object(sub1, "run") as mock_subscriber1_run, + patch.object(sub2, "run") as mock_subscriber2_run, + ): pro.update() cls.assertEqual(mock_subscriber1_run.call_count, 0) cls.assertEqual(mock_subscriber2_run.call_count, 0) @@ -58,9 +59,10 @@ def test_provider_shall_update_affected_subscribers_with_published_subscription( pub.publish("sub 1 msg 2") pub.publish("sub 2 msg 1") pub.publish("sub 2 msg 2") - with patch.object(sub1, "run") as mock_subscriber1_run, patch.object( - sub2, "run" - ) as mock_subscriber2_run: + with ( + patch.object(sub1, "run") as mock_subscriber1_run, + patch.object(sub2, "run") as mock_subscriber2_run, + ): pro.update() expected_sub1_calls = [call("sub 1 msg 1"), call("sub 1 msg 2")] mock_subscriber1_run.assert_has_calls(expected_sub1_calls) diff --git a/tests/behavioral/test_servant.py b/tests/behavioral/test_servant.py index e5edb70d..dd487171 100644 --- a/tests/behavioral/test_servant.py +++ b/tests/behavioral/test_servant.py @@ -7,18 +7,20 @@ def circle(): return Circle(3, Position(0, 0)) + @pytest.fixture def rectangle(): return Rectangle(4, 5, Position(0, 0)) def test_calculate_area(circle, rectangle): - assert GeometryTools.calculate_area(circle) == math.pi * 3 ** 2 + assert GeometryTools.calculate_area(circle) == math.pi * 3**2 assert GeometryTools.calculate_area(rectangle) == 4 * 5 with pytest.raises(ValueError): GeometryTools.calculate_area("invalid shape") + def test_calculate_perimeter(circle, rectangle): assert GeometryTools.calculate_perimeter(circle) == 2 * math.pi * 3 assert GeometryTools.calculate_perimeter(rectangle) == 2 * (4 + 5) diff --git a/tests/behavioral/test_visitor.py b/tests/behavioral/test_visitor.py new file mode 100644 index 00000000..31d230de --- /dev/null +++ b/tests/behavioral/test_visitor.py @@ -0,0 +1,22 @@ +import pytest + +from patterns.behavioral.visitor import A, B, C, Visitor + +@pytest.fixture +def visitor(): + return Visitor() + +def test_visiting_generic_node(visitor): + a = A() + token = visitor.visit(a) + assert token == 'generic_visit A', "The expected generic object was not called" + +def test_visiting_specific_nodes(visitor): + b = B() + token = visitor.visit(b) + assert token == 'visit_B B', "The expected specific object was not called" + +def test_visiting_inherited_nodes(visitor): + c = C() + token = visitor.visit(c) + assert token == 'visit_B C', "The expected inherited object was not called" diff --git a/tests/structural/test_bridge.py b/tests/structural/test_bridge.py index 7fa8a278..6665f327 100644 --- a/tests/structural/test_bridge.py +++ b/tests/structural/test_bridge.py @@ -8,9 +8,10 @@ class BridgeTest(unittest.TestCase): def test_bridge_shall_draw_with_concrete_api_implementation(cls): ci1 = DrawingAPI1() ci2 = DrawingAPI2() - with patch.object(ci1, "draw_circle") as mock_ci1_draw_circle, patch.object( - ci2, "draw_circle" - ) as mock_ci2_draw_circle: + with ( + patch.object(ci1, "draw_circle") as mock_ci1_draw_circle, + patch.object(ci2, "draw_circle") as mock_ci2_draw_circle, + ): sh1 = CircleShape(1, 2, 3, ci1) sh1.draw() cls.assertEqual(mock_ci1_draw_circle.call_count, 1) @@ -33,9 +34,10 @@ def test_bridge_shall_scale_both_api_circles_with_own_implementation(cls): sh2.scale(SCALE_FACTOR) cls.assertEqual(sh1._radius, EXPECTED_CIRCLE1_RADIUS) cls.assertEqual(sh2._radius, EXPECTED_CIRCLE2_RADIUS) - with patch.object(sh1, "scale") as mock_sh1_scale_circle, patch.object( - sh2, "scale" - ) as mock_sh2_scale_circle: + with ( + patch.object(sh1, "scale") as mock_sh1_scale_circle, + patch.object(sh2, "scale") as mock_sh2_scale_circle, + ): sh1.scale(2) sh2.scale(2) cls.assertEqual(mock_sh1_scale_circle.call_count, 1) diff --git a/tests/test_hsm.py b/tests/test_hsm.py index f42323a9..5b49fb97 100644 --- a/tests/test_hsm.py +++ b/tests/test_hsm.py @@ -58,15 +58,14 @@ def test_given_standby_on_message_switchover_shall_set_active(cls): cls.assertEqual(isinstance(cls.hsm._current_state, Active), True) def test_given_standby_on_message_switchover_shall_call_hsm_methods(cls): - with patch.object( - cls.hsm, "_perform_switchover" - ) as mock_perform_switchover, patch.object( - cls.hsm, "_check_mate_status" - ) as mock_check_mate_status, patch.object( - cls.hsm, "_send_switchover_response" - ) as mock_send_switchover_response, patch.object( - cls.hsm, "_next_state" - ) as mock_next_state: + with ( + patch.object(cls.hsm, "_perform_switchover") as mock_perform_switchover, + patch.object(cls.hsm, "_check_mate_status") as mock_check_mate_status, + patch.object( + cls.hsm, "_send_switchover_response" + ) as mock_send_switchover_response, + patch.object(cls.hsm, "_next_state") as mock_next_state, + ): cls.hsm.on_message("switchover") cls.assertEqual(mock_perform_switchover.call_count, 1) cls.assertEqual(mock_check_mate_status.call_count, 1)