Passed
Push — main ( 393c8e...520e83 )
by Douglas
01:36
created

NestedDotDict.write_toml()   A

Complexity

Conditions 1

Size

Total Lines 2
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nop 2
dl 0
loc 2
rs 10
c 0
b 0
f 0
1
from __future__ import annotations
0 ignored issues
show
introduced by
Missing module docstring
Loading history...
2
3
import gzip
0 ignored issues
show
Unused Code introduced by
The import gzip seems to be unused.
Loading history...
4
import json
5
import pickle
6
from copy import copy
7
from datetime import date, datetime
8
from pathlib import Path, PurePath
9
from typing import Any, ByteString, Callable, Mapping, Optional, Sequence
10
from typing import Tuple as Tup
11
from typing import Type, TypeVar, Union
12
13
import orjson
0 ignored issues
show
introduced by
Unable to import 'orjson'
Loading history...
14
import toml
0 ignored issues
show
introduced by
third party import "import toml" should be placed before "import orjson"
Loading history...
15
16
from pocketutils.core import PathLike
0 ignored issues
show
introduced by
Cannot import 'pocketutils.core' due to syntax error 'invalid syntax (<unknown>, line 134)'
Loading history...
Bug introduced by
The name core does not seem to exist in module pocketutils.
Loading history...
17
from pocketutils.core._internal import read_txt_or_gz, write_txt_or_gz
0 ignored issues
show
Bug introduced by
The name core does not seem to exist in module pocketutils.
Loading history...
18
from pocketutils.core.exceptions import XKeyError, XTypeError, XValueError
0 ignored issues
show
Bug introduced by
The name core does not seem to exist in module pocketutils.
Loading history...
19
20
PICKLE_PROTOCOL = 5
21
T = TypeVar("T")
0 ignored issues
show
Coding Style Naming introduced by
Class name "T" doesn't conform to PascalCase naming style ('[^\\W\\da-z][^\\W_]+$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
22
23
24
def _json_encode_default(obj: Any) -> Any:
0 ignored issues
show
Unused Code introduced by
Either all return statements in a function should return an expression, or none of them should.
Loading history...
25
    if isinstance(obj, NestedDotDict):
26
        # noinspection PyProtectedMember
27
        return dict(obj._x)
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like _x was declared protected and should not be accessed from this context.

Prefixing a member variable _ is usually regarded as the equivalent of declaring it with protected visibility that exists in other languages. Consequentially, such a member should only be accessed from the same class or a child class:

class MyParent:
    def __init__(self):
        self._x = 1;
        self.y = 2;

class MyChild(MyParent):
    def some_method(self):
        return self._x    # Ok, since accessed from a child class

class AnotherClass:
    def some_method(self, instance_of_my_child):
        return instance_of_my_child._x   # Would be flagged as AnotherClass is not
                                         # a child class of MyParent
Loading history...
28
29
30
class NestedDotDict(Mapping):
0 ignored issues
show
introduced by
Inheriting 'Mapping', which is not a class.
Loading history...
best-practice introduced by
Too many public methods (23/20)
Loading history...
31
    """
32
    A thin wrapper around a nested dict to make getting values easier.
33
    This was designed as a wrapper for TOML, but it works more generally too.
34
35
    Keys must be strings that do not contain a dot (.).
36
    A dot is reserved for splitting values to traverse the tree.
37
    For example, ``dotdict["pet.species.name"]``.
38
39
    """
40
41
    @classmethod
42
    def read_toml(cls, path: PathLike) -> NestedDotDict:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
43
        return NestedDotDict(toml.loads(read_txt_or_gz(path)))
44
45
    @classmethod
46
    def read_json(cls, path: Union[PurePath, str]) -> NestedDotDict:
47
        """
48
        Reads JSON from a file, into a NestedDotDict.
49
        If the JSON data is a list type, converts into a dict with the key ``data``.
50
        Can read .json or .json.gz.
51
        """
52
        data = orjson.loads(read_txt_or_gz(path))
53
        if isinstance(data, list):
54
            data = {"data": data}
55
        return cls(data)
56
57
    @classmethod
58
    def read_pickle(cls, path: Union[PurePath, str]) -> NestedDotDict:
59
        """
60
61
        Note that this function has potential security concerns.
62
        This is because it relies on the pickle module.
63
        """
64
        data = Path(path).read_bytes()
65
        data = pickle.loads(data)  # nosec
66
        return cls(data)
67
68
    @classmethod
69
    def parse_toml(cls, data: str) -> NestedDotDict:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
70
        return cls(toml.loads(data))
