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 4b654cff..288a94b0 100644 --- a/.github/workflows/lint_python.yml +++ b/.github/workflows/lint_python.yml @@ -2,12 +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@v3 - uses: actions/setup-python@v4 with: - python-version: 3.x - - shell: bash - name: Lint and test - run: ./lint.sh + 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 d272a2e1..4521242b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,8 @@ venv/ .vscode/ .python-version .coverage +.project +.pydevproject +/.pytest_cache/ build/ -dist/ \ No newline at end of file +dist/ diff --git a/.travis.yml b/.travis.yml index ab6ba6bf..dfeece70 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,11 @@ os: linux -dist: focal +dist: noble language: python jobs: include: - - python: "3.8" - env: TOXENV=py38 - - python: "3.9" - env: TOXENV=py39 + - python: "3.12" + env: TOXENV=py312 cache: - pip diff --git a/README.md b/README.md index 6e873aa0..4d12a2f1 100644 --- a/README.md +++ b/README.md @@ -50,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 | @@ -120,6 +121,26 @@ You can also run `flake8` or `pytest` commands manually. Examples can be found i 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). -## AI codebase assistance ## -The folks at Mutable.ai have built an AI assistant that is codebase-aware. Give it a try [![Mutable.ai Auto Wiki](https://img.shields.io/badge/Auto_Wiki-Mutable.ai-blue)](https://wiki.mutable.ai/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/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 91% rename from setup.cfg rename to config_backup/setup.cfg index eb556c0a..e109555b 100644 --- a/setup.cfg +++ b/config_backup/setup.cfg @@ -9,5 +9,5 @@ filterwarnings = ignore:.*test class 'TestRunner'.*:Warning [mypy] -python_version = 3.8 +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 1eca32ab..36e2577e 100644 --- a/tox.ini +++ b/config_backup/tox.ini @@ -1,18 +1,22 @@ [tox] -envlist = py38,py39,py310,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="./.*, venv" + 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 index a3ce9719..a7eebda1 100755 --- a/lint.sh +++ b/lint.sh @@ -2,13 +2,15 @@ pip install --upgrade pip pip install black codespell flake8 isort mypy pytest pyupgrade tox -black --check . -codespell --quiet-level=2 # --ignore-words-list="" --skip="" -flake8 . --count --show-source --statistics -isort --profile black . -tox pip install -e . -mypy --ignore-missing-imports . || true -pytest . -pytest --doctest-modules . || true -shopt -s globstar && pyupgrade --py37-plus **/*.py \ No newline at end of file + +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 ba85f500..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,25 +27,24 @@ def __init__(self, param: str) -> None: raise ValueError(f"Invalid Value for Param: {param}") @staticmethod - def _static_method_1() -> None: - print("executed method 1!") + def _static_method_1() -> str: + return "executed method 1!" @staticmethod - def _static_method_2() -> None: - print("executed method 2!") + def _static_method_2() -> str: + return "executed method 2!" - def main_method(self) -> None: + def main_method(self) -> str: """will execute either _static_method_1 or _static_method_2 depending on self.param value """ - self._static_method_choices[self.param]() + return self._static_method_choices[self.param]() # Alternative implementation for different levels of methods class CatalogInstance: """catalog of multiple methods that are executed depending on an init - parameter """ @@ -60,29 +57,28 @@ def __init__(self, param: str) -> None: else: raise ValueError(f"Invalid Value for Param: {param}") - def _instance_method_1(self) -> None: - print(f"Value {self.x1}") + def _instance_method_1(self) -> str: + return f"Value {self.x1}" - def _instance_method_2(self) -> None: - print(f"Value {self.x2}") + def _instance_method_2(self) -> str: + return f"Value {self.x2}" _instance_method_choices = { "param_value_1": _instance_method_1, "param_value_2": _instance_method_2, } - def main_method(self) -> None: + def main_method(self) -> str: """will execute either _instance_method_1 or _instance_method_2 depending on self.param value """ - self._instance_method_choices[self.param].__get__(self)() # type: ignore + return self._instance_method_choices[self.param].__get__(self)() # type: ignore # type ignore reason: https://github.com/python/mypy/issues/10206 class CatalogClass: """catalog of multiple class methods that are executed depending on an init - parameter """ @@ -97,30 +93,29 @@ def __init__(self, param: str) -> None: raise ValueError(f"Invalid Value for Param: {param}") @classmethod - def _class_method_1(cls) -> None: - print(f"Value {cls.x1}") + def _class_method_1(cls) -> str: + return f"Value {cls.x1}" @classmethod - def _class_method_2(cls) -> None: - print(f"Value {cls.x2}") + def _class_method_2(cls) -> str: + return f"Value {cls.x2}" _class_method_choices = { "param_value_1": _class_method_1, "param_value_2": _class_method_2, } - def main_method(self): + def main_method(self) -> str: """will execute either _class_method_1 or _class_method_2 depending on self.param value """ - self._class_method_choices[self.param].__get__(None, self.__class__)() # type: ignore + return self._class_method_choices[self.param].__get__(None, self.__class__)() # type: ignore # type ignore reason: https://github.com/python/mypy/issues/10206 class CatalogStatic: """catalog of multiple static methods that are executed depending on an init - parameter """ @@ -132,25 +127,25 @@ def __init__(self, param: str) -> None: raise ValueError(f"Invalid Value for Param: {param}") @staticmethod - def _static_method_1() -> None: - print("executed method 1!") + def _static_method_1() -> str: + return "executed method 1!" @staticmethod - def _static_method_2() -> None: - print("executed method 2!") + def _static_method_2() -> str: + return "executed method 2!" _static_method_choices = { "param_value_1": _static_method_1, "param_value_2": _static_method_2, } - def main_method(self) -> None: + def main_method(self) -> str: """will execute either _static_method_1 or _static_method_2 depending on self.param value """ - self._static_method_choices[self.param].__get__(None, self.__class__)() # type: ignore + return self._static_method_choices[self.param].__get__(None, self.__class__)() # type: ignore # type ignore reason: https://github.com/python/mypy/issues/10206 @@ -158,19 +153,19 @@ def main(): """ >>> test = Catalog('param_value_2') >>> test.main_method() - executed method 2! + 'executed method 2!' >>> test = CatalogInstance('param_value_1') >>> test.main_method() - Value x1 + 'Value x1' >>> test = CatalogClass('param_value_2') >>> test.main_method() - Value x2 + 'Value x2' >>> test = CatalogStatic('param_value_1') >>> test.main_method() - executed method 1! + 'executed method 1!' """ diff --git a/patterns/behavioral/mediator.py b/patterns/behavioral/mediator.py index e4b3c34a..6a59bbb6 100644 --- a/patterns/behavioral/mediator.py +++ b/patterns/behavioral/mediator.py @@ -15,7 +15,7 @@ class ChatRoom: """Mediator class""" def display_message(self, user: User, message: str) -> None: - print(f"[{user} says]: {message}") + return f"[{user} says]: {message}" class User: @@ -26,7 +26,7 @@ def __init__(self, name: str) -> None: self.chat_room = ChatRoom() def say(self, message: str) -> None: - self.chat_room.display_message(self, message) + return self.chat_room.display_message(self, message) def __str__(self) -> str: return self.name @@ -39,11 +39,11 @@ def main(): >>> ethan = User('Ethan') >>> molly.say("Hi Team! Meeting at 3 PM today.") - [Molly says]: Hi Team! Meeting at 3 PM today. + '[Molly says]: Hi Team! Meeting at 3 PM today.' >>> mark.say("Roger that!") - [Mark says]: Roger that! + '[Mark says]: Roger that!' >>> ethan.say("Alright.") - [Ethan says]: Alright. + '[Ethan says]: Alright.' """ diff --git a/patterns/behavioral/memento.py b/patterns/behavioral/memento.py index c1bc7f0b..4d072833 100644 --- a/patterns/behavioral/memento.py +++ b/patterns/behavioral/memento.py @@ -9,10 +9,10 @@ from typing import Callable, List -def memento(obj, deep=False): +def memento(obj: Any, deep: bool = False) -> Callable: state = deepcopy(obj.__dict__) if deep else copy(obj.__dict__) - def restore(): + def restore() -> None: obj.__dict__.clear() obj.__dict__.update(state) @@ -28,15 +28,15 @@ class Transaction: deep = False states: List[Callable[[], None]] = [] - def __init__(self, deep, *targets): + def __init__(self, deep: bool, *targets: Any) -> None: self.deep = deep self.targets = targets self.commit() - def commit(self): + def commit(self) -> None: self.states = [memento(target, self.deep) for target in self.targets] - def rollback(self): + def rollback(self) -> None: for a_state in self.states: a_state() @@ -47,27 +47,40 @@ def Transactional(method): :param method: The function to be decorated. """ - def transaction(obj, *args, **kwargs): - state = memento(obj) - try: - return method(obj, *args, **kwargs) - except Exception as e: - state() - raise e + + def __init__(self, method: Callable) -> None: + self.method = method + + def __get__(self, obj: Any, T: Type) -> Callable: + """ + A decorator that makes a function transactional. + + :param method: The function to be decorated. + """ + + def transaction(*args, **kwargs): + state = memento(obj) + try: + return self.method(obj, *args, **kwargs) + except Exception as e: + state() + raise e + return transaction + class NumObj: - def __init__(self, value): + def __init__(self, value: int) -> None: self.value = value - def __repr__(self): + def __repr__(self) -> str: return f"<{self.__class__.__name__}: {self.value!r}>" - def increment(self): + def increment(self) -> None: self.value += 1 @Transactional - def do_stuff(self): + def do_stuff(self) -> None: self.value = "1111" # <- invalid value self.increment() # <- will fail and rollback diff --git a/patterns/behavioral/observer.py b/patterns/behavioral/observer.py index 03d970ad..c9184be1 100644 --- a/patterns/behavioral/observer.py +++ b/patterns/behavioral/observer.py @@ -9,34 +9,59 @@ Flask Signals: https://flask.palletsprojects.com/en/1.1.x/signals/ """ -from __future__ import annotations - -from contextlib import suppress -from typing import Protocol +# observer.py +from __future__ import annotations +from typing import List -# define a generic observer type -class Observer(Protocol): +class Observer: def update(self, subject: Subject) -> None: + """ + Receive update from the subject. + + Args: + subject (Subject): The subject instance sending the update. + """ pass class Subject: + _observers: List[Observer] + def __init__(self) -> None: - self._observers: list[Observer] = [] + """ + Initialize the subject with an empty observer list. + """ + self._observers = [] def attach(self, observer: Observer) -> None: + """ + Attach an observer to the subject. + + Args: + observer (Observer): The observer instance to attach. + """ if observer not in self._observers: self._observers.append(observer) def detach(self, observer: Observer) -> None: - with suppress(ValueError): + """ + Detach an observer from the subject. + + Args: + observer (Observer): The observer instance to detach. + """ + try: self._observers.remove(observer) + except ValueError: + pass - def notify(self, modifier: Observer | None = None) -> None: + def notify(self) -> None: + """ + Notify all attached observers by calling their update method. + """ for observer in self._observers: - if modifier != observer: - observer.update(self) + observer.update(self) class Data(Subject): diff --git a/patterns/behavioral/registry.py b/patterns/behavioral/registry.py index d44a992e..60cae019 100644 --- a/patterns/behavioral/registry.py +++ b/patterns/behavioral/registry.py @@ -2,7 +2,6 @@ class RegistryHolder(type): - REGISTRY: Dict[str, "RegistryHolder"] = {} def __new__(cls, name, bases, attrs): diff --git a/patterns/behavioral/servant.py b/patterns/behavioral/servant.py 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 303ee513..10d22689 100644 --- a/patterns/behavioral/specification.py +++ b/patterns/behavioral/specification.py @@ -6,6 +6,7 @@ """ from abc import abstractmethod +from typing import Union class Specification: @@ -28,22 +29,22 @@ class CompositeSpecification(Specification): def is_satisfied_by(self, candidate): pass - def and_specification(self, candidate): + def and_specification(self, candidate: "Specification") -> "AndSpecification": return AndSpecification(self, candidate) - def or_specification(self, candidate): + def or_specification(self, candidate: "Specification") -> "OrSpecification": return OrSpecification(self, candidate) - def not_specification(self): + def not_specification(self) -> "NotSpecification": return NotSpecification(self) class AndSpecification(CompositeSpecification): - def __init__(self, one, other): + def __init__(self, one: "Specification", other: "Specification") -> None: self._one: Specification = one self._other: Specification = other - def is_satisfied_by(self, candidate): + def is_satisfied_by(self, candidate: Union["User", str]) -> bool: return bool( self._one.is_satisfied_by(candidate) and self._other.is_satisfied_by(candidate) @@ -51,11 +52,11 @@ def is_satisfied_by(self, candidate): class OrSpecification(CompositeSpecification): - def __init__(self, one, other): + def __init__(self, one: "Specification", other: "Specification") -> None: self._one: Specification = one self._other: Specification = other - def is_satisfied_by(self, candidate): + def is_satisfied_by(self, candidate: Union["User", str]): return bool( self._one.is_satisfied_by(candidate) or self._other.is_satisfied_by(candidate) @@ -63,25 +64,25 @@ def is_satisfied_by(self, candidate): class NotSpecification(CompositeSpecification): - def __init__(self, wrapped): + def __init__(self, wrapped: "Specification"): self._wrapped: Specification = wrapped - def is_satisfied_by(self, candidate): + def is_satisfied_by(self, candidate: Union["User", str]): return bool(not self._wrapped.is_satisfied_by(candidate)) class User: - def __init__(self, super_user=False): + def __init__(self, super_user: bool = False) -> None: self.super_user = super_user class UserSpecification(CompositeSpecification): - def is_satisfied_by(self, candidate): + def is_satisfied_by(self, candidate: Union["User", str]) -> bool: return isinstance(candidate, User) class SuperUserSpecification(CompositeSpecification): - def is_satisfied_by(self, candidate): + def is_satisfied_by(self, candidate: "User") -> bool: return getattr(candidate, "super_user", False) diff --git a/patterns/behavioral/visitor.py b/patterns/behavioral/visitor.py index 00d95248..aa10b58c 100644 --- a/patterns/behavioral/visitor.py +++ b/patterns/behavioral/visitor.py @@ -14,6 +14,7 @@ which is then being used e.g. in tools like `pyflakes`. - `Black` formatter tool implements it's own: https://github.com/ambv/black/blob/master/black.py#L718 """ +from typing import Union class Node: @@ -33,7 +34,7 @@ class C(A, B): class Visitor: - def visit(self, node, *args, **kwargs): + def visit(self, node: Union[A, C, B], *args, **kwargs) -> None: meth = None for cls in node.__class__.__mro__: meth_name = "visit_" + cls.__name__ @@ -45,10 +46,10 @@ def visit(self, node, *args, **kwargs): meth = self.generic_visit return meth(node, *args, **kwargs) - def generic_visit(self, node, *args, **kwargs): + def generic_visit(self, node: A, *args, **kwargs) -> None: print("generic_visit " + node.__class__.__name__) - def visit_B(self, node, *args, **kwargs): + def visit_B(self, node: Union[C, B], *args, **kwargs) -> None: print("visit_B " + node.__class__.__name__) @@ -58,13 +59,13 @@ def main(): >>> visitor = Visitor() >>> visitor.visit(a) - generic_visit A + 'generic_visit A' >>> visitor.visit(b) - visit_B B + 'visit_B B' >>> visitor.visit(c) - visit_B C + 'visit_B C' """ diff --git a/patterns/creational/abstract_factory.py b/patterns/creational/abstract_factory.py index 0ec49bbf..15e5d67f 100644 --- a/patterns/creational/abstract_factory.py +++ b/patterns/creational/abstract_factory.py @@ -90,6 +90,9 @@ def main() -> None: if __name__ == "__main__": + animals = [Dog, Cat] + random_animal: Type[Pet] = random.choice(animals) + shop = PetShop(random_animal) import doctest diff --git a/patterns/creational/builder.py b/patterns/creational/builder.py index 22383923..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,16 +21,15 @@ 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) -> None: diff --git a/patterns/creational/factory.py b/patterns/creational/factory.py index 3ef2d2a8..f75bb2b2 100644 --- a/patterns/creational/factory.py +++ b/patterns/creational/factory.py @@ -26,8 +26,7 @@ class Localizer(Protocol): - def localize(self, msg: str) -> str: - pass + def localize(self, msg: str) -> str: ... class GreekLocalizer: @@ -55,7 +54,8 @@ def get_localizer(language: str = "English") -> Localizer: "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 b56daf0c..1f8db6bd 100644 --- a/patterns/creational/lazy_evaluation.py +++ b/patterns/creational/lazy_evaluation.py @@ -20,14 +20,15 @@ """ import functools +from typing import Callable, Type class lazy_property: - def __init__(self, function): + def __init__(self, function: Callable) -> None: self.function = function functools.update_wrapper(self, function) - def __get__(self, obj, type_): + def __get__(self, obj: "Person", type_: Type["Person"]) -> str: if obj is None: return self val = self.function(obj) @@ -35,7 +36,7 @@ def __get__(self, obj, type_): return val -def lazy_property2(fn): +def lazy_property2(fn: Callable) -> property: """ A lazy property decorator. @@ -54,19 +55,19 @@ def _lazy_property(self): class Person: - def __init__(self, name, occupation): + def __init__(self, name: str, occupation: str) -> None: self.name = name self.occupation = occupation self.call_count2 = 0 @lazy_property - def relatives(self): + def relatives(self) -> str: # Get all relatives, let's assume that it costs much time. relatives = "Many relatives." return relatives @lazy_property2 - def parents(self): + def parents(self) -> str: self.call_count2 += 1 return "Father and mother" diff --git a/patterns/creational/pool.py b/patterns/creational/pool.py index 1d70ea69..02f61791 100644 --- a/patterns/creational/pool.py +++ b/patterns/creational/pool.py @@ -27,24 +27,32 @@ *TL;DR Stores a set of initialized objects kept ready to use. """ +from queue import Queue +from types import TracebackType +from typing import Union class ObjectPool: - def __init__(self, queue, auto_get=False): + def __init__(self, queue: Queue, auto_get: bool = False) -> None: self._queue = queue self.item = self._queue.get() if auto_get else None - def __enter__(self): + def __enter__(self) -> str: if self.item is None: self.item = self._queue.get() return self.item - def __exit__(self, Type, value, traceback): + def __exit__( + self, + Type: Union[type[BaseException], None], + value: Union[BaseException, None], + traceback: Union[TracebackType, None], + ) -> None: if self.item is not None: self._queue.put(self.item) self.item = None - def __del__(self): + def __del__(self) -> None: if self.item is not None: self._queue.put(self.item) self.item = None diff --git a/patterns/fundamental/delegation_pattern.py b/patterns/fundamental/delegation_pattern.py index 34e1071f..f7a7c2f5 100644 --- a/patterns/fundamental/delegation_pattern.py +++ b/patterns/fundamental/delegation_pattern.py @@ -19,13 +19,15 @@ 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) -> None: @@ -47,8 +49,8 @@ class Delegate: 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 cd2eb7ab..0269a3e7 100644 --- a/patterns/other/blackboard.py +++ b/patterns/other/blackboard.py @@ -9,15 +9,32 @@ https://en.wikipedia.org/wiki/Blackboard_system """ -from __future__ import annotations - -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: + """The blackboard system that holds the common state.""" + def __init__(self) -> None: - self.experts = [] + self.experts: list = [] self.common_state = { "problems": 0, "suggestions": 0, @@ -30,6 +47,8 @@ def add_expert(self, expert: AbstractExpert) -> None: class Controller: + """The controller that manages the blackboard system.""" + def __init__(self, blackboard: Blackboard) -> None: self.blackboard = blackboard @@ -45,21 +64,12 @@ def run_loop(self): return self.blackboard.common_state["contributions"] -class AbstractExpert(metaclass=abc.ABCMeta): - def __init__(self, blackboard: Blackboard) -> None: - 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) -> bool: return True @@ -72,6 +82,11 @@ def contribute(self) -> None: class Scientist(AbstractExpert): + """Concrete class for a scientist expert.""" + + def __init__(self, blackboard) -> None: + super().__init__(blackboard) + @property def is_eager_to_contribute(self) -> int: return random.randint(0, 1) @@ -84,6 +99,9 @@ def contribute(self) -> None: class Professor(AbstractExpert): + def __init__(self, blackboard) -> None: + super().__init__(blackboard) + @property def is_eager_to_contribute(self) -> bool: return True if self.blackboard.common_state["problems"] > 100 else False @@ -107,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 262a6f08..6e3cdffb 100644 --- a/patterns/other/graph_search.py +++ b/patterns/other/graph_search.py @@ -1,3 +1,6 @@ +from typing import Any, Dict, List, Optional, Union + + class GraphSearch: """Graph search emulation in python, from source http://www.python.org/doc/essays/graphs/ @@ -5,10 +8,12 @@ class GraphSearch: dfs stands for Depth First Search bfs stands for Breadth First Search""" - def __init__(self, graph): + def __init__(self, graph: Dict[str, List[str]]) -> None: self.graph = graph - def find_path_dfs(self, start, end, path=None): + def find_path_dfs( + self, start: str, end: str, path: Optional[List[str]] = None + ) -> Optional[List[str]]: path = path or [] path.append(start) @@ -20,7 +25,9 @@ def find_path_dfs(self, start, end, path=None): if newpath: return newpath - def find_all_paths_dfs(self, start, end, path=None): + def find_all_paths_dfs( + self, start: str, end: str, path: Optional[List[str]] = None + ) -> List[Union[List[str], Any]]: path = path or [] path.append(start) if start == end: @@ -32,7 +39,9 @@ def find_all_paths_dfs(self, start, end, path=None): paths.extend(newpaths) return paths - def find_shortest_path_dfs(self, start, end, path=None): + def find_shortest_path_dfs( + self, start: str, end: str, path: Optional[List[str]] = None + ) -> Optional[List[str]]: path = path or [] path.append(start) @@ -47,7 +56,7 @@ def find_shortest_path_dfs(self, start, end, path=None): shortest = newpath return shortest - def find_shortest_path_bfs(self, start, end): + def find_shortest_path_bfs(self, start: str, end: str) -> Optional[List[str]]: """ Finds the shortest path between two nodes in a graph using breadth-first search. diff --git a/patterns/structural/3-tier.py b/patterns/structural/3-tier.py index ecc04243..287badaf 100644 --- a/patterns/structural/3-tier.py +++ b/patterns/structural/3-tier.py @@ -16,7 +16,6 @@ class Data: } def __get__(self, obj, klas): - print("(Fetching from Data Store)") return {"products": self.products} diff --git a/patterns/structural/bridge.py b/patterns/structural/bridge.py index feddb675..1575cb53 100644 --- a/patterns/structural/bridge.py +++ b/patterns/structural/bridge.py @@ -5,34 +5,37 @@ *TL;DR Decouples an abstraction from its implementation. """ +from typing import Union # ConcreteImplementor 1/2 class DrawingAPI1: - def draw_circle(self, x, y, radius): + def draw_circle(self, x: int, y: int, radius: float) -> None: print(f"API1.circle at {x}:{y} radius {radius}") # ConcreteImplementor 2/2 class DrawingAPI2: - def draw_circle(self, x, y, radius): + def draw_circle(self, x: int, y: int, radius: float) -> None: print(f"API2.circle at {x}:{y} radius {radius}") # Refined Abstraction class CircleShape: - def __init__(self, x, y, radius, drawing_api): + def __init__( + self, x: int, y: int, radius: int, drawing_api: Union[DrawingAPI2, DrawingAPI1] + ) -> None: self._x = x self._y = y self._radius = radius self._drawing_api = drawing_api # low-level i.e. Implementation specific - def draw(self): + def draw(self) -> None: self._drawing_api.draw_circle(self._x, self._y, self._radius) # high-level i.e. Abstraction specific - def scale(self, pct): + def scale(self, pct: float) -> None: self._radius *= pct diff --git a/patterns/structural/flyweight.py b/patterns/structural/flyweight.py index fad17a8b..68b6f43c 100644 --- a/patterns/structural/flyweight.py +++ b/patterns/structural/flyweight.py @@ -36,7 +36,7 @@ class Card: # when there are no other references to it. _pool: weakref.WeakValueDictionary = weakref.WeakValueDictionary() - def __new__(cls, value, suit): + def __new__(cls, value: str, suit: str): # If the object exists in the pool - just return it obj = cls._pool.get(value + suit) # otherwise - create new one (and add it to the pool) @@ -52,7 +52,7 @@ def __new__(cls, value, suit): # def __init__(self, value, suit): # self.value, self.suit = value, suit - def __repr__(self): + def __repr__(self) -> str: return f"" diff --git a/patterns/structural/mvc.py b/patterns/structural/mvc.py index 3f7dc315..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,35 +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() @@ -147,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 0de4748b..1194272a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,8 +1,8 @@ --e . - -pytest~=6.2.0 -pytest-cov~=2.11.0 -pytest-randomly~=3.1.0 -black>=20.8b1 -isort~=5.7.0 -flake8~=3.8.0 \ No newline at end of file +flake8 +black +isort +pytest +pytest-randomly +mypy +pyupgrade +tox diff --git a/setup.py b/setup.py deleted file mode 100644 index ec2528f4..00000000 --- a/setup.py +++ /dev/null @@ -1,17 +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", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - ], -) 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 c153da5b..8bb7130c 100644 --- a/tests/behavioral/test_publish_subscribe.py +++ b/tests/behavioral/test_publish_subscribe.py @@ -48,9 +48,10 @@ def test_provider_shall_update_affected_subscribers_with_published_subscription( sub2 = Subscriber("sub 2 name", pro) sub2.subscribe("sub 2 msg 1") sub2.subscribe("sub 2 msg 2") - with patch.object(sub1, "run") as mock_subscriber1_run, patch.object( - sub2, "run" - ) as mock_subscriber2_run: + with ( + patch.object(sub1, "run") as mock_subscriber1_run, + patch.object(sub2, "run") as mock_subscriber2_run, + ): pro.update() cls.assertEqual(mock_subscriber1_run.call_count, 0) cls.assertEqual(mock_subscriber2_run.call_count, 0) @@ -58,9 +59,10 @@ def test_provider_shall_update_affected_subscribers_with_published_subscription( pub.publish("sub 1 msg 2") pub.publish("sub 2 msg 1") pub.publish("sub 2 msg 2") - with patch.object(sub1, "run") as mock_subscriber1_run, patch.object( - sub2, "run" - ) as mock_subscriber2_run: + with ( + patch.object(sub1, "run") as mock_subscriber1_run, + patch.object(sub2, "run") as mock_subscriber2_run, + ): pro.update() expected_sub1_calls = [call("sub 1 msg 1"), call("sub 1 msg 2")] mock_subscriber1_run.assert_has_calls(expected_sub1_calls) diff --git a/tests/behavioral/test_servant.py b/tests/behavioral/test_servant.py 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_visitor.py b/tests/behavioral/test_visitor.py new file mode 100644 index 00000000..31d230de --- /dev/null +++ b/tests/behavioral/test_visitor.py @@ -0,0 +1,22 @@ +import pytest + +from patterns.behavioral.visitor import A, B, C, Visitor + +@pytest.fixture +def visitor(): + return Visitor() + +def test_visiting_generic_node(visitor): + a = A() + token = visitor.visit(a) + assert token == 'generic_visit A', "The expected generic object was not called" + +def test_visiting_specific_nodes(visitor): + b = B() + token = visitor.visit(b) + assert token == 'visit_B B', "The expected specific object was not called" + +def test_visiting_inherited_nodes(visitor): + c = C() + token = visitor.visit(c) + assert token == 'visit_B C', "The expected inherited object was not called" diff --git a/tests/structural/test_bridge.py b/tests/structural/test_bridge.py index 7fa8a278..6665f327 100644 --- a/tests/structural/test_bridge.py +++ b/tests/structural/test_bridge.py @@ -8,9 +8,10 @@ class BridgeTest(unittest.TestCase): def test_bridge_shall_draw_with_concrete_api_implementation(cls): ci1 = DrawingAPI1() ci2 = DrawingAPI2() - with patch.object(ci1, "draw_circle") as mock_ci1_draw_circle, patch.object( - ci2, "draw_circle" - ) as mock_ci2_draw_circle: + with ( + patch.object(ci1, "draw_circle") as mock_ci1_draw_circle, + patch.object(ci2, "draw_circle") as mock_ci2_draw_circle, + ): sh1 = CircleShape(1, 2, 3, ci1) sh1.draw() cls.assertEqual(mock_ci1_draw_circle.call_count, 1) @@ -33,9 +34,10 @@ def test_bridge_shall_scale_both_api_circles_with_own_implementation(cls): sh2.scale(SCALE_FACTOR) cls.assertEqual(sh1._radius, EXPECTED_CIRCLE1_RADIUS) cls.assertEqual(sh2._radius, EXPECTED_CIRCLE2_RADIUS) - with patch.object(sh1, "scale") as mock_sh1_scale_circle, patch.object( - sh2, "scale" - ) as mock_sh2_scale_circle: + with ( + patch.object(sh1, "scale") as mock_sh1_scale_circle, + patch.object(sh2, "scale") as mock_sh2_scale_circle, + ): sh1.scale(2) sh2.scale(2) cls.assertEqual(mock_sh1_scale_circle.call_count, 1) diff --git a/tests/test_hsm.py b/tests/test_hsm.py index f42323a9..5b49fb97 100644 --- a/tests/test_hsm.py +++ b/tests/test_hsm.py @@ -58,15 +58,14 @@ def test_given_standby_on_message_switchover_shall_set_active(cls): cls.assertEqual(isinstance(cls.hsm._current_state, Active), True) def test_given_standby_on_message_switchover_shall_call_hsm_methods(cls): - with patch.object( - cls.hsm, "_perform_switchover" - ) as mock_perform_switchover, patch.object( - cls.hsm, "_check_mate_status" - ) as mock_check_mate_status, patch.object( - cls.hsm, "_send_switchover_response" - ) as mock_send_switchover_response, patch.object( - cls.hsm, "_next_state" - ) as mock_next_state: + with ( + patch.object(cls.hsm, "_perform_switchover") as mock_perform_switchover, + patch.object(cls.hsm, "_check_mate_status") as mock_check_mate_status, + patch.object( + cls.hsm, "_send_switchover_response" + ) as mock_send_switchover_response, + patch.object(cls.hsm, "_next_state") as mock_next_state, + ): cls.hsm.on_message("switchover") cls.assertEqual(mock_perform_switchover.call_count, 1) cls.assertEqual(mock_check_mate_status.call_count, 1)