├── .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 |
--------------------------------------------------------------------------------