71
72
    @classmethod
73
    def parse_json(cls, data: str) -> NestedDotDict:
74
        """
75
        Parses JSON from a string, into a NestedDotDict.
76
        If the JSON data is a list type, converts into a dict with the key ``data``.
77
        """
78
        data = json.loads(data)
79
        if isinstance(data, list):
80
            data = {"data": data}
81
        return cls(data)
82
83
    @classmethod
84
    def parse_pickle(cls, data: ByteString) -> NestedDotDict:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
85
        if not isinstance(data, bytes):
86
            data = bytes(data)
87
        return NestedDotDict(pickle.loads(data))
88
89
    def __init__(self, x: Mapping[str, Any]) -> None:
0 ignored issues
show
Bug introduced by
The __init__ method of the super-class _GenericAlias is not called.

It is generally advisable to initialize the super-class by calling its __init__ method:

class SomeParent:
    def __init__(self):
        self.x = 1

class SomeChild(SomeParent):
    def __init__(self):
        # Initialize the super class
        SomeParent.__init__(self)
Loading history...
90
        """
91
        Constructor.
92
93
        Raises:
94
            XValueError: If a key (in this dict or a sub-dict) is not a str or contains a dot
95
        """
96
        if not (hasattr(x, "items") and hasattr(x, "keys") and hasattr(x, "values")):
97
            raise XTypeError(
98
                f"Type {type(x)} for value {x} appears not to be dict-like", actual=str(type(x))
99
            )
100
        bad = [k for k in x if not isinstance(k, str)]
101
        if len(bad) > 0:
102
            raise XValueError(f"Keys were not strings for these values: {bad}", value=bad)
103
        bad = [k for k in x if "." in k]
104
        if len(bad) > 0:
105
            raise XValueError(f"Keys contained dots (.) for these values: {bad}", value=bad)
106
        self._x = x
107
        # Let's make sure this constructor gets called on subdicts:
108
        self.leaves()
109
110
    def write_json(self, path: PathLike, *, indent: bool = False) -> None:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
111
        write_txt_or_gz(self.to_json(indent=indent), path)
112
113
    def write_toml(self, path: PathLike) -> None:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
114
        write_txt_or_gz(self.to_toml(), path)
115
116
    def write_pickle(self, path: PathLike) -> None:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
117
        Path(path).write_bytes(pickle.dumps(self._x, protocol=PICKLE_PROTOCOL))
118
119
    def to_json(self, *, indent: bool = False) -> str:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
120
        kwargs = dict(option=orjson.OPT_INDENT_2) if indent else {}
121
        encoded = orjson.dumps(self._x, default=_json_encode_default, **kwargs)
122
        return encoded.decode(encoding="utf8")
123
124
    def to_toml(self) -> str:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
125
        return toml.dumps(self._x)
126
127
    def leaves(self) -> Mapping[str, Any]:
128
        """
129
        Gets the leaves in this tree.
130
131
        Returns:
132
            A dict mapping dot-joined keys to their values
133
        """
134
        mp = {}
0 ignored issues
show
Coding Style Naming introduced by
Variable name "mp" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
135
        for key, value in self._x.items():
136
            if len(key) == 0:
137
                raise AssertionError(f"Key is empty (value={value})")
138
            if isinstance(value, dict):
139
                mp.update({key + "." + k: v for k, v in NestedDotDict(value).leaves().items()})
140
            else:
141
                mp[key] = value
142
        return mp
143
144
    def sub(self, items: str) -> NestedDotDict:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
145
        return NestedDotDict(self[items])
146
147
    def exactly(self, items: str, astype: Type[T]) -> T:
148
        """
149
        Gets the key ``items`` from the dict if it has type ``astype``.
150
        Calling ``dotdict.exactly(k, t) is equivalent to calling ``t(dotdict[k])``,
151
        but a raised ``TypeError`` will note the key, making this a useful shorthand for the above within a try-except.
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (119/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
152
153
        Args:
154
            items: The key hierarchy, with a dot (.) as a separator
155
            astype: The type, which will be checked using ``isinstance``
156
157
        Returns:
158
            The value in the required type
159
160
        Raises:
161
            TypeError: If not ``isinstance(value, astype)``
162
        """
163
        z = self[items]
0 ignored issues
show
Coding Style Naming introduced by
Variable name "z" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
164
        if not isinstance(z, astype):
165
            raise XTypeError(
166
                f"Value {z} from {items} is a {type(z)}, not {astype}",
167
                actual=str(type(z)),
168
                expected=str(astype),
169
            )
170
        return z
