diff --git a/.black.toml b/.black.toml index 9414fb7..bedc251 100644 --- a/.black.toml +++ b/.black.toml @@ -1,6 +1,6 @@ [tool.black] line-length = 120 -target-version = ['py36', 'py37', 'py38'] +target-version = ['py37', 'py38', 'py39', 'py310', 'py311'] exclude = ''' /( \.eggs diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index fafcb53..e385f04 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -1,31 +1,91 @@ -# This workflows will upload a Python Package using Twine when a release is created +# This workflow will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Python Package on: release: - types: [created] + types: [ created ] jobs: - deploy: + deploy-generic: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + - name: Set up Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: - python-version: '3.8' + python-version: '3.14' + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine --upgrade + + + - name: Build and Publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + + run: | + python setup.py sdist bdist_wheel + twine upload dist/* + + deploy-cython: + strategy: + matrix: + os: [macos-latest, windows-latest] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip - pip install setuptools wheel twine + pip install setuptools wheel twine Cython>=3.0.11 --upgrade - name: Build and publish env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | - python setup.py sdist bdist_wheel + python setup.py bdist_wheel twine upload dist/* + + deploy-cython-manylinux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Build wheels + run: | + python -m pip install --upgrade pip + pip install cibuildwheel setuptools wheel + python -m cibuildwheel --output-dir dist + env: + CIBW_BUILD: cp310-manylinux_x86_64 cp311-manylinux_x86_64 cp312-manylinux_x86_64 cp313-manylinux_x86_64 + CIBW_BEFORE_BUILD: pip install Cython>=3.0.11 setuptools wheel + CIBW_BUILD_FRONTEND: "build; args: --no-isolation" + CIBW_BEFORE_TEST: pip install -r requirements.txt -r requirements-test.txt setuptools wheel twine Cython>=3.0.11 + CIBW_BUILD_VERBOSITY: 1 + CIBW_TEST_COMMAND: pytest {package}/test -vv + + - name: Publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + pip install twine + twine upload dist/*-manylinux*.whl diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e98009f..e06da9a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,64 +5,117 @@ name: Tests on: push: - branches: [ master, development, develop, test, tests ] + branches: [ test, tests ] pull_request: branches: [ master, development, develop, test, tests ] jobs: - package_checks: - runs-on: ubuntu-latest + package-checks: strategy: matrix: - python-version: [3.8] - + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "pypy-3.11"] + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} +# - uses: actions/cache@v3 +# with: +# path: ~/.cache/pip +# key: package-check-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt pip install -r requirements-test.txt - pip install coveralls flake8 flake8-print mypy setuptools wheel twine - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors, undefined names or print statements - flake8 box --count --select=E9,F63,F7,F82,T001,T002,T003,T004 --show-source --statistics - # exit-zero treats all errors as warnings. - flake8 . --count --exit-zero --max-complexity=20 --max-line-length=120 --statistics --extend-ignore E203 + pip install coveralls mypy setuptools wheel twine Cython>=3.0.11 - name: Run mypy + if: "!startsWith(matrix.python-version, 'pypy')" run: mypy box - - name: Check distrubiton log description + - name: Build Wheel and check distribution log description run: | python setup.py sdist bdist_wheel twine check dist/* + - name: Test packaged wheel on *nix + if: matrix.os != 'windows-latest' + run: | + pip install dist/*.whl + rm -rf box + python -m pytest -vv + - name: Test packaged wheel on Windows + if: matrix.os == 'windows-latest' + run: | + $wheel = (Get-ChildItem dist\*.whl | Sort lastWriteTime | Select-Object -last 1).Name + pip install dist\${wheel} + Remove-item box -recurse -force + python -m pytest -vv + - name: Upload wheel artifact + uses: actions/upload-artifact@v4 + with: + name: python_box_${{matrix.os}}_${{ matrix.python-version }} + path: dist/*.whl - test: + package-manylinux-checks: runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + +# - uses: actions/cache@v3 +# with: +# path: ~/.cache/pip +# key: package-manylinux-check-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} + + - name: Build wheels + run: | + python -m pip install --upgrade pip + pip install cibuildwheel + python -m cibuildwheel --output-dir dist + env: + CIBW_BUILD: cp310-manylinux_x86_64 cp311-manylinux_x86_64 cp312-manylinux_x86_64 cp313-manylinux_x86_64 + CIBW_BEFORE_BUILD: pip install Cython>=3.0.11 setuptools wheel + CIBW_BUILD_FRONTEND: "build; args: --no-isolation" + CIBW_BEFORE_TEST: pip install -r requirements.txt -r requirements-test.txt setuptools wheel twine Cython>=3.0.11 + CIBW_BUILD_VERBOSITY: 1 + CIBW_TEST_COMMAND: pytest {package}/test -vv + + - name: Upload wheel artifact + uses: actions/upload-artifact@v4 + with: + name: python_box_manylinux + path: dist/*-manylinux*.whl + + test: strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9.0-rc.1, pypy3] - + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} +# - uses: actions/cache@v3 +# with: +# path: ~/.cache/pip +# key: test-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt pip install -r requirements-test.txt - pip install coveralls + pip install setuptools wheel Cython>=3.0.11 + python setup.py build_ext --inplace - name: Test with pytest env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - pytest --cov=box test/ - coveralls - + pytest --cov=box -vv test/ diff --git a/.gitignore b/.gitignore index 92026ef..a9543e8 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,10 @@ ENV/ .pypirc release.bat coverage/ +# don't upload cython files +box/*.c +.pytest_cache/ + +box/*.rst +box/LICENSE +box/*png diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9b33903..cee0904 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,21 +1,58 @@ repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.5.0 - hooks: - - id: mixed-line-ending - - id: trailing-whitespace +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + # Identify invalid files + - id: check-ast + - id: check-yaml + - id: check-json + - id: check-toml + # git checks + - id: check-merge-conflict + - id: check-added-large-files + exclude: ^test/data/.+ + - id: detect-private-key + - id: check-case-conflict + # Python checks + - id: check-docstring-first + - id: debug-statements - id: requirements-txt-fixer - id: fix-encoding-pragma - - id: check-byte-order-marker - - id: debug-statements - - id: check-yaml -- repo: https://github.com/ambv/black - rev: stable - hooks: + - id: fix-byte-order-marker + # General quality checks +# - id: mixed-line-ending +# args: [--fix=lf] + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: check-executables-have-shebangs + - id: end-of-file-fixer + exclude: ^test/data/.+ + +- repo: https://github.com/ambv/black + rev: 24.10.0 + hooks: - id: black args: [--config=.black.toml] -- repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v0.770' - hooks: + +- repo: local + hooks: + - id: cythonize-check + name: Cythonize + entry: pip install -e . + language: system + types: [python] + pass_filenames: false + # cythonize must come before the pytest to make sure we update all c code + - id: pytest-check + name: Check pytest + entry: pytest + language: system + pass_filenames: false + always_run: true + +- repo: https://github.com/pre-commit/mirrors-mypy + rev: 'v1.13.0' + hooks: - id: mypy - additional_dependencies: [ruamel.yaml,toml,msgpack] + types: [python] + additional_dependencies: [ruamel.yaml,toml,types-toml,tomli,tomli-w,msgpack,types-PyYAML] diff --git a/AUTHORS.rst b/AUTHORS.rst index 8325c5e..6b44851 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -24,6 +24,20 @@ Code contributions: - Noam Graetz (NoamGraetz2) - Fabian Affolter (fabaff) - Varun Madiath (vamega) +- Jacob Hayes (JacobHayes) +- Dominic (Yobmod) +- Ivan Pepelnjak (ipspace) +- Michał Górny (mgorny) +- Serge Lu (Serge45) +- Eric Prestat (ericpre) +- Gabriel Mitelman Tkacz (gtkacz) +- Muspi Merol (CNSeniorious000) +- YISH (mokeyish) +- Bit0r +- Jesper Schlegel (jesperschlegel) +- J vanBemmel (jbemmel) +- m-janicki + Suggestions and bug reporting: @@ -70,4 +84,17 @@ Suggestions and bug reporting: - Hitz (hitengajjar) - David Aronchick (aronchick) - Alexander Kapustin (dyens) -- Marcelo Huerta (richieadler) +- Tim Schwenke (trallnag) +- Marcos Dione (mdione-cloudian) +- Varun Madiath (vamega) +- Rexbard +- Martin Schorfmann (schorfma) +- aviveh21 +- Nishikant Parmar (nishikantparmariam) +- Peter B (barmettl) +- Ash A. (dragonpaw) +- Коптев Роман Викторович (romikforest) +- lei wang (191801737) +- d00m514y3r +- Sébastien Weber (seb5g) +- Ward Loos (wrdls) diff --git a/CHANGES.rst b/CHANGES.rst index 1e42b0c..c4a347d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,154 @@ Changelog ========= +Version 7.4.1 +------------- + +* Fixing #303 Wrong version number (7.3.3) in 7.4.0 release (thanks to Michał Górny) + +Version 7.4.0 +------------- + +* Adding #297 'box_dots_exclude' parameter to keep certain keys with dots from being broken down (thanks to J vanBemmel) +* Adding #301 support for TOON serialization format (thanks to richieadler) +* Adding support for YAML width +* Adding support for Python 3.14 +* Fixing #291 adding frozen boxes (thanks to m-janicki) +* Removing support for Python 3.9 as it is EOL + +Version 7.3.3 +------------- + +* Mistakenly released 7.4.0 as 7.3.3 in PyPI, same as above + +Version 7.3.2 +------------- + +* Fixing #288 default get value error when using box_dots (thanks to Sébastien Weber) + +Version 7.3.1 +------------- + +* Fixing #275 default_box_create_on_get is ignored with from_yaml (thanks to Ward Loos) +* Fixing #285 Infinite Recursion when accessing non existent list index in a DefaultBox with box_dots (thanks to Jesper Schlegel) + + +Version 7.3.0 +------------- + +* Adding tests and Cython releases for Python 3.13 +* Fixing #281 consistent error message about missing YAML parser (thanks to J vanBemmel) +* Removing support for Python 3.8 as it is EOL + +Version 7.2.0 +------------- + +* Adding #266 support for accessing nested items in BoxList using numpy-style tuple indexing (thanks to Bit0r) +* Adding tests and Cython releases for Python 3.12 +* Fixing #251 support for circular references in lists (thanks to Muspi Merol) +* Fixing #261 altering all `__repr__` methods so that subclassing will output the correct class name (thanks to Gabriel Tkacz) +* Fixing #267 Fix type 'int' not iterable (thanks to YISH) + +Version 7.1.1 +------------- + +* Fixing Cython optimized build deployments for linux + +Version 7.1.0 +------------- + +* Adding #255 defer ipython import for large import speed improvements (thanks to Eric Prestat) +* Adding testing for Python 3.12 +* Fixing #253 merge_update box list merge types not populated to sub dictionaries (thanks to lei wang) +* Fixing #257 Two test failures due to arguments having incorrect types (thanks to Michał Górny) +* Fixing stub files to match latest code signatures +* Removing #251 support for circular references in lists (thanks to d00m514y3r) +* Removing support for Python 3.7 as it is EOL + +Version 7.0.1 +------------- + +* Switching off of poetry due to multiple build issues + +Version 7.0.0 +------------- + +* Adding #169 default functions with the box_instance and key parameter (thanks to Коптев Роман Викторович) +* Adding #170 Be able to initialize with a flattened dict - by using DDBox (thanks to Ash A.) +* Adding #192 box_dots treats all keys with periods in them as separate keys (thanks to Rexbard) +* Adding #211 support for properties and setters in subclasses (thanks to Serge Lu and David Aronchick) +* Adding #226 namespace to track changes to the box (thanks to Jacob Hayes) +* Adding #236 iPython detection to prevent adding attribute lookup words (thanks to Nishikant Parmar) +* Adding #238 allow ``|`` and ``+`` for frozen boxes (thanks to Peter B) +* Adding new DDBox class (Default Dots Box) that is a subclass of SBox +* Adding #242 more Cython builds using cibuildwheel (thanks to Jacob Hayes) +* Fixing #235 how ``|`` and ``+`` updates were performed for right operations (thanks to aviveh21) +* Fixing #234 typos (thanks to Martin Schorfmann) +* Fixing no implicit optionals with type hinting +* Removing Cython builds for mac until we can build universal2 wheels for arm M1 macs + +Version 6.1.0 +------------- + +* Adding Python 3.11 support +* Adding #195 box_from_string function (thanks to Marcelo Huerta) +* Changing the deprecated ``toml`` package with modern ``tomllib``, ``tomli`` and ``tomli-w`` usage (thanks to Michał Górny) +* Fixing mypy __ior__ type (thanks to Jacob Hayes) +* Fixing line endings with a pre-commit update +* Fixing BoxList was using old style of `super` in internal code usage + +Version 6.0.2 +------------- + +* Fixing that the typing `pyi` files were not included in the manifest (thanks to Julian Torres) + +Version 6.0.1 +------------- + +* Fixing #218 Box dots would not raise KeyError on bad key (thanks to Cliff Wells) +* Fixing #217 wording in readme overview needed updated (thanks to Julie Jones) + +Version 6.0.0 +------------- + +* Adding Cython support to greatly speed up normal Box operations on supported systems +* Adding #161 support for access box dots with `get` and checking with `in` (thanks to scott-createplay) +* Adding #183 support for all allowed character sets (thanks to Giulio Malventi) +* Adding #196 support for sliceable boxes (thanks to Dias) +* Adding #164 default_box_create_on_get toggle to disable setting box variable on get request (thanks to ipcoder) +* Changing #208 __repr__ to produce `eval`-able text (thanks to Jeff Robbins) +* Changing #215 support ruamel.yaml new syntax (thanks to Ivan Pepelnjak) +* Changing `update` and `merge_update` to not use a keyword that could cause issues in rare circumstances +* Changing internal `_safe_key` logic to be twice as fast +* Removing support for ruamel.yaml < 0.17 + +Version 5.4.1 +------------- + +* Fixing #205 setdefault behavior with box_dots (thanks to Ivan Pepelnjak) + +Version 5.4.0 +------------- + +* Adding py.typed for mypy support (thanks to Dominic) +* Adding testing for Python 3.10-dev +* Fixing #189 by adding mappings for mypy +* Fixing setdefault behavior with box_dots (thanks to ipcoder) +* Changing #193 how magic methods are handled with default_box (thanks to Rexbard) + + +Version 5.3.0 +------------- + +* Adding support for functions to box_recast (thanks to Jacob Hayes) +* Adding #181 support for extending or adding new items to list during `merge_update` (thanks to Marcos Dione) +* Fixing maintain stacktrace cause for BoxKeyError and BoxValueError (thanks to Jacob Hayes) +* Fixing #177 that emtpy yaml files raised errors instead of returning empty objects (thanks to Tim Schwenke) +* Fixing #171 that `popitems` wasn't first checking if box was frozen (thanks to Varun Madiath) +* Changing all files to LF line endings +* Removing duplicate `box_recast` calls (thanks to Jacob Hayes) +* Removing coveralls code coverage, due to repeated issues with service + Version 5.2.0 ------------- diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 67701c9..8792eec 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -23,7 +23,7 @@ Pull Requests - Follow PEP8 -- Select to merge into `development` branch, NOT `master` +- Select to merge into `develop` branch, NOT `master` - New features should have - Reasoning for addition in pull request diff --git a/LICENSE b/LICENSE index f0c1a42..8d34381 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017-2020 Chris Griffith +Copyright (c) 2017-2026 Chris Griffith Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in index adc6e10..916f2ad 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,16 @@ include LICENSE include AUTHORS.rst include CHANGES.rst +include box/py.typed +include box/*.c +include box/*.so +include box/*.pyd +include box/*.pyi +include test/__init__.py +include test/common.py +include test/data/*.csv +include test/data/*.json +include test/data/*.msgpack +include test/data/*.tml +include test/data/*.txt +include test/data/*.yaml diff --git a/README.rst b/README.rst index 1d25e47..9fc9f31 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -|BuildStatus| |CoverageStatus| |License| +|BuildStatus| |License| |BoxImage| @@ -23,36 +23,79 @@ Check out the new `Box github wiki `_ fo Install ======= +**Version Pin Your Box!** + +If you aren't in the habit of version pinning your libraries, it will eventually bite you. +Box has a `list of breaking change `_ between major versions you should always check out before updating. + +requirements.txt +---------------- + +.. code:: text + + python-box[all]~=7.0 + +As Box adheres to semantic versioning (aka API changes will only occur on between major version), +it is best to use `Compatible release `_ matching using the `~=` clause. + +Install from command line +------------------------- + .. code:: bash - pip install --upgrade python-box[all] + python -m pip install --upgrade pip + pip install python-box[all]~=7.0 --upgrade -Box 5 is no longer forcing install of external dependencies such as yaml and toml. Instead you can specify which you want, -for example, `all` is shorthand for: +Install with selected dependencies +---------------------------------- + +Box does not install external dependencies such as yaml and toml writers. Instead you can specify which you want, +for example, `[all]` is shorthand for: .. code:: bash - pip install --upgrade python-box[ruamel.yaml,toml,msgpack] + pip install python-box[ruamel.yaml,tomli_w,msgpack]~=7.0 --upgrade -But you can also sub out "ruamel.yaml" for "PyYAML". +But you can also sub out `ruamel.yaml` for `PyYAML`. Check out `more details `_ on installation details. -Box 5 is tested on python 3.6+ and pypy3, if you are upgrading from previous versions, please look through -`any breaking changes and new features `_. +Box 7 is tested on python 3.7+, if you are upgrading from previous versions, please look through +`any breaking changes and new features `_. + +Optimized Version +----------------- +Box has introduced Cython optimizations for major platforms by default. +Loading large data sets can be up to 10x faster! + +If you are **not** on a x86_64 supported system you will need to do some extra work to install the optimized version. +There will be an warning of "WARNING: Cython not installed, could not optimize box" during install. +You will need python development files, system compiler, and the python packages `Cython` and `wheel`. + +**Linux Example:** + +First make sure you have python development files installed (`python3-dev` or `python3-devel` in most repos). +You will then need `Cython` and `wheel` installed and then install (or re-install with `--force`) `python-box`. + +.. code:: bash + + pip install Cython wheel + pip install python-box[all]~=7.0 --upgrade --force If you have any issues please open a github issue with the error you are experiencing! Overview ======== -`Box` is designed to be an easy drop in transparently replacements for -dictionaries, thanks to Python's -duck typing capabilities, which adds dot notation access. Any sub -dictionaries or ones set after initiation will be automatically converted to -a `Box` object. You can always run `.to_dict()` on it to return the object -and all sub objects back into a regular dictionary. +`Box` is designed to be a near transparent drop in replacements for +dictionaries that add dot notation access and other powerful feature. + +There are a lot of `types of boxes `_ +to customize it for your needs, as well as handy `converters `_! + +Keep in mind any sub dictionaries or ones set after initiation will be automatically converted to +a `Box` object, and lists will be converted to `BoxList`, all other objects stay intact. Check out the `Quick Start `_ for more in depth details. @@ -76,7 +119,7 @@ sure everything stored in the dict can be accessed as an attribute or key value. small_box = Box({'data': 2, 'count': 5}) small_box.data == small_box['data'] == getattr(small_box, 'data') -All dicts (and lists) added to a `Box` will be converted on lookup to a `Box` (or `BoxList`), +All dicts (and lists) added to a `Box` will be converted on insertion to a `Box` (or `BoxList`), allowing for recursive dot notation access. `Box` also includes helper functions to transform it back into a `dict`, @@ -96,15 +139,13 @@ Also special shout-out to PythonBytes_, who featured Box on their podcast. License ======= -MIT License, Copyright (c) 2017-2020 Chris Griffith. See LICENSE_ file. +MIT License, Copyright (c) 2017-2026 Chris Griffith. See LICENSE_ file. .. |BoxImage| image:: https://raw.githubusercontent.com/cdgriffith/Box/master/box_logo.png :target: https://github.com/cdgriffith/Box .. |BuildStatus| image:: https://github.com/cdgriffith/Box/workflows/Tests/badge.svg?branch=master :target: https://github.com/cdgriffith/Box/actions?query=workflow%3ATests -.. |CoverageStatus| image:: https://img.shields.io/coveralls/cdgriffith/Box/master.svg?maxAge=2592000 - :target: https://coveralls.io/r/cdgriffith/Box?branch=master .. |License| image:: https://img.shields.io/pypi/l/python-box.svg :target: https://pypi.python.org/pypi/python-box/ @@ -113,4 +154,4 @@ MIT License, Copyright (c) 2017-2020 Chris Griffith. See LICENSE_ file. .. _`Wrapt Documentation`: https://wrapt.readthedocs.io/en/latest .. _reusables: https://github.com/cdgriffith/reusables#reusables .. _created: https://github.com/cdgriffith/Reusables/commit/df20de4db74371c2fedf1578096f3e29c93ccdf3#diff-e9a0f470ef3e8afb4384dc2824943048R51 -.. _LICENSE: https://github.com/cdgriffith/Box/blob/master/LICENSE \ No newline at end of file +.. _LICENSE: https://github.com/cdgriffith/Box/blob/master/LICENSE diff --git a/box/__init__.py b/box/__init__.py index d5120d3..23ebd2e 100644 --- a/box/__init__.py +++ b/box/__init__.py @@ -2,14 +2,15 @@ # -*- coding: utf-8 -*- __author__ = "Chris Griffith" -__version__ = "5.2.0" +__version__ = "7.4.1" from box.box import Box from box.box_list import BoxList from box.config_box import ConfigBox from box.exceptions import BoxError, BoxKeyError -from box.from_file import box_from_file -from box.shorthand_box import SBox +from box.from_file import box_from_file, box_from_string +from box.shorthand_box import SBox, DDBox +import box.converters __all__ = [ "Box", @@ -19,4 +20,5 @@ "BoxKeyError", "box_from_file", "SBox", + "DDBox", ] diff --git a/box/box.py b/box/box.py index 8905a5c..8252643 100644 --- a/box/box.py +++ b/box/box.py @@ -1,18 +1,21 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- # -# Copyright (c) 2017-2020 - Chris Griffith - MIT License +# Copyright (c) 2017-2026 - Chris Griffith - MIT License """ Improved dictionary access through dot notation with additional tools. """ +from __future__ import annotations + import copy import re -import string import warnings -from collections.abc import Callable, Iterable, Mapping -from keyword import kwlist +from collections.abc import Callable, Generator, Iterable, Mapping +from inspect import signature +from keyword import iskeyword from os import PathLike -from typing import Any, Dict, Generator, List, Tuple, Union +from typing import Any, Literal + import box from box.converters import ( @@ -20,13 +23,17 @@ _from_json, _from_msgpack, _from_toml, + _from_toon, _from_yaml, _to_json, _to_msgpack, _to_toml, + _to_toon, _to_yaml, msgpack_available, - toml_available, + toon_available, + toml_read_library, + toml_write_library, yaml_available, ) from box.exceptions import BoxError, BoxKeyError, BoxTypeError, BoxValueError, BoxWarning @@ -40,6 +47,29 @@ # a sentinel object for indicating no default, in order to allow users # to pass `None` as a valid default value NO_DEFAULT = object() +# a sentinel object for indicating when to skip adding a new namespace, allowing `None` keys +NO_NAMESPACE = object() + + +def _is_ipython(): + try: + from IPython import get_ipython + except ImportError: + ipython = False + else: + ipython = True if get_ipython() else False + + return ipython + + +def _exception_cause(e): + """ + Unwrap BoxKeyError and BoxValueError errors to their cause. + + Use with `raise ... from _exception_cause(err)` to avoid deeply nested stacktraces, but keep the + context. + """ + return e.__cause__ if isinstance(e, (BoxKeyError, BoxValueError)) else e def _camel_killer(attr): @@ -72,13 +102,34 @@ def _parse_box_dots(bx, item, setting=False): if char == "[": return item[:idx], item[idx:] elif char == ".": - if item[:idx] in bx: - return item[:idx], item[idx + 1 :] + return item[:idx], item[idx + 1 :] if setting and "." in item: return item.split(".", 1) raise BoxError("Could not split box dots properly") +def _get_dot_paths(bx, current=""): + """A generator of all the end node keys in a box in box_dots format""" + + def handle_dicts(sub_bx, paths=""): + for key, value in sub_bx.items(): + yield f"{paths}.{key}" if paths else key + if isinstance(value, dict): + yield from handle_dicts(value, f"{paths}.{key}" if paths else key) + elif isinstance(value, list): + yield from handle_lists(value, f"{paths}.{key}" if paths else key) + + def handle_lists(bx_list, paths=""): + for i, value in enumerate(bx_list): + yield f"{paths}[{i}]" + if isinstance(value, list): + yield from handle_lists(value, f"{paths}[{i}]") + if isinstance(value, dict): + yield from handle_dicts(value, f"{paths}[{i}]") + + yield from handle_dicts(bx, current) + + def _get_box_config(): return { # Internal use only @@ -87,6 +138,22 @@ def _get_box_config(): } +def _get_property_func(obj, key): + """ + Try to get property helper functions of given object and property name. + + :param obj: object to be checked for property + :param key: property name + :return: a tuple for helper functions(fget, fset, fdel). If no such property, a (None, None, None) returns + """ + obj_type = type(obj) + + if not hasattr(obj_type, key): + return None, None, None + attr = getattr(obj_type, key) + return attr.fget, attr.fset, attr.fdel + + class Box(dict): """ Improved dictionary access through dot notation with additional tools. @@ -95,6 +162,7 @@ class Box(dict): :param default_box_attr: Specify the default replacement. WARNING: If this is not the default 'Box', it will not be recursive :param default_box_none_transform: When using default_box, treat keys with none values as absent. True by default + :param default_box_create_on_get: On lookup of a key that doesn't exist, create it if missing :param frozen_box: After creation, the box cannot be modified :param camel_killer_box: Convert CamelCase to snake_case :param conversion_box: Check for near matching keys as attributes @@ -104,10 +172,12 @@ class Box(dict): :param box_intact_types: tuple of types to ignore converting :param box_recast: cast certain keys to a specified type :param box_dots: access nested Boxes by period separated keys in string + :param box_dots_exclude: optional regular expression for dotted keys to exclude :param box_class: change what type of class sub-boxes will be created as + :param box_namespace: the namespace this (possibly nested) Box lives within """ - _box_config: Dict[str, Any] + _box_config: dict[str, Any] _protected_keys = [ "to_dict", @@ -126,16 +196,19 @@ def __new__( default_box: bool = False, default_box_attr: Any = NO_DEFAULT, default_box_none_transform: bool = True, + default_box_create_on_get: bool = True, frozen_box: bool = False, camel_killer_box: bool = False, conversion_box: bool = True, modify_tuples_box: bool = False, box_safe_prefix: str = "x", box_duplicates: str = "ignore", - box_intact_types: Union[Tuple, List] = (), - box_recast: Dict = None, + box_intact_types: tuple | list = (), + box_recast: dict | None = None, box_dots: bool = False, - box_class: Union[Dict, "Box"] = None, + box_dots_exclude: str | None = None, + box_class: dict | type[Box] | None = None, + box_namespace: tuple[str, ...] | Literal[False] = (), **kwargs: Any, ): """ @@ -149,6 +222,7 @@ def __new__( "default_box": default_box, "default_box_attr": cls.__class__ if default_box_attr is NO_DEFAULT else default_box_attr, "default_box_none_transform": default_box_none_transform, + "default_box_create_on_get": default_box_create_on_get, "conversion_box": conversion_box, "box_safe_prefix": box_safe_prefix, "frozen_box": frozen_box, @@ -158,7 +232,9 @@ def __new__( "box_intact_types": tuple(box_intact_types), "box_recast": box_recast, "box_dots": box_dots, + "box_dots_exclude": re.compile(box_dots_exclude) if box_dots_exclude else None, "box_class": box_class if box_class is not None else Box, + "box_namespace": box_namespace, } ) return obj @@ -169,16 +245,19 @@ def __init__( default_box: bool = False, default_box_attr: Any = NO_DEFAULT, default_box_none_transform: bool = True, + default_box_create_on_get: bool = True, frozen_box: bool = False, camel_killer_box: bool = False, conversion_box: bool = True, modify_tuples_box: bool = False, box_safe_prefix: str = "x", box_duplicates: str = "ignore", - box_intact_types: Union[Tuple, List] = (), - box_recast: Dict = None, + box_intact_types: tuple | list = (), + box_recast: dict | None = None, box_dots: bool = False, - box_class: Union[Dict, "Box"] = None, + box_dots_exclude: str | None = None, + box_class: dict | type[Box] | None = None, + box_namespace: tuple[str, ...] | Literal[False] = (), **kwargs: Any, ): super().__init__() @@ -188,6 +267,7 @@ def __init__( "default_box": default_box, "default_box_attr": self.__class__ if default_box_attr is NO_DEFAULT else default_box_attr, "default_box_none_transform": default_box_none_transform, + "default_box_create_on_get": default_box_create_on_get, "conversion_box": conversion_box, "box_safe_prefix": box_safe_prefix, "frozen_box": frozen_box, @@ -197,7 +277,9 @@ def __init__( "box_intact_types": tuple(box_intact_types), "box_recast": box_recast, "box_dots": box_dots, + "box_dots_exclude": re.compile(box_dots_exclude) if box_dots_exclude else None, "box_class": box_class if box_class is not None else self.__class__, + "box_namespace": box_namespace, } ) if not self._box_config["conversion_box"] and self._box_config["box_duplicates"] != "ignore": @@ -227,50 +309,61 @@ def __init__( self._box_config["__created"] = True - def __add__(self, other: dict): + def __add__(self, other: Mapping[Any, Any]): if not isinstance(other, dict): raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") new_box = self.copy() - new_box.merge_update(other) + new_box.merge_update(other, _force_unfrozen=True) # type: ignore[attr-defined] return new_box - def __radd__(self, other: dict): + def __radd__(self, other: Mapping[Any, Any]): if not isinstance(other, dict): raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") - new_box = self.copy() - new_box.merge_update(other) + + new_box = other.copy() + if not isinstance(other, Box): + new_box = self._box_config["box_class"](new_box) + new_box.merge_update(self, _force_unfrozen=True) # type: ignore[attr-defined] + new_box._box_config["frozen_box"] = self._box_config["frozen_box"] # type: ignore[attr-defined] return new_box - def __iadd__(self, other: dict): + def __iadd__(self, other: Mapping[Any, Any]): if not isinstance(other, dict): raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") self.merge_update(other) return self - def __or__(self, other: dict): + def __or__(self, other: Mapping[Any, Any]): if not isinstance(other, dict): raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") new_box = self.copy() - new_box.update(other) + new_box._box_config["frozen_box"] = False + new_box.update(other) # type: ignore[attr-defined] + new_box._box_config["frozen_box"] = self._box_config["frozen_box"] return new_box - def __ror__(self, other: dict): + def __ror__(self, other: Mapping[Any, Any]): if not isinstance(other, dict): raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") - new_box = self.copy() - new_box.update(other) + new_box = other.copy() + if not isinstance(other, Box): + new_box = self._box_config["box_class"](new_box) + new_box._box_config["frozen_box"] = False # type: ignore[attr-defined] + new_box.update(self) # type: ignore[attr-defined] + new_box._box_config["frozen_box"] = self._box_config["frozen_box"] # type: ignore[attr-defined] return new_box - def __ior__(self, other: dict): + def __ior__(self, other: Mapping[Any, Any]): # type: ignore[override] if not isinstance(other, dict): raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") self.update(other) return self - def __sub__(self, other: dict): + def __sub__(self, other: Mapping[Any, Any]): frozen = self._box_config["frozen_box"] config = self.__box_config() config["frozen_box"] = False + config.pop("box_namespace") # Detach namespace; it will be reassigned if we nest again output = self._box_config["box_class"](**config) if not isinstance(other, dict): raise BoxError("Box can only compare two boxes or a box and a dictionary.") @@ -294,18 +387,13 @@ def __hash__(self): return hashing raise BoxTypeError('unhashable type: "Box"') - def __dir__(self): - allowed = string.ascii_letters + string.digits + "_" + def __dir__(self) -> list[str]: items = set(super().__dir__()) # Only show items accessible by dot notation for key in self.keys(): key = str(key) - if " " not in key and key[0] not in string.digits and key not in kwlist: - for letter in key: - if letter not in allowed: - break - else: - items.add(key) + if key.isidentifier() and not iskeyword(key): + items.add(key) for key in self.keys(): if key not in items: @@ -316,7 +404,25 @@ def __dir__(self): return list(items) - def keys(self, dotted: Union[bool] = False): + def __contains__(self, item): + in_me = super().__contains__(item) + if not self._box_config["box_dots"] or not isinstance(item, str): + return in_me + if in_me: + return True + if "." not in item: + return False + try: + first_item, children = _parse_box_dots(self, item) + except BoxError: + return False + else: + if not super().__contains__(first_item): + return False + it = self[first_item] + return isinstance(it, Iterable) and children in it + + def keys(self, dotted: bool = False): if not dotted: return super().keys() @@ -339,7 +445,7 @@ def keys(self, dotted: Union[bool] = False): keys.add(key) return sorted(keys, key=lambda x: str(x)) - def items(self, dotted: Union[bool] = False): + def items(self, dotted: bool = False): if not dotted: return super().items() @@ -362,13 +468,15 @@ def get(self, key, default=NO_DEFAULT): return default return self[key] - def copy(self) -> "Box": - return Box(super().copy(), **self.__box_config()) + def copy(self) -> Box: + config = self.__box_config() + config.pop("box_namespace") # Detach namespace; it will be reassigned if we nest again + return Box(super().copy(), **config) - def __copy__(self) -> "Box": - return Box(super().copy(), **self.__box_config()) + def __copy__(self) -> Box: + return self.copy() - def __deepcopy__(self, memodict=None) -> "Box": + def __deepcopy__(self, memodict=None) -> Box: frozen = self._box_config["frozen_box"] config = self.__box_config() config["frozen_box"] = False @@ -384,40 +492,84 @@ def __setstate__(self, state): self._box_config = state["_box_config"] self.__dict__.update(state) + def __process_dotted_key(self, item): + if self._box_config["box_dots"] and isinstance(item, str): + return ("[" in item) or ( + "." in item + and not (self._box_config["box_dots_exclude"] and self._box_config["box_dots_exclude"].match(item)) + ) + return False + def __get_default(self, item, attr=False): + if item in ("getdoc", "shape") and _is_ipython(): + return None default_value = self._box_config["default_box_attr"] if default_value in (self._box_config["box_class"], dict): - value = self._box_config["box_class"](**self.__box_config()) + value = self._box_config["box_class"](**self.__box_config(extra_namespace=item)) elif isinstance(default_value, dict): - value = self._box_config["box_class"](**self.__box_config(), **default_value) + value = self._box_config["box_class"](**self.__box_config(extra_namespace=item), **default_value) elif isinstance(default_value, list): - value = box.BoxList(**self.__box_config()) + value = box.BoxList(**self.__box_config(extra_namespace=item)) elif isinstance(default_value, Callable): - value = default_value() + args = [] + kwargs = {} + p_sigs = [ + p.name + for p in signature(default_value).parameters.values() + if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) + ] + k_sigs = [p.name for p in signature(default_value).parameters.values() if p.kind is p.KEYWORD_ONLY] + for name in p_sigs: + if name not in ("key", "box_instance"): + raise BoxError("default_box_attr can only have the arguments 'key' and 'box_instance'") + if "key" in p_sigs: + args.append(item) + if "box_instance" in p_sigs: + args.insert(p_sigs.index("box_instance"), self) + if "key" in k_sigs: + kwargs["key"] = item + if "box_instance" in k_sigs: + kwargs["box_instance"] = self + value = default_value(*args, **kwargs) elif hasattr(default_value, "copy"): value = default_value.copy() else: value = default_value - if not attr or (not item.startswith("_") and not item.endswith("_")): - super().__setitem__(item, value) + if self._box_config["default_box_create_on_get"]: + if not attr or not (item.startswith("_") and item.endswith("_")): + if self.__process_dotted_key(item): + first_item, children = _parse_box_dots(self, item, setting=True) + if first_item in self.keys(): + if hasattr(self[first_item], "__setitem__"): + self[first_item].__setitem__(children, value) + else: + super().__setitem__( + first_item, self._box_config["box_class"](**self.__box_config(extra_namespace=first_item)) + ) + self[first_item].__setitem__(children, value) + else: + super().__setitem__(item, value) return value - def __box_config(self) -> Dict: + def __box_config(self, extra_namespace: Any = NO_NAMESPACE) -> dict: out = {} for k, v in self._box_config.copy().items(): if not k.startswith("__"): out[k] = v + if extra_namespace is not NO_NAMESPACE and self._box_config["box_namespace"] is not False: + out["box_namespace"] = (*out["box_namespace"], extra_namespace) return out def __recast(self, item, value): if self._box_config["box_recast"] and item in self._box_config["box_recast"]: + recast = self._box_config["box_recast"][item] try: - if issubclass(self._box_config["box_recast"][item], (Box, box.BoxList)): - return self._box_config["box_recast"][item](value, **self.__box_config()) + if isinstance(recast, type) and issubclass(recast, (Box, box.BoxList)): + return recast(value, **self.__box_config()) else: - return self._box_config["box_recast"][item](value) - except ValueError: - raise BoxValueError(f'Cannot convert {value} to {self._box_config["box_recast"][item]}') from None + return recast(value) + except ValueError as err: + raise BoxValueError(f"Cannot convert {value} to {recast}") from _exception_cause(err) return value def __convert_and_store(self, item, value): @@ -432,18 +584,20 @@ def __convert_and_store(self, item, value): # This is the magic sauce that makes sub dictionaries into new box objects if isinstance(value, dict): # We always re-create even if it was already a Box object to pass down configurations correctly - value = self._box_config["box_class"](value, **self.__box_config()) + value = self._box_config["box_class"](value, **self.__box_config(extra_namespace=item)) elif isinstance(value, list) and not isinstance(value, box.BoxList): if self._box_config["frozen_box"]: value = _recursive_tuples( - value, recreate_tuples=self._box_config["modify_tuples_box"], **self.__box_config() + value, + recreate_tuples=self._box_config["modify_tuples_box"], + **self.__box_config(extra_namespace=item), ) else: - value = box.BoxList(value, **self.__box_config()) + value = box.BoxList(value, **self.__box_config(extra_namespace=item)) elif isinstance(value, box.BoxList): - value.box_options.update(self.__box_config()) + value.box_options.update(self.__box_config(extra_namespace=item)) elif self._box_config["modify_tuples_box"] and isinstance(value, tuple): - value = _recursive_tuples(value, recreate_tuples=True, **self.__box_config()) + value = _recursive_tuples(value, recreate_tuples=True, **self.__box_config(extra_namespace=item)) super().__setitem__(item, value) def __getitem__(self, item, _ignore_default=False): @@ -451,14 +605,21 @@ def __getitem__(self, item, _ignore_default=False): return super().__getitem__(item) except KeyError as err: if item == "_box_config": - raise BoxKeyError("_box_config should only exist as an attribute and is never defaulted") from None - if self._box_config["box_dots"] and isinstance(item, str) and ("." in item or "[" in item): + cause = _exception_cause(err) + raise BoxKeyError("_box_config should only exist as an attribute and is never defaulted") from cause + if isinstance(item, slice): + # In Python 3.12 this changes to a KeyError instead of TypeError + new_box = self._box_config["box_class"](**self.__box_config()) + for x in list(super().keys())[item.start : item.stop : item.step]: + new_box[x] = self[x] + return new_box + if self.__process_dotted_key(item): try: first_item, children = _parse_box_dots(self, item) except BoxError: if self._box_config["default_box"] and not _ignore_default: return self.__get_default(item) - raise + raise BoxKeyError(str(item)) from _exception_cause(err) if first_item in self.keys(): if hasattr(self[first_item], "__getitem__"): return self[first_item][children] @@ -468,7 +629,14 @@ def __getitem__(self, item, _ignore_default=False): return super().__getitem__(converted) if self._box_config["default_box"] and not _ignore_default: return self.__get_default(item) - raise BoxKeyError(str(err)) from None + raise BoxKeyError(str(err)) from _exception_cause(err) + except TypeError as err: + if isinstance(item, slice): + new_box = self._box_config["box_class"](**self.__box_config()) + for x in list(super().keys())[item.start : item.stop : item.step]: + new_box[x] = self[x] + return new_box + raise BoxTypeError(str(err)) from _exception_cause(err) def __getattr__(self, item): try: @@ -478,26 +646,38 @@ def __getattr__(self, item): value = object.__getattribute__(self, item) except AttributeError as err: if item == "__getstate__": - raise BoxKeyError(item) from None + raise BoxKeyError(item) from _exception_cause(err) if item == "_box_config": - raise BoxError("_box_config key must exist") from None + raise BoxError("_box_config key must exist") from _exception_cause(err) if self._box_config["conversion_box"]: safe_key = self._safe_attr(item) if safe_key in self._box_config["__safe_keys"]: return self.__getitem__(self._box_config["__safe_keys"][safe_key]) if self._box_config["default_box"]: + if item.startswith("_") and item.endswith("_"): + raise BoxKeyError(f"{item}: Does not exist and internal methods are never defaulted") return self.__get_default(item, attr=True) - raise BoxKeyError(str(err)) from None + raise BoxKeyError(str(err)) from _exception_cause(err) return value def __setitem__(self, key, value): - if key != "_box_config" and self._box_config["__created"] and self._box_config["frozen_box"]: + if key != "_box_config" and self._box_config["frozen_box"] and self._box_config["__created"]: raise BoxError("Box is frozen") - if self._box_config["box_dots"] and isinstance(key, str) and ("." in key or "[" in key): + if self.__process_dotted_key(key): first_item, children = _parse_box_dots(self, key, setting=True) if first_item in self.keys(): if hasattr(self[first_item], "__setitem__"): return self[first_item].__setitem__(children, value) + elif self._box_config["default_box"]: + if children[0] == "[": + super().__setitem__(first_item, box.BoxList(**self.__box_config(extra_namespace=first_item))) + else: + super().__setitem__( + first_item, self._box_config["box_class"](**self.__box_config(extra_namespace=first_item)) + ) + return self[first_item].__setitem__(children, value) + else: + raise BoxKeyError(f"'{self.__class__}' object has no attribute {first_item}") value = self.__recast(key, value) if key not in self.keys() and self._box_config["camel_killer_box"]: if self._box_config["camel_killer_box"] and isinstance(key, str): @@ -507,28 +687,31 @@ def __setitem__(self, key, value): self.__convert_and_store(key, value) def __setattr__(self, key, value): - if key != "_box_config" and self._box_config["frozen_box"] and self._box_config["__created"]: + if key == "_box_config": + return object.__setattr__(self, key, value) + if self._box_config["frozen_box"] and self._box_config["__created"]: raise BoxError("Box is frozen") if key in self._protected_keys: raise BoxKeyError(f'Key name "{key}" is protected') - if key == "_box_config": - return object.__setattr__(self, key, value) - value = self.__recast(key, value) + safe_key = self._safe_attr(key) if safe_key in self._box_config["__safe_keys"]: key = self._box_config["__safe_keys"][safe_key] - self.__setitem__(key, value) + + # if user has customized property setter, fall back to default implementation + if _get_property_func(self, key)[1] is not None: + super().__setattr__(key, value) + else: + self.__setitem__(key, value) def __delitem__(self, key): if self._box_config["frozen_box"]: raise BoxError("Box is frozen") - if ( - key not in self.keys() - and self._box_config["box_dots"] - and isinstance(key, str) - and ("." in key or "[" in key) - ): - first_item, children = _parse_box_dots(self, key) + if key not in self.keys() and self.__process_dotted_key(key): + try: + first_item, children = _parse_box_dots(self, key) + except BoxError: + raise BoxKeyError(str(key)) from None if hasattr(self[first_item], "__delitem__"): return self[first_item].__delitem__(children) if key not in self.keys() and self._box_config["camel_killer_box"]: @@ -540,7 +723,7 @@ def __delitem__(self, key): try: super().__delitem__(key) except KeyError as err: - raise BoxKeyError(str(err)) from None + raise BoxKeyError(str(err)) from _exception_cause(err) def __delattr__(self, item): if self._box_config["frozen_box"]: @@ -549,6 +732,13 @@ def __delattr__(self, item): raise BoxError('"_box_config" is protected') if item in self._protected_keys: raise BoxKeyError(f'Key name "{item}" is protected') + + property_fdel = _get_property_func(self, item)[2] + + # if user has customized property deleter, route to it + if property_fdel is not None: + property_fdel(self) + return try: self.__delitem__(item) except KeyError as err: @@ -558,7 +748,7 @@ def __delattr__(self, item): self.__delitem__(self._box_config["__safe_keys"][safe_key]) del self._box_config["__safe_keys"][safe_key] return - raise BoxKeyError(str(err)) from None + raise BoxKeyError(str(err)) from _exception_cause(err) def pop(self, key, *args): if self._box_config["frozen_box"]: @@ -589,6 +779,8 @@ def clear(self): self._box_config["__safe_keys"].clear() def popitem(self): + if self._box_config["frozen_box"]: + raise BoxError("Box is frozen") try: key = next(self.__iter__()) except StopIteration: @@ -596,20 +788,20 @@ def popitem(self): return key, self.pop(key) def __repr__(self) -> str: - return f"" + return f"{self.__class__.__name__}({self})" def __str__(self) -> str: return str(self.to_dict()) - def __iter__(self) -> Generator: + def __iter__(self) -> Generator: # type: ignore[type-arg] for key in self.keys(): yield key - def __reversed__(self) -> Generator: + def __reversed__(self) -> Generator: # type: ignore[type-arg] for key in reversed(list(self.keys())): yield key - def to_dict(self) -> Dict: + def to_dict(self) -> dict: """ Turn the Box and sub Boxes back into a native python dictionary. @@ -625,58 +817,96 @@ def to_dict(self) -> Dict: out_dict[k] = v.to_list() return out_dict - def update(self, __m=None, **kwargs): + def update(self, *args, **kwargs): if self._box_config["frozen_box"]: raise BoxError("Box is frozen") - - if __m: - if hasattr(__m, "keys"): - for k in __m: - self.__convert_and_store(k, __m[k]) + if (len(args) + int(bool(kwargs))) > 1: + raise BoxTypeError(f"update expected at most 1 argument, got {len(args) + int(bool(kwargs))}") + single_arg = next(iter(args), None) + if single_arg: + if hasattr(single_arg, "keys"): + for k in single_arg: + self.__convert_and_store(k, single_arg[k]) else: - for k, v in __m: + for k, v in single_arg: self.__convert_and_store(k, v) for k in kwargs: self.__convert_and_store(k, kwargs[k]) - def merge_update(self, __m=None, **kwargs): - def convert_and_set(k, v): - intact_type = self._box_config["box_intact_types"] and isinstance(v, self._box_config["box_intact_types"]) - if isinstance(v, dict) and not intact_type: - # Box objects must be created in case they are already - # in the `converted` box_config set - v = self._box_config["box_class"](v, **self.__box_config()) - if k in self and isinstance(self[k], dict): - self[k].merge_update(v) - return - if isinstance(v, list) and not intact_type: - v = box.BoxList(v, **self.__box_config()) - self.__setitem__(k, v) + def merge_update(self, *args, **kwargs): + merge_type = None + if "box_merge_lists" in kwargs: + merge_type = kwargs.pop("box_merge_lists") + force_unfrozen = kwargs.pop("_force_unfrozen", False) - if __m: - if hasattr(__m, "keys"): - for key in __m: - convert_and_set(key, __m[key]) - else: - for key, value in __m: - convert_and_set(key, value) - for key in kwargs: - convert_and_set(key, kwargs[key]) + was_frozen = self._box_config["frozen_box"] + if force_unfrozen: + self._box_config["frozen_box"] = False + + try: + + def convert_and_set(k, v): + intact_type = self._box_config["box_intact_types"] and isinstance( + v, self._box_config["box_intact_types"] + ) + if isinstance(v, dict) and not intact_type: + # Box objects must be created in case they are already + # in the `converted` box_config set + v = self._box_config["box_class"](v, **self.__box_config(extra_namespace=k)) + if k in self and isinstance(self[k], dict): + self[k].merge_update(v, box_merge_lists=merge_type, _force_unfrozen=force_unfrozen) + return + if isinstance(v, list) and not intact_type: + v = box.BoxList(v, **self.__box_config(extra_namespace=k)) + if merge_type == "extend" and k in self and isinstance(self[k], list): + self[k].extend(v) + return + if merge_type == "unique" and k in self and isinstance(self[k], list): + for item in v: + if item not in self[k]: + self[k].append(item) + return + self.__setitem__(k, v) + + if (len(args) + int(bool(kwargs))) > 1: + raise BoxTypeError(f"merge_update expected at most 1 argument, got {len(args) + int(bool(kwargs))}") + single_arg = next(iter(args), None) + if single_arg: + if hasattr(single_arg, "keys"): + for k in single_arg: + convert_and_set(k, single_arg[k]) + else: + for k, v in single_arg: + convert_and_set(k, v) + + for key in kwargs: + convert_and_set(key, kwargs[key]) + + finally: + if force_unfrozen: + self._box_config["frozen_box"] = was_frozen def setdefault(self, item, default=None): if item in self: return self[item] + if self._box_config["box_dots"]: + if item in _get_dot_paths(self): + return self[item] + if isinstance(default, dict): - default = self._box_config["box_class"](default, **self.__box_config()) + default = self._box_config["box_class"](default, **self.__box_config(extra_namespace=item)) if isinstance(default, list): - default = box.BoxList(default, **self.__box_config()) + default = box.BoxList(default, **self.__box_config(extra_namespace=item)) self[item] = default return self[item] def _safe_attr(self, attr): """Convert a key into something that is accessible as an attribute""" - allowed = string.ascii_letters + string.digits + "_" + if isinstance(attr, str): + # By assuming most people are using string first we get substantial speed ups + if attr.isidentifier() and not iskeyword(attr): + return attr if isinstance(attr, tuple): attr = "_".join([str(x) for x in attr]) @@ -685,10 +915,18 @@ def _safe_attr(self, attr): if self.__box_config()["camel_killer_box"]: attr = _camel_killer(attr) + if attr.isidentifier() and not iskeyword(attr): + return attr + + if sum(1 for character in attr if character.isidentifier() and not iskeyword(character)) == 0: + attr = f'{self.__box_config()["box_safe_prefix"]}{attr}' + if attr.isidentifier() and not iskeyword(attr): + return attr + out = [] last_safe = 0 for i, character in enumerate(attr): - if character in allowed: + if f"x{character}".isidentifier(): last_safe = i out.append(character) elif not out: @@ -706,7 +944,7 @@ def _safe_attr(self, attr): else: out = f'{self.__box_config()["box_safe_prefix"]}{out}' - if out in kwlist: + if iskeyword(out): out = f'{self.__box_config()["box_safe_prefix"]}{out}' return out @@ -727,7 +965,11 @@ def _conversion_checks(self, item): raise BoxError(f"Duplicate conversion attributes exist: {dups}") def to_json( - self, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict", **json_kwargs + self, + filename: str | PathLike | None = None, + encoding: str = "utf-8", + errors: str = "strict", + **json_kwargs, ): """ Transform the Box object into a JSON string. @@ -743,12 +985,12 @@ def to_json( @classmethod def from_json( cls, - json_string: str = None, - filename: Union[str, PathLike] = None, + json_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, - ) -> "Box": + ) -> Box: """ Transform a json object string into a Box object. If the incoming json is a list, you must use BoxList.from_json. @@ -775,10 +1017,11 @@ def from_json( def to_yaml( self, - filename: Union[str, PathLike] = None, + filename: str | PathLike | None = None, default_flow_style: bool = False, encoding: str = "utf-8", errors: str = "strict", + width: int = 120, **yaml_kwargs, ): """ @@ -788,6 +1031,7 @@ def to_yaml( :param default_flow_style: False will recursively dump dicts :param encoding: File encoding :param errors: How to handle encoding errors + :param width: Line width for YAML output :param yaml_kwargs: additional arguments to pass to yaml.dump :return: string of YAML (if no filename provided) """ @@ -797,18 +1041,19 @@ def to_yaml( default_flow_style=default_flow_style, encoding=encoding, errors=errors, + width=width, **yaml_kwargs, ) @classmethod def from_yaml( cls, - yaml_string: str = None, - filename: Union[str, PathLike] = None, + yaml_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, - ) -> "Box": + ) -> Box: """ Transform a yaml object string into a Box object. By default will use SafeLoader. @@ -825,6 +1070,8 @@ def from_yaml( box_args[arg] = kwargs.pop(arg) data = _from_yaml(yaml_string=yaml_string, filename=filename, encoding=encoding, errors=errors, **kwargs) + if not data: + return cls(**box_args) if not isinstance(data, dict): raise BoxError(f"yaml data not returned as a dictionary but rather a {type(data).__name__}") return cls(data, **box_args) @@ -833,10 +1080,11 @@ def from_yaml( def to_yaml( self, - filename: Union[str, PathLike] = None, + filename: str | PathLike | None = None, default_flow_style: bool = False, encoding: str = "utf-8", errors: str = "strict", + width: int = 120, **yaml_kwargs, ): raise BoxError('yaml is unavailable on this system, please install the "ruamel.yaml" or "PyYAML" package') @@ -844,17 +1092,17 @@ def to_yaml( @classmethod def from_yaml( cls, - yaml_string: str = None, - filename: Union[str, PathLike] = None, + yaml_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, - ) -> "Box": + ) -> Box: raise BoxError('yaml is unavailable on this system, please install the "ruamel.yaml" or "PyYAML" package') - if toml_available: + if toml_write_library is not None: - def to_toml(self, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict"): + def to_toml(self, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict"): """ Transform the Box object into a toml string. @@ -865,15 +1113,22 @@ def to_toml(self, filename: Union[str, PathLike] = None, encoding: str = "utf-8" """ return _to_toml(self.to_dict(), filename=filename, encoding=encoding, errors=errors) + else: + + def to_toml(self, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict"): + raise BoxError('toml is unavailable on this system, please install the "tomli-w" package') + + if toml_read_library is not None: + @classmethod def from_toml( cls, - toml_string: str = None, - filename: Union[str, PathLike] = None, + toml_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, - ) -> "Box": + ) -> Box: """ Transforms a toml string or file into a Box object @@ -894,23 +1149,20 @@ def from_toml( else: - def to_toml(self, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict"): - raise BoxError('toml is unavailable on this system, please install the "toml" package') - @classmethod def from_toml( cls, - toml_string: str = None, - filename: Union[str, PathLike] = None, + toml_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, - ) -> "Box": - raise BoxError('toml is unavailable on this system, please install the "toml" package') + ) -> Box: + raise BoxError('toml is unavailable on this system, please install the "tomli" package') if msgpack_available: - def to_msgpack(self, filename: Union[str, PathLike] = None, **kwargs): + def to_msgpack(self, filename: str | PathLike | None = None, **kwargs): """ Transform the Box object into a msgpack string. @@ -923,10 +1175,10 @@ def to_msgpack(self, filename: Union[str, PathLike] = None, **kwargs): @classmethod def from_msgpack( cls, - msgpack_bytes: bytes = None, - filename: Union[str, PathLike] = None, + msgpack_bytes: bytes | None = None, + filename: str | PathLike | None = None, **kwargs, - ) -> "Box": + ) -> Box: """ Transforms msgpack bytes or file into a Box object @@ -947,16 +1199,79 @@ def from_msgpack( else: - def to_msgpack(self, filename: Union[str, PathLike] = None, **kwargs): + def to_msgpack(self, filename: str | PathLike | None = None, **kwargs): raise BoxError('msgpack is unavailable on this system, please install the "msgpack" package') @classmethod def from_msgpack( cls, - msgpack_bytes: bytes = None, - filename: Union[str, PathLike] = None, + msgpack_bytes: bytes | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, - ) -> "Box": + ) -> Box: raise BoxError('msgpack is unavailable on this system, please install the "msgpack" package') + + if toon_available: + + def to_toon( + self, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs + ): + """ + Transform the Box object into a TOON string. + + :param filename: File to write TOON object too + :param encoding: File encoding + :param errors: How to handle encoding errors + :param kwargs: parameters to pass to `toon_format.encode` + :return: string of TOON (if no filename provided) + """ + return _to_toon(self.to_dict(), filename=filename, encoding=encoding, errors=errors, **kwargs) + + @classmethod + def from_toon( + cls, + toon_string: str | None = None, + filename: str | PathLike | None = None, + encoding: str = "utf-8", + errors: str = "strict", + **kwargs, + ) -> Box: + """ + Transforms a TOON string or file into a Box object + + :param toon_string: string to pass to `toon_format.decode` + :param filename: filename to open and pass to `toon_format.decode` + :param encoding: File encoding + :param errors: How to handle encoding errors + :param kwargs: parameters to pass to `Box()` + :return: Box object + """ + box_args = {} + for arg in kwargs.copy(): + if arg in BOX_PARAMETERS: + box_args[arg] = kwargs.pop(arg) + + data = _from_toon(toon_string=toon_string, filename=filename, encoding=encoding, errors=errors, **kwargs) + if not isinstance(data, dict): + raise BoxError(f"toon data not returned as a dictionary but rather a {type(data).__name__}") + return cls(data, **box_args) + + else: + + def to_toon( + self, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs + ): + raise BoxError('toon is unavailable on this system, please install the "toon_format" package') + + @classmethod + def from_toon( + cls, + toon_string: str | None = None, + filename: str | PathLike | None = None, + encoding: str = "utf-8", + errors: str = "strict", + **kwargs, + ) -> Box: + raise BoxError('toon is unavailable on this system, please install the "toon_format" package') diff --git a/box/box.pyi b/box/box.pyi new file mode 100644 index 0000000..05f0906 --- /dev/null +++ b/box/box.pyi @@ -0,0 +1,132 @@ +from _typeshed import Incomplete +from collections.abc import Generator, Mapping +from os import PathLike +from typing import Any, Literal + +class Box(dict): + def __new__( + cls, + *args: Any, + default_box: bool = ..., + default_box_attr: Any = ..., + default_box_none_transform: bool = ..., + default_box_create_on_get: bool = ..., + frozen_box: bool = ..., + camel_killer_box: bool = ..., + conversion_box: bool = ..., + modify_tuples_box: bool = ..., + box_safe_prefix: str = ..., + box_duplicates: str = ..., + box_intact_types: tuple | list = ..., + box_recast: dict | None = ..., + box_dots: bool = ..., + box_dots_exclude: str | None = ..., + box_class: dict | type[Box] | None = ..., + box_namespace: tuple[str, ...] | Literal[False] = ..., + **kwargs: Any, + ): ... + def __init__( + self, + *args: Any, + default_box: bool = ..., + default_box_attr: Any = ..., + default_box_none_transform: bool = ..., + default_box_create_on_get: bool = ..., + frozen_box: bool = ..., + camel_killer_box: bool = ..., + conversion_box: bool = ..., + modify_tuples_box: bool = ..., + box_safe_prefix: str = ..., + box_duplicates: str = ..., + box_intact_types: tuple | list = ..., + box_recast: dict | None = ..., + box_dots: bool = ..., + box_dots_exclude: str | None = ..., + box_class: dict | type[Box] | None = ..., + box_namespace: tuple[str, ...] | Literal[False] = ..., + **kwargs: Any, + ) -> None: ... + def __add__(self, other: Mapping[Any, Any]): ... + def __radd__(self, other: Mapping[Any, Any]): ... + def __iadd__(self, other: Mapping[Any, Any]): ... + def __or__(self, other: Mapping[Any, Any]): ... + def __ror__(self, other: Mapping[Any, Any]): ... + def __ior__(self, other: Mapping[Any, Any]): ... # type: ignore[override] + def __sub__(self, other: Mapping[Any, Any]): ... + def __hash__(self): ... + def __dir__(self) -> list[str]: ... + def __contains__(self, item) -> bool: ... + def keys(self, dotted: bool = ...): ... + def items(self, dotted: bool = ...): ... + def get(self, key, default=...): ... + def copy(self) -> Box: ... + def __copy__(self) -> Box: ... + def __deepcopy__(self, memodict: Incomplete | None = ...) -> Box: ... + def __getitem__(self, item, _ignore_default: bool = ...): ... + def __getattr__(self, item): ... + def __setitem__(self, key, value): ... + def __setattr__(self, key, value): ... + def __delitem__(self, key): ... + def __delattr__(self, item) -> None: ... + def pop(self, key, *args): ... + def clear(self) -> None: ... + def popitem(self): ... + def __iter__(self) -> Generator: ... + def __reversed__(self) -> Generator: ... + def to_dict(self) -> dict: ... + def update(self, *args, **kwargs) -> None: ... + def merge_update(self, *args, **kwargs) -> None: ... + def setdefault(self, item, default: Incomplete | None = ...): ... + def to_json(self, filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ..., **json_kwargs): ... + @classmethod + def from_json( + cls, + json_string: str | None = ..., + filename: str | PathLike | None = ..., + encoding: str = ..., + errors: str = ..., + **kwargs, + ) -> Box: ... + def to_yaml( + self, + filename: str | PathLike | None = ..., + default_flow_style: bool = ..., + encoding: str = ..., + errors: str = ..., + width: int = ..., + **yaml_kwargs, + ): ... + @classmethod + def from_yaml( + cls, + yaml_string: str | None = ..., + filename: str | PathLike | None = ..., + encoding: str = ..., + errors: str = ..., + **kwargs, + ) -> Box: ... + def to_toml(self, filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ...): ... + @classmethod + def from_toml( + cls, + toml_string: str | None = ..., + filename: str | PathLike | None = ..., + encoding: str = ..., + errors: str = ..., + **kwargs, + ) -> Box: ... + def to_msgpack(self, filename: str | PathLike | None = ..., **kwargs): ... + @classmethod + def from_msgpack( + cls, msgpack_bytes: bytes | None = ..., filename: str | PathLike | None = ..., **kwargs + ) -> Box: ... + def to_toon(self, filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ..., **kwargs): ... + @classmethod + def from_toon( + cls, + toon_string: str | None = ..., + filename: str | PathLike | None = ..., + encoding: str = ..., + errors: str = ..., + **kwargs, + ) -> Box: ... diff --git a/box/box_list.py b/box/box_list.py index 54cb189..72609ff 100644 --- a/box/box_list.py +++ b/box/box_list.py @@ -1,11 +1,14 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- # -# Copyright (c) 2017-2020 - Chris Griffith - MIT License +# Copyright (c) 2017-2026 - Chris Griffith - MIT License +from __future__ import annotations + import copy import re +from collections.abc import Iterable from os import PathLike -from typing import Iterable, Type, Union +from typing import Any import box from box.converters import ( @@ -14,14 +17,17 @@ _from_json, _from_msgpack, _from_toml, + _from_toon, _from_yaml, _to_csv, _to_json, _to_msgpack, _to_toml, + _to_toon, _to_yaml, msgpack_available, - toml_available, + toon_available, + toml_read_library, yaml_available, ) from box.exceptions import BoxError, BoxTypeError @@ -40,16 +46,17 @@ def __new__(cls, *args, **kwargs): # This is required for pickling to work correctly obj.box_options = {"box_class": box.Box} obj.box_options.update(kwargs) - obj.box_org_ref = 0 + obj.box_org_ref = None return obj - def __init__(self, iterable: Iterable = None, box_class: Type[box.Box] = box.Box, **box_options): + def __init__(self, iterable: Iterable | None = None, box_class: type[box.Box] = box.Box, **box_options): self.box_options = box_options self.box_options["box_class"] = box_class - self.box_org_ref = id(iterable) if iterable else 0 + self.box_org_ref = iterable if iterable: for x in iterable: self.append(x) + self.box_org_ref = None if box_options.get("frozen_box"): def frozen(*args, **kwargs): @@ -61,11 +68,19 @@ def frozen(*args, **kwargs): def __getitem__(self, item): if self.box_options.get("box_dots") and isinstance(item, str) and item.startswith("["): list_pos = _list_pos_re.search(item) - value = super(BoxList, self).__getitem__(int(list_pos.groups()[0])) + value = super().__getitem__(int(list_pos.groups()[0])) if len(list_pos.group()) == len(item): return value return value.__getitem__(item[len(list_pos.group()) :].lstrip(".")) - return super(BoxList, self).__getitem__(item) + if isinstance(item, tuple): + result = self + for idx in item: + if isinstance(result, list): + result = result[idx] + else: + raise BoxTypeError(f"Cannot numpy-style indexing on {type(result).__name__}.") + return result + return super().__getitem__(item) def __delitem__(self, key): if self.box_options.get("frozen_box"): @@ -74,10 +89,10 @@ def __delitem__(self, key): list_pos = _list_pos_re.search(key) pos = int(list_pos.groups()[0]) if len(list_pos.group()) == len(key): - return super(BoxList, self).__delitem__(pos) + return super().__delitem__(pos) if hasattr(self[pos], "__delitem__"): return self[pos].__delitem__(key[len(list_pos.group()) :].lstrip(".")) # type: ignore - super(BoxList, self).__delitem__(key) + super().__delitem__(key) def __setitem__(self, key, value): if self.box_options.get("frozen_box"): @@ -85,10 +100,18 @@ def __setitem__(self, key, value): if self.box_options.get("box_dots") and isinstance(key, str) and key.startswith("["): list_pos = _list_pos_re.search(key) pos = int(list_pos.groups()[0]) + if pos >= len(self) and self.box_options.get("default_box"): + self.extend([None] * (pos - len(self) + 1)) if len(list_pos.group()) == len(key): - return super(BoxList, self).__setitem__(pos, value) - return super(BoxList, self).__getitem__(pos).__setitem__(key[len(list_pos.group()) :].lstrip("."), value) - super(BoxList, self).__setitem__(key, value) + return super().__setitem__(pos, value) + children = key[len(list_pos.group()) :].lstrip(".") + if self.box_options.get("default_box"): + if children[0] == "[": + super().__setitem__(pos, box.BoxList(**self.box_options)) + else: + super().__setitem__(pos, self.box_options.get("box_class")(**self.box_options)) + return super().__getitem__(pos).__setitem__(children, value) + super().__setitem__(key, value) def _is_intact_type(self, obj): if self.box_options.get("box_intact_types") and isinstance(obj, self.box_options["box_intact_types"]): @@ -101,22 +124,26 @@ def _convert(self, p_object): elif isinstance(p_object, box.Box): p_object._box_config.update(self.box_options) if isinstance(p_object, list) and not self._is_intact_type(p_object): - p_object = self if id(p_object) == self.box_org_ref else self.__class__(p_object, **self.box_options) + p_object = ( + self + if p_object is self or p_object is self.box_org_ref + else self.__class__(p_object, **self.box_options) + ) elif isinstance(p_object, BoxList): p_object.box_options.update(self.box_options) return p_object def append(self, p_object): - super(BoxList, self).append(self._convert(p_object)) + super().append(self._convert(p_object)) def extend(self, iterable): for item in iterable: self.append(item) def insert(self, index, p_object): - super(BoxList, self).insert(index, self._convert(p_object)) + super().insert(index, self._convert(p_object)) - def _dotted_helper(self): + def _dotted_helper(self) -> list[str]: keys = [] for idx, item in enumerate(self): added = False @@ -133,7 +160,7 @@ def _dotted_helper(self): return keys def __repr__(self): - return f"" + return f"{self.__class__.__name__}({self.to_list()})" def __str__(self): return str(self.to_list()) @@ -149,15 +176,15 @@ def __deepcopy__(self, memo=None): out.append(copy.deepcopy(k, memo=memo)) return out - def __hash__(self): + def __hash__(self) -> int: # type: ignore[override] if self.box_options.get("frozen_box"): hashing = 98765 hashing ^= hash(tuple(self)) return hashing raise BoxTypeError("unhashable type: 'BoxList'") - def to_list(self): - new_list = [] + def to_list(self) -> list: + new_list: list[Any] = [] for x in self: if x is self: new_list.append(new_list) @@ -171,7 +198,7 @@ def to_list(self): def to_json( self, - filename: Union[str, PathLike] = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", multiline: bool = False, @@ -197,8 +224,8 @@ def to_json( @classmethod def from_json( cls, - json_string: str = None, - filename: Union[str, PathLike] = None, + json_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", multiline: bool = False, @@ -216,10 +243,10 @@ def from_json( :param kwargs: parameters to pass to `Box()` or `json.loads` :return: BoxList object from json data """ - bx_args = {} + box_args = {} for arg in list(kwargs.keys()): if arg in BOX_PARAMETERS: - bx_args[arg] = kwargs.pop(arg) + box_args[arg] = kwargs.pop(arg) data = _from_json( json_string, filename=filename, encoding=encoding, errors=errors, multiline=multiline, **kwargs @@ -227,16 +254,17 @@ def from_json( if not isinstance(data, list): raise BoxError(f"json data not returned as a list, but rather a {type(data).__name__}") - return cls(data, **bx_args) + return cls(data, **box_args) if yaml_available: def to_yaml( self, - filename: Union[str, PathLike] = None, + filename: str | PathLike | None = None, default_flow_style: bool = False, encoding: str = "utf-8", errors: str = "strict", + width: int = 120, **yaml_kwargs, ): """ @@ -246,6 +274,7 @@ def to_yaml( :param default_flow_style: False will recursively dump dicts :param encoding: File encoding :param errors: How to handle encoding errors + :param width: Line width for YAML output :param yaml_kwargs: additional arguments to pass to yaml.dump :return: string of YAML or return of `yaml.dump` """ @@ -255,14 +284,15 @@ def to_yaml( default_flow_style=default_flow_style, encoding=encoding, errors=errors, + width=width, **yaml_kwargs, ) @classmethod def from_yaml( cls, - yaml_string: str = None, - filename: Union[str, PathLike] = None, + yaml_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, @@ -277,24 +307,27 @@ def from_yaml( :param kwargs: parameters to pass to `BoxList()` or `yaml.load` :return: BoxList object from yaml data """ - bx_args = {} + box_args = {} for arg in list(kwargs.keys()): if arg in BOX_PARAMETERS: - bx_args[arg] = kwargs.pop(arg) + box_args[arg] = kwargs.pop(arg) data = _from_yaml(yaml_string=yaml_string, filename=filename, encoding=encoding, errors=errors, **kwargs) + if not data: + return cls(**box_args) if not isinstance(data, list): raise BoxError(f"yaml data not returned as a list but rather a {type(data).__name__}") - return cls(data, **bx_args) + return cls(data, **box_args) else: def to_yaml( self, - filename: Union[str, PathLike] = None, + filename: str | PathLike | None = None, default_flow_style: bool = False, encoding: str = "utf-8", errors: str = "strict", + width: int = 120, **yaml_kwargs, ): raise BoxError('yaml is unavailable on this system, please install the "ruamel.yaml" or "PyYAML" package') @@ -302,19 +335,19 @@ def to_yaml( @classmethod def from_yaml( cls, - yaml_string: str = None, - filename: Union[str, PathLike] = None, + yaml_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, ): raise BoxError('yaml is unavailable on this system, please install the "ruamel.yaml" or "PyYAML" package') - if toml_available: + if toml_read_library is not None: def to_toml( self, - filename: Union[str, PathLike] = None, + filename: str | PathLike | None = None, key_name: str = "toml", encoding: str = "utf-8", errors: str = "strict", @@ -331,11 +364,24 @@ def to_toml( """ return _to_toml({key_name: self.to_list()}, filename=filename, encoding=encoding, errors=errors) + else: + + def to_toml( + self, + filename: str | PathLike | None = None, + key_name: str = "toml", + encoding: str = "utf-8", + errors: str = "strict", + ): + raise BoxError('toml is unavailable on this system, please install the "tomli-w" package') + + if toml_read_library is not None: + @classmethod def from_toml( cls, - toml_string: str = None, - filename: Union[str, PathLike] = None, + toml_string: str | None = None, + filename: str | PathLike | None = None, key_name: str = "toml", encoding: str = "utf-8", errors: str = "strict", @@ -353,32 +399,23 @@ def from_toml( :param kwargs: parameters to pass to `Box()` :return: """ - bx_args = {} + box_args = {} for arg in list(kwargs.keys()): if arg in BOX_PARAMETERS: - bx_args[arg] = kwargs.pop(arg) + box_args[arg] = kwargs.pop(arg) data = _from_toml(toml_string=toml_string, filename=filename, encoding=encoding, errors=errors) if key_name not in data: raise BoxError(f"{key_name} was not found.") - return cls(data[key_name], **bx_args) + return cls(data[key_name], **box_args) else: - def to_toml( - self, - filename: Union[str, PathLike] = None, - key_name: str = "toml", - encoding: str = "utf-8", - errors: str = "strict", - ): - raise BoxError('toml is unavailable on this system, please install the "toml" package') - @classmethod def from_toml( cls, - toml_string: str = None, - filename: Union[str, PathLike] = None, + toml_string: str | None = None, + filename: str | PathLike | None = None, key_name: str = "toml", encoding: str = "utf-8", errors: str = "strict", @@ -388,7 +425,7 @@ def from_toml( if msgpack_available: - def to_msgpack(self, filename: Union[str, PathLike] = None, **kwargs): + def to_msgpack(self, filename: str | PathLike | None = None, **kwargs): """ Transform the BoxList object into a toml string. @@ -398,7 +435,7 @@ def to_msgpack(self, filename: Union[str, PathLike] = None, **kwargs): return _to_msgpack(self.to_list(), filename=filename, **kwargs) @classmethod - def from_msgpack(cls, msgpack_bytes: bytes = None, filename: Union[str, PathLike] = None, **kwargs): + def from_msgpack(cls, msgpack_bytes: bytes | None = None, filename: str | PathLike | None = None, **kwargs): """ Transforms a toml string or file into a BoxList object @@ -407,40 +444,103 @@ def from_msgpack(cls, msgpack_bytes: bytes = None, filename: Union[str, PathLike :param kwargs: parameters to pass to `Box()` :return: """ - bx_args = {} + box_args = {} for arg in list(kwargs.keys()): if arg in BOX_PARAMETERS: - bx_args[arg] = kwargs.pop(arg) + box_args[arg] = kwargs.pop(arg) data = _from_msgpack(msgpack_bytes=msgpack_bytes, filename=filename, **kwargs) if not isinstance(data, list): raise BoxError(f"msgpack data not returned as a list but rather a {type(data).__name__}") - return cls(data, **bx_args) + return cls(data, **box_args) else: - def to_msgpack(self, filename: Union[str, PathLike] = None, **kwargs): + def to_msgpack(self, filename: str | PathLike | None = None, **kwargs): raise BoxError('msgpack is unavailable on this system, please install the "msgpack" package') @classmethod def from_msgpack( cls, - msgpack_bytes: bytes = None, - filename: Union[str, PathLike] = None, + msgpack_bytes: bytes | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, ): raise BoxError('msgpack is unavailable on this system, please install the "msgpack" package') - def to_csv(self, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict"): + if toon_available: + + def to_toon( + self, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs + ): + """ + Transform the BoxList object into a TOON string. + + :param filename: File to write TOON object too + :param encoding: File encoding + :param errors: How to handle encoding errors + :param kwargs: parameters to pass to `toon_format.encode` + :return: string of TOON (if no filename provided) + """ + return _to_toon(self.to_list(), filename=filename, encoding=encoding, errors=errors, **kwargs) + + @classmethod + def from_toon( + cls, + toon_string: str | None = None, + filename: str | PathLike | None = None, + encoding: str = "utf-8", + errors: str = "strict", + **kwargs, + ): + """ + Transforms a TOON string or file into a BoxList object + + :param toon_string: string to pass to `toon_format.decode` + :param filename: filename to open and pass to `toon_format.decode` + :param encoding: File encoding + :param errors: How to handle encoding errors + :param kwargs: parameters to pass to `BoxList()` + :return: BoxList object + """ + box_args = {} + for arg in list(kwargs.keys()): + if arg in BOX_PARAMETERS: + box_args[arg] = kwargs.pop(arg) + + data = _from_toon(toon_string=toon_string, filename=filename, encoding=encoding, errors=errors, **kwargs) + if not isinstance(data, list): + raise BoxError(f"toon data not returned as a list but rather a {type(data).__name__}") + return cls(data, **box_args) + + else: + + def to_toon( + self, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs + ): + raise BoxError('toon is unavailable on this system, please install the "toon_format" package') + + @classmethod + def from_toon( + cls, + toon_string: str | None = None, + filename: str | PathLike | None = None, + encoding: str = "utf-8", + errors: str = "strict", + **kwargs, + ): + raise BoxError('toon is unavailable on this system, please install the "toon_format" package') + + def to_csv(self, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict"): return _to_csv(self, filename=filename, encoding=encoding, errors=errors) @classmethod def from_csv( cls, - csv_string: str = None, - filename: Union[str, PathLike] = None, + csv_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", ): diff --git a/box/box_list.pyi b/box/box_list.pyi new file mode 100644 index 0000000..a9e769e --- /dev/null +++ b/box/box_list.pyi @@ -0,0 +1,95 @@ +import box +from box.converters import ( + BOX_PARAMETERS as BOX_PARAMETERS, + msgpack_available as msgpack_available, + toml_read_library as toml_read_library, + toml_write_library as toml_write_library, + yaml_available as yaml_available, +) +from collections.abc import Iterable +from os import PathLike as PathLike +from typing import Any + +class BoxList(list): + def __new__(cls, *args: Any, **kwargs: Any): ... + box_options: Any + box_org_ref: Any + def __init__(self, iterable: Iterable = ..., box_class: type[box.Box] = ..., **box_options: Any) -> None: ... + def __getitem__(self, item: Any): ... + def __delitem__(self, key: Any): ... + def __setitem__(self, key: Any, value: Any): ... + def append(self, p_object: Any) -> None: ... + def extend(self, iterable: Any) -> None: ... + def insert(self, index: Any, p_object: Any) -> None: ... + def __copy__(self) -> BoxList: ... + def __deepcopy__(self, memo: Any | None = ...) -> BoxList: ... + def __hash__(self) -> int: ... # type: ignore[override] + def to_list(self) -> list: ... + def _dotted_helper(self) -> list[str]: ... + def to_json( + self, + filename: str | PathLike = ..., + encoding: str = ..., + errors: str = ..., + multiline: bool = ..., + **json_kwargs: Any, + ) -> Any: ... + @classmethod + def from_json( + cls, + json_string: str = ..., + filename: str | PathLike = ..., + encoding: str = ..., + errors: str = ..., + multiline: bool = ..., + **kwargs: Any, + ) -> Any: ... + def to_yaml( + self, + filename: str | PathLike = ..., + default_flow_style: bool = ..., + encoding: str = ..., + errors: str = ..., + width: int = ..., + **yaml_kwargs: Any, + ) -> Any: ... + @classmethod + def from_yaml( + cls, + yaml_string: str = ..., + filename: str | PathLike = ..., + encoding: str = ..., + errors: str = ..., + **kwargs: Any, + ) -> Any: ... + def to_toml( + self, filename: str | PathLike = ..., key_name: str = ..., encoding: str = ..., errors: str = ... + ) -> Any: ... + @classmethod + def from_toml( + cls, + toml_string: str = ..., + filename: str | PathLike = ..., + key_name: str = ..., + encoding: str = ..., + errors: str = ..., + **kwargs: Any, + ) -> Any: ... + def to_msgpack(self, filename: str | PathLike = ..., **kwargs: Any) -> Any: ... + @classmethod + def from_msgpack(cls, msgpack_bytes: bytes = ..., filename: str | PathLike = ..., **kwargs: Any) -> Any: ... + def to_toon(self, filename: str | PathLike = ..., encoding: str = ..., errors: str = ..., **kwargs: Any) -> Any: ... + @classmethod + def from_toon( + cls, + toon_string: str = ..., + filename: str | PathLike = ..., + encoding: str = ..., + errors: str = ..., + **kwargs: Any, + ) -> Any: ... + def to_csv(self, filename: str | PathLike = ..., encoding: str = ..., errors: str = ...) -> Any: ... + @classmethod + def from_csv( + cls, csv_string: str = ..., filename: str | PathLike = ..., encoding: str = ..., errors: str = ... + ) -> Any: ... diff --git a/box/config_box.py b/box/config_box.py index 67ef962..5f8ad55 100644 --- a/box/config_box.py +++ b/box/config_box.py @@ -1,5 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +from __future__ import annotations from box.box import Box @@ -29,7 +30,7 @@ def __getattr__(self, item): except AttributeError: return super().__getattr__(item.lower()) - def __dir__(self): + def __dir__(self) -> list[str]: return super().__dir__() + ["bool", "int", "float", "list", "getboolean", "getfloat", "getint"] def bool(self, item, default=None): @@ -87,7 +88,7 @@ def float(self, item, default=None): raise err return float(item) - def list(self, item, default=None, spliter=",", strip=True, mod=None): + def list(self, item, default=None, spliter: str = ",", strip=True, mod=None): """ Return value of key as a list @@ -123,7 +124,7 @@ def getfloat(self, item, default=None): return self.float(item, default) def __repr__(self): - return "".format(str(self.to_dict())) + return f"{self.__class__.__name__}({str(self.to_dict())})" def copy(self): return ConfigBox(super().copy()) diff --git a/box/config_box.pyi b/box/config_box.pyi new file mode 100644 index 0000000..6d72f27 --- /dev/null +++ b/box/config_box.pyi @@ -0,0 +1,15 @@ +from box.box import Box as Box +from typing import Any + +class ConfigBox(Box): + def __getattr__(self, item: Any): ... + def __dir__(self) -> list[str]: ... + def bool(self, item: Any, default: Any | None = ...): ... + def int(self, item: Any, default: Any | None = ...): ... + def float(self, item: Any, default: Any | None = ...): ... + def list(self, item: Any, default: Any | None = ..., spliter: str = ..., strip: bool = ..., mod: Any | None = ...): ... # type: ignore + def getboolean(self, item: Any, default: Any | None = ...): ... + def getint(self, item: Any, default: Any | None = ...): ... + def getfloat(self, item: Any, default: Any | None = ...): ... + def copy(self) -> ConfigBox: ... + def __copy__(self) -> ConfigBox: ... diff --git a/box/converters.py b/box/converters.py index 95e8550..bb91ba6 100644 --- a/box/converters.py +++ b/box/converters.py @@ -1,59 +1,143 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +from __future__ import annotations # Abstract converter functions for use in any Box class import csv import json +from collections.abc import Callable from io import StringIO from os import PathLike from pathlib import Path -from typing import Union +from typing import Any from box.exceptions import BoxError -yaml_available = True -toml_available = True +pyyaml_available = True +ruamel_available = True msgpack_available = True try: - import ruamel.yaml as yaml + from ruamel.yaml import version_info, YAML except ImportError: - try: - import yaml # type: ignore - except ImportError: - yaml = None # type: ignore - yaml_available = False + ruamel_available = False +else: + if version_info[1] < 17: + ruamel_available = False + +try: + import yaml +except ImportError: + pyyaml_available = False + +MISSING_PARSER_ERROR = "No YAML Parser available, please install ruamel.yaml>=0.17 or PyYAML" + +toml_read_library: Any | None = None +toml_write_library: Any | None = None +toml_decode_error: Callable | None = None + +__all__ = [ + "_to_json", + "_to_yaml", + "_to_toml", + "_to_csv", + "_to_msgpack", + "_to_toon", + "_from_json", + "_from_yaml", + "_from_toml", + "_from_csv", + "_from_msgpack", + "_from_toon", +] + + +class BoxTomlDecodeError(BoxError): + """Toml Decode Error""" + try: import toml except ImportError: - toml = None # type: ignore - toml_available = False + pass +else: + toml_read_library = toml + toml_write_library = toml + toml_decode_error = toml.TomlDecodeError + + class BoxTomlDecodeError(BoxError, toml.TomlDecodeError): # type: ignore + """Toml Decode Error""" + + +try: + import tomllib +except ImportError: + pass +else: + toml_read_library = tomllib + toml_decode_error = tomllib.TOMLDecodeError + + class BoxTomlDecodeError(BoxError, tomllib.TOMLDecodeError): # type: ignore + """Toml Decode Error""" + + +try: + import tomli +except ImportError: + pass +else: + toml_read_library = tomli + toml_decode_error = tomli.TOMLDecodeError + + class BoxTomlDecodeError(BoxError, tomli.TOMLDecodeError): # type: ignore + """Toml Decode Error""" + + +try: + import tomli_w +except ImportError: + pass +else: + toml_write_library = tomli_w + + try: import msgpack # type: ignore except ImportError: msgpack = None # type: ignore msgpack_available = False +toon_available = True + +try: + from toon_format import encode as toon_encode, decode as toon_decode +except ImportError: + toon_available = False + +yaml_available = pyyaml_available or ruamel_available + BOX_PARAMETERS = ( "default_box", "default_box_attr", - "conversion_box", + "default_box_none_transform", + "default_box_create_on_get", "frozen_box", "camel_killer_box", + "conversion_box", + "modify_tuples_box", "box_safe_prefix", "box_duplicates", - "ordered_box", - "default_box_none_transform", - "box_dots", - "modify_tuples_box", "box_intact_types", + "box_dots", + "box_dots_exclude", "box_recast", + "box_class", + "box_namespace", ) -def _exists(filename: Union[str, PathLike], create: bool = False) -> Path: +def _exists(filename: str | PathLike, create: bool = False) -> Path: path = Path(filename) if create: try: @@ -70,7 +154,7 @@ def _exists(filename: Union[str, PathLike], create: bool = False) -> Path: def _to_json( - obj, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict", **json_kwargs + obj, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **json_kwargs ): if filename: _exists(filename, create=True) @@ -81,8 +165,8 @@ def _to_json( def _from_json( - json_string: str = None, - filename: Union[str, PathLike] = None, + json_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", multiline: bool = False, @@ -107,64 +191,134 @@ def _from_json( def _to_yaml( obj, - filename: Union[str, PathLike] = None, + filename: str | PathLike | None = None, default_flow_style: bool = False, encoding: str = "utf-8", errors: str = "strict", + ruamel_typ: str = "rt", + ruamel_attrs: dict | None = None, + width: int = 120, **yaml_kwargs, ): + if not ruamel_attrs: + ruamel_attrs = {} if filename: _exists(filename, create=True) with open(filename, "w", encoding=encoding, errors=errors) as f: - yaml.dump(obj, stream=f, default_flow_style=default_flow_style, **yaml_kwargs) + if ruamel_available: + yaml_dumper = YAML(typ=ruamel_typ) + yaml_dumper.default_flow_style = default_flow_style + yaml_dumper.width = width + for attr, value in ruamel_attrs.items(): + setattr(yaml_dumper, attr, value) + return yaml_dumper.dump(obj, stream=f, **yaml_kwargs) + elif pyyaml_available: + return yaml.dump(obj, stream=f, default_flow_style=default_flow_style, width=width, **yaml_kwargs) + else: + raise BoxError(MISSING_PARSER_ERROR) + else: - return yaml.dump(obj, default_flow_style=default_flow_style, **yaml_kwargs) + if ruamel_available: + yaml_dumper = YAML(typ=ruamel_typ) + yaml_dumper.default_flow_style = default_flow_style + yaml_dumper.width = width + for attr, value in ruamel_attrs.items(): + setattr(yaml_dumper, attr, value) + with StringIO() as string_stream: + yaml_dumper.dump(obj, stream=string_stream, **yaml_kwargs) + return string_stream.getvalue() + elif pyyaml_available: + return yaml.dump(obj, default_flow_style=default_flow_style, width=width, **yaml_kwargs) + else: + raise BoxError(MISSING_PARSER_ERROR) def _from_yaml( - yaml_string: str = None, - filename: Union[str, PathLike] = None, + yaml_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", + ruamel_typ: str = "rt", + ruamel_attrs: dict | None = None, **kwargs, ): - if "Loader" not in kwargs: - kwargs["Loader"] = yaml.SafeLoader + if not ruamel_attrs: + ruamel_attrs = {} if filename: _exists(filename) with open(filename, "r", encoding=encoding, errors=errors) as f: - data = yaml.load(f, **kwargs) + if ruamel_available: + yaml_loader = YAML(typ=ruamel_typ) + for attr, value in ruamel_attrs.items(): + setattr(yaml_loader, attr, value) + data = yaml_loader.load(stream=f) + elif pyyaml_available: + if "Loader" not in kwargs: + kwargs["Loader"] = yaml.SafeLoader + data = yaml.load(f, **kwargs) + else: + raise BoxError(MISSING_PARSER_ERROR) elif yaml_string: - data = yaml.load(yaml_string, **kwargs) + if ruamel_available: + yaml_loader = YAML(typ=ruamel_typ) + for attr, value in ruamel_attrs.items(): + setattr(yaml_loader, attr, value) + data = yaml_loader.load(stream=yaml_string) + elif pyyaml_available: + if "Loader" not in kwargs: + kwargs["Loader"] = yaml.SafeLoader + data = yaml.load(yaml_string, **kwargs) + else: + raise BoxError(MISSING_PARSER_ERROR) else: raise BoxError("from_yaml requires a string or filename") return data -def _to_toml(obj, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict"): +def _to_toml(obj, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict"): if filename: _exists(filename, create=True) - with open(filename, "w", encoding=encoding, errors=errors) as f: - toml.dump(obj, f) + if toml_write_library.__name__ == "toml": # type: ignore + with open(filename, "w", encoding=encoding, errors=errors) as f: + try: + toml_write_library.dump(obj, f) # type: ignore + except toml_decode_error as err: # type: ignore + raise BoxTomlDecodeError(err) from err + else: + with open(filename, "wb") as f: + try: + toml_write_library.dump(obj, f) # type: ignore + except toml_decode_error as err: # type: ignore + raise BoxTomlDecodeError(err) from err else: - return toml.dumps(obj) + try: + return toml_write_library.dumps(obj) # type: ignore + except toml_decode_error as err: # type: ignore + raise BoxTomlDecodeError(err) from err def _from_toml( - toml_string: str = None, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict" + toml_string: str | None = None, + filename: str | PathLike | None = None, + encoding: str = "utf-8", + errors: str = "strict", ): if filename: _exists(filename) - with open(filename, "r", encoding=encoding, errors=errors) as f: - data = toml.load(f) + if toml_read_library.__name__ == "toml": # type: ignore + with open(filename, "r", encoding=encoding, errors=errors) as f: + data = toml_read_library.load(f) # type: ignore + else: + with open(filename, "rb") as f: + data = toml_read_library.load(f) # type: ignore elif toml_string: - data = toml.loads(toml_string) + data = toml_read_library.loads(toml_string) # type: ignore else: raise BoxError("from_toml requires a string or filename") return data -def _to_msgpack(obj, filename: Union[str, PathLike] = None, **kwargs): +def _to_msgpack(obj, filename: str | PathLike | None = None, **kwargs): if filename: _exists(filename, create=True) with open(filename, "wb") as f: @@ -173,7 +327,7 @@ def _to_msgpack(obj, filename: Union[str, PathLike] = None, **kwargs): return msgpack.packb(obj, **kwargs) -def _from_msgpack(msgpack_bytes: bytes = None, filename: Union[str, PathLike] = None, **kwargs): +def _from_msgpack(msgpack_bytes: bytes | None = None, filename: str | PathLike | None = None, **kwargs): if filename: _exists(filename) with open(filename, "rb") as f: @@ -185,7 +339,36 @@ def _from_msgpack(msgpack_bytes: bytes = None, filename: Union[str, PathLike] = return data -def _to_csv(box_list, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict", **kwargs): +def _to_toon(obj, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs): + if filename: + _exists(filename, create=True) + with open(filename, "w", encoding=encoding, errors=errors) as f: + f.write(toon_encode(obj, **kwargs)) + else: + return toon_encode(obj, **kwargs) + + +def _from_toon( + toon_string: str | None = None, + filename: str | PathLike | None = None, + encoding: str = "utf-8", + errors: str = "strict", + **kwargs, +): + if filename: + _exists(filename) + with open(filename, "r", encoding=encoding, errors=errors) as f: + data = toon_decode(f.read(), **kwargs) + elif toon_string: + data = toon_decode(toon_string, **kwargs) + else: + raise BoxError("from_toon requires a string or filename") + return data + + +def _to_csv( + box_list, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs +): csv_column_names = list(box_list[0].keys()) for row in box_list: if list(row.keys()) != csv_column_names: @@ -206,8 +389,8 @@ def _to_csv(box_list, filename: Union[str, PathLike] = None, encoding: str = "ut def _from_csv( - csv_string: str = None, - filename: Union[str, PathLike] = None, + csv_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, diff --git a/box/converters.pyi b/box/converters.pyi new file mode 100644 index 0000000..975b902 --- /dev/null +++ b/box/converters.pyi @@ -0,0 +1,67 @@ +from collections.abc import Callable +from os import PathLike +from typing import Any + +yaml_available: bool +toml_available: bool +msgpack_available: bool +toon_available: bool +BOX_PARAMETERS: Any +toml_read_library: Any | None +toml_write_library: Any | None +toml_decode_error: Callable | None + +def _to_json(obj, filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ..., **json_kwargs): ... +def _from_json( + json_string: str | None = ..., + filename: str | PathLike | None = ..., + encoding: str = ..., + errors: str = ..., + multiline: bool = ..., + **kwargs, +): ... +def _to_yaml( + obj, + filename: str | PathLike | None = ..., + default_flow_style: bool = ..., + encoding: str = ..., + errors: str = ..., + ruamel_typ: str = ..., + ruamel_attrs: dict | None = ..., + width: int = ..., + **yaml_kwargs, +): ... +def _from_yaml( + yaml_string: str | None = ..., + filename: str | PathLike | None = ..., + encoding: str = ..., + errors: str = ..., + ruamel_typ: str = ..., + ruamel_attrs: dict | None = ..., + **kwargs, +): ... +def _to_toml(obj, filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ...): ... +def _from_toml( + toml_string: str | None = ..., + filename: str | PathLike | None = ..., + encoding: str = ..., + errors: str = ..., +): ... +def _to_msgpack(obj, filename: str | PathLike | None = ..., **kwargs): ... +def _from_msgpack(msgpack_bytes: bytes | None = ..., filename: str | PathLike | None = ..., **kwargs): ... +def _to_toon(obj, filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ..., **kwargs): ... +def _from_toon( + toon_string: str | None = ..., + filename: str | PathLike | None = ..., + encoding: str = ..., + errors: str = ..., + **kwargs, +): ... +def _to_csv(box_list, filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ..., **kwargs): ... +def _from_csv( + csv_string: str | None = ..., + filename: str | PathLike | None = ..., + encoding: str = ..., + errors: str = ..., + **kwargs, +): ... diff --git a/box/exceptions.pyi b/box/exceptions.pyi new file mode 100644 index 0000000..2be6b54 --- /dev/null +++ b/box/exceptions.pyi @@ -0,0 +1,5 @@ +class BoxError(Exception): ... +class BoxKeyError(BoxError, KeyError, AttributeError): ... +class BoxTypeError(BoxError, TypeError): ... +class BoxValueError(BoxError, ValueError): ... +class BoxWarning(UserWarning): ... diff --git a/box/from_file.py b/box/from_file.py index 2cf60e1..3fb7a6e 100644 --- a/box/from_file.py +++ b/box/from_file.py @@ -1,13 +1,16 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +from __future__ import annotations + +import sys +from collections.abc import Callable from json import JSONDecodeError from os import PathLike from pathlib import Path -from typing import Callable, Dict, Union from box.box import Box from box.box_list import BoxList -from box.converters import msgpack_available, toml_available, yaml_available +from box.converters import msgpack_available, toon_available, toml_read_library, yaml_available, toml_decode_error from box.exceptions import BoxError try: @@ -19,17 +22,17 @@ YAMLError = False # type: ignore try: - from toml import TomlDecodeError + from msgpack import UnpackException # type: ignore except ImportError: - TomlDecodeError = False # type: ignore + UnpackException = False # type: ignore try: - from msgpack import UnpackException # type: ignore + from toon_format import ToonDecodeError # type: ignore except ImportError: - UnpackException = False # type: ignore + ToonDecodeError = False # type: ignore -__all__ = ["box_from_file"] +__all__ = ["box_from_file", "box_from_string"] def _to_json(file, encoding, errors, **kwargs): @@ -59,11 +62,11 @@ def _to_yaml(file, encoding, errors, **kwargs): def _to_toml(file, encoding, errors, **kwargs): - if not toml_available: - raise BoxError(f'File "{file}" is toml but no package is available to open it. Please install "toml"') + if not toml_read_library: + raise BoxError(f'File "{file}" is toml but no package is available to open it. Please install "tomli"') try: return Box.from_toml(filename=file, encoding=encoding, errors=errors, **kwargs) - except TomlDecodeError: + except toml_decode_error: raise BoxError("File is not TOML as expected") @@ -78,6 +81,17 @@ def _to_msgpack(file, _, __, **kwargs): return BoxList.from_msgpack(filename=file, **kwargs) +def _to_toon(file, encoding, errors, **kwargs): + if not toon_available: + raise BoxError(f'File "{file}" is toon but no package is available to open it. Please install "toon_format"') + try: + return Box.from_toon(filename=file, encoding=encoding, errors=errors, **kwargs) + except (ToonDecodeError, ValueError): + raise BoxError("File is not TOON as expected") + except BoxError: + return BoxList.from_toon(filename=file, encoding=encoding, errors=errors, **kwargs) + + converters = { "json": _to_json, "jsn": _to_json, @@ -85,15 +99,20 @@ def _to_msgpack(file, _, __, **kwargs): "yml": _to_yaml, "toml": _to_toml, "tml": _to_toml, + "toon": _to_toon, "msgpack": _to_msgpack, "pack": _to_msgpack, "csv": _to_csv, -} # type: Dict[str, Callable] +} # type: dict[str, Callable] def box_from_file( - file: Union[str, PathLike], file_type: str = None, encoding: str = "utf-8", errors: str = "strict", **kwargs -) -> Union[Box, BoxList]: + file: str | PathLike, + file_type: str | None = None, + encoding: str = "utf-8", + errors: str = "strict", + **kwargs, +) -> Box | BoxList: """ Loads the provided file and tries to parse it into a Box or BoxList object as appropriate. @@ -112,4 +131,47 @@ def box_from_file( file_type = file_type.lower().lstrip(".") if file_type.lower() in converters: return converters[file_type.lower()](file, encoding, errors, **kwargs) # type: ignore - raise BoxError(f'"{file_type}" is an unknown type. Please use either csv, toml, msgpack, yaml or json') + raise BoxError(f'"{file_type}" is an unknown type. Please use either csv, toon, toml, msgpack, yaml or json') + + +def box_from_string(content: str, string_type: str = "json") -> Box | BoxList: + """ + Parse the provided string into a Box or BoxList object as appropriate. + + :param content: String to parse + :param string_type: manually specify file type: json, toml or yaml + :return: Box or BoxList + """ + + if string_type == "json": + try: + return Box.from_json(json_string=content) + except JSONDecodeError: + raise BoxError("File is not JSON as expected") + except BoxError: + return BoxList.from_json(json_string=content) + elif string_type == "toml": + try: + return Box.from_toml(toml_string=content) + except toml_decode_error: # type: ignore + raise BoxError("File is not TOML as expected") + except BoxError: + return BoxList.from_toml(toml_string=content) + elif string_type == "yaml": + try: + return Box.from_yaml(yaml_string=content) + except YAMLError: + raise BoxError("File is not YAML as expected") + except BoxError: + return BoxList.from_yaml(yaml_string=content) + elif string_type == "toon": + if not toon_available: + raise BoxError('toon is unavailable on this system, please install the "toon_format" package') + try: + return Box.from_toon(toon_string=content) + except (ToonDecodeError, ValueError): + raise BoxError("String is not TOON as expected") + except BoxError: + return BoxList.from_toon(toon_string=content) + else: + raise BoxError(f"Unsupported string_string of {string_type}") diff --git a/box/from_file.pyi b/box/from_file.pyi new file mode 100644 index 0000000..bae0c57 --- /dev/null +++ b/box/from_file.pyi @@ -0,0 +1,16 @@ +from box.box import Box as Box +from box.box_list import BoxList as BoxList +from os import PathLike +from typing import Any + +def box_from_file( + file: str | PathLike, + file_type: str = ..., + encoding: str = ..., + errors: str = ..., + **kwargs: Any, +) -> Box | BoxList: ... +def box_from_string( + content: str, + string_type: str = ..., +) -> Box | BoxList: ... diff --git a/box/py.typed b/box/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/box/shorthand_box.py b/box/shorthand_box.py index db3e584..aecfcc5 100644 --- a/box/shorthand_box.py +++ b/box/shorthand_box.py @@ -1,8 +1,11 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +from __future__ import annotations from box.box import Box +__all__ = ["SBox", "DDBox"] + class SBox(Box): """ @@ -25,26 +28,42 @@ class SBox(Box): ] @property - def dict(self): + def dict(self) -> dict: return self.to_dict() @property - def json(self): + def json(self) -> str: return self.to_json() @property - def yaml(self): + def yaml(self) -> str: return self.to_yaml() @property - def toml(self): + def toml(self) -> str: return self.to_toml() def __repr__(self): - return "".format(str(self.to_dict())) + return f"{self.__class__.__name__}({self})" - def copy(self): + def copy(self) -> SBox: return SBox(super(SBox, self).copy()) - def __copy__(self): + def __copy__(self) -> SBox: return SBox(super(SBox, self).copy()) + + +class DDBox(SBox): + def __init__(self, *args, **kwargs): + kwargs["box_dots"] = True + kwargs["default_box"] = True + super().__init__(*args, **kwargs) + + def __new__(cls, *args, **kwargs): + obj = super().__new__(cls, *args, **kwargs) + obj._box_config["box_dots"] = True + obj._box_config["default_box"] = True + return obj + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self})" diff --git a/box/shorthand_box.pyi b/box/shorthand_box.pyi new file mode 100644 index 0000000..be577f2 --- /dev/null +++ b/box/shorthand_box.pyi @@ -0,0 +1,15 @@ +from box.box import Box as Box + +class SBox(Box): + @property + def dict(self) -> dict: ... + @property + def json(self) -> str: ... + @property + def yaml(self) -> str: ... + @property + def toml(self) -> str: ... + def copy(self) -> SBox: ... + def __copy__(self) -> SBox: ... + +class DDBox(Box): ... diff --git a/requirements-dev.txt b/requirements-dev.txt index 881f0ab..0a430f9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,7 @@ # Files needed for pre-commit hooks -black>=19.10b0 -mypy>=0.770 -pre-commit>=2.2.0 +black>=23.1.0 +Cython>=3.0.11 +mypy>=1.0.1 +pre-commit>=2.21.0 +setuptools>=75.6.0 +toon_format @ git+https://github.com/toon-format/toon-python.git diff --git a/requirements-test.txt b/requirements-test.txt index c5397d6..071ec6d 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,6 +1,10 @@ -coverage>=5.0.4 +coverage>=7.6.9 msgpack>=1.0 -pytest-cov>=2.8.1 -pytest>=5.4.1 -ruamel.yaml>=0.16 +pytest>=7.1.3 +pytest-cov<6.0.0 +ruamel.yaml>=0.19.1 +tomli>=1.2.3; python_version < '3.11' +tomli-w>=1.0.0 +toon_format @ git+https://github.com/toon-format/toon-python.git +types-PyYAML>=6.0.3 wheel>=0.34.2 diff --git a/requirements.txt b/requirements.txt index b62a689..8a77de2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ msgpack>=1.0.0 -ruamel.yaml>=0.16.10 -toml>=0.10.1 +ruamel.yaml>=0.19.1 +tomli>=1.2.3; python_version < '3.11' +tomli-w diff --git a/setup.py b/setup.py index 6c8ac56..3898da7 100644 --- a/setup.py +++ b/setup.py @@ -5,11 +5,24 @@ import multiprocessing # noqa: F401 import os import re +from pathlib import Path +import sys +import shutil from setuptools import setup root = os.path.abspath(os.path.dirname(__file__)) +try: + from Cython.Build import cythonize +except ImportError: + extra = None +else: + extra = cythonize( + [str(file.relative_to(root)) for file in Path(root, "box").glob("*.py") if file.name != "__init__.py"], + compiler_directives={"language_level": 3}, + ) + with open(os.path.join(root, "box", "__init__.py"), "r") as init_file: init_content = init_file.read() @@ -31,16 +44,18 @@ long_description_content_type="text/x-rst", py_modules=["box"], packages=["box"], - python_requires=">=3.6", + ext_modules=extra, + python_requires=">=3.9", include_package_data=True, platforms="any", classifiers=[ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Development Status :: 5 - Production/Stable", "Natural Language :: English", @@ -52,10 +67,15 @@ "Topic :: Software Development :: Libraries :: Python Modules", ], extras_require={ - "all": ["ruamel.yaml", "toml", "msgpack"], - "ruamel.yaml": ["ruamel.yaml"], + "all": ["ruamel.yaml>=0.19.1", "toml", "msgpack"], + "yaml": ["ruamel.yaml>=0.19.1"], + "ruamel.yaml": ["ruamel.yaml>=0.19.1"], "PyYAML": ["PyYAML"], + "tomli": ["tomli; python_version < '3.11'", "tomli-w"], "toml": ["toml"], "msgpack": ["msgpack"], }, ) + +if not extra: + print("WARNING: Cython not installed, could not optimize box.", file=sys.stderr) diff --git a/test/common.py b/test/common.py index f9d9805..7688be1 100644 --- a/test/common.py +++ b/test/common.py @@ -33,6 +33,7 @@ tmp_json_file = os.path.join(test_root, "tmp", "tmp_json_file.json") tmp_yaml_file = os.path.join(test_root, "tmp", "tmp_yaml_file.yaml") tmp_msgpack_file = os.path.join(test_root, "tmp", "tmp_msgpack_file.msgpack") +tmp_toon_file = os.path.join(test_root, "tmp", "tmp_toon_file.toon") movie_data = { "movies": { @@ -83,7 +84,7 @@ def __init__(self): [1, 2, 3], {}, ([], {}), - lambda x: x ** 2, + lambda x: x**2, function_example, ClassExample(), ) # type: ignore diff --git a/test/data/bad_file.txt b/test/data/bad_file.txt index c7fb49c..a4cc880 100644 --- a/test/data/bad_file.txt +++ b/test/data/bad_file.txt @@ -1,3 +1,3 @@ Nothing good in here # bad data -test/ \ No newline at end of file +test/ diff --git a/test/data/json_list.json b/test/data/json_list.json index 70ac11a..a4dffb1 100644 --- a/test/data/json_list.json +++ b/test/data/json_list.json @@ -1,4 +1,4 @@ [ "test", "data" -] \ No newline at end of file +] diff --git a/test/test_box.py b/test/test_box.py index 451471f..e5c56e0 100644 --- a/test/test_box.py +++ b/test/test_box.py @@ -9,6 +9,7 @@ import shutil from multiprocessing import Queue from pathlib import Path +from io import StringIO from test.common import ( data_json_file, data_yaml_file, @@ -19,13 +20,16 @@ tmp_dir, tmp_json_file, tmp_msgpack_file, + tmp_toon_file, tmp_yaml_file, ) import pytest -import ruamel.yaml as yaml +from ruamel.yaml import YAML -from box import Box, BoxError, BoxKeyError, BoxList, ConfigBox, SBox, box +from box import Box, BoxError, BoxKeyError, BoxList, ConfigBox, SBox, DDBox +from box.box import _get_dot_paths, _camel_killer, _recursive_tuples # type: ignore +from box.converters import BOX_PARAMETERS def mp_queue_test(q): @@ -57,8 +61,8 @@ def test_safe_attrs(self): assert Box()._safe_attr(356) == "x356" def test_camel_killer(self): - assert box._camel_killer("CamelCase") == "camel_case" - assert box._camel_killer("Terrible321KeyA") == "terrible321_key_a" + assert _camel_killer("CamelCase") == "camel_case" + assert _camel_killer("Terrible321KeyA") == "terrible321_key_a" bx = Box(camel_killer_box=True, conversion_box=False) bx.DeadCamel = 3 @@ -87,7 +91,7 @@ def test_camel_killer(self): assert len(bx1.keys()) == 0 def test_recursive_tuples(self): - out = box._recursive_tuples( + out = _recursive_tuples( ({"test": "a"}, ({"second": "b"}, {"third": "c"}, ("fourth",))), dict, recreate_tuples=True ) assert isinstance(out, tuple) @@ -107,7 +111,7 @@ def test_box(self): assert "TEST_KEY" not in bx.to_dict(), bx.to_dict() assert isinstance(bx["Key 2"].Key4, Box) assert "'key1': 'value1'" in str(bx) - assert repr(bx).startswith(" int: + if val == "bad_id": + raise CustomError() + return int(val) + + b = Box(id="6", box_recast={"id": cast_id}) + assert isinstance(b.id, int) + with pytest.raises(ValueError) as exc_info: + b["sub_box"] = {"id": "bad_id"} + assert isinstance(exc_info.value.__cause__, CustomError) + def test_box_dots(self): b = Box( {"my_key": {"does stuff": {"to get to": "where I want"}}, "key.with.list": [[[{"test": "value"}]]]}, box_dots=True, + default_box=True, ) for key in b.keys(dotted=True): b[key] @@ -1111,6 +1213,26 @@ def test_msgpack_no_input(self): with pytest.raises(BoxError): Box.from_msgpack() + def test_toon_strings(self): + box1 = Box(test_dict) + toon_str = box1.to_toon() + assert Box.from_toon(toon_str) == box1 + + def test_toon_files(self): + box1 = Box(test_dict) + box1.to_toon(filename=tmp_toon_file) + assert Box.from_toon(filename=tmp_toon_file) == box1 + + def test_toon_no_input(self): + with pytest.raises(BoxError): + Box.from_toon() + + def test_toon_from_toon_with_box_args(self): + box1 = Box(test_dict) + toon_str = box1.to_toon() + box2 = Box.from_toon(toon_str, default_box=True) + assert box2.nonexistent == Box() + def test_value_view(self): a = Box() my_view = a.values() @@ -1153,18 +1275,234 @@ def test_box_safe_references(self): def test_default_box_restricted_calls(self): a = Box(default_box=True) - a._test_thing_ + with pytest.raises(BoxKeyError): + a._test_thing_ assert len(list(a.keys())) == 0 + # Based on argparse.parse_args internal behavior, the following + # creates the attribute in hasattr due to default_box=True, then + # deletes it in delattr. + if hasattr(a, "_unrecognized_args"): + delattr(a, "_unrecognized_args") + + a._allowed_prefix + a.allowed_postfix_ + assert len(list(a.keys())) == 2 + def test_default_dots(self): + bx1 = Box(default_box=True, box_dots=True) + bx1["a.a.a"] + assert bx1 == {"a": {"a": {"a": {}}}} + a = Box(default_box=True, box_dots=True) - a["a.a.a"] - assert a == {"a.a.a": {}} - a["a.a.a."] - a["a.a.a.."] - assert a == {"a.a.a": {"": {"": {}}}} + a["a."] + a["a.."] + assert a == {"a": {"": {"": {}}}} a["b.b"] = 3 - assert a == {"a.a.a": {"": {"": {}}}, "b.b": 3} + assert a == {"a": {"": {"": {}}}, "b": {"b": 3}} a.b.b = 4 - assert a == {"a.a.a": {"": {"": {}}}, "b.b": 3, "b": {"b": 4}} + assert a == {"a": {"": {"": {}}}, "b": {"b": 4}} assert a["non.existent.key"] == {} + + def test_merge_list_options(self): + a = Box() + a.merge_update({"lister": ["a"]}) + a.merge_update({"lister": ["a", "b", "c"]}, box_merge_lists="extend") + assert a.lister == ["a", "a", "b", "c"] + a.merge_update({"lister": ["a", "b", "c"]}, box_merge_lists="unique") + assert a.lister == ["a", "a", "b", "c"] + a.merge_update({"lister": ["a", "d", "b", "c"]}, box_merge_lists="unique") + assert a.lister == ["a", "a", "b", "c", "d"] + a.merge_update({"key1": {"new": 5}, "Key 2": {"add_key": 6}, "lister": ["a"]}) + assert a.lister == ["a"] + + d1 = {"app": {"S3": {"S3Service": [{"bucket": "bucket001"}]}}} + + d2 = {"app": {"S3": {"S3Service": [{"expirationDate": "2099-10-25"}]}}} + + box1 = Box(d1) + + box1.merge_update(d2, box_merge_lists="extend") + + assert box1 == Box( + {"app": {"S3": {"S3Service": [{"bucket": "bucket001"}, {"expirationDate": "2099-10-25"}]}}} + ), box1 + + def test_box_from_empty_yaml(self): + out = Box.from_yaml("---") + assert out == Box() + + out2 = BoxList.from_yaml("---") + assert out2 == BoxList() + + def test_setdefault_simple(self): + box = Box({"a": 1}) + box.setdefault("b", 2) + box.setdefault("c", "test") + box.setdefault("d", {"e": True}) + box.setdefault("f", [1, 2]) + + assert box["b"] == 2 + assert box["c"] == "test" + assert isinstance(box["d"], Box) + assert box["d"]["e"] == True + assert isinstance(box["f"], BoxList) + assert box["f"][1] == 2 + + def test_setdefault_dots(self): + box = Box({"a": 1}, box_dots=True) + box.setdefault("b", 2) + box.c = {"d": 3} + box.setdefault("c.e", "test") + box.setdefault("d", {"e": True}) + box.setdefault("f", [1, 2]) + + assert box.b == 2 + assert box.c.e == "test" + assert isinstance(box["d"], Box) + assert box.d.e == True + assert isinstance(box["f"], BoxList) + assert box.f[1] == 2 + + def test_setdefault_dots_default(self): + box = Box({"a": 1}, box_dots=True, default_box=True) + box.b.c.d.setdefault("e", 2) + box.c.setdefault("e", "test") + box.d.e.setdefault("f", {"g": True}) + box.e.setdefault("f", [1, 2]) + + assert box["b.c.d"].e == 2 + assert box.c.e == "test" + assert isinstance(box["d.e.f"], Box) + assert box.d.e["f.g"] is True + assert isinstance(box["e.f"], BoxList) + assert box.e.f[1] == 2 + + def test_box_slice(self): + data = Box(qwe=123, asd=234, q=1) + assert data[:-1] == Box(qwe=123, asd=234) + + def test_box_kwargs_should_not_be_included(self): + params = { + "default_box": True, + "default_box_attr": True, + "conversion_box": True, + "frozen_box": True, + "camel_killer_box": True, + "box_safe_prefix": "x", + "box_duplicates": "error", + "default_box_none_transform": True, + "box_dots": True, + "modify_tuples_box": True, + "box_intact_types": (), + "box_recast": {"id": int}, + } + + bx = Box(**params) + assert bx == Box() + + for param in params: + assert param in BOX_PARAMETERS + + def test_box_greek(self): + # WARNING μ is ord 956 whereas µ is ord 181 and will not work due to python NFKC normalization + a = Box() + a.σeq = 1 + a.µeq = 2 + assert a == Box({"σeq": 1, "μeq": 2}) + + def test_box_default_not_create_on_get(self): + box = Box(default_box=True) + + assert box.a.b.c == Box() + + assert box == Box(a=Box(b=Box(c=Box()))) + assert "c" in box.a.b + + box2 = Box(default_box=True, default_box_create_on_get=False) + + assert box2.a.b.c == Box() + + assert "c" not in box2.a.b + + assert box2 == Box() + + def test_box_property_support(self): + class BoxWithProperty(Box): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + @property + def field(self): + return self._field + + @field.setter + def field(self, value): + self._field = value + + @field.deleter + def field(self): + """ + This is required to make `del box.field` work properly otherwise a `BoxKeyError` would be thrown. + """ + del self._field + + box = BoxWithProperty() + box.field = 5 + + assert "field" not in box + assert "_field" in box + assert box.field == 5 + assert box._field == 5 + del box.field + assert not "_field" in box + + def test_box_namespace(self): + bx = Box(default_box=True) + assert bx._box_config["box_namespace"] == () + bx.a.b.c = 5 + assert bx.a._box_config["box_namespace"] == ("a",) + assert bx.a.b._box_config["box_namespace"] == ("a", "b") + bx.x = {"y": {"z": 5}} + assert bx.x._box_config["box_namespace"] == ("x",) + assert bx.x.y._box_config["box_namespace"] == ("x", "y") + bx[None][1][2] = 3 + assert bx[None][1]._box_config["box_namespace"] == (None, 1) + + for modified_box in [ + bx.a + bx.x, + bx.a - bx.x, + bx.a | bx.x, + ]: + assert modified_box._box_config["box_namespace"] == () + assert modified_box.b._box_config["box_namespace"] == ("b",) + assert modified_box.y._box_config["box_namespace"] == ("y",) + + bx.modified = {} + assert bx.modified._box_config["box_namespace"] == ("modified",) + bx.modified += bx.a + assert bx.modified.b._box_config["box_namespace"] == ("modified", "b") + bx.modified |= bx.x + assert bx.modified.y._box_config["box_namespace"] == ("modified", "y") + bx.modified -= bx.a + assert bx.modified._box_config["box_namespace"] == ("modified",) + + bx2 = Box(box_namespace=False) + assert bx2._box_config["box_namespace"] is False + bx2["x"] = {"y": {"z": 5}} + assert bx2._box_config["box_namespace"] is False + assert bx2["x"]._box_config["box_namespace"] is False + + def test_union_frozen_box(self): + my_box = Box(a=5, frozen_box=True) + + assert my_box | {"a": 1} == {"a": 1} + assert {"a": 1} | my_box == {"a": 5} + + def test_default_box_callable(self): + def func(box_instance, key): + return DDBox(bi=str(box_instance), key=key) + + my_box = DDBox(default_box_attr=func) + + assert my_box.a == {"bi": "{}", "key": "a"} diff --git a/test/test_box_list.py b/test/test_box_list.py index 5e975db..9cd90e0 100644 --- a/test/test_box_list.py +++ b/test/test_box_list.py @@ -5,14 +5,17 @@ import json import os import shutil +import sys +import platform from pathlib import Path +from io import StringIO from test.common import test_root, tmp_dir import pytest -import ruamel.yaml as yaml -import toml +from ruamel.yaml import YAML from box import Box, BoxError, BoxList +from box.converters import toml_read_library, toml_write_library class TestBoxList: @@ -32,7 +35,11 @@ def test_box_list(self): assert new_list[-1].item == 22 new_list.append([{"bad_item": 33}]) assert new_list[-1][0].bad_item == 33 - assert repr(new_list).startswith("