├── .black.toml ├── .coveragerc ├── .github └── workflows │ ├── pythonpublish.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── AUTHORS.rst ├── CHANGES.rst ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── box ├── __init__.py ├── box.py ├── box.pyi ├── box_list.py ├── box_list.pyi ├── config_box.py ├── config_box.pyi ├── converters.py ├── converters.pyi ├── exceptions.py ├── exceptions.pyi ├── from_file.py ├── from_file.pyi ├── py.typed ├── shorthand_box.py └── shorthand_box.pyi ├── box_logo.png ├── docs └── 4.x_changes.rst ├── requirements-dev.txt ├── requirements-test.txt ├── requirements.txt ├── setup.py └── test ├── __init__.py ├── common.py ├── data ├── bad_file.txt ├── csv_file.csv ├── json_file.json ├── json_list.json ├── msgpack_file.msgpack ├── msgpack_list.msgpack ├── toml_file.tml ├── yaml_file.yaml └── yaml_list.yaml ├── test_box.py ├── test_box_list.py ├── test_config_box.py ├── test_converters.py ├── test_from_file.py └── test_sbox.py /.black.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | target-version = ['py37', 'py38', 'py39', 'py310', 'py311'] 4 | exclude = ''' 5 | /( 6 | \.eggs 7 | | \.git 8 | | \.idea 9 | | \.pytest_cache 10 | | _build 11 | | build 12 | | dist 13 | | venv 14 | )/ 15 | ''' 16 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = 3 | */python?.?/* 4 | */site-packages/nose/* 5 | */test/* 6 | */pypy/* 7 | */nose/* 8 | */.*/* 9 | */*.egg-info/* 10 | exclude_lines = 11 | pragma: no cover 12 | raise err 13 | except ImportError 14 | yaml = none 15 | raise BoxError\("_box_config key must exist 16 | raise err 17 | if "box_class" in self.__dict__: 18 | raise BoxError\(f"File 19 | except OSError 20 | raise BoxError\(f"{filename} 21 | item._box_config["__box_heritage"] = \(\) 22 | raise BoxKeyError\(err 23 | (.*)is unavailable on this system 24 | def to_ 25 | def from_ 26 | warnings.warn 27 | @classmethod 28 | no package is available to open it 29 | -------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [ created ] 9 | 10 | jobs: 11 | deploy-generic: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: '3.13' 22 | 23 | - name: Install Dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install setuptools wheel twine --upgrade 27 | 28 | 29 | - name: Build and Publish 30 | env: 31 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 32 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 33 | 34 | run: | 35 | python setup.py sdist bdist_wheel 36 | twine upload dist/* 37 | 38 | deploy-cython: 39 | strategy: 40 | matrix: 41 | os: [macos-latest, windows-latest] 42 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 43 | runs-on: ${{ matrix.os }} 44 | 45 | steps: 46 | - uses: actions/checkout@v4 47 | - name: Set up Python ${{ matrix.python-version }} 48 | uses: actions/setup-python@v5 49 | with: 50 | python-version: ${{ matrix.python-version }} 51 | - name: Install dependencies 52 | run: | 53 | python -m pip install --upgrade pip 54 | pip install setuptools wheel twine Cython>=3.0.11 --upgrade 55 | - name: Build and publish 56 | env: 57 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 58 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 59 | run: | 60 | python setup.py bdist_wheel 61 | twine upload dist/* 62 | 63 | deploy-cython-manylinux: 64 | runs-on: ubuntu-latest 65 | steps: 66 | - uses: actions/checkout@v4 67 | - name: Set up Python 3.13 68 | uses: actions/setup-python@v5 69 | with: 70 | python-version: "3.13" 71 | 72 | - name: Build wheels 73 | run: | 74 | python -m pip install --upgrade pip 75 | pip install cibuildwheel setuptools wheel 76 | python -m cibuildwheel --output-dir dist 77 | env: 78 | CIBW_BUILD: cp39-manylinux_x86_64 cp310-manylinux_x86_64 cp311-manylinux_x86_64 cp312-manylinux_x86_64 cp313-manylinux_x86_64 79 | CIBW_BEFORE_BUILD: pip install Cython>=3.0.11 setuptools wheel 80 | CIBW_BEFORE_TEST: pip install -r requirements.txt -r requirements-test.txt setuptools wheel twine Cython>=3.0.11 81 | CIBW_BUILD_VERBOSITY: 1 82 | CIBW_TEST_COMMAND: pytest {package}/test -vv 83 | 84 | - name: Publish 85 | env: 86 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 87 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 88 | run: | 89 | pip install twine 90 | twine upload dist/*-manylinux*.whl 91 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Tests 5 | 6 | on: 7 | push: 8 | branches: [ test, tests ] 9 | pull_request: 10 | branches: [ master, development, develop, test, tests ] 11 | 12 | jobs: 13 | package-checks: 14 | strategy: 15 | matrix: 16 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy-3.10"] 17 | os: [ubuntu-latest, macos-latest, windows-latest] 18 | runs-on: ${{ matrix.os }} 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | # - uses: actions/cache@v3 26 | # with: 27 | # path: ~/.cache/pip 28 | # key: package-check-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install -r requirements.txt 33 | pip install -r requirements-test.txt 34 | pip install coveralls flake8 flake8-print mypy setuptools wheel twine Cython>=3.0.11 35 | - name: Lint with flake8 36 | run: | 37 | # stop the build if there are Python syntax errors, undefined names or print statements 38 | flake8 box --count --select=E9,F63,F7,F82,T001,T002,T003,T004 --show-source --statistics 39 | # exit-zero treats all errors as warnings. 40 | flake8 . --count --exit-zero --max-complexity=20 --max-line-length=120 --statistics --extend-ignore E203 41 | - name: Run mypy 42 | run: mypy box 43 | - name: Build Wheel and check distribution log description 44 | run: | 45 | python setup.py sdist bdist_wheel 46 | twine check dist/* 47 | - name: Test packaged wheel on *nix 48 | if: matrix.os != 'windows-latest' 49 | run: | 50 | pip install dist/*.whl 51 | rm -rf box 52 | python -m pytest -vv 53 | - name: Test packaged wheel on Windows 54 | if: matrix.os == 'windows-latest' 55 | run: | 56 | $wheel = (Get-ChildItem dist\*.whl | Sort lastWriteTime | Select-Object -last 1).Name 57 | pip install dist\${wheel} 58 | Remove-item box -recurse -force 59 | python -m pytest -vv 60 | - name: Upload wheel artifact 61 | uses: actions/upload-artifact@v4 62 | with: 63 | name: python_box_${{matrix.os}}_${{ matrix.python-version }} 64 | path: dist/*.whl 65 | 66 | package-manylinux-checks: 67 | runs-on: ubuntu-latest 68 | steps: 69 | - uses: actions/checkout@v4 70 | - name: Set up Python 3.13 71 | uses: actions/setup-python@v5 72 | with: 73 | python-version: "3.13" 74 | 75 | # - uses: actions/cache@v3 76 | # with: 77 | # path: ~/.cache/pip 78 | # key: package-manylinux-check-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} 79 | 80 | - name: Build wheels 81 | run: | 82 | python -m pip install --upgrade pip 83 | pip install cibuildwheel 84 | python -m cibuildwheel --output-dir dist 85 | env: 86 | CIBW_BUILD: cp39-manylinux_x86_64 cp310-manylinux_x86_64 cp311-manylinux_x86_64 cp312-manylinux_x86_64 cp313-manylinux_x86_64 87 | CIBW_BEFORE_BUILD: pip install Cython>=3.0.11 setuptools wheel 88 | CIBW_BEFORE_TEST: pip install -r requirements.txt -r requirements-test.txt setuptools wheel twine Cython>=3.0.11 89 | CIBW_BUILD_VERBOSITY: 1 90 | CIBW_TEST_COMMAND: pytest {package}/test -vv 91 | 92 | - name: Upload wheel artifact 93 | uses: actions/upload-artifact@v4 94 | with: 95 | name: python_box_manylinux 96 | path: dist/*-manylinux*.whl 97 | 98 | test: 99 | strategy: 100 | matrix: 101 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 102 | os: [ubuntu-latest, macos-latest, windows-latest] 103 | runs-on: ${{ matrix.os }} 104 | steps: 105 | - uses: actions/checkout@v4 106 | - name: Set up Python ${{ matrix.python-version }} 107 | uses: actions/setup-python@v5 108 | with: 109 | python-version: ${{ matrix.python-version }} 110 | # - uses: actions/cache@v3 111 | # with: 112 | # path: ~/.cache/pip 113 | # key: test-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} 114 | - name: Install dependencies 115 | run: | 116 | python -m pip install --upgrade pip 117 | pip install -r requirements.txt 118 | pip install -r requirements-test.txt 119 | pip install setuptools wheel Cython>=3.0.11 120 | python setup.py build_ext --inplace 121 | - name: Test with pytest 122 | env: 123 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 124 | run: | 125 | pytest --cov=box -vv test/ 126 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | .mypy_cache/ 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # IPython Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # dotenv 80 | .env 81 | 82 | # virtualenv 83 | venv/ 84 | venv3/ 85 | ENV/ 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | 93 | .idea/ 94 | .pypirc 95 | release.bat 96 | coverage/ 97 | # don't upload cython files 98 | box/*.c 99 | .pytest_cache/ 100 | 101 | box/*.rst 102 | box/LICENSE 103 | box/*png 104 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | # Identify invalid files 6 | - id: check-ast 7 | - id: check-yaml 8 | - id: check-json 9 | - id: check-toml 10 | # git checks 11 | - id: check-merge-conflict 12 | - id: check-added-large-files 13 | exclude: ^test/data/.+ 14 | - id: detect-private-key 15 | - id: check-case-conflict 16 | # Python checks 17 | - id: check-docstring-first 18 | - id: debug-statements 19 | - id: requirements-txt-fixer 20 | - id: fix-encoding-pragma 21 | - id: fix-byte-order-marker 22 | # General quality checks 23 | # - id: mixed-line-ending 24 | # args: [--fix=lf] 25 | - id: trailing-whitespace 26 | args: [--markdown-linebreak-ext=md] 27 | - id: check-executables-have-shebangs 28 | - id: end-of-file-fixer 29 | exclude: ^test/data/.+ 30 | 31 | - repo: https://github.com/ambv/black 32 | rev: 24.10.0 33 | hooks: 34 | - id: black 35 | args: [--config=.black.toml] 36 | 37 | - repo: local 38 | hooks: 39 | - id: cythonize-check 40 | name: Cythonize 41 | entry: pip install -e . 42 | language: system 43 | types: [python] 44 | pass_filenames: false 45 | # cythonize must come before the pytest to make sure we update all c code 46 | - id: pytest-check 47 | name: Check pytest 48 | entry: pytest 49 | language: system 50 | pass_filenames: false 51 | always_run: true 52 | 53 | - repo: https://github.com/pre-commit/mirrors-mypy 54 | rev: 'v1.13.0' 55 | hooks: 56 | - id: mypy 57 | types: [python] 58 | additional_dependencies: [ruamel.yaml,toml,types-toml,tomli,tomli-w,msgpack,types-PyYAML] 59 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Box is written and maintained by Chris Griffith chris@cdgriffith.com. 2 | 3 | A big thank you to everyone that has helped! From PRs to suggestions and bug 4 | reporting, all input is greatly appreciated! 5 | 6 | Code contributions: 7 | 8 | - Alexandre Decan (AlexandreDecan) 9 | - dhilipsiva (dhilipsiva) 10 | - MAA (FooBarQuaxx) 11 | - Jiang Chen (criver) 12 | - Matan Rosenberg (matan129) 13 | - Matt Wisniewski (polishmatt) 14 | - Martijn Pieters (mjpieters) 15 | - (sdementen) 16 | - Brandon Gomes (bhgomes) 17 | - Stretch (str3tch) 18 | - (pwwang) 19 | - Harun Tuncay (haruntuncay) 20 | - Jeremiah Lowin (jlowin) 21 | - (jandelgado) 22 | - Jonas Irgens Kylling (jkylling) 23 | - Bruno Rocha (rochacbruno) 24 | - Noam Graetz (NoamGraetz2) 25 | - Fabian Affolter (fabaff) 26 | - Varun Madiath (vamega) 27 | - Jacob Hayes (JacobHayes) 28 | - Dominic (Yobmod) 29 | - Ivan Pepelnjak (ipspace) 30 | - Michał Górny (mgorny) 31 | - Serge Lu (Serge45) 32 | - Eric Prestat (ericpre) 33 | - Gabriel Mitelman Tkacz (gtkacz) 34 | - Muspi Merol (CNSeniorious000) 35 | - YISH (mokeyish) 36 | - Bit0r 37 | - Jesper Schlegel (jesperschlegel) 38 | 39 | 40 | Suggestions and bug reporting: 41 | 42 | - JiuLi Gao (gaojiuli) 43 | - Jürgen Hermann (jhermann) 44 | - tilkau [reddit] 45 | - Jumpy89 [reddit] 46 | - can_dry [reddit] 47 | - spidyfan21 [reddit] 48 | - Casey Havenor (chavenor) 49 | - wim glenn (wimglenn) 50 | - Vishwas B Sharma (csurfer) 51 | - John Benediktsson (mrjbq7) 52 | - delirious_lettuce [reddit] 53 | - Justin Iso (justiniso) 54 | - (crazyplum) 55 | - Christopher Toth (ctoth) 56 | - RickS (rshap91) 57 | - askvictor [Hacker News] 58 | - wouter bolsterlee (wbolster) 59 | - Mickaël Thomas (mickael9) 60 | - (pwwang) 61 | - (richieadler) 62 | - V.Anh Tran (tranvietanh1991) 63 | - (ipcoder) 64 | - (cebaa) 65 | - (deluxghost) 66 | - Nikolay Stanishev (nikolaystanishev) 67 | - Craig Quiter (crizCraig) 68 | - Michael Stella (alertedsnake) 69 | - (FunkyLoveCow) 70 | - Kevin Cross (kevinhcross) 71 | - (Patrock) 72 | - Tim Gates (timgates42) 73 | - (iordanivanov) 74 | - Steven McGrath (SteveMcGrath) 75 | - Marcelo Huerta (richieadler) 76 | - Wenbo Zhao (zhaowb) 77 | - Yordan Ivanov (iordanivanov) 78 | - Lei (NEOOOOOOOOOO) 79 | - Pymancer 80 | - Krishna Penukonda (tasercake) 81 | - J Alan Brogan (jalanb) 82 | - Hitz (hitengajjar) 83 | - David Aronchick (aronchick) 84 | - Alexander Kapustin (dyens) 85 | - Marcelo Huerta (richieadler) 86 | - Tim Schwenke (trallnag) 87 | - Marcos Dione (mdione-cloudian) 88 | - Varun Madiath (vamega) 89 | - Rexbard 90 | - Martin Schorfmann (schorfma) 91 | - aviveh21 92 | - Nishikant Parmar (nishikantparmariam) 93 | - Peter B (barmettl) 94 | - Ash A. (dragonpaw) 95 | - Коптев Роман Викторович (romikforest) 96 | - lei wang (191801737) 97 | - d00m514y3r 98 | - Sébastien Weber (seb5g) 99 | - Ward Loos (wrdls) 100 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | Version 7.3.2 5 | ------------- 6 | 7 | * Fixing #288 default get value error when using box_dots (thanks to Sébastien Weber) 8 | 9 | Version 7.3.1 10 | ------------- 11 | 12 | * Fixing #275 default_box_create_on_get is ignored with from_yaml (thanks to Ward Loos) 13 | * Fixing #285 Infinite Recursion when accessing non existent list index in a DefaultBox with box_dots (thanks to Jesper Schlegel) 14 | 15 | 16 | Version 7.3.0 17 | ------------- 18 | 19 | * Adding tests and Cython releases for Python 3.13 20 | * Fixing #281 consistent error message about missing YAML parser (thanks to J vanBemmel) 21 | * Removing support for Python 3.8 as it is EOL 22 | 23 | Version 7.2.0 24 | ------------- 25 | 26 | * Adding #266 support for accessing nested items in BoxList using numpy-style tuple indexing (thanks to Bit0r) 27 | * Adding tests and Cython releases for Python 3.12 28 | * Fixing #251 support for circular references in lists (thanks to Muspi Merol) 29 | * Fixing #261 altering all `__repr__` methods so that subclassing will output the correct class name (thanks to Gabriel Tkacz) 30 | * Fixing #267 Fix type 'int' not iterable (thanks to YISH) 31 | 32 | Version 7.1.1 33 | ------------- 34 | 35 | * Fixing Cython optimized build deployments for linux 36 | 37 | Version 7.1.0 38 | ------------- 39 | 40 | * Adding #255 defer ipython import for large import speed improvements (thanks to Eric Prestat) 41 | * Adding testing for Python 3.12 42 | * Fixing #253 merge_update box list merge types not populated to sub dictionaries (thanks to lei wang) 43 | * Fixing #257 Two test failures due to arguments having incorrect types (thanks to Michał Górny) 44 | * Fixing stub files to match latest code signatures 45 | * Removing #251 support for circular references in lists (thanks to d00m514y3r) 46 | * Removing support for Python 3.7 as it is EOL 47 | 48 | Version 7.0.1 49 | ------------- 50 | 51 | * Switching off of poetry due to multiple build issues 52 | 53 | Version 7.0.0 54 | ------------- 55 | 56 | * Adding #169 default functions with the box_instance and key parameter (thanks to Коптев Роман Викторович) 57 | * Adding #170 Be able to initialize with a flattened dict - by using DDBox (thanks to Ash A.) 58 | * Adding #192 box_dots treats all keys with periods in them as separate keys (thanks to Rexbard) 59 | * Adding #211 support for properties and setters in subclasses (thanks to Serge Lu and David Aronchick) 60 | * Adding #226 namespace to track changes to the box (thanks to Jacob Hayes) 61 | * Adding #236 iPython detection to prevent adding attribute lookup words (thanks to Nishikant Parmar) 62 | * Adding #238 allow ``|`` and ``+`` for frozen boxes (thanks to Peter B) 63 | * Adding new DDBox class (Default Dots Box) that is a subclass of SBox 64 | * Adding #242 more Cython builds using cibuildwheel (thanks to Jacob Hayes) 65 | * Fixing #235 how ``|`` and ``+`` updates were performed for right operations (thanks to aviveh21) 66 | * Fixing #234 typos (thanks to Martin Schorfmann) 67 | * Fixing no implicit optionals with type hinting 68 | * Removing Cython builds for mac until we can build universal2 wheels for arm M1 macs 69 | 70 | Version 6.1.0 71 | ------------- 72 | 73 | * Adding Python 3.11 support 74 | * Adding #195 box_from_string function (thanks to Marcelo Huerta) 75 | * Changing the deprecated ``toml`` package with modern ``tomllib``, ``tomli`` and ``tomli-w`` usage (thanks to Michał Górny) 76 | * Fixing mypy __ior__ type (thanks to Jacob Hayes) 77 | * Fixing line endings with a pre-commit update 78 | * Fixing BoxList was using old style of `super` in internal code usage 79 | 80 | Version 6.0.2 81 | ------------- 82 | 83 | * Fixing that the typing `pyi` files were not included in the manifest (thanks to Julian Torres) 84 | 85 | Version 6.0.1 86 | ------------- 87 | 88 | * Fixing #218 Box dots would not raise KeyError on bad key (thanks to Cliff Wells) 89 | * Fixing #217 wording in readme overview needed updated (thanks to Julie Jones) 90 | 91 | Version 6.0.0 92 | ------------- 93 | 94 | * Adding Cython support to greatly speed up normal Box operations on supported systems 95 | * Adding #161 support for access box dots with `get` and checking with `in` (thanks to scott-createplay) 96 | * Adding #183 support for all allowed character sets (thanks to Giulio Malventi) 97 | * Adding #196 support for sliceable boxes (thanks to Dias) 98 | * Adding #164 default_box_create_on_get toggle to disable setting box variable on get request (thanks to ipcoder) 99 | * Changing #208 __repr__ to produce `eval`-able text (thanks to Jeff Robbins) 100 | * Changing #215 support ruamel.yaml new syntax (thanks to Ivan Pepelnjak) 101 | * Changing `update` and `merge_update` to not use a keyword that could cause issues in rare circumstances 102 | * Changing internal `_safe_key` logic to be twice as fast 103 | * Removing support for ruamel.yaml < 0.17 104 | 105 | Version 5.4.1 106 | ------------- 107 | 108 | * Fixing #205 setdefault behavior with box_dots (thanks to Ivan Pepelnjak) 109 | 110 | Version 5.4.0 111 | ------------- 112 | 113 | * Adding py.typed for mypy support (thanks to Dominic) 114 | * Adding testing for Python 3.10-dev 115 | * Fixing #189 by adding mappings for mypy 116 | * Fixing setdefault behavior with box_dots (thanks to ipcoder) 117 | * Changing #193 how magic methods are handled with default_box (thanks to Rexbard) 118 | 119 | 120 | Version 5.3.0 121 | ------------- 122 | 123 | * Adding support for functions to box_recast (thanks to Jacob Hayes) 124 | * Adding #181 support for extending or adding new items to list during `merge_update` (thanks to Marcos Dione) 125 | * Fixing maintain stacktrace cause for BoxKeyError and BoxValueError (thanks to Jacob Hayes) 126 | * Fixing #177 that emtpy yaml files raised errors instead of returning empty objects (thanks to Tim Schwenke) 127 | * Fixing #171 that `popitems` wasn't first checking if box was frozen (thanks to Varun Madiath) 128 | * Changing all files to LF line endings 129 | * Removing duplicate `box_recast` calls (thanks to Jacob Hayes) 130 | * Removing coveralls code coverage, due to repeated issues with service 131 | 132 | Version 5.2.0 133 | ------------- 134 | 135 | * Adding checks for frozen boxes to `pop`, `popitem` and `clear` (thanks to Varun Madiath) 136 | * Fixing requirements-test.txt (thanks to Fabian Affolter) 137 | * Fixing Flake8 conflicts with black (thanks to Varun Madiath) 138 | * Fixing coveralls update (thanks to Varun Madiath) 139 | 140 | Version 5.1.1 141 | ------------- 142 | 143 | * Adding testing for Python 3.9 144 | * Fixing #165 `box_dots` to work with `default_box` 145 | 146 | Version 5.1.0 147 | ------------- 148 | 149 | * Adding #152 `dotted` option for `items` function (thanks to ipcoder) 150 | * Fixing #157 bug in box.set_default where value is dictionary, return the internal value and not detached temporary (thanks to Noam Graetz) 151 | * Removing warnings on import if optional libraries are missing 152 | 153 | Version 5.0.1 154 | ------------- 155 | 156 | * Fixing #155 default box saving internal method calls and restricted options (thanks to Marcelo Huerta) 157 | 158 | Version 5.0.0 159 | ------------- 160 | 161 | * Adding support for msgpack converters `to_msgpack` and `from_msgpack` 162 | * Adding #144 support for comparision of `Box` to other boxes or dicts via the `-` sub operator (thanks to Hitz) 163 | * Adding support to `|` union boxes like will come default in Python 3.9 from PEP 0584 164 | * Adding `mypy` type checking, `black` formatting and other checks on commit 165 | * Adding #148 new parameter `box_class` for cleaner inheritance (thanks to David Aronchick) 166 | * Adding #152 `dotted` option for `keys` method to return box_dots style keys (thanks to ipcoder) 167 | * Fixing box_dots to properly delete items from lists 168 | * Fixing box_dots to properly find items with dots in their key 169 | * Fixing that recast of subclassses of `Box` or `BoxList` were not fed box properties (thanks to Alexander Kapustin) 170 | * Changing #150 that sub boxes are always created to properly propagate settings and copy objects (thanks to ipcoder) 171 | * Changing #67 that default_box will not raise key errors on `pop` (thanks to Patrock) 172 | * Changing `to_csv` and `from_csv` to have same string and filename options as all other transforms 173 | * Changing #127 back to no required external imports, instead have extra requires like [all] (thanks to wim glenn) 174 | * Changing from putting all details in README.rst to a github wiki at https://github.com/cdgriffith/Box/wiki 175 | * Changing `BoxList.box_class` to be stored in `BoxList.box_options` dict as `box_class` 176 | * Changing `del` will raise `BoxKeyError`, subclass of both `KeyError` and `BoxError` 177 | * Removing support for single level circular references 178 | * Removing readthedocs generation 179 | * Removing overrides for `keys`, `values` and `items` which will return views again 180 | 181 | Version 4.2.3 182 | ------------- 183 | 184 | * Fixing README.md example #149 (thanks to J Alan Brogan) 185 | * Changing `protected_keys` to remove magic methods from dict #146 (thanks to Krishna Penukonda) 186 | 187 | Version 4.2.2 188 | ------------- 189 | 190 | * Fixing `default_box` doesn't first look for safe attributes before falling back to default (thanks to Pymancer) 191 | * Changing from TravisCI to Github Actions 192 | * Changing that due to `default_box` fix, `pop` or `del` no longer raise BoxKeyErrors on missing items (UNCAUGHT BUG) 193 | 194 | Version 4.2.1 195 | ------------- 196 | 197 | * Fixing uncaught print statement (thanks to Bruno Rocha) 198 | * Fixing old references to `box_it_up` in the documentation 199 | 200 | 201 | Version 4.2.0 202 | ------------- 203 | 204 | * Adding optimizations for speed ups to creation and inserts 205 | * Adding internal record of safe attributes for faster lookups, increases memory footprint for speed (thanks to Jonas Irgens Kylling) 206 | * Adding all additional methods specific to `Box` as protected keys 207 | * Fixing `merge_update` from incorrectly calling `__setattr__` which was causing a huge slowdown (thanks to Jonas Irgens Kylling) 208 | * Fixing `copy` and `__copy__` not copying box options 209 | 210 | 211 | Version 4.1.0 212 | ------------- 213 | 214 | * Adding support for list traversal with `box_dots` (thanks to Lei) 215 | * Adding `BoxWarning` class to allow for the clean suppression of warnings 216 | * Fixing default_box_attr to accept items that evaluate to `None` (thanks to Wenbo Zhao and Yordan Ivanov) 217 | * Fixing `BoxList` to properly send internal box options down into new lists 218 | * Fixing issues with conversion and camel killer boxes not being set properly on insert 219 | * Changing default_box to set objects in box on lookup 220 | * Changing `camel_killer` to convert items on insert, which will change the keys when converted back to dict unlike before 221 | * Fallback to `PyYAML` if `ruamel.yaml` is not detected (thanks to wim glenn) 222 | * Removing official support for `pypy` as it's pickling behavior is not the same as CPython 223 | * Removing internal __box_heritage as it was no longer needed due to behavior update 224 | 225 | Version 4.0.4 226 | ------------- 227 | 228 | * Fixing `get` to return None when not using default box (thanks to Jeremiah Lowin) 229 | 230 | Version 4.0.3 231 | ------------- 232 | 233 | * Fixing non-string keys breaking when box_dots is enabled (thanks to Marcelo Huerta) 234 | 235 | Version 4.0.2 236 | ------------- 237 | 238 | * Fixing converters to properly pass through new box arguments (thanks to Marcelo Huerta) 239 | 240 | Version 4.0.1 241 | ------------- 242 | 243 | * Fixing setup.py for release 244 | * Fixing documentation link 245 | 246 | Version 4.0.0 247 | ------------- 248 | 249 | * Adding support for retrieving items via dot notation in keys 250 | * Adding `box_from_file` helper function 251 | * Adding merge_update that acts like previous Box magic update 252 | * Adding support to `+` boxes together 253 | * Adding default_box now can support expanding on `None` placeholders (thanks to Harun Tuncay and Jeremiah Lowin) 254 | * Adding ability to recast specified fields (thanks to Steven McGrath) 255 | * Adding to_csv and from_csv capability for BoxList objects (thanks to Jiuli Gao) 256 | * Changing layout of project to be more object specific 257 | * Changing update to act like normal dict update 258 | * Changing to 120 line character limit 259 | * Changing how `safe_attr` handles unsafe characters 260 | * Changing all exceptions to be bases of BoxError so can always be caught with that base exception 261 | * Changing delete to also access converted keys (thanks to iordanivanov) 262 | * Changing from `PyYAML` to `ruamel.yaml` as default yaml import, aka yaml version default is 1.2 instead of 1.1 263 | * Removing `ordered_box` as Python 3.6+ is ordered by default 264 | * Removing `BoxObject` in favor of it being another module 265 | 266 | Version 3.4.6 267 | ------------- 268 | 269 | * Fixing allowing frozen boxes to be deep copyable (thanks to jandelgado) 270 | 271 | Version 3.4.5 272 | ------------- 273 | 274 | * Fixing update does not convert new sub dictionaries or lists (thanks to Michael Stella) 275 | * Changing update to work as it used to with sub merging until major release 276 | 277 | Version 3.4.4 278 | ------------- 279 | 280 | * Fixing pop not properly resetting box_heritage (thanks to Jeremiah Lowin) 281 | 282 | Version 3.4.3 283 | ------------- 284 | 285 | * Fixing propagation of box options when adding a new list via setdefault (thanks to Stretch) 286 | * Fixing update does not keep box_intact_types (thanks to pwwang) 287 | * Fixing update to operate the same way as a normal dictionary (thanks to Craig Quiter) 288 | * Fixing deepcopy not copying box options (thanks to Nikolay Stanishev) 289 | 290 | Version 3.4.2 291 | ------------- 292 | 293 | * Adding license, changes and authors files to source distribution 294 | 295 | Version 3.4.1 296 | ------------- 297 | 298 | * Fixing copy of inherited classes (thanks to pwwang) 299 | * Fixing `get` when used with default_box 300 | 301 | Version 3.4.0 302 | ------------- 303 | 304 | * Adding `box_intact_types` that allows preservation of selected object types (thanks to pwwang) 305 | * Adding limitations section to readme 306 | 307 | Version 3.3.0 308 | ------------- 309 | 310 | * Adding `BoxObject` (thanks to Brandon Gomes) 311 | 312 | Version 3.2.4 313 | ------------- 314 | 315 | * Fixing recursion issue #68 when using setdefault (thanks to sdementen) 316 | * Fixing ordered_box would make 'ordered_box_values' internal helper as key in sub boxes 317 | 318 | Version 3.2.3 319 | ------------- 320 | 321 | * Fixing pickling with default box (thanks to sdementen) 322 | 323 | Version 3.2.2 324 | ------------- 325 | 326 | * Adding hash abilities to new frozen BoxList 327 | * Fixing hashing returned unpredictable values (thanks to cebaa) 328 | * Fixing update to not handle protected words correctly (thanks to deluxghost) 329 | * Removing non-collection support for mapping and callable identification 330 | 331 | Version 3.2.1 332 | ------------- 333 | 334 | * Fixing pickling on python 3.7 (thanks to Martijn Pieters) 335 | * Fixing rumel loader error (thanks to richieadler) 336 | * Fixing frozen_box does not freeze the outermost BoxList (thanks to V.Anh Tran) 337 | 338 | Version 3.2.0 339 | ------------- 340 | 341 | * Adding `ordered_box` option to keep key order based on insertion (thanks to pwwang) 342 | * Adding custom `__iter__`, `__revered__`, `pop`, `popitems` 343 | * Fixing ordering of camel_case_killer vs default_box (thanks to Matan Rosenberg) 344 | * Fixing non string keys not being supported correctly (thanks to Matt Wisniewski) 345 | 346 | Version 3.1.1 347 | ------------- 348 | 349 | * Fixing `__contains__` (thanks to Jiang Chen) 350 | * Fixing `get` could return non box objects 351 | 352 | Version 3.1.0 353 | ------------- 354 | 355 | * Adding `copy` and `deepcopy` support that with return a Box object 356 | * Adding support for customizable safe attr replacement 357 | * Adding custom error for missing keys 358 | * Changing that for this 3.x release, 2.6 support exists 359 | * Fixing that a recursion loop could occur if `_box_config` was somehow removed 360 | * Fixing pickling 361 | 362 | Version 3.0.1 363 | ------------- 364 | 365 | * Fixing first level recursion errors 366 | * Fixing spelling mistakes (thanks to John Benediktsson) 367 | * Fixing that list insert of lists did not use the original list but create an empty one 368 | 369 | Version 3.0.0 370 | ------------- 371 | 372 | * Adding default object abilities with `default_box` and `default_box_attr` kwargs 373 | * Adding `from_json` and `from_yaml` functions to both `Box` and `BoxList` 374 | * Adding `frozen_box` option 375 | * Adding `BoxError` exception for custom errors 376 | * Adding `conversion_box` to automatically try to find matching attributes 377 | * Adding `camel_killer_box` that converts CamelCaseKeys to camel_case_keys 378 | * Adding `SBox` that has `json` and `yaml` properties that map to default `to_json()` and `to_yaml()` 379 | * Adding `box_it_up` property that will make sure all boxes are created and populated like previous version 380 | * Adding `modify_tuples_box` option to recreate tuples with Boxes instead of dicts 381 | * Adding `to_json` and `to_yaml` for `BoxList` 382 | * Changing how the Box object works, to conversion on extraction 383 | * Removing `__call__` for compatibly with django and to make more like dict object 384 | * Removing support for python 2.6 385 | * Removing `LightBox` 386 | * Removing default indent for `to_json` 387 | 388 | Version 2.2.0 389 | ------------- 390 | 391 | * Adding support for `ruamel.yaml` (Thanks to Alexandre Decan) 392 | * Adding Contributing and Authors files 393 | 394 | Version 2.1.0 395 | ------------- 396 | 397 | * Adding `.update` and `.set_default` functionality 398 | * Adding `dir` support 399 | 400 | Version 2.0.0 401 | ------------- 402 | 403 | * Adding `BoxList` to allow for any `Box` to be recursively added to lists as well 404 | * Adding `to_json` and `to_yaml` functions 405 | * Changing `Box` original functionality to `LightBox`, `Box` now searches lists 406 | * Changing `Box` callable to return keys, not values, and they are sorted 407 | * Removing `tree_view` as near same can be seen with YAML 408 | 409 | 410 | Version 1.0.0 411 | ------------- 412 | 413 | * Initial release, copy from `reusables.Namespace` 414 | * Original creation, 2\13\2014 415 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing to Box 2 | =================== 3 | 4 | Thank you for looking into supporting Box. Any constructive input 5 | is greatly appreciated! 6 | 7 | Questions and Ideas 8 | ------------------- 9 | 10 | Even if you don't have code contributions, but just an idea, or a question about 11 | Box, please feel free to open an issue! 12 | 13 | Reporting Bugs 14 | -------------- 15 | 16 | - Please include sample code and traceback (or unexpected behavior) 17 | of the error you are experiencing. 18 | 19 | - Also include Python version and Operating System. 20 | 21 | Pull Requests 22 | ------------- 23 | 24 | - Follow PEP8 25 | 26 | - Select to merge into `develop` branch, NOT `master` 27 | 28 | - New features should have 29 | - Reasoning for addition in pull request 30 | - Docstring with code block example and parameters 31 | - Tests with as much coverage as reasonable 32 | - Tests should go through both sad and happy paths 33 | 34 | - Bug fixes should include 35 | - Explain under which circumstances the bug occurs in the pull request 36 | - Tests for new happy and sad paths 37 | - Test proving error without new code 38 | 39 | - Update CHANGES.rst to include new feature or fix 40 | 41 | - Add yourself to AUTHORS.rst! 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2023 Chris Griffith 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include AUTHORS.rst 3 | include CHANGES.rst 4 | include box/py.typed 5 | include box/*.c 6 | include box/*.so 7 | include box/*.pyd 8 | include box/*.pyi 9 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |BuildStatus| |License| 2 | 3 | |BoxImage| 4 | 5 | .. code:: python 6 | 7 | from box import Box 8 | 9 | movie_box = Box({ "Robin Hood: Men in Tights": { "imdb stars": 6.7, "length": 104 } }) 10 | 11 | movie_box.Robin_Hood_Men_in_Tights.imdb_stars 12 | # 6.7 13 | 14 | 15 | Box will automatically make otherwise inaccessible keys safe to access as an attribute. 16 | You can always pass `conversion_box=False` to `Box` to disable that behavior. 17 | Also, all new dict and lists added to a Box or BoxList object are converted automatically. 18 | 19 | There are over a half dozen ways to customize your Box and make it work for you. 20 | 21 | Check out the new `Box github wiki `_ for more details and examples! 22 | 23 | Install 24 | ======= 25 | 26 | **Version Pin Your Box!** 27 | 28 | If you aren't in the habit of version pinning your libraries, it will eventually bite you. 29 | Box has a `list of breaking change `_ between major versions you should always check out before updating. 30 | 31 | requirements.txt 32 | ---------------- 33 | 34 | .. code:: text 35 | 36 | python-box[all]~=7.0 37 | 38 | As Box adheres to semantic versioning (aka API changes will only occur on between major version), 39 | it is best to use `Compatible release `_ matching using the `~=` clause. 40 | 41 | Install from command line 42 | ------------------------- 43 | 44 | .. code:: bash 45 | 46 | python -m pip install --upgrade pip 47 | pip install python-box[all]~=7.0 --upgrade 48 | 49 | Install with selected dependencies 50 | ---------------------------------- 51 | 52 | Box does not install external dependencies such as yaml and toml writers. Instead you can specify which you want, 53 | for example, `[all]` is shorthand for: 54 | 55 | .. code:: bash 56 | 57 | pip install python-box[ruamel.yaml,tomli_w,msgpack]~=7.0 --upgrade 58 | 59 | But you can also sub out `ruamel.yaml` for `PyYAML`. 60 | 61 | Check out `more details `_ on installation details. 62 | 63 | Box 7 is tested on python 3.7+, if you are upgrading from previous versions, please look through 64 | `any breaking changes and new features `_. 65 | 66 | Optimized Version 67 | ----------------- 68 | 69 | Box has introduced Cython optimizations for major platforms by default. 70 | Loading large data sets can be up to 10x faster! 71 | 72 | If you are **not** on a x86_64 supported system you will need to do some extra work to install the optimized version. 73 | There will be an warning of "WARNING: Cython not installed, could not optimize box" during install. 74 | You will need python development files, system compiler, and the python packages `Cython` and `wheel`. 75 | 76 | **Linux Example:** 77 | 78 | First make sure you have python development files installed (`python3-dev` or `python3-devel` in most repos). 79 | You will then need `Cython` and `wheel` installed and then install (or re-install with `--force`) `python-box`. 80 | 81 | .. code:: bash 82 | 83 | pip install Cython wheel 84 | pip install python-box[all]~=7.0 --upgrade --force 85 | 86 | If you have any issues please open a github issue with the error you are experiencing! 87 | 88 | Overview 89 | ======== 90 | 91 | `Box` is designed to be a near transparent drop in replacements for 92 | dictionaries that add dot notation access and other powerful feature. 93 | 94 | There are a lot of `types of boxes `_ 95 | to customize it for your needs, as well as handy `converters `_! 96 | 97 | Keep in mind any sub dictionaries or ones set after initiation will be automatically converted to 98 | a `Box` object, and lists will be converted to `BoxList`, all other objects stay intact. 99 | 100 | Check out the `Quick Start `_ for more in depth details. 101 | 102 | `Box` can be instantiated the same ways as `dict`. 103 | 104 | .. code:: python 105 | 106 | Box({'data': 2, 'count': 5}) 107 | Box(data=2, count=5) 108 | Box({'data': 2, 'count': 1}, count=5) 109 | Box([('data', 2), ('count', 5)]) 110 | 111 | # All will create 112 | # 113 | 114 | `Box` is a subclass of `dict` which overrides some base functionality to make 115 | sure everything stored in the dict can be accessed as an attribute or key value. 116 | 117 | .. code:: python 118 | 119 | small_box = Box({'data': 2, 'count': 5}) 120 | small_box.data == small_box['data'] == getattr(small_box, 'data') 121 | 122 | All dicts (and lists) added to a `Box` will be converted on insertion to a `Box` (or `BoxList`), 123 | allowing for recursive dot notation access. 124 | 125 | `Box` also includes helper functions to transform it back into a `dict`, 126 | as well as into `JSON`, `YAML`, `TOML`, or `msgpack` strings or files. 127 | 128 | 129 | Thanks 130 | ====== 131 | 132 | A huge thank you to everyone that has given features and feedback over the years to Box! Check out everyone that has contributed_. 133 | 134 | A big thanks to Python Software Foundation, and PSF-Trademarks Committee, for official approval to use the Python logo on the `Box` logo! 135 | 136 | Also special shout-out to PythonBytes_, who featured Box on their podcast. 137 | 138 | 139 | License 140 | ======= 141 | 142 | MIT License, Copyright (c) 2017-2023 Chris Griffith. See LICENSE_ file. 143 | 144 | 145 | .. |BoxImage| image:: https://raw.githubusercontent.com/cdgriffith/Box/master/box_logo.png 146 | :target: https://github.com/cdgriffith/Box 147 | .. |BuildStatus| image:: https://github.com/cdgriffith/Box/workflows/Tests/badge.svg?branch=master 148 | :target: https://github.com/cdgriffith/Box/actions?query=workflow%3ATests 149 | .. |License| image:: https://img.shields.io/pypi/l/python-box.svg 150 | :target: https://pypi.python.org/pypi/python-box/ 151 | 152 | .. _PythonBytes: https://pythonbytes.fm/episodes/show/19/put-your-python-dictionaries-in-a-box-and-apparently-python-is-really-wanted 153 | .. _contributed: AUTHORS.rst 154 | .. _`Wrapt Documentation`: https://wrapt.readthedocs.io/en/latest 155 | .. _reusables: https://github.com/cdgriffith/reusables#reusables 156 | .. _created: https://github.com/cdgriffith/Reusables/commit/df20de4db74371c2fedf1578096f3e29c93ccdf3#diff-e9a0f470ef3e8afb4384dc2824943048R51 157 | .. _LICENSE: https://github.com/cdgriffith/Box/blob/master/LICENSE 158 | -------------------------------------------------------------------------------- /box/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | __author__ = "Chris Griffith" 5 | __version__ = "7.3.2" 6 | 7 | from box.box import Box 8 | from box.box_list import BoxList 9 | from box.config_box import ConfigBox 10 | from box.exceptions import BoxError, BoxKeyError 11 | from box.from_file import box_from_file, box_from_string 12 | from box.shorthand_box import SBox, DDBox 13 | import box.converters 14 | 15 | __all__ = [ 16 | "Box", 17 | "BoxList", 18 | "ConfigBox", 19 | "BoxError", 20 | "BoxKeyError", 21 | "box_from_file", 22 | "SBox", 23 | "DDBox", 24 | ] 25 | -------------------------------------------------------------------------------- /box/box.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright (c) 2017-2023 - Chris Griffith - MIT License 5 | """ 6 | Improved dictionary access through dot notation with additional tools. 7 | """ 8 | import copy 9 | import re 10 | import warnings 11 | from keyword import iskeyword 12 | from os import PathLike 13 | from typing import Any, Dict, Generator, List, Optional, Tuple, Type, Union, Literal 14 | from inspect import signature 15 | 16 | try: 17 | from typing import Callable, Iterable, Mapping 18 | except ImportError: 19 | from collections.abc import Callable, Iterable, Mapping 20 | 21 | 22 | import box 23 | from box.converters import ( 24 | BOX_PARAMETERS, 25 | _from_json, 26 | _from_msgpack, 27 | _from_toml, 28 | _from_yaml, 29 | _to_json, 30 | _to_msgpack, 31 | _to_toml, 32 | _to_yaml, 33 | msgpack_available, 34 | toml_read_library, 35 | toml_write_library, 36 | yaml_available, 37 | ) 38 | from box.exceptions import BoxError, BoxKeyError, BoxTypeError, BoxValueError, BoxWarning 39 | 40 | __all__ = ["Box"] 41 | 42 | _first_cap_re = re.compile("(.)([A-Z][a-z]+)") 43 | _all_cap_re = re.compile("([a-z0-9])([A-Z])") 44 | _list_pos_re = re.compile(r"\[(\d+)\]") 45 | 46 | # a sentinel object for indicating no default, in order to allow users 47 | # to pass `None` as a valid default value 48 | NO_DEFAULT = object() 49 | # a sentinel object for indicating when to skip adding a new namespace, allowing `None` keys 50 | NO_NAMESPACE = object() 51 | 52 | 53 | def _is_ipython(): 54 | try: 55 | from IPython import get_ipython 56 | except ImportError: 57 | ipython = False 58 | else: 59 | ipython = True if get_ipython() else False 60 | 61 | return ipython 62 | 63 | 64 | def _exception_cause(e): 65 | """ 66 | Unwrap BoxKeyError and BoxValueError errors to their cause. 67 | 68 | Use with `raise ... from _exception_cause(err)` to avoid deeply nested stacktraces, but keep the 69 | context. 70 | """ 71 | return e.__cause__ if isinstance(e, (BoxKeyError, BoxValueError)) else e 72 | 73 | 74 | def _camel_killer(attr): 75 | """ 76 | CamelKiller, qu'est-ce que c'est? 77 | 78 | Taken from http://stackoverflow.com/a/1176023/3244542 79 | """ 80 | attr = str(attr) 81 | 82 | s1 = _first_cap_re.sub(r"\1_\2", attr) 83 | s2 = _all_cap_re.sub(r"\1_\2", s1) 84 | return re.sub(" *_+", "_", s2.lower()) 85 | 86 | 87 | def _recursive_tuples(iterable, box_class, recreate_tuples=False, **kwargs): 88 | out_list = [] 89 | for i in iterable: 90 | if isinstance(i, dict): 91 | out_list.append(box_class(i, **kwargs)) 92 | elif isinstance(i, list) or (recreate_tuples and isinstance(i, tuple)): 93 | out_list.append(_recursive_tuples(i, box_class, recreate_tuples, **kwargs)) 94 | else: 95 | out_list.append(i) 96 | return tuple(out_list) 97 | 98 | 99 | def _parse_box_dots(bx, item, setting=False): 100 | for idx, char in enumerate(item): 101 | if char == "[": 102 | return item[:idx], item[idx:] 103 | elif char == ".": 104 | return item[:idx], item[idx + 1 :] 105 | if setting and "." in item: 106 | return item.split(".", 1) 107 | raise BoxError("Could not split box dots properly") 108 | 109 | 110 | def _get_dot_paths(bx, current=""): 111 | """A generator of all the end node keys in a box in box_dots format""" 112 | 113 | def handle_dicts(sub_bx, paths=""): 114 | for key, value in sub_bx.items(): 115 | yield f"{paths}.{key}" if paths else key 116 | if isinstance(value, dict): 117 | yield from handle_dicts(value, f"{paths}.{key}" if paths else key) 118 | elif isinstance(value, list): 119 | yield from handle_lists(value, f"{paths}.{key}" if paths else key) 120 | 121 | def handle_lists(bx_list, paths=""): 122 | for i, value in enumerate(bx_list): 123 | yield f"{paths}[{i}]" 124 | if isinstance(value, list): 125 | yield from handle_lists(value, f"{paths}[{i}]") 126 | if isinstance(value, dict): 127 | yield from handle_dicts(value, f"{paths}[{i}]") 128 | 129 | yield from handle_dicts(bx, current) 130 | 131 | 132 | def _get_box_config(): 133 | return { 134 | # Internal use only 135 | "__created": False, 136 | "__safe_keys": {}, 137 | } 138 | 139 | 140 | def _get_property_func(obj, key): 141 | """ 142 | Try to get property helper functions of given object and property name. 143 | 144 | :param obj: object to be checked for property 145 | :param key: property name 146 | :return: a tuple for helper functions(fget, fset, fdel). If no such property, a (None, None, None) returns 147 | """ 148 | obj_type = type(obj) 149 | 150 | if not hasattr(obj_type, key): 151 | return None, None, None 152 | attr = getattr(obj_type, key) 153 | return attr.fget, attr.fset, attr.fdel 154 | 155 | 156 | class Box(dict): 157 | """ 158 | Improved dictionary access through dot notation with additional tools. 159 | 160 | :param default_box: Similar to defaultdict, return a default value 161 | :param default_box_attr: Specify the default replacement. 162 | WARNING: If this is not the default 'Box', it will not be recursive 163 | :param default_box_none_transform: When using default_box, treat keys with none values as absent. True by default 164 | :param default_box_create_on_get: On lookup of a key that doesn't exist, create it if missing 165 | :param frozen_box: After creation, the box cannot be modified 166 | :param camel_killer_box: Convert CamelCase to snake_case 167 | :param conversion_box: Check for near matching keys as attributes 168 | :param modify_tuples_box: Recreate incoming tuples with dicts into Boxes 169 | :param box_safe_prefix: Conversion box prefix for unsafe attributes 170 | :param box_duplicates: "ignore", "error" or "warn" when duplicates exists in a conversion_box 171 | :param box_intact_types: tuple of types to ignore converting 172 | :param box_recast: cast certain keys to a specified type 173 | :param box_dots: access nested Boxes by period separated keys in string 174 | :param box_class: change what type of class sub-boxes will be created as 175 | :param box_namespace: the namespace this (possibly nested) Box lives within 176 | """ 177 | 178 | _box_config: Dict[str, Any] 179 | 180 | _protected_keys = [ 181 | "to_dict", 182 | "to_json", 183 | "to_yaml", 184 | "from_yaml", 185 | "from_json", 186 | "from_toml", 187 | "to_toml", 188 | "merge_update", 189 | ] + [attr for attr in dir({}) if not attr.startswith("_")] 190 | 191 | def __new__( 192 | cls, 193 | *args: Any, 194 | default_box: bool = False, 195 | default_box_attr: Any = NO_DEFAULT, 196 | default_box_none_transform: bool = True, 197 | default_box_create_on_get: bool = True, 198 | frozen_box: bool = False, 199 | camel_killer_box: bool = False, 200 | conversion_box: bool = True, 201 | modify_tuples_box: bool = False, 202 | box_safe_prefix: str = "x", 203 | box_duplicates: str = "ignore", 204 | box_intact_types: Union[Tuple, List] = (), 205 | box_recast: Optional[Dict] = None, 206 | box_dots: bool = False, 207 | box_class: Optional[Union[Dict, Type["Box"]]] = None, 208 | box_namespace: Union[Tuple[str, ...], Literal[False]] = (), 209 | **kwargs: Any, 210 | ): 211 | """ 212 | Due to the way pickling works in python 3, we need to make sure 213 | the box config is created as early as possible. 214 | """ 215 | obj = super().__new__(cls, *args, **kwargs) 216 | obj._box_config = _get_box_config() 217 | obj._box_config.update( 218 | { 219 | "default_box": default_box, 220 | "default_box_attr": cls.__class__ if default_box_attr is NO_DEFAULT else default_box_attr, 221 | "default_box_none_transform": default_box_none_transform, 222 | "default_box_create_on_get": default_box_create_on_get, 223 | "conversion_box": conversion_box, 224 | "box_safe_prefix": box_safe_prefix, 225 | "frozen_box": frozen_box, 226 | "camel_killer_box": camel_killer_box, 227 | "modify_tuples_box": modify_tuples_box, 228 | "box_duplicates": box_duplicates, 229 | "box_intact_types": tuple(box_intact_types), 230 | "box_recast": box_recast, 231 | "box_dots": box_dots, 232 | "box_class": box_class if box_class is not None else Box, 233 | "box_namespace": box_namespace, 234 | } 235 | ) 236 | return obj 237 | 238 | def __init__( 239 | self, 240 | *args: Any, 241 | default_box: bool = False, 242 | default_box_attr: Any = NO_DEFAULT, 243 | default_box_none_transform: bool = True, 244 | default_box_create_on_get: bool = True, 245 | frozen_box: bool = False, 246 | camel_killer_box: bool = False, 247 | conversion_box: bool = True, 248 | modify_tuples_box: bool = False, 249 | box_safe_prefix: str = "x", 250 | box_duplicates: str = "ignore", 251 | box_intact_types: Union[Tuple, List] = (), 252 | box_recast: Optional[Dict] = None, 253 | box_dots: bool = False, 254 | box_class: Optional[Union[Dict, Type["Box"]]] = None, 255 | box_namespace: Union[Tuple[str, ...], Literal[False]] = (), 256 | **kwargs: Any, 257 | ): 258 | super().__init__() 259 | self._box_config = _get_box_config() 260 | self._box_config.update( 261 | { 262 | "default_box": default_box, 263 | "default_box_attr": self.__class__ if default_box_attr is NO_DEFAULT else default_box_attr, 264 | "default_box_none_transform": default_box_none_transform, 265 | "default_box_create_on_get": default_box_create_on_get, 266 | "conversion_box": conversion_box, 267 | "box_safe_prefix": box_safe_prefix, 268 | "frozen_box": frozen_box, 269 | "camel_killer_box": camel_killer_box, 270 | "modify_tuples_box": modify_tuples_box, 271 | "box_duplicates": box_duplicates, 272 | "box_intact_types": tuple(box_intact_types), 273 | "box_recast": box_recast, 274 | "box_dots": box_dots, 275 | "box_class": box_class if box_class is not None else self.__class__, 276 | "box_namespace": box_namespace, 277 | } 278 | ) 279 | if not self._box_config["conversion_box"] and self._box_config["box_duplicates"] != "ignore": 280 | raise BoxError("box_duplicates are only for conversion_boxes") 281 | if len(args) == 1: 282 | if isinstance(args[0], str): 283 | raise BoxValueError("Cannot extrapolate Box from string") 284 | if isinstance(args[0], Mapping): 285 | for k, v in args[0].items(): 286 | if v is args[0]: 287 | v = self 288 | if v is None and self._box_config["default_box"] and self._box_config["default_box_none_transform"]: 289 | continue 290 | self.__setitem__(k, v) 291 | elif isinstance(args[0], Iterable): 292 | for k, v in args[0]: 293 | self.__setitem__(k, v) 294 | else: 295 | raise BoxValueError("First argument must be mapping or iterable") 296 | elif args: 297 | raise BoxTypeError(f"Box expected at most 1 argument, got {len(args)}") 298 | 299 | for k, v in kwargs.items(): 300 | if args and isinstance(args[0], Mapping) and v is args[0]: 301 | v = self 302 | self.__setitem__(k, v) 303 | 304 | self._box_config["__created"] = True 305 | 306 | def __add__(self, other: Mapping[Any, Any]): 307 | if not isinstance(other, dict): 308 | raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") 309 | new_box = self.copy() 310 | new_box._box_config["frozen_box"] = False 311 | new_box.merge_update(other) # type: ignore[attr-defined] 312 | new_box._box_config["frozen_box"] = self._box_config["frozen_box"] 313 | return new_box 314 | 315 | def __radd__(self, other: Mapping[Any, Any]): 316 | if not isinstance(other, dict): 317 | raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") 318 | 319 | new_box = other.copy() 320 | if not isinstance(other, Box): 321 | new_box = self._box_config["box_class"](new_box) 322 | new_box._box_config["frozen_box"] = False # type: ignore[attr-defined] 323 | new_box.merge_update(self) # type: ignore[attr-defined] 324 | new_box._box_config["frozen_box"] = self._box_config["frozen_box"] # type: ignore[attr-defined] 325 | return new_box 326 | 327 | def __iadd__(self, other: Mapping[Any, Any]): 328 | if not isinstance(other, dict): 329 | raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") 330 | self.merge_update(other) 331 | return self 332 | 333 | def __or__(self, other: Mapping[Any, Any]): 334 | if not isinstance(other, dict): 335 | raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") 336 | new_box = self.copy() 337 | new_box._box_config["frozen_box"] = False 338 | new_box.update(other) # type: ignore[attr-defined] 339 | new_box._box_config["frozen_box"] = self._box_config["frozen_box"] 340 | return new_box 341 | 342 | def __ror__(self, other: Mapping[Any, Any]): 343 | if not isinstance(other, dict): 344 | raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") 345 | new_box = other.copy() 346 | if not isinstance(other, Box): 347 | new_box = self._box_config["box_class"](new_box) 348 | new_box._box_config["frozen_box"] = False # type: ignore[attr-defined] 349 | new_box.update(self) # type: ignore[attr-defined] 350 | new_box._box_config["frozen_box"] = self._box_config["frozen_box"] # type: ignore[attr-defined] 351 | return new_box 352 | 353 | def __ior__(self, other: Mapping[Any, Any]): # type: ignore[override] 354 | if not isinstance(other, dict): 355 | raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") 356 | self.update(other) 357 | return self 358 | 359 | def __sub__(self, other: Mapping[Any, Any]): 360 | frozen = self._box_config["frozen_box"] 361 | config = self.__box_config() 362 | config["frozen_box"] = False 363 | config.pop("box_namespace") # Detach namespace; it will be reassigned if we nest again 364 | output = self._box_config["box_class"](**config) 365 | if not isinstance(other, dict): 366 | raise BoxError("Box can only compare two boxes or a box and a dictionary.") 367 | if not isinstance(other, Box): 368 | other = self._box_config["box_class"](other, **config) 369 | for item in self: 370 | if item not in other: 371 | output[item] = self[item] 372 | elif isinstance(self.get(item), Box) and isinstance(other.get(item), Box): 373 | output[item] = self[item] - other[item] 374 | if not output[item]: 375 | del output[item] 376 | output._box_config["frozen_box"] = frozen 377 | return output 378 | 379 | def __hash__(self): 380 | if self._box_config["frozen_box"]: 381 | hashing = 54321 382 | for item in self.items(): 383 | hashing ^= hash(item) 384 | return hashing 385 | raise BoxTypeError('unhashable type: "Box"') 386 | 387 | def __dir__(self) -> List[str]: 388 | items = set(super().__dir__()) 389 | # Only show items accessible by dot notation 390 | for key in self.keys(): 391 | key = str(key) 392 | if key.isidentifier() and not iskeyword(key): 393 | items.add(key) 394 | 395 | for key in self.keys(): 396 | if key not in items: 397 | if self._box_config["conversion_box"]: 398 | key = self._safe_attr(key) 399 | if key: 400 | items.add(key) 401 | 402 | return list(items) 403 | 404 | def __contains__(self, item): 405 | in_me = super().__contains__(item) 406 | if not self._box_config["box_dots"] or not isinstance(item, str): 407 | return in_me 408 | if in_me: 409 | return True 410 | if "." not in item: 411 | return False 412 | try: 413 | first_item, children = _parse_box_dots(self, item) 414 | except BoxError: 415 | return False 416 | else: 417 | if not super().__contains__(first_item): 418 | return False 419 | it = self[first_item] 420 | return isinstance(it, Iterable) and children in it 421 | 422 | def keys(self, dotted: Union[bool] = False): 423 | if not dotted: 424 | return super().keys() 425 | 426 | if not self._box_config["box_dots"]: 427 | raise BoxError("Cannot return dotted keys as this Box does not have `box_dots` enabled") 428 | 429 | keys = set() 430 | for key, value in self.items(): 431 | added = False 432 | if isinstance(key, str): 433 | if isinstance(value, Box): 434 | for sub_key in value.keys(dotted=True): 435 | keys.add(f"{key}.{sub_key}") 436 | added = True 437 | elif isinstance(value, box.BoxList): 438 | for pos in value._dotted_helper(): 439 | keys.add(f"{key}{pos}") 440 | added = True 441 | if not added: 442 | keys.add(key) 443 | return sorted(keys, key=lambda x: str(x)) 444 | 445 | def items(self, dotted: Union[bool] = False): 446 | if not dotted: 447 | return super().items() 448 | 449 | if not self._box_config["box_dots"]: 450 | raise BoxError("Cannot return dotted keys as this Box does not have `box_dots` enabled") 451 | 452 | return [(k, self[k]) for k in self.keys(dotted=True)] 453 | 454 | def get(self, key, default=NO_DEFAULT): 455 | if key not in self: 456 | if default is NO_DEFAULT: 457 | if self._box_config["default_box"] and self._box_config["default_box_none_transform"]: 458 | return self.__get_default(key) 459 | else: 460 | return None 461 | if isinstance(default, dict) and not isinstance(default, Box): 462 | return Box(default) 463 | if isinstance(default, list) and not isinstance(default, box.BoxList): 464 | return box.BoxList(default) 465 | return default 466 | return self[key] 467 | 468 | def copy(self) -> "Box": 469 | config = self.__box_config() 470 | config.pop("box_namespace") # Detach namespace; it will be reassigned if we nest again 471 | return Box(super().copy(), **config) 472 | 473 | def __copy__(self) -> "Box": 474 | return self.copy() 475 | 476 | def __deepcopy__(self, memodict=None) -> "Box": 477 | frozen = self._box_config["frozen_box"] 478 | config = self.__box_config() 479 | config["frozen_box"] = False 480 | out = self._box_config["box_class"](**config) 481 | memodict = memodict or {} 482 | memodict[id(self)] = out 483 | for k, v in self.items(): 484 | out[copy.deepcopy(k, memodict)] = copy.deepcopy(v, memodict) 485 | out._box_config["frozen_box"] = frozen 486 | return out 487 | 488 | def __setstate__(self, state): 489 | self._box_config = state["_box_config"] 490 | self.__dict__.update(state) 491 | 492 | def __get_default(self, item, attr=False): 493 | if item in ("getdoc", "shape") and _is_ipython(): 494 | return None 495 | default_value = self._box_config["default_box_attr"] 496 | if default_value in (self._box_config["box_class"], dict): 497 | value = self._box_config["box_class"](**self.__box_config(extra_namespace=item)) 498 | elif isinstance(default_value, dict): 499 | value = self._box_config["box_class"](**self.__box_config(extra_namespace=item), **default_value) 500 | elif isinstance(default_value, list): 501 | value = box.BoxList(**self.__box_config(extra_namespace=item)) 502 | elif isinstance(default_value, Callable): 503 | args = [] 504 | kwargs = {} 505 | p_sigs = [ 506 | p.name 507 | for p in signature(default_value).parameters.values() 508 | if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) 509 | ] 510 | k_sigs = [p.name for p in signature(default_value).parameters.values() if p.kind is p.KEYWORD_ONLY] 511 | for name in p_sigs: 512 | if name not in ("key", "box_instance"): 513 | raise BoxError("default_box_attr can only have the arguments 'key' and 'box_instance'") 514 | if "key" in p_sigs: 515 | args.append(item) 516 | if "box_instance" in p_sigs: 517 | args.insert(p_sigs.index("box_instance"), self) 518 | if "key" in k_sigs: 519 | kwargs["key"] = item 520 | if "box_instance" in k_sigs: 521 | kwargs["box_instance"] = self 522 | value = default_value(*args, **kwargs) 523 | elif hasattr(default_value, "copy"): 524 | value = default_value.copy() 525 | else: 526 | value = default_value 527 | if self._box_config["default_box_create_on_get"]: 528 | if not attr or not (item.startswith("_") and item.endswith("_")): 529 | if self._box_config["box_dots"] and isinstance(item, str) and ("." in item or "[" in item): 530 | first_item, children = _parse_box_dots(self, item, setting=True) 531 | if first_item in self.keys(): 532 | if hasattr(self[first_item], "__setitem__"): 533 | self[first_item].__setitem__(children, value) 534 | else: 535 | super().__setitem__( 536 | first_item, self._box_config["box_class"](**self.__box_config(extra_namespace=first_item)) 537 | ) 538 | self[first_item].__setitem__(children, value) 539 | else: 540 | super().__setitem__(item, value) 541 | return value 542 | 543 | def __box_config(self, extra_namespace: Any = NO_NAMESPACE) -> Dict: 544 | out = {} 545 | for k, v in self._box_config.copy().items(): 546 | if not k.startswith("__"): 547 | out[k] = v 548 | if extra_namespace is not NO_NAMESPACE and self._box_config["box_namespace"] is not False: 549 | out["box_namespace"] = (*out["box_namespace"], extra_namespace) 550 | return out 551 | 552 | def __recast(self, item, value): 553 | if self._box_config["box_recast"] and item in self._box_config["box_recast"]: 554 | recast = self._box_config["box_recast"][item] 555 | try: 556 | if isinstance(recast, type) and issubclass(recast, (Box, box.BoxList)): 557 | return recast(value, **self.__box_config()) 558 | else: 559 | return recast(value) 560 | except ValueError as err: 561 | raise BoxValueError(f"Cannot convert {value} to {recast}") from _exception_cause(err) 562 | return value 563 | 564 | def __convert_and_store(self, item, value): 565 | if self._box_config["conversion_box"]: 566 | safe_key = self._safe_attr(item) 567 | self._box_config["__safe_keys"][safe_key] = item 568 | if isinstance(value, (int, float, str, bytes, bytearray, bool, complex, set, frozenset)): 569 | return super().__setitem__(item, value) 570 | # If the value has already been converted or should not be converted, return it as-is 571 | if self._box_config["box_intact_types"] and isinstance(value, self._box_config["box_intact_types"]): 572 | return super().__setitem__(item, value) 573 | # This is the magic sauce that makes sub dictionaries into new box objects 574 | if isinstance(value, dict): 575 | # We always re-create even if it was already a Box object to pass down configurations correctly 576 | value = self._box_config["box_class"](value, **self.__box_config(extra_namespace=item)) 577 | elif isinstance(value, list) and not isinstance(value, box.BoxList): 578 | if self._box_config["frozen_box"]: 579 | value = _recursive_tuples( 580 | value, 581 | recreate_tuples=self._box_config["modify_tuples_box"], 582 | **self.__box_config(extra_namespace=item), 583 | ) 584 | else: 585 | value = box.BoxList(value, **self.__box_config(extra_namespace=item)) 586 | elif isinstance(value, box.BoxList): 587 | value.box_options.update(self.__box_config(extra_namespace=item)) 588 | elif self._box_config["modify_tuples_box"] and isinstance(value, tuple): 589 | value = _recursive_tuples(value, recreate_tuples=True, **self.__box_config(extra_namespace=item)) 590 | super().__setitem__(item, value) 591 | 592 | def __getitem__(self, item, _ignore_default=False): 593 | try: 594 | return super().__getitem__(item) 595 | except KeyError as err: 596 | if item == "_box_config": 597 | cause = _exception_cause(err) 598 | raise BoxKeyError("_box_config should only exist as an attribute and is never defaulted") from cause 599 | if isinstance(item, slice): 600 | # In Python 3.12 this changes to a KeyError instead of TypeError 601 | new_box = self._box_config["box_class"](**self.__box_config()) 602 | for x in list(super().keys())[item.start : item.stop : item.step]: 603 | new_box[x] = self[x] 604 | return new_box 605 | if self._box_config["box_dots"] and isinstance(item, str) and ("." in item or "[" in item): 606 | try: 607 | first_item, children = _parse_box_dots(self, item) 608 | except BoxError: 609 | if self._box_config["default_box"] and not _ignore_default: 610 | return self.__get_default(item) 611 | raise BoxKeyError(str(item)) from _exception_cause(err) 612 | if first_item in self.keys(): 613 | if hasattr(self[first_item], "__getitem__"): 614 | return self[first_item][children] 615 | if self._box_config["camel_killer_box"] and isinstance(item, str): 616 | converted = _camel_killer(item) 617 | if converted in self.keys(): 618 | return super().__getitem__(converted) 619 | if self._box_config["default_box"] and not _ignore_default: 620 | return self.__get_default(item) 621 | raise BoxKeyError(str(err)) from _exception_cause(err) 622 | except TypeError as err: 623 | if isinstance(item, slice): 624 | new_box = self._box_config["box_class"](**self.__box_config()) 625 | for x in list(super().keys())[item.start : item.stop : item.step]: 626 | new_box[x] = self[x] 627 | return new_box 628 | raise BoxTypeError(str(err)) from _exception_cause(err) 629 | 630 | def __getattr__(self, item): 631 | try: 632 | try: 633 | value = self.__getitem__(item, _ignore_default=True) 634 | except KeyError: 635 | value = object.__getattribute__(self, item) 636 | except AttributeError as err: 637 | if item == "__getstate__": 638 | raise BoxKeyError(item) from _exception_cause(err) 639 | if item == "_box_config": 640 | raise BoxError("_box_config key must exist") from _exception_cause(err) 641 | if self._box_config["conversion_box"]: 642 | safe_key = self._safe_attr(item) 643 | if safe_key in self._box_config["__safe_keys"]: 644 | return self.__getitem__(self._box_config["__safe_keys"][safe_key]) 645 | if self._box_config["default_box"]: 646 | if item.startswith("_") and item.endswith("_"): 647 | raise BoxKeyError(f"{item}: Does not exist and internal methods are never defaulted") 648 | return self.__get_default(item, attr=True) 649 | raise BoxKeyError(str(err)) from _exception_cause(err) 650 | return value 651 | 652 | def __setitem__(self, key, value): 653 | if key != "_box_config" and self._box_config["frozen_box"] and self._box_config["__created"]: 654 | raise BoxError("Box is frozen") 655 | if self._box_config["box_dots"] and isinstance(key, str) and ("." in key or "[" in key): 656 | first_item, children = _parse_box_dots(self, key, setting=True) 657 | if first_item in self.keys(): 658 | if hasattr(self[first_item], "__setitem__"): 659 | return self[first_item].__setitem__(children, value) 660 | elif self._box_config["default_box"]: 661 | if children[0] == "[": 662 | super().__setitem__(first_item, box.BoxList(**self.__box_config(extra_namespace=first_item))) 663 | else: 664 | super().__setitem__( 665 | first_item, self._box_config["box_class"](**self.__box_config(extra_namespace=first_item)) 666 | ) 667 | return self[first_item].__setitem__(children, value) 668 | else: 669 | raise BoxKeyError(f"'{self.__class__}' object has no attribute {first_item}") 670 | value = self.__recast(key, value) 671 | if key not in self.keys() and self._box_config["camel_killer_box"]: 672 | if self._box_config["camel_killer_box"] and isinstance(key, str): 673 | key = _camel_killer(key) 674 | if self._box_config["conversion_box"] and self._box_config["box_duplicates"] != "ignore": 675 | self._conversion_checks(key) 676 | self.__convert_and_store(key, value) 677 | 678 | def __setattr__(self, key, value): 679 | if key == "_box_config": 680 | return object.__setattr__(self, key, value) 681 | if self._box_config["frozen_box"] and self._box_config["__created"]: 682 | raise BoxError("Box is frozen") 683 | if key in self._protected_keys: 684 | raise BoxKeyError(f'Key name "{key}" is protected') 685 | 686 | safe_key = self._safe_attr(key) 687 | if safe_key in self._box_config["__safe_keys"]: 688 | key = self._box_config["__safe_keys"][safe_key] 689 | 690 | # if user has customized property setter, fall back to default implementation 691 | if _get_property_func(self, key)[1] is not None: 692 | super().__setattr__(key, value) 693 | else: 694 | self.__setitem__(key, value) 695 | 696 | def __delitem__(self, key): 697 | if self._box_config["frozen_box"]: 698 | raise BoxError("Box is frozen") 699 | if ( 700 | key not in self.keys() 701 | and self._box_config["box_dots"] 702 | and isinstance(key, str) 703 | and ("." in key or "[" in key) 704 | ): 705 | try: 706 | first_item, children = _parse_box_dots(self, key) 707 | except BoxError: 708 | raise BoxKeyError(str(key)) from None 709 | if hasattr(self[first_item], "__delitem__"): 710 | return self[first_item].__delitem__(children) 711 | if key not in self.keys() and self._box_config["camel_killer_box"]: 712 | if self._box_config["camel_killer_box"] and isinstance(key, str): 713 | for each_key in self: 714 | if _camel_killer(key) == each_key: 715 | key = each_key 716 | break 717 | try: 718 | super().__delitem__(key) 719 | except KeyError as err: 720 | raise BoxKeyError(str(err)) from _exception_cause(err) 721 | 722 | def __delattr__(self, item): 723 | if self._box_config["frozen_box"]: 724 | raise BoxError("Box is frozen") 725 | if item == "_box_config": 726 | raise BoxError('"_box_config" is protected') 727 | if item in self._protected_keys: 728 | raise BoxKeyError(f'Key name "{item}" is protected') 729 | 730 | property_fdel = _get_property_func(self, item)[2] 731 | 732 | # if user has customized property deleter, route to it 733 | if property_fdel is not None: 734 | property_fdel(self) 735 | return 736 | try: 737 | self.__delitem__(item) 738 | except KeyError as err: 739 | if self._box_config["conversion_box"]: 740 | safe_key = self._safe_attr(item) 741 | if safe_key in self._box_config["__safe_keys"]: 742 | self.__delitem__(self._box_config["__safe_keys"][safe_key]) 743 | del self._box_config["__safe_keys"][safe_key] 744 | return 745 | raise BoxKeyError(str(err)) from _exception_cause(err) 746 | 747 | def pop(self, key, *args): 748 | if self._box_config["frozen_box"]: 749 | raise BoxError("Box is frozen") 750 | 751 | if args: 752 | if len(args) != 1: 753 | raise BoxError('pop() takes only one optional argument "default"') 754 | try: 755 | item = self[key] 756 | except KeyError: 757 | return args[0] 758 | else: 759 | del self[key] 760 | return item 761 | try: 762 | item = self[key] 763 | except KeyError: 764 | raise BoxKeyError(f"{key}") from None 765 | else: 766 | del self[key] 767 | return item 768 | 769 | def clear(self): 770 | if self._box_config["frozen_box"]: 771 | raise BoxError("Box is frozen") 772 | super().clear() 773 | self._box_config["__safe_keys"].clear() 774 | 775 | def popitem(self): 776 | if self._box_config["frozen_box"]: 777 | raise BoxError("Box is frozen") 778 | try: 779 | key = next(self.__iter__()) 780 | except StopIteration: 781 | raise BoxKeyError("Empty box") from None 782 | return key, self.pop(key) 783 | 784 | def __repr__(self) -> str: 785 | return f"{self.__class__.__name__}({self})" 786 | 787 | def __str__(self) -> str: 788 | return str(self.to_dict()) 789 | 790 | def __iter__(self) -> Generator: 791 | for key in self.keys(): 792 | yield key 793 | 794 | def __reversed__(self) -> Generator: 795 | for key in reversed(list(self.keys())): 796 | yield key 797 | 798 | def to_dict(self) -> Dict: 799 | """ 800 | Turn the Box and sub Boxes back into a native python dictionary. 801 | 802 | :return: python dictionary of this Box 803 | """ 804 | out_dict = dict(self) 805 | for k, v in out_dict.items(): 806 | if v is self: 807 | out_dict[k] = out_dict 808 | elif isinstance(v, Box): 809 | out_dict[k] = v.to_dict() 810 | elif isinstance(v, box.BoxList): 811 | out_dict[k] = v.to_list() 812 | return out_dict 813 | 814 | def update(self, *args, **kwargs): 815 | if self._box_config["frozen_box"]: 816 | raise BoxError("Box is frozen") 817 | if (len(args) + int(bool(kwargs))) > 1: 818 | raise BoxTypeError(f"update expected at most 1 argument, got {len(args) + int(bool(kwargs))}") 819 | single_arg = next(iter(args), None) 820 | if single_arg: 821 | if hasattr(single_arg, "keys"): 822 | for k in single_arg: 823 | self.__convert_and_store(k, single_arg[k]) 824 | else: 825 | for k, v in single_arg: 826 | self.__convert_and_store(k, v) 827 | for k in kwargs: 828 | self.__convert_and_store(k, kwargs[k]) 829 | 830 | def merge_update(self, *args, **kwargs): 831 | merge_type = None 832 | if "box_merge_lists" in kwargs: 833 | merge_type = kwargs.pop("box_merge_lists") 834 | 835 | def convert_and_set(k, v): 836 | intact_type = self._box_config["box_intact_types"] and isinstance(v, self._box_config["box_intact_types"]) 837 | if isinstance(v, dict) and not intact_type: 838 | # Box objects must be created in case they are already 839 | # in the `converted` box_config set 840 | v = self._box_config["box_class"](v, **self.__box_config(extra_namespace=k)) 841 | if k in self and isinstance(self[k], dict): 842 | self[k].merge_update(v, box_merge_lists=merge_type) 843 | return 844 | if isinstance(v, list) and not intact_type: 845 | v = box.BoxList(v, **self.__box_config(extra_namespace=k)) 846 | if merge_type == "extend" and k in self and isinstance(self[k], list): 847 | self[k].extend(v) 848 | return 849 | if merge_type == "unique" and k in self and isinstance(self[k], list): 850 | for item in v: 851 | if item not in self[k]: 852 | self[k].append(item) 853 | return 854 | self.__setitem__(k, v) 855 | 856 | if (len(args) + int(bool(kwargs))) > 1: 857 | raise BoxTypeError(f"merge_update expected at most 1 argument, got {len(args) + int(bool(kwargs))}") 858 | single_arg = next(iter(args), None) 859 | if single_arg: 860 | if hasattr(single_arg, "keys"): 861 | for k in single_arg: 862 | convert_and_set(k, single_arg[k]) 863 | else: 864 | for k, v in single_arg: 865 | convert_and_set(k, v) 866 | 867 | for key in kwargs: 868 | convert_and_set(key, kwargs[key]) 869 | 870 | def setdefault(self, item, default=None): 871 | if item in self: 872 | return self[item] 873 | 874 | if self._box_config["box_dots"]: 875 | if item in _get_dot_paths(self): 876 | return self[item] 877 | 878 | if isinstance(default, dict): 879 | default = self._box_config["box_class"](default, **self.__box_config(extra_namespace=item)) 880 | if isinstance(default, list): 881 | default = box.BoxList(default, **self.__box_config(extra_namespace=item)) 882 | self[item] = default 883 | return self[item] 884 | 885 | def _safe_attr(self, attr): 886 | """Convert a key into something that is accessible as an attribute""" 887 | if isinstance(attr, str): 888 | # By assuming most people are using string first we get substantial speed ups 889 | if attr.isidentifier() and not iskeyword(attr): 890 | return attr 891 | 892 | if isinstance(attr, tuple): 893 | attr = "_".join([str(x) for x in attr]) 894 | 895 | attr = attr.decode("utf-8", "ignore") if isinstance(attr, bytes) else str(attr) 896 | if self.__box_config()["camel_killer_box"]: 897 | attr = _camel_killer(attr) 898 | 899 | if attr.isidentifier() and not iskeyword(attr): 900 | return attr 901 | 902 | if sum(1 for character in attr if character.isidentifier() and not iskeyword(character)) == 0: 903 | attr = f'{self.__box_config()["box_safe_prefix"]}{attr}' 904 | if attr.isidentifier() and not iskeyword(attr): 905 | return attr 906 | 907 | out = [] 908 | last_safe = 0 909 | for i, character in enumerate(attr): 910 | if f"x{character}".isidentifier(): 911 | last_safe = i 912 | out.append(character) 913 | elif not out: 914 | continue 915 | else: 916 | if last_safe == i - 1: 917 | out.append("_") 918 | 919 | out = "".join(out)[: last_safe + 1] 920 | 921 | try: 922 | int(out[0]) 923 | except (ValueError, IndexError): 924 | pass 925 | else: 926 | out = f'{self.__box_config()["box_safe_prefix"]}{out}' 927 | 928 | if iskeyword(out): 929 | out = f'{self.__box_config()["box_safe_prefix"]}{out}' 930 | 931 | return out 932 | 933 | def _conversion_checks(self, item): 934 | """ 935 | Internal use for checking if a duplicate safe attribute already exists 936 | 937 | :param item: Item to see if a dup exists 938 | """ 939 | safe_item = self._safe_attr(item) 940 | 941 | if safe_item in self._box_config["__safe_keys"]: 942 | dups = [f"{item}({safe_item})", f'{self._box_config["__safe_keys"][safe_item]}({safe_item})'] 943 | if self._box_config["box_duplicates"].startswith("warn"): 944 | warnings.warn(f"Duplicate conversion attributes exist: {dups}", BoxWarning) 945 | else: 946 | raise BoxError(f"Duplicate conversion attributes exist: {dups}") 947 | 948 | def to_json( 949 | self, 950 | filename: Optional[Union[str, PathLike]] = None, 951 | encoding: str = "utf-8", 952 | errors: str = "strict", 953 | **json_kwargs, 954 | ): 955 | """ 956 | Transform the Box object into a JSON string. 957 | 958 | :param filename: If provided will save to file 959 | :param encoding: File encoding 960 | :param errors: How to handle encoding errors 961 | :param json_kwargs: additional arguments to pass to json.dump(s) 962 | :return: string of JSON (if no filename provided) 963 | """ 964 | return _to_json(self.to_dict(), filename=filename, encoding=encoding, errors=errors, **json_kwargs) 965 | 966 | @classmethod 967 | def from_json( 968 | cls, 969 | json_string: Optional[str] = None, 970 | filename: Optional[Union[str, PathLike]] = None, 971 | encoding: str = "utf-8", 972 | errors: str = "strict", 973 | **kwargs, 974 | ) -> "Box": 975 | """ 976 | Transform a json object string into a Box object. If the incoming 977 | json is a list, you must use BoxList.from_json. 978 | 979 | :param json_string: string to pass to `json.loads` 980 | :param filename: filename to open and pass to `json.load` 981 | :param encoding: File encoding 982 | :param errors: How to handle encoding errors 983 | :param kwargs: parameters to pass to `Box()` or `json.loads` 984 | :return: Box object from json data 985 | """ 986 | box_args = {} 987 | for arg in kwargs.copy(): 988 | if arg in BOX_PARAMETERS: 989 | box_args[arg] = kwargs.pop(arg) 990 | 991 | data = _from_json(json_string, filename=filename, encoding=encoding, errors=errors, **kwargs) 992 | 993 | if not isinstance(data, dict): 994 | raise BoxError(f"json data not returned as a dictionary, but rather a {type(data).__name__}") 995 | return cls(data, **box_args) 996 | 997 | if yaml_available: 998 | 999 | def to_yaml( 1000 | self, 1001 | filename: Optional[Union[str, PathLike]] = None, 1002 | default_flow_style: bool = False, 1003 | encoding: str = "utf-8", 1004 | errors: str = "strict", 1005 | **yaml_kwargs, 1006 | ): 1007 | """ 1008 | Transform the Box object into a YAML string. 1009 | 1010 | :param filename: If provided will save to file 1011 | :param default_flow_style: False will recursively dump dicts 1012 | :param encoding: File encoding 1013 | :param errors: How to handle encoding errors 1014 | :param yaml_kwargs: additional arguments to pass to yaml.dump 1015 | :return: string of YAML (if no filename provided) 1016 | """ 1017 | return _to_yaml( 1018 | self.to_dict(), 1019 | filename=filename, 1020 | default_flow_style=default_flow_style, 1021 | encoding=encoding, 1022 | errors=errors, 1023 | **yaml_kwargs, 1024 | ) 1025 | 1026 | @classmethod 1027 | def from_yaml( 1028 | cls, 1029 | yaml_string: Optional[str] = None, 1030 | filename: Optional[Union[str, PathLike]] = None, 1031 | encoding: str = "utf-8", 1032 | errors: str = "strict", 1033 | **kwargs, 1034 | ) -> "Box": 1035 | """ 1036 | Transform a yaml object string into a Box object. By default will use SafeLoader. 1037 | 1038 | :param yaml_string: string to pass to `yaml.load` 1039 | :param filename: filename to open and pass to `yaml.load` 1040 | :param encoding: File encoding 1041 | :param errors: How to handle encoding errors 1042 | :param kwargs: parameters to pass to `Box()` or `yaml.load` 1043 | :return: Box object from yaml data 1044 | """ 1045 | box_args = {} 1046 | for arg in kwargs.copy(): 1047 | if arg in BOX_PARAMETERS: 1048 | box_args[arg] = kwargs.pop(arg) 1049 | 1050 | data = _from_yaml(yaml_string=yaml_string, filename=filename, encoding=encoding, errors=errors, **kwargs) 1051 | if not data: 1052 | return cls(**box_args) 1053 | if not isinstance(data, dict): 1054 | raise BoxError(f"yaml data not returned as a dictionary but rather a {type(data).__name__}") 1055 | return cls(data, **box_args) 1056 | 1057 | else: 1058 | 1059 | def to_yaml( 1060 | self, 1061 | filename: Optional[Union[str, PathLike]] = None, 1062 | default_flow_style: bool = False, 1063 | encoding: str = "utf-8", 1064 | errors: str = "strict", 1065 | **yaml_kwargs, 1066 | ): 1067 | raise BoxError('yaml is unavailable on this system, please install the "ruamel.yaml" or "PyYAML" package') 1068 | 1069 | @classmethod 1070 | def from_yaml( 1071 | cls, 1072 | yaml_string: Optional[str] = None, 1073 | filename: Optional[Union[str, PathLike]] = None, 1074 | encoding: str = "utf-8", 1075 | errors: str = "strict", 1076 | **kwargs, 1077 | ) -> "Box": 1078 | raise BoxError('yaml is unavailable on this system, please install the "ruamel.yaml" or "PyYAML" package') 1079 | 1080 | if toml_write_library is not None: 1081 | 1082 | def to_toml( 1083 | self, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict" 1084 | ): 1085 | """ 1086 | Transform the Box object into a toml string. 1087 | 1088 | :param filename: File to write toml object too 1089 | :param encoding: File encoding 1090 | :param errors: How to handle encoding errors 1091 | :return: string of TOML (if no filename provided) 1092 | """ 1093 | return _to_toml(self.to_dict(), filename=filename, encoding=encoding, errors=errors) 1094 | 1095 | else: 1096 | 1097 | def to_toml( 1098 | self, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict" 1099 | ): 1100 | raise BoxError('toml is unavailable on this system, please install the "tomli-w" package') 1101 | 1102 | if toml_read_library is not None: 1103 | 1104 | @classmethod 1105 | def from_toml( 1106 | cls, 1107 | toml_string: Optional[str] = None, 1108 | filename: Optional[Union[str, PathLike]] = None, 1109 | encoding: str = "utf-8", 1110 | errors: str = "strict", 1111 | **kwargs, 1112 | ) -> "Box": 1113 | """ 1114 | Transforms a toml string or file into a Box object 1115 | 1116 | :param toml_string: string to pass to `toml.load` 1117 | :param filename: filename to open and pass to `toml.load` 1118 | :param encoding: File encoding 1119 | :param errors: How to handle encoding errors 1120 | :param kwargs: parameters to pass to `Box()` 1121 | :return: Box object 1122 | """ 1123 | box_args = {} 1124 | for arg in kwargs.copy(): 1125 | if arg in BOX_PARAMETERS: 1126 | box_args[arg] = kwargs.pop(arg) 1127 | 1128 | data = _from_toml(toml_string=toml_string, filename=filename, encoding=encoding, errors=errors) 1129 | return cls(data, **box_args) 1130 | 1131 | else: 1132 | 1133 | @classmethod 1134 | def from_toml( 1135 | cls, 1136 | toml_string: Optional[str] = None, 1137 | filename: Optional[Union[str, PathLike]] = None, 1138 | encoding: str = "utf-8", 1139 | errors: str = "strict", 1140 | **kwargs, 1141 | ) -> "Box": 1142 | raise BoxError('toml is unavailable on this system, please install the "tomli" package') 1143 | 1144 | if msgpack_available: 1145 | 1146 | def to_msgpack(self, filename: Optional[Union[str, PathLike]] = None, **kwargs): 1147 | """ 1148 | Transform the Box object into a msgpack string. 1149 | 1150 | :param filename: File to write msgpack object too 1151 | :param kwargs: parameters to pass to `msgpack.pack` 1152 | :return: bytes of msgpack (if no filename provided) 1153 | """ 1154 | return _to_msgpack(self.to_dict(), filename=filename, **kwargs) 1155 | 1156 | @classmethod 1157 | def from_msgpack( 1158 | cls, 1159 | msgpack_bytes: Optional[bytes] = None, 1160 | filename: Optional[Union[str, PathLike]] = None, 1161 | **kwargs, 1162 | ) -> "Box": 1163 | """ 1164 | Transforms msgpack bytes or file into a Box object 1165 | 1166 | :param msgpack_bytes: string to pass to `msgpack.unpackb` 1167 | :param filename: filename to open and pass to `msgpack.unpack` 1168 | :param kwargs: parameters to pass to `Box()` 1169 | :return: Box object 1170 | """ 1171 | box_args = {} 1172 | for arg in kwargs.copy(): 1173 | if arg in BOX_PARAMETERS: 1174 | box_args[arg] = kwargs.pop(arg) 1175 | 1176 | data = _from_msgpack(msgpack_bytes=msgpack_bytes, filename=filename, **kwargs) 1177 | if not isinstance(data, dict): 1178 | raise BoxError(f"msgpack data not returned as a dictionary but rather a {type(data).__name__}") 1179 | return cls(data, **box_args) 1180 | 1181 | else: 1182 | 1183 | def to_msgpack(self, filename: Optional[Union[str, PathLike]] = None, **kwargs): 1184 | raise BoxError('msgpack is unavailable on this system, please install the "msgpack" package') 1185 | 1186 | @classmethod 1187 | def from_msgpack( 1188 | cls, 1189 | msgpack_bytes: Optional[bytes] = None, 1190 | filename: Optional[Union[str, PathLike]] = None, 1191 | encoding: str = "utf-8", 1192 | errors: str = "strict", 1193 | **kwargs, 1194 | ) -> "Box": 1195 | raise BoxError('msgpack is unavailable on this system, please install the "msgpack" package') 1196 | -------------------------------------------------------------------------------- /box/box.pyi: -------------------------------------------------------------------------------- 1 | from _typeshed import Incomplete 2 | from collections.abc import Mapping 3 | from os import PathLike 4 | from typing import Any, Dict, Generator, List, Optional, Tuple, Type, Union, Literal 5 | 6 | class Box(dict): 7 | def __new__( 8 | cls, 9 | *args: Any, 10 | default_box: bool = ..., 11 | default_box_attr: Any = ..., 12 | default_box_none_transform: bool = ..., 13 | default_box_create_on_get: bool = ..., 14 | frozen_box: bool = ..., 15 | camel_killer_box: bool = ..., 16 | conversion_box: bool = ..., 17 | modify_tuples_box: bool = ..., 18 | box_safe_prefix: str = ..., 19 | box_duplicates: str = ..., 20 | box_intact_types: Union[Tuple, List] = ..., 21 | box_recast: Optional[Dict] = ..., 22 | box_dots: bool = ..., 23 | box_class: Optional[Union[Dict, Type["Box"]]] = ..., 24 | box_namespace: Union[Tuple[str, ...], Literal[False]] = ..., 25 | **kwargs: Any, 26 | ): ... 27 | def __init__( 28 | self, 29 | *args: Any, 30 | default_box: bool = ..., 31 | default_box_attr: Any = ..., 32 | default_box_none_transform: bool = ..., 33 | default_box_create_on_get: bool = ..., 34 | frozen_box: bool = ..., 35 | camel_killer_box: bool = ..., 36 | conversion_box: bool = ..., 37 | modify_tuples_box: bool = ..., 38 | box_safe_prefix: str = ..., 39 | box_duplicates: str = ..., 40 | box_intact_types: Union[Tuple, List] = ..., 41 | box_recast: Optional[Dict] = ..., 42 | box_dots: bool = ..., 43 | box_class: Optional[Union[Dict, Type["Box"]]] = ..., 44 | box_namespace: Union[Tuple[str, ...], Literal[False]] = ..., 45 | **kwargs: Any, 46 | ) -> None: ... 47 | def __add__(self, other: Mapping[Any, Any]): ... 48 | def __radd__(self, other: Mapping[Any, Any]): ... 49 | def __iadd__(self, other: Mapping[Any, Any]): ... 50 | def __or__(self, other: Mapping[Any, Any]): ... 51 | def __ror__(self, other: Mapping[Any, Any]): ... 52 | def __ior__(self, other: Mapping[Any, Any]): ... # type: ignore[override] 53 | def __sub__(self, other: Mapping[Any, Any]): ... 54 | def __hash__(self): ... 55 | def __dir__(self) -> List[str]: ... 56 | def __contains__(self, item) -> bool: ... 57 | def keys(self, dotted: Union[bool] = ...): ... 58 | def items(self, dotted: Union[bool] = ...): ... 59 | def get(self, key, default=...): ... 60 | def copy(self) -> Box: ... 61 | def __copy__(self) -> Box: ... 62 | def __deepcopy__(self, memodict: Incomplete | None = ...) -> Box: ... 63 | def __getitem__(self, item, _ignore_default: bool = ...): ... 64 | def __getattr__(self, item): ... 65 | def __setitem__(self, key, value): ... 66 | def __setattr__(self, key, value): ... 67 | def __delitem__(self, key): ... 68 | def __delattr__(self, item) -> None: ... 69 | def pop(self, key, *args): ... 70 | def clear(self) -> None: ... 71 | def popitem(self): ... 72 | def __iter__(self) -> Generator: ... 73 | def __reversed__(self) -> Generator: ... 74 | def to_dict(self) -> Dict: ... 75 | def update(self, *args, **kwargs) -> None: ... 76 | def merge_update(self, *args, **kwargs) -> None: ... 77 | def setdefault(self, item, default: Incomplete | None = ...): ... 78 | def to_json( 79 | self, filename: Optional[Union[str, PathLike]] = ..., encoding: str = ..., errors: str = ..., **json_kwargs 80 | ): ... 81 | @classmethod 82 | def from_json( 83 | cls, 84 | json_string: Optional[str] = ..., 85 | filename: Optional[Union[str, PathLike]] = ..., 86 | encoding: str = ..., 87 | errors: str = ..., 88 | **kwargs, 89 | ) -> Box: ... 90 | def to_yaml( 91 | self, 92 | filename: Optional[Union[str, PathLike]] = ..., 93 | default_flow_style: bool = ..., 94 | encoding: str = ..., 95 | errors: str = ..., 96 | **yaml_kwargs, 97 | ): ... 98 | @classmethod 99 | def from_yaml( 100 | cls, 101 | yaml_string: Optional[str] = ..., 102 | filename: Optional[Union[str, PathLike]] = ..., 103 | encoding: str = ..., 104 | errors: str = ..., 105 | **kwargs, 106 | ) -> Box: ... 107 | def to_toml(self, filename: Optional[Union[str, PathLike]] = ..., encoding: str = ..., errors: str = ...): ... 108 | @classmethod 109 | def from_toml( 110 | cls, 111 | toml_string: Optional[str] = ..., 112 | filename: Optional[Union[str, PathLike]] = ..., 113 | encoding: str = ..., 114 | errors: str = ..., 115 | **kwargs, 116 | ) -> Box: ... 117 | def to_msgpack(self, filename: Optional[Union[str, PathLike]] = ..., **kwargs): ... 118 | @classmethod 119 | def from_msgpack( 120 | cls, msgpack_bytes: Optional[bytes] = ..., filename: Optional[Union[str, PathLike]] = ..., **kwargs 121 | ) -> Box: ... 122 | -------------------------------------------------------------------------------- /box/box_list.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright (c) 2017-2023 - Chris Griffith - MIT License 5 | import copy 6 | import re 7 | from os import PathLike 8 | from typing import Optional, Iterable, Type, Union, List, Any 9 | 10 | import box 11 | from box.converters import ( 12 | BOX_PARAMETERS, 13 | _from_csv, 14 | _from_json, 15 | _from_msgpack, 16 | _from_toml, 17 | _from_yaml, 18 | _to_csv, 19 | _to_json, 20 | _to_msgpack, 21 | _to_toml, 22 | _to_yaml, 23 | msgpack_available, 24 | toml_read_library, 25 | yaml_available, 26 | ) 27 | from box.exceptions import BoxError, BoxTypeError 28 | 29 | _list_pos_re = re.compile(r"\[(\d+)\]") 30 | 31 | 32 | class BoxList(list): 33 | """ 34 | Drop in replacement of list, that converts added objects to Box or BoxList 35 | objects as necessary. 36 | """ 37 | 38 | def __new__(cls, *args, **kwargs): 39 | obj = super().__new__(cls, *args, **kwargs) 40 | # This is required for pickling to work correctly 41 | obj.box_options = {"box_class": box.Box} 42 | obj.box_options.update(kwargs) 43 | obj.box_org_ref = None 44 | return obj 45 | 46 | def __init__(self, iterable: Optional[Iterable] = None, box_class: Type[box.Box] = box.Box, **box_options): 47 | self.box_options = box_options 48 | self.box_options["box_class"] = box_class 49 | self.box_org_ref = iterable 50 | if iterable: 51 | for x in iterable: 52 | self.append(x) 53 | self.box_org_ref = None 54 | if box_options.get("frozen_box"): 55 | 56 | def frozen(*args, **kwargs): 57 | raise BoxError("BoxList is frozen") 58 | 59 | for method in ["append", "extend", "insert", "pop", "remove", "reverse", "sort"]: 60 | self.__setattr__(method, frozen) 61 | 62 | def __getitem__(self, item): 63 | if self.box_options.get("box_dots") and isinstance(item, str) and item.startswith("["): 64 | list_pos = _list_pos_re.search(item) 65 | value = super().__getitem__(int(list_pos.groups()[0])) 66 | if len(list_pos.group()) == len(item): 67 | return value 68 | return value.__getitem__(item[len(list_pos.group()) :].lstrip(".")) 69 | if isinstance(item, tuple): 70 | result = self 71 | for idx in item: 72 | if isinstance(result, list): 73 | result = result[idx] 74 | else: 75 | raise BoxTypeError(f"Cannot numpy-style indexing on {type(result).__name__}.") 76 | return result 77 | return super().__getitem__(item) 78 | 79 | def __delitem__(self, key): 80 | if self.box_options.get("frozen_box"): 81 | raise BoxError("BoxList is frozen") 82 | if self.box_options.get("box_dots") and isinstance(key, str) and key.startswith("["): 83 | list_pos = _list_pos_re.search(key) 84 | pos = int(list_pos.groups()[0]) 85 | if len(list_pos.group()) == len(key): 86 | return super().__delitem__(pos) 87 | if hasattr(self[pos], "__delitem__"): 88 | return self[pos].__delitem__(key[len(list_pos.group()) :].lstrip(".")) # type: ignore 89 | super().__delitem__(key) 90 | 91 | def __setitem__(self, key, value): 92 | if self.box_options.get("frozen_box"): 93 | raise BoxError("BoxList is frozen") 94 | if self.box_options.get("box_dots") and isinstance(key, str) and key.startswith("["): 95 | list_pos = _list_pos_re.search(key) 96 | pos = int(list_pos.groups()[0]) 97 | if pos >= len(self) and self.box_options.get("default_box"): 98 | self.extend([None] * (pos - len(self) + 1)) 99 | if len(list_pos.group()) == len(key): 100 | return super().__setitem__(pos, value) 101 | children = key[len(list_pos.group()):].lstrip(".") 102 | if self.box_options.get("default_box"): 103 | if children[0] == "[": 104 | super().__setitem__(pos, box.BoxList(**self.box_options)) 105 | else: 106 | super().__setitem__(pos, self.box_options.get("box_class")(**self.box_options)) 107 | return super().__getitem__(pos).__setitem__(children, value) 108 | super().__setitem__(key, value) 109 | 110 | def _is_intact_type(self, obj): 111 | if self.box_options.get("box_intact_types") and isinstance(obj, self.box_options["box_intact_types"]): 112 | return True 113 | return False 114 | 115 | def _convert(self, p_object): 116 | if isinstance(p_object, dict) and not self._is_intact_type(p_object): 117 | p_object = self.box_options["box_class"](p_object, **self.box_options) 118 | elif isinstance(p_object, box.Box): 119 | p_object._box_config.update(self.box_options) 120 | if isinstance(p_object, list) and not self._is_intact_type(p_object): 121 | p_object = ( 122 | self 123 | if p_object is self or p_object is self.box_org_ref 124 | else self.__class__(p_object, **self.box_options) 125 | ) 126 | elif isinstance(p_object, BoxList): 127 | p_object.box_options.update(self.box_options) 128 | return p_object 129 | 130 | def append(self, p_object): 131 | super().append(self._convert(p_object)) 132 | 133 | def extend(self, iterable): 134 | for item in iterable: 135 | self.append(item) 136 | 137 | def insert(self, index, p_object): 138 | super().insert(index, self._convert(p_object)) 139 | 140 | def _dotted_helper(self) -> List[str]: 141 | keys = [] 142 | for idx, item in enumerate(self): 143 | added = False 144 | if isinstance(item, box.Box): 145 | for key in item.keys(dotted=True): 146 | keys.append(f"[{idx}].{key}") 147 | added = True 148 | elif isinstance(item, BoxList): 149 | for key in item._dotted_helper(): 150 | keys.append(f"[{idx}]{key}") 151 | added = True 152 | if not added: 153 | keys.append(f"[{idx}]") 154 | return keys 155 | 156 | def __repr__(self): 157 | return f"{self.__class__.__name__}({self.to_list()})" 158 | 159 | def __str__(self): 160 | return str(self.to_list()) 161 | 162 | def __copy__(self): 163 | return self.__class__((x for x in self), **self.box_options) 164 | 165 | def __deepcopy__(self, memo=None): 166 | out = self.__class__() 167 | memo = memo or {} 168 | memo[id(self)] = out 169 | for k in self: 170 | out.append(copy.deepcopy(k, memo=memo)) 171 | return out 172 | 173 | def __hash__(self) -> int: # type: ignore[override] 174 | if self.box_options.get("frozen_box"): 175 | hashing = 98765 176 | hashing ^= hash(tuple(self)) 177 | return hashing 178 | raise BoxTypeError("unhashable type: 'BoxList'") 179 | 180 | def to_list(self) -> List: 181 | new_list: List[Any] = [] 182 | for x in self: 183 | if x is self: 184 | new_list.append(new_list) 185 | elif isinstance(x, box.Box): 186 | new_list.append(x.to_dict()) 187 | elif isinstance(x, BoxList): 188 | new_list.append(x.to_list()) 189 | else: 190 | new_list.append(x) 191 | return new_list 192 | 193 | def to_json( 194 | self, 195 | filename: Optional[Union[str, PathLike]] = None, 196 | encoding: str = "utf-8", 197 | errors: str = "strict", 198 | multiline: bool = False, 199 | **json_kwargs, 200 | ): 201 | """ 202 | Transform the BoxList object into a JSON string. 203 | 204 | :param filename: If provided will save to file 205 | :param encoding: File encoding 206 | :param errors: How to handle encoding errors 207 | :param multiline: Put each item in list onto it's own line 208 | :param json_kwargs: additional arguments to pass to json.dump(s) 209 | :return: string of JSON or return of `json.dump` 210 | """ 211 | if filename and multiline: 212 | lines = [_to_json(item, filename=None, encoding=encoding, errors=errors, **json_kwargs) for item in self] 213 | with open(filename, "w", encoding=encoding, errors=errors) as f: 214 | f.write("\n".join(lines)) 215 | else: 216 | return _to_json(self.to_list(), filename=filename, encoding=encoding, errors=errors, **json_kwargs) 217 | 218 | @classmethod 219 | def from_json( 220 | cls, 221 | json_string: Optional[str] = None, 222 | filename: Optional[Union[str, PathLike]] = None, 223 | encoding: str = "utf-8", 224 | errors: str = "strict", 225 | multiline: bool = False, 226 | **kwargs, 227 | ): 228 | """ 229 | Transform a json object string into a BoxList object. If the incoming 230 | json is a dict, you must use Box.from_json. 231 | 232 | :param json_string: string to pass to `json.loads` 233 | :param filename: filename to open and pass to `json.load` 234 | :param encoding: File encoding 235 | :param errors: How to handle encoding errors 236 | :param multiline: One object per line 237 | :param kwargs: parameters to pass to `Box()` or `json.loads` 238 | :return: BoxList object from json data 239 | """ 240 | box_args = {} 241 | for arg in list(kwargs.keys()): 242 | if arg in BOX_PARAMETERS: 243 | box_args[arg] = kwargs.pop(arg) 244 | 245 | data = _from_json( 246 | json_string, filename=filename, encoding=encoding, errors=errors, multiline=multiline, **kwargs 247 | ) 248 | 249 | if not isinstance(data, list): 250 | raise BoxError(f"json data not returned as a list, but rather a {type(data).__name__}") 251 | return cls(data, **box_args) 252 | 253 | if yaml_available: 254 | 255 | def to_yaml( 256 | self, 257 | filename: Optional[Union[str, PathLike]] = None, 258 | default_flow_style: bool = False, 259 | encoding: str = "utf-8", 260 | errors: str = "strict", 261 | **yaml_kwargs, 262 | ): 263 | """ 264 | Transform the BoxList object into a YAML string. 265 | 266 | :param filename: If provided will save to file 267 | :param default_flow_style: False will recursively dump dicts 268 | :param encoding: File encoding 269 | :param errors: How to handle encoding errors 270 | :param yaml_kwargs: additional arguments to pass to yaml.dump 271 | :return: string of YAML or return of `yaml.dump` 272 | """ 273 | return _to_yaml( 274 | self.to_list(), 275 | filename=filename, 276 | default_flow_style=default_flow_style, 277 | encoding=encoding, 278 | errors=errors, 279 | **yaml_kwargs, 280 | ) 281 | 282 | @classmethod 283 | def from_yaml( 284 | cls, 285 | yaml_string: Optional[str] = None, 286 | filename: Optional[Union[str, PathLike]] = None, 287 | encoding: str = "utf-8", 288 | errors: str = "strict", 289 | **kwargs, 290 | ): 291 | """ 292 | Transform a yaml object string into a BoxList object. 293 | 294 | :param yaml_string: string to pass to `yaml.load` 295 | :param filename: filename to open and pass to `yaml.load` 296 | :param encoding: File encoding 297 | :param errors: How to handle encoding errors 298 | :param kwargs: parameters to pass to `BoxList()` or `yaml.load` 299 | :return: BoxList object from yaml data 300 | """ 301 | box_args = {} 302 | for arg in list(kwargs.keys()): 303 | if arg in BOX_PARAMETERS: 304 | box_args[arg] = kwargs.pop(arg) 305 | 306 | data = _from_yaml(yaml_string=yaml_string, filename=filename, encoding=encoding, errors=errors, **kwargs) 307 | if not data: 308 | return cls(**box_args) 309 | if not isinstance(data, list): 310 | raise BoxError(f"yaml data not returned as a list but rather a {type(data).__name__}") 311 | return cls(data, **box_args) 312 | 313 | else: 314 | 315 | def to_yaml( 316 | self, 317 | filename: Optional[Union[str, PathLike]] = None, 318 | default_flow_style: bool = False, 319 | encoding: str = "utf-8", 320 | errors: str = "strict", 321 | **yaml_kwargs, 322 | ): 323 | raise BoxError('yaml is unavailable on this system, please install the "ruamel.yaml" or "PyYAML" package') 324 | 325 | @classmethod 326 | def from_yaml( 327 | cls, 328 | yaml_string: Optional[str] = None, 329 | filename: Optional[Union[str, PathLike]] = None, 330 | encoding: str = "utf-8", 331 | errors: str = "strict", 332 | **kwargs, 333 | ): 334 | raise BoxError('yaml is unavailable on this system, please install the "ruamel.yaml" or "PyYAML" package') 335 | 336 | if toml_read_library is not None: 337 | 338 | def to_toml( 339 | self, 340 | filename: Optional[Union[str, PathLike]] = None, 341 | key_name: str = "toml", 342 | encoding: str = "utf-8", 343 | errors: str = "strict", 344 | ): 345 | """ 346 | Transform the BoxList object into a toml string. 347 | 348 | :param filename: File to write toml object too 349 | :param key_name: Specify the name of the key to store the string under 350 | (cannot directly convert to toml) 351 | :param encoding: File encoding 352 | :param errors: How to handle encoding errors 353 | :return: string of TOML (if no filename provided) 354 | """ 355 | return _to_toml({key_name: self.to_list()}, filename=filename, encoding=encoding, errors=errors) 356 | 357 | else: 358 | 359 | def to_toml( 360 | self, 361 | filename: Optional[Union[str, PathLike]] = None, 362 | key_name: str = "toml", 363 | encoding: str = "utf-8", 364 | errors: str = "strict", 365 | ): 366 | raise BoxError('toml is unavailable on this system, please install the "tomli-w" package') 367 | 368 | if toml_read_library is not None: 369 | 370 | @classmethod 371 | def from_toml( 372 | cls, 373 | toml_string: Optional[str] = None, 374 | filename: Optional[Union[str, PathLike]] = None, 375 | key_name: str = "toml", 376 | encoding: str = "utf-8", 377 | errors: str = "strict", 378 | **kwargs, 379 | ): 380 | """ 381 | Transforms a toml string or file into a BoxList object 382 | 383 | :param toml_string: string to pass to `toml.load` 384 | :param filename: filename to open and pass to `toml.load` 385 | :param key_name: Specify the name of the key to pull the list from 386 | (cannot directly convert from toml) 387 | :param encoding: File encoding 388 | :param errors: How to handle encoding errors 389 | :param kwargs: parameters to pass to `Box()` 390 | :return: 391 | """ 392 | box_args = {} 393 | for arg in list(kwargs.keys()): 394 | if arg in BOX_PARAMETERS: 395 | box_args[arg] = kwargs.pop(arg) 396 | 397 | data = _from_toml(toml_string=toml_string, filename=filename, encoding=encoding, errors=errors) 398 | if key_name not in data: 399 | raise BoxError(f"{key_name} was not found.") 400 | return cls(data[key_name], **box_args) 401 | 402 | else: 403 | 404 | @classmethod 405 | def from_toml( 406 | cls, 407 | toml_string: Optional[str] = None, 408 | filename: Optional[Union[str, PathLike]] = None, 409 | key_name: str = "toml", 410 | encoding: str = "utf-8", 411 | errors: str = "strict", 412 | **kwargs, 413 | ): 414 | raise BoxError('toml is unavailable on this system, please install the "toml" package') 415 | 416 | if msgpack_available: 417 | 418 | def to_msgpack(self, filename: Optional[Union[str, PathLike]] = None, **kwargs): 419 | """ 420 | Transform the BoxList object into a toml string. 421 | 422 | :param filename: File to write toml object too 423 | :return: string of TOML (if no filename provided) 424 | """ 425 | return _to_msgpack(self.to_list(), filename=filename, **kwargs) 426 | 427 | @classmethod 428 | def from_msgpack( 429 | cls, msgpack_bytes: Optional[bytes] = None, filename: Optional[Union[str, PathLike]] = None, **kwargs 430 | ): 431 | """ 432 | Transforms a toml string or file into a BoxList object 433 | 434 | :param msgpack_bytes: string to pass to `msgpack.packb` 435 | :param filename: filename to open and pass to `msgpack.pack` 436 | :param kwargs: parameters to pass to `Box()` 437 | :return: 438 | """ 439 | box_args = {} 440 | for arg in list(kwargs.keys()): 441 | if arg in BOX_PARAMETERS: 442 | box_args[arg] = kwargs.pop(arg) 443 | 444 | data = _from_msgpack(msgpack_bytes=msgpack_bytes, filename=filename, **kwargs) 445 | if not isinstance(data, list): 446 | raise BoxError(f"msgpack data not returned as a list but rather a {type(data).__name__}") 447 | return cls(data, **box_args) 448 | 449 | else: 450 | 451 | def to_msgpack(self, filename: Optional[Union[str, PathLike]] = None, **kwargs): 452 | raise BoxError('msgpack is unavailable on this system, please install the "msgpack" package') 453 | 454 | @classmethod 455 | def from_msgpack( 456 | cls, 457 | msgpack_bytes: Optional[bytes] = None, 458 | filename: Optional[Union[str, PathLike]] = None, 459 | encoding: str = "utf-8", 460 | errors: str = "strict", 461 | **kwargs, 462 | ): 463 | raise BoxError('msgpack is unavailable on this system, please install the "msgpack" package') 464 | 465 | def to_csv(self, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict"): 466 | return _to_csv(self, filename=filename, encoding=encoding, errors=errors) 467 | 468 | @classmethod 469 | def from_csv( 470 | cls, 471 | csv_string: Optional[str] = None, 472 | filename: Optional[Union[str, PathLike]] = None, 473 | encoding: str = "utf-8", 474 | errors: str = "strict", 475 | ): 476 | return cls(_from_csv(csv_string=csv_string, filename=filename, encoding=encoding, errors=errors)) 477 | -------------------------------------------------------------------------------- /box/box_list.pyi: -------------------------------------------------------------------------------- 1 | import box 2 | from box.converters import ( 3 | BOX_PARAMETERS as BOX_PARAMETERS, 4 | msgpack_available as msgpack_available, 5 | toml_read_library as toml_read_library, 6 | toml_write_library as toml_write_library, 7 | yaml_available as yaml_available, 8 | ) 9 | from os import PathLike as PathLike 10 | from typing import Any, Iterable, Optional, Type, Union, List 11 | 12 | class BoxList(list): 13 | def __new__(cls, *args: Any, **kwargs: Any): ... 14 | box_options: Any 15 | box_org_ref: Any 16 | def __init__(self, iterable: Iterable = ..., box_class: Type[box.Box] = ..., **box_options: Any) -> None: ... 17 | def __getitem__(self, item: Any): ... 18 | def __delitem__(self, key: Any): ... 19 | def __setitem__(self, key: Any, value: Any): ... 20 | def append(self, p_object: Any) -> None: ... 21 | def extend(self, iterable: Any) -> None: ... 22 | def insert(self, index: Any, p_object: Any) -> None: ... 23 | def __copy__(self) -> "BoxList": ... 24 | def __deepcopy__(self, memo: Optional[Any] = ...) -> "BoxList": ... 25 | def __hash__(self) -> int: ... # type: ignore[override] 26 | def to_list(self) -> List: ... 27 | def _dotted_helper(self) -> List[str]: ... 28 | def to_json( 29 | self, 30 | filename: Union[str, PathLike] = ..., 31 | encoding: str = ..., 32 | errors: str = ..., 33 | multiline: bool = ..., 34 | **json_kwargs: Any, 35 | ) -> Any: ... 36 | @classmethod 37 | def from_json( 38 | cls, 39 | json_string: str = ..., 40 | filename: Union[str, PathLike] = ..., 41 | encoding: str = ..., 42 | errors: str = ..., 43 | multiline: bool = ..., 44 | **kwargs: Any, 45 | ) -> Any: ... 46 | def to_yaml( 47 | self, 48 | filename: Union[str, PathLike] = ..., 49 | default_flow_style: bool = ..., 50 | encoding: str = ..., 51 | errors: str = ..., 52 | **yaml_kwargs: Any, 53 | ) -> Any: ... 54 | @classmethod 55 | def from_yaml( 56 | cls, 57 | yaml_string: str = ..., 58 | filename: Union[str, PathLike] = ..., 59 | encoding: str = ..., 60 | errors: str = ..., 61 | **kwargs: Any, 62 | ) -> Any: ... 63 | def to_toml( 64 | self, filename: Union[str, PathLike] = ..., key_name: str = ..., encoding: str = ..., errors: str = ... 65 | ) -> Any: ... 66 | @classmethod 67 | def from_toml( 68 | cls, 69 | toml_string: str = ..., 70 | filename: Union[str, PathLike] = ..., 71 | key_name: str = ..., 72 | encoding: str = ..., 73 | errors: str = ..., 74 | **kwargs: Any, 75 | ) -> Any: ... 76 | def to_msgpack(self, filename: Union[str, PathLike] = ..., **kwargs: Any) -> Any: ... 77 | @classmethod 78 | def from_msgpack(cls, msgpack_bytes: bytes = ..., filename: Union[str, PathLike] = ..., **kwargs: Any) -> Any: ... 79 | def to_csv(self, filename: Union[str, PathLike] = ..., encoding: str = ..., errors: str = ...) -> Any: ... 80 | @classmethod 81 | def from_csv( 82 | cls, csv_string: str = ..., filename: Union[str, PathLike] = ..., encoding: str = ..., errors: str = ... 83 | ) -> Any: ... 84 | -------------------------------------------------------------------------------- /box/config_box.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from typing import List 4 | 5 | from box.box import Box 6 | 7 | 8 | class ConfigBox(Box): 9 | """ 10 | Modified box object to add object transforms. 11 | 12 | Allows for build in transforms like: 13 | 14 | cns = ConfigBox(my_bool='yes', my_int='5', my_list='5,4,3,3,2') 15 | 16 | cns.bool('my_bool') # True 17 | cns.int('my_int') # 5 18 | cns.list('my_list', mod=lambda x: int(x)) # [5, 4, 3, 3, 2] 19 | """ 20 | 21 | _protected_keys = dir(Box) + ["bool", "int", "float", "list", "getboolean", "getfloat", "getint"] 22 | 23 | def __getattr__(self, item): 24 | """ 25 | Config file keys are stored in lower case, be a little more 26 | loosey goosey 27 | """ 28 | try: 29 | return super().__getattr__(item) 30 | except AttributeError: 31 | return super().__getattr__(item.lower()) 32 | 33 | def __dir__(self) -> List[str]: 34 | return super().__dir__() + ["bool", "int", "float", "list", "getboolean", "getfloat", "getint"] 35 | 36 | def bool(self, item, default=None): 37 | """ 38 | Return value of key as a boolean 39 | 40 | :param item: key of value to transform 41 | :param default: value to return if item does not exist 42 | :return: approximated bool of value 43 | """ 44 | try: 45 | item = self.__getattr__(item) 46 | except AttributeError as err: 47 | if default is not None: 48 | return default 49 | raise err 50 | 51 | if isinstance(item, (bool, int)): 52 | return bool(item) 53 | 54 | if isinstance(item, str) and item.lower() in ("n", "no", "false", "f", "0"): 55 | return False 56 | 57 | return True if item else False 58 | 59 | def int(self, item, default=None): 60 | """ 61 | Return value of key as an int 62 | 63 | :param item: key of value to transform 64 | :param default: value to return if item does not exist 65 | :return: int of value 66 | """ 67 | try: 68 | item = self.__getattr__(item) 69 | except AttributeError as err: 70 | if default is not None: 71 | return default 72 | raise err 73 | return int(item) 74 | 75 | def float(self, item, default=None): 76 | """ 77 | Return value of key as a float 78 | 79 | :param item: key of value to transform 80 | :param default: value to return if item does not exist 81 | :return: float of value 82 | """ 83 | try: 84 | item = self.__getattr__(item) 85 | except AttributeError as err: 86 | if default is not None: 87 | return default 88 | raise err 89 | return float(item) 90 | 91 | def list(self, item, default=None, spliter: str = ",", strip=True, mod=None): 92 | """ 93 | Return value of key as a list 94 | 95 | :param item: key of value to transform 96 | :param mod: function to map against list 97 | :param default: value to return if item does not exist 98 | :param spliter: character to split str on 99 | :param strip: clean the list with the `strip` 100 | :return: list of items 101 | """ 102 | try: 103 | item = self.__getattr__(item) 104 | except AttributeError as err: 105 | if default is not None: 106 | return default 107 | raise err 108 | if strip: 109 | item = item.lstrip("[").rstrip("]") 110 | out = [x.strip() if strip else x for x in item.split(spliter)] 111 | if mod: 112 | return list(map(mod, out)) 113 | return out 114 | 115 | # loose configparser compatibility 116 | 117 | def getboolean(self, item, default=None): 118 | return self.bool(item, default) 119 | 120 | def getint(self, item, default=None): 121 | return self.int(item, default) 122 | 123 | def getfloat(self, item, default=None): 124 | return self.float(item, default) 125 | 126 | def __repr__(self): 127 | return f"{self.__class__.__name__}({str(self.to_dict())})" 128 | 129 | def copy(self): 130 | return ConfigBox(super().copy()) 131 | 132 | def __copy__(self): 133 | return ConfigBox(super().copy()) 134 | -------------------------------------------------------------------------------- /box/config_box.pyi: -------------------------------------------------------------------------------- 1 | from box.box import Box as Box 2 | from typing import Any, Optional, List 3 | 4 | class ConfigBox(Box): 5 | def __getattr__(self, item: Any): ... 6 | def __dir__(self) -> List[str]: ... 7 | def bool(self, item: Any, default: Optional[Any] = ...): ... 8 | def int(self, item: Any, default: Optional[Any] = ...): ... 9 | def float(self, item: Any, default: Optional[Any] = ...): ... 10 | def list(self, item: Any, default: Optional[Any] = ..., spliter: str = ..., strip: bool = ..., mod: Optional[Any] = ...): ... # type: ignore 11 | def getboolean(self, item: Any, default: Optional[Any] = ...): ... 12 | def getint(self, item: Any, default: Optional[Any] = ...): ... 13 | def getfloat(self, item: Any, default: Optional[Any] = ...): ... 14 | def copy(self) -> "ConfigBox": ... 15 | def __copy__(self) -> "ConfigBox": ... 16 | -------------------------------------------------------------------------------- /box/converters.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Abstract converter functions for use in any Box class 5 | 6 | import csv 7 | import json 8 | from io import StringIO 9 | from os import PathLike 10 | from pathlib import Path 11 | from typing import Union, Optional, Dict, Any, Callable 12 | 13 | from box.exceptions import BoxError 14 | 15 | pyyaml_available = True 16 | ruamel_available = True 17 | msgpack_available = True 18 | 19 | try: 20 | from ruamel.yaml import version_info, YAML 21 | except ImportError: 22 | ruamel_available = False 23 | else: 24 | if version_info[1] < 17: 25 | ruamel_available = False 26 | 27 | try: 28 | import yaml 29 | except ImportError: 30 | pyyaml_available = False 31 | 32 | MISSING_PARSER_ERROR = "No YAML Parser available, please install ruamel.yaml>=0.17 or PyYAML" 33 | 34 | toml_read_library: Optional[Any] = None 35 | toml_write_library: Optional[Any] = None 36 | toml_decode_error: Optional[Callable] = None 37 | 38 | __all__ = [ 39 | "_to_json", 40 | "_to_yaml", 41 | "_to_toml", 42 | "_to_csv", 43 | "_to_msgpack", 44 | "_from_json", 45 | "_from_yaml", 46 | "_from_toml", 47 | "_from_csv", 48 | "_from_msgpack", 49 | ] 50 | 51 | 52 | class BoxTomlDecodeError(BoxError): 53 | """Toml Decode Error""" 54 | 55 | 56 | try: 57 | import toml 58 | except ImportError: 59 | pass 60 | else: 61 | toml_read_library = toml 62 | toml_write_library = toml 63 | toml_decode_error = toml.TomlDecodeError 64 | 65 | class BoxTomlDecodeError(BoxError, toml.TomlDecodeError): # type: ignore 66 | """Toml Decode Error""" 67 | 68 | 69 | try: 70 | import tomllib 71 | except ImportError: 72 | pass 73 | else: 74 | toml_read_library = tomllib 75 | toml_decode_error = tomllib.TOMLDecodeError 76 | 77 | class BoxTomlDecodeError(BoxError, tomllib.TOMLDecodeError): # type: ignore 78 | """Toml Decode Error""" 79 | 80 | 81 | try: 82 | import tomli 83 | except ImportError: 84 | pass 85 | else: 86 | toml_read_library = tomli 87 | toml_decode_error = tomli.TOMLDecodeError 88 | 89 | class BoxTomlDecodeError(BoxError, tomli.TOMLDecodeError): # type: ignore 90 | """Toml Decode Error""" 91 | 92 | 93 | try: 94 | import tomli_w 95 | except ImportError: 96 | pass 97 | else: 98 | toml_write_library = tomli_w 99 | 100 | 101 | try: 102 | import msgpack # type: ignore 103 | except ImportError: 104 | msgpack = None # type: ignore 105 | msgpack_available = False 106 | 107 | yaml_available = pyyaml_available or ruamel_available 108 | 109 | BOX_PARAMETERS = ( 110 | "default_box", 111 | "default_box_attr", 112 | "default_box_none_transform", 113 | "default_box_create_on_get", 114 | "frozen_box", 115 | "camel_killer_box", 116 | "conversion_box", 117 | "modify_tuples_box", 118 | "box_safe_prefix", 119 | "box_duplicates", 120 | "box_intact_types", 121 | "box_dots", 122 | "box_recast", 123 | "box_class", 124 | "box_namespace", 125 | ) 126 | 127 | 128 | def _exists(filename: Union[str, PathLike], create: bool = False) -> Path: 129 | path = Path(filename) 130 | if create: 131 | try: 132 | path.touch(exist_ok=True) 133 | except OSError as err: 134 | raise BoxError(f"Could not create file {filename} - {err}") 135 | else: 136 | return path 137 | if not path.exists(): 138 | raise BoxError(f'File "{filename}" does not exist') 139 | if not path.is_file(): 140 | raise BoxError(f"{filename} is not a file") 141 | return path 142 | 143 | 144 | def _to_json( 145 | obj, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **json_kwargs 146 | ): 147 | if filename: 148 | _exists(filename, create=True) 149 | with open(filename, "w", encoding=encoding, errors=errors) as f: 150 | json.dump(obj, f, ensure_ascii=False, **json_kwargs) 151 | else: 152 | return json.dumps(obj, ensure_ascii=False, **json_kwargs) 153 | 154 | 155 | def _from_json( 156 | json_string: Optional[str] = None, 157 | filename: Optional[Union[str, PathLike]] = None, 158 | encoding: str = "utf-8", 159 | errors: str = "strict", 160 | multiline: bool = False, 161 | **kwargs, 162 | ): 163 | if filename: 164 | with open(filename, "r", encoding=encoding, errors=errors) as f: 165 | if multiline: 166 | data = [ 167 | json.loads(line.strip(), **kwargs) 168 | for line in f 169 | if line.strip() and not line.strip().startswith("#") 170 | ] 171 | else: 172 | data = json.load(f, **kwargs) 173 | elif json_string: 174 | data = json.loads(json_string, **kwargs) 175 | else: 176 | raise BoxError("from_json requires a string or filename") 177 | return data 178 | 179 | 180 | def _to_yaml( 181 | obj, 182 | filename: Optional[Union[str, PathLike]] = None, 183 | default_flow_style: bool = False, 184 | encoding: str = "utf-8", 185 | errors: str = "strict", 186 | ruamel_typ: str = "rt", 187 | ruamel_attrs: Optional[Dict] = None, 188 | **yaml_kwargs, 189 | ): 190 | if not ruamel_attrs: 191 | ruamel_attrs = {} 192 | if filename: 193 | _exists(filename, create=True) 194 | with open(filename, "w", encoding=encoding, errors=errors) as f: 195 | if ruamel_available: 196 | yaml_dumper = YAML(typ=ruamel_typ) 197 | yaml_dumper.default_flow_style = default_flow_style 198 | for attr, value in ruamel_attrs.items(): 199 | setattr(yaml_dumper, attr, value) 200 | return yaml_dumper.dump(obj, stream=f, **yaml_kwargs) 201 | elif pyyaml_available: 202 | return yaml.dump(obj, stream=f, default_flow_style=default_flow_style, **yaml_kwargs) 203 | else: 204 | raise BoxError(MISSING_PARSER_ERROR) 205 | 206 | else: 207 | if ruamel_available: 208 | yaml_dumper = YAML(typ=ruamel_typ) 209 | yaml_dumper.default_flow_style = default_flow_style 210 | for attr, value in ruamel_attrs.items(): 211 | setattr(yaml_dumper, attr, value) 212 | with StringIO() as string_stream: 213 | yaml_dumper.dump(obj, stream=string_stream, **yaml_kwargs) 214 | return string_stream.getvalue() 215 | elif pyyaml_available: 216 | return yaml.dump(obj, default_flow_style=default_flow_style, **yaml_kwargs) 217 | else: 218 | raise BoxError(MISSING_PARSER_ERROR) 219 | 220 | 221 | def _from_yaml( 222 | yaml_string: Optional[str] = None, 223 | filename: Optional[Union[str, PathLike]] = None, 224 | encoding: str = "utf-8", 225 | errors: str = "strict", 226 | ruamel_typ: str = "rt", 227 | ruamel_attrs: Optional[Dict] = None, 228 | **kwargs, 229 | ): 230 | if not ruamel_attrs: 231 | ruamel_attrs = {} 232 | if filename: 233 | _exists(filename) 234 | with open(filename, "r", encoding=encoding, errors=errors) as f: 235 | if ruamel_available: 236 | yaml_loader = YAML(typ=ruamel_typ) 237 | for attr, value in ruamel_attrs.items(): 238 | setattr(yaml_loader, attr, value) 239 | data = yaml_loader.load(stream=f) 240 | elif pyyaml_available: 241 | if "Loader" not in kwargs: 242 | kwargs["Loader"] = yaml.SafeLoader 243 | data = yaml.load(f, **kwargs) 244 | else: 245 | raise BoxError(MISSING_PARSER_ERROR) 246 | elif yaml_string: 247 | if ruamel_available: 248 | yaml_loader = YAML(typ=ruamel_typ) 249 | for attr, value in ruamel_attrs.items(): 250 | setattr(yaml_loader, attr, value) 251 | data = yaml_loader.load(stream=yaml_string) 252 | elif pyyaml_available: 253 | if "Loader" not in kwargs: 254 | kwargs["Loader"] = yaml.SafeLoader 255 | data = yaml.load(yaml_string, **kwargs) 256 | else: 257 | raise BoxError(MISSING_PARSER_ERROR) 258 | else: 259 | raise BoxError("from_yaml requires a string or filename") 260 | return data 261 | 262 | 263 | def _to_toml(obj, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict"): 264 | if filename: 265 | _exists(filename, create=True) 266 | if toml_write_library.__name__ == "toml": # type: ignore 267 | with open(filename, "w", encoding=encoding, errors=errors) as f: 268 | try: 269 | toml_write_library.dump(obj, f) # type: ignore 270 | except toml_decode_error as err: # type: ignore 271 | raise BoxTomlDecodeError(err) from err 272 | else: 273 | with open(filename, "wb") as f: 274 | try: 275 | toml_write_library.dump(obj, f) # type: ignore 276 | except toml_decode_error as err: # type: ignore 277 | raise BoxTomlDecodeError(err) from err 278 | else: 279 | try: 280 | return toml_write_library.dumps(obj) # type: ignore 281 | except toml_decode_error as err: # type: ignore 282 | raise BoxTomlDecodeError(err) from err 283 | 284 | 285 | def _from_toml( 286 | toml_string: Optional[str] = None, 287 | filename: Optional[Union[str, PathLike]] = None, 288 | encoding: str = "utf-8", 289 | errors: str = "strict", 290 | ): 291 | if filename: 292 | _exists(filename) 293 | if toml_read_library.__name__ == "toml": # type: ignore 294 | with open(filename, "r", encoding=encoding, errors=errors) as f: 295 | data = toml_read_library.load(f) # type: ignore 296 | else: 297 | with open(filename, "rb") as f: 298 | data = toml_read_library.load(f) # type: ignore 299 | elif toml_string: 300 | data = toml_read_library.loads(toml_string) # type: ignore 301 | else: 302 | raise BoxError("from_toml requires a string or filename") 303 | return data 304 | 305 | 306 | def _to_msgpack(obj, filename: Optional[Union[str, PathLike]] = None, **kwargs): 307 | if filename: 308 | _exists(filename, create=True) 309 | with open(filename, "wb") as f: 310 | msgpack.pack(obj, f, **kwargs) 311 | else: 312 | return msgpack.packb(obj, **kwargs) 313 | 314 | 315 | def _from_msgpack(msgpack_bytes: Optional[bytes] = None, filename: Optional[Union[str, PathLike]] = None, **kwargs): 316 | if filename: 317 | _exists(filename) 318 | with open(filename, "rb") as f: 319 | data = msgpack.unpack(f, **kwargs) 320 | elif msgpack_bytes: 321 | data = msgpack.unpackb(msgpack_bytes, **kwargs) 322 | else: 323 | raise BoxError("from_msgpack requires a string or filename") 324 | return data 325 | 326 | 327 | def _to_csv( 328 | box_list, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **kwargs 329 | ): 330 | csv_column_names = list(box_list[0].keys()) 331 | for row in box_list: 332 | if list(row.keys()) != csv_column_names: 333 | raise BoxError("BoxList must contain the same dictionary structure for every item to convert to csv") 334 | 335 | if filename: 336 | _exists(filename, create=True) 337 | out_data = open(filename, "w", encoding=encoding, errors=errors, newline="") 338 | else: 339 | out_data = StringIO("") 340 | writer = csv.DictWriter(out_data, fieldnames=csv_column_names, **kwargs) 341 | writer.writeheader() 342 | for data in box_list: 343 | writer.writerow(data) 344 | if not filename: 345 | return out_data.getvalue() # type: ignore 346 | out_data.close() 347 | 348 | 349 | def _from_csv( 350 | csv_string: Optional[str] = None, 351 | filename: Optional[Union[str, PathLike]] = None, 352 | encoding: str = "utf-8", 353 | errors: str = "strict", 354 | **kwargs, 355 | ): 356 | if csv_string: 357 | with StringIO(csv_string) as cs: 358 | reader = csv.DictReader(cs) 359 | return [row for row in reader] 360 | _exists(filename) # type: ignore 361 | with open(filename, "r", encoding=encoding, errors=errors, newline="") as f: # type: ignore 362 | reader = csv.DictReader(f, **kwargs) 363 | return [row for row in reader] 364 | -------------------------------------------------------------------------------- /box/converters.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Optional, Union, Dict 2 | from os import PathLike 3 | 4 | yaml_available: bool 5 | toml_available: bool 6 | msgpack_available: bool 7 | BOX_PARAMETERS: Any 8 | toml_read_library: Optional[Any] 9 | toml_write_library: Optional[Any] 10 | toml_decode_error: Optional[Callable] 11 | 12 | def _to_json( 13 | obj, filename: Optional[Union[str, PathLike]] = ..., encoding: str = ..., errors: str = ..., **json_kwargs 14 | ): ... 15 | def _from_json( 16 | json_string: Optional[str] = ..., 17 | filename: Optional[Union[str, PathLike]] = ..., 18 | encoding: str = ..., 19 | errors: str = ..., 20 | multiline: bool = ..., 21 | **kwargs, 22 | ): ... 23 | def _to_yaml( 24 | obj, 25 | filename: Optional[Union[str, PathLike]] = ..., 26 | default_flow_style: bool = ..., 27 | encoding: str = ..., 28 | errors: str = ..., 29 | ruamel_typ: str = ..., 30 | ruamel_attrs: Optional[Dict] = ..., 31 | **yaml_kwargs, 32 | ): ... 33 | def _from_yaml( 34 | yaml_string: Optional[str] = ..., 35 | filename: Optional[Union[str, PathLike]] = ..., 36 | encoding: str = ..., 37 | errors: str = ..., 38 | ruamel_typ: str = ..., 39 | ruamel_attrs: Optional[Dict] = ..., 40 | **kwargs, 41 | ): ... 42 | def _to_toml(obj, filename: Optional[Union[str, PathLike]] = ..., encoding: str = ..., errors: str = ...): ... 43 | def _from_toml( 44 | toml_string: Optional[str] = ..., 45 | filename: Optional[Union[str, PathLike]] = ..., 46 | encoding: str = ..., 47 | errors: str = ..., 48 | ): ... 49 | def _to_msgpack(obj, filename: Optional[Union[str, PathLike]] = ..., **kwargs): ... 50 | def _from_msgpack(msgpack_bytes: Optional[bytes] = ..., filename: Optional[Union[str, PathLike]] = ..., **kwargs): ... 51 | def _to_csv( 52 | box_list, filename: Optional[Union[str, PathLike]] = ..., encoding: str = ..., errors: str = ..., **kwargs 53 | ): ... 54 | def _from_csv( 55 | csv_string: Optional[str] = ..., 56 | filename: Optional[Union[str, PathLike]] = ..., 57 | encoding: str = ..., 58 | errors: str = ..., 59 | **kwargs, 60 | ): ... 61 | -------------------------------------------------------------------------------- /box/exceptions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | class BoxError(Exception): 6 | """Non standard dictionary exceptions""" 7 | 8 | 9 | class BoxKeyError(BoxError, KeyError, AttributeError): 10 | """Key does not exist""" 11 | 12 | 13 | class BoxTypeError(BoxError, TypeError): 14 | """Cannot handle that instance's type""" 15 | 16 | 17 | class BoxValueError(BoxError, ValueError): 18 | """Issue doing something with that value""" 19 | 20 | 21 | class BoxWarning(UserWarning): 22 | """Here be dragons""" 23 | -------------------------------------------------------------------------------- /box/exceptions.pyi: -------------------------------------------------------------------------------- 1 | class BoxError(Exception): ... 2 | class BoxKeyError(BoxError, KeyError, AttributeError): ... 3 | class BoxTypeError(BoxError, TypeError): ... 4 | class BoxValueError(BoxError, ValueError): ... 5 | class BoxWarning(UserWarning): ... 6 | -------------------------------------------------------------------------------- /box/from_file.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from json import JSONDecodeError 4 | from os import PathLike 5 | from pathlib import Path 6 | from typing import Optional, Callable, Dict, Union 7 | import sys 8 | 9 | from box.box import Box 10 | from box.box_list import BoxList 11 | from box.converters import msgpack_available, toml_read_library, yaml_available, toml_decode_error 12 | from box.exceptions import BoxError 13 | 14 | try: 15 | from ruamel.yaml import YAMLError 16 | except ImportError: 17 | try: 18 | from yaml import YAMLError # type: ignore 19 | except ImportError: 20 | YAMLError = False # type: ignore 21 | 22 | try: 23 | from msgpack import UnpackException # type: ignore 24 | except ImportError: 25 | UnpackException = False # type: ignore 26 | 27 | 28 | __all__ = ["box_from_file", "box_from_string"] 29 | 30 | 31 | def _to_json(file, encoding, errors, **kwargs): 32 | try: 33 | return Box.from_json(filename=file, encoding=encoding, errors=errors, **kwargs) 34 | except JSONDecodeError: 35 | raise BoxError("File is not JSON as expected") 36 | except BoxError: 37 | return BoxList.from_json(filename=file, encoding=encoding, errors=errors, **kwargs) 38 | 39 | 40 | def _to_csv(file, encoding, errors, **kwargs): 41 | return BoxList.from_csv(filename=file, encoding=encoding, errors=errors, **kwargs) 42 | 43 | 44 | def _to_yaml(file, encoding, errors, **kwargs): 45 | if not yaml_available: 46 | raise BoxError( 47 | f'File "{file}" is yaml but no package is available to open it. Please install "ruamel.yaml" or "PyYAML"' 48 | ) 49 | try: 50 | return Box.from_yaml(filename=file, encoding=encoding, errors=errors, **kwargs) 51 | except YAMLError: 52 | raise BoxError("File is not YAML as expected") 53 | except BoxError: 54 | return BoxList.from_yaml(filename=file, encoding=encoding, errors=errors, **kwargs) 55 | 56 | 57 | def _to_toml(file, encoding, errors, **kwargs): 58 | if not toml_read_library: 59 | raise BoxError(f'File "{file}" is toml but no package is available to open it. Please install "tomli"') 60 | try: 61 | return Box.from_toml(filename=file, encoding=encoding, errors=errors, **kwargs) 62 | except toml_decode_error: 63 | raise BoxError("File is not TOML as expected") 64 | 65 | 66 | def _to_msgpack(file, _, __, **kwargs): 67 | if not msgpack_available: 68 | raise BoxError(f'File "{file}" is msgpack but no package is available to open it. Please install "msgpack"') 69 | try: 70 | return Box.from_msgpack(filename=file, **kwargs) 71 | except (UnpackException, ValueError): 72 | raise BoxError("File is not msgpack as expected") 73 | except BoxError: 74 | return BoxList.from_msgpack(filename=file, **kwargs) 75 | 76 | 77 | converters = { 78 | "json": _to_json, 79 | "jsn": _to_json, 80 | "yaml": _to_yaml, 81 | "yml": _to_yaml, 82 | "toml": _to_toml, 83 | "tml": _to_toml, 84 | "msgpack": _to_msgpack, 85 | "pack": _to_msgpack, 86 | "csv": _to_csv, 87 | } # type: Dict[str, Callable] 88 | 89 | 90 | def box_from_file( 91 | file: Union[str, PathLike], 92 | file_type: Optional[str] = None, 93 | encoding: str = "utf-8", 94 | errors: str = "strict", 95 | **kwargs, 96 | ) -> Union[Box, BoxList]: 97 | """ 98 | Loads the provided file and tries to parse it into a Box or BoxList object as appropriate. 99 | 100 | :param file: Location of file 101 | :param encoding: File encoding 102 | :param errors: How to handle encoding errors 103 | :param file_type: manually specify file type: json, toml or yaml 104 | :return: Box or BoxList 105 | """ 106 | 107 | if not isinstance(file, Path): 108 | file = Path(file) 109 | if not file.exists(): 110 | raise BoxError(f'file "{file}" does not exist') 111 | file_type = file_type or file.suffix 112 | file_type = file_type.lower().lstrip(".") 113 | if file_type.lower() in converters: 114 | return converters[file_type.lower()](file, encoding, errors, **kwargs) # type: ignore 115 | raise BoxError(f'"{file_type}" is an unknown type. Please use either csv, toml, msgpack, yaml or json') 116 | 117 | 118 | def box_from_string(content: str, string_type: str = "json") -> Union[Box, BoxList]: 119 | """ 120 | Parse the provided string into a Box or BoxList object as appropriate. 121 | 122 | :param content: String to parse 123 | :param string_type: manually specify file type: json, toml or yaml 124 | :return: Box or BoxList 125 | """ 126 | 127 | if string_type == "json": 128 | try: 129 | return Box.from_json(json_string=content) 130 | except JSONDecodeError: 131 | raise BoxError("File is not JSON as expected") 132 | except BoxError: 133 | return BoxList.from_json(json_string=content) 134 | elif string_type == "toml": 135 | try: 136 | return Box.from_toml(toml_string=content) 137 | except toml_decode_error: # type: ignore 138 | raise BoxError("File is not TOML as expected") 139 | except BoxError: 140 | return BoxList.from_toml(toml_string=content) 141 | elif string_type == "yaml": 142 | try: 143 | return Box.from_yaml(yaml_string=content) 144 | except YAMLError: 145 | raise BoxError("File is not YAML as expected") 146 | except BoxError: 147 | return BoxList.from_yaml(yaml_string=content) 148 | else: 149 | raise BoxError(f"Unsupported string_string of {string_type}") 150 | -------------------------------------------------------------------------------- /box/from_file.pyi: -------------------------------------------------------------------------------- 1 | from box.box import Box as Box 2 | from box.box_list import BoxList as BoxList 3 | from os import PathLike 4 | from typing import Any, Union 5 | 6 | def box_from_file( 7 | file: Union[str, PathLike], 8 | file_type: str = ..., 9 | encoding: str = ..., 10 | errors: str = ..., 11 | **kwargs: Any, 12 | ) -> Union[Box, BoxList]: ... 13 | def box_from_string( 14 | content: str, 15 | string_type: str = ..., 16 | ) -> Union[Box, BoxList]: ... 17 | -------------------------------------------------------------------------------- /box/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdgriffith/Box/b071107161228f32762ece8f6039b6906c2570db/box/py.typed -------------------------------------------------------------------------------- /box/shorthand_box.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from typing import Dict 4 | 5 | from box.box import Box 6 | 7 | __all__ = ["SBox", "DDBox"] 8 | 9 | 10 | class SBox(Box): 11 | """ 12 | ShorthandBox (SBox) allows for 13 | property access of `dict` `json` and `yaml` 14 | """ 15 | 16 | _protected_keys = dir({}) + [ 17 | "to_dict", 18 | "to_json", 19 | "to_yaml", 20 | "json", 21 | "yaml", 22 | "from_yaml", 23 | "from_json", 24 | "dict", 25 | "toml", 26 | "from_toml", 27 | "to_toml", 28 | ] 29 | 30 | @property 31 | def dict(self) -> Dict: 32 | return self.to_dict() 33 | 34 | @property 35 | def json(self) -> str: 36 | return self.to_json() 37 | 38 | @property 39 | def yaml(self) -> str: 40 | return self.to_yaml() 41 | 42 | @property 43 | def toml(self) -> str: 44 | return self.to_toml() 45 | 46 | def __repr__(self): 47 | return f"{self.__class__.__name__}({self})" 48 | 49 | def copy(self) -> "SBox": 50 | return SBox(super(SBox, self).copy()) 51 | 52 | def __copy__(self) -> "SBox": 53 | return SBox(super(SBox, self).copy()) 54 | 55 | 56 | class DDBox(SBox): 57 | def __init__(self, *args, **kwargs): 58 | kwargs["box_dots"] = True 59 | kwargs["default_box"] = True 60 | super().__init__(*args, **kwargs) 61 | 62 | def __new__(cls, *args, **kwargs): 63 | obj = super().__new__(cls, *args, **kwargs) 64 | obj._box_config["box_dots"] = True 65 | obj._box_config["default_box"] = True 66 | return obj 67 | 68 | def __repr__(self) -> str: 69 | return f"{self.__class__.__name__}({self})" 70 | -------------------------------------------------------------------------------- /box/shorthand_box.pyi: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from box.box import Box as Box 4 | 5 | class SBox(Box): 6 | @property 7 | def dict(self) -> Dict: ... 8 | @property 9 | def json(self) -> str: ... 10 | @property 11 | def yaml(self) -> str: ... 12 | @property 13 | def toml(self) -> str: ... 14 | def copy(self) -> "SBox": ... 15 | def __copy__(self) -> "SBox": ... 16 | 17 | class DDBox(Box): ... 18 | -------------------------------------------------------------------------------- /box_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdgriffith/Box/b071107161228f32762ece8f6039b6906c2570db/box_logo.png -------------------------------------------------------------------------------- /docs/4.x_changes.rst: -------------------------------------------------------------------------------- 1 | Box 4.0 Features and Changes 2 | ============================ 3 | 4 | Box 4.0 has brought a lot of great new features, but also some breaking changes. They are documented here to help you upgrade. 5 | 6 | To install the latest 4.x you will need at least Python 3.6 (or current supported python 3.x version) 7 | 8 | ..code:: bash 9 | 10 | pip install --upgrade python-box>=4 11 | 12 | 13 | 14 | If your application is no longer working, and need a quick fix: 15 | 16 | ..code:: bash 17 | 18 | pip install --upgrade python-box<4 19 | 20 | 21 | Additions 22 | --------- 23 | 24 | Dot notation access by keys! 25 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 26 | 27 | Enabled with `box_dots=True`. 28 | 29 | .. code:: python 30 | 31 | from box import Box 32 | my_box = Box(a={'b': {'c': {'d': 'my_value'}}}, box_dots=True) 33 | print(my_box['a.b.c.d']) 34 | # 'my_value' 35 | my_box['a.b.c.d'] = 'test' 36 | # 37 | del my_box['a.b.c.d'] 38 | # 39 | 40 | This only works with keys that are string to begin with, as we don't do any automatic conversion behind the scene. 41 | 42 | 4.1 Update: This now also supports list traversal, like `my_box['my_key[0][0]']` 43 | 44 | 45 | Support for adding two Boxes together 46 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 47 | 48 | .. code:: python 49 | 50 | from box import Box 51 | Box(a=4) + Box(a='overwritten', b=5) 52 | # 53 | 54 | 55 | Additional additions 56 | ~~~~~~~~~~~~~~~~~~~~ 57 | 58 | * Added toml conversion support 59 | * Added CSV conversion support 60 | * Added box_from_file helper function 61 | 62 | Changes 63 | ------- 64 | 65 | Adding merge_update as its own function, update now works like the default dictionary update 66 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 67 | 68 | Traditional update is destructive to nested dictionaries. 69 | 70 | .. code:: python 71 | 72 | from box import Box 73 | box_one = Box(inside_dict={'data': 5}) 74 | box_two = Box(inside_dict={'folly': True}) 75 | box_one.update(box_two) 76 | repr(box_one) 77 | # 78 | 79 | 80 | Merge update takes existing sub dictionaries into consideration 81 | 82 | .. code:: python 83 | 84 | from box import Box 85 | box_one = Box(inside_dict={'data': 5}) 86 | box_two = Box(inside_dict={'folly': True}) 87 | box_one.merge_update(box_two) 88 | repr(box_one) 89 | "" 90 | 91 | Camel Killer Box now changes keys on insertion 92 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 93 | 94 | There was a bug in the 4.0 code that meant camel killer was not working at all under normal conditions due 95 | to the change of how the box is instantiated. 96 | 97 | .. code:: python 98 | 99 | from box import Box 100 | 101 | my_box = Box({'CamelCase': 'Item'}, camel_killer_box=True) 102 | assert my_box.camel_case == 'Item' 103 | print(my_box.to_dict()) 104 | # {'camel_case': 'Item'} 105 | 106 | 107 | Conversion keys are now a bit smarter with how they are handled 108 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 109 | 110 | Keys with safety underscores used to be treated internally as if the underscores didn't always exist, i.e. 111 | 112 | .. code:: python 113 | 114 | from box import Box 115 | b = Box(_out = 'preserved') 116 | b.update({'out': 'updated'}) 117 | # expected: 118 | # {'_out': 'preserved', 'out': 'updated'} 119 | # observed: 120 | # {'_out': 'updated'} 121 | 122 | 123 | Those issues have been (hopefully) overcome and now will have the expected `` 124 | 125 | YAML 1.2 default instead of 1.1 126 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 127 | 128 | ruamel.yaml is now an install requirement and new default instead of PyYAML. 129 | By design ruamel.yaml uses the newer YAML v1.2 (which PyYAML does not yet support as of Jan 2020). 130 | 131 | To use the older version of 1.1, make sure to specify the version while using the from_yaml methods. 132 | 133 | .. code:: python 134 | 135 | from box import Box 136 | Box.from_yaml("fire_ze_missiles: no") 137 | 138 | 139 | Box.from_yaml("fire_ze_missiles: no", version='1.1') 140 | 141 | 142 | You can read more about the differences `here `_ 143 | 144 | To use PyYAML instead of ruamel.yaml you must install box without dependencies (such as `--no-deps` with `pip`) 145 | 146 | If you do chose to stick with PyYaML, you can suppress the warning on just box's import: 147 | 148 | .. code:: python 149 | 150 | import warnings 151 | with warnings.catch_warnings(): 152 | warnings.simplefilter("ignore") 153 | from box import Box 154 | 155 | 156 | Additional changes 157 | ~~~~~~~~~~~~~~~~~~ 158 | 159 | * Default Box will also work on `None` placeholders 160 | 161 | Removed 162 | ------- 163 | 164 | No more Python 2 support 165 | ~~~~~~~~~~~~~~~~~~~~~~~~ 166 | 167 | Python 2 is soon officially EOL and Box 4 won't support it in anyway. Box 3 will not be updated, other than will consider PRs for bugs or security issues. 168 | 169 | Removing Ordered Box 170 | ~~~~~~~~~~~~~~~~~~~~ 171 | 172 | As dictionaries are ordered by default in Python 3.6+ there is no point to continue writing and testing code outside of that. 173 | 174 | Removing `BoxObject` 175 | ~~~~~~~~~~~~~~~~~~~~ 176 | 177 | As BoxObject was not cross platform compatible and had some `issues `_ it has been removed. 178 | 179 | Removing `box_it_up` 180 | ~~~~~~~~~~~~~~~~~~~~ 181 | 182 | Everything is converted on creation again, as the speed was seldom worth the extra headaches associated with such a design. 183 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # Files needed for pre-commit hooks 2 | black>=23.1.0 3 | Cython>=3.0.11 4 | mypy>=1.0.1 5 | pre-commit>=2.21.0 6 | setuptools>=75.6.0 7 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | coverage>=7.6.9 2 | msgpack>=1.0 3 | pytest>=7.1.3 4 | pytest-cov<6.0.0 5 | ruamel.yaml>=0.17 6 | tomli>=1.2.3; python_version < '3.11' 7 | tomli-w>=1.0.0 8 | types-PyYAML>=6.0.3 9 | wheel>=0.34.2 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | msgpack>=1.0.0 2 | ruamel.yaml>=0.17 3 | tomli>=1.2.3; python_version < '3.11' 4 | tomli-w 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Must import multiprocessing as a fix for issues with testing, experienced on win10 5 | import multiprocessing # noqa: F401 6 | import os 7 | import re 8 | from pathlib import Path 9 | import sys 10 | import shutil 11 | 12 | from setuptools import setup 13 | 14 | root = os.path.abspath(os.path.dirname(__file__)) 15 | 16 | try: 17 | from Cython.Build import cythonize 18 | except ImportError: 19 | extra = None 20 | else: 21 | extra = cythonize( 22 | [str(file.relative_to(root)) for file in Path(root, "box").glob("*.py") if file.name != "__init__.py"], 23 | compiler_directives={"language_level": 3}, 24 | ) 25 | 26 | with open(os.path.join(root, "box", "__init__.py"), "r") as init_file: 27 | init_content = init_file.read() 28 | 29 | attrs = dict(re.findall(r"__([a-z]+)__ *= *['\"](.+)['\"]", init_content)) 30 | 31 | with open("README.rst", "r") as readme_file: 32 | long_description = readme_file.read() 33 | 34 | setup( 35 | name="python-box", 36 | version=attrs["version"], 37 | url="https://github.com/cdgriffith/Box", 38 | license="MIT", 39 | author=attrs["author"], 40 | install_requires=[], 41 | author_email="chris@cdgriffith.com", 42 | description="Advanced Python dictionaries with dot notation access", 43 | long_description=long_description, 44 | long_description_content_type="text/x-rst", 45 | py_modules=["box"], 46 | packages=["box"], 47 | ext_modules=extra, 48 | python_requires=">=3.9", 49 | include_package_data=True, 50 | platforms="any", 51 | classifiers=[ 52 | "Programming Language :: Python", 53 | "Programming Language :: Python :: 3", 54 | "Programming Language :: Python :: 3.9", 55 | "Programming Language :: Python :: 3.10", 56 | "Programming Language :: Python :: 3.11", 57 | "Programming Language :: Python :: 3.12", 58 | "Programming Language :: Python :: 3.13", 59 | "Programming Language :: Python :: Implementation :: CPython", 60 | "Development Status :: 5 - Production/Stable", 61 | "Natural Language :: English", 62 | "Intended Audience :: Developers", 63 | "License :: OSI Approved :: MIT License", 64 | "Operating System :: OS Independent", 65 | "Topic :: Utilities", 66 | "Topic :: Software Development", 67 | "Topic :: Software Development :: Libraries :: Python Modules", 68 | ], 69 | extras_require={ 70 | "all": ["ruamel.yaml>=0.17", "toml", "msgpack"], 71 | "yaml": ["ruamel.yaml>=0.17"], 72 | "ruamel.yaml": ["ruamel.yaml>=0.17"], 73 | "PyYAML": ["PyYAML"], 74 | "tomli": ["tomli; python_version < '3.11'", "tomli-w"], 75 | "toml": ["toml"], 76 | "msgpack": ["msgpack"], 77 | }, 78 | ) 79 | 80 | if not extra: 81 | print("WARNING: Cython not installed, could not optimize box.", file=sys.stderr) 82 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdgriffith/Box/b071107161228f32762ece8f6039b6906c2570db/test/__init__.py -------------------------------------------------------------------------------- /test/common.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import sys 4 | from pathlib import Path 5 | 6 | PY3 = sys.version_info >= (3, 0) 7 | 8 | test_root = os.path.abspath(os.path.dirname(__file__)) 9 | tmp_dir = Path(test_root, "tmp") 10 | 11 | test_dict = { 12 | "key1": "value1", 13 | "not$allowed": "fine_value", 14 | "BigCamel": "hi", 15 | "alist": [{"a": 1}], 16 | "Key 2": {"Key 3": "Value 3", "Key4": {"Key5": "Value5"}}, 17 | } 18 | 19 | extended_test_dict = { 20 | 3: "howdy", 21 | "not": "true", 22 | (3, 4): "test", 23 | "_box_config": True, 24 | "CamelCase": "21", 25 | "321CamelCase": 321, 26 | False: "tree", 27 | "tuples_galore": ({"item": 3}, ({"item": 4}, 5)), 28 | } 29 | extended_test_dict.update(test_dict) # type: ignore 30 | 31 | data_json_file = os.path.join(test_root, "data", "json_file.json") 32 | data_yaml_file = os.path.join(test_root, "data", "yaml_file.yaml") 33 | tmp_json_file = os.path.join(test_root, "tmp", "tmp_json_file.json") 34 | tmp_yaml_file = os.path.join(test_root, "tmp", "tmp_yaml_file.yaml") 35 | tmp_msgpack_file = os.path.join(test_root, "tmp", "tmp_msgpack_file.msgpack") 36 | 37 | movie_data = { 38 | "movies": { 39 | "Spaceballs": { 40 | "imdb_stars": 7.1, 41 | "rating": "PG", 42 | "length": 96, 43 | "Director": "Mel Brooks", 44 | "Stars": [ 45 | {"name": "Mel Brooks", "imdb": "nm0000316", "role": "President Skroob"}, 46 | {"name": "John Candy", "imdb": "nm0001006", "role": "Barf"}, 47 | {"name": "Rick Moranis", "imdb": "nm0001548", "role": "Dark Helmet"}, 48 | ], 49 | }, 50 | "Robin Hood: Men in Tights": { 51 | "imdb_stars": 6.7, 52 | "rating": "PG-13", 53 | "length": 104, 54 | "Director": "Mel Brooks", 55 | "Stars": [ 56 | {"name": "Cary Elwes", "imdb": "nm0000144", "role": "Robin Hood"}, 57 | {"name": "Richard Lewis", "imdb": "nm0507659", "role": "Prince John"}, 58 | {"name": "Roger Rees", "imdb": "nm0715953", "role": "Sheriff of Rottingham"}, 59 | {"name": "Amy Yasbeck", "imdb": "nm0001865", "role": "Marian"}, 60 | ], 61 | }, 62 | } 63 | } 64 | 65 | 66 | def function_example(value): 67 | yield value 68 | 69 | 70 | class ClassExample(object): 71 | def __init__(self): 72 | self.a = "a" 73 | self.b = 2 74 | 75 | 76 | python_example_objects = ( 77 | None, # type: ignore 78 | True, 79 | False, 80 | 1, 81 | 3.14, 82 | "abc", 83 | [1, 2, 3], 84 | {}, 85 | ([], {}), 86 | lambda x: x**2, 87 | function_example, 88 | ClassExample(), 89 | ) # type: ignore 90 | -------------------------------------------------------------------------------- /test/data/bad_file.txt: -------------------------------------------------------------------------------- 1 | Nothing good in here 2 | # bad data 3 | test/ 4 | -------------------------------------------------------------------------------- /test/data/csv_file.csv: -------------------------------------------------------------------------------- 1 | Number,Name,Country 2 | 1,Chris,US 3 | 2,Sam,US 4 | 3,Jess,US 5 | 4,Frank,UK 6 | 5,Demo,CA 7 | -------------------------------------------------------------------------------- /test/data/json_file.json: -------------------------------------------------------------------------------- 1 | {"widget": { 2 | "debug": "on", 3 | "window": { 4 | "title": "Sample Konfabulator Widget", 5 | "name": "main_window", 6 | "width": 500, 7 | "height": 500 8 | }, 9 | "image": { 10 | "src": "Images/Sun.png", 11 | "name": "sun1", 12 | "hOffset": 250, 13 | "vOffset": 250, 14 | "alignment": "center" 15 | }, 16 | "text": { 17 | "data": "Click Here", 18 | "size": 36, 19 | "style": "bold", 20 | "name": "text1", 21 | "hOffset": 250, 22 | "vOffset": 100, 23 | "alignment": "center", 24 | "onMouseUp": "sun1.opacity = (sun1.opacity / 100) * 90;" 25 | } 26 | }} 27 | -------------------------------------------------------------------------------- /test/data/json_list.json: -------------------------------------------------------------------------------- 1 | [ 2 | "test", 3 | "data" 4 | ] 5 | -------------------------------------------------------------------------------- /test/data/msgpack_file.msgpack: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdgriffith/Box/b071107161228f32762ece8f6039b6906c2570db/test/data/msgpack_file.msgpack -------------------------------------------------------------------------------- /test/data/msgpack_list.msgpack: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdgriffith/Box/b071107161228f32762ece8f6039b6906c2570db/test/data/msgpack_list.msgpack -------------------------------------------------------------------------------- /test/data/toml_file.tml: -------------------------------------------------------------------------------- 1 | # Official example file https://raw.githubusercontent.com/toml-lang/toml/master/tests/example.toml 2 | # This is a TOML document. Boom. 3 | 4 | title = "TOML Example" 5 | 6 | [owner] 7 | name = "Tom Preston-Werner" 8 | organization = "GitHub" 9 | bio = "GitHub Cofounder & CEO\nLikes tater tots and beer." 10 | dob = 1979-05-27T07:32:00Z # First class dates? Why not? 11 | 12 | [database] 13 | server = "192.168.1.1" 14 | ports = [ 8001, 8001, 8002 ] 15 | connection_max = 5000 16 | enabled = true 17 | 18 | [servers] 19 | 20 | # You can indent as you please. Tabs or spaces. TOML don't care. 21 | [servers.alpha] 22 | ip = "10.0.0.1" 23 | dc = "eqdc10" 24 | 25 | [servers.beta] 26 | ip = "10.0.0.2" 27 | dc = "eqdc10" 28 | country = "中国" # This should be parsed as UTF-8 29 | 30 | [clients] 31 | data = [ ["gamma", "delta"], [1, 2] ] # just an update to make sure parsers support it 32 | 33 | # Line breaks are OK when inside arrays 34 | hosts = [ 35 | "alpha", 36 | "omega" 37 | ] 38 | 39 | # Products 40 | 41 | [[products]] 42 | name = "Hammer" 43 | sku = 738594937 44 | 45 | [[products]] 46 | name = "Nail" 47 | sku = 284758393 48 | color = "gray" 49 | -------------------------------------------------------------------------------- /test/data/yaml_file.yaml: -------------------------------------------------------------------------------- 1 | invoice: 34843 2 | date : 2001-01-23 3 | bill-to: &id001 4 | given : Chris 5 | family : Dumars 6 | address: 7 | lines: | 8 | 458 Walkman Dr. 9 | Suite #292 10 | city : Royal Oak 11 | state : MI 12 | postal : 48046 13 | ship-to: *id001 14 | product: 15 | - sku : BL394D 16 | quantity : 4 17 | description : Basketball 18 | price : 450.00 19 | - sku : BL4438H 20 | quantity : 1 21 | description : Super Hoop 22 | price : 2392.00 23 | tax : 251.42 24 | total: 4443.52 25 | comments: > 26 | Late afternoon is best. 27 | Backup contact is Nancy 28 | Billsmer @ 338-4338. 29 | -------------------------------------------------------------------------------- /test/data/yaml_list.yaml: -------------------------------------------------------------------------------- 1 | - sku : BL394D 2 | quantity : 4 3 | description : Basketball 4 | price : 450.00 5 | - sku : BL4438H 6 | quantity : 1 7 | description : Super Hoop 8 | price : 2392.00 9 | -------------------------------------------------------------------------------- /test/test_box_list.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # Test files gathered from json.org and yaml.org 4 | 5 | import json 6 | import os 7 | import shutil 8 | import sys 9 | import platform 10 | from pathlib import Path 11 | from io import StringIO 12 | from test.common import test_root, tmp_dir 13 | 14 | import pytest 15 | from ruamel.yaml import YAML 16 | 17 | from box import Box, BoxError, BoxList 18 | from box.converters import toml_read_library, toml_write_library 19 | 20 | 21 | class TestBoxList: 22 | @pytest.fixture(autouse=True) 23 | def temp_dir_cleanup(self): 24 | shutil.rmtree(str(tmp_dir), ignore_errors=True) 25 | try: 26 | os.mkdir(str(tmp_dir)) 27 | except OSError: 28 | pass 29 | yield 30 | shutil.rmtree(str(tmp_dir), ignore_errors=True) 31 | 32 | def test_box_list(self): 33 | new_list = BoxList({"item": x} for x in range(0, 10)) 34 | new_list.extend([{"item": 22}]) 35 | assert new_list[-1].item == 22 36 | new_list.append([{"bad_item": 33}]) 37 | assert new_list[-1][0].bad_item == 33 38 | new_list[-1].append([{"bad_item": 33}]) 39 | assert new_list[-1, -1, 0].bad_item == 33 40 | bx = Box({0: {1: {2: {3: 3}}}, (0, 1, 2, 3): 4}) 41 | assert bx[0, 1, 2, 3] == 4 42 | assert repr(new_list).startswith("BoxList(") 43 | for x in new_list.to_list(): 44 | assert not isinstance(x, (BoxList, Box)) 45 | new_list.insert(0, {"test": 5}) 46 | new_list.insert(1, ["a", "b"]) 47 | new_list.append("x") 48 | assert new_list[0].test == 5 49 | assert isinstance(str(new_list), str) 50 | assert isinstance(new_list[1], BoxList) 51 | assert not isinstance(new_list.to_list(), BoxList) 52 | 53 | def test_frozen_list(self): 54 | bl = BoxList([5, 4, 3], frozen_box=True) 55 | with pytest.raises(BoxError): 56 | bl.pop(1) 57 | with pytest.raises(BoxError): 58 | bl.remove(4) 59 | with pytest.raises(BoxError): 60 | bl.sort() 61 | with pytest.raises(BoxError): 62 | bl.reverse() 63 | with pytest.raises(BoxError): 64 | bl.append("test") 65 | with pytest.raises(BoxError): 66 | bl.extend([4]) 67 | with pytest.raises(BoxError): 68 | del bl[0] 69 | with pytest.raises(BoxError): 70 | bl[0] = 5 71 | bl2 = BoxList([5, 4, 3]) 72 | del bl2[0] 73 | assert bl2[0] == 4 74 | bl2[1] = 4 75 | assert bl2[1] == 4 76 | 77 | def test_box_list_to_json(self): 78 | bl = BoxList([{"item": 1, "CamelBad": 2}]) 79 | assert json.loads(bl.to_json())[0]["item"] == 1 80 | 81 | def test_box_list_from_json(self): 82 | alist = [{"item": 1}, {"CamelBad": 2}] 83 | json_list = json.dumps(alist) 84 | bl = BoxList.from_json(json_list, camel_killer_box=True) 85 | assert bl[0].item == 1 86 | assert bl[1].camel_bad == 2 87 | 88 | with pytest.raises(BoxError): 89 | BoxList.from_json(json.dumps({"a": 2})) 90 | 91 | def test_box_list_to_yaml(self): 92 | bl = BoxList([{"item": 1, "CamelBad": 2}]) 93 | yaml = YAML() 94 | assert yaml.load(bl.to_yaml())[0]["item"] == 1 95 | 96 | def test_box_list_from_yaml(self): 97 | alist = [{"item": 1}, {"CamelBad": 2}] 98 | yaml = YAML() 99 | with StringIO() as sio: 100 | yaml.dump(alist, stream=sio) 101 | bl = BoxList.from_yaml(sio.getvalue(), camel_killer_box=True) 102 | assert bl[0].item == 1 103 | assert bl[1].camel_bad == 2 104 | 105 | with pytest.raises(BoxError): 106 | BoxList.from_yaml("a: 2") 107 | 108 | def test_box_list_to_toml(self): 109 | bl = BoxList([{"item": 1, "CamelBad": 2}]) 110 | assert toml_read_library.loads(bl.to_toml(key_name="test"))["test"][0]["item"] == 1 111 | with pytest.raises(BoxError): 112 | BoxList.from_toml("[[test]]\nitem = 1\nCamelBad = 2\n\n", key_name="does not exist") 113 | 114 | def test_box_list_from_tml(self): 115 | alist = [{"item": 1}, {"CamelBad": 2}] 116 | toml_list = toml_write_library.dumps({"key": alist}) 117 | bl = BoxList.from_toml(toml_string=toml_list, key_name="key", camel_killer_box=True) 118 | assert bl[0].item == 1 119 | assert bl[1].camel_bad == 2 120 | 121 | with pytest.raises(BoxError): 122 | BoxList.from_toml(toml_write_library.dumps({"a": 2}), "a") 123 | 124 | with pytest.raises(BoxError): 125 | BoxList.from_toml(toml_list, "bad_key") 126 | 127 | def test_intact_types_list(self): 128 | class MyList(list): 129 | pass 130 | 131 | bl = BoxList([[1, 2], MyList([3, 4])], box_intact_types=(MyList,)) 132 | assert isinstance(bl[0], BoxList) 133 | 134 | def test_to_csv(self): 135 | data = BoxList( 136 | [ 137 | {"Number": 1, "Name": "Chris", "Country": "US"}, 138 | {"Number": 2, "Name": "Sam", "Country": "US"}, 139 | {"Number": 3, "Name": "Jess", "Country": "US"}, 140 | {"Number": 4, "Name": "Frank", "Country": "UK"}, 141 | {"Number": 5, "Name": "Demo", "Country": "CA"}, 142 | ] 143 | ) 144 | 145 | file = Path(tmp_dir, "csv_file.csv") 146 | data.to_csv(filename=file) 147 | assert file.read_text().startswith("Number,Name,Country\n1,Chris,US") 148 | assert data.to_csv().endswith("2,Sam,US\r\n3,Jess,US\r\n4,Frank,UK\r\n5,Demo,CA\r\n") 149 | 150 | def test_from_csv(self): 151 | bl = BoxList.from_csv(filename=Path(test_root, "data", "csv_file.csv")) 152 | assert bl[1].Name == "Sam" 153 | b2 = BoxList.from_csv( 154 | "Number,Name,Country\r\n1,Chris,US\r\n2,Sam" ",US\r\n3,Jess,US\r\n4,Frank,UK\r\n5,Demo,CA\r\n" 155 | ) 156 | assert b2[2].Name == "Jess" 157 | 158 | def test_bad_csv(self): 159 | data = BoxList([{"test": 1}, {"bad": 2, "data": 3}]) 160 | file = Path(tmp_dir, "csv_file.csv") 161 | with pytest.raises(BoxError): 162 | data.to_csv(file) 163 | 164 | def test_box_list_dots(self): 165 | data = BoxList( 166 | [ 167 | {"test": 1}, 168 | {"bad": 2, "data": 3}, 169 | [[[0, -1], [77, 88]], {"inner": "one", "lister": [[{"down": "rabbit"}]]}], 170 | 4, 171 | ], 172 | box_dots=True, 173 | ) 174 | 175 | assert data["[0].test"] == 1 176 | assert data["[1].data"] == 3 177 | assert data[1].data == 3 178 | data["[1].data"] = "new_data" 179 | assert data["[1].data"] == "new_data" 180 | assert data["[2][0][0][1]"] == -1 181 | assert data[2][0][0][1] == -1 182 | data["[2][0][0][1]"] = 1_000_000 183 | assert data["[2][0][0][1]"] == 1_000_000 184 | assert data[2][0][0][1] == 1_000_000 185 | assert data["[2][1].lister[0][0].down"] == "rabbit" 186 | data["[2][1].lister[0][0].down"] = "hole" 187 | assert data["[2][1].lister[0][0].down"] == "hole" 188 | assert data[2][1].lister[0][0].down == "hole" 189 | 190 | db = Box(a=data, box_dots=True) 191 | keys = db.keys(dotted=True) 192 | assert keys == [ 193 | "a[0].test", 194 | "a[1].bad", 195 | "a[1].data", 196 | "a[2][0][0][0]", 197 | "a[2][0][0][1]", 198 | "a[2][0][1][0]", 199 | "a[2][0][1][1]", 200 | "a[2][1].inner", 201 | "a[2][1].lister[0][0].down", 202 | "a[3]", 203 | ] 204 | for key in keys: 205 | db[key] 206 | 207 | def test_box_list_default_dots(self): 208 | box_1 = Box(default_box=True, box_dots=True) 209 | box_1["a[0]"] = 42 210 | assert box_1.a[0] == 42 211 | 212 | box_1["b[0].c[0].d"] = 42 213 | assert box_1.b[0].c[0].d == 42 214 | 215 | box_1["c[0][0][0]"] = 42 216 | assert box_1.c[0][0][0] == 42 217 | 218 | box_2 = Box(default_box=True, box_dots=True) 219 | box_2["a[4]"] = 42 220 | assert box_2.a.to_list() == [None, None, None, None, 42] 221 | 222 | box_3 = Box(default_box=True, box_dots=True) 223 | box_3["a.b[0]"] = 42 224 | assert box_3.a.b[0] == 42 225 | 226 | def test_box_config_propagate(self): 227 | structure = Box(a=[Box(default_box=False)], default_box=True, box_inherent_settings=True) 228 | assert structure._box_config["default_box"] is True 229 | assert structure.a[0]._box_config["default_box"] is True 230 | 231 | base = BoxList([BoxList([Box(default_box=False)])], default_box=True) 232 | assert base[0].box_options["default_box"] is True 233 | 234 | base2 = BoxList((BoxList([Box()], default_box=False),), default_box=True) 235 | assert base2[0][0]._box_config["default_box"] is True 236 | 237 | base3 = Box( 238 | a=[Box(default_box=False)], default_box=True, box_inherent_settings=True, box_intact_types=[Box, BoxList] 239 | ) 240 | base3.a.append(Box(default_box=False)) 241 | base3.a.append(BoxList(default_box=False)) 242 | 243 | for item in base3.a: 244 | if isinstance(item, Box): 245 | assert item._box_config["default_box"] is True 246 | elif isinstance(item, BoxList): 247 | assert item.box_options["default_box"] is True 248 | 249 | def test_no_recursion_errors(self): 250 | a = Box({"list_of_dicts": [[{"example1": 1}]]}) 251 | a.list_of_dicts.append([{"example2": 2}]) 252 | assert a["list_of_dicts"][1] == [{"example2": 2}] 253 | 254 | def test_circular_references(self): 255 | circular_list = [] 256 | circular_list.append(circular_list) 257 | circular_box = BoxList(circular_list) 258 | assert circular_box[0] == circular_box 259 | -------------------------------------------------------------------------------- /test/test_config_box.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from test.common import test_dict 5 | 6 | from box import Box, ConfigBox 7 | 8 | 9 | class TestConfigBox: 10 | def test_config_box(self): 11 | g = { 12 | "b0": "no", 13 | "b1": "yes", 14 | "b2": "True", 15 | "b3": "false", 16 | "b4": True, 17 | "i0": "34", 18 | "f0": "5.5", 19 | "f1": "3.333", 20 | "l0": "4,5,6,7,8", 21 | "l1": "[2 3 4 5 6]", 22 | } 23 | 24 | cns = ConfigBox(bb=g) 25 | assert cns.bb.list("l1", spliter=" ") == ["2", "3", "4", "5", "6"] 26 | assert cns.bb.list("l0", mod=lambda x: int(x)) == [4, 5, 6, 7, 8] 27 | assert not cns.bb.bool("b0") 28 | assert cns.bb.bool("b1") 29 | assert cns.bb.bool("b2") 30 | assert not cns.bb.bool("b3") 31 | assert cns.bb.int("i0") == 34 32 | assert cns.bb.float("f0") == 5.5 33 | assert cns.bb.float("f1") == 3.333 34 | assert cns.bb.getboolean("b4"), cns.bb.getboolean("b4") 35 | assert cns.bb.getfloat("f0") == 5.5 36 | assert cns.bb.getint("i0") == 34 37 | assert cns.bb.getint("Hello!", 5) == 5 38 | assert cns.bb.getfloat("Wooo", 4.4) == 4.4 39 | assert cns.bb.getboolean("huh", True) is True 40 | assert cns.bb.list("Waaaa", [1]) == [1] 41 | assert repr(cns).startswith("ConfigBox(") 42 | 43 | def test_dir(self): 44 | b = ConfigBox(test_dict) 45 | 46 | for item in ("to_yaml", "to_dict", "to_json", "int", "list", "float"): 47 | assert item in dir(b) 48 | 49 | def test_config_default(self): 50 | bx4 = Box(default_box=True, default_box_attr=ConfigBox) 51 | assert isinstance(bx4.bbbbb, ConfigBox) 52 | -------------------------------------------------------------------------------- /test/test_converters.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import json 4 | import os 5 | import shutil 6 | from pathlib import Path 7 | from test.common import movie_data, tmp_dir 8 | 9 | import msgpack 10 | import pytest 11 | from ruamel.yaml import YAML 12 | 13 | from box import BoxError 14 | from box.converters import _from_toml, _to_json, _to_msgpack, _to_toml, _to_yaml 15 | 16 | toml_string = """[movies.Spaceballs] 17 | imdb_stars = 7.1 18 | rating = "PG" 19 | length = 96 20 | Director = "Mel Brooks" 21 | [[movies.Spaceballs.Stars]] 22 | name = "Mel Brooks" 23 | imdb = "nm0000316" 24 | role = "President Skroob" 25 | 26 | [[movies.Spaceballs.Stars]] 27 | name = "John Candy" 28 | imdb = "nm0001006" 29 | role = "Barf" 30 | """ 31 | 32 | 33 | class TestConverters: 34 | @pytest.fixture(autouse=True) 35 | def temp_dir_cleanup(self): 36 | shutil.rmtree(str(tmp_dir), ignore_errors=True) 37 | try: 38 | os.mkdir(str(tmp_dir)) 39 | except OSError: 40 | pass 41 | yield 42 | shutil.rmtree(str(tmp_dir), ignore_errors=True) 43 | 44 | def test_to_toml(self): 45 | formatted = _to_toml(movie_data) 46 | assert formatted.startswith("[movies.Spaceballs]") 47 | 48 | def test_to_toml_file(self): 49 | out_file = Path(tmp_dir, "toml_test.tml") 50 | assert not out_file.exists() 51 | _to_toml(movie_data, filename=out_file) 52 | assert out_file.exists() 53 | assert out_file.read_text().startswith("[movies.Spaceballs]") 54 | 55 | def test_from_toml(self): 56 | result = _from_toml(toml_string) 57 | assert result["movies"]["Spaceballs"]["length"] == 96 58 | 59 | def test_from_toml_file(self): 60 | out_file = Path(tmp_dir, "toml_test.tml") 61 | assert not out_file.exists() 62 | out_file.write_text(toml_string) 63 | result = _from_toml(filename=out_file) 64 | assert result["movies"]["Spaceballs"]["length"] == 96 65 | 66 | def test_bad_from_toml(self): 67 | with pytest.raises(BoxError): 68 | _from_toml() 69 | 70 | def test_to_json(self): 71 | m_file = os.path.join(tmp_dir, "movie_data") 72 | movie_string = _to_json(movie_data) 73 | assert "Rick Moranis" in movie_string 74 | _to_json(movie_data, filename=m_file) 75 | assert "Rick Moranis" in open(m_file).read() 76 | assert json.load(open(m_file)) == json.loads(movie_string) 77 | 78 | def test_to_yaml(self): 79 | m_file = os.path.join(tmp_dir, "movie_data") 80 | movie_string = _to_yaml(movie_data) 81 | assert "Rick Moranis" in movie_string 82 | _to_yaml(movie_data, filename=m_file) 83 | assert "Rick Moranis" in open(m_file).read() 84 | yaml = YAML() 85 | assert yaml.load(open(m_file)) == yaml.load(movie_string) 86 | 87 | def test_to_msgpack(self): 88 | m_file = os.path.join(tmp_dir, "movie_data") 89 | msg_data = _to_msgpack(movie_data) 90 | assert b"Rick Moranis" in msg_data 91 | _to_msgpack(movie_data, filename=m_file) 92 | assert b"Rick Moranis" in open(m_file, "rb").read() 93 | assert msgpack.unpack(open(m_file, "rb")) == msgpack.unpackb(msg_data) 94 | 95 | def test_to_yaml_ruamel(self): 96 | movie_string = _to_yaml(movie_data, ruamel_attrs={"width": 12}) 97 | multiline_except = """ - name: Roger 98 | Rees 99 | imdb: nm0715953 100 | role: Sheriff 101 | of Rottingham 102 | - name: Amy 103 | Yasbeck""" 104 | assert multiline_except in movie_string 105 | -------------------------------------------------------------------------------- /test/test_from_file.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from pathlib import Path 4 | from test.common import test_root 5 | 6 | import pytest 7 | 8 | from box import Box, BoxError, BoxList, box_from_file, box_from_string 9 | 10 | 11 | class TestFromFile: 12 | def test_from_all(self): 13 | assert isinstance(box_from_file(Path(test_root, "data", "json_file.json")), Box) 14 | assert isinstance(box_from_file(Path(test_root, "data", "toml_file.tml")), Box) 15 | assert isinstance(box_from_file(Path(test_root, "data", "yaml_file.yaml")), Box) 16 | assert isinstance(box_from_file(Path(test_root, "data", "json_file.json"), file_type="json"), Box) 17 | assert isinstance(box_from_file(Path(test_root, "data", "toml_file.tml"), file_type="toml"), Box) 18 | assert isinstance(box_from_file(Path(test_root, "data", "yaml_file.yaml"), file_type="yaml"), Box) 19 | assert isinstance(box_from_file(Path(test_root, "data", "json_list.json")), BoxList) 20 | assert isinstance(box_from_file(Path(test_root, "data", "yaml_list.yaml")), BoxList) 21 | assert isinstance(box_from_file(Path(test_root, "data", "msgpack_file.msgpack")), Box) 22 | assert isinstance(box_from_file(Path(test_root, "data", "msgpack_list.msgpack")), BoxList) 23 | assert isinstance(box_from_file(Path(test_root, "data", "csv_file.csv")), BoxList) 24 | 25 | def test_bad_file(self): 26 | with pytest.raises(BoxError): 27 | box_from_file(Path(test_root, "data", "bad_file.txt"), file_type="json") 28 | with pytest.raises(BoxError): 29 | box_from_file(Path(test_root, "data", "bad_file.txt"), file_type="toml") 30 | with pytest.raises(BoxError): 31 | box_from_file(Path(test_root, "data", "bad_file.txt"), file_type="yaml") 32 | with pytest.raises(BoxError): 33 | box_from_file(Path(test_root, "data", "bad_file.txt"), file_type="msgpack") 34 | with pytest.raises(BoxError): 35 | box_from_file(Path(test_root, "data", "bad_file.txt"), file_type="unknown") 36 | with pytest.raises(BoxError): 37 | box_from_file(Path(test_root, "data", "bad_file.txt")) 38 | with pytest.raises(BoxError): 39 | box_from_file("does not exist") 40 | 41 | def test_from_string_all(self): 42 | with open(Path(test_root, "data", "json_file.json"), "r") as f: 43 | box_from_string(f.read()) 44 | 45 | with open(Path(test_root, "data", "toml_file.tml"), "r") as f: 46 | box_from_string(f.read(), string_type="toml") 47 | 48 | with open(Path(test_root, "data", "yaml_file.yaml"), "r") as f: 49 | box_from_string(f.read(), string_type="yaml") 50 | -------------------------------------------------------------------------------- /test/test_sbox.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import json 4 | from test.common import test_dict 5 | 6 | import pytest 7 | 8 | from ruamel.yaml import YAML 9 | 10 | from box import Box, SBox 11 | 12 | 13 | class TestSBox: 14 | def test_property_box(self): 15 | td = test_dict.copy() 16 | td["inner"] = {"CamelCase": "Item"} 17 | 18 | pbox = SBox(td, camel_killer_box=True) 19 | assert isinstance(pbox.inner, SBox) 20 | assert pbox.inner.camel_case == "Item" 21 | assert json.loads(pbox.json)["inner"]["camel_case"] == "Item" 22 | yaml = YAML() 23 | test_item = yaml.load(pbox.yaml) 24 | assert test_item["inner"]["camel_case"] == "Item" 25 | assert repr(pbox["inner"]).startswith("SBox(") 26 | assert not isinstance(pbox.dict, Box) 27 | assert pbox.dict["inner"]["camel_case"] == "Item" 28 | assert pbox.toml.startswith('key1 = "value1"') 29 | --------------------------------------------------------------------------------