171
172
    def get_as(
173
        self, items: str, astype: Callable[[Any], T], default: Optional[T] = None
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
174
    ) -> Optional[T]:
175
        """
176
        Gets the value of an *optional* key, or ``default`` if it doesn't exist.
177
        Also see ``req_as``.
178
179
        Args:
180
            items: The key hierarchy, with a dot (.) as a separator.
181
                   Ex: ``animal.species.name``.
182
            astype: Any function that converts the found value to type ``T``.
183
                    Can be a ``Type``, such as ``int``.
184
                    Despite the annotated type, this function only needs to accept the actual value of the key
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (110/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
185
                    as input, not ``Any``.
186
            default: Return this value if the key is not found (at any level)
187
188
        Returns:
189
            The value of found key in this dot-dict, or ``default``.
190
191
        Raises:
192
            XValueError: Likely exception raised if calling ``astype`` fails
193
        """
194
        x = self.get(items)
0 ignored issues
show
Coding Style Naming introduced by
Variable name "x" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
195
        if x is None:
196
            return default
197
        if astype is date:
198
            return self._to_date(x)
199
        if astype is datetime:
200
            return self._to_datetime(x)
201
        return astype(x)
202
203
    def req_as(self, items: str, astype: Optional[Callable[[Any], T]]) -> T:
204
        """
205
        Gets the value of a *required* key.
206
        Also see ``get_as`` and ``exactly``.
207
208
        Args:
209
            items: The key hierarchy, with a dot (.) as a separator.
210
                   Ex: ``animal.species.name``.
211
            astype: Any function that converts the found value to type ``T``.
212
                    Can be a ``Type``, such as ``int``.
213
                    Despite the annotated type, this function only needs to accept the actual value of the key
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (110/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
214
                    as input, not ``Any``.
215
216
        Returns:
217
            The value of found key in this dot-dict.
218
219
        Raises:
220
            XKeyError: If the key is not found (at any level).
221
            XValueError: Likely exception raised if calling ``astype`` fails
222
        """
223
        x = self[items]
0 ignored issues
show
Coding Style Naming introduced by
Variable name "x" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
224
        return astype(x)
225
226
    def get_list_as(
227
        self, items: str, astype: Callable[[Any], T], default: Optional[Sequence[T]] = None
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
228
    ) -> Optional[Sequence[T]]:
229
        """
230
        Gets list values from an *optional* key.
231
        Note that ``astype`` here converts elements *within* the list, not the whole list.
232
        Also see ``req_list_as``.
233
234
        Args:
235
            items: The key hierarchy, with a dot (.) as a separator. Ex: ``animal.species.name``.
236
            astype: Any function that converts the found value to type ``T``. Ex: ``int``.
237
            default: Return this value if the key wasn't found
238
239
        Returns:
240
            ``[astype(v) for v in self[items]]``, or ``default`` if ``items`` was not found.
241
242
        Raises:
243
            XValueError: Likely exception raised if calling ``astype`` fails
244
            XTypeError: If the found value is not a (non-``str``) ``Sequence``
245
        """
246
        x = self.get(items)
0 ignored issues
show
Coding Style Naming introduced by
Variable name "x" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
247
        if x is None:
248
            return default
249
        if not isinstance(x, Sequence) or isinstance(x, str):
0 ignored issues
show
introduced by
Second argument of isinstance is not a type
Loading history...
250
            raise XTypeError(f"Value {x} is not a list for lookup {items}", actual=str(type(x)))
251
        return [astype(y) for y in x]
252
253
    def req_list_as(self, items: str, astype: Optional[Callable[[Any], T]]) -> Sequence[T]:
254
        """
255
        Gets list values from a *required* key.
256
        Note that ``astype`` here converts elements *within* the list, not the whole list.
257
        Also see ``get_list_as``.
258
259
        Args:
260
            items: The key hierarchy, with a dot (.) as a separator. Ex: ``animal.species.name``.
261
            astype: Any function that converts the found value to type ``T``. Ex: ``int``.
262
263
        Returns:
264
            ``[astype(v) for v in self[items]]``
265
266
        Raises:
267
            XValueError: Likely exception raised if calling ``astype`` fails
268
            XTypeError: If the found value is not a (non-``str``) ``Sequence``
269
            XKeyError: If the key was not found (at any level)
270
        """
271
        x = self[items]
0 ignored issues
show
Coding Style Naming introduced by
Variable name "x" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
272
        if not isinstance(x, Sequence) or isinstance(x, str):
0 ignored issues
show
introduced by
Second argument of isinstance is not a type
Loading history...
273
            raise XTypeError(f"Value {x} is not a list for lookup {items}", actual=str(type(x)))
