Skip to content

Commit

Permalink
Handles edge cases handling errors where only=/ignore=/exclude= 0 and…
Browse files Browse the repository at this point in the history
… 1 (int)
  • Loading branch information
Peter Fison committed Feb 5, 2021
1 parent e710fa9 commit 47ea74b
Show file tree
Hide file tree
Showing 4 changed files with 75 additions and 46 deletions.
4 changes: 2 additions & 2 deletions cleverdict.egg-info/PKG-INFO
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
Metadata-Version: 2.1
Name: cleverdict
Version: 1.9.0
Version: 1.9.1
Summary: A JSON-friendly data structure which allows both object attributes and dictionary keys and values to be used simultaneously and interchangeably.
Home-page: https://github.com/Pfython/cleverdict
Author: Peter Fison
Author-email: [email protected]
License: MIT License
Download-URL: https://github.com/Pfython/cleverdict/archive/1.9.0.tar.gz
Download-URL: https://github.com/Pfython/cleverdict/archive/1.9.1.tar.gz
Description: # `CleverDict`
<p align="center">
<a href="https://pypi.python.org/pypi/cleverdict"><img alt="PyPI" src="https://img.shields.io/pypi/v/cleverdict.svg"></a>
Expand Down
72 changes: 44 additions & 28 deletions cleverdict/cleverdict.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@
Change log
==========
version 1.9.1
-------------
Handles edge cases handling errors where only=/ignore=/exclude= 0 and 1 (int)
version 1.9.0
-------------
Added exclude= as an alternative for ignore= (for Marshmallow fans)
Added only= (for Marshmallow fans)
Added exclude= ignore= and only= to __init__ for selective import
Made exclude= ignore= and only= permissive (lists OR single item strings)
Refactored using preprocess_options()
Refactored using _preprocess_options()
y=CleverDict(x) now imports a fullcopy of x e.g. including _aliases/_vars
version 1.8.1
Expand Down Expand Up @@ -244,23 +248,23 @@ def _posixify(name):
)


def preprocess_options(ignore, exclude, only):
def _preprocess_options(ignore, exclude, only):
"""
Performs preparatory transformations of ignore, exclude and only, inculding:
- Convert to iterables if single item string supplied.
- Check that only one argument is truthy (to avoid logic bombs).
- Check that only one argument is not None (to avoid logic bombs).
- Fail gracefully if not.
- Set ignore if exclude was used, and reset exclude (to avoid logic bombs)
- Convert ignore to set() neither it nor exclude are specified
Parameters
----------
ignore: None | list
ignore: None | iterable
Items to ignore during subsequent processing
exclude: None | list
Alternative alias for ignore
only: None | list
exclude: None | iterable
Alias for ignore
only: None | iterable
Items to exclusively include during subsequent processing
Returns
Expand All @@ -270,18 +274,30 @@ def preprocess_options(ignore, exclude, only):
"""

def make_set(arg):
if arg is None or arg == CleverDict.ignore:
if arg is None:
return set()
return set(arg) if not isinstance(arg, str) else {arg}
return (
set(arg) if hasattr(arg, "__iter__") and not isinstance(arg, str) else {arg}
)

if ignore == CleverDict.ignore:
ignore = None
if exclude == CleverDict.ignore:
exclude = None

only = make_set(only)
ignore = make_set(ignore)
exclude = make_set(exclude)
if sum([bool(x) for x in (ignore, exclude, only)]) > 1:
if sum(x is not None for x in (ignore, exclude, only)) > 1:
raise TypeError(
f"Only one argument from ['only=', 'ignore=', 'exclude='] allowed."
)
return (ignore or exclude) | CleverDict.ignore, only

if only is not None: # leave only is None for proper only tests
only = (
set(only)
if hasattr(only, "__iter__") and not isinstance(only, str)
else {only}
)

return make_set(ignore) | make_set(exclude) | CleverDict.ignore, only


