forestwalker 0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forestwalker-0.1/.gitignore +4 -0
- forestwalker-0.1/.readthedocs.yml +19 -0
- forestwalker-0.1/LICENSE +20 -0
- forestwalker-0.1/PKG-INFO +39 -0
- forestwalker-0.1/README.md +22 -0
- forestwalker-0.1/docs/_static/.dir +0 -0
- forestwalker-0.1/docs/_templates/.dir +0 -0
- forestwalker-0.1/docs/api.rst +7 -0
- forestwalker-0.1/docs/conf.py +29 -0
- forestwalker-0.1/docs/example1.py +15 -0
- forestwalker-0.1/docs/examples.rst +13 -0
- forestwalker-0.1/docs/index.rst +27 -0
- forestwalker-0.1/docs/requirements.txt +2 -0
- forestwalker-0.1/pyproject.toml +27 -0
- forestwalker-0.1/requirements.txt +4 -0
- forestwalker-0.1/src/forestwalker/__init__.py +380 -0
- forestwalker-0.1/tests/test_forestwalker.py +92 -0
- forestwalker-0.1/tools/gen-doc +2 -0
- forestwalker-0.1/tools/release +13 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Read the Docs configuration file
|
|
2
|
+
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
|
3
|
+
|
|
4
|
+
# Required
|
|
5
|
+
version: 2
|
|
6
|
+
|
|
7
|
+
# Set the OS, Python version, and other tools you might need
|
|
8
|
+
build:
|
|
9
|
+
os: ubuntu-24.04
|
|
10
|
+
tools:
|
|
11
|
+
python: "3.13"
|
|
12
|
+
|
|
13
|
+
# Build documentation in the "docs/" directory with Sphinx
|
|
14
|
+
sphinx:
|
|
15
|
+
configuration: docs/conf.py
|
|
16
|
+
|
|
17
|
+
python:
|
|
18
|
+
install:
|
|
19
|
+
- requirements: docs/requirements.txt
|
forestwalker-0.1/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright (c) 2026 Martin Mareš
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
4
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
5
|
+
the Software without restriction, including without limitation the rights to
|
|
6
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
|
7
|
+
of the Software, and to permit persons to whom the Software is furnished to do
|
|
8
|
+
so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice (including the next
|
|
11
|
+
paragraph) shall be included in all copies or substantial portions of the
|
|
12
|
+
Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
15
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
16
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
17
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
18
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
19
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
20
|
+
SOFTWARE.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forestwalker
|
|
3
|
+
Version: 0.1
|
|
4
|
+
Summary: A library for walking JSON-like trees, parsing and validating them.
|
|
5
|
+
Project-URL: Repository, https://github.com/gollux/forestwalker.git
|
|
6
|
+
Project-URL: Documentation, https://forestwalker.readthedocs.io/en/latest/
|
|
7
|
+
Project-URL: Issues, https://github.com/gollux/forestwalker/issues
|
|
8
|
+
Author-email: Martin Mareš <mj@ucw.cz>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: File Formats :: JSON
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# The Forest Walker
|
|
19
|
+
|
|
20
|
+
Python modules for parsing JSON, TOML, YAML, and similar formats
|
|
21
|
+
produce simple tree structures composed of numbers, strings, Booleans,
|
|
22
|
+
arrays, and dictionaries. These structures are then parsed for the second
|
|
23
|
+
time to produce more Pythonic data structures. This usually involves
|
|
24
|
+
validation of some kind.
|
|
25
|
+
|
|
26
|
+
We firmly believe that parsing and validation should not be separate
|
|
27
|
+
steps: structure of data should have a single source of truth (doing
|
|
28
|
+
otherwise opens door to security issues). Also, existing formal descriptions
|
|
29
|
+
of syntax (e.g., JSON schema) are too weak for many purposes and unwieldy
|
|
30
|
+
for others.
|
|
31
|
+
|
|
32
|
+
Enters the ``forestwalker``. A Python module that makes it easy to traverse
|
|
33
|
+
trees, parse values while checking types, and report invalid data with
|
|
34
|
+
their precise location in the tree.
|
|
35
|
+
|
|
36
|
+
## License
|
|
37
|
+
|
|
38
|
+
The Forest Walker was written by Martin Mareš <mj@ucw.cz>.
|
|
39
|
+
It can be freely used and distributed under the MIT License.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# The Forest Walker
|
|
2
|
+
|
|
3
|
+
Python modules for parsing JSON, TOML, YAML, and similar formats
|
|
4
|
+
produce simple tree structures composed of numbers, strings, Booleans,
|
|
5
|
+
arrays, and dictionaries. These structures are then parsed for the second
|
|
6
|
+
time to produce more Pythonic data structures. This usually involves
|
|
7
|
+
validation of some kind.
|
|
8
|
+
|
|
9
|
+
We firmly believe that parsing and validation should not be separate
|
|
10
|
+
steps: structure of data should have a single source of truth (doing
|
|
11
|
+
otherwise opens door to security issues). Also, existing formal descriptions
|
|
12
|
+
of syntax (e.g., JSON schema) are too weak for many purposes and unwieldy
|
|
13
|
+
for others.
|
|
14
|
+
|
|
15
|
+
Enters the ``forestwalker``. A Python module that makes it easy to traverse
|
|
16
|
+
trees, parse values while checking types, and report invalid data with
|
|
17
|
+
their precise location in the tree.
|
|
18
|
+
|
|
19
|
+
## License
|
|
20
|
+
|
|
21
|
+
The Forest Walker was written by Martin Mareš <mj@ucw.cz>.
|
|
22
|
+
It can be freely used and distributed under the MIT License.
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Configuration file for the Sphinx documentation builder.
|
|
2
|
+
#
|
|
3
|
+
# For the full list of built-in configuration values, see the documentation:
|
|
4
|
+
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
|
5
|
+
|
|
6
|
+
# -- Project information -----------------------------------------------------
|
|
7
|
+
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
|
8
|
+
|
|
9
|
+
project = 'The Forest Walker'
|
|
10
|
+
copyright = '2026, Martin Mareš'
|
|
11
|
+
author = 'Martin Mareš'
|
|
12
|
+
|
|
13
|
+
# -- General configuration ---------------------------------------------------
|
|
14
|
+
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
|
15
|
+
|
|
16
|
+
extensions = ['sphinx.ext.autodoc']
|
|
17
|
+
|
|
18
|
+
templates_path = ['_templates']
|
|
19
|
+
exclude_patterns = []
|
|
20
|
+
|
|
21
|
+
autodoc_inherit_docstrings = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# -- Options for HTML output -------------------------------------------------
|
|
26
|
+
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
|
27
|
+
|
|
28
|
+
html_theme = 'alabaster'
|
|
29
|
+
html_static_path = ['_static']
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from forestwalker import Walker, WalkerError
|
|
2
|
+
|
|
3
|
+
tree = {
|
|
4
|
+
'name': 'Robin Hood',
|
|
5
|
+
'height': 184,
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
root = Walker(tree)
|
|
10
|
+
with root.enter_object() as obj:
|
|
11
|
+
name = obj['name'].as_str()
|
|
12
|
+
height = obj['height'].as_float(100)
|
|
13
|
+
print(name, height)
|
|
14
|
+
except WalkerError as err:
|
|
15
|
+
print(f'Parse error: {err}')
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Examples
|
|
2
|
+
========
|
|
3
|
+
|
|
4
|
+
==============
|
|
5
|
+
Object parsing
|
|
6
|
+
==============
|
|
7
|
+
|
|
8
|
+
The following code parses a simple JSON object (dictionary)
|
|
9
|
+
with two attributes: ``name`` is mandatory, while ``height``
|
|
10
|
+
defaults to 100 if missing. The context manager makes sure
|
|
11
|
+
that the object contains only keys referenced by the parser.
|
|
12
|
+
|
|
13
|
+
.. literalinclude:: example1.py
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
The Forest Walker
|
|
2
|
+
=================
|
|
3
|
+
|
|
4
|
+
Python modules for parsing JSON, TOML, YAML, and similar formats
|
|
5
|
+
produce simple tree structures composed of numbers, strings, Booleans,
|
|
6
|
+
arrays, and dictionaries. These structures are then parsed for the second
|
|
7
|
+
time to produce more Pythonic data structures. This usually involves
|
|
8
|
+
validation of some kind.
|
|
9
|
+
|
|
10
|
+
We firmly believe that parsing and validation should not be separate
|
|
11
|
+
steps: structure of data should have a single source of truth (doing
|
|
12
|
+
otherwise opens door to security issues). Also, existing formal descriptions
|
|
13
|
+
of syntax (e.g., JSON schema) are too weak for many purposes and unwieldy
|
|
14
|
+
for others.
|
|
15
|
+
|
|
16
|
+
Enters the ``forestwalker``. A Python module that makes it easy to traverse
|
|
17
|
+
trees, parse values while checking types, and report invalid data with
|
|
18
|
+
their precise location in the tree.
|
|
19
|
+
|
|
20
|
+
Table of contents:
|
|
21
|
+
|
|
22
|
+
.. toctree::
|
|
23
|
+
:maxdepth: 2
|
|
24
|
+
|
|
25
|
+
self
|
|
26
|
+
examples
|
|
27
|
+
api
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "forestwalker"
|
|
3
|
+
description = "A library for walking JSON-like trees, parsing and validating them."
|
|
4
|
+
version = "0.1"
|
|
5
|
+
authors = [ { name = "Martin Mareš", email = "mj@ucw.cz" } ]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
requires-python = ">= 3.11"
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Programming Language :: Python :: 3",
|
|
10
|
+
"Operating System :: OS Independent",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"Topic :: File Formats :: JSON",
|
|
13
|
+
]
|
|
14
|
+
license = "MIT"
|
|
15
|
+
license-file = "LICENSE"
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Repository = "https://github.com/gollux/forestwalker.git"
|
|
19
|
+
Documentation = "https://forestwalker.readthedocs.io/en/latest/"
|
|
20
|
+
Issues = "https://github.com/gollux/forestwalker/issues"
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["hatchling >= 1.26"]
|
|
24
|
+
build-backend = "hatchling.build"
|
|
25
|
+
|
|
26
|
+
[pytest]
|
|
27
|
+
testpaths = "tests"
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
# A simple module for walking through a parsed JSON-like file
|
|
2
|
+
# (c) 2023-2026 Martin Mareš <mj@ucw.cz>
|
|
3
|
+
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from enum import Enum
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, Optional, NoReturn, Tuple, Set, Type, TypeVar, Self
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
T = TypeVar('T')
|
|
11
|
+
E = TypeVar('E', bound=Enum)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Walker:
|
|
15
|
+
"""
|
|
16
|
+
A Walker is a pointer to a particular node of the tree,
|
|
17
|
+
or possible a missing child node. It also remembers the
|
|
18
|
+
path to the node.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
obj: Any
|
|
22
|
+
"""The tree node. :py:class:`MissingValue` if it is a missing node."""
|
|
23
|
+
|
|
24
|
+
parent: Optional['Walker'] = None
|
|
25
|
+
"""Walker of the parent node. Used to reconstruct the path."""
|
|
26
|
+
|
|
27
|
+
custom_context: str = ""
|
|
28
|
+
|
|
29
|
+
def __init__(self, root: Any) -> None:
|
|
30
|
+
"""Create a walker point to the given root of the tree."""
|
|
31
|
+
self.obj = root
|
|
32
|
+
|
|
33
|
+
def raise_error(self, msg) -> NoReturn:
|
|
34
|
+
"""Raise a :py:exc:`WalkerError` on the current node with the given error message."""
|
|
35
|
+
raise WalkerError(self, msg)
|
|
36
|
+
|
|
37
|
+
def is_null(self) -> bool:
|
|
38
|
+
"""Tests if the current node is ``None`` (JSON ``null``)."""
|
|
39
|
+
return self.obj is None
|
|
40
|
+
|
|
41
|
+
def is_str(self) -> bool:
|
|
42
|
+
"""Tests if the current node is a string."""
|
|
43
|
+
return isinstance(self.obj, str)
|
|
44
|
+
|
|
45
|
+
def is_int(self) -> bool:
|
|
46
|
+
"""Tests if the current node is an integer."""
|
|
47
|
+
return isinstance(self.obj, int)
|
|
48
|
+
|
|
49
|
+
def is_number(self) -> bool:
|
|
50
|
+
"""Tests if the current node is an integer or float."""
|
|
51
|
+
return isinstance(self.obj, int) or isinstance(self.obj, float)
|
|
52
|
+
|
|
53
|
+
def is_bool(self) -> bool:
|
|
54
|
+
"""Tests if the current node is a Boolean value."""
|
|
55
|
+
return isinstance(self.obj, bool)
|
|
56
|
+
|
|
57
|
+
def is_missing(self) -> bool:
|
|
58
|
+
"""Tests if the current node is a missing child."""
|
|
59
|
+
return isinstance(self.obj, MissingValue)
|
|
60
|
+
|
|
61
|
+
def is_present(self) -> bool:
|
|
62
|
+
"""Tests if the current node is *not* a missing child."""
|
|
63
|
+
return not isinstance(self.obj, MissingValue)
|
|
64
|
+
|
|
65
|
+
def is_array(self) -> bool:
|
|
66
|
+
"""Tests if the current node is an array."""
|
|
67
|
+
return isinstance(self.obj, list)
|
|
68
|
+
|
|
69
|
+
def is_object(self) -> bool:
|
|
70
|
+
"""Tests if the current node is an object (dictionary)."""
|
|
71
|
+
return isinstance(self.obj, dict)
|
|
72
|
+
|
|
73
|
+
def expect_present(self) -> Self:
|
|
74
|
+
"""Raises an error if the current node is a missing child. Returns itself."""
|
|
75
|
+
if self.is_missing():
|
|
76
|
+
self.raise_error('Mandatory key is missing')
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def as_type(self, typ: Type[T], msg: str, default: Optional[T] = None) -> T:
|
|
80
|
+
if isinstance(self.obj, typ):
|
|
81
|
+
return self.obj
|
|
82
|
+
elif self.is_missing():
|
|
83
|
+
if default is None:
|
|
84
|
+
self.raise_error('Mandatory key is missing')
|
|
85
|
+
else:
|
|
86
|
+
return default
|
|
87
|
+
else:
|
|
88
|
+
self.raise_error(msg)
|
|
89
|
+
|
|
90
|
+
def as_optional_type(self, typ: Type[T], msg: str) -> Optional[T]:
|
|
91
|
+
if isinstance(self.obj, typ):
|
|
92
|
+
return self.obj
|
|
93
|
+
elif self.is_missing():
|
|
94
|
+
return None
|
|
95
|
+
else:
|
|
96
|
+
self.raise_error(msg)
|
|
97
|
+
|
|
98
|
+
def as_str(self, default: Optional[str] = None) -> str:
|
|
99
|
+
"""
|
|
100
|
+
If the current node is a string, returns its value.
|
|
101
|
+
If it is missing and *default* is given, returns *default*.
|
|
102
|
+
Otherwise raises :py:exc:`WalkerError`.
|
|
103
|
+
"""
|
|
104
|
+
return self.as_type(str, 'Expected a string', default)
|
|
105
|
+
|
|
106
|
+
def as_int(self, default: Optional[int] = None) -> int:
|
|
107
|
+
"""
|
|
108
|
+
If the current node is an integer, returns its value.
|
|
109
|
+
If it is missing and *default* is given, returns *default*.
|
|
110
|
+
Otherwise raises :py:exc:`WalkerError`.
|
|
111
|
+
"""
|
|
112
|
+
return self.as_type(int, 'Expected an integer', default)
|
|
113
|
+
|
|
114
|
+
def as_float(self, default: Optional[float] = None) -> float:
|
|
115
|
+
"""
|
|
116
|
+
If the current node is a float, returns its value.
|
|
117
|
+
If it is an integer, it is cast to a float.
|
|
118
|
+
If it is missing and *default* is given, returns *default*.
|
|
119
|
+
Otherwise raises :py:exc:`WalkerError`.
|
|
120
|
+
"""
|
|
121
|
+
if isinstance(self.obj, int):
|
|
122
|
+
return float(self.obj)
|
|
123
|
+
else:
|
|
124
|
+
return self.as_type(float, 'Expected a number', default)
|
|
125
|
+
|
|
126
|
+
def as_bool(self, default: Optional[bool] = None) -> bool:
|
|
127
|
+
"""
|
|
128
|
+
If the current node is a Boolean, returns its value.
|
|
129
|
+
If it is missing and *default* is given, returns *default*.
|
|
130
|
+
Otherwise raises :py:exc:`WalkerError`.
|
|
131
|
+
"""
|
|
132
|
+
return self.as_type(bool, 'Expected a Boolean value', default)
|
|
133
|
+
|
|
134
|
+
def as_enum(self, enum: Type[E], default: Optional[E] = None) -> E:
|
|
135
|
+
"""
|
|
136
|
+
If the current node is a string, returns its value
|
|
137
|
+
cast to the given enumeration type (descendant of :py:class:`Enum`).
|
|
138
|
+
If the node is missing and *default* is given, returns *default*.
|
|
139
|
+
Otherwise raises :py:exc:`WalkerError`.
|
|
140
|
+
"""
|
|
141
|
+
if self.is_missing() and default is not None:
|
|
142
|
+
return default
|
|
143
|
+
try:
|
|
144
|
+
return enum(self.as_str())
|
|
145
|
+
except ValueError:
|
|
146
|
+
self.raise_error('Must be one of ' + '/'.join(sorted(enum.__members__.values()))) # FIXME: type
|
|
147
|
+
|
|
148
|
+
def as_optional_str(self) -> Optional[str]:
|
|
149
|
+
"""
|
|
150
|
+
If the current node is a string, returns its value.
|
|
151
|
+
If it is missing, returns ``None``.
|
|
152
|
+
Otherwise raises :py:exc:`WalkerError`.
|
|
153
|
+
"""
|
|
154
|
+
return self.as_optional_type(str, 'Expected a string')
|
|
155
|
+
|
|
156
|
+
def as_optional_int(self) -> Optional[int]:
|
|
157
|
+
"""
|
|
158
|
+
If the current node is an integer, returns its value.
|
|
159
|
+
If it is missing, returns ``None``.
|
|
160
|
+
Otherwise raises :py:exc:`WalkerError`.
|
|
161
|
+
"""
|
|
162
|
+
return self.as_optional_type(int, 'Expected an integer')
|
|
163
|
+
|
|
164
|
+
def as_optional_float(self) -> Optional[float]:
|
|
165
|
+
"""
|
|
166
|
+
If the current node is a float, returns its value.
|
|
167
|
+
If it is an integer, it is cast to a float.
|
|
168
|
+
If it is missing, returns ``None``.
|
|
169
|
+
Otherwise raises :py:exc:`WalkerError`.
|
|
170
|
+
"""
|
|
171
|
+
if isinstance(self.obj, int):
|
|
172
|
+
return float(self.obj)
|
|
173
|
+
else:
|
|
174
|
+
return self.as_optional_type(float, 'Expected a number')
|
|
175
|
+
|
|
176
|
+
def as_optional_bool(self) -> Optional[bool]:
|
|
177
|
+
"""
|
|
178
|
+
If the current node is a Boolean, returns its value.
|
|
179
|
+
If it is missing, returns ``None``.
|
|
180
|
+
Otherwise raises :py:exc:`WalkerError`.
|
|
181
|
+
"""
|
|
182
|
+
return self.as_optional_type(bool, 'Expected a Boolean value')
|
|
183
|
+
|
|
184
|
+
def array_values(self) -> Iterator['WalkerInArray']:
|
|
185
|
+
"""
|
|
186
|
+
Produces an iterator over an array node, which yields :py:class:`WalkerInArray`
|
|
187
|
+
objects for the elements of the array.
|
|
188
|
+
If the node is not an array, raises :py:exc:`WalkerError`.
|
|
189
|
+
"""
|
|
190
|
+
ary = self.as_type(list, 'Expected an array')
|
|
191
|
+
for i, obj in enumerate(ary):
|
|
192
|
+
yield WalkerInArray(obj, self, i)
|
|
193
|
+
|
|
194
|
+
def object_values(self) -> Iterator['WalkerInObject']:
|
|
195
|
+
"""
|
|
196
|
+
Produces an iterator over attributes of an object (dictionary), which yields :py:class:`WalkerInObject`
|
|
197
|
+
objects for the values of the attributes.
|
|
198
|
+
If the node is not an object, raises :py:exc:`WalkerError`.
|
|
199
|
+
"""
|
|
200
|
+
dct = self.as_type(dict, 'Expected an object')
|
|
201
|
+
for key, obj in dct.items():
|
|
202
|
+
yield WalkerInObject(obj, self, key)
|
|
203
|
+
|
|
204
|
+
def object_items(self) -> Iterator[Tuple[str, 'WalkerInObject']]:
|
|
205
|
+
"""
|
|
206
|
+
Produces an iterator over attributes of an object (dictionary), which yields pairs of the
|
|
207
|
+
attribute's name and a :py:class:`WalkerInObject` object for the attribute's value.
|
|
208
|
+
If the node is not an object, raises :py:exc:`WalkerError`.
|
|
209
|
+
"""
|
|
210
|
+
dct = self.as_type(dict, 'Expected an object')
|
|
211
|
+
for key, obj in dct.items():
|
|
212
|
+
yield key, WalkerInObject(obj, self, key)
|
|
213
|
+
|
|
214
|
+
def enter_object(self) -> 'ObjectWalker':
|
|
215
|
+
"""
|
|
216
|
+
Produces an :py:class:`ObjectWalker` for an object (dictionary),
|
|
217
|
+
which allows indexing of the object's attributes and checking which
|
|
218
|
+
attributes are missing.
|
|
219
|
+
If the node is not an object, raises :py:exc:`WalkerError`.
|
|
220
|
+
"""
|
|
221
|
+
dct = self.as_type(dict, 'Expected an object')
|
|
222
|
+
return ObjectWalker(dct, self)
|
|
223
|
+
|
|
224
|
+
def default_to(self, default) -> 'Walker': # XXX: Use Self when available
|
|
225
|
+
"""
|
|
226
|
+
If the current node is missing, make the walker point to *default* instead.
|
|
227
|
+
Returns itself, so it is possible to write e.g.
|
|
228
|
+
``walker.default_to([]).array_values()``.
|
|
229
|
+
"""
|
|
230
|
+
if self.is_missing():
|
|
231
|
+
self.obj = default
|
|
232
|
+
return self
|
|
233
|
+
|
|
234
|
+
def context(self) -> str:
|
|
235
|
+
"""Construct a path fragment for the current node."""
|
|
236
|
+
return 'root'
|
|
237
|
+
|
|
238
|
+
def set_custom_context(self, ctx: str) -> None:
|
|
239
|
+
"""Set a string that is appended to the path fragment returned by :py:meth:`context`."""
|
|
240
|
+
self.custom_context = ctx
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class WalkerInArray(Walker):
|
|
244
|
+
"""
|
|
245
|
+
A :py:class:`Walker` referring to an array item.
|
|
246
|
+
Never constructed directly, use :py:meth:`Walker.array_items` to obtain it.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
index: int
|
|
250
|
+
"""Position of the item in the array (zero-based)."""
|
|
251
|
+
|
|
252
|
+
def __init__(self, obj: Any, parent: Walker, index: int) -> None:
|
|
253
|
+
super().__init__(obj)
|
|
254
|
+
self.parent = parent
|
|
255
|
+
self.index = index
|
|
256
|
+
|
|
257
|
+
def context(self) -> str:
|
|
258
|
+
return f'[{self.index}]'
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class WalkerInObject(Walker):
|
|
262
|
+
"""
|
|
263
|
+
A :py:class:`Walker` referring to an object attribute (dictionary item).
|
|
264
|
+
Never constructed directly, use :py:meth:`Walker.object_values`,
|
|
265
|
+
:py:meth:`Walker.object_items`, or indexing in :py:meth:`ObjectWalker`
|
|
266
|
+
to obtain it.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
key: str
|
|
270
|
+
"""Key of the attribute."""
|
|
271
|
+
|
|
272
|
+
def __init__(self, obj: Any, parent: Walker, key: str) -> None:
|
|
273
|
+
super().__init__(obj)
|
|
274
|
+
self.parent = parent
|
|
275
|
+
self.key = key
|
|
276
|
+
|
|
277
|
+
def context(self) -> str:
|
|
278
|
+
if re.fullmatch(r'\w+', self.key):
|
|
279
|
+
return f'.{self.key}'
|
|
280
|
+
else:
|
|
281
|
+
quoted_key = re.sub(r'(\\|")', r'\\\1', self.key)
|
|
282
|
+
return f'."{quoted_key}"'
|
|
283
|
+
|
|
284
|
+
def unexpected(self) -> NoReturn:
|
|
285
|
+
"""Raises a :py:exc:`WalkerError` complaining that this key was not expected."""
|
|
286
|
+
self.raise_error('Unexpected key')
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class ObjectWalker(Walker):
|
|
290
|
+
"""
|
|
291
|
+
A :py:class:`Walker` for inspecting an object (dictionary).
|
|
292
|
+
In addition to the default walker, it allows indexing of attribute
|
|
293
|
+
by the square brackets operator. It also remembers which attributes
|
|
294
|
+
were referenced this way and when used as a context manager, it
|
|
295
|
+
complains upon exit from the context if the object contains
|
|
296
|
+
unreferenced attributes.
|
|
297
|
+
|
|
298
|
+
Never constructed directly, use :py:meth:`Walker.enter_object`
|
|
299
|
+
to obtain it.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
referenced_keys: Set[str]
|
|
303
|
+
"""A set of keys of referenced attributes."""
|
|
304
|
+
|
|
305
|
+
def __init__(self, obj: Any, parent: Walker) -> None:
|
|
306
|
+
super().__init__(obj)
|
|
307
|
+
assert isinstance(obj, dict)
|
|
308
|
+
self.parent = parent
|
|
309
|
+
self.referenced_keys = set()
|
|
310
|
+
|
|
311
|
+
def __enter__(self) -> 'ObjectWalker':
|
|
312
|
+
"""Enter a context."""
|
|
313
|
+
return self
|
|
314
|
+
|
|
315
|
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
|
316
|
+
"""Exit a context, calling :py:meth:`assert_no_other_keys`."""
|
|
317
|
+
if exc_type is None:
|
|
318
|
+
self.assert_no_other_keys()
|
|
319
|
+
|
|
320
|
+
def context(self) -> str:
|
|
321
|
+
return ""
|
|
322
|
+
|
|
323
|
+
def __contains__(self, key: str) -> bool:
|
|
324
|
+
"""
|
|
325
|
+
Implements the ``in`` operator for testing if the object contains an
|
|
326
|
+
attribute with the given key.
|
|
327
|
+
"""
|
|
328
|
+
return key in self.obj
|
|
329
|
+
|
|
330
|
+
def __getitem__(self, key: str) -> WalkerInObject:
|
|
331
|
+
"""
|
|
332
|
+
Implements the ``[]`` operator for accessing an attribute with
|
|
333
|
+
the given key. Returns a :py:class:`WalkerInObject` referring to
|
|
334
|
+
the attribute's value.
|
|
335
|
+
"""
|
|
336
|
+
if key in self.obj:
|
|
337
|
+
self.referenced_keys.add(key)
|
|
338
|
+
return WalkerInObject(self.obj[key], self, key)
|
|
339
|
+
else:
|
|
340
|
+
return WalkerInObject(MissingValue(), self, key)
|
|
341
|
+
|
|
342
|
+
def assert_no_other_keys(self) -> None:
|
|
343
|
+
"""
|
|
344
|
+
Checks if the object has attributes not accessed by the ``[]``
|
|
345
|
+
operator. Raises an :py:exc:`WalkerError` if there are any.
|
|
346
|
+
"""
|
|
347
|
+
for key, val in self.obj.items():
|
|
348
|
+
if key not in self.referenced_keys:
|
|
349
|
+
WalkerInObject(val, self, key).unexpected()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class MissingValue:
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class WalkerError(Exception):
|
|
357
|
+
"""An exception for any error occurring during walking the tree."""
|
|
358
|
+
|
|
359
|
+
walker: Walker
|
|
360
|
+
"""
|
|
361
|
+
The walker that raised the exception. Used to reconstruct the path
|
|
362
|
+
to the erroneous node.
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
msg: str
|
|
366
|
+
"""Error message."""
|
|
367
|
+
|
|
368
|
+
def __init__(self, walker: Walker, msg: str) -> None:
|
|
369
|
+
self.walker = walker
|
|
370
|
+
self.msg = msg
|
|
371
|
+
|
|
372
|
+
def __str__(self) -> str:
|
|
373
|
+
"""Returns a string consisting of the path in the tree and the error message."""
|
|
374
|
+
contexts = []
|
|
375
|
+
w: Optional[Walker] = self.walker
|
|
376
|
+
while w is not None:
|
|
377
|
+
contexts.append(w.context())
|
|
378
|
+
contexts.append(w.custom_context)
|
|
379
|
+
w = w.parent
|
|
380
|
+
return "".join(reversed(contexts)) + ": " + self.msg
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from forestwalker import Walker, WalkerError
|
|
2
|
+
import unittest
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TestWalker(unittest.TestCase):
|
|
6
|
+
|
|
7
|
+
def test_is_int(self):
|
|
8
|
+
self.assertEqual(Walker(42).is_int(), True)
|
|
9
|
+
|
|
10
|
+
def test_is_int_not(self):
|
|
11
|
+
self.assertEqual(Walker('forty-two').is_int(), False)
|
|
12
|
+
|
|
13
|
+
def test_as_int(self):
|
|
14
|
+
self.assertEqual(Walker(42).as_int(), 42)
|
|
15
|
+
|
|
16
|
+
def test_as_int_not(self):
|
|
17
|
+
with self.assertRaises(WalkerError):
|
|
18
|
+
Walker('forty-two').as_int()
|
|
19
|
+
|
|
20
|
+
def test_is_bool(self):
|
|
21
|
+
self.assertEqual(Walker(False).is_bool(), True)
|
|
22
|
+
|
|
23
|
+
def test_array(self):
|
|
24
|
+
w = Walker([1, 2, 3])
|
|
25
|
+
b = []
|
|
26
|
+
for val in w.array_values():
|
|
27
|
+
b.append(val.as_int())
|
|
28
|
+
self.assertEqual(b, [1, 2, 3])
|
|
29
|
+
|
|
30
|
+
def test_array_not(self):
|
|
31
|
+
with self.assertRaises(WalkerError):
|
|
32
|
+
for _ in Walker(42).array_values():
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
def test_array_context(self):
|
|
36
|
+
with self.assertRaisesRegex(WalkerError, r'^root\[1]: '):
|
|
37
|
+
w = Walker([1, None, 3])
|
|
38
|
+
for val in w.array_values():
|
|
39
|
+
val.as_int()
|
|
40
|
+
|
|
41
|
+
def test_object_values(self):
|
|
42
|
+
d = {"one": 1, "three": 3, "eleven": 11}
|
|
43
|
+
w = Walker(d)
|
|
44
|
+
b = set()
|
|
45
|
+
for val in w.object_values():
|
|
46
|
+
b.add(val.as_int())
|
|
47
|
+
self.assertEqual(b, set(d.values()))
|
|
48
|
+
|
|
49
|
+
def test_object_items(self):
|
|
50
|
+
d = {"one": 1, "three": 3, "eleven": 11}
|
|
51
|
+
w = Walker(d)
|
|
52
|
+
b = {}
|
|
53
|
+
for key, val in w.object_items():
|
|
54
|
+
b[key] = val.as_int()
|
|
55
|
+
self.assertEqual(b, d)
|
|
56
|
+
|
|
57
|
+
def test_object_values_not(self):
|
|
58
|
+
with self.assertRaises(WalkerError):
|
|
59
|
+
for _ in Walker(42).object_values():
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def test_complex_context(self):
|
|
63
|
+
with self.assertRaisesRegex(WalkerError, r'^root\.three\[2]: '):
|
|
64
|
+
w = Walker({"one": 1, "three": [5, 6, 'huiiii!', 7], "eleven": 11})
|
|
65
|
+
for key, val in w.object_items():
|
|
66
|
+
if val.is_array():
|
|
67
|
+
for wal in val.array_values():
|
|
68
|
+
wal.as_int()
|
|
69
|
+
else:
|
|
70
|
+
val.as_int()
|
|
71
|
+
|
|
72
|
+
def test_object_walker_not(self):
|
|
73
|
+
with self.assertRaises(WalkerError):
|
|
74
|
+
Walker(42).enter_object()
|
|
75
|
+
|
|
76
|
+
def test_object_walker(self):
|
|
77
|
+
with Walker({'one': 1, 'two': 'dva', 'three': False}).enter_object() as w:
|
|
78
|
+
self.assertEqual(w['one'].as_int(4), 1)
|
|
79
|
+
self.assertEqual(w['two'].as_str(), 'dva')
|
|
80
|
+
self.assertEqual(w['three'].as_bool(), False)
|
|
81
|
+
self.assertEqual(w['four'].as_int(4), 4)
|
|
82
|
+
|
|
83
|
+
def test_object_walker_other(self):
|
|
84
|
+
with self.assertRaisesRegex(WalkerError, r'^root\.three: Unexpected key$'):
|
|
85
|
+
with Walker({'one': 1, 'two': 'dva', 'three': False}).enter_object() as w:
|
|
86
|
+
self.assertEqual(w['one'].as_int(4), 1)
|
|
87
|
+
self.assertEqual(w['two'].as_str(), 'dva')
|
|
88
|
+
|
|
89
|
+
def test_object_walker_missing(self):
|
|
90
|
+
with self.assertRaisesRegex(WalkerError, r'^root\.five: Mandatory key is missing$'):
|
|
91
|
+
with Walker({'one': 1, 'two': 'dva', 'three': False}).enter_object() as w:
|
|
92
|
+
self.assertEqual(w['five'].as_int())
|