274
        return [astype(y) for y in x]
275
276
    def get(self, items: str, default: Any = None) -> Any:
277
        """
278
        Gets a value from an optional key.
279
        Also see ``__getitem__``.
280
        """
281
        try:
282
            return self[items]
283
        except KeyError:
284
            return default
285
286
    def __getitem__(self, items: str) -> Any:
287
        """
288
        Gets a value from a required key.
289
        Analogous to ``dict.__getitem__``, but this can operate on dot-joined strings.
290
291
        **NOTE:** The number of keys for which this returns a value can be different from ``len(self)``.
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (104/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
292
293
        Example:
294
            >>> d = NestedDotDict(dict(a=dict(b=1)))
295
            >>> assert d["a.b"] == 1
296
        """
297
        at = self._x
0 ignored issues
show
Coding Style Naming introduced by
Variable name "at" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
298
        for item in items.split("."):
299
            if item not in at:
300
                raise XKeyError(f"{items} not found: {item} does not exist")
301
            at = at[item]
0 ignored issues
show
Coding Style Naming introduced by
Variable name "at" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
302
        return NestedDotDict(at) if isinstance(at, dict) else copy(at)
303
304
    def items(self) -> Sequence[Tup[str, Any]]:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
305
        return list(self._x.items())
306
307
    def keys(self) -> Sequence[str]:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
308
        return list(self._x.keys())
309
310
    def values(self) -> Sequence[Any]:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
311
        return list(self._x.values())
312
313
    def pretty_str(self) -> str:
314
        """
315
        Pretty-prints the leaves of this dict using ``json.dumps``.
316
317
        Returns:
318
            A multi-line string
319
        """
320
        return json.dumps(
321
            self.leaves(),
322
            sort_keys=True,
323
            indent=4,
324
            ensure_ascii=False,
325
        )
326
327
    def __len__(self) -> int:
328
        """
329
        Returns the number of values in this dict.
330
        Does **NOT** include nested values.
331
        """
332
        return len(self._x)
333
334
    def __iter__(self):
335
        """
336
        Iterates over values in this dict.
337
        Does **NOT** include nested items.
338
        """
339
        return iter(self._x)
340
341
    def __repr__(self):
342
        return repr(self._x)
343
344
    def __str__(self):
345
        return str(self._x)
346
347
    def __eq__(self, other):
348
        return str(self) == str(other)
349
350
    def _to_date(self, s) -> date:
0 ignored issues
show
Coding Style Naming introduced by
Argument name "s" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
Coding Style introduced by
This method could be written as a function/class method.

If a method does not access any attributes of the class, it could also be implemented as a function or static method. This can help improve readability. For example

class Foo:
    def some_method(self, x, y):
        return x + y;

could be written as

class Foo:
    @classmethod
    def some_method(cls, x, y):
        return x + y;
Loading history...
351
        if isinstance(s, date):
0 ignored issues
show
unused-code introduced by
Unnecessary "elif" after "return"
Loading history...
352
            return s
353
        elif isinstance(s, str):
354
            # This is MUCH faster than tomlkit's
355
            return date.fromisoformat(s)
356
        else:
357
            raise XTypeError(f"Invalid type {type(s)} for {s}", actual=str(type(s)))
358
359
    def _to_datetime(self, s) -> datetime:
0 ignored issues
show
Coding Style Naming introduced by
Argument name "s" doesn't conform to snake_case naming style ('([^\\W\\dA-Z][^\\WA-Z]2,|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$' pattern)

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
Coding Style introduced by
This method could be written as a function/class method.

If a method does not access any attributes of the class, it could also be implemented as a function or static method. This can help improve readability. For example

class Foo:
    def some_method(self, x, y):
        return x + y;

could be written as

class Foo:
    @classmethod
    def some_method(cls, x, y):
        return x + y;
Loading history...
360
        if isinstance(s, datetime):
0 ignored issues
show
unused-code introduced by
Unnecessary "elif" after "return"
Loading history...
361
            return s
362
        elif isinstance(s, str):
363
            # This is MUCH faster than tomlkit's
364
            if s.count(":") < 2:
365
                raise XValueError(
366
                    f"Datetime {s} does not contain hours, minutes, and seconds", value=s
367
                )
368
            return datetime.fromisoformat(s.upper().replace("Z", "+00:00"))
369
        else:
370
            raise XTypeError(f"Invalid type {type(s)} for {s}", actual=str(type(s)))
371
372
373
__all__ = ["NestedDotDict"]
374