diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a850f71..bf7a5c81 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,10 +19,10 @@ jobs: id: tag run: echo ::set-output name=tag::${GITHUB_REF#refs/tags/} - - name: Set up Python 3.9 + - name: Set up Python 3.11 uses: actions/setup-python@v4 with: - python-version: "3.9" + python-version: "3.11" - name: Install and set up Poetry run: | @@ -44,8 +44,9 @@ jobs: uses: ncipollo/release-action@v1 with: artifacts: "dist/*" - token: ${{ secrets.GITHUB_TOKEN }} draft: false + allowUpdates: true + generateReleaseNotes: true prerelease: steps.check-version.outputs.prerelease == 'true' - name: Publish to PyPI diff --git a/CHANGELOG.md b/CHANGELOG.md index 781e0ff6..d7a65549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Change Log +## [0.12.0] - 2023-07-27 + +### Added + +- Allow users to specify encoders for custom types. ([#296](https://github.com/sdispater/tomlkit/issues/296)) + +### Fixed + +- Fix the incorrect sort when building a table with dotted keys. +- Complete the methods required for integer and float items. ([#307](https://github.com/sdispater/tomlkit/issues/307)) +- Replace the deprecated usage of `datetime.utcnow()`. ([#308](https://github.com/sdispater/tomlkit/issues/308)) +- Minor performance improvements when iterating over the escape sequences. ([#304](https://github.com/sdispater/tomlkit/issues/304)) + ## [0.11.8] - 2023-04-27 ### Fixed @@ -346,7 +359,8 @@ - Fixed handling of super tables with different sections. - Fixed raw strings escaping. -[unreleased]: https://github.com/sdispater/tomlkit/compare/0.11.8...master +[unreleased]: https://github.com/sdispater/tomlkit/compare/0.12.0...master +[0.12.0]: https://github.com/sdispater/tomlkit/releases/tag/0.12.0 [0.11.8]: https://github.com/sdispater/tomlkit/releases/tag/0.11.8 [0.11.7]: https://github.com/sdispater/tomlkit/releases/tag/0.11.7 [0.11.6]: https://github.com/sdispater/tomlkit/releases/tag/0.11.6 diff --git a/README.md b/README.md index 641e7cbc..0d4701d6 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Part of the implementation has been adapted, improved and fixed from [Molten](ht ## Usage -See the [documentation](https://github.com/sdispater/tomlkit/blob/master/docs/quickstart.rst) for more information. +See the [documentation](https://tomlkit.readthedocs.io/) for more information. ## Installation diff --git a/poetry.lock b/poetry.lock index ef441b90..21711ca4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,9 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. [[package]] name = "alabaster" version = "0.7.13" description = "A configurable sidebar-enabled Sphinx theme" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -16,7 +15,6 @@ files = [ name = "attrs" version = "22.2.0" description = "Classes Without Boilerplate" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -35,7 +33,6 @@ tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy name = "babel" version = "2.11.0" description = "Internationalization utilities" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -50,7 +47,6 @@ pytz = ">=2015.7" name = "beautifulsoup4" version = "4.11.1" description = "Screen-scraping library" -category = "dev" optional = false python-versions = ">=3.6.0" files = [ @@ -67,21 +63,19 @@ lxml = ["lxml"] [[package]] name = "certifi" -version = "2022.12.7" +version = "2023.7.22" description = "Python package for providing Mozilla's CA Bundle." -category = "dev" optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, - {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, + {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, + {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, ] [[package]] name = "cfgv" version = "3.3.1" description = "Validate configuration and produce human readable error messages." -category = "dev" optional = false python-versions = ">=3.6.1" files = [ @@ -93,7 +87,6 @@ files = [ name = "charset-normalizer" version = "2.0.12" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "dev" optional = false python-versions = ">=3.5.0" files = [ @@ -108,7 +101,6 @@ unicode-backport = ["unicodedata2"] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -120,7 +112,6 @@ files = [ name = "coverage" version = "7.1.0" description = "Code coverage measurement for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -187,7 +178,6 @@ toml = ["tomli"] name = "distlib" version = "0.3.6" description = "Distribution utilities" -category = "dev" optional = false python-versions = "*" files = [ @@ -199,7 +189,6 @@ files = [ name = "docutils" version = "0.17.1" description = "Docutils -- Python Documentation Utilities" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -211,7 +200,6 @@ files = [ name = "exceptiongroup" version = "1.1.0" description = "Backport of PEP 654 (exception groups)" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -226,7 +214,6 @@ test = ["pytest (>=6)"] name = "filelock" version = "3.9.0" description = "A platform independent file lock." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -242,7 +229,6 @@ testing = ["covdefaults (>=2.2.2)", "coverage (>=7.0.1)", "pytest (>=7.2)", "pyt name = "furo" version = "2022.9.29" description = "A clean customisable Sphinx documentation theme." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -260,7 +246,6 @@ sphinx-basic-ng = "*" name = "identify" version = "2.5.16" description = "File identification library for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -275,7 +260,6 @@ license = ["ukkonen"] name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -287,7 +271,6 @@ files = [ name = "imagesize" version = "1.4.1" description = "Getting image size from png/jpeg/jpeg2000/gif file" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -299,7 +282,6 @@ files = [ name = "importlib-metadata" version = "6.0.0" description = "Read metadata from Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -320,7 +302,6 @@ testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packag name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -332,7 +313,6 @@ files = [ name = "jinja2" version = "3.1.2" description = "A very fast and expressive template engine." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -350,7 +330,6 @@ i18n = ["Babel (>=2.7)"] name = "markupsafe" version = "2.1.2" description = "Safely add untrusted strings to HTML/XML markup." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -410,7 +389,6 @@ files = [ name = "mypy" version = "0.990" description = "Optional static typing for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -462,7 +440,6 @@ reports = ["lxml"] name = "mypy-extensions" version = "0.4.3" description = "Experimental type system extensions for programs checked with the mypy typechecker." -category = "dev" optional = false python-versions = "*" files = [ @@ -474,7 +451,6 @@ files = [ name = "nodeenv" version = "1.7.0" description = "Node.js virtual environment builder" -category = "dev" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" files = [ @@ -489,7 +465,6 @@ setuptools = "*" name = "packaging" version = "23.0" description = "Core utilities for Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -501,7 +476,6 @@ files = [ name = "platformdirs" version = "2.6.2" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -520,7 +494,6 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2)", "pytest- name = "pluggy" version = "1.0.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -539,7 +512,6 @@ testing = ["pytest", "pytest-benchmark"] name = "pre-commit" version = "2.21.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -557,14 +529,13 @@ virtualenv = ">=20.10.0" [[package]] name = "pygments" -version = "2.14.0" +version = "2.15.0" description = "Pygments is a syntax highlighting package written in Python." -category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "Pygments-2.14.0-py3-none-any.whl", hash = "sha256:fa7bd7bd2771287c0de303af8bfdfc731f51bd2c6a47ab69d117138893b82717"}, - {file = "Pygments-2.14.0.tar.gz", hash = "sha256:b3ed06a9e8ac9a9aae5a6f5dbe78a8a58655d17b43b93c078f094ddc476ae297"}, + {file = "Pygments-2.15.0-py3-none-any.whl", hash = "sha256:77a3299119af881904cd5ecd1ac6a66214b6e9bed1f2db16993b54adede64094"}, + {file = "Pygments-2.15.0.tar.gz", hash = "sha256:f7e36cffc4c517fbc252861b9a6e4644ca0e5abadf9a113c72d1358ad09b9500"}, ] [package.extras] @@ -574,7 +545,6 @@ plugins = ["importlib-metadata"] name = "pytest" version = "7.2.1" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -599,7 +569,6 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2. name = "pytest-cov" version = "4.0.0" description = "Pytest plugin for measuring coverage." -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -618,7 +587,6 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale name = "pytz" version = "2022.7.1" description = "World timezone definitions, modern and historical" -category = "dev" optional = false python-versions = "*" files = [ @@ -630,7 +598,6 @@ files = [ name = "pyyaml" version = "6.0" description = "YAML parser and emitter for Python" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -678,31 +645,29 @@ files = [ [[package]] name = "requests" -version = "2.27.1" +version = "2.31.0" description = "Python HTTP for Humans." -category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = ">=3.7" files = [ - {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, - {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, ] [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} -idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} -urllib3 = ">=1.21.1,<1.27" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" [package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<5)"] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "setuptools" version = "67.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -719,7 +684,6 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs ( name = "snowballstemmer" version = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." -category = "dev" optional = false python-versions = "*" files = [ @@ -731,7 +695,6 @@ files = [ name = "soupsieve" version = "2.3.2.post1" description = "A modern CSS selector implementation for Beautiful Soup." -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -743,7 +706,6 @@ files = [ name = "sphinx" version = "4.5.0" description = "Python documentation generator" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -779,7 +741,6 @@ test = ["cython", "html5lib", "pytest", "pytest-cov", "typed-ast"] name = "sphinx-basic-ng" version = "1.0.0b1" description = "A modern skeleton for Sphinx themes." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -797,7 +758,6 @@ docs = ["furo", "ipython", "myst-parser", "sphinx-copybutton", "sphinx-inline-ta name = "sphinxcontrib-applehelp" version = "1.0.2" description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -813,7 +773,6 @@ test = ["pytest"] name = "sphinxcontrib-devhelp" version = "1.0.2" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -829,7 +788,6 @@ test = ["pytest"] name = "sphinxcontrib-htmlhelp" version = "2.0.0" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -845,7 +803,6 @@ test = ["html5lib", "pytest"] name = "sphinxcontrib-jsmath" version = "1.0.1" description = "A sphinx extension which renders display math in HTML via JavaScript" -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -860,7 +817,6 @@ test = ["flake8", "mypy", "pytest"] name = "sphinxcontrib-qthelp" version = "1.0.3" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -876,7 +832,6 @@ test = ["pytest"] name = "sphinxcontrib-serializinghtml" version = "1.1.5" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -892,7 +847,6 @@ test = ["pytest"] name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -904,7 +858,6 @@ files = [ name = "typed-ast" version = "1.5.4" description = "a fork of Python 2 and 3 ast modules with type comment support" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -938,7 +891,6 @@ files = [ name = "typing-extensions" version = "4.4.0" description = "Backported and Experimental Type Hints for Python 3.7+" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -950,7 +902,6 @@ files = [ name = "urllib3" version = "1.26.14" description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ @@ -967,7 +918,6 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] name = "virtualenv" version = "20.17.1" description = "Virtual Python Environment builder" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -989,7 +939,6 @@ testing = ["coverage (>=6.2)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7 name = "zipp" version = "3.12.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "dev" optional = false python-versions = ">=3.7" files = [ diff --git a/pyproject.toml b/pyproject.toml index 2ce869b4..ac0807a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "tomlkit" -version = "0.11.8" +version = "0.12.0" description = "Style preserving TOML library" authors = [ "Sébastien Eustace ", diff --git a/tests/test_items.py b/tests/test_items.py index 45aea258..783cab37 100644 --- a/tests/test_items.py +++ b/tests/test_items.py @@ -6,6 +6,7 @@ from datetime import datetime from datetime import time from datetime import timedelta +from datetime import timezone import pytest @@ -103,7 +104,7 @@ def test_true_unwrap(): def test_datetime_unwrap(): - dt = datetime.utcnow() + dt = datetime.now(tz=timezone.utc) elementary_test(item(dt), datetime) @@ -946,3 +947,21 @@ def test_copy_copy(): ) def test_escape_key(key_str, escaped): assert api.key(key_str).as_string() == escaped + + +def test_custom_encoders(): + import decimal + + @api.register_encoder + def encode_decimal(obj): + if isinstance(obj, decimal.Decimal): + return api.float_(str(obj)) + raise TypeError + + assert api.item(decimal.Decimal("1.23")).as_string() == "1.23" + + with pytest.raises(TypeError): + api.item(object()) + + assert api.dumps({"foo": decimal.Decimal("1.23")}) == "foo = 1.23\n" + api.unregister_encoder(encode_decimal) diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index faadae41..eb4b3743 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -1072,3 +1072,15 @@ def test_parse_subtables_no_extra_indent(): """ doc = parse(expected) assert doc.as_string() == expected + + +def test_item_preserves_the_order(): + t = tomlkit.inline_table() + t.update({"a": 1, "b": 2}) + doc = {"name": "foo", "table": t, "age": 42} + expected = """\ +name = "foo" +table = {a = 1, b = 2} +age = 42 +""" + assert tomlkit.dumps(doc) == expected diff --git a/tomlkit/__init__.py b/tomlkit/__init__.py index acc7046c..1236d017 100644 --- a/tomlkit/__init__.py +++ b/tomlkit/__init__.py @@ -18,14 +18,16 @@ from tomlkit.api import loads from tomlkit.api import nl from tomlkit.api import parse +from tomlkit.api import register_encoder from tomlkit.api import string from tomlkit.api import table from tomlkit.api import time +from tomlkit.api import unregister_encoder from tomlkit.api import value from tomlkit.api import ws -__version__ = "0.11.8" +__version__ = "0.12.0" __all__ = [ "aot", "array", @@ -52,4 +54,6 @@ "TOMLDocument", "value", "ws", + "register_encoder", + "unregister_encoder", ] diff --git a/tomlkit/_types.py b/tomlkit/_types.py new file mode 100644 index 00000000..cc1847b5 --- /dev/null +++ b/tomlkit/_types.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from typing import Any +from typing import TypeVar + + +WT = TypeVar("WT", bound="WrapperType") + +if TYPE_CHECKING: # pragma: no cover + # Define _CustomList and _CustomDict as a workaround for: + # https://github.com/python/mypy/issues/11427 + # + # According to this issue, the typeshed contains a "lie" + # (it adds MutableSequence to the ancestry of list and MutableMapping to + # the ancestry of dict) which completely messes with the type inference for + # Table, InlineTable, Array and Container. + # + # Importing from builtins is preferred over simple assignment, see issues: + # https://github.com/python/mypy/issues/8715 + # https://github.com/python/mypy/issues/10068 + from builtins import dict as _CustomDict # noqa: N812 + from builtins import float as _CustomFloat # noqa: N812 + from builtins import int as _CustomInt # noqa: N812 + from builtins import list as _CustomList # noqa: N812 + from typing import Callable + from typing import Concatenate + from typing import ParamSpec + from typing import Protocol + + P = ParamSpec("P") + + class WrapperType(Protocol): + def _new(self: WT, value: Any) -> WT: + ... + +else: + from collections.abc import MutableMapping + from collections.abc import MutableSequence + from numbers import Integral + from numbers import Real + + class _CustomList(MutableSequence, list): + """Adds MutableSequence mixin while pretending to be a builtin list""" + + class _CustomDict(MutableMapping, dict): + """Adds MutableMapping mixin while pretending to be a builtin dict""" + + class _CustomInt(Integral, int): + """Adds Integral mixin while pretending to be a builtin int""" + + class _CustomFloat(Real, float): + """Adds Real mixin while pretending to be a builtin float""" + + +def wrap_method( + original_method: Callable[Concatenate[WT, P], Any] +) -> Callable[Concatenate[WT, P], Any]: + def wrapper(self: WT, *args: P.args, **kwargs: P.kwargs) -> Any: + result = original_method(self, *args, **kwargs) + if result is NotImplemented: + return result + return self._new(result) + + return wrapper diff --git a/tomlkit/_utils.py b/tomlkit/_utils.py index a6ed9c64..f87fd7b5 100644 --- a/tomlkit/_utils.py +++ b/tomlkit/_utils.py @@ -133,9 +133,11 @@ def flush(inc=1): return i + inc + found_sequences = {seq for seq in escape_sequences if seq in s} + i = 0 while i < len(s): - for seq in escape_sequences: + for seq in found_sequences: seq_len = len(seq) if s[i:].startswith(seq): start = flush(seq_len) diff --git a/tomlkit/api.py b/tomlkit/api.py index 8ec5653c..686fd1c0 100644 --- a/tomlkit/api.py +++ b/tomlkit/api.py @@ -1,14 +1,17 @@ from __future__ import annotations +import contextlib import datetime as _datetime from collections.abc import Mapping from typing import IO from typing import Iterable +from typing import TypeVar from tomlkit._utils import parse_rfc3339 from tomlkit.container import Container from tomlkit.exceptions import UnexpectedCharError +from tomlkit.items import CUSTOM_ENCODERS from tomlkit.items import AoT from tomlkit.items import Array from tomlkit.items import Bool @@ -16,6 +19,7 @@ from tomlkit.items import Date from tomlkit.items import DateTime from tomlkit.items import DottedKey +from tomlkit.items import Encoder from tomlkit.items import Float from tomlkit.items import InlineTable from tomlkit.items import Integer @@ -284,3 +288,21 @@ def nl() -> Whitespace: def comment(string: str) -> Comment: """Create a comment item.""" return Comment(Trivia(comment_ws=" ", comment="# " + string)) + + +E = TypeVar("E", bound=Encoder) + + +def register_encoder(encoder: E) -> E: + """Add a custom encoder, which should be a function that will be called + if the value can't otherwise be converted. It should takes a single value + and return a TOMLKit item or raise a ``TypeError``. + """ + CUSTOM_ENCODERS.append(encoder) + return encoder + + +def unregister_encoder(encoder: Encoder) -> None: + """Unregister a custom encoder.""" + with contextlib.suppress(ValueError): + CUSTOM_ENCODERS.remove(encoder) diff --git a/tomlkit/container.py b/tomlkit/container.py index 66448754..27d69170 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -6,6 +6,7 @@ from typing import Iterator from tomlkit._compat import decode +from tomlkit._types import _CustomDict from tomlkit._utils import merge_dicts from tomlkit.exceptions import KeyAlreadyPresent from tomlkit.exceptions import NonExistentKey @@ -19,7 +20,6 @@ from tomlkit.items import Table from tomlkit.items import Trivia from tomlkit.items import Whitespace -from tomlkit.items import _CustomDict from tomlkit.items import item as _item diff --git a/tomlkit/items.py b/tomlkit/items.py index 74c3e785..853754a7 100644 --- a/tomlkit/items.py +++ b/tomlkit/items.py @@ -3,8 +3,10 @@ import abc import copy import dataclasses +import math import re import string +import sys from datetime import date from datetime import datetime @@ -13,6 +15,7 @@ from enum import Enum from typing import TYPE_CHECKING from typing import Any +from typing import Callable from typing import Collection from typing import Iterable from typing import Iterator @@ -23,40 +26,31 @@ from tomlkit._compat import PY38 from tomlkit._compat import decode +from tomlkit._types import _CustomDict +from tomlkit._types import _CustomFloat +from tomlkit._types import _CustomInt +from tomlkit._types import _CustomList +from tomlkit._types import wrap_method from tomlkit._utils import CONTROL_CHARS from tomlkit._utils import escape_string from tomlkit.exceptions import InvalidStringError -if TYPE_CHECKING: # pragma: no cover - # Define _CustomList and _CustomDict as a workaround for: - # https://github.com/python/mypy/issues/11427 - # - # According to this issue, the typeshed contains a "lie" - # (it adds MutableSequence to the ancestry of list and MutableMapping to - # the ancestry of dict) which completely messes with the type inference for - # Table, InlineTable, Array and Container. - # - # Importing from builtins is preferred over simple assignment, see issues: - # https://github.com/python/mypy/issues/8715 - # https://github.com/python/mypy/issues/10068 - from builtins import dict as _CustomDict # noqa: N812, TC004 - from builtins import list as _CustomList # noqa: N812, TC004 - - # Allow type annotations but break circular imports +if TYPE_CHECKING: from tomlkit import container -else: - from collections.abc import MutableMapping - from collections.abc import MutableSequence - class _CustomList(MutableSequence, list): - """Adds MutableSequence mixin while pretending to be a builtin list""" - class _CustomDict(MutableMapping, dict): - """Adds MutableMapping mixin while pretending to be a builtin dict""" +ItemT = TypeVar("ItemT", bound="Item") +Encoder = Callable[[Any], "Item"] +CUSTOM_ENCODERS: list[Encoder] = [] +AT = TypeVar("AT", bound="AbstractTable") -ItemT = TypeVar("ItemT", bound="Item") +class _ConvertError(TypeError, ValueError): + """An internal error raised when item() fails to convert a value. + It should be a TypeError, but due to historical reasons + it needs to subclass ValueError as well. + """ @overload @@ -155,7 +149,7 @@ def item(value: Any, _parent: Item | None = None, _sort_keys: bool = False) -> I val = table_constructor(Container(), Trivia(), False) for k, v in sorted( value.items(), - key=lambda i: (isinstance(i[1], dict), i[0] if _sort_keys else 1), + key=lambda i: (isinstance(i[1], dict), i[0]) if _sort_keys else 1, ): val[k] = item(v, _parent=val, _sort_keys=_sort_keys) @@ -218,8 +212,20 @@ def item(value: Any, _parent: Item | None = None, _sort_keys: bool = False) -> I Trivia(), value.isoformat(), ) + else: + for encoder in CUSTOM_ENCODERS: + try: + rv = encoder(value) + except TypeError: + pass + else: + if not isinstance(rv, Item): + raise _ConvertError( + f"Custom encoder returned {type(rv)}, not a subclass of Item" + ) + return rv - raise ValueError(f"Invalid type {type(value)}") + raise _ConvertError(f"Invalid type {type(value)}") class StringType(Enum): @@ -434,7 +440,7 @@ def __eq__(self, other: Any) -> bool: class DottedKey(Key): def __init__( self, - keys: Iterable[Key], + keys: Iterable[SingleKey], sep: str | None = None, original: str | None = None, ) -> None: @@ -584,17 +590,17 @@ def __str__(self) -> str: return f"{self._trivia.indent}{decode(self._trivia.comment)}" -class Integer(int, Item): +class Integer(Item, _CustomInt): """ An integer literal. """ def __new__(cls, value: int, trivia: Trivia, raw: str) -> Integer: - return super().__new__(cls, value) + return int.__new__(cls, value) - def __init__(self, _: int, trivia: Trivia, raw: str) -> None: + def __init__(self, value: int, trivia: Trivia, raw: str) -> None: super().__init__(trivia) - + self._original = value self._raw = raw self._sign = False @@ -602,7 +608,9 @@ def __init__(self, _: int, trivia: Trivia, raw: str) -> None: self._sign = True def unwrap(self) -> int: - return int(self) + return self._original + + __int__ = unwrap @property def discriminant(self) -> int: @@ -616,30 +624,6 @@ def value(self) -> int: def as_string(self) -> str: return self._raw - def __add__(self, other): - result = super().__add__(other) - if result is NotImplemented: - return result - return self._new(result) - - def __radd__(self, other): - result = super().__radd__(other) - if result is NotImplemented: - return result - return self._new(result) - - def __sub__(self, other): - result = super().__sub__(other) - if result is NotImplemented: - return result - return self._new(result) - - def __rsub__(self, other): - result = super().__rsub__(other) - if result is NotImplemented: - return result - return self._new(result) - def _new(self, result): raw = str(result) if self._sign: @@ -651,18 +635,53 @@ def _new(self, result): def _getstate(self, protocol=3): return int(self), self._trivia, self._raw - -class Float(float, Item): + # int methods + __abs__ = wrap_method(int.__abs__) + __add__ = wrap_method(int.__add__) + __and__ = wrap_method(int.__and__) + __ceil__ = wrap_method(int.__ceil__) + __eq__ = int.__eq__ + __floor__ = wrap_method(int.__floor__) + __floordiv__ = wrap_method(int.__floordiv__) + __invert__ = wrap_method(int.__invert__) + __le__ = int.__le__ + __lshift__ = wrap_method(int.__lshift__) + __lt__ = int.__lt__ + __mod__ = wrap_method(int.__mod__) + __mul__ = wrap_method(int.__mul__) + __neg__ = wrap_method(int.__neg__) + __or__ = wrap_method(int.__or__) + __pos__ = wrap_method(int.__pos__) + __pow__ = wrap_method(int.__pow__) + __radd__ = wrap_method(int.__radd__) + __rand__ = wrap_method(int.__rand__) + __rfloordiv__ = wrap_method(int.__rfloordiv__) + __rlshift__ = wrap_method(int.__rlshift__) + __rmod__ = wrap_method(int.__rmod__) + __rmul__ = wrap_method(int.__rmul__) + __ror__ = wrap_method(int.__ror__) + __round__ = wrap_method(int.__round__) + __rpow__ = wrap_method(int.__rpow__) + __rrshift__ = wrap_method(int.__rrshift__) + __rshift__ = wrap_method(int.__rshift__) + __rtruediv__ = wrap_method(int.__rtruediv__) + __rxor__ = wrap_method(int.__rxor__) + __truediv__ = wrap_method(int.__truediv__) + __trunc__ = wrap_method(int.__trunc__) + __xor__ = wrap_method(int.__xor__) + + +class Float(Item, _CustomFloat): """ A float literal. """ - def __new__(cls, value: float, trivia: Trivia, raw: str) -> Integer: - return super().__new__(cls, value) + def __new__(cls, value: float, trivia: Trivia, raw: str) -> Float: + return float.__new__(cls, value) - def __init__(self, _: float, trivia: Trivia, raw: str) -> None: + def __init__(self, value: float, trivia: Trivia, raw: str) -> None: super().__init__(trivia) - + self._original = value self._raw = raw self._sign = False @@ -670,7 +689,9 @@ def __init__(self, _: float, trivia: Trivia, raw: str) -> None: self._sign = True def unwrap(self) -> float: - return float(self) + return self._original + + __float__ = unwrap @property def discriminant(self) -> int: @@ -684,32 +705,6 @@ def value(self) -> float: def as_string(self) -> str: return self._raw - def __add__(self, other): - result = super().__add__(other) - - return self._new(result) - - def __radd__(self, other): - result = super().__radd__(other) - - if isinstance(other, Float): - return self._new(result) - - return result - - def __sub__(self, other): - result = super().__sub__(other) - - return self._new(result) - - def __rsub__(self, other): - result = super().__rsub__(other) - - if isinstance(other, Float): - return self._new(result) - - return result - def _new(self, result): raw = str(result) @@ -722,6 +717,35 @@ def _new(self, result): def _getstate(self, protocol=3): return float(self), self._trivia, self._raw + # float methods + __abs__ = wrap_method(float.__abs__) + __add__ = wrap_method(float.__add__) + __eq__ = float.__eq__ + __floordiv__ = wrap_method(float.__floordiv__) + __le__ = float.__le__ + __lt__ = float.__lt__ + __mod__ = wrap_method(float.__mod__) + __mul__ = wrap_method(float.__mul__) + __neg__ = wrap_method(float.__neg__) + __pos__ = wrap_method(float.__pos__) + __pow__ = wrap_method(float.__pow__) + __radd__ = wrap_method(float.__radd__) + __rfloordiv__ = wrap_method(float.__rfloordiv__) + __rmod__ = wrap_method(float.__rmod__) + __rmul__ = wrap_method(float.__rmul__) + __round__ = wrap_method(float.__round__) + __rpow__ = wrap_method(float.__rpow__) + __rtruediv__ = wrap_method(float.__rtruediv__) + __truediv__ = wrap_method(float.__truediv__) + __trunc__ = float.__trunc__ + + if sys.version_info >= (3, 9): + __ceil__ = float.__ceil__ + __floor__ = float.__floor__ + else: + __ceil__ = math.ceil + __floor__ = math.floor + class Bool(Item): """ @@ -1388,9 +1412,6 @@ def _getstate(self, protocol=3): return list(self._iter_items()), self._trivia, self._multiline -AT = TypeVar("AT", bound="AbstractTable") - - class AbstractTable(Item, _CustomDict): """Common behaviour of both :class:`Table` and :class:`InlineTable`""" @@ -1430,11 +1451,11 @@ def append(self, key, value): raise NotImplementedError @overload - def add(self: AT, value: Comment | Whitespace) -> AT: + def add(self: AT, key: Comment | Whitespace) -> AT: ... @overload - def add(self: AT, key: Key | str, value: Any) -> AT: + def add(self: AT, key: Key | str, value: Any = ...) -> AT: ... def add(self, key, value=None): diff --git a/tomlkit/parser.py b/tomlkit/parser.py index 17e957bf..bdf0c4a2 100644 --- a/tomlkit/parser.py +++ b/tomlkit/parser.py @@ -60,7 +60,7 @@ class Parser: Parser for TOML documents. """ - def __init__(self, string: str) -> None: + def __init__(self, string: str | bytes) -> None: # Input to parse self._src = Source(decode(string)) diff --git a/tomlkit/source.py b/tomlkit/source.py index ab29854a..0e4db243 100644 --- a/tomlkit/source.py +++ b/tomlkit/source.py @@ -50,7 +50,7 @@ def __init__(self, source: Source) -> None: def __call__(self, *args, **kwargs): return _State(self._source, *args, **kwargs) - def __enter__(self) -> None: + def __enter__(self) -> _State: state = self() self._states.append(state) return state.__enter__()