json_tabulator 0.1.0__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.
- json_tabulator-0.1.0/LICENSE +21 -0
- json_tabulator-0.1.0/PKG-INFO +74 -0
- json_tabulator-0.1.0/README.md +54 -0
- json_tabulator-0.1.0/json_tabulator/__init__.py +8 -0
- json_tabulator-0.1.0/json_tabulator/api.py +35 -0
- json_tabulator-0.1.0/json_tabulator/expression.py +89 -0
- json_tabulator-0.1.0/json_tabulator/parser.py +67 -0
- json_tabulator-0.1.0/json_tabulator/query.py +63 -0
- json_tabulator-0.1.0/pyproject.toml +35 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Matthias Ossadnik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: json_tabulator
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Simple query language to extract tables from JSON.
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Matthias Ossadnik
|
|
7
|
+
Author-email: ossadnik.matthias@gmail.com
|
|
8
|
+
Requires-Python: >=3.9,<4.0
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Project-URL: homepage, https://github.com/mossadnik/json_tabulator
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# json_tabulator
|
|
21
|
+
|
|
22
|
+
A simple query language for extracting tables from JSON-like objects.
|
|
23
|
+
|
|
24
|
+
Working with tabular data is much easier than working with nested documents. json-tables helps to extract tables from JSON-like objects in a simple, declarative manner. All further processing is left to the many powerful tools that exist for working with tables, such as Spark or Pandas.
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
Install from pypi:
|
|
30
|
+
|
|
31
|
+
```shell
|
|
32
|
+
pip install json_tabulator
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
The `json_tabulator` module provides tools to extract a JSON document into a set of related tables. Let's start with a simple document
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
data = {
|
|
41
|
+
'id': 'doc-1',
|
|
42
|
+
'table': [
|
|
43
|
+
{'id': 1, 'name': 'row-1'},
|
|
44
|
+
{'id': 2, 'name': 'row-2'}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The document consists of a document-level value `id` as well as a nested sub-table `table`. We want to extract it into a single table, with the global value folded into the table.
|
|
50
|
+
|
|
51
|
+
To do this, we write a query that defines the conversion into a table like this:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from json_tabulator import query
|
|
55
|
+
|
|
56
|
+
my_query = query({
|
|
57
|
+
'document_id': 'id',
|
|
58
|
+
'row_id': 'table.*.id',
|
|
59
|
+
'row_name': 'table.*.name'
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
rows = my_query.execute(data)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
This returns an iterator of rows, where each row is a dict `{<column_name>: <value>}`:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
>>> list(rows)
|
|
69
|
+
[
|
|
70
|
+
{'document_id': 'doc-1', 'row_id': 1, 'row_name': 'row-1'},
|
|
71
|
+
{'document_id': 'doc-1', 'row_id': 2, 'row_name': 'row-2'}
|
|
72
|
+
]
|
|
73
|
+
```
|
|
74
|
+
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# json_tabulator
|
|
2
|
+
|
|
3
|
+
A simple query language for extracting tables from JSON-like objects.
|
|
4
|
+
|
|
5
|
+
Working with tabular data is much easier than working with nested documents. json-tables helps to extract tables from JSON-like objects in a simple, declarative manner. All further processing is left to the many powerful tools that exist for working with tables, such as Spark or Pandas.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Install from pypi:
|
|
11
|
+
|
|
12
|
+
```shell
|
|
13
|
+
pip install json_tabulator
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quickstart
|
|
17
|
+
|
|
18
|
+
The `json_tabulator` module provides tools to extract a JSON document into a set of related tables. Let's start with a simple document
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
data = {
|
|
22
|
+
'id': 'doc-1',
|
|
23
|
+
'table': [
|
|
24
|
+
{'id': 1, 'name': 'row-1'},
|
|
25
|
+
{'id': 2, 'name': 'row-2'}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The document consists of a document-level value `id` as well as a nested sub-table `table`. We want to extract it into a single table, with the global value folded into the table.
|
|
31
|
+
|
|
32
|
+
To do this, we write a query that defines the conversion into a table like this:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from json_tabulator import query
|
|
36
|
+
|
|
37
|
+
my_query = query({
|
|
38
|
+
'document_id': 'id',
|
|
39
|
+
'row_id': 'table.*.id',
|
|
40
|
+
'row_name': 'table.*.name'
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
rows = my_query.execute(data)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
This returns an iterator of rows, where each row is a dict `{<column_name>: <value>}`:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
>>> list(rows)
|
|
50
|
+
[
|
|
51
|
+
{'document_id': 'doc-1', 'row_id': 1, 'row_name': 'row-1'},
|
|
52
|
+
{'document_id': 'doc-1', 'row_id': 2, 'row_name': 'row-2'}
|
|
53
|
+
]
|
|
54
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from .expression import Expression
|
|
3
|
+
from .query import QueryPlan
|
|
4
|
+
from .parser import parse_expression
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Attribute:
|
|
9
|
+
name: str
|
|
10
|
+
expression: Expression
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Query:
|
|
15
|
+
attributes: list[Attribute]
|
|
16
|
+
plan: QueryPlan
|
|
17
|
+
omit_missing_attributes: bool
|
|
18
|
+
|
|
19
|
+
def execute(self, data):
|
|
20
|
+
return self.plan.execute(data, omit_missing_attributes=self.omit_missing_attributes)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def query(
|
|
24
|
+
attributes: dict[str, str],
|
|
25
|
+
omit_missing_attributes: bool = False
|
|
26
|
+
) -> Query:
|
|
27
|
+
if isinstance(attributes, dict):
|
|
28
|
+
attributes = [
|
|
29
|
+
Attribute(name, expression=parse_expression(expr))
|
|
30
|
+
for name, expr in attributes.items()
|
|
31
|
+
]
|
|
32
|
+
else:
|
|
33
|
+
raise ValueError(f'Query not understood: {attributes}')
|
|
34
|
+
plan = QueryPlan.from_dict({a.name: a.expression for a in attributes})
|
|
35
|
+
return Query(attributes, plan, omit_missing_attributes=omit_missing_attributes)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import itertools as it
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class Segment:
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Key(Segment):
|
|
12
|
+
value: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Star(Segment):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Index(Segment):
|
|
22
|
+
value: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_wildcard(segment: Segment):
|
|
27
|
+
return isinstance(segment, Star)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Expression(tuple):
|
|
31
|
+
def __repr__(self):
|
|
32
|
+
return f'Expression({str(self)})'
|
|
33
|
+
|
|
34
|
+
def __str__(self):
|
|
35
|
+
def render_element(seg):
|
|
36
|
+
if isinstance(seg, Star):
|
|
37
|
+
return '*'
|
|
38
|
+
elif isinstance(seg, Key):
|
|
39
|
+
return seg.value
|
|
40
|
+
elif isinstance(seg, Index):
|
|
41
|
+
return str(seg.value)
|
|
42
|
+
else:
|
|
43
|
+
raise ValueError(f'Not a path segment: {seg}')
|
|
44
|
+
|
|
45
|
+
return '.'.join(map(render_element, self))
|
|
46
|
+
|
|
47
|
+
def _iter_generic(self):
|
|
48
|
+
return (seg if isinstance(seg, Key) else Star() for seg in self)
|
|
49
|
+
|
|
50
|
+
def get_attribute(self):
|
|
51
|
+
return Expression(self._iter_generic())
|
|
52
|
+
|
|
53
|
+
def get_table(self):
|
|
54
|
+
idx = -1
|
|
55
|
+
for i, p in enumerate(self._iter_generic()):
|
|
56
|
+
if not isinstance(p, Key):
|
|
57
|
+
idx = i
|
|
58
|
+
return Expression(it.islice(self._iter_generic(), idx + 1))
|
|
59
|
+
|
|
60
|
+
def is_valid(self):
|
|
61
|
+
has_valid_elements = all(
|
|
62
|
+
isinstance(value, Key) and isinstance(value.value, str)
|
|
63
|
+
or isinstance(value, Index) and value.value >= 0
|
|
64
|
+
or isinstance(value, Star)
|
|
65
|
+
for value in self
|
|
66
|
+
)
|
|
67
|
+
return has_valid_elements and (self.is_generic() or self.is_concrete())
|
|
68
|
+
|
|
69
|
+
def coincides_with(self, other):
|
|
70
|
+
length = min(len(self), len(other))
|
|
71
|
+
return self[:length] == other[:length]
|
|
72
|
+
|
|
73
|
+
def get_row(self):
|
|
74
|
+
idx = -1
|
|
75
|
+
for i, seg in enumerate(self):
|
|
76
|
+
if isinstance(seg, Index):
|
|
77
|
+
idx = i
|
|
78
|
+
elif isinstance(seg, Star):
|
|
79
|
+
raise ValueError(f'Cannot get row because path is not concrete: {self}.')
|
|
80
|
+
return Expression(self[:idx + 1])
|
|
81
|
+
|
|
82
|
+
def is_generic(self):
|
|
83
|
+
return not any(isinstance(seg, Index) for seg in self)
|
|
84
|
+
|
|
85
|
+
def is_concrete(self):
|
|
86
|
+
return not any(isinstance(seg, Star) for seg in self)
|
|
87
|
+
|
|
88
|
+
def __add__(self, other):
|
|
89
|
+
return Expression(super().__add__(other))
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
import itertools as it
|
|
3
|
+
|
|
4
|
+
from .expression import Expression, Key, Star
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class State(Enum):
|
|
8
|
+
start_segment = 0
|
|
9
|
+
within_segment = 1
|
|
10
|
+
end_segment = 2
|
|
11
|
+
double_quotes = 3
|
|
12
|
+
single_quotes = 4
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InvalidExpression(ValueError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def error(s, i):
|
|
20
|
+
raise InvalidExpression(f'Parsing expression {s}, failed at position {i}')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_expression(s: str) -> Expression:
|
|
24
|
+
return Expression(_parse(s))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse(s: str):
|
|
28
|
+
state = State.start_segment
|
|
29
|
+
i_token = 0
|
|
30
|
+
for i, c in enumerate(it.chain(s, [None])):
|
|
31
|
+
if c in ('.', None):
|
|
32
|
+
if state in (State.within_segment, State.end_segment):
|
|
33
|
+
if i_token is not None:
|
|
34
|
+
text = s[i_token:i]
|
|
35
|
+
if text == '*':
|
|
36
|
+
yield Star()
|
|
37
|
+
else:
|
|
38
|
+
yield Key(text)
|
|
39
|
+
i_token = i + 1
|
|
40
|
+
state = State.start_segment
|
|
41
|
+
elif i == 0 and state == State.start_segment:
|
|
42
|
+
pass
|
|
43
|
+
else:
|
|
44
|
+
error(s, i)
|
|
45
|
+
elif c == '*':
|
|
46
|
+
if state == State.start_segment:
|
|
47
|
+
state = State.end_segment
|
|
48
|
+
elif state in (State.double_quotes, State.single_quotes):
|
|
49
|
+
pass
|
|
50
|
+
else:
|
|
51
|
+
error(s, i)
|
|
52
|
+
elif c in ['"', "'"]:
|
|
53
|
+
quote_state = State.double_quotes if c == '"' else State.single_quotes
|
|
54
|
+
if state == State.start_segment:
|
|
55
|
+
i_token = i + 1
|
|
56
|
+
state = quote_state
|
|
57
|
+
elif state == quote_state:
|
|
58
|
+
yield Key(s[i_token:i])
|
|
59
|
+
i_token = None
|
|
60
|
+
state = State.end_segment
|
|
61
|
+
else:
|
|
62
|
+
if state in (State.start_segment, State.within_segment):
|
|
63
|
+
state = State.within_segment
|
|
64
|
+
elif state in (State.double_quotes, State.single_quotes):
|
|
65
|
+
pass
|
|
66
|
+
else:
|
|
67
|
+
error(s, i)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
|
|
5
|
+
from .expression import Expression, Star
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def nested_get(data, keys) -> tuple[Any, bool]:
|
|
9
|
+
res = data
|
|
10
|
+
for k in keys:
|
|
11
|
+
if not isinstance(res, dict) or k not in res:
|
|
12
|
+
return None, False
|
|
13
|
+
res = res[k]
|
|
14
|
+
return res, True
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class QueryPlan:
|
|
19
|
+
path: Expression
|
|
20
|
+
extracts: dict[Expression, dict[str, tuple]]
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_dict(cls, query: dict[str, Expression]) -> 'QueryPlan':
|
|
24
|
+
steps = defaultdict(dict)
|
|
25
|
+
query_path = Expression()
|
|
26
|
+
for name, expr in query.items():
|
|
27
|
+
table = expr.get_table()
|
|
28
|
+
|
|
29
|
+
if not table.coincides_with(query_path):
|
|
30
|
+
raise ValueError(f'Illegal query: Paths {table} and {query_path} are not compatible.')
|
|
31
|
+
|
|
32
|
+
query_path = max(query_path, table, key=len)
|
|
33
|
+
steps[table][name] = tuple(seg.value for seg in expr[len(table):])
|
|
34
|
+
|
|
35
|
+
return cls(path=query_path, extracts=steps)
|
|
36
|
+
|
|
37
|
+
def execute(self, data, omit_missing_attributes: bool):
|
|
38
|
+
def _recurse(data, head, tail, extract):
|
|
39
|
+
if head in self.extracts:
|
|
40
|
+
update = (
|
|
41
|
+
(name, *nested_get(data, keys))
|
|
42
|
+
for name, keys in self.extracts[head].items()
|
|
43
|
+
)
|
|
44
|
+
extract = {
|
|
45
|
+
**extract,
|
|
46
|
+
**{
|
|
47
|
+
name: value
|
|
48
|
+
for name, value, success in update
|
|
49
|
+
if success or not omit_missing_attributes
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if tail:
|
|
53
|
+
current, tail = tail[0], tail[1:]
|
|
54
|
+
head = head + (current,)
|
|
55
|
+
if isinstance(current, Star) and isinstance(data, list):
|
|
56
|
+
for item in data:
|
|
57
|
+
yield from _recurse(item, head, tail, extract)
|
|
58
|
+
elif isinstance(data, dict):
|
|
59
|
+
yield from _recurse(data.get(current.value), head, tail, extract)
|
|
60
|
+
else:
|
|
61
|
+
yield extract
|
|
62
|
+
|
|
63
|
+
yield from _recurse(data, Expression(), self.path, {})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "json_tabulator"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Simple query language to extract tables from JSON."
|
|
5
|
+
authors = ["Matthias Ossadnik <ossadnik.matthias@gmail.com>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Development Status :: 4 - Beta",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[tool.poetry.urls]
|
|
13
|
+
homepage = "https://github.com/mossadnik/json_tabulator"
|
|
14
|
+
|
|
15
|
+
[tool.poetry.dependencies]
|
|
16
|
+
python = "^3.9"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
[tool.poetry.group.dev.dependencies]
|
|
20
|
+
ipykernel = "^6.29.5"
|
|
21
|
+
jupyter = "*"
|
|
22
|
+
pandas = "*"
|
|
23
|
+
ruff = "^0.8.6"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
[tool.poetry.group.test.dependencies]
|
|
27
|
+
pytest = "^8.3.4"
|
|
28
|
+
pytest-cov = "^6.0.0"
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["poetry-core"]
|
|
32
|
+
build-backend = "poetry.core.masonry.api"
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
addopts = "--cov=json_tables --cov-report html"
|