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.
@@ -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,8 @@
1
+ __all__ = [
2
+ 'query',
3
+ ]
4
+
5
+
6
+ __version__ = '0.1.0'
7
+
8
+ from .api import query
@@ -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"