class Expand:
Expand Down Expand Up @@ -362,7 +378,7 @@ def __init__(
exclude=None,
**kwargs,
):
ignore, only = preprocess_options(ignore, exclude, only)
ignore, only = _preprocess_options(ignore, exclude, only)
self.setattr_direct("_aliases", {})
if isinstance(mapping, CleverDict):
# for key, alias in mapping._aliases.items():
Expand All @@ -383,7 +399,7 @@ def __init__(
mapping = {k: v for k, v in mapping.items() if k not in ignore}
if isinstance(mapping, list):
mapping = {k: v for k, v in mapping if k not in ignore}
if only:
if only is not None:
if isinstance(mapping, dict):
mapping = {k: v for k, v in mapping.items() if k in only}
if isinstance(mapping, list):
Expand Down Expand Up @@ -456,7 +472,7 @@ def __repr__(self, ignore=None, exclude=None, only=None):
only: iterable | str
Only return output for specified keys
"""
ignore, only = preprocess_options(ignore, exclude, only)
ignore, only = _preprocess_options(ignore, exclude, only)
mapping = self._filtered_mapping(ignore, only)
_aliases = {
k: v for k, v in self._aliases.items() if k not in self and v in mapping
Expand Down Expand Up @@ -522,7 +538,7 @@ def _filtered_mapping(self, ignore=None, only=False):
for k, v in self._aliases.items():
if k in ignore and v in mapping:
del mapping[v]
if only:
if only is not None:
return {k: v for k, v in mapping.items() if k in only}
else:
return mapping
Expand Down Expand Up @@ -568,7 +584,7 @@ def info(self, as_str=False, ignore=None, exclude=None, only=None):
information (if as_str is True): str
None (if as_str is False)
"""
ignore, only = preprocess_options(ignore, exclude, only)
ignore, only = _preprocess_options(ignore, exclude, only)
mapping = self._filtered_mapping(ignore, only)
indent = " "
frame = inspect.currentframe().f_back.f_locals
Expand Down Expand Up @@ -745,7 +761,7 @@ def to_list(self, ignore=None, exclude=None, only=None):
[(1, "one"), (2, "two")]
"""
ignore, only = preprocess_options(ignore, exclude, only)
ignore, only = _preprocess_options(ignore, exclude, only)
mapping = self._filtered_mapping(ignore, only)
return [(k, v) for k, v in mapping.items()]

Expand All @@ -768,7 +784,7 @@ def to_dict(self, ignore=None, exclude=None, only=None):
-------
dict
"""
ignore, only = preprocess_options(ignore, exclude, only)
ignore, only = _preprocess_options(ignore, exclude, only)
return self._filtered_mapping(ignore=ignore, only=only)

@classmethod
Expand Down Expand Up @@ -800,10 +816,10 @@ def fromkeys(cls, iterable, value, ignore=None, exclude=None, only=None):
-------
New CleverDict with keys from iterable and values equal to value.
"""
ignore, only = preprocess_options(ignore, exclude, only)
ignore, only = _preprocess_options(ignore, exclude, only)
if ignore:
iterable = {k: value for k in iterable if k not in ignore}
if only:
if only is not None:
iterable = {k: value for k in iterable if k in only}
return CleverDict({k: value for k in iterable})

Expand Down Expand Up @@ -838,7 +854,7 @@ def to_lines(
values joined by "\n" (if file_path is not specified) : str
None (if file_path is specified)
"""
ignore, only = preprocess_options(ignore, exclude, only)
ignore, only = _preprocess_options(ignore, exclude, only)
mapping = self._filtered_mapping(ignore, only)
if start_from_key is None:
start_from_key = self.get_aliases()[0]
Expand Down Expand Up @@ -901,7 +917,7 @@ def from_lines(
-----
specifying both lines and file_path raises a ValueError
"""
ignore, only = preprocess_options(ignore, exclude, only)
ignore, only = _preprocess_options(ignore, exclude, only)
if not isinstance(start_from_key, int):
raise TypeError(".from_lines(start_from_key=) must be an integer")
if lines and file_path:
Expand All @@ -912,7 +928,7 @@ def from_lines(
with open(file_path, "r", encoding="utf-8") as file:
lines = file.read()
index = {k + start_from_key: v.strip() for k, v in enumerate(lines.split("\n"))}
if only:
if only is not None:
index = {k: v for k, v in index.items() if v in only}
if ignore:
index = {k: v for k, v in index.items() if v not in ignore}
Expand Down Expand Up @@ -954,7 +970,7 @@ def to_json(
Derived only from dictionary data if fullcopy==False
Includes ._aliases and ._vars if fullcopy==True
"""
ignore, only = preprocess_options(ignore, exclude, only)
ignore, only = _preprocess_options(ignore, exclude, only)
mapping = self._filtered_mapping(ignore, only)

if not fullcopy:
Expand Down Expand Up @@ -1010,7 +1026,7 @@ def from_json(
-------
New CleverDict: CleverDict
"""
ignore, only = preprocess_options(ignore, exclude, only)
ignore, only = _preprocess_options(ignore, exclude, only)
kwargs = {"ignore": ignore, "only": only}
if json_data and file_path:
raise ValueError("both json_data and file_path specified")
Expand Down
43 changes: 28 additions & 15 deletions cleverdict/test_cleverdict.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,13 +265,33 @@ def test_only(self):
"""only=[list] should return output ONLY matching the given keys"""
x = CleverDict({"Apples": "Green", "Bananas": "Yellow", "Oranges": "Purple"})
a_and_o = CleverDict({"Apples": "Green", "Oranges": "Purple"})
for (
func
) in "__repr__() to_json() to_dict() to_list() to_lines() info(,as_str=True)".split():
result1 = eval("x." + func.replace("(", "(only=['Apples', 'Oranges']"))
result2 = eval("a_and_o." + func.replace(",as_str", "as_str"))
for func in "__repr__ to_json to_dict to_list to_lines info".split():
as_str = {"as_str": True} if func == "info" else {}
result1 = getattr(x, func)(only=["Apples", "Oranges"], **as_str)
result2 = getattr(a_and_o, func)(
**({"as_str": True} if func == "info" else {})
)
assert str(result1) == str(result2).replace("a_and_o", "x")

def test_only_edge_cases(self):
x = CleverDict({"Apples": "Green", "Bananas": "Yellow", "Oranges": "Purple"})
with pytest.raises(TypeError):
x.to_list(exclude=[], only=[])
x.to_list(ignore=[], only=[])
x.to_list(exclude=[], ignore=[])
x.to_list(exclude=[], ignore=[], only=[])
x.to_list(
ignore=CleverDict.ignore, exclude=CleverDict.ignore, only=CleverDict.ignore
)

x = CleverDict({0: "Zero", 1: "One"})
assert x.to_list(only=[1]) == [(1, "One")]
assert x.to_list(ignore=[0]) == [(1, "One")]
assert x.to_list(exclude=[0]) == [(1, "One")]
assert x.to_list(only=1) == [(1, "One")]
assert x.to_list(ignore=0) == [(1, "One")]
assert x.to_list(exclude=0) == [(1, "One")]

def test_permissive(self):
"""
only= exclude= ignore= should accept iterables AND single items strings.
Expand Down Expand Up @@ -316,15 +336,6 @@ def test_only_OR_ignore_OR_exclude_as_args(self):
with pytest.raises(TypeError):
eval("x." + func.replace("(", "(" + args))

def test_bool_logic_with_bool_keys(self):
"""Internal logic of preprocess() might be tricked if single item
(bool) key is supplied"""
x = CleverDict({1: "include me", 0: "exclude/ignore me"})
with pytest.raises(TypeError):
x.to_list(only=0, ignore=0)
with pytest.raises(TypeError):
x.to_json(ignore=1, exclude=0)

def test_filters_with_init(self):
"""
only= exclude= ignore= should work as part of object instantiation.
Expand Down Expand Up @@ -654,7 +665,7 @@ def test_import_existing_cleverdict(test):
x = CleverDict({"name": "Peter", "nationality": "British"})
x.add_alias("name", "nom")
x.setattr_direct("private", "parts")
x.autosave()
x.autosave(silent=True)
y = CleverDict(x)
assert y.nom == "Peter"
assert y.private == "parts"
Expand Down Expand Up @@ -1129,6 +1140,7 @@ def test_IMPORT_EXPORT_7(self):
)
x.to_json(file_path="mydata.json")
y = CleverDict.from_json(file_path="mydata.json")
os.remove("mydata.json")
assert x == y

def test_IMPORT_EXPORT_8(self):
Expand Down Expand Up @@ -1318,6 +1330,7 @@ def test_AUTOSAVE_7(self):
x.setattr_direct("Quest", "The Holy Grail")
x.to_json(file_path="mydata.json", fullcopy=True)
y = CleverDict.from_json(file_path="mydata.json")
os.remove("mydata.json")
assert y.save.__name__ == "save"
y.autosave(fullcopy=True, silent=True)
assert y.save.__name__ == "_auto_save_fullcopy"
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
HERE = Path(__file__).parent
NAME = "cleverdict"
GITHUB_ID = "Pfython"
VERSION = "1.9.0"
VERSION = "1.9.1"
DESCRIPTION = "A JSON-friendly data structure which allows both object attributes and dictionary keys and values to be used simultaneously and interchangeably."
LICENSE = "MIT License"
AUTHOR = "Peter Fison"
Expand Down

0 comments on commit 47ea74b

Please sign in to comment.