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..f18e5c2e --- /dev/null +++ b/.github/workflows/lint_pr.yml @@ -0,0 +1,286 @@ +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 + echo "files=$CHANGED_FILES" >> $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 new file mode 100644 index 00000000..19d6c078 --- /dev/null +++ b/.github/workflows/lint_python.yml @@ -0,0 +1,36 @@ +name: lint_python +on: [pull_request, push] +jobs: + lint_python: + runs-on: ubuntu-24.04 + steps: + - 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 -r requirements-dev.txt + - 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..d272a2e1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,12 @@ __pycache__ .idea *.egg-info/ .tox/ -venv +env/ +venv/ +.env +.venv .vscode/ -.python-version \ No newline at end of file +.python-version +.coverage +build/ +dist/ \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index ba68fde3..dfeece70 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,21 +1,17 @@ -dist: xenial +os: linux +dist: noble language: python -sudo: false - -matrix: +jobs: include: - - python: "3.7" - env: TOXENV=ci37 - - python: "3.8" - env: TOXENV=ci38 + - python: "3.12" + env: TOXENV=py312 cache: - pip install: - - pip install tox - - pip install codecov + - pip install codecov tox script: - tox 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..05222bc9 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 ---------------- @@ -90,12 +92,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,19 +100,19 @@ 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`. 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 61% rename from setup.cfg rename to config_backup/setup.cfg index 536a52d7..e109555b 100644 --- a/setup.cfg +++ b/config_backup/setup.cfg @@ -1,9 +1,13 @@ [flake8] max-line-length = 120 -ignore = E266 E731 -exclude = .venv* +ignore = E266 E731 W503 +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 64% rename from tox.ini rename to config_backup/tox.ini index d86eeec9..36e2577e 100644 --- a/tox.ini +++ b/config_backup/tox.ini @@ -1,17 +1,22 @@ [tox] -envlist = ci37,ci38,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 patterns/ + 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..ba85f500 100644 --- a/patterns/behavioral/catalog.py +++ b/patterns/behavioral/catalog.py @@ -46,7 +46,6 @@ def main_method(self) -> None: # Alternative implementation for different levels of methods class CatalogInstance: - """catalog of multiple methods that are executed depending on an init parameter @@ -77,11 +76,11 @@ def main_method(self) -> None: depending on self.param value """ - self._instance_method_choices[self.param].__get__(self)() + 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 @@ -115,11 +114,11 @@ def main_method(self): depending on self.param value """ - self._class_method_choices[self.param].__get__(None, self.__class__)() + 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 @@ -151,7 +150,8 @@ def main_method(self) -> None: depending on self.param value """ - self._static_method_choices[self.param].__get__(None, self.__class__)() + self._static_method_choices[self.param].__get__(None, self.__class__)() # type: ignore + # type ignore reason: https://github.com/python/mypy/issues/10206 def main(): diff --git a/patterns/behavioral/chain_of_responsibility.py b/patterns/behavioral/chain_of_responsibility.py index d9bb80b5..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 Callable, 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 7af9ea02..3ed4043b 100644 --- a/patterns/behavioral/iterator.py +++ b/patterns/behavioral/iterator.py @@ -7,16 +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"] - for number in numbers[:count]: - yield number + 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/memento.py b/patterns/behavioral/memento.py index ef551438..c1bc7f0b 100644 --- a/patterns/behavioral/memento.py +++ b/patterns/behavioral/memento.py @@ -6,6 +6,7 @@ """ from copy import copy, deepcopy +from typing import Callable, List def memento(obj, deep=False): @@ -25,7 +26,7 @@ class Transaction: """ deep = False - states = [] + states: List[Callable[[], None]] = [] def __init__(self, deep, *targets): self.deep = deep @@ -40,33 +41,27 @@ def rollback(self): 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): - self.method = method - - def __get__(self, obj, T): - def transaction(*args, **kwargs): - state = memento(obj) - try: - return self.method(obj, *args, **kwargs) - except Exception as e: - state() - raise e - - return transaction - + def transaction(obj, *args, **kwargs): + state = memento(obj) + try: + return method(obj, *args, **kwargs) + except Exception as e: + state() + raise e + return transaction class NumObj: def __init__(self, value): self.value = value def __repr__(self): - return "<%s: %r>" % (self.__class__.__name__, self.value) + return f"<{self.__class__.__name__}: {self.value!r}>" def increment(self): self.value += 1 diff --git a/patterns/behavioral/observer.py b/patterns/behavioral/observer.py index b2d503b7..03d970ad 100644 --- a/patterns/behavioral/observer.py +++ b/patterns/behavioral/observer.py @@ -12,7 +12,7 @@ from __future__ import annotations from contextlib import suppress -from typing import List, Optional, Protocol +from typing import Protocol # define a generic observer type @@ -23,7 +23,7 @@ def update(self, subject: Subject) -> None: class Subject: def __init__(self) -> None: - self._observers: List[Observer] = [] + self._observers: list[Observer] = [] def attach(self, observer: Observer) -> None: if observer not in self._observers: @@ -33,7 +33,7 @@ def detach(self, observer: Observer) -> None: with suppress(ValueError): self._observers.remove(observer) - def notify(self, modifier: Optional[Observer] = None) -> None: + def notify(self, modifier: Observer | None = None) -> None: for observer in self._observers: if modifier != observer: observer.update(self) diff --git a/patterns/behavioral/publish_subscribe.py b/patterns/behavioral/publish_subscribe.py index d9e2a7c4..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,26 +29,26 @@ 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): - print("{} got {}".format(self.name, msg)) + def run(self, msg: str) -> None: + print(f"{self.name} got {msg}") def main(): @@ -65,7 +67,7 @@ def main(): >>> vani.subscribe("movie") >>> vani.unsubscribe("movie") - # Note that no one subscirbed to `ads` + # Note that no one subscribed to `ads` # and that vani changed their mind >>> fftv.publish("cartoon") diff --git a/patterns/behavioral/registry.py b/patterns/behavioral/registry.py index a9fca443..d44a992e 100644 --- a/patterns/behavioral/registry.py +++ b/patterns/behavioral/registry.py @@ -1,6 +1,9 @@ +from typing import Dict + + class RegistryHolder(type): - REGISTRY = {} + REGISTRY: Dict[str, "RegistryHolder"] = {} def __new__(cls, name, bases, attrs): new_cls = type.__new__(cls, name, bases, attrs) diff --git a/patterns/behavioral/specification.py b/patterns/behavioral/specification.py index 07db267e..303ee513 100644 --- a/patterns/behavioral/specification.py +++ b/patterns/behavioral/specification.py @@ -39,12 +39,9 @@ def not_specification(self): class AndSpecification(CompositeSpecification): - _one = Specification() - _other = Specification() - def __init__(self, one, other): - self._one = one - self._other = other + self._one: Specification = one + self._other: Specification = other def is_satisfied_by(self, candidate): return bool( @@ -54,12 +51,9 @@ def is_satisfied_by(self, candidate): class OrSpecification(CompositeSpecification): - _one = Specification() - _other = Specification() - def __init__(self, one, other): - self._one = one - self._other = other + self._one: Specification = one + self._other: Specification = other def is_satisfied_by(self, candidate): return bool( @@ -69,10 +63,8 @@ def is_satisfied_by(self, candidate): class NotSpecification(CompositeSpecification): - _wrapped = Specification() - def __init__(self, wrapped): - self._wrapped = wrapped + self._wrapped: Specification = wrapped def is_satisfied_by(self, candidate): return bool(not self._wrapped.is_satisfied_by(candidate)) 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 6596fee5..76fc136b 100644 --- a/patterns/behavioral/template.py +++ b/patterns/behavioral/template.py @@ -10,30 +10,30 @@ """ -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 "{} as text".format(data) + 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("Got `{}`".format(data)) + print(f"Got `{data}`") if len(data) <= 3 and converter: data = converter(data) @@ -43,7 +43,7 @@ def template_function(getter, converter=False, to_save=False): if to_save: saver() - print("`{}` was processed".format(data)) + print(f"`{data}` was processed") def main(): diff --git a/patterns/creational/abstract_factory.py b/patterns/creational/abstract_factory.py index 63648636..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("We have a lovely {}".format(pet)) - print("It says {}".format(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..22383923 100644 --- a/patterns/creational/builder.py +++ b/patterns/creational/builder.py @@ -34,7 +34,7 @@ class for a building, where the initializer (__init__ method) specifies the # Abstract Building class Building: - def __init__(self): + def __init__(self) -> None: self.build_floor() self.build_size() @@ -44,24 +44,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 +72,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..3ef2d2a8 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,13 @@ Creates objects without having to specify the exact class. """ +from typing import Dict, Protocol, Type + + +class Localizer(Protocol): + def localize(self, msg: str) -> str: + pass + class GreekLocalizer: """A simple localizer a la gettext""" @@ -44,10 +48,9 @@ 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, } diff --git a/patterns/creational/lazy_evaluation.py b/patterns/creational/lazy_evaluation.py index b3e73743..b56daf0c 100644 --- a/patterns/creational/lazy_evaluation.py +++ b/patterns/creational/lazy_evaluation.py @@ -10,7 +10,7 @@ https://github.com/django/django/blob/ffd18732f3ee9e6f0374aff9ccf350d85187fac2/django/utils/functional.py#L19 pip https://github.com/pypa/pip/blob/cb75cca785629e15efb46c35903827b3eae13481/pip/utils/__init__.py#L821 -pyramimd +pyramid https://github.com/Pylons/pyramid/blob/7909e9503cdfc6f6e84d2c7ace1d3c03ca1d8b73/pyramid/decorator.py#L4 werkzeug https://github.com/pallets/werkzeug/blob/5a2bf35441006d832ab1ed5a31963cbc366c99ac/werkzeug/utils.py#L35 @@ -36,6 +36,12 @@ def __get__(self, obj, type_): def lazy_property2(fn): + """ + 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 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/dependency_injection.py b/patterns/dependency_injection.py index e5ddfbac..2979f763 100644 --- a/patterns/dependency_injection.py +++ b/patterns/dependency_injection.py @@ -74,7 +74,7 @@ def production_code_time_provider() -> str: datetime for this example). """ current_time = datetime.datetime.now() - current_time_formatted = "{}:{}".format(current_time.hour, current_time.minute) + current_time_formatted = f"{current_time.hour}:{current_time.minute}" return current_time_formatted 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..58fbdb98 100644 --- a/patterns/other/blackboard.py +++ b/patterns/other/blackboard.py @@ -9,13 +9,30 @@ 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 +40,20 @@ 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 +61,16 @@ 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) + @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 +78,15 @@ 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 +94,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 +120,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 7e0885c7..262a6f08 100644 --- a/patterns/other/graph_search.py +++ b/patterns/other/graph_search.py @@ -1,12 +1,14 @@ class GraphSearch: - """Graph search emulation in python, from source - http://www.python.org/doc/essays/graphs/""" + http://www.python.org/doc/essays/graphs/ + + dfs stands for Depth First Search + bfs stands for Breadth First Search""" def __init__(self, graph): self.graph = graph - def find_path(self, start, end, path=None): + def find_path_dfs(self, start, end, path=None): path = path or [] path.append(start) @@ -14,11 +16,11 @@ def find_path(self, start, end, path=None): return path for node in self.graph.get(start, []): if node not in path: - newpath = self.find_path(node, end, path[:]) + newpath = self.find_path_dfs(node, end, path[:]) if newpath: return newpath - def find_all_path(self, start, end, path=None): + def find_all_paths_dfs(self, start, end, path=None): path = path or [] path.append(start) if start == end: @@ -26,11 +28,11 @@ def find_all_path(self, start, end, path=None): paths = [] for node in self.graph.get(start, []): if node not in path: - newpaths = self.find_all_path(node, end, path[:]) + newpaths = self.find_all_paths_dfs(node, end, path[:]) paths.extend(newpaths) return paths - def find_shortest_path(self, start, end, path=None): + def find_shortest_path_dfs(self, start, end, path=None): path = path or [] path.append(start) @@ -39,27 +41,106 @@ def find_shortest_path(self, start, end, path=None): shortest = None for node in self.graph.get(start, []): if node not in path: - newpath = self.find_shortest_path(node, end, path[:]) + newpath = self.find_shortest_path_dfs(node, end, path[:]) if newpath: if not shortest or len(newpath) < len(shortest): shortest = newpath return shortest + def find_shortest_path_bfs(self, start, end): + """ + 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 = {} + + if start == end: + return queue + + while len(queue): + value = queue.pop(0) + for node in self.graph[value]: + if node not in dist_to.keys(): + edge_to[node] = value + dist_to[node] = dist_to[value] + 1 + queue.append(node) + if end in edge_to.keys(): + path = [] + node = end + while dist_to[node] != 0: + path.insert(0, node) + node = edge_to[node] + path.insert(0, start) + return path + def main(): """ # example of graph usage - >>> graph = {'A': ['B', 'C'], 'B': ['C', 'D'], 'C': ['D'], 'D': ['C'], 'E': ['F'], 'F': ['C']} + >>> graph = { + ... 'A': ['B', 'C'], + ... 'B': ['C', 'D'], + ... 'C': ['D', 'G'], + ... 'D': ['C'], + ... 'E': ['F'], + ... 'F': ['C'], + ... 'G': ['E'], + ... 'H': ['C'] + ... } # initialization of new graph search object - >>> graph1 = GraphSearch(graph) + >>> graph_search = GraphSearch(graph) - >>> print(graph1.find_path('A', 'D')) + >>> print(graph_search.find_path_dfs('A', 'D')) ['A', 'B', 'C', 'D'] - >>> print(graph1.find_all_path('A', 'D')) + + # start the search somewhere in the middle + >>> print(graph_search.find_path_dfs('G', 'F')) + ['G', 'E', 'F'] + + # unreachable node + >>> print(graph_search.find_path_dfs('C', 'H')) + None + + # non existing node + >>> print(graph_search.find_path_dfs('C', 'X')) + None + + >>> print(graph_search.find_all_paths_dfs('A', 'D')) [['A', 'B', 'C', 'D'], ['A', 'B', 'D'], ['A', 'C', 'D']] - >>> print(graph1.find_shortest_path('A', 'D')) + >>> print(graph_search.find_shortest_path_dfs('A', 'D')) + ['A', 'B', 'D'] + >>> print(graph_search.find_shortest_path_dfs('A', 'F')) + ['A', 'C', 'G', 'E', 'F'] + + >>> print(graph_search.find_shortest_path_bfs('A', 'D')) ['A', 'B', 'D'] + >>> print(graph_search.find_shortest_path_bfs('A', 'F')) + ['A', 'C', 'G', 'E', 'F'] + + # start the search somewhere in the middle + >>> print(graph_search.find_shortest_path_bfs('G', 'F')) + ['G', 'E', 'F'] + + # unreachable node + >>> print(graph_search.find_shortest_path_bfs('A', 'H')) + None + + # non existing node + >>> print(graph_search.find_shortest_path_bfs('A', 'X')) + None """ diff --git a/patterns/structural/3-tier.py b/patterns/structural/3-tier.py index 0fd4cbda..ecc04243 100644 --- a/patterns/structural/3-tier.py +++ b/patterns/structural/3-tier.py @@ -3,11 +3,11 @@ Separates presentation, application processing, and data management functions. """ -from typing import Dict, KeysView, Optional, Type, TypeVar, Union +from typing import Dict, KeysView, Optional, Union class Data: - """ Data Store Class """ + """Data Store Class""" products = { "milk": {"price": 1.50, "quantity": 10}, @@ -22,7 +22,7 @@ def __get__(self, obj, klas): class BusinessLogic: - """ Business logic holding data store instances """ + """Business logic holding data store instances""" data = Data() @@ -36,7 +36,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 e2f6fa10..feddb675 100644 --- a/patterns/structural/bridge.py +++ b/patterns/structural/bridge.py @@ -10,13 +10,13 @@ # ConcreteImplementor 1/2 class DrawingAPI1: def draw_circle(self, x, y, radius): - print("API1.circle at {}:{} radius {}".format(x, y, radius)) + print(f"API1.circle at {x}:{y} radius {radius}") # ConcreteImplementor 2/2 class DrawingAPI2: def draw_circle(self, x, y, radius): - print("API2.circle at {}:{} radius {}".format(x, y, radius)) + print(f"API2.circle at {x}:{y} radius {radius}") # Refined Abstraction diff --git a/patterns/structural/decorator.py b/patterns/structural/decorator.py index 08504bcf..a32e2b06 100644 --- a/patterns/structural/decorator.py +++ b/patterns/structural/decorator.py @@ -28,31 +28,31 @@ 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): - return "{}".format(self._wrapped.render()) + 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): - return "{}".format(self._wrapped.render()) + def render(self) -> str: + return f"{self._wrapped.render()}" def main(): diff --git a/patterns/structural/facade.py b/patterns/structural/facade.py index 290afe6d..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,8 +50,8 @@ class Memory: Simple memory representation. """ - def load(self, position, data): - print("Loading from {0} data: '{1}'.".format(position, data)) + def load(self, position: str, data: str) -> None: + print(f"Loading from {position} data: '{data}'.") class SolidStateDrive: @@ -59,8 +59,8 @@ class SolidStateDrive: Simple solid state drive representation. """ - def read(self, lba, size): - return "Some data from sector {0} with size {1}".format(lba, size) + def read(self, lba: str, size: str) -> str: + return f"Some data from sector {lba} with size {size}" class ComputerFacade: diff --git a/patterns/structural/flyweight.py b/patterns/structural/flyweight.py index b9589319..fad17a8b 100644 --- a/patterns/structural/flyweight.py +++ b/patterns/structural/flyweight.py @@ -34,7 +34,7 @@ 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): # If the object exists in the pool - just return it @@ -53,7 +53,7 @@ def __new__(cls, value, suit): # self.value, self.suit = value, suit def __repr__(self): - return "".format(self.value, self.suit) + return f"" def main(): diff --git a/patterns/structural/front_controller.py b/patterns/structural/front_controller.py index 02d6aecb..92f58b21 100644 --- a/patterns/structural/front_controller.py +++ b/patterns/structural/front_controller.py @@ -5,38 +5,51 @@ 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: self.tablet_view.show_index_page() else: - print("cant dispatch the request") + print("Cannot dispatch the 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" @@ -69,7 +82,7 @@ def main(): Displaying tablet index page >>> front_controller.dispatch_request(Request('desktop')) - cant dispatch the request + Cannot dispatch the request >>> front_controller.dispatch_request('mobile') request must be a Request object diff --git a/patterns/structural/mvc.py b/patterns/structural/mvc.py index c57c9041..24b0017a 100644 --- a/patterns/structural/mvc.py +++ b/patterns/structural/mvc.py @@ -4,32 +4,37 @@ """ from abc import ABC, abstractmethod +from inspect import signature +from sys import argv +from typing import Any class Model(ABC): + """The Model is the data layer of the application.""" @abstractmethod - def __iter__(self): + 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): - return "{:.2f}".format(self) + def __str__(self) -> str: + return f"{self:.2f}" products = { "milk": {"price": Price(1.50), "quantity": 10}, @@ -39,11 +44,10 @@ def __str__(self): item_type = "product" - def __iter__(self): - for item in self.products: - yield item + 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: @@ -51,33 +55,37 @@ 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(): @@ -85,31 +93,58 @@ def show_item_information(self, item_type, item_name, item_info): printout += "\n" print(printout) - def item_not_found(self, item_type, item_name): - print('That {} "{}" does not exist in the records'.format(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 __init__(self, model_class: Model, view_class: View) -> None: + self.model: Model = model_class + self.view: View = view_class - def show_items(self): + 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() @@ -144,6 +179,26 @@ def main(): if __name__ == "__main__": - import doctest + 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/patterns/structural/proxy.py b/patterns/structural/proxy.py index 51edb4a7..3ef74ec0 100644 --- a/patterns/structural/proxy.py +++ b/patterns/structural/proxy.py @@ -56,7 +56,7 @@ def do_the_job(self, user: str) -> None: if user == "admin": self._real_subject.do_the_job(user) else: - print(f"[log] I can do the job just for `admins`.") + print("[log] I can do the job just for `admins`.") def client(job_doer: Union[RealSubject, Proxy], user: str) -> None: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..57f6fbe7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,98 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "patterns" +description = "A collection of design patterns and idioms in Python." +version = "0.1.0" +readme = "README.md" +requires-python = ">=3.9" +license = {text = "MIT"} +classifiers = [ + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] + +[project.optional-dependencies] +dev = ["pytest", "pytest-cov", "pytest-randomly", "flake8", "mypy", "coverage"] + +[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..4aaa81f2 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,9 @@ --e . - -pytest~=4.3.0 -pytest-cov~=2.6.0 -flake8~=3.7.0 -pytest-randomly~=3.1.0 +mypy +pyupgrade +pytest>=6.2.0 +pytest-cov>=2.11.0 +pytest-randomly>=3.1.0 +black>=25.1.0 +isort>=5.7.0 +flake8>=7.1.0 +tox>=4.25.0 \ No newline at end of file diff --git a/setup.py b/setup.py index b4218c1c..72bc2b46 100644 --- a/setup.py +++ b/setup.py @@ -5,10 +5,10 @@ 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", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ], ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/behavioral/test_publish_subscribe.py b/tests/behavioral/test_publish_subscribe.py index 86488343..c153da5b 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) 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/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_borg.py b/tests/creational/test_borg.py index 019bc5aa..182611c3 100644 --- a/tests/creational/test_borg.py +++ b/tests/creational/test_borg.py @@ -7,8 +7,13 @@ class BorgTest(unittest.TestCase): def setUp(self): self.b1 = Borg() self.b2 = Borg() + # creating YourBorg instance implicitly sets the state attribute + # for all borg instances. self.ib1 = YourBorg() + def tearDown(self): + self.ib1.state = "Init" + def test_initial_borg_state_shall_be_init(self): b = Borg() self.assertEqual(b.state, "Init") diff --git a/tests/creational/test_lazy.py b/tests/creational/test_lazy.py index 8d92be88..1b815b60 100644 --- a/tests/creational/test_lazy.py +++ b/tests/creational/test_lazy.py @@ -1,5 +1,3 @@ -from __future__ import print_function - import unittest from patterns.creational.lazy_evaluation import Person @@ -19,7 +17,7 @@ def test_relatives_not_in_properties(self): self.assertNotIn("relatives", self.John.__dict__) def test_extended_properties(self): - print(u"John's relatives: {0}".format(self.John.relatives)) + print(f"John's relatives: {self.John.relatives}") self.assertDictEqual( { "name": "John", @@ -31,7 +29,7 @@ def test_extended_properties(self): ) def test_relatives_after_access(self): - print(u"John's relatives: {0}".format(self.John.relatives)) + print(f"John's relatives: {self.John.relatives}") self.assertIn("relatives", self.John.__dict__) def test_parents(self): diff --git a/tests/creational/test_pool.py b/tests/creational/test_pool.py index 18f844e3..cd501db3 100644 --- a/tests/creational/test_pool.py +++ b/tests/creational/test_pool.py @@ -29,10 +29,9 @@ 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))""" + queue_object = QueueObject(queue, True) + print('Inside func: {}'.format(queue_object.object))""" def test_pool_behavior_with_single_object_inside(self): sample_queue = queue.Queue() diff --git a/tests/structural/test_proxy.py b/tests/structural/test_proxy.py index ff4dcfb1..3409bf0b 100644 --- a/tests/structural/test_proxy.py +++ b/tests/structural/test_proxy.py @@ -1,94 +1,37 @@ import sys import unittest from io import StringIO -from time import time -from patterns.structural.proxy import NoTalkProxy, Proxy +from patterns.structural.proxy import Proxy, client class ProxyTest(unittest.TestCase): @classmethod def setUpClass(cls): - """ Class scope setup. """ - cls.p = Proxy() + """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 - def test_sales_manager_shall_talk_through_proxy_with_delay(cls): - cls.p.busy = "No" - start_time = time() - cls.p.talk() - end_time = time() - execution_time = end_time - start_time - print_output = cls.output.getvalue() - expected_print_output = "Proxy checking for Sales Manager availability\n\ -Sales Manager ready to talk\n" - cls.assertEqual(print_output, expected_print_output) - expected_execution_time = 1 - cls.assertEqual(int(execution_time * 10), expected_execution_time) - - def test_sales_manager_shall_respond_through_proxy_with_delay(cls): - cls.p.busy = "Yes" - start_time = time() - cls.p.talk() - end_time = time() - execution_time = end_time - start_time - print_output = cls.output.getvalue() - expected_print_output = "Proxy checking for Sales Manager availability\n\ -Sales Manager is busy\n" - cls.assertEqual(print_output, expected_print_output) - expected_execution_time = 1 - cls.assertEqual(int(execution_time * 10), expected_execution_time) - - -class NoTalkProxyTest(unittest.TestCase): - @classmethod - def setUpClass(cls): - """ Class scope setup. """ - cls.ntp = NoTalkProxy() - - def setUp(cls): - """ 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. """ - cls.output.close() - sys.stdout = cls.saved_stdout - - def test_sales_manager_shall_not_talk_through_proxy_with_delay(cls): - cls.ntp.busy = "No" - start_time = time() - cls.ntp.talk() - end_time = time() - execution_time = end_time - start_time - print_output = cls.output.getvalue() - expected_print_output = "Proxy checking for Sales Manager availability\n\ -This Sales Manager will not talk to you whether he/she is busy or not\n" - cls.assertEqual(print_output, expected_print_output) - expected_execution_time = 1 - cls.assertEqual(int(execution_time * 10), expected_execution_time) - - def test_sales_manager_shall_not_respond_through_proxy_with_delay(cls): - cls.ntp.busy = "Yes" - start_time = time() - cls.ntp.talk() - end_time = time() - execution_time = end_time - start_time - print_output = cls.output.getvalue() - expected_print_output = "Proxy checking for Sales Manager availability\n\ -This Sales Manager will not talk to you whether he/she is busy or not\n" - cls.assertEqual(print_output, expected_print_output) - expected_execution_time = 1 - cls.assertEqual(int(execution_time * 10), expected_execution_time) + def test_do_the_job_for_admin_shall_pass(self): + client(self.proxy, "admin") + assert self.output.getvalue() == ( + "[log] Doing the job for admin is requested.\n" + "I am doing the job for admin\n" + ) + + def test_do_the_job_for_anonymous_shall_reject(self): + client(self.proxy, "anonymous") + assert self.output.getvalue() == ( + "[log] Doing the job for anonymous is requested.\n" + "[log] I can do the job just for `admins`.\n" + ) diff --git a/tests/test_hsm.py b/tests/test_hsm.py index b6ff3065..f42323a9 100644 --- a/tests/test_hsm.py +++ b/tests/test_hsm.py @@ -35,16 +35,16 @@ def test_calling_next_state_shall_change_current_state(cls): cls.hsm._current_state = Standby(cls.hsm) # initial state def test_method_perform_switchover_shall_return_specifically(cls): - """ Exemplary HierachicalStateMachine method test. - (here: _perform_switchover()). Add additional test cases... """ + """Exemplary HierachicalStateMachine method test. + (here: _perform_switchover()). Add additional test cases...""" return_value = cls.hsm._perform_switchover() expected_return_value = "perform switchover" cls.assertEqual(return_value, expected_return_value) class StandbyStateTest(unittest.TestCase): - """ Exemplary 2nd level state test class (here: Standby state). Add missing - state test classes... """ + """Exemplary 2nd level state test class (here: Standby state). Add missing + state test classes...""" @classmethod def setUpClass(cls):