diff --git a/.codespellignore b/.codespellignore new file mode 100644 index 00000000..d272a2e1 --- /dev/null +++ b/.codespellignore @@ -0,0 +1,14 @@ +__pycache__ +*.pyc +.idea +*.egg-info/ +.tox/ +env/ +venv/ +.env +.venv +.vscode/ +.python-version +.coverage +build/ +dist/ \ No newline at end of file diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 3778bf3d..00000000 --- a/.coveragerc +++ /dev/null @@ -1,6 +0,0 @@ -[report] -exclude_lines = - pragma: no cover - # Don't complain if tests don't hit defensive assertion code: - # See: https://stackoverflow.com/a/9212387/3407256 - raise NotImplementedError diff --git a/.github/workflows/lint_pr.yml b/.github/workflows/lint_pr.yml new file mode 100644 index 00000000..ca1eaddf --- /dev/null +++ b/.github/workflows/lint_pr.yml @@ -0,0 +1,288 @@ +name: lint_pull_request +on: [pull_request, push] +jobs: + check_changes: + runs-on: ubuntu-24.04 + outputs: + has_python_changes: ${{ steps.changed-files.outputs.has_python_changes }} + files: ${{ steps.changed-files.outputs.files }} + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 # To get all history for git diff commands + + - name: Get changed Python files + id: changed-files + run: | + if [ "${{ github.event_name }}" == "pull_request" ]; then + # For PRs, compare against base branch + CHANGED_FILES=$(git diff --name-only --diff-filter=ACMRT origin/${{ github.base_ref }} HEAD | grep '\.py$' | grep -v "^setup\.py$" || echo "") + # Check if setup.py specifically changed + SETUP_PY_CHANGED=$(git diff --name-only --diff-filter=ACMRT origin/${{ github.base_ref }} HEAD | grep "^setup\.py$" || echo "") + if [ ! -z "$SETUP_PY_CHANGED" ]; then + CHANGED_FILES="$CHANGED_FILES $SETUP_PY_CHANGED" + fi + else + # For pushes, use the before/after SHAs + CHANGED_FILES=$(git diff --name-only --diff-filter=ACMRT ${{ github.event.before }} ${{ github.event.after }} | grep '\.py$' | grep -v "^setup\.py$" || echo "") + # Check if setup.py specifically changed + SETUP_PY_CHANGED=$(git diff --name-only --diff-filter=ACMRT ${{ github.event.before }} ${{ github.event.after }} | grep "^setup\.py$" || echo "") + if [ ! -z "$SETUP_PY_CHANGED" ]; then + 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" + echo "has_python_changes=false" >> $GITHUB_OUTPUT + echo "files=" >> $GITHUB_OUTPUT + else + echo "Changed Python files: $CHANGED_FILES" + echo "has_python_changes=true" >> $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: | + if [[ "${{ steps.changed-files.outputs.has_python_changes }}" == "true" ]]; then + echo "This PR contains Python changes that will be linted." + else + echo "This PR contains no Python changes, but still requires manual approval." + fi + + lint: + needs: check_changes + if: ${{ needs.check_changes.outputs.has_python_changes == 'true' }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + tool: [flake8, format, mypy, pytest, pyupgrade, tox] + steps: + # Additional check to ensure we have Python files before proceeding + - name: Verify Python changes + run: | + if [[ "${{ needs.check_changes.outputs.has_python_changes }}" != "true" ]]; then + 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' }} + id: flake8 + 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' }} + id: format + run: | + echo "Checking format with isort for: ${{ needs.check_changes.outputs.files }}" + 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' }} + id: mypy + 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' }} + id: pytest + 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 + # Convert file path to module path (remove .py and replace / with .) + if [[ $file == patterns/* ]]; then + module_path=${file%.py} + module_path=${module_path//\//.} + 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 + if [[ $file == *.py ]]; then + echo "Running doctest for $file" + 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' }} + id: tox + 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 + echo "deps =" >> tox_pr.ini + echo " -r requirements-dev.txt" >> tox_pr.ini + echo "allowlist_externals =" >> tox_pr.ini + echo " pytest" >> tox_pr.ini + 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 + elif [[ $file == tests/* ]]; then + 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 + # Run coverage tests for implementation files + if [[ $file == patterns/* ]]; then + module_name=$(basename $file .py) + + # Get the pattern type (behavioral, structural, etc.) + if [[ $file == patterns/behavioral/* ]]; then + pattern_dir="behavioral" + elif [[ $file == patterns/creational/* ]]; then + pattern_dir="creational" + elif [[ $file == patterns/structural/* ]]; then + pattern_dir="structural" + elif [[ $file == patterns/fundamental/* ]]; then + pattern_dir="fundamental" + elif [[ $file == patterns/other/* ]]; then + pattern_dir="other" + else + pattern_dir="" + fi + + echo " # Testing $file" >> tox_pr.ini + + # Check if specific test exists + if [ -n "$pattern_dir" ]; then + test_path="tests/${pattern_dir}/test_${module_name}.py" + echo " if [ -f \"${test_path}\" ]; then echo \"Test file ${test_path} exists: true\" && coverage run -m pytest -xvs --cov=patterns --cov-append ${test_path}; else echo \"Test file ${test_path} exists: false\"; fi" >> tox_pr.ini + + # Also try to find any test that might include this module + echo " coverage run -m pytest -xvs --cov=patterns --cov-append tests/${pattern_dir}/ -k \"${module_name}\" --no-header" >> tox_pr.ini + fi + + # Run doctests for the file + echo " coverage run -m pytest --doctest-modules -v --cov=patterns --cov-append $file" >> tox_pr.ini + + has_tests=true + fi + + # Run test files directly if modified + if [[ $file == tests/* ]]; then + echo " coverage run -m pytest -xvs --cov=patterns --cov-append $file" >> tox_pr.ini + has_tests=true + 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 ========================" + cat tox_pr.ini + echo "===========================================================" + tox -c tox_pr.ini + + summary: + needs: [check_changes, lint] + # Run summary in all cases, regardless of whether lint job ran + if: ${{ always() }} + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v3 + + - name: Summarize results + run: | + echo "## Pull Request Lint Results" >> $GITHUB_STEP_SUMMARY + if [[ "${{ needs.check_changes.outputs.has_python_changes }}" == "true" ]]; then + echo "Linting has completed for all Python files changed in this PR." >> $GITHUB_STEP_SUMMARY + echo "See individual job logs for detailed results." >> $GITHUB_STEP_SUMMARY + else + echo "No Python files were changed in this PR. Linting was skipped." >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + echo "⚠️ **Note:** This PR still requires manual approval regardless of linting results." >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/lint_python.yml b/.github/workflows/lint_python.yml index 63796567..288a94b0 100644 --- a/.github/workflows/lint_python.yml +++ b/.github/workflows/lint_python.yml @@ -2,19 +2,35 @@ name: lint_python on: [pull_request, push] jobs: lint_python: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - run: pip install --upgrade pip - - run: pip install black codespell flake8 isort mypy pytest pyupgrade tox - - run: black --check . - - run: codespell --quiet-level=2 # --ignore-words-list="" --skip="" - - run: flake8 . --count --show-source --statistics - - run: isort --profile black . - - run: tox - - run: pip install -e . - - run: mypy --ignore-missing-imports . || true - - run: pytest . - - run: pytest --doctest-modules . || true - - run: shopt -s globstar && pyupgrade --py36-plus **/*.py + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: 3.12 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install .[dev] + - name: Lint with flake8 + run: flake8 ./patterns --count --show-source --statistics + continue-on-error: true + - name: Format check with isort and black + run: | + isort --profile black --check ./patterns + black --check ./patterns + continue-on-error: true + - name: Type check with mypy + run: mypy --ignore-missing-imports ./patterns || true + continue-on-error: true + - name: Run tests with pytest + run: | + pytest ./patterns + pytest --doctest-modules ./patterns || true + continue-on-error: true + - name: Check Python version compatibility + run: shopt -s globstar && pyupgrade --py312-plus ./patterns/**/*.py + continue-on-error: true + - name: Run tox + run: tox + continue-on-error: true diff --git a/.gitignore b/.gitignore index a7379521..4521242b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,15 @@ __pycache__ .idea *.egg-info/ .tox/ -venv +env/ +venv/ +.env +.venv .vscode/ -.python-version \ No newline at end of file +.python-version +.coverage +.project +.pydevproject +/.pytest_cache/ +build/ +dist/ diff --git a/.travis.yml b/.travis.yml index f7719a4c..dfeece70 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,11 @@ os: linux -dist: focal +dist: noble language: python jobs: include: - - python: "3.7" - env: TOXENV=py37 - - python: "3.9" - env: TOXENV=py39 + - python: "3.12" + env: TOXENV=py312 cache: - pip diff --git a/Makefile b/Makefile index 25826c8b..92ba244a 100644 --- a/Makefile +++ b/Makefile @@ -39,9 +39,8 @@ ifeq ("$(wildcard venv/bin/pip-sync)","") endif # pip-tools - @pip-compile --upgrade requirements-dev.txt - @pip-compile --upgrade requirements.txt - @pip-sync requirements-dev.txt requirements.txt + # @pip-compile --upgrade requirements-dev.txt + @pip-sync requirements-dev.txt .PHONY: pylinter @@ -85,4 +84,4 @@ endif --select "B,C,E,F,W,T4,B9" \ --ignore "E203,E266,E501,W503,F403,F401,E402" \ --exclude ".git,__pycache__,old, build, \ - dist, venv" $(path) + dist, venv, .tox" $(path) diff --git a/README.md b/README.md index 49ad4d4a..4d12a2f1 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ python-patterns A collection of design patterns and idioms in Python. +Remember that each pattern has its own trade-offs. And you need to pay attention more to why you're choosing a certain pattern than to how to implement it. + Current Patterns ---------------- @@ -48,6 +50,7 @@ __Behavioral Patterns__: | [observer](patterns/behavioral/observer.py) | provide a callback for notification of events/changes to data | | [publish_subscribe](patterns/behavioral/publish_subscribe.py) | a source syndicates events/data to 0+ registered listeners | | [registry](patterns/behavioral/registry.py) | keep track of all subclasses of a given class | +| [servant](patterns/behavioral/servant.py) | provide common functionality to a group of classes without using inheritance | | [specification](patterns/behavioral/specification.py) | business rules can be recombined by chaining the business rules together using boolean logic | | [state](patterns/behavioral/state.py) | logic is organized into a discrete number of potential states and the next state that can be transitioned to | | [strategy](patterns/behavioral/strategy.py) | selectable operations over the same data | @@ -90,12 +93,6 @@ Contributing ------------ When an implementation is added or modified, please review the following guidelines: -##### Output -All files with example patterns have `### OUTPUT ###` section at the bottom -(migration to OUTPUT = """...""" is in progress). - -Run `append_output.sh` (e.g. `./append_output.sh borg.py`) to generate/update it. - ##### Docstrings Add module level description in form of a docstring with links to corresponding references or other useful information. @@ -104,22 +101,46 @@ Add "Examples in Python ecosystem" section if you know some. It shows how patter [facade.py](patterns/structural/facade.py) has a good example of detailed description, but sometimes the shorter one as in [template.py](patterns/behavioral/template.py) would suffice. -In some cases class-level docstring with doctest would also help (see [adapter.py](patterns/structural/adapter.py)) -but readable OUTPUT section is much better. - - ##### Python 2 compatibility To see Python 2 compatible versions of some patterns please check-out the [legacy](https://github.com/faif/python-patterns/tree/legacy) tag. ##### Update README When everything else is done - update corresponding part of README. - ##### Travis CI -Please run `tox` or `tox -e ci37` before submitting a patch to be sure your changes will pass CI. +Please run the following before submitting a patch +- `black .` This lints your code. + +Then either: +- `tox` or `tox -e ci37` This runs unit tests. see tox.ini for further details. +- If you have a bash compatible shell use `./lint.sh` This script will lint and test your code. This script mirrors the CI pipeline actions. You can also run `flake8` or `pytest` commands manually. Examples can be found in `tox.ini`. ## Contributing via issue triage [![Open Source Helpers](https://www.codetriage.com/faif/python-patterns/badges/users.svg)](https://www.codetriage.com/faif/python-patterns) You can triage issues and pull requests which may include reproducing bug reports or asking for vital information, such as version numbers or reproduction instructions. If you would like to start triaging issues, one easy way to get started is to [subscribe to python-patterns on CodeTriage](https://www.codetriage.com/faif/python-patterns). + + +## 🚫 Anti-Patterns + +This section lists some common design patterns that are **not recommended** in Python and explains why. + +### 🧱 Singleton +**Why not:** +- Python modules are already singletons β€” every module is imported only once. +- Explicit singleton classes add unnecessary complexity. +- Better alternatives: use module-level variables or dependency injection. + +### πŸŒ€ God Object +**Why not:** +- Centralizes too much logic in a single class. +- Makes code harder to test and maintain. +- Better alternative: split functionality into smaller, cohesive classes. + +### πŸ” Inheritance overuse +**Why not:** +- Deep inheritance trees make code brittle. +- Prefer composition and delegation. +- β€œFavor composition over inheritance.” + diff --git a/append_output.sh b/append_output.sh deleted file mode 100755 index 3bb9202c..00000000 --- a/append_output.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -# This script (given path to a python script as an argument) -# appends python outputs to given file. - -set -e - -output_marker='OUTPUT = """' - -# get everything (excluding part between `output_marker` and the end of the file) -# into `src` var -src=$(sed -n -e "/$output_marker/,\$!p" "$1") -output=$(python3 "$1") - -echo "$src" > $1 -echo -e "\n" >> $1 -echo "$output_marker" >> $1 -echo "$output" >> $1 -echo '""" # noqa' >> $1 diff --git a/config_backup/.coveragerc b/config_backup/.coveragerc new file mode 100644 index 00000000..98306ea9 --- /dev/null +++ b/config_backup/.coveragerc @@ -0,0 +1,25 @@ +[run] +branch = True + +[report] +; Regexes for lines to exclude from consideration +exclude_also = + ; Don't complain about missing debug-only code: + def __repr__ + if self\.debug + + ; Don't complain if tests don't hit defensive assertion code: + raise AssertionError + raise NotImplementedError + + ; Don't complain if non-runnable code isn't run: + if 0: + if __name__ == .__main__.: + + ; Don't complain about abstract methods, they aren't run: + @(abc\.)?abstractmethod + +ignore_errors = True + +[html] +directory = coverage_html_report \ No newline at end of file diff --git a/setup.cfg b/config_backup/setup.cfg similarity index 70% rename from setup.cfg rename to config_backup/setup.cfg index 159357a4..e109555b 100644 --- a/setup.cfg +++ b/config_backup/setup.cfg @@ -1,9 +1,13 @@ [flake8] max-line-length = 120 ignore = E266 E731 W503 -exclude = .venv* +exclude = venv* [tool:pytest] filterwarnings = ; ignore TestRunner class from facade example ignore:.*test class 'TestRunner'.*:Warning + +[mypy] +python_version = 3.12 +ignore_missing_imports = True diff --git a/tox.ini b/config_backup/tox.ini similarity index 69% rename from tox.ini rename to config_backup/tox.ini index ee8b9579..36e2577e 100644 --- a/tox.ini +++ b/config_backup/tox.ini @@ -1,18 +1,22 @@ [tox] -envlist = py37,py38,py39,cov-report +envlist = py312,cov-report skip_missing_interpreters = true - +usedevelop = true [testenv] setenv = COVERAGE_FILE = .coverage.{envname} deps = -r requirements-dev.txt +allowlist_externals = + pytest + flake8 + mypy commands = - flake8 . --exclude=./.* + flake8 --exclude="venv/,.tox/" patterns/ ; `randomly-seed` option from `pytest-randomly` helps with deterministic outputs for examples like `other/blackboard.py` pytest --randomly-seed=1234 --doctest-modules patterns/ - pytest -s -vv --cov={envsitepackagesdir}/patterns --log-level=INFO tests/ + pytest -s -vv --cov=patterns/ --log-level=INFO tests/ [testenv:cov-report] diff --git a/lint.sh b/lint.sh new file mode 100755 index 00000000..a7eebda1 --- /dev/null +++ b/lint.sh @@ -0,0 +1,16 @@ +#! /bin/bash + +pip install --upgrade pip +pip install black codespell flake8 isort mypy pytest pyupgrade tox +pip install -e . + +source_dir="./patterns" + +codespell --quiet-level=2 ./patterns # --ignore-words-list="" --skip="" +flake8 "${source_dir}" --count --show-source --statistics +isort --profile black "${source_dir}" +tox +mypy --ignore-missing-imports "${source_dir}" || true +pytest "${source_dir}" +pytest --doctest-modules "${source_dir}" || true +shopt -s globstar && pyupgrade --py312-plus ${source_dir}/*.py diff --git a/patterns/behavioral/catalog.py b/patterns/behavioral/catalog.py index f979ac32..11a730c3 100644 --- a/patterns/behavioral/catalog.py +++ b/patterns/behavioral/catalog.py @@ -1,19 +1,17 @@ """ -A class that uses different static function depending of 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,26 +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 """ @@ -61,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)() + 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 """ @@ -98,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__)() + 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 """ @@ -133,44 +127,45 @@ 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__)() + return self._static_method_choices[self.param].__get__(None, self.__class__)() # type: ignore + # type ignore reason: https://github.com/python/mypy/issues/10206 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/chain_of_responsibility.py b/patterns/behavioral/chain_of_responsibility.py index d80b1633..9d46c4a8 100644 --- a/patterns/behavioral/chain_of_responsibility.py +++ b/patterns/behavioral/chain_of_responsibility.py @@ -19,13 +19,11 @@ """ from abc import ABC, abstractmethod -from typing import Optional, Tuple, TypeVar - -T = TypeVar("T") +from typing import Optional, Tuple class Handler(ABC): - def __init__(self, successor: Optional[T] = None): + def __init__(self, successor: Optional["Handler"] = None): self.successor = successor def handle(self, request: int) -> None: @@ -55,6 +53,7 @@ def check_range(request: int) -> Optional[bool]: if 0 <= request < 10: print(f"request {request} handled in handler 0") return True + return None class ConcreteHandler1(Handler): @@ -66,6 +65,7 @@ def check_range(self, request: int) -> Optional[bool]: if self.start <= request < self.end: print(f"request {request} handled in handler 1") return True + return None class ConcreteHandler2(Handler): @@ -76,6 +76,7 @@ def check_range(self, request: int) -> Optional[bool]: if start <= request < end: print(f"request {request} handled in handler 2") return True + return None @staticmethod def get_interval_from_db() -> Tuple[int, int]: diff --git a/patterns/behavioral/chaining_method.py b/patterns/behavioral/chaining_method.py index 1fd261a4..26f11018 100644 --- a/patterns/behavioral/chaining_method.py +++ b/patterns/behavioral/chaining_method.py @@ -1,30 +1,32 @@ +from __future__ import annotations + + class Person: - def __init__(self, name, action): + def __init__(self, name: str) -> None: self.name = name - self.action = action - def do_action(self): - print(self.name, self.action.name, end=" ") - return self.action + def do_action(self, action: Action) -> Action: + print(self.name, action.name, end=" ") + return action class Action: - def __init__(self, name): + def __init__(self, name: str) -> None: self.name = name - def amount(self, val): + def amount(self, val: str) -> Action: print(val, end=" ") return self - def stop(self): + def stop(self) -> None: print("then stop") def main(): """ >>> move = Action('move') - >>> person = Person('Jack', move) - >>> person.do_action().amount('5m').stop() + >>> person = Person('Jack') + >>> person.do_action(move).amount('5m').stop() Jack move 5m then stop """ diff --git a/patterns/behavioral/command.py b/patterns/behavioral/command.py index b21d7f73..a88ea8be 100644 --- a/patterns/behavioral/command.py +++ b/patterns/behavioral/command.py @@ -20,7 +20,7 @@ https://docs.djangoproject.com/en/2.1/ref/request-response/#httprequest-objects """ -from typing import Union +from typing import List, Union class HideFileCommand: @@ -30,7 +30,7 @@ class HideFileCommand: def __init__(self) -> None: # an array of files hidden, to undo them as needed - self._hidden_files = [] + self._hidden_files: List[str] = [] def execute(self, filename: str) -> None: print(f"hiding {filename}") @@ -48,7 +48,7 @@ class DeleteFileCommand: def __init__(self) -> None: # an array of deleted files, to undo them as needed - self._deleted_files = [] + self._deleted_files: List[str] = [] def execute(self, filename: str) -> None: print(f"deleting {filename}") diff --git a/patterns/behavioral/iterator.py b/patterns/behavioral/iterator.py index 3c6ec985..3ed4043b 100644 --- a/patterns/behavioral/iterator.py +++ b/patterns/behavioral/iterator.py @@ -7,15 +7,19 @@ """ -def count_to(count): +def count_to(count: int): """Counts by word numbers, up to a maximum of five""" numbers = ["one", "two", "three", "four", "five"] yield from numbers[:count] # Test the generator -count_to_two = lambda: count_to(2) -count_to_five = lambda: count_to(5) +def count_to_two() -> None: + return count_to(2) + + +def count_to_five() -> None: + return count_to(5) def main(): diff --git a/patterns/behavioral/iterator_alt.py b/patterns/behavioral/iterator_alt.py index 2e3a8ba3..a2a71d82 100644 --- a/patterns/behavioral/iterator_alt.py +++ b/patterns/behavioral/iterator_alt.py @@ -5,6 +5,8 @@ Traverses a container and accesses the container's elements. """ +from __future__ import annotations + class NumberWords: """Counts by word numbers, up to a maximum of five""" @@ -17,14 +19,14 @@ class NumberWords: "five", ) - def __init__(self, start, stop): + def __init__(self, start: int, stop: int) -> None: self.start = start self.stop = stop - def __iter__(self): # this makes the class an Iterable + def __iter__(self) -> NumberWords: # this makes the class an Iterable return self - def __next__(self): # this makes the class an Iterator + def __next__(self) -> str: # this makes the class an Iterator if self.start > self.stop or self.start > len(self._WORD_MAP): raise StopIteration current = self.start 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 7ac7aa28..4d072833 100644 --- a/patterns/behavioral/memento.py +++ b/patterns/behavioral/memento.py @@ -6,12 +6,13 @@ """ from copy import copy, deepcopy +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) @@ -25,31 +26,38 @@ class Transaction: """ deep = False - states = [] + 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() -class Transactional: +def Transactional(method): """Adds transactional semantics to methods. Methods decorated with + @Transactional will roll back to entry-state upon exceptions. - @Transactional will rollback to entry-state upon exceptions. + :param method: The function to be decorated. """ - def __init__(self, method): + def __init__(self, method: Callable) -> None: self.method = method - def __get__(self, obj, T): + 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: @@ -58,21 +66,21 @@ def transaction(*args, **kwargs): state() raise e - return transaction + 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 b2d503b7..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 List, Optional, 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: Optional[Observer] = 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/publish_subscribe.py b/patterns/behavioral/publish_subscribe.py index 760d8e7b..7e76955c 100644 --- a/patterns/behavioral/publish_subscribe.py +++ b/patterns/behavioral/publish_subscribe.py @@ -4,22 +4,24 @@ Author: https://github.com/HanWenfang """ +from __future__ import annotations + class Provider: - def __init__(self): + def __init__(self) -> None: self.msg_queue = [] self.subscribers = {} - def notify(self, msg): + def notify(self, msg: str) -> None: self.msg_queue.append(msg) - def subscribe(self, msg, subscriber): + def subscribe(self, msg: str, subscriber: Subscriber) -> None: self.subscribers.setdefault(msg, []).append(subscriber) - def unsubscribe(self, msg, subscriber): + def unsubscribe(self, msg: str, subscriber: Subscriber) -> None: self.subscribers[msg].remove(subscriber) - def update(self): + def update(self) -> None: for msg in self.msg_queue: for sub in self.subscribers.get(msg, []): sub.run(msg) @@ -27,25 +29,25 @@ def update(self): class Publisher: - def __init__(self, msg_center): + def __init__(self, msg_center: Provider) -> None: self.provider = msg_center - def publish(self, msg): + def publish(self, msg: str) -> None: self.provider.notify(msg) class Subscriber: - def __init__(self, name, msg_center): + def __init__(self, name: str, msg_center: Provider) -> None: self.name = name self.provider = msg_center - def subscribe(self, msg): + def subscribe(self, msg: str) -> None: self.provider.subscribe(msg, self) - def unsubscribe(self, msg): + def unsubscribe(self, msg: str) -> None: self.provider.unsubscribe(msg, self) - def run(self, msg): + def run(self, msg: str) -> None: print(f"{self.name} got {msg}") diff --git a/patterns/behavioral/registry.py b/patterns/behavioral/registry.py index a9fca443..60cae019 100644 --- a/patterns/behavioral/registry.py +++ b/patterns/behavioral/registry.py @@ -1,6 +1,8 @@ -class RegistryHolder(type): +from typing import Dict + - REGISTRY = {} +class RegistryHolder(type): + REGISTRY: Dict[str, "RegistryHolder"] = {} def __new__(cls, name, bases, attrs): new_cls = type.__new__(cls, name, bases, attrs) diff --git a/patterns/behavioral/servant.py b/patterns/behavioral/servant.py new file mode 100644 index 00000000..776c4126 --- /dev/null +++ b/patterns/behavioral/servant.py @@ -0,0 +1,131 @@ +""" +Implementation of the Servant design pattern. + +The Servant design pattern is a behavioral pattern used to offer functionality +to a group of classes without requiring them to inherit from a base class. + +This pattern involves creating a Servant class that provides certain services +or functionalities. These services are used by other classes which do not need +to be related through a common parent class. It is particularly useful in +scenarios where adding the desired functionality through inheritance is impractical +or would lead to a rigid class hierarchy. + +This pattern is characterized by the following: + +- A Servant class that provides specific services or actions. +- Client classes that need these services, but do not derive from the Servant class. +- The use of the Servant class by the client classes to perform actions on their behalf. + +References: +- https://en.wikipedia.org/wiki/Servant_(design_pattern) +""" + +import math + + +class Position: + """Representation of a 2D position with x and y coordinates.""" + + def __init__(self, x, y): + self.x = x + self.y = y + + +class Circle: + """Representation of a circle defined by a radius and a position.""" + + 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.""" + + def __init__(self, width, height, position: Position): + self.width = width + self.height = height + self.position = position + + +class GeometryTools: + """ + Servant class providing geometry-related services, including area and + perimeter calculations and position updates. + """ + + @staticmethod + def calculate_area(shape): + """ + Calculate the area of a given shape. + + Args: + shape: The geometric shape whose area is to be calculated. + + Returns: + The area of the shape. + + Raises: + ValueError: If the shape type is unsupported. + """ + if isinstance(shape, Circle): + return math.pi * shape.radius**2 + elif isinstance(shape, Rectangle): + return shape.width * shape.height + else: + raise ValueError("Unsupported shape type") + + @staticmethod + def calculate_perimeter(shape): + """ + Calculate the perimeter of a given shape. + + Args: + shape: The geometric shape whose perimeter is to be calculated. + + Returns: + The perimeter of the shape. + + Raises: + ValueError: If the shape type is unsupported. + """ + if isinstance(shape, Circle): + return 2 * math.pi * shape.radius + elif isinstance(shape, Rectangle): + return 2 * (shape.width + shape.height) + else: + raise ValueError("Unsupported shape type") + + @staticmethod + def move_to(shape, new_position: Position): + """ + Move a given shape to a new position. + + Args: + shape: The geometric shape to be moved. + new_position: The new position to move the shape to. + """ + shape.position = new_position + print(f"Moved to ({shape.position.x}, {shape.position.y})") + + +def main(): + """ + >>> servant = GeometryTools() + >>> circle = Circle(5, Position(0, 0)) + >>> rectangle = Rectangle(3, 4, Position(0, 0)) + >>> servant.calculate_area(circle) + 78.53981633974483 + >>> servant.calculate_perimeter(rectangle) + 14 + >>> servant.move_to(circle, Position(3, 4)) + Moved to (3, 4) + >>> servant.move_to(rectangle, Position(5, 6)) + Moved to (5, 6) + """ + + +if __name__ == "__main__": + import doctest + + doctest.testmod() diff --git a/patterns/behavioral/specification.py b/patterns/behavioral/specification.py index 07db267e..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,25 +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): - _one = Specification() - _other = Specification() + def __init__(self, one: "Specification", other: "Specification") -> None: + self._one: Specification = one + self._other: Specification = other - def __init__(self, one, other): - self._one = one - self._other = 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) @@ -54,14 +52,11 @@ def is_satisfied_by(self, candidate): class OrSpecification(CompositeSpecification): - _one = Specification() - _other = Specification() - - def __init__(self, one, other): - self._one = one - self._other = 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) @@ -69,27 +64,25 @@ def is_satisfied_by(self, candidate): class NotSpecification(CompositeSpecification): - _wrapped = Specification() - - def __init__(self, wrapped): - self._wrapped = 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/state.py b/patterns/behavioral/state.py index 3c606ca8..db4d9468 100644 --- a/patterns/behavioral/state.py +++ b/patterns/behavioral/state.py @@ -8,57 +8,57 @@ Implements state transitions by invoking methods from the pattern's superclass. """ +from __future__ import annotations -class State: +class State: """Base state. This is to share functionality""" - def scan(self): + def scan(self) -> None: """Scan the dial to the next station""" self.pos += 1 if self.pos == len(self.stations): self.pos = 0 - print("Scanning... Station is {} {}".format(self.stations[self.pos], self.name)) + print(f"Scanning... Station is {self.stations[self.pos]} {self.name}") class AmState(State): - def __init__(self, radio): + def __init__(self, radio: Radio) -> None: self.radio = radio self.stations = ["1250", "1380", "1510"] self.pos = 0 self.name = "AM" - def toggle_amfm(self): + def toggle_amfm(self) -> None: print("Switching to FM") self.radio.state = self.radio.fmstate class FmState(State): - def __init__(self, radio): + def __init__(self, radio: Radio) -> None: self.radio = radio self.stations = ["81.3", "89.1", "103.9"] self.pos = 0 self.name = "FM" - def toggle_amfm(self): + def toggle_amfm(self) -> None: print("Switching to AM") self.radio.state = self.radio.amstate class Radio: - """A radio. It has a scan button, and an AM/FM toggle switch.""" - def __init__(self): + def __init__(self) -> None: """We have an AM state and an FM state""" self.amstate = AmState(self) self.fmstate = FmState(self) self.state = self.amstate - def toggle_amfm(self): + def toggle_amfm(self) -> None: self.state.toggle_amfm() - def scan(self): + def scan(self) -> None: self.state.scan() diff --git a/patterns/behavioral/strategy.py b/patterns/behavioral/strategy.py index 92d11f25..000ff2ad 100644 --- a/patterns/behavioral/strategy.py +++ b/patterns/behavioral/strategy.py @@ -7,42 +7,82 @@ Enables selecting an algorithm at runtime. """ +from __future__ import annotations + +from typing import Callable + + +class DiscountStrategyValidator: # Descriptor class for check perform + @staticmethod + def validate(obj: Order, value: Callable) -> bool: + try: + if obj.price - value(obj) < 0: + raise ValueError( + f"Discount cannot be applied due to negative price resulting. {value.__name__}" + ) + except ValueError as ex: + print(str(ex)) + return False + else: + return True + + def __set_name__(self, owner, name: str) -> None: + self.private_name = f"_{name}" + + def __set__(self, obj: Order, value: Callable = None) -> None: + if value and self.validate(obj, value): + setattr(obj, self.private_name, value) + else: + setattr(obj, self.private_name, None) + + def __get__(self, obj: object, objtype: type = None): + return getattr(obj, self.private_name) + class Order: - def __init__(self, price, discount_strategy=None): - self.price = price + discount_strategy = DiscountStrategyValidator() + + def __init__(self, price: float, discount_strategy: Callable = None) -> None: + self.price: float = price self.discount_strategy = discount_strategy - def price_after_discount(self): + def apply_discount(self) -> float: if self.discount_strategy: discount = self.discount_strategy(self) else: discount = 0 + return self.price - discount - def __repr__(self): - fmt = "" - return fmt.format(self.price, self.price_after_discount()) + def __repr__(self) -> str: + strategy = getattr(self.discount_strategy, "__name__", None) + return f"" -def ten_percent_discount(order): +def ten_percent_discount(order: Order) -> float: return order.price * 0.10 -def on_sale_discount(order): +def on_sale_discount(order: Order) -> float: return order.price * 0.25 + 20 def main(): """ - >>> Order(100) - - - >>> Order(100, discount_strategy=ten_percent_discount) - - - >>> Order(1000, discount_strategy=on_sale_discount) - + >>> order = Order(100, discount_strategy=ten_percent_discount) + >>> print(order) + + >>> print(order.apply_discount()) + 90.0 + >>> order = Order(100, discount_strategy=on_sale_discount) + >>> print(order) + + >>> print(order.apply_discount()) + 55.0 + >>> order = Order(10, discount_strategy=on_sale_discount) + Discount cannot be applied due to negative price resulting. on_sale_discount + >>> print(order) + """ diff --git a/patterns/behavioral/template.py b/patterns/behavioral/template.py index d2d83174..76fc136b 100644 --- a/patterns/behavioral/template.py +++ b/patterns/behavioral/template.py @@ -10,28 +10,28 @@ """ -def get_text(): +def get_text() -> str: return "plain-text" -def get_pdf(): +def get_pdf() -> str: return "pdf" -def get_csv(): +def get_csv() -> str: return "csv" -def convert_to_text(data): +def convert_to_text(data: str) -> str: print("[CONVERT]") return f"{data} as text" -def saver(): +def saver() -> None: print("[SAVE]") -def template_function(getter, converter=False, to_save=False): +def template_function(getter, converter=False, to_save=False) -> None: data = getter() print(f"Got `{data}`") 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/abstract_factory.py b/patterns/creational/abstract_factory.py index 9e2e73d4..15e5d67f 100644 --- a/patterns/creational/abstract_factory.py +++ b/patterns/creational/abstract_factory.py @@ -31,77 +31,69 @@ """ import random +from typing import Type -class PetShop: +class Pet: + def __init__(self, name: str) -> None: + self.name = name - """A pet shop""" + def speak(self) -> None: + raise NotImplementedError - def __init__(self, animal_factory=None): - """pet_factory is our abstract factory. We can set it at will.""" + def __str__(self) -> str: + raise NotImplementedError - self.pet_factory = animal_factory - def show_pet(self): - """Creates and shows a pet using the abstract factory""" +class Dog(Pet): + def speak(self) -> None: + print("woof") - pet = self.pet_factory() - print(f"We have a lovely {pet}") - print(f"It says {pet.speak()}") + def __str__(self) -> str: + return f"Dog<{self.name}>" -class Dog: - def speak(self): - return "woof" +class Cat(Pet): + def speak(self) -> None: + print("meow") - def __str__(self): - return "Dog" + def __str__(self) -> str: + return f"Cat<{self.name}>" -class Cat: - def speak(self): - return "meow" +class PetShop: + """A pet shop""" - def __str__(self): - return "Cat" + def __init__(self, animal_factory: Type[Pet]) -> None: + """pet_factory is our abstract factory. We can set it at will.""" + self.pet_factory = animal_factory -# Additional factories: + def buy_pet(self, name: str) -> Pet: + """Creates and shows a pet using the abstract factory""" -# Create a random animal -def random_animal(): - """Let's be dynamic!""" - return random.choice([Dog, Cat])() + pet = self.pet_factory(name) + print(f"Here is your lovely {pet}") + return pet # Show pets with various factories -def main(): +def main() -> None: """ # A Shop that sells only cats >>> cat_shop = PetShop(Cat) - >>> cat_shop.show_pet() - We have a lovely Cat - It says meow - - # A shop that sells random animals - >>> shop = PetShop(random_animal) - >>> for i in range(3): - ... shop.show_pet() - ... print("=" * 20) - We have a lovely Cat - It says meow - ==================== - We have a lovely Dog - It says woof - ==================== - We have a lovely Dog - It says woof - ==================== + >>> pet = cat_shop.buy_pet("Lucy") + Here is your lovely Cat + >>> pet.speak() + meow """ if __name__ == "__main__": - random.seed(1234) # for deterministic doctest outputs + animals = [Dog, Cat] + random_animal: Type[Pet] = random.choice(animals) + + shop = PetShop(random_animal) import doctest doctest.testmod() diff --git a/patterns/creational/borg.py b/patterns/creational/borg.py index e3f04b66..edd0589d 100644 --- a/patterns/creational/borg.py +++ b/patterns/creational/borg.py @@ -13,7 +13,7 @@ its own dictionary, but the Borg pattern modifies this so that all instances have the same dictionary. In this example, the __shared_state attribute will be the dictionary -shared between all instances, and this is ensured by assigining +shared between all instances, and this is ensured by assigning __shared_state to the __dict__ variable when initializing a new instance (i.e., in the __init__ method). Other attributes are usually added to the instance's attribute dictionary, but, since the attribute @@ -33,16 +33,18 @@ Provides singleton-like behavior sharing state between instances. """ +from typing import Dict + class Borg: - _shared_state = {} + _shared_state: Dict[str, str] = {} - def __init__(self): + def __init__(self) -> None: self.__dict__ = self._shared_state class YourBorg(Borg): - def __init__(self, state=None): + def __init__(self, state: str = None) -> None: super().__init__() if state: self.state = state @@ -51,7 +53,7 @@ def __init__(self, state=None): if not hasattr(self, "state"): self.state = "Init" - def __str__(self): + def __str__(self) -> str: return self.state diff --git a/patterns/creational/builder.py b/patterns/creational/builder.py index b1f463ee..16af2295 100644 --- a/patterns/creational/builder.py +++ b/patterns/creational/builder.py @@ -1,13 +1,12 @@ """ -*What is this pattern about? +What is this pattern about? It decouples the creation of a complex object and its representation, so that the same process can be reused to build objects from the same family. This is useful when you must separate the specification of an object from its actual representation (generally for abstraction). -*What does this example do? - +What does this example do? The first example achieves this by using an abstract base class for a building, where the initializer (__init__ method) specifies the steps needed, and the concrete subclasses implement these steps. @@ -22,19 +21,18 @@ class for a building, where the initializer (__init__ method) specifies the In general, in Python this won't be necessary, but a second example showing this kind of arrangement is also included. -*Where is the pattern used practically? - -*References: -https://sourcemaking.com/design_patterns/builder +Where is the pattern used practically? +See: https://sourcemaking.com/design_patterns/builder -*TL;DR +TL;DR Decouples the creation of a complex object and its representation. """ + # Abstract Building class Building: - def __init__(self): + def __init__(self) -> None: self.build_floor() self.build_size() @@ -44,24 +42,24 @@ def build_floor(self): def build_size(self): raise NotImplementedError - def __repr__(self): + def __repr__(self) -> str: return "Floor: {0.floor} | Size: {0.size}".format(self) # Concrete Buildings class House(Building): - def build_floor(self): + def build_floor(self) -> None: self.floor = "One" - def build_size(self): + def build_size(self) -> None: self.size = "Big" class Flat(Building): - def build_floor(self): + def build_floor(self) -> None: self.floor = "More than One" - def build_size(self): + def build_size(self) -> None: self.size = "Small" @@ -72,19 +70,19 @@ def build_size(self): class ComplexBuilding: - def __repr__(self): + def __repr__(self) -> str: return "Floor: {0.floor} | Size: {0.size}".format(self) class ComplexHouse(ComplexBuilding): - def build_floor(self): + def build_floor(self) -> None: self.floor = "One" - def build_size(self): + def build_size(self) -> None: self.size = "Big and fancy" -def construct_building(cls): +def construct_building(cls) -> Building: building = cls() building.build_floor() building.build_size() diff --git a/patterns/creational/factory.py b/patterns/creational/factory.py index e70e0f15..f75bb2b2 100644 --- a/patterns/creational/factory.py +++ b/patterns/creational/factory.py @@ -12,11 +12,8 @@ *Where can the pattern be used practically? The Factory Method can be seen in the popular web framework Django: -http://django.wikispaces.asu.edu/*NEW*+Django+Design+Patterns For -example, in a contact form of a web page, the subject and the message -fields are created using the same form factory (CharField()), even -though they have different implementations according to their -purposes. +https://docs.djangoproject.com/en/4.0/topics/forms/formsets/ +For example, different types of forms are created using a formset_factory *References: http://ginstrom.com/scribbles/2007/10/08/design-patterns-python-style/ @@ -25,6 +22,12 @@ Creates objects without having to specify the exact class. """ +from typing import Dict, Protocol, Type + + +class Localizer(Protocol): + def localize(self, msg: str) -> str: ... + class GreekLocalizer: """A simple localizer a la gettext""" @@ -44,15 +47,15 @@ def localize(self, msg: str) -> str: return msg -def get_localizer(language: str = "English") -> object: - +def get_localizer(language: str = "English") -> Localizer: """Factory""" - localizers = { + localizers: Dict[str, Type[Localizer]] = { "English": EnglishLocalizer, "Greek": GreekLocalizer, } - return localizers[language]() + return localizers.get(language, EnglishLocalizer)() + def main(): diff --git a/patterns/creational/lazy_evaluation.py b/patterns/creational/lazy_evaluation.py index ea087e59..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,13 @@ def __get__(self, obj, type_): return val -def lazy_property2(fn): +def lazy_property2(fn: Callable) -> property: + """ + A lazy property decorator. + + The function decorated is called the first time to retrieve the result and + then that calculated result is used the next time you access the value. + """ attr = "_lazy__" + fn.__name__ @property @@ -48,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/creational/prototype.py b/patterns/creational/prototype.py index 18fd602c..4c2dd7ed 100644 --- a/patterns/creational/prototype.py +++ b/patterns/creational/prototype.py @@ -21,15 +21,21 @@ Creates new object instances by cloning prototype. """ +from __future__ import annotations + +from typing import Any -class Prototype: - value = "default" +class Prototype: + def __init__(self, value: str = "default", **attrs: Any) -> None: + self.value = value + self.__dict__.update(attrs) - def clone(self, **attrs): + def clone(self, **attrs: Any) -> Prototype: """Clone a prototype and update inner attributes dictionary""" # Python in Practice, Mark Summerfield - obj = self.__class__() + # copy.deepcopy can be used instead of next line. + obj = self.__class__(**self.__dict__) obj.__dict__.update(attrs) return obj @@ -38,33 +44,36 @@ class PrototypeDispatcher: def __init__(self): self._objects = {} - def get_objects(self): + def get_objects(self) -> dict[str, Prototype]: """Get all objects""" return self._objects - def register_object(self, name, obj): + def register_object(self, name: str, obj: Prototype) -> None: """Register an object""" self._objects[name] = obj - def unregister_object(self, name): + def unregister_object(self, name: str) -> None: """Unregister an object""" del self._objects[name] -def main(): +def main() -> None: """ >>> dispatcher = PrototypeDispatcher() >>> prototype = Prototype() >>> d = prototype.clone() >>> a = prototype.clone(value='a-value', category='a') - >>> b = prototype.clone(value='b-value', is_checked=True) + >>> b = a.clone(value='b-value', is_checked=True) >>> dispatcher.register_object('objecta', a) >>> dispatcher.register_object('objectb', b) >>> dispatcher.register_object('default', d) >>> [{n: p.value} for n, p in dispatcher.get_objects().items()] [{'objecta': 'a-value'}, {'objectb': 'b-value'}, {'default': 'default'}] + + >>> print(b.category, b.is_checked) + a True """ diff --git a/patterns/fundamental/delegation_pattern.py b/patterns/fundamental/delegation_pattern.py index 2d2f8534..f7a7c2f5 100644 --- a/patterns/fundamental/delegation_pattern.py +++ b/patterns/fundamental/delegation_pattern.py @@ -8,7 +8,7 @@ from __future__ import annotations -from typing import Any, Callable, Union +from typing import Any, Callable class Delegator: @@ -19,19 +19,21 @@ class Delegator: >>> delegator.p2 Traceback (most recent call last): ... - AttributeError: 'Delegate' object has no attribute 'p2' + AttributeError: 'Delegate' object has no attribute 'p2'. Did you mean: 'p1'? >>> delegator.do_something("nothing") 'Doing nothing' + >>> delegator.do_something("something", kw=", faif!") + 'Doing something, faif!' >>> delegator.do_anything() Traceback (most recent call last): ... - AttributeError: 'Delegate' object has no attribute 'do_anything' + AttributeError: 'Delegate' object has no attribute 'do_anything'. Did you mean: 'do_something'? """ - def __init__(self, delegate: Delegate): + def __init__(self, delegate: Delegate) -> None: self.delegate = delegate - def __getattr__(self, name: str) -> Union[Any, Callable]: + def __getattr__(self, name: str) -> Any | Callable: attr = getattr(self.delegate, name) if not callable(attr): @@ -44,11 +46,11 @@ def wrapper(*args, **kwargs): class Delegate: - def __init__(self): + def __init__(self) -> None: self.p1 = 123 - def do_something(self, something: str) -> str: - return f"Doing {something}" + def do_something(self, something: str, kw=None) -> str: + return f"Doing {something}{kw or ''}" if __name__ == "__main__": diff --git a/patterns/other/blackboard.py b/patterns/other/blackboard.py index 999da064..0269a3e7 100644 --- a/patterns/other/blackboard.py +++ b/patterns/other/blackboard.py @@ -9,13 +9,32 @@ https://en.wikipedia.org/wiki/Blackboard_system """ -import abc +from abc import ABC, abstractmethod import random +class AbstractExpert(ABC): + """Abstract class for experts in the blackboard system.""" + + @abstractmethod + def __init__(self, blackboard) -> None: + self.blackboard = blackboard + + @property + @abstractmethod + def is_eager_to_contribute(self) -> int: + raise NotImplementedError("Must provide implementation in subclass.") + + @abstractmethod + def contribute(self) -> None: + raise NotImplementedError("Must provide implementation in subclass.") + + class Blackboard: - def __init__(self): - self.experts = [] + """The blackboard system that holds the common state.""" + + def __init__(self) -> None: + self.experts: list = [] self.common_state = { "problems": 0, "suggestions": 0, @@ -23,15 +42,21 @@ def __init__(self): "progress": 0, # percentage, if 100 -> task is finished } - def add_expert(self, expert): + def add_expert(self, expert: AbstractExpert) -> None: self.experts.append(expert) class Controller: - def __init__(self, blackboard): + """The controller that manages the blackboard system.""" + + def __init__(self, blackboard: Blackboard) -> None: self.blackboard = blackboard def run_loop(self): + """ + This function is a loop that runs until the progress reaches 100. + It checks if an expert is eager to contribute and then calls its contribute method. + """ while self.blackboard.common_state["progress"] < 100: for expert in self.blackboard.experts: if expert.is_eager_to_contribute: @@ -39,26 +64,17 @@ def run_loop(self): return self.blackboard.common_state["contributions"] -class AbstractExpert(metaclass=abc.ABCMeta): - def __init__(self, blackboard): - self.blackboard = blackboard - - @property - @abc.abstractmethod - def is_eager_to_contribute(self): - raise NotImplementedError("Must provide implementation in subclass.") - - @abc.abstractmethod - def contribute(self): - raise NotImplementedError("Must provide implementation in subclass.") +class Student(AbstractExpert): + """Concrete class for a student expert.""" + def __init__(self, blackboard) -> None: + super().__init__(blackboard) -class Student(AbstractExpert): @property - def is_eager_to_contribute(self): + def is_eager_to_contribute(self) -> bool: return True - def contribute(self): + def contribute(self) -> None: self.blackboard.common_state["problems"] += random.randint(1, 10) self.blackboard.common_state["suggestions"] += random.randint(1, 10) self.blackboard.common_state["contributions"] += [self.__class__.__name__] @@ -66,11 +82,16 @@ def contribute(self): class Scientist(AbstractExpert): + """Concrete class for a scientist expert.""" + + def __init__(self, blackboard) -> None: + super().__init__(blackboard) + @property - def is_eager_to_contribute(self): + def is_eager_to_contribute(self) -> int: return random.randint(0, 1) - def contribute(self): + def contribute(self) -> None: self.blackboard.common_state["problems"] += random.randint(10, 20) self.blackboard.common_state["suggestions"] += random.randint(10, 20) self.blackboard.common_state["contributions"] += [self.__class__.__name__] @@ -78,11 +99,14 @@ def contribute(self): class Professor(AbstractExpert): + def __init__(self, blackboard) -> None: + super().__init__(blackboard) + @property - def is_eager_to_contribute(self): + def is_eager_to_contribute(self) -> bool: return True if self.blackboard.common_state["problems"] > 100 else False - def contribute(self): + def contribute(self) -> None: self.blackboard.common_state["problems"] += random.randint(1, 2) self.blackboard.common_state["suggestions"] += random.randint(10, 20) self.blackboard.common_state["contributions"] += [self.__class__.__name__] @@ -101,21 +125,13 @@ def main(): >>> from pprint import pprint >>> pprint(contributions) - ['Student', - 'Student', - 'Student', - 'Student', - 'Scientist', - 'Student', - 'Student', - 'Student', - 'Scientist', - 'Student', - 'Scientist', - 'Student', - 'Student', - 'Scientist', - 'Professor'] + ['Student', + 'Scientist', + 'Student', + 'Scientist', + 'Student', + 'Scientist', + 'Professor'] """ diff --git a/patterns/other/graph_search.py b/patterns/other/graph_search.py index 9b04925f..6e3cdffb 100644 --- a/patterns/other/graph_search.py +++ b/patterns/other/graph_search.py @@ -1,15 +1,19 @@ -class GraphSearch: +from typing import Any, Dict, List, Optional, Union + +class GraphSearch: """Graph search emulation in python, from source http://www.python.org/doc/essays/graphs/ 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) @@ -21,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: @@ -33,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) @@ -48,7 +56,21 @@ 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. + + :param start: The node to start from. + :type start: str or int + :param end: The node to find the shortest path to. + :type end: str or int + + :returns queue_path_to_end, dist_to[end]: A list of nodes + representing the shortest path from `start` to `end`, and a dictionary + mapping each node in the graph (except for `start`) with its distance from it + (in terms of hops). If no such path exists, returns an empty list and an empty + dictionary instead. + """ queue = [start] dist_to = {start: 0} edge_to = {} diff --git a/patterns/structural/3-tier.py b/patterns/structural/3-tier.py index 64835f99..287badaf 100644 --- a/patterns/structural/3-tier.py +++ b/patterns/structural/3-tier.py @@ -7,7 +7,7 @@ class Data: - """ Data Store Class """ + """Data Store Class""" products = { "milk": {"price": 1.50, "quantity": 10}, @@ -16,13 +16,12 @@ class Data: } def __get__(self, obj, klas): - print("(Fetching from Data Store)") return {"products": self.products} class BusinessLogic: - """ Business logic holding data store instances """ + """Business logic holding data store instances""" data = Data() @@ -36,7 +35,7 @@ def product_information( class Ui: - """ UI interaction class """ + """UI interaction class""" def __init__(self) -> None: self.business_logic = BusinessLogic() 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/decorator.py b/patterns/structural/decorator.py index 01c91b00..a32e2b06 100644 --- a/patterns/structural/decorator.py +++ b/patterns/structural/decorator.py @@ -28,30 +28,30 @@ class TextTag: """Represents a base text tag""" - def __init__(self, text): + def __init__(self, text: str) -> None: self._text = text - def render(self): + def render(self) -> str: return self._text class BoldWrapper(TextTag): """Wraps a tag in """ - def __init__(self, wrapped): + def __init__(self, wrapped: TextTag) -> None: self._wrapped = wrapped - def render(self): + def render(self) -> str: return f"{self._wrapped.render()}" class ItalicWrapper(TextTag): """Wraps a tag in """ - def __init__(self, wrapped): + def __init__(self, wrapped: TextTag) -> None: self._wrapped = wrapped - def render(self): + def render(self) -> str: return f"{self._wrapped.render()}" diff --git a/patterns/structural/facade.py b/patterns/structural/facade.py index 6561c6dc..f7b00be3 100644 --- a/patterns/structural/facade.py +++ b/patterns/structural/facade.py @@ -35,13 +35,13 @@ class CPU: Simple CPU representation. """ - def freeze(self): + def freeze(self) -> None: print("Freezing processor.") - def jump(self, position): + def jump(self, position: str) -> None: print("Jumping to:", position) - def execute(self): + def execute(self) -> None: print("Executing.") @@ -50,7 +50,7 @@ class Memory: Simple memory representation. """ - def load(self, position, data): + def load(self, position: str, data: str) -> None: print(f"Loading from {position} data: '{data}'.") @@ -59,7 +59,7 @@ class SolidStateDrive: Simple solid state drive representation. """ - def read(self, lba, size): + def read(self, lba: str, size: str) -> str: return f"Some data from sector {lba} with size {size}" diff --git a/patterns/structural/flyweight.py b/patterns/structural/flyweight.py index 29015705..68b6f43c 100644 --- a/patterns/structural/flyweight.py +++ b/patterns/structural/flyweight.py @@ -34,9 +34,9 @@ class Card: # Could be a simple dict. # With WeakValueDictionary garbage collection can reclaim the object # when there are no other references to it. - _pool = weakref.WeakValueDictionary() + _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/front_controller.py b/patterns/structural/front_controller.py index 9377fefe..92f58b21 100644 --- a/patterns/structural/front_controller.py +++ b/patterns/structural/front_controller.py @@ -5,23 +5,33 @@ Provides a centralized entry point that controls and manages request handling. """ +from __future__ import annotations + +from typing import Any + class MobileView: - def show_index_page(self): + def show_index_page(self) -> None: print("Displaying mobile index page") class TabletView: - def show_index_page(self): + def show_index_page(self) -> None: print("Displaying tablet index page") class Dispatcher: - def __init__(self): + def __init__(self) -> None: self.mobile_view = MobileView() self.tablet_view = TabletView() - def dispatch(self, request): + def dispatch(self, request: Request) -> None: + """ + This function is used to dispatch the request based on the type of device. + If it is a mobile, then mobile view will be called and if it is a tablet, + then tablet view will be called. + Otherwise, an error message will be printed saying that cannot dispatch the request. + """ if request.type == Request.mobile_type: self.mobile_view.show_index_page() elif request.type == Request.tablet_type: @@ -31,12 +41,15 @@ def dispatch(self, request): class RequestController: - """ front controller """ + """front controller""" - def __init__(self): + def __init__(self) -> None: self.dispatcher = Dispatcher() - def dispatch_request(self, request): + def dispatch_request(self, request: Any) -> None: + """ + This function takes a request object and sends it to the dispatcher. + """ if isinstance(request, Request): self.dispatcher.dispatch(request) else: @@ -44,7 +57,7 @@ def dispatch_request(self, request): class Request: - """ request """ + """request""" mobile_type = "mobile" tablet_type = "tablet" diff --git a/patterns/structural/mvc.py b/patterns/structural/mvc.py index df6611ce..27765fb7 100644 --- a/patterns/structural/mvc.py +++ b/patterns/structural/mvc.py @@ -4,31 +4,39 @@ """ from abc import ABC, abstractmethod +from ProductModel import Price +from typing import Dict, List, Union, Any +from inspect import signature +from sys import argv class Model(ABC): + """The Model is the data layer of the application.""" + @abstractmethod - def __iter__(self): + def __iter__(self) -> Any: pass @abstractmethod - def get(self, item): + def get(self, item: str) -> dict: """Returns an object with a .items() call method that iterates over key,value pairs of its information.""" pass @property @abstractmethod - def item_type(self): + def item_type(self) -> str: pass 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.""" - def __str__(self): + def __str__(self) -> str: return f"{self:.2f}" products = { @@ -39,10 +47,10 @@ def __str__(self): item_type = "product" - def __iter__(self): + def __iter__(self) -> Any: yield from self.products - def get(self, product): + def get(self, product: str) -> dict: try: return self.products[product] except KeyError as e: @@ -50,33 +58,43 @@ def get(self, product): class View(ABC): + """The View is the presentation layer of the application.""" + @abstractmethod - def show_item_list(self, item_type, item_list): + def show_item_list(self, item_type: str, item_list: list) -> None: pass @abstractmethod - def show_item_information(self, item_type, item_name, item_info): + 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 @abstractmethod - def item_not_found(self, item_type, item_name): + def item_not_found(self, item_type: str, item_name: str) -> None: pass class ConsoleView(View): - def show_item_list(self, item_type, item_list): + """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: print(item) print("") @staticmethod - def capitalizer(string): + 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, item_name, item_info): + 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 for key, value in item_info.items(): @@ -84,31 +102,61 @@ def show_item_information(self, item_type, item_name, item_info): printout += "\n" print(printout) - def item_not_found(self, item_type, item_name): + def item_not_found(self, item_type: str, item_name: str) -> None: print(f'That {item_type} "{item_name}" does not exist in the records') class Controller: - def __init__(self, model, view): - self.model = model - self.view = view + """The Controller is the intermediary between the Model and the View.""" - def show_items(self): + def __init__(self, model_class: Model, view_class: View) -> None: + self.model: Model = model_class + self.view: View = view_class + + def show_items(self) -> None: items = list(self.model) item_type = self.model.item_type self.view.show_item_list(item_type, items) - def show_item_information(self, item_name): + def show_item_information(self, item_name: str) -> None: + """ + Show information about a {item_type} item. + :param str item_name: the name of the {item_type} item to show information about + """ + item_type: str = self.model.item_type try: - item_info = self.model.get(item_name) + item_info: dict = self.model.get(item_name) except Exception: - item_type = self.model.item_type self.view.item_not_found(item_type, item_name) else: - item_type = self.model.item_type self.view.show_item_information(item_type, item_name, item_info) +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: + model_instance: Model = model_class() + view_instance: View = view_class() + self.routes[path] = controller_class(model_instance, view_instance) + + def resolve(self, path: str) -> Controller: + if self.routes.get(path): + controller: Controller = self.routes[path] + return controller + else: + raise KeyError(f"No controller registered for path '{path}'") + + def main(): """ >>> model = ProductModel() @@ -143,6 +191,27 @@ def main(): if __name__ == "__main__": + router = Router() + router.register("products", Controller, ProductModel, ConsoleView) + 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 "" + + if hasattr(controller, action): + command = getattr(controller, action) + sig = signature(command) + + if len(sig.parameters) > 0: + if args: + command(args) + else: + print("Command requires arguments.") + else: + command() + else: + print(f"Command {action} not found in the controller.") + import doctest doctest.testmod() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..dfac5da9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,120 @@ +[build-system] +requires = ["setuptools >= 77.0.3"] +build-backend = "setuptools.build_meta" + +[project] +name = "python-patterns" +description = "A collection of design patterns and idioms in Python." +version = "0.1.0" +readme = "README.md" +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dependencies= [ +] + +maintainers=[ + { name="faif" } +] + +[project.urls] +Homepage = "https://github.com/faif/python-patterns" +Repository = "https://github.com/faif/python-patterns" +"Bug Tracker" = "https://github.com/faif/python-patterns/issues" +Contributors = "https://github.com/faif/python-patterns/graphs/contributors" + +[project.optional-dependencies] +dev = [ + "mypy", + "pipx>=1.7.1", + "pyupgrade", + "pytest>=6.2.0", + "pytest-cov>=2.11.0", + "pytest-randomly>=3.1.0", + "black>=25.1.0", + "build>=1.2.2", + "isort>=5.7.0", + "flake8>=7.1.0", + "tox>=4.25.0" +] + +[tool.setuptools] +packages = ["patterns"] + +[tool.pytest.ini_options] +filterwarnings = [ + "ignore::Warning:.*test class 'TestRunner'.*" +] +# Adding settings from tox.ini for pytest +testpaths = ["tests"] +#testpaths = ["tests", "patterns"] +python_files = ["test_*.py", "*_test.py"] +# Enable doctest discovery in patterns directory +addopts = "--doctest-modules --randomly-seed=1234 --cov=patterns --cov-report=term-missing" +doctest_optionflags = ["ELLIPSIS", "NORMALIZE_WHITESPACE"] +log_level = "INFO" + +[tool.coverage.run] +branch = true +source = ["./"] +#source = ["patterns"] +# Ensure coverage data is collected properly +relative_files = true +parallel = true +dynamic_context = "test_function" +data_file = ".coverage" + +[tool.coverage.report] +# Regexes for lines to exclude from consideration +exclude_lines = [ + "def __repr__", + "if self\\.debug", + "raise AssertionError", + "raise NotImplementedError", + "if 0:", + "if __name__ == .__main__.:", + "@(abc\\.)?abstractmethod" +] +ignore_errors = true + +[tool.coverage.html] +directory = "coverage_html_report" + +[tool.mypy] +python_version = "3.12" +ignore_missing_imports = true + +[tool.flake8] +max-line-length = 120 +ignore = ["E266", "E731", "W503"] +exclude = ["venv*"] + +[tool.tox] +legacy_tox_ini = """ +[tox] +envlist = py312,cov-report +skip_missing_interpreters = true +usedevelop = true + +#[testenv] +#setenv = +# COVERAGE_FILE = .coverage.{envname} +#deps = +# -r requirements-dev.txt +#commands = +# flake8 --exclude="venv/,.tox/" patterns/ +# coverage run -m pytest --randomly-seed=1234 --doctest-modules patterns/ +# coverage run -m pytest -s -vv --cov=patterns/ --log-level=INFO tests/ + +#[testenv:cov-report] +#setenv = +# COVERAGE_FILE = .coverage +#deps = coverage +#commands = +# coverage combine +# coverage report +#""" \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index 451dad45..1194272a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,8 @@ --e . - -pytest~=4.3.0 -pytest-cov~=2.6.0 -flake8~=3.7.0 -pytest-randomly~=3.1.0 +flake8 +black +isort +pytest +pytest-randomly +mypy +pyupgrade +tox diff --git a/setup.py b/setup.py deleted file mode 100644 index b4218c1c..00000000 --- a/setup.py +++ /dev/null @@ -1,14 +0,0 @@ -from setuptools import find_packages, setup - -setup( - name="patterns", - packages=find_packages(), - description="A collection of design patterns and idioms in Python.", - classifiers=[ - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - ], -) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b 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 86488343..8bb7130c 100644 --- a/tests/behavioral/test_publish_subscribe.py +++ b/tests/behavioral/test_publish_subscribe.py @@ -27,7 +27,7 @@ def test_subscriber_shall_be_detachable_from_subscriptions(cls): cls.assertEqual(len(pro.subscribers[subscription]), 0) def test_publisher_shall_append_subscription_message_to_queue(cls): - """ msg_queue ~ Provider.notify(msg) ~ Publisher.publish(msg) """ + """msg_queue ~ Provider.notify(msg) ~ Publisher.publish(msg)""" expected_msg = "expected msg" pro = Provider() pub = Publisher(pro) @@ -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 new file mode 100644 index 00000000..dd487171 --- /dev/null +++ b/tests/behavioral/test_servant.py @@ -0,0 +1,39 @@ +from patterns.behavioral.servant import GeometryTools, Circle, Rectangle, Position +import pytest +import math + + +@pytest.fixture +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(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) + + with pytest.raises(ValueError): + GeometryTools.calculate_perimeter("invalid shape") + + +def test_move_to(circle, rectangle): + new_position = Position(1, 1) + GeometryTools.move_to(circle, new_position) + assert circle.position == new_position + + new_position = Position(1, 1) + GeometryTools.move_to(rectangle, new_position) + assert rectangle.position == new_position diff --git a/tests/behavioral/test_strategy.py b/tests/behavioral/test_strategy.py new file mode 100644 index 00000000..53976f38 --- /dev/null +++ b/tests/behavioral/test_strategy.py @@ -0,0 +1,41 @@ +import pytest + +from patterns.behavioral.strategy import Order, on_sale_discount, ten_percent_discount + + +@pytest.fixture +def order(): + return Order(100) + + +@pytest.mark.parametrize( + "func, discount", [(ten_percent_discount, 10.0), (on_sale_discount, 45.0)] +) +def test_discount_function_return(func, order, discount): + assert func(order) == discount + + +@pytest.mark.parametrize( + "func, price", [(ten_percent_discount, 100), (on_sale_discount, 100)] +) +def test_order_discount_strategy_validate_success(func, price): + order = Order(price, func) + + assert order.price == price + assert order.discount_strategy == func + + +def test_order_discount_strategy_validate_error(): + order = Order(10, discount_strategy=on_sale_discount) + + assert order.discount_strategy is None + + +@pytest.mark.parametrize( + "func, price, discount", + [(ten_percent_discount, 100, 90.0), (on_sale_discount, 100, 55.0)], +) +def test_discount_apply_success(func, price, discount): + order = Order(price, func) + + assert order.apply_discount() == discount 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/creational/test_abstract_factory.py b/tests/creational/test_abstract_factory.py index ad818f59..1676e59d 100644 --- a/tests/creational/test_abstract_factory.py +++ b/tests/creational/test_abstract_factory.py @@ -8,5 +8,6 @@ class TestPetShop(unittest.TestCase): def test_dog_pet_shop_shall_show_dog_instance(self): dog_pet_shop = PetShop(Dog) with patch.object(Dog, "speak") as mock_Dog_speak: - dog_pet_shop.show_pet() + pet = dog_pet_shop.buy_pet("") + pet.speak() self.assertEqual(mock_Dog_speak.call_count, 1) diff --git a/tests/creational/test_pool.py b/tests/creational/test_pool.py index 38476eb7..cd501db3 100644 --- a/tests/creational/test_pool.py +++ b/tests/creational/test_pool.py @@ -29,7 +29,6 @@ def test_frozen_pool(self): class TestNaitivePool(unittest.TestCase): - """def test_object(queue): queue_object = QueueObject(queue, True) print('Inside func: {}'.format(queue_object.object))""" 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/structural/test_proxy.py b/tests/structural/test_proxy.py index ec660986..3409bf0b 100644 --- a/tests/structural/test_proxy.py +++ b/tests/structural/test_proxy.py @@ -8,17 +8,17 @@ class ProxyTest(unittest.TestCase): @classmethod def setUpClass(cls): - """ Class scope setup. """ + """Class scope setup.""" cls.proxy = Proxy() def setUp(cls): - """ Function/test case scope setup. """ + """Function/test case scope setup.""" cls.output = StringIO() cls.saved_stdout = sys.stdout sys.stdout = cls.output def tearDown(cls): - """ Function/test case scope teardown. """ + """Function/test case scope teardown.""" cls.output.close() sys.stdout = cls.saved_stdout 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)