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