├── izulu ├── __init__.py ├── py.typed ├── tools.py ├── _utils.py ├── root.py └── _reraise.py ├── tests ├── __init__.py ├── error │ ├── __init__.py │ ├── test_factory.py │ ├── test_stringification.py │ ├── test_features.py │ ├── test_init_subclass.py │ ├── test_dumping.py │ ├── test_checks.py │ └── test_error.py ├── reraise │ ├── __init__.py │ └── test_fatal.py ├── conftest.py ├── helpers.py ├── errors.py └── test_utils.py ├── docs ├── source │ ├── user │ │ ├── faq.rst │ │ ├── features.rst │ │ ├── tutorial.rst │ │ ├── index.rst │ │ ├── install.rst │ │ ├── recipes.rst │ │ ├── intro.rst │ │ └── quickstart.rst │ ├── ref.rst │ ├── specs │ │ ├── index.rst │ │ ├── pillars.rst │ │ ├── validations.rst │ │ ├── toggles.rst │ │ ├── additional.rst │ │ └── mechanics.rst │ ├── _templates │ │ └── sourcelink.html │ ├── index.rst │ ├── contributors.rst │ ├── _static │ │ └── _js │ │ │ └── custom-icon.js │ └── conf.py ├── Makefile └── make.bat ├── .taplo.toml ├── .gitlint ├── .yamllint.yaml ├── .readthedocs.yaml ├── .pre-commit-config.yaml ├── LICENSE ├── README.tpl.rst ├── .github └── workflows │ ├── checks.yaml │ └── publish-to-pypi.yaml ├── .gitignore ├── pyproject.toml └── README.rst /izulu/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /izulu/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/error/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/reraise/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/user/faq.rst: -------------------------------------------------------------------------------- 1 | FAQ 2 | === 3 | -------------------------------------------------------------------------------- /docs/source/user/features.rst: -------------------------------------------------------------------------------- 1 | Features 2 | ======== 3 | -------------------------------------------------------------------------------- /docs/source/user/tutorial.rst: -------------------------------------------------------------------------------- 1 | Tutorial 2 | ======== 3 | -------------------------------------------------------------------------------- /.taplo.toml: -------------------------------------------------------------------------------- 1 | exclude = ["**/.venv/**/*.toml"] 2 | 3 | [formatting] 4 | array_auto_collapse = false 5 | column_width = 120 6 | -------------------------------------------------------------------------------- /.gitlint: -------------------------------------------------------------------------------- 1 | [general] 2 | verbosity = 3 3 | ignore=B6 4 | 5 | ### Configuring rules ### 6 | [title-max-length] 7 | line-length=120 8 | 9 | [title-min-length] 10 | min-length=5 11 | -------------------------------------------------------------------------------- /docs/source/user/index.rst: -------------------------------------------------------------------------------- 1 | User Guide 2 | ========== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | intro 8 | install 9 | quickstart 10 | tutorial 11 | recipes 12 | faq 13 | features 14 | -------------------------------------------------------------------------------- /docs/source/ref.rst: -------------------------------------------------------------------------------- 1 | Reference 2 | ========= 3 | 4 | Core 5 | ---- 6 | 7 | .. automodule:: izulu.root 8 | :members: 9 | :undoc-members: 10 | 11 | 12 | Reraise 13 | ------- 14 | 15 | .. automodule:: izulu._reraise 16 | :members: 17 | :undoc-members: 18 | -------------------------------------------------------------------------------- /docs/source/specs/index.rst: -------------------------------------------------------------------------------- 1 | Specifications 2 | ============== 3 | 4 | 5 | ``izulu`` bases on class definitions to provide handy instance creation. 6 | 7 | 8 | .. toctree:: 9 | :maxdepth: 2 10 | 11 | pillars 12 | mechanics 13 | toggles 14 | additional 15 | validations 16 | -------------------------------------------------------------------------------- /.yamllint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | 4 | ignore: 5 | - "**/.venv/*" 6 | - "**/.tox/*" 7 | 8 | rules: 9 | comments: 10 | require-starting-space: true 11 | min-spaces-from-content: 1 12 | quoted-strings: 13 | quote-type: double 14 | required: false 15 | check-keys: true 16 | -------------------------------------------------------------------------------- /docs/source/user/install.rst: -------------------------------------------------------------------------------- 1 | .. _install: 2 | 3 | Installation 4 | ============ 5 | 6 | For Python versions prior to 3.11 also install ``izulu[compatibility]``. 7 | 8 | :: 9 | 10 | # py311 and higher 11 | pip install izulu 12 | 13 | # py38-py310 14 | pip install izulu izulu[compatibility] 15 | -------------------------------------------------------------------------------- /docs/source/_templates/sourcelink.html: -------------------------------------------------------------------------------- 1 | {# Displays a link to the .rst source of the current page. #} 2 | {% if show_source and has_source and sourcename %} 3 | 8 | {% endif %} 9 | -------------------------------------------------------------------------------- /tests/reraise/test_fatal.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from izulu import _reraise 4 | 5 | 6 | def test_fatal_direct_inheritance(): 7 | type("Klass", (_reraise.FatalMixin,), {}) 8 | 9 | 10 | def test_fatal_indirect_inheritance(): 11 | kls = type("Klass", (_reraise.FatalMixin,), {}) 12 | 13 | with pytest.raises(TypeError): 14 | type("Klass2", (kls,), {}) 15 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | 4 | build: 5 | os: "ubuntu-lts-latest" 6 | tools: 7 | python: "3.13" 8 | jobs: 9 | install: 10 | - pip install -U pip 11 | - pip install -e . 12 | - pip install --group 'docs' 13 | 14 | sphinx: 15 | configuration: docs/source/conf.py 16 | # fail_on_warning: true 17 | 18 | # formats: 19 | # - pdf 20 | # - epub 21 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from tests import errors 6 | 7 | 8 | @pytest.fixture 9 | def derived_error(): 10 | ts = datetime.datetime.now(datetime.timezone.utc) 11 | return errors.DerivedError( 12 | name="John", 13 | surname="Brown", 14 | note="...", 15 | age=42, 16 | updated_at=ts, 17 | full_name="secret", 18 | box=dict(), 19 | ) 20 | -------------------------------------------------------------------------------- /tests/error/test_factory.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from unittest import mock # noqa: RUF100 3 | 4 | import pytest 5 | 6 | from izulu import root 7 | 8 | 9 | @pytest.mark.parametrize("flag", [True, False]) 10 | def test_factory(flag): 11 | expected = uuid.uuid4() 12 | m = mock.Mock(return_value=expected) 13 | attr = root.factory(default_factory=m, self=flag) 14 | k = type("Klass", tuple(), {"attr_with_self": attr})() 15 | call_args = (k,) 16 | 17 | result = k.attr_with_self 18 | 19 | assert result is expected 20 | m.assert_called_once_with(*call_args[:flag]) 21 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | izulu documentation 2 | =================== 3 | 4 | .. list-table:: 5 | 6 | * - .. image:: https://repository-images.githubusercontent.com/766241795/85494614-5974-4b26-bfec-03b8e393c7f0 7 | :width: 256px 8 | 9 | | 10 | 11 | .. epigraph:: 12 | 13 | *"The exceptional library"* 14 | 15 | | 16 | 17 | | **Author:** Dima Burmistrov (`pyctrl `__) 18 | | *Special thanks to* `Eugene Frolov `__ *for inspiration.* 19 | | 20 | 21 | This project is `licensed `__ under the X11 License (extended MIT). 22 | 23 | 24 | - .. toctree:: 25 | :maxdepth: 2 26 | 27 | user/index 28 | specs/index 29 | ref 30 | contributors 31 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | import types 2 | 3 | from izulu import _utils 4 | 5 | 6 | def _make_store_kwargs( 7 | fields=None, 8 | const_hints=None, 9 | inst_hints=None, 10 | consts=None, 11 | defaults=None, 12 | ) -> dict: 13 | return dict( 14 | fields=frozenset(fields or tuple()), 15 | const_hints=types.MappingProxyType(const_hints or dict()), 16 | inst_hints=types.MappingProxyType(inst_hints or dict()), 17 | consts=types.MappingProxyType(consts or dict()), 18 | defaults=frozenset(defaults or tuple()), 19 | ) 20 | 21 | 22 | def _make_store( 23 | fields=None, 24 | const_hints=None, 25 | inst_hints=None, 26 | consts=None, 27 | defaults=None, 28 | ) -> _utils.Store: 29 | return _utils.Store( 30 | **_make_store_kwargs( 31 | fields=fields, 32 | inst_hints=inst_hints, 33 | const_hints=const_hints, 34 | consts=consts, 35 | defaults=defaults, 36 | ) 37 | ) 38 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v5.0.0 5 | hooks: 6 | - id: check-added-large-files 7 | args: ["--maxkb=500"] 8 | - id: check-json 9 | - id: check-toml 10 | - id: detect-private-key 11 | - id: end-of-file-fixer 12 | - id: fix-byte-order-marker 13 | - id: mixed-line-ending 14 | - id: name-tests-test 15 | args: ["--pytest-test-first"] 16 | exclude: "^tests/(errors|helpers).py" 17 | - id: no-commit-to-branch 18 | - id: pretty-format-json 19 | - id: trailing-whitespace 20 | - repo: https://github.com/adrienverge/yamllint 21 | rev: v1.35.1 22 | hooks: 23 | - id: yamllint 24 | - repo: https://github.com/pre-commit/pygrep-hooks 25 | rev: v1.10.0 26 | hooks: 27 | - id: python-check-blanket-type-ignore 28 | - id: python-no-eval 29 | - id: python-no-log-warn 30 | - repo: https://github.com/jorisroovers/gitlint 31 | rev: v0.19.1 32 | hooks: 33 | - id: gitlint 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023-2025 Dmitry Burmistrov 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | 21 | Except as contained in this notice, the name(s) of the above copyright holders 22 | shall not be used in advertising or otherwise to promote the sale, use or other 23 | dealings in this Software without prior written authorization. 24 | -------------------------------------------------------------------------------- /docs/source/contributors.rst: -------------------------------------------------------------------------------- 1 | Contributors 2 | ============ 3 | 4 | For developers 5 | ************** 6 | 7 | 1. Install required tools 8 | 9 | * `uv `__ (manually) 10 | * `Taplo `__ (manually) 11 | * `Tox `__ 12 | 13 | .. code-block:: shell 14 | 15 | uv tool install tox --with tox-uv 16 | 17 | 2. Clone `repository `__ 18 | 19 | 3. Initialize developer's environment 20 | 21 | .. code-block:: shell 22 | 23 | uv sync 24 | tox run -e init 25 | 26 | 4. Run tests 27 | 28 | .. code-block:: shell 29 | 30 | # run only mypy env 31 | tox run -e lint-mypy 32 | 33 | # run all linting envs (labeled) 34 | tox run -m lint 35 | 36 | # run only ruff formatting env 37 | tox run -e fmt-py 38 | 39 | # run all formatting envs (labeled) 40 | tox run -m fmt 41 | 42 | # list all envs 43 | tox list 44 | 45 | # run all envs 46 | tox run 47 | 48 | 5. Contributing — start from opening an `issue `__ 49 | 50 | 51 | Versioning 52 | ********** 53 | 54 | `SemVer `__ used for versioning. 55 | For available versions see the repository 56 | `tags `__ 57 | and `releases `__. 58 | -------------------------------------------------------------------------------- /README.tpl.rst: -------------------------------------------------------------------------------- 1 | izulu 2 | ##### 3 | 4 | .. image:: https://repository-images.githubusercontent.com/766241795/85494614-5974-4b26-bfec-03b8e393c7f0 5 | :width: 128px 6 | 7 | | 8 | 9 | *"The exceptional library"* 10 | 11 | | 12 | 13 | 14 | **Installation** 15 | 16 | .. include:: docs/source/user/install.rst 17 | :start-line: 5 18 | 19 | .. include:: docs/source/user/intro.rst 20 | 21 | 22 | Quickstart 23 | ========== 24 | 25 | .. include:: docs/source/user/quickstart.rst 26 | :start-line: 6 27 | 28 | Specifications 29 | ************** 30 | 31 | ``izulu`` bases on class definitions to provide handy instance creation. 32 | 33 | 34 | .. include:: docs/source/specs/pillars.rst 35 | :start-line: 3 36 | 37 | .. include:: docs/source/specs/mechanics.rst 38 | 39 | Features 40 | ======== 41 | 42 | .. include:: docs/source/specs/toggles.rst 43 | :start-line: 3 44 | 45 | .. include:: docs/source/specs/validations.rst 46 | 47 | .. include:: docs/source/specs/additional.rst 48 | 49 | .. include:: docs/source/user/recipes.rst 50 | 51 | .. include:: docs/source/dev/index.rst 52 | :start-line: 6 53 | 54 | 55 | Authors 56 | ******* 57 | 58 | - **Dima Burmistrov** - *Initial work* - 59 | `pyctrl `__ 60 | 61 | *Special thanks to* `Eugene Frolov `__ *for inspiration.* 62 | 63 | 64 | License 65 | ******* 66 | 67 | This project is licensed under the X11 License (extended MIT) - see the 68 | `LICENSE `__ file for details 69 | -------------------------------------------------------------------------------- /docs/source/specs/pillars.rst: -------------------------------------------------------------------------------- 1 | The 6 pillars 2 | ============= 3 | 4 | **The 6 pillars of** ``izulu`` 5 | 6 | * all behavior is defined on the class-level 7 | 8 | * ``__template__`` class attribute defines the template for target error message 9 | 10 | * template may contain *"fields"* for substitution from ``kwargs`` and *"defaults"* to produce final error message 11 | 12 | * ``__toggles__`` class attribute defines constraints and behaviour (see "Toggles" section below) 13 | 14 | * by default all constraints are enabled 15 | 16 | * *"class hints"* annotated with ``ClassVar`` are noted by ``izulu`` 17 | 18 | * annotated class attributes normally should have values (treated as *"class defaults"*) 19 | * *"class defaults"* can only be static 20 | * *"class defaults"* may be referred within ``__template__`` 21 | 22 | * *"instance hints"* regularly annotated (not with ``ClassVar``) are noted by ``izulu`` 23 | 24 | * all annotated attributes are treated as *"instance attributes"* 25 | * each *"instance attribute"* will automatically obtain value from the ``kwarg`` of the same name 26 | * *"instance attributes"* with default are also treated as *"instance defaults"* 27 | * *"instance defaults"* may be **static and dynamic** 28 | * *"instance defaults"* may be referred within ``__template__`` 29 | 30 | * ``kwargs`` — the new and main way to form exceptions/error instance 31 | 32 | * forget about creating exception instances from message strings 33 | * ``kwargs`` are the datasource for template *"fields"* and *"instance attributes"* 34 | (shared input for templating attribution) 35 | 36 | .. warning:: **Types from type hints are not validated or enforced!** 37 | -------------------------------------------------------------------------------- /.github/workflows/checks.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: tests 3 | 4 | "on": 5 | push: 6 | pull_request: 7 | 8 | jobs: 9 | 10 | misc: 11 | name: "Linting configs and git" 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 12 17 | 18 | - uses: docker://docker.io/tamasfe/taplo:latest 19 | name: "Lint TOMLs" 20 | with: 21 | args: fmt --check --diff 22 | 23 | - uses: actions/setup-python@v5 24 | - name: "Install tox" 25 | run: | 26 | pip install tox 27 | - name: "Lint YAMLs" 28 | run: | 29 | tox -e lint-yaml 30 | - name: "Lint git" 31 | run: | 32 | tox -e lint-git 33 | 34 | lint: 35 | name: "Linting and type checking" 36 | runs-on: ubuntu-24.04 37 | strategy: 38 | fail-fast: true 39 | steps: 40 | - uses: actions/checkout@v4 41 | - uses: actions/setup-python@v5 42 | with: 43 | python-version: "3.13" 44 | - name: "Install tox" 45 | run: | 46 | pip install tox 47 | - name: "Lint formatting" 48 | run: | 49 | tox -e lint-py 50 | - name: "Type checking" 51 | run: | 52 | tox -e lint-mypy 53 | 54 | tests: 55 | name: "Run unit tests" 56 | runs-on: ubuntu-24.04 57 | strategy: 58 | fail-fast: true 59 | matrix: 60 | python-version: ["3.9", "3.10", "3.12", "3.13"] 61 | steps: 62 | - uses: actions/checkout@v4 63 | - uses: actions/setup-python@v5 64 | with: 65 | python-version: ${{ matrix.python-version }} 66 | - name: "Install tox" 67 | run: pip install tox 68 | - name: "Unit tests" 69 | run: | 70 | tox run -e ${{ matrix.python-version }} 71 | -------------------------------------------------------------------------------- /izulu/tools.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import logging 5 | import typing as t 6 | 7 | _LOG = logging.getLogger(__name__) 8 | 9 | 10 | class ErrorDumpDict(t.TypedDict): 11 | type: str 12 | reason: str 13 | fields: t.Dict[str, t.Any] | None 14 | details: t.Dict[t.Any, t.Any] 15 | 16 | 17 | @contextlib.contextmanager 18 | def suppress( 19 | *excs: t.Type[Exception], 20 | exclude: t.Optional[t.Type[Exception]] = None, 21 | ) -> t.Generator[None, None, None]: 22 | exc_targets = excs or Exception 23 | try: 24 | yield 25 | except exc_targets as e: 26 | if exclude and isinstance(e, exclude): 27 | raise 28 | _LOG.error("Error suppressed: %s", e) 29 | 30 | 31 | def error_chain(exc: BaseException) -> t.Generator[BaseException, None, None]: 32 | """Return generator over the whole exception chain.""" 33 | yield exc 34 | while exc.__cause__ is not None: 35 | exc = exc.__cause__ 36 | yield exc 37 | 38 | 39 | @t.overload 40 | def dump(exc: BaseException, /) -> ErrorDumpDict: ... 41 | 42 | 43 | @t.overload 44 | def dump( 45 | exc: BaseException, 46 | /, 47 | *excs: BaseException, 48 | ) -> t.Tuple[ErrorDumpDict, ...]: ... 49 | 50 | 51 | def dump( 52 | exc: BaseException, 53 | /, 54 | *excs: BaseException, 55 | ) -> t.Union[ErrorDumpDict, t.Tuple[ErrorDumpDict, ...]]: 56 | """Return single or tuple of dict representations.""" 57 | fields = None 58 | if hasattr(exc, "_Error__cls_store"): 59 | fields = exc.as_dict(wide=True) # type: ignore[attr-defined] 60 | 61 | dumped: ErrorDumpDict = dict( 62 | type=exc.__class__.__name__, 63 | reason=str(exc), 64 | fields=fields, 65 | details={}, 66 | ) 67 | 68 | if excs: 69 | return dumped, *(dump(e) for e in excs) 70 | 71 | return dumped 72 | -------------------------------------------------------------------------------- /docs/source/specs/validations.rst: -------------------------------------------------------------------------------- 1 | Validation and behavior in case of problems 2 | =========================================== 3 | 4 | ``izulu`` may trigger native Python exceptions on invalid data during validation process. 5 | By default you should expect following ones 6 | 7 | * ``TypeError``: argument constraints issues 8 | * ``ValueError``: template and formatting issues 9 | 10 | Some exceptions are *raised from* original exception (e.g. template formatting issues), 11 | so you can check ``e.__cause__`` and traceback output for details. 12 | 13 | 14 | The validation behavior depends on the set of enabled toggles. 15 | Changing toggle set may cause different and raw exceptions being raised. 16 | Read and understand **"Toggles"** section to predict and experiment with different situations and behaviours. 17 | 18 | 19 | ``izulu`` has **2 validation stages:** 20 | 21 | * class definition stage 22 | 23 | * validation is made during error class definition 24 | 25 | .. code-block:: python 26 | 27 | # when you import error module 28 | from izulu import root 29 | 30 | # when you import error from module 31 | from izulu.root import Error 32 | 33 | # when you interactively define new error classes 34 | class MyError(Error): 35 | pass 36 | 37 | * class attributes ``__template__`` and ``__toggles__`` are validated 38 | 39 | .. code-block:: python 40 | 41 | class MyError(Error): 42 | __template__ = "Hello {}" 43 | 44 | # ValueError: Field names can't be empty 45 | 46 | * runtime stage 47 | 48 | * validation is made during error instantiation 49 | 50 | .. code-block:: python 51 | 52 | root.Error() 53 | 54 | * ``kwargs`` are validated according to enabled toggles 55 | 56 | .. code-block:: python 57 | 58 | class MyError(Error): 59 | __template__ = "Hello {name}" 60 | name: str 61 | 62 | MyError() 63 | # TypeError: Missing arguments: 'name' 64 | -------------------------------------------------------------------------------- /docs/source/_static/_js/custom-icon.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | * Set a custom icon for pypi as it's not available in the fa built-in brands 3 | */ 4 | FontAwesome.library.add( 5 | (faListOldStyle = { 6 | prefix: "fa-custom", 7 | iconName: "pypi", 8 | icon: [ 9 | 17.313, // viewBox width 10 | 19.807, // viewBox height 11 | [], // ligature 12 | "e001", // unicode codepoint - private use area 13 | "m10.383 0.2-3.239 1.1769 3.1883 1.1614 3.239-1.1798zm-3.4152 1.2411-3.2362 1.1769 3.1855 1.1614 3.2369-1.1769zm6.7177 0.00281-3.2947 1.2009v3.8254l3.2947-1.1988zm-3.4145 1.2439-3.2926 1.1981v3.8254l0.17548-0.064132 3.1171-1.1347zm-6.6564 0.018325v3.8247l3.244 1.1805v-3.8254zm10.191 0.20931v2.3137l3.1777-1.1558zm3.2947 1.2425-3.2947 1.1988v3.8254l3.2947-1.1988zm-8.7058 0.45739c0.00929-1.931e-4 0.018327-2.977e-4 0.027485 0 0.25633 0.00851 0.4263 0.20713 0.42638 0.49826 1.953e-4 0.38532-0.29327 0.80469-0.65542 0.93662-0.36226 0.13215-0.65608-0.073306-0.65613-0.4588-6.28e-5 -0.38556 0.2938-0.80504 0.65613-0.93662 0.068422-0.024919 0.13655-0.038114 0.20156-0.039466zm5.2913 0.78369-3.2947 1.1988v3.8247l3.2947-1.1981zm-10.132 1.239-3.2362 1.1769 3.1883 1.1614 3.2362-1.1769zm6.7177 0.00213-3.2926 1.2016v3.8247l3.2926-1.2009zm-3.4124 1.2439-3.2947 1.1988v3.8254l3.2947-1.1988zm-6.6585 0.016195v3.8275l3.244 1.1805v-3.8254zm16.9 0.21143-3.2947 1.1988v3.8247l3.2947-1.1981zm-3.4145 1.2411-3.2926 1.2016v3.8247l3.2926-1.2009zm-3.4145 1.2411-3.2926 1.2016v3.8247l3.2926-1.2009zm-3.4124 1.2432-3.2947 1.1988v3.8254l3.2947-1.1988zm-6.6585 0.019027v3.8247l3.244 1.1805v-3.8254zm13.485 1.4497-3.2947 1.1988v3.8247l3.2947-1.1981zm-3.4145 1.2411-3.2926 1.2016v3.8247l3.2926-1.2009zm2.4018 0.38127c0.0093-1.83e-4 0.01833-3.16e-4 0.02749 0 0.25633 0.0085 0.4263 0.20713 0.42638 0.49826 1.97e-4 0.38532-0.29327 0.80469-0.65542 0.93662-0.36188 0.1316-0.65525-0.07375-0.65542-0.4588-1.95e-4 -0.38532 0.29328-0.80469 0.65542-0.93662 0.06842-0.02494 0.13655-0.03819 0.20156-0.03947zm-5.8142 0.86403-3.244 1.1805v1.4201l3.244 1.1805z", // svg path (https://simpleicons.org/icons/pypi.svg) 14 | ], 15 | }), 16 | ); 17 | -------------------------------------------------------------------------------- /tests/error/test_stringification.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from unittest import mock 3 | 4 | import pytest 5 | 6 | from tests import errors 7 | 8 | TS = datetime.datetime.now(datetime.timezone.utc) 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "kwargs", 13 | [ 14 | dict(), 15 | dict(name="John"), 16 | dict(age=42), 17 | dict(name="John", age=42), 18 | dict(name="John", age=42, ts=53452345.3465), 19 | dict(name="John", age="Karl", ts=TS), 20 | ], 21 | ) 22 | def test_templating(kwargs): 23 | match = "Failed to format template with provided kwargs:" 24 | 25 | with pytest.raises(ValueError, match=match): 26 | errors.ComplexTemplateOnlyError(**kwargs) 27 | 28 | 29 | @pytest.mark.parametrize( 30 | ("err", "expected"), 31 | [ 32 | (errors.RootError(), "Unspecified error"), 33 | ( 34 | errors.MixedError(name="John", age=10, note="..."), 35 | "The John is 10 years old with ...", 36 | ), 37 | ], 38 | ) 39 | def test_str(err, expected): 40 | assert str(err) == expected 41 | 42 | 43 | @pytest.mark.parametrize( 44 | ("err", "expected"), 45 | [ 46 | (errors.RootError(), "tests.errors.RootError()"), 47 | ( 48 | errors.MixedError(name="John", age=10, note="...", timestamp=TS), 49 | ( 50 | "tests.errors.MixedError(name='John', age=10," 51 | f" note='...', timestamp={TS!r}, my_type='MixedError')" 52 | ), 53 | ), 54 | ], 55 | ) 56 | def test_repr(err, expected): 57 | assert repr(err) == expected 58 | 59 | 60 | def test_repl_repr(): 61 | e = errors.TemplateOnlyError(name="John", age=42) 62 | 63 | with mock.patch.object( 64 | errors.TemplateOnlyError, 65 | "__module__", 66 | new_callable=mock.PropertyMock, 67 | ) as mocked: 68 | mocked.return_value = "__main__" 69 | 70 | assert repr(e) == "__main__.TemplateOnlyError(name='John', age=42)" 71 | 72 | 73 | @pytest.mark.parametrize( 74 | ("err", "expected"), 75 | [ 76 | (errors.RootError(), "RootError: Unspecified error"), 77 | ( 78 | errors.MixedError(name="John", age=10, note="..."), 79 | "MixedError: The John is 10 years old with ...", 80 | ), 81 | ], 82 | ) 83 | def test_as_str(err, expected): 84 | assert err.as_str() == expected 85 | -------------------------------------------------------------------------------- /tests/errors.py: -------------------------------------------------------------------------------- 1 | import datetime as dtm 2 | import typing as t 3 | 4 | from izulu import root 5 | 6 | 7 | class RootError(root.Error): 8 | __toggles__ = root.Toggles.DEFAULT ^ root.Toggles.FORBID_UNANNOTATED_FIELDS 9 | 10 | 11 | class TemplateOnlyError(RootError): 12 | __template__ = "The {name} is {age} years old" 13 | 14 | 15 | class ComplexTemplateOnlyError(RootError): 16 | __template__ = "{name:*^20} {age: f} {age:#b} {ts:%Y-%m-%d %H:%M:%S}" 17 | __toggles__ = root.Toggles.NONE 18 | 19 | 20 | class AttributesOnlyError(RootError): 21 | __template__ = "Static message template" 22 | 23 | name: str 24 | age: int 25 | 26 | 27 | class AttributesWithStaticDefaultsError(RootError): 28 | __template__ = "Static message template" 29 | 30 | name: str 31 | age: int = 0 32 | 33 | 34 | class AttributesWithDynamicDefaultsError(RootError): 35 | __template__ = "Static message template" 36 | 37 | name: str 38 | age: int = root.factory(default_factory=int) 39 | 40 | 41 | class ClassVarsError(RootError): 42 | __template__ = "Static message template" 43 | 44 | name: t.ClassVar[str] = "Username" 45 | age: t.ClassVar[int] = 42 46 | blah: t.ClassVar[float] 47 | 48 | 49 | class MixedError(RootError): 50 | __template__ = "The {name} is {age} years old with {note}" 51 | 52 | entity: t.ClassVar[str] = "The Entity" 53 | 54 | name: str 55 | age: int = 0 56 | timestamp: dtm.datetime = root.factory(default_factory=dtm.datetime.now) 57 | my_type: str = root.factory( 58 | default_factory=lambda self: self.__class__.__name__, 59 | self=True, 60 | ) 61 | 62 | 63 | class DerivedError(MixedError): 64 | __template__ = "The {name} {surname} is {age} years old with {note}" 65 | 66 | entity: t.ClassVar[str] = "The Entity" 67 | 68 | surname: str 69 | location: t.Tuple[float, float] = (50.3, 3.608) 70 | updated_at: dtm.datetime = root.factory(default_factory=dtm.datetime.now) 71 | full_name: str = root.factory( 72 | default_factory=lambda self: f"{self.name} {self.surname}", 73 | self=True, 74 | ) 75 | box: dict 76 | 77 | 78 | class MyError(RootError): 79 | __template__ = "The {name} is {age} years old with {ENTITY} {note}" 80 | 81 | DEFAULT = "default" 82 | HINT: t.ClassVar[int] 83 | ENTITY: t.ClassVar[str] = "The Entity" 84 | 85 | name: str 86 | age: int = 0 87 | timestamp: dtm.datetime = root.factory(default_factory=dtm.datetime.now) 88 | my_type: str = root.factory( 89 | default_factory=lambda self: self.__class__.__name__, 90 | self=True, 91 | ) 92 | -------------------------------------------------------------------------------- /docs/source/user/recipes.rst: -------------------------------------------------------------------------------- 1 | Recipes & Tips 2 | ************** 3 | 4 | 1. inheritance / root exception 5 | =============================== 6 | 7 | .. code-block:: python 8 | 9 | # intermediate class to centrally control the default behaviour 10 | class BaseError(Error): # <-- inherit from this in your code (not directly from ``izulu``) 11 | __toggles__ = Toggles.None 12 | 13 | 14 | class MyRealError(BaseError): 15 | __template__ = "Having count={count} for owner={owner}" 16 | owner: str 17 | count: int 18 | 19 | 20 | 2. factories 21 | ============ 22 | 23 | TODO: self=True / self.as_kwargs() (as_dict forbidden? - recursion) 24 | 25 | 26 | * stdlib factories 27 | 28 | .. code-block:: python 29 | 30 | from uuid import uuid4 31 | 32 | class MyError(Error): 33 | id: datetime = factory(uuid4) 34 | timestamp: datetime = factory(datetime.now) 35 | 36 | * lambdas 37 | 38 | .. code-block:: python 39 | 40 | class MyError(Error): 41 | timestamp: datetime = factory(lambda: datetime.now().isoformat()) 42 | 43 | * function 44 | 45 | .. code-block:: python 46 | 47 | from random import randint 48 | 49 | def flip_coin(): 50 | return "TAILS" if randint(0, 100) % 2 else "HEADS 51 | 52 | class MyError(Error): 53 | coin: str = factory(flip_coin) 54 | 55 | 56 | * method 57 | 58 | .. code-block:: python 59 | 60 | class MyError(Error): 61 | __template__ = "Having count={count} for owner={owner}" 62 | owner: str 63 | count: int 64 | 65 | def __make_duration(self) -> timedelta: 66 | kwargs = self.as_kwargs() 67 | return self.timestamp - kwargs["begin"] 68 | 69 | timestamp: datetime = factory(datetime.now) 70 | duration: timedelta = factory(__make_duration, self=True) 71 | 72 | 73 | begin = datetime.fromordinal(date.today().toordinal()) 74 | e = MyError(count=10, begin=begin) 75 | 76 | print(e.begin) 77 | # 2023-09-27 00:00:00 78 | print(e.duration) 79 | # 18:45:44.502490 80 | print(e.timestamp) 81 | # 2023-09-27 18:45:44.502490 82 | 83 | 84 | 3. handling errors in presentation layers / APIs 85 | ================================================ 86 | 87 | .. code-block:: python 88 | 89 | err = Error() 90 | view = RespModel(error=err.as_dict(wide=True) 91 | 92 | 93 | class MyRealError(BaseError): 94 | __template__ = "Having count={count} for owner={owner}" 95 | owner: str 96 | count: int 97 | 98 | 99 | Additional examples 100 | ------------------- 101 | 102 | TBD 103 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Publish Python 🐍 distribution 📦 to PyPI" 3 | 4 | "on": 5 | push: 6 | tags: 7 | - "*" 8 | 9 | jobs: 10 | build: 11 | name: "Build distribution 📦" 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | persist-credentials: false 18 | - name: "Set up Python" 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.12" 22 | - name: "Install pypa/build" 23 | run: >- 24 | python3 -m 25 | pip install 26 | build 27 | --user 28 | - name: "Build a binary wheel and a source tarball" 29 | run: python3 -m build 30 | - name: "Store the distribution packages" 31 | uses: actions/upload-artifact@v4 32 | with: 33 | name: python-package-distributions 34 | path: dist/ 35 | 36 | publish-to-pypi: 37 | name: "Publish Python 🐍 distribution 📦 to PyPI" 38 | needs: 39 | - build 40 | runs-on: ubuntu-latest 41 | environment: 42 | name: pypi 43 | url: https://pypi.org/p/izulu 44 | permissions: 45 | id-token: write # IMPORTANT: mandatory for trusted publishing 46 | 47 | steps: 48 | - name: "Download all the dists" 49 | uses: actions/download-artifact@v4 50 | with: 51 | name: python-package-distributions 52 | path: dist/ 53 | - name: "Publish distribution 📦 to PyPI" 54 | uses: pypa/gh-action-pypi-publish@release/v1 55 | 56 | github-release: 57 | name: >- 58 | Sign the Python 🐍 distribution 📦 with Sigstore 59 | and upload them to GitHub Release 60 | needs: 61 | - publish-to-pypi 62 | runs-on: ubuntu-latest 63 | 64 | permissions: 65 | contents: write # IMPORTANT: mandatory for making GitHub Releases 66 | id-token: write # IMPORTANT: mandatory for sigstore 67 | 68 | steps: 69 | - name: "Download all the dists" 70 | uses: actions/download-artifact@v4 71 | with: 72 | name: python-package-distributions 73 | path: dist/ 74 | - name: "Sign the dists with Sigstore" 75 | uses: sigstore/gh-action-sigstore-python@v3.0.0 76 | with: 77 | inputs: >- 78 | ./dist/*.tar.gz 79 | ./dist/*.whl 80 | - name: "Create GitHub Release" 81 | env: 82 | GITHUB_TOKEN: ${{ github.token }} 83 | run: >- 84 | gh release create 85 | "$GITHUB_REF_NAME" 86 | --repo "$GITHUB_REPOSITORY" 87 | --notes "" 88 | - name: "Upload artifact signatures to GitHub Release" 89 | env: 90 | GITHUB_TOKEN: ${{ github.token }} 91 | # Upload to GitHub Release using the `gh` CLI. 92 | # `dist/` contains the built packages, and the 93 | # sigstore-produced signatures and certificates. 94 | run: >- 95 | gh release upload 96 | "$GITHUB_REF_NAME" dist/** 97 | --repo "$GITHUB_REPOSITORY" 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # uv 30 | uv.lock 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | cover/ 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Sphinx documentation 59 | docs/build/ 60 | 61 | # PyBuilder 62 | .pybuilder/ 63 | target/ 64 | 65 | # IPython 66 | profile_default/ 67 | ipython_config.py 68 | 69 | # Translations 70 | *.mo 71 | *.pot 72 | 73 | # pyenv 74 | # For a library or package, you might want to ignore these files since the code is 75 | # intended to run in multiple environments; otherwise, check them in: 76 | .python-version 77 | 78 | # pipenv 79 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 80 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 81 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 82 | # install all needed dependencies. 83 | #Pipfile.lock 84 | 85 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 86 | __pypackages__/ 87 | 88 | # Environments 89 | .env 90 | .venv 91 | env/ 92 | venv/ 93 | ENV/ 94 | env.bak/ 95 | venv.bak/ 96 | 97 | # mypy 98 | .mypy_cache/ 99 | .dmypy.json 100 | dmypy.json 101 | 102 | # Pyre type checker 103 | .pyre/ 104 | 105 | # pytype static type analyzer 106 | .pytype/ 107 | 108 | # Cython debug symbols 109 | cython_debug/ 110 | 111 | # Lints 112 | .ruff_cache 113 | 114 | # PyCharm 115 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 116 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 117 | # and can be added to the global gitignore or merged into this file. For a more nuclear 118 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 119 | .idea/ 120 | 121 | # vscode 122 | .vscode/ 123 | 124 | # Emacs 125 | *~ 126 | \#*\# 127 | /.emacs.desktop 128 | /.emacs.desktop.lock 129 | *.elc 130 | auto-save-list 131 | tramp 132 | .\#* 133 | 134 | # Org-mode 135 | .org-id-locations 136 | *_archive 137 | 138 | # flymake-mode 139 | *_flymake.* 140 | 141 | # eshell files 142 | /eshell/history 143 | /eshell/lastdir 144 | 145 | # elpa packages 146 | /elpa/ 147 | 148 | # reftex files 149 | *.rel 150 | 151 | # Flycheck 152 | flycheck_*.el 153 | 154 | # projectiles files 155 | .projectile 156 | 157 | # directory configuration 158 | .dir-locals.el 159 | 160 | # MacOS 161 | .DS_Store 162 | 163 | *.html 164 | -------------------------------------------------------------------------------- /docs/source/user/intro.rst: -------------------------------------------------------------------------------- 1 | Presenting "izulu" 2 | ****************** 3 | 4 | Bring OOP into exception/error management 5 | ========================================= 6 | 7 | You can read docs *from top to bottom* or jump straight into **"Quickstart"** section. 8 | For details note **"Specifications"** sections below. 9 | 10 | 11 | Neat #1: Stop messing with raw strings and manual message formatting 12 | -------------------------------------------------------------------- 13 | 14 | .. code-block:: python 15 | 16 | if not data: 17 | raise ValueError("Data is invalid: no data") 18 | 19 | amount = data["amount"] 20 | if amount < 0: 21 | raise ValueError(f"Data is invalid: amount can't be negative ({amount})") 22 | elif amount > 1000: 23 | raise ValueError(f"Data is invalid: amount is too large ({amount})") 24 | 25 | if data["status"] not in {"READY", "IN_PROGRESS"}: 26 | raise ValueError("Data is invalid: unprocessable status") 27 | 28 | With ``izulu`` you can forget about manual error message management all over the codebase! 29 | 30 | .. code-block:: python 31 | 32 | class ValidationError(Error): 33 | __template__ = "Data is invalid: {reason}" 34 | reason: str 35 | 36 | class AmountValidationError(ValidationError): 37 | __template__ = "Invalid amount: {amount}" 38 | amount: int 39 | 40 | 41 | if not data: 42 | raise ValidationError(reason="no data") 43 | 44 | amount = data["amount"] 45 | if amount < 0: 46 | raise AmountValidationError(reason="amount can't be negative", amount=amount) 47 | elif amount > 1000: 48 | raise AmountValidationError(reason="amount is too large", amount=amount) 49 | 50 | if data["status"] not in {"READY", "IN_PROGRESS"}: 51 | raise ValidationError(reason="unprocessable status") 52 | 53 | 54 | Provide only variable data for error instantiations. Keep static data within error class. 55 | 56 | Under the hood ``kwargs`` are used to format ``__template__`` into final error message. 57 | 58 | 59 | Neat #2: Attribute errors with useful fields 60 | -------------------------------------------- 61 | 62 | .. code-block:: python 63 | 64 | from falcon import HTTPBadRequest 65 | 66 | class AmountValidationError(ValidationError): 67 | __template__ = "Data is invalid: {reason} ({amount})" 68 | reason: str 69 | amount: int 70 | 71 | 72 | try: 73 | validate(data) 74 | except AmountValidationError as e: 75 | if e.amount < 0: 76 | raise HTTPBadRequest(f"Bad amount: {e.amount}") 77 | raise 78 | 79 | 80 | Annotated instance attributes automatically populated from ``kwargs``. 81 | 82 | 83 | Neat #3: Static and dynamic defaults 84 | ------------------------------------ 85 | 86 | .. code-block:: python 87 | 88 | class AmountValidationError(ValidationError): 89 | __template__ = "Data is invalid: {reason} ({amount}; MAX={_MAX}) at {ts}" 90 | _MAX: ClassVar[int] = 1000 91 | amount: int 92 | reason: str = "amount is too large" 93 | ts: datetime = factory(datetime.now) 94 | 95 | 96 | print(AmountValidationError(amount=15000)) 97 | # Data is invalid: amount is too large (15000; MAX=1000) at 2024-01-13 22:59:25.132699 98 | 99 | print(AmountValidationError(amount=-1, reason="amount can't be negative")) 100 | # Data is invalid: amount can't be negative (-1; MAX=1000) at 2024-01-13 22:59:54.482577 101 | -------------------------------------------------------------------------------- /tests/error/test_features.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from izulu import root 6 | from tests import errors 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "kls", 11 | [errors.TemplateOnlyError, errors.AttributesOnlyError], 12 | ) 13 | @pytest.mark.parametrize("kwargs", [dict(), dict(name="John"), dict(age=42)]) 14 | def test_forbid_missing_fields_triggered(kls, kwargs): 15 | toggles = {"__toggles__": root.Toggles.FORBID_MISSING_FIELDS} 16 | 17 | with pytest.raises(TypeError): 18 | type("TestError", (kls,), toggles)(**kwargs) 19 | 20 | 21 | @pytest.mark.parametrize( 22 | ("kls", "kwargs"), 23 | [ 24 | (errors.RootError, dict(field="value")), 25 | (errors.TemplateOnlyError, dict(name="John", age=42, field="field")), 26 | (errors.AttributesOnlyError, dict(name="John", age=42, field="field")), 27 | ], 28 | ) 29 | def test_forbid_undeclared_fields_triggered(kls, kwargs): 30 | toggles = {"__toggles__": root.Toggles.FORBID_UNDECLARED_FIELDS} 31 | 32 | with pytest.raises(TypeError): 33 | type("TestError", (kls,), toggles)(**kwargs) 34 | 35 | 36 | @pytest.mark.parametrize( 37 | ("kls", "kwargs"), 38 | [ 39 | (errors.ClassVarsError, dict(name="John")), 40 | (errors.ClassVarsError, dict(age=0)), 41 | (errors.ClassVarsError, dict(blah=1.0)), 42 | (errors.ClassVarsError, dict(name="John", age=0)), 43 | (errors.ClassVarsError, dict(age=0, blah=1.0)), 44 | (errors.MixedError, dict(name="John", age=42, entity="thing")), 45 | ], 46 | ) 47 | def test_forbid_kwarg_consts_triggered(kls, kwargs): 48 | toggles = {"__toggles__": root.Toggles.FORBID_KWARG_CONSTS} 49 | 50 | with pytest.raises(TypeError): 51 | type("TestError", (kls,), toggles)(**kwargs) 52 | 53 | 54 | @pytest.mark.parametrize("toggles", [root.Toggles(i) for i in range(7)]) 55 | @mock.patch("izulu._utils.check_kwarg_consts") 56 | @mock.patch("izulu._utils.check_undeclared_fields") 57 | @mock.patch("izulu._utils.check_missing_fields") 58 | def test_process_toggles(mock_missing, mock_undeclared, mock_const, toggles): 59 | with mock.patch.object( 60 | errors.RootError, 61 | "__toggles__", 62 | new_callable=mock.PropertyMock, 63 | ) as mocked: 64 | mocked.return_value = toggles 65 | 66 | e = errors.RootError() 67 | args = (e._Error__cls_store, frozenset(e._Error__kwargs)) 68 | 69 | if root.Toggles.FORBID_MISSING_FIELDS in toggles: 70 | mock_missing.assert_called_once_with(*args) 71 | else: 72 | mock_missing.assert_not_called() 73 | if root.Toggles.FORBID_UNDECLARED_FIELDS in toggles: 74 | mock_undeclared.assert_called_once_with(*args) 75 | else: 76 | mock_undeclared.assert_not_called() 77 | if root.Toggles.FORBID_KWARG_CONSTS in toggles: 78 | mock_const.assert_called_once_with(*args) 79 | else: 80 | mock_const.assert_not_called() 81 | 82 | 83 | def test_feature_presets(): 84 | default = ( 85 | root.Toggles.FORBID_MISSING_FIELDS 86 | | root.Toggles.FORBID_UNDECLARED_FIELDS 87 | | root.Toggles.FORBID_KWARG_CONSTS 88 | | root.Toggles.FORBID_NON_NAMED_FIELDS 89 | | root.Toggles.FORBID_UNANNOTATED_FIELDS 90 | ) 91 | 92 | assert root.Toggles.NONE is root.Toggles(0) 93 | assert root.Toggles(0) == root.Toggles.NONE 94 | assert root.Toggles.DEFAULT is default 95 | assert default == root.Toggles.DEFAULT 96 | 97 | 98 | def test_default_toggles(): 99 | assert root.Error.__toggles__ is root.Toggles.DEFAULT 100 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | from importlib import metadata 6 | 7 | # -- Project information ----------------------------------------------------- 8 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 9 | 10 | project = "izulu" 11 | author = "Dima Burmistrov" 12 | copyright = "2023-%Y, " + author 13 | # -- setuptools_scm ---------------------------------------------------------- 14 | version = metadata.version(project) 15 | release = ".".join(version.split(".")[:3]) 16 | 17 | 18 | # -- General configuration --------------------------------------------------- 19 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 20 | 21 | extensions = [ 22 | "sphinx.ext.duration", 23 | "sphinx.ext.todo", # Support for todo items 24 | "sphinx.ext.viewcode", # Add links to highlighted source code 25 | "sphinx.ext.intersphinx", # Link to other projects’ documentation 26 | # custom extentions 27 | "sphinx_copybutton", # add a little “copy” button to the right of your code blocks 28 | "sphinx_design", # for designing beautiful, screen-size responsive web-components 29 | "sphinx_favicon", 30 | "sphinx_togglebutton", 31 | # not used 32 | "sphinx.ext.autodoc", # Include documentation from docstrings # check sphinx.ext.apidoc 33 | "sphinx.ext.napoleon", # Support for NumPy and Google style docstrings 34 | # "sphinx.ext.autosummary", # Generate autodoc summaries 35 | # "sphinx.ext.graphviz", # Add Graphviz graphs 36 | ] 37 | 38 | autosummary_generate = True 39 | 40 | exclude_patterns = [] 41 | 42 | 43 | # -- Options for HTML output ------------------------------------------------- 44 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 45 | 46 | html_theme = "pydata_sphinx_theme" 47 | html_logo = "https://repository-images.githubusercontent.com/766241795/85494614-5974-4b26-bfec-03b8e393c7f0" 48 | 49 | templates_path = ["_templates"] 50 | html_static_path = ["_static"] 51 | html_js_files = ["_js/custom-icon.js"] 52 | html_sourcelink_suffix = "" 53 | 54 | html_theme_options = { 55 | "use_edit_page_button": True, 56 | 57 | "show_toc_level": 2, 58 | 59 | "secondary_sidebar_items": { 60 | "**/*": ["page-toc", "edit-this-page", "sourcelink"], 61 | "*": ["page-toc", "edit-this-page", "sourcelink"], 62 | "index": [], 63 | }, 64 | 65 | "icon_links": [ 66 | { 67 | "name": "GitHub", 68 | "url": "https://github.com/pyctrl/izulu", 69 | "icon": "fa-brands fa-github", 70 | }, 71 | { 72 | "name": "PyPI", 73 | "url": "https://www.pypi.org", 74 | "icon": "fa-custom fa-pypi", 75 | }, 76 | { 77 | "name": "pyctrl", 78 | "url": "https://github.com/pyctrl", 79 | "icon": "https://github.com/pyctrl/pyctrl/blob/main/logo/pyctrl/gray-460x460.png?raw=true", 80 | "type": "url", 81 | }, 82 | ], 83 | 84 | "footer_start": ["copyright"], 85 | "footer_center": ["sphinx-version"], 86 | } 87 | 88 | html_context = { 89 | "github_url": "https://github.com", 90 | "github_user": "pyctrl", 91 | "github_repo": "izulu", 92 | "github_version": "main", 93 | "doc_path": "docs/source/", 94 | } 95 | 96 | favicons = [ 97 | { 98 | "sizes": "16x16", 99 | "href": "https://github.com/pyctrl/pyctrl/blob/main/logo/izulu/izulu_logo_512.png?raw=true", 100 | }, 101 | { 102 | "sizes": "32x32", 103 | "href": "https://github.com/pyctrl/pyctrl/blob/main/logo/izulu/izulu_logo_512.png?raw=true", 104 | }, 105 | ] 106 | -------------------------------------------------------------------------------- /docs/source/user/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ========== 3 | 4 | If you haven't done so already, please take a moment to 5 | :ref:`install ` the ``izulu`` before continuing. 6 | 7 | 8 | .. note:: 9 | 10 | **Prepare playground** 11 | 12 | :: 13 | 14 | pip install ipython izulu 15 | 16 | ipython -i -c 'from izulu.root import *; from typing import *; from datetime import *' 17 | 18 | 19 | Let's start with defining our initial error class (exception) 20 | ------------------------------------------------------------- 21 | 22 | #. subclass ``Error`` 23 | #. provide special message template for each of your exceptions 24 | #. use **only kwargs** to instantiate exception *(no more message copying across the codebase)* 25 | 26 | .. code-block:: python 27 | 28 | class MyError(Error): 29 | __template__ = "Having count={count} for owner={owner}" 30 | owner: str 31 | count: int 32 | 33 | 34 | print(MyError(count=10, owner="me")) 35 | # MyError: Having count=10 for owner=me 36 | 37 | MyError(10, owner="me") 38 | # TypeError: __init__() takes 1 positional argument but 2 were given 39 | 40 | 41 | Move on and improve our class with attributes 42 | --------------------------------------------- 43 | 44 | #. define annotations for fields you want to publish as exception instance attributes 45 | #. you have to define desired template fields in annotations too 46 | (see ``AttributeError`` for ``owner``) 47 | #. you can provide annotation for attributes not included in template (see ``timestamp``) 48 | #. **type hinting from annotations are not enforced or checked** (see ``timestamp``) 49 | 50 | .. code-block:: python 51 | 52 | class MyError(Error): 53 | __template__ = "Having count={count} for owner={owner}" 54 | count: int 55 | owner: str 56 | timestamp: datetime 57 | 58 | e = MyError(count=10, owner="me", timestamp=datetime.now()) 59 | 60 | print(e.count) 61 | # 10 62 | print(e.timestamp) 63 | # 2023-09-27 18:18:22.957925 64 | 65 | e.owner 66 | # AttributeError: 'MyError' object has no attribute 'owner' 67 | 68 | 69 | We can provide defaults for our attributes 70 | ------------------------------------------ 71 | 72 | #. define *default static values* after field annotation just as usual 73 | #. for *dynamic defaults* use provided ``factory`` tool with your callable - it would be 74 | evaluated without arguments during exception instantiation 75 | #. now fields would receive values from ``kwargs`` if present - otherwise from *defaults* 76 | 77 | .. code-block:: python 78 | 79 | class MyError(Error): 80 | __template__ = "Having count={count} for owner={owner}" 81 | count: int 82 | owner: str = "nobody" 83 | timestamp: datetime = factory(datetime.now) 84 | 85 | e = MyError(count=10) 86 | 87 | print(e.count) 88 | # 10 89 | print(e.owner) 90 | # nobody 91 | print(e.timestamp) 92 | # 2023-09-27 18:19:37.252577 93 | 94 | 95 | Dynamic defaults also supported 96 | ------------------------------- 97 | 98 | .. code-block:: python 99 | 100 | class MyError(Error): 101 | __template__ = "Having count={count} for owner={owner}" 102 | 103 | count: int 104 | begin: datetime 105 | owner: str = "nobody" 106 | timestamp: datetime = factory(datetime.now) 107 | duration: timedelta = factory(lambda self: self.timestamp - self.begin, self=True) 108 | 109 | 110 | begin = datetime.fromordinal(date.today().toordinal()) 111 | e = MyError(count=10, begin=begin) 112 | 113 | print(e.begin) 114 | # 2023-09-27 00:00:00 115 | print(e.duration) 116 | # 18:45:44.502490 117 | print(e.timestamp) 118 | # 2023-09-27 18:45:44.502490 119 | 120 | 121 | * very similar to dynamic defaults, but callable must accept single 122 | argument - your exception fresh instance 123 | * **don't forget** to provide second ``True`` argument for ``factory`` tool 124 | (keyword or positional - doesn't matter) 125 | -------------------------------------------------------------------------------- /izulu/_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import _string # type: ignore[import-not-found] # noqa: PLC2701 4 | import dataclasses 5 | import string 6 | import typing as t 7 | 8 | if t.TYPE_CHECKING: 9 | import types 10 | 11 | _IZULU_ATTRS = { 12 | "__template__", 13 | "__toggles__", 14 | "_Error__cls_store", 15 | "__reraising__", 16 | "_ReraisingMixin__reraising", 17 | } 18 | _FORMATTER = string.Formatter() 19 | 20 | 21 | # TODO(d.burmistrov): dataclass options 22 | @dataclasses.dataclass 23 | class Store: 24 | fields: t.FrozenSet[str] 25 | const_hints: types.MappingProxyType[str, type] 26 | inst_hints: types.MappingProxyType[str, type] 27 | consts: types.MappingProxyType[str, t.Any] 28 | defaults: t.FrozenSet[str] 29 | 30 | registered: t.FrozenSet[str] = dataclasses.field(init=False) 31 | 32 | def __post_init__(self) -> None: 33 | self.registered = self.fields.union(self.inst_hints) 34 | 35 | 36 | def check_missing_fields(store: Store, kws: t.FrozenSet[str]) -> None: 37 | missing = store.registered.difference(store.defaults, store.consts, kws) 38 | if missing: 39 | raise TypeError(f"Missing arguments: {join_items(missing)}") 40 | 41 | 42 | def check_undeclared_fields(store: Store, kws: t.FrozenSet[str]) -> None: 43 | undeclared = kws.difference(store.registered, store.const_hints) 44 | if undeclared: 45 | raise TypeError(f"Undeclared arguments: {join_items(undeclared)}") 46 | 47 | 48 | def check_kwarg_consts(store: Store, kws: t.FrozenSet[str]) -> None: 49 | consts = kws.intersection(store.const_hints) 50 | if consts: 51 | raise TypeError(f"Constants in arguments: {join_items(consts)}") 52 | 53 | 54 | def check_non_named_fields(store: Store) -> None: 55 | for field in store.fields: 56 | if isinstance(field, int): 57 | msg = f"Field names can't be digits: {field}" 58 | raise ValueError(msg) # noqa: TRY004 59 | if not field: 60 | raise ValueError("Field names can't be empty") 61 | 62 | 63 | def check_unannotated_fields(store: Store) -> None: 64 | unannotated = store.fields - set(store.const_hints) - set(store.inst_hints) 65 | if unannotated: 66 | msg = f"Fields must be annotated: {join_items(unannotated)}" 67 | raise ValueError(msg) 68 | 69 | 70 | def join_items(items: t.Iterable[str]) -> str: 71 | return ", ".join(map("'{}'".format, items)) 72 | 73 | 74 | def join_kwargs(**kwargs: t.Any) -> str: # noqa: ANN401 75 | return ", ".join(f"{k!s}={v!r}" for k, v in kwargs.items()) 76 | 77 | 78 | def format_template(template: str, kwargs: t.Dict[str, t.Any]) -> str: 79 | try: 80 | return template.format_map(kwargs) 81 | except Exception as e: 82 | msg_part = "Failed to format template with provided kwargs: " 83 | raise ValueError(msg_part + join_kwargs(**kwargs)) from e 84 | 85 | 86 | def iter_fields(template: str) -> t.Generator[str, None, None]: 87 | # https://docs.python.org/3/library/string.html#format-string-syntax 88 | for _, fn, _, _ in _FORMATTER.parse(str(template)): 89 | if fn is not None: 90 | yield _string.formatter_field_name_split(fn)[0] 91 | 92 | 93 | def split_cls_hints( 94 | cls: type, 95 | ) -> t.Tuple[t.Dict[str, type], t.Dict[str, type]]: 96 | const_hints: t.Dict[str, type] = {} 97 | inst_hints: t.Dict[str, type] = {} 98 | 99 | for k, v in t.get_type_hints(cls).items(): 100 | if k in _IZULU_ATTRS: 101 | continue 102 | if t.get_origin(v) is t.ClassVar: 103 | const_hints[k] = v 104 | else: 105 | inst_hints[k] = v 106 | 107 | return const_hints, inst_hints 108 | 109 | 110 | def get_cls_defaults( 111 | cls: type, 112 | attrs: t.Iterable[str], 113 | ) -> t.Dict[str, t.Any]: 114 | return {attr: getattr(cls, attr) for attr in attrs if hasattr(cls, attr)} 115 | 116 | 117 | def traverse_tree(cls: type) -> t.Generator[type, None, None]: 118 | workload = cls.__subclasses__() 119 | discovered = [] 120 | while workload: 121 | item = workload.pop() 122 | discovered.append(item) 123 | workload.extend(item.__subclasses__()) 124 | yield from discovered 125 | -------------------------------------------------------------------------------- /tests/error/test_init_subclass.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import types 3 | import typing as t 4 | from unittest import mock 5 | 6 | import pytest 7 | 8 | from izulu import root 9 | from tests import errors 10 | 11 | 12 | @pytest.mark.parametrize( 13 | ("kls", "fields", "hints", "registered", "defaults", "consts"), 14 | [ 15 | ( 16 | errors.RootError, 17 | frozenset(), 18 | types.MappingProxyType({}), 19 | frozenset(), 20 | frozenset(), 21 | types.MappingProxyType({}), 22 | ), 23 | ( 24 | errors.TemplateOnlyError, 25 | frozenset(("name", "age")), 26 | types.MappingProxyType({}), 27 | frozenset(("name", "age")), 28 | frozenset(), 29 | types.MappingProxyType({}), 30 | ), 31 | ( 32 | errors.AttributesOnlyError, 33 | frozenset(), 34 | types.MappingProxyType(dict(name=str, age=int)), 35 | frozenset(("name", "age")), 36 | frozenset(), 37 | types.MappingProxyType({}), 38 | ), 39 | ( 40 | errors.AttributesWithStaticDefaultsError, 41 | frozenset(), 42 | types.MappingProxyType(dict(name=str, age=int)), 43 | frozenset(("name", "age")), 44 | frozenset(("age",)), 45 | types.MappingProxyType({}), 46 | ), 47 | ( 48 | errors.AttributesWithDynamicDefaultsError, 49 | frozenset(), 50 | types.MappingProxyType(dict(name=str, age=int)), 51 | frozenset(("name", "age")), 52 | frozenset(("age",)), 53 | types.MappingProxyType({}), 54 | ), 55 | ( 56 | errors.ClassVarsError, 57 | frozenset(), 58 | types.MappingProxyType({}), 59 | frozenset(), 60 | frozenset(), 61 | types.MappingProxyType(dict(name="Username", age=42)), 62 | ), 63 | ( 64 | errors.MixedError, 65 | frozenset(("name", "age", "note")), 66 | types.MappingProxyType( 67 | dict( 68 | name=str, age=int, timestamp=datetime.datetime, my_type=str 69 | ) 70 | ), 71 | frozenset(("name", "age", "note", "timestamp", "my_type")), 72 | frozenset(("age", "timestamp", "my_type")), 73 | types.MappingProxyType(dict(entity="The Entity")), 74 | ), 75 | ( 76 | errors.DerivedError, 77 | frozenset(("name", "surname", "age", "note")), 78 | types.MappingProxyType( 79 | dict( 80 | name=str, 81 | age=int, 82 | timestamp=datetime.datetime, 83 | my_type=str, 84 | surname=str, 85 | location=t.Tuple[float, float], 86 | updated_at=datetime.datetime, 87 | full_name=str, 88 | box=dict, 89 | ) 90 | ), 91 | frozenset( 92 | ( 93 | "name", 94 | "age", 95 | "note", 96 | "timestamp", 97 | "my_type", 98 | "surname", 99 | "location", 100 | "updated_at", 101 | "full_name", 102 | "box", 103 | ) 104 | ), 105 | frozenset( 106 | ( 107 | "age", 108 | "timestamp", 109 | "my_type", 110 | "location", 111 | "updated_at", 112 | "full_name", 113 | ) 114 | ), 115 | types.MappingProxyType(dict(entity="The Entity")), 116 | ), 117 | ], 118 | ) 119 | def test_cls_store(kls, fields, hints, registered, defaults, consts): 120 | """Validates store management from root.Error.__init_subclass__.""" 121 | store = kls._Error__cls_store 122 | 123 | assert type(store.fields) is type(fields) 124 | assert store.fields == fields 125 | assert type(store.inst_hints) is type(hints) 126 | assert store.inst_hints == hints 127 | assert type(store.registered) is type(registered) 128 | assert store.registered == registered 129 | assert type(store.defaults) is type(defaults) 130 | assert store.defaults == defaults 131 | assert type(store.consts) is type(consts) 132 | assert store.consts == consts 133 | 134 | 135 | @pytest.mark.parametrize( 136 | "toggles", 137 | [root.Toggles.FORBID_NON_NAMED_FIELDS, root.Toggles.NONE], 138 | ) 139 | @mock.patch("izulu._utils.check_non_named_fields", return_value=0) 140 | def test_cls_validation(mocked_check, toggles): 141 | """Validates feature checks from root.Error.__init_subclass__.""" 142 | type("Err", (errors.ClassVarsError,), {"__toggles__": toggles}) 143 | 144 | if toggles is root.Toggles.NONE: 145 | mocked_check.assert_not_called() 146 | else: 147 | mocked_check.assert_called_once() 148 | -------------------------------------------------------------------------------- /docs/source/specs/toggles.rst: -------------------------------------------------------------------------------- 1 | Toggles 2 | ======= 3 | 4 | The ``izulu`` error class behaviour is controlled by ``__toggles__`` class attribute. 5 | 6 | (For details about "runtime" and "class definition" stages 7 | see **Validation and behavior in case of problems** below) 8 | 9 | 10 | Supported toggles 11 | ----------------- 12 | 13 | * ``FORBID_MISSING_FIELDS``: checks provided ``kwargs`` contain data for all template *"fields"* 14 | and *"instance attributes"* that have no *"defaults"* 15 | 16 | * always should be enabled (provides consistent and detailed ``TypeError`` exceptions 17 | for appropriate arguments) 18 | * if disabled raw exceptions from ``izulu`` machinery internals could appear 19 | 20 | ======= ============= 21 | Stage Raises 22 | ======= ============= 23 | runtime ``TypeError`` 24 | ======= ============= 25 | 26 | .. code-block:: python 27 | 28 | class AmountError(Error): 29 | __template__ = "Some {amount} of money for {client_id} client" 30 | amount: int 31 | client_id: int 32 | 33 | # I. enabled 34 | AmountError() 35 | # TypeError: Missing arguments: client_id, amount 36 | 37 | # II. disabled 38 | AmountError.__toggles__ ^= Toggles.FORBID_MISSING_FIELDS 39 | 40 | AmountError() 41 | # ValueError: Failed to format template with provided kwargs: 42 | 43 | * ``FORBID_UNDECLARED_FIELDS``: forbids undefined arguments in provided ``kwargs`` 44 | (names not present in template *"fields"* and *"instance/class hints"*) 45 | 46 | * if disabled allows and **completely ignores** unknown data in ``kwargs`` 47 | 48 | ======= ============= 49 | Stage Raises 50 | ======= ============= 51 | runtime ``TypeError`` 52 | ======= ============= 53 | 54 | .. code-block:: python 55 | 56 | class MyError(Error): 57 | __template__ = "My error occurred" 58 | 59 | # I. enabled 60 | MyError(unknown_data="data") 61 | # Undeclared arguments: unknown_data 62 | 63 | # II. disabled 64 | MyError.__toggles__ ^= Toggles.FORBID_UNDECLARED_FIELDS 65 | err = MyError(unknown_data="data") 66 | 67 | print(err) 68 | # Unspecified error 69 | print(repr(err)) 70 | # __main__.MyError(unknown_data='data') 71 | err.unknown_data 72 | # AttributeError: 'MyError' object has no attribute 'unknown_data' 73 | 74 | * ``FORBID_KWARG_CONSTS``: checks provided ``kwargs`` not to contain attributes defined as ``ClassVar`` 75 | 76 | * if disabled allows data in ``kwargs`` to overlap class attributes during template formatting 77 | * overlapping data won't modify class attribute values 78 | 79 | ======= ============= 80 | Stage Raises 81 | ======= ============= 82 | runtime ``TypeError`` 83 | ======= ============= 84 | 85 | .. code-block:: python 86 | 87 | class MyError(Error): 88 | __template__ = "My error occurred {_TYPE}" 89 | _TYPE: ClassVar[str] 90 | 91 | # I. enabled 92 | MyError(_TYPE="SOME_ERROR_TYPE") 93 | # TypeError: Constants in arguments: _TYPE 94 | 95 | # II. disabled 96 | MyError.__toggles__ ^= Toggles.FORBID_KWARG_CONSTS 97 | err = MyError(_TYPE="SOME_ERROR_TYPE") 98 | 99 | print(err) 100 | # My error occurred SOME_ERROR_TYPE 101 | print(repr(err)) 102 | # __main__.MyError(_TYPE='SOME_ERROR_TYPE') 103 | err._TYPE 104 | # AttributeError: 'MyError' object has no attribute '_TYPE' 105 | 106 | * ``FORBID_NON_NAMED_FIELDS``: forbids empty and digit field names in ``__template__`` 107 | 108 | * if disabled validation (runtime issues) 109 | * ``izulu`` relies on ``kwargs`` and named fields 110 | * by default it's forbidden to provide empty (``{}``) and digit (``{0}``) fields in ``__template__`` 111 | 112 | ================ ============== 113 | Stage Raises 114 | ================ ============== 115 | class definition ``ValueError`` 116 | ================ ============== 117 | 118 | .. code-block:: python 119 | 120 | class MyError(Error): 121 | __template__ = "My error occurred {_TYPE}" 122 | _TYPE: ClassVar[str] 123 | 124 | # I. enabled 125 | MyError(_TYPE="SOME_ERROR_TYPE") 126 | # TypeError: Constants in arguments: _TYPE 127 | 128 | # II. disabled 129 | MyError.__toggles__ ^= Toggles.FORBID_KWARG_CONSTS 130 | err = MyError(_TYPE="SOME_ERROR_TYPE") 131 | 132 | print(err) 133 | # My error occurred SOME_ERROR_TYPE 134 | print(repr(err)) 135 | # __main__.MyError(_TYPE='SOME_ERROR_TYPE') 136 | err._TYPE 137 | # AttributeError: 'MyError' object has no attribute '_TYPE' 138 | 139 | 140 | Tuning ``__toggles__`` 141 | ----------------------- 142 | 143 | Toggles are represented as *"Flag Enum"*, so you can use regular operations 144 | to configure desired behaviour. 145 | Examples: 146 | 147 | * Use single option 148 | 149 | .. code-block:: python 150 | 151 | class AmountError(Error): 152 | __toggles__ = Toggles.FORBID_MISSING_FIELDS 153 | 154 | * Use presets 155 | 156 | .. code-block:: python 157 | 158 | class AmountError(Error): 159 | __toggles__ = Toggles.NONE 160 | 161 | * Combining wanted toggles: 162 | 163 | .. code-block:: python 164 | 165 | class AmountError(Error): 166 | __toggles__ = Toggles.FORBID_MISSING_FIELDS | Toggles.FORBID_KWARG_CONSTS 167 | 168 | * Discarding unwanted toggle from default toggle set: 169 | 170 | .. code-block:: python 171 | 172 | class AmountError(Error): 173 | __toggles__ = Toggles.DEFAULT ^ Toggles.FORBID_UNDECLARED_FIELDS 174 | -------------------------------------------------------------------------------- /tests/error/test_dumping.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import datetime 3 | import pickle 4 | 5 | import pytest 6 | 7 | from izulu import root 8 | from tests import errors 9 | 10 | TS = datetime.datetime.now(datetime.timezone.utc) 11 | 12 | 13 | @pytest.mark.parametrize( 14 | ("err", "expected"), 15 | [ 16 | (errors.RootError(), dict()), 17 | ( 18 | errors.MixedError(name="John", age=10, note="..."), 19 | dict(name="John", age=10, note="..."), 20 | ), 21 | ( 22 | errors.DerivedError( 23 | name="John", surname="Brown", note="...", box={} 24 | ), 25 | dict(name="John", surname="Brown", note="...", box={}), 26 | ), 27 | ], 28 | ) 29 | def test_as_kwargs(err, expected): 30 | kwargs = err.as_kwargs() 31 | assert kwargs == expected 32 | 33 | kwargs["item"] = "SURPRISE" 34 | assert kwargs != err._Error__kwargs 35 | 36 | assert id(kwargs) != id(err._Error__kwargs) 37 | 38 | 39 | @pytest.mark.parametrize( 40 | ("err", "expected", "wide"), 41 | [ 42 | (errors.RootError(), dict(), False), 43 | (errors.RootError(), dict(), True), 44 | (errors.ClassVarsError(), dict(), False), 45 | (errors.ClassVarsError(), dict(age=42, name="Username"), True), 46 | ( 47 | errors.AttributesWithStaticDefaultsError(name="John"), 48 | dict(name="John", age=0), 49 | False, 50 | ), 51 | ( 52 | errors.AttributesWithStaticDefaultsError(name="John"), 53 | dict(name="John", age=0), 54 | True, 55 | ), 56 | ( 57 | errors.MixedError(name="John", age=10, note="...", timestamp=TS), 58 | dict( 59 | name="John", 60 | age=10, 61 | note="...", 62 | my_type="MixedError", 63 | timestamp=TS, 64 | ), 65 | False, 66 | ), 67 | ( 68 | errors.MixedError(name="John", age=10, note="...", timestamp=TS), 69 | dict( 70 | name="John", 71 | age=10, 72 | note="...", 73 | my_type="MixedError", 74 | timestamp=TS, 75 | entity="The Entity", 76 | ), 77 | True, 78 | ), 79 | ( 80 | errors.DerivedError( 81 | name="John", 82 | surname="Brown", 83 | note="...", 84 | box={}, 85 | timestamp=TS, 86 | updated_at=TS, 87 | ), 88 | dict( 89 | name="John", 90 | surname="Brown", 91 | full_name="John Brown", 92 | note="...", 93 | age=0, 94 | box={}, 95 | location=(50.3, 3.608), 96 | my_type="DerivedError", 97 | timestamp=TS, 98 | updated_at=TS, 99 | ), 100 | False, 101 | ), 102 | ( 103 | errors.DerivedError( 104 | name="John", 105 | surname="Brown", 106 | note="...", 107 | box={}, 108 | timestamp=TS, 109 | updated_at=TS, 110 | ), 111 | dict( 112 | name="John", 113 | surname="Brown", 114 | full_name="John Brown", 115 | note="...", 116 | age=0, 117 | box={}, 118 | location=(50.3, 3.608), 119 | my_type="DerivedError", 120 | timestamp=TS, 121 | updated_at=TS, 122 | entity="The Entity", 123 | ), 124 | True, 125 | ), 126 | ], 127 | ) 128 | def test_as_dict(err, expected, wide): 129 | data = err.as_dict(wide=wide) 130 | assert data == expected 131 | 132 | data["item"] = "SURPRISE" 133 | assert "key" not in err._Error__kwargs 134 | 135 | with pytest.raises(AttributeError): 136 | _ = data.key 137 | 138 | assert id(data) != id(err._Error__kwargs) 139 | 140 | 141 | def test_as_dict_wide_override_const(): 142 | toggles = {"__toggles__": root.Toggles.NONE} 143 | kls = type("Err", (errors.ClassVarsError,), toggles) 144 | 145 | err = kls(age=500) 146 | 147 | assert err.as_dict(wide=True) == dict(age=500, name="Username") 148 | 149 | 150 | def _assert_copy_mutual(orig, cp): 151 | assert cp is not orig 152 | assert cp._Error__kwargs is not orig._Error__kwargs 153 | assert cp.as_dict() == orig.as_dict() 154 | assert cp.box == orig.box == dict() 155 | 156 | 157 | def test_copy_shallow(derived_error): 158 | orig = derived_error 159 | shallow = copy.copy(orig) 160 | 161 | assert shallow.box is orig.box 162 | _assert_copy_mutual(orig, shallow) 163 | 164 | orig.box.update(a=11) 165 | 166 | assert shallow.box == dict(a=11) 167 | 168 | 169 | def test_copy_deep(derived_error): 170 | orig = derived_error 171 | deep = copy.deepcopy(orig) 172 | 173 | assert deep is not orig.box 174 | _assert_copy_mutual(orig, deep) 175 | 176 | orig.box.update(a=11) 177 | 178 | assert deep.box == dict() 179 | 180 | 181 | @pytest.mark.parametrize( 182 | "err", 183 | [ 184 | errors.RootError(), 185 | errors.TemplateOnlyError(name="John", age=42), 186 | errors.AttributesOnlyError(name="John", age=42), 187 | errors.MixedError(name="John", note="..."), 188 | errors.DerivedError(name="John", surname="Brown", note="...", box={}), 189 | ], 190 | ) 191 | def test_pickling(err): 192 | dumped = pickle.dumps(err) 193 | resurrected = pickle.loads(dumped) 194 | 195 | assert str(err) == str(resurrected) 196 | assert repr(err) == repr(resurrected) 197 | assert err.as_dict() == resurrected.as_dict() 198 | -------------------------------------------------------------------------------- /docs/source/specs/additional.rst: -------------------------------------------------------------------------------- 1 | Additional APIs 2 | =============== 3 | 4 | 5 | Representations 6 | --------------- 7 | 8 | .. code-block:: python 9 | 10 | class AmountValidationError(Error): 11 | __template__ = "Data is invalid: {reason} ({amount}; MAX={_MAX}) at {ts}" 12 | _MAX: ClassVar[int] = 1000 13 | amount: int 14 | reason: str = "amount is too large" 15 | ts: datetime = factory(datetime.now) 16 | 17 | 18 | err = AmountValidationError(amount=15000) 19 | 20 | print(str(err)) 21 | # Data is invalid: amount is too large (15000; MAX=1000) at 2024-01-13 23:33:13.847586 22 | 23 | print(repr(err)) 24 | # __main__.AmountValidationError(amount=15000, ts=datetime.datetime(2024, 1, 13, 23, 33, 13, 847586), reason='amount is too large') 25 | 26 | 27 | * ``str`` and ``repr`` output differs 28 | * ``str`` is for humans and Python (Python dictates the result to be exactly and only the message) 29 | * ``repr`` allows to reconstruct the same error instance from its output 30 | (if data provided into ``kwargs`` supports ``repr`` the same way) 31 | 32 | **note:** class name is fully qualified name of class (dot-separated module full path with class name) 33 | 34 | .. code-block:: python 35 | 36 | reconstructed = eval(repr(err).replace("__main__.", "", 1)) 37 | 38 | print(str(reconstructed)) 39 | # Data is invalid: amount is too large (15000; MAX=1000) at 2024-01-13 23:33:13.847586 40 | 41 | print(repr(reconstructed)) 42 | # AmountValidationError(amount=15000, ts=datetime.datetime(2024, 1, 13, 23, 33, 13, 847586), reason='amount is too large') 43 | 44 | * in addition to ``str`` there is another human-readable representations provided by ``.as_str()`` method; 45 | it prepends message with class name: 46 | 47 | .. code-block:: python 48 | 49 | print(err.as_str()) 50 | # AmountValidationError: Data is invalid: amount is too large (15000; MAX=1000) at 2024-01-13 23:33:13.847586 51 | 52 | 53 | Pickling 54 | -------- 55 | 56 | ``izulu``-based errors **support pickling** by default. 57 | 58 | 59 | Dumping and loading 60 | ------------------- 61 | 62 | **Dumping** 63 | 64 | * ``.as_kwargs()`` dumps shallow copy of original ``kwargs`` 65 | 66 | .. code-block:: python 67 | 68 | err.as_kwargs() 69 | # {'amount': 15000} 70 | 71 | * ``.as_dict()`` by default, combines original ``kwargs`` and all *"instance attribute"* values into *"full state"* 72 | 73 | .. code-block:: python 74 | 75 | err.as_dict() 76 | # {'amount': 15000, 'ts': datetime(2024, 1, 13, 23, 33, 13, 847586), 'reason': 'amount is too large'} 77 | 78 | Additionally, there is the ``wide`` flag for enriching the result with *"class defaults"* 79 | (note additional ``_MAX`` data) 80 | 81 | .. code-block:: python 82 | 83 | err.as_dict(True) 84 | # {'amount': 15000, 'ts': datetime(2024, 1, 13, 23, 33, 13, 847586), 'reason': 'amount is too large', '_MAX': 1000} 85 | 86 | Data combination process follows prioritization — if there are multiple values for same name then high priority data 87 | will overlap data with lower priority. Here is the prioritized list of data sources: 88 | 89 | #. ``kwargs`` (max priority) 90 | #. *"instance attributes"* 91 | #. *"class defaults"* 92 | 93 | 94 | **Loading** 95 | 96 | * ``.as_kwargs()`` result can be used to create **inaccurate** copy of original error, 97 | but pay attention to dynamic factories — ``datetime.now()``, ``uuid()`` and many others would produce new values 98 | for data missing in ``kwargs`` (note ``ts`` field in the example below) 99 | 100 | .. code-block:: python 101 | 102 | inaccurate_copy = AmountValidationError(**err.as_kwargs()) 103 | 104 | print(inaccurate_copy) 105 | # Data is invalid: amount is too large (15000; MAX=1000) at 2024-02-01 21:11:21.681080 106 | print(repr(inaccurate_copy)) 107 | # __main__.AmountValidationError(amount=15000, reason='amount is too large', ts=datetime.datetime(2024, 2, 1, 21, 11, 21, 681080)) 108 | 109 | * ``.as_dict()`` result can be used to create **accurate** copy of original error; 110 | flag ``wide`` should be ``False`` by default according to ``FORBID_KWARG_CONSTS`` restriction 111 | (if you disable ``FORBID_KWARG_CONSTS`` then you may need to use ``wide=True`` depending on your situation) 112 | 113 | .. code-block:: python 114 | 115 | accurate_copy = AmountValidationError(**err.as_dict()) 116 | 117 | print(accurate_copy) 118 | # Data is invalid: amount is too large (15000; MAX=1000) at 2024-02-01 21:11:21.681080 119 | print(repr(accurate_copy)) 120 | # __main__.AmountValidationError(amount=15000, reason='amount is too large', ts=datetime.datetime(2024, 2, 1, 21, 11, 21, 681080)) 121 | 122 | 123 | (advanced) Wedge 124 | ---------------- 125 | 126 | There is a special method you can override and additionally manage the machinery. 127 | 128 | But it should not be need in 99,9% cases. Avoid it, please. 129 | 130 | .. code-block:: python 131 | 132 | def _override_message( 133 | self, 134 | store: _utils.Store, # noqa: ARG002 135 | kwargs: t.Dict[str, t.Any], # noqa: ARG002 136 | msg: str, 137 | ) -> str: 138 | """Adapter method to wedge user logic into izulu machinery 139 | 140 | This is the place to override message/formatting if regular mechanics 141 | don't work for you. It has to return original or your flavored message. 142 | The method is invoked between izulu preparations and original 143 | `Exception` constructor receiving the result of this hook. 144 | 145 | You can also do any other logic here. You will be provided with 146 | complete set of prepared data from izulu. But it's recommended 147 | to use classic OOP inheritance for ordinary behaviour extension. 148 | 149 | Params: 150 | * store: dataclass containing inner error class specifications 151 | * kwargs: original kwargs from user 152 | * msg: formatted message from the error template 153 | """ 154 | 155 | return msg 156 | -------------------------------------------------------------------------------- /tests/error/test_checks.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from izulu import _utils 4 | from tests import helpers as h 5 | 6 | 7 | @pytest.mark.parametrize( 8 | ("store", "kws"), 9 | [ 10 | (h._make_store(), tuple()), 11 | (h._make_store(fields=("name", "age")), ("name", "age")), 12 | ( 13 | h._make_store( 14 | fields=("name", "age"), inst_hints=dict(name=str, age=int) 15 | ), 16 | ("name", "age"), 17 | ), 18 | ( 19 | h._make_store( 20 | fields=("name", "age"), 21 | inst_hints=dict(name=str, age=int), 22 | defaults=("age",), 23 | ), 24 | ("name",), 25 | ), 26 | ( 27 | h._make_store( 28 | fields=("name", "age", "ENTITY"), 29 | inst_hints=dict(name=str, age=int), 30 | defaults=("age",), 31 | consts=dict(ENTITY="THING"), 32 | ), 33 | ("name",), 34 | ), 35 | ( 36 | h._make_store( 37 | fields=("name", "age"), 38 | inst_hints=dict(name=str, age=int), 39 | defaults=("name", "age"), 40 | consts=dict(ENTITY="THING"), 41 | ), 42 | tuple(), 43 | ), 44 | ( 45 | h._make_store( 46 | fields=("name", "age"), 47 | inst_hints=dict(name=str, age=int), 48 | defaults=("age",), 49 | consts=dict(ENTITY="THING"), 50 | ), 51 | ("name",), 52 | ), 53 | ( 54 | h._make_store( 55 | fields=("name", "ENTITY"), 56 | inst_hints=dict(name=str), 57 | consts=dict(ENTITY="THING"), 58 | ), 59 | ("name",), 60 | ), 61 | ( 62 | h._make_store( 63 | fields=("name", "ENTITY"), consts=dict(ENTITY="THING") 64 | ), 65 | ("name",), 66 | ), 67 | ( 68 | h._make_store( 69 | inst_hints=dict(name=str, age=int), defaults=dict(age=42) 70 | ), 71 | ("name",), 72 | ), 73 | ( 74 | h._make_store(defaults=("age",), consts=dict(ENTITY="THING")), 75 | ("name",), 76 | ), 77 | ], 78 | ) 79 | def test_check_missing_fields_ok(store, kws): 80 | _utils.check_missing_fields(store, frozenset(kws)) 81 | 82 | 83 | @pytest.mark.parametrize( 84 | ("store", "kws"), 85 | [ 86 | (h._make_store(fields=("name", "age")), tuple()), 87 | (h._make_store(fields=("name", "age")), ("age",)), 88 | ( 89 | h._make_store( 90 | fields=("name",), inst_hints=dict(name=str, age=int) 91 | ), 92 | ("name",), 93 | ), 94 | ( 95 | h._make_store(fields=("name", "age", "ENTITY"), defaults=("age",)), 96 | ("name",), 97 | ), 98 | (h._make_store(inst_hints=dict(name=str, age=int)), ("name",)), 99 | ( 100 | h._make_store( 101 | fields=("name", "ENTITY"), 102 | inst_hints=dict(name=str), 103 | consts=dict(ENTITY="THING"), 104 | ), 105 | ("ENTITY",), 106 | ), 107 | ( 108 | h._make_store( 109 | fields=("name", "ENTITY"), consts=dict(ENTITY="THING") 110 | ), 111 | ("age",), 112 | ), 113 | ], 114 | ) 115 | def test_check_missing_fields_fail(store, kws): 116 | with pytest.raises(TypeError): 117 | _utils.check_missing_fields(store, frozenset(kws)) 118 | 119 | 120 | @pytest.mark.parametrize( 121 | ("store", "kws"), 122 | [ 123 | (h._make_store(), tuple()), 124 | (h._make_store(fields=("name", "age")), ("name", "age")), 125 | (h._make_store(inst_hints=dict(name=str, age=int)), ("name", "age")), 126 | (h._make_store(const_hints=dict(name=str, age=int)), ("name", "age")), 127 | ( 128 | h._make_store( 129 | fields=("name", "age", "ENTITY"), 130 | inst_hints=dict(name=str, age=int), 131 | defaults=("age",), 132 | const_hints=dict(ENTITY=str), 133 | ), 134 | ("name",), 135 | ), 136 | ( 137 | h._make_store( 138 | fields=("name",), 139 | inst_hints=dict(name=str, age=int), 140 | defaults=("name", "age"), 141 | const_hints=dict(ENTITY=str), 142 | ), 143 | tuple(), 144 | ), 145 | ], 146 | ) 147 | def test_check_undeclared_fields_ok(store, kws): 148 | _utils.check_undeclared_fields(store, frozenset(kws)) 149 | 150 | 151 | @pytest.mark.parametrize( 152 | ("store", "kws"), 153 | [ 154 | (h._make_store(), ("entity",)), 155 | (h._make_store(fields=("name", "age")), ("entity",)), 156 | (h._make_store(inst_hints=dict(name=str, age=int)), ("entity",)), 157 | (h._make_store(consts=dict(name="John", age=42)), ("entity",)), 158 | ], 159 | ) 160 | def test_check_undeclared_fields_fail(store, kws): 161 | with pytest.raises(TypeError): 162 | _utils.check_undeclared_fields(store, frozenset(kws)) 163 | 164 | 165 | @pytest.mark.parametrize( 166 | ("store", "kws"), 167 | [ 168 | (h._make_store(), tuple()), 169 | (h._make_store(const_hints=dict(ENTITY=str)), tuple()), 170 | (h._make_store(const_hints=dict(ENTITY=str)), ("name",)), 171 | ], 172 | ) 173 | def test_check_kwarg_consts_ok(store, kws): 174 | _utils.check_kwarg_consts(store, frozenset(kws)) 175 | 176 | 177 | @pytest.mark.parametrize( 178 | ("store", "kws"), 179 | [ 180 | (h._make_store(const_hints=dict(ENTITY=str)), ("ENTITY",)), 181 | (h._make_store(const_hints=dict(ENTITY=str)), ("ENTITY", "name")), 182 | ], 183 | ) 184 | def test_check_kwarg_consts_fail(store, kws): 185 | with pytest.raises(TypeError): 186 | _utils.check_kwarg_consts(store, frozenset(kws)) 187 | 188 | 189 | @pytest.mark.parametrize( 190 | "store", 191 | [ 192 | h._make_store(), 193 | h._make_store(fields=frozenset(("abc", "-", "01abc"))), 194 | ], 195 | ) 196 | def test_check_non_named_fields_ok(store): 197 | _utils.check_non_named_fields(store) 198 | 199 | 200 | @pytest.mark.parametrize( 201 | "store", 202 | [ 203 | h._make_store(fields=frozenset(("",))), 204 | h._make_store(fields=frozenset((1,))), 205 | h._make_store(fields=frozenset((0,))), 206 | ], 207 | ) 208 | def test_check_non_named_fields_fail(store): 209 | with pytest.raises(ValueError, match="Field names can't be"): 210 | _utils.check_non_named_fields(store) 211 | -------------------------------------------------------------------------------- /tests/error/test_error.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import types 3 | from unittest import mock 4 | 5 | import pytest 6 | 7 | from izulu import _utils 8 | from izulu import root 9 | from tests import errors 10 | 11 | TS = datetime.datetime.now(datetime.timezone.utc) 12 | 13 | 14 | @mock.patch("izulu.root.Error._override_message") 15 | @mock.patch("izulu.root.Error._Error__process_template") 16 | @mock.patch("izulu.root.Error._Error__populate_attrs") 17 | @mock.patch("izulu.root.Error._Error__process_toggles") 18 | def test_init( 19 | fake_proc_ftrs, 20 | fake_set_attrs, 21 | fake_proc_tpl, 22 | fake_override_message, 23 | ): 24 | fake_proc_tpl.return_value = errors.RootError.__template__ 25 | overriden_message = "overriden message" 26 | fake_override_message.return_value = overriden_message 27 | store = errors.RootError._Error__cls_store 28 | manager = mock.Mock() 29 | manager.attach_mock(fake_proc_ftrs, "fake_proc_ftrs") 30 | manager.attach_mock(fake_set_attrs, "fake_set_attrs") 31 | manager.attach_mock(fake_proc_tpl, "fake_proc_tpl") 32 | manager.attach_mock(fake_override_message, "fake_override_message") 33 | fake_override_message_call = mock.call.fake_override_message( 34 | store, 35 | {}, 36 | errors.RootError.__template__, 37 | ) 38 | expected_calls = [ 39 | mock.call.fake_proc_ftrs(), 40 | mock.call.fake_set_attrs(), 41 | mock.call.fake_proc_tpl({}), 42 | fake_override_message_call, 43 | ] 44 | 45 | e = errors.RootError() 46 | 47 | assert manager.mock_calls == expected_calls 48 | assert str(e) == overriden_message 49 | 50 | 51 | @pytest.mark.parametrize( 52 | ("kls", "kwargs", "msg", "attrs", "not_attrs"), 53 | [ 54 | (errors.RootError, dict(), "Unspecified error", dict(), tuple()), 55 | ( 56 | errors.TemplateOnlyError, 57 | dict(name="John", age=42), 58 | "The John is 42 years old", 59 | dict(), 60 | ("name", "age"), 61 | ), 62 | ( 63 | errors.ComplexTemplateOnlyError, 64 | dict(name="John", age=42, ts=TS), 65 | ( 66 | "********John******** 42.000000" 67 | f" 0b101010 {TS:%Y-%m-%d %H:%M:%S}" 68 | ), 69 | dict(), 70 | ("name", "age", "ts"), 71 | ), 72 | ( 73 | errors.AttributesOnlyError, 74 | dict(name="John", age=42), 75 | "Static message template", 76 | dict(name="John", age=42), 77 | tuple(), 78 | ), 79 | ( 80 | errors.AttributesWithStaticDefaultsError, 81 | dict(name="John"), 82 | "Static message template", 83 | dict(name="John", age=0), 84 | tuple(), 85 | ), 86 | ( 87 | errors.AttributesWithDynamicDefaultsError, 88 | dict(name="John"), 89 | "Static message template", 90 | dict(name="John", age=0), 91 | tuple(), 92 | ), 93 | ( 94 | errors.ClassVarsError, 95 | dict(), 96 | "Static message template", 97 | dict(name="Username", age=42), 98 | tuple(), 99 | ), 100 | ( 101 | errors.MixedError, 102 | dict(name="John", age=10, note="...", timestamp=TS), 103 | "The John is 10 years old with ...", 104 | dict( 105 | entity="The Entity", 106 | name="John", 107 | age=10, 108 | timestamp=TS, 109 | my_type="MixedError", 110 | ), 111 | ("note",), 112 | ), 113 | ( 114 | errors.DerivedError, 115 | dict( 116 | name="John", 117 | age=10, 118 | note="...", 119 | timestamp=TS, 120 | surname="Brown", 121 | box=dict(a=11), 122 | ), 123 | "The John Brown is 10 years old with ...", 124 | dict( 125 | entity="The Entity", 126 | name="John", 127 | age=10, 128 | timestamp=TS, 129 | my_type="DerivedError", 130 | surname="Brown", 131 | box=dict(a=11), 132 | ), 133 | ("note",), 134 | ), 135 | ], 136 | ) 137 | def test_instantiate_ok(kls, kwargs, msg, attrs, not_attrs): 138 | e = kls(**kwargs) 139 | 140 | assert str(e) == msg 141 | for attr, value in attrs.items(): 142 | assert getattr(e, attr) == value 143 | for attr in not_attrs: 144 | assert not hasattr(e, attr) 145 | 146 | 147 | @pytest.mark.parametrize( 148 | ("kls", "kwargs"), 149 | [ 150 | (errors.RootError, dict(name="John")), 151 | (errors.TemplateOnlyError, dict(name="John")), 152 | (errors.AttributesOnlyError, dict(age=42)), 153 | (errors.AttributesWithStaticDefaultsError, dict()), 154 | (errors.AttributesWithDynamicDefaultsError, dict(age=0)), 155 | (errors.ClassVarsError, dict(name="John")), 156 | ], 157 | ) 158 | def test_instantiate_fail(kls, kwargs): 159 | with pytest.raises(TypeError): 160 | kls(**kwargs) 161 | 162 | 163 | @pytest.mark.parametrize( 164 | ("kls", "kwargs", "expected_kwargs"), 165 | [ 166 | (errors.RootError, dict(), dict()), 167 | (errors.RootError, dict(name="John"), dict(name="John")), 168 | (errors.ClassVarsError, dict(), dict(name="Username", age=42)), 169 | (errors.ClassVarsError, dict(name="John"), dict(name="John", age=42)), 170 | ( 171 | errors.MixedError, 172 | dict(name="John", surname="Brown", updated_at=TS, timestamp=TS), 173 | dict( 174 | name="John", 175 | surname="Brown", 176 | age=0, 177 | my_type="MixedError", 178 | entity="The Entity", 179 | updated_at=TS, 180 | timestamp=TS, 181 | ), 182 | ), 183 | ( 184 | errors.MixedError, 185 | dict(updated_at=TS, timestamp=TS), 186 | dict( 187 | age=0, 188 | my_type="MixedError", 189 | entity="The Entity", 190 | updated_at=TS, 191 | timestamp=TS, 192 | ), 193 | ), 194 | ( 195 | errors.DerivedError, 196 | dict(name="John", surname="Brown", updated_at=TS, timestamp=TS), 197 | dict( 198 | name="John", 199 | surname="Brown", 200 | full_name="John Brown", 201 | age=0, 202 | my_type="DerivedError", 203 | entity="The Entity", 204 | location=(50.3, 3.608), 205 | updated_at=TS, 206 | timestamp=TS, 207 | ), 208 | ), 209 | ], 210 | ) 211 | @mock.patch("izulu._utils.format_template") 212 | def test_process_template(mock_format, kls, kwargs, expected_kwargs): 213 | with mock.patch.object( 214 | kls, 215 | "__toggles__", 216 | new_callable=mock.PropertyMock, 217 | ) as mocked: 218 | mocked.return_value = root.Toggles.NONE 219 | kls(**kwargs) 220 | 221 | mock_format.assert_called_once_with(kls.__template__, expected_kwargs) 222 | 223 | 224 | def test_override_message(): 225 | e = errors.RootError() 226 | orig_msg = "my message" 227 | 228 | final_msg = e._override_message(e._Error__cls_store, dict(), orig_msg) 229 | 230 | assert final_msg == orig_msg 231 | assert id(final_msg) == id(orig_msg) 232 | 233 | 234 | def test_constants(): 235 | e = errors.RootError() 236 | toggles = root.Toggles.DEFAULT ^ root.Toggles.FORBID_UNANNOTATED_FIELDS 237 | 238 | assert e.__template__ == "Unspecified error" 239 | assert e.__toggles__ == toggles 240 | assert e._Error__cls_store == _utils.Store( 241 | fields=frozenset(), 242 | const_hints=types.MappingProxyType(dict()), 243 | inst_hints=types.MappingProxyType(dict()), 244 | consts=types.MappingProxyType(dict()), 245 | defaults=frozenset(), 246 | ) 247 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import typing as t 3 | 4 | import pytest 5 | 6 | from izulu import _utils 7 | from tests import errors 8 | from tests import helpers as h 9 | 10 | count = 42 11 | owner = "somebody" 12 | dt = datetime.datetime.now(datetime.timezone.utc) 13 | 14 | 15 | @pytest.mark.parametrize( 16 | ("kwargs", "expected"), 17 | [ 18 | (h._make_store_kwargs(), frozenset()), 19 | ( 20 | h._make_store_kwargs(fields=("field_1", "field_2")), 21 | frozenset(("field_1", "field_2")), 22 | ), 23 | ( 24 | h._make_store_kwargs(inst_hints=dict(field_2=2, field_3=3)), 25 | frozenset(("field_2", "field_3")), 26 | ), 27 | ( 28 | h._make_store_kwargs( 29 | fields=("field_1", "field_2"), 30 | inst_hints=dict(field_2=2, field_3=3), 31 | ), 32 | frozenset(("field_1", "field_2", "field_3")), 33 | ), 34 | ( 35 | h._make_store_kwargs( 36 | fields=("field_1", "field_2"), 37 | inst_hints=dict(field_1=1, field_2=2), 38 | ), 39 | frozenset(("field_1", "field_2")), 40 | ), 41 | ], 42 | ) 43 | def test_store_post_init(kwargs, expected): 44 | assert _utils.Store(**kwargs).registered == expected 45 | 46 | 47 | @pytest.mark.parametrize( 48 | ("args", "expected"), 49 | [ 50 | ((tuple(),), ""), 51 | ((("item_1",),), "'item_1'"), 52 | ((("item_1", "item_2", "item_3"),), "'item_1', 'item_2', 'item_3'"), 53 | ], 54 | ) 55 | def test_join_items(args, expected): 56 | assert _utils.join_items(*args) == expected 57 | 58 | 59 | @pytest.mark.parametrize( 60 | ("data", "expected"), 61 | [ 62 | (dict(), ""), 63 | (dict(a=42), "a=42"), 64 | ( 65 | dict(owner=owner, count=count, timestamp=dt), 66 | f"{owner=!r}, {count=!r}, timestamp={dt!r}", 67 | ), 68 | (dict(timestamp=dt), f"timestamp={dt!r}"), 69 | ], 70 | ) 71 | def test_join_kwargs(data, expected): 72 | assert _utils.join_kwargs(**data) == expected 73 | 74 | 75 | @pytest.mark.parametrize( 76 | ("template", "kwargs", "expected"), 77 | [ 78 | ("Static message template", dict(), "Static message template"), 79 | ( 80 | "Static message template", 81 | dict(name="John", age=42, ENTITY="The thing"), 82 | "Static message template", 83 | ), 84 | ( 85 | "The {name} and {ENTITY}", 86 | dict(name="John", age=42, ENTITY="The thing"), 87 | "The John and The thing", 88 | ), 89 | ( 90 | "The {name} of {age} and {ENTITY}", 91 | dict(name="John", age=42, ENTITY="The thing"), 92 | "The John of 42 and The thing", 93 | ), 94 | ( 95 | "The {name} of {age:f} and {ENTITY}", 96 | dict(name="John", age=42, ENTITY="The thing"), 97 | "The John of 42.000000 and The thing", 98 | ), 99 | ], 100 | ) 101 | def test_format_template_ok(template, kwargs, expected): 102 | assert _utils.format_template(template, kwargs) == expected 103 | 104 | 105 | @pytest.mark.parametrize( 106 | ("template", "kwargs"), 107 | [ 108 | ( 109 | "The {name} of {age} and {ENTITY}", 110 | dict(name="John", ENTITY="The thing"), 111 | ), 112 | ( 113 | "The {name} of {age:f} and {ENTITY}", 114 | dict(name="John", age="42", ENTITY="The thing"), 115 | ), 116 | ], 117 | ) 118 | def test_format_template_fail(template, kwargs): 119 | with pytest.raises( 120 | ValueError, match="Failed to format template with provided kwargs:" 121 | ): 122 | _utils.format_template(template, kwargs) 123 | 124 | 125 | @pytest.mark.parametrize( 126 | ("tpl", "expected"), 127 | [ 128 | ("", tuple()), 129 | ("abc def", tuple()), 130 | ("{}", ("",)), 131 | ("abc {} def", ("",)), 132 | ("{-}", ("-",)), 133 | ("{_}", ("_",)), 134 | ("{'}", ("'",)), 135 | ("{01abc}", ("01abc",)), 136 | ("{]}", ("]",)), 137 | ("{1}", (1,)), 138 | ("{field}", ("field",)), 139 | ("{field.attribute}", ("field",)), 140 | ("{field[key]}", ("field",)), 141 | ("{field[key].attr}", ("field",)), 142 | ("{field.attr[key]}", ("field",)), 143 | ("{field.attr[key].attr}", ("field",)), 144 | ("{field[key][another]}", ("field",)), 145 | ("{field.attribute.attr}", ("field",)), 146 | ("{fi-eld}", ("fi-eld",)), 147 | ("{field.-attribute}", ("field",)), 148 | ("{field[-key]}", ("field",)), 149 | ("{-field[key].attr}", ("-field",)), 150 | ("{field-.-attr[-key]}", ("field-",)), 151 | ("{fi-eld.at-tr[key].attr}", ("fi-eld",)), 152 | ("{field[0key][another]}", ("field",)), 153 | ("{fi-eld.0attribute.attr}", ("fi-eld",)), 154 | ("Having boring message here", tuple()), 155 | ("Hello {}!", ("",)), 156 | ("Hello {0}!", (0,)), 157 | ("Hello {you}!", ("you",)), 158 | ("Hello {you:f}!", ("you",)), 159 | ("Hello {you}! How are you, {you!a}", ("you", "you")), 160 | ( 161 | "{owner:f!a}: Having {!a} count={count!a:f} for owner={0:f}", 162 | ("owner", "", "count", 0), 163 | ), 164 | ], 165 | ) 166 | def test_iterate_field_specs(tpl, expected): 167 | assert tuple(_utils.iter_fields(tpl)) == expected 168 | 169 | 170 | @pytest.mark.parametrize( 171 | ("kls", "const_hints", "inst_hints"), 172 | [ 173 | (errors.RootError, dict(), dict()), 174 | (errors.TemplateOnlyError, dict(), dict()), 175 | (errors.ComplexTemplateOnlyError, dict(), dict()), 176 | (errors.AttributesOnlyError, dict(), dict(age=int, name=str)), 177 | ( 178 | errors.AttributesWithStaticDefaultsError, 179 | dict(), 180 | dict(age=int, name=str), 181 | ), 182 | ( 183 | errors.AttributesWithDynamicDefaultsError, 184 | dict(), 185 | dict(age=int, name=str), 186 | ), 187 | ( 188 | errors.ClassVarsError, 189 | dict( 190 | name=t.ClassVar[str], 191 | age=t.ClassVar[int], 192 | blah=t.ClassVar[float], 193 | ), 194 | dict(), 195 | ), 196 | ( 197 | errors.MixedError, 198 | dict(entity=t.ClassVar[str]), 199 | dict(name=str, age=int, my_type=str, timestamp=datetime.datetime), 200 | ), 201 | ( 202 | errors.DerivedError, 203 | dict(entity=t.ClassVar[str]), 204 | dict( 205 | name=str, 206 | surname=str, 207 | full_name=str, 208 | age=int, 209 | my_type=str, 210 | timestamp=datetime.datetime, 211 | updated_at=datetime.datetime, 212 | box=dict, 213 | location=t.Tuple[float, float], 214 | ), 215 | ), 216 | ], 217 | ) 218 | def test_split_cls_hints(kls, const_hints, inst_hints): 219 | assert _utils.split_cls_hints(kls) == (const_hints, inst_hints) 220 | 221 | 222 | @pytest.mark.parametrize( 223 | ("kls", "attrs", "expected"), 224 | [ 225 | (errors.DerivedError, (), dict()), 226 | (errors.DerivedError, ("entity",), dict(entity="The Entity")), 227 | ( 228 | errors.DerivedError, 229 | ( 230 | "name", 231 | "surname", 232 | "age", 233 | "timestamp", 234 | "my_type", 235 | "location", 236 | "updated_at", 237 | "full_name", 238 | "box", 239 | ), 240 | dict( 241 | age=0, 242 | location=(50.3, 3.608), 243 | timestamp=errors.DerivedError.timestamp, 244 | updated_at=errors.DerivedError.updated_at, 245 | full_name=errors.DerivedError.full_name, 246 | my_type=errors.DerivedError.my_type, 247 | ), 248 | ), 249 | (errors.RootError, ("entity",), dict()), 250 | (errors.TemplateOnlyError, ("name",), dict()), 251 | ], 252 | ) 253 | def test_get_cls_defaults(kls, attrs, expected): 254 | assert _utils.get_cls_defaults(kls, attrs) == expected 255 | -------------------------------------------------------------------------------- /docs/source/specs/mechanics.rst: -------------------------------------------------------------------------------- 1 | Mechanics 2 | ========= 3 | 4 | .. note:: 5 | 6 | **Prepare playground** 7 | 8 | :: 9 | 10 | pip install ipython izulu 11 | 12 | ipython -i -c 'from izulu.root import *; from typing import *; from datetime import *' 13 | 14 | 15 | * inheritance from ``izulu.root.Error`` is required 16 | 17 | .. code-block:: python 18 | 19 | class AmountError(Error): 20 | pass 21 | 22 | * **optionally** behaviour can be adjusted with ``__toggles__`` (not recommended) 23 | 24 | .. code-block:: python 25 | 26 | class AmountError(Error): 27 | __toggles__ = Toggles.DEFAULT ^ Toggles.FORBID_UNDECLARED_FIELDS 28 | 29 | * you should provide a template for the target error message with ``__template__`` 30 | 31 | .. code-block:: python 32 | 33 | class AmountError(Error): 34 | __template__ = "Data is invalid: {reason} (amount={amount})" 35 | 36 | print(AmountError(reason="negative amount", amount=-10.52)) 37 | # [2024-01-23 19:16] Data is invalid: negative amount (amount=-10.52) 38 | 39 | * sources of formatting arguments: 40 | 41 | * *"class defaults"* 42 | * *"instance defaults"* 43 | * ``kwargs`` (overlap any *"default"*) 44 | 45 | * new style formatting is used: 46 | 47 | .. code-block:: python 48 | 49 | class AmountError(Error): 50 | __template__ = "[{ts:%Y-%m-%d %H:%M}] Data is invalid: {reason:_^20} (amount={amount:06.2f})" 51 | 52 | print(AmountError(ts=datetime.now(), reason="negative amount", amount=-10.52)) 53 | # [2024-01-23 19:16] Data is invalid: __negative amount___ (amount=-10.52) 54 | 55 | * ``help(str.format)`` 56 | * https://pyformat.info/ 57 | * https://docs.python.org/3/library/string.html#format-string-syntax 58 | 59 | .. warning:: 60 | There is a difference between docs and actual behaviour: 61 | https://discuss.python.org/t/format-string-syntax-specification-differs-from-actual-behaviour/46716 62 | 63 | * only named fields are allowed 64 | 65 | * positional (digit) and empty field are forbidden 66 | 67 | * error instantiation requires data to format ``__template__`` 68 | 69 | * all data for ``__template__`` fields must be provided 70 | 71 | .. code-block:: python 72 | 73 | class AmountError(Error): 74 | __template__ = "Data is invalid: {reason} (amount={amount})" 75 | 76 | print(AmountError(reason="amount can't be negative", amount=-10)) 77 | # Data is invalid: amount can't be negative (amount=-10) 78 | 79 | AmountError() 80 | # TypeError: Missing arguments: 'reason', 'amount' 81 | AmountError(amount=-10) 82 | # TypeError: Missing arguments: 'reason' 83 | 84 | * only named arguments allowed: ``__init__()`` accepts only ``kwargs`` 85 | 86 | .. code-block:: python 87 | 88 | class AmountError(Error): 89 | __template__ = "Data is invalid: {reason} (amount={amount})" 90 | 91 | print(AmountError(reason="amount can't be negative", amount=-10)) 92 | # Data is invalid: amount can't be negative (amount=-10) 93 | 94 | AmountError("amount can't be negative", -10) 95 | # TypeError: __init__() takes 1 positional argument but 3 were given 96 | AmountError("amount can't be negative", amount=-10) 97 | # TypeError: __init__() takes 1 positional argument but 2 were given 98 | 99 | * *"class defaults"* can be defined and used 100 | 101 | * *"class defaults"* must be type hinted with ``ClassVar`` annotation and provide static values 102 | * template *"fields"* may refer *"class defaults"* 103 | 104 | .. code-block:: python 105 | 106 | class AmountError(Error): 107 | LIMIT: ClassVar[int] = 10_000 108 | __template__ = "Amount is too large: amount={amount} limit={LIMIT}" 109 | amount: int 110 | 111 | print(AmountError(amount=10_500)) 112 | # Amount is too large: amount=10500 limit=10000 113 | 114 | * *"instance attributes"* are populated from relevant ``kwargs`` 115 | 116 | .. code-block:: python 117 | 118 | class AmountError(Error): 119 | amount: int 120 | 121 | print(AmountError(amount=-10).amount) 122 | # -10 123 | 124 | * instance and class attribute types from **annotations are not validated or enforced** 125 | (``izulu`` uses type hints just for attribute discovery and only ``ClassVar`` marker 126 | is processed for instance/class segregation) 127 | 128 | .. code-block:: python 129 | 130 | class AmountError(Error): 131 | amount: int 132 | 133 | print(AmountError(amount="lots of money").amount) 134 | # lots of money 135 | 136 | * static *"instance defaults"* can be provided regularly with instance type hints and static values 137 | 138 | .. code-block:: python 139 | 140 | class AmountError(Error): 141 | amount: int = 500 142 | 143 | print(AmountError().amount) 144 | # 500 145 | 146 | * dynamic *"instance defaults"* are also supported 147 | 148 | * they must be type hinted and have special value 149 | * value must be a callable object wrapped with ``factory`` helper 150 | * ``factory`` provides 2 modes depending on value of the ``self`` flag: 151 | 152 | * ``self=False`` (default): callable accepting no arguments 153 | 154 | .. code-block:: python 155 | 156 | class AmountError(Error): 157 | ts: datetime = factory(datetime.now) 158 | 159 | print(AmountError().ts) 160 | # 2024-01-23 23:18:22.019963 161 | 162 | * ``self=True``: provide callable accepting single argument (error instance) 163 | 164 | .. code-block:: python 165 | 166 | class AmountError(Error): 167 | LIMIT = 10_000 168 | amount: int 169 | overflow: int = factory(lambda self: self.amount - self.LIMIT, self=True) 170 | 171 | print(AmountError(amount=10_500).overflow) 172 | # 500 173 | 174 | * *"instance defaults"* and *"instance attributes"* may be referred in ``__template__`` 175 | 176 | .. code-block:: python 177 | 178 | class AmountError(Error): 179 | __template__ = "[{ts:%Y-%m-%d %H:%M}] Amount is too large: {amount}" 180 | amount: int 181 | ts: datetime = factory(datetime.now) 182 | 183 | print(AmountError(amount=10_500)) 184 | # [2024-01-23 23:21] Amount is too large: 10500 185 | 186 | * *Pause and sum up: defaults, attributes and template* 187 | 188 | .. code-block:: python 189 | 190 | class AmountError(Error): 191 | LIMIT: ClassVar[int] = 10_000 192 | __template__ = "[{ts:%Y-%m-%d %H:%M}] Amount is too large: amount={amount} limit={LIMIT} overflow={overflow}" 193 | amount: int 194 | overflow: int = factory(lambda self: self.amount - self.LIMIT, self=True) 195 | ts: datetime = factory(datetime.now) 196 | 197 | err = AmountError(amount=15_000) 198 | 199 | print(err.amount) 200 | # 15000 201 | print(err.LIMIT) 202 | # 10000 203 | print(err.overflow) 204 | # 5000 205 | print(err.ts) 206 | # 2024-01-23 23:21:26 207 | 208 | print(err) 209 | # [2024-01-23 23:21] Amount is too large: amount=15000 limit=10000 overflow=5000 210 | 211 | * ``kwargs`` overlap *"instance defaults"* 212 | 213 | .. code-block:: python 214 | 215 | class AmountError(Error): 216 | LIMIT: ClassVar[int] = 10_000 217 | __template__ = "[{ts:%Y-%m-%d %H:%M}] Amount is too large: amount={amount} limit={LIMIT} overflow={overflow}" 218 | amount: int = 15_000 219 | overflow: int = factory(lambda self: self.amount - self.LIMIT, self=True) 220 | ts: datetime = factory(datetime.now) 221 | 222 | print(AmountError()) 223 | # [2024-01-23 23:21] Amount is too large: amount=15000 limit=10000 overflow=5000 224 | 225 | print(AmountError(amount=10_333, overflow=42, ts=datetime(1900, 1, 1))) 226 | # [2024-01-23 23:21] Amount is too large: amount=10333 limit=10000 overflow=42 227 | 228 | * ``izulu`` provides flexibility for templates, fields, attributes and defaults 229 | 230 | * *"defaults"* are not required to be ``__template__`` *"fields"* 231 | 232 | .. code-block:: python 233 | 234 | class AmountError(Error): 235 | LIMIT: ClassVar[int] = 10_000 236 | __template__ = "Amount is too large" 237 | 238 | print(AmountError().LIMIT) 239 | # 10000 240 | print(AmountError()) 241 | # Amount is too large 242 | 243 | * there can be hints for attributes not present in error message template 244 | 245 | .. code-block:: python 246 | 247 | class AmountError(Error): 248 | __template__ = "Amount is too large" 249 | amount: int 250 | 251 | print(AmountError(amount=500).amount) 252 | # 500 253 | print(AmountError(amount=500)) 254 | # Amount is too large 255 | 256 | * *"fields"* don't have to be hinted as instance attributes 257 | 258 | .. code-block:: python 259 | 260 | class AmountError(Error): 261 | __template__ = "Amount is too large: {amount}" 262 | 263 | print(AmountError(amount=500)) 264 | # Amount is too large: 500 265 | print(AmountError(amount=500).amount) 266 | # AttributeError: 'AmountError' object has no attribute 'amount' 267 | -------------------------------------------------------------------------------- /izulu/root.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import copy 4 | import enum 5 | import functools 6 | import logging 7 | import types 8 | import typing as t 9 | 10 | from izulu import _utils 11 | from izulu import tools 12 | 13 | _IMPORT_ERROR_TEXTS = ( 14 | "", 15 | "You have early version of Python.", 16 | " Extra compatibility dependency required.", 17 | " Please add 'izulu[compatibility]' to your project dependencies.", 18 | "", 19 | "Pip: `pip install izulu[compatibility]`", 20 | ) 21 | 22 | 23 | if hasattr(t, "dataclass_transform"): 24 | t_ext = t 25 | else: 26 | try: 27 | import typing_extensions as t_ext # type: ignore[no-redef] 28 | except ImportError: 29 | for message in _IMPORT_ERROR_TEXTS: 30 | logging.error(message) # noqa: LOG015,TRY400 31 | raise 32 | 33 | FactoryReturnType = t.TypeVar("FactoryReturnType") 34 | 35 | 36 | @t.overload 37 | def factory( 38 | *, 39 | default_factory: t.Callable[[], FactoryReturnType], 40 | self: t.Literal[False] = False, 41 | ) -> FactoryReturnType: ... 42 | 43 | 44 | @t.overload 45 | def factory( 46 | *, 47 | default_factory: t.Callable[[Error], FactoryReturnType], 48 | self: t.Literal[True], 49 | ) -> FactoryReturnType: ... 50 | 51 | 52 | def factory( 53 | *, 54 | default_factory: t.Callable[..., t.Any], 55 | self: bool = False, 56 | ) -> t.Any: 57 | """ 58 | Attaches factory for dynamic default values. 59 | 60 | Args: 61 | default_factory: callable factory receiving 0 or 1 argument 62 | (see ``self`` param) 63 | self: controls callable factory argument 64 | if ``True`` factory will receive single argument of error instance 65 | otherwise factory will be invoked without argument 66 | 67 | """ 68 | target = default_factory if self else (lambda _: default_factory()) 69 | return functools.cached_property(target) 70 | 71 | 72 | class Toggles(enum.Flag): 73 | FORBID_MISSING_FIELDS = enum.auto() 74 | FORBID_UNDECLARED_FIELDS = enum.auto() 75 | FORBID_KWARG_CONSTS = enum.auto() 76 | FORBID_NON_NAMED_FIELDS = enum.auto() 77 | FORBID_UNANNOTATED_FIELDS = enum.auto() 78 | 79 | NONE = 0 80 | DEFAULT = ( 81 | FORBID_MISSING_FIELDS 82 | | FORBID_UNDECLARED_FIELDS 83 | | FORBID_KWARG_CONSTS 84 | | FORBID_NON_NAMED_FIELDS 85 | | FORBID_UNANNOTATED_FIELDS 86 | ) 87 | 88 | 89 | @t_ext.dataclass_transform( 90 | eq_default=False, 91 | order_default=False, 92 | kw_only_default=True, 93 | frozen_default=False, 94 | field_specifiers=(factory,), 95 | ) 96 | class Error(Exception): 97 | """ 98 | Base class for your exception trees. 99 | 100 | Example:: 101 | 102 | class MyError(root.Error): 103 | __template__ = "{smth} has happened at {ts}" 104 | 105 | smth: str 106 | ts: root.factory(datetime.now) 107 | 108 | Provides 4 main features: 109 | 110 | * Instead of manual error message formatting (and copying it all over 111 | the codebase) provide just ``kwargs``: 112 | 113 | - before: ``raise MyError(f"{smth} has happened at {datetime.now()}")`` 114 | - after: ``raise MyError(smth=smth)`` 115 | 116 | Provide ``__template__`` class attribute with your error message 117 | template string. New style formatting is used: 118 | 119 | - ``str.format()`` 120 | - https://pyformat.info/ 121 | - https://docs.python.org/3/library/string.html#formatspec 122 | 123 | * Automatic ``kwargs`` conversion into error instance attributes 124 | 125 | * You can attach static and dynamic default values: 126 | this is why ``datetime.now()`` was omitted above 127 | 128 | * Out-of-box validation for provided ``kwargs`` 129 | (individually enable/disable checks with ``__toggles__`` attribute) 130 | 131 | """ 132 | 133 | __template__: t.ClassVar[str] = "Unspecified error" 134 | __toggles__: t.ClassVar[Toggles] = Toggles.DEFAULT 135 | 136 | __cls_store: t.ClassVar[_utils.Store] = _utils.Store( 137 | fields=frozenset(), 138 | const_hints=types.MappingProxyType(dict()), 139 | inst_hints=types.MappingProxyType(dict()), 140 | consts=types.MappingProxyType(dict()), 141 | defaults=frozenset(), 142 | ) 143 | 144 | def __iter__(self) -> t.Iterator[BaseException]: 145 | """Return iterator over the whole exception chain.""" 146 | return tools.error_chain(self) 147 | 148 | def __init_subclass__(cls, **kwargs: t.Any) -> None: # noqa: ANN401 149 | super().__init_subclass__(**kwargs) 150 | fields = frozenset(_utils.iter_fields(cls.__template__)) 151 | const_hints, inst_hints = _utils.split_cls_hints(cls) 152 | consts = _utils.get_cls_defaults(cls, const_hints) 153 | defaults = _utils.get_cls_defaults(cls, inst_hints) 154 | cls.__cls_store = _utils.Store( 155 | fields=fields, 156 | const_hints=types.MappingProxyType(const_hints), 157 | inst_hints=types.MappingProxyType(inst_hints), 158 | consts=types.MappingProxyType(consts), 159 | defaults=frozenset(defaults), 160 | ) 161 | if Toggles.FORBID_NON_NAMED_FIELDS in cls.__toggles__: 162 | _utils.check_non_named_fields(cls.__cls_store) 163 | if Toggles.FORBID_UNANNOTATED_FIELDS in cls.__toggles__: 164 | _utils.check_unannotated_fields(cls.__cls_store) 165 | 166 | def __init__(self, **kwargs: t.Any) -> None: # noqa: ANN401 167 | self.__iter = None 168 | self.__kwargs = kwargs.copy() 169 | self.__process_toggles() 170 | self.__populate_attrs() 171 | msg = self.__process_template(self.as_dict()) 172 | msg = self._override_message(self.__cls_store, kwargs, msg) 173 | super().__init__(msg) 174 | 175 | def __process_toggles(self) -> None: 176 | """Trigger toggles.""" 177 | store = self.__cls_store 178 | kws = frozenset(self.__kwargs) 179 | 180 | if Toggles.FORBID_MISSING_FIELDS in self.__toggles__: 181 | _utils.check_missing_fields(store, kws) 182 | 183 | if Toggles.FORBID_UNDECLARED_FIELDS in self.__toggles__: 184 | _utils.check_undeclared_fields(store, kws) 185 | 186 | if Toggles.FORBID_KWARG_CONSTS in self.__toggles__: 187 | _utils.check_kwarg_consts(store, kws) 188 | 189 | def __populate_attrs(self) -> None: 190 | """Set hinted kwargs as exception attributes.""" 191 | for k, v in self.__kwargs.items(): 192 | if k in self.__cls_store.inst_hints: 193 | setattr(self, k, v) 194 | 195 | def __process_template(self, data: t.Dict[str, t.Any]) -> str: 196 | """Format the error template from provided data (kwargs & defaults).""" 197 | kwargs = self.__cls_store.consts.copy() 198 | kwargs.update(data) 199 | return _utils.format_template(self.__template__, kwargs) 200 | 201 | def _override_message( # noqa: PLR6301 202 | self, 203 | store: _utils.Store, # noqa: ARG002 204 | kwargs: t.Dict[str, t.Any], # noqa: ARG002 205 | msg: str, 206 | ) -> str: 207 | """ 208 | Adapter method to wedge user logic into izulu machinery. 209 | 210 | This is the place to override message/formatting if regular mechanics 211 | don't work for you. It has to return original or your flavored message. 212 | The method is invoked between izulu preparations and original 213 | ``Exception`` constructor receiving the result of this hook. 214 | 215 | You can also do any other logic here. You will be provided with 216 | a complete set of prepared data from izulu. But it's recommended 217 | to use classic OOP inheritance for ordinary behavior extension. 218 | 219 | Args: 220 | store: dataclass containing inner error class specifications 221 | kwargs: original kwargs from user 222 | msg: formatted message from the error template 223 | 224 | """ 225 | return msg 226 | 227 | def __repr__(self) -> str: 228 | kwargs = _utils.join_kwargs(**self.as_dict()) 229 | return f"{self.__module__}.{self.__class__.__qualname__}({kwargs})" 230 | 231 | def __copy__(self) -> Error: 232 | return type(self)(**self.as_dict()) 233 | 234 | def __deepcopy__(self, memo: t.Dict[int, t.Any]) -> Error: 235 | id_ = id(self) 236 | if id_ not in memo: 237 | kwargs = { 238 | k: copy.deepcopy(v, memo) for k, v in self.as_dict().items() 239 | } 240 | new = type(self)(**kwargs) 241 | new.__cause__ = copy.deepcopy(self.__cause__, memo) 242 | memo[id_] = new 243 | return t.cast("Error", memo[id_]) 244 | 245 | def __reduce__(self) -> t.Tuple[t.Any, ...]: 246 | return functools.partial(self.__class__, **self.as_dict()), tuple() 247 | 248 | def as_str(self) -> str: 249 | """Represent error as an exception type with message.""" 250 | return f"{self.__class__.__qualname__}: {self}" 251 | 252 | def as_kwargs(self) -> t.Dict[str, t.Any]: 253 | """Return the copy of original kwargs used to initialize the error.""" 254 | return self.__kwargs.copy() 255 | 256 | def as_dict(self, *, wide: bool = False) -> t.Dict[str, t.Any]: 257 | """ 258 | Represent error as dict of fields including default values. 259 | 260 | By default, only *instance* data and defaults are provided. 261 | 262 | Args: 263 | wide: if ``True`` *class* defaults will be included in result 264 | 265 | """ 266 | d = self.__kwargs.copy() 267 | for field in self.__cls_store.defaults: 268 | d.setdefault(field, getattr(self, field)) 269 | if wide: 270 | for field, const in self.__cls_store.consts.items(): 271 | d.setdefault(field, const) 272 | return d 273 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "izulu" 3 | dynamic = ["version"] 4 | description = "The exceptional library" 5 | authors = [{ name = "Dima Burmistrov", email = "pyctrl.dev@gmail.com" }] 6 | license-files = ["LICENSE"] 7 | readme = "README.rst" 8 | 9 | requires-python = ">=3.9" 10 | classifiers = [ 11 | "Programming Language :: Python :: 3", 12 | "License :: OSI Approved :: MIT License", 13 | "Operating System :: OS Independent", 14 | "Development Status :: 4 - Beta", 15 | "Intended Audience :: Developers", 16 | "Topic :: Software Development :: Libraries", 17 | "Topic :: Software Development :: Libraries :: Python Modules", 18 | "Typing :: Typed", 19 | ] 20 | keywords = ["error", "exception", "oop", "izulu"] 21 | 22 | [project.urls] 23 | homepage = "https://github.com/pyctrl/izulu" 24 | source = "https://github.com/pyctrl/izulu" 25 | documentation = "https://izulu.readthedocs.io/en/latest/" 26 | issues = "https://github.com/pyctrl/izulu/issues" 27 | 28 | 29 | [project.optional-dependencies] 30 | compatibility = ["typing-extensions>=4.5.0"] 31 | 32 | 33 | [dependency-groups] 34 | dev = [ 35 | "ipython", 36 | "ruff", 37 | "mypy", 38 | "gitlint", 39 | "typing-extensions", 40 | { include-group = "docs" }, 41 | { include-group = "tests" }, 42 | ] 43 | 44 | tests = [ 45 | "pytest", 46 | "pytest-cov", 47 | ] 48 | 49 | #rst = ["restructuredtext_lint", "Pygments"] 50 | ## readme: 51 | ## rst 52 | ## rst-include = `rst_include include ` 53 | ## docutils = `docutils README.rst README.html` 54 | 55 | docs = [ 56 | # base 57 | "sphinx", 58 | "sphinx-autobuild", 59 | # theme 60 | "pydata-sphinx-theme", 61 | # extensions 62 | "sphinx-copybutton", 63 | "sphinx-design", 64 | "sphinx-favicon", 65 | "sphinx-togglebutton", 66 | # project 67 | "setuptools-scm", 68 | ] 69 | 70 | ## release 71 | build = ["rst-include", "build", "twine"] 72 | upload = ["twine"] 73 | 74 | 75 | [build-system] 76 | requires = ["setuptools>=61.2", "setuptools-scm", "wheel"] 77 | build-backend = "setuptools.build_meta" 78 | 79 | [tool.setuptools_scm] 80 | 81 | 82 | [tool.ruff] 83 | target-version = "py39" 84 | line-length = 79 85 | extend-exclude = [".git", ".venv", "docs"] 86 | 87 | [tool.ruff.lint] 88 | preview = true 89 | extend-select = ["ALL"] 90 | extend-ignore = [ 91 | "D10", # missing documentation 92 | "D203", # 1 of conflicting code-styles 93 | "D212", # 1 of conflicting code-styles 94 | "C408", # allow `dict()` instead of literal 95 | "TD003", # don't require issue link 96 | # Completely disable 97 | "FIX", 98 | "CPY", 99 | # formatter conflict rules 100 | "W191", 101 | "E111", 102 | "E114", 103 | "E117", 104 | "EM101", 105 | "EM102", 106 | "D206", 107 | "D300", 108 | "DOC201", 109 | "DOC402", 110 | "Q000", 111 | "Q001", 112 | "Q002", 113 | "Q003", 114 | "COM812", 115 | "COM819", 116 | "ISC001", 117 | "ISC002", 118 | "TRY003", 119 | "UP006", 120 | "UP007", 121 | "UP035", 122 | "UP045", 123 | "FA100", 124 | ] 125 | 126 | [tool.ruff.lint.extend-per-file-ignores] 127 | "**/tests/*" = [ 128 | "S101", # allow assert 129 | "SLF001", # allow private member access 130 | "ANN", # annotations not required in tests 131 | "PLR0913", # Too many arguments in function definition 132 | "PLR0917", # Too many positional arguments 133 | "PLC2701", # Private name import 134 | ] 135 | "tests/error/test_dumping.py" = ["S301", "S403"] 136 | 137 | [tool.ruff.lint.flake8-import-conventions.extend-aliases] 138 | "typing" = "t" 139 | 140 | [tool.ruff.lint.flake8-import-conventions] 141 | banned-from = ["typing"] 142 | 143 | [tool.ruff.lint.isort] 144 | force-single-line = true 145 | 146 | [tool.ruff.lint.flake8-tidy-imports] 147 | ban-relative-imports = "all" 148 | 149 | [tool.ruff.lint.flake8-builtins] 150 | builtins-ignorelist = ["id"] 151 | 152 | [tool.ruff.format] 153 | quote-style = "double" 154 | indent-style = "space" 155 | 156 | 157 | [tool.coverage.run] 158 | branch = true 159 | 160 | [tool.coverage.report] 161 | include_namespace_packages = true 162 | # Regexes for lines to exclude from consideration 163 | omit = ["*/.venv/*"] 164 | exclude_also = [ 165 | # Don't complain about missing debug-only code: 166 | "def __repr__", 167 | 168 | # Don't complain if tests don't hit defensive assertion code: 169 | "raise NotImplementedError", 170 | 171 | # Don't complain if non-runnable code isn't run: 172 | "if __name__ == .__main__.:", 173 | 174 | # Don't complain about abstract methods, they aren't run: 175 | "@(abc\\.)?abstractmethod", 176 | 177 | "if TYPE_CHECKING:", 178 | ] 179 | 180 | [tool.mypy] 181 | strict = true 182 | exclude = ["docs", "tests", ".venv"] 183 | 184 | 185 | #[tool.tox] 186 | ## requires = ["tox>4.23", "tox-uv>=1.13"] 187 | ## runner = "uv-venv-lock-runner" 188 | #requires = ["tox>4.23"] 189 | #env_list = ["pep8", "rst", "py312", "mypy"] 190 | #use_develop = true 191 | 192 | #[tool.tox.env.rst] 193 | #skip_install = true 194 | #dependency_groups = ["rst"] 195 | #commands = [ 196 | # [ 197 | # "rst-lint", 198 | # { replace = "posargs", extend = true }, 199 | # "{tox_root}{/}README.rst", 200 | # ], 201 | #] 202 | 203 | [tool.tox.env.build] 204 | skip_install = true 205 | dependency_groups = ["build"] 206 | commands = [ 207 | # [ 208 | # "rst_include", 209 | # "include", 210 | # "README.tpl.rst", 211 | # "README.rst", 212 | # ], 213 | [ 214 | "python3", 215 | "-m", 216 | "build", 217 | { replace = "posargs", extend = true }, 218 | ], 219 | [ 220 | "python3", 221 | "-m", 222 | "twine", 223 | "check", 224 | { replace = "posargs", extend = true }, 225 | "dist{/}*", 226 | ], 227 | ] 228 | 229 | [tool.tox.env.upload_test] 230 | skip_install = true 231 | dependency_groups = ["upload"] 232 | commands = [ 233 | [ 234 | "python3", 235 | "-m", 236 | "twine", 237 | "upload", 238 | { replace = "posargs", extend = true }, 239 | "--repository", 240 | "{env:PYPI_REPOSITORY:testpypi}", 241 | "dist{/}*", 242 | ], 243 | ] 244 | 245 | [tool.tox.env.upload_prod] 246 | base = ["tool.tox.env.upload_test"] 247 | set_env.PYPI_REPOSITORY = "pypi" 248 | 249 | 250 | # Tox config 251 | [tool.tox] 252 | requires = ["tox>=4.23", "tox-uv>=1.13"] 253 | runner = "uv-venv-lock-runner" 254 | skip_missing_interpreters = true 255 | env_list = [ 256 | "py39", 257 | "py310", 258 | "py311", 259 | "py312", 260 | "py313", 261 | "fmt-py", 262 | "fmt-toml", 263 | "lint-py", 264 | "lint-mypy", 265 | "lint-toml", 266 | "lint-yaml", 267 | "lint-git", 268 | "coverage", 269 | ] 270 | 271 | [tool.tox.labels] 272 | fmt = [ 273 | "fmt-py", 274 | "fmt-toml", 275 | ] 276 | lint = [ 277 | "lint-py", 278 | "lint-mypy", 279 | "lint-toml", 280 | "lint-yaml", 281 | "lint-git", 282 | ] 283 | 284 | [tool.tox.env_run_base] 285 | description = "Run unit tests with coverage report ({env_name})" 286 | dependency_groups = ["tests"] 287 | commands = [ 288 | [ 289 | "pytest", 290 | "--cov={[project]name}", 291 | "--cov-branch", 292 | "--cov-report=term-missing:skip-covered", 293 | "tests", 294 | { replace = "posargs", default = ["--cov-report=xml:coverage.xml"], extend = true }, 295 | ], 296 | ] 297 | 298 | 299 | [tool.tox.env.init] 300 | description = "Configure developer's environment" 301 | skip_install = true 302 | commands = [ 303 | ["uv", "--quiet", "run", "pre-commit", "install"], 304 | ["uv", "--quiet", "run", "gitlint", "install-hook"], 305 | ] 306 | 307 | [tool.tox.env.lint-py] 308 | description = "Lint python files" 309 | deps = ["ruff"] 310 | skip_install = true 311 | commands = [ 312 | ["ruff", "format", "--diff", { replace = "posargs", default = ["{tox_root}"], extend = true }], 313 | ["ruff", "check", { replace = "posargs", default = ["{tox_root}"], extend = true }], 314 | ] 315 | 316 | [tool.tox.env.lint-mypy] 317 | description = "Type checking" 318 | deps = ["mypy"] 319 | commands = [["mypy", { replace = "posargs", default = ["{tox_root}"], extend = true }]] 320 | 321 | [tool.tox.env.lint-toml] 322 | description = "Lint TOML files" 323 | allowlist_externals = ["taplo"] 324 | skip_install = true 325 | commands = [ 326 | ["taplo", "lint", { replace = "posargs", extend = true }], 327 | ["taplo", "format", "--check", "--diff", { replace = "posargs", extend = true }], 328 | ] 329 | 330 | [tool.tox.env.lint-yaml] 331 | description = "Lint YAML files" 332 | deps = ["yamllint"] 333 | skip_install = true 334 | commands = [["yamllint", "--strict", { replace = "posargs", default = ["{tox_root}"], extend = true }]] 335 | 336 | [tool.tox.env.lint-git] 337 | description = "Lint git branch commits" 338 | skip_install = true 339 | commands = [["uv", "--quiet", "run", "gitlint", "--commits", "HEAD~10..HEAD"]] 340 | 341 | [tool.tox.env.pre-commit] 342 | description = "Run pre-commit" 343 | skip_install = true 344 | deps = ["pre-commit-uv"] 345 | commands = [ 346 | [ 347 | "pre-commit", 348 | "run", 349 | { replace = "posargs", default = ["--all-files", "--show-diff-on-failure"], extend = true }, 350 | ], 351 | ] 352 | 353 | [tool.tox.env.fmt-py] 354 | description = "Format python files" 355 | deps = ["ruff"] 356 | skip_install = true 357 | commands = [ 358 | [ 359 | "ruff", 360 | "format", 361 | { replace = "posargs", default = ["{tox_root}"], extend = true }, 362 | ], 363 | [ 364 | "ruff", 365 | "check", 366 | "--fix", 367 | "--show-fixes", 368 | { replace = "posargs", default = ["{tox_root}"], extend = true }, 369 | ], 370 | ] 371 | 372 | [tool.tox.env.fmt-toml] 373 | description = "Format TOML files" 374 | allowlist_externals = ["taplo"] 375 | skip_install = true 376 | commands = [["taplo", "format", { replace = "posargs", extend = true }]] 377 | 378 | [tool.tox.env.docs] 379 | dependency_groups = ["docs"] 380 | commands = [ 381 | [ 382 | "sphinx-build", 383 | "-M", 384 | "html", 385 | "{tox_root}/docs/source", 386 | "{tox_root}/docs/build", 387 | "--fail-on-warning", 388 | ], 389 | ] 390 | 391 | [tool.tox.env.docs-serve] 392 | dependency_groups = ["docs"] 393 | commands = [ 394 | [ 395 | "sphinx-autobuild", 396 | "-M", 397 | "html", 398 | "{tox_root}/docs/source", 399 | "{tox_root}/docs/build", 400 | "--fail-on-warning", 401 | ], 402 | ] 403 | 404 | [tool.tox.env.clean] 405 | dependency_groups = [] 406 | skip_install = true 407 | allowlist_externals = ["rm"] 408 | commands = [ 409 | [ 410 | "rm", 411 | "-rf", 412 | "{tox_root}/.coverage", 413 | "{tox_root}/coverage.xml", 414 | "{tox_root}/dist/", 415 | "{tox_root}/docs/build/", 416 | "{tox_root}/{[project]name}.egg-info/", 417 | ], 418 | ] 419 | -------------------------------------------------------------------------------- /izulu/_reraise.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import logging 5 | import typing as t 6 | 7 | from izulu import _utils 8 | 9 | _IMPORT_ERROR_TEXTS = ( 10 | "", 11 | "You have early version of Python.", 12 | " Extra compatibility dependency required.", 13 | " Please add 'izulu[compatibility]' to your project dependencies.", 14 | "", 15 | "Pip: `pip install izulu[compatibility]`", 16 | ) 17 | 18 | 19 | if hasattr(t, "dataclass_transform"): 20 | t_ext = t 21 | else: 22 | try: 23 | import typing_extensions as t_ext # type: ignore[no-redef] 24 | except ImportError: 25 | for message in _IMPORT_ERROR_TEXTS: 26 | logging.error(message) # noqa: LOG015,TRY400 27 | raise 28 | 29 | _T_KWARGS = t.Dict[str, t.Any] 30 | _T_EXC_CLASS_OR_TUPLE = t.Union[ 31 | t.Type[Exception], 32 | t.Tuple[t.Type[Exception], ...], 33 | ] 34 | _T_FACTORY = t.Callable[ 35 | [t.Type[Exception], Exception, _T_KWARGS], 36 | t.Optional[Exception], 37 | ] 38 | _T_ACTION = t.Union[str, t.Type[Exception], _T_FACTORY, None] 39 | _T_RULE = t.Tuple[t.Tuple[_T_EXC_CLASS_OR_TUPLE, _T_ACTION], ...] 40 | _T_RULES = t.Union[ 41 | bool, 42 | _T_RULE, # tup, chain? 43 | ] 44 | _T_RERAISING = t.Union[ 45 | _T_RULE, # tup, chain? 46 | bool, 47 | None, 48 | ] 49 | _T_COMPILED_ACTION = t.Callable[[Exception, _T_KWARGS], t.Optional[Exception]] 50 | _T_COMPILED_RULES = t.Union[ 51 | bool, 52 | t.Tuple[t.Tuple[_T_EXC_CLASS_OR_TUPLE, _T_COMPILED_ACTION], ...], 53 | ] 54 | 55 | _MISSING = object() 56 | 57 | DecParam = t_ext.ParamSpec("DecParam") 58 | DecReturnType = t.TypeVar("DecReturnType") 59 | 60 | 61 | class FatalMixin: 62 | """ 63 | Mark exception as non-recoverable. 64 | 65 | Should be directly inherited. You can't inherit from fatal exception. 66 | Fatal exceptions are by-passed by ``ReraisingMixin`` tools. 67 | """ 68 | 69 | def __init_subclass__(cls, **kwargs: t.Any) -> None: # noqa: ANN401 70 | if FatalMixin not in cls.__bases__: 71 | raise TypeError("Fatal can't be indirectly inherited") 72 | super().__init_subclass__(**kwargs) 73 | 74 | 75 | class ReraisingMixin: 76 | __reraising__: _T_RULES = False 77 | 78 | __reraising: _T_COMPILED_RULES 79 | 80 | def __init_subclass__(cls, **kwargs: t.Any) -> None: # noqa: ANN401 81 | super().__init_subclass__(**kwargs) 82 | rules = cls.__dict__.get("__reraising__", False) 83 | cls.__reraising = cls.__compile_rules(rules) 84 | 85 | @classmethod 86 | def __compile_rules(cls, rules: _T_RULES) -> _T_COMPILED_RULES: 87 | if isinstance(rules, bool): 88 | return rules 89 | 90 | return tuple( 91 | (exc_type, cls.__compile_action(action)) 92 | for exc_type, action in rules 93 | ) 94 | 95 | @classmethod 96 | def __compile_action( # noqa: C901 97 | cls, 98 | action: _T_ACTION, 99 | ) -> _T_COMPILED_ACTION: 100 | if action is None: 101 | 102 | def compiled_action( 103 | orig: Exception, # noqa: ARG001 104 | kwargs: _T_KWARGS, # noqa: ARG001 105 | ) -> t.Optional[Exception]: 106 | return None 107 | 108 | # TODO(d.burmistrov): temporary ignore 109 | elif action is t_ext.Self: # type: ignore[comparison-overlap] 110 | 111 | def compiled_action( 112 | orig: Exception, # noqa: ARG001 113 | kwargs: _T_KWARGS, 114 | ) -> t.Optional[Exception]: 115 | kls = t.cast("t.Type[Exception]", cls) 116 | return kls(**kwargs) 117 | 118 | elif isinstance(action, str): 119 | 120 | def compiled_action( 121 | orig: Exception, 122 | kwargs: _T_KWARGS, 123 | ) -> t.Optional[Exception]: 124 | action_ = t.cast( 125 | "t.Callable[[Exception, _T_KWARGS], t.Optional[Exception]]", # noqa: E501 126 | getattr(cls, action), 127 | ) 128 | return action_(orig, kwargs) 129 | 130 | elif isinstance(action, type) and issubclass(action, Exception): 131 | 132 | def compiled_action( 133 | orig: Exception, # noqa: ARG001 134 | kwargs: _T_KWARGS, 135 | ) -> t.Optional[Exception]: 136 | return action(**kwargs) 137 | 138 | elif callable(action): 139 | 140 | def compiled_action( 141 | orig: Exception, 142 | kwargs: _T_KWARGS, 143 | ) -> t.Optional[Exception]: 144 | kls = t.cast("t.Type[Exception]", cls) 145 | return t.cast("_T_FACTORY", action)(kls, orig, kwargs) 146 | 147 | else: 148 | raise ValueError(f"Unsupported action: {action}") 149 | 150 | return compiled_action 151 | 152 | @classmethod 153 | def remap( 154 | cls, 155 | exc: Exception, 156 | *, 157 | reraising: _T_RERAISING = None, 158 | remap_kwargs: t.Optional[_T_KWARGS] = None, 159 | original_over_none: bool = False, 160 | ) -> t.Union[Exception, None]: 161 | """ 162 | Return remapped exception instance. 163 | 164 | Remapping rules: 165 | 166 | 1. if the result of remapping is to leave the original exception 167 | method will return 168 | 169 | a. ``None`` for ``original_over_none=False``, 170 | b. original exception for ``original_over_none=True``. 171 | 172 | 2. early-return rule works first to abort remapping process for: 173 | 174 | a. exception with ``FatalMixin``, 175 | b. descendant exceptions for used class. 176 | 177 | 3. Default behaviour is not to remap exception. 178 | 179 | 4. Rules: ... 180 | 181 | Args: 182 | exc: original exception to be remapped 183 | reraising: manual overriding reraising rules 184 | remap_kwargs: provide kwargs for reraise exception 185 | original_over_none: if ``True`` return original 186 | exception instead of ``None`` 187 | 188 | Returns: 189 | reraising context manager 190 | 191 | """ 192 | reraising_ = cls.__reraising 193 | if reraising is not None: 194 | reraising_ = cls.__compile_rules(reraising) 195 | 196 | if ( 197 | isinstance(exc, cls) 198 | or not reraising_ 199 | or FatalMixin in exc.__class__.__bases__ 200 | ): 201 | if original_over_none: 202 | return exc 203 | return None 204 | 205 | remap_kwargs = remap_kwargs or {} 206 | 207 | # greedy remapping (any occurred exception) 208 | if reraising_ is True: 209 | kls = t.cast("t.Type[Exception]", cls) 210 | return kls(**remap_kwargs) 211 | 212 | reraising__ = t.cast( 213 | "t.Tuple[t.Tuple[_T_EXC_CLASS_OR_TUPLE, _T_COMPILED_ACTION], ...]", 214 | reraising_, 215 | ) 216 | 217 | for match, rule in reraising__: 218 | if not isinstance(exc, match): 219 | continue 220 | 221 | e = rule(exc, remap_kwargs) 222 | if e is None: 223 | break 224 | 225 | return e 226 | 227 | if original_over_none: 228 | return exc 229 | return None 230 | 231 | @classmethod 232 | @contextlib.contextmanager 233 | def reraise( # type: ignore[no-untyped-def] # noqa: ANN206 234 | cls, 235 | reraising: _T_RERAISING = None, 236 | remap_kwargs: t.Optional[_T_KWARGS] = None, 237 | ): 238 | """ 239 | Context Manager & Decorator to raise class exception over original. 240 | 241 | Args: 242 | reraising: manual overriding reraising rules 243 | remap_kwargs: provide kwargs for reraise exception 244 | 245 | """ 246 | try: 247 | yield 248 | except Exception as e: # noqa: BLE001 249 | orig = e 250 | else: 251 | return 252 | 253 | exc = cls.remap( 254 | exc=orig, 255 | reraising=reraising, 256 | remap_kwargs=remap_kwargs, 257 | ) 258 | if exc is None: 259 | raise # noqa: PLE0704 260 | 261 | raise exc from orig 262 | 263 | @classmethod 264 | @contextlib.asynccontextmanager 265 | async def async_reraise( # type: ignore[no-untyped-def] # noqa: ANN206 266 | cls, 267 | reraising: _T_RERAISING = None, 268 | remap_kwargs: t.Optional[_T_KWARGS] = None, 269 | ): 270 | """ 271 | Async version of `reraise`. 272 | 273 | Args: 274 | reraising: manual overriding reraising rules 275 | remap_kwargs: provide kwargs for reraise exception 276 | 277 | """ 278 | with cls.reraise(reraising=reraising, remap_kwargs=remap_kwargs): 279 | yield 280 | 281 | 282 | def skip(target: t.Type[Exception]) -> _T_RULE: 283 | return ((target, None),) 284 | 285 | 286 | def catch( 287 | target: t.Type[Exception] = Exception, 288 | *, 289 | exclude: t.Optional[t.Type[Exception]] = None, 290 | new: t.Any = t_ext.Self, # noqa: ANN401 291 | ) -> _T_RULE: 292 | rule = (target, new) 293 | if exclude: 294 | return (exclude, None), rule 295 | return (rule,) 296 | 297 | 298 | class chain: # noqa: N801 299 | def __init__(self, kls: ReraisingMixin, *klasses: ReraisingMixin) -> None: 300 | self._klasses = (kls, *klasses) 301 | 302 | def __call__( 303 | self, 304 | actor: t.Type[Exception], # noqa: ARG002 305 | exc: Exception, 306 | reraising: _T_RERAISING = None, # noqa: ARG002 307 | remap_kwargs: t.Optional[_T_KWARGS] = None, 308 | ) -> t.Optional[Exception]: 309 | for kls in self._klasses: 310 | remapped = kls.remap(exc=exc, remap_kwargs=remap_kwargs) 311 | if remapped is not None: 312 | return remapped 313 | return None 314 | 315 | @classmethod 316 | def from_subtree(cls, klass: t.Type[ReraisingMixin]) -> chain: 317 | it = ( 318 | t.cast("ReraisingMixin", kls) 319 | for kls in _utils.traverse_tree(klass) 320 | ) 321 | return cls(*it) 322 | 323 | @classmethod 324 | def from_names(cls, name: str, *names: str) -> chain: 325 | objects = globals() 326 | err_klasses = [] 327 | for name in (name, *names): # noqa: B020,PLR1704 328 | kls = objects.get(name, _MISSING) 329 | if kls is _MISSING: 330 | msg = f"module '{__name__}' has no attribute '{name}'" 331 | raise AttributeError(msg) 332 | 333 | err_klasses.append(kls) 334 | 335 | return cls(*err_klasses) 336 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | izulu 2 | ##### 3 | 4 | .. image:: https://repository-images.githubusercontent.com/766241795/85494614-5974-4b26-bfec-03b8e393c7f0 5 | :width: 128px 6 | 7 | | 8 | 9 | *"The exceptional library"* 10 | 11 | | 12 | 13 | 14 | **Installation** 15 | 16 | For Python versions prior to 3.11 also install ``izulu[compatibility]``. 17 | 18 | :: 19 | 20 | # py311 and higher 21 | pip install izulu 22 | 23 | # py38-py310 24 | pip install izulu izulu[compatibility] 25 | 26 | Presenting "izulu" 27 | ****************** 28 | 29 | Bring OOP into exception/error management 30 | ========================================= 31 | 32 | You can read docs *from top to bottom* or jump straight into **"Quickstart"** section. 33 | For details note **"Specifications"** sections below. 34 | 35 | 36 | Neat #1: Stop messing with raw strings and manual message formatting 37 | -------------------------------------------------------------------- 38 | 39 | .. code-block:: python 40 | 41 | if not data: 42 | raise ValueError("Data is invalid: no data") 43 | 44 | amount = data["amount"] 45 | if amount < 0: 46 | raise ValueError(f"Data is invalid: amount can't be negative ({amount})") 47 | elif amount > 1000: 48 | raise ValueError(f"Data is invalid: amount is too large ({amount})") 49 | 50 | if data["status"] not in {"READY", "IN_PROGRESS"}: 51 | raise ValueError("Data is invalid: unprocessable status") 52 | 53 | With ``izulu`` you can forget about manual error message management all over the codebase! 54 | 55 | .. code-block:: python 56 | 57 | class ValidationError(Error): 58 | __template__ = "Data is invalid: {reason}" 59 | 60 | class AmountValidationError(ValidationError): 61 | __template__ = "Invalid amount: {amount}" 62 | 63 | 64 | if not data: 65 | raise ValidationError(reason="no data") 66 | 67 | amount = data["amount"] 68 | if amount < 0: 69 | raise AmountValidationError(reason="amount can't be negative", amount=amount) 70 | elif amount > 1000: 71 | raise AmountValidationError(reason="amount is too large", amount=amount) 72 | 73 | if data["status"] not in {"READY", "IN_PROGRESS"}: 74 | raise ValidationError(reason="unprocessable status") 75 | 76 | 77 | Provide only variable data for error instantiations. Keep static data within error class. 78 | 79 | Under the hood ``kwargs`` are used to format ``__template__`` into final error message. 80 | 81 | 82 | Neat #2: Attribute errors with useful fields 83 | -------------------------------------------- 84 | 85 | .. code-block:: python 86 | 87 | from falcon import HTTPBadRequest 88 | 89 | class AmountValidationError(ValidationError): 90 | __template__ = "Data is invalid: {reason} ({amount})" 91 | amount: int 92 | 93 | 94 | try: 95 | validate(data) 96 | except AmountValidationError as e: 97 | if e.amount < 0: 98 | raise HTTPBadRequest(f"Bad amount: {e.amount}") 99 | raise 100 | 101 | 102 | Annotated instance attributes automatically populated from ``kwargs``. 103 | 104 | 105 | Neat #3: Static and dynamic defaults 106 | ------------------------------------ 107 | 108 | .. code-block:: python 109 | 110 | class AmountValidationError(ValidationError): 111 | __template__ = "Data is invalid: {reason} ({amount}; MAX={_MAX}) at {ts}" 112 | _MAX: ClassVar[int] = 1000 113 | amount: int 114 | reason: str = "amount is too large" 115 | ts: datetime = factory(datetime.now) 116 | 117 | 118 | print(AmountValidationError(amount=15000)) 119 | # Data is invalid: amount is too large (15000; MAX=1000) at 2024-01-13 22:59:25.132699 120 | 121 | print(AmountValidationError(amount=-1, reason="amount can't be negative")) 122 | # Data is invalid: amount can't be negative (-1; MAX=1000) at 2024-01-13 22:59:54.482577 123 | 124 | Quickstart 125 | ========== 126 | 127 | .. note:: 128 | 129 | **Prepare playground** 130 | 131 | :: 132 | 133 | pip install ipython izulu 134 | 135 | ipython -i -c 'from izulu.root import *; from typing import *; from datetime import *' 136 | 137 | 138 | Let's start with defining our initial error class (exception) 139 | ------------------------------------------------------------- 140 | 141 | #. subclass ``Error`` 142 | #. provide special message template for each of your exceptions 143 | #. use **only kwargs** to instantiate exception *(no more message copying across the codebase)* 144 | 145 | .. code-block:: python 146 | 147 | class MyError(Error): 148 | __template__ = "Having count={count} for owner={owner}" 149 | 150 | 151 | print(MyError(count=10, owner="me")) 152 | # MyError: Having count=10 for owner=me 153 | 154 | MyError(10, owner="me") 155 | # TypeError: __init__() takes 1 positional argument but 2 were given 156 | 157 | 158 | Move on and improve our class with attributes 159 | --------------------------------------------- 160 | 161 | #. define annotations for fields you want to publish as exception instance attributes 162 | #. you have to define desired template fields in annotations too 163 | (see ``AttributeError`` for ``owner``) 164 | #. you can provide annotation for attributes not included in template (see ``timestamp``) 165 | #. **type hinting from annotations are not enforced or checked** (see ``timestamp``) 166 | 167 | .. code-block:: python 168 | 169 | class MyError(Error): 170 | __template__ = "Having count={count} for owner={owner}" 171 | count: int 172 | timestamp: datetime 173 | 174 | e = MyError(count=10, owner="me", timestamp=datetime.now()) 175 | 176 | print(e.count) 177 | # 10 178 | print(e.timestamp) 179 | # 2023-09-27 18:18:22.957925 180 | 181 | e.owner 182 | # AttributeError: 'MyError' object has no attribute 'owner' 183 | 184 | 185 | We can provide defaults for our attributes 186 | ------------------------------------------ 187 | 188 | #. define *default static values* after field annotation just as usual 189 | #. for *dynamic defaults* use provided ``factory`` tool with your callable - it would be 190 | evaluated without arguments during exception instantiation 191 | #. now fields would receive values from ``kwargs`` if present - otherwise from *defaults* 192 | 193 | .. code-block:: python 194 | 195 | class MyError(Error): 196 | __template__ = "Having count={count} for owner={owner}" 197 | count: int 198 | owner: str = "nobody" 199 | timestamp: datetime = factory(datetime.now) 200 | 201 | e = MyError(count=10) 202 | 203 | print(e.count) 204 | # 10 205 | print(e.owner) 206 | # nobody 207 | print(e.timestamp) 208 | # 2023-09-27 18:19:37.252577 209 | 210 | 211 | Dynamic defaults also supported 212 | ------------------------------- 213 | 214 | .. code-block:: python 215 | 216 | class MyError(Error): 217 | __template__ = "Having count={count} for owner={owner}" 218 | 219 | count: int 220 | begin: datetime 221 | owner: str = "nobody" 222 | timestamp: datetime = factory(datetime.now) 223 | duration: timedelta = factory(lambda self: self.timestamp - self.begin, self=True) 224 | 225 | 226 | begin = datetime.fromordinal(date.today().toordinal()) 227 | e = MyError(count=10, begin=begin) 228 | 229 | print(e.begin) 230 | # 2023-09-27 00:00:00 231 | print(e.duration) 232 | # 18:45:44.502490 233 | print(e.timestamp) 234 | # 2023-09-27 18:45:44.502490 235 | 236 | 237 | * very similar to dynamic defaults, but callable must accept single 238 | argument - your exception fresh instance 239 | * **don't forget** to provide second ``True`` argument for ``factory`` tool 240 | (keyword or positional - doesn't matter) 241 | 242 | Specifications 243 | ************** 244 | 245 | ``izulu`` bases on class definitions to provide handy instance creation. 246 | 247 | **The 6 pillars of** ``izulu`` 248 | 249 | * all behavior is defined on the class-level 250 | 251 | * ``__template__`` class attribute defines the template for target error message 252 | 253 | * template may contain *"fields"* for substitution from ``kwargs`` and *"defaults"* to produce final error message 254 | 255 | * ``__features__`` class attribute defines constraints and behaviour (see "Features" section below) 256 | 257 | * by default all constraints are enabled 258 | 259 | * *"class hints"* annotated with ``ClassVar`` are noted by ``izulu`` 260 | 261 | * annotated class attributes normally should have values (treated as *"class defaults"*) 262 | * *"class defaults"* can only be static 263 | * *"class defaults"* may be referred within ``__template__`` 264 | 265 | * *"instance hints"* regularly annotated (not with ``ClassVar``) are noted by ``izulu`` 266 | 267 | * all annotated attributes are treated as *"instance attributes"* 268 | * each *"instance attribute"* will automatically obtain value from the ``kwarg`` of the same name 269 | * *"instance attributes"* with default are also treated as *"instance defaults"* 270 | * *"instance defaults"* may be **static and dynamic** 271 | * *"instance defaults"* may be referred within ``__template__`` 272 | 273 | * ``kwargs`` — the new and main way to form exceptions/error instance 274 | 275 | * forget about creating exception instances from message strings 276 | * ``kwargs`` are the datasource for template *"fields"* and *"instance attributes"* 277 | (shared input for templating attribution) 278 | 279 | .. warning:: **Types from type hints are not validated or enforced!** 280 | 281 | Mechanics 282 | ========= 283 | 284 | .. note:: 285 | 286 | **Prepare playground** 287 | 288 | :: 289 | 290 | pip install ipython izulu 291 | 292 | ipython -i -c 'from izulu.root import *; from typing import *; from datetime import *' 293 | 294 | 295 | * inheritance from ``izulu.root.Error`` is required 296 | 297 | .. code-block:: python 298 | 299 | class AmountError(Error): 300 | pass 301 | 302 | * **optionally** behaviour can be adjusted with ``__features__`` (not recommended) 303 | 304 | .. code-block:: python 305 | 306 | class AmountError(Error): 307 | __features__ = Features.DEFAULT ^ Features.FORBID_UNDECLARED_FIELDS 308 | 309 | * you should provide a template for the target error message with ``__template__`` 310 | 311 | .. code-block:: python 312 | 313 | class AmountError(Error): 314 | __template__ = "Data is invalid: {reason} (amount={amount})" 315 | 316 | print(AmountError(reason="negative amount", amount=-10.52)) 317 | # [2024-01-23 19:16] Data is invalid: negative amount (amount=-10.52) 318 | 319 | * sources of formatting arguments: 320 | 321 | * *"class defaults"* 322 | * *"instance defaults"* 323 | * ``kwargs`` (overlap any *"default"*) 324 | 325 | * new style formatting is used: 326 | 327 | .. code-block:: python 328 | 329 | class AmountError(Error): 330 | __template__ = "[{ts:%Y-%m-%d %H:%M}] Data is invalid: {reason:_^20} (amount={amount:06.2f})" 331 | 332 | print(AmountError(ts=datetime.now(), reason="negative amount", amount=-10.52)) 333 | # [2024-01-23 19:16] Data is invalid: __negative amount___ (amount=-10.52) 334 | 335 | * ``help(str.format)`` 336 | * https://pyformat.info/ 337 | * https://docs.python.org/3/library/string.html#format-string-syntax 338 | 339 | .. warning:: 340 | There is a difference between docs and actual behaviour: 341 | https://discuss.python.org/t/format-string-syntax-specification-differs-from-actual-behaviour/46716 342 | 343 | * only named fields are allowed 344 | 345 | * positional (digit) and empty field are forbidden 346 | 347 | * error instantiation requires data to format ``__template__`` 348 | 349 | * all data for ``__template__`` fields must be provided 350 | 351 | .. code-block:: python 352 | 353 | class AmountError(Error): 354 | __template__ = "Data is invalid: {reason} (amount={amount})" 355 | 356 | print(AmountError(reason="amount can't be negative", amount=-10)) 357 | # Data is invalid: amount can't be negative (amount=-10) 358 | 359 | AmountError() 360 | # TypeError: Missing arguments: 'reason', 'amount' 361 | AmountError(amount=-10) 362 | # TypeError: Missing arguments: 'reason' 363 | 364 | * only named arguments allowed: ``__init__()`` accepts only ``kwargs`` 365 | 366 | .. code-block:: python 367 | 368 | class AmountError(Error): 369 | __template__ = "Data is invalid: {reason} (amount={amount})" 370 | 371 | print(AmountError(reason="amount can't be negative", amount=-10)) 372 | # Data is invalid: amount can't be negative (amount=-10) 373 | 374 | AmountError("amount can't be negative", -10) 375 | # TypeError: __init__() takes 1 positional argument but 3 were given 376 | AmountError("amount can't be negative", amount=-10) 377 | # TypeError: __init__() takes 1 positional argument but 2 were given 378 | 379 | * *"class defaults"* can be defined and used 380 | 381 | * *"class defaults"* must be type hinted with ``ClassVar`` annotation and provide static values 382 | * template *"fields"* may refer *"class defaults"* 383 | 384 | .. code-block:: python 385 | 386 | class AmountError(Error): 387 | LIMIT: ClassVar[int] = 10_000 388 | __template__ = "Amount is too large: amount={amount} limit={LIMIT}" 389 | amount: int 390 | 391 | print(AmountError(amount=10_500)) 392 | # Amount is too large: amount=10500 limit=10000 393 | 394 | * *"instance attributes"* are populated from relevant ``kwargs`` 395 | 396 | .. code-block:: python 397 | 398 | class AmountError(Error): 399 | amount: int 400 | 401 | print(AmountError(amount=-10).amount) 402 | # -10 403 | 404 | * instance and class attribute types from **annotations are not validated or enforced** 405 | (``izulu`` uses type hints just for attribute discovery and only ``ClassVar`` marker 406 | is processed for instance/class segregation) 407 | 408 | .. code-block:: python 409 | 410 | class AmountError(Error): 411 | amount: int 412 | 413 | print(AmountError(amount="lots of money").amount) 414 | # lots of money 415 | 416 | * static *"instance defaults"* can be provided regularly with instance type hints and static values 417 | 418 | .. code-block:: python 419 | 420 | class AmountError(Error): 421 | amount: int = 500 422 | 423 | print(AmountError().amount) 424 | # 500 425 | 426 | * dynamic *"instance defaults"* are also supported 427 | 428 | * they must be type hinted and have special value 429 | * value must be a callable object wrapped with ``factory`` helper 430 | * ``factory`` provides 2 modes depending on value of the ``self`` flag: 431 | 432 | * ``self=False`` (default): callable accepting no arguments 433 | 434 | .. code-block:: python 435 | 436 | class AmountError(Error): 437 | ts: datetime = factory(datetime.now) 438 | 439 | print(AmountError().ts) 440 | # 2024-01-23 23:18:22.019963 441 | 442 | * ``self=True``: provide callable accepting single argument (error instance) 443 | 444 | .. code-block:: python 445 | 446 | class AmountError(Error): 447 | LIMIT = 10_000 448 | amount: int 449 | overflow: int = factory(lambda self: self.amount - self.LIMIT, self=True) 450 | 451 | print(AmountError(amount=10_500).overflow) 452 | # 500 453 | 454 | * *"instance defaults"* and *"instance attributes"* may be referred in ``__template__`` 455 | 456 | .. code-block:: python 457 | 458 | class AmountError(Error): 459 | __template__ = "[{ts:%Y-%m-%d %H:%M}] Amount is too large: {amount}" 460 | amount: int 461 | ts: datetime = factory(datetime.now) 462 | 463 | print(AmountError(amount=10_500)) 464 | # [2024-01-23 23:21] Amount is too large: 10500 465 | 466 | * *Pause and sum up: defaults, attributes and template* 467 | 468 | .. code-block:: python 469 | 470 | class AmountError(Error): 471 | LIMIT: ClassVar[int] = 10_000 472 | __template__ = "[{ts:%Y-%m-%d %H:%M}] Amount is too large: amount={amount} limit={LIMIT} overflow={overflow}" 473 | amount: int 474 | overflow: int = factory(lambda self: self.amount - self.LIMIT, self=True) 475 | ts: datetime = factory(datetime.now) 476 | 477 | err = AmountError(amount=15_000) 478 | 479 | print(err.amount) 480 | # 15000 481 | print(err.LIMIT) 482 | # 10000 483 | print(err.overflow) 484 | # 5000 485 | print(err.ts) 486 | # 2024-01-23 23:21:26 487 | 488 | print(err) 489 | # [2024-01-23 23:21] Amount is too large: amount=15000 limit=10000 overflow=5000 490 | 491 | * ``kwargs`` overlap *"instance defaults"* 492 | 493 | .. code-block:: python 494 | 495 | class AmountError(Error): 496 | LIMIT: ClassVar[int] = 10_000 497 | __template__ = "[{ts:%Y-%m-%d %H:%M}] Amount is too large: amount={amount} limit={LIMIT} overflow={overflow}" 498 | amount: int = 15_000 499 | overflow: int = factory(lambda self: self.amount - self.LIMIT, self=True) 500 | ts: datetime = factory(datetime.now) 501 | 502 | print(AmountError()) 503 | # [2024-01-23 23:21] Amount is too large: amount=15000 limit=10000 overflow=5000 504 | 505 | print(AmountError(amount=10_333, overflow=42, ts=datetime(1900, 1, 1))) 506 | # [2024-01-23 23:21] Amount is too large: amount=10333 limit=10000 overflow=42 507 | 508 | * ``izulu`` provides flexibility for templates, fields, attributes and defaults 509 | 510 | * *"defaults"* are not required to be ``__template__`` *"fields"* 511 | 512 | .. code-block:: python 513 | 514 | class AmountError(Error): 515 | LIMIT: ClassVar[int] = 10_000 516 | __template__ = "Amount is too large" 517 | 518 | print(AmountError().LIMIT) 519 | # 10000 520 | print(AmountError()) 521 | # Amount is too large 522 | 523 | * there can be hints for attributes not present in error message template 524 | 525 | .. code-block:: python 526 | 527 | class AmountError(Error): 528 | __template__ = "Amount is too large" 529 | amount: int 530 | 531 | print(AmountError(amount=500).amount) 532 | # 500 533 | print(AmountError(amount=500)) 534 | # Amount is too large 535 | 536 | * *"fields"* don't have to be hinted as instance attributes 537 | 538 | .. code-block:: python 539 | 540 | class AmountError(Error): 541 | __template__ = "Amount is too large: {amount}" 542 | 543 | print(AmountError(amount=500)) 544 | # Amount is too large: 500 545 | print(AmountError(amount=500).amount) 546 | # AttributeError: 'AmountError' object has no attribute 'amount' 547 | 548 | Features 549 | ======== 550 | 551 | The ``izulu`` error class behaviour is controlled by ``__features__`` class attribute. 552 | 553 | (For details about "runtime" and "class definition" stages 554 | see **Validation and behavior in case of problems** below) 555 | 556 | 557 | Supported features 558 | ------------------ 559 | 560 | * ``FORBID_MISSING_FIELDS``: checks provided ``kwargs`` contain data for all template *"fields"* 561 | and *"instance attributes"* that have no *"defaults"* 562 | 563 | * always should be enabled (provides consistent and detailed ``TypeError`` exceptions 564 | for appropriate arguments) 565 | * if disabled raw exceptions from ``izulu`` machinery internals could appear 566 | 567 | ======= ============= 568 | Stage Raises 569 | ======= ============= 570 | runtime ``TypeError`` 571 | ======= ============= 572 | 573 | .. code-block:: python 574 | 575 | class AmountError(Error): 576 | __template__ = "Some {amount} of money for {client_id} client" 577 | client_id: int 578 | 579 | # I. enabled 580 | AmountError() 581 | # TypeError: Missing arguments: client_id, amount 582 | 583 | # II. disabled 584 | AmountError.__features__ ^= Features.FORBID_MISSING_FIELDS 585 | 586 | AmountError() 587 | # ValueError: Failed to format template with provided kwargs: 588 | 589 | * ``FORBID_UNDECLARED_FIELDS``: forbids undefined arguments in provided ``kwargs`` 590 | (names not present in template *"fields"* and *"instance/class hints"*) 591 | 592 | * if disabled allows and **completely ignores** unknown data in ``kwargs`` 593 | 594 | ======= ============= 595 | Stage Raises 596 | ======= ============= 597 | runtime ``TypeError`` 598 | ======= ============= 599 | 600 | .. code-block:: python 601 | 602 | class MyError(Error): 603 | __template__ = "My error occurred" 604 | 605 | # I. enabled 606 | MyError(unknown_data="data") 607 | # Undeclared arguments: unknown_data 608 | 609 | # II. disabled 610 | MyError.__features__ ^= Features.FORBID_UNDECLARED_FIELDS 611 | err = MyError(unknown_data="data") 612 | 613 | print(err) 614 | # Unspecified error 615 | print(repr(err)) 616 | # __main__.MyError(unknown_data='data') 617 | err.unknown_data 618 | # AttributeError: 'MyError' object has no attribute 'unknown_data' 619 | 620 | * ``FORBID_KWARG_CONSTS``: checks provided ``kwargs`` not to contain attributes defined as ``ClassVar`` 621 | 622 | * if disabled allows data in ``kwargs`` to overlap class attributes during template formatting 623 | * overlapping data won't modify class attribute values 624 | 625 | ======= ============= 626 | Stage Raises 627 | ======= ============= 628 | runtime ``TypeError`` 629 | ======= ============= 630 | 631 | .. code-block:: python 632 | 633 | class MyError(Error): 634 | __template__ = "My error occurred {_TYPE}" 635 | _TYPE: ClassVar[str] 636 | 637 | # I. enabled 638 | MyError(_TYPE="SOME_ERROR_TYPE") 639 | # TypeError: Constants in arguments: _TYPE 640 | 641 | # II. disabled 642 | MyError.__features__ ^= Features.FORBID_KWARG_CONSTS 643 | err = MyError(_TYPE="SOME_ERROR_TYPE") 644 | 645 | print(err) 646 | # My error occurred SOME_ERROR_TYPE 647 | print(repr(err)) 648 | # __main__.MyError(_TYPE='SOME_ERROR_TYPE') 649 | err._TYPE 650 | # AttributeError: 'MyError' object has no attribute '_TYPE' 651 | 652 | * ``FORBID_NON_NAMED_FIELDS``: forbids empty and digit field names in ``__template__`` 653 | 654 | * if disabled validation (runtime issues) 655 | * ``izulu`` relies on ``kwargs`` and named fields 656 | * by default it's forbidden to provide empty (``{}``) and digit (``{0}``) fields in ``__template__`` 657 | 658 | ================ ============== 659 | Stage Raises 660 | ================ ============== 661 | class definition ``ValueError`` 662 | ================ ============== 663 | 664 | .. code-block:: python 665 | 666 | class MyError(Error): 667 | __template__ = "My error occurred {_TYPE}" 668 | _TYPE: ClassVar[str] 669 | 670 | # I. enabled 671 | MyError(_TYPE="SOME_ERROR_TYPE") 672 | # TypeError: Constants in arguments: _TYPE 673 | 674 | # II. disabled 675 | MyError.__features__ ^= Features.FORBID_KWARG_CONSTS 676 | err = MyError(_TYPE="SOME_ERROR_TYPE") 677 | 678 | print(err) 679 | # My error occurred SOME_ERROR_TYPE 680 | print(repr(err)) 681 | # __main__.MyError(_TYPE='SOME_ERROR_TYPE') 682 | err._TYPE 683 | # AttributeError: 'MyError' object has no attribute '_TYPE' 684 | 685 | 686 | Tuning ``__features__`` 687 | ----------------------- 688 | 689 | Features are represented as *"Flag Enum"*, so you can use regular operations 690 | to configure desired behaviour. 691 | Examples: 692 | 693 | * Use single option 694 | 695 | .. code-block:: python 696 | 697 | class AmountError(Error): 698 | __features__ = Features.FORBID_MISSING_FIELDS 699 | 700 | * Use presets 701 | 702 | .. code-block:: python 703 | 704 | class AmountError(Error): 705 | __features__ = Features.NONE 706 | 707 | * Combining wanted features: 708 | 709 | .. code-block:: python 710 | 711 | class AmountError(Error): 712 | __features__ = Features.FORBID_MISSING_FIELDS | Features.FORBID_KWARG_CONSTS 713 | 714 | * Discarding unwanted feature from default feature set: 715 | 716 | .. code-block:: python 717 | 718 | class AmountError(Error): 719 | __features__ = Features.DEFAULT ^ Features.FORBID_UNDECLARED_FIELDS 720 | 721 | Validation and behavior in case of problems 722 | =========================================== 723 | 724 | ``izulu`` may trigger native Python exceptions on invalid data during validation process. 725 | By default you should expect following ones 726 | 727 | * ``TypeError``: argument constraints issues 728 | * ``ValueError``: template and formatting issues 729 | 730 | Some exceptions are *raised from* original exception (e.g. template formatting issues), 731 | so you can check ``e.__cause__`` and traceback output for details. 732 | 733 | 734 | The validation behavior depends on the set of enabled features. 735 | Changing feature set may cause different and raw exceptions being raised. 736 | Read and understand **"Features"** section to predict and experiment with different situations and behaviours. 737 | 738 | 739 | ``izulu`` has **2 validation stages:** 740 | 741 | * class definition stage 742 | 743 | * validation is made during error class definition 744 | 745 | .. code-block:: python 746 | 747 | # when you import error module 748 | from izulu import root 749 | 750 | # when you import error from module 751 | from izulu.root import Error 752 | 753 | # when you interactively define new error classes 754 | class MyError(Error): 755 | pass 756 | 757 | * class attributes ``__template__`` and ``__features__`` are validated 758 | 759 | .. code-block:: python 760 | 761 | class MyError(Error): 762 | __template__ = "Hello {}" 763 | 764 | # ValueError: Field names can't be empty 765 | 766 | * runtime stage 767 | 768 | * validation is made during error instantiation 769 | 770 | .. code-block:: python 771 | 772 | root.Error() 773 | 774 | * ``kwargs`` are validated according to enabled features 775 | 776 | .. code-block:: python 777 | 778 | class MyError(Error): 779 | __template__ = "Hello {name}" 780 | 781 | MyError() 782 | # TypeError: Missing arguments: 'name' 783 | 784 | Additional APIs 785 | =============== 786 | 787 | 788 | Representations 789 | --------------- 790 | 791 | .. code-block:: python 792 | 793 | class AmountValidationError(Error): 794 | __template__ = "Data is invalid: {reason} ({amount}; MAX={_MAX}) at {ts}" 795 | _MAX: ClassVar[int] = 1000 796 | amount: int 797 | reason: str = "amount is too large" 798 | ts: datetime = factory(datetime.now) 799 | 800 | 801 | err = AmountValidationError(amount=15000) 802 | 803 | print(str(err)) 804 | # Data is invalid: amount is too large (15000; MAX=1000) at 2024-01-13 23:33:13.847586 805 | 806 | print(repr(err)) 807 | # __main__.AmountValidationError(amount=15000, ts=datetime.datetime(2024, 1, 13, 23, 33, 13, 847586), reason='amount is too large') 808 | 809 | 810 | * ``str`` and ``repr`` output differs 811 | * ``str`` is for humans and Python (Python dictates the result to be exactly and only the message) 812 | * ``repr`` allows to reconstruct the same error instance from its output 813 | (if data provided into ``kwargs`` supports ``repr`` the same way) 814 | 815 | **note:** class name is fully qualified name of class (dot-separated module full path with class name) 816 | 817 | .. code-block:: python 818 | 819 | reconstructed = eval(repr(err).replace("__main__.", "", 1)) 820 | 821 | print(str(reconstructed)) 822 | # Data is invalid: amount is too large (15000; MAX=1000) at 2024-01-13 23:33:13.847586 823 | 824 | print(repr(reconstructed)) 825 | # AmountValidationError(amount=15000, ts=datetime.datetime(2024, 1, 13, 23, 33, 13, 847586), reason='amount is too large') 826 | 827 | * in addition to ``str`` there is another human-readable representations provided by ``.as_str()`` method; 828 | it prepends message with class name: 829 | 830 | .. code-block:: python 831 | 832 | print(err.as_str()) 833 | # AmountValidationError: Data is invalid: amount is too large (15000; MAX=1000) at 2024-01-13 23:33:13.847586 834 | 835 | 836 | Pickling 837 | -------- 838 | 839 | ``izulu``-based errors **support pickling** by default. 840 | 841 | 842 | Dumping and loading 843 | ------------------- 844 | 845 | **Dumping** 846 | 847 | * ``.as_kwargs()`` dumps shallow copy of original ``kwargs`` 848 | 849 | .. code-block:: python 850 | 851 | err.as_kwargs() 852 | # {'amount': 15000} 853 | 854 | * ``.as_dict()`` by default, combines original ``kwargs`` and all *"instance attribute"* values into *"full state"* 855 | 856 | .. code-block:: python 857 | 858 | err.as_dict() 859 | # {'amount': 15000, 'ts': datetime(2024, 1, 13, 23, 33, 13, 847586), 'reason': 'amount is too large'} 860 | 861 | Additionally, there is the ``wide`` flag for enriching the result with *"class defaults"* 862 | (note additional ``_MAX`` data) 863 | 864 | .. code-block:: python 865 | 866 | err.as_dict(True) 867 | # {'amount': 15000, 'ts': datetime(2024, 1, 13, 23, 33, 13, 847586), 'reason': 'amount is too large', '_MAX': 1000} 868 | 869 | Data combination process follows prioritization — if there are multiple values for same name then high priority data 870 | will overlap data with lower priority. Here is the prioritized list of data sources: 871 | 872 | #. ``kwargs`` (max priority) 873 | #. *"instance attributes"* 874 | #. *"class defaults"* 875 | 876 | 877 | **Loading** 878 | 879 | * ``.as_kwargs()`` result can be used to create **inaccurate** copy of original error, 880 | but pay attention to dynamic factories — ``datetime.now()``, ``uuid()`` and many others would produce new values 881 | for data missing in ``kwargs`` (note ``ts`` field in the example below) 882 | 883 | .. code-block:: python 884 | 885 | inaccurate_copy = AmountValidationError(**err.as_kwargs()) 886 | 887 | print(inaccurate_copy) 888 | # Data is invalid: amount is too large (15000; MAX=1000) at 2024-02-01 21:11:21.681080 889 | print(repr(inaccurate_copy)) 890 | # __main__.AmountValidationError(amount=15000, reason='amount is too large', ts=datetime.datetime(2024, 2, 1, 21, 11, 21, 681080)) 891 | 892 | * ``.as_dict()`` result can be used to create **accurate** copy of original error; 893 | flag ``wide`` should be ``False`` by default according to ``FORBID_KWARG_CONSTS`` restriction 894 | (if you disable ``FORBID_KWARG_CONSTS`` then you may need to use ``wide=True`` depending on your situation) 895 | 896 | .. code-block:: python 897 | 898 | accurate_copy = AmountValidationError(**err.as_dict()) 899 | 900 | print(accurate_copy) 901 | # Data is invalid: amount is too large (15000; MAX=1000) at 2024-02-01 21:11:21.681080 902 | print(repr(accurate_copy)) 903 | # __main__.AmountValidationError(amount=15000, reason='amount is too large', ts=datetime.datetime(2024, 2, 1, 21, 11, 21, 681080)) 904 | 905 | 906 | (advanced) Wedge 907 | ---------------- 908 | 909 | There is a special method you can override and additionally manage the machinery. 910 | 911 | But it should not be need in 99,9% cases. Avoid it, please. 912 | 913 | .. code-block:: python 914 | 915 | def _hook(self, 916 | store: _utils.Store, 917 | kwargs: dict[str, t.Any], 918 | msg: str) -> str: 919 | """Adapter method to wedge user logic into izulu machinery 920 | 921 | This is the place to override message/formatting if regular mechanics 922 | don't work for you. It has to return original or your flavored message. 923 | The method is invoked between izulu preparations and original 924 | `Exception` constructor receiving the result of this hook. 925 | 926 | You can also do any other logic here. You will be provided with 927 | complete set of prepared data from izulu. But it's recommended 928 | to use classic OOP inheritance for ordinary behaviour extension. 929 | 930 | Params: 931 | * store: dataclass containing inner error class specifications 932 | * kwargs: original kwargs from user 933 | * msg: formatted message from the error template 934 | """ 935 | 936 | return msg 937 | 938 | Recipes & Tips 939 | ************** 940 | 941 | 1. inheritance / root exception 942 | =============================== 943 | 944 | .. code-block:: python 945 | 946 | # intermediate class to centrally control the default behaviour 947 | class BaseError(Error): # <-- inherit from this in your code (not directly from ``izulu``) 948 | __features__ = Features.None 949 | 950 | 951 | class MyRealError(BaseError): 952 | __template__ = "Having count={count} for owner={owner}" 953 | 954 | 955 | 2. factories 956 | ============ 957 | 958 | TODO: self=True / self.as_kwargs() (as_dict forbidden? - recursion) 959 | 960 | 961 | * stdlib factories 962 | 963 | .. code-block:: python 964 | 965 | from uuid import uuid4 966 | 967 | class MyError(Error): 968 | id: datetime = factory(uuid4) 969 | timestamp: datetime = factory(datetime.now) 970 | 971 | * lambdas 972 | 973 | .. code-block:: python 974 | 975 | class MyError(Error): 976 | timestamp: datetime = factory(lambda: datetime.now().isoformat()) 977 | 978 | * function 979 | 980 | .. code-block:: python 981 | 982 | from random import randint 983 | 984 | def flip_coin(): 985 | return "TAILS" if randint(0, 100) % 2 else "HEADS 986 | 987 | class MyError(Error): 988 | coin: str = factory(flip_coin) 989 | 990 | 991 | * method 992 | 993 | .. code-block:: python 994 | 995 | class MyError(Error): 996 | __template__ = "Having count={count} for owner={owner}" 997 | 998 | def __make_duration(self) -> timedelta: 999 | kwargs = self.as_kwargs() 1000 | return self.timestamp - kwargs["begin"] 1001 | 1002 | timestamp: datetime = factory(datetime.now) 1003 | duration: timedelta = factory(__make_duration, self=True) 1004 | 1005 | 1006 | begin = datetime.fromordinal(date.today().toordinal()) 1007 | e = MyError(count=10, begin=begin) 1008 | 1009 | print(e.begin) 1010 | # 2023-09-27 00:00:00 1011 | print(e.duration) 1012 | # 18:45:44.502490 1013 | print(e.timestamp) 1014 | # 2023-09-27 18:45:44.502490 1015 | 1016 | 1017 | 3. handling errors in presentation layers / APIs 1018 | ================================================ 1019 | 1020 | .. code-block:: python 1021 | 1022 | err = Error() 1023 | view = RespModel(error=err.as_dict(wide=True) 1024 | 1025 | 1026 | class MyRealError(BaseError): 1027 | __template__ = "Having count={count} for owner={owner}" 1028 | 1029 | 1030 | Additional examples 1031 | ------------------- 1032 | 1033 | TBD 1034 | 1035 | For developers 1036 | ************** 1037 | 1038 | * Use regular virtualenv or any other (no pre-defined preparations provided) 1039 | 1040 | * Running tests:: 1041 | 1042 | tox 1043 | 1044 | * Building package:: 1045 | 1046 | tox -e build 1047 | 1048 | * Contributing: contact me through `Issues `__ 1049 | 1050 | 1051 | Versioning 1052 | ********** 1053 | 1054 | `SemVer `__ used for versioning. 1055 | For available versions see the repository 1056 | `tags `__ 1057 | and `releases `__. 1058 | 1059 | Authors 1060 | ******* 1061 | 1062 | - **Dima Burmistrov** - *Initial work* - 1063 | `pyctrl `__ 1064 | 1065 | *Special thanks to* `Eugene Frolov `__ *for inspiration.* 1066 | 1067 | 1068 | License 1069 | ******* 1070 | 1071 | This project is licensed under the X11 License (extended MIT) - see the 1072 | `LICENSE `__ file for details 1073 | --------------------------------------------------------------------------------