├── .codecov.yml ├── .editorconfig ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── .isort.cfg ├── .pre-commit-config.yaml ├── .project ├── .pydevproject ├── .readthedocs.yml ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── LICENSE ├── README.rst ├── docs ├── api.rst ├── callbacks.rst ├── changelog.rst ├── conf.py ├── contributing.rst ├── img │ └── logo.png ├── index.rst ├── installation.rst ├── interfaces.rst ├── readme.rst └── requirements.txt ├── environment.devenv.yml ├── mypy.ini ├── pyproject.toml ├── setup.py ├── sonar-project.properties ├── src └── oop_ext │ ├── __init__.py │ ├── _type_checker_fixture.py │ ├── conftest.py │ ├── foundation │ ├── __init__.py │ ├── _tests │ │ ├── __init__.py │ │ ├── test_cached_method.py │ │ ├── test_decorators.py │ │ ├── test_immutable.py │ │ ├── test_is_frozen.py │ │ ├── test_odict.py │ │ ├── test_singleton.py │ │ ├── test_types.py │ │ └── test_weak_ref.py │ ├── cached_method.py │ ├── callback │ │ ├── __init__.py │ │ ├── _callback.py │ │ ├── _callbacks.py │ │ ├── _priority_callback.py │ │ ├── _shortcuts.py │ │ ├── _tests │ │ │ ├── __init__.py │ │ │ ├── test_callback.py │ │ │ ├── test_priority_callback.py │ │ │ ├── test_single_call_callback.py │ │ │ └── test_typed_callback.py │ │ ├── _typed_callback.py │ │ └── single_call_callback.py │ ├── compat.py │ ├── decorators.py │ ├── exceptions.py │ ├── immutable.py │ ├── is_frozen.py │ ├── odict.py │ ├── singleton.py │ ├── types_.py │ └── weak_ref.py │ ├── interface │ ├── __init__.py │ ├── _adaptable_interface.py │ ├── _interface.py │ └── _tests │ │ ├── __init__.py │ │ └── test_interface.py │ └── py.typed └── tox.ini /.codecov.yml: -------------------------------------------------------------------------------- 1 | 2 | codecov: 3 | notify: 4 | require_ci_to_pass: yes 5 | 6 | coverage: 7 | precision: 2 8 | round: down 9 | range: "70...100" 10 | 11 | status: 12 | project: yes 13 | patch: yes 14 | changes: no 15 | 16 | parsers: 17 | gcov: 18 | branch_detection: 19 | conditional: yes 20 | loop: yes 21 | method: no 22 | macro: no 23 | 24 | comment: 25 | layout: "header, diff" 26 | behavior: default 27 | require_changes: no 28 | 29 | ignore: 30 | - "setup.py" 31 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # Source code 5 | *.c text 6 | *.cpp text 7 | *.h text 8 | *.hpp text 9 | *.cxx text 10 | *.hxx text 11 | *.py text 12 | *.pyx text 13 | *.pxd text 14 | # Configuration files and scripts 15 | *.sh text eol=lf 16 | *.bat text eol=crlf 17 | *.cmd text eol=crlf 18 | *.cfg text 19 | *.csv text eol=lf 20 | *.cmake text 21 | *.json text 22 | *.jinja2 text 23 | *.yaml text 24 | *.yml text 25 | *.xml text 26 | *.md text 27 | *.txt text 28 | *.ini text 29 | *.ps1 text 30 | .coveragerc text 31 | .gitignore text 32 | .mu_repo text 33 | .cproject text 34 | .project text 35 | .pydevproject text 36 | # Documentation 37 | *.css text 38 | *.html text 39 | *.rst text 40 | *.in text 41 | LICENSE text 42 | Doxyfile text 43 | # Binary files 44 | *.png binary 45 | *.jpg binary 46 | *.jpeg binary 47 | *.db binary 48 | *.pickle binary 49 | *.h5 binary 50 | *.hdf binary 51 | *.xls binary 52 | *.xlsx binary 53 | *.db binary 54 | *.p binary 55 | *.pkl binary 56 | *.pyc binary 57 | *.pyd binary 58 | *.pyo binary 59 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Keep GitHub Actions up to date with GitHub's Dependabot... 2 | # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot 3 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem 4 | version: 2 5 | updates: 6 | - package-ecosystem: github-actions 7 | directory: / 8 | groups: 9 | github-actions: 10 | patterns: 11 | - "*" # Group all Actions updates into a single larger pull request 12 | schedule: 13 | interval: weekly 14 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - "v[0-9]+.[0-9]+.[0-9]+" 9 | 10 | pull_request: 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ${{ matrix.os }} 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | python: ["3.10", "3.11", "3.12", "3.13"] 21 | os: [ubuntu-latest, windows-latest] 22 | 23 | steps: 24 | - uses: actions/checkout@v4.2.2 25 | - name: Set up Python 26 | uses: actions/setup-python@v5.6.0 27 | with: 28 | python-version: ${{ matrix.python }} 29 | - name: Install tox 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install tox 33 | - name: Test 34 | run: | 35 | tox -e py 36 | - name: Upload coverage reports to Codecov 37 | uses: codecov/codecov-action@v5.4.3 38 | with: 39 | token: ${{ secrets.CODECOV_TOKEN }} 40 | fail_ci_if_error: true 41 | 42 | deploy: 43 | 44 | if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') 45 | 46 | runs-on: ubuntu-latest 47 | 48 | needs: build 49 | 50 | steps: 51 | - uses: actions/checkout@v4.2.2 52 | - name: Set up Python 53 | uses: actions/setup-python@v5.6.0 54 | with: 55 | python-version: "3.x" 56 | - name: Install wheel 57 | run: | 58 | python -m pip install --upgrade pip 59 | pip install build 60 | - name: Build package 61 | run: | 62 | python -m build 63 | - name: Publish package to PyPI 64 | uses: pypa/gh-action-pypi-publish@v1.12.4 65 | with: 66 | user: __token__ 67 | password: ${{ secrets.pypi_token }} 68 | attestations: true 69 | - name: GitHub Release 70 | uses: softprops/action-gh-release@v2.2.2 71 | with: 72 | files: dist/* 73 | 74 | sonarcloud: 75 | 76 | runs-on: ubuntu-latest 77 | 78 | steps: 79 | - uses: actions/checkout@v4.2.2 80 | with: 81 | # Disabling shallow clone is recommended for improving relevancy of reporting 82 | fetch-depth: 0 83 | - name: SonarCloud Scan 84 | uses: sonarsource/sonarcloud-github-action@master 85 | env: 86 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 87 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | .*cache 4 | *.egg-info 5 | .installed.cfg 6 | *.egg 7 | .~* 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | env/ 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # pyenv 80 | .python-version 81 | 82 | # celery beat schedule file 83 | celerybeat-schedule 84 | 85 | # SageMath parsed files 86 | *.sage.py 87 | 88 | # dotenv 89 | .env 90 | 91 | # virtualenv 92 | .venv 93 | venv/ 94 | ENV/ 95 | 96 | # conda env 97 | environment.yml 98 | 99 | # Spyder project settings 100 | .spyderproject 101 | .spyproject 102 | 103 | # Rope project settings 104 | .ropeproject 105 | 106 | # Idea settings 107 | .idea/ 108 | .settings/ 109 | 110 | # VSCode local history 111 | .history 112 | 113 | # mkdocs documentation 114 | /site 115 | 116 | # mypy 117 | .mypy_cache/ 118 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | line_length=100 3 | multi_line_output=4 4 | use_parentheses=true 5 | known_standard_library=Bastion,CGIHTTPServer,DocXMLRPCServer,HTMLParser,MimeWriter,SimpleHTTPServer,UserDict,UserList,UserString,aifc,antigravity,ast,audiodev,bdb,binhex,cgi,chunk,code,codeop,colorsys,cookielib,copy_reg,dummy_thread,dummy_threading,formatter,fpformat,ftplib,genericpath,htmlentitydefs,htmllib,httplib,ihooks,imghdr,imputil,keyword,macpath,macurl2path,mailcap,markupbase,md5,mimetools,mimetypes,mimify,modulefinder,multifile,mutex,netrc,new,nntplib,ntpath,nturl2path,numbers,opcode,os2emxpath,pickletools,popen2,poplib,posixfile,posixpath,pty,py_compile,quopri,repr,rexec,rfc822,runpy,sets,sgmllib,sha,sndhdr,sre,sre_compile,sre_constants,sre_parse,ssl,stat,statvfs,stringold,stringprep,sunau,sunaudio,symbol,symtable,telnetlib,this,toaiff,token,tokenize,tty,types,typing,user,uu,wave,xdrlib,xmllib 6 | known_third_party=six,six.moves,sip 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 25.1.0 4 | hooks: 5 | - id: black 6 | args: [--safe, --quiet] 7 | language_version: python3 8 | - repo: https://github.com/asottile/blacken-docs 9 | rev: 1.19.1 10 | hooks: 11 | - id: blacken-docs 12 | additional_dependencies: [black==24.10.0] 13 | - repo: https://github.com/pre-commit/pre-commit-hooks 14 | rev: v5.0.0 15 | hooks: 16 | - id: trailing-whitespace 17 | - id: end-of-file-fixer 18 | - id: debug-statements 19 | - repo: https://github.com/pycqa/isort 20 | rev: 6.0.1 21 | hooks: 22 | - id: isort 23 | name: isort 24 | args: ["--force-single-line", "--line-length=100", "--profile=black"] 25 | - repo: local 26 | hooks: 27 | - id: rst 28 | name: rst 29 | entry: rst-lint --encoding utf-8 30 | files: ^(CHANGELOG.rst|README.rst)$ 31 | language: python 32 | additional_dependencies: [pygments, restructuredtext_lint] 33 | - repo: https://github.com/pre-commit/mirrors-mypy 34 | rev: v1.16.0 35 | hooks: 36 | - id: mypy 37 | files: ^(src/) 38 | args: [] 39 | additional_dependencies: [types-attrs] 40 | -------------------------------------------------------------------------------- /.project: -------------------------------------------------------------------------------- 1 | 2 | 3 | oop-ext 4 | 5 | 6 | 7 | 8 | 9 | org.python.pydev.PyDevBuilder 10 | 11 | 12 | 13 | 14 | 15 | org.python.pydev.pythonNature 16 | 17 | 18 | -------------------------------------------------------------------------------- /.pydevproject: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | /${PROJECT_DIR_NAME}/src 5 | 6 | python interpreter 7 | Default 8 | 9 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.11" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | python: 12 | install: 13 | - path: . 14 | - requirements: docs/requirements.txt 15 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 2.2.0 2 | ----- 3 | 4 | **Release**: 2024-10-24 5 | 6 | * ``PriorityCallback`` now has type checking support, similar to ``Callback``, with type checked variants: ``PriorityCallback0``, ``PriorityCallback1``, etc (`#128`_). 7 | * ``UnregisterContext`` is now public, meant to be used in type annotations. 8 | * Python 3.10+ is now required. 9 | 10 | .. _#128: https://github.com/ESSS/oop-ext/pull/128 11 | 12 | 2.1.0 (2021-03-19) 13 | ------------------ 14 | 15 | * #48: New type-checker friendly ``proxy = GetProxy(I, obj)`` function as an alternative to ``proxy = I(obj)``. The 16 | latter is not accepted by type checkers in general because interfaces are protocols, which can't be instantiated. 17 | 18 | Also fixed a type-checking error with ``AsssertImplements``:: 19 | 20 | Only concrete class can be given where "Type[Interface]" is expected 21 | 22 | This happens due to `python/mypy#5374 `__. 23 | 24 | 25 | 2.0.0 (2021-03-10) 26 | ------------------ 27 | 28 | * #47: Interfaces no longer check type annotations at all. 29 | 30 | It was supported initially, but in practice 31 | this feature has shown up to be an impediment to adopting type annotations incrementally, as it 32 | discourages adding type annotations to improve existing interfaces, or annotating 33 | existing implementations without having to update the interface (and all other implementations 34 | by consequence). 35 | 36 | It was decided to let the static type checker correctly deal with matching type annotations, as 37 | it can do so more accurately than ``oop-ext`` did before. 38 | 39 | 1.2.0 (2021-03-09) 40 | ------------------ 41 | 42 | * #43: Fix support for type annotated ``Attribute`` and ``ReadOnlyAttribute``: 43 | 44 | .. code-block:: python 45 | 46 | class IFoo(Interface): 47 | value: int = Attribute(int) 48 | 49 | 1.1.2 (2021-02-23) 50 | ------------------ 51 | 52 | * #41: Fix regression introduced in ``1.1.0`` where installing a callback using 53 | ``callback.After`` or ``callback.Before`` would make a method no longer compliant with 54 | the signature required by its interface. 55 | 56 | 1.1.1 (2021-02-23) 57 | ------------------ 58 | 59 | * #38: Reintroduce ``extra_args`` argument to ``Callback._GetKey``, so subclasses can make use 60 | of it. 61 | 62 | * #36: Fix regression introduced in ``1.1.0`` where ``Abstract`` and ``Implements`` decorators 63 | could no longer be used in interfaces implementations. 64 | 65 | 1.1.0 (2021-02-19) 66 | ------------------ 67 | 68 | * #25: ``oop-ext`` now includes inline type annotations and exposes them to user programs. 69 | 70 | If you are running a type checker such as mypy on your tests, you may start noticing type errors indicating incorrect usage. 71 | If you run into an error that you believe to be incorrect, please let us know in an issue. 72 | 73 | The types were developed against ``mypy`` version 0.800. 74 | 75 | * #26: New type-checked ``Callback`` variants, ``Callback0``, ``Callback1``, ``Callback2``, etc, providing 76 | type checking for all operations(calling, ``Register``, etc) at nearly zero runtime cost. 77 | 78 | Example: 79 | 80 | .. code-block:: python 81 | 82 | from oop_ext.foundation.callback import Callback2 83 | 84 | 85 | def changed(x: int, v: float) -> None: ... 86 | 87 | 88 | on_changed = Callback2[int, float]() 89 | on_changed(10, 5.25) 90 | 91 | 92 | * Fixed ``Callbacks.Before`` and ``Callbacks.After`` signatures: previously their signature conveyed 93 | that they supported multiple callbacks, but it was a mistake which would break callers because 94 | every parameter after the 2nd would be considered the ``sender_as_parameter`` parameter, which 95 | was forwarded to ``After`` and ``Before`` functions of the ``_shortcuts.py`` 96 | module. 97 | 98 | 1.0.0 (2020-10-01) 99 | ------------------ 100 | 101 | * ``Callbacks`` can be used as context manager, which provides a ``Register(callback, function)``, 102 | which automatically unregisters all functions when the context manager ends. 103 | 104 | * ``Callback.Register(function)`` now returns an object with a ``Unregister()`` method, which 105 | can be used to undo the register call. 106 | 107 | 0.6.0 (2020-01-31) 108 | ================== 109 | 110 | * Change back the default value of ``requires_declaration`` to ``True`` and fix an error (#22) where the cache wasn't properly cleared. 111 | 112 | 0.5.1 (2019-12-20) 113 | ------------------ 114 | 115 | * Fixes an issue (#20) where mocked `classmethods` weren't considered a valid method during internal checks. 116 | 117 | 0.5.0 (2019-12-12) 118 | ------------------ 119 | 120 | * Add optional argument ``requires_declaration`` so users can decide whether or not ``@ImplementsInterface`` declarations are necessary. 121 | 122 | 0.4.0 (2019-12-03) 123 | ------------------ 124 | 125 | * Implementations no longer need to explicitly declare that they declare an interface with ``@ImplementsInterface``: the check is done implicitly (and cached) by `AssertImplements` and equivalent functions. 126 | 127 | 0.3.2 (2019-08-22) 128 | ------------------ 129 | 130 | * Interface and implementation methods can no longer contain mutable defaults, as this is considered 131 | a bad practice in general. 132 | 133 | * ``Null`` instances are now hashable. 134 | 135 | 136 | 0.3.1 (2019-08-16) 137 | ------------------ 138 | 139 | * Fix mismatching signatures when creating "interface stubs" for instances: 140 | 141 | .. code-block:: python 142 | 143 | foo = IFoo(Foo()) 144 | 145 | 146 | 0.3.0 (2019-08-08) 147 | ------------------ 148 | 149 | * Interfaces now support keyword-only arguments. 150 | 151 | 0.2.4 (2019-03-22) 152 | ------------------ 153 | 154 | * Remove ``FunctionNotRegisteredError`` exception, which has not been in use for a few years. 155 | 156 | 157 | 0.2.3 (2019-03-22) 158 | ------------------ 159 | 160 | * Fix issues of ignored exception on nested callback. 161 | 162 | 163 | 0.2.1 (2019-03-14) 164 | ------------------ 165 | 166 | * Fix issues and remove obsolete code. 167 | 168 | 169 | 0.1.8 (2019-03-12) 170 | ------------------ 171 | 172 | * First release on PyPI. 173 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | 11 | Get Started! 12 | ------------ 13 | 14 | Ready to contribute? Here's how to set up `oop_ext` for local development. 15 | 16 | #. Fork the `oop_ext` repo on GitHub. 17 | #. Clone your fork locally:: 18 | 19 | $ git clone git@github.com:your_github_username_here/oop-ext.git 20 | 21 | #. Create a virtual environment and activate it:: 22 | 23 | $ python -m virtualenv .env 24 | 25 | $ .env\Scripts\activate # For Windows 26 | $ source .env/bin/activate # For Linux 27 | 28 | #. Install the development dependencies for setting up your fork for local development:: 29 | 30 | $ cd oop_ext/ 31 | $ pip install -e .[testing,docs] 32 | 33 | .. note:: 34 | 35 | If you use ``conda``, you can install ``virtualenv`` in the root environment:: 36 | 37 | $ conda install -n root virtualenv 38 | 39 | Don't worry as this is safe to do. 40 | 41 | #. Install pre-commit:: 42 | 43 | $ pre-commit install 44 | 45 | #. Create a branch for local development:: 46 | 47 | $ git checkout -b name-of-your-bugfix-or-feature 48 | 49 | Now you can make your changes locally. 50 | 51 | #. When you're done making changes, run the tests:: 52 | 53 | $ pytest 54 | 55 | #. If you want to check the modification made on the documentation, you can generate the docs locally:: 56 | 57 | $ tox -e docs 58 | 59 | The documentation files will be generated in ``docs/_build``. 60 | 61 | #. Commit your changes and push your branch to GitHub:: 62 | 63 | $ git add . 64 | $ git commit -m "Your detailed description of your changes." 65 | $ git push origin name-of-your-bugfix-or-feature 66 | 67 | #. Submit a pull request through the GitHub website. 68 | 69 | Pull Request Guidelines 70 | ----------------------- 71 | 72 | Before you submit a pull request, check that it meets these guidelines: 73 | 74 | 1. The pull request should include tests. 75 | 2. If the pull request adds functionality, the docs should be updated. 76 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018, ESSS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ====================================================================== 2 | OOP Extensions 3 | ====================================================================== 4 | 5 | .. image:: https://img.shields.io/pypi/v/oop-ext.svg 6 | :target: https://pypi.python.org/pypi/oop-ext 7 | 8 | .. image:: https://img.shields.io/pypi/pyversions/oop-ext.svg 9 | :target: https://pypi.org/project/oop-ext 10 | 11 | .. image:: https://github.com/ESSS/oop-ext/workflows/build/badge.svg 12 | :target: https://github.com/ESSS/oop-ext/actions 13 | 14 | .. image:: https://codecov.io/gh/ESSS/oop-ext/branch/master/graph/badge.svg 15 | :target: https://codecov.io/gh/ESSS/oop-ext 16 | 17 | .. image:: https://img.shields.io/readthedocs/oop-extensions.svg 18 | :target: https://oop-extensions.readthedocs.io/en/latest/ 19 | 20 | .. image:: https://results.pre-commit.ci/badge/github/ESSS/oop-ext/master.svg 21 | :target: https://results.pre-commit.ci/latest/github/ESSS/oop-ext/master 22 | 23 | .. image:: https://sonarcloud.io/api/project_badges/measure?project=ESSS_oop-ext&metric=alert_status 24 | :target: https://sonarcloud.io/project/overview?id=ESSS_oop-ext 25 | 26 | 27 | What is OOP Extensions ? 28 | ================================================================================ 29 | 30 | OOP Extensions is a set of utilities for object oriented programming which is missing on Python core libraries. 31 | 32 | Usage 33 | ================================================================================ 34 | ``oop_ext`` brings a set of object oriented utilities, it supports the concept of interfaces, 35 | abstract/overridable methods and more. ``oop_ext`` carefully checks that implementations 36 | have the same method signatures as the interface it implements and raises exceptions otherwise. 37 | 38 | Here's a simple example showing some nice features: 39 | 40 | .. code-block:: python 41 | 42 | from oop_ext.interface import Interface, ImplementsInterface 43 | 44 | 45 | class IDisposable(Interface): 46 | def dispose(self): 47 | """ 48 | Clears this object 49 | """ 50 | 51 | def is_disposed(self) -> bool: 52 | """ 53 | Returns True if the object has been cleared 54 | """ 55 | 56 | 57 | @ImplementsInterface(IDisposable) 58 | class MyObject(Disposable): 59 | def __init__(self): 60 | super().__init__() 61 | self._data = [0] * 100 62 | self._is_disposed = False 63 | 64 | def is_disposed(self) -> bool: 65 | return self._is_disposed 66 | 67 | def dispose(self): 68 | self._is_disposed = True 69 | self._data = [] 70 | 71 | 72 | If any of the two methods in ``MyObject`` are not implemented or have differ signatures than 73 | the ones declared in ``IDisposable``, the ``ImplementsInterface`` decorator will raise an 74 | error during import. 75 | 76 | Arbitrary objects can be verified if they implement a certain interface by using ``IsImplementation``: 77 | 78 | .. code-block:: python 79 | 80 | from oop_ext.interface import IsImplementation 81 | 82 | my_object = MyObject() 83 | if IsImplementation(my_object, IDisposable): 84 | # my_object is guaranteed to implement IDisposable completely 85 | my_object.dispose() 86 | 87 | Alternatively you can assert that an object implements the desired interface with ``AssertImplements``: 88 | 89 | .. code-block:: python 90 | 91 | from oop_ext.interface import AssertImplements 92 | 93 | my_object = MyObject() 94 | AssertImplements(my_object, IDisposable) 95 | my_object.dispose() 96 | 97 | 98 | Type Checking 99 | ------------- 100 | 101 | As of ``1.1.0``, ``oop-ext`` includes inline type annotations and exposes them to user programs. 102 | 103 | If you are running a type checker such as mypy on your tests, you may start noticing type errors indicating incorrect usage. 104 | If you run into an error that you believe to be incorrect, please let us know in an issue. 105 | 106 | The types were developed against ``mypy`` version 0.800. 107 | 108 | See `the docs `__ 109 | for more information. 110 | 111 | Contributing 112 | ------------ 113 | 114 | For guidance on setting up a development environment and how to make a 115 | contribution to oop_ext, see the `contributing guidelines`_. 116 | 117 | .. _contributing guidelines: https://github.com/ESSS/oop-ext/blob/master/CONTRIBUTING.rst 118 | 119 | 120 | Release 121 | ------- 122 | A reminder for the maintainers on how to make a new release. 123 | 124 | Note that the VERSION should follow the semantic versioning as ``X.Y.Z`` (e.g. ``v1.0.5``). 125 | 126 | 1. Create a ``release-VERSION`` branch from ``upstream/master``. 127 | 2. Update ``CHANGELOG.rst``. 128 | 3. Push a branch with the changes. 129 | 4. Once all builds pass, push a ``VERSION`` tag to ``upstream``. 130 | 5. Merge the PR. 131 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | .. note:: 5 | 6 | This page is WIP, PRs are welcome! 7 | 8 | .. py:module:: oop_ext.foundation.callback 9 | 10 | .. autoclass:: Callback 11 | :members: 12 | 13 | .. autoclass:: Callbacks 14 | :members: 15 | 16 | 17 | .. py:module:: oop_ext.interface 18 | 19 | .. autoclass:: Interface 20 | :members: 21 | 22 | .. autofunction:: ImplementsInterface 23 | .. autofunction:: GetProxy 24 | -------------------------------------------------------------------------------- /docs/callbacks.rst: -------------------------------------------------------------------------------- 1 | Callbacks 2 | ========= 3 | 4 | .. automodule:: oop_ext.foundation.callback._callback 5 | :noindex: 6 | 7 | 8 | Type Checking 9 | ------------- 10 | 11 | .. versionadded:: 1.1.0 12 | 13 | ``oop-ext`` also provides type-checked variants, ``Callback0``, ``Callback1``, ``Callback2``, etc, 14 | which explicitly declare the number of arguments and types of the parameters supported by 15 | the callback. 16 | 17 | Example: 18 | 19 | .. code-block:: python 20 | 21 | class Point: 22 | def __init__(self, x: float, y: float) -> None: 23 | self._x = x 24 | self._y = y 25 | self.on_changed = Callback2[float, float]() 26 | 27 | def update(self, x: float, y: float) -> None: 28 | self._x = x 29 | self._y = y 30 | self.on_changed(x, y) 31 | 32 | 33 | def on_point_changed(x: float, y: float) -> None: 34 | print(f"point changed: ({x}, {y})") 35 | 36 | 37 | p = Point(0.0, 0.0) 38 | p.on_changed.Register(on_point_changed) 39 | p.update(100.0, 2.5) 40 | 41 | 42 | In the example above, both the calls ``self.on_changed`` and ``on_changed.Register`` are properly 43 | type checked for number of arguments and types. 44 | 45 | The method specialized signatures are only seen by the type checker, so using one of the specialized 46 | variants should have nearly zero runtime cost (only the cost of an empty subclass). 47 | 48 | .. versionadded:: 2.2.0 49 | 50 | ``PriorityCallback`` has the same support, with ``PriorityCallback0``, ``PriorityCallback1``, ``PriorityCallback2``, etc. 51 | 52 | .. note:: 53 | The separate callback classes are needed for now, but once we require Python 3.11 54 | (`pep-0646 `__, we should be able to 55 | implement the generic variants into ``Callback`` and ``PriorityCallback`` themselves. 56 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | CHANGELOG 3 | ========= 4 | 5 | .. include:: ../CHANGELOG.rst 6 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # oop_ext documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Jun 9 13:47:02 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | # If extensions (or modules to document with autodoc) are in another 16 | # directory, add these directories to sys.path here. If the directory is 17 | # relative to the documentation root, use os.path.abspath to make it 18 | # absolute, like shown here. 19 | # 20 | import os 21 | import sys 22 | 23 | sys.path.insert(0, os.path.abspath("..")) 24 | 25 | 26 | # -- General configuration --------------------------------------------- 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | # 30 | # needs_sphinx = '1.0' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 34 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode", "sphinx.ext.napoleon"] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ["_templates"] 38 | 39 | # The suffix(es) of source filenames. 40 | # You can specify multiple suffix as a list of string: 41 | # 42 | # source_suffix = ['.rst', '.md'] 43 | source_suffix = ".rst" 44 | 45 | # The master toctree document. 46 | master_doc = "index" 47 | 48 | # General information about the project. 49 | project = "Oop_ext" 50 | copyright = "2018, ESSS" 51 | author = "ESSS" 52 | 53 | # The version info for the project you're documenting, acts as replacement 54 | # for |version| and |release|, also used in various other places throughout 55 | # the built documents. 56 | # 57 | # The short X.Y version. 58 | # import pkg_resources 59 | # version = pkg_resources.get_distribution('oop_ext').ver 60 | # The full version, including alpha/beta/rc tags. 61 | # release = version 62 | 63 | # The language for content autogenerated by Sphinx. Refer to documentation 64 | # for a list of supported languages. 65 | # 66 | # This is also used if you do content translation via gettext catalogs. 67 | # Usually you set "language" from the command line for these cases. 68 | language = "en" 69 | 70 | # List of patterns, relative to source directory, that match files and 71 | # directories to ignore when looking for source files. 72 | # This patterns also effect to html_static_path and html_extra_path 73 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 74 | 75 | # The name of the Pygments (syntax highlighting) style to use. 76 | pygments_style = "sphinx" 77 | 78 | # If true, `todo` and `todoList` produce output, else they produce nothing. 79 | todo_include_todos = False 80 | 81 | 82 | # -- Options for HTML output ------------------------------------------- 83 | 84 | # The theme to use for HTML and HTML Help pages. See the documentation for 85 | # a list of builtin themes. 86 | # 87 | html_theme = "sphinx_rtd_theme" 88 | html_logo = "img/logo.png" 89 | 90 | # Theme options are theme-specific and customize the look and feel of a 91 | # theme further. For a list of options available for each theme, see the 92 | # documentation. 93 | # 94 | # html_theme_options = {} 95 | 96 | # Add any paths that contain custom static files (such as style sheets) here, 97 | # relative to this directory. They are copied after the builtin static files, 98 | # so a file named "default.css" will overwrite the builtin "default.css". 99 | # html_static_path = ["_static"] 100 | 101 | 102 | # -- Options for HTMLHelp output --------------------------------------- 103 | 104 | # Output file base name for HTML help builder. 105 | htmlhelp_basename = "oop_extdoc" 106 | 107 | 108 | # -- Options for LaTeX output ------------------------------------------ 109 | 110 | latex_elements = { 111 | # The paper size ('letterpaper' or 'a4paper'). 112 | # 113 | # 'papersize': 'letterpaper', 114 | # The font size ('10pt', '11pt' or '12pt'). 115 | # 116 | # 'pointsize': '10pt', 117 | # Additional stuff for the LaTeX preamble. 118 | # 119 | # 'preamble': '', 120 | # Latex figure (float) alignment 121 | # 122 | # 'figure_align': 'htbp', 123 | } 124 | 125 | # Grouping the document tree into LaTeX files. List of tuples 126 | # (source start file, target name, title, author, documentclass 127 | # [howto, manual, or own class]). 128 | latex_documents = [ 129 | (master_doc, "oop_ext.tex", "Oop_ext Documentation", "ESSS", "manual") 130 | ] 131 | 132 | 133 | # -- Options for manual page output ------------------------------------ 134 | 135 | # One entry per manual page. List of tuples 136 | # (source start file, name, description, authors, manual section). 137 | man_pages = [(master_doc, "oop_ext", "Oop_ext Documentation", [author], 1)] 138 | 139 | 140 | # -- Options for Texinfo output ---------------------------------------- 141 | 142 | # Grouping the document tree into Texinfo files. List of tuples 143 | # (source start file, target name, title, author, 144 | # dir menu entry, description, category) 145 | texinfo_documents = [ 146 | ( 147 | master_doc, 148 | "oop_ext", 149 | "Oop_ext Documentation", 150 | author, 151 | "oop_ext", 152 | "One line description of project.", 153 | "Miscellaneous", 154 | ) 155 | ] 156 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ESSS/oop-ext/0efdefb55dc9a20d8b767cfd8208d3e744a528b6/docs/img/logo.png -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to Oop_ext documentation! 2 | ====================================================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | readme 9 | installation 10 | callbacks 11 | interfaces 12 | api 13 | contributing 14 | changelog 15 | 16 | Indices and tables 17 | ================== 18 | * :ref:`genindex` 19 | * :ref:`modindex` 20 | * :ref:`search` 21 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | 8 | Stable release 9 | -------------- 10 | 11 | To install Oop_ext, run this command in your terminal: 12 | 13 | .. code-block:: console 14 | 15 | $ pip install oop_ext 16 | 17 | This is the preferred method to install Oop_ext, as it will always install the most recent stable release. 18 | 19 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide 20 | you through the process. 21 | 22 | .. _pip: https://pip.pypa.io 23 | .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ 24 | 25 | 26 | From sources 27 | ------------ 28 | 29 | The sources for Oop_ext can be downloaded from the `Github repo`_. 30 | 31 | You can either clone the public repository: 32 | 33 | .. code-block:: console 34 | 35 | $ git clone git://github.com/ESSS/oop-ext 36 | 37 | Or download the `tarball`_: 38 | 39 | .. code-block:: console 40 | 41 | $ curl -OL https://github.com/ESSS/oop-ext/tarball/master 42 | 43 | Once you have a copy of the source, you can install it with: 44 | 45 | .. code-block:: console 46 | 47 | $ python setup.py install 48 | 49 | 50 | .. _Github repo: https://github.com/ESSS/oop-ext 51 | .. _tarball: https://github.com/ESSS/oop-ext/tarball/master 52 | -------------------------------------------------------------------------------- /docs/interfaces.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Interfaces 3 | ========== 4 | 5 | ``oop-ext`` introduces the concept of interfaces, common in other languages. 6 | 7 | An interface is a class which defines methods and attributes, defining a specific behavior, 8 | so implementations can declare that they work with an specific interface without worrying about 9 | implementations details. 10 | 11 | Interfaces are declared by subclassing :class:`oop_ext.interface.Interface`: 12 | 13 | .. code-block:: python 14 | 15 | 16 | from oop_ext.interface import Interface 17 | 18 | 19 | class IDataSaver(Interface): 20 | """ 21 | Interface for classes capable of saving a dict containing 22 | builtin types into persistent storage. 23 | """ 24 | 25 | def save(self, data: dict[Any, Any]) -> None: 26 | """Saves the given list of strings in persistent storage.""" 27 | 28 | 29 | (By convention, interfaces start with the letter ``I``). 30 | 31 | We can write a function which gets some data and saves it to persistent storage, without hard coding 32 | it to any specific implementation: 33 | 34 | .. code-block:: python 35 | 36 | 37 | def run_simulation(params: SimulationParameters, saver: IDataSaver) -> None: 38 | data = calculate(params) 39 | saver.save(data) 40 | 41 | 42 | ``run_simulation`` computes some simulation data, and uses a generic ``saver`` to persist it 43 | somewhere. 44 | 45 | We can now have multiple implementations of ``IDataSaver``, for example: 46 | 47 | 48 | .. code-block:: python 49 | 50 | from oop_ext.interface import ImplementsInterface 51 | 52 | 53 | @ImplementsInterface(IDataSaver) 54 | class JSONSaver: 55 | def __init__(self, path: Path) -> None: 56 | self.path = path 57 | 58 | def save(self, data: dict[Any, Any]) -> None: 59 | with self.path.open("w", encoding="UTF-8") as f: 60 | json.dump(f, data) 61 | 62 | And use it like this: 63 | 64 | .. code-block:: python 65 | 66 | run_simulation(params, JSONSaver(Path("out.json"))) 67 | 68 | What about duck typing? 69 | ----------------------- 70 | 71 | In Python declaring interfaces is not really necessary due to *duck typing*, however interfaces 72 | bring to the table **runtime validation**. 73 | 74 | If later on we add a new method to our ``IDataSaver`` interface, we will get errors at during 75 | *import time* about implementations which don't implement the new method, making it easy to spot 76 | the problems early. Interfaces also verify parameters names and default values, making 77 | it easy to keep implementations and interfaces in sync. 78 | 79 | .. note:: 80 | 81 | .. versionchanged:: 2.0.0 82 | 83 | Interfaces do not check type annotations at all. 84 | 85 | It was supported initially, but in practice 86 | this feature has shown up to be an impediment to adopting type annotations incrementally, as it 87 | discourages adding type annotations to improve existing interfaces, or annotating 88 | existing implementations without having to update the interface (and all other implementations 89 | by consequence). 90 | 91 | It was decided to let the static type checker correctly deal with matching type annotations, as 92 | it can do so more accurately than ``oop-ext`` did before. 93 | 94 | Type Checking 95 | ------------- 96 | 97 | .. versionadded:: 1.1.0 98 | 99 | The interfaces implementation has been implemented many years ago, before type checking in Python 100 | became a thing. 101 | 102 | The static type checking approach is to use `Protocols `__, 103 | which has the same benefits and flexibility of interfaces, but without the runtime cost. At ESSS 104 | however migrating the entire code base, which makes extensive use of interfaces, is a lengthy process 105 | so we need an intermediate solution to fill the gaps. 106 | 107 | To bridge the gap between the runtime-based approach of interfaces, and the static 108 | type checking provided by static type checkers, one just needs to subclass from both 109 | `Interface` and ``TypeCheckingSupport``: 110 | 111 | .. code-block:: python 112 | 113 | from oop_ext.interface import Interface, TypeCheckingSupport 114 | 115 | 116 | class IDataSaver(Interface, TypeCheckingSupport): 117 | """ 118 | Interface for classes capable of saving a dict containing 119 | builtin types into persistent storage. 120 | """ 121 | 122 | def save(self, data: dict[Any, Any]) -> None: 123 | """Saves the given list of strings in persistent storage.""" 124 | 125 | The ``TypeCheckingSupport`` class hides from the user the details necessary to make type checkers 126 | understand ``Interface`` subclasses. 127 | 128 | Note that subclassing from ``TypeCheckingSupport`` has zero runtime cost, existing only 129 | for the benefits of the type checkers. 130 | 131 | .. note:: 132 | 133 | Due to how ``Protocol`` works in Python, every ``Interface`` subclass **also** needs to subclass 134 | ``TypeCheckingSupport``. 135 | 136 | 137 | Proxies 138 | ------- 139 | 140 | Given an interface and an object that implements an interface, you can call :func:`GetProxy ` 141 | to obtain a *proxy object* which only contains methods and attributes defined in the interface. 142 | 143 | For example, using the ``JSONSaver`` from the previous example: 144 | 145 | .. code-block:: python 146 | 147 | def run_simulation(params, saver): 148 | data = calculate(params) 149 | proxy = GetProxy(IDataSaver, saver) 150 | proxy.save(data) 151 | 152 | The ``proxy`` object contains a stub implementation which contains only methods and attributes in ``IDataSaver``. This 153 | prevents mistakes like accessing a method that is defined in ``JSONSaver``, but is not part of ``IDataSaver``. 154 | 155 | Legacy Proxies 156 | ^^^^^^^^^^^^^^ 157 | 158 | With type annotations however, this is redundant: the type checker will prevent access to any method not declared in 159 | ``IDataSaver``: 160 | 161 | .. code-block:: python 162 | 163 | def run_simulation(params: SimulationParameters, saver: IDataSaver) -> None: 164 | data = calculate(params) 165 | saver.save(data) 166 | 167 | However when adding type annotations to legacy code, one will encounter this construct: 168 | 169 | .. code-block:: python 170 | 171 | def run_simulation(params, saver): 172 | data = calculate(params) 173 | proxy = IDataSaver(saver) 174 | proxy.save(data) 175 | 176 | Here "creating an instance" of the interface, passing an implementation of that interface, returns the stub 177 | implementation. This API was implemented like this for historic reasons, mainly because it would trick IDEs into 178 | providing code completion for ``proxy`` as if a ``IDataSaver`` instance. 179 | 180 | When adding type annotations, prefer to convert that to :func:`GetProxy `, 181 | which is friendlier to type checkers: 182 | 183 | .. code-block:: python 184 | 185 | def run_simulation(params: SimulationParameters, saver: IDataSaver) -> None: 186 | data = calculate(params) 187 | proxy = GetProxy(IDataSaver, saver) 188 | proxy.save(data) 189 | 190 | Or even better, if you don't require runtime checking, let the type checker do its job: 191 | 192 | .. code-block:: python 193 | 194 | def run_simulation(params: SimulationParameters, saver: IDataSaver) -> None: 195 | data = calculate(params) 196 | saver.save(data) 197 | 198 | 199 | .. note:: 200 | 201 | As of ``mypy 0.812``, there's `a bug `__ that prevents 202 | :func:`GetProxy ` from being properly type annotated. Hopefully this will be improved in the future. 203 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx-rtd-theme 3 | -------------------------------------------------------------------------------- /environment.devenv.yml: -------------------------------------------------------------------------------- 1 | name: oop-ext 2 | 3 | dependencies: 4 | - pre_commit 5 | - pytest 6 | - pytest-mock>=1.10 7 | - python>=3.6 8 | - tox 9 | 10 | environment: 11 | PYTHONPATH: 12 | - {{ root }}/src 13 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | files = src 3 | ignore_missing_imports = True 4 | no_implicit_optional = True 5 | show_error_codes = True 6 | strict_equality = True 7 | warn_redundant_casts = True 8 | warn_unused_configs = True 9 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools", 4 | "setuptools-scm[toml]", 5 | "wheel", 6 | ] 7 | build-backend = "setuptools.build_meta" 8 | 9 | [tool.setuptools_scm] 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """The setup script.""" 4 | import io 5 | from setuptools import find_packages 6 | from setuptools import setup 7 | 8 | with io.open("README.rst", encoding="UTF-8") as readme_file: 9 | readme = readme_file.read() 10 | 11 | with io.open("CHANGELOG.rst", encoding="UTF-8") as changelog_file: 12 | history = changelog_file.read() 13 | 14 | requirements = ["attrs"] 15 | extras_require = { 16 | "docs": ["sphinx >= 1.4", "sphinx_rtd_theme", "sphinx-autodoc-typehints"], 17 | "testing": [ 18 | "codecov", 19 | "pytest", 20 | "pytest-cov", 21 | "pytest-mock", 22 | "pre-commit", 23 | "tox", 24 | "mypy", 25 | ], 26 | } 27 | setup( 28 | author="ESSS", 29 | author_email="foss@esss.co", 30 | classifiers=[ 31 | "Development Status :: 5 - Production/Stable", 32 | "Intended Audience :: Developers", 33 | "License :: OSI Approved :: MIT License", 34 | "Programming Language :: Python :: 3.10", 35 | "Programming Language :: Python :: 3.11", 36 | "Programming Language :: Python :: 3.12", 37 | "Programming Language :: Python :: 3.13", 38 | ], 39 | description="OOP Extensions is a set of utilities for object oriented programming not found on Python's standard library.", 40 | extras_require=extras_require, 41 | install_requires=requirements, 42 | license="MIT license", 43 | long_description=readme + "\n\n" + history, 44 | include_package_data=True, 45 | python_requires=">=3.10", 46 | keywords="oop_ext", 47 | name="oop-ext", 48 | packages=find_packages(where="src"), 49 | package_dir={"": "src"}, 50 | url="http://github.com/ESSS/oop-ext", 51 | zip_safe=False, 52 | ) 53 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.organization=esss-sonarcloud 2 | sonar.projectKey=ESSS_oop-ext 3 | 4 | # relative paths to source directories. More details and properties are described 5 | # in https://sonarcloud.io/documentation/project-administration/narrowing-the-focus/ 6 | sonar.sources=./src/oop_ext 7 | -------------------------------------------------------------------------------- /src/oop_ext/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ESSS/oop-ext/0efdefb55dc9a20d8b767cfd8208d3e744a528b6/src/oop_ext/__init__.py -------------------------------------------------------------------------------- /src/oop_ext/_type_checker_fixture.py: -------------------------------------------------------------------------------- 1 | # mypy: disallow-untyped-defs 2 | # mypy: disallow-any-decorated 3 | from typing import List 4 | from typing import Tuple 5 | 6 | import attr 7 | import mypy.api 8 | import os 9 | import pytest 10 | import re 11 | from pathlib import Path 12 | from textwrap import dedent 13 | 14 | 15 | @attr.s(auto_attribs=True) 16 | class _Result: 17 | """ 18 | Encapsulates the result of a call to ``mypy.api``, providing helpful functions to check 19 | that output. 20 | """ 21 | 22 | output: tuple[str, str, int] 23 | 24 | def assert_errors(self, messages: list[str]) -> None: 25 | assert self.error_report == "" 26 | lines = self.report_lines 27 | assert len(lines) == len( 28 | messages 29 | ), f"Expected {len(messages)} failures, got {len(lines)}:\n" + "\n".join(lines) 30 | for index, (obtained, expected) in enumerate(zip(lines, messages)): 31 | m = re.search(expected, obtained) 32 | assert m is not None, ( 33 | f"Expected regex at index {index}:\n" 34 | f" {expected}\n" 35 | f"did not match:\n" 36 | f" {obtained}\n" 37 | f"(note: use re.escape() to escape regex special characters)" 38 | ) 39 | 40 | def assert_ok(self) -> None: 41 | assert len(self.report_lines) == 0, "Expected no errors, got:\n " + "\n".join( 42 | self.report_lines 43 | ) 44 | assert self.exit_status == 0 45 | 46 | @property 47 | def normal_report(self) -> str: 48 | return self.output[0] 49 | 50 | @property 51 | def error_report(self) -> str: 52 | return self.output[1] 53 | 54 | @property 55 | def exit_status(self) -> int: 56 | return self.output[2] 57 | 58 | @property 59 | def report_lines(self) -> list[str]: 60 | lines = [x.strip() for x in self.normal_report.split("\n") if x.strip()] 61 | # Drop last line (summary). 62 | return lines[:-1] 63 | 64 | 65 | @attr.s(auto_attribs=True) 66 | class TypeCheckerFixture: 67 | """ 68 | Fixture to help running mypy in source code and checking for success/specific errors. 69 | 70 | This fixture is useful for libraries which provide type checking, allowing them 71 | to ensure the type support is working as intended. 72 | """ 73 | 74 | path: Path 75 | request: pytest.FixtureRequest 76 | 77 | def make_file(self, source: str) -> None: 78 | name = self.request.node.name + ".py" 79 | self.path.joinpath(name).write_text(dedent(source)) 80 | 81 | def run(self) -> _Result: 82 | # Change current directory so error messages show only the relative 83 | # path to the files. 84 | cwd = os.getcwd() 85 | try: 86 | os.chdir(self.path) 87 | x = mypy.api.run(["."]) 88 | return _Result(x) 89 | finally: 90 | os.chdir(cwd) 91 | -------------------------------------------------------------------------------- /src/oop_ext/conftest.py: -------------------------------------------------------------------------------- 1 | # mypy: disallow-untyped-defs 2 | from typing import TYPE_CHECKING 3 | 4 | import pytest 5 | from pathlib import Path 6 | 7 | if TYPE_CHECKING: 8 | from ._type_checker_fixture import TypeCheckerFixture 9 | 10 | 11 | @pytest.fixture 12 | def type_checker( 13 | request: pytest.FixtureRequest, tmp_path: Path 14 | ) -> "TypeCheckerFixture": 15 | """ 16 | Fixture to help checking source code for type checking errors. 17 | 18 | Note: We plan to extract this to its own plugin. 19 | """ 20 | from ._type_checker_fixture import TypeCheckerFixture 21 | 22 | return TypeCheckerFixture(tmp_path, request) 23 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ESSS/oop-ext/0efdefb55dc9a20d8b767cfd8208d3e744a528b6/src/oop_ext/foundation/__init__.py -------------------------------------------------------------------------------- /src/oop_ext/foundation/_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ESSS/oop-ext/0efdefb55dc9a20d8b767cfd8208d3e744a528b6/src/oop_ext/foundation/_tests/__init__.py -------------------------------------------------------------------------------- /src/oop_ext/foundation/_tests/test_cached_method.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from oop_ext.foundation.cached_method import AbstractCachedMethod 4 | from oop_ext.foundation.cached_method import AttributeBasedCachedMethod 5 | from oop_ext.foundation.cached_method import CachedMethod 6 | from oop_ext.foundation.cached_method import LastResultCachedMethod 7 | 8 | 9 | def testCacheMethod(cached_obj: "MyTestObj") -> None: 10 | cache = MyMethod = CachedMethod(cached_obj.MyMethod) 11 | 12 | MyMethod(1) 13 | cached_obj.CheckCounts(cache, method=1, miss=1) 14 | 15 | MyMethod(1) 16 | cached_obj.CheckCounts(cache, hit=1) 17 | 18 | MyMethod(2) 19 | cached_obj.CheckCounts(cache, method=1, miss=1) 20 | 21 | MyMethod(2) 22 | cached_obj.CheckCounts(cache, hit=1) 23 | 24 | # ALL results are stored, so these calls are HITs 25 | MyMethod(1) 26 | cached_obj.CheckCounts(cache, hit=1) 27 | 28 | MyMethod(2) 29 | cached_obj.CheckCounts(cache, hit=1) 30 | 31 | 32 | def testCacheMethodEnabled(cached_obj: "MyTestObj") -> None: 33 | cache = MyMethod = CachedMethod(cached_obj.MyMethod) 34 | 35 | MyMethod(1) 36 | cached_obj.CheckCounts(cache, method=1, miss=1) 37 | 38 | MyMethod(1) 39 | cached_obj.CheckCounts(cache, hit=1) 40 | 41 | MyMethod.enabled = False 42 | 43 | MyMethod(1) 44 | cached_obj.CheckCounts(cache, method=1, miss=1) 45 | 46 | MyMethod.enabled = True 47 | 48 | MyMethod(1) 49 | cached_obj.CheckCounts(cache, hit=1) 50 | 51 | 52 | def testCacheMethodLastResultCachedMethod(cached_obj: "MyTestObj") -> None: 53 | cache = MyMethod = LastResultCachedMethod(cached_obj.MyMethod) 54 | 55 | MyMethod(1) 56 | cached_obj.CheckCounts(cache, method=1, miss=1) 57 | 58 | MyMethod(1) 59 | cached_obj.CheckCounts(cache, hit=1) 60 | 61 | MyMethod(2) 62 | cached_obj.CheckCounts(cache, method=1, miss=1) 63 | 64 | MyMethod(2) 65 | cached_obj.CheckCounts(cache, hit=1) 66 | 67 | # Only the LAST result is stored, so this call is a MISS. 68 | MyMethod(1) 69 | cached_obj.CheckCounts(cache, method=1, miss=1) 70 | 71 | 72 | def testCacheMethodObjectInKey(cached_obj: "MyTestObj") -> None: 73 | cache = MyMethod = CachedMethod(cached_obj.MyMethod) 74 | 75 | class MyObject: 76 | def __init__(self): 77 | self.name = "alpha" 78 | self.id = 1 79 | 80 | def __str__(self): 81 | return "%s %d" % (self.name, self.id) 82 | 83 | alpha = MyObject() 84 | 85 | MyMethod(alpha) 86 | cached_obj.CheckCounts(cache, method=1, miss=1) 87 | 88 | MyMethod(alpha) 89 | cached_obj.CheckCounts(cache, hit=1) 90 | 91 | alpha.name = "bravo" 92 | alpha.id = 2 93 | 94 | MyMethod(alpha) 95 | cached_obj.CheckCounts(cache, method=1, miss=1) 96 | 97 | 98 | def testCacheMethodAttributeBasedCachedMethod() -> None: 99 | class TestObject: 100 | def __init__(self): 101 | self.name = "alpha" 102 | self.id = 1 103 | self.n_calls = 0 104 | 105 | def Foo(self, par): 106 | self.n_calls += 1 107 | return "%s %d" % (par, self.id) 108 | 109 | alpha = TestObject() 110 | alpha.Foo = AttributeBasedCachedMethod( # type:ignore[assignment] 111 | alpha.Foo, "id", cache_size=3 112 | ) 113 | alpha.Foo("test1") # type:ignore[misc] 114 | alpha.Foo("test1") # type:ignore[misc] 115 | 116 | assert alpha.n_calls == 1 117 | 118 | alpha.Foo("test2") # type:ignore[misc] 119 | assert alpha.n_calls == 2 120 | assert len(alpha.Foo._results) == 2 # type:ignore[attr-defined] 121 | 122 | alpha.id = 3 123 | alpha.Foo("test2") # type:ignore[misc] 124 | assert alpha.n_calls == 3 125 | 126 | assert len(alpha.Foo._results) == 3 # type:ignore[attr-defined] 127 | 128 | alpha.Foo("test3") # type:ignore[misc] 129 | assert alpha.n_calls == 4 130 | assert len(alpha.Foo._results) == 3 # type:ignore[attr-defined] 131 | 132 | 133 | @pytest.fixture 134 | def cached_obj(): 135 | """ 136 | A test_object common to many cached_method tests. 137 | """ 138 | return MyTestObj() 139 | 140 | 141 | class MyTestObj: 142 | def __init__(self): 143 | self.method_count = 0 144 | 145 | def MyMethod(self, *args, **kwargs) -> int: 146 | self.method_count += 1 147 | return self.method_count 148 | 149 | def CheckCounts(self, cache, method=0, miss=0, hit=0): 150 | if not hasattr(cache, "check_counts"): 151 | cache.check_counts = dict(method=0, miss=0, hit=0, call=0) 152 | 153 | cache.check_counts["method"] += method 154 | cache.check_counts["miss"] += miss 155 | cache.check_counts["hit"] += hit 156 | cache.check_counts["call"] += miss + hit 157 | 158 | assert self.method_count == cache.check_counts["method"] 159 | assert cache.miss_count == cache.check_counts["miss"] 160 | assert cache.hit_count == cache.check_counts["hit"] 161 | assert cache.call_count == cache.check_counts["call"] 162 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/_tests/test_decorators.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from typing import List 3 | from typing import Tuple 4 | 5 | import pytest 6 | import warnings 7 | 8 | from oop_ext.foundation import is_frozen 9 | from oop_ext.foundation.decorators import Abstract 10 | from oop_ext.foundation.decorators import Deprecated 11 | from oop_ext.foundation.decorators import Implements 12 | from oop_ext.foundation.decorators import Override 13 | 14 | 15 | def testImplementsFail() -> None: 16 | with pytest.raises(AssertionError): 17 | 18 | class IFoo: 19 | def DoIt(self): 20 | """ """ 21 | 22 | class Implementation: 23 | @Implements(IFoo.DoIt) 24 | def DoNotDoIt(self): 25 | """ """ 26 | 27 | 28 | def testImplementsOK() -> None: 29 | class IFoo: 30 | def Foo(self): 31 | """ 32 | docstring 33 | """ 34 | 35 | class Impl: 36 | @Implements(IFoo.Foo) 37 | def Foo(self): 38 | return self.__class__.__name__ 39 | 40 | assert IFoo.Foo.__doc__ == Impl.Foo.__doc__ 41 | 42 | # Just for 100% coverage. 43 | assert Impl().Foo() == "Impl" 44 | 45 | 46 | def testOverride() -> None: 47 | def TestOK(): 48 | class A: 49 | def Method(self): 50 | """ 51 | docstring 52 | """ 53 | 54 | class B(A): 55 | @Override(A.Method) 56 | def Method(self): 57 | return 2 58 | 59 | b = B() 60 | assert b.Method() == 2 61 | assert A.Method.__doc__ == B.Method.__doc__ 62 | 63 | def TestERROR(): 64 | class A: 65 | def MyMethod(self): 66 | """ """ 67 | 68 | class B(A): 69 | @Override(A.Method) # it will raise an error at this point 70 | def Method(self): 71 | """ """ 72 | 73 | def TestNoMatch(): 74 | class A: 75 | def Method(self): 76 | """ """ 77 | 78 | class B(A): 79 | @Override(A.Method) 80 | def MethodNoMatch(self): 81 | """ """ 82 | 83 | TestOK() 84 | with pytest.raises(AttributeError): 85 | TestERROR() 86 | 87 | with pytest.raises(AssertionError): 88 | TestNoMatch() 89 | 90 | 91 | def testDeprecated(monkeypatch) -> None: 92 | def MyWarn(*args, **kwargs): 93 | warn_params.append((args, kwargs)) 94 | 95 | monkeypatch.setattr(warnings, "warn", MyWarn) 96 | 97 | was_development = is_frozen.SetIsDevelopment(True) 98 | try: 99 | # Emit messages when in development 100 | warn_params: list[tuple[Any, Any]] = [] 101 | 102 | # ... deprecation with alternative 103 | @Deprecated("OtherMethod") 104 | def Method1(): 105 | pass 106 | 107 | # ... deprecation without alternative 108 | @Deprecated() 109 | def Method2(): 110 | pass 111 | 112 | Method1() 113 | Method2() 114 | assert warn_params == [ 115 | ( 116 | ("DEPRECATED: 'Method1' is deprecated, use 'OtherMethod' instead",), 117 | {"stacklevel": 2}, 118 | ), 119 | (("DEPRECATED: 'Method2' is deprecated",), {"stacklevel": 2}), 120 | ] 121 | 122 | # No messages on release code 123 | is_frozen.SetIsDevelopment(False) 124 | 125 | warn_params = [] 126 | 127 | @Deprecated() 128 | def FrozenMethod(): 129 | pass 130 | 131 | FrozenMethod() 132 | assert warn_params == [] 133 | finally: 134 | is_frozen.SetIsDevelopment(was_development) 135 | 136 | 137 | def testAbstract() -> None: 138 | class Alpha: 139 | @Abstract 140 | def Method(self): 141 | """ """ 142 | 143 | alpha = Alpha() 144 | with pytest.raises(NotImplementedError): 145 | alpha.Method() 146 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/_tests/test_immutable.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from copy import copy 3 | from copy import deepcopy 4 | 5 | from oop_ext.foundation.immutable import AsImmutable 6 | from oop_ext.foundation.immutable import IdentityHashableRef 7 | from oop_ext.foundation.immutable import ImmutableDict 8 | 9 | 10 | def testImmutable() -> None: 11 | class MyClass: 12 | pass 13 | 14 | d = AsImmutable(dict(a=1, b=dict(b=2))) 15 | assert d == {"a": 1, "b": {"b": 2}} 16 | with pytest.raises(NotImplementedError): 17 | d.__setitem__("a", 2) 18 | 19 | assert d["b"].AsMutable() == dict(b=2) 20 | AsImmutable(d, return_str_if_not_expected=False) 21 | d = d.AsMutable() 22 | d["a"] = 2 23 | 24 | c = deepcopy(d) 25 | assert c == d 26 | 27 | c = copy(d) 28 | assert c == d 29 | assert AsImmutable({1, 2, 3}) == {1, 2, 3} 30 | assert AsImmutable(([1, 2], [2, 3])) == ((1, 2), (2, 3)) 31 | assert AsImmutable(None) is None 32 | assert isinstance(AsImmutable({1, 2, 4}), frozenset) 33 | assert isinstance(AsImmutable(frozenset([1, 2, 4])), frozenset) 34 | assert isinstance(AsImmutable([1, 2, 4]), tuple) 35 | assert isinstance(AsImmutable((1, 2, 4)), tuple) 36 | 37 | # Primitive non-container types 38 | def AssertIsSame(value): 39 | assert AsImmutable(value) is value 40 | 41 | AssertIsSame(True) 42 | AssertIsSame(1.0) 43 | AssertIsSame(1) 44 | AssertIsSame("a") 45 | AssertIsSame(b"b") 46 | 47 | # Dealing with derived values 48 | a = MyClass() 49 | assert AsImmutable(a, return_str_if_not_expected=True) == str(a) 50 | with pytest.raises(RuntimeError): 51 | AsImmutable(a, return_str_if_not_expected=False) 52 | 53 | # Derived basics 54 | class MyStr(str): 55 | pass 56 | 57 | assert AsImmutable(MyStr("alpha")) == "alpha" 58 | 59 | class MyList(list): 60 | pass 61 | 62 | assert AsImmutable(MyList()) == () 63 | 64 | class MySet(set): 65 | pass 66 | 67 | assert AsImmutable(MySet()) == frozenset() 68 | 69 | 70 | def testImmutableDict() -> None: 71 | d = ImmutableDict(alpha=1, bravo=2) 72 | 73 | with pytest.raises(NotImplementedError): 74 | d["charlie"] = 3 75 | 76 | with pytest.raises(NotImplementedError): 77 | del d["alpha"] 78 | 79 | with pytest.raises(NotImplementedError): 80 | d.clear() 81 | 82 | with pytest.raises(NotImplementedError): 83 | d.setdefault("charlie", 3) 84 | 85 | with pytest.raises(NotImplementedError): 86 | d.popitem() 87 | 88 | with pytest.raises(NotImplementedError): 89 | d.update({"charlie": 3}) 90 | 91 | 92 | def testIdentityHashableRef() -> None: 93 | a = {1: 2} 94 | b = {1: 2} 95 | 96 | assert IdentityHashableRef(a)() is a 97 | assert a == b 98 | assert IdentityHashableRef(a) != IdentityHashableRef(b) 99 | assert IdentityHashableRef(a) == IdentityHashableRef(a) 100 | 101 | set_a = {IdentityHashableRef(a)} 102 | assert IdentityHashableRef(a) in set_a 103 | assert IdentityHashableRef(b) not in set_a 104 | 105 | dict_b = {IdentityHashableRef(b): 7} 106 | assert IdentityHashableRef(a) not in dict_b 107 | assert IdentityHashableRef(b) in dict_b 108 | assert dict_b[IdentityHashableRef(b)] == 7 109 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/_tests/test_is_frozen.py: -------------------------------------------------------------------------------- 1 | from oop_ext.foundation.is_frozen import IsDevelopment 2 | from oop_ext.foundation.is_frozen import IsFrozen 3 | from oop_ext.foundation.is_frozen import SetIsDevelopment 4 | from oop_ext.foundation.is_frozen import SetIsFrozen 5 | 6 | 7 | def testIsFrozenIsDevelopment() -> None: 8 | # Note: this test is checking if we're always running tests while not in frozen mode, 9 | # still, we have to do a try..finally to make sure we restore things to the proper state. 10 | was_frozen = IsFrozen() 11 | try: 12 | assert IsFrozen() == False 13 | assert IsDevelopment() == True 14 | 15 | SetIsDevelopment(False) 16 | assert IsFrozen() == False 17 | assert IsDevelopment() == False 18 | 19 | SetIsDevelopment(True) 20 | assert IsFrozen() == False 21 | assert IsDevelopment() == True 22 | 23 | SetIsFrozen(True) 24 | assert IsFrozen() == True 25 | assert IsDevelopment() == True 26 | 27 | SetIsFrozen(False) 28 | assert IsFrozen() == False 29 | assert IsDevelopment() == True 30 | finally: 31 | SetIsFrozen(was_frozen) 32 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/_tests/test_odict.py: -------------------------------------------------------------------------------- 1 | from oop_ext.foundation.odict import odict 2 | 3 | 4 | def testInsert() -> None: 5 | d = odict() 6 | d[1] = "alpha" 7 | d[3] = "charlie" 8 | 9 | assert list(d.items()) == [(1, "alpha"), (3, "charlie")] 10 | 11 | d.insert(0, 0, "ZERO") 12 | assert list(d.items()) == [(0, "ZERO"), (1, "alpha"), (3, "charlie")] 13 | 14 | d.insert(2, 2, "bravo") 15 | assert list(d.items()) == [(0, "ZERO"), (1, "alpha"), (2, "bravo"), (3, "charlie")] 16 | 17 | d.insert(99, 4, "echo") 18 | assert list(d.items()) == [ 19 | (0, "ZERO"), 20 | (1, "alpha"), 21 | (2, "bravo"), 22 | (3, "charlie"), 23 | (4, "echo"), 24 | ] 25 | 26 | 27 | def testDelWithSlices() -> None: 28 | d = odict() 29 | d[1] = 1 30 | d[2] = 2 31 | d[3] = 3 32 | 33 | del d[1:] 34 | 35 | assert len(d) == 1 36 | assert d[1] == 1 37 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/_tests/test_singleton.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from oop_ext.foundation.callback import After 4 | from oop_ext.foundation.decorators import Override 5 | from oop_ext.foundation.singleton import PushPopSingletonError 6 | from oop_ext.foundation.singleton import Singleton 7 | from oop_ext.foundation.singleton import SingletonAlreadySetError 8 | from oop_ext.foundation.singleton import SingletonNotSetError 9 | 10 | 11 | def CheckCurrentSingleton(singleton_class, value): 12 | singleton = singleton_class.GetSingleton() 13 | assert singleton.value == value 14 | 15 | 16 | def testSingleton() -> None: 17 | class MySingleton(Singleton): 18 | def __init__(self, value): 19 | self.value = value 20 | 21 | @classmethod 22 | @Override(Singleton.CreateDefaultSingleton) 23 | def CreateDefaultSingleton(cls): 24 | return MySingleton(value=0) 25 | 26 | # Default singleton (created automatically and also put in the stack) 27 | CheckCurrentSingleton(MySingleton, 0) 28 | default_singleton = MySingleton.GetSingleton() 29 | default_singleton.value = 10 30 | 31 | # SetSingleton must be called only when there is no singleton set. In this case, 32 | # GetSingleton already set the singleton. 33 | with pytest.raises(SingletonAlreadySetError): 34 | MySingleton.SetSingleton(MySingleton(value=999)) 35 | CheckCurrentSingleton(MySingleton, 10) 36 | 37 | # push a new instance and test it 38 | MySingleton.PushSingleton(MySingleton(2000)) 39 | CheckCurrentSingleton(MySingleton, 2000) 40 | 41 | # Calling SetSingleton after using Push/Pop is an error: we do this so that 42 | # in tests we know someone is doing a SetSingleton when they shouldn't 43 | with pytest.raises(PushPopSingletonError): 44 | MySingleton.SetSingleton(MySingleton(value=10)) 45 | 46 | # pop, returns to the initial 47 | MySingleton.PopSingleton() 48 | CheckCurrentSingleton(MySingleton, 10) 49 | 50 | # SetSingleton given SingletonAlreadySet when outside Push/Pop 51 | with pytest.raises(SingletonAlreadySetError): 52 | MySingleton.SetSingleton(MySingleton(value=999)) 53 | CheckCurrentSingleton(MySingleton, 10) 54 | 55 | # The singleton set with "SetSingleton" or created automatically by "GetSingleton" is not 56 | # part of the stack 57 | with pytest.raises(PushPopSingletonError): 58 | MySingleton.PopSingleton() 59 | 60 | 61 | def testSetSingleton() -> None: 62 | class MySingleton(Singleton): 63 | def __init__(self, value=None): 64 | self.value = value 65 | 66 | assert not MySingleton.HasSingleton() 67 | 68 | MySingleton.SetSingleton(MySingleton(value=999)) 69 | assert MySingleton.HasSingleton() 70 | CheckCurrentSingleton(MySingleton, 999) 71 | 72 | with pytest.raises(SingletonAlreadySetError): 73 | MySingleton.SetSingleton(MySingleton(value=999)) 74 | 75 | MySingleton.ClearSingleton() 76 | assert not MySingleton.HasSingleton() 77 | 78 | with pytest.raises(SingletonNotSetError): 79 | MySingleton.ClearSingleton() 80 | 81 | 82 | def testPushPop() -> None: 83 | class MySingleton(Singleton): 84 | def __init__(self, value=None): 85 | self.value = value 86 | 87 | MySingleton.PushSingleton() 88 | 89 | assert MySingleton.GetStackCount() == 1 90 | 91 | with pytest.raises(PushPopSingletonError): 92 | MySingleton.ClearSingleton() 93 | 94 | MySingleton.PushSingleton() 95 | assert MySingleton.GetStackCount() == 2 96 | 97 | MySingleton.PopSingleton() 98 | assert MySingleton.GetStackCount() == 1 99 | 100 | MySingleton.PopSingleton() 101 | assert MySingleton.GetStackCount() == 0 102 | 103 | with pytest.raises(PushPopSingletonError): 104 | MySingleton.PopSingleton() 105 | 106 | 107 | def testSingletonOptimization() -> None: 108 | class MySingleton(Singleton): 109 | pass 110 | 111 | class MockClass: 112 | called = False 113 | 114 | def ObtainStack(self, *args, **kwargs): 115 | self.called = True 116 | 117 | obj = MockClass() 118 | After(MySingleton._ObtainStack, obj.ObtainStack) 119 | 120 | obj.called = False 121 | MySingleton.GetSingleton() 122 | assert obj.called 123 | 124 | obj.called = False 125 | MySingleton.GetSingleton() 126 | assert not obj.called 127 | 128 | 129 | def testGetSingletonThreadSafe(mocker) -> None: 130 | from threading import Event 131 | from threading import Thread 132 | 133 | class MySingleton(Singleton): 134 | @classmethod 135 | def SlowConstructor(cls, event): 136 | event.wait(1) 137 | return MySingleton() 138 | 139 | thrlist = [Thread(target=MySingleton.GetSingleton) for _ in range(3)] 140 | create_singleton_mock = mocker.patch.object(MySingleton, "CreateDefaultSingleton") 141 | 142 | event = Event() 143 | create_singleton_mock.side_effect = lambda: MySingleton.SlowConstructor(event) 144 | for thread in thrlist: 145 | thread.start() 146 | event.set() 147 | for thread in thrlist: 148 | thread.join() 149 | assert create_singleton_mock.call_count == 1 150 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/_tests/test_types.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from oop_ext.foundation.types_ import Null 4 | 5 | 6 | def testNull() -> None: 7 | # constructing and calling 8 | 9 | dummy = Null() 10 | dummy = Null("value") 11 | n = Null("value", param="value") 12 | 13 | n() 14 | n("value") 15 | n("value", param="value") 16 | 17 | # attribute handling 18 | n.attr1 19 | n.attr1.attr2 20 | n.method1() 21 | n.method1().method2() 22 | n.method("value") 23 | n.method(param="value") 24 | n.method("value", param="value") 25 | n.attr1.method1() 26 | n.method1().attr1 27 | 28 | n.attr1 = "value" 29 | n.attr1.attr2 = "value" # type:ignore[attr-defined] 30 | 31 | del n.attr1 32 | del n.attr1.attr2.attr3 # type:ignore[attr-defined] 33 | 34 | # Iteration 35 | for _ in n: 36 | "Not executed" 37 | 38 | # representation and conversion to a string 39 | assert repr(n) == "" 40 | assert str(n) == "Null" 41 | 42 | # truth value 43 | assert bool(n) == False 44 | assert bool(n.foo()) == False 45 | 46 | dummy = Null() 47 | # context manager 48 | with dummy: 49 | assert dummy.__name__ == "Null" # Name should return a str 50 | 51 | # Null objects are always equal to other null object 52 | assert n != 1 53 | assert n == dummy 54 | 55 | assert hash(Null()) == hash(Null()) 56 | 57 | 58 | def testNullCopy() -> None: 59 | n = Null() 60 | n1 = copy.copy(n) 61 | assert str(n) == str(n1) 62 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/_tests/test_weak_ref.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | import sys 5 | import weakref 6 | 7 | from oop_ext.foundation.weak_ref import GetRealObj 8 | from oop_ext.foundation.weak_ref import GetWeakProxy 9 | from oop_ext.foundation.weak_ref import GetWeakRef 10 | from oop_ext.foundation.weak_ref import IsSame 11 | from oop_ext.foundation.weak_ref import IsWeakProxy 12 | from oop_ext.foundation.weak_ref import IsWeakRef 13 | from oop_ext.foundation.weak_ref import WeakList 14 | from oop_ext.foundation.weak_ref import WeakMethodProxy 15 | from oop_ext.foundation.weak_ref import WeakMethodRef 16 | from oop_ext.foundation.weak_ref import WeakSet 17 | 18 | 19 | class _Stub: 20 | def __hash__(self): 21 | return 1 22 | 23 | def __eq__(self, o): 24 | return True # always equal 25 | 26 | def __ne__(self, o): 27 | return not self == o 28 | 29 | def Method(self): 30 | pass 31 | 32 | 33 | class Obj: 34 | def __init__(self, name): 35 | self.name = name 36 | 37 | def __repr__(self): 38 | return self.name 39 | 40 | 41 | def testStub() -> None: 42 | a = _Stub() 43 | b = _Stub() 44 | assert not a != a 45 | assert not a != b 46 | assert a == a 47 | assert a == b 48 | a.Method() 49 | 50 | 51 | def testIsSame() -> None: 52 | s1 = _Stub() 53 | s2 = _Stub() 54 | 55 | r1 = weakref.ref(s1) 56 | r2 = weakref.ref(s2) 57 | 58 | p1 = weakref.proxy(s1) 59 | p2 = weakref.proxy(s2) 60 | 61 | assert IsSame(s1, s1) 62 | assert not IsSame(s1, s2) 63 | 64 | assert IsSame(s1, r1) 65 | assert IsSame(s1, p1) 66 | 67 | assert not IsSame(s1, r2) 68 | assert not IsSame(s1, p2) 69 | 70 | assert IsSame(p2, r2) 71 | assert IsSame(r1, p1) 72 | assert not IsSame(r1, p2) 73 | 74 | with pytest.raises(ReferenceError): 75 | IsSame(p1, p2) 76 | 77 | 78 | def testGetWeakRef() -> None: 79 | b = GetWeakRef(None) 80 | assert callable(b) 81 | assert b() is None 82 | 83 | 84 | def testGeneral() -> None: 85 | b = _Stub() 86 | r = GetWeakRef(b.Method) 87 | assert callable(r) 88 | assert ( 89 | r() is not None 90 | ) # should not be a regular weak ref here (but a weak method ref) 91 | 92 | assert IsWeakRef(r) 93 | assert not IsWeakProxy(r) 94 | 95 | r = GetWeakProxy(b.Method) 96 | assert callable(r) 97 | r() 98 | assert IsWeakProxy(r) 99 | assert not IsWeakRef(r) 100 | 101 | r = weakref.ref(b) 102 | b2 = _Stub() 103 | r2 = weakref.ref(b2) 104 | assert r == r2 105 | assert hash(r) == hash(r2) 106 | 107 | r_m1 = GetWeakRef(b.Method) 108 | r_m2 = GetWeakRef(b.Method) 109 | assert r_m1 == r_m2 110 | assert hash(r_m1) == hash(r_m2) 111 | 112 | 113 | def testGetRealObj() -> None: 114 | b = _Stub() 115 | r = GetWeakRef(b) 116 | assert GetRealObj(r) is b 117 | 118 | r = GetWeakRef(None) 119 | assert GetRealObj(r) is None 120 | 121 | 122 | def testGetWeakProxyFromWeakRef() -> None: 123 | b = _Stub() 124 | r = GetWeakRef(b) 125 | proxy = GetWeakProxy(r) 126 | assert IsWeakProxy(proxy) 127 | 128 | 129 | def testWeakSet() -> None: 130 | weak_set = WeakSet[Any]() 131 | s1 = _Stub() 132 | s2 = _Stub() 133 | 134 | weak_set.add(s1) 135 | assert isinstance(next(iter(weak_set)), _Stub) 136 | 137 | assert s1 in weak_set 138 | CustomAssertEqual(len(weak_set), 1) 139 | del s1 140 | CustomAssertEqual(len(weak_set), 0) 141 | 142 | weak_set.add(s2) 143 | CustomAssertEqual(len(weak_set), 1) 144 | weak_set.remove(s2) 145 | CustomAssertEqual(len(weak_set), 0) 146 | 147 | weak_set.add(s2) 148 | weak_set.clear() 149 | CustomAssertEqual(len(weak_set), 0) 150 | 151 | weak_set.add(s2) 152 | weak_set.add(s2) 153 | weak_set.add(s2) 154 | CustomAssertEqual(len(weak_set), 1) 155 | del s2 156 | CustomAssertEqual(len(weak_set), 0) 157 | 158 | # >>> Testing with FUNCTION 159 | 160 | # Adding twice, having one 161 | def function() -> None: 162 | pass 163 | 164 | weak_set.add(function) 165 | weak_set.add(function) 166 | CustomAssertEqual(len(weak_set), 1) 167 | 168 | 169 | def testRemove() -> None: 170 | weak_set = WeakSet[_Stub]() 171 | 172 | s1 = _Stub() 173 | 174 | CustomAssertEqual(len(weak_set), 0) 175 | 176 | # Trying remove, raises KeyError 177 | with pytest.raises(KeyError): 178 | weak_set.remove(s1) 179 | CustomAssertEqual(len(weak_set), 0) 180 | 181 | # Trying discard, no exception raised 182 | weak_set.discard(s1) 183 | CustomAssertEqual(len(weak_set), 0) 184 | 185 | 186 | def testWeakSet2() -> None: 187 | weak_set = WeakSet[Any]() 188 | 189 | # >>> Removing with DEL 190 | s1 = _Stub() 191 | weak_set.add(s1.Method) 192 | CustomAssertEqual(len(weak_set), 1) 193 | del s1 194 | CustomAssertEqual(len(weak_set), 0) 195 | 196 | # >>> Removing with REMOVE 197 | s2 = _Stub() 198 | weak_set.add(s2.Method) 199 | CustomAssertEqual(len(weak_set), 1) 200 | weak_set.remove(s2.Method) 201 | CustomAssertEqual(len(weak_set), 0) 202 | 203 | 204 | def testWeakSetUnionWithWeakSet() -> None: 205 | ws1, ws2 = WeakSet[Obj](), WeakSet[Obj]() 206 | a, b, c = Obj("a"), Obj("b"), Obj("c") 207 | 208 | ws1.add(a) 209 | ws1.add(b) 210 | 211 | ws2.add(a) 212 | ws2.add(c) 213 | 214 | ws3 = ws1.union(ws2) 215 | assert set(ws3) == set(ws2.union(ws1)) == {a, b, c} 216 | 217 | del c 218 | assert set(ws3) == set(ws2.union(ws1)) == {a, b} 219 | 220 | 221 | def testWeakSetUnionWithSet() -> None: 222 | ws = WeakSet[Obj]() 223 | a, b, c = Obj("a"), Obj("b"), Obj("c") 224 | 225 | ws.add(a) 226 | ws.add(b) 227 | 228 | s = {a, c} 229 | 230 | ws3 = ws.union(s) 231 | assert set(ws3) == set(s.union(set(ws))) == {a, b, c} 232 | 233 | del b 234 | assert set(ws3) == set(s.union(set(ws))) == {a, c} 235 | 236 | 237 | def testWeakSetSubWithWeakSet() -> None: 238 | ws1, ws2 = WeakSet[Obj](), WeakSet[Obj]() 239 | a, b, c = Obj("a"), Obj("b"), Obj("c") 240 | 241 | ws1.add(a) 242 | ws1.add(b) 243 | 244 | ws2.add(a) 245 | ws2.add(c) 246 | assert set(ws1 - ws2) == {b} 247 | assert set(ws2 - ws1) == {c} 248 | 249 | del c 250 | assert set(ws1 - ws2) == {b} 251 | assert set(ws2 - ws1) == set() 252 | 253 | 254 | def testWeakSetSubWithSet() -> None: 255 | ws = WeakSet[Obj]() 256 | s = set() 257 | a, b, c = Obj("a"), Obj("b"), Obj("c") 258 | 259 | ws.add(a) 260 | ws.add(b) 261 | 262 | s.add(a) 263 | s.add(c) 264 | 265 | assert set(ws - s) == {b} 266 | assert s - ws == {c} 267 | 268 | del b 269 | assert set(ws - s) == set() 270 | assert s - ws == {c} 271 | 272 | 273 | def testWithError() -> None: 274 | weak_set = WeakSet[Any]() 275 | 276 | # Not WITH, everything ok 277 | s1 = _Stub() 278 | weak_set.add(s1.Method) 279 | CustomAssertEqual(len(weak_set), 1) 280 | del s1 281 | CustomAssertEqual(len(weak_set), 0) 282 | 283 | # Using WITH, s2 is not deleted from weak_set 284 | s2 = _Stub() 285 | with pytest.raises(KeyError): 286 | raise KeyError("key") 287 | CustomAssertEqual(len(weak_set), 0) 288 | 289 | weak_set.add(s2.Method) 290 | CustomAssertEqual(len(weak_set), 1) 291 | del s2 292 | CustomAssertEqual(len(weak_set), 0) 293 | 294 | 295 | def testFunction() -> None: 296 | weak_set = WeakSet[Any]() 297 | 298 | def function() -> None: 299 | "Never called" 300 | 301 | # Adding twice, having one. 302 | weak_set.add(function) 303 | weak_set.add(function) 304 | CustomAssertEqual(len(weak_set), 1) 305 | 306 | # Removing function 307 | weak_set.remove(function) 308 | assert len(weak_set) == 0 309 | 310 | 311 | def CustomAssertEqual(a, b): 312 | """ 313 | Avoiding using "assert a == b" because it adds another reference to the ref-count. 314 | """ 315 | if a == b: 316 | pass 317 | else: 318 | assert False, f"{a} != {b}" 319 | 320 | 321 | def SetupTestAttributes() -> Any: 322 | class C: 323 | x: int 324 | 325 | def f(self, y=0): 326 | return self.x + y 327 | 328 | class D: 329 | def f(self): 330 | "Never called" 331 | 332 | c = C() 333 | c.x = 1 334 | d = D() 335 | 336 | return (C, c, d) 337 | 338 | 339 | def testCustomAssertEqual() -> None: 340 | with pytest.raises(AssertionError) as excinfo: 341 | CustomAssertEqual(1, 2) 342 | 343 | assert str(excinfo.value) == "1 != 2\nassert False" 344 | 345 | 346 | def testRefcount() -> None: 347 | _, c, _ = SetupTestAttributes() 348 | 349 | CustomAssertEqual( 350 | sys.getrefcount(c), 2 351 | ) # 2: one in self, and one as argument to getrefcount() 352 | cf = c.f 353 | CustomAssertEqual(sys.getrefcount(c), 3) # 3: as above, plus cf 354 | rf = WeakMethodRef(c.f) 355 | pf = WeakMethodProxy(c.f) 356 | CustomAssertEqual(sys.getrefcount(c), 3) 357 | del cf 358 | CustomAssertEqual(sys.getrefcount(c), 2) 359 | rf2 = WeakMethodRef(c.f) 360 | CustomAssertEqual(sys.getrefcount(c), 2) 361 | del rf 362 | del rf2 363 | del pf 364 | CustomAssertEqual(sys.getrefcount(c), 2) 365 | 366 | 367 | def testDies() -> None: 368 | _, c, _ = SetupTestAttributes() 369 | 370 | rf = WeakMethodRef(c.f) 371 | pf = WeakMethodProxy(c.f) 372 | assert not rf.is_dead() 373 | assert not pf.is_dead() 374 | assert rf()() == 1 375 | assert pf(2) == 3 376 | c = None 377 | assert rf.is_dead() 378 | assert pf.is_dead() 379 | assert rf() == None 380 | with pytest.raises(ReferenceError): 381 | pf() 382 | 383 | 384 | def testWorksWithFunctions() -> None: 385 | SetupTestAttributes() 386 | 387 | def foo(y): 388 | return y + 1 389 | 390 | rf = WeakMethodRef(foo) 391 | pf = WeakMethodProxy(foo) 392 | assert foo(1) == 2 393 | assert rf()(1) == 2 394 | assert pf(1) == 2 395 | assert not rf.is_dead() 396 | assert not pf.is_dead() 397 | 398 | 399 | def testWorksWithUnboundMethods() -> None: 400 | C, c, _ = SetupTestAttributes() 401 | 402 | meth = C.f 403 | rf = WeakMethodRef(meth) 404 | pf = WeakMethodProxy(meth) 405 | assert meth(c) == 1 406 | assert rf()(c) == 1 407 | assert pf(c) == 1 408 | assert not rf.is_dead() 409 | assert not pf.is_dead() 410 | 411 | 412 | def testEq() -> None: 413 | _, c, d = SetupTestAttributes() 414 | 415 | rf1 = WeakMethodRef(c.f) 416 | rf2 = WeakMethodRef(c.f) 417 | assert rf1 == rf2 418 | rf3 = WeakMethodRef(d.f) 419 | assert rf1 != rf3 420 | del c 421 | assert rf1.is_dead() 422 | assert rf2.is_dead() 423 | assert rf1 == rf2 424 | 425 | 426 | def testProxyEq() -> None: 427 | _, c, d = SetupTestAttributes() 428 | 429 | pf1 = WeakMethodProxy(c.f) 430 | pf2 = WeakMethodProxy(c.f) 431 | pf3 = WeakMethodProxy(d.f) 432 | assert pf1 == pf2 433 | assert pf3 != pf2 434 | del c 435 | assert pf1 == pf2 436 | assert pf1.is_dead() 437 | assert pf2.is_dead() 438 | 439 | 440 | def testHash() -> None: 441 | _, c, _ = SetupTestAttributes() 442 | 443 | r = WeakMethodRef(c.f) 444 | r2 = WeakMethodRef(c.f) 445 | assert r == r2 446 | h = hash(r) 447 | assert hash(r) == hash(r2) 448 | del c 449 | assert r() is None 450 | assert hash(r) == h 451 | 452 | 453 | def testRepr() -> None: 454 | _, c, _ = SetupTestAttributes() 455 | 456 | r = WeakMethodRef(c.f) 457 | assert str(r)[:33] == " None: 460 | "Never called" 461 | 462 | r = WeakMethodRef(Foo) 463 | assert str(r) == "" 464 | 465 | 466 | def testWeakRefToWeakMethodRef() -> None: 467 | def Foo() -> None: 468 | "Never called" 469 | 470 | r = WeakMethodRef(Foo) 471 | m_ref = weakref.ref(r) 472 | assert m_ref() is r 473 | 474 | 475 | def testWeakList() -> None: 476 | weak_list = WeakList[_Stub]() 477 | s1 = _Stub() 478 | s2 = _Stub() 479 | 480 | weak_list.append(s1) 481 | assert isinstance(weak_list[0], _Stub) 482 | 483 | assert s1 in weak_list 484 | assert 1 == len(weak_list) 485 | del s1 486 | assert 0 == len(weak_list) 487 | 488 | weak_list.append(s2) 489 | assert 1 == len(weak_list) 490 | weak_list.remove(s2) 491 | assert 0 == len(weak_list) 492 | 493 | weak_list.append(s2) 494 | del weak_list[:] 495 | assert 0 == len(weak_list) 496 | 497 | weak_list.append(s2) 498 | del s2 499 | del weak_list[:] 500 | assert 0 == len(weak_list) 501 | 502 | s1 = _Stub() 503 | weak_list.append(s1) 504 | assert 1 == len(weak_list[:]) 505 | 506 | del s1 507 | 508 | assert 0 == len(weak_list[:]) 509 | 510 | def m1() -> None: 511 | "Never called" 512 | 513 | weak_list.append(m1) # type:ignore[arg-type] 514 | assert 1 == len(weak_list[:]) 515 | del m1 516 | assert 0 == len(weak_list[:]) 517 | 518 | s = _Stub() 519 | weak_list.append(s.Method) # type:ignore[arg-type] 520 | assert 1 == len(weak_list[:]) 521 | ref_s = weakref.ref(s) 522 | del s 523 | assert 0 == len(weak_list[:]) 524 | assert ref_s() is None 525 | 526 | s0 = _Stub() 527 | s1 = _Stub() 528 | weak_list.extend([s0, s1]) 529 | assert len(weak_list) == 2 530 | 531 | 532 | def testSetItem() -> None: 533 | weak_list = WeakList[_Stub]() 534 | s1 = _Stub() 535 | s2 = _Stub() 536 | weak_list.append(s1) 537 | weak_list.append(s1) 538 | assert s1 == weak_list[0] 539 | weak_list[0] = s2 540 | assert s2 == weak_list[0] 541 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/cached_method.py: -------------------------------------------------------------------------------- 1 | # mypy: disallow-untyped-defs 2 | from typing import Dict 3 | from typing import Generic 4 | from typing import Optional 5 | from typing import TypeVar 6 | from typing import Union 7 | from typing import cast 8 | 9 | from abc import abstractmethod 10 | from collections.abc import Callable 11 | from collections.abc import Hashable 12 | from collections.abc import Sequence 13 | 14 | from .immutable import AsImmutable 15 | from .odict import odict 16 | from .types_ import Method 17 | from .weak_ref import WeakMethodRef 18 | 19 | ResultType = TypeVar("ResultType") 20 | 21 | 22 | class AbstractCachedMethod(Method, Generic[ResultType]): 23 | """ 24 | Base class for cache-manager. 25 | The abstract class does not implement the storage of results. 26 | """ 27 | 28 | def __init__(self, cached_method: Callable[..., ResultType]) -> None: 29 | # Using WeakMethodRef to avoid cyclic reference. 30 | self._method = WeakMethodRef(cached_method) 31 | self.enabled = True 32 | self.ResetCounters() 33 | 34 | def __call__(self, *args: object, **kwargs: object) -> ResultType: 35 | key = self.GetCacheKey(*args, **kwargs) 36 | 37 | if self.enabled and self._HasResult(key): 38 | self.hit_count += 1 39 | result = self._GetCacheResult(key, cast(ResultType, None)) 40 | else: 41 | self.miss_count += 1 42 | result = self._CallMethod(*args, **kwargs) 43 | self._AddCacheResult(key, result) 44 | 45 | self.call_count += 1 46 | return result 47 | 48 | def _CallMethod(self, *args: object, **kwargs: object) -> ResultType: 49 | return self._method()(*args, **kwargs) 50 | 51 | def GetCacheKey(self, *args: object, **kwargs: object) -> Hashable: 52 | """ 53 | Use the arguments to build the cache-key. 54 | """ 55 | if args: 56 | if kwargs: 57 | return AsImmutable(args), AsImmutable(kwargs) 58 | 59 | return AsImmutable(args) 60 | 61 | if kwargs: 62 | return AsImmutable(kwargs) 63 | 64 | return None 65 | 66 | @abstractmethod 67 | def _HasResult(self, key: Hashable) -> bool: 68 | raise NotImplementedError() 69 | 70 | @abstractmethod 71 | def _AddCacheResult(self, key: Hashable, result: ResultType) -> None: 72 | raise NotImplementedError() 73 | 74 | @abstractmethod 75 | def DoClear(self) -> None: 76 | raise NotImplementedError() 77 | 78 | def Clear(self) -> None: 79 | self.DoClear() 80 | self.ResetCounters() 81 | 82 | def ResetCounters(self) -> None: 83 | self.call_count = 0 84 | self.hit_count = 0 85 | self.miss_count = 0 86 | 87 | @abstractmethod 88 | def _GetCacheResult(self, key: Hashable, result: ResultType) -> ResultType: 89 | raise NotImplementedError() 90 | 91 | 92 | class CachedMethod(AbstractCachedMethod, Generic[ResultType]): 93 | """ 94 | Stores ALL the different results and never delete them. 95 | """ 96 | 97 | def __init__(self, cached_method: Callable[..., ResultType]) -> None: 98 | super().__init__(cached_method) 99 | self._results: dict[Hashable, ResultType] = {} 100 | 101 | def _HasResult(self, key: Hashable) -> bool: 102 | return key in self._results 103 | 104 | def _AddCacheResult(self, key: Hashable, result: ResultType) -> None: 105 | self._results[key] = result 106 | 107 | def DoClear(self) -> None: 108 | self._results.clear() 109 | 110 | def _GetCacheResult(self, key: Hashable, result: ResultType) -> ResultType: 111 | return self._results[key] 112 | 113 | 114 | class ImmutableParamsCachedMethod(CachedMethod, Generic[ResultType]): 115 | """ 116 | Expects all parameters to already be immutable 117 | Considers only the positional parameters of key, ignoring the keyword arguments 118 | """ 119 | 120 | def GetCacheKey(self, *args: object, **kwargs: object) -> Hashable: 121 | """ 122 | Use the arguments to build the cache-key. 123 | """ 124 | return args 125 | 126 | 127 | class LastResultCachedMethod(AbstractCachedMethod, Generic[ResultType]): 128 | """ 129 | A cache that stores only the last result. 130 | """ 131 | 132 | def __init__(self, cached_method: Callable[..., ResultType]) -> None: 133 | super().__init__(cached_method) 134 | self._key: object | None = None 135 | self._result: ResultType | None = None 136 | 137 | def _HasResult(self, key: Hashable) -> bool: 138 | return self._key == key 139 | 140 | def _AddCacheResult(self, key: Hashable, result: ResultType) -> None: 141 | self._key = key 142 | self._result = result 143 | 144 | def DoClear(self) -> None: 145 | self._key = None 146 | self._result = None 147 | 148 | def _GetCacheResult(self, key: Hashable, result: ResultType) -> ResultType: 149 | # This could return None (_result is Optional), but not doing an assert 150 | # here to avoid breaking code. 151 | return self._result # type:ignore[return-value] 152 | 153 | 154 | class AttributeBasedCachedMethod(CachedMethod, Generic[ResultType]): 155 | """ 156 | This cached method consider changes in object attributes 157 | """ 158 | 159 | def __init__( 160 | self, 161 | cached_method: Callable[..., ResultType], 162 | attr_name_list: str | Sequence[str], 163 | cache_size: int = 1, 164 | results: odict | None = None, 165 | ): 166 | """ 167 | :type cached_method: bound method to be cached 168 | :param cached_method: 169 | :type attr_name_list: attr names in a C{str} separated by spaces OR in a sequence of C{str} 170 | :param attr_name_list: 171 | :type cache_size: the cache size 172 | :param cache_size: 173 | :type results: an optional ref. to an C{odict} for keep cache results 174 | :param results: 175 | """ 176 | CachedMethod.__init__(self, cached_method) 177 | if isinstance(attr_name_list, str): 178 | self._attr_name_list = attr_name_list.split() 179 | else: 180 | self._attr_name_list = list(attr_name_list) 181 | self._cache_size = cache_size 182 | if results is None: 183 | self._results = odict() 184 | else: 185 | self._results = results 186 | 187 | def GetCacheKey(self, *args: object, **kwargs: object) -> Hashable: 188 | instance = self._method().__self__ 189 | for attr_name in self._attr_name_list: 190 | kwargs["_object_%s" % attr_name] = getattr(instance, attr_name) 191 | return super().GetCacheKey(*args, **kwargs) 192 | 193 | def _AddCacheResult(self, key: Hashable, result: ResultType) -> None: 194 | super()._AddCacheResult(key, result) 195 | if len(self._results) > self._cache_size: 196 | key0 = next(iter(self._results)) 197 | del self._results[key0] 198 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/callback/__init__.py: -------------------------------------------------------------------------------- 1 | from ._callback import Callback 2 | from ._callbacks import Callbacks 3 | from ._priority_callback import PriorityCallback 4 | from ._shortcuts import After 5 | from ._shortcuts import Before 6 | from ._shortcuts import Remove 7 | from ._shortcuts import WrapForCallback 8 | from ._typed_callback import Callback0 9 | from ._typed_callback import Callback1 10 | from ._typed_callback import Callback2 11 | from ._typed_callback import Callback3 12 | from ._typed_callback import Callback4 13 | from ._typed_callback import Callback5 14 | from ._typed_callback import PriorityCallback0 15 | from ._typed_callback import PriorityCallback1 16 | from ._typed_callback import PriorityCallback2 17 | from ._typed_callback import PriorityCallback3 18 | from ._typed_callback import PriorityCallback4 19 | from ._typed_callback import PriorityCallback5 20 | from ._typed_callback import UnregisterContext 21 | 22 | __all__ = [ 23 | "After", 24 | "Before", 25 | "Callback", 26 | "Callback0", 27 | "Callback1", 28 | "Callback2", 29 | "Callback3", 30 | "Callback4", 31 | "Callback5", 32 | "Callbacks", 33 | "PriorityCallback", 34 | "PriorityCallback0", 35 | "PriorityCallback1", 36 | "PriorityCallback2", 37 | "PriorityCallback3", 38 | "PriorityCallback4", 39 | "PriorityCallback5", 40 | "UnregisterContext", 41 | "Remove", 42 | "WrapForCallback", 43 | ] 44 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/callback/_callback.py: -------------------------------------------------------------------------------- 1 | # mypy: disallow-untyped-defs 2 | # mypy: disallow-any-decorated 3 | """ 4 | Callbacks provide an interface to register other callbacks, that will be *called back* when the 5 | ``Callback`` object is called. 6 | 7 | A ``Callback`` is similar to holding a pointer to a function, except it supports multiple functions. 8 | 9 | Example: 10 | 11 | .. code-block:: 12 | 13 | class Data: 14 | 15 | def __init__(self, x: int) -> None: 16 | self._x = x 17 | self.on_changed = Callback() 18 | 19 | @property 20 | def x(self) -> int: 21 | return self._x 22 | 23 | @x.setter 24 | def x(self, x: int) -> None: 25 | self._x = x 26 | self.on_changed(x) 27 | 28 | In the code above, ``Data`` contains a ``x`` property, which triggers a ``on_changed`` callback 29 | whenever ``x`` changes. 30 | 31 | We can be notified whenever ``x`` changes by registering a function in the callback: 32 | 33 | .. code-block:: 34 | 35 | def on_x(x: int) -> None: 36 | print(f"x changed to {x}") 37 | 38 | data = Data(10) 39 | data.on_changed.Register(on_x) 40 | data.x = 20 41 | 42 | The code above will print ``x changed to 20``, because changing ``data.x`` triggers all functions 43 | registered in ``data.on_changed``. 44 | 45 | An important feature is that the functions connected to the callback are *weakly referenced*, so 46 | methods connected to a callback won't keep the method instance alive due to the connection. 47 | 48 | We can unregister functions using :meth:`Unregister `, check if a function 49 | is registered with :meth:`Contains `, and unregister all connected functions 50 | with :meth:`UnregisterAll `. 51 | """ 52 | import types 53 | from typing import Any 54 | from typing import Optional 55 | from typing import Tuple 56 | from typing import Union 57 | from typing import cast 58 | 59 | import attr 60 | import functools 61 | import inspect 62 | import logging 63 | import weakref 64 | from collections.abc import Callable 65 | from collections.abc import Hashable 66 | from collections.abc import Sequence 67 | 68 | from oop_ext.foundation.compat import GetClassForUnboundMethod 69 | from oop_ext.foundation.is_frozen import IsDevelopment 70 | from oop_ext.foundation.odict import odict 71 | from oop_ext.foundation.types_ import Method 72 | from oop_ext.foundation.weak_ref import WeakMethodProxy 73 | 74 | log = logging.getLogger(__name__) 75 | 76 | 77 | class Callback: 78 | """ 79 | Object that provides a way for others to connect in it and later call it to call 80 | those connected. 81 | 82 | Callbacks are stored as weakrefs to objects connected. 83 | 84 | **Determining kind of callable (Python 3)** 85 | 86 | Many parts of callback implementation rely on identifying the kind of callable: is it a 87 | free function? is it a function bound to an object? 88 | 89 | Below there is a table to help understand how different objects are classified: 90 | 91 | .. code-block:: 92 | 93 | |has__self__|has__call__|has__call__self__|isbuiltin|isfunction|ismethod 94 | --------------------|-----------|-----------|-----------------|---------|----------|-------- 95 | free function |False |True |True |False |True |False 96 | bound method |True |True |True |False |False |True 97 | class method |True |True |True |False |False |True 98 | bound class method |True |True |True |False |False |True 99 | function object |False |True |True |False |False |False 100 | builtin function |True |True |True |True |False |False 101 | object |True |True |True |True |False |False 102 | custom object |False |False |False |False |False |False 103 | string |False |False |False |False |False |False 104 | 105 | where rows are: 106 | 107 | .. code-block:: python 108 | 109 | def free_fn(foo): 110 | # `free function` 111 | pass 112 | 113 | 114 | class Foo: 115 | def bound_fn(self, foo): 116 | pass 117 | 118 | 119 | class Bar: 120 | @classmethod 121 | def class_fn(cls, foo): 122 | pass 123 | 124 | 125 | class ObjectFn: 126 | def __call__(self, foo): 127 | pass 128 | 129 | 130 | foo = Foo() # foo is `custom object`, foo.bound_fn is `bound method` 131 | bar = Bar() # Bar.class_fn is `class method`, bar.class_fn is `bound class method` 132 | 133 | object_fn = ObjectFn() # `function object` 134 | 135 | obj = object() # `object` 136 | string = "foo" # `string` 137 | builtin_fn = string.split # `builtin function` 138 | 139 | And where columns are: 140 | 141 | * isbuiltin: inspect.isbuiltin 142 | * isfunction: inspect.isfunction 143 | * ismethod: inspect.ismethod 144 | * has__self__: hasattr(obj, '__self__') 145 | * has__call__: hasattr(obj, '__call__') 146 | * has__call__self__: hasattr(obj.__call__, '__self__') if hasattr(obj, '__call__') else False 147 | 148 | .. note:: 149 | After an internal refactoring, ``__slots__`` has been added, so, it cannot have 150 | weakrefs to it (but as it stores weakrefs internally, that shouldn't be a problem). 151 | If weakrefs are really needed, ``__weakref__`` should be added to the slots. 152 | """ 153 | 154 | __slots__ = ["_callbacks", "_handle_errors", "__weakref__"] 155 | 156 | INFO_POS_FUNC_OBJ = 0 157 | INFO_POS_FUNC_FUNC = 1 158 | INFO_POS_FUNC_CLASS = 2 159 | 160 | # Can be set to True to debug (should be removed after all applications 161 | # properly test the new behavior). 162 | DEBUG_NEW_WEAKREFS = False 163 | 164 | def __init__(self) -> None: 165 | # callbacks is no longer lazily created: This makes the creation a bit slower, but 166 | # everything else is faster (as having to check for hasattr each time is slow). 167 | self._callbacks = odict() 168 | 169 | def _GetKey( 170 | self, 171 | func: Union["_CallbackWrapper", Method, Callable], 172 | extra_args: Sequence[object], 173 | ) -> Hashable: 174 | """ 175 | :param func: 176 | The function for which we want the key. 177 | 178 | :param extra_args: 179 | Extra arguments associated with the function. 180 | 181 | IMPORTANT: while this argument is not used here, subclasses might use that 182 | argument themselves, so don't remove it. 183 | 184 | :returns: 185 | Returns the key to be used to access the object. 186 | 187 | .. note:: The key is guaranteed to be unique among the living objects, but if the object 188 | is garbage collected, a new function may end up having the same key. 189 | """ 190 | if func.__class__ == _CallbackWrapper: 191 | func = cast(_CallbackWrapper, func) 192 | func = func.OriginalMethod() 193 | 194 | try: 195 | if func.__self__ is not None: # type:ignore[union-attr] 196 | # bound method 197 | return ( 198 | id(func.__self__), # type:ignore[union-attr] 199 | id(func.__func__), # type:ignore[union-attr] 200 | id(func.__self__.__class__), # type:ignore[union-attr] 201 | ) 202 | else: 203 | return ( 204 | id(func.__func__), # type:ignore[union-attr] 205 | id(GetClassForUnboundMethod(func)), 206 | ) 207 | 208 | except AttributeError: 209 | # not a method -- a callable: create a strong reference (the CallbackWrapper 210 | # is depending on this behaviour... is it correct?) 211 | return id(func) 212 | 213 | def _GetInfo( 214 | self, func: None | WeakMethodProxy | Method | Callable 215 | ) -> tuple[Any, Any, Any]: 216 | """ 217 | :rtype: tuple(func_obj, func_func, func_class) 218 | :returns: 219 | Returns a tuple with the information needed to call a method later on (close to the 220 | WeakMethodRef, but a bit more specialized -- and faster for this context). 221 | """ 222 | # Note: if it's a _CallbackWrapper, we want to register it and not the 'original method' 223 | # at this point, but if it's a WeakMethodProxy, register the original method (we'll make a 224 | # weak reference later anyways). 225 | if func.__class__ == WeakMethodProxy: 226 | func = cast(WeakMethodProxy, func) 227 | func = func.GetWrappedFunction() 228 | 229 | if _IsCallableObject(func): 230 | if self.DEBUG_NEW_WEAKREFS: 231 | obj_str = f"{func.__class__}" 232 | print("Changed behavior for: %s" % obj_str) 233 | 234 | def on_die(r: Any) -> None: 235 | # I.e.: the hint here is that a reference may die before expected 236 | print(f"Reference died: {obj_str}") 237 | 238 | return (weakref.ref(func, on_die), None, None) 239 | return (weakref.ref(func), None, None) 240 | 241 | try: 242 | if ( 243 | func.__self__ is not None # type:ignore[union-attr] 244 | and func.__func__ is not None # type:ignore[union-attr] 245 | ): 246 | # bound method 247 | return ( 248 | weakref.ref(func.__self__), # type:ignore[union-attr] 249 | func.__func__, # type:ignore[union-attr] 250 | func.__self__.__class__, # type:ignore[union-attr] 251 | ) 252 | else: 253 | # unbound method 254 | return ( 255 | None, 256 | func.__func__, # type:ignore[union-attr] 257 | GetClassForUnboundMethod(func), 258 | ) 259 | except AttributeError: 260 | # not a method -- a callable: create a strong reference (CallbackWrapper 261 | # is depending on this behaviour... is it correct?) 262 | return (None, func, None) 263 | 264 | def __call__(self, *args: object, **kwargs: object) -> None: # @DontTrace 265 | """ 266 | Calls every registered function with the given args and kwargs. 267 | """ 268 | callbacks = self._callbacks 269 | if not callbacks: 270 | return 271 | 272 | to_call = [] 273 | 274 | for cb_id, info_and_extra_args in list(callbacks.items()): # iterate in a copy 275 | info = info_and_extra_args[0] 276 | func_obj = info[self.INFO_POS_FUNC_OBJ] 277 | if func_obj is not None: 278 | # Ok, we have a self. 279 | func_obj = func_obj() 280 | if func_obj is None: 281 | # self is dead 282 | del callbacks[cb_id] 283 | else: 284 | func_func = info[self.INFO_POS_FUNC_FUNC] 285 | if func_func is None: 286 | to_call.append((func_obj, info_and_extra_args[1])) 287 | else: 288 | to_call.append( 289 | ( 290 | types.MethodType(func_func, func_obj), 291 | info_and_extra_args[1], 292 | ) 293 | ) 294 | else: 295 | func_func = info[self.INFO_POS_FUNC_FUNC] 296 | if func_func.__class__ == _CallbackWrapper: 297 | # The instance of the _CallbackWrapper already died! (func_obj is None) 298 | original_method = func_func.OriginalMethod() 299 | if original_method is None: 300 | del callbacks[cb_id] 301 | continue 302 | 303 | # No self: either classmethod or just callable 304 | to_call.append((func_func, info_and_extra_args[1])) 305 | 306 | to_call = self._FilterToCall(to_call, args, kwargs) 307 | 308 | # Iterate over callbacks running and checking for exceptions... 309 | for func, extra_args in to_call: 310 | func(*extra_args + args, **kwargs) 311 | 312 | def _FilterToCall(self, to_call: Any, args: Any, kwargs: Any) -> Any: 313 | """ 314 | Provides a chance for subclasses to filter the function/extra arguments to call. 315 | 316 | :param list(tuple(method,tuple)) to_call: 317 | list(function_to_call, extra_arguments) 318 | 319 | :param args: 320 | Arguments being passed to the call. 321 | 322 | :param kwargs: 323 | Keyword arguments being passed to the call. 324 | 325 | :return list(tuple(method,tuple): 326 | Return the filtered list with the function/extra arguments to call. 327 | """ 328 | return to_call 329 | 330 | _EXTRA_ARGS_CONSTANT: tuple[object, ...] = tuple() 331 | 332 | def Register( 333 | self, 334 | func: Callable[..., Any], 335 | extra_args: Sequence[object] = _EXTRA_ARGS_CONSTANT, 336 | ) -> "UnregisterContext": 337 | """ 338 | Registers a function in the callback. 339 | 340 | :param func: 341 | Method or function that will be called later. 342 | 343 | :param extra_args: 344 | Arguments that will be passed automatically to the passed function 345 | when the callback is called. 346 | 347 | :return: 348 | A context which can be used to unregister this call. 349 | 350 | The context object provides this low level functionality, if you are registering 351 | many callbacks at once and plan to unregister them all at the same time, consider 352 | using `Callbacks` instead. 353 | """ 354 | if IsDevelopment() and hasattr(func, "im_class"): 355 | msg = ( 356 | "%r object has inconsistent internal attributes and is not compatible with Callback.\n" 357 | "im_class = %r\n" 358 | "(If using a MagicMock, remember to pass spec=lambda:None)." 359 | ) 360 | raise RuntimeError(msg % (func, getattr(func, "im_class"))) 361 | if extra_args is not self._EXTRA_ARGS_CONSTANT: 362 | extra_args = tuple(extra_args) 363 | 364 | key = self._GetKey(func, extra_args) 365 | callbacks = self._callbacks 366 | callbacks.pop(key, None) # Remove if it exists 367 | callbacks[key] = (self._GetInfo(func), extra_args) 368 | return UnregisterContext(self, key) 369 | 370 | def Contains( 371 | self, 372 | func: Callable[..., Any], 373 | extra_args: Sequence[object] = _EXTRA_ARGS_CONSTANT, 374 | ) -> bool: 375 | """ 376 | :param object func: 377 | The function that may be contained in this callback. 378 | 379 | :rtype: bool 380 | :returns: 381 | True if the function is already registered within the callbacks and False 382 | otherwise. 383 | """ 384 | key = self._GetKey(func, extra_args) 385 | 386 | callbacks = self._callbacks 387 | 388 | info_and_extra_args = callbacks.get(key) 389 | if info_and_extra_args is None: 390 | return False 391 | 392 | real_func: Callable | None = func 393 | 394 | if isinstance(real_func, WeakMethodProxy): 395 | real_func = real_func.GetWrappedFunction() 396 | 397 | # We must check if it's actually the same, because it may be that the ids we've gotten for 398 | # this object were actually from a garbage-collected function that was previously registered. 399 | 400 | info = info_and_extra_args[0] 401 | func_obj = info[self.INFO_POS_FUNC_OBJ] 402 | func_func = info[self.INFO_POS_FUNC_FUNC] 403 | if func_obj is not None: 404 | # Ok, we have a self. 405 | func_obj = func_obj() 406 | if func_obj is None: 407 | # self is dead 408 | del callbacks[key] 409 | return False 410 | else: 411 | return real_func is func_obj or ( 412 | func_func is not None 413 | and real_func == types.MethodType(func_func, func_obj) 414 | ) 415 | else: 416 | if type(func_func) is _CallbackWrapper: 417 | # The instance of the _CallbackWrapper already died! (func_obj is None) 418 | original_method = func_func.OriginalMethod() 419 | if original_method is None: 420 | del callbacks[key] 421 | return False 422 | return original_method == real_func 423 | 424 | if func_func == real_func: 425 | return True 426 | try: 427 | f = real_func.__func__ # type:ignore[union-attr] 428 | except AttributeError: 429 | return False 430 | else: 431 | return f == func_func 432 | 433 | def Unregister( 434 | self, 435 | func: Callable[..., Any], 436 | extra_args: Sequence[object] = _EXTRA_ARGS_CONSTANT, 437 | ) -> None: 438 | """ 439 | Unregister a function previously registered with Register. 440 | 441 | :param object func: 442 | The function to be unregistered. 443 | """ 444 | key = self._GetKey(func, extra_args) 445 | self._UnregisterByKey(key) 446 | 447 | def _UnregisterByKey(self, key: Hashable) -> None: 448 | """Unregisters a function registered with Register() by providing the internal key.""" 449 | try: 450 | # As there can only be 1 instance with the same id alive, it should be OK just 451 | # deleting it directly (because if there was a dead reference pointing to it it will 452 | # be already dead anyways) 453 | del self._callbacks[key] 454 | except (KeyError, AttributeError): 455 | # Even when unregistering some function that isn't registered we shouldn't trigger an 456 | # exception, just do nothing 457 | pass 458 | 459 | def UnregisterAll(self) -> None: 460 | """ 461 | Unregisters all functions 462 | """ 463 | self._callbacks.clear() 464 | 465 | def __len__(self) -> int: 466 | return len(self._callbacks) 467 | 468 | 469 | def _IsCallableObject(func: object) -> bool: 470 | return ( 471 | not inspect.isbuiltin(func) 472 | and not inspect.isfunction(func) 473 | and not inspect.ismethod(func) 474 | and not func.__class__ == functools.partial 475 | and func.__class__ != _CallbackWrapper 476 | and not getattr(func, "__CALLBACK_KEEP_STRONG_REFERENCE__", False) 477 | ) 478 | 479 | 480 | @attr.s(auto_attribs=True) 481 | class UnregisterContext: 482 | """ 483 | Returned by Register(), supports easy removal of the callback later. 484 | 485 | Useful if many related callbacks are registered, so the contexts can be stored and used to 486 | unregister all the callbacks at once. 487 | 488 | Note: this class was called `_UnregisterContext` initially, but for type-checking purposes 489 | we made it public. The old name is still available for backward compatibility. 490 | """ 491 | 492 | _callback: Callback 493 | _key: Hashable 494 | 495 | def Unregister(self) -> None: 496 | """Unregister the callback which returned this context""" 497 | self._callback._UnregisterByKey(self._key) 498 | 499 | 500 | # Backward compatibility alias. 501 | _UnregisterContext = UnregisterContext 502 | 503 | 504 | class _CallbackWrapper(Method): 505 | def __init__(self, weak_method_callback: Callable) -> None: 506 | self.weak_method_callback = weak_method_callback 507 | 508 | # Maintaining the OriginalMethod() interface that clients expect. 509 | self.OriginalMethod = weak_method_callback 510 | 511 | def __call__(self, sender: Any, *args: object, **kwargs: object) -> None: 512 | c = self.weak_method_callback() 513 | if c is None: 514 | raise ReferenceError( 515 | "This should never happen: The sender already died, so, " 516 | "how can this method still be called?" 517 | ) 518 | c(sender(), *args, **kwargs) 519 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/callback/_callbacks.py: -------------------------------------------------------------------------------- 1 | # mypy: disallow-untyped-defs 2 | # mypy: disallow-any-decorated 3 | from typing import Any 4 | from typing import List 5 | from typing import Tuple 6 | from typing import TypeVar 7 | from typing import cast 8 | 9 | from collections.abc import Callable 10 | 11 | from ._callback import Callback 12 | from ._callback import UnregisterContext 13 | from ._shortcuts import After 14 | from ._shortcuts import Before 15 | from ._shortcuts import Remove 16 | 17 | T = TypeVar("T", bound=Callable) 18 | 19 | 20 | class Callbacks: 21 | """ 22 | Holds created callbacks, making it easy to disconnect later. 23 | 24 | This class provides two methods of operation: 25 | 26 | * :meth:`Before` and :meth:`After`: 27 | 28 | This provides connection support for arbitrary functions 29 | and methods, similar to mocking them. 30 | 31 | * :meth:`Register`: 32 | 33 | Registers a function into a :class:`Callback`, making the callback 34 | call the registered function when it gets itself called. 35 | 36 | In both modes, :meth:`RemoveAll` can be used to unregister all callbacks. 37 | 38 | The class can also be used in context-manager form, in which case all callbacks 39 | are unregistered when the context-manager ends. 40 | 41 | .. note:: 42 | This class keeps a strong reference to the callback and the sender, thus 43 | they won't be garbage-collected while still connected. 44 | """ 45 | 46 | def __init__(self) -> None: 47 | self._function_callbacks: list[tuple[Callable, Callable]] = [] 48 | self._contexts: list[UnregisterContext] = [] 49 | 50 | def Before( 51 | self, sender: T, callback: Callable, *, sender_as_parameter: bool = False 52 | ) -> T: 53 | """ 54 | Registers a callback to be executed before an arbitrary function. 55 | 56 | Example:: 57 | 58 | class C: 59 | def foo(self, x): ... 60 | 61 | def callback(x): ... 62 | 63 | 64 | Before(C.foo, callback) 65 | 66 | The call above will result in ``callback`` to be called for *every instance* of ``C``. 67 | """ 68 | sender = cast( 69 | T, Before(sender, callback, sender_as_parameter=sender_as_parameter) 70 | ) 71 | self._function_callbacks.append((sender, callback)) 72 | return sender 73 | 74 | def After( 75 | self, sender: T, callback: Callable, *, sender_as_parameter: bool = False 76 | ) -> T: 77 | """ 78 | Same as :meth:`Before`, but will call the callback after the ``sender`` function has 79 | been called. 80 | """ 81 | sender = cast( 82 | T, After(sender, callback, sender_as_parameter=sender_as_parameter) 83 | ) 84 | self._function_callbacks.append((sender, callback)) 85 | return sender 86 | 87 | def RemoveAll(self) -> None: 88 | """ 89 | Remove all registered functions, either from :meth:`Before`, :meth:`After`, or 90 | :meth:`Register`. 91 | """ 92 | for sender, callback in self._function_callbacks: 93 | Remove(sender, callback) 94 | self._function_callbacks.clear() 95 | for context in self._contexts: 96 | context.Unregister() 97 | self._contexts.clear() 98 | 99 | def __enter__(self) -> "Callbacks": 100 | """Context manager support: when the context ends, unregister all callbacks.""" 101 | return self 102 | 103 | def __exit__(self, *args: object) -> None: 104 | """Context manager support: when the context ends, unregister all callbacks.""" 105 | self.RemoveAll() 106 | 107 | def Register(self, callback: Callback, func: Callable) -> None: 108 | """ 109 | Registers the given function into the given callback. 110 | 111 | This will automatically unregister the function from the given callback when 112 | :meth:`Callbacks.RemoveAll` is called or the context manager ends in the context manager form. 113 | """ 114 | self._contexts.append(callback.Register(func)) 115 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/callback/_priority_callback.py: -------------------------------------------------------------------------------- 1 | # mypy: disallow-untyped-defs 2 | # mypy: disallow-any-decorated 3 | from typing import Any 4 | from typing import Tuple 5 | 6 | from collections.abc import Callable 7 | 8 | from oop_ext.foundation.decorators import Override 9 | from oop_ext.foundation.odict import odict 10 | 11 | from ._callback import Callback 12 | from ._callback import UnregisterContext 13 | 14 | 15 | class PriorityCallback(Callback): 16 | """ 17 | Class that's able to give a priority to the added callbacks when they're registered. 18 | """ 19 | 20 | INFO_POS_PRIORITY = 3 21 | 22 | @Override(Callback._GetInfo) 23 | def _GetInfo( # type:ignore[misc, override] 24 | self, func: Callable, priority: int 25 | ) -> Any: 26 | """ 27 | Overridden to add the priority to the info. 28 | 29 | :param priority: 30 | The priority to be set to the added callback. 31 | """ 32 | info = Callback._GetInfo(self, func) 33 | return info + (priority,) 34 | 35 | @Override(Callback.Register) 36 | def Register( # type:ignore[misc, override] 37 | self, 38 | func: Callable, 39 | extra_args: tuple[object, ...] = Callback._EXTRA_ARGS_CONSTANT, 40 | priority: int = 5, 41 | ) -> UnregisterContext: 42 | """ 43 | Register a function in the callback. 44 | :param object func: 45 | Method or function that will be called later. 46 | 47 | :param int priority: 48 | If passed, it'll be be used to put the callback into the correct place based on the 49 | priority passed (lower numbers have higher priority). 50 | """ 51 | if extra_args is not self._EXTRA_ARGS_CONSTANT: 52 | extra_args = tuple(extra_args) 53 | 54 | key = self._GetKey(func, extra_args) 55 | try: 56 | callbacks = self._callbacks 57 | except AttributeError: 58 | callbacks = self._callbacks = odict() 59 | 60 | callbacks.pop(key, None) # Remove if it exists 61 | new_info = self._GetInfo(func, priority) 62 | 63 | i = 0 64 | for i, (info, _extra) in enumerate(callbacks.values()): 65 | if info[self.INFO_POS_PRIORITY] > priority: 66 | break 67 | else: 68 | # Iterated all... so, go one more the last position. 69 | i += 1 70 | 71 | callbacks.insert(i, key, (new_info, extra_args)) 72 | return UnregisterContext(self, key) 73 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/callback/_shortcuts.py: -------------------------------------------------------------------------------- 1 | # mypy: disallow-untyped-defs 2 | # mypy: disallow-any-decorated 3 | from typing import Any 4 | from typing import Optional 5 | from typing import Tuple 6 | from typing import Union 7 | 8 | import weakref 9 | from collections.abc import Callable 10 | from collections.abc import Sequence 11 | 12 | from oop_ext.foundation.types_ import Method 13 | from oop_ext.foundation.weak_ref import WeakMethodRef 14 | 15 | from ._callback import Callback 16 | from ._callback import GetClassForUnboundMethod 17 | from ._callback import _CallbackWrapper 18 | 19 | 20 | def _CreateBeforeOrAfter( 21 | method: Callable, callback: Callable, sender_as_parameter: bool, before: bool = True 22 | ) -> "_MethodWrapper": 23 | wrapper = WrapForCallback(method) 24 | original_method = wrapper.OriginalMethod() 25 | 26 | extra_args = [] 27 | 28 | if sender_as_parameter: 29 | try: 30 | im_self = original_method.__self__ 31 | except AttributeError: 32 | pass 33 | else: 34 | extra_args.append(weakref.ref(im_self)) 35 | 36 | # this is not garbage collected directly when added to the wrapper (which will create a WeakMethodRef to it) 37 | # because it's not a real method, so, WeakMethodRef will actually maintain a strong reference to it. 38 | callback = _CallbackWrapper(WeakMethodRef(callback)) 39 | 40 | if before: 41 | wrapper.AppendBefore(callback, extra_args) 42 | else: 43 | wrapper.AppendAfter(callback, extra_args) 44 | 45 | return wrapper 46 | 47 | 48 | def Before( 49 | method: Callable, callback: Callable, sender_as_parameter: bool = False 50 | ) -> "_MethodWrapper": 51 | """ 52 | Registers the given callback to be executed before the given method is called, with the 53 | same arguments. 54 | 55 | The method can be eiher an unbound method or a bound method. If it is an unbound method, 56 | *all* instances of the class will generate callbacks when method is called. If it is a bound 57 | method, only the method of the instance will generate callbacks. 58 | 59 | Remarks: 60 | The function has changed its signature to accept an extra parameter (sender_as_parameter). 61 | Using "*args" as before made impossible to add new parameters to the function. 62 | """ 63 | return _CreateBeforeOrAfter(method, callback, sender_as_parameter) 64 | 65 | 66 | def After( 67 | method: Callable, callback: Callable, sender_as_parameter: bool = False 68 | ) -> "_MethodWrapper": 69 | """ 70 | Registers the given callbacks to be execute after the given method is called, with the same 71 | arguments. 72 | 73 | The method can be eiher an unbound method or a bound method. If it is an unbound method, 74 | *all* instances of the class will generate callbacks when method is called. If it is a bound 75 | method, only the method of the instance will generate callbacks. 76 | 77 | Remarks: 78 | This function has changed its signature to accept an extra parameter (sender_as_parameter). 79 | Using "*args" as before made impossible to add new parameters to the function. 80 | """ 81 | return _CreateBeforeOrAfter(method, callback, sender_as_parameter, before=False) 82 | 83 | 84 | def Remove(method: Callable, callback: Callable) -> bool: 85 | """ 86 | Removes the given callback from a method previously connected using after or before. 87 | Return true if the callback was removed, false otherwise. 88 | """ 89 | wrapped = _GetWrapped(method) 90 | if wrapped: 91 | return wrapped.Remove(callback) 92 | return False 93 | 94 | 95 | class _MethodWrapper( 96 | Method 97 | ): # It needs to be a subclass of Method for interface checks. 98 | __slots__ = ["_before", "_after", "_method", "_name", "OriginalMethod"] 99 | 100 | def __init__(self, method: Union[Method, "_MethodWrapper", Callable]): 101 | self._before: Callback | None = None 102 | self._after: Callback | None = None 103 | self._method = WeakMethodRef(method) 104 | self._name = method.__name__ 105 | 106 | # Maintaining the OriginalMethod() interface that clients expect. 107 | self.OriginalMethod = self._method 108 | 109 | def __repr__(self) -> str: 110 | return f"_MethodWrapper({id(self)}): {self._name}" 111 | 112 | def __call__(self, *args: object, **kwargs: object) -> Any: 113 | if self._before is not None: 114 | self._before(*args, **kwargs) 115 | 116 | m = self._method() 117 | if m is None: 118 | raise ReferenceError( 119 | "Error: the object that contained this method (%s) has already been garbage collected" 120 | % self._name 121 | ) 122 | 123 | result = m(*args, **kwargs) 124 | 125 | if self._after is not None: 126 | self._after(*args, **kwargs) 127 | 128 | return result 129 | 130 | def AppendBefore( 131 | self, callback: Callable, extra_args: Sequence[object] | None = None 132 | ) -> None: 133 | """ 134 | Append the given callbacks in the list of callback to be executed BEFORE the method. 135 | """ 136 | if extra_args is None: 137 | extra_args = () 138 | 139 | if self._before is None: 140 | self._before = Callback() 141 | self._before.Register(callback, extra_args) 142 | 143 | def AppendAfter( 144 | self, callback: Callable, extra_args: Sequence[object] | None = None 145 | ) -> None: 146 | """ 147 | Append the given callbacks in the list of callback to be executed AFTER the method. 148 | """ 149 | if extra_args is None: 150 | extra_args = [] 151 | 152 | if self._after is None: 153 | self._after = Callback() 154 | self._after.Register(callback, extra_args) 155 | 156 | def Remove(self, callback: Callable) -> bool: 157 | """ 158 | Remove the given callback from both the BEFORE and AFTER callbacks lists. 159 | """ 160 | if self._before is not None and self._before.Contains(callback): 161 | self._before.Unregister(callback) 162 | return True 163 | if self._after is not None and self._after.Contains(callback): 164 | self._after.Unregister(callback) 165 | return True 166 | 167 | return False 168 | 169 | 170 | def _GetWrapped(method: Method | _MethodWrapper | Callable) -> _MethodWrapper | None: 171 | """ 172 | Returns true if the given method is already wrapped. 173 | """ 174 | if isinstance(method, _MethodWrapper): 175 | return method 176 | try: 177 | return method._wrapped_instance # type:ignore[attr-defined, union-attr] 178 | except AttributeError: 179 | return None 180 | 181 | 182 | def WrapForCallback(method: Method | _MethodWrapper | Callable) -> _MethodWrapper: 183 | """Generates a wrapper for the given method, or returns the method itself 184 | if it is already a wrapper. 185 | """ 186 | wrapped = _GetWrapped(method) 187 | if wrapped is not None: 188 | # its a wrapper already 189 | if not hasattr(method, "__self__"): 190 | return wrapped 191 | 192 | # Taking care for the situation where we add a callback to the class and later to the 193 | # instance. 194 | # Note that the other way around does not work at all (i.e.: if a callback is first added 195 | # to the instance, there's no way we'll find about that when adding it to the class 196 | # anyways). 197 | if method.__self__ is None: # type:ignore[union-attr] 198 | if wrapped._method._obj is None: 199 | return wrapped 200 | 201 | wrapper = _MethodWrapper(method) 202 | if getattr(method, "__self__", None) is None: 203 | # override the class method 204 | 205 | # we must make it a regular call for classmethods (it MUST not be a bound 206 | # method nor class when doing that). 207 | def call(*args: object, **kwargs: object) -> Any: 208 | return wrapper(*args, **kwargs) 209 | 210 | call.__name__ = method.__name__ 211 | call._wrapped_instance = wrapper # type:ignore[attr-defined] 212 | 213 | setattr(GetClassForUnboundMethod(method), method.__name__, call) 214 | else: 215 | # override the instance method 216 | setattr(method.__self__, method.__name__, wrapper) # type:ignore[union-attr] 217 | return wrapper 218 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/callback/_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ESSS/oop-ext/0efdefb55dc9a20d8b767cfd8208d3e744a528b6/src/oop_ext/foundation/callback/_tests/__init__.py -------------------------------------------------------------------------------- /src/oop_ext/foundation/callback/_tests/test_priority_callback.py: -------------------------------------------------------------------------------- 1 | from oop_ext.foundation.callback import PriorityCallback0 2 | 3 | 4 | def testPriorityCallback() -> None: 5 | priority_callback = PriorityCallback0() 6 | 7 | called = [] 8 | 9 | def OnCall1(): 10 | called.append(1) 11 | 12 | def OnCall2(): 13 | called.append(2) 14 | 15 | def OnCall3(): 16 | called.append(3) 17 | 18 | def OnCall4(): 19 | called.append(4) 20 | 21 | def OnCall5(): 22 | called.append(5) 23 | 24 | priority_callback.Register(OnCall1, priority=2) 25 | priority_callback.Register(OnCall2, priority=2) 26 | priority_callback.Register(OnCall3, priority=1) 27 | priority_callback.Register(OnCall4, priority=3) 28 | unregister5 = priority_callback.Register(OnCall5, priority=2) 29 | 30 | priority_callback() 31 | assert called == [3, 1, 2, 5, 4] 32 | 33 | called.clear() 34 | unregister5.Unregister() 35 | priority_callback() 36 | assert called == [3, 1, 2, 4] 37 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/callback/_tests/test_single_call_callback.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from oop_ext.foundation.callback.single_call_callback import SingleCallCallback 4 | 5 | 6 | def testSingleCallCallback() -> None: 7 | class Stub: 8 | pass 9 | 10 | stub = Stub() 11 | callback = SingleCallCallback(stub) 12 | 13 | called = [] 14 | 15 | def Method1(arg): 16 | called.append(arg) 17 | 18 | def Method2(arg): 19 | called.append(arg) 20 | 21 | def Method3(arg): 22 | called.append(arg) 23 | 24 | callback.Register(Method1) 25 | 26 | callback() 27 | 28 | assert called == [stub] 29 | 30 | with pytest.raises(AssertionError): 31 | callback() 32 | 33 | assert called == [stub] 34 | 35 | callback.Register(Method1) # It was already there, so, won't be called again. 36 | 37 | assert called == [stub] 38 | 39 | callback.Register(Method2) 40 | 41 | assert called == [stub, stub] 42 | 43 | del stub 44 | del called[:] 45 | 46 | with pytest.raises(ReferenceError): 47 | callback.Register(Method1) 48 | 49 | 50 | def testSingleCallCallbackNoParameter() -> None: 51 | class Stub: 52 | pass 53 | 54 | callback = SingleCallCallback(None) 55 | 56 | called = [] 57 | 58 | def Method1(): 59 | called.append("Method1") 60 | 61 | def Method2(): 62 | called.append("Method2") 63 | 64 | callback.Register(Method1) 65 | 66 | callback() 67 | 68 | assert called == ["Method1"] 69 | 70 | callback.Register(Method2) 71 | 72 | assert called == ["Method1", "Method2"] 73 | 74 | with pytest.raises(AssertionError): 75 | callback() 76 | 77 | callback.AllowCallingAgain() 78 | callback() 79 | 80 | assert called == ["Method1", "Method2", "Method1", "Method2"] 81 | 82 | callback.Register(Method1) 83 | assert called == ["Method1", "Method2", "Method1", "Method2"] 84 | 85 | callback.Unregister(Method1) 86 | callback.Register(Method1) 87 | assert called == ["Method1", "Method2", "Method1", "Method2", "Method1"] 88 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/callback/_tests/test_typed_callback.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import pytest 4 | import re 5 | 6 | from oop_ext._type_checker_fixture import TypeCheckerFixture 7 | 8 | 9 | def testCallback0(type_checker: TypeCheckerFixture) -> None: 10 | type_checker.make_file( 11 | """ 12 | from oop_ext.foundation.callback import Callback0 13 | 14 | c = Callback0() 15 | c(10) 16 | 17 | def fail(x): pass 18 | c.Register(fail) 19 | """ 20 | ) 21 | result = type_checker.run() 22 | result.assert_errors( 23 | [ 24 | 'Too many arguments for "__call__"', 25 | re.escape('incompatible type "Callable[[Any], Any]"'), 26 | ] 27 | ) 28 | 29 | type_checker.make_file( 30 | """ 31 | from oop_ext.foundation.callback import Callback0 32 | 33 | c = Callback0() 34 | c() 35 | 36 | def ok(): pass 37 | c.Register(ok) 38 | """ 39 | ) 40 | result = type_checker.run() 41 | result.assert_ok() 42 | 43 | 44 | def testCallback1(type_checker: TypeCheckerFixture) -> None: 45 | type_checker.make_file( 46 | """ 47 | from oop_ext.foundation.callback import Callback1 48 | 49 | c = Callback1[int]() 50 | c() 51 | c(10, 10) 52 | 53 | def fail(): pass 54 | c.Register(fail) 55 | 56 | def fail2(x: str): pass 57 | c.Register(fail2) 58 | """ 59 | ) 60 | result = type_checker.run() 61 | result.assert_errors( 62 | [ 63 | "Missing positional argument", 64 | 'Too many arguments for "__call__" of "Callback1"', 65 | re.escape( 66 | 'Argument 1 to "Register" of "Callback1" has incompatible type "Callable[[], Any]"' 67 | ), 68 | re.escape( 69 | 'Argument 1 to "Register" of "Callback1" has incompatible type "Callable[[str], Any]"' 70 | ), 71 | ] 72 | ) 73 | 74 | type_checker.make_file( 75 | """ 76 | from oop_ext.foundation.callback import Callback1 77 | 78 | c = Callback1[int]() 79 | c(10) 80 | 81 | def ok(x: int): pass 82 | c.Register(ok) 83 | """ 84 | ) 85 | result = type_checker.run() 86 | result.assert_ok() 87 | 88 | 89 | def testPriorityCallback0(type_checker: TypeCheckerFixture) -> None: 90 | type_checker.make_file( 91 | """ 92 | from oop_ext.foundation.callback import PriorityCallback0 93 | 94 | c = PriorityCallback0() 95 | c(10) 96 | 97 | def fail(x): pass 98 | c.Register(fail, priority=2) 99 | """ 100 | ) 101 | result = type_checker.run() 102 | result.assert_errors( 103 | [ 104 | 'Too many arguments for "__call__"', 105 | re.escape('incompatible type "Callable[[Any], Any]"'), 106 | ] 107 | ) 108 | 109 | type_checker.make_file( 110 | """ 111 | from oop_ext.foundation.callback import PriorityCallback0 112 | 113 | c = PriorityCallback0() 114 | c() 115 | 116 | def ok(): pass 117 | c.Register(ok, priority=2) 118 | """ 119 | ) 120 | result = type_checker.run() 121 | result.assert_ok() 122 | 123 | 124 | def testPriorityCallback1(type_checker: TypeCheckerFixture) -> None: 125 | type_checker.make_file( 126 | """ 127 | from oop_ext.foundation.callback import PriorityCallback1 128 | 129 | c = PriorityCallback1[int]() 130 | c() 131 | c(10, 10) 132 | 133 | def fail(): pass 134 | c.Register(fail, priority=2) 135 | 136 | def fail2(x: str): pass 137 | c.Register(fail2, priority=2) 138 | """ 139 | ) 140 | result = type_checker.run() 141 | result.assert_errors( 142 | [ 143 | "Missing positional argument", 144 | 'Too many arguments for "__call__" of "PriorityCallback1"', 145 | re.escape( 146 | 'Argument 1 to "Register" of "PriorityCallback1" has incompatible type "Callable[[], Any]"' 147 | ), 148 | re.escape( 149 | 'Argument 1 to "Register" of "PriorityCallback1" has incompatible type "Callable[[str], Any]"' 150 | ), 151 | ] 152 | ) 153 | 154 | type_checker.make_file( 155 | """ 156 | from oop_ext.foundation.callback import PriorityCallback1 157 | 158 | c = PriorityCallback1[int]() 159 | c(10) 160 | 161 | def ok(x: int): pass 162 | c.Register(ok, priority=2) 163 | """ 164 | ) 165 | result = type_checker.run() 166 | result.assert_ok() 167 | 168 | 169 | @pytest.mark.parametrize("args_count", [1, 2, 3, 4, 5]) 170 | def testAllCallbacksSmokeTest( 171 | args_count: int, type_checker: TypeCheckerFixture 172 | ) -> None: 173 | """ 174 | Parametrized test to do basic checking over all Callbacks (except Callback0). 175 | 176 | We generate functions with too much arguments, too few, and correct number, and check 177 | that the errors are as expected. 178 | 179 | This should be enough to catch copy/paste errors when declaring the 180 | Callback overloads. 181 | """ 182 | 183 | def gen_signature_and_args(count: int) -> tuple[str, str, str]: 184 | # Generates "v1: int, v2: int" etc 185 | signature = ", ".join(f"v{i}: int" for i in range(count)) 186 | # Generates "10, 20" etc 187 | args = ", ".join(f"{i+1}0" for i in range(count)) 188 | # Generates "int, int" etc 189 | types = ", ".join("int" for _ in range(count)) 190 | return signature, args, types 191 | 192 | sig_too_few, args_too_few, types_too_few = gen_signature_and_args(args_count - 1) 193 | sig_too_many, args_too_many, types_too_many = gen_signature_and_args(args_count + 1) 194 | sig_ok, args_ok, types_ok = gen_signature_and_args(args_count) 195 | 196 | type_checker.make_file( 197 | f""" 198 | from oop_ext.foundation.callback import Callback{args_count} 199 | 200 | c = Callback{args_count}[{types_ok}]() 201 | 202 | def too_few_func({sig_too_few}) -> None: ... 203 | c.Register(too_few_func) 204 | c({args_too_few}) 205 | 206 | def too_many_func({sig_too_many}) -> None: ... 207 | c.Register(too_many_func) 208 | c({args_too_many}) 209 | 210 | def ok_func({sig_ok}) -> None: ... 211 | c.Register(ok_func) 212 | c({args_ok}) 213 | """ 214 | ) 215 | result = type_checker.run() 216 | result.assert_errors( 217 | [ 218 | "has incompatible type", 219 | "Missing positional argument", 220 | "has incompatible type", 221 | "Too many arguments", 222 | ] 223 | ) 224 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/callback/_typed_callback.py: -------------------------------------------------------------------------------- 1 | # mypy: disallow-untyped-defs 2 | # mypy: disallow-any-decorated 3 | """ 4 | Implement specializations of Callback and PriorityCallback which are type-checker friendly. 5 | 6 | Each new Callback variant (Callback0, Callback1, Callback2, etc) subclasses ``Callback``, but 7 | explicitly declare the signature of each method so it only accepts the correct number and type 8 | of arguments of the declaration. Same for `PriorityCallback`. 9 | 10 | Also, the method signatures are only seen by the type checker, so using one of the specialized 11 | variants should have nearly zero runtime cost (only the cost of an empty subclass). 12 | 13 | Implemented so far up to 5 arguments, more can be added if we think it is necessary. 14 | 15 | Note the separate classes are needed for now, but after Python 3.11, we should be able to 16 | implement the generic variants (`pep-0646 `__) into ``Callback`` itself. 17 | """ 18 | from typing import TYPE_CHECKING 19 | from typing import Generic 20 | from typing import TypeVar 21 | 22 | from collections.abc import Callable 23 | from collections.abc import Sequence 24 | 25 | from ._callback import Callback 26 | from ._callback import UnregisterContext 27 | from ._priority_callback import PriorityCallback 28 | 29 | T1 = TypeVar("T1") 30 | T2 = TypeVar("T2") 31 | T3 = TypeVar("T3") 32 | T4 = TypeVar("T4") 33 | T5 = TypeVar("T5") 34 | 35 | 36 | class Callback0(Callback): 37 | if TYPE_CHECKING: 38 | 39 | def __call__(self) -> None: # type:ignore[override] 40 | ... 41 | 42 | def Register( 43 | self, 44 | func: Callable[[], None], 45 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 46 | ) -> "UnregisterContext": ... 47 | 48 | def Unregister( 49 | self, 50 | func: Callable[[], None], 51 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 52 | ) -> None: ... 53 | 54 | def Contains( 55 | self, 56 | func: Callable[[], None], 57 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 58 | ) -> bool: ... 59 | 60 | 61 | class Callback1(Callback, Generic[T1]): 62 | if TYPE_CHECKING: 63 | 64 | def __call__(self, v1: T1) -> None: # type:ignore[override] 65 | ... 66 | 67 | def Register( 68 | self, 69 | func: Callable[[T1], None], 70 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 71 | ) -> "UnregisterContext": ... 72 | 73 | def Unregister( 74 | self, 75 | func: Callable[[T1], None], 76 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 77 | ) -> None: ... 78 | 79 | def Contains( 80 | self, 81 | func: Callable[[T1], None], 82 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 83 | ) -> bool: ... 84 | 85 | 86 | class Callback2(Callback, Generic[T1, T2]): 87 | if TYPE_CHECKING: 88 | 89 | def __call__(self, v1: T1, v2: T2) -> None: # type:ignore[override] 90 | ... 91 | 92 | def Register( 93 | self, 94 | func: Callable[[T1, T2], None], 95 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 96 | ) -> "UnregisterContext": ... 97 | 98 | def Unregister( 99 | self, 100 | func: Callable[[T1, T2], None], 101 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 102 | ) -> None: ... 103 | 104 | def Contains( 105 | self, 106 | func: Callable[[T1, T2], None], 107 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 108 | ) -> bool: ... 109 | 110 | 111 | class Callback3(Callback, Generic[T1, T2, T3]): 112 | if TYPE_CHECKING: 113 | 114 | def __call__(self, v1: T1, v2: T2, v3: T3) -> None: # type:ignore[override] 115 | ... 116 | 117 | def Register( 118 | self, 119 | func: Callable[[T1, T2, T3], None], 120 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 121 | ) -> "UnregisterContext": ... 122 | 123 | def Unregister( 124 | self, 125 | func: Callable[[T1, T2, T3], None], 126 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 127 | ) -> None: ... 128 | 129 | def Contains( 130 | self, 131 | func: Callable[[T1, T2, T3], None], 132 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 133 | ) -> bool: ... 134 | 135 | 136 | class Callback4(Callback, Generic[T1, T2, T3, T4]): 137 | if TYPE_CHECKING: 138 | 139 | def __call__( # type:ignore[override] 140 | self, v1: T1, v2: T2, v3: T3, v4: T4 141 | ) -> None: ... 142 | 143 | def Register( 144 | self, 145 | func: Callable[[T1, T2, T3, T4], None], 146 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 147 | ) -> "UnregisterContext": ... 148 | 149 | def Unregister( 150 | self, 151 | func: Callable[[T1, T2, T3, T4], None], 152 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 153 | ) -> None: ... 154 | 155 | def Contains( 156 | self, 157 | func: Callable[[T1, T2, T3, T4], None], 158 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 159 | ) -> bool: ... 160 | 161 | 162 | class Callback5(Callback, Generic[T1, T2, T3, T4, T5]): 163 | if TYPE_CHECKING: 164 | 165 | def __call__( # type:ignore[override] 166 | self, v1: T1, v2: T2, v3: T3, v4: T4, v5: T5 167 | ) -> None: ... 168 | 169 | def Register( 170 | self, 171 | func: Callable[[T1, T2, T3, T4, T5], None], 172 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 173 | ) -> "UnregisterContext": ... 174 | 175 | def Unregister( 176 | self, 177 | func: Callable[[T1, T2, T3, T4, T5], None], 178 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 179 | ) -> None: ... 180 | 181 | def Contains( 182 | self, 183 | func: Callable[[T1, T2, T3, T4, T5], None], 184 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 185 | ) -> bool: ... 186 | 187 | 188 | class PriorityCallback0(PriorityCallback): 189 | if TYPE_CHECKING: 190 | 191 | def __call__(self) -> None: # type:ignore[override] 192 | ... 193 | 194 | def Register( 195 | self, 196 | func: Callable[[], None], 197 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 198 | priority: int = 5, 199 | ) -> "UnregisterContext": ... 200 | 201 | def Unregister( 202 | self, 203 | func: Callable[[], None], 204 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 205 | ) -> None: ... 206 | 207 | def Contains( 208 | self, 209 | func: Callable[[], None], 210 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 211 | ) -> bool: ... 212 | 213 | 214 | class PriorityCallback1(PriorityCallback, Generic[T1]): 215 | if TYPE_CHECKING: 216 | 217 | def __call__( # type:ignore[override] 218 | self, v1: T1 219 | ) -> None: ... 220 | 221 | def Register( 222 | self, 223 | func: Callable[[T1], None], 224 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 225 | priority: int = 5, 226 | ) -> "UnregisterContext": ... 227 | 228 | def Unregister( 229 | self, 230 | func: Callable[[T1], None], 231 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 232 | ) -> None: ... 233 | 234 | def Contains( 235 | self, 236 | func: Callable[[T1], None], 237 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 238 | ) -> bool: ... 239 | 240 | 241 | class PriorityCallback2(PriorityCallback, Generic[T1, T2]): 242 | if TYPE_CHECKING: 243 | 244 | def __call__( # type:ignore[override] 245 | self, v1: T1, v2: T2 246 | ) -> None: ... 247 | 248 | def Register( 249 | self, 250 | func: Callable[[T1, T2], None], 251 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 252 | priority: int = 5, 253 | ) -> "UnregisterContext": ... 254 | 255 | def Unregister( 256 | self, 257 | func: Callable[[T1, T2], None], 258 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 259 | ) -> None: ... 260 | 261 | def Contains( 262 | self, 263 | func: Callable[[T1, T2], None], 264 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 265 | ) -> bool: ... 266 | 267 | 268 | class PriorityCallback3(PriorityCallback, Generic[T1, T2, T3]): 269 | if TYPE_CHECKING: 270 | 271 | def __call__( # type:ignore[override] 272 | self, v1: T1, v2: T2, v3: T3 273 | ) -> None: ... 274 | 275 | def Register( 276 | self, 277 | func: Callable[[T1, T2, T3], None], 278 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 279 | priority: int = 5, 280 | ) -> "UnregisterContext": ... 281 | 282 | def Unregister( 283 | self, 284 | func: Callable[[T1, T2, T3], None], 285 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 286 | ) -> None: ... 287 | 288 | def Contains( 289 | self, 290 | func: Callable[[T1, T2, T3], None], 291 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 292 | ) -> bool: ... 293 | 294 | 295 | class PriorityCallback4(PriorityCallback, Generic[T1, T2, T3, T4]): 296 | if TYPE_CHECKING: 297 | 298 | def __call__( # type:ignore[override] 299 | self, v1: T1, v2: T2, v3: T3, v4: T4 300 | ) -> None: ... 301 | 302 | def Register( 303 | self, 304 | func: Callable[[T1, T2, T3, T4], None], 305 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 306 | priority: int = 5, 307 | ) -> "UnregisterContext": ... 308 | 309 | def Unregister( 310 | self, 311 | func: Callable[[T1, T2, T3, T4], None], 312 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 313 | ) -> None: ... 314 | 315 | def Contains( 316 | self, 317 | func: Callable[[T1, T2, T3, T4], None], 318 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 319 | ) -> bool: ... 320 | 321 | 322 | class PriorityCallback5(PriorityCallback, Generic[T1, T2, T3, T4, T5]): 323 | if TYPE_CHECKING: 324 | 325 | def __call__( # type:ignore[override] 326 | self, v1: T1, v2: T2, v3: T3, v4: T4, v5: T5 327 | ) -> None: ... 328 | 329 | def Register( 330 | self, 331 | func: Callable[[T1, T2, T2, T3, T4], None], 332 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 333 | priority: int = 5, 334 | ) -> "UnregisterContext": ... 335 | 336 | def Unregister( 337 | self, 338 | func: Callable[[T1, T2, T3, T4, T5], None], 339 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 340 | ) -> None: ... 341 | 342 | def Contains( 343 | self, 344 | func: Callable[[T1, T2, T3, T4, T5], None], 345 | extra_args: Sequence[object] = Callback._EXTRA_ARGS_CONSTANT, 346 | ) -> bool: ... 347 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/callback/single_call_callback.py: -------------------------------------------------------------------------------- 1 | # mypy: disallow-untyped-defs 2 | # mypy: disallow-any-decorated 3 | from typing import Dict 4 | from typing import Tuple 5 | 6 | from collections.abc import Callable 7 | 8 | from ._callback import Callback 9 | 10 | 11 | class SingleCallCallback: 12 | """ 13 | Callback-like implementation used for a callback to which __call__ is called only once (and 14 | subsequent calls will always trigger the same callback). 15 | 16 | The callback parameter is pre-registered and kept as a weak-reference. 17 | """ 18 | 19 | def __init__(self, callback_parameter: object) -> None: 20 | """ 21 | :param object callback_parameter: 22 | A weak-reference is kept to this object (because the usual use-case is making a call 23 | passing the object that contains this callback). 24 | """ 25 | from oop_ext.foundation.weak_ref import GetWeakRef 26 | 27 | if callback_parameter is None: 28 | self._callback_parameter = None 29 | else: 30 | self._callback_parameter = GetWeakRef(callback_parameter) 31 | self._done_callbacks = Callback() 32 | self._done = False 33 | 34 | self._args: tuple[object, ...] = () 35 | self._kwargs: dict[str, object] = {} 36 | 37 | def __call__(self, *args: object, **kwargs: object) -> None: 38 | if self._done: 39 | raise AssertionError("This callback can only be called once.") 40 | 41 | # Keep the args passed to call it later on... 42 | self._args = args 43 | self._kwargs = kwargs 44 | 45 | if self._callback_parameter is not None: 46 | callback_parameter = self._callback_parameter() 47 | if callback_parameter is None: 48 | raise ReferenceError("Callback parameter is already garbage collected.") 49 | else: 50 | callback_parameter = None 51 | 52 | # We can dispose of it (as of now, callbacks should be called directly). 53 | self._done = True 54 | if callback_parameter is not None: 55 | self._done_callbacks(callback_parameter, *args, **kwargs) 56 | else: 57 | self._done_callbacks(*args, **kwargs) 58 | 59 | def Unregister(self, fn: Callable) -> None: 60 | self._done_callbacks.Unregister(fn) 61 | 62 | def UnregisterAll(self) -> None: 63 | self._done_callbacks.UnregisterAll() 64 | 65 | def Register(self, fn: Callable) -> None: 66 | if self._callback_parameter is not None: 67 | callback_parameter = self._callback_parameter() 68 | if callback_parameter is None: 69 | raise ReferenceError("Callback parameter is already garbage collected.") 70 | else: 71 | callback_parameter = None 72 | 73 | contains = self._done_callbacks.Contains(fn) 74 | 75 | self._done_callbacks.Register(fn) 76 | if self._done and not contains: 77 | if callback_parameter is not None: 78 | fn(callback_parameter, *self._args, **self._kwargs) 79 | else: 80 | fn(*self._args, **self._kwargs) 81 | 82 | def AllowCallingAgain(self) -> None: 83 | """ 84 | This callback is usually called only once, afterwards, any registry will call it directly 85 | (and the callback cannot be called anymore). 86 | 87 | By calling this method, we allow calling this callback again (and stop directly notifying 88 | clients just registered until it's called again). 89 | """ 90 | self._done = False 91 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/compat.py: -------------------------------------------------------------------------------- 1 | # mypy: disallow-untyped-defs 2 | """ 3 | A compatibility module for quirks when porting from py2->py3. 4 | """ 5 | from typing import Any 6 | 7 | 8 | def GetClassForUnboundMethod(method: Any) -> Any: 9 | """ 10 | On Python 3 there are no unbound methods anymore. They are only regular functions. 11 | 12 | This function abstracts that difference and implements a workaround for Python 3. 13 | 14 | However this has a drawback: callback to method of local classes AREN'T SUPPORTED anymore, 15 | as it is impossible to retrieve their class object just by method object alone. 16 | """ 17 | locals_name = "" 18 | 19 | # Find the class which this method belongs too. We need this because on Python 3, unbound 20 | # methods are just regular functions with no reference to its class 21 | names = method.__qualname__.split(".") 22 | names.pop() 23 | method_class = method.__globals__[names.pop(0)] 24 | while names: 25 | name = names.pop(0) 26 | if name == locals_name: 27 | raise NotImplementedError( 28 | "Impossible to retrieve class object for " 29 | "unbound methods in local classes." 30 | ) 31 | 32 | method_class = getattr(method_class, name) 33 | return method_class 34 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/decorators.py: -------------------------------------------------------------------------------- 1 | # mypy: disallow-untyped-defs 2 | """ 3 | Collection of decorator with ONLY standard library dependencies. 4 | """ 5 | from typing import TYPE_CHECKING 6 | from typing import Any 7 | from typing import NoReturn 8 | from typing import Optional 9 | from typing import TypeVar 10 | from typing import cast 11 | 12 | import warnings 13 | from collections.abc import Callable 14 | 15 | from oop_ext.foundation.is_frozen import IsDevelopment 16 | 17 | F = TypeVar("F", bound=Callable[..., Any]) 18 | G = TypeVar("G", bound=Callable[..., Any]) 19 | 20 | 21 | def Override(method: G) -> Callable[[F], F]: 22 | """ 23 | Decorator that marks that a method overrides a method in the superclass. 24 | 25 | :param type method: 26 | The overridden method 27 | 28 | :returns function: 29 | The decorated function 30 | 31 | .. note:: This decorator actually works by only making the user to access the class and the overridden method at 32 | class level scope, so if in the future that method gets deleted or renamed, the import of the decorated method will 33 | fail. 34 | 35 | Example:: 36 | 37 | class MyInterface: 38 | def foo(): 39 | pass 40 | 41 | class MyClass(MyInterface): 42 | 43 | @Overrides(MyInterace.foo) 44 | def foo(): 45 | pass 46 | """ 47 | 48 | def Wrapper(func: F) -> F: 49 | if func.__name__ != method.__name__: 50 | msg = "Wrong @Override: %r expected, but overwriting %r." 51 | msg = msg % (func.__name__, method.__name__) 52 | raise AssertionError(msg) 53 | 54 | if func.__doc__ is None: 55 | func.__doc__ = method.__doc__ 56 | 57 | return func 58 | 59 | return Wrapper 60 | 61 | 62 | def Implements(method: G) -> Callable[[F], F]: 63 | """ 64 | Decorator that marks that a method implements a method in some interface. 65 | 66 | :param function method: 67 | The implemented method 68 | 69 | :returns function: 70 | The decorated function 71 | 72 | :raises AssertionError: 73 | if the implementation method's name is different from the one 74 | that is being defined. This is a common error when copying/pasting the @Implements code. 75 | 76 | .. note:: This decorator actually works by only making the user to access the class and the implemented method at 77 | class level scope, so if in the future that method gets deleted or renamed, the import of the decorated method will 78 | fail. 79 | 80 | Example:: 81 | 82 | class MyInterface: 83 | def foo(): 84 | pass 85 | 86 | class MyClass(MyInterface): 87 | 88 | @Implements(MyInterace.foo) 89 | def foo(): 90 | pass 91 | """ 92 | 93 | def Wrapper(func: Callable) -> Callable: 94 | if func.__name__ != method.__name__: 95 | msg = "Wrong @Implements: %r expected, but overwriting %r." 96 | msg = msg % (func.__name__, method.__name__) 97 | raise AssertionError(msg) 98 | 99 | if func.__doc__ is None: 100 | func.__doc__ = method.__doc__ 101 | 102 | return func 103 | 104 | return cast(Callable[[F], F], Wrapper) 105 | 106 | 107 | def Deprecated(what: object | None = None) -> Callable[[F], F]: 108 | """ 109 | Decorator that marks a method as deprecated. 110 | 111 | :param what: 112 | Method that replaces the deprecated method, if any. Here it is common to pass 113 | either a function or the name of the method. 114 | """ 115 | if not IsDevelopment(): 116 | # Optimization: we don't want deprecated to add overhead in release mode. 117 | 118 | def DeprecatedDecorator(func: Callable) -> Callable: 119 | return func 120 | 121 | else: 122 | 123 | def DeprecatedDecorator(func: Callable) -> Callable: 124 | """ 125 | The actual deprecated decorator, configured with the name parameter. 126 | """ 127 | 128 | def DeprecatedWrapper(*args: object, **kwargs: object) -> object: 129 | """ 130 | This method wrapper gives a deprecated message before calling the original 131 | implementation. 132 | """ 133 | if what is not None: 134 | msg = "DEPRECATED: '{}' is deprecated, use '{}' instead".format( 135 | func.__name__, 136 | what, 137 | ) 138 | else: 139 | msg = "DEPRECATED: '%s' is deprecated" % func.__name__ 140 | warnings.warn(msg, stacklevel=2) 141 | return func(*args, **kwargs) 142 | 143 | DeprecatedWrapper.__name__ = func.__name__ 144 | DeprecatedWrapper.__doc__ = func.__doc__ 145 | return DeprecatedWrapper 146 | 147 | return cast(Callable[[F], F], DeprecatedDecorator) 148 | 149 | 150 | def Abstract(func: F) -> F: 151 | ''' 152 | Decorator to make methods 'abstract', which are meant to be overwritten in subclasses. If some 153 | subclass doesn't override the method, it will raise NotImplementedError when called. Note that 154 | this decorator should be used together with :dec:Override. 155 | 156 | Example:: 157 | 158 | class Base(object): 159 | 160 | @Abstract 161 | def Foo(self): 162 | """ 163 | This method ... 164 | """ 165 | # no body required here; an exception will be raised automatically 166 | 167 | 168 | class Derived(Base): 169 | 170 | @Override(Base.Foo) 171 | def Foo(self): 172 | ... 173 | 174 | ''' 175 | 176 | # Make sure to use one of the valid general signatures accepted by AssertImplements 177 | # so this decorator can be used in interface implementations. 178 | def AbstractWrapper(self: object, *args: object, **kwargs: object) -> NoReturn: 179 | """ 180 | This wrapper method replaces the implementation of the (abstract) method, providing a 181 | friendly message to the user. 182 | """ 183 | # # Unused argument args, kwargs 184 | # # pylint: disable-msg=W0613 185 | msg = "method {} not implemented in class {}.".format( 186 | repr(func.__name__), repr(self.__class__) 187 | ) 188 | raise NotImplementedError(msg) 189 | 190 | # # Redefining build-in 191 | # # pylint: disable-msg=W0622 192 | AbstractWrapper.__name__ = func.__name__ 193 | AbstractWrapper.__doc__ = func.__doc__ 194 | return cast(F, AbstractWrapper) 195 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/exceptions.py: -------------------------------------------------------------------------------- 1 | # mypy: disallow-untyped-defs 2 | from typing import Optional 3 | 4 | 5 | def ExceptionToUnicode(exception: Exception) -> str: 6 | """ 7 | Python 3 exception handling already deals with string error messages. Here we 8 | will only append the original exception message to the returned message (this is automatically done in Python 2 9 | since the original exception message is added into the new exception while Python 3 keeps the original exception 10 | as a separated attribute 11 | """ 12 | messages = [] 13 | exc: BaseException | None = exception 14 | while exc: 15 | messages.append(str(exc)) 16 | exc = exc.__cause__ or exc.__context__ 17 | return "\n".join(messages) 18 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/immutable.py: -------------------------------------------------------------------------------- 1 | # mypy: disallow-untyped-defs 2 | """ 3 | Defines types and functions to generate immutable structures. 4 | 5 | USER: The cache-manager uses this module to generate a valid KEY for its cache dictionary. 6 | """ 7 | from typing import Any 8 | from typing import Dict 9 | from typing import Generic 10 | from typing import NoReturn 11 | from typing import Tuple 12 | from typing import Type 13 | from typing import TypeVar 14 | 15 | from collections.abc import Callable 16 | 17 | _IMMUTABLE_TYPES = {float, int, str, bytes, bool, type(None)} 18 | 19 | 20 | def RegisterAsImmutable(immutable_type: type[object]) -> None: 21 | """ 22 | Registers the given class as being immutable. This makes it be immutable for this module and 23 | also registers a faster copy in the copy module (to return the same instance being copied). 24 | 25 | :param type immutable_type: 26 | The type to be considered immutable. 27 | """ 28 | _IMMUTABLE_TYPES.add(immutable_type) 29 | 30 | # Fix it for the copy too! 31 | import copy 32 | 33 | copy._copy_dispatch[ # type:ignore[attr-defined] 34 | immutable_type 35 | ] = ( 36 | copy._copy_immutable # type:ignore[attr-defined] 37 | ) 38 | 39 | 40 | def AsImmutable(value: Any, return_str_if_not_expected: bool = True) -> Any: 41 | """ 42 | Returns the given instance as a immutable object: 43 | - Converts lists to tuples 44 | - Converts dicts to ImmutableDicts 45 | - Converts other objects to str 46 | - Does not convert basic types (int/float/str/bool) 47 | 48 | :param object value: 49 | The value to be returned as an immutable value 50 | 51 | :param bool return_str_if_not_expected: 52 | If True, a string representation of the object will be returned if we're unable to match the 53 | type as a known type (otherwise, an error is thrown if we cannot handle the passed type). 54 | 55 | :rtype: object 56 | :returns: 57 | Returns an immutable representation of the passed object 58 | """ 59 | 60 | # Micro-optimization (a 40% improvement on the AsImmutable function overall in a real case 61 | # using sci20 processes). 62 | # Checking the type of the class before going to the isinstance series and added 63 | # SemanticAssociation as an immutable object. 64 | value_class = value.__class__ 65 | 66 | if value_class in _IMMUTABLE_TYPES: 67 | return value 68 | 69 | if value_class == dict: 70 | return ImmutableDict((i, AsImmutable(j)) for i, j in value.items()) 71 | 72 | if value_class in (tuple, list): 73 | return tuple(AsImmutable(i) for i in value) 74 | 75 | if value_class in (set, frozenset): 76 | return frozenset(value) 77 | 78 | # Now, on to the isinstance series... 79 | if isinstance(value, int): 80 | return value 81 | 82 | if isinstance(value, (float, str, bytes, bool)): 83 | return value 84 | 85 | if isinstance(value, dict): 86 | return ImmutableDict((i, AsImmutable(j)) for i, j in value.items()) 87 | 88 | if isinstance(value, (tuple, list)): 89 | return tuple(AsImmutable(i) for i in value) 90 | 91 | if isinstance(value, (set, frozenset)): 92 | return frozenset(value) 93 | 94 | if return_str_if_not_expected: 95 | return str(value) 96 | 97 | else: 98 | raise RuntimeError("Cannot make %s immutable (not supported)." % value) 99 | 100 | 101 | class ImmutableDict(dict): 102 | """A hashable dict.""" 103 | 104 | def __deepcopy__(self, memo: Any) -> "ImmutableDict": 105 | return self # it's immutable, so, there's no real need to make any copy 106 | 107 | def __setitem__(self, key: object, value: object) -> NoReturn: 108 | raise NotImplementedError("dict is immutable") 109 | 110 | def __delitem__(self, key: object) -> NoReturn: 111 | raise NotImplementedError("dict is immutable") 112 | 113 | def clear(self) -> NoReturn: 114 | raise NotImplementedError("dict is immutable") 115 | 116 | def setdefault(self, k: Any, default: Any = None) -> NoReturn: 117 | raise NotImplementedError("dict is immutable") 118 | 119 | def popitem(self) -> NoReturn: 120 | raise NotImplementedError("dict is immutable") 121 | 122 | def update(self, *args: object) -> NoReturn: # type:ignore[override] 123 | raise NotImplementedError("dict is immutable") 124 | 125 | def __hash__(self) -> int: # type:ignore[override] 126 | if not hasattr(self, "_hash"): 127 | # must be sorted (could give different results for dicts that should be the same 128 | # if it's not). 129 | self._hash = hash(tuple(sorted(self.items()))) 130 | 131 | return self._hash 132 | 133 | def AsMutable(self) -> dict: 134 | """ 135 | :rtype: this dict as a new dict that can be changed (without altering the state 136 | of this immutable dict). 137 | """ 138 | return dict(self.items()) 139 | 140 | def __reduce__(self) -> tuple[Callable, tuple[object]]: 141 | """ 142 | Making ImmutableDict work with newer versions of pickle protocol. 143 | 144 | Without this, it uses the default behavior on loading which tries to create an empty dict 145 | and then set its items, which is not an allowed operation on ImmutableDict. 146 | 147 | In general, there are higher level functions to be redefined for pickle customization, but 148 | for dict subclasses we need to define __reduce__ method. For more details of this special 149 | case, see __reduce__ in the referenced docs (links below). 150 | 151 | See also: 152 | - https://docs.python.org/2/library/pickle.html#pickling-and-unpickling-extension-types 153 | - https://docs.python.org/3/library/pickle.html#pickling-class-instances 154 | 155 | :return tuple: 156 | (Callable, tuple of arguments). See __reduce__ docs for more details. 157 | """ 158 | return (ImmutableDict, (list(self.items()),)) 159 | 160 | 161 | T = TypeVar("T") 162 | 163 | 164 | class IdentityHashableRef(Generic[T]): 165 | """ 166 | Represents a immutable reference to an object. 167 | 168 | Useful when is desired to use some mutable object as key in a dict or element in a set. 169 | Any form of overwriting the `__hash__`, `__eq__`, or `__ne__` in the original object is ignored 170 | when taking the hash or comparing the reference (for they to be equal they must point to the 171 | same object and if equal they will have the same hash). 172 | 173 | Usage: 174 | 175 | ``` 176 | foo = NonHashableWithFancyEquality() 177 | ref_to_foo = IdentityHashableRef(foo) 178 | 179 | ref_to_foo() is foo # True 180 | 181 | aset = set() 182 | aset.add(IdentityHashableRef(foo)) 183 | IdentityHashableRef(foo) in aset # True 184 | 185 | adict = dict() 186 | adict[IdentityHashableRef(foo)] = 7 187 | IdentityHashableRef(foo) in adict # True 188 | ``` 189 | """ 190 | 191 | _SENTINEL = object() 192 | 193 | def __init__(self, original: T): 194 | self._original = original 195 | 196 | def __eq__(self, other: object) -> bool: 197 | return self._original is getattr(other, "_original", self._SENTINEL) 198 | 199 | def __ne__(self, other: object) -> bool: 200 | return self._original is not getattr(other, "_original", self._SENTINEL) 201 | 202 | def __hash__(self) -> int: 203 | return id(self._original) 204 | 205 | def __call__(self) -> T: 206 | return self._original 207 | 208 | 209 | RegisterAsImmutable(IdentityHashableRef) 210 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/is_frozen.py: -------------------------------------------------------------------------------- 1 | """ 2 | frozen 3 | Setup the sys.frozen attribute when the application is not in release mode. 4 | This attribute is automatically set when the source code is in an executable. 5 | 6 | Use "IsFrozen" instead of "sys.frozen == False" because some libraries (pywin32) checks for the 7 | attribute existence, not the value. 8 | """ 9 | 10 | import sys 11 | 12 | _is_frozen = hasattr(sys, "frozen") and getattr(sys, "frozen") 13 | 14 | 15 | def IsFrozen() -> bool: 16 | """ 17 | Returns true if the code is frozen, that is, the code is inside a generated executable. 18 | 19 | Frozen == False means the we are running the code using Python interpreter, usually associated with the code being 20 | in development. 21 | """ 22 | return _is_frozen 23 | 24 | 25 | def SetIsFrozen(is_frozen: bool) -> bool: 26 | """ 27 | Sets the is_frozen value manually, overriding the "calculated" value. 28 | 29 | :param bool is_frozen: 30 | The new value for is_frozen. 31 | 32 | :returns bool: 33 | Returns the original value, before the given value is set. 34 | """ 35 | global _is_frozen 36 | try: 37 | return _is_frozen 38 | finally: 39 | _is_frozen = is_frozen 40 | 41 | 42 | _is_development = not _is_frozen 43 | 44 | 45 | def IsDevelopment() -> bool: 46 | """ 47 | This function is used to indentify if we're in a development environment or production 48 | environment. 49 | 50 | :return bool: 51 | Returns True if we're in a development environment or False if we're in a production 52 | environment. 53 | 54 | By default, the "development environment" is understood as not in frozen mode. However, be 55 | careful not think that this will always be equivalent to 'not IsFrozen()'. This could also 56 | return True in frozen environment, particularly when running tests on the executable. 57 | 58 | ..seealso:: SetIsDevelopment to understand why. 59 | """ 60 | return _is_development 61 | 62 | 63 | def SetIsDevelopment(is_development: bool) -> bool: 64 | """ 65 | :param bool is_development: 66 | The new is-development value, which is returned by ..seealso:: IsDevelopment. 67 | 68 | :return bool: 69 | The previous value of is-development property. 70 | 71 | We wanted this method for the following reason: 72 | Some methods we use in our codebase can make some checks/assertions that might be overly time-consuming to 73 | have them running in production code. Therefore, the helper IsDevelopment is used to know if those methods 74 | should run or not. However, due to the fact that we run tests on the executable and we want those methods 75 | to be executed during testing, we need this method to make sure IsDevelopment returns true even in "frozen 76 | environment". 77 | 78 | DevelopmentCheckType is an example of a method using IsDevelopment to be enabled. 79 | 80 | So always mind this difference and think. 81 | """ 82 | global _is_development 83 | try: 84 | return _is_development 85 | finally: 86 | _is_development = is_development 87 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/odict.py: -------------------------------------------------------------------------------- 1 | # mypy: disallow-untyped-defs 2 | from typing import Any 3 | from typing import Union 4 | 5 | import collections 6 | from collections.abc import Hashable 7 | from collections.abc import Iterable 8 | 9 | 10 | class odict(collections.OrderedDict): 11 | def insert( 12 | self, 13 | index: int, 14 | key: Hashable, 15 | value: Any, 16 | dict_setitem: Any = dict.__setitem__, 17 | ) -> None: 18 | """ 19 | Convenience method to have same interface as `ruamel.ordereddict`, which as traditionally 20 | used on Python 2. 21 | """ 22 | self[key] = value 23 | # Determine which direction is cheaper to move items first. If new item is more to the left 24 | # of center, move items to its left to first, otherwise it is cheaper to move items to 25 | # right to last. 26 | # 27 | # Note that `move_to_end` is a O(1) operation that just swaps endpoints of underlying 28 | # double linked list maintained by C-extension ordered dict. 29 | moved: Iterable[Any] 30 | if (len(self) - index) <= (len(self) // 2): 31 | moved = [k for i, k in enumerate(self.keys()) if i >= index and k != key] 32 | last = True 33 | else: 34 | moved = reversed( 35 | [k for i, k in enumerate(self.keys()) if i < index or k == key] 36 | ) 37 | 38 | last = False 39 | for k in moved: 40 | self.move_to_end(k, last=last) 41 | 42 | def __delitem__(self, key: Hashable | slice) -> None: 43 | if isinstance(key, slice): 44 | # Properly deal with slices (based on order). 45 | keys = list(self.keys()) 46 | for k in keys[key]: 47 | collections.OrderedDict.__delitem__(self, k) 48 | 49 | else: 50 | collections.OrderedDict.__delitem__(self, key) # type:ignore[arg-type] 51 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/singleton.py: -------------------------------------------------------------------------------- 1 | # mypy: disallow-untyped-defs 2 | from typing import Generic 3 | from typing import List 4 | from typing import Optional 5 | from typing import Set 6 | from typing import Type 7 | from typing import TypeVar 8 | 9 | import threading 10 | 11 | 12 | class SingletonError(RuntimeError): 13 | """ 14 | Base class for all Singleton-related exceptions. 15 | """ 16 | 17 | 18 | class SingletonAlreadySetError(SingletonError): 19 | """ 20 | Trying to set a singleton when the class already have one defined. 21 | """ 22 | 23 | 24 | class SingletonNotSetError(SingletonError): 25 | """ 26 | Trying to clear a singleton when there's none defined. 27 | """ 28 | 29 | 30 | class PushPopSingletonError(SingletonError): 31 | """ 32 | Trying to set a singleton between a PushSingleton/PopSingleton calls. 33 | """ 34 | 35 | 36 | T = TypeVar("T", bound="Singleton") 37 | 38 | 39 | class Singleton(Generic[T]): 40 | """ 41 | Base class for singletons. 42 | 43 | A Singleton class should have a unique instance during the lifetime of the application. Besides 44 | the functionality of obtaining the singleton instance, this class also provides methods to push 45 | and pop singletons, useful for testing, where you push a singleton into a known state during 46 | setUp and pops it back during tearDown 47 | """ 48 | 49 | # name of the attribute that holds the stack of singletons 50 | __singleton_stack_start_index = 0 51 | __lock = threading.RLock() 52 | 53 | _singleton_classes: set[type["Singleton"]] = set() 54 | 55 | __singleton_singleton_stack__: list[T] 56 | 57 | @staticmethod 58 | def ResetDefaultSingletonInstances() -> None: 59 | """ 60 | This singleton class is intended to be used in tests with the push / pop protocol. However some singleton 61 | dependencies might be hidden away from the test creator (or even be introduced after the test creation) making 62 | easy for a code to access and change the default class singleton (for example registering on its callbacks). 63 | 64 | This code is intended to clear any change made in such default singletons. Pushed singletons will not be cleared 65 | because if a test has correctly pushed it singleton, it is reasonable to assume that the test will correctly 66 | clean (pop) it. 67 | 68 | TODO: ETK-1235 As soon as the classes with ResetInstance are moved to do not be a singleton, then this method 69 | can be removed. 70 | """ 71 | for cls in Singleton._singleton_classes: 72 | if cls._UsingDefaultSingleton(): 73 | instance = cls.GetSingleton() 74 | instance.ResetInstance() 75 | 76 | @classmethod 77 | def GetSingleton(cls: type[T]) -> T: 78 | """ 79 | :rtype: Singleton 80 | :returns: 81 | Returns the current singleton instance. 82 | 83 | .. note:: This function is thread-safe, but all the other methods (such as SetSingleton, 84 | PushSingleton, PopSingleton, etc) are not (which should be Ok as those are mostly 85 | test-related, as singletons shouldn't really be changed after the application is up 86 | especially on multi-threaded environments). 87 | """ 88 | Singleton._singleton_classes.add(cls) 89 | 90 | try: 91 | # Make common case faster. 92 | return cls.__singleton_singleton_stack__[-1] 93 | except (AttributeError, IndexError): 94 | with cls.__lock: 95 | # Only lock if the 'fast path' did not work. 96 | stack = cls._ObtainStack() 97 | 98 | if not stack: # Faster than doing len(stack) == 0 99 | return cls.SetSingleton(None) 100 | 101 | return stack[-1] 102 | 103 | @classmethod 104 | def SetSingleton(cls: type[T], instance: T | None) -> T: 105 | """ 106 | Sets the current singleton. 107 | 108 | :param Singleton instance: 109 | The Singleton to pass as parameter 110 | 111 | :rtype: Singleton 112 | :returns: 113 | The singleton passed as parameter. 114 | 115 | @raise PushPopSingletonError 116 | @raise SingletonAlreadySetError 117 | """ 118 | stack = cls._ObtainStack() 119 | 120 | # Error if we trying to use SetSingleton between a Push/Pop 121 | if len(stack) != cls.__singleton_stack_start_index: 122 | raise PushPopSingletonError( 123 | "SetSingleton can not be called between a Push/Pop pair." 124 | ) 125 | 126 | if len(stack) > 0: 127 | raise SingletonAlreadySetError( 128 | "SetSingleton can only be called when there is no singleton set." 129 | ) 130 | 131 | # Obtain default instance (if needed) 132 | if instance is None: 133 | instance = cls.CreateDefaultSingleton() 134 | 135 | # Set the stack[0] as the singleton 136 | if len(stack) == 0: 137 | stack.append(instance) 138 | cls.__singleton_stack_start_index = 1 139 | else: 140 | stack[0] = instance 141 | 142 | assert cls.__singleton_stack_start_index == 1 143 | 144 | return instance 145 | 146 | @classmethod 147 | def _UsingDefaultSingleton(cls) -> bool: 148 | """ 149 | Checks if the current singleton instance is the default instance. 150 | 151 | :rtype: bool 152 | :returns: 153 | True if the current singleton instance is the default created instance. Returns False if the current instance 154 | is a pushed singleton or if no instance is currently set 155 | """ 156 | stack = cls._ObtainStack() 157 | has_pushed = len(stack) != cls.__singleton_stack_start_index 158 | has_singleton = cls.HasSingleton() 159 | 160 | return has_singleton and not has_pushed 161 | 162 | def ResetInstance(self) -> None: 163 | """ 164 | Restore the instance original configuration. Singleton classes should not have a internal state to reset 165 | (as described in issue ETK-1235), so subclasses that implement this method are strong candidates to be refactored 166 | to do not be a singleton. 167 | 168 | This method is used to avoid interference between tests while ETK-1235 is not implemented. 169 | """ 170 | pass 171 | 172 | @classmethod 173 | def ClearSingleton(cls) -> None: 174 | """ 175 | Clears the current singleton 176 | """ 177 | stack = cls._ObtainStack() 178 | 179 | # Error if we trying to use ClearSingleton between a Push/Pop 180 | if len(stack) != cls.__singleton_stack_start_index: 181 | raise PushPopSingletonError( 182 | "ClearSingleton can not be called between a Push/Pop pair." 183 | ) 184 | 185 | if not stack: 186 | raise SingletonNotSetError( 187 | "ClearSingleton can only be called when THERE IS singleton set." 188 | ) 189 | 190 | del stack[0] 191 | cls.__singleton_stack_start_index = 0 192 | 193 | @classmethod 194 | def HasSingleton(cls) -> bool: 195 | """ 196 | Do we have any singleton set? 197 | 198 | :rtype: bool 199 | :returns: 200 | True if there's a singleton set. 201 | """ 202 | stack = cls._ObtainStack() 203 | return len(stack) > 0 204 | 205 | @classmethod 206 | def CreateDefaultSingleton(cls: type[T]) -> T: 207 | """ 208 | Creates the default singleton instance, that will be used when no singleton has been installed. 209 | By default, tries to create the class without constructor. 210 | 211 | :rtype: Singleton 212 | :returns: 213 | an instance of the singleton subclass 214 | """ 215 | return cls() 216 | 217 | # Push/Pop ------------------------------------------------------------------------------------- 218 | 219 | @classmethod 220 | def PushSingleton(cls, instance: T | None = None) -> T: 221 | """ 222 | Pushes the given singleton to the top of the stack. The previous singleton will be restored 223 | when PopSingleton is called. 224 | 225 | :param Singleton instance: 226 | The singleton to install as the current one. If not given, a new singleton default 227 | is created. 228 | 229 | :rtype: Singleton 230 | :returns: 231 | The current singleton. 232 | """ 233 | if instance is None: 234 | instance = cls.CreateDefaultSingleton() 235 | stack = cls._ObtainStack() 236 | 237 | # DEBUG CODE 238 | # print '%s.PushSingleton' % cls.__name__, map(id, stack) 239 | # if len(stack) > 1: 240 | # from coilib50.debug import PrintTrace 241 | # PrintTrace(count=5) 242 | 243 | stack.append(instance) 244 | return instance 245 | 246 | @classmethod 247 | def PopSingleton(cls: type[T]) -> T: 248 | """ 249 | Restores the singleton that was the current before the last PushSingleton. 250 | 251 | :rtype: Singleton 252 | :returns: 253 | Return the removed singleton. 254 | """ 255 | stack = cls._ObtainStack() 256 | 257 | # DEBUG CODE 258 | # print '%s.PopSingleton' % cls.__name__, map(id, stack) 259 | # if len(stack) > 1: 260 | # from coilib50.debug import PrintTrace 261 | # PrintTrace(count=5) 262 | 263 | if len(stack) == cls.__singleton_stack_start_index: 264 | raise PushPopSingletonError( 265 | "PopSingleton called without a pair PushSingleton call" 266 | ) 267 | 268 | return cls._ObtainStack().pop(-1) 269 | 270 | @classmethod 271 | def _ObtainStack(cls) -> list[T]: 272 | """ 273 | Obtains the stack of singletons. 274 | 275 | :rtype: list 276 | :returns: 277 | The singleton stack. 278 | """ 279 | try: 280 | return cls.__singleton_singleton_stack__ 281 | except AttributeError: 282 | assert ( 283 | cls is not Singleton 284 | ), "This method can only be called from a Singleton subclass." 285 | stack: list[T] = [] 286 | cls.__singleton_singleton_stack__ = stack 287 | return stack 288 | 289 | @classmethod 290 | def GetStackCount(cls) -> int: 291 | """ 292 | @return int: 293 | The number of elements added int the stack using PushSingleton. 294 | """ 295 | stack = cls._ObtainStack() 296 | return len(stack) - cls.__singleton_stack_start_index 297 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/types_.py: -------------------------------------------------------------------------------- 1 | # mypy: disallow-untyped-defs 2 | """ 3 | Extensions to python native types. 4 | """ 5 | from typing import TYPE_CHECKING 6 | from typing import Any 7 | from typing import NoReturn 8 | 9 | from collections.abc import Iterator 10 | 11 | if TYPE_CHECKING: 12 | from typing import Literal 13 | 14 | 15 | class Method: 16 | """ 17 | This class is an 'organization' class, so that subclasses are considered as methods 18 | (and its __call__ method is checked for the parameters) 19 | """ 20 | 21 | __self__: object 22 | __name__: str 23 | 24 | 25 | class Null: 26 | """ 27 | This is a sample implementation of the 'Null Object' design pattern. 28 | 29 | Roughly, the goal with Null objects is to provide an 'intelligent' 30 | replacement for the often used primitive data type None in Python or 31 | Null (or Null pointers) in other languages. These are used for many 32 | purposes including the important case where one member of some group 33 | of otherwise similar elements is special for whatever reason. Most 34 | often this results in conditional statements to distinguish between 35 | ordinary elements and the primitive Null value. 36 | 37 | Among the advantages of using Null objects are the following: 38 | 39 | - Superfluous conditional statements can be avoided 40 | by providing a first class object alternative for 41 | the primitive value None. 42 | 43 | - Code readability is improved. 44 | 45 | - Null objects can act as a placeholder for objects 46 | with behaviour that is not yet implemented. 47 | 48 | - Null objects can be replaced for any other class. 49 | 50 | - Null objects are very predictable at what they do. 51 | 52 | To cope with the disadvantage of creating large numbers of passive 53 | objects that do nothing but occupy memory space Null objects are 54 | often combined with the Singleton pattern. 55 | 56 | For more information use any internet search engine and look for 57 | combinations of these words: Null, object, design and pattern. 58 | 59 | Dinu C. Gherman, 60 | August 2001 61 | 62 | --- 63 | 64 | A class for implementing Null objects. 65 | 66 | This class ignores all parameters passed when constructing or 67 | calling instances and traps all attribute and method requests. 68 | Instances of it always (and reliably) do 'nothing'. 69 | 70 | The code might benefit from implementing some further special 71 | Python methods depending on the context in which its instances 72 | are used. Especially when comparing and coercing Null objects 73 | the respective methods' implementation will depend very much 74 | on the environment and, hence, these special methods are not 75 | provided here. 76 | """ 77 | 78 | # object constructing 79 | 80 | def __init__(self, *_args: object, **_kwargs: object) -> None: 81 | "Ignore parameters." 82 | # Setting the name of what's gotten (so that __name__ is properly preserved). 83 | self.__dict__["_Null__name__"] = "Null" 84 | 85 | def __call__(self, *_args: object, **_kwargs: object) -> "Null": 86 | "Ignore method calls." 87 | return self 88 | 89 | def __getattr__(self, mname: str) -> Any: 90 | "Ignore attribute requests." 91 | if mname == "__getnewargs__": 92 | raise AttributeError( 93 | "No support for that (pickle causes error if it returns self in this case.)" 94 | ) 95 | 96 | if mname == "__name__": 97 | return self.__dict__["_Null__name__"] 98 | 99 | return self 100 | 101 | def __setattr__(self, _name: str, _value: object) -> Any: 102 | "Ignore attribute setting." 103 | return self 104 | 105 | def __delattr__(self, _name: str) -> None: 106 | "Ignore deleting attributes." 107 | 108 | def __enter__(self) -> "Null": 109 | return self 110 | 111 | def __exit__(self, *args: object, **kwargs: object) -> None: 112 | pass 113 | 114 | def __repr__(self) -> str: 115 | "Return a string representation." 116 | return "" 117 | 118 | def __str__(self) -> str: 119 | "Convert to a string and return it." 120 | return "Null" 121 | 122 | def __bool__(self) -> "Literal[False]": 123 | "Null objects are always false" 124 | return False 125 | 126 | # iter 127 | 128 | def __iter__(self) -> Iterator["Null"]: 129 | "I will stop it in the first iteration" 130 | return iter([self]) 131 | 132 | def __next__(self) -> NoReturn: 133 | "Stop the iteration right now" 134 | raise StopIteration() 135 | 136 | def __eq__(self, o: Any) -> Any: 137 | "It is just equal to another Null object." 138 | return self.__class__ == o.__class__ 139 | 140 | def __hash__(self) -> int: 141 | """Null is hashable""" 142 | return 0 143 | 144 | 145 | NULL = Null() # Create a default instance to be used. 146 | -------------------------------------------------------------------------------- /src/oop_ext/foundation/weak_ref.py: -------------------------------------------------------------------------------- 1 | # mypy: disallow-untyped-defs 2 | from types import LambdaType 3 | from types import MethodType 4 | from typing import Any 5 | from typing import Generic 6 | from typing import List 7 | from typing import Optional 8 | from typing import Set 9 | from typing import TypeVar 10 | from typing import Union 11 | from typing import cast 12 | from typing import overload 13 | 14 | import inspect 15 | import weakref 16 | from collections.abc import Callable 17 | from collections.abc import Iterable 18 | from collections.abc import Iterator 19 | from weakref import ReferenceType 20 | 21 | from oop_ext.foundation.decorators import Implements 22 | 23 | T = TypeVar("T") 24 | SomeWeakRef = Union[ReferenceType, "WeakMethodRef"] 25 | 26 | 27 | class WeakList(Generic[T]): 28 | """ 29 | The weak list is a list that will only keep weak-references to objects passed to it. 30 | 31 | When iterating the actual objects are used, but internally, only weakrefs are kept. 32 | 33 | It does not contain the whole list interface (but can be extended as needed). 34 | 35 | IMPORTANT: if you got here and need to implement a new feature or fix a bug, 36 | consider replacing this implementation by this one instead: 37 | https://github.com/apieum/weakreflist 38 | """ 39 | 40 | def __init__(self, initlist: Iterable[T] | None = None): 41 | self.data: list[SomeWeakRef] = [] 42 | 43 | if initlist is not None: 44 | for x in initlist: 45 | self.append(x) 46 | 47 | @Implements(list.append) 48 | def append(self, item: T) -> None: 49 | self.data.append(GetWeakRef(item)) 50 | 51 | @Implements(list.extend) 52 | def extend(self, lst: Iterable[T]) -> None: 53 | for o in lst: 54 | self.append(o) 55 | 56 | def __iter__(self) -> Iterator[T]: 57 | # iterate in a copy 58 | for ref in self.data[:]: 59 | assert callable(ref), f"ref is not callable: {repr(ref)}" 60 | d = ref() 61 | if d is None: 62 | self.data.remove(ref) 63 | else: 64 | yield d 65 | 66 | def remove(self, item: T) -> None: 67 | """ 68 | Remove first occurrence of a value. 69 | 70 | It differs from the normal version because it will not raise an exception if the 71 | item is not found (because it may be garbage-collected already). 72 | 73 | :param object item: 74 | The object to be removed. 75 | """ 76 | # iterate in a copy 77 | for ref in self.data[:]: 78 | assert callable(ref), f"ref is not callable: {repr(ref)}" 79 | d = ref() 80 | 81 | if d is None: 82 | self.data.remove(ref) 83 | 84 | elif d == item: 85 | self.data.remove(ref) 86 | break 87 | 88 | def __len__(self) -> int: 89 | i = 0 90 | for _k in self: # we make an iteration to remove dead references... 91 | i += 1 92 | return i 93 | 94 | def __delitem__(self, i: int | slice) -> None: 95 | self.data.__delitem__(i) 96 | 97 | @overload 98 | def __getitem__(self, i: int) -> T | None: ... 99 | 100 | @overload 101 | def __getitem__(self, i: slice) -> "WeakList": ... 102 | 103 | def __getitem__(self, i: int | slice) -> Union[T | None, "WeakList"]: 104 | if isinstance(i, slice): 105 | slice_ = [] 106 | for ref in self.data[i.start : i.stop : i.step]: 107 | assert callable(ref), f"ref is not callable: {repr(ref)}" 108 | d = ref() 109 | if d is not None: 110 | slice_.append(d) 111 | 112 | return WeakList(slice_) 113 | else: 114 | ref = self.data[i] 115 | assert callable(ref), f"ref is not callable: {repr(ref)}" 116 | return ref() 117 | 118 | def __setitem__(self, i: int, item: T) -> None: 119 | """ 120 | Set a weakref of item on the ith position 121 | """ 122 | self.data[i] = GetWeakRef(item) 123 | 124 | def __str__(self) -> str: 125 | return "\n".join(str(x) for x in self) 126 | 127 | 128 | class WeakMethodRef: 129 | """ 130 | Weak reference to bound-methods. This allows the client to hold a bound method 131 | while allowing GC to work. 132 | 133 | Based on recipe from Python Cookbook, page 191. Differs by only working on 134 | boundmethods and returning a true boundmethod in the __call__() function. 135 | 136 | Keeps a reference to an object but doesn't prevent that object from being garbage collected. 137 | """ 138 | 139 | __slots__ = ["_obj", "_func", "_class", "_hash", "__weakref__"] 140 | 141 | def __init__(self, method: Any): 142 | self._obj: weakref.ReferenceType | None 143 | try: 144 | if method.__self__ is not None: 145 | # bound method 146 | self._obj = weakref.ref(method.__self__) 147 | else: 148 | # unbound method 149 | self._obj = None 150 | self._func = method.__func__ 151 | self._class = method.__self__.__class__ 152 | except AttributeError: 153 | # not a method -- a callable: create a strong reference (the CallbackWrapper 154 | # is depending on this behaviour... is it correct?) 155 | self._obj = None 156 | self._func = method 157 | self._class = None 158 | 159 | def __call__(self) -> Any: 160 | """ 161 | Return a new bound-method like the original, or the original function if refers just to 162 | a function or unbound method. 163 | 164 | @return: 165 | None if the original object doesn't exist anymore. 166 | """ 167 | if self.is_dead(): 168 | return None 169 | if self._obj is not None: 170 | # we have an instance: return a bound method 171 | return MethodType(self._func, self._obj()) 172 | else: 173 | # we don't have an instance: return just the function 174 | return self._func 175 | 176 | def is_dead(self) -> bool: 177 | """Returns True if the referenced callable was a bound method and 178 | the instance no longer exists. Otherwise, return False. 179 | """ 180 | return self._obj is not None and self._obj() is None 181 | 182 | def __eq__(self, other: object) -> bool: 183 | try: 184 | return ( 185 | type(self) is type(other) and self() == other() # type:ignore[operator] 186 | ) 187 | except: 188 | return False 189 | 190 | def __ne__(self, other: object) -> bool: 191 | return not self == other 192 | 193 | def __hash__(self) -> int: 194 | if not hasattr(self, "_hash"): 195 | # The hash should be immutable (must be calculated once and never changed -- otherwise 196 | # we won't be able to get it when the object dies) 197 | self._hash = hash(WeakMethodRef.__call__(self)) 198 | 199 | return self._hash 200 | 201 | def __repr__(self) -> str: 202 | func_name = getattr(self._func, "__name__", str(self._func)) 203 | if self._obj is not None: 204 | obj = self._obj() 205 | if obj is None: 206 | obj_str = "" 207 | else: 208 | obj_str = "%X" % id(obj) 209 | msg = "" 210 | return msg % (self._class.__name__, func_name, obj_str) 211 | else: 212 | return "" % func_name 213 | 214 | 215 | class WeakMethodProxy(WeakMethodRef): 216 | """ 217 | Like ref, but calling it will cause the referent method to be called with the same 218 | arguments. If the referent's object no longer lives, ReferenceError is raised. 219 | """ 220 | 221 | def GetWrappedFunction(self) -> Callable | None: 222 | return WeakMethodRef.__call__(self) 223 | 224 | def __call__(self, *args: object, **kwargs: object) -> Any: 225 | func = WeakMethodRef.__call__(self) 226 | if func is None: 227 | raise ReferenceError(f"Object is dead. Was of class: {self._class}") 228 | else: 229 | return func(*args, **kwargs) 230 | 231 | def __eq__(self, other: object) -> bool: 232 | try: 233 | func1 = WeakMethodRef.__call__(self) 234 | func2 = WeakMethodRef.__call__(other) # type:ignore[arg-type] 235 | return type(self) == type(other) and func1 == func2 236 | except: 237 | return False 238 | 239 | 240 | class WeakSet(Generic[T]): 241 | """ 242 | Just like `weakref.WeakSet`, but supports adding methods (the standard `weakref.WeakSet` can't 243 | add methods, this feature comes from `oop_ext.foundation.weak_ref.GetWeakRef`, see `testWeakSet2`). 244 | 245 | It does not contain the whole set interface (but can be extended as needed). 246 | 247 | ..see:: oop_ext.foundation.weak_ref.GetWeakRef 248 | ..see:: weakref.WeakSet 249 | """ 250 | 251 | def __init__(self) -> None: 252 | self.data: set[SomeWeakRef] = set() 253 | 254 | def add(self, item: T) -> None: 255 | self.data.add(GetWeakRef(item)) 256 | 257 | def clear(self) -> None: 258 | self.data.clear() 259 | 260 | def __iter__(self) -> Iterator[T]: 261 | # iterate in a copy 262 | for ref in self.data.copy(): 263 | assert callable(ref), f"ref is not callable: {repr(ref)}" 264 | d = ref() 265 | if d is None: 266 | self.data.remove(ref) 267 | else: 268 | yield d 269 | 270 | def remove(self, item: T) -> None: 271 | """ 272 | Remove an item from the available data. 273 | 274 | :param object item: 275 | The object to be removed. 276 | """ 277 | self.data.remove(GetWeakRef(item)) 278 | 279 | def union(self, another_set: Iterable[T]) -> "WeakSet": 280 | result = WeakSet[T]() 281 | result.data = self.data.copy() 282 | for i in another_set: 283 | result.add(i) 284 | return result 285 | 286 | def copy(self) -> "WeakSet[T]": 287 | result = WeakSet[T]() 288 | result.data = self.data.copy() 289 | return result 290 | 291 | def __sub__(self, another_set: Iterable[T]) -> "WeakSet": 292 | result = WeakSet[T]() 293 | result.data = self.data.copy() 294 | for i in another_set: 295 | result.discard(i) 296 | return result 297 | 298 | def __rsub__(self, another_set: Any) -> Any: 299 | result = another_set.copy() 300 | for i in self: 301 | result.discard(i) 302 | return result 303 | 304 | def discard(self, item: T) -> None: 305 | try: 306 | self.remove(item) 307 | except KeyError: 308 | pass 309 | 310 | def __len__(self) -> int: 311 | i = 0 312 | for _k in self: # we make an iteration to remove dead references... 313 | i += 1 314 | return i 315 | 316 | def __str__(self) -> str: 317 | return "\n".join(str(x) for x in self) 318 | 319 | 320 | def IsWeakProxy(obj: object) -> bool: 321 | """ 322 | Returns whether the given object is a weak-proxy 323 | """ 324 | return isinstance(obj, (weakref.ProxyType, WeakMethodProxy)) 325 | 326 | 327 | def IsWeakRef(obj: object) -> bool: 328 | """ 329 | Returns wheter ths given object is a weak-reference. 330 | """ 331 | return isinstance(obj, (weakref.ReferenceType, WeakMethodRef)) and not isinstance( 332 | obj, WeakMethodProxy 333 | ) 334 | 335 | 336 | def IsWeakObj(obj: object) -> bool: 337 | """ 338 | Returns whether the given object is a weak object. Either a weak-proxy or a weak-reference. 339 | 340 | :param obj: The object that may be a weak reference or proxy 341 | :return bool: True if it is a proxy or a weak reference. 342 | """ 343 | return IsWeakProxy(obj) or IsWeakRef(obj) 344 | 345 | 346 | def GetRealObj(obj: Any) -> Any: 347 | """ 348 | Returns the real-object from a weakref, or the object itself otherwise. 349 | """ 350 | if IsWeakRef(obj): 351 | return obj() 352 | if isinstance(obj, LambdaType): 353 | return obj() 354 | return obj 355 | 356 | 357 | def GetWeakProxy(obj: Any) -> Any: 358 | """ 359 | :param obj: This is the object we want to get as a proxy 360 | :return: 361 | Returns the object as a proxy (if it is still not already a proxy or a weak ref, in which case the passed object 362 | is returned itself) 363 | """ 364 | if obj is None: 365 | return None 366 | 367 | if not IsWeakProxy(obj): 368 | if IsWeakRef(obj): 369 | obj = obj() 370 | 371 | # for methods we cannot create regular weak-refs 372 | if inspect.ismethod(obj): 373 | return WeakMethodProxy(obj) 374 | 375 | return weakref.proxy(obj) 376 | 377 | return obj 378 | 379 | 380 | # Keep the same lambda for weak-refs (to be reused among all places that use GetWeakRef(None) 381 | _NONE_REF = WeakMethodRef(None) 382 | 383 | 384 | def GetWeakRef(obj: T) -> SomeWeakRef: 385 | """ 386 | :type obj: this is the object we want to get as a weak ref 387 | :param obj: 388 | @return the object as a proxy (if it is still not already a proxy or a weak ref, in which case the passed 389 | object is returned itself) 390 | """ 391 | if obj is None: 392 | return _NONE_REF 393 | 394 | if IsWeakProxy(obj): 395 | raise RuntimeError("Unable to get weak ref for proxy.") 396 | 397 | if not IsWeakRef(obj): 398 | # for methods we cannot create regular weak-refs 399 | if inspect.ismethod(obj): 400 | return WeakMethodRef(obj) 401 | 402 | return weakref.ref(obj) 403 | return cast(ReferenceType, obj) 404 | 405 | 406 | def IsSame(o1: Any, o2: Any) -> bool: 407 | """ 408 | This checks for the identity even if one of the parameters is a weak reference 409 | 410 | :param o1: 411 | first object to compare 412 | 413 | :param o2: 414 | second object to compare 415 | 416 | @raise 417 | RuntimeError if both of the passed parameters are weak references 418 | """ 419 | # get rid of weak refs (we only need special treatment for proxys) 420 | if IsWeakRef(o1): 421 | o1 = o1() 422 | if IsWeakRef(o2): 423 | o2 = o2() 424 | 425 | # simple case (no weak objects) 426 | if not IsWeakObj(o1) and not IsWeakObj(o2): 427 | return o1 is o2 428 | 429 | # all weak proxys 430 | if IsWeakProxy(o1) and IsWeakProxy(o2): 431 | if not o1 == o2: 432 | # if they are not equal, we know they're not the same 433 | return False 434 | 435 | # but we cannot say anything if they are the same if they are equal 436 | raise ReferenceError( 437 | "Cannot check if object is same if both arguments passed are weak objects" 438 | ) 439 | 440 | # one is weak and the other is not 441 | if IsWeakObj(o1): 442 | weak = o1 443 | original = o2 444 | else: 445 | weak = o2 446 | original = o1 447 | 448 | weaks = weakref.getweakrefs(original) 449 | for w in weaks: 450 | if w is weak: # check the weak object identity 451 | return True 452 | 453 | return False 454 | -------------------------------------------------------------------------------- /src/oop_ext/interface/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Interfaces module. 3 | 4 | A Interface describes a behaviour that some objects must implement. 5 | 6 | To declare a interface, just subclass from Interface:: 7 | 8 | class IFoo(interface.Interface): 9 | ... 10 | 11 | To create a class that implements that interface, use interface.Implements: 12 | 13 | class Foo(object): 14 | interface.Implements(IFoo) 15 | 16 | If Foo doesn't implement some method from IFoo, an exception is raised at class creation time. 17 | """ 18 | 19 | from ._adaptable_interface import IAdaptable 20 | from ._interface import AssertDeclaresInterface 21 | from ._interface import AssertImplements 22 | from ._interface import AssertImplementsFullChecking 23 | from ._interface import Attribute 24 | from ._interface import BadImplementationError 25 | from ._interface import CacheInterfaceAttrs 26 | from ._interface import DeclareClassImplements 27 | from ._interface import GetImplementedInterfaces 28 | from ._interface import GetProxy 29 | from ._interface import ImplementsInterface 30 | from ._interface import Interface 31 | from ._interface import InterfaceError 32 | from ._interface import InterfaceImplementationMetaClass 33 | from ._interface import InterfaceImplementorStub 34 | from ._interface import IsImplementation 35 | from ._interface import IsImplementationOfAny 36 | from ._interface import ReadOnlyAttribute 37 | from ._interface import TypeCheckingSupport 38 | 39 | __all__ = [ 40 | "AssertDeclaresInterface", 41 | "AssertImplements", 42 | "AssertImplementsFullChecking", 43 | "Attribute", 44 | "BadImplementationError", 45 | "CacheInterfaceAttrs", 46 | "DeclareClassImplements", 47 | "GetImplementedInterfaces", 48 | "GetProxy", 49 | "IAdaptable", 50 | "ImplementsInterface", 51 | "Interface", 52 | "InterfaceError", 53 | "InterfaceImplementationMetaClass", 54 | "InterfaceImplementorStub", 55 | "IsImplementation", 56 | "IsImplementationOfAny", 57 | "ReadOnlyAttribute", 58 | "TypeCheckingSupport", 59 | ] 60 | -------------------------------------------------------------------------------- /src/oop_ext/interface/_adaptable_interface.py: -------------------------------------------------------------------------------- 1 | from ._interface import Interface 2 | from ._interface import TypeCheckingSupport 3 | 4 | 5 | class IAdaptable(Interface, TypeCheckingSupport): 6 | """ 7 | An interface for an object that is adaptable. 8 | 9 | Adaptable objects can be queried about interfaces they adapt to (to which they 10 | may respond or not). 11 | 12 | For example: 13 | 14 | a = [some IAdaptable]; 15 | x = a.GetAdapter(IFoo); 16 | if x is not None: 17 | [do IFoo things with x] 18 | """ 19 | 20 | def GetAdapter(self, interface_class): 21 | """ 22 | :type interface_class: this is the interface for which an adaptation is required 23 | :param interface_class: 24 | :rtype: an object implementing the required interface or None if this object cannot 25 | adapt to that interface. 26 | 27 | Note: explicitly not adding type hints here as this would break every implementation, as 28 | Interface also checks type hints. 29 | """ 30 | -------------------------------------------------------------------------------- /src/oop_ext/interface/_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ESSS/oop-ext/0efdefb55dc9a20d8b767cfd8208d3e744a528b6/src/oop_ext/interface/_tests/__init__.py -------------------------------------------------------------------------------- /src/oop_ext/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ESSS/oop-ext/0efdefb55dc9a20d8b767cfd8208d3e744a528b6/src/oop_ext/py.typed -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = true 3 | 4 | [testenv] 5 | extras = testing 6 | commands = 7 | pytest --cov={envsitepackagesdir}/oop_ext --cov-report=xml --pyargs oop_ext --color=yes 8 | 9 | [testenv:docs] 10 | usedevelop = True 11 | changedir = docs 12 | extras = docs 13 | commands = 14 | sphinx-build -W -b html . _build 15 | --------------------------------------------------------------------------------