From 0b4caf825014816eaec00852149d3b0ce2636615 Mon Sep 17 00:00:00 2001 From: Colin Watson Date: Sun, 4 May 2025 23:30:53 +0100 Subject: [PATCH 1/8] Add more test files to sdist (#290) The sdist previously contained `test/test*.py` (because `setuptools` does that by default), but not the other files in `test/`. --- MANIFEST.in | 8 ++++++++ 1 file changed, 8 insertions(+) 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 From a6b71cb7b4fa3daa3dd844eeb00df940aa0c25e1 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Thu, 19 Feb 2026 21:14:31 -0600 Subject: [PATCH 2/8] * Adding support for YAML width * Adding support for Python 3.14 * Removing support for Python 3.9 as it is EOL --- .github/workflows/pythonpublish.yml | 4 ++-- .github/workflows/tests.yml | 4 ++-- CHANGES.rst | 7 +++++++ LICENSE | 2 +- README.rst | 2 +- box/__init__.py | 2 +- box/box.py | 6 +++++- box/box.pyi | 1 + box/box_list.py | 8 ++++++-- box/box_list.pyi | 1 + box/converters.py | 7 +++++-- box/converters.pyi | 1 + requirements-test.txt | 2 +- requirements.txt | 2 +- setup.py | 8 ++++---- test/test_box.py | 8 ++++++++ test/test_converters.py | 8 +------- 17 files changed, 48 insertions(+), 25 deletions(-) diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index c4478d8..5bc00c0 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: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 834bfc0..ba2a1f0 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.10"] os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: @@ -98,7 +98,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/CHANGES.rst b/CHANGES.rst index f74e97d..3664ff7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,13 @@ Changelog ========= +Version 7.4.0 +------------- + +* Adding support for YAML width +* Adding support for Python 3.14 +* 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/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..58d3d8a 100644 --- a/box/box.py +++ b/box/box.py @@ -1,7 +1,7 @@ #!/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. """ @@ -1002,6 +1002,7 @@ def to_yaml( default_flow_style: bool = False, encoding: str = "utf-8", errors: str = "strict", + width: int = 120, **yaml_kwargs, ): """ @@ -1011,6 +1012,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,6 +1022,7 @@ def to_yaml( default_flow_style=default_flow_style, encoding=encoding, errors=errors, + width=width, **yaml_kwargs, ) @@ -1062,6 +1065,7 @@ def to_yaml( 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') diff --git a/box/box.pyi b/box/box.pyi index 9d56ab3..b352d8f 100644 --- a/box/box.pyi +++ b/box/box.pyi @@ -93,6 +93,7 @@ class Box(dict): default_flow_style: bool = ..., encoding: str = ..., errors: str = ..., + width: int = ..., **yaml_kwargs, ): ... @classmethod diff --git a/box/box_list.py b/box/box_list.py index fb00864..9e794e1 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-2023 - Chris Griffith - MIT License +# Copyright (c) 2017-2026 - Chris Griffith - MIT License import copy import re from os import PathLike @@ -98,7 +98,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)) @@ -258,6 +258,7 @@ def to_yaml( default_flow_style: bool = False, encoding: str = "utf-8", errors: str = "strict", + width: int = 120, **yaml_kwargs, ): """ @@ -267,6 +268,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,6 +278,7 @@ def to_yaml( default_flow_style=default_flow_style, encoding=encoding, errors=errors, + width=width, **yaml_kwargs, ) @@ -318,6 +321,7 @@ def to_yaml( 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') diff --git a/box/box_list.pyi b/box/box_list.pyi index 982093f..4f84782 100644 --- a/box/box_list.pyi +++ b/box/box_list.pyi @@ -49,6 +49,7 @@ class BoxList(list): default_flow_style: bool = ..., encoding: str = ..., errors: str = ..., + width: int = ..., **yaml_kwargs: Any, ) -> Any: ... @classmethod diff --git a/box/converters.py b/box/converters.py index 80c1ced..e89f5b6 100644 --- a/box/converters.py +++ b/box/converters.py @@ -185,6 +185,7 @@ def _to_yaml( errors: str = "strict", ruamel_typ: str = "rt", ruamel_attrs: Optional[Dict] = None, + width: int = 120, **yaml_kwargs, ): if not ruamel_attrs: @@ -195,11 +196,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,13 +209,14 @@ 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) diff --git a/box/converters.pyi b/box/converters.pyi index 43d2020..322722f 100644 --- a/box/converters.pyi +++ b/box/converters.pyi @@ -28,6 +28,7 @@ def _to_yaml( errors: str = ..., ruamel_typ: str = ..., ruamel_attrs: Optional[Dict] = ..., + width: int = ..., **yaml_kwargs, ): ... def _from_yaml( diff --git a/requirements-test.txt b/requirements-test.txt index 0952d6f..d7641a8 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -2,7 +2,7 @@ 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 types-PyYAML>=6.0.3 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/test_box.py b/test/test_box.py index 1232f3c..9085609 100644 --- a/test/test_box.py +++ b/test/test_box.py @@ -221,6 +221,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) 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 1ef8c79f47143ee4306d04c9f2bbd9469ac1a91c Mon Sep 17 00:00:00 2001 From: J vanBemmel Date: Thu, 19 Feb 2026 21:26:21 -0600 Subject: [PATCH 3/8] Add 'box_dots_exclude' parameter to keep certain keys with dots from being broken down (#297) --- box/box.py | 24 +++++++++++++++--------- box/converters.py | 1 + test/test_box.py | 7 +++++++ 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/box/box.py b/box/box.py index 58d3d8a..bd24d46 100644 --- a/box/box.py +++ b/box/box.py @@ -171,6 +171,7 @@ 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 """ @@ -204,6 +205,7 @@ def __new__( box_intact_types: Union[Tuple, List] = (), box_recast: Optional[Dict] = None, box_dots: bool = False, + box_dots_exclude: str = None, box_class: Optional[Union[Dict, Type["Box"]]] = None, box_namespace: Union[Tuple[str, ...], Literal[False]] = (), **kwargs: Any, @@ -229,6 +231,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, } @@ -251,6 +254,7 @@ def __init__( box_intact_types: Union[Tuple, List] = (), box_recast: Optional[Dict] = None, box_dots: bool = False, + box_dots_exclude: str = None, box_class: Optional[Union[Dict, Type["Box"]]] = None, box_namespace: Union[Tuple[str, ...], Literal[False]] = (), **kwargs: Any, @@ -272,6 +276,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, } @@ -489,6 +494,12 @@ 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__"): @@ -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: diff --git a/box/converters.py b/box/converters.py index e89f5b6..d178329 100644 --- a/box/converters.py +++ b/box/converters.py @@ -119,6 +119,7 @@ class BoxTomlDecodeError(BoxError, tomli.TOMLDecodeError): # type: ignore "box_duplicates", "box_intact_types", "box_dots", + "box_dots_exclude", "box_recast", "box_class", "box_namespace", diff --git a/test/test_box.py b/test/test_box.py index 9085609..c7c7c02 100644 --- a/test/test_box.py +++ b/test/test_box.py @@ -944,6 +944,13 @@ 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 From aa10aead36b02b56e96663328294429dee2d0f85 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Thu, 19 Feb 2026 21:31:19 -0600 Subject: [PATCH 4/8] * Fixing #291 adding frozen boxes (thanks to m-janicki) --- AUTHORS.rst | 2 + CHANGES.rst | 2 + box/box.py | 96 +++++++++++++++++++++++++++--------------------- test/test_box.py | 30 +++++++++++++-- 4 files changed, 85 insertions(+), 45 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index c713b93..33d7cae 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: diff --git a/CHANGES.rst b/CHANGES.rst index 3664ff7..109191e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,8 +4,10 @@ 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 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/box/box.py b/box/box.py index bd24d46..3722234 100644 --- a/box/box.py +++ b/box/box.py @@ -205,7 +205,7 @@ def __new__( box_intact_types: Union[Tuple, List] = (), box_recast: Optional[Dict] = None, box_dots: bool = False, - box_dots_exclude: str = None, + box_dots_exclude: Optional[str] = None, box_class: Optional[Union[Dict, Type["Box"]]] = None, box_namespace: Union[Tuple[str, ...], Literal[False]] = (), **kwargs: Any, @@ -254,7 +254,7 @@ def __init__( box_intact_types: Union[Tuple, List] = (), box_recast: Optional[Dict] = None, box_dots: bool = False, - box_dots_exclude: str = None, + box_dots_exclude: Optional[str] = None, box_class: Optional[Union[Dict, Type["Box"]]] = None, box_namespace: Union[Tuple[str, ...], Literal[False]] = (), **kwargs: Any, @@ -312,9 +312,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]): @@ -324,8 +322,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 @@ -494,10 +491,12 @@ def __setstate__(self, state): self._box_config = state["_box_config"] self.__dict__.update(state) - def __process_dotted_key(self,item): + 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 ("[" 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): @@ -837,41 +836,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: diff --git a/test/test_box.py b/test/test_box.py index c7c7c02..d4e424b 100644 --- a/test/test_box.py +++ b/test/test_box.py @@ -945,11 +945,16 @@ def test_dots(self): 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]+') + 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"] + del bx["0"] def test_unicode(self): bx = Box() @@ -999,6 +1004,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) From bee4bd44642f84a3992ab0ff53d906f8140c07d9 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Thu, 19 Feb 2026 21:40:39 -0600 Subject: [PATCH 5/8] Remove flake8, changing to modern Python 3.10+ typehinting --- .github/workflows/tests.yml | 8 +-- box/box.py | 114 +++++++++++++++++------------------- box/box.pyi | 54 ++++++++--------- box/box_list.py | 61 +++++++++---------- box/box_list.pyi | 33 ++++++----- box/config_box.py | 4 +- box/config_box.pyi | 22 +++---- box/converters.py | 44 +++++++------- box/converters.pyi | 45 +++++++------- box/from_file.py | 16 ++--- box/from_file.pyi | 8 +-- box/shorthand_box.py | 8 +-- box/shorthand_box.pyi | 8 +-- 13 files changed, 207 insertions(+), 218 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ba2a1f0..b2d5cd9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,13 +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>=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 run: mypy box - name: Build Wheel and check distribution log description diff --git a/box/box.py b/box/box.py index 3722234..cafe1cf 100644 --- a/box/box.py +++ b/box/box.py @@ -5,18 +5,16 @@ """ 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 @@ -176,7 +174,7 @@ class Box(dict): :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", @@ -202,12 +200,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_dots_exclude: Optional[str] = None, - 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, ): """ @@ -251,12 +249,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_dots_exclude: Optional[str] = None, - 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__() @@ -386,7 +384,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(): @@ -421,7 +419,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() @@ -444,7 +442,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() @@ -467,15 +465,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 @@ -550,7 +548,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("__"): @@ -792,15 +790,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. @@ -965,7 +963,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, @@ -984,12 +982,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. @@ -1016,7 +1014,7 @@ 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", @@ -1047,12 +1045,12 @@ 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: """ Transform a yaml object string into a Box object. By default will use SafeLoader. @@ -1079,7 +1077,7 @@ 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", @@ -1091,19 +1089,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. @@ -1116,9 +1112,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: @@ -1126,12 +1120,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 @@ -1155,17 +1149,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. @@ -1178,10 +1172,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 @@ -1202,16 +1196,16 @@ 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') diff --git a/box/box.pyi b/box/box.pyi index b352d8f..91afc13 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,25 +73,23 @@ 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 = ..., @@ -99,24 +99,24 @@ class Box(dict): @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: ... diff --git a/box/box_list.py b/box/box_list.py index 9e794e1..00ee234 100644 --- a/box/box_list.py +++ b/box/box_list.py @@ -2,10 +2,13 @@ # -*- coding: utf-8 -*- # # 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 ( @@ -43,7 +46,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 @@ -137,7 +140,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 +180,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 +195,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 +221,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,7 +257,7 @@ 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", @@ -285,8 +288,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, @@ -317,7 +320,7 @@ 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", @@ -329,8 +332,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, @@ -341,7 +344,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", @@ -362,7 +365,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", @@ -374,8 +377,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", @@ -408,8 +411,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", @@ -419,7 +422,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. @@ -429,9 +432,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 @@ -452,28 +453,28 @@ 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"): + 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 4f84782..8916c1d 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,7 +46,7 @@ class BoxList(list): ) -> Any: ... def to_yaml( self, - filename: Union[str, PathLike] = ..., + filename: str | PathLike = ..., default_flow_style: bool = ..., encoding: str = ..., errors: str = ..., @@ -56,29 +57,29 @@ class BoxList(list): 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_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 d178329..f59a53a 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", @@ -126,7 +128,7 @@ class BoxTomlDecodeError(BoxError, tomli.TOMLDecodeError): # type: ignore ) -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: @@ -143,7 +145,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) @@ -154,8 +156,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, @@ -180,12 +182,12 @@ 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, ): @@ -223,12 +225,12 @@ def _to_yaml( 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: @@ -264,7 +266,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 @@ -287,8 +289,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", ): @@ -307,7 +309,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: @@ -316,7 +318,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: @@ -329,7 +331,7 @@ def _from_msgpack(msgpack_bytes: Optional[bytes] = None, filename: Optional[Unio 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: @@ -351,8 +353,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 322722f..1266789 100644 --- a/box/converters.pyi +++ b/box/converters.pyi @@ -1,20 +1,19 @@ -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 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,39 +21,37 @@ 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_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..771f948 100644 --- a/box/from_file.py +++ b/box/from_file.py @@ -1,10 +1,12 @@ #!/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 @@ -84,16 +86,16 @@ def _to_msgpack(file, _, __, **kwargs): "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. @@ -115,7 +117,7 @@ def box_from_file( 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]: +def box_from_string(content: str, string_type: str = "json") -> Box | BoxList: """ Parse the provided string into a Box or BoxList object as appropriate. 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): ... From c9ba62c95b15c10250a50e45ae978f2b55268add Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Thu, 19 Feb 2026 22:41:24 -0600 Subject: [PATCH 6/8] * Adding #301 support for TOON serialization format (thanks to richieadler) --- AUTHORS.rst | 1 - CHANGES.rst | 1 + box/box.py | 66 +++++++++++++++++++++++++++++++++++++++++++ box/box.pyi | 10 +++++++ box/box_list.py | 66 +++++++++++++++++++++++++++++++++++++++++++ box/box_list.pyi | 10 +++++++ box/converters.py | 36 +++++++++++++++++++++++ box/converters.pyi | 9 ++++++ box/from_file.py | 30 ++++++++++++++++++-- requirements-dev.txt | 1 + requirements-test.txt | 1 + test/common.py | 1 + test/test_box.py | 21 ++++++++++++++ test/test_box_list.py | 18 ++++++++++++ 14 files changed, 268 insertions(+), 3 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index 33d7cae..6b44851 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -84,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 109191e..07f555f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,7 @@ 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) diff --git a/box/box.py b/box/box.py index cafe1cf..8252643 100644 --- a/box/box.py +++ b/box/box.py @@ -23,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, @@ -1209,3 +1212,66 @@ def from_msgpack( **kwargs, ) -> 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 91afc13..05f0906 100644 --- a/box/box.pyi +++ b/box/box.pyi @@ -120,3 +120,13 @@ class Box(dict): def from_msgpack( cls, msgpack_bytes: bytes | None = ..., filename: str | PathLike | None = ..., **kwargs ) -> Box: ... + def to_toon(self, filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ..., **kwargs): ... + @classmethod + def from_toon( + cls, + toon_string: str | None = ..., + filename: str | PathLike | None = ..., + encoding: str = ..., + errors: str = ..., + **kwargs, + ) -> Box: ... diff --git a/box/box_list.py b/box/box_list.py index 00ee234..72609ff 100644 --- a/box/box_list.py +++ b/box/box_list.py @@ -17,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, ) @@ -467,6 +470,69 @@ def from_msgpack( ): 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 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) diff --git a/box/box_list.pyi b/box/box_list.pyi index 8916c1d..a9e769e 100644 --- a/box/box_list.pyi +++ b/box/box_list.pyi @@ -78,6 +78,16 @@ class BoxList(list): def to_msgpack(self, filename: str | PathLike = ..., **kwargs: Any) -> Any: ... @classmethod def from_msgpack(cls, msgpack_bytes: bytes = ..., filename: str | PathLike = ..., **kwargs: Any) -> Any: ... + def to_toon(self, filename: str | PathLike = ..., encoding: str = ..., errors: str = ..., **kwargs: Any) -> Any: ... + @classmethod + def from_toon( + cls, + toon_string: str = ..., + filename: str | PathLike = ..., + encoding: str = ..., + errors: str = ..., + **kwargs: Any, + ) -> Any: ... def to_csv(self, filename: str | PathLike = ..., encoding: str = ..., errors: str = ...) -> Any: ... @classmethod def from_csv( diff --git a/box/converters.py b/box/converters.py index f59a53a..bb91ba6 100644 --- a/box/converters.py +++ b/box/converters.py @@ -43,11 +43,13 @@ "_to_toml", "_to_csv", "_to_msgpack", + "_to_toon", "_from_json", "_from_yaml", "_from_toml", "_from_csv", "_from_msgpack", + "_from_toon", ] @@ -106,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 = ( @@ -330,6 +339,33 @@ def _from_msgpack(msgpack_bytes: bytes | None = None, filename: str | PathLike | 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: str | PathLike | None = None, encoding: str = "utf-8", errors: str = "strict", **kwargs ): diff --git a/box/converters.pyi b/box/converters.pyi index 1266789..975b902 100644 --- a/box/converters.pyi +++ b/box/converters.pyi @@ -5,6 +5,7 @@ from typing import Any yaml_available: bool toml_available: bool msgpack_available: bool +toon_available: bool BOX_PARAMETERS: Any toml_read_library: Any | None toml_write_library: Any | None @@ -48,6 +49,14 @@ def _from_toml( ): ... def _to_msgpack(obj, filename: str | PathLike | None = ..., **kwargs): ... def _from_msgpack(msgpack_bytes: bytes | None = ..., filename: str | PathLike | None = ..., **kwargs): ... +def _to_toon(obj, filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ..., **kwargs): ... +def _from_toon( + toon_string: str | None = ..., + filename: str | PathLike | None = ..., + encoding: str = ..., + errors: str = ..., + **kwargs, +): ... def _to_csv(box_list, filename: str | PathLike | None = ..., encoding: str = ..., errors: str = ..., **kwargs): ... def _from_csv( csv_string: str | None = ..., diff --git a/box/from_file.py b/box/from_file.py index 771f948..3fb7a6e 100644 --- a/box/from_file.py +++ b/box/from_file.py @@ -10,7 +10,7 @@ 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: @@ -26,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"] @@ -76,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, @@ -83,6 +99,7 @@ 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, @@ -114,7 +131,7 @@ def box_from_file( file_type = file_type.lower().lstrip(".") if file_type.lower() in converters: return converters[file_type.lower()](file, encoding, errors, **kwargs) # type: ignore - raise BoxError(f'"{file_type}" is an unknown type. Please use either csv, toml, msgpack, yaml or json') + raise BoxError(f'"{file_type}" is an unknown type. Please use either csv, toon, toml, msgpack, yaml or json') def box_from_string(content: str, string_type: str = "json") -> Box | BoxList: @@ -147,5 +164,14 @@ def box_from_string(content: str, string_type: str = "json") -> Box | BoxList: 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/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 d7641a8..071ec6d 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -5,5 +5,6 @@ pytest-cov<6.0.0 ruamel.yaml>=0.19.1 tomli>=1.2.3; python_version < '3.11' tomli-w>=1.0.0 +toon_format @ git+https://github.com/toon-format/toon-python.git types-PyYAML>=6.0.3 wheel>=0.34.2 diff --git a/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 d4e424b..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, ) @@ -1212,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( [ From 4cb190ef272c1d249a2910853cb313f25c610887 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Thu, 19 Feb 2026 22:51:26 -0600 Subject: [PATCH 7/8] Fix new maylinux options --- .github/workflows/pythonpublish.yml | 3 ++- .github/workflows/tests.yml | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index 5bc00c0..e385f04 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -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 b2d5cd9..e539b23 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,7 +13,7 @@ jobs: package-checks: strategy: matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "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: @@ -77,8 +77,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 From 2f2f0c4883ad3d6d29f0c769e570894725312e38 Mon Sep 17 00:00:00 2001 From: Chris Griffith Date: Thu, 19 Feb 2026 23:05:30 -0600 Subject: [PATCH 8/8] Fix pypy mypy issues --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e539b23..e06da9a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,6 +33,7 @@ jobs: pip install -r requirements-test.txt 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: |