From 9170539f2f41fe800e4074488ec0a6979e40473d Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Sat, 13 Feb 2021 10:13:26 -0600 Subject: [PATCH 01/17] Version 5.3.0 (#185) * Adding support for functions to box_recast (thanks to Jacob Hayes) * Adding #181 support for extending or adding new items to list during `merge_update` (thanks to Marcos Dione) * Fixing maintain stacktrace cause for BoxKeyError and BoxValueError (thanks to Jacob Hayes) * Fixing #177 that emtpy yaml files raised errors instead of returning empty objects (thanks to Tim Schwenke) * Fixing #171 that `popitems` wasn't first checking if box was frozen (thanks to Varun Madiath) Co-authored-by: Jacob Hayes --- .github/workflows/pythonpublish.yml | 6 ++-- .github/workflows/tests.yml | 6 ++-- AUTHORS.rst | 4 +++ CHANGES.rst | 12 +++++++ README.rst | 4 +-- box/__init__.py | 2 +- box/box.py | 51 +++++++++++++++++++++-------- box/box_list.py | 26 ++++++++------- test/test_box.py | 34 +++++++++++++++++++ 9 files changed, 108 insertions(+), 37 deletions(-) diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index fafcb53..2249f32 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -1,4 +1,4 @@ -# This workflows will upload a Python Package using Twine when a release is created +# This workflow will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Python Package @@ -17,11 +17,11 @@ jobs: - name: Set up Python uses: actions/setup-python@v1 with: - python-version: '3.8' + python-version: '3.9' - name: Install dependencies run: | python -m pip install --upgrade pip - pip install setuptools wheel twine + pip install setuptools wheel twine --upgrade - name: Build and publish env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e98009f..0035620 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8] + python-version: [3.9] steps: - uses: actions/checkout@v2 @@ -45,7 +45,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9.0-rc.1, pypy3] + python-version: [3.6, 3.7, 3.8, 3.9, pypy3] steps: - uses: actions/checkout@v2 @@ -58,11 +58,9 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt pip install -r requirements-test.txt - pip install coveralls - name: Test with pytest env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | pytest --cov=box test/ - coveralls diff --git a/AUTHORS.rst b/AUTHORS.rst index 8325c5e..cc957d4 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -24,6 +24,7 @@ Code contributions: - Noam Graetz (NoamGraetz2) - Fabian Affolter (fabaff) - Varun Madiath (vamega) +- Jacob Hayes (JacobHayes) Suggestions and bug reporting: @@ -71,3 +72,6 @@ Suggestions and bug reporting: - David Aronchick (aronchick) - Alexander Kapustin (dyens) - Marcelo Huerta (richieadler) +- Tim Schwenke (trallnag) +- Marcos Dione (mdione-cloudian) +- Varun Madiath (vamega) \ No newline at end of file diff --git a/CHANGES.rst b/CHANGES.rst index 1e42b0c..3286e05 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,18 @@ Changelog ========= +Version 5.3.0 +------------- + +* Adding support for functions to box_recast (thanks to Jacob Hayes) +* Adding #181 support for extending or adding new items to list during `merge_update` (thanks to Marcos Dione) +* Fixing maintain stacktrace cause for BoxKeyError and BoxValueError (thanks to Jacob Hayes) +* Fixing #177 that emtpy yaml files raised errors instead of returning empty objects (thanks to Tim Schwenke) +* Fixing #171 that `popitems` wasn't first checking if box was frozen (thanks to Varun Madiath) +* Changing all files to LF line endings +* Removing duplicate `box_recast` calls (thanks to Jacob Hayes) +* Removing coveralls code coverage, due to repeated issues with service + Version 5.2.0 ------------- diff --git a/README.rst b/README.rst index 1d25e47..486f0f2 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -|BuildStatus| |CoverageStatus| |License| +|BuildStatus| |License| |BoxImage| @@ -103,8 +103,6 @@ MIT License, Copyright (c) 2017-2020 Chris Griffith. See LICENSE_ file. :target: https://github.com/cdgriffith/Box .. |BuildStatus| image:: https://github.com/cdgriffith/Box/workflows/Tests/badge.svg?branch=master :target: https://github.com/cdgriffith/Box/actions?query=workflow%3ATests -.. |CoverageStatus| image:: https://img.shields.io/coveralls/cdgriffith/Box/master.svg?maxAge=2592000 - :target: https://coveralls.io/r/cdgriffith/Box?branch=master .. |License| image:: https://img.shields.io/pypi/l/python-box.svg :target: https://pypi.python.org/pypi/python-box/ diff --git a/box/__init__.py b/box/__init__.py index d5120d3..3307128 100644 --- a/box/__init__.py +++ b/box/__init__.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- __author__ = "Chris Griffith" -__version__ = "5.2.0" +__version__ = "5.3.0" from box.box import Box from box.box_list import BoxList diff --git a/box/box.py b/box/box.py index 8905a5c..169cdf7 100644 --- a/box/box.py +++ b/box/box.py @@ -42,6 +42,15 @@ NO_DEFAULT = object() +def _exception_cause(e): + """ + Unwrap BoxKeyError and BoxValueError errors to their cause. + + Use with `raise ... from _exception_cause(err)` to avoid deeply nested stacktraces, but keep the + context. + """ + return e.__cause__ if isinstance(e, (BoxKeyError, BoxValueError)) else e + def _camel_killer(attr): """ CamelKiller, qu'est-ce que c'est? @@ -411,13 +420,14 @@ def __box_config(self) -> Dict: def __recast(self, item, value): if self._box_config["box_recast"] and item in self._box_config["box_recast"]: + recast = self._box_config["box_recast"][item] try: - if issubclass(self._box_config["box_recast"][item], (Box, box.BoxList)): - return self._box_config["box_recast"][item](value, **self.__box_config()) + if isinstance(recast, type) and issubclass(recast, (Box, box.BoxList)): + return recast(value, **self.__box_config()) else: - return self._box_config["box_recast"][item](value) - except ValueError: - raise BoxValueError(f'Cannot convert {value} to {self._box_config["box_recast"][item]}') from None + return recast(value) + except ValueError as err: + raise BoxValueError(f'Cannot convert {value} to {recast}') from _exception_cause(err) return value def __convert_and_store(self, item, value): @@ -451,7 +461,8 @@ def __getitem__(self, item, _ignore_default=False): return super().__getitem__(item) except KeyError as err: if item == "_box_config": - raise BoxKeyError("_box_config should only exist as an attribute and is never defaulted") from None + cause = _exception_cause(err) + raise BoxKeyError("_box_config should only exist as an attribute and is never defaulted") from cause if self._box_config["box_dots"] and isinstance(item, str) and ("." in item or "[" in item): try: first_item, children = _parse_box_dots(self, item) @@ -468,7 +479,7 @@ def __getitem__(self, item, _ignore_default=False): return super().__getitem__(converted) if self._box_config["default_box"] and not _ignore_default: return self.__get_default(item) - raise BoxKeyError(str(err)) from None + raise BoxKeyError(str(err)) from _exception_cause(err) def __getattr__(self, item): try: @@ -478,20 +489,20 @@ def __getattr__(self, item): value = object.__getattribute__(self, item) except AttributeError as err: if item == "__getstate__": - raise BoxKeyError(item) from None + raise BoxKeyError(item) from _exception_cause(err) if item == "_box_config": - raise BoxError("_box_config key must exist") from None + raise BoxError("_box_config key must exist") from _exception_cause(err) if self._box_config["conversion_box"]: safe_key = self._safe_attr(item) if safe_key in self._box_config["__safe_keys"]: return self.__getitem__(self._box_config["__safe_keys"][safe_key]) if self._box_config["default_box"]: return self.__get_default(item, attr=True) - raise BoxKeyError(str(err)) from None + raise BoxKeyError(str(err)) from _exception_cause(err) return value def __setitem__(self, key, value): - if key != "_box_config" and self._box_config["__created"] and self._box_config["frozen_box"]: + if key != "_box_config" and self._box_config["frozen_box"] and self._box_config["__created"]: raise BoxError("Box is frozen") if self._box_config["box_dots"] and isinstance(key, str) and ("." in key or "[" in key): first_item, children = _parse_box_dots(self, key, setting=True) @@ -513,7 +524,6 @@ def __setattr__(self, key, value): raise BoxKeyError(f'Key name "{key}" is protected') if key == "_box_config": return object.__setattr__(self, key, value) - value = self.__recast(key, value) safe_key = self._safe_attr(key) if safe_key in self._box_config["__safe_keys"]: key = self._box_config["__safe_keys"][safe_key] @@ -540,7 +550,7 @@ def __delitem__(self, key): try: super().__delitem__(key) except KeyError as err: - raise BoxKeyError(str(err)) from None + raise BoxKeyError(str(err)) from _exception_cause(err) def __delattr__(self, item): if self._box_config["frozen_box"]: @@ -558,7 +568,7 @@ def __delattr__(self, item): self.__delitem__(self._box_config["__safe_keys"][safe_key]) del self._box_config["__safe_keys"][safe_key] return - raise BoxKeyError(str(err)) from None + raise BoxKeyError(str(err)) from _exception_cause(err) def pop(self, key, *args): if self._box_config["frozen_box"]: @@ -589,6 +599,8 @@ def clear(self): self._box_config["__safe_keys"].clear() def popitem(self): + if self._box_config["frozen_box"]: + raise BoxError("Box is frozen") try: key = next(self.__iter__()) except StopIteration: @@ -651,6 +663,15 @@ def convert_and_set(k, v): return if isinstance(v, list) and not intact_type: v = box.BoxList(v, **self.__box_config()) + merge_type = kwargs.get('box_merge_lists') + if merge_type == "extend" and k in self and isinstance(self[k], list): + self[k].extend(v) + return + if merge_type == "unique" and k in self and isinstance(self[k], list): + for item in v: + if item not in self[k]: + self[k].append(item) + return self.__setitem__(k, v) if __m: @@ -825,6 +846,8 @@ def from_yaml( box_args[arg] = kwargs.pop(arg) data = _from_yaml(yaml_string=yaml_string, filename=filename, encoding=encoding, errors=errors, **kwargs) + if not data: + return cls(**box_args) if not isinstance(data, dict): raise BoxError(f"yaml data not returned as a dictionary but rather a {type(data).__name__}") return cls(data, **box_args) diff --git a/box/box_list.py b/box/box_list.py index 54cb189..c5fc827 100644 --- a/box/box_list.py +++ b/box/box_list.py @@ -216,10 +216,10 @@ def from_json( :param kwargs: parameters to pass to `Box()` or `json.loads` :return: BoxList object from json data """ - bx_args = {} + box_args = {} for arg in list(kwargs.keys()): if arg in BOX_PARAMETERS: - bx_args[arg] = kwargs.pop(arg) + box_args[arg] = kwargs.pop(arg) data = _from_json( json_string, filename=filename, encoding=encoding, errors=errors, multiline=multiline, **kwargs @@ -227,7 +227,7 @@ def from_json( if not isinstance(data, list): raise BoxError(f"json data not returned as a list, but rather a {type(data).__name__}") - return cls(data, **bx_args) + return cls(data, **box_args) if yaml_available: @@ -277,15 +277,17 @@ def from_yaml( :param kwargs: parameters to pass to `BoxList()` or `yaml.load` :return: BoxList object from yaml data """ - bx_args = {} + box_args = {} for arg in list(kwargs.keys()): if arg in BOX_PARAMETERS: - bx_args[arg] = kwargs.pop(arg) + box_args[arg] = kwargs.pop(arg) data = _from_yaml(yaml_string=yaml_string, filename=filename, encoding=encoding, errors=errors, **kwargs) + if not data: + return cls(**box_args) if not isinstance(data, list): raise BoxError(f"yaml data not returned as a list but rather a {type(data).__name__}") - return cls(data, **bx_args) + return cls(data, **box_args) else: @@ -353,15 +355,15 @@ def from_toml( :param kwargs: parameters to pass to `Box()` :return: """ - bx_args = {} + box_args = {} for arg in list(kwargs.keys()): if arg in BOX_PARAMETERS: - bx_args[arg] = kwargs.pop(arg) + box_args[arg] = kwargs.pop(arg) data = _from_toml(toml_string=toml_string, filename=filename, encoding=encoding, errors=errors) if key_name not in data: raise BoxError(f"{key_name} was not found.") - return cls(data[key_name], **bx_args) + return cls(data[key_name], **box_args) else: @@ -407,15 +409,15 @@ def from_msgpack(cls, msgpack_bytes: bytes = None, filename: Union[str, PathLike :param kwargs: parameters to pass to `Box()` :return: """ - bx_args = {} + box_args = {} for arg in list(kwargs.keys()): if arg in BOX_PARAMETERS: - bx_args[arg] = kwargs.pop(arg) + box_args[arg] = kwargs.pop(arg) data = _from_msgpack(msgpack_bytes=msgpack_bytes, filename=filename, **kwargs) if not isinstance(data, list): raise BoxError(f"msgpack data not returned as a list but rather a {type(data).__name__}") - return cls(data, **bx_args) + return cls(data, **box_args) else: diff --git a/test/test_box.py b/test/test_box.py index 451471f..0a02a17 100644 --- a/test/test_box.py +++ b/test/test_box.py @@ -990,6 +990,21 @@ def test_type_recast(self): with pytest.raises(ValueError): b["sub_box"] = {"id": "bad_id"} + def test_nontype_recast(self): + class CustomError(ValueError): + pass + + def cast_id(val) -> int: + if val == "bad_id": + raise CustomError() + return int(val) + + b = Box(id="6", box_recast={"id": cast_id}) + assert isinstance(b.id, int) + with pytest.raises(ValueError) as exc_info: + b["sub_box"] = {"id": "bad_id"} + assert isinstance(exc_info.value.__cause__, CustomError) + def test_box_dots(self): b = Box( {"my_key": {"does stuff": {"to get to": "where I want"}}, "key.with.list": [[[{"test": "value"}]]]}, @@ -1168,3 +1183,22 @@ def test_default_dots(self): a.b.b = 4 assert a == {"a.a.a": {"": {"": {}}}, "b.b": 3, "b": {"b": 4}} assert a["non.existent.key"] == {} + + def test_merge_list_options(self): + a = Box() + a.merge_update({"lister": ["a"]}) + a.merge_update({"lister": ["a", "b", "c"]}, box_merge_lists="extend") + assert a.lister == ["a", "a", "b", "c"] + a.merge_update({"lister": ["a", "b", "c"]}, box_merge_lists="unique") + assert a.lister == ["a", "a", "b", "c"] + a.merge_update({"lister": ["a", "d", "b", "c"]}, box_merge_lists="unique") + assert a.lister == ["a", "a", "b", "c", "d"] + a.merge_update({"key1": {"new": 5}, "Key 2": {"add_key": 6}, "lister": ["a"]}) + assert a.lister == ["a"] + + def test_box_from_empty_yaml(self): + out = Box.from_yaml("---") + assert out == Box() + + out2 = BoxList.from_yaml("---") + assert out2 == BoxList() From 51042b8ed33b1fab652b52f0ca0541b4492005eb Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Sat, 14 Aug 2021 19:58:16 -0500 Subject: [PATCH 02/17] Version 5.4.0 (#204) * Adding py.typed for mypy support (thanks to Dominic) * Adding testing for Python 3.10-dev * Fixing #189 by adding mappings for mypy * Fixing setdefault behavior with box_dots (thanks to ipcoder) * Changing #193 how magic methods are handled with default_box (thanks to Rexbard) Co-authored-by: Dominic --- .github/workflows/tests.yml | 3 +- .pre-commit-config.yaml | 34 ++++++++--- AUTHORS.rst | 4 +- CHANGES.rst | 10 ++++ MANIFEST.in | 1 + README.rst | 2 +- box/__init__.py | 2 +- box/box.py | 46 +++++++++------ box/box.pyi | 115 ++++++++++++++++++++++++++++++++++++ box/box_list.pyi | 85 ++++++++++++++++++++++++++ box/config_box.py | 2 +- box/config_box.pyi | 15 +++++ box/converters.py | 2 +- box/converters.pyi | 52 ++++++++++++++++ box/exceptions.pyi | 5 ++ box/from_file.py | 2 +- box/from_file.pyi | 8 +++ box/py.typed | 0 box/shorthand_box.pyi | 13 ++++ requirements-dev.txt | 6 +- requirements-test.txt | 6 +- requirements.txt | 4 +- setup.py | 1 + test/test_box.py | 19 +++++- 24 files changed, 394 insertions(+), 43 deletions(-) create mode 100644 box/box.pyi create mode 100644 box/box_list.pyi create mode 100644 box/config_box.pyi create mode 100644 box/converters.pyi create mode 100644 box/exceptions.pyi create mode 100644 box/from_file.pyi create mode 100644 box/py.typed create mode 100644 box/shorthand_box.pyi diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0035620..3da6079 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -45,7 +45,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9, pypy3] + python-version: [3.6, 3.7, 3.8, 3.9, 3.10-dev, pypy3] steps: - uses: actions/checkout@v2 @@ -63,4 +63,3 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | pytest --cov=box test/ - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9b33903..2e12462 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,21 +1,39 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.5.0 + rev: v4.0.1 hooks: - - id: mixed-line-ending - - id: trailing-whitespace + # Identify invalid files + - id: check-ast + - id: check-yaml + - id: check-json + - id: check-toml + # git checks + - id: check-merge-conflict + - id: check-added-large-files + exclude: ^test/data/.+ + - id: detect-private-key + - id: check-case-conflict + # Python checks + - id: check-docstring-first + - id: debug-statements - id: requirements-txt-fixer - id: fix-encoding-pragma - - id: check-byte-order-marker - - id: debug-statements - - id: check-yaml + - id: fix-byte-order-marker + # General quality checks + - id: mixed-line-ending + args: [--fix=lf] + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: check-executables-have-shebangs + - id: end-of-file-fixer + exclude: ^test/data/.+ - repo: https://github.com/ambv/black - rev: stable + rev: 21.7b0 hooks: - id: black args: [--config=.black.toml] - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v0.770' + rev: 'v0.910' hooks: - id: mypy additional_dependencies: [ruamel.yaml,toml,msgpack] diff --git a/AUTHORS.rst b/AUTHORS.rst index cc957d4..58e838a 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -25,6 +25,7 @@ Code contributions: - Fabian Affolter (fabaff) - Varun Madiath (vamega) - Jacob Hayes (JacobHayes) +- Dominic (Yobmod) Suggestions and bug reporting: @@ -74,4 +75,5 @@ Suggestions and bug reporting: - Marcelo Huerta (richieadler) - Tim Schwenke (trallnag) - Marcos Dione (mdione-cloudian) -- Varun Madiath (vamega) \ No newline at end of file +- Varun Madiath (vamega) +- Rexbard diff --git a/CHANGES.rst b/CHANGES.rst index 3286e05..8187448 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,16 @@ Changelog ========= +Version 5.4.0 +------------- + +* Adding py.typed for mypy support (thanks to Dominic) +* Adding testing for Python 3.10-dev +* Fixing #189 by adding mappings for mypy +* Fixing setdefault behavior with box_dots (thanks to ipcoder) +* Changing #193 how magic methods are handled with default_box (thanks to Rexbard) + + Version 5.3.0 ------------- diff --git a/MANIFEST.in b/MANIFEST.in index adc6e10..2c7d665 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ include LICENSE include AUTHORS.rst include CHANGES.rst +include box/py.typed diff --git a/README.rst b/README.rst index 486f0f2..b9336b0 100644 --- a/README.rst +++ b/README.rst @@ -111,4 +111,4 @@ MIT License, Copyright (c) 2017-2020 Chris Griffith. See LICENSE_ file. .. _`Wrapt Documentation`: https://wrapt.readthedocs.io/en/latest .. _reusables: https://github.com/cdgriffith/reusables#reusables .. _created: https://github.com/cdgriffith/Reusables/commit/df20de4db74371c2fedf1578096f3e29c93ccdf3#diff-e9a0f470ef3e8afb4384dc2824943048R51 -.. _LICENSE: https://github.com/cdgriffith/Box/blob/master/LICENSE \ No newline at end of file +.. _LICENSE: https://github.com/cdgriffith/Box/blob/master/LICENSE diff --git a/box/__init__.py b/box/__init__.py index 3307128..a67ed40 100644 --- a/box/__init__.py +++ b/box/__init__.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- __author__ = "Chris Griffith" -__version__ = "5.3.0" +__version__ = "5.4.0" from box.box import Box from box.box_list import BoxList diff --git a/box/box.py b/box/box.py index 169cdf7..0d70cce 100644 --- a/box/box.py +++ b/box/box.py @@ -9,11 +9,15 @@ import re import string import warnings -from collections.abc import Callable, Iterable, Mapping from keyword import kwlist from os import PathLike from typing import Any, Dict, Generator, List, Tuple, Union +try: + from typing import Callable, Iterable, Mapping +except ImportError: + from collections.abc import Callable, Iterable, Mapping + import box from box.converters import ( BOX_PARAMETERS, @@ -51,6 +55,7 @@ def _exception_cause(e): """ return e.__cause__ if isinstance(e, (BoxKeyError, BoxValueError)) else e + def _camel_killer(attr): """ CamelKiller, qu'est-ce que c'est? @@ -236,47 +241,47 @@ def __init__( self._box_config["__created"] = True - def __add__(self, other: dict): + def __add__(self, other: Mapping[Any, Any]): if not isinstance(other, dict): raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") new_box = self.copy() new_box.merge_update(other) return new_box - def __radd__(self, other: dict): + def __radd__(self, other: Mapping[Any, Any]): if not isinstance(other, dict): raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") new_box = self.copy() new_box.merge_update(other) return new_box - def __iadd__(self, other: dict): + def __iadd__(self, other: Mapping[Any, Any]): if not isinstance(other, dict): raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") self.merge_update(other) return self - def __or__(self, other: dict): + def __or__(self, other: Mapping[Any, Any]): if not isinstance(other, dict): raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") new_box = self.copy() new_box.update(other) return new_box - def __ror__(self, other: dict): + def __ror__(self, other: Mapping[Any, Any]): if not isinstance(other, dict): raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") new_box = self.copy() new_box.update(other) return new_box - def __ior__(self, other: dict): + def __ior__(self, other: Mapping[Any, Any]): if not isinstance(other, dict): raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") self.update(other) return self - def __sub__(self, other: dict): + def __sub__(self, other: Mapping[Any, Any]): frozen = self._box_config["frozen_box"] config = self.__box_config() config["frozen_box"] = False @@ -407,7 +412,7 @@ def __get_default(self, item, attr=False): value = default_value.copy() else: value = default_value - if not attr or (not item.startswith("_") and not item.endswith("_")): + if not attr or not (item.startswith("_") and item.endswith("_")): super().__setitem__(item, value) return value @@ -427,7 +432,7 @@ def __recast(self, item, value): else: return recast(value) except ValueError as err: - raise BoxValueError(f'Cannot convert {value} to {recast}') from _exception_cause(err) + raise BoxValueError(f"Cannot convert {value} to {recast}") from _exception_cause(err) return value def __convert_and_store(self, item, value): @@ -497,6 +502,8 @@ def __getattr__(self, item): if safe_key in self._box_config["__safe_keys"]: return self.__getitem__(self._box_config["__safe_keys"][safe_key]) if self._box_config["default_box"]: + if item.startswith("_") and item.endswith("_"): + raise BoxKeyError(f"{item}: Does not exist and internal methods are never defaulted") return self.__get_default(item, attr=True) raise BoxKeyError(str(err)) from _exception_cause(err) return value @@ -663,7 +670,7 @@ def convert_and_set(k, v): return if isinstance(v, list) and not intact_type: v = box.BoxList(v, **self.__box_config()) - merge_type = kwargs.get('box_merge_lists') + merge_type = kwargs.get("box_merge_lists") if merge_type == "extend" and k in self and isinstance(self[k], list): self[k].extend(v) return @@ -685,15 +692,16 @@ def convert_and_set(k, v): convert_and_set(key, kwargs[key]) def setdefault(self, item, default=None): - if item in self: + # Have to use a try except instead of "item in self" as box_dots may not be in iterable + try: + return self[item] + except KeyError: + if isinstance(default, dict): + default = self._box_config["box_class"](default, **self.__box_config()) + if isinstance(default, list): + default = box.BoxList(default, **self.__box_config()) + self[item] = default return self[item] - - if isinstance(default, dict): - default = self._box_config["box_class"](default, **self.__box_config()) - if isinstance(default, list): - default = box.BoxList(default, **self.__box_config()) - self[item] = default - return self[item] def _safe_attr(self, attr): """Convert a key into something that is accessible as an attribute""" diff --git a/box/box.pyi b/box/box.pyi new file mode 100644 index 0000000..da6cead --- /dev/null +++ b/box/box.pyi @@ -0,0 +1,115 @@ +from collections.abc import Mapping +from os import PathLike +from typing import Any, Dict, Generator, List, Optional, Tuple, Union + +class Box(dict): + def __new__( + cls: Any, + *args: Any, + default_box: bool = ..., + default_box_attr: Any = ..., + default_box_none_transform: bool = ..., + frozen_box: bool = ..., + camel_killer_box: bool = ..., + conversion_box: bool = ..., + modify_tuples_box: bool = ..., + box_safe_prefix: str = ..., + box_duplicates: str = ..., + box_intact_types: Union[Tuple, List] = ..., + box_recast: Dict = ..., + box_dots: bool = ..., + box_class: Union[Dict, Box] = ..., + **kwargs: Any, + ) -> Any: ... + def __init__( + self, + *args: Any, + default_box: bool = ..., + default_box_attr: Any = ..., + default_box_none_transform: bool = ..., + frozen_box: bool = ..., + camel_killer_box: bool = ..., + conversion_box: bool = ..., + modify_tuples_box: bool = ..., + box_safe_prefix: str = ..., + box_duplicates: str = ..., + box_intact_types: Union[Tuple, List] = ..., + box_recast: Dict = ..., + box_dots: bool = ..., + box_class: Union[Dict, Box] = ..., + **kwargs: Any, + ) -> None: ... + def __add__(self, other: Mapping[Any, Any]) -> Any: ... + def __radd__(self, other: Mapping[Any, Any]) -> Any: ... + def __iadd__(self, other: Mapping[Any, Any]) -> Any: ... + def __or__(self, other: Mapping[Any, Any]) -> Any: ... + def __ror__(self, other: Mapping[Any, Any]) -> Any: ... + def __ior__(self, other: Mapping[Any, Any]) -> Any: ... + def __sub__(self, other: Mapping[Any, Any]) -> Any: ... + def __hash__(self) -> Any: ... # type: ignore[override] + def __dir__(self): ... + def keys(self, dotted: Union[bool] = ...) -> Any: ... + def items(self, dotted: Union[bool] = ...) -> Any: ... + def get(self, key: Any, default: Any = ...): ... + def copy(self) -> Box: ... + def __copy__(self) -> Box: ... + def __deepcopy__(self, memodict: Any = ...) -> Box: ... + def __getitem__(self, item: Any, _ignore_default: bool = ...): ... + def __getattr__(self, item: Any): ... + def __setitem__(self, key: Any, value: Any): ... + def __setattr__(self, key: Any, value: Any): ... + def __delitem__(self, key: Any): ... + def __delattr__(self, item: Any) -> None: ... + def pop(self, key: Any, *args: Any): ... + def clear(self) -> None: ... + def popitem(self): ... + def __iter__(self) -> Generator: ... + def __reversed__(self) -> Generator: ... + def to_dict(self) -> Dict: ... + def update(self, __m: Optional[Any] = ..., **kwargs: Any) -> None: ... + def merge_update(self, __m: Optional[Any] = ..., **kwargs: Any) -> None: ... + def setdefault(self, item: Any, default: Optional[Any] = ...): ... + def to_json( + self, filename: Union[str, PathLike] = ..., encoding: str = ..., errors: str = ..., **json_kwargs: Any + ) -> Any: ... + @classmethod + def from_json( + cls: Any, + json_string: str = ..., + filename: Union[str, PathLike] = ..., + encoding: str = ..., + errors: str = ..., + **kwargs: Any, + ) -> Box: ... + def to_yaml( + self, + filename: Union[str, PathLike] = ..., + default_flow_style: bool = ..., + encoding: str = ..., + errors: str = ..., + **yaml_kwargs: Any, + ) -> Any: ... + @classmethod + def from_yaml( + cls: Any, + yaml_string: str = ..., + filename: Union[str, PathLike] = ..., + encoding: str = ..., + errors: str = ..., + **kwargs: Any, + ) -> Box: ... + def to_toml(self, filename: Union[str, PathLike] = ..., encoding: str = ..., errors: str = ...) -> Any: ... + @classmethod + def from_toml( + cls: Any, + toml_string: str = ..., + filename: Union[str, PathLike] = ..., + encoding: str = ..., + errors: str = ..., + **kwargs: Any, + ) -> Box: ... + def to_msgpack(self, filename: Union[str, PathLike] = ..., **kwargs: Any) -> Any: ... + @classmethod + def from_msgpack( + cls: Any, msgpack_bytes: bytes = ..., filename: Union[str, PathLike] = ..., **kwargs: Any + ) -> Box: ... diff --git a/box/box_list.pyi b/box/box_list.pyi new file mode 100644 index 0000000..8c9a93f --- /dev/null +++ b/box/box_list.pyi @@ -0,0 +1,85 @@ +import box +from box.converters import ( + BOX_PARAMETERS as BOX_PARAMETERS, + msgpack_available as msgpack_available, + toml_available as toml_available, + yaml_available as yaml_available, +) +from box.exceptions import BoxError as BoxError, BoxTypeError as BoxTypeError +from os import PathLike as PathLike +from typing import Any, Iterable, Optional, Type, Union + +class BoxList(list): + def __new__(cls, *args: Any, **kwargs: Any): ... + box_options: Any = ... + box_org_ref: Any = ... + def __init__(self, iterable: Iterable = ..., box_class: Type[box.Box] = ..., **box_options: Any) -> None: ... + def __getitem__(self, item: Any): ... + def __delitem__(self, key: Any): ... + def __setitem__(self, key: Any, value: Any): ... + def append(self, p_object: Any) -> None: ... + def extend(self, iterable: Any) -> None: ... + def insert(self, index: Any, p_object: Any) -> None: ... + def __copy__(self): ... + def __deepcopy__(self, memo: Optional[Any] = ...): ... + def __hash__(self) -> Any: ... # type: ignore[override] + def to_list(self): ... + def to_json( + self, + filename: Union[str, PathLike] = ..., + encoding: str = ..., + errors: str = ..., + multiline: bool = ..., + **json_kwargs: Any, + ) -> Any: ... + @classmethod + def from_json( + cls: Any, + json_string: str = ..., + filename: Union[str, PathLike] = ..., + encoding: str = ..., + errors: str = ..., + multiline: bool = ..., + **kwargs: Any, + ) -> Any: ... + def to_yaml( + self, + filename: Union[str, PathLike] = ..., + default_flow_style: bool = ..., + encoding: str = ..., + errors: str = ..., + **yaml_kwargs: Any, + ) -> Any: ... + @classmethod + def from_yaml( + cls: Any, + yaml_string: str = ..., + filename: Union[str, PathLike] = ..., + encoding: str = ..., + errors: str = ..., + **kwargs: Any, + ) -> Any: ... + def to_toml( + self, filename: Union[str, PathLike] = ..., key_name: str = ..., encoding: str = ..., errors: str = ... + ) -> Any: ... + @classmethod + def from_toml( + cls: Any, + toml_string: str = ..., + filename: Union[str, PathLike] = ..., + key_name: str = ..., + encoding: str = ..., + errors: str = ..., + **kwargs: Any, + ) -> Any: ... + def to_msgpack(self, filename: Union[str, PathLike] = ..., **kwargs: Any) -> Any: ... + @classmethod + def from_msgpack( + cls: Any, msgpack_bytes: bytes = ..., filename: Union[str, PathLike] = ..., **kwargs: Any + ) -> Any: ... + def to_csv(self, filename: Union[str, PathLike] = ..., encoding: str = ..., errors: str = ...) -> Any: ... + @classmethod + def from_csv( + cls: Any, csv_string: str = ..., filename: Union[str, PathLike] = ..., encoding: str = ..., errors: str = ... + ) -> Any: ... + def _dotted_helper(self) -> Any: ... diff --git a/box/config_box.py b/box/config_box.py index 67ef962..a8ad1ed 100644 --- a/box/config_box.py +++ b/box/config_box.py @@ -87,7 +87,7 @@ def float(self, item, default=None): raise err return float(item) - def list(self, item, default=None, spliter=",", strip=True, mod=None): + def list(self, item, default=None, spliter: str = ",", strip=True, mod=None): """ Return value of key as a list diff --git a/box/config_box.pyi b/box/config_box.pyi new file mode 100644 index 0000000..75afb44 --- /dev/null +++ b/box/config_box.pyi @@ -0,0 +1,15 @@ +from box.box import Box as Box +from typing import Any, Optional + +class ConfigBox(Box): + def __getattr__(self, item: Any): ... + def __dir__(self): ... + def bool(self, item: Any, default: Optional[Any] = ...): ... + def int(self, item: Any, default: Optional[Any] = ...): ... + def float(self, item: Any, default: Optional[Any] = ...): ... + def list(self, item: Any, default: Optional[Any] = ..., spliter: str = ..., strip: bool = ..., mod: Optional[Any] = ...): ... # type: ignore + def getboolean(self, item: Any, default: Optional[Any] = ...): ... + def getint(self, item: Any, default: Optional[Any] = ...): ... + def getfloat(self, item: Any, default: Optional[Any] = ...): ... + def copy(self): ... + def __copy__(self): ... diff --git a/box/converters.py b/box/converters.py index 95e8550..446ac9f 100644 --- a/box/converters.py +++ b/box/converters.py @@ -24,12 +24,12 @@ except ImportError: yaml = None # type: ignore yaml_available = False - try: import toml except ImportError: toml = None # type: ignore toml_available = False + try: import msgpack # type: ignore except ImportError: diff --git a/box/converters.pyi b/box/converters.pyi new file mode 100644 index 0000000..926f060 --- /dev/null +++ b/box/converters.pyi @@ -0,0 +1,52 @@ +from box.exceptions import BoxError as BoxError +from os import PathLike as PathLike +from typing import Any, Union + +yaml_available: bool +toml_available: bool +msgpack_available: bool +BOX_PARAMETERS: Any + +def _exists(filename: Union[str, PathLike], create: bool = False) -> Any: ... +def _to_json( + obj, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict", **json_kwargs +) -> Any: ... +def _from_json( + json_string: str = None, + filename: Union[str, PathLike] = None, + encoding: str = "utf-8", + errors: str = "strict", + multiline: bool = False, + **kwargs, +) -> Any: ... +def _to_yaml( + obj, + filename: Union[str, PathLike] = None, + default_flow_style: bool = False, + encoding: str = "utf-8", + errors: str = "strict", + **yaml_kwargs, +) -> Any: ... +def _from_yaml( + yaml_string: str = None, + filename: Union[str, PathLike] = None, + encoding: str = "utf-8", + errors: str = "strict", + **kwargs, +) -> Any: ... +def _to_toml(obj, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict") -> Any: ... +def _from_toml( + toml_string: str = None, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict" +) -> Any: ... +def _to_msgpack(obj, filename: Union[str, PathLike] = None, **kwargs) -> Any: ... +def _from_msgpack(msgpack_bytes: bytes = None, filename: Union[str, PathLike] = None, **kwargs) -> Any: ... +def _to_csv( + box_list, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict", **kwargs +) -> Any: ... +def _from_csv( + csv_string: str = None, + filename: Union[str, PathLike] = None, + encoding: str = "utf-8", + errors: str = "strict", + **kwargs, +) -> Any: ... diff --git a/box/exceptions.pyi b/box/exceptions.pyi new file mode 100644 index 0000000..2be6b54 --- /dev/null +++ b/box/exceptions.pyi @@ -0,0 +1,5 @@ +class BoxError(Exception): ... +class BoxKeyError(BoxError, KeyError, AttributeError): ... +class BoxTypeError(BoxError, TypeError): ... +class BoxValueError(BoxError, ValueError): ... +class BoxWarning(UserWarning): ... diff --git a/box/from_file.py b/box/from_file.py index 2cf60e1..e171b4a 100644 --- a/box/from_file.py +++ b/box/from_file.py @@ -19,7 +19,7 @@ YAMLError = False # type: ignore try: - from toml import TomlDecodeError + from toml import TomlDecodeError # type: ignore except ImportError: TomlDecodeError = False # type: ignore diff --git a/box/from_file.pyi b/box/from_file.pyi new file mode 100644 index 0000000..0dc30f5 --- /dev/null +++ b/box/from_file.pyi @@ -0,0 +1,8 @@ +from box.box import Box +from box.box_list import BoxList +from os import PathLike +from typing import Any, Union + +def box_from_file( + file: Union[str, PathLike], file_type: str = ..., encoding: str = ..., errors: str = ..., **kwargs: Any +) -> Union[Box, BoxList]: ... diff --git a/box/py.typed b/box/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/box/shorthand_box.pyi b/box/shorthand_box.pyi new file mode 100644 index 0000000..78ffb6f --- /dev/null +++ b/box/shorthand_box.pyi @@ -0,0 +1,13 @@ +from box.box import Box as Box + +class SBox(Box): + @property + def dict(self): ... + @property + def json(self): ... + @property + def yaml(self): ... + @property + def toml(self): ... + def copy(self): ... + def __copy__(self): ... diff --git a/requirements-dev.txt b/requirements-dev.txt index 881f0ab..33abd7e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,4 @@ # Files needed for pre-commit hooks -black>=19.10b0 -mypy>=0.770 -pre-commit>=2.2.0 +black>=21.7b0 +mypy>=0.910 +pre-commit>=4.0.1 diff --git a/requirements-test.txt b/requirements-test.txt index c5397d6..618fb54 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,6 +1,8 @@ coverage>=5.0.4 msgpack>=1.0 -pytest-cov>=2.8.1 pytest>=5.4.1 -ruamel.yaml>=0.16 +pytest-cov>=2.8.1 +ruamel.yaml>=0.16,<0.17 +toml>=0.10.2 +types-toml>=0.1.3 wheel>=0.34.2 diff --git a/requirements.txt b/requirements.txt index b62a689..2ef4138 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ msgpack>=1.0.0 -ruamel.yaml>=0.16.10 -toml>=0.10.1 +ruamel.yaml>=0.16.10,<0.17 +toml>=0.10.2 diff --git a/setup.py b/setup.py index 6c8ac56..20eef2f 100644 --- a/setup.py +++ b/setup.py @@ -53,6 +53,7 @@ ], extras_require={ "all": ["ruamel.yaml", "toml", "msgpack"], + "yaml": ["ruamel.yaml"], "ruamel.yaml": ["ruamel.yaml"], "PyYAML": ["PyYAML"], "toml": ["toml"], diff --git a/test/test_box.py b/test/test_box.py index 0a02a17..742e2a9 100644 --- a/test/test_box.py +++ b/test/test_box.py @@ -306,6 +306,12 @@ def test_set_default(self): assert a.key3.item == 2 assert a.lister[0].gah == 7 + def test_set_default_box_dots(self): + a = Box(box_dots=True) + a["x"] = {"y": 10} + a.setdefault("x.y", 20) + assert a["x.y"] == 10 + def test_from_json_file(self): bx = Box.from_json(filename=data_json_file) assert isinstance(bx, Box) @@ -1168,9 +1174,20 @@ def test_box_safe_references(self): def test_default_box_restricted_calls(self): a = Box(default_box=True) - a._test_thing_ + with pytest.raises(BoxKeyError): + a._test_thing_ assert len(list(a.keys())) == 0 + # Based on argparse.parse_args internal behavior, the following + # creates the attribute in hasattr due to default_box=True, then + # deletes it in delattr. + if hasattr(a, "_unrecognized_args"): + delattr(a, "_unrecognized_args") + + a._allowed_prefix + a.allowed_postfix_ + assert len(list(a.keys())) == 2 + def test_default_dots(self): a = Box(default_box=True, box_dots=True) a["a.a.a"] From 4f9b3a68a1ed8aff19bcc074408209341457732f Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Sun, 22 Aug 2021 10:06:26 -0500 Subject: [PATCH 03/17] Version 5.4.1 (#206) * Fixing setdefault behavior with box_dots (thanks to Ivan Pepelnjak) Co-authored-by: Ivan Pepelnjak --- AUTHORS.rst | 1 + CHANGES.rst | 5 ++++ box/__init__.py | 2 +- box/box.py | 43 ++++++++++++++++++++++++++------- test/test_box.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 10 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index 58e838a..76312ba 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -26,6 +26,7 @@ Code contributions: - Varun Madiath (vamega) - Jacob Hayes (JacobHayes) - Dominic (Yobmod) +- Ivan Pepelnjak (ipspace) Suggestions and bug reporting: diff --git a/CHANGES.rst b/CHANGES.rst index 8187448..9935397 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,11 @@ Changelog ========= +Version 5.4.1 +------------- + +* Fixing #205 setdefault behavior with box_dots (thanks to Ivan Pepelnjak) + Version 5.4.0 ------------- diff --git a/box/__init__.py b/box/__init__.py index a67ed40..bd331c5 100644 --- a/box/__init__.py +++ b/box/__init__.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- __author__ = "Chris Griffith" -__version__ = "5.4.0" +__version__ = "5.4.1" from box.box import Box from box.box_list import BoxList diff --git a/box/box.py b/box/box.py index 0d70cce..ffb48d5 100644 --- a/box/box.py +++ b/box/box.py @@ -93,6 +93,28 @@ def _parse_box_dots(bx, item, setting=False): raise BoxError("Could not split box dots properly") +def _get_dot_paths(bx, current=""): + """A generator of all the end node keys in a box in box_dots format""" + + def handle_dicts(sub_bx, paths=""): + for key, value in sub_bx.items(): + yield f"{paths}.{key}" if paths else key + if isinstance(value, dict): + yield from handle_dicts(value, f"{paths}.{key}" if paths else key) + elif isinstance(value, list): + yield from handle_lists(value, f"{paths}.{key}" if paths else key) + + def handle_lists(bx_list, paths=""): + for i, value in enumerate(bx_list): + yield f"{paths}[{i}]" + if isinstance(value, list): + yield from handle_lists(value, f"{paths}[{i}]") + if isinstance(value, dict): + yield from handle_dicts(value, f"{paths}[{i}]") + + yield from handle_dicts(bx, current) + + def _get_box_config(): return { # Internal use only @@ -692,17 +714,20 @@ def convert_and_set(k, v): convert_and_set(key, kwargs[key]) def setdefault(self, item, default=None): - # Have to use a try except instead of "item in self" as box_dots may not be in iterable - try: - return self[item] - except KeyError: - if isinstance(default, dict): - default = self._box_config["box_class"](default, **self.__box_config()) - if isinstance(default, list): - default = box.BoxList(default, **self.__box_config()) - self[item] = default + if item in self: return self[item] + if self._box_config["box_dots"]: + if item in _get_dot_paths(self): + return self[item] + + if isinstance(default, dict): + default = self._box_config["box_class"](default, **self.__box_config()) + if isinstance(default, list): + default = box.BoxList(default, **self.__box_config()) + self[item] = default + return self[item] + def _safe_attr(self, attr): """Convert a key into something that is accessible as an attribute""" allowed = string.ascii_letters + string.digits + "_" diff --git a/test/test_box.py b/test/test_box.py index 742e2a9..86e708c 100644 --- a/test/test_box.py +++ b/test/test_box.py @@ -26,6 +26,7 @@ import ruamel.yaml as yaml from box import Box, BoxError, BoxKeyError, BoxList, ConfigBox, SBox, box +from box.box import _get_dot_paths # type: ignore def mp_queue_test(q): @@ -312,6 +313,24 @@ def test_set_default_box_dots(self): a.setdefault("x.y", 20) assert a["x.y"] == 10 + a["lists"] = [[[{"test": "here"}], {1, 2}], (4, 5)] + assert list(_get_dot_paths(a)) == [ + "x", + "x.y", + "lists", + "lists[0]", + "lists[0][0]", + "lists[0][0][0]", + "lists[0][0][0].test", + "lists[0][1]", + "lists[1]", + ] + + t = Box({"a": 1}, default_box=True, box_dots=True, default_box_none_transform=False) + assert t.setdefault("b", [1, 2]) == [1, 2] + assert t == Box(a=1, b=[1, 2]) + assert t.setdefault("c", [{"d": 2}]) == BoxList([{"d": 2}]) + def test_from_json_file(self): bx = Box.from_json(filename=data_json_file) assert isinstance(bx, Box) @@ -1219,3 +1238,46 @@ def test_box_from_empty_yaml(self): out2 = BoxList.from_yaml("---") assert out2 == BoxList() + + def test_setdefault_simple(self): + box = Box({"a": 1}) + box.setdefault("b", 2) + box.setdefault("c", "test") + box.setdefault("d", {"e": True}) + box.setdefault("f", [1, 2]) + + assert box["b"] == 2 + assert box["c"] == "test" + assert isinstance(box["d"], Box) + assert box["d"]["e"] == True + assert isinstance(box["f"], BoxList) + assert box["f"][1] == 2 + + def test_setdefault_dots(self): + box = Box({"a": 1}, box_dots=True) + box.setdefault("b", 2) + box.c = {"d": 3} + box.setdefault("c.e", "test") + box.setdefault("d", {"e": True}) + box.setdefault("f", [1, 2]) + + assert box.b == 2 + assert box.c.e == "test" + assert isinstance(box["d"], Box) + assert box.d.e == True + assert isinstance(box["f"], BoxList) + assert box.f[1] == 2 + + def test_setdefault_dots_default(self): + box = Box({"a": 1}, box_dots=True, default_box=True) + box.b.c.d.setdefault("e", 2) + box.c.setdefault("e", "test") + box.d.e.setdefault("f", {"g": True}) + box.e.setdefault("f", [1, 2]) + + assert box["b.c.d"].e == 2 + assert box.c.e == "test" + assert isinstance(box["d.e.f"], Box) + assert box.d.e["f.g"] == True + assert isinstance(box["e.f"], BoxList) + assert box.e.f[1] == 2 From 985df83887c8a72625c8e86cef2b8a3276b93013 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Tue, 15 Mar 2022 09:25:49 -0500 Subject: [PATCH 04/17] Version 6.0.0 (#216) * Adding Cython support to greatly speed up normal Box operations on supported systems * Adding #161 support for access box dots with `get` and checking with `in` (thanks to scott-createplay) * Adding #183 support for all allowed character sets (thanks to Giulio Malventi) * Adding #196 support for sliceable boxes (thanks to Dias) * Adding #164 default_box_create_on_get toggle to disable setting box variable on get request (thanks to ipcoder) * Changing #208 __repr__ to produce `eval`-able text (thanks to Jeff Robbins) * Changing #215 support ruamel.yaml new syntax (thanks to Ivan Pepelnjak) * Changing `update` and `merge_update` to not use a keyword that could cause issues in rare circumstances * Changing internal `_safe_key` logic to be twice as fast * Removing support for ruamel.yaml < 0.17 --- .github/workflows/pythonpublish.yml | 53 ++++++++++++- .github/workflows/tests.yml | 85 +++++++++++++++++--- .gitignore | 3 + .pre-commit-config.yaml | 39 +++++++--- CHANGES.rst | 14 ++++ LICENSE | 2 +- MANIFEST.in | 3 + README.rst | 56 ++++++++++++-- box/__init__.py | 3 +- box/box.py | 116 +++++++++++++++++++--------- box/box.pyi | 2 + box/box_list.py | 4 +- box/config_box.py | 2 +- box/converters.py | 84 ++++++++++++++++---- box/converters.pyi | 6 +- box/shorthand_box.py | 2 +- requirements-dev.txt | 3 +- requirements-test.txt | 3 +- requirements.txt | 2 +- setup.py | 23 +++++- test/common.py | 2 +- test/test_box.py | 86 ++++++++++++++++++--- test/test_box_list.py | 16 ++-- test/test_config_box.py | 2 +- test/test_converters.py | 16 +++- test/test_sbox.py | 7 +- 26 files changed, 516 insertions(+), 118 deletions(-) diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index 2249f32..dae54d3 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -8,7 +8,7 @@ on: types: [created] jobs: - deploy: + deploy-generic: runs-on: ubuntu-latest @@ -29,3 +29,54 @@ jobs: run: | python setup.py sdist bdist_wheel twine upload dist/* + + deploy-cython: + strategy: + matrix: + os: [macos-latest, windows-latest] + python-version: ["3.7", "3.8", "3.9", "3.10"] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine Cython --upgrade + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py bdist_wheel + twine upload dist/* + + deploy-cython-manylinux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.10 + uses: actions/setup-python@v1 + with: + python-version: "3.10" + + - uses: RalfG/python-wheels-manylinux-build@v0.4.2-manylinux2014_x86_64 + with: + python-versions: 'cp37-cp37m cp38-cp38 cp39-cp39 cp310-cp310' + build-requirements: 'cython' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install twine --upgrade + + - name: Publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + twine upload dist/*-manylinux*.whl diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3da6079..4df0bed 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,24 +10,28 @@ on: branches: [ master, development, develop, test, tests ] jobs: - package_checks: - runs-on: ubuntu-latest + package-checks: strategy: matrix: - python-version: [3.9] - + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "pypy-3.8"] + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} + - uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: package-check-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt pip install -r requirements-test.txt - pip install coveralls flake8 flake8-print mypy setuptools wheel twine + pip install coveralls flake8 flake8-print mypy setuptools wheel twine Cython - name: Lint with flake8 run: | # stop the build if there are Python syntax errors, undefined names or print statements @@ -36,28 +40,89 @@ jobs: flake8 . --count --exit-zero --max-complexity=20 --max-line-length=120 --statistics --extend-ignore E203 - name: Run mypy run: mypy box - - name: Check distrubiton log description + - name: Build Wheel and check distrubiton log description run: | python setup.py sdist bdist_wheel twine check dist/* + - name: Test packaged wheel on *nix + if: matrix.os != 'windows-latest' + run: | + pip install dist/*.whl + rm -rf box + python -m pytest + - name: Test packaged wheel on Windows + if: matrix.os == 'windows-latest' + run: | + $wheel = (Get-ChildItem dist\*.whl | Sort lastWriteTime | Select-Object -last 1).Name + pip install dist\${wheel} + Remove-item box -recurse -force + python -m pytest + - name: Upload wheel artifact + uses: actions/upload-artifact@v2 + with: + name: python_box + path: dist/*.whl - test: + package-manylinux-checks: runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.10 + uses: actions/setup-python@v1 + with: + python-version: "3.10" + + - uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: package-manylinux-check-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-test.txt + pip install coveralls flake8 flake8-print mypy setuptools wheel twine Cython + + - uses: RalfG/python-wheels-manylinux-build@v0.4.2-manylinux2014_x86_64 + with: + python-versions: 'cp37-cp37m cp38-cp38 cp39-cp39 cp310-cp310' + build-requirements: 'cython' + + - name: Test packaged wheel on linux + run: | + pip install dist/*cp310-manylinux*.whl + rm -rf box + python -m pytest + + - name: Upload wheel artifact + uses: actions/upload-artifact@v2 + with: + name: python_box + path: dist/*-manylinux*.whl + + test: strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9, 3.10-dev, pypy3] - + python-version: ["3.7", "3.8", "3.9", "3.10"] + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} + - uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: test-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt pip install -r requirements-test.txt + pip install setuptools wheel Cython + python setup.py build_ext --inplace - name: Test with pytest env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 92026ef..e75de53 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,6 @@ ENV/ .pypirc release.bat coverage/ +# don't upload cython files +box/*.c +.pytest_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2e12462..3ce0c43 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 - hooks: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.1.0 + hooks: # Identify invalid files - id: check-ast - id: check-yaml @@ -27,13 +27,32 @@ repos: - id: check-executables-have-shebangs - id: end-of-file-fixer exclude: ^test/data/.+ -- repo: https://github.com/ambv/black - rev: 21.7b0 - hooks: + +- repo: https://github.com/ambv/black + rev: 22.1.0 + hooks: - id: black args: [--config=.black.toml] -- repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v0.910' - hooks: + +- repo: local + hooks: + - id: cythonize-check + name: Cythonize + entry: python setup.py build_ext --inplace + language: system + types: [python] + pass_filenames: false + # cythonize must come before the pytest to make sure we update all c code + - id: pytest-check + name: Check pytest + entry: pytest + language: system + pass_filenames: false + always_run: true + +- repo: https://github.com/pre-commit/mirrors-mypy + rev: 'v0.931' + hooks: - id: mypy - additional_dependencies: [ruamel.yaml,toml,msgpack] + types: [python] + additional_dependencies: [ruamel.yaml,toml,msgpack,types-PyYAML,types-toml] diff --git a/CHANGES.rst b/CHANGES.rst index 9935397..bd8aff9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,20 @@ Changelog ========= +Version 6.0.0 +------------- + +* Adding Cython support to greatly speed up normal Box operations on supported systems +* Adding #161 support for access box dots with `get` and checking with `in` (thanks to scott-createplay) +* Adding #183 support for all allowed character sets (thanks to Giulio Malventi) +* Adding #196 support for sliceable boxes (thanks to Dias) +* Adding #164 default_box_create_on_get toggle to disable setting box variable on get request (thanks to ipcoder) +* Changing #208 __repr__ to produce `eval`-able text (thanks to Jeff Robbins) +* Changing #215 support ruamel.yaml new syntax (thanks to Ivan Pepelnjak) +* Changing `update` and `merge_update` to not use a keyword that could cause issues in rare circumstances +* Changing internal `_safe_key` logic to be twice as fast +* Removing support for ruamel.yaml < 0.17 + Version 5.4.1 ------------- diff --git a/LICENSE b/LICENSE index f0c1a42..2d55592 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017-2020 Chris Griffith +Copyright (c) 2017-2022 Chris Griffith Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in index 2c7d665..fffa170 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,3 +2,6 @@ include LICENSE include AUTHORS.rst include CHANGES.rst include box/py.typed +include box/*.c +include box/*.so +include box/*.pyd diff --git a/README.rst b/README.rst index b9336b0..fb63188 100644 --- a/README.rst +++ b/README.rst @@ -23,24 +23,64 @@ Check out the new `Box github wiki `_ fo Install ======= +**Version Pin Your Box!** + +If you aren't in the habit of version pinning your libraries, it will eventually bite you. +Box has a `list of breaking change `_ between major versions you should always check out before updating. + +requirements.txt +---------------- + +.. code:: text + + python-box[all]~=6.0 + +As Box adheres to semantic versioning (aka API changes will only occur on between major version), +it is best to use `Compatible release `_ matching using the `~=` clause. + +Install from command line +------------------------- + .. code:: bash - pip install --upgrade python-box[all] + pip install python-box[all]~=6.0 --upgrade + +Install with selected dependencies +---------------------------------- -Box 5 is no longer forcing install of external dependencies such as yaml and toml. Instead you can specify which you want, -for example, `all` is shorthand for: +Box is no longer forcing install of external dependencies such as yaml and toml. Instead you can specify which you want, +for example, `[all]` is shorthand for: .. code:: bash - pip install --upgrade python-box[ruamel.yaml,toml,msgpack] + pip install python-box[ruamel.yaml,toml,msgpack]~=6.0 --upgrade -But you can also sub out "ruamel.yaml" for "PyYAML". +But you can also sub out `ruamel.yaml` for `PyYAML`. Check out `more details `_ on installation details. -Box 5 is tested on python 3.6+ and pypy3, if you are upgrading from previous versions, please look through -`any breaking changes and new features `_. +Box 6 is tested on python 3.6+, if you are upgrading from previous versions, please look through +`any breaking changes and new features `_. + +Optimized Version +----------------- + +Box 6 is introducing Cython optimizations for major platforms by default. +Loading large data sets can be up to 10x faster! + +If you are **not** on a x86_64 supported system you will need to do some extra work to install the optimized version. +There will be an warning of "WARNING: Cython not installed, could not optimize box" during install. +You will need python development files, system compiler, and the python packages `Cython` and `wheel`. + +**Linux Example:** + +First make sure you have python development files installed (`python3-dev` or `python3-devel` in most repos). +You will then need `Cython` and `wheel` installed and then install (or re-install with `--force`) `python-box`. + +.. code:: bash + pip install Cython wheel + pip install python-box[all]~=6.0 --upgrade --force If you have any issues please open a github issue with the error you are experiencing! @@ -96,7 +136,7 @@ Also special shout-out to PythonBytes_, who featured Box on their podcast. License ======= -MIT License, Copyright (c) 2017-2020 Chris Griffith. See LICENSE_ file. +MIT License, Copyright (c) 2017-2022 Chris Griffith. See LICENSE_ file. .. |BoxImage| image:: https://raw.githubusercontent.com/cdgriffith/Box/master/box_logo.png diff --git a/box/__init__.py b/box/__init__.py index bd331c5..3970189 100644 --- a/box/__init__.py +++ b/box/__init__.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- __author__ = "Chris Griffith" -__version__ = "5.4.1" +__version__ = "6.0.0" from box.box import Box from box.box_list import BoxList @@ -10,6 +10,7 @@ from box.exceptions import BoxError, BoxKeyError from box.from_file import box_from_file from box.shorthand_box import SBox +import box.converters __all__ = [ "Box", diff --git a/box/box.py b/box/box.py index ffb48d5..b899c32 100644 --- a/box/box.py +++ b/box/box.py @@ -1,17 +1,16 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- # -# Copyright (c) 2017-2020 - Chris Griffith - MIT License +# Copyright (c) 2017-2022 - Chris Griffith - MIT License """ Improved dictionary access through dot notation with additional tools. """ import copy import re -import string import warnings -from keyword import kwlist +from keyword import iskeyword from os import PathLike -from typing import Any, Dict, Generator, List, Tuple, Union +from typing import Any, Dict, Generator, List, Tuple, Union, Type try: from typing import Callable, Iterable, Mapping @@ -162,6 +161,7 @@ def __new__( default_box: bool = False, default_box_attr: Any = NO_DEFAULT, default_box_none_transform: bool = True, + default_box_create_on_get: bool = True, frozen_box: bool = False, camel_killer_box: bool = False, conversion_box: bool = True, @@ -171,7 +171,7 @@ def __new__( box_intact_types: Union[Tuple, List] = (), box_recast: Dict = None, box_dots: bool = False, - box_class: Union[Dict, "Box"] = None, + box_class: Union[Dict, Type["Box"]] = None, **kwargs: Any, ): """ @@ -185,6 +185,7 @@ def __new__( "default_box": default_box, "default_box_attr": cls.__class__ if default_box_attr is NO_DEFAULT else default_box_attr, "default_box_none_transform": default_box_none_transform, + "default_box_create_on_get": default_box_create_on_get, "conversion_box": conversion_box, "box_safe_prefix": box_safe_prefix, "frozen_box": frozen_box, @@ -205,6 +206,7 @@ def __init__( default_box: bool = False, default_box_attr: Any = NO_DEFAULT, default_box_none_transform: bool = True, + default_box_create_on_get: bool = True, frozen_box: bool = False, camel_killer_box: bool = False, conversion_box: bool = True, @@ -214,7 +216,7 @@ def __init__( box_intact_types: Union[Tuple, List] = (), box_recast: Dict = None, box_dots: bool = False, - box_class: Union[Dict, "Box"] = None, + box_class: Union[Dict, Type["Box"]] = None, **kwargs: Any, ): super().__init__() @@ -224,6 +226,7 @@ def __init__( "default_box": default_box, "default_box_attr": self.__class__ if default_box_attr is NO_DEFAULT else default_box_attr, "default_box_none_transform": default_box_none_transform, + "default_box_create_on_get": default_box_create_on_get, "conversion_box": conversion_box, "box_safe_prefix": box_safe_prefix, "frozen_box": frozen_box, @@ -331,17 +334,12 @@ def __hash__(self): raise BoxTypeError('unhashable type: "Box"') def __dir__(self): - allowed = string.ascii_letters + string.digits + "_" items = set(super().__dir__()) # Only show items accessible by dot notation for key in self.keys(): key = str(key) - if " " not in key and key[0] not in string.digits and key not in kwlist: - for letter in key: - if letter not in allowed: - break - else: - items.add(key) + if key.isidentifier() and not iskeyword(key): + items.add(key) for key in self.keys(): if key not in items: @@ -352,6 +350,21 @@ def __dir__(self): return list(items) + def __contains__(self, item): + in_me = super().__contains__(item) + if not self._box_config["box_dots"] or not isinstance(item, str): + return in_me + if in_me: + return True + if "." not in item: + return False + try: + first_item, children = _parse_box_dots(self, item) + except BoxError: + return False + else: + return children in self[first_item] + def keys(self, dotted: Union[bool] = False): if not dotted: return super().keys() @@ -434,8 +447,9 @@ def __get_default(self, item, attr=False): value = default_value.copy() else: value = default_value - if not attr or not (item.startswith("_") and item.endswith("_")): - super().__setitem__(item, value) + if self._box_config["default_box_create_on_get"]: + if not attr or not (item.startswith("_") and item.endswith("_")): + super().__setitem__(item, value) return value def __box_config(self) -> Dict: @@ -507,6 +521,13 @@ def __getitem__(self, item, _ignore_default=False): if self._box_config["default_box"] and not _ignore_default: return self.__get_default(item) raise BoxKeyError(str(err)) from _exception_cause(err) + except TypeError as err: + if isinstance(item, slice): + new_box = self._box_config["box_class"](**self.__box_config()) + for x in list(super().keys())[item.start : item.stop : item.step]: + new_box[x] = self[x] + return new_box + raise BoxTypeError(str(err)) from _exception_cause(err) def __getattr__(self, item): try: @@ -547,12 +568,13 @@ def __setitem__(self, key, value): self.__convert_and_store(key, value) def __setattr__(self, key, value): - if key != "_box_config" and self._box_config["frozen_box"] and self._box_config["__created"]: + if key == "_box_config": + return object.__setattr__(self, key, value) + if self._box_config["frozen_box"] and self._box_config["__created"]: raise BoxError("Box is frozen") if key in self._protected_keys: raise BoxKeyError(f'Key name "{key}" is protected') - if key == "_box_config": - return object.__setattr__(self, key, value) + safe_key = self._safe_attr(key) if safe_key in self._box_config["__safe_keys"]: key = self._box_config["__safe_keys"][safe_key] @@ -637,7 +659,7 @@ def popitem(self): return key, self.pop(key) def __repr__(self) -> str: - return f"" + return f"Box({self})" def __str__(self) -> str: return str(self.to_dict()) @@ -666,21 +688,27 @@ def to_dict(self) -> Dict: out_dict[k] = v.to_list() return out_dict - def update(self, __m=None, **kwargs): + def update(self, *args, **kwargs): if self._box_config["frozen_box"]: raise BoxError("Box is frozen") - - if __m: - if hasattr(__m, "keys"): - for k in __m: - self.__convert_and_store(k, __m[k]) + if (len(args) + int(bool(kwargs))) > 1: + raise BoxTypeError(f"update expected at most 1 argument, got {len(args) + int(bool(kwargs))}") + single_arg = next(iter(args), None) + if single_arg: + if hasattr(single_arg, "keys"): + for k in single_arg: + self.__convert_and_store(k, single_arg[k]) else: - for k, v in __m: + for k, v in single_arg: self.__convert_and_store(k, v) for k in kwargs: self.__convert_and_store(k, kwargs[k]) - def merge_update(self, __m=None, **kwargs): + def merge_update(self, *args, **kwargs): + merge_type = None + if "box_merge_lists" in kwargs: + merge_type = kwargs.pop("box_merge_lists") + def convert_and_set(k, v): intact_type = self._box_config["box_intact_types"] and isinstance(v, self._box_config["box_intact_types"]) if isinstance(v, dict) and not intact_type: @@ -692,7 +720,6 @@ def convert_and_set(k, v): return if isinstance(v, list) and not intact_type: v = box.BoxList(v, **self.__box_config()) - merge_type = kwargs.get("box_merge_lists") if merge_type == "extend" and k in self and isinstance(self[k], list): self[k].extend(v) return @@ -703,13 +730,17 @@ def convert_and_set(k, v): return self.__setitem__(k, v) - if __m: - if hasattr(__m, "keys"): - for key in __m: - convert_and_set(key, __m[key]) + if (len(args) + int(bool(kwargs))) > 1: + raise BoxTypeError(f"merge_update expected at most 1 argument, got {len(args) + int(bool(kwargs))}") + single_arg = next(iter(args), None) + if single_arg: + if hasattr(single_arg, "keys"): + for k in single_arg: + convert_and_set(k, single_arg[k]) else: - for key, value in __m: - convert_and_set(key, value) + for k, v in single_arg: + convert_and_set(k, v) + for key in kwargs: convert_and_set(key, kwargs[key]) @@ -730,7 +761,10 @@ def setdefault(self, item, default=None): def _safe_attr(self, attr): """Convert a key into something that is accessible as an attribute""" - allowed = string.ascii_letters + string.digits + "_" + if isinstance(attr, str): + # By assuming most people are using string first we get substantial speed ups + if attr.isidentifier() and not iskeyword(attr): + return attr if isinstance(attr, tuple): attr = "_".join([str(x) for x in attr]) @@ -739,10 +773,18 @@ def _safe_attr(self, attr): if self.__box_config()["camel_killer_box"]: attr = _camel_killer(attr) + if attr.isidentifier() and not iskeyword(attr): + return attr + + if sum(1 for character in attr if character.isidentifier() and not iskeyword(character)) == 0: + attr = f'{self.__box_config()["box_safe_prefix"]}{attr}' + if attr.isidentifier() and not iskeyword(attr): + return attr + out = [] last_safe = 0 for i, character in enumerate(attr): - if character in allowed: + if f"x{character}".isidentifier(): last_safe = i out.append(character) elif not out: @@ -760,7 +802,7 @@ def _safe_attr(self, attr): else: out = f'{self.__box_config()["box_safe_prefix"]}{out}' - if out in kwlist: + if iskeyword(out): out = f'{self.__box_config()["box_safe_prefix"]}{out}' return out diff --git a/box/box.pyi b/box/box.pyi index da6cead..2e89991 100644 --- a/box/box.pyi +++ b/box/box.pyi @@ -9,6 +9,7 @@ class Box(dict): default_box: bool = ..., default_box_attr: Any = ..., default_box_none_transform: bool = ..., + default_box_create_on_get: bool = ..., frozen_box: bool = ..., camel_killer_box: bool = ..., conversion_box: bool = ..., @@ -27,6 +28,7 @@ class Box(dict): default_box: bool = ..., default_box_attr: Any = ..., default_box_none_transform: bool = ..., + default_box_create_on_get: bool = ..., frozen_box: bool = ..., camel_killer_box: bool = ..., conversion_box: bool = ..., diff --git a/box/box_list.py b/box/box_list.py index c5fc827..06da4b4 100644 --- a/box/box_list.py +++ b/box/box_list.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- # -# Copyright (c) 2017-2020 - Chris Griffith - MIT License +# Copyright (c) 2017-2022 - Chris Griffith - MIT License import copy import re from os import PathLike @@ -133,7 +133,7 @@ def _dotted_helper(self): return keys def __repr__(self): - return f"" + return f"BoxList({self.to_list()})" def __str__(self): return str(self.to_list()) diff --git a/box/config_box.py b/box/config_box.py index a8ad1ed..0d42b3b 100644 --- a/box/config_box.py +++ b/box/config_box.py @@ -123,7 +123,7 @@ def getfloat(self, item, default=None): return self.float(item, default) def __repr__(self): - return "".format(str(self.to_dict())) + return "ConfigBox({0})".format(str(self.to_dict())) def copy(self): return ConfigBox(super().copy()) diff --git a/box/converters.py b/box/converters.py index 446ac9f..b628ae7 100644 --- a/box/converters.py +++ b/box/converters.py @@ -8,22 +8,28 @@ from io import StringIO from os import PathLike from pathlib import Path -from typing import Union +from typing import Union, Optional, Dict from box.exceptions import BoxError -yaml_available = True +pyyaml_available = True +ruamel_available = True toml_available = True msgpack_available = True try: - import ruamel.yaml as yaml + from ruamel.yaml import version_info, YAML except ImportError: - try: - import yaml # type: ignore - except ImportError: - yaml = None # type: ignore - yaml_available = False + ruamel_available = False +else: + if version_info[1] < 17: + ruamel_available = False + +try: + import yaml +except ImportError: + pyyaml_available = False + try: import toml except ImportError: @@ -36,6 +42,8 @@ msgpack = None # type: ignore msgpack_available = False +yaml_available = pyyaml_available or ruamel_available + BOX_PARAMETERS = ( "default_box", "default_box_attr", @@ -44,7 +52,6 @@ "camel_killer_box", "box_safe_prefix", "box_duplicates", - "ordered_box", "default_box_none_transform", "box_dots", "modify_tuples_box", @@ -111,14 +118,39 @@ def _to_yaml( default_flow_style: bool = False, encoding: str = "utf-8", errors: str = "strict", + ruamel_typ: str = "rt", + ruamel_attrs: Optional[Dict] = None, **yaml_kwargs, ): + if not ruamel_attrs: + ruamel_attrs = {} if filename: _exists(filename, create=True) with open(filename, "w", encoding=encoding, errors=errors) as f: - yaml.dump(obj, stream=f, default_flow_style=default_flow_style, **yaml_kwargs) + if ruamel_available: + yaml_dumper = YAML(typ=ruamel_typ) + yaml_dumper.default_flow_style = default_flow_style + for attr, value in ruamel_attrs.items(): + setattr(yaml_dumper, attr, value) + return yaml_dumper.dump(obj, stream=f, **yaml_kwargs) + elif pyyaml_available: + return yaml.dump(obj, stream=f, default_flow_style=default_flow_style, **yaml_kwargs) + else: + raise BoxError("No YAML Parser available, please install ruamel.yaml>0.17 or PyYAML") + else: - return yaml.dump(obj, default_flow_style=default_flow_style, **yaml_kwargs) + if ruamel_available: + yaml_dumper = YAML(typ=ruamel_typ) + yaml_dumper.default_flow_style = default_flow_style + for attr, value in ruamel_attrs.items(): + setattr(yaml_dumper, attr, value) + with StringIO() as string_stream: + yaml_dumper.dump(obj, stream=string_stream, **yaml_kwargs) + return string_stream.getvalue() + elif pyyaml_available: + return yaml.dump(obj, default_flow_style=default_flow_style, **yaml_kwargs) + else: + raise BoxError("No YAML Parser available, please install ruamel.yaml>0.17 or PyYAML") def _from_yaml( @@ -126,16 +158,38 @@ def _from_yaml( filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict", + ruamel_typ: str = "rt", + ruamel_attrs: Optional[Dict] = None, **kwargs, ): - if "Loader" not in kwargs: - kwargs["Loader"] = yaml.SafeLoader + if not ruamel_attrs: + ruamel_attrs = {} if filename: _exists(filename) with open(filename, "r", encoding=encoding, errors=errors) as f: - data = yaml.load(f, **kwargs) + if ruamel_available: + yaml_loader = YAML(typ=ruamel_typ) + for attr, value in ruamel_attrs.items(): + setattr(yaml_loader, attr, value) + data = yaml_loader.load(stream=f) + elif pyyaml_available: + if "Loader" not in kwargs: + kwargs["Loader"] = yaml.SafeLoader + data = yaml.load(f, **kwargs) + else: + raise BoxError("No YAML Parser available, please install ruamel.yaml>0.15 or PyYAML") elif yaml_string: - data = yaml.load(yaml_string, **kwargs) + if ruamel_available: + yaml_loader = YAML(typ=ruamel_typ) + for attr, value in ruamel_attrs.items(): + setattr(yaml_loader, attr, value) + data = yaml_loader.load(stream=yaml_string) + elif pyyaml_available: + if "Loader" not in kwargs: + kwargs["Loader"] = yaml.SafeLoader + data = yaml.load(yaml_string, **kwargs) + else: + raise BoxError("No YAML Parser available, please install ruamel.yaml>0.17 or PyYAML") else: raise BoxError("from_yaml requires a string or filename") return data diff --git a/box/converters.pyi b/box/converters.pyi index 926f060..9da1b0f 100644 --- a/box/converters.pyi +++ b/box/converters.pyi @@ -1,6 +1,6 @@ from box.exceptions import BoxError as BoxError from os import PathLike as PathLike -from typing import Any, Union +from typing import Any, Union, Optional, Dict yaml_available: bool toml_available: bool @@ -25,6 +25,8 @@ def _to_yaml( default_flow_style: bool = False, encoding: str = "utf-8", errors: str = "strict", + ruamel_typ: str = "rt", + ruamel_attrs: Optional[Dict] = None, **yaml_kwargs, ) -> Any: ... def _from_yaml( @@ -32,6 +34,8 @@ def _from_yaml( filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict", + ruamel_typ: str = "rt", + ruamel_attrs: Optional[Dict] = None, **kwargs, ) -> Any: ... def _to_toml(obj, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict") -> Any: ... diff --git a/box/shorthand_box.py b/box/shorthand_box.py index db3e584..ab91b9c 100644 --- a/box/shorthand_box.py +++ b/box/shorthand_box.py @@ -41,7 +41,7 @@ def toml(self): return self.to_toml() def __repr__(self): - return "".format(str(self.to_dict())) + return "ShorthandBox({0})".format(str(self.to_dict())) def copy(self): return SBox(super(SBox, self).copy()) diff --git a/requirements-dev.txt b/requirements-dev.txt index 33abd7e..ec76399 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,5 @@ # Files needed for pre-commit hooks black>=21.7b0 +Cython>=0.29 mypy>=0.910 -pre-commit>=4.0.1 +pre-commit>=2.15 diff --git a/requirements-test.txt b/requirements-test.txt index 618fb54..91a12c3 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -2,7 +2,8 @@ coverage>=5.0.4 msgpack>=1.0 pytest>=5.4.1 pytest-cov>=2.8.1 -ruamel.yaml>=0.16,<0.17 +ruamel.yaml>=0.17 toml>=0.10.2 +types-PyYAML>=6.0.3 types-toml>=0.1.3 wheel>=0.34.2 diff --git a/requirements.txt b/requirements.txt index 2ef4138..b245b05 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ msgpack>=1.0.0 -ruamel.yaml>=0.16.10,<0.17 +ruamel.yaml>=0.17 toml>=0.10.2 diff --git a/setup.py b/setup.py index 20eef2f..effcfd4 100644 --- a/setup.py +++ b/setup.py @@ -5,11 +5,23 @@ import multiprocessing # noqa: F401 import os import re +from pathlib import Path +import sys from setuptools import setup root = os.path.abspath(os.path.dirname(__file__)) +try: + from Cython.Build import cythonize +except ImportError: + extra = None +else: + extra = cythonize( + [str(file.relative_to(root)) for file in Path(root, "box").glob("*.py") if file.name != "__init__.py"], + compiler_directives={"language_level": 3}, + ) + with open(os.path.join(root, "box", "__init__.py"), "r") as init_file: init_content = init_file.read() @@ -31,6 +43,7 @@ long_description_content_type="text/x-rst", py_modules=["box"], packages=["box"], + ext_modules=extra, python_requires=">=3.6", include_package_data=True, platforms="any", @@ -41,6 +54,7 @@ "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Programming Language :: Python :: Implementation :: CPython", "Development Status :: 5 - Production/Stable", "Natural Language :: English", @@ -52,11 +66,14 @@ "Topic :: Software Development :: Libraries :: Python Modules", ], extras_require={ - "all": ["ruamel.yaml", "toml", "msgpack"], - "yaml": ["ruamel.yaml"], - "ruamel.yaml": ["ruamel.yaml"], + "all": ["ruamel.yaml>=0.17", "toml", "msgpack"], + "yaml": ["ruamel.yaml>=0.17"], + "ruamel.yaml": ["ruamel.yaml>=0.17"], "PyYAML": ["PyYAML"], "toml": ["toml"], "msgpack": ["msgpack"], }, ) + +if not extra: + print("WARNING: Cython not installed, could not optimize box.", file=sys.stderr) diff --git a/test/common.py b/test/common.py index f9d9805..b50eee0 100644 --- a/test/common.py +++ b/test/common.py @@ -83,7 +83,7 @@ def __init__(self): [1, 2, 3], {}, ([], {}), - lambda x: x ** 2, + lambda x: x**2, function_example, ClassExample(), ) # type: ignore diff --git a/test/test_box.py b/test/test_box.py index 86e708c..dd3ba85 100644 --- a/test/test_box.py +++ b/test/test_box.py @@ -9,6 +9,7 @@ import shutil from multiprocessing import Queue from pathlib import Path +from io import StringIO from test.common import ( data_json_file, data_yaml_file, @@ -23,10 +24,11 @@ ) import pytest -import ruamel.yaml as yaml +from ruamel.yaml import YAML -from box import Box, BoxError, BoxKeyError, BoxList, ConfigBox, SBox, box -from box.box import _get_dot_paths # type: ignore +from box import Box, BoxError, BoxKeyError, BoxList, ConfigBox, SBox +from box.box import _get_dot_paths, _camel_killer, _recursive_tuples # type: ignore +from box.converters import BOX_PARAMETERS def mp_queue_test(q): @@ -58,8 +60,8 @@ def test_safe_attrs(self): assert Box()._safe_attr(356) == "x356" def test_camel_killer(self): - assert box._camel_killer("CamelCase") == "camel_case" - assert box._camel_killer("Terrible321KeyA") == "terrible321_key_a" + assert _camel_killer("CamelCase") == "camel_case" + assert _camel_killer("Terrible321KeyA") == "terrible321_key_a" bx = Box(camel_killer_box=True, conversion_box=False) bx.DeadCamel = 3 @@ -88,7 +90,7 @@ def test_camel_killer(self): assert len(bx1.keys()) == 0 def test_recursive_tuples(self): - out = box._recursive_tuples( + out = _recursive_tuples( ({"test": "a"}, ({"second": "b"}, {"third": "c"}, ("fourth",))), dict, recreate_tuples=True ) assert isinstance(out, tuple) @@ -108,7 +110,7 @@ def test_box(self): assert "TEST_KEY" not in bx.to_dict(), bx.to_dict() assert isinstance(bx["Key 2"].Key4, Box) assert "'key1': 'value1'" in str(bx) - assert repr(bx).startswith(" Date: Wed, 16 Mar 2022 17:13:04 -0500 Subject: [PATCH 05/17] Version 6.0.1 (#219) * Fixing #218 Box dots would not raise KeyError on bad key (thanks to Cliff Wells) * Fixing #217 wording in readme overview needed updated (thanks to Julie Jones) --- CHANGES.rst | 6 ++++++ README.rst | 16 +++++++++------- box/__init__.py | 2 +- box/box.py | 8 ++++++-- test/test_box.py | 10 ++++++++++ 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index bd8aff9..0e181e5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,12 @@ Changelog ========= +Version 6.0.1 +------------- + +* Fixing #218 Box dots would not raise KeyError on bad key (thanks to Cliff Wells) +* Fixing #217 wording in readme overview needed updated (thanks to Julie Jones) + Version 6.0.0 ------------- diff --git a/README.rst b/README.rst index fb63188..240770a 100644 --- a/README.rst +++ b/README.rst @@ -87,12 +87,14 @@ If you have any issues please open a github issue with the error you are experie Overview ======== -`Box` is designed to be an easy drop in transparently replacements for -dictionaries, thanks to Python's -duck typing capabilities, which adds dot notation access. Any sub -dictionaries or ones set after initiation will be automatically converted to -a `Box` object. You can always run `.to_dict()` on it to return the object -and all sub objects back into a regular dictionary. +`Box` is designed to be a near transparent drop in replacements for +dictionaries that add dot notation access and other powerful feature. + +There are a lot of `types of boxes `_ +to customize it for your needs, as well as handy `converters `_! + +Keep in mind any sub dictionaries or ones set after initiation will be automatically converted to +a `Box` object, and lists will be converted to `BoxList`, all other objects stay intact. Check out the `Quick Start `_ for more in depth details. @@ -116,7 +118,7 @@ sure everything stored in the dict can be accessed as an attribute or key value. small_box = Box({'data': 2, 'count': 5}) small_box.data == small_box['data'] == getattr(small_box, 'data') -All dicts (and lists) added to a `Box` will be converted on lookup to a `Box` (or `BoxList`), +All dicts (and lists) added to a `Box` will be converted on insertion to a `Box` (or `BoxList`), allowing for recursive dot notation access. `Box` also includes helper functions to transform it back into a `dict`, diff --git a/box/__init__.py b/box/__init__.py index 3970189..1a12f5d 100644 --- a/box/__init__.py +++ b/box/__init__.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- __author__ = "Chris Griffith" -__version__ = "6.0.0" +__version__ = "6.0.1" from box.box import Box from box.box_list import BoxList diff --git a/box/box.py b/box/box.py index b899c32..ee8b506 100644 --- a/box/box.py +++ b/box/box.py @@ -130,6 +130,7 @@ class Box(dict): :param default_box_attr: Specify the default replacement. WARNING: If this is not the default 'Box', it will not be recursive :param default_box_none_transform: When using default_box, treat keys with none values as absent. True by default + :param default_box_create_on_get: On lookup of a key that doesn't exist, create it if missing :param frozen_box: After creation, the box cannot be modified :param camel_killer_box: Convert CamelCase to snake_case :param conversion_box: Check for near matching keys as attributes @@ -510,7 +511,7 @@ def __getitem__(self, item, _ignore_default=False): except BoxError: if self._box_config["default_box"] and not _ignore_default: return self.__get_default(item) - raise + raise BoxKeyError(str(item)) from _exception_cause(err) if first_item in self.keys(): if hasattr(self[first_item], "__getitem__"): return self[first_item][children] @@ -589,7 +590,10 @@ def __delitem__(self, key): and isinstance(key, str) and ("." in key or "[" in key) ): - first_item, children = _parse_box_dots(self, key) + try: + first_item, children = _parse_box_dots(self, key) + except BoxError: + raise BoxKeyError(str(key)) from None if hasattr(self[first_item], "__delitem__"): return self[first_item].__delitem__(children) if key not in self.keys() and self._box_config["camel_killer_box"]: diff --git a/test/test_box.py b/test/test_box.py index dd3ba85..814ff54 100644 --- a/test/test_box.py +++ b/test/test_box.py @@ -932,6 +932,16 @@ def test_dots(self): _parse_box_dots({}, "-") + with pytest.raises(KeyError): + b["a.b"] + with pytest.raises(BoxKeyError): + b["a.b"] + + with pytest.raises(KeyError): + del b["a.b"] + with pytest.raises(BoxKeyError): + del b["a.b"] + def test_unicode(self): bx = Box() bx["\U0001f631"] = 4 From f15fa1b6a736b15aafd053d9613edd3c1d2527cc Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Fri, 1 Apr 2022 21:24:21 -0500 Subject: [PATCH 06/17] Version 6.0.2 (#221) * Fixing that the typing `pyi` files were not included in the manifest (thanks to Julian Torres) --- CHANGES.rst | 5 +++++ MANIFEST.in | 1 + box/__init__.py | 2 +- box/box.pyi | 4 ++-- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 0e181e5..fff0ada 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,11 @@ Changelog ========= +Version 6.0.2 +------------- + +* Fixing that the typing `pyi` files were not included in the manifest (thanks to Julian Torres) + Version 6.0.1 ------------- diff --git a/MANIFEST.in b/MANIFEST.in index fffa170..462abbb 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -5,3 +5,4 @@ include box/py.typed include box/*.c include box/*.so include box/*.pyd +include box/*.pyi diff --git a/box/__init__.py b/box/__init__.py index 1a12f5d..c0337c0 100644 --- a/box/__init__.py +++ b/box/__init__.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- __author__ = "Chris Griffith" -__version__ = "6.0.1" +__version__ = "6.0.2" from box.box import Box from box.box_list import BoxList diff --git a/box/box.pyi b/box/box.pyi index 2e89991..4907b00 100644 --- a/box/box.pyi +++ b/box/box.pyi @@ -56,8 +56,8 @@ class Box(dict): def copy(self) -> Box: ... def __copy__(self) -> Box: ... def __deepcopy__(self, memodict: Any = ...) -> Box: ... - def __getitem__(self, item: Any, _ignore_default: bool = ...): ... - def __getattr__(self, item: Any): ... + def __getitem__(self, item: Any, _ignore_default: bool = ...) -> Any: ... + def __getattr__(self, item: Any) -> Any: ... def __setitem__(self, key: Any, value: Any): ... def __setattr__(self, key: Any, value: Any): ... def __delitem__(self, key: Any): ... From f5c326c9d498cb1bfaabc8f51441ac81f1ec3844 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Sat, 29 Oct 2022 17:23:35 -0500 Subject: [PATCH 07/17] Version 6.10.0 (#232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adding Python 3.11 support * Adding #195 box_from_string function (thanks to Marcelo Huerta) * Changing the deprecated ``toml`` package with modern ``tomllib``, ``tomli`` and ``tomli-w`` usage (thanks to Michał Górny) * Fixing mypy __ior__ type (thanks to Jacob Hayes) * Fixing line endings with a pre-commit update * Fixing BoxList was using old style of `super` in internal code usage Co-authored-by: Jacob Hayes Co-authored-by: Michał Górny --- .github/workflows/pythonpublish.yml | 18 +++--- .github/workflows/tests.yml | 18 +++--- .pre-commit-config.yaml | 8 +-- AUTHORS.rst | 1 + CHANGES.rst | 10 ++++ README.rst | 2 +- box/__init__.py | 4 +- box/box.py | 19 +++--- box/box.pyi | 2 +- box/box_list.py | 45 ++++++++------- box/box_list.pyi | 9 +-- box/converters.py | 90 +++++++++++++++++++++++++---- box/converters.pyi | 5 +- box/from_file.py | 50 ++++++++++++---- box/from_file.pyi | 1 + requirements-dev.txt | 6 +- requirements-test.txt | 6 +- requirements.txt | 3 +- setup.py | 4 +- test/data/bad_file.txt | 2 +- test/data/json_list.json | 2 +- test/test_box_list.py | 8 +-- test/test_from_file.py | 12 +++- test/test_sbox.py | 2 + 24 files changed, 233 insertions(+), 94 deletions(-) diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index dae54d3..689dde7 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -13,11 +13,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: - python-version: '3.9' + python-version: '3.10' - name: Install dependencies run: | python -m pip install --upgrade pip @@ -34,13 +34,13 @@ jobs: strategy: matrix: os: [macos-latest, windows-latest] - python-version: ["3.7", "3.8", "3.9", "3.10"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -58,15 +58,15 @@ jobs: deploy-cython-manylinux: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python 3.10 - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: "3.10" - uses: RalfG/python-wheels-manylinux-build@v0.4.2-manylinux2014_x86_64 with: - python-versions: 'cp37-cp37m cp38-cp38 cp39-cp39 cp310-cp310' + python-versions: 'cp37-cp37m cp38-cp38 cp39-cp39 cp310-cp310 cp311-cp311' build-requirements: 'cython' - name: Install dependencies diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4df0bed..bc7fe2a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,13 +13,13 @@ jobs: package-checks: strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "pypy-3.8"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "pypy-3.8"] os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - uses: actions/cache@v2 @@ -66,9 +66,9 @@ jobs: package-manylinux-checks: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python 3.10 - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: "3.10" @@ -85,7 +85,7 @@ jobs: - uses: RalfG/python-wheels-manylinux-build@v0.4.2-manylinux2014_x86_64 with: - python-versions: 'cp37-cp37m cp38-cp38 cp39-cp39 cp310-cp310' + python-versions: 'cp37-cp37m cp38-cp38 cp39-cp39 cp310-cp310 cp311-cp311' build-requirements: 'cython' - name: Test packaged wheel on linux @@ -103,13 +103,13 @@ jobs: test: strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - uses: actions/cache@v2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3ce0c43..6270614 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.1.0 + rev: v4.3.0 hooks: # Identify invalid files - id: check-ast @@ -29,7 +29,7 @@ repos: exclude: ^test/data/.+ - repo: https://github.com/ambv/black - rev: 22.1.0 + rev: 22.10.0 hooks: - id: black args: [--config=.black.toml] @@ -51,8 +51,8 @@ repos: always_run: true - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v0.931' + rev: 'v0.982' hooks: - id: mypy types: [python] - additional_dependencies: [ruamel.yaml,toml,msgpack,types-PyYAML,types-toml] + additional_dependencies: [ruamel.yaml,toml,types-toml,tomli,tomli-w,msgpack,types-PyYAML] diff --git a/AUTHORS.rst b/AUTHORS.rst index 76312ba..2efd090 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -27,6 +27,7 @@ Code contributions: - Jacob Hayes (JacobHayes) - Dominic (Yobmod) - Ivan Pepelnjak (ipspace) +- Michał Górny (mgorny) Suggestions and bug reporting: diff --git a/CHANGES.rst b/CHANGES.rst index fff0ada..c023470 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,16 @@ Changelog ========= +Version 6.1.0 +------------- + +* Adding Python 3.11 support +* Adding #195 box_from_string function (thanks to Marcelo Huerta) +* Changing the deprecated ``toml`` package with modern ``tomllib``, ``tomli`` and ``tomli-w`` usage (thanks to Michał Górny) +* Fixing mypy __ior__ type (thanks to Jacob Hayes) +* Fixing line endings with a pre-commit update +* Fixing BoxList was using old style of `super` in internal code usage + Version 6.0.2 ------------- diff --git a/README.rst b/README.rst index 240770a..6cb139e 100644 --- a/README.rst +++ b/README.rst @@ -59,7 +59,7 @@ But you can also sub out `ruamel.yaml` for `PyYAML`. Check out `more details `_ on installation details. -Box 6 is tested on python 3.6+, if you are upgrading from previous versions, please look through +Box 6 is tested on python 3.7+, if you are upgrading from previous versions, please look through `any breaking changes and new features `_. Optimized Version diff --git a/box/__init__.py b/box/__init__.py index c0337c0..8a44dd1 100644 --- a/box/__init__.py +++ b/box/__init__.py @@ -2,13 +2,13 @@ # -*- coding: utf-8 -*- __author__ = "Chris Griffith" -__version__ = "6.0.2" +__version__ = "6.1.0" from box.box import Box from box.box_list import BoxList from box.config_box import ConfigBox from box.exceptions import BoxError, BoxKeyError -from box.from_file import box_from_file +from box.from_file import box_from_file, box_from_string from box.shorthand_box import SBox import box.converters diff --git a/box/box.py b/box/box.py index ee8b506..06b8a60 100644 --- a/box/box.py +++ b/box/box.py @@ -29,7 +29,8 @@ _to_toml, _to_yaml, msgpack_available, - toml_available, + toml_read_library, + toml_write_library, yaml_available, ) from box.exceptions import BoxError, BoxKeyError, BoxTypeError, BoxValueError, BoxWarning @@ -301,7 +302,7 @@ def __ror__(self, other: Mapping[Any, Any]): new_box.update(other) return new_box - def __ior__(self, other: Mapping[Any, Any]): + def __ior__(self, other: Mapping[Any, Any]): # type: ignore[override] if not isinstance(other, dict): raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") self.update(other) @@ -954,7 +955,7 @@ def from_yaml( ) -> "Box": raise BoxError('yaml is unavailable on this system, please install the "ruamel.yaml" or "PyYAML" package') - if toml_available: + if toml_write_library is not None: def to_toml(self, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict"): """ @@ -967,6 +968,13 @@ def to_toml(self, filename: Union[str, PathLike] = None, encoding: str = "utf-8" """ return _to_toml(self.to_dict(), filename=filename, encoding=encoding, errors=errors) + else: + + def to_toml(self, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict"): + raise BoxError('toml is unavailable on this system, please install the "tomli-w" package') + + if toml_read_library is not None: + @classmethod def from_toml( cls, @@ -996,9 +1004,6 @@ def from_toml( else: - def to_toml(self, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict"): - raise BoxError('toml is unavailable on this system, please install the "toml" package') - @classmethod def from_toml( cls, @@ -1008,7 +1013,7 @@ def from_toml( errors: str = "strict", **kwargs, ) -> "Box": - raise BoxError('toml is unavailable on this system, please install the "toml" package') + raise BoxError('toml is unavailable on this system, please install the "tomli" package') if msgpack_available: diff --git a/box/box.pyi b/box/box.pyi index 4907b00..02ba3d4 100644 --- a/box/box.pyi +++ b/box/box.pyi @@ -46,7 +46,7 @@ class Box(dict): def __iadd__(self, other: Mapping[Any, Any]) -> Any: ... def __or__(self, other: Mapping[Any, Any]) -> Any: ... def __ror__(self, other: Mapping[Any, Any]) -> Any: ... - def __ior__(self, other: Mapping[Any, Any]) -> Any: ... + def __ior__(self, other: Mapping[Any, Any]) -> Any: ... # type: ignore[override] def __sub__(self, other: Mapping[Any, Any]) -> Any: ... def __hash__(self) -> Any: ... # type: ignore[override] def __dir__(self): ... diff --git a/box/box_list.py b/box/box_list.py index 06da4b4..8034ad2 100644 --- a/box/box_list.py +++ b/box/box_list.py @@ -21,7 +21,8 @@ _to_toml, _to_yaml, msgpack_available, - toml_available, + toml_read_library, + toml_write_library, yaml_available, ) from box.exceptions import BoxError, BoxTypeError @@ -61,11 +62,11 @@ def frozen(*args, **kwargs): def __getitem__(self, item): if self.box_options.get("box_dots") and isinstance(item, str) and item.startswith("["): list_pos = _list_pos_re.search(item) - value = super(BoxList, self).__getitem__(int(list_pos.groups()[0])) + value = super().__getitem__(int(list_pos.groups()[0])) if len(list_pos.group()) == len(item): return value return value.__getitem__(item[len(list_pos.group()) :].lstrip(".")) - return super(BoxList, self).__getitem__(item) + return super().__getitem__(item) def __delitem__(self, key): if self.box_options.get("frozen_box"): @@ -74,10 +75,10 @@ def __delitem__(self, key): list_pos = _list_pos_re.search(key) pos = int(list_pos.groups()[0]) if len(list_pos.group()) == len(key): - return super(BoxList, self).__delitem__(pos) + return super().__delitem__(pos) if hasattr(self[pos], "__delitem__"): return self[pos].__delitem__(key[len(list_pos.group()) :].lstrip(".")) # type: ignore - super(BoxList, self).__delitem__(key) + super().__delitem__(key) def __setitem__(self, key, value): if self.box_options.get("frozen_box"): @@ -86,9 +87,9 @@ def __setitem__(self, key, value): list_pos = _list_pos_re.search(key) pos = int(list_pos.groups()[0]) if len(list_pos.group()) == len(key): - return super(BoxList, self).__setitem__(pos, value) - return super(BoxList, self).__getitem__(pos).__setitem__(key[len(list_pos.group()) :].lstrip("."), value) - super(BoxList, self).__setitem__(key, value) + return super().__setitem__(pos, value) + return super().__getitem__(pos).__setitem__(key[len(list_pos.group()) :].lstrip("."), value) + super().__setitem__(key, value) def _is_intact_type(self, obj): if self.box_options.get("box_intact_types") and isinstance(obj, self.box_options["box_intact_types"]): @@ -107,14 +108,14 @@ def _convert(self, p_object): return p_object def append(self, p_object): - super(BoxList, self).append(self._convert(p_object)) + super().append(self._convert(p_object)) def extend(self, iterable): for item in iterable: self.append(item) def insert(self, index, p_object): - super(BoxList, self).insert(index, self._convert(p_object)) + super().insert(index, self._convert(p_object)) def _dotted_helper(self): keys = [] @@ -312,7 +313,7 @@ def from_yaml( ): raise BoxError('yaml is unavailable on this system, please install the "ruamel.yaml" or "PyYAML" package') - if toml_available: + if toml_read_library is not None: def to_toml( self, @@ -333,6 +334,19 @@ def to_toml( """ return _to_toml({key_name: self.to_list()}, filename=filename, encoding=encoding, errors=errors) + else: + + def to_toml( + self, + filename: Union[str, PathLike] = None, + key_name: str = "toml", + encoding: str = "utf-8", + errors: str = "strict", + ): + raise BoxError('toml is unavailable on this system, please install the "tomli-w" package') + + if toml_read_library is not None: + @classmethod def from_toml( cls, @@ -367,15 +381,6 @@ def from_toml( else: - def to_toml( - self, - filename: Union[str, PathLike] = None, - key_name: str = "toml", - encoding: str = "utf-8", - errors: str = "strict", - ): - raise BoxError('toml is unavailable on this system, please install the "toml" package') - @classmethod def from_toml( cls, diff --git a/box/box_list.pyi b/box/box_list.pyi index 8c9a93f..dbd2007 100644 --- a/box/box_list.pyi +++ b/box/box_list.pyi @@ -1,9 +1,10 @@ import box from box.converters import ( - BOX_PARAMETERS as BOX_PARAMETERS, - msgpack_available as msgpack_available, - toml_available as toml_available, - yaml_available as yaml_available, + BOX_PARAMETERS, + msgpack_available, + yaml_available, + toml_read_library, + toml_write_library, ) from box.exceptions import BoxError as BoxError, BoxTypeError as BoxTypeError from os import PathLike as PathLike diff --git a/box/converters.py b/box/converters.py index b628ae7..0114453 100644 --- a/box/converters.py +++ b/box/converters.py @@ -5,16 +5,16 @@ import csv import json +import sys from io import StringIO from os import PathLike from pathlib import Path -from typing import Union, Optional, Dict +from typing import Union, Optional, Dict, Any, Callable from box.exceptions import BoxError pyyaml_available = True ruamel_available = True -toml_available = True msgpack_available = True try: @@ -30,11 +30,59 @@ except ImportError: pyyaml_available = False +toml_read_library: Optional[Any] = None +toml_write_library: Optional[Any] = None +toml_decode_error: Optional[Callable] = None + + +class BoxTomlDecodeError(BoxError): + """Toml Decode Error""" + + try: import toml except ImportError: - toml = None # type: ignore - toml_available = False + pass +else: + toml_read_library = toml + toml_write_library = toml + toml_decode_error = toml.TomlDecodeError + + class BoxTomlDecodeError(BoxError, toml.TomlDecodeError): # type: ignore + """Toml Decode Error""" + + +try: + import tomllib +except ImportError: + pass +else: + toml_read_library = tomllib + toml_decode_error = tomllib.TOMLDecodeError + + class BoxTomlDecodeError(BoxError, tomllib.TOMLDecodeError): # type: ignore + """Toml Decode Error""" + + +try: + import tomli +except ImportError: + pass +else: + toml_read_library = tomli + toml_decode_error = tomli.TOMLDecodeError + + class BoxTomlDecodeError(BoxError, tomli.TOMLDecodeError): # type: ignore + """Toml Decode Error""" + + +try: + import tomli_w +except ImportError: + pass +else: + toml_write_library = tomli_w + try: import msgpack # type: ignore @@ -198,21 +246,41 @@ def _from_yaml( def _to_toml(obj, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict"): if filename: _exists(filename, create=True) - with open(filename, "w", encoding=encoding, errors=errors) as f: - toml.dump(obj, f) + if toml_write_library.__name__ == "toml": # type: ignore + with open(filename, "w", encoding=encoding, errors=errors) as f: + try: + toml_write_library.dump(obj, f) # type: ignore + except toml_decode_error as err: # type: ignore + raise BoxTomlDecodeError(err) from err + else: + with open(filename, "wb") as f: + try: + toml_write_library.dump(obj, f) # type: ignore + except toml_decode_error as err: # type: ignore + raise BoxTomlDecodeError(err) from err else: - return toml.dumps(obj) + try: + return toml_write_library.dumps(obj) # type: ignore + except toml_decode_error as err: # type: ignore + raise BoxTomlDecodeError(err) from err def _from_toml( - toml_string: str = None, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict" + toml_string: str = None, + filename: Union[str, PathLike] = None, + encoding: str = "utf-8", + errors: str = "strict", ): if filename: _exists(filename) - with open(filename, "r", encoding=encoding, errors=errors) as f: - data = toml.load(f) + if toml_read_library.__name__ == "toml": # type: ignore + with open(filename, "r", encoding=encoding, errors=errors) as f: + data = toml_read_library.load(f) # type: ignore + else: + with open(filename, "rb") as f: + data = toml_read_library.load(f) # type: ignore elif toml_string: - data = toml.loads(toml_string) + data = toml_read_library.loads(toml_string) # type: ignore else: raise BoxError("from_toml requires a string or filename") return data diff --git a/box/converters.pyi b/box/converters.pyi index 9da1b0f..f18d1cf 100644 --- a/box/converters.pyi +++ b/box/converters.pyi @@ -1,11 +1,14 @@ from box.exceptions import BoxError as BoxError from os import PathLike as PathLike -from typing import Any, Union, Optional, Dict +from typing import Any, Union, Optional, Dict, Callable yaml_available: bool toml_available: bool msgpack_available: bool BOX_PARAMETERS: Any +toml_read_library: Optional[Any] +toml_write_library: Optional[Any] +toml_decode_error: Optional[Callable] def _exists(filename: Union[str, PathLike], create: bool = False) -> Any: ... def _to_json( diff --git a/box/from_file.py b/box/from_file.py index e171b4a..268f136 100644 --- a/box/from_file.py +++ b/box/from_file.py @@ -4,10 +4,11 @@ from os import PathLike from pathlib import Path from typing import Callable, Dict, Union +import sys from box.box import Box from box.box_list import BoxList -from box.converters import msgpack_available, toml_available, yaml_available +from box.converters import msgpack_available, toml_read_library, yaml_available, toml_decode_error from box.exceptions import BoxError try: @@ -18,18 +19,13 @@ except ImportError: YAMLError = False # type: ignore -try: - from toml import TomlDecodeError # type: ignore -except ImportError: - TomlDecodeError = False # type: ignore - try: from msgpack import UnpackException # type: ignore except ImportError: UnpackException = False # type: ignore -__all__ = ["box_from_file"] +__all__ = ["box_from_file", "box_from_string"] def _to_json(file, encoding, errors, **kwargs): @@ -59,11 +55,11 @@ def _to_yaml(file, encoding, errors, **kwargs): def _to_toml(file, encoding, errors, **kwargs): - if not toml_available: - raise BoxError(f'File "{file}" is toml but no package is available to open it. Please install "toml"') + if not toml_read_library: + raise BoxError(f'File "{file}" is toml but no package is available to open it. Please install "tomli"') try: return Box.from_toml(filename=file, encoding=encoding, errors=errors, **kwargs) - except TomlDecodeError: + except toml_decode_error: raise BoxError("File is not TOML as expected") @@ -113,3 +109,37 @@ def box_from_file( if file_type.lower() in converters: return converters[file_type.lower()](file, encoding, errors, **kwargs) # type: ignore raise BoxError(f'"{file_type}" is an unknown type. Please use either csv, toml, msgpack, yaml or json') + + +def box_from_string(content: str, string_type: str = "json") -> Union[Box, BoxList]: + """ + Parse the provided string into a Box or BoxList object as appropriate. + + :param content: String to parse + :param string_type: manually specify file type: json, toml or yaml + :return: Box or BoxList + """ + + if string_type == "json": + try: + return Box.from_json(json_string=content) + except JSONDecodeError: + raise BoxError("File is not JSON as expected") + except BoxError: + return BoxList.from_json(json_string=content) + elif string_type == "toml": + try: + return Box.from_toml(toml_string=content) + except toml_decode_error: # type: ignore + raise BoxError("File is not TOML as expected") + except BoxError: + return BoxList.from_toml(toml_string=content) + elif string_type == "yaml": + try: + return Box.from_yaml(yaml_string=content) + except YAMLError: + raise BoxError("File is not YAML as expected") + except BoxError: + return BoxList.from_yaml(yaml_string=content) + else: + raise BoxError(f"Unsupported string_string of {string_type}") diff --git a/box/from_file.pyi b/box/from_file.pyi index 0dc30f5..00657eb 100644 --- a/box/from_file.pyi +++ b/box/from_file.pyi @@ -6,3 +6,4 @@ from typing import Any, Union def box_from_file( file: Union[str, PathLike], file_type: str = ..., encoding: str = ..., errors: str = ..., **kwargs: Any ) -> Union[Box, BoxList]: ... +def box_from_string(content: str, string_type: str = ...) -> Union[Box, BoxList]: ... diff --git a/requirements-dev.txt b/requirements-dev.txt index ec76399..eb71a71 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ # Files needed for pre-commit hooks -black>=21.7b0 +black>=22.10.0 Cython>=0.29 -mypy>=0.910 -pre-commit>=2.15 +mypy>=0.982 +pre-commit>=2.20 diff --git a/requirements-test.txt b/requirements-test.txt index 91a12c3..1296ffa 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,9 +1,9 @@ coverage>=5.0.4 msgpack>=1.0 -pytest>=5.4.1 +pytest>=7.1.3 pytest-cov>=2.8.1 ruamel.yaml>=0.17 -toml>=0.10.2 +tomli>=1.2.3; python_version < '3.11' +tomli-w>=1.0.0 types-PyYAML>=6.0.3 -types-toml>=0.1.3 wheel>=0.34.2 diff --git a/requirements.txt b/requirements.txt index b245b05..57e2f36 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ msgpack>=1.0.0 ruamel.yaml>=0.17 -toml>=0.10.2 +tomli>=1.2.3; python_version < '3.11' +tomli-w diff --git a/setup.py b/setup.py index effcfd4..7200751 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ py_modules=["box"], packages=["box"], ext_modules=extra, - python_requires=">=3.6", + python_requires=">=3.7", include_package_data=True, platforms="any", classifiers=[ @@ -55,6 +55,7 @@ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: Implementation :: CPython", "Development Status :: 5 - Production/Stable", "Natural Language :: English", @@ -70,6 +71,7 @@ "yaml": ["ruamel.yaml>=0.17"], "ruamel.yaml": ["ruamel.yaml>=0.17"], "PyYAML": ["PyYAML"], + "tomli": ["tomli; python_version < '3.11'", "tomli-w"], "toml": ["toml"], "msgpack": ["msgpack"], }, diff --git a/test/data/bad_file.txt b/test/data/bad_file.txt index c7fb49c..a4cc880 100644 --- a/test/data/bad_file.txt +++ b/test/data/bad_file.txt @@ -1,3 +1,3 @@ Nothing good in here # bad data -test/ \ No newline at end of file +test/ diff --git a/test/data/json_list.json b/test/data/json_list.json index 70ac11a..a4dffb1 100644 --- a/test/data/json_list.json +++ b/test/data/json_list.json @@ -1,4 +1,4 @@ [ "test", "data" -] \ No newline at end of file +] diff --git a/test/test_box_list.py b/test/test_box_list.py index 0b736a2..e68e521 100644 --- a/test/test_box_list.py +++ b/test/test_box_list.py @@ -11,9 +11,9 @@ import pytest from ruamel.yaml import YAML -import toml from box import Box, BoxError, BoxList +from box.converters import toml_read_library, toml_write_library class TestBoxList: @@ -101,19 +101,19 @@ def test_box_list_from_yaml(self): def test_box_list_to_toml(self): bl = BoxList([{"item": 1, "CamelBad": 2}]) - assert toml.loads(bl.to_toml(key_name="test"))["test"][0]["item"] == 1 + assert toml_read_library.loads(bl.to_toml(key_name="test"))["test"][0]["item"] == 1 with pytest.raises(BoxError): BoxList.from_toml("[[test]]\nitem = 1\nCamelBad = 2\n\n", key_name="does not exist") def test_box_list_from_tml(self): alist = [{"item": 1}, {"CamelBad": 2}] - toml_list = toml.dumps({"key": alist}) + toml_list = toml_write_library.dumps({"key": alist}) bl = BoxList.from_toml(toml_string=toml_list, key_name="key", camel_killer_box=True) assert bl[0].item == 1 assert bl[1].camel_bad == 2 with pytest.raises(BoxError): - BoxList.from_toml(toml.dumps({"a": 2}), "a") + BoxList.from_toml(toml_write_library.dumps({"a": 2}), "a") with pytest.raises(BoxError): BoxList.from_toml(toml_list, "bad_key") diff --git a/test/test_from_file.py b/test/test_from_file.py index 90b8076..7361e84 100644 --- a/test/test_from_file.py +++ b/test/test_from_file.py @@ -5,7 +5,7 @@ import pytest -from box import Box, BoxError, BoxList, box_from_file +from box import Box, BoxError, BoxList, box_from_file, box_from_string class TestFromFile: @@ -37,3 +37,13 @@ def test_bad_file(self): box_from_file(Path(test_root, "data", "bad_file.txt")) with pytest.raises(BoxError): box_from_file("does not exist") + + def test_from_string_all(self): + with open(Path(test_root, "data", "json_file.json"), "r") as f: + box_from_string(f.read()) + + with open(Path(test_root, "data", "toml_file.tml"), "r") as f: + box_from_string(f.read(), string_type="toml") + + with open(Path(test_root, "data", "yaml_file.yaml"), "r") as f: + box_from_string(f.read(), string_type="yaml") diff --git a/test/test_sbox.py b/test/test_sbox.py index 33b462b..8b7ee02 100644 --- a/test/test_sbox.py +++ b/test/test_sbox.py @@ -3,6 +3,8 @@ import json from test.common import test_dict +import pytest + from ruamel.yaml import YAML from box import Box, SBox From 0bfcb2d37ddc4c60f5aa48a8c258f8a324e24d18 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Fri, 3 Feb 2023 21:39:51 -0600 Subject: [PATCH 08/17] Version 7.0.0 (#241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adding #169 default functions with the box_instance and key parameter (thanks to Коптев Роман Викторович) * Adding #170 Be able to initialize with a flattened dict - by using DDBox (thanks to Ash A.) * Adding #192 box_dots treats all keys with periods in them as separate keys (thanks to Rexbard) * Adding #211 support for properties and setters in subclasses (thanks to Serge Lu and David Aronchick) * Adding #226 namespace to track changes to the box (thanks to Jacob Hayes) * Adding #236 iPython detection to prevent adding attribute lookup words (thanks to Nishikant Parmar) * Adding #238 allow ``|`` and ``+`` for frozen boxes (thanks to Peter B) * Adding new DDBox class (Default Dots Box) that is a subclass of SBox * Fixing #235 how ``|`` and ``+`` updates were performed for right operations (thanks to aviveh21) * Fixing #234 typos (thanks to Martin Schorfmann) * Fixing no implicit optionals with type hinting --- .github/workflows/pythonpublish.yml | 86 ++++++----- .github/workflows/tests.yml | 152 ++++++++++---------- .pre-commit-config.yaml | 8 +- AUTHORS.rst | 8 ++ CHANGES.rst | 17 +++ CONTRIBUTING.rst | 2 +- LICENSE | 2 +- README.rst | 17 +-- box/__init__.py | 5 +- box/box.py | 212 +++++++++++++++++++++------- box/box_list.py | 54 +++---- box/converters.py | 30 ++-- box/converters.pyi | 33 +++-- box/from_file.py | 8 +- box/shorthand_box.py | 20 ++- box/shorthand_box.pyi | 2 + build.py | 34 +++++ pyproject.toml | 97 +++++++++++++ requirements-dev.txt | 5 - requirements-test.txt | 9 -- requirements.txt | 4 - setup.py | 81 ----------- test/test_box.py | 107 ++++++++++++-- test/test_sbox.py | 2 +- 24 files changed, 638 insertions(+), 357 deletions(-) create mode 100644 build.py create mode 100644 pyproject.toml delete mode 100644 requirements-dev.txt delete mode 100644 requirements-test.txt delete mode 100644 requirements.txt delete mode 100644 setup.py diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index 689dde7..9f0cd6e 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -5,7 +5,7 @@ name: Upload Python Package on: release: - types: [created] + types: [ created ] jobs: deploy-generic: @@ -14,69 +14,63 @@ jobs: steps: - uses: actions/checkout@v3 + - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.10' - - name: Install dependencies + + - name: Install Dependencies run: | python -m pip install --upgrade pip - pip install setuptools wheel twine --upgrade - - name: Build and publish + pip install poetry --upgrade + poetry config virtualenvs.create false --local + + - name: Poetry Install + run: poetry install --with dev + continue-on-error: true + + - name: Poetry Install 2 + run: poetry install --with dev + + - name: Build and Publish env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | - python setup.py sdist bdist_wheel + poetry build twine upload dist/* deploy-cython: strategy: matrix: - os: [macos-latest, windows-latest] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + os: [ ubuntu-latest, macos-12, windows-latest ] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine Cython --upgrade - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - python setup.py bdist_wheel - twine upload dist/* - - deploy-cython-manylinux: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.10 - uses: actions/setup-python@v4 - with: - python-version: "3.10" + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: "3.11" - - uses: RalfG/python-wheels-manylinux-build@v0.4.2-manylinux2014_x86_64 - with: - python-versions: 'cp37-cp37m cp38-cp38 cp39-cp39 cp310-cp310 cp311-cp311' - build-requirements: 'cython' + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + python -m pip install cibuildwheel twine --upgrade - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install twine --upgrade + - name: Build Wheels + env: + CIBW_SKIP: "cp36-* pp* *i686" + CIBW_BUILD_VERBOSITY: 1 + CIBW_TEST_REQUIRES: "pytest msgpack ruamel.yaml tomli tomli-w" + CIBW_TEST_COMMAND: "pytest {package}/test" + run: python -m cibuildwheel --output-dir wheels - - name: Publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - twine upload dist/*-manylinux*.whl + - name: Publish Wheels + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + twine upload wheels/* diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bc7fe2a..d40f647 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,102 +5,85 @@ name: Tests on: push: - branches: [ master, development, develop, test, tests ] + branches: [ test, tests ] pull_request: branches: [ master, development, develop, test, tests ] jobs: - package-checks: + build-checks: strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "pypy-3.8"] - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, macos-12, windows-latest] runs-on: ${{ matrix.os }} + steps: - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} + + - name: Set up Python 3.11 uses: actions/setup-python@v4 with: - python-version: ${{ matrix.python-version }} - - uses: actions/cache@v2 - with: - path: ~/.cache/pip - key: package-check-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} - - name: Install dependencies + python-version: "3.11" + + - name: Build Wheels + env: + CIBW_SKIP: "cp36-* pp* *i686" + CIBW_BUILD_VERBOSITY: 1 + CIBW_TEST_REQUIRES: "pytest msgpack ruamel.yaml tomli tomli-w" + CIBW_TEST_COMMAND: "pytest {package}/test" run: | python -m pip install --upgrade pip - pip install -r requirements.txt - pip install -r requirements-test.txt - pip install coveralls flake8 flake8-print mypy setuptools wheel twine Cython - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors, undefined names or print statements - flake8 box --count --select=E9,F63,F7,F82,T001,T002,T003,T004 --show-source --statistics - # exit-zero treats all errors as warnings. - flake8 . --count --exit-zero --max-complexity=20 --max-line-length=120 --statistics --extend-ignore E203 - - name: Run mypy - run: mypy box - - name: Build Wheel and check distrubiton log description - run: | - python setup.py sdist bdist_wheel - twine check dist/* - - name: Test packaged wheel on *nix - if: matrix.os != 'windows-latest' - run: | - pip install dist/*.whl - rm -rf box - python -m pytest - - name: Test packaged wheel on Windows - if: matrix.os == 'windows-latest' - run: | - $wheel = (Get-ChildItem dist\*.whl | Sort lastWriteTime | Select-Object -last 1).Name - pip install dist\${wheel} - Remove-item box -recurse -force - python -m pytest + python -m pip install cibuildwheel --upgrade + python -m cibuildwheel --output-dir wheels + - name: Upload wheel artifact uses: actions/upload-artifact@v2 with: name: python_box - path: dist/*.whl + path: wheels/*.whl - package-manylinux-checks: + lint-and-mypy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.10 - uses: actions/setup-python@v4 - with: - python-version: "3.10" + - uses: actions/checkout@v3 - - uses: actions/cache@v2 - with: - path: ~/.cache/pip - key: package-manylinux-check-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install -r requirements-test.txt - pip install coveralls flake8 flake8-print mypy setuptools wheel twine Cython + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: "3.11" - - uses: RalfG/python-wheels-manylinux-build@v0.4.2-manylinux2014_x86_64 - with: - python-versions: 'cp37-cp37m cp38-cp38 cp39-cp39 cp310-cp310 cp311-cp311' - build-requirements: 'cython' + - uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: lint-and-mypy-${{ hashFiles('pyproject.toml') }} - - name: Test packaged wheel on linux - run: | - pip install dist/*cp310-manylinux*.whl - rm -rf box - python -m pytest + - name: Install Dependencies + run: | + python -m pip install poetry --upgrade + poetry config virtualenvs.create false --local - - name: Upload wheel artifact - uses: actions/upload-artifact@v2 - with: - name: python_box - path: dist/*-manylinux*.whl + - name: Poetry Install + run: poetry install --with dev + continue-on-error: true + + - name: Poetry Install 2 + run: poetry install --with dev + + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors, undefined names or print statements + python -m flake8 box --count --select=E9,F63,F7,F82,T001,T002,T003,T004 --show-source --statistics + # exit-zero treats all errors as warnings. + python -m flake8 . --count --exit-zero --max-complexity=20 --max-line-length=120 --statistics --extend-ignore E203 - test: + - name: Run mypy + run: python -m mypy box + + - name: Build Wheel and Check Distribution Log Description + run: | + poetry build + python -m twine check dist/* + + test-non-cython: strategy: matrix: python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] @@ -108,23 +91,36 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + - uses: actions/cache@v2 with: path: ~/.cache/pip - key: test-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} - - name: Install dependencies + key: test-${{ hashFiles('pyproject.toml') }} + + - name: Install Dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt - pip install -r requirements-test.txt - pip install setuptools wheel Cython - python setup.py build_ext --inplace + pip install poetry --upgrade + poetry config virtualenvs.create false --local + + - name: Poetry Install + run: poetry install --with dev --with test + continue-on-error: true + + - name: Poetry Install 2 + run: poetry install --with dev --with test + + - name: Poetry Build + run: poetry build + continue-on-error: true + - name: Test with pytest env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - pytest --cov=box test/ + python -m pytest --cov=box test/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6270614..a9ab210 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v4.4.0 hooks: # Identify invalid files - id: check-ast @@ -29,7 +29,7 @@ repos: exclude: ^test/data/.+ - repo: https://github.com/ambv/black - rev: 22.10.0 + rev: 22.12.0 hooks: - id: black args: [--config=.black.toml] @@ -38,7 +38,7 @@ repos: hooks: - id: cythonize-check name: Cythonize - entry: python setup.py build_ext --inplace + entry: python -m poetry build language: system types: [python] pass_filenames: false @@ -51,7 +51,7 @@ repos: always_run: true - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v0.982' + rev: 'v0.991' hooks: - id: mypy types: [python] diff --git a/AUTHORS.rst b/AUTHORS.rst index 2efd090..1fb7e2d 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -28,6 +28,8 @@ Code contributions: - Dominic (Yobmod) - Ivan Pepelnjak (ipspace) - Michał Górny (mgorny) +- Serge Lu (Serge45) + Suggestions and bug reporting: @@ -79,3 +81,9 @@ Suggestions and bug reporting: - Marcos Dione (mdione-cloudian) - Varun Madiath (vamega) - Rexbard +- Martin Schorfmann (schorfma) +- aviveh21 +- Nishikant Parmar (nishikantparmariam) +- Peter B (barmettl) +- Ash A. (dragonpaw) +- Коптев Роман Викторович (romikforest) diff --git a/CHANGES.rst b/CHANGES.rst index c023470..681554b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,23 @@ Changelog ========= +Version 7.0.0 +------------- + +* Adding #169 default functions with the box_instance and key parameter (thanks to Коптев Роман Викторович) +* Adding #170 Be able to initialize with a flattened dict - by using DDBox (thanks to Ash A.) +* Adding #192 box_dots treats all keys with periods in them as separate keys (thanks to Rexbard) +* Adding #211 support for properties and setters in subclasses (thanks to Serge Lu and David Aronchick) +* Adding #226 namespace to track changes to the box (thanks to Jacob Hayes) +* Adding #236 iPython detection to prevent adding attribute lookup words (thanks to Nishikant Parmar) +* Adding #238 allow ``|`` and ``+`` for frozen boxes (thanks to Peter B) +* Adding new DDBox class (Default Dots Box) that is a subclass of SBox +* Adding #242 more Cython builds using cibuildwheel (thanks to Jacob Hayes) +* Fixing #235 how ``|`` and ``+`` updates were performed for right operations (thanks to aviveh21) +* Fixing #234 typos (thanks to Martin Schorfmann) +* Fixing no implicit optionals with type hinting +* Removing Cython builds for mac until we can build universal2 wheels for arm M1 macs + Version 6.1.0 ------------- diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 67701c9..8792eec 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -23,7 +23,7 @@ Pull Requests - Follow PEP8 -- Select to merge into `development` branch, NOT `master` +- Select to merge into `develop` branch, NOT `master` - New features should have - Reasoning for addition in pull request diff --git a/LICENSE b/LICENSE index 2d55592..1cd0118 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017-2022 Chris Griffith +Copyright (c) 2017-2023 Chris Griffith Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.rst b/README.rst index 6cb139e..456dc40 100644 --- a/README.rst +++ b/README.rst @@ -33,7 +33,7 @@ requirements.txt .. code:: text - python-box[all]~=6.0 + python-box[all]~=7.0 As Box adheres to semantic versioning (aka API changes will only occur on between major version), it is best to use `Compatible release `_ matching using the `~=` clause. @@ -43,29 +43,30 @@ Install from command line .. code:: bash - pip install python-box[all]~=6.0 --upgrade + python -m pip install --upgrade pip + pip install python-box[all]~=7.0 --upgrade Install with selected dependencies ---------------------------------- -Box is no longer forcing install of external dependencies such as yaml and toml. Instead you can specify which you want, +Box does not install external dependencies such as yaml and toml writers. Instead you can specify which you want, for example, `[all]` is shorthand for: .. code:: bash - pip install python-box[ruamel.yaml,toml,msgpack]~=6.0 --upgrade + pip install python-box[ruamel.yaml,tomli_w,msgpack]~=7.0 --upgrade But you can also sub out `ruamel.yaml` for `PyYAML`. Check out `more details `_ on installation details. -Box 6 is tested on python 3.7+, if you are upgrading from previous versions, please look through +Box 7 is tested on python 3.7+, if you are upgrading from previous versions, please look through `any breaking changes and new features `_. Optimized Version ----------------- -Box 6 is introducing Cython optimizations for major platforms by default. +Box has introduced Cython optimizations for major platforms by default. Loading large data sets can be up to 10x faster! If you are **not** on a x86_64 supported system you will need to do some extra work to install the optimized version. @@ -80,7 +81,7 @@ You will then need `Cython` and `wheel` installed and then install (or re-instal .. code:: bash pip install Cython wheel - pip install python-box[all]~=6.0 --upgrade --force + pip install python-box[all]~=7.0 --upgrade --force If you have any issues please open a github issue with the error you are experiencing! @@ -138,7 +139,7 @@ Also special shout-out to PythonBytes_, who featured Box on their podcast. License ======= -MIT License, Copyright (c) 2017-2022 Chris Griffith. See LICENSE_ file. +MIT License, Copyright (c) 2017-2023 Chris Griffith. See LICENSE_ file. .. |BoxImage| image:: https://raw.githubusercontent.com/cdgriffith/Box/master/box_logo.png diff --git a/box/__init__.py b/box/__init__.py index 8a44dd1..a1795a1 100644 --- a/box/__init__.py +++ b/box/__init__.py @@ -2,14 +2,14 @@ # -*- coding: utf-8 -*- __author__ = "Chris Griffith" -__version__ = "6.1.0" +__version__ = "7.0.0" from box.box import Box from box.box_list import BoxList from box.config_box import ConfigBox from box.exceptions import BoxError, BoxKeyError from box.from_file import box_from_file, box_from_string -from box.shorthand_box import SBox +from box.shorthand_box import SBox, DDBox import box.converters __all__ = [ @@ -20,4 +20,5 @@ "BoxKeyError", "box_from_file", "SBox", + "DDBox", ] diff --git a/box/box.py b/box/box.py index 06b8a60..322b313 100644 --- a/box/box.py +++ b/box/box.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- # -# Copyright (c) 2017-2022 - Chris Griffith - MIT License +# Copyright (c) 2017-2023 - Chris Griffith - MIT License """ Improved dictionary access through dot notation with additional tools. """ @@ -10,13 +10,21 @@ import warnings from keyword import iskeyword from os import PathLike -from typing import Any, Dict, Generator, List, Tuple, Union, Type +from typing import Any, Dict, Generator, List, Optional, Tuple, Type, Union +from inspect import signature try: from typing import Callable, Iterable, Mapping except ImportError: from collections.abc import Callable, Iterable, Mapping +try: + from IPython import get_ipython +except ImportError: + ipython = False +else: + ipython = True if get_ipython() else False + import box from box.converters import ( BOX_PARAMETERS, @@ -44,6 +52,8 @@ # a sentinel object for indicating no default, in order to allow users # to pass `None` as a valid default value NO_DEFAULT = object() +# a sentinel object for indicating when to skip adding a new namespace, allowing `None` keys +NO_NAMESPACE = object() def _exception_cause(e): @@ -123,6 +133,22 @@ def _get_box_config(): } +def _get_property_func(obj, key): + """ + Try to get property helper functions of given object and property name. + + :param obj: object to be checked for property + :param key: property name + :return: a tuple for helper functions(fget, fset, fdel). If no such property, a (None, None, None) returns + """ + obj_type = type(obj) + + if not hasattr(obj_type, key): + return None, None, None + attr = getattr(obj_type, key) + return attr.fget, attr.fset, attr.fdel + + class Box(dict): """ Improved dictionary access through dot notation with additional tools. @@ -142,6 +168,7 @@ class Box(dict): :param box_recast: cast certain keys to a specified type :param box_dots: access nested Boxes by period separated keys in string :param box_class: change what type of class sub-boxes will be created as + :param box_namespace: the namespace this (possibly nested) Box lives within """ _box_config: Dict[str, Any] @@ -171,9 +198,10 @@ def __new__( box_safe_prefix: str = "x", box_duplicates: str = "ignore", box_intact_types: Union[Tuple, List] = (), - box_recast: Dict = None, + box_recast: Optional[Dict] = None, box_dots: bool = False, - box_class: Union[Dict, Type["Box"]] = None, + box_class: Optional[Union[Dict, Type["Box"]]] = None, + box_namespace: Tuple[str, ...] = (), **kwargs: Any, ): """ @@ -198,6 +226,7 @@ def __new__( "box_recast": box_recast, "box_dots": box_dots, "box_class": box_class if box_class is not None else Box, + "box_namespace": box_namespace, } ) return obj @@ -216,9 +245,10 @@ def __init__( box_safe_prefix: str = "x", box_duplicates: str = "ignore", box_intact_types: Union[Tuple, List] = (), - box_recast: Dict = None, + box_recast: Optional[Dict] = None, box_dots: bool = False, - box_class: Union[Dict, Type["Box"]] = None, + box_class: Optional[Union[Dict, Type["Box"]]] = None, + box_namespace: Tuple[str, ...] = (), **kwargs: Any, ): super().__init__() @@ -239,6 +269,7 @@ def __init__( "box_recast": box_recast, "box_dots": box_dots, "box_class": box_class if box_class is not None else self.__class__, + "box_namespace": box_namespace, } ) if not self._box_config["conversion_box"] and self._box_config["box_duplicates"] != "ignore": @@ -272,14 +303,21 @@ def __add__(self, other: Mapping[Any, Any]): if not isinstance(other, dict): raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") new_box = self.copy() - new_box.merge_update(other) + new_box._box_config["frozen_box"] = False + new_box.merge_update(other) # type: ignore[attr-defined] + new_box._box_config["frozen_box"] = self._box_config["frozen_box"] return new_box def __radd__(self, other: Mapping[Any, Any]): if not isinstance(other, dict): raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") - new_box = self.copy() - new_box.merge_update(other) + + new_box = other.copy() + if not isinstance(other, Box): + new_box = self._box_config["box_class"](new_box) + new_box._box_config["frozen_box"] = False # type: ignore[attr-defined] + new_box.merge_update(self) # type: ignore[attr-defined] + new_box._box_config["frozen_box"] = self._box_config["frozen_box"] # type: ignore[attr-defined] return new_box def __iadd__(self, other: Mapping[Any, Any]): @@ -292,14 +330,20 @@ def __or__(self, other: Mapping[Any, Any]): if not isinstance(other, dict): raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") new_box = self.copy() - new_box.update(other) + new_box._box_config["frozen_box"] = False + new_box.update(other) # type: ignore[attr-defined] + new_box._box_config["frozen_box"] = self._box_config["frozen_box"] return new_box def __ror__(self, other: Mapping[Any, Any]): if not isinstance(other, dict): raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") - new_box = self.copy() - new_box.update(other) + new_box = other.copy() + if not isinstance(other, Box): + new_box = self._box_config["box_class"](new_box) + new_box._box_config["frozen_box"] = False # type: ignore[attr-defined] + new_box.update(self) # type: ignore[attr-defined] + new_box._box_config["frozen_box"] = self._box_config["frozen_box"] # type: ignore[attr-defined] return new_box def __ior__(self, other: Mapping[Any, Any]): # type: ignore[override] @@ -312,6 +356,7 @@ def __sub__(self, other: Mapping[Any, Any]): frozen = self._box_config["frozen_box"] config = self.__box_config() config["frozen_box"] = False + config.pop("box_namespace") # Detach namespace; it will be reassigned if we nest again output = self._box_config["box_class"](**config) if not isinstance(other, dict): raise BoxError("Box can only compare two boxes or a box and a dictionary.") @@ -414,10 +459,12 @@ def get(self, key, default=NO_DEFAULT): return self[key] def copy(self) -> "Box": - return Box(super().copy(), **self.__box_config()) + config = self.__box_config() + config.pop("box_namespace") # Detach namespace; it will be reassigned if we nest again + return Box(super().copy(), **config) def __copy__(self) -> "Box": - return Box(super().copy(), **self.__box_config()) + return self.copy() def __deepcopy__(self, memodict=None) -> "Box": frozen = self._box_config["frozen_box"] @@ -436,29 +483,63 @@ def __setstate__(self, state): self.__dict__.update(state) def __get_default(self, item, attr=False): + if ipython and item in ("getdoc", "shape"): + return None default_value = self._box_config["default_box_attr"] if default_value in (self._box_config["box_class"], dict): - value = self._box_config["box_class"](**self.__box_config()) + value = self._box_config["box_class"](**self.__box_config(extra_namespace=item)) elif isinstance(default_value, dict): - value = self._box_config["box_class"](**self.__box_config(), **default_value) + value = self._box_config["box_class"](**self.__box_config(extra_namespace=item), **default_value) elif isinstance(default_value, list): - value = box.BoxList(**self.__box_config()) + value = box.BoxList(**self.__box_config(extra_namespace=item)) elif isinstance(default_value, Callable): - value = default_value() + args = [] + kwargs = {} + p_sigs = [ + p.name + for p in signature(default_value).parameters.values() + if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) + ] + k_sigs = [p.name for p in signature(default_value).parameters.values() if p.kind is p.KEYWORD_ONLY] + for name in p_sigs: + if name not in ("key", "box_instance"): + raise BoxError("default_box_attr can only have the arguments 'key' and 'box_instance'") + if "key" in p_sigs: + args.append(item) + if "box_instance" in p_sigs: + args.insert(p_sigs.index("box_instance"), self) + if "key" in k_sigs: + kwargs["key"] = item + if "box_instance" in k_sigs: + kwargs["box_instance"] = self + value = default_value(*args, **kwargs) elif hasattr(default_value, "copy"): value = default_value.copy() else: value = default_value if self._box_config["default_box_create_on_get"]: if not attr or not (item.startswith("_") and item.endswith("_")): - super().__setitem__(item, value) + if self._box_config["box_dots"] and isinstance(item, str) and ("." in item or "[" in item): + first_item, children = _parse_box_dots(self, item, setting=True) + if first_item in self.keys(): + if hasattr(self[first_item], "__setitem__"): + self[first_item].__setitem__(children, value) + else: + super().__setitem__( + first_item, self._box_config["box_class"](**self.__box_config(extra_namespace=first_item)) + ) + self[first_item].__setitem__(children, value) + else: + super().__setitem__(item, value) return value - def __box_config(self) -> Dict: + def __box_config(self, extra_namespace: Any = NO_NAMESPACE) -> Dict: out = {} for k, v in self._box_config.copy().items(): if not k.startswith("__"): out[k] = v + if extra_namespace is not NO_NAMESPACE and self._box_config["box_namespace"] is not False: + out["box_namespace"] = (*out["box_namespace"], extra_namespace) return out def __recast(self, item, value): @@ -485,18 +566,20 @@ def __convert_and_store(self, item, value): # This is the magic sauce that makes sub dictionaries into new box objects if isinstance(value, dict): # We always re-create even if it was already a Box object to pass down configurations correctly - value = self._box_config["box_class"](value, **self.__box_config()) + value = self._box_config["box_class"](value, **self.__box_config(extra_namespace=item)) elif isinstance(value, list) and not isinstance(value, box.BoxList): if self._box_config["frozen_box"]: value = _recursive_tuples( - value, recreate_tuples=self._box_config["modify_tuples_box"], **self.__box_config() + value, + recreate_tuples=self._box_config["modify_tuples_box"], + **self.__box_config(extra_namespace=item), ) else: - value = box.BoxList(value, **self.__box_config()) + value = box.BoxList(value, **self.__box_config(extra_namespace=item)) elif isinstance(value, box.BoxList): - value.box_options.update(self.__box_config()) + value.box_options.update(self.__box_config(extra_namespace=item)) elif self._box_config["modify_tuples_box"] and isinstance(value, tuple): - value = _recursive_tuples(value, recreate_tuples=True, **self.__box_config()) + value = _recursive_tuples(value, recreate_tuples=True, **self.__box_config(extra_namespace=item)) super().__setitem__(item, value) def __getitem__(self, item, _ignore_default=False): @@ -561,6 +644,13 @@ def __setitem__(self, key, value): if first_item in self.keys(): if hasattr(self[first_item], "__setitem__"): return self[first_item].__setitem__(children, value) + elif self._box_config["default_box"]: + super().__setitem__( + first_item, self._box_config["box_class"](**self.__box_config(extra_namespace=first_item)) + ) + return self[first_item].__setitem__(children, value) + else: + raise BoxKeyError(f"'{self.__class__}' object has no attribute {first_item}") value = self.__recast(key, value) if key not in self.keys() and self._box_config["camel_killer_box"]: if self._box_config["camel_killer_box"] and isinstance(key, str): @@ -580,7 +670,12 @@ def __setattr__(self, key, value): safe_key = self._safe_attr(key) if safe_key in self._box_config["__safe_keys"]: key = self._box_config["__safe_keys"][safe_key] - self.__setitem__(key, value) + + # if user has customized property setter, fall back to default implementation + if _get_property_func(self, key)[1] is not None: + super().__setattr__(key, value) + else: + self.__setitem__(key, value) def __delitem__(self, key): if self._box_config["frozen_box"]: @@ -615,6 +710,13 @@ def __delattr__(self, item): raise BoxError('"_box_config" is protected') if item in self._protected_keys: raise BoxKeyError(f'Key name "{item}" is protected') + + property_fdel = _get_property_func(self, item)[2] + + # if user has customized property deleter, route to it + if property_fdel is not None: + property_fdel(self) + return try: self.__delitem__(item) except KeyError as err: @@ -719,12 +821,12 @@ def convert_and_set(k, v): if isinstance(v, dict) and not intact_type: # Box objects must be created in case they are already # in the `converted` box_config set - v = self._box_config["box_class"](v, **self.__box_config()) + v = self._box_config["box_class"](v, **self.__box_config(extra_namespace=k)) if k in self and isinstance(self[k], dict): self[k].merge_update(v) return if isinstance(v, list) and not intact_type: - v = box.BoxList(v, **self.__box_config()) + v = box.BoxList(v, **self.__box_config(extra_namespace=k)) if merge_type == "extend" and k in self and isinstance(self[k], list): self[k].extend(v) return @@ -758,9 +860,9 @@ def setdefault(self, item, default=None): return self[item] if isinstance(default, dict): - default = self._box_config["box_class"](default, **self.__box_config()) + default = self._box_config["box_class"](default, **self.__box_config(extra_namespace=item)) if isinstance(default, list): - default = box.BoxList(default, **self.__box_config()) + default = box.BoxList(default, **self.__box_config(extra_namespace=item)) self[item] = default return self[item] @@ -828,7 +930,11 @@ def _conversion_checks(self, item): raise BoxError(f"Duplicate conversion attributes exist: {dups}") def to_json( - self, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict", **json_kwargs + self, + filename: Optional[Union[str, PathLike]] = None, + encoding: str = "utf-8", + errors: str = "strict", + **json_kwargs, ): """ Transform the Box object into a JSON string. @@ -844,8 +950,8 @@ def to_json( @classmethod def from_json( cls, - json_string: str = None, - filename: Union[str, PathLike] = None, + json_string: Optional[str] = None, + filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, @@ -876,7 +982,7 @@ def from_json( def to_yaml( self, - filename: Union[str, PathLike] = None, + filename: Optional[Union[str, PathLike]] = None, default_flow_style: bool = False, encoding: str = "utf-8", errors: str = "strict", @@ -904,8 +1010,8 @@ def to_yaml( @classmethod def from_yaml( cls, - yaml_string: str = None, - filename: Union[str, PathLike] = None, + yaml_string: Optional[str] = None, + filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, @@ -936,7 +1042,7 @@ def from_yaml( def to_yaml( self, - filename: Union[str, PathLike] = None, + filename: Optional[Union[str, PathLike]] = None, default_flow_style: bool = False, encoding: str = "utf-8", errors: str = "strict", @@ -947,8 +1053,8 @@ def to_yaml( @classmethod def from_yaml( cls, - yaml_string: str = None, - filename: Union[str, PathLike] = None, + yaml_string: Optional[str] = None, + filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, @@ -957,7 +1063,9 @@ def from_yaml( if toml_write_library is not None: - def to_toml(self, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict"): + def to_toml( + self, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict" + ): """ Transform the Box object into a toml string. @@ -970,7 +1078,9 @@ def to_toml(self, filename: Union[str, PathLike] = None, encoding: str = "utf-8" else: - def to_toml(self, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict"): + def to_toml( + self, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict" + ): raise BoxError('toml is unavailable on this system, please install the "tomli-w" package') if toml_read_library is not None: @@ -978,8 +1088,8 @@ def to_toml(self, filename: Union[str, PathLike] = None, encoding: str = "utf-8" @classmethod def from_toml( cls, - toml_string: str = None, - filename: Union[str, PathLike] = None, + toml_string: Optional[str] = None, + filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, @@ -1007,8 +1117,8 @@ def from_toml( @classmethod def from_toml( cls, - toml_string: str = None, - filename: Union[str, PathLike] = None, + toml_string: Optional[str] = None, + filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, @@ -1017,7 +1127,7 @@ def from_toml( if msgpack_available: - def to_msgpack(self, filename: Union[str, PathLike] = None, **kwargs): + def to_msgpack(self, filename: Optional[Union[str, PathLike]] = None, **kwargs): """ Transform the Box object into a msgpack string. @@ -1030,8 +1140,8 @@ def to_msgpack(self, filename: Union[str, PathLike] = None, **kwargs): @classmethod def from_msgpack( cls, - msgpack_bytes: bytes = None, - filename: Union[str, PathLike] = None, + msgpack_bytes: Optional[bytes] = None, + filename: Optional[Union[str, PathLike]] = None, **kwargs, ) -> "Box": """ @@ -1054,14 +1164,14 @@ def from_msgpack( else: - def to_msgpack(self, filename: Union[str, PathLike] = None, **kwargs): + def to_msgpack(self, filename: Optional[Union[str, PathLike]] = None, **kwargs): raise BoxError('msgpack is unavailable on this system, please install the "msgpack" package') @classmethod def from_msgpack( cls, - msgpack_bytes: bytes = None, - filename: Union[str, PathLike] = None, + msgpack_bytes: Optional[bytes] = None, + filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, diff --git a/box/box_list.py b/box/box_list.py index 8034ad2..750f3f0 100644 --- a/box/box_list.py +++ b/box/box_list.py @@ -1,11 +1,11 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- # -# Copyright (c) 2017-2022 - Chris Griffith - MIT License +# Copyright (c) 2017-2023 - Chris Griffith - MIT License import copy import re from os import PathLike -from typing import Iterable, Type, Union +from typing import Optional, Iterable, Type, Union import box from box.converters import ( @@ -44,7 +44,7 @@ def __new__(cls, *args, **kwargs): obj.box_org_ref = 0 return obj - def __init__(self, iterable: Iterable = None, box_class: Type[box.Box] = box.Box, **box_options): + def __init__(self, iterable: Optional[Iterable] = None, box_class: Type[box.Box] = box.Box, **box_options): self.box_options = box_options self.box_options["box_class"] = box_class self.box_org_ref = id(iterable) if iterable else 0 @@ -172,7 +172,7 @@ def to_list(self): def to_json( self, - filename: Union[str, PathLike] = None, + filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", multiline: bool = False, @@ -198,8 +198,8 @@ def to_json( @classmethod def from_json( cls, - json_string: str = None, - filename: Union[str, PathLike] = None, + json_string: Optional[str] = None, + filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", multiline: bool = False, @@ -234,7 +234,7 @@ def from_json( def to_yaml( self, - filename: Union[str, PathLike] = None, + filename: Optional[Union[str, PathLike]] = None, default_flow_style: bool = False, encoding: str = "utf-8", errors: str = "strict", @@ -262,8 +262,8 @@ def to_yaml( @classmethod def from_yaml( cls, - yaml_string: str = None, - filename: Union[str, PathLike] = None, + yaml_string: Optional[str] = None, + filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, @@ -294,7 +294,7 @@ def from_yaml( def to_yaml( self, - filename: Union[str, PathLike] = None, + filename: Optional[Union[str, PathLike]] = None, default_flow_style: bool = False, encoding: str = "utf-8", errors: str = "strict", @@ -305,8 +305,8 @@ def to_yaml( @classmethod def from_yaml( cls, - yaml_string: str = None, - filename: Union[str, PathLike] = None, + yaml_string: Optional[str] = None, + filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, @@ -317,7 +317,7 @@ def from_yaml( def to_toml( self, - filename: Union[str, PathLike] = None, + filename: Optional[Union[str, PathLike]] = None, key_name: str = "toml", encoding: str = "utf-8", errors: str = "strict", @@ -338,7 +338,7 @@ def to_toml( def to_toml( self, - filename: Union[str, PathLike] = None, + filename: Optional[Union[str, PathLike]] = None, key_name: str = "toml", encoding: str = "utf-8", errors: str = "strict", @@ -350,8 +350,8 @@ def to_toml( @classmethod def from_toml( cls, - toml_string: str = None, - filename: Union[str, PathLike] = None, + toml_string: Optional[str] = None, + filename: Optional[Union[str, PathLike]] = None, key_name: str = "toml", encoding: str = "utf-8", errors: str = "strict", @@ -384,8 +384,8 @@ def from_toml( @classmethod def from_toml( cls, - toml_string: str = None, - filename: Union[str, PathLike] = None, + toml_string: Optional[str] = None, + filename: Optional[Union[str, PathLike]] = None, key_name: str = "toml", encoding: str = "utf-8", errors: str = "strict", @@ -395,7 +395,7 @@ def from_toml( if msgpack_available: - def to_msgpack(self, filename: Union[str, PathLike] = None, **kwargs): + def to_msgpack(self, filename: Optional[Union[str, PathLike]] = None, **kwargs): """ Transform the BoxList object into a toml string. @@ -405,7 +405,9 @@ def to_msgpack(self, filename: Union[str, PathLike] = None, **kwargs): return _to_msgpack(self.to_list(), filename=filename, **kwargs) @classmethod - def from_msgpack(cls, msgpack_bytes: bytes = None, filename: Union[str, PathLike] = None, **kwargs): + def from_msgpack( + cls, msgpack_bytes: Optional[bytes] = None, filename: Optional[Union[str, PathLike]] = None, **kwargs + ): """ Transforms a toml string or file into a BoxList object @@ -426,28 +428,28 @@ def from_msgpack(cls, msgpack_bytes: bytes = None, filename: Union[str, PathLike else: - def to_msgpack(self, filename: Union[str, PathLike] = None, **kwargs): + def to_msgpack(self, filename: Optional[Union[str, PathLike]] = None, **kwargs): raise BoxError('msgpack is unavailable on this system, please install the "msgpack" package') @classmethod def from_msgpack( cls, - msgpack_bytes: bytes = None, - filename: Union[str, PathLike] = None, + msgpack_bytes: Optional[bytes] = None, + filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, ): raise BoxError('msgpack is unavailable on this system, please install the "msgpack" package') - def to_csv(self, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict"): + def to_csv(self, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict"): return _to_csv(self, filename=filename, encoding=encoding, errors=errors) @classmethod def from_csv( cls, - csv_string: str = None, - filename: Union[str, PathLike] = None, + csv_string: Optional[str] = None, + filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", ): diff --git a/box/converters.py b/box/converters.py index 0114453..29da488 100644 --- a/box/converters.py +++ b/box/converters.py @@ -125,7 +125,7 @@ def _exists(filename: Union[str, PathLike], create: bool = False) -> Path: def _to_json( - obj, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict", **json_kwargs + obj, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **json_kwargs ): if filename: _exists(filename, create=True) @@ -136,8 +136,8 @@ def _to_json( def _from_json( - json_string: str = None, - filename: Union[str, PathLike] = None, + json_string: Optional[str] = None, + filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", multiline: bool = False, @@ -162,7 +162,7 @@ def _from_json( def _to_yaml( obj, - filename: Union[str, PathLike] = None, + filename: Optional[Union[str, PathLike]] = None, default_flow_style: bool = False, encoding: str = "utf-8", errors: str = "strict", @@ -202,8 +202,8 @@ def _to_yaml( def _from_yaml( - yaml_string: str = None, - filename: Union[str, PathLike] = None, + yaml_string: Optional[str] = None, + filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", ruamel_typ: str = "rt", @@ -243,7 +243,7 @@ def _from_yaml( return data -def _to_toml(obj, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict"): +def _to_toml(obj, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict"): if filename: _exists(filename, create=True) if toml_write_library.__name__ == "toml": # type: ignore @@ -266,8 +266,8 @@ def _to_toml(obj, filename: Union[str, PathLike] = None, encoding: str = "utf-8" def _from_toml( - toml_string: str = None, - filename: Union[str, PathLike] = None, + toml_string: Optional[str] = None, + filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", ): @@ -286,7 +286,7 @@ def _from_toml( return data -def _to_msgpack(obj, filename: Union[str, PathLike] = None, **kwargs): +def _to_msgpack(obj, filename: Optional[Union[str, PathLike]] = None, **kwargs): if filename: _exists(filename, create=True) with open(filename, "wb") as f: @@ -295,7 +295,7 @@ def _to_msgpack(obj, filename: Union[str, PathLike] = None, **kwargs): return msgpack.packb(obj, **kwargs) -def _from_msgpack(msgpack_bytes: bytes = None, filename: Union[str, PathLike] = None, **kwargs): +def _from_msgpack(msgpack_bytes: Optional[bytes] = None, filename: Optional[Union[str, PathLike]] = None, **kwargs): if filename: _exists(filename) with open(filename, "rb") as f: @@ -307,7 +307,9 @@ def _from_msgpack(msgpack_bytes: bytes = None, filename: Union[str, PathLike] = return data -def _to_csv(box_list, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict", **kwargs): +def _to_csv( + box_list, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **kwargs +): csv_column_names = list(box_list[0].keys()) for row in box_list: if list(row.keys()) != csv_column_names: @@ -328,8 +330,8 @@ def _to_csv(box_list, filename: Union[str, PathLike] = None, encoding: str = "ut def _from_csv( - csv_string: str = None, - filename: Union[str, PathLike] = None, + csv_string: Optional[str] = None, + filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, diff --git a/box/converters.pyi b/box/converters.pyi index f18d1cf..5f94f33 100644 --- a/box/converters.pyi +++ b/box/converters.pyi @@ -12,11 +12,11 @@ toml_decode_error: Optional[Callable] def _exists(filename: Union[str, PathLike], create: bool = False) -> Any: ... def _to_json( - obj, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict", **json_kwargs + obj, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **json_kwargs ) -> Any: ... def _from_json( - json_string: str = None, - filename: Union[str, PathLike] = None, + json_string: Optional[str] = None, + filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", multiline: bool = False, @@ -24,7 +24,7 @@ def _from_json( ) -> Any: ... def _to_yaml( obj, - filename: Union[str, PathLike] = None, + filename: Optional[Union[str, PathLike]] = None, default_flow_style: bool = False, encoding: str = "utf-8", errors: str = "strict", @@ -33,26 +33,33 @@ def _to_yaml( **yaml_kwargs, ) -> Any: ... def _from_yaml( - yaml_string: str = None, - filename: Union[str, PathLike] = None, + yaml_string: Optional[str] = None, + filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", ruamel_typ: str = "rt", ruamel_attrs: Optional[Dict] = None, **kwargs, ) -> Any: ... -def _to_toml(obj, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict") -> Any: ... +def _to_toml( + obj, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict" +) -> Any: ... def _from_toml( - toml_string: str = None, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict" + toml_string: Optional[str] = None, + filename: Optional[Union[str, PathLike]] = None, + encoding: str = "utf-8", + errors: str = "strict", +) -> Any: ... +def _to_msgpack(obj, filename: Optional[Union[str, PathLike]] = None, **kwargs) -> Any: ... +def _from_msgpack( + msgpack_bytes: Optional[bytes] = None, filename: Optional[Union[str, PathLike]] = None, **kwargs ) -> Any: ... -def _to_msgpack(obj, filename: Union[str, PathLike] = None, **kwargs) -> Any: ... -def _from_msgpack(msgpack_bytes: bytes = None, filename: Union[str, PathLike] = None, **kwargs) -> Any: ... def _to_csv( - box_list, filename: Union[str, PathLike] = None, encoding: str = "utf-8", errors: str = "strict", **kwargs + box_list, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **kwargs ) -> Any: ... def _from_csv( - csv_string: str = None, - filename: Union[str, PathLike] = None, + csv_string: Optional[str] = None, + filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, diff --git a/box/from_file.py b/box/from_file.py index 268f136..8f4ce68 100644 --- a/box/from_file.py +++ b/box/from_file.py @@ -3,7 +3,7 @@ from json import JSONDecodeError from os import PathLike from pathlib import Path -from typing import Callable, Dict, Union +from typing import Optional, Callable, Dict, Union import sys from box.box import Box @@ -88,7 +88,11 @@ def _to_msgpack(file, _, __, **kwargs): def box_from_file( - file: Union[str, PathLike], file_type: str = None, encoding: str = "utf-8", errors: str = "strict", **kwargs + file: Union[str, PathLike], + file_type: Optional[str] = None, + encoding: str = "utf-8", + errors: str = "strict", + **kwargs, ) -> Union[Box, BoxList]: """ Loads the provided file and tries to parse it into a Box or BoxList object as appropriate. diff --git a/box/shorthand_box.py b/box/shorthand_box.py index ab91b9c..5d24604 100644 --- a/box/shorthand_box.py +++ b/box/shorthand_box.py @@ -3,6 +3,8 @@ from box.box import Box +__all__ = ["SBox", "DDBox"] + class SBox(Box): """ @@ -41,10 +43,26 @@ def toml(self): return self.to_toml() def __repr__(self): - return "ShorthandBox({0})".format(str(self.to_dict())) + return f"SBox({self})" def copy(self): return SBox(super(SBox, self).copy()) def __copy__(self): return SBox(super(SBox, self).copy()) + + +class DDBox(SBox): + def __init__(self, *args, **kwargs): + kwargs["box_dots"] = True + kwargs["default_box"] = True + super().__init__(*args, **kwargs) + + def __new__(cls, *args, **kwargs): + obj = super().__new__(cls, *args, **kwargs) + obj._box_config["box_dots"] = True + obj._box_config["default_box"] = True + return obj + + def __repr__(self): + return f"DDBox({self})" diff --git a/box/shorthand_box.pyi b/box/shorthand_box.pyi index 78ffb6f..4b86f73 100644 --- a/box/shorthand_box.pyi +++ b/box/shorthand_box.pyi @@ -11,3 +11,5 @@ class SBox(Box): def toml(self): ... def copy(self): ... def __copy__(self): ... + +class DDBox(Box): ... diff --git a/build.py b/build.py new file mode 100644 index 0000000..1ca7ea0 --- /dev/null +++ b/build.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import os +from distutils.command.build_ext import build_ext + +from pathlib import Path + +root = os.path.abspath(os.path.dirname(__file__)) + +try: + from Cython.Build import cythonize +except ImportError: + # Got to provide this function. Otherwise, poetry will fail + def build(setup_kwargs): + pass + + +# Cython is installed. Compile +else: + # This function will be executed in setup.py: + def build(setup_kwargs): + # Build + setup_kwargs.update( + { + "ext_modules": cythonize( + [ + str(file.relative_to(root)) + for file in Path(root, "box").glob("*.py") + if file.name != "__init__.py" + ], + compiler_directives={"language_level": 3}, + ), + } + ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dd9cda5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,97 @@ +[tool.black] +line-length = 120 +target-version = ['py37', 'py38', 'py39', 'py310', 'py311'] +exclude = ''' +/( + \.eggs + | \.git + | \.idea + | \.pytest_cache + | _build + | build + | dist + | venv +)/ +''' + +[tool.poetry] +name = "python-box" +version = "7.0.0" +description = "Advanced Python dictionaries with dot notation access" +license = "MIT" +authors = ["Chris Griffith ", ] +include = ["box_logo.png", "AUTHORS.rst", "CHANGES.rst", "LICENSE"] +readme = "README.rst" +repository = "https://github.com/cdgriffith/Box" +build = "build.py" +packages = [ { include = "box" }] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: Implementation :: CPython", + "Development Status :: 5 - Production/Stable", + "Natural Language :: English", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Topic :: Utilities", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries :: Python Modules" +] + +[tool.poetry.dependencies] +python = ">=3.7" +msgpack = {version = ">=1.0.0", optional = true} +"ruamel.yaml" = {version = ">=0.17", optional = true} +tomli = {version = ">=1.2.3", python = '<3.11', optional = true} +tomli-w = {version = ">=1.0.0", optional = true} +PyYAML = {version = ">=6.0", optional = true} +toml = {version = ">=0.10.2", optional = true} + +[tool.poetry.group.dev] +optional = true + +[tool.poetry.group.dev.dependencies] +black = ">=22.10.0" +Cython = ">=0.29" +pre-commit = ">=2.20" +coveralls = ">=3.3.1" +flake8 = ">=5.0.4" +flake8-print = ">=5.0.0" +mypy = ">=0.991" +setuptools = ">=67.0.0" +wheel = ">=0.38.4" +twine = ">=4.0.2" + +[tool.poetry.group.test] +optional = true + +[tool.poetry.group.test.dependencies] +coverage = ">=5.0.4" +pytest = ">=7.1.3" +pytest-cov = ">=2.8.1" +msgpack = ">=1.0.0" +"ruamel.yaml" =">=0.17" +tomli = {version = ">=1.2.3", python = '<3.11'} +tomli-w = ">=1.0.0" +types-PyYAML = ">=6.0.3" +wheel = ">=0.34.2" + + +[tool.poetry.extras] +all = ["ruamel.yaml", "tomli", "tomli-w", "msgpack"] +yaml = ["ruamel.yaml"] +"ruamel.yaml" = ["ruamel.yaml"] +PyYAML = ["PyYAML"] +tomli = ["tomli", "tomli-w"] +toml = ["toml"] +msgpack= ["msgpack"] + +[build-system] +requires = ["poetry>=0.12", "Cython", "setuptools", "wheel"] +build-backend = "poetry.masonry.api" diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index eb71a71..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Files needed for pre-commit hooks -black>=22.10.0 -Cython>=0.29 -mypy>=0.982 -pre-commit>=2.20 diff --git a/requirements-test.txt b/requirements-test.txt deleted file mode 100644 index 1296ffa..0000000 --- a/requirements-test.txt +++ /dev/null @@ -1,9 +0,0 @@ -coverage>=5.0.4 -msgpack>=1.0 -pytest>=7.1.3 -pytest-cov>=2.8.1 -ruamel.yaml>=0.17 -tomli>=1.2.3; python_version < '3.11' -tomli-w>=1.0.0 -types-PyYAML>=6.0.3 -wheel>=0.34.2 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 57e2f36..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -msgpack>=1.0.0 -ruamel.yaml>=0.17 -tomli>=1.2.3; python_version < '3.11' -tomli-w diff --git a/setup.py b/setup.py deleted file mode 100644 index 7200751..0000000 --- a/setup.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -# Must import multiprocessing as a fix for issues with testing, experienced on win10 -import multiprocessing # noqa: F401 -import os -import re -from pathlib import Path -import sys - -from setuptools import setup - -root = os.path.abspath(os.path.dirname(__file__)) - -try: - from Cython.Build import cythonize -except ImportError: - extra = None -else: - extra = cythonize( - [str(file.relative_to(root)) for file in Path(root, "box").glob("*.py") if file.name != "__init__.py"], - compiler_directives={"language_level": 3}, - ) - -with open(os.path.join(root, "box", "__init__.py"), "r") as init_file: - init_content = init_file.read() - -attrs = dict(re.findall(r"__([a-z]+)__ *= *['\"](.+)['\"]", init_content)) - -with open("README.rst", "r") as readme_file: - long_description = readme_file.read() - -setup( - name="python-box", - version=attrs["version"], - url="https://github.com/cdgriffith/Box", - license="MIT", - author=attrs["author"], - install_requires=[], - author_email="chris@cdgriffith.com", - description="Advanced Python dictionaries with dot notation access", - long_description=long_description, - long_description_content_type="text/x-rst", - py_modules=["box"], - packages=["box"], - ext_modules=extra, - python_requires=">=3.7", - include_package_data=True, - platforms="any", - classifiers=[ - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: Implementation :: CPython", - "Development Status :: 5 - Production/Stable", - "Natural Language :: English", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Topic :: Utilities", - "Topic :: Software Development", - "Topic :: Software Development :: Libraries :: Python Modules", - ], - extras_require={ - "all": ["ruamel.yaml>=0.17", "toml", "msgpack"], - "yaml": ["ruamel.yaml>=0.17"], - "ruamel.yaml": ["ruamel.yaml>=0.17"], - "PyYAML": ["PyYAML"], - "tomli": ["tomli; python_version < '3.11'", "tomli-w"], - "toml": ["toml"], - "msgpack": ["msgpack"], - }, -) - -if not extra: - print("WARNING: Cython not installed, could not optimize box.", file=sys.stderr) diff --git a/test/test_box.py b/test/test_box.py index 814ff54..c7b51b7 100644 --- a/test/test_box.py +++ b/test/test_box.py @@ -26,7 +26,7 @@ import pytest from ruamel.yaml import YAML -from box import Box, BoxError, BoxKeyError, BoxList, ConfigBox, SBox +from box import Box, BoxError, BoxKeyError, BoxList, ConfigBox, SBox, DDBox from box.box import _get_dot_paths, _camel_killer, _recursive_tuples # type: ignore from box.converters import BOX_PARAMETERS @@ -560,7 +560,9 @@ def test_functional_data(self): data.widget._bad_value base_config = data._Box__box_config() + assert base_config.pop("box_namespace") == () widget_config = data.widget._Box__box_config() + assert widget_config.pop("box_namespace") == ("widget",) assert base_config == widget_config, "{} != {}".format(base_config, widget_config) @@ -1005,8 +1007,9 @@ def test_radd_boxes(self): d | a b = dict(c=1, d={"sub": 1}, e=1) c = Box(d={"val": 2}, e=4) - assert c.__radd__(b) == Box(c=1, d={"sub": 1, "val": 2}, e=1) + assert (b + c) == Box(c=1, d={"sub": 1, "val": 2}, e=4) assert c + b == Box(c=1, d={"sub": 1, "val": 2}, e=1) + assert isinstance(b | c, Box) with pytest.raises(BoxError): BoxList() + Box() @@ -1029,8 +1032,9 @@ def test_ior_boxes(self): def test_ror_boxes(self): b = dict(c=1, d={"sub": 1}, e=1) c = Box(d={"val": 2}, e=4) - assert c.__ror__(b) == Box(c=1, d={"sub": 1}, e=1) + assert c.__ror__(b) == Box(c=1, d={"val": 2}, e=4) assert c | b == Box(c=1, d={"sub": 1}, e=1) + assert isinstance(b | c, Box) with pytest.raises(BoxError): BoxList() | Box() @@ -1059,6 +1063,7 @@ def test_box_dots(self): b = Box( {"my_key": {"does stuff": {"to get to": "where I want"}}, "key.with.list": [[[{"test": "value"}]]]}, box_dots=True, + default_box=True, ) for key in b.keys(dotted=True): b[key] @@ -1233,16 +1238,18 @@ def test_default_box_restricted_calls(self): assert len(list(a.keys())) == 2 def test_default_dots(self): + bx1 = Box(default_box=True, box_dots=True) + bx1["a.a.a"] + assert bx1 == {"a": {"a": {"a": {}}}} + a = Box(default_box=True, box_dots=True) - a["a.a.a"] - assert a == {"a.a.a": {}} - a["a.a.a."] - a["a.a.a.."] - assert a == {"a.a.a": {"": {"": {}}}} + a["a."] + a["a.."] + assert a == {"a": {"": {"": {}}}} a["b.b"] = 3 - assert a == {"a.a.a": {"": {"": {}}}, "b.b": 3} + assert a == {"a": {"": {"": {}}}, "b": {"b": 3}} a.b.b = 4 - assert a == {"a.a.a": {"": {"": {}}}, "b.b": 3, "b": {"b": 4}} + assert a == {"a": {"": {"": {}}}, "b": {"b": 4}} assert a["non.existent.key"] == {} def test_merge_list_options(self): @@ -1355,3 +1362,83 @@ def test_box_default_not_create_on_get(self): assert "c" not in box2.a.b assert box2 == Box() + + def test_box_property_support(self): + class BoxWithProperty(Box): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + @property + def field(self): + return self._field + + @field.setter + def field(self, value): + self._field = value + + @field.deleter + def field(self): + """ + This is required to make `del box.field` work properly otherwise a `BoxKeyError` would be thrown. + """ + del self._field + + box = BoxWithProperty() + box.field = 5 + + assert "field" not in box + assert "_field" in box + assert box.field == 5 + assert box._field == 5 + del box.field + assert not "_field" in box + + def test_box_namespace(self): + bx = Box(default_box=True) + assert bx._box_config["box_namespace"] == () + bx.a.b.c = 5 + assert bx.a._box_config["box_namespace"] == ("a",) + assert bx.a.b._box_config["box_namespace"] == ("a", "b") + bx.x = {"y": {"z": 5}} + assert bx.x._box_config["box_namespace"] == ("x",) + assert bx.x.y._box_config["box_namespace"] == ("x", "y") + bx[None][1][2] = 3 + assert bx[None][1]._box_config["box_namespace"] == (None, 1) + + for modified_box in [ + bx.a + bx.x, + bx.a - bx.x, + bx.a | bx.x, + ]: + assert modified_box._box_config["box_namespace"] == () + assert modified_box.b._box_config["box_namespace"] == ("b",) + assert modified_box.y._box_config["box_namespace"] == ("y",) + + bx.modified = {} + assert bx.modified._box_config["box_namespace"] == ("modified",) + bx.modified += bx.a + assert bx.modified.b._box_config["box_namespace"] == ("modified", "b") + bx.modified |= bx.x + assert bx.modified.y._box_config["box_namespace"] == ("modified", "y") + bx.modified -= bx.a + assert bx.modified._box_config["box_namespace"] == ("modified",) + + bx2 = Box(box_namespace=False) + assert bx2._box_config["box_namespace"] is False + bx2["x"] = {"y": {"z": 5}} + assert bx2._box_config["box_namespace"] is False + assert bx2["x"]._box_config["box_namespace"] is False + + def test_union_frozen_box(self): + my_box = Box(a=5, frozen_box=True) + + assert my_box | {"a": 1} == {"a": 1} + assert {"a": 1} | my_box == {"a": 5} + + def test_default_box_callable(self): + def func(box_instance, key): + return DDBox(bi=str(box_instance), key=key) + + my_box = DDBox(default_box_attr=func) + + assert my_box.a == {"bi": "{}", "key": "a"} diff --git a/test/test_sbox.py b/test/test_sbox.py index 8b7ee02..ea0d06a 100644 --- a/test/test_sbox.py +++ b/test/test_sbox.py @@ -22,7 +22,7 @@ def test_property_box(self): yaml = YAML() test_item = yaml.load(pbox.yaml) assert test_item["inner"]["camel_case"] == "Item" - assert repr(pbox["inner"]).startswith("ShorthandBox(") + assert repr(pbox["inner"]).startswith("SBox(") assert not isinstance(pbox.dict, Box) assert pbox.dict["inner"]["camel_case"] == "Item" assert pbox.toml.startswith('key1 = "value1"') From 9a4b108fed5a18846c8484207467058e5135b4f1 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Tue, 21 Feb 2023 21:50:44 -0600 Subject: [PATCH 09/17] Version 7.0.1 (#248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Switching off of poetry due to multiple build issues --------- Co-authored-by: Michał Górny --- .black.toml | 2 +- .github/workflows/pythonpublish.yml | 81 ++++++++------- .github/workflows/tests.yml | 153 ++++++++++++++-------------- .gitignore | 4 + .pre-commit-config.yaml | 6 +- CHANGES.rst | 5 + box/__init__.py | 2 +- build.py | 34 ------- pyproject.toml | 97 ------------------ requirements-dev.txt | 5 + requirements-test.txt | 9 ++ requirements.txt | 4 + setup.py | 82 +++++++++++++++ 13 files changed, 239 insertions(+), 245 deletions(-) delete mode 100644 build.py delete mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 requirements-test.txt create mode 100644 requirements.txt create mode 100644 setup.py diff --git a/.black.toml b/.black.toml index 9414fb7..bedc251 100644 --- a/.black.toml +++ b/.black.toml @@ -1,6 +1,6 @@ [tool.black] line-length = 120 -target-version = ['py36', 'py37', 'py38'] +target-version = ['py37', 'py38', 'py39', 'py310', 'py311'] exclude = ''' /( \.eggs diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index 9f0cd6e..f7d8c27 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -23,15 +23,8 @@ jobs: - name: Install Dependencies run: | python -m pip install --upgrade pip - pip install poetry --upgrade - poetry config virtualenvs.create false --local + pip install setuptools wheel twine --upgrade - - name: Poetry Install - run: poetry install --with dev - continue-on-error: true - - - name: Poetry Install 2 - run: poetry install --with dev - name: Build and Publish env: @@ -39,38 +32,56 @@ jobs: TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | - poetry build + python setup.py sdist bdist_wheel twine upload dist/* deploy-cython: strategy: matrix: - os: [ ubuntu-latest, macos-12, windows-latest ] + os: [macos-11, windows-latest] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.11 - uses: actions/setup-python@v4 - with: - python-version: "3.11" - - - name: Install Dependencies - run: | - python -m pip install --upgrade pip - python -m pip install cibuildwheel twine --upgrade - - - name: Build Wheels - env: - CIBW_SKIP: "cp36-* pp* *i686" - CIBW_BUILD_VERBOSITY: 1 - CIBW_TEST_REQUIRES: "pytest msgpack ruamel.yaml tomli tomli-w" - CIBW_TEST_COMMAND: "pytest {package}/test" - run: python -m cibuildwheel --output-dir wheels - - - name: Publish Wheels - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - twine upload wheels/* + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine Cython --upgrade + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py bdist_wheel + twine upload dist/* + + deploy-cython-manylinux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - uses: RalfG/python-wheels-manylinux-build@v0.7.1-manylinux2010_x86_64 + with: + python-versions: 'cp37-cp37m cp38-cp38 cp39-cp39 cp310-cp310' + build-requirements: 'cython' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install twine --upgrade + + - name: Publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + twine upload dist/*-manylinux*.whl diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d40f647..35eff83 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,117 +10,122 @@ on: branches: [ master, development, develop, test, tests ] jobs: - build-checks: + package-checks: strategy: matrix: - os: [ubuntu-latest, macos-12, windows-latest] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "pypy-3.8"] + os: [ubuntu-latest, macos-11, windows-latest] runs-on: ${{ matrix.os }} - steps: - uses: actions/checkout@v3 - - - name: Set up Python 3.11 + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: - python-version: "3.11" - - - name: Build Wheels - env: - CIBW_SKIP: "cp36-* pp* *i686" - CIBW_BUILD_VERBOSITY: 1 - CIBW_TEST_REQUIRES: "pytest msgpack ruamel.yaml tomli tomli-w" - CIBW_TEST_COMMAND: "pytest {package}/test" + python-version: ${{ matrix.python-version }} + - uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: package-check-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} + - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install cibuildwheel --upgrade - python -m cibuildwheel --output-dir wheels - + pip install -r requirements.txt + pip install -r requirements-test.txt + pip install coveralls flake8 flake8-print mypy setuptools wheel twine Cython + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors, undefined names or print statements + flake8 box --count --select=E9,F63,F7,F82,T001,T002,T003,T004 --show-source --statistics + # exit-zero treats all errors as warnings. + flake8 . --count --exit-zero --max-complexity=20 --max-line-length=120 --statistics --extend-ignore E203 + - name: Run mypy + run: mypy box + - name: Build Wheel and check distribution log description + run: | + python setup.py sdist bdist_wheel + twine check dist/* + - name: Test packaged wheel on *nix + if: matrix.os != 'windows-latest' + run: | + pip install dist/*.whl + rm -rf box + python -m pytest + - name: Test packaged wheel on Windows + if: matrix.os == 'windows-latest' + run: | + $wheel = (Get-ChildItem dist\*.whl | Sort lastWriteTime | Select-Object -last 1).Name + pip install dist\${wheel} + Remove-item box -recurse -force + python -m pytest - name: Upload wheel artifact uses: actions/upload-artifact@v2 with: name: python_box - path: wheels/*.whl + path: dist/*.whl - lint-and-mypy: + package-manylinux-checks: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - - name: Set up Python 3.11 - uses: actions/setup-python@v4 - with: - python-version: "3.11" - - - uses: actions/cache@v2 - with: - path: ~/.cache/pip - key: lint-and-mypy-${{ hashFiles('pyproject.toml') }} - - - name: Install Dependencies - run: | - python -m pip install poetry --upgrade - poetry config virtualenvs.create false --local + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v4 + with: + python-version: "3.10" - - name: Poetry Install - run: poetry install --with dev - continue-on-error: true + - uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: package-manylinux-check-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} - - name: Poetry Install 2 - run: poetry install --with dev + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-test.txt + pip install coveralls flake8 flake8-print mypy setuptools wheel twine Cython - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors, undefined names or print statements - python -m flake8 box --count --select=E9,F63,F7,F82,T001,T002,T003,T004 --show-source --statistics - # exit-zero treats all errors as warnings. - python -m flake8 . --count --exit-zero --max-complexity=20 --max-line-length=120 --statistics --extend-ignore E203 + - uses: RalfG/python-wheels-manylinux-build@v0.7.1-manylinux2010_x86_64 + with: + python-versions: 'cp37-cp37m cp38-cp38 cp39-cp39 cp310-cp310' + build-requirements: 'cython' - - name: Run mypy - run: python -m mypy box + - name: Test packaged wheel on linux + run: | + pip install dist/*cp310-manylinux*.whl + rm -rf box + python -m pytest - - name: Build Wheel and Check Distribution Log Description - run: | - poetry build - python -m twine check dist/* + - name: Upload wheel artifact + uses: actions/upload-artifact@v2 + with: + name: python_box + path: dist/*-manylinux*.whl - test-non-cython: + test: strategy: matrix: python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, macos-11, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - - uses: actions/cache@v2 + - uses: actions/cache@v3 with: path: ~/.cache/pip - key: test-${{ hashFiles('pyproject.toml') }} - - - name: Install Dependencies + key: test-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} + - name: Install dependencies run: | python -m pip install --upgrade pip - pip install poetry --upgrade - poetry config virtualenvs.create false --local - - - name: Poetry Install - run: poetry install --with dev --with test - continue-on-error: true - - - name: Poetry Install 2 - run: poetry install --with dev --with test - - - name: Poetry Build - run: poetry build - continue-on-error: true - + pip install -r requirements.txt + pip install -r requirements-test.txt + pip install setuptools wheel Cython + python setup.py build_ext --inplace - name: Test with pytest env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - python -m pytest --cov=box test/ + pytest --cov=box test/ diff --git a/.gitignore b/.gitignore index e75de53..a9543e8 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,7 @@ coverage/ # don't upload cython files box/*.c .pytest_cache/ + +box/*.rst +box/LICENSE +box/*png diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a9ab210..2fc0ba4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,7 +29,7 @@ repos: exclude: ^test/data/.+ - repo: https://github.com/ambv/black - rev: 22.12.0 + rev: 23.1.0 hooks: - id: black args: [--config=.black.toml] @@ -38,7 +38,7 @@ repos: hooks: - id: cythonize-check name: Cythonize - entry: python -m poetry build + entry: pip install -e . language: system types: [python] pass_filenames: false @@ -51,7 +51,7 @@ repos: always_run: true - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v0.991' + rev: 'v1.0.1' hooks: - id: mypy types: [python] diff --git a/CHANGES.rst b/CHANGES.rst index 681554b..eb7921b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,11 @@ Changelog ========= +Version 7.0.1 +------------- + +* Switching off of poetry due to multiple build issues + Version 7.0.0 ------------- diff --git a/box/__init__.py b/box/__init__.py index a1795a1..b2225bb 100644 --- a/box/__init__.py +++ b/box/__init__.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- __author__ = "Chris Griffith" -__version__ = "7.0.0" +__version__ = "7.0.1" from box.box import Box from box.box_list import BoxList diff --git a/build.py b/build.py deleted file mode 100644 index 1ca7ea0..0000000 --- a/build.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -import os -from distutils.command.build_ext import build_ext - -from pathlib import Path - -root = os.path.abspath(os.path.dirname(__file__)) - -try: - from Cython.Build import cythonize -except ImportError: - # Got to provide this function. Otherwise, poetry will fail - def build(setup_kwargs): - pass - - -# Cython is installed. Compile -else: - # This function will be executed in setup.py: - def build(setup_kwargs): - # Build - setup_kwargs.update( - { - "ext_modules": cythonize( - [ - str(file.relative_to(root)) - for file in Path(root, "box").glob("*.py") - if file.name != "__init__.py" - ], - compiler_directives={"language_level": 3}, - ), - } - ) diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index dd9cda5..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,97 +0,0 @@ -[tool.black] -line-length = 120 -target-version = ['py37', 'py38', 'py39', 'py310', 'py311'] -exclude = ''' -/( - \.eggs - | \.git - | \.idea - | \.pytest_cache - | _build - | build - | dist - | venv -)/ -''' - -[tool.poetry] -name = "python-box" -version = "7.0.0" -description = "Advanced Python dictionaries with dot notation access" -license = "MIT" -authors = ["Chris Griffith ", ] -include = ["box_logo.png", "AUTHORS.rst", "CHANGES.rst", "LICENSE"] -readme = "README.rst" -repository = "https://github.com/cdgriffith/Box" -build = "build.py" -packages = [ { include = "box" }] -classifiers = [ - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: Implementation :: CPython", - "Development Status :: 5 - Production/Stable", - "Natural Language :: English", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Topic :: Utilities", - "Topic :: Software Development", - "Topic :: Software Development :: Libraries :: Python Modules" -] - -[tool.poetry.dependencies] -python = ">=3.7" -msgpack = {version = ">=1.0.0", optional = true} -"ruamel.yaml" = {version = ">=0.17", optional = true} -tomli = {version = ">=1.2.3", python = '<3.11', optional = true} -tomli-w = {version = ">=1.0.0", optional = true} -PyYAML = {version = ">=6.0", optional = true} -toml = {version = ">=0.10.2", optional = true} - -[tool.poetry.group.dev] -optional = true - -[tool.poetry.group.dev.dependencies] -black = ">=22.10.0" -Cython = ">=0.29" -pre-commit = ">=2.20" -coveralls = ">=3.3.1" -flake8 = ">=5.0.4" -flake8-print = ">=5.0.0" -mypy = ">=0.991" -setuptools = ">=67.0.0" -wheel = ">=0.38.4" -twine = ">=4.0.2" - -[tool.poetry.group.test] -optional = true - -[tool.poetry.group.test.dependencies] -coverage = ">=5.0.4" -pytest = ">=7.1.3" -pytest-cov = ">=2.8.1" -msgpack = ">=1.0.0" -"ruamel.yaml" =">=0.17" -tomli = {version = ">=1.2.3", python = '<3.11'} -tomli-w = ">=1.0.0" -types-PyYAML = ">=6.0.3" -wheel = ">=0.34.2" - - -[tool.poetry.extras] -all = ["ruamel.yaml", "tomli", "tomli-w", "msgpack"] -yaml = ["ruamel.yaml"] -"ruamel.yaml" = ["ruamel.yaml"] -PyYAML = ["PyYAML"] -tomli = ["tomli", "tomli-w"] -toml = ["toml"] -msgpack= ["msgpack"] - -[build-system] -requires = ["poetry>=0.12", "Cython", "setuptools", "wheel"] -build-backend = "poetry.masonry.api" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..cb6ad4c --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +# Files needed for pre-commit hooks +black>=23.1.0 +Cython>=0.29 +mypy>=1.0.1 +pre-commit>=2.21.0 diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..1296ffa --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,9 @@ +coverage>=5.0.4 +msgpack>=1.0 +pytest>=7.1.3 +pytest-cov>=2.8.1 +ruamel.yaml>=0.17 +tomli>=1.2.3; python_version < '3.11' +tomli-w>=1.0.0 +types-PyYAML>=6.0.3 +wheel>=0.34.2 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..57e2f36 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +msgpack>=1.0.0 +ruamel.yaml>=0.17 +tomli>=1.2.3; python_version < '3.11' +tomli-w diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..0f0519e --- /dev/null +++ b/setup.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Must import multiprocessing as a fix for issues with testing, experienced on win10 +import multiprocessing # noqa: F401 +import os +import re +from pathlib import Path +import sys +import shutil + +from setuptools import setup + +root = os.path.abspath(os.path.dirname(__file__)) + +try: + from Cython.Build import cythonize +except ImportError: + extra = None +else: + extra = cythonize( + [str(file.relative_to(root)) for file in Path(root, "box").glob("*.py") if file.name != "__init__.py"], + compiler_directives={"language_level": 3}, + ) + +with open(os.path.join(root, "box", "__init__.py"), "r") as init_file: + init_content = init_file.read() + +attrs = dict(re.findall(r"__([a-z]+)__ *= *['\"](.+)['\"]", init_content)) + +with open("README.rst", "r") as readme_file: + long_description = readme_file.read() + +setup( + name="python-box", + version=attrs["version"], + url="https://github.com/cdgriffith/Box", + license="MIT", + author=attrs["author"], + install_requires=[], + author_email="chris@cdgriffith.com", + description="Advanced Python dictionaries with dot notation access", + long_description=long_description, + long_description_content_type="text/x-rst", + py_modules=["box"], + packages=["box"], + ext_modules=extra, + python_requires=">=3.7", + include_package_data=True, + platforms="any", + classifiers=[ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: Implementation :: CPython", + "Development Status :: 5 - Production/Stable", + "Natural Language :: English", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Topic :: Utilities", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries :: Python Modules", + ], + extras_require={ + "all": ["ruamel.yaml>=0.17", "toml", "msgpack"], + "yaml": ["ruamel.yaml>=0.17"], + "ruamel.yaml": ["ruamel.yaml>=0.17"], + "PyYAML": ["PyYAML"], + "tomli": ["tomli; python_version < '3.11'", "tomli-w"], + "toml": ["toml"], + "msgpack": ["msgpack"], + }, +) + +if not extra: + print("WARNING: Cython not installed, could not optimize box.", file=sys.stderr) From e61a7d238f9def9faa81ed05f84ab3e76c95271a Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Sat, 26 Aug 2023 09:19:52 -0500 Subject: [PATCH 10/17] Version 7.1.0 (#256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adding #255 defer ipython import for large import speed improvements (thanks to Eric Prestat) * Adding testing for Python 3.12 * Fixing #253 merge_update box list merge types not populated to sub dictionaries (thanks to lei wang) * Fixing #257 Two test failures due to arguments having incorrect types (thanks to Michał Górny) * Fixing stub files to match latest code signatures * Removing #251 support for circular references in lists (thanks to d00m514y3r) --------- Co-authored-by: Eric Prestat --- .github/workflows/pythonpublish.yml | 26 +++---- .github/workflows/tests.yml | 45 +++++------- .pre-commit-config.yaml | 4 +- AUTHORS.rst | 4 ++ CHANGES.rst | 11 +++ box/__init__.py | 2 +- box/box.py | 35 ++++++---- box/box.pyi | 104 +++++++++++++++------------- box/box_list.py | 13 ++-- box/box_list.pyi | 39 +++++------ box/config_box.py | 3 +- box/config_box.pyi | 8 +-- box/converters.py | 14 +++- box/converters.pyi | 84 +++++++++++----------- box/from_file.pyi | 15 ++-- box/shorthand_box.py | 15 ++-- box/shorthand_box.pyi | 14 ++-- requirements-dev.txt | 2 +- setup.py | 5 +- test/test_box.py | 23 +++--- test/test_box_list.py | 15 ++++ 21 files changed, 268 insertions(+), 213 deletions(-) diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index f7d8c27..a9d01fc 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.10' + python-version: '3.11' - name: Install Dependencies run: | @@ -39,7 +39,7 @@ jobs: strategy: matrix: os: [macos-11, windows-latest] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11"] runs-on: ${{ matrix.os }} steps: @@ -51,7 +51,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install setuptools wheel twine Cython --upgrade + pip install setuptools wheel twine Cython==3.0.0 --upgrade - name: Build and publish env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} @@ -64,20 +64,21 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Set up Python 3.10 + - name: Set up Python 3.11 uses: actions/setup-python@v4 with: - python-version: "3.10" + python-version: "3.11" - - uses: RalfG/python-wheels-manylinux-build@v0.7.1-manylinux2010_x86_64 - with: - python-versions: 'cp37-cp37m cp38-cp38 cp39-cp39 cp310-cp310' - build-requirements: 'cython' - - - name: Install dependencies + - name: Build wheels run: | python -m pip install --upgrade pip - pip install twine --upgrade + pip install cibuildwheel + python -m cibuildwheel --output-dir dist + env: + CIBW_BUILD: cp38-manylinux_x86_64 cp39-manylinux_x86_64 cp310-manylinux_x86_64 cp311-manylinux_x86_64 cp311-macosx_x86_64 + CIBW_BEFORE_BUILD: pip install Cython==3.0.0 + CIBW_BEFORE_TEST: pip install -r requirements.txt -r requirements-test.txt setuptools wheel twine + CIBW_TEST_COMMAND: pytest {package}/test -vv - name: Publish env: @@ -85,3 +86,4 @@ jobs: TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | twine upload dist/*-manylinux*.whl + twine upload dist/*-macosx*.whl diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 35eff83..db506f9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,7 +13,7 @@ jobs: package-checks: strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "pypy-3.8"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12-dev", "pypy-3.8"] os: [ubuntu-latest, macos-11, windows-latest] runs-on: ${{ matrix.os }} steps: @@ -31,7 +31,7 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt pip install -r requirements-test.txt - pip install coveralls flake8 flake8-print mypy setuptools wheel twine Cython + pip install coveralls flake8 flake8-print mypy setuptools wheel twine Cython==3.0.0 - name: Lint with flake8 run: | # stop the build if there are Python syntax errors, undefined names or print statements @@ -49,16 +49,16 @@ jobs: run: | pip install dist/*.whl rm -rf box - python -m pytest + python -m pytest -vv - name: Test packaged wheel on Windows if: matrix.os == 'windows-latest' run: | $wheel = (Get-ChildItem dist\*.whl | Sort lastWriteTime | Select-Object -last 1).Name pip install dist\${wheel} Remove-item box -recurse -force - python -m pytest + python -m pytest -vv - name: Upload wheel artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: python_box path: dist/*.whl @@ -67,36 +67,29 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Set up Python 3.10 + - name: Set up Python 3.11 uses: actions/setup-python@v4 with: - python-version: "3.10" + python-version: "3.11" - uses: actions/cache@v3 with: path: ~/.cache/pip key: package-manylinux-check-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} - - name: Install dependencies + - name: Build wheels run: | python -m pip install --upgrade pip - pip install -r requirements.txt - pip install -r requirements-test.txt - pip install coveralls flake8 flake8-print mypy setuptools wheel twine Cython - - - uses: RalfG/python-wheels-manylinux-build@v0.7.1-manylinux2010_x86_64 - with: - python-versions: 'cp37-cp37m cp38-cp38 cp39-cp39 cp310-cp310' - build-requirements: 'cython' - - - name: Test packaged wheel on linux - run: | - pip install dist/*cp310-manylinux*.whl - rm -rf box - python -m pytest + pip install cibuildwheel + python -m cibuildwheel --output-dir dist + env: + CIBW_BUILD: cp38-manylinux_x86_64 cp39-manylinux_x86_64 cp310-manylinux_x86_64 cp311-manylinux_x86_64 cp311-macosx_x86_64 + CIBW_BEFORE_BUILD: pip install Cython==3.0.0 + CIBW_BEFORE_TEST: pip install -r requirements.txt -r requirements-test.txt setuptools wheel twine + CIBW_TEST_COMMAND: pytest {package}/test -vv - name: Upload wheel artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: python_box path: dist/*-manylinux*.whl @@ -104,7 +97,7 @@ jobs: test: strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11"] os: [ubuntu-latest, macos-11, windows-latest] runs-on: ${{ matrix.os }} steps: @@ -122,10 +115,10 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt pip install -r requirements-test.txt - pip install setuptools wheel Cython + pip install setuptools wheel Cython==3.0.0 python setup.py build_ext --inplace - name: Test with pytest env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - pytest --cov=box test/ + pytest --cov=box -vv test/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2fc0ba4..eb3c1f0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,7 +29,7 @@ repos: exclude: ^test/data/.+ - repo: https://github.com/ambv/black - rev: 23.1.0 + rev: 23.7.0 hooks: - id: black args: [--config=.black.toml] @@ -51,7 +51,7 @@ repos: always_run: true - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v1.0.1' + rev: 'v1.4.1' hooks: - id: mypy types: [python] diff --git a/AUTHORS.rst b/AUTHORS.rst index 1fb7e2d..b67a735 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -29,6 +29,8 @@ Code contributions: - Ivan Pepelnjak (ipspace) - Michał Górny (mgorny) - Serge Lu (Serge45) +- Eric Prestat (ericpre) + Suggestions and bug reporting: @@ -87,3 +89,5 @@ Suggestions and bug reporting: - Peter B (barmettl) - Ash A. (dragonpaw) - Коптев Роман Викторович (romikforest) +- lei wang (191801737) +- d00m514y3r diff --git a/CHANGES.rst b/CHANGES.rst index eb7921b..20fc77f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,17 @@ Changelog ========= +Version 7.1.0 +------------- + +* Adding #255 defer ipython import for large import speed improvements (thanks to Eric Prestat) +* Adding testing for Python 3.12 +* Fixing #253 merge_update box list merge types not populated to sub dictionaries (thanks to lei wang) +* Fixing #257 Two test failures due to arguments having incorrect types (thanks to Michał Górny) +* Fixing stub files to match latest code signatures +* Removing #251 support for circular references in lists (thanks to d00m514y3r) +* Removing support for Python 3.7 as it is EOL + Version 7.0.1 ------------- diff --git a/box/__init__.py b/box/__init__.py index b2225bb..64d0ca8 100644 --- a/box/__init__.py +++ b/box/__init__.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- __author__ = "Chris Griffith" -__version__ = "7.0.1" +__version__ = "7.1.0" from box.box import Box from box.box_list import BoxList diff --git a/box/box.py b/box/box.py index 322b313..158a55e 100644 --- a/box/box.py +++ b/box/box.py @@ -10,7 +10,7 @@ import warnings from keyword import iskeyword from os import PathLike -from typing import Any, Dict, Generator, List, Optional, Tuple, Type, Union +from typing import Any, Dict, Generator, List, Optional, Tuple, Type, Union, Literal from inspect import signature try: @@ -18,12 +18,6 @@ except ImportError: from collections.abc import Callable, Iterable, Mapping -try: - from IPython import get_ipython -except ImportError: - ipython = False -else: - ipython = True if get_ipython() else False import box from box.converters import ( @@ -56,6 +50,17 @@ NO_NAMESPACE = object() +def _is_ipython(): + try: + from IPython import get_ipython + except ImportError: + ipython = False + else: + ipython = True if get_ipython() else False + + return ipython + + def _exception_cause(e): """ Unwrap BoxKeyError and BoxValueError errors to their cause. @@ -201,7 +206,7 @@ def __new__( box_recast: Optional[Dict] = None, box_dots: bool = False, box_class: Optional[Union[Dict, Type["Box"]]] = None, - box_namespace: Tuple[str, ...] = (), + box_namespace: Union[Tuple[str, ...], Literal[False]] = (), **kwargs: Any, ): """ @@ -248,7 +253,7 @@ def __init__( box_recast: Optional[Dict] = None, box_dots: bool = False, box_class: Optional[Union[Dict, Type["Box"]]] = None, - box_namespace: Tuple[str, ...] = (), + box_namespace: Union[Tuple[str, ...], Literal[False]] = (), **kwargs: Any, ): super().__init__() @@ -380,7 +385,7 @@ def __hash__(self): return hashing raise BoxTypeError('unhashable type: "Box"') - def __dir__(self): + def __dir__(self) -> List[str]: items = set(super().__dir__()) # Only show items accessible by dot notation for key in self.keys(): @@ -483,7 +488,7 @@ def __setstate__(self, state): self.__dict__.update(state) def __get_default(self, item, attr=False): - if ipython and item in ("getdoc", "shape"): + if item in ("getdoc", "shape") and _is_ipython(): return None default_value = self._box_config["default_box_attr"] if default_value in (self._box_config["box_class"], dict): @@ -589,6 +594,12 @@ def __getitem__(self, item, _ignore_default=False): if item == "_box_config": cause = _exception_cause(err) raise BoxKeyError("_box_config should only exist as an attribute and is never defaulted") from cause + if isinstance(item, slice): + # In Python 3.12 this changes to a KeyError instead of TypeError + new_box = self._box_config["box_class"](**self.__box_config()) + for x in list(super().keys())[item.start : item.stop : item.step]: + new_box[x] = self[x] + return new_box if self._box_config["box_dots"] and isinstance(item, str) and ("." in item or "[" in item): try: first_item, children = _parse_box_dots(self, item) @@ -823,7 +834,7 @@ def convert_and_set(k, v): # in the `converted` box_config set v = self._box_config["box_class"](v, **self.__box_config(extra_namespace=k)) if k in self and isinstance(self[k], dict): - self[k].merge_update(v) + self[k].merge_update(v, box_merge_lists=merge_type) return if isinstance(v, list) and not intact_type: v = box.BoxList(v, **self.__box_config(extra_namespace=k)) diff --git a/box/box.pyi b/box/box.pyi index 02ba3d4..9d56ab3 100644 --- a/box/box.pyi +++ b/box/box.pyi @@ -1,10 +1,11 @@ +from _typeshed import Incomplete from collections.abc import Mapping from os import PathLike -from typing import Any, Dict, Generator, List, Optional, Tuple, Union +from typing import Any, Dict, Generator, List, Optional, Tuple, Type, Union, Literal class Box(dict): def __new__( - cls: Any, + cls, *args: Any, default_box: bool = ..., default_box_attr: Any = ..., @@ -17,11 +18,12 @@ class Box(dict): box_safe_prefix: str = ..., box_duplicates: str = ..., box_intact_types: Union[Tuple, List] = ..., - box_recast: Dict = ..., + box_recast: Optional[Dict] = ..., box_dots: bool = ..., - box_class: Union[Dict, Box] = ..., + box_class: Optional[Union[Dict, Type["Box"]]] = ..., + box_namespace: Union[Tuple[str, ...], Literal[False]] = ..., **kwargs: Any, - ) -> Any: ... + ): ... def __init__( self, *args: Any, @@ -36,82 +38,84 @@ class Box(dict): box_safe_prefix: str = ..., box_duplicates: str = ..., box_intact_types: Union[Tuple, List] = ..., - box_recast: Dict = ..., + box_recast: Optional[Dict] = ..., box_dots: bool = ..., - box_class: Union[Dict, Box] = ..., + box_class: Optional[Union[Dict, Type["Box"]]] = ..., + box_namespace: Union[Tuple[str, ...], Literal[False]] = ..., **kwargs: Any, ) -> None: ... - def __add__(self, other: Mapping[Any, Any]) -> Any: ... - def __radd__(self, other: Mapping[Any, Any]) -> Any: ... - def __iadd__(self, other: Mapping[Any, Any]) -> Any: ... - def __or__(self, other: Mapping[Any, Any]) -> Any: ... - def __ror__(self, other: Mapping[Any, Any]) -> Any: ... - def __ior__(self, other: Mapping[Any, Any]) -> Any: ... # type: ignore[override] - def __sub__(self, other: Mapping[Any, Any]) -> Any: ... - def __hash__(self) -> Any: ... # type: ignore[override] - def __dir__(self): ... - def keys(self, dotted: Union[bool] = ...) -> Any: ... - def items(self, dotted: Union[bool] = ...) -> Any: ... - def get(self, key: Any, default: Any = ...): ... + def __add__(self, other: Mapping[Any, Any]): ... + def __radd__(self, other: Mapping[Any, Any]): ... + def __iadd__(self, other: Mapping[Any, Any]): ... + def __or__(self, other: Mapping[Any, Any]): ... + def __ror__(self, other: Mapping[Any, Any]): ... + def __ior__(self, other: Mapping[Any, Any]): ... # type: ignore[override] + def __sub__(self, other: Mapping[Any, Any]): ... + def __hash__(self): ... + def __dir__(self) -> List[str]: ... + def __contains__(self, item) -> bool: ... + def keys(self, dotted: Union[bool] = ...): ... + def items(self, dotted: Union[bool] = ...): ... + def get(self, key, default=...): ... def copy(self) -> Box: ... def __copy__(self) -> Box: ... - def __deepcopy__(self, memodict: Any = ...) -> Box: ... - def __getitem__(self, item: Any, _ignore_default: bool = ...) -> Any: ... - def __getattr__(self, item: Any) -> Any: ... - def __setitem__(self, key: Any, value: Any): ... - def __setattr__(self, key: Any, value: Any): ... - def __delitem__(self, key: Any): ... - def __delattr__(self, item: Any) -> None: ... - def pop(self, key: Any, *args: Any): ... + def __deepcopy__(self, memodict: Incomplete | None = ...) -> Box: ... + def __getitem__(self, item, _ignore_default: bool = ...): ... + def __getattr__(self, item): ... + def __setitem__(self, key, value): ... + def __setattr__(self, key, value): ... + def __delitem__(self, key): ... + def __delattr__(self, item) -> None: ... + def pop(self, key, *args): ... def clear(self) -> None: ... def popitem(self): ... def __iter__(self) -> Generator: ... def __reversed__(self) -> Generator: ... def to_dict(self) -> Dict: ... - def update(self, __m: Optional[Any] = ..., **kwargs: Any) -> None: ... - def merge_update(self, __m: Optional[Any] = ..., **kwargs: Any) -> None: ... - def setdefault(self, item: Any, default: Optional[Any] = ...): ... + def update(self, *args, **kwargs) -> None: ... + def merge_update(self, *args, **kwargs) -> None: ... + def setdefault(self, item, default: Incomplete | None = ...): ... def to_json( - self, filename: Union[str, PathLike] = ..., encoding: str = ..., errors: str = ..., **json_kwargs: Any - ) -> Any: ... + self, filename: Optional[Union[str, PathLike]] = ..., encoding: str = ..., errors: str = ..., **json_kwargs + ): ... @classmethod def from_json( - cls: Any, - json_string: str = ..., - filename: Union[str, PathLike] = ..., + cls, + json_string: Optional[str] = ..., + filename: Optional[Union[str, PathLike]] = ..., encoding: str = ..., errors: str = ..., - **kwargs: Any, + **kwargs, ) -> Box: ... def to_yaml( self, - filename: Union[str, PathLike] = ..., + filename: Optional[Union[str, PathLike]] = ..., default_flow_style: bool = ..., encoding: str = ..., errors: str = ..., - **yaml_kwargs: Any, - ) -> Any: ... + **yaml_kwargs, + ): ... @classmethod def from_yaml( - cls: Any, - yaml_string: str = ..., - filename: Union[str, PathLike] = ..., + cls, + yaml_string: Optional[str] = ..., + filename: Optional[Union[str, PathLike]] = ..., encoding: str = ..., errors: str = ..., - **kwargs: Any, + **kwargs, ) -> Box: ... - def to_toml(self, filename: Union[str, PathLike] = ..., encoding: str = ..., errors: str = ...) -> Any: ... + def to_toml(self, filename: Optional[Union[str, PathLike]] = ..., encoding: str = ..., errors: str = ...): ... @classmethod def from_toml( - cls: Any, - toml_string: str = ..., - filename: Union[str, PathLike] = ..., + cls, + toml_string: Optional[str] = ..., + filename: Optional[Union[str, PathLike]] = ..., encoding: str = ..., errors: str = ..., - **kwargs: Any, + **kwargs, ) -> Box: ... - def to_msgpack(self, filename: Union[str, PathLike] = ..., **kwargs: Any) -> Any: ... + def to_msgpack(self, filename: Optional[Union[str, PathLike]] = ..., **kwargs): ... @classmethod def from_msgpack( - cls: Any, msgpack_bytes: bytes = ..., filename: Union[str, PathLike] = ..., **kwargs: Any + cls, msgpack_bytes: Optional[bytes] = ..., filename: Optional[Union[str, PathLike]] = ..., **kwargs ) -> Box: ... diff --git a/box/box_list.py b/box/box_list.py index 750f3f0..048b014 100644 --- a/box/box_list.py +++ b/box/box_list.py @@ -5,7 +5,7 @@ import copy import re from os import PathLike -from typing import Optional, Iterable, Type, Union +from typing import Optional, Iterable, Type, Union, List, Any import box from box.converters import ( @@ -22,7 +22,6 @@ _to_yaml, msgpack_available, toml_read_library, - toml_write_library, yaml_available, ) from box.exceptions import BoxError, BoxTypeError @@ -102,7 +101,7 @@ def _convert(self, p_object): elif isinstance(p_object, box.Box): p_object._box_config.update(self.box_options) if isinstance(p_object, list) and not self._is_intact_type(p_object): - p_object = self if id(p_object) == self.box_org_ref else self.__class__(p_object, **self.box_options) + p_object = self.__class__(p_object, **self.box_options) elif isinstance(p_object, BoxList): p_object.box_options.update(self.box_options) return p_object @@ -117,7 +116,7 @@ def extend(self, iterable): def insert(self, index, p_object): super().insert(index, self._convert(p_object)) - def _dotted_helper(self): + def _dotted_helper(self) -> List[str]: keys = [] for idx, item in enumerate(self): added = False @@ -150,15 +149,15 @@ def __deepcopy__(self, memo=None): out.append(copy.deepcopy(k, memo=memo)) return out - def __hash__(self): + def __hash__(self) -> int: # type: ignore[override] if self.box_options.get("frozen_box"): hashing = 98765 hashing ^= hash(tuple(self)) return hashing raise BoxTypeError("unhashable type: 'BoxList'") - def to_list(self): - new_list = [] + def to_list(self) -> List: + new_list: List[Any] = [] for x in self: if x is self: new_list.append(new_list) diff --git a/box/box_list.pyi b/box/box_list.pyi index dbd2007..982093f 100644 --- a/box/box_list.pyi +++ b/box/box_list.pyi @@ -1,19 +1,18 @@ import box from box.converters import ( - BOX_PARAMETERS, - msgpack_available, - yaml_available, - toml_read_library, - toml_write_library, + BOX_PARAMETERS as BOX_PARAMETERS, + msgpack_available as msgpack_available, + toml_read_library as toml_read_library, + toml_write_library as toml_write_library, + yaml_available as yaml_available, ) -from box.exceptions import BoxError as BoxError, BoxTypeError as BoxTypeError from os import PathLike as PathLike -from typing import Any, Iterable, Optional, Type, Union +from typing import Any, Iterable, Optional, Type, Union, List class BoxList(list): def __new__(cls, *args: Any, **kwargs: Any): ... - box_options: Any = ... - box_org_ref: Any = ... + box_options: Any + box_org_ref: Any def __init__(self, iterable: Iterable = ..., box_class: Type[box.Box] = ..., **box_options: Any) -> None: ... def __getitem__(self, item: Any): ... def __delitem__(self, key: Any): ... @@ -21,10 +20,11 @@ class BoxList(list): def append(self, p_object: Any) -> None: ... def extend(self, iterable: Any) -> None: ... def insert(self, index: Any, p_object: Any) -> None: ... - def __copy__(self): ... - def __deepcopy__(self, memo: Optional[Any] = ...): ... - def __hash__(self) -> Any: ... # type: ignore[override] - def to_list(self): ... + def __copy__(self) -> "BoxList": ... + def __deepcopy__(self, memo: Optional[Any] = ...) -> "BoxList": ... + def __hash__(self) -> int: ... # type: ignore[override] + def to_list(self) -> List: ... + def _dotted_helper(self) -> List[str]: ... def to_json( self, filename: Union[str, PathLike] = ..., @@ -35,7 +35,7 @@ class BoxList(list): ) -> Any: ... @classmethod def from_json( - cls: Any, + cls, json_string: str = ..., filename: Union[str, PathLike] = ..., encoding: str = ..., @@ -53,7 +53,7 @@ class BoxList(list): ) -> Any: ... @classmethod def from_yaml( - cls: Any, + cls, yaml_string: str = ..., filename: Union[str, PathLike] = ..., encoding: str = ..., @@ -65,7 +65,7 @@ class BoxList(list): ) -> Any: ... @classmethod def from_toml( - cls: Any, + cls, toml_string: str = ..., filename: Union[str, PathLike] = ..., key_name: str = ..., @@ -75,12 +75,9 @@ class BoxList(list): ) -> Any: ... def to_msgpack(self, filename: Union[str, PathLike] = ..., **kwargs: Any) -> Any: ... @classmethod - def from_msgpack( - cls: Any, msgpack_bytes: bytes = ..., filename: Union[str, PathLike] = ..., **kwargs: Any - ) -> Any: ... + def from_msgpack(cls, msgpack_bytes: bytes = ..., filename: Union[str, PathLike] = ..., **kwargs: Any) -> Any: ... def to_csv(self, filename: Union[str, PathLike] = ..., encoding: str = ..., errors: str = ...) -> Any: ... @classmethod def from_csv( - cls: Any, csv_string: str = ..., filename: Union[str, PathLike] = ..., encoding: str = ..., errors: str = ... + cls, csv_string: str = ..., filename: Union[str, PathLike] = ..., encoding: str = ..., errors: str = ... ) -> Any: ... - def _dotted_helper(self) -> Any: ... diff --git a/box/config_box.py b/box/config_box.py index 0d42b3b..0202ca3 100644 --- a/box/config_box.py +++ b/box/config_box.py @@ -1,5 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +from typing import List from box.box import Box @@ -29,7 +30,7 @@ def __getattr__(self, item): except AttributeError: return super().__getattr__(item.lower()) - def __dir__(self): + def __dir__(self) -> List[str]: return super().__dir__() + ["bool", "int", "float", "list", "getboolean", "getfloat", "getint"] def bool(self, item, default=None): diff --git a/box/config_box.pyi b/box/config_box.pyi index 75afb44..1022f1b 100644 --- a/box/config_box.pyi +++ b/box/config_box.pyi @@ -1,9 +1,9 @@ from box.box import Box as Box -from typing import Any, Optional +from typing import Any, Optional, List class ConfigBox(Box): def __getattr__(self, item: Any): ... - def __dir__(self): ... + def __dir__(self) -> List[str]: ... def bool(self, item: Any, default: Optional[Any] = ...): ... def int(self, item: Any, default: Optional[Any] = ...): ... def float(self, item: Any, default: Optional[Any] = ...): ... @@ -11,5 +11,5 @@ class ConfigBox(Box): def getboolean(self, item: Any, default: Optional[Any] = ...): ... def getint(self, item: Any, default: Optional[Any] = ...): ... def getfloat(self, item: Any, default: Optional[Any] = ...): ... - def copy(self): ... - def __copy__(self): ... + def copy(self) -> "ConfigBox": ... + def __copy__(self) -> "ConfigBox": ... diff --git a/box/converters.py b/box/converters.py index 29da488..3bb003b 100644 --- a/box/converters.py +++ b/box/converters.py @@ -5,7 +5,6 @@ import csv import json -import sys from io import StringIO from os import PathLike from pathlib import Path @@ -34,6 +33,19 @@ toml_write_library: Optional[Any] = None toml_decode_error: Optional[Callable] = None +__all__ = [ + "_to_json", + "_to_yaml", + "_to_toml", + "_to_csv", + "_to_msgpack", + "_from_json", + "_from_yaml", + "_from_toml", + "_from_csv", + "_from_msgpack", +] + class BoxTomlDecodeError(BoxError): """Toml Decode Error""" diff --git a/box/converters.pyi b/box/converters.pyi index 5f94f33..43d2020 100644 --- a/box/converters.pyi +++ b/box/converters.pyi @@ -1,6 +1,5 @@ -from box.exceptions import BoxError as BoxError -from os import PathLike as PathLike -from typing import Any, Union, Optional, Dict, Callable +from typing import Any, Callable, Optional, Union, Dict +from os import PathLike yaml_available: bool toml_available: bool @@ -10,57 +9,52 @@ toml_read_library: Optional[Any] toml_write_library: Optional[Any] toml_decode_error: Optional[Callable] -def _exists(filename: Union[str, PathLike], create: bool = False) -> Any: ... def _to_json( - obj, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **json_kwargs -) -> Any: ... + obj, filename: Optional[Union[str, PathLike]] = ..., encoding: str = ..., errors: str = ..., **json_kwargs +): ... def _from_json( - json_string: Optional[str] = None, - filename: Optional[Union[str, PathLike]] = None, - encoding: str = "utf-8", - errors: str = "strict", - multiline: bool = False, + json_string: Optional[str] = ..., + filename: Optional[Union[str, PathLike]] = ..., + encoding: str = ..., + errors: str = ..., + multiline: bool = ..., **kwargs, -) -> Any: ... +): ... def _to_yaml( obj, - filename: Optional[Union[str, PathLike]] = None, - default_flow_style: bool = False, - encoding: str = "utf-8", - errors: str = "strict", - ruamel_typ: str = "rt", - ruamel_attrs: Optional[Dict] = None, + filename: Optional[Union[str, PathLike]] = ..., + default_flow_style: bool = ..., + encoding: str = ..., + errors: str = ..., + ruamel_typ: str = ..., + ruamel_attrs: Optional[Dict] = ..., **yaml_kwargs, -) -> Any: ... +): ... def _from_yaml( - yaml_string: Optional[str] = None, - filename: Optional[Union[str, PathLike]] = None, - encoding: str = "utf-8", - errors: str = "strict", - ruamel_typ: str = "rt", - ruamel_attrs: Optional[Dict] = None, + yaml_string: Optional[str] = ..., + filename: Optional[Union[str, PathLike]] = ..., + encoding: str = ..., + errors: str = ..., + ruamel_typ: str = ..., + ruamel_attrs: Optional[Dict] = ..., **kwargs, -) -> Any: ... -def _to_toml( - obj, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict" -) -> Any: ... +): ... +def _to_toml(obj, filename: Optional[Union[str, PathLike]] = ..., encoding: str = ..., errors: str = ...): ... def _from_toml( - toml_string: Optional[str] = None, - filename: Optional[Union[str, PathLike]] = None, - encoding: str = "utf-8", - errors: str = "strict", -) -> Any: ... -def _to_msgpack(obj, filename: Optional[Union[str, PathLike]] = None, **kwargs) -> Any: ... -def _from_msgpack( - msgpack_bytes: Optional[bytes] = None, filename: Optional[Union[str, PathLike]] = None, **kwargs -) -> Any: ... + toml_string: Optional[str] = ..., + filename: Optional[Union[str, PathLike]] = ..., + encoding: str = ..., + errors: str = ..., +): ... +def _to_msgpack(obj, filename: Optional[Union[str, PathLike]] = ..., **kwargs): ... +def _from_msgpack(msgpack_bytes: Optional[bytes] = ..., filename: Optional[Union[str, PathLike]] = ..., **kwargs): ... def _to_csv( - box_list, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **kwargs -) -> Any: ... + box_list, filename: Optional[Union[str, PathLike]] = ..., encoding: str = ..., errors: str = ..., **kwargs +): ... def _from_csv( - csv_string: Optional[str] = None, - filename: Optional[Union[str, PathLike]] = None, - encoding: str = "utf-8", - errors: str = "strict", + csv_string: Optional[str] = ..., + filename: Optional[Union[str, PathLike]] = ..., + encoding: str = ..., + errors: str = ..., **kwargs, -) -> Any: ... +): ... diff --git a/box/from_file.pyi b/box/from_file.pyi index 00657eb..9e8be8a 100644 --- a/box/from_file.pyi +++ b/box/from_file.pyi @@ -1,9 +1,16 @@ -from box.box import Box -from box.box_list import BoxList +from box.box import Box as Box +from box.box_list import BoxList as BoxList from os import PathLike from typing import Any, Union def box_from_file( - file: Union[str, PathLike], file_type: str = ..., encoding: str = ..., errors: str = ..., **kwargs: Any + file: Union[str, PathLike], + file_type: str = ..., + encoding: str = ..., + errors: str = ..., + **kwargs: Any, +) -> Union[Box, BoxList]: ... +def box_from_string( + content: str, + string_type: str = ..., ) -> Union[Box, BoxList]: ... -def box_from_string(content: str, string_type: str = ...) -> Union[Box, BoxList]: ... diff --git a/box/shorthand_box.py b/box/shorthand_box.py index 5d24604..99dfc8d 100644 --- a/box/shorthand_box.py +++ b/box/shorthand_box.py @@ -1,5 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +from typing import Dict from box.box import Box @@ -27,28 +28,28 @@ class SBox(Box): ] @property - def dict(self): + def dict(self) -> Dict: return self.to_dict() @property - def json(self): + def json(self) -> str: return self.to_json() @property - def yaml(self): + def yaml(self) -> str: return self.to_yaml() @property - def toml(self): + def toml(self) -> str: return self.to_toml() def __repr__(self): return f"SBox({self})" - def copy(self): + def copy(self) -> "SBox": return SBox(super(SBox, self).copy()) - def __copy__(self): + def __copy__(self) -> "SBox": return SBox(super(SBox, self).copy()) @@ -64,5 +65,5 @@ def __new__(cls, *args, **kwargs): obj._box_config["default_box"] = True return obj - def __repr__(self): + def __repr__(self) -> str: return f"DDBox({self})" diff --git a/box/shorthand_box.pyi b/box/shorthand_box.pyi index 4b86f73..deef693 100644 --- a/box/shorthand_box.pyi +++ b/box/shorthand_box.pyi @@ -1,15 +1,17 @@ +from typing import Dict + from box.box import Box as Box class SBox(Box): @property - def dict(self): ... + def dict(self) -> Dict: ... @property - def json(self): ... + def json(self) -> str: ... @property - def yaml(self): ... + def yaml(self) -> str: ... @property - def toml(self): ... - def copy(self): ... - def __copy__(self): ... + def toml(self) -> str: ... + def copy(self) -> "SBox": ... + def __copy__(self) -> "SBox": ... class DDBox(Box): ... diff --git a/requirements-dev.txt b/requirements-dev.txt index cb6ad4c..e41d73f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ # Files needed for pre-commit hooks black>=23.1.0 -Cython>=0.29 +Cython==3.0.0 mypy>=1.0.1 pre-commit>=2.21.0 diff --git a/setup.py b/setup.py index 0f0519e..3781c42 100644 --- a/setup.py +++ b/setup.py @@ -45,18 +45,17 @@ py_modules=["box"], packages=["box"], ext_modules=extra, - python_requires=">=3.7", + python_requires=">=3.8", include_package_data=True, platforms="any", classifiers=[ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Development Status :: 5 - Production/Stable", "Natural Language :: English", diff --git a/test/test_box.py b/test/test_box.py index c7b51b7..d9012e9 100644 --- a/test/test_box.py +++ b/test/test_box.py @@ -588,15 +588,6 @@ def test_circular_references(self): bx.to_json() - circular_list = [] - circular_list.append(circular_list) - bl = BoxList(circular_list) - assert bl == bl[0] - assert isinstance(bl[0], BoxList) - circular_list_2 = bl.to_list() - assert circular_list_2 == circular_list_2[0] - assert isinstance(circular_list_2, list) - def test_to_multiline(self): a = BoxList([Box(a=1), Box(b=2), Box(three=5)]) @@ -1264,6 +1255,18 @@ def test_merge_list_options(self): a.merge_update({"key1": {"new": 5}, "Key 2": {"add_key": 6}, "lister": ["a"]}) assert a.lister == ["a"] + d1 = {"app": {"S3": {"S3Service": [{"bucket": "bucket001"}]}}} + + d2 = {"app": {"S3": {"S3Service": [{"expirationDate": "2099-10-25"}]}}} + + box1 = Box(d1) + + box1.merge_update(d2, box_merge_lists="extend") + + assert box1 == Box( + {"app": {"S3": {"S3Service": [{"bucket": "bucket001"}, {"expirationDate": "2099-10-25"}]}}} + ), box1 + def test_box_from_empty_yaml(self): out = Box.from_yaml("---") assert out == Box() @@ -1331,7 +1334,7 @@ def test_box_kwargs_should_not_be_included(self): "box_dots": True, "modify_tuples_box": True, "box_intact_types": (), - "box_recast": True, + "box_recast": {"id": int}, } bx = Box(**params) diff --git a/test/test_box_list.py b/test/test_box_list.py index e68e521..536520f 100644 --- a/test/test_box_list.py +++ b/test/test_box_list.py @@ -5,6 +5,8 @@ import json import os import shutil +import sys +import platform from pathlib import Path from io import StringIO from test.common import test_root, tmp_dir @@ -220,3 +222,16 @@ def test_box_config_propagate(self): assert item._box_config["default_box"] is True elif isinstance(item, BoxList): assert item.box_options["default_box"] is True + + def test_no_recursion_errors(self): + a = Box({"list_of_dicts": [[{"example1": 1}]]}) + a.list_of_dicts.append([{"example2": 2}]) + assert a["list_of_dicts"][1] == [{"example2": 2}] + + def test_no_circular_references(self): + if sys.version_info >= (3, 12) and sys.platform == "win32": + pytest.skip("Windows fatal exception: stack overflow on github actions") + circular_list = [] + circular_list.append(circular_list) + with pytest.raises(RecursionError): + BoxList(circular_list) From cc26a46869e0f134d66a88765b7e31a48cc60cc2 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Sat, 26 Aug 2023 09:44:06 -0500 Subject: [PATCH 11/17] Version 7.1.1 Fixing Cython optimized build deployments for linux --- .github/workflows/pythonpublish.yml | 3 ++- CHANGES.rst | 5 +++++ box/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index a9d01fc..e24267a 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -72,7 +72,7 @@ jobs: - name: Build wheels run: | python -m pip install --upgrade pip - pip install cibuildwheel + pip install cibuildwheel setuptools wheel python -m cibuildwheel --output-dir dist env: CIBW_BUILD: cp38-manylinux_x86_64 cp39-manylinux_x86_64 cp310-manylinux_x86_64 cp311-manylinux_x86_64 cp311-macosx_x86_64 @@ -85,5 +85,6 @@ jobs: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | + pip install twine twine upload dist/*-manylinux*.whl twine upload dist/*-macosx*.whl diff --git a/CHANGES.rst b/CHANGES.rst index 20fc77f..0272697 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,11 @@ Changelog ========= +Version 7.1.1 +------------- + +* Fixing Cython optimized build deployments for linux + Version 7.1.0 ------------- diff --git a/box/__init__.py b/box/__init__.py index 64d0ca8..10b4ce6 100644 --- a/box/__init__.py +++ b/box/__init__.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- __author__ = "Chris Griffith" -__version__ = "7.1.0" +__version__ = "7.1.1" from box.box import Box from box.box_list import BoxList From a23451d2869a511280eebe194efca41efadd2706 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Wed, 12 Jun 2024 15:06:30 -0500 Subject: [PATCH 12/17] Version 7.2.0 (#271) * Adding #266 support for accessing nested items in BoxList using numpy-style tuple indexing (thanks to Bit0r) * Adding tests and Cython releases for Python 3.12 * Fixing #251 support for circular references in lists (thanks to Muspi Merol) * Fixing #261 altering all `__repr__` methods so that subclassing will output the correct class name (thanks to Gabriel Tkacz) * Fixing #267 Fix type 'int' not iterable (thanks to YISH) --------- Co-authored-by: Bit0r Co-authored-by: Muspi Merol Co-authored-by: Gabriel Tkacz <55806524+gtkacz@users.noreply.github.com> Co-authored-by: Gabriel Tkacz Co-authored-by: YISH --- .github/workflows/pythonpublish.yml | 23 +++++++++++----------- .github/workflows/tests.yml | 30 ++++++++++++++--------------- .pre-commit-config.yaml | 10 +++++----- AUTHORS.rst | 5 ++++- CHANGES.rst | 9 +++++++++ box/__init__.py | 2 +- box/box.py | 5 +++-- box/box_list.py | 21 ++++++++++++++++---- box/config_box.py | 2 +- box/shorthand_box.py | 4 ++-- test/test_box_list.py | 12 +++++++----- 11 files changed, 75 insertions(+), 48 deletions(-) diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index e24267a..791b92a 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -13,12 +13,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' - name: Install Dependencies run: | @@ -39,13 +39,13 @@ jobs: strategy: matrix: os: [macos-11, windows-latest] - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -63,11 +63,11 @@ jobs: deploy-cython-manylinux: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.11 - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - name: Set up Python 3.12 + uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.12" - name: Build wheels run: | @@ -75,7 +75,7 @@ jobs: pip install cibuildwheel setuptools wheel python -m cibuildwheel --output-dir dist env: - CIBW_BUILD: cp38-manylinux_x86_64 cp39-manylinux_x86_64 cp310-manylinux_x86_64 cp311-manylinux_x86_64 cp311-macosx_x86_64 + CIBW_BUILD: cp38-manylinux_x86_64 cp39-manylinux_x86_64 cp310-manylinux_x86_64 cp311-manylinux_x86_64 cp312-manylinux_x86_64 CIBW_BEFORE_BUILD: pip install Cython==3.0.0 CIBW_BEFORE_TEST: pip install -r requirements.txt -r requirements-test.txt setuptools wheel twine CIBW_TEST_COMMAND: pytest {package}/test -vv @@ -87,4 +87,3 @@ jobs: run: | pip install twine twine upload dist/*-manylinux*.whl - twine upload dist/*-macosx*.whl diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index db506f9..389b419 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,13 +13,13 @@ jobs: package-checks: strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12-dev", "pypy-3.8"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "pypy-3.8"] os: [ubuntu-latest, macos-11, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - uses: actions/cache@v3 @@ -58,19 +58,19 @@ jobs: Remove-item box -recurse -force python -m pytest -vv - name: Upload wheel artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: python_box + name: python_box_${{matrix.os}}_${{ matrix.python-version }} path: dist/*.whl package-manylinux-checks: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.11 - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - name: Set up Python 3.12 + uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.12" - uses: actions/cache@v3 with: @@ -83,27 +83,27 @@ jobs: pip install cibuildwheel python -m cibuildwheel --output-dir dist env: - CIBW_BUILD: cp38-manylinux_x86_64 cp39-manylinux_x86_64 cp310-manylinux_x86_64 cp311-manylinux_x86_64 cp311-macosx_x86_64 + CIBW_BUILD: cp38-manylinux_x86_64 cp39-manylinux_x86_64 cp310-manylinux_x86_64 cp311-manylinux_x86_64 cp312-manylinux_x86_64 CIBW_BEFORE_BUILD: pip install Cython==3.0.0 CIBW_BEFORE_TEST: pip install -r requirements.txt -r requirements-test.txt setuptools wheel twine CIBW_TEST_COMMAND: pytest {package}/test -vv - name: Upload wheel artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: python_box + name: python_box_manylinux path: dist/*-manylinux*.whl test: strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] os: [ubuntu-latest, macos-11, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - uses: actions/cache@v3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eb3c1f0..b91d368 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.6.0 hooks: # Identify invalid files - id: check-ast @@ -20,8 +20,8 @@ repos: - id: fix-encoding-pragma - id: fix-byte-order-marker # General quality checks - - id: mixed-line-ending - args: [--fix=lf] +# - id: mixed-line-ending +# args: [--fix=lf] - id: trailing-whitespace args: [--markdown-linebreak-ext=md] - id: check-executables-have-shebangs @@ -29,7 +29,7 @@ repos: exclude: ^test/data/.+ - repo: https://github.com/ambv/black - rev: 23.7.0 + rev: 24.4.2 hooks: - id: black args: [--config=.black.toml] @@ -51,7 +51,7 @@ repos: always_run: true - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v1.4.1' + rev: 'v1.10.0' hooks: - id: mypy types: [python] diff --git a/AUTHORS.rst b/AUTHORS.rst index b67a735..c0b068d 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -30,7 +30,10 @@ Code contributions: - Michał Górny (mgorny) - Serge Lu (Serge45) - Eric Prestat (ericpre) - +- Gabriel Mitelman Tkacz (gtkacz) +- Muspi Merol (CNSeniorious000) +- YISH (mokeyish) +- Bit0r Suggestions and bug reporting: diff --git a/CHANGES.rst b/CHANGES.rst index 0272697..f2db4c8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,15 @@ Changelog ========= +Version 7.2.0 +------------- + +* Adding #266 support for accessing nested items in BoxList using numpy-style tuple indexing (thanks to Bit0r) +* Adding tests and Cython releases for Python 3.12 +* Fixing #251 support for circular references in lists (thanks to Muspi Merol) +* Fixing #261 altering all `__repr__` methods so that subclassing will output the correct class name (thanks to Gabriel Tkacz) +* Fixing #267 Fix type 'int' not iterable (thanks to YISH) + Version 7.1.1 ------------- diff --git a/box/__init__.py b/box/__init__.py index 10b4ce6..adc6199 100644 --- a/box/__init__.py +++ b/box/__init__.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- __author__ = "Chris Griffith" -__version__ = "7.1.1" +__version__ = "7.2.0" from box.box import Box from box.box_list import BoxList diff --git a/box/box.py b/box/box.py index 158a55e..6729616 100644 --- a/box/box.py +++ b/box/box.py @@ -415,7 +415,8 @@ def __contains__(self, item): except BoxError: return False else: - return children in self[first_item] + it = self[first_item] + return isinstance(it, Iterable) and children in it def keys(self, dotted: Union[bool] = False): if not dotted: @@ -777,7 +778,7 @@ def popitem(self): return key, self.pop(key) def __repr__(self) -> str: - return f"Box({self})" + return f"{self.__class__.__name__}({self})" def __str__(self) -> str: return str(self.to_dict()) diff --git a/box/box_list.py b/box/box_list.py index 048b014..3535f22 100644 --- a/box/box_list.py +++ b/box/box_list.py @@ -40,16 +40,17 @@ def __new__(cls, *args, **kwargs): # This is required for pickling to work correctly obj.box_options = {"box_class": box.Box} obj.box_options.update(kwargs) - obj.box_org_ref = 0 + obj.box_org_ref = None return obj def __init__(self, iterable: Optional[Iterable] = None, box_class: Type[box.Box] = box.Box, **box_options): self.box_options = box_options self.box_options["box_class"] = box_class - self.box_org_ref = id(iterable) if iterable else 0 + self.box_org_ref = iterable if iterable: for x in iterable: self.append(x) + self.box_org_ref = None if box_options.get("frozen_box"): def frozen(*args, **kwargs): @@ -65,6 +66,14 @@ def __getitem__(self, item): if len(list_pos.group()) == len(item): return value return value.__getitem__(item[len(list_pos.group()) :].lstrip(".")) + if isinstance(item, tuple): + result = self + for idx in item: + if isinstance(result, list): + result = result[idx] + else: + raise BoxTypeError(f"Cannot numpy-style indexing on {type(result).__name__}.") + return result return super().__getitem__(item) def __delitem__(self, key): @@ -101,7 +110,11 @@ def _convert(self, p_object): elif isinstance(p_object, box.Box): p_object._box_config.update(self.box_options) if isinstance(p_object, list) and not self._is_intact_type(p_object): - p_object = self.__class__(p_object, **self.box_options) + p_object = ( + self + if p_object is self or p_object is self.box_org_ref + else self.__class__(p_object, **self.box_options) + ) elif isinstance(p_object, BoxList): p_object.box_options.update(self.box_options) return p_object @@ -133,7 +146,7 @@ def _dotted_helper(self) -> List[str]: return keys def __repr__(self): - return f"BoxList({self.to_list()})" + return f"{self.__class__.__name__}({self.to_list()})" def __str__(self): return str(self.to_list()) diff --git a/box/config_box.py b/box/config_box.py index 0202ca3..4c48877 100644 --- a/box/config_box.py +++ b/box/config_box.py @@ -124,7 +124,7 @@ def getfloat(self, item, default=None): return self.float(item, default) def __repr__(self): - return "ConfigBox({0})".format(str(self.to_dict())) + return f"{self.__class__.__name__}({str(self.to_dict())})" def copy(self): return ConfigBox(super().copy()) diff --git a/box/shorthand_box.py b/box/shorthand_box.py index 99dfc8d..a82edbd 100644 --- a/box/shorthand_box.py +++ b/box/shorthand_box.py @@ -44,7 +44,7 @@ def toml(self) -> str: return self.to_toml() def __repr__(self): - return f"SBox({self})" + return f"{self.__class__.__name__}({self})" def copy(self) -> "SBox": return SBox(super(SBox, self).copy()) @@ -66,4 +66,4 @@ def __new__(cls, *args, **kwargs): return obj def __repr__(self) -> str: - return f"DDBox({self})" + return f"{self.__class__.__name__}({self})" diff --git a/test/test_box_list.py b/test/test_box_list.py index 536520f..e22c3a5 100644 --- a/test/test_box_list.py +++ b/test/test_box_list.py @@ -35,6 +35,10 @@ def test_box_list(self): assert new_list[-1].item == 22 new_list.append([{"bad_item": 33}]) assert new_list[-1][0].bad_item == 33 + new_list[-1].append([{"bad_item": 33}]) + assert new_list[-1, -1, 0].bad_item == 33 + bx = Box({0: {1: {2: {3: 3}}}, (0, 1, 2, 3): 4}) + assert bx[0, 1, 2, 3] == 4 assert repr(new_list).startswith("BoxList(") for x in new_list.to_list(): assert not isinstance(x, (BoxList, Box)) @@ -228,10 +232,8 @@ def test_no_recursion_errors(self): a.list_of_dicts.append([{"example2": 2}]) assert a["list_of_dicts"][1] == [{"example2": 2}] - def test_no_circular_references(self): - if sys.version_info >= (3, 12) and sys.platform == "win32": - pytest.skip("Windows fatal exception: stack overflow on github actions") + def test_circular_references(self): circular_list = [] circular_list.append(circular_list) - with pytest.raises(RecursionError): - BoxList(circular_list) + circular_box = BoxList(circular_list) + assert circular_box[0] == circular_box From 37b9181ab01a014e9ae58bfa737c78b774fd453c Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Mon, 9 Dec 2024 21:20:24 -0600 Subject: [PATCH 13/17] Version 7.3.0 (#283) * Adding tests and Cython releases for Python 3.13 * Fixing #281 consistent error message about missing YAML parser (thanks to J vanBemmel) * Removing support for Python 3.8 as it is EOL --------- Co-authored-by: J vanBemmel --- .github/workflows/pythonpublish.yml | 19 ++++++------ .github/workflows/tests.yml | 47 +++++++++++++++-------------- .pre-commit-config.yaml | 6 ++-- CHANGES.rst | 7 +++++ box/__init__.py | 2 +- box/converters.py | 10 +++--- requirements-dev.txt | 3 +- requirements-test.txt | 4 +-- setup.py | 4 +-- 9 files changed, 57 insertions(+), 45 deletions(-) diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index 791b92a..c4478d8 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: '3.13' - name: Install Dependencies run: | @@ -38,8 +38,8 @@ jobs: deploy-cython: strategy: matrix: - os: [macos-11, windows-latest] - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + os: [macos-latest, windows-latest] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] runs-on: ${{ matrix.os }} steps: @@ -51,7 +51,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install setuptools wheel twine Cython==3.0.0 --upgrade + pip install setuptools wheel twine Cython>=3.0.11 --upgrade - name: Build and publish env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} @@ -64,10 +64,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up Python 3.12 + - name: Set up Python 3.13 uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Build wheels run: | @@ -75,9 +75,10 @@ jobs: pip install cibuildwheel setuptools wheel python -m cibuildwheel --output-dir dist env: - CIBW_BUILD: cp38-manylinux_x86_64 cp39-manylinux_x86_64 cp310-manylinux_x86_64 cp311-manylinux_x86_64 cp312-manylinux_x86_64 - CIBW_BEFORE_BUILD: pip install Cython==3.0.0 - CIBW_BEFORE_TEST: pip install -r requirements.txt -r requirements-test.txt setuptools wheel twine + CIBW_BUILD: cp39-manylinux_x86_64 cp310-manylinux_x86_64 cp311-manylinux_x86_64 cp312-manylinux_x86_64 cp313-manylinux_x86_64 + CIBW_BEFORE_BUILD: pip install Cython>=3.0.11 setuptools wheel + CIBW_BEFORE_TEST: pip install -r requirements.txt -r requirements-test.txt setuptools wheel twine Cython>=3.0.11 + CIBW_BUILD_VERBOSITY: 1 CIBW_TEST_COMMAND: pytest {package}/test -vv - name: Publish diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 389b419..834bfc0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,8 +13,8 @@ jobs: package-checks: strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "pypy-3.8"] - os: [ubuntu-latest, macos-11, windows-latest] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy-3.10"] + os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -22,16 +22,16 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - uses: actions/cache@v3 - with: - path: ~/.cache/pip - key: package-check-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} +# - uses: actions/cache@v3 +# with: +# path: ~/.cache/pip +# key: package-check-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt pip install -r requirements-test.txt - pip install coveralls flake8 flake8-print mypy setuptools wheel twine Cython==3.0.0 + pip install coveralls flake8 flake8-print mypy setuptools wheel twine Cython>=3.0.11 - name: Lint with flake8 run: | # stop the build if there are Python syntax errors, undefined names or print statements @@ -67,15 +67,15 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up Python 3.12 + - name: Set up Python 3.13 uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - - uses: actions/cache@v3 - with: - path: ~/.cache/pip - key: package-manylinux-check-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} +# - uses: actions/cache@v3 +# with: +# path: ~/.cache/pip +# key: package-manylinux-check-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} - name: Build wheels run: | @@ -83,9 +83,10 @@ jobs: pip install cibuildwheel python -m cibuildwheel --output-dir dist env: - CIBW_BUILD: cp38-manylinux_x86_64 cp39-manylinux_x86_64 cp310-manylinux_x86_64 cp311-manylinux_x86_64 cp312-manylinux_x86_64 - CIBW_BEFORE_BUILD: pip install Cython==3.0.0 - CIBW_BEFORE_TEST: pip install -r requirements.txt -r requirements-test.txt setuptools wheel twine + CIBW_BUILD: cp39-manylinux_x86_64 cp310-manylinux_x86_64 cp311-manylinux_x86_64 cp312-manylinux_x86_64 cp313-manylinux_x86_64 + CIBW_BEFORE_BUILD: pip install Cython>=3.0.11 setuptools wheel + CIBW_BEFORE_TEST: pip install -r requirements.txt -r requirements-test.txt setuptools wheel twine Cython>=3.0.11 + CIBW_BUILD_VERBOSITY: 1 CIBW_TEST_COMMAND: pytest {package}/test -vv - name: Upload wheel artifact @@ -97,8 +98,8 @@ jobs: test: strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] - os: [ubuntu-latest, macos-11, windows-latest] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -106,16 +107,16 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - uses: actions/cache@v3 - with: - path: ~/.cache/pip - key: test-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} +# - uses: actions/cache@v3 +# with: +# path: ~/.cache/pip +# key: test-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt pip install -r requirements-test.txt - pip install setuptools wheel Cython==3.0.0 + pip install setuptools wheel Cython>=3.0.11 python setup.py build_ext --inplace - name: Test with pytest env: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b91d368..cee0904 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: # Identify invalid files - id: check-ast @@ -29,7 +29,7 @@ repos: exclude: ^test/data/.+ - repo: https://github.com/ambv/black - rev: 24.4.2 + rev: 24.10.0 hooks: - id: black args: [--config=.black.toml] @@ -51,7 +51,7 @@ repos: always_run: true - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v1.10.0' + rev: 'v1.13.0' hooks: - id: mypy types: [python] diff --git a/CHANGES.rst b/CHANGES.rst index f2db4c8..e6b19a8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,13 @@ Changelog ========= +Version 7.3.0 +------------- + +* Adding tests and Cython releases for Python 3.13 +* Fixing #281 consistent error message about missing YAML parser (thanks to J vanBemmel) +* Removing support for Python 3.8 as it is EOL + Version 7.2.0 ------------- diff --git a/box/__init__.py b/box/__init__.py index adc6199..0f805f9 100644 --- a/box/__init__.py +++ b/box/__init__.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- __author__ = "Chris Griffith" -__version__ = "7.2.0" +__version__ = "7.3.0" from box.box import Box from box.box_list import BoxList diff --git a/box/converters.py b/box/converters.py index 3bb003b..a464046 100644 --- a/box/converters.py +++ b/box/converters.py @@ -29,6 +29,8 @@ except ImportError: pyyaml_available = False +MISSING_PARSER_ERROR = "No YAML Parser available, please install ruamel.yaml>=0.17 or PyYAML" + toml_read_library: Optional[Any] = None toml_write_library: Optional[Any] = None toml_decode_error: Optional[Callable] = None @@ -196,7 +198,7 @@ def _to_yaml( elif pyyaml_available: return yaml.dump(obj, stream=f, default_flow_style=default_flow_style, **yaml_kwargs) else: - raise BoxError("No YAML Parser available, please install ruamel.yaml>0.17 or PyYAML") + raise BoxError(MISSING_PARSER_ERROR) else: if ruamel_available: @@ -210,7 +212,7 @@ def _to_yaml( elif pyyaml_available: return yaml.dump(obj, default_flow_style=default_flow_style, **yaml_kwargs) else: - raise BoxError("No YAML Parser available, please install ruamel.yaml>0.17 or PyYAML") + raise BoxError(MISSING_PARSER_ERROR) def _from_yaml( @@ -237,7 +239,7 @@ def _from_yaml( kwargs["Loader"] = yaml.SafeLoader data = yaml.load(f, **kwargs) else: - raise BoxError("No YAML Parser available, please install ruamel.yaml>0.15 or PyYAML") + raise BoxError(MISSING_PARSER_ERROR) elif yaml_string: if ruamel_available: yaml_loader = YAML(typ=ruamel_typ) @@ -249,7 +251,7 @@ def _from_yaml( kwargs["Loader"] = yaml.SafeLoader data = yaml.load(yaml_string, **kwargs) else: - raise BoxError("No YAML Parser available, please install ruamel.yaml>0.17 or PyYAML") + raise BoxError(MISSING_PARSER_ERROR) else: raise BoxError("from_yaml requires a string or filename") return data diff --git a/requirements-dev.txt b/requirements-dev.txt index e41d73f..617f8b9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,6 @@ # Files needed for pre-commit hooks black>=23.1.0 -Cython==3.0.0 +Cython>=3.0.11 mypy>=1.0.1 pre-commit>=2.21.0 +setuptools>=75.6.0 diff --git a/requirements-test.txt b/requirements-test.txt index 1296ffa..0952d6f 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,7 +1,7 @@ -coverage>=5.0.4 +coverage>=7.6.9 msgpack>=1.0 pytest>=7.1.3 -pytest-cov>=2.8.1 +pytest-cov<6.0.0 ruamel.yaml>=0.17 tomli>=1.2.3; python_version < '3.11' tomli-w>=1.0.0 diff --git a/setup.py b/setup.py index 3781c42..8abda61 100644 --- a/setup.py +++ b/setup.py @@ -45,17 +45,17 @@ py_modules=["box"], packages=["box"], ext_modules=extra, - python_requires=">=3.8", + python_requires=">=3.9", include_package_data=True, platforms="any", classifiers=[ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Development Status :: 5 - Production/Stable", "Natural Language :: English", From 91cc956aa2d480202aebb21cda01e19d351624b5 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Tue, 14 Jan 2025 19:26:20 -0600 Subject: [PATCH 14/17] Version 7.3.1 (#287) * Fixing #275 default_box_create_on_get is ignored with from_yaml (thanks to Ward Loos) * Fixing #285 Infinite Recursion when accessing non existent list index in a DefaultBox with box_dots (thanks to Jesper Schlegel) --------- Co-authored-by: jesperschlegel Co-authored-by: Jesper Schlegel --- AUTHORS.rst | 1 + CHANGES.rst | 7 +++++++ box/__init__.py | 2 +- box/box.py | 14 +++++++++----- box/box_list.py | 10 +++++++++- box/converters.py | 11 +++++++---- test/test_box.py | 4 ++-- test/test_box_list.py | 19 +++++++++++++++++++ 8 files changed, 55 insertions(+), 13 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index c0b068d..9072429 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -34,6 +34,7 @@ Code contributions: - Muspi Merol (CNSeniorious000) - YISH (mokeyish) - Bit0r +- Jesper Schlegel (jesperschlegel) Suggestions and bug reporting: diff --git a/CHANGES.rst b/CHANGES.rst index e6b19a8..9546d63 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,13 @@ Changelog ========= +Version 7.3.1 +------------- + +* Fixing #275 default_box_create_on_get is ignored with from_yaml (thanks to Ward Loos) +* Fixing #285 Infinite Recursion when accessing non existent list index in a DefaultBox with box_dots (thanks to Jesper Schlegel) + + Version 7.3.0 ------------- diff --git a/box/__init__.py b/box/__init__.py index 0f805f9..5fe3311 100644 --- a/box/__init__.py +++ b/box/__init__.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- __author__ = "Chris Griffith" -__version__ = "7.3.0" +__version__ = "7.3.1" from box.box import Box from box.box_list import BoxList diff --git a/box/box.py b/box/box.py index 6729616..937ba0e 100644 --- a/box/box.py +++ b/box/box.py @@ -101,8 +101,7 @@ def _parse_box_dots(bx, item, setting=False): if char == "[": return item[:idx], item[idx:] elif char == ".": - if item[:idx] in bx: - return item[:idx], item[idx + 1 :] + return item[:idx], item[idx + 1 :] if setting and "." in item: return item.split(".", 1) raise BoxError("Could not split box dots properly") @@ -657,9 +656,14 @@ def __setitem__(self, key, value): if hasattr(self[first_item], "__setitem__"): return self[first_item].__setitem__(children, value) elif self._box_config["default_box"]: - super().__setitem__( - first_item, self._box_config["box_class"](**self.__box_config(extra_namespace=first_item)) - ) + if children[0] == "[": + super().__setitem__( + first_item, box.BoxList(**self.__box_config(extra_namespace=first_item)) + ) + else: + super().__setitem__( + first_item, self._box_config["box_class"](**self.__box_config(extra_namespace=first_item)) + ) return self[first_item].__setitem__(children, value) else: raise BoxKeyError(f"'{self.__class__}' object has no attribute {first_item}") diff --git a/box/box_list.py b/box/box_list.py index 3535f22..fb00864 100644 --- a/box/box_list.py +++ b/box/box_list.py @@ -94,9 +94,17 @@ def __setitem__(self, key, value): if self.box_options.get("box_dots") and isinstance(key, str) and key.startswith("["): list_pos = _list_pos_re.search(key) pos = int(list_pos.groups()[0]) + if pos >= len(self) and self.box_options.get("default_box"): + self.extend([None] * (pos - len(self) + 1)) if len(list_pos.group()) == len(key): return super().__setitem__(pos, value) - return super().__getitem__(pos).__setitem__(key[len(list_pos.group()) :].lstrip("."), value) + children = key[len(list_pos.group()):].lstrip(".") + if self.box_options.get("default_box"): + if children[0] == "[": + super().__setitem__(pos, box.BoxList(**self.box_options)) + else: + super().__setitem__(pos, self.box_options.get("box_class")(**self.box_options)) + return super().__getitem__(pos).__setitem__(children, value) super().__setitem__(key, value) def _is_intact_type(self, obj): diff --git a/box/converters.py b/box/converters.py index a464046..80c1ced 100644 --- a/box/converters.py +++ b/box/converters.py @@ -109,16 +109,19 @@ class BoxTomlDecodeError(BoxError, tomli.TOMLDecodeError): # type: ignore BOX_PARAMETERS = ( "default_box", "default_box_attr", - "conversion_box", + "default_box_none_transform", + "default_box_create_on_get", "frozen_box", "camel_killer_box", + "conversion_box", + "modify_tuples_box", "box_safe_prefix", "box_duplicates", - "default_box_none_transform", - "box_dots", - "modify_tuples_box", "box_intact_types", + "box_dots", "box_recast", + "box_class", + "box_namespace", ) diff --git a/test/test_box.py b/test/test_box.py index d9012e9..a40a669 100644 --- a/test/test_box.py +++ b/test/test_box.py @@ -1340,8 +1340,8 @@ def test_box_kwargs_should_not_be_included(self): bx = Box(**params) assert bx == Box() - for param in BOX_PARAMETERS: - assert param in params + for param in params: + assert param in BOX_PARAMETERS def test_box_greek(self): # WARNING μ is ord 956 whereas µ is ord 181 and will not work due to python NFKC normalization diff --git a/test/test_box_list.py b/test/test_box_list.py index e22c3a5..99c65d7 100644 --- a/test/test_box_list.py +++ b/test/test_box_list.py @@ -204,6 +204,25 @@ def test_box_list_dots(self): for key in keys: db[key] + def test_box_list_default_dots(self): + box_1 = Box(default_box=True, box_dots=True) + box_1["a[0]"] = 42 + assert box_1.a[0] == 42 + + box_1["b[0].c[0].d"] = 42 + assert box_1.b[0].c[0].d == 42 + + box_1["c[0][0][0]"] = 42 + assert box_1.c[0][0][0] == 42 + + box_2 = Box(default_box=True, box_dots=True) + box_2["a[4]"] = 42 + assert box_2.a.to_list() == [None, None, None, None, 42] + + box_3 = Box(default_box=True, box_dots=True) + box_3["a.b[0]"] = 42 + assert box_3.a.b[0] == 42 + def test_box_config_propagate(self): structure = Box(a=[Box(default_box=False)], default_box=True, box_inherent_settings=True) assert structure._box_config["default_box"] is True From b071107161228f32762ece8f6039b6906c2570db Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Thu, 16 Jan 2025 13:09:08 -0600 Subject: [PATCH 15/17] Version 7.3.2 (#289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fixing #288 default get value error when using box_dots (thanks to Sébastien Weber) --- AUTHORS.rst | 2 ++ CHANGES.rst | 5 +++++ box/__init__.py | 2 +- box/box.py | 6 +++--- test/test_box.py | 1 + 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index 9072429..c713b93 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -95,3 +95,5 @@ Suggestions and bug reporting: - Коптев Роман Викторович (romikforest) - lei wang (191801737) - d00m514y3r +- Sébastien Weber (seb5g) +- Ward Loos (wrdls) diff --git a/CHANGES.rst b/CHANGES.rst index 9546d63..f74e97d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,11 @@ Changelog ========= +Version 7.3.2 +------------- + +* Fixing #288 default get value error when using box_dots (thanks to Sébastien Weber) + Version 7.3.1 ------------- diff --git a/box/__init__.py b/box/__init__.py index 5fe3311..7cd918f 100644 --- a/box/__init__.py +++ b/box/__init__.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- __author__ = "Chris Griffith" -__version__ = "7.3.1" +__version__ = "7.3.2" from box.box import Box from box.box_list import BoxList diff --git a/box/box.py b/box/box.py index 937ba0e..8be6738 100644 --- a/box/box.py +++ b/box/box.py @@ -414,6 +414,8 @@ def __contains__(self, item): except BoxError: return False else: + if not super().__contains__(first_item): + return False it = self[first_item] return isinstance(it, Iterable) and children in it @@ -657,9 +659,7 @@ def __setitem__(self, key, value): return self[first_item].__setitem__(children, value) elif self._box_config["default_box"]: if children[0] == "[": - super().__setitem__( - first_item, box.BoxList(**self.__box_config(extra_namespace=first_item)) - ) + super().__setitem__(first_item, box.BoxList(**self.__box_config(extra_namespace=first_item))) else: super().__setitem__( first_item, self._box_config["box_class"](**self.__box_config(extra_namespace=first_item)) diff --git a/test/test_box.py b/test/test_box.py index a40a669..1232f3c 100644 --- a/test/test_box.py +++ b/test/test_box.py @@ -755,6 +755,7 @@ def test_get(self): assert isinstance(bx.get("a", [1, 2]), BoxList) bx_dot = Box(a=Box(b=Box(c="me!")), box_dots=True) assert bx_dot.get("a.b.c") == "me!" + assert bx_dot.get("def.not.in.the.box", 4) == 4 def test_contains(self): bx_dot = Box(a=Box(b=Box(c=Box())), box_dots=True) From 580663d45f6af6881f3da2989ddc1b4ddd601a3e Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Fri, 20 Feb 2026 07:17:41 -0600 Subject: [PATCH 16/17] Version 7.4.0 (#302) * Adding #297 'box_dots_exclude' parameter to keep certain keys with dots from being broken down (thanks to J vanBemmel) * Adding #301 support for TOON serialization format (thanks to richieadler) * Adding support for YAML width * Adding support for Python 3.14 * Fixing #291 adding frozen boxes (thanks to m-janicki) * Removing support for Python 3.9 as it is EOL --------- Co-authored-by: Colin Watson Co-authored-by: J vanBemmel --- .github/workflows/pythonpublish.yml | 7 +- .github/workflows/tests.yml | 18 +- AUTHORS.rst | 3 +- CHANGES.rst | 10 + LICENSE | 2 +- MANIFEST.in | 8 + README.rst | 2 +- box/__init__.py | 2 +- box/box.py | 292 ++++++++++++++++++---------- box/box.pyi | 65 ++++--- box/box_list.py | 135 ++++++++++--- box/box_list.pyi | 44 +++-- box/config_box.py | 4 +- box/config_box.pyi | 22 +-- box/converters.py | 88 ++++++--- box/converters.pyi | 53 ++--- box/from_file.py | 46 ++++- box/from_file.pyi | 8 +- box/shorthand_box.py | 8 +- box/shorthand_box.pyi | 8 +- requirements-dev.txt | 1 + requirements-test.txt | 3 +- requirements.txt | 2 +- setup.py | 8 +- test/common.py | 1 + test/test_box.py | 60 ++++++ test/test_box_list.py | 18 ++ test/test_converters.py | 8 +- 28 files changed, 634 insertions(+), 292 deletions(-) diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index c4478d8..e385f04 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.13' + python-version: '3.14' - name: Install Dependencies run: | @@ -39,7 +39,7 @@ jobs: strategy: matrix: os: [macos-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] runs-on: ${{ matrix.os }} steps: @@ -75,8 +75,9 @@ jobs: pip install cibuildwheel setuptools wheel python -m cibuildwheel --output-dir dist env: - CIBW_BUILD: cp39-manylinux_x86_64 cp310-manylinux_x86_64 cp311-manylinux_x86_64 cp312-manylinux_x86_64 cp313-manylinux_x86_64 + CIBW_BUILD: cp310-manylinux_x86_64 cp311-manylinux_x86_64 cp312-manylinux_x86_64 cp313-manylinux_x86_64 CIBW_BEFORE_BUILD: pip install Cython>=3.0.11 setuptools wheel + CIBW_BUILD_FRONTEND: "build; args: --no-isolation" CIBW_BEFORE_TEST: pip install -r requirements.txt -r requirements-test.txt setuptools wheel twine Cython>=3.0.11 CIBW_BUILD_VERBOSITY: 1 CIBW_TEST_COMMAND: pytest {package}/test -vv diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 834bfc0..e06da9a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,7 +13,7 @@ jobs: package-checks: strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy-3.10"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "pypy-3.11"] os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: @@ -31,14 +31,9 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt pip install -r requirements-test.txt - pip install coveralls flake8 flake8-print mypy setuptools wheel twine Cython>=3.0.11 - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors, undefined names or print statements - flake8 box --count --select=E9,F63,F7,F82,T001,T002,T003,T004 --show-source --statistics - # exit-zero treats all errors as warnings. - flake8 . --count --exit-zero --max-complexity=20 --max-line-length=120 --statistics --extend-ignore E203 + pip install coveralls mypy setuptools wheel twine Cython>=3.0.11 - name: Run mypy + if: "!startsWith(matrix.python-version, 'pypy')" run: mypy box - name: Build Wheel and check distribution log description run: | @@ -83,8 +78,9 @@ jobs: pip install cibuildwheel python -m cibuildwheel --output-dir dist env: - CIBW_BUILD: cp39-manylinux_x86_64 cp310-manylinux_x86_64 cp311-manylinux_x86_64 cp312-manylinux_x86_64 cp313-manylinux_x86_64 - CIBW_BEFORE_BUILD: pip install Cython>=3.0.11 setuptools wheel + CIBW_BUILD: cp310-manylinux_x86_64 cp311-manylinux_x86_64 cp312-manylinux_x86_64 cp313-manylinux_x86_64 + CIBW_BEFORE_BUILD: pip install Cython>=3.0.11 setuptools wheel + CIBW_BUILD_FRONTEND: "build; args: --no-isolation" CIBW_BEFORE_TEST: pip install -r requirements.txt -r requirements-test.txt setuptools wheel twine Cython>=3.0.11 CIBW_BUILD_VERBOSITY: 1 CIBW_TEST_COMMAND: pytest {package}/test -vv @@ -98,7 +94,7 @@ jobs: test: strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: diff --git a/AUTHORS.rst b/AUTHORS.rst index c713b93..6b44851 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -35,6 +35,8 @@ Code contributions: - YISH (mokeyish) - Bit0r - Jesper Schlegel (jesperschlegel) +- J vanBemmel (jbemmel) +- m-janicki Suggestions and bug reporting: @@ -82,7 +84,6 @@ Suggestions and bug reporting: - Hitz (hitengajjar) - David Aronchick (aronchick) - Alexander Kapustin (dyens) -- Marcelo Huerta (richieadler) - Tim Schwenke (trallnag) - Marcos Dione (mdione-cloudian) - Varun Madiath (vamega) diff --git a/CHANGES.rst b/CHANGES.rst index f74e97d..07f555f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,16 @@ Changelog ========= +Version 7.4.0 +------------- + +* Adding #297 'box_dots_exclude' parameter to keep certain keys with dots from being broken down (thanks to J vanBemmel) +* Adding #301 support for TOON serialization format (thanks to richieadler) +* Adding support for YAML width +* Adding support for Python 3.14 +* Fixing #291 adding frozen boxes (thanks to m-janicki) +* Removing support for Python 3.9 as it is EOL + Version 7.3.2 ------------- diff --git a/LICENSE b/LICENSE index 1cd0118..8d34381 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017-2023 Chris Griffith +Copyright (c) 2017-2026 Chris Griffith Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in index 462abbb..916f2ad 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,3 +6,11 @@ include box/*.c include box/*.so include box/*.pyd include box/*.pyi +include test/__init__.py +include test/common.py +include test/data/*.csv +include test/data/*.json +include test/data/*.msgpack +include test/data/*.tml +include test/data/*.txt +include test/data/*.yaml diff --git a/README.rst b/README.rst index 456dc40..9fc9f31 100644 --- a/README.rst +++ b/README.rst @@ -139,7 +139,7 @@ Also special shout-out to PythonBytes_, who featured Box on their podcast. License ======= -MIT License, Copyright (c) 2017-2023 Chris Griffith. See LICENSE_ file. +MIT License, Copyright (c) 2017-2026 Chris Griffith. See LICENSE_ file. .. |BoxImage| image:: https://raw.githubusercontent.com/cdgriffith/Box/master/box_logo.png diff --git a/box/__init__.py b/box/__init__.py index 7cd918f..8d9d2b5 100644 --- a/box/__init__.py +++ b/box/__init__.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- __author__ = "Chris Griffith" -__version__ = "7.3.2" +__version__ = "7.3.3" from box.box import Box from box.box_list import BoxList diff --git a/box/box.py b/box/box.py index 8be6738..8252643 100644 --- a/box/box.py +++ b/box/box.py @@ -1,22 +1,20 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- # -# Copyright (c) 2017-2023 - Chris Griffith - MIT License +# Copyright (c) 2017-2026 - Chris Griffith - MIT License """ Improved dictionary access through dot notation with additional tools. """ +from __future__ import annotations + import copy import re import warnings +from collections.abc import Callable, Generator, Iterable, Mapping +from inspect import signature from keyword import iskeyword from os import PathLike -from typing import Any, Dict, Generator, List, Optional, Tuple, Type, Union, Literal -from inspect import signature - -try: - from typing import Callable, Iterable, Mapping -except ImportError: - from collections.abc import Callable, Iterable, Mapping +from typing import Any, Literal import box @@ -25,12 +23,15 @@ _from_json, _from_msgpack, _from_toml, + _from_toon, _from_yaml, _to_json, _to_msgpack, _to_toml, + _to_toon, _to_yaml, msgpack_available, + toon_available, toml_read_library, toml_write_library, yaml_available, @@ -171,11 +172,12 @@ class Box(dict): :param box_intact_types: tuple of types to ignore converting :param box_recast: cast certain keys to a specified type :param box_dots: access nested Boxes by period separated keys in string + :param box_dots_exclude: optional regular expression for dotted keys to exclude :param box_class: change what type of class sub-boxes will be created as :param box_namespace: the namespace this (possibly nested) Box lives within """ - _box_config: Dict[str, Any] + _box_config: dict[str, Any] _protected_keys = [ "to_dict", @@ -201,11 +203,12 @@ def __new__( modify_tuples_box: bool = False, box_safe_prefix: str = "x", box_duplicates: str = "ignore", - box_intact_types: Union[Tuple, List] = (), - box_recast: Optional[Dict] = None, + box_intact_types: tuple | list = (), + box_recast: dict | None = None, box_dots: bool = False, - box_class: Optional[Union[Dict, Type["Box"]]] = None, - box_namespace: Union[Tuple[str, ...], Literal[False]] = (), + box_dots_exclude: str | None = None, + box_class: dict | type[Box] | None = None, + box_namespace: tuple[str, ...] | Literal[False] = (), **kwargs: Any, ): """ @@ -229,6 +232,7 @@ def __new__( "box_intact_types": tuple(box_intact_types), "box_recast": box_recast, "box_dots": box_dots, + "box_dots_exclude": re.compile(box_dots_exclude) if box_dots_exclude else None, "box_class": box_class if box_class is not None else Box, "box_namespace": box_namespace, } @@ -248,11 +252,12 @@ def __init__( modify_tuples_box: bool = False, box_safe_prefix: str = "x", box_duplicates: str = "ignore", - box_intact_types: Union[Tuple, List] = (), - box_recast: Optional[Dict] = None, + box_intact_types: tuple | list = (), + box_recast: dict | None = None, box_dots: bool = False, - box_class: Optional[Union[Dict, Type["Box"]]] = None, - box_namespace: Union[Tuple[str, ...], Literal[False]] = (), + box_dots_exclude: str | None = None, + box_class: dict | type[Box] | None = None, + box_namespace: tuple[str, ...] | Literal[False] = (), **kwargs: Any, ): super().__init__() @@ -272,6 +277,7 @@ def __init__( "box_intact_types": tuple(box_intact_types), "box_recast": box_recast, "box_dots": box_dots, + "box_dots_exclude": re.compile(box_dots_exclude) if box_dots_exclude else None, "box_class": box_class if box_class is not None else self.__class__, "box_namespace": box_namespace, } @@ -307,9 +313,7 @@ def __add__(self, other: Mapping[Any, Any]): if not isinstance(other, dict): raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.") new_box = self.copy() - new_box._box_config["frozen_box"] = False - new_box.merge_update(other) # type: ignore[attr-defined] - new_box._box_config["frozen_box"] = self._box_config["frozen_box"] + new_box.merge_update(other, _force_unfrozen=True) # type: ignore[attr-defined] return new_box def __radd__(self, other: Mapping[Any, Any]): @@ -319,8 +323,7 @@ def __radd__(self, other: Mapping[Any, Any]): new_box = other.copy() if not isinstance(other, Box): new_box = self._box_config["box_class"](new_box) - new_box._box_config["frozen_box"] = False # type: ignore[attr-defined] - new_box.merge_update(self) # type: ignore[attr-defined] + new_box.merge_update(self, _force_unfrozen=True) # type: ignore[attr-defined] new_box._box_config["frozen_box"] = self._box_config["frozen_box"] # type: ignore[attr-defined] return new_box @@ -384,7 +387,7 @@ def __hash__(self): return hashing raise BoxTypeError('unhashable type: "Box"') - def __dir__(self) -> List[str]: + def __dir__(self) -> list[str]: items = set(super().__dir__()) # Only show items accessible by dot notation for key in self.keys(): @@ -419,7 +422,7 @@ def __contains__(self, item): it = self[first_item] return isinstance(it, Iterable) and children in it - def keys(self, dotted: Union[bool] = False): + def keys(self, dotted: bool = False): if not dotted: return super().keys() @@ -442,7 +445,7 @@ def keys(self, dotted: Union[bool] = False): keys.add(key) return sorted(keys, key=lambda x: str(x)) - def items(self, dotted: Union[bool] = False): + def items(self, dotted: bool = False): if not dotted: return super().items() @@ -465,15 +468,15 @@ def get(self, key, default=NO_DEFAULT): return default return self[key] - def copy(self) -> "Box": + def copy(self) -> Box: config = self.__box_config() config.pop("box_namespace") # Detach namespace; it will be reassigned if we nest again return Box(super().copy(), **config) - def __copy__(self) -> "Box": + def __copy__(self) -> Box: return self.copy() - def __deepcopy__(self, memodict=None) -> "Box": + def __deepcopy__(self, memodict=None) -> Box: frozen = self._box_config["frozen_box"] config = self.__box_config() config["frozen_box"] = False @@ -489,6 +492,14 @@ def __setstate__(self, state): self._box_config = state["_box_config"] self.__dict__.update(state) + def __process_dotted_key(self, item): + if self._box_config["box_dots"] and isinstance(item, str): + return ("[" in item) or ( + "." in item + and not (self._box_config["box_dots_exclude"] and self._box_config["box_dots_exclude"].match(item)) + ) + return False + def __get_default(self, item, attr=False): if item in ("getdoc", "shape") and _is_ipython(): return None @@ -526,7 +537,7 @@ def __get_default(self, item, attr=False): value = default_value if self._box_config["default_box_create_on_get"]: if not attr or not (item.startswith("_") and item.endswith("_")): - if self._box_config["box_dots"] and isinstance(item, str) and ("." in item or "[" in item): + if self.__process_dotted_key(item): first_item, children = _parse_box_dots(self, item, setting=True) if first_item in self.keys(): if hasattr(self[first_item], "__setitem__"): @@ -540,7 +551,7 @@ def __get_default(self, item, attr=False): super().__setitem__(item, value) return value - def __box_config(self, extra_namespace: Any = NO_NAMESPACE) -> Dict: + def __box_config(self, extra_namespace: Any = NO_NAMESPACE) -> dict: out = {} for k, v in self._box_config.copy().items(): if not k.startswith("__"): @@ -602,7 +613,7 @@ def __getitem__(self, item, _ignore_default=False): for x in list(super().keys())[item.start : item.stop : item.step]: new_box[x] = self[x] return new_box - if self._box_config["box_dots"] and isinstance(item, str) and ("." in item or "[" in item): + if self.__process_dotted_key(item): try: first_item, children = _parse_box_dots(self, item) except BoxError: @@ -652,7 +663,7 @@ def __getattr__(self, item): def __setitem__(self, key, value): if key != "_box_config" and self._box_config["frozen_box"] and self._box_config["__created"]: raise BoxError("Box is frozen") - if self._box_config["box_dots"] and isinstance(key, str) and ("." in key or "[" in key): + if self.__process_dotted_key(key): first_item, children = _parse_box_dots(self, key, setting=True) if first_item in self.keys(): if hasattr(self[first_item], "__setitem__"): @@ -696,12 +707,7 @@ def __setattr__(self, key, value): def __delitem__(self, key): if self._box_config["frozen_box"]: raise BoxError("Box is frozen") - if ( - key not in self.keys() - and self._box_config["box_dots"] - and isinstance(key, str) - and ("." in key or "[" in key) - ): + if key not in self.keys() and self.__process_dotted_key(key): try: first_item, children = _parse_box_dots(self, key) except BoxError: @@ -787,15 +793,15 @@ def __repr__(self) -> str: def __str__(self) -> str: return str(self.to_dict()) - def __iter__(self) -> Generator: + def __iter__(self) -> Generator: # type: ignore[type-arg] for key in self.keys(): yield key - def __reversed__(self) -> Generator: + def __reversed__(self) -> Generator: # type: ignore[type-arg] for key in reversed(list(self.keys())): yield key - def to_dict(self) -> Dict: + def to_dict(self) -> dict: """ Turn the Box and sub Boxes back into a native python dictionary. @@ -831,41 +837,54 @@ def merge_update(self, *args, **kwargs): merge_type = None if "box_merge_lists" in kwargs: merge_type = kwargs.pop("box_merge_lists") + force_unfrozen = kwargs.pop("_force_unfrozen", False) - def convert_and_set(k, v): - intact_type = self._box_config["box_intact_types"] and isinstance(v, self._box_config["box_intact_types"]) - if isinstance(v, dict) and not intact_type: - # Box objects must be created in case they are already - # in the `converted` box_config set - v = self._box_config["box_class"](v, **self.__box_config(extra_namespace=k)) - if k in self and isinstance(self[k], dict): - self[k].merge_update(v, box_merge_lists=merge_type) - return - if isinstance(v, list) and not intact_type: - v = box.BoxList(v, **self.__box_config(extra_namespace=k)) - if merge_type == "extend" and k in self and isinstance(self[k], list): - self[k].extend(v) - return - if merge_type == "unique" and k in self and isinstance(self[k], list): - for item in v: - if item not in self[k]: - self[k].append(item) - return - self.__setitem__(k, v) + was_frozen = self._box_config["frozen_box"] + if force_unfrozen: + self._box_config["frozen_box"] = False - if (len(args) + int(bool(kwargs))) > 1: - raise BoxTypeError(f"merge_update expected at most 1 argument, got {len(args) + int(bool(kwargs))}") - single_arg = next(iter(args), None) - if single_arg: - if hasattr(single_arg, "keys"): - for k in single_arg: - convert_and_set(k, single_arg[k]) - else: - for k, v in single_arg: - convert_and_set(k, v) + try: + + def convert_and_set(k, v): + intact_type = self._box_config["box_intact_types"] and isinstance( + v, self._box_config["box_intact_types"] + ) + if isinstance(v, dict) and not intact_type: + # Box objects must be created in case they are already + # in the `converted` box_config set + v = self._box_config["box_class"](v, **self.__box_config(extra_namespace=k)) + if k in self and isinstance(self[k], dict): + self[k].merge_update(v, box_merge_lists=merge_type, _force_unfrozen=force_unfrozen) + return + if isinstance(v, list) and not intact_type: + v = box.BoxList(v, **self.__box_config(extra_namespace=k)) + if merge_type == "extend" and k in self and isinstance(self[k], list): + self[k].extend(v) + return + if merge_type == "unique" and k in self and isinstance(self[k], list): + for item in v: + if item not in self[k]: + self[k].append(item) + return + self.__setitem__(k, v) + + if (len(args) + int(bool(kwargs))) > 1: + raise BoxTypeError(f"merge_update expected at most 1 argument, got {len(args) + int(bool(kwargs))}") + single_arg = next(iter(args), None) + if single_arg: + if hasattr(single_arg, "keys"): + for k in single_arg: + convert_and_set(k, single_arg[k]) + else: + for k, v in single_arg: + convert_and_set(k, v) - for key in kwargs: - convert_and_set(key, kwargs[key]) + for key in kwargs: + convert_and_set(key, kwargs[key]) + + finally: + if force_unfrozen: + self._box_config["frozen_box"] = was_frozen def setdefault(self, item, default=None): if item in self: @@ -947,7 +966,7 @@ def _conversion_checks(self, item): def to_json( self, - filename: Optional[Union[str, PathLike]] = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **json_kwargs, @@ -966,12 +985,12 @@ def to_json( @classmethod def from_json( cls, - json_string: Optional[str] = None, - filename: Optional[Union[str, PathLike]] = None, + json_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, - ) -> "Box": + ) -> Box: """ Transform a json object string into a Box object. If the incoming json is a list, you must use BoxList.from_json. @@ -998,10 +1017,11 @@ def from_json( def to_yaml( self, - filename: Optional[Union[str, PathLike]] = None, + filename: str | PathLike | None = None, default_flow_style: bool = False, encoding: str = "utf-8", errors: str = "strict", + width: int = 120, **yaml_kwargs, ): """ @@ -1011,6 +1031,7 @@ def to_yaml( :param default_flow_style: False will recursively dump dicts :param encoding: File encoding :param errors: How to handle encoding errors + :param width: Line width for YAML output :param yaml_kwargs: additional arguments to pass to yaml.dump :return: string of YAML (if no filename provided) """ @@ -1020,18 +1041,19 @@ def to_yaml( default_flow_style=default_flow_style, encoding=encoding, errors=errors, + width=width, **yaml_kwargs, ) @classmethod def from_yaml( cls, - yaml_string: Optional[str] = None, - filename: Optional[Union[str, PathLike]] = None, + yaml_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, - ) -> "Box": + ) -> Box: """ Transform a yaml object string into a Box object. By default will use SafeLoader. @@ -1058,10 +1080,11 @@ def from_yaml( def to_yaml( self, - filename: Optional[Union[str, PathLike]] = None, + filename: str | PathLike | None = None, default_flow_style: bool = False, encoding: str = "utf-8", errors: str = "strict", + width: int = 120, **yaml_kwargs, ): raise BoxError('yaml is unavailable on this system, please install the "ruamel.yaml" or "PyYAML" package') @@ -1069,19 +1092,17 @@ def to_yaml( @classmethod def from_yaml( cls, - yaml_string: Optional[str] = None, - filename: Optional[Union[str, PathLike]] = None, + yaml_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, - ) -> "Box": + ) -> Box: raise BoxError('yaml is unavailable on this system, please install the "ruamel.yaml" or "PyYAML" package') if toml_write_library is not None: - def to_toml( - self, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict" - ): + def to_toml(self, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict"): """ Transform the Box object into a toml string. @@ -1094,9 +1115,7 @@ def to_toml( else: - def to_toml( - self, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict" - ): + def to_toml(self, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict"): raise BoxError('toml is unavailable on this system, please install the "tomli-w" package') if toml_read_library is not None: @@ -1104,12 +1123,12 @@ def to_toml( @classmethod def from_toml( cls, - toml_string: Optional[str] = None, - filename: Optional[Union[str, PathLike]] = None, + toml_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, - ) -> "Box": + ) -> Box: """ Transforms a toml string or file into a Box object @@ -1133,17 +1152,17 @@ def from_toml( @classmethod def from_toml( cls, - toml_string: Optional[str] = None, - filename: Optional[Union[str, PathLike]] = None, + toml_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, - ) -> "Box": + ) -> Box: raise BoxError('toml is unavailable on this system, please install the "tomli" package') if msgpack_available: - def to_msgpack(self, filename: Optional[Union[str, PathLike]] = None, **kwargs): + def to_msgpack(self, filename: str | PathLike | None = None, **kwargs): """ Transform the Box object into a msgpack string. @@ -1156,10 +1175,10 @@ def to_msgpack(self, filename: Optional[Union[str, PathLike]] = None, **kwargs): @classmethod def from_msgpack( cls, - msgpack_bytes: Optional[bytes] = None, - filename: Optional[Union[str, PathLike]] = None, + msgpack_bytes: bytes | None = None, + filename: str | PathLike | None = None, **kwargs, - ) -> "Box": + ) -> Box: """ Transforms msgpack bytes or file into a Box object @@ -1180,16 +1199,79 @@ def from_msgpack( else: - def to_msgpack(self, filename: Optional[Union[str, PathLike]] = None, **kwargs): + def to_msgpack(self, filename: str | PathLike | None = None, **kwargs): raise BoxError('msgpack is unavailable on this system, please install the "msgpack" package') @classmethod def from_msgpack( cls, - msgpack_bytes: Optional[bytes] = None, - filename: Optional[Union[str, PathLike]] = None, + msgpack_bytes: bytes | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, - ) -> "Box": + ) -> Box: raise BoxError('msgpack is unavailable on this system, please install the "msgpack" package') + + if toon_available: + + def to_toon( + self, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs + ): + """ + Transform the Box object into a TOON string. + + :param filename: File to write TOON object too + :param encoding: File encoding + :param errors: How to handle encoding errors + :param kwargs: parameters to pass to `toon_format.encode` + :return: string of TOON (if no filename provided) + """ + return _to_toon(self.to_dict(), filename=filename, encoding=encoding, errors=errors, **kwargs) + + @classmethod + def from_toon( + cls, + toon_string: str | None = None, + filename: str | PathLike | None = None, + encoding: str = "utf-8", + errors: str = "strict", + **kwargs, + ) -> Box: + """ + Transforms a TOON string or file into a Box object + + :param toon_string: string to pass to `toon_format.decode` + :param filename: filename to open and pass to `toon_format.decode` + :param encoding: File encoding + :param errors: How to handle encoding errors + :param kwargs: parameters to pass to `Box()` + :return: Box object + """ + box_args = {} + for arg in kwargs.copy(): + if arg in BOX_PARAMETERS: + box_args[arg] = kwargs.pop(arg) + + data = _from_toon(toon_string=toon_string, filename=filename, encoding=encoding, errors=errors, **kwargs) + if not isinstance(data, dict): + raise BoxError(f"toon data not returned as a dictionary but rather a {type(data).__name__}") + return cls(data, **box_args) + + else: + + def to_toon( + self, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs + ): + raise BoxError('toon is unavailable on this system, please install the "toon_format" package') + + @classmethod + def from_toon( + cls, + toon_string: str | None = None, + filename: str | PathLike | None = None, + encoding: str = "utf-8", + errors: str = "strict", + **kwargs, + ) -> Box: + raise BoxError('toon is unavailable on this system, please install the "toon_format" package') diff --git a/box/box.pyi b/box/box.pyi index 9d56ab3..05f0906 100644 --- a/box/box.pyi +++ b/box/box.pyi @@ -1,7 +1,7 @@ from _typeshed import Incomplete -from collections.abc import Mapping +from collections.abc import Generator, Mapping from os import PathLike -from typing import Any, Dict, Generator, List, Optional, Tuple, Type, Union, Literal +from typing import Any, Literal class Box(dict): def __new__( @@ -17,11 +17,12 @@ class Box(dict): modify_tuples_box: bool = ..., box_safe_prefix: str = ..., box_duplicates: str = ..., - box_intact_types: Union[Tuple, List] = ..., - box_recast: Optional[Dict] = ..., + box_intact_types: tuple | list = ..., + box_recast: dict | None = ..., box_dots: bool = ..., - box_class: Optional[Union[Dict, Type["Box"]]] = ..., - box_namespace: Union[Tuple[str, ...], Literal[False]] = ..., + box_dots_exclude: str | None = ..., + box_class: dict | type[Box] | None = ..., + box_namespace: tuple[str, ...] | Literal[False] = ..., **kwargs: Any, ): ... def __init__( @@ -37,11 +38,12 @@ class Box(dict): modify_tuples_box: bool = ..., box_safe_prefix: str = ..., box_duplicates: str = ..., - box_intact_types: Union[Tuple, List] = ..., - box_recast: Optional[Dict] = ..., + box_intact_types: tuple | list = ..., + box_recast: dict | None = ..., box_dots: bool = ..., - box_class: Optional[Union[Dict, Type["Box"]]] = ..., - box_namespace: Union[Tuple[str, ...], Literal[False]] = ..., + box_dots_exclude: str | None = ..., + box_class: dict | type[Box] | None = ..., + box_namespace: tuple[str, ...] | Literal[False] = ..., **kwargs: Any, ) -> None: ... def __add__(self, other: Mapping[Any, Any]): ... @@ -52,10 +54,10 @@ class Box(dict): def __ior__(self, other: Mapping[Any, Any]): ... # type: ignore[override] def __sub__(self, other: Mapping[Any, Any]): ... def __hash__(self): ... - def __dir__(self) -> List[str]: ... + def __dir__(self) -> list[str]: ... def __contains__(self, item) -> bool: ... - def keys(self, dotted: Union[bool] = ...): ... - def items(self, dotted: Union[bool] = ...): ... + def keys(self, dotted: bool = ...): ... + def items(self, dotted: bool = ...): ... def get(self, key, default=...): ... def copy(self) -> Box: ... def __copy__(self) -> Box: ... @@ -71,51 +73,60 @@ class Box(dict): def popitem(self): ... def __iter__(self) -> Generator: ... def __reversed__(self) -> Generator: ... - def to_dict(self) -> Dict: ... + def to_dict(self) -> dict: ... def update(self, *args, **kwargs) -> None: ... def merge_update(self, *args, **kwargs) -> None: ... def setdefault(self, item, default: Incomplete | None = ...): ... - def to_json( - self, filename: Optional[Union[str, PathLike]] = ..., encoding: str = ..., errors: str = ..., **json_kwargs - ): ... + def to_json(self, filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ..., **json_kwargs): ... @classmethod def from_json( cls, - json_string: Optional[str] = ..., - filename: Optional[Union[str, PathLike]] = ..., + json_string: str | None = ..., + filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ..., **kwargs, ) -> Box: ... def to_yaml( self, - filename: Optional[Union[str, PathLike]] = ..., + filename: str | PathLike | None = ..., default_flow_style: bool = ..., encoding: str = ..., errors: str = ..., + width: int = ..., **yaml_kwargs, ): ... @classmethod def from_yaml( cls, - yaml_string: Optional[str] = ..., - filename: Optional[Union[str, PathLike]] = ..., + yaml_string: str | None = ..., + filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ..., **kwargs, ) -> Box: ... - def to_toml(self, filename: Optional[Union[str, PathLike]] = ..., encoding: str = ..., errors: str = ...): ... + def to_toml(self, filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ...): ... @classmethod def from_toml( cls, - toml_string: Optional[str] = ..., - filename: Optional[Union[str, PathLike]] = ..., + toml_string: str | None = ..., + filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ..., **kwargs, ) -> Box: ... - def to_msgpack(self, filename: Optional[Union[str, PathLike]] = ..., **kwargs): ... + def to_msgpack(self, filename: str | PathLike | None = ..., **kwargs): ... @classmethod def from_msgpack( - cls, msgpack_bytes: Optional[bytes] = ..., filename: Optional[Union[str, PathLike]] = ..., **kwargs + cls, msgpack_bytes: bytes | None = ..., filename: str | PathLike | None = ..., **kwargs + ) -> Box: ... + def to_toon(self, filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ..., **kwargs): ... + @classmethod + def from_toon( + cls, + toon_string: str | None = ..., + filename: str | PathLike | None = ..., + encoding: str = ..., + errors: str = ..., + **kwargs, ) -> Box: ... diff --git a/box/box_list.py b/box/box_list.py index fb00864..72609ff 100644 --- a/box/box_list.py +++ b/box/box_list.py @@ -1,11 +1,14 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- # -# Copyright (c) 2017-2023 - Chris Griffith - MIT License +# Copyright (c) 2017-2026 - Chris Griffith - MIT License +from __future__ import annotations + import copy import re +from collections.abc import Iterable from os import PathLike -from typing import Optional, Iterable, Type, Union, List, Any +from typing import Any import box from box.converters import ( @@ -14,13 +17,16 @@ _from_json, _from_msgpack, _from_toml, + _from_toon, _from_yaml, _to_csv, _to_json, _to_msgpack, _to_toml, + _to_toon, _to_yaml, msgpack_available, + toon_available, toml_read_library, yaml_available, ) @@ -43,7 +49,7 @@ def __new__(cls, *args, **kwargs): obj.box_org_ref = None return obj - def __init__(self, iterable: Optional[Iterable] = None, box_class: Type[box.Box] = box.Box, **box_options): + def __init__(self, iterable: Iterable | None = None, box_class: type[box.Box] = box.Box, **box_options): self.box_options = box_options self.box_options["box_class"] = box_class self.box_org_ref = iterable @@ -98,7 +104,7 @@ def __setitem__(self, key, value): self.extend([None] * (pos - len(self) + 1)) if len(list_pos.group()) == len(key): return super().__setitem__(pos, value) - children = key[len(list_pos.group()):].lstrip(".") + children = key[len(list_pos.group()) :].lstrip(".") if self.box_options.get("default_box"): if children[0] == "[": super().__setitem__(pos, box.BoxList(**self.box_options)) @@ -137,7 +143,7 @@ def extend(self, iterable): def insert(self, index, p_object): super().insert(index, self._convert(p_object)) - def _dotted_helper(self) -> List[str]: + def _dotted_helper(self) -> list[str]: keys = [] for idx, item in enumerate(self): added = False @@ -177,8 +183,8 @@ def __hash__(self) -> int: # type: ignore[override] return hashing raise BoxTypeError("unhashable type: 'BoxList'") - def to_list(self) -> List: - new_list: List[Any] = [] + def to_list(self) -> list: + new_list: list[Any] = [] for x in self: if x is self: new_list.append(new_list) @@ -192,7 +198,7 @@ def to_list(self) -> List: def to_json( self, - filename: Optional[Union[str, PathLike]] = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", multiline: bool = False, @@ -218,8 +224,8 @@ def to_json( @classmethod def from_json( cls, - json_string: Optional[str] = None, - filename: Optional[Union[str, PathLike]] = None, + json_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", multiline: bool = False, @@ -254,10 +260,11 @@ def from_json( def to_yaml( self, - filename: Optional[Union[str, PathLike]] = None, + filename: str | PathLike | None = None, default_flow_style: bool = False, encoding: str = "utf-8", errors: str = "strict", + width: int = 120, **yaml_kwargs, ): """ @@ -267,6 +274,7 @@ def to_yaml( :param default_flow_style: False will recursively dump dicts :param encoding: File encoding :param errors: How to handle encoding errors + :param width: Line width for YAML output :param yaml_kwargs: additional arguments to pass to yaml.dump :return: string of YAML or return of `yaml.dump` """ @@ -276,14 +284,15 @@ def to_yaml( default_flow_style=default_flow_style, encoding=encoding, errors=errors, + width=width, **yaml_kwargs, ) @classmethod def from_yaml( cls, - yaml_string: Optional[str] = None, - filename: Optional[Union[str, PathLike]] = None, + yaml_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, @@ -314,10 +323,11 @@ def from_yaml( def to_yaml( self, - filename: Optional[Union[str, PathLike]] = None, + filename: str | PathLike | None = None, default_flow_style: bool = False, encoding: str = "utf-8", errors: str = "strict", + width: int = 120, **yaml_kwargs, ): raise BoxError('yaml is unavailable on this system, please install the "ruamel.yaml" or "PyYAML" package') @@ -325,8 +335,8 @@ def to_yaml( @classmethod def from_yaml( cls, - yaml_string: Optional[str] = None, - filename: Optional[Union[str, PathLike]] = None, + yaml_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, @@ -337,7 +347,7 @@ def from_yaml( def to_toml( self, - filename: Optional[Union[str, PathLike]] = None, + filename: str | PathLike | None = None, key_name: str = "toml", encoding: str = "utf-8", errors: str = "strict", @@ -358,7 +368,7 @@ def to_toml( def to_toml( self, - filename: Optional[Union[str, PathLike]] = None, + filename: str | PathLike | None = None, key_name: str = "toml", encoding: str = "utf-8", errors: str = "strict", @@ -370,8 +380,8 @@ def to_toml( @classmethod def from_toml( cls, - toml_string: Optional[str] = None, - filename: Optional[Union[str, PathLike]] = None, + toml_string: str | None = None, + filename: str | PathLike | None = None, key_name: str = "toml", encoding: str = "utf-8", errors: str = "strict", @@ -404,8 +414,8 @@ def from_toml( @classmethod def from_toml( cls, - toml_string: Optional[str] = None, - filename: Optional[Union[str, PathLike]] = None, + toml_string: str | None = None, + filename: str | PathLike | None = None, key_name: str = "toml", encoding: str = "utf-8", errors: str = "strict", @@ -415,7 +425,7 @@ def from_toml( if msgpack_available: - def to_msgpack(self, filename: Optional[Union[str, PathLike]] = None, **kwargs): + def to_msgpack(self, filename: str | PathLike | None = None, **kwargs): """ Transform the BoxList object into a toml string. @@ -425,9 +435,7 @@ def to_msgpack(self, filename: Optional[Union[str, PathLike]] = None, **kwargs): return _to_msgpack(self.to_list(), filename=filename, **kwargs) @classmethod - def from_msgpack( - cls, msgpack_bytes: Optional[bytes] = None, filename: Optional[Union[str, PathLike]] = None, **kwargs - ): + def from_msgpack(cls, msgpack_bytes: bytes | None = None, filename: str | PathLike | None = None, **kwargs): """ Transforms a toml string or file into a BoxList object @@ -448,28 +456,91 @@ def from_msgpack( else: - def to_msgpack(self, filename: Optional[Union[str, PathLike]] = None, **kwargs): + def to_msgpack(self, filename: str | PathLike | None = None, **kwargs): raise BoxError('msgpack is unavailable on this system, please install the "msgpack" package') @classmethod def from_msgpack( cls, - msgpack_bytes: Optional[bytes] = None, - filename: Optional[Union[str, PathLike]] = None, + msgpack_bytes: bytes | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, ): raise BoxError('msgpack is unavailable on this system, please install the "msgpack" package') - def to_csv(self, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict"): + if toon_available: + + def to_toon( + self, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs + ): + """ + Transform the BoxList object into a TOON string. + + :param filename: File to write TOON object too + :param encoding: File encoding + :param errors: How to handle encoding errors + :param kwargs: parameters to pass to `toon_format.encode` + :return: string of TOON (if no filename provided) + """ + return _to_toon(self.to_list(), filename=filename, encoding=encoding, errors=errors, **kwargs) + + @classmethod + def from_toon( + cls, + toon_string: str | None = None, + filename: str | PathLike | None = None, + encoding: str = "utf-8", + errors: str = "strict", + **kwargs, + ): + """ + Transforms a TOON string or file into a BoxList object + + :param toon_string: string to pass to `toon_format.decode` + :param filename: filename to open and pass to `toon_format.decode` + :param encoding: File encoding + :param errors: How to handle encoding errors + :param kwargs: parameters to pass to `BoxList()` + :return: BoxList object + """ + box_args = {} + for arg in list(kwargs.keys()): + if arg in BOX_PARAMETERS: + box_args[arg] = kwargs.pop(arg) + + data = _from_toon(toon_string=toon_string, filename=filename, encoding=encoding, errors=errors, **kwargs) + if not isinstance(data, list): + raise BoxError(f"toon data not returned as a list but rather a {type(data).__name__}") + return cls(data, **box_args) + + else: + + def to_toon( + self, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs + ): + raise BoxError('toon is unavailable on this system, please install the "toon_format" package') + + @classmethod + def from_toon( + cls, + toon_string: str | None = None, + filename: str | PathLike | None = None, + encoding: str = "utf-8", + errors: str = "strict", + **kwargs, + ): + raise BoxError('toon is unavailable on this system, please install the "toon_format" package') + + def to_csv(self, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict"): return _to_csv(self, filename=filename, encoding=encoding, errors=errors) @classmethod def from_csv( cls, - csv_string: Optional[str] = None, - filename: Optional[Union[str, PathLike]] = None, + csv_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", ): diff --git a/box/box_list.pyi b/box/box_list.pyi index 982093f..a9e769e 100644 --- a/box/box_list.pyi +++ b/box/box_list.pyi @@ -6,28 +6,29 @@ from box.converters import ( toml_write_library as toml_write_library, yaml_available as yaml_available, ) +from collections.abc import Iterable from os import PathLike as PathLike -from typing import Any, Iterable, Optional, Type, Union, List +from typing import Any class BoxList(list): def __new__(cls, *args: Any, **kwargs: Any): ... box_options: Any box_org_ref: Any - def __init__(self, iterable: Iterable = ..., box_class: Type[box.Box] = ..., **box_options: Any) -> None: ... + def __init__(self, iterable: Iterable = ..., box_class: type[box.Box] = ..., **box_options: Any) -> None: ... def __getitem__(self, item: Any): ... def __delitem__(self, key: Any): ... def __setitem__(self, key: Any, value: Any): ... def append(self, p_object: Any) -> None: ... def extend(self, iterable: Any) -> None: ... def insert(self, index: Any, p_object: Any) -> None: ... - def __copy__(self) -> "BoxList": ... - def __deepcopy__(self, memo: Optional[Any] = ...) -> "BoxList": ... + def __copy__(self) -> BoxList: ... + def __deepcopy__(self, memo: Any | None = ...) -> BoxList: ... def __hash__(self) -> int: ... # type: ignore[override] - def to_list(self) -> List: ... - def _dotted_helper(self) -> List[str]: ... + def to_list(self) -> list: ... + def _dotted_helper(self) -> list[str]: ... def to_json( self, - filename: Union[str, PathLike] = ..., + filename: str | PathLike = ..., encoding: str = ..., errors: str = ..., multiline: bool = ..., @@ -37,7 +38,7 @@ class BoxList(list): def from_json( cls, json_string: str = ..., - filename: Union[str, PathLike] = ..., + filename: str | PathLike = ..., encoding: str = ..., errors: str = ..., multiline: bool = ..., @@ -45,39 +46,50 @@ class BoxList(list): ) -> Any: ... def to_yaml( self, - filename: Union[str, PathLike] = ..., + filename: str | PathLike = ..., default_flow_style: bool = ..., encoding: str = ..., errors: str = ..., + width: int = ..., **yaml_kwargs: Any, ) -> Any: ... @classmethod def from_yaml( cls, yaml_string: str = ..., - filename: Union[str, PathLike] = ..., + filename: str | PathLike = ..., encoding: str = ..., errors: str = ..., **kwargs: Any, ) -> Any: ... def to_toml( - self, filename: Union[str, PathLike] = ..., key_name: str = ..., encoding: str = ..., errors: str = ... + self, filename: str | PathLike = ..., key_name: str = ..., encoding: str = ..., errors: str = ... ) -> Any: ... @classmethod def from_toml( cls, toml_string: str = ..., - filename: Union[str, PathLike] = ..., + filename: str | PathLike = ..., key_name: str = ..., encoding: str = ..., errors: str = ..., **kwargs: Any, ) -> Any: ... - def to_msgpack(self, filename: Union[str, PathLike] = ..., **kwargs: Any) -> Any: ... + def to_msgpack(self, filename: str | PathLike = ..., **kwargs: Any) -> Any: ... @classmethod - def from_msgpack(cls, msgpack_bytes: bytes = ..., filename: Union[str, PathLike] = ..., **kwargs: Any) -> Any: ... - def to_csv(self, filename: Union[str, PathLike] = ..., encoding: str = ..., errors: str = ...) -> Any: ... + def from_msgpack(cls, msgpack_bytes: bytes = ..., filename: str | PathLike = ..., **kwargs: Any) -> Any: ... + def to_toon(self, filename: str | PathLike = ..., encoding: str = ..., errors: str = ..., **kwargs: Any) -> Any: ... + @classmethod + def from_toon( + cls, + toon_string: str = ..., + filename: str | PathLike = ..., + encoding: str = ..., + errors: str = ..., + **kwargs: Any, + ) -> Any: ... + def to_csv(self, filename: str | PathLike = ..., encoding: str = ..., errors: str = ...) -> Any: ... @classmethod def from_csv( - cls, csv_string: str = ..., filename: Union[str, PathLike] = ..., encoding: str = ..., errors: str = ... + cls, csv_string: str = ..., filename: str | PathLike = ..., encoding: str = ..., errors: str = ... ) -> Any: ... diff --git a/box/config_box.py b/box/config_box.py index 4c48877..5f8ad55 100644 --- a/box/config_box.py +++ b/box/config_box.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from typing import List +from __future__ import annotations from box.box import Box @@ -30,7 +30,7 @@ def __getattr__(self, item): except AttributeError: return super().__getattr__(item.lower()) - def __dir__(self) -> List[str]: + def __dir__(self) -> list[str]: return super().__dir__() + ["bool", "int", "float", "list", "getboolean", "getfloat", "getint"] def bool(self, item, default=None): diff --git a/box/config_box.pyi b/box/config_box.pyi index 1022f1b..6d72f27 100644 --- a/box/config_box.pyi +++ b/box/config_box.pyi @@ -1,15 +1,15 @@ from box.box import Box as Box -from typing import Any, Optional, List +from typing import Any class ConfigBox(Box): def __getattr__(self, item: Any): ... - def __dir__(self) -> List[str]: ... - def bool(self, item: Any, default: Optional[Any] = ...): ... - def int(self, item: Any, default: Optional[Any] = ...): ... - def float(self, item: Any, default: Optional[Any] = ...): ... - def list(self, item: Any, default: Optional[Any] = ..., spliter: str = ..., strip: bool = ..., mod: Optional[Any] = ...): ... # type: ignore - def getboolean(self, item: Any, default: Optional[Any] = ...): ... - def getint(self, item: Any, default: Optional[Any] = ...): ... - def getfloat(self, item: Any, default: Optional[Any] = ...): ... - def copy(self) -> "ConfigBox": ... - def __copy__(self) -> "ConfigBox": ... + def __dir__(self) -> list[str]: ... + def bool(self, item: Any, default: Any | None = ...): ... + def int(self, item: Any, default: Any | None = ...): ... + def float(self, item: Any, default: Any | None = ...): ... + def list(self, item: Any, default: Any | None = ..., spliter: str = ..., strip: bool = ..., mod: Any | None = ...): ... # type: ignore + def getboolean(self, item: Any, default: Any | None = ...): ... + def getint(self, item: Any, default: Any | None = ...): ... + def getfloat(self, item: Any, default: Any | None = ...): ... + def copy(self) -> ConfigBox: ... + def __copy__(self) -> ConfigBox: ... diff --git a/box/converters.py b/box/converters.py index 80c1ced..bb91ba6 100644 --- a/box/converters.py +++ b/box/converters.py @@ -1,14 +1,16 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +from __future__ import annotations # Abstract converter functions for use in any Box class import csv import json +from collections.abc import Callable from io import StringIO from os import PathLike from pathlib import Path -from typing import Union, Optional, Dict, Any, Callable +from typing import Any from box.exceptions import BoxError @@ -31,9 +33,9 @@ MISSING_PARSER_ERROR = "No YAML Parser available, please install ruamel.yaml>=0.17 or PyYAML" -toml_read_library: Optional[Any] = None -toml_write_library: Optional[Any] = None -toml_decode_error: Optional[Callable] = None +toml_read_library: Any | None = None +toml_write_library: Any | None = None +toml_decode_error: Callable | None = None __all__ = [ "_to_json", @@ -41,11 +43,13 @@ "_to_toml", "_to_csv", "_to_msgpack", + "_to_toon", "_from_json", "_from_yaml", "_from_toml", "_from_csv", "_from_msgpack", + "_from_toon", ] @@ -104,6 +108,13 @@ class BoxTomlDecodeError(BoxError, tomli.TOMLDecodeError): # type: ignore msgpack = None # type: ignore msgpack_available = False +toon_available = True + +try: + from toon_format import encode as toon_encode, decode as toon_decode +except ImportError: + toon_available = False + yaml_available = pyyaml_available or ruamel_available BOX_PARAMETERS = ( @@ -119,13 +130,14 @@ class BoxTomlDecodeError(BoxError, tomli.TOMLDecodeError): # type: ignore "box_duplicates", "box_intact_types", "box_dots", + "box_dots_exclude", "box_recast", "box_class", "box_namespace", ) -def _exists(filename: Union[str, PathLike], create: bool = False) -> Path: +def _exists(filename: str | PathLike, create: bool = False) -> Path: path = Path(filename) if create: try: @@ -142,7 +154,7 @@ def _exists(filename: Union[str, PathLike], create: bool = False) -> Path: def _to_json( - obj, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **json_kwargs + obj, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **json_kwargs ): if filename: _exists(filename, create=True) @@ -153,8 +165,8 @@ def _to_json( def _from_json( - json_string: Optional[str] = None, - filename: Optional[Union[str, PathLike]] = None, + json_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", multiline: bool = False, @@ -179,12 +191,13 @@ def _from_json( def _to_yaml( obj, - filename: Optional[Union[str, PathLike]] = None, + filename: str | PathLike | None = None, default_flow_style: bool = False, encoding: str = "utf-8", errors: str = "strict", ruamel_typ: str = "rt", - ruamel_attrs: Optional[Dict] = None, + ruamel_attrs: dict | None = None, + width: int = 120, **yaml_kwargs, ): if not ruamel_attrs: @@ -195,11 +208,12 @@ def _to_yaml( if ruamel_available: yaml_dumper = YAML(typ=ruamel_typ) yaml_dumper.default_flow_style = default_flow_style + yaml_dumper.width = width for attr, value in ruamel_attrs.items(): setattr(yaml_dumper, attr, value) return yaml_dumper.dump(obj, stream=f, **yaml_kwargs) elif pyyaml_available: - return yaml.dump(obj, stream=f, default_flow_style=default_flow_style, **yaml_kwargs) + return yaml.dump(obj, stream=f, default_flow_style=default_flow_style, width=width, **yaml_kwargs) else: raise BoxError(MISSING_PARSER_ERROR) @@ -207,24 +221,25 @@ def _to_yaml( if ruamel_available: yaml_dumper = YAML(typ=ruamel_typ) yaml_dumper.default_flow_style = default_flow_style + yaml_dumper.width = width for attr, value in ruamel_attrs.items(): setattr(yaml_dumper, attr, value) with StringIO() as string_stream: yaml_dumper.dump(obj, stream=string_stream, **yaml_kwargs) return string_stream.getvalue() elif pyyaml_available: - return yaml.dump(obj, default_flow_style=default_flow_style, **yaml_kwargs) + return yaml.dump(obj, default_flow_style=default_flow_style, width=width, **yaml_kwargs) else: raise BoxError(MISSING_PARSER_ERROR) def _from_yaml( - yaml_string: Optional[str] = None, - filename: Optional[Union[str, PathLike]] = None, + yaml_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", ruamel_typ: str = "rt", - ruamel_attrs: Optional[Dict] = None, + ruamel_attrs: dict | None = None, **kwargs, ): if not ruamel_attrs: @@ -260,7 +275,7 @@ def _from_yaml( return data -def _to_toml(obj, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict"): +def _to_toml(obj, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict"): if filename: _exists(filename, create=True) if toml_write_library.__name__ == "toml": # type: ignore @@ -283,8 +298,8 @@ def _to_toml(obj, filename: Optional[Union[str, PathLike]] = None, encoding: str def _from_toml( - toml_string: Optional[str] = None, - filename: Optional[Union[str, PathLike]] = None, + toml_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", ): @@ -303,7 +318,7 @@ def _from_toml( return data -def _to_msgpack(obj, filename: Optional[Union[str, PathLike]] = None, **kwargs): +def _to_msgpack(obj, filename: str | PathLike | None = None, **kwargs): if filename: _exists(filename, create=True) with open(filename, "wb") as f: @@ -312,7 +327,7 @@ def _to_msgpack(obj, filename: Optional[Union[str, PathLike]] = None, **kwargs): return msgpack.packb(obj, **kwargs) -def _from_msgpack(msgpack_bytes: Optional[bytes] = None, filename: Optional[Union[str, PathLike]] = None, **kwargs): +def _from_msgpack(msgpack_bytes: bytes | None = None, filename: str | PathLike | None = None, **kwargs): if filename: _exists(filename) with open(filename, "rb") as f: @@ -324,8 +339,35 @@ def _from_msgpack(msgpack_bytes: Optional[bytes] = None, filename: Optional[Unio return data +def _to_toon(obj, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs): + if filename: + _exists(filename, create=True) + with open(filename, "w", encoding=encoding, errors=errors) as f: + f.write(toon_encode(obj, **kwargs)) + else: + return toon_encode(obj, **kwargs) + + +def _from_toon( + toon_string: str | None = None, + filename: str | PathLike | None = None, + encoding: str = "utf-8", + errors: str = "strict", + **kwargs, +): + if filename: + _exists(filename) + with open(filename, "r", encoding=encoding, errors=errors) as f: + data = toon_decode(f.read(), **kwargs) + elif toon_string: + data = toon_decode(toon_string, **kwargs) + else: + raise BoxError("from_toon requires a string or filename") + return data + + def _to_csv( - box_list, filename: Optional[Union[str, PathLike]] = None, encoding: str = "utf-8", errors: str = "strict", **kwargs + box_list, filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs ): csv_column_names = list(box_list[0].keys()) for row in box_list: @@ -347,8 +389,8 @@ def _to_csv( def _from_csv( - csv_string: Optional[str] = None, - filename: Optional[Union[str, PathLike]] = None, + csv_string: str | None = None, + filename: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, diff --git a/box/converters.pyi b/box/converters.pyi index 43d2020..975b902 100644 --- a/box/converters.pyi +++ b/box/converters.pyi @@ -1,20 +1,20 @@ -from typing import Any, Callable, Optional, Union, Dict +from collections.abc import Callable from os import PathLike +from typing import Any yaml_available: bool toml_available: bool msgpack_available: bool +toon_available: bool BOX_PARAMETERS: Any -toml_read_library: Optional[Any] -toml_write_library: Optional[Any] -toml_decode_error: Optional[Callable] +toml_read_library: Any | None +toml_write_library: Any | None +toml_decode_error: Callable | None -def _to_json( - obj, filename: Optional[Union[str, PathLike]] = ..., encoding: str = ..., errors: str = ..., **json_kwargs -): ... +def _to_json(obj, filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ..., **json_kwargs): ... def _from_json( - json_string: Optional[str] = ..., - filename: Optional[Union[str, PathLike]] = ..., + json_string: str | None = ..., + filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ..., multiline: bool = ..., @@ -22,38 +22,45 @@ def _from_json( ): ... def _to_yaml( obj, - filename: Optional[Union[str, PathLike]] = ..., + filename: str | PathLike | None = ..., default_flow_style: bool = ..., encoding: str = ..., errors: str = ..., ruamel_typ: str = ..., - ruamel_attrs: Optional[Dict] = ..., + ruamel_attrs: dict | None = ..., + width: int = ..., **yaml_kwargs, ): ... def _from_yaml( - yaml_string: Optional[str] = ..., - filename: Optional[Union[str, PathLike]] = ..., + yaml_string: str | None = ..., + filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ..., ruamel_typ: str = ..., - ruamel_attrs: Optional[Dict] = ..., + ruamel_attrs: dict | None = ..., **kwargs, ): ... -def _to_toml(obj, filename: Optional[Union[str, PathLike]] = ..., encoding: str = ..., errors: str = ...): ... +def _to_toml(obj, filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ...): ... def _from_toml( - toml_string: Optional[str] = ..., - filename: Optional[Union[str, PathLike]] = ..., + toml_string: str | None = ..., + filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ..., ): ... -def _to_msgpack(obj, filename: Optional[Union[str, PathLike]] = ..., **kwargs): ... -def _from_msgpack(msgpack_bytes: Optional[bytes] = ..., filename: Optional[Union[str, PathLike]] = ..., **kwargs): ... -def _to_csv( - box_list, filename: Optional[Union[str, PathLike]] = ..., encoding: str = ..., errors: str = ..., **kwargs +def _to_msgpack(obj, filename: str | PathLike | None = ..., **kwargs): ... +def _from_msgpack(msgpack_bytes: bytes | None = ..., filename: str | PathLike | None = ..., **kwargs): ... +def _to_toon(obj, filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ..., **kwargs): ... +def _from_toon( + toon_string: str | None = ..., + filename: str | PathLike | None = ..., + encoding: str = ..., + errors: str = ..., + **kwargs, ): ... +def _to_csv(box_list, filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ..., **kwargs): ... def _from_csv( - csv_string: Optional[str] = ..., - filename: Optional[Union[str, PathLike]] = ..., + csv_string: str | None = ..., + filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ..., **kwargs, diff --git a/box/from_file.py b/box/from_file.py index 8f4ce68..3fb7a6e 100644 --- a/box/from_file.py +++ b/box/from_file.py @@ -1,14 +1,16 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +from __future__ import annotations + +import sys +from collections.abc import Callable from json import JSONDecodeError from os import PathLike from pathlib import Path -from typing import Optional, Callable, Dict, Union -import sys from box.box import Box from box.box_list import BoxList -from box.converters import msgpack_available, toml_read_library, yaml_available, toml_decode_error +from box.converters import msgpack_available, toon_available, toml_read_library, yaml_available, toml_decode_error from box.exceptions import BoxError try: @@ -24,6 +26,11 @@ except ImportError: UnpackException = False # type: ignore +try: + from toon_format import ToonDecodeError # type: ignore +except ImportError: + ToonDecodeError = False # type: ignore + __all__ = ["box_from_file", "box_from_string"] @@ -74,6 +81,17 @@ def _to_msgpack(file, _, __, **kwargs): return BoxList.from_msgpack(filename=file, **kwargs) +def _to_toon(file, encoding, errors, **kwargs): + if not toon_available: + raise BoxError(f'File "{file}" is toon but no package is available to open it. Please install "toon_format"') + try: + return Box.from_toon(filename=file, encoding=encoding, errors=errors, **kwargs) + except (ToonDecodeError, ValueError): + raise BoxError("File is not TOON as expected") + except BoxError: + return BoxList.from_toon(filename=file, encoding=encoding, errors=errors, **kwargs) + + converters = { "json": _to_json, "jsn": _to_json, @@ -81,19 +99,20 @@ def _to_msgpack(file, _, __, **kwargs): "yml": _to_yaml, "toml": _to_toml, "tml": _to_toml, + "toon": _to_toon, "msgpack": _to_msgpack, "pack": _to_msgpack, "csv": _to_csv, -} # type: Dict[str, Callable] +} # type: dict[str, Callable] def box_from_file( - file: Union[str, PathLike], - file_type: Optional[str] = None, + file: str | PathLike, + file_type: str | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs, -) -> Union[Box, BoxList]: +) -> Box | BoxList: """ Loads the provided file and tries to parse it into a Box or BoxList object as appropriate. @@ -112,10 +131,10 @@ def box_from_file( file_type = file_type.lower().lstrip(".") if file_type.lower() in converters: return converters[file_type.lower()](file, encoding, errors, **kwargs) # type: ignore - raise BoxError(f'"{file_type}" is an unknown type. Please use either csv, toml, msgpack, yaml or json') + raise BoxError(f'"{file_type}" is an unknown type. Please use either csv, toon, toml, msgpack, yaml or json') -def box_from_string(content: str, string_type: str = "json") -> Union[Box, BoxList]: +def box_from_string(content: str, string_type: str = "json") -> Box | BoxList: """ Parse the provided string into a Box or BoxList object as appropriate. @@ -145,5 +164,14 @@ def box_from_string(content: str, string_type: str = "json") -> Union[Box, BoxLi raise BoxError("File is not YAML as expected") except BoxError: return BoxList.from_yaml(yaml_string=content) + elif string_type == "toon": + if not toon_available: + raise BoxError('toon is unavailable on this system, please install the "toon_format" package') + try: + return Box.from_toon(toon_string=content) + except (ToonDecodeError, ValueError): + raise BoxError("String is not TOON as expected") + except BoxError: + return BoxList.from_toon(toon_string=content) else: raise BoxError(f"Unsupported string_string of {string_type}") diff --git a/box/from_file.pyi b/box/from_file.pyi index 9e8be8a..bae0c57 100644 --- a/box/from_file.pyi +++ b/box/from_file.pyi @@ -1,16 +1,16 @@ from box.box import Box as Box from box.box_list import BoxList as BoxList from os import PathLike -from typing import Any, Union +from typing import Any def box_from_file( - file: Union[str, PathLike], + file: str | PathLike, file_type: str = ..., encoding: str = ..., errors: str = ..., **kwargs: Any, -) -> Union[Box, BoxList]: ... +) -> Box | BoxList: ... def box_from_string( content: str, string_type: str = ..., -) -> Union[Box, BoxList]: ... +) -> Box | BoxList: ... diff --git a/box/shorthand_box.py b/box/shorthand_box.py index a82edbd..aecfcc5 100644 --- a/box/shorthand_box.py +++ b/box/shorthand_box.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from typing import Dict +from __future__ import annotations from box.box import Box @@ -28,7 +28,7 @@ class SBox(Box): ] @property - def dict(self) -> Dict: + def dict(self) -> dict: return self.to_dict() @property @@ -46,10 +46,10 @@ def toml(self) -> str: def __repr__(self): return f"{self.__class__.__name__}({self})" - def copy(self) -> "SBox": + def copy(self) -> SBox: return SBox(super(SBox, self).copy()) - def __copy__(self) -> "SBox": + def __copy__(self) -> SBox: return SBox(super(SBox, self).copy()) diff --git a/box/shorthand_box.pyi b/box/shorthand_box.pyi index deef693..be577f2 100644 --- a/box/shorthand_box.pyi +++ b/box/shorthand_box.pyi @@ -1,17 +1,15 @@ -from typing import Dict - from box.box import Box as Box class SBox(Box): @property - def dict(self) -> Dict: ... + def dict(self) -> dict: ... @property def json(self) -> str: ... @property def yaml(self) -> str: ... @property def toml(self) -> str: ... - def copy(self) -> "SBox": ... - def __copy__(self) -> "SBox": ... + def copy(self) -> SBox: ... + def __copy__(self) -> SBox: ... class DDBox(Box): ... diff --git a/requirements-dev.txt b/requirements-dev.txt index 617f8b9..0a430f9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,3 +4,4 @@ Cython>=3.0.11 mypy>=1.0.1 pre-commit>=2.21.0 setuptools>=75.6.0 +toon_format @ git+https://github.com/toon-format/toon-python.git diff --git a/requirements-test.txt b/requirements-test.txt index 0952d6f..071ec6d 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -2,8 +2,9 @@ coverage>=7.6.9 msgpack>=1.0 pytest>=7.1.3 pytest-cov<6.0.0 -ruamel.yaml>=0.17 +ruamel.yaml>=0.19.1 tomli>=1.2.3; python_version < '3.11' tomli-w>=1.0.0 +toon_format @ git+https://github.com/toon-format/toon-python.git types-PyYAML>=6.0.3 wheel>=0.34.2 diff --git a/requirements.txt b/requirements.txt index 57e2f36..8a77de2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ msgpack>=1.0.0 -ruamel.yaml>=0.17 +ruamel.yaml>=0.19.1 tomli>=1.2.3; python_version < '3.11' tomli-w diff --git a/setup.py b/setup.py index 8abda61..3898da7 100644 --- a/setup.py +++ b/setup.py @@ -51,11 +51,11 @@ classifiers=[ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Development Status :: 5 - Production/Stable", "Natural Language :: English", @@ -67,9 +67,9 @@ "Topic :: Software Development :: Libraries :: Python Modules", ], extras_require={ - "all": ["ruamel.yaml>=0.17", "toml", "msgpack"], - "yaml": ["ruamel.yaml>=0.17"], - "ruamel.yaml": ["ruamel.yaml>=0.17"], + "all": ["ruamel.yaml>=0.19.1", "toml", "msgpack"], + "yaml": ["ruamel.yaml>=0.19.1"], + "ruamel.yaml": ["ruamel.yaml>=0.19.1"], "PyYAML": ["PyYAML"], "tomli": ["tomli; python_version < '3.11'", "tomli-w"], "toml": ["toml"], diff --git a/test/common.py b/test/common.py index b50eee0..7688be1 100644 --- a/test/common.py +++ b/test/common.py @@ -33,6 +33,7 @@ tmp_json_file = os.path.join(test_root, "tmp", "tmp_json_file.json") tmp_yaml_file = os.path.join(test_root, "tmp", "tmp_yaml_file.yaml") tmp_msgpack_file = os.path.join(test_root, "tmp", "tmp_msgpack_file.msgpack") +tmp_toon_file = os.path.join(test_root, "tmp", "tmp_toon_file.toon") movie_data = { "movies": { diff --git a/test/test_box.py b/test/test_box.py index 1232f3c..e5c56e0 100644 --- a/test/test_box.py +++ b/test/test_box.py @@ -20,6 +20,7 @@ tmp_dir, tmp_json_file, tmp_msgpack_file, + tmp_toon_file, tmp_yaml_file, ) @@ -221,6 +222,14 @@ def test_to_yaml_file(self): data = yaml.load(f) assert data == test_dict + def test_to_yaml_width(self): + long_value = "a " * 80 # 160 character string + a = Box({"key": long_value.strip()}) + narrow = a.to_yaml(width=40) + wide = a.to_yaml(width=200) + # With width=200, the value should fit on fewer lines than width=40 + assert len(wide.splitlines()) < len(narrow.splitlines()) + def test_dir(self): a = Box(test_dict, camel_killer_box=True) assert "key1" in dir(a) @@ -936,6 +945,18 @@ def test_dots(self): with pytest.raises(BoxKeyError): del b["a.b"] + def test_dots_exclusion(self): + bx = Box.from_yaml( + yaml_string="0.0.0.1: True", + default_box=True, + default_box_none_transform=False, + box_dots=True, + box_dots_exclude=r"[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+", + ) + assert bx["0.0.0.1"] == True + with pytest.raises(BoxKeyError): + del bx["0"] + def test_unicode(self): bx = Box() bx["\U0001f631"] = 4 @@ -984,6 +1005,25 @@ def test_add_boxes(self): with pytest.raises(BoxError): Box() + BoxList() + def test_add_frozen_boxes(self): + b = Box(c=1, d={"sub": 1}, e=1, frozen_box=True) + c = dict(d={"val": 2}, e=4) + assert b + c == Box(c=1, d={"sub": 1, "val": 2}, e=4) + + def test_adding_frozen_boxes_result_in_frozen_box(self): + a = Box({"one": 1}, frozen_box=True) + b = Box({"two": 2}, frozen_box=True) + c = a + b + with pytest.raises(BoxError): + c.three = 3 + + def test_adding_nested_frozen_boxes_result_in_frozen_box(self): + a = Box({"one": {"two": "1.2"}}, frozen_box=True) + b = Box({"one": {"three": "1.3"}}, frozen_box=True) + c = a + b + with pytest.raises(BoxError): + c.one.four = "1.4" + def test_iadd_boxes(self): b = Box(c=1, d={"sub": 1}, e=1) c = dict(d={"val": 2}, e=4) @@ -1173,6 +1213,26 @@ def test_msgpack_no_input(self): with pytest.raises(BoxError): Box.from_msgpack() + def test_toon_strings(self): + box1 = Box(test_dict) + toon_str = box1.to_toon() + assert Box.from_toon(toon_str) == box1 + + def test_toon_files(self): + box1 = Box(test_dict) + box1.to_toon(filename=tmp_toon_file) + assert Box.from_toon(filename=tmp_toon_file) == box1 + + def test_toon_no_input(self): + with pytest.raises(BoxError): + Box.from_toon() + + def test_toon_from_toon_with_box_args(self): + box1 = Box(test_dict) + toon_str = box1.to_toon() + box2 = Box.from_toon(toon_str, default_box=True) + assert box2.nonexistent == Box() + def test_value_view(self): a = Box() my_view = a.values() diff --git a/test/test_box_list.py b/test/test_box_list.py index 99c65d7..9cd90e0 100644 --- a/test/test_box_list.py +++ b/test/test_box_list.py @@ -161,6 +161,24 @@ def test_bad_csv(self): with pytest.raises(BoxError): data.to_csv(file) + def test_toon_strings(self): + bl = BoxList([{"item": 1, "name": "test"}, {"item": 2, "name": "two"}]) + toon_str = bl.to_toon() + result = BoxList.from_toon(toon_str) + assert result[0]["item"] == 1 + assert result[1]["name"] == "two" + + def test_toon_files(self): + bl = BoxList([{"item": 1, "name": "test"}, {"item": 2, "name": "two"}]) + file = Path(tmp_dir, "toon_file.toon") + bl.to_toon(filename=file) + result = BoxList.from_toon(filename=file) + assert result[0]["item"] == 1 + + def test_toon_no_input(self): + with pytest.raises(BoxError): + BoxList.from_toon() + def test_box_list_dots(self): data = BoxList( [ diff --git a/test/test_converters.py b/test/test_converters.py index 014f1a4..d66b75b 100644 --- a/test/test_converters.py +++ b/test/test_converters.py @@ -94,11 +94,5 @@ def test_to_msgpack(self): def test_to_yaml_ruamel(self): movie_string = _to_yaml(movie_data, ruamel_attrs={"width": 12}) - multiline_except = """ - name: Roger - Rees - imdb: nm0715953 - role: Sheriff - of Rottingham - - name: Amy - Yasbeck""" + multiline_except = """ - name: \n Roger\n Rees\n imdb: \n nm0715953\n role: \n Sheriff\n of \n Rottingham\n - name: \n Amy \n Yasbeck""" assert multiline_except in movie_string From a4c10e977b574114613431394b30412b50aaacce Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Sat, 21 Feb 2026 10:19:51 -0600 Subject: [PATCH 17/17] Version 7.4.1 (#304) --- CHANGES.rst | 10 ++++++++++ box/__init__.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 07f555f..c4a347d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,11 @@ Changelog ========= +Version 7.4.1 +------------- + +* Fixing #303 Wrong version number (7.3.3) in 7.4.0 release (thanks to Michał Górny) + Version 7.4.0 ------------- @@ -11,6 +16,11 @@ Version 7.4.0 * Fixing #291 adding frozen boxes (thanks to m-janicki) * Removing support for Python 3.9 as it is EOL +Version 7.3.3 +------------- + +* Mistakenly released 7.4.0 as 7.3.3 in PyPI, same as above + Version 7.3.2 ------------- diff --git a/box/__init__.py b/box/__init__.py index 8d9d2b5..23ebd2e 100644 --- a/box/__init__.py +++ b/box/__init__.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- __author__ = "Chris Griffith" -__version__ = "7.3.3" +__version__ = "7.4.1" from box.box import Box from box.box_list import BoxList