templify-cli 0.1.0__py3-none-any.whl
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.
- templify/__init__.py +1 -0
- templify/cli/__init__.py +1 -0
- templify/cli/dependencies/__init__.py +1 -0
- templify/cli/dependencies/console.py +9 -0
- templify/cli/dependencies/data.py +196 -0
- templify/cli/dependencies/template.py +96 -0
- templify/cli/help.py +61 -0
- templify/cli/main.py +165 -0
- templify/filter_rules.py +366 -0
- templify/models.py +9 -0
- templify/py.typed +0 -0
- templify/readers.py +148 -0
- templify/transformers.py +120 -0
- templify/writers.py +312 -0
- templify_cli-0.1.0.dist-info/METADATA +980 -0
- templify_cli-0.1.0.dist-info/RECORD +19 -0
- templify_cli-0.1.0.dist-info/WHEEL +4 -0
- templify_cli-0.1.0.dist-info/entry_points.txt +3 -0
- templify_cli-0.1.0.dist-info/licenses/LICENSE +232 -0
templify/filter_rules.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Filter rules for the `--filter` option, each keeps the records matching a condition.
|
|
2
|
+
|
|
3
|
+
The first argument of every rule is the column (`key`), and values coming from the filter
|
|
4
|
+
JSON are converted to the rule type, so `{"name": "int__gt", "args": ["age", "30"]}` works.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import date, datetime
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pyspecification import SubscriptableRulesRegistry
|
|
14
|
+
|
|
15
|
+
rules = SubscriptableRulesRegistry[dict[str, Any], str, bool](
|
|
16
|
+
operator="logical",
|
|
17
|
+
check_key_existence=True,
|
|
18
|
+
)
|
|
19
|
+
"""The registry of every filter rule, keyed by rule name."""
|
|
20
|
+
|
|
21
|
+
# -----------------------
|
|
22
|
+
# bool
|
|
23
|
+
# -----------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@rules.rule()
|
|
27
|
+
def is_true(d: dict[str, Any], key: str) -> bool:
|
|
28
|
+
"""Keep records where `key` is `true`."""
|
|
29
|
+
return d[key] is True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@rules.rule()
|
|
33
|
+
def is_null(d: dict[str, Any], key: str) -> bool:
|
|
34
|
+
"""Keep records where `key` is empty (`null`)."""
|
|
35
|
+
return d[key] is None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# -----------------------
|
|
39
|
+
# int
|
|
40
|
+
# -----------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@rules.rule(processors=int)
|
|
44
|
+
def int__eq(d: dict[str, Any], key: str, value: int) -> bool:
|
|
45
|
+
"""Keep records where `key` equals `value`."""
|
|
46
|
+
return d[key] == value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@rules.rule(processors=int)
|
|
50
|
+
def int__gt(d: dict[str, Any], key: str, value: int) -> bool:
|
|
51
|
+
"""Keep records where `key` is greater than `value`."""
|
|
52
|
+
return d[key] > value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@rules.rule(processors=int)
|
|
56
|
+
def int__ge(d: dict[str, Any], key: str, value: int) -> bool:
|
|
57
|
+
"""Keep records where `key` is greater than or equal to `value`."""
|
|
58
|
+
return d[key] >= value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@rules.rule(processors=int)
|
|
62
|
+
def int__lt(d: dict[str, Any], key: str, value: int) -> bool:
|
|
63
|
+
"""Keep records where `key` is less than `value`."""
|
|
64
|
+
return d[key] < value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@rules.rule(processors=int)
|
|
68
|
+
def int__le(d: dict[str, Any], key: str, value: int) -> bool:
|
|
69
|
+
"""Keep records where `key` is less than or equal to `value`."""
|
|
70
|
+
return d[key] <= value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# -----------------------
|
|
74
|
+
# float
|
|
75
|
+
# -----------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@rules.rule(processors=float)
|
|
79
|
+
def float__eq(d: dict[str, Any], key: str, value: float) -> bool:
|
|
80
|
+
"""Keep records where `key` equals `value`."""
|
|
81
|
+
return d[key] == value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@rules.rule(processors=float)
|
|
85
|
+
def float__gt(d: dict[str, Any], key: str, value: float) -> bool:
|
|
86
|
+
"""Keep records where `key` is greater than `value`."""
|
|
87
|
+
return d[key] > value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@rules.rule(processors=float)
|
|
91
|
+
def float__ge(d: dict[str, Any], key: str, value: float) -> bool:
|
|
92
|
+
"""Keep records where `key` is greater than or equal to `value`."""
|
|
93
|
+
return d[key] >= value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@rules.rule(processors=float)
|
|
97
|
+
def float__lt(d: dict[str, Any], key: str, value: float) -> bool:
|
|
98
|
+
"""Keep records where `key` is less than `value`."""
|
|
99
|
+
return d[key] < value
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@rules.rule(processors=float)
|
|
103
|
+
def float__le(d: dict[str, Any], key: str, value: float) -> bool:
|
|
104
|
+
"""Keep records where `key` is less than or equal to `value`."""
|
|
105
|
+
return d[key] <= value
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# -----------------------
|
|
109
|
+
# string
|
|
110
|
+
# -----------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@rules.rule()
|
|
114
|
+
def string__eq(d: dict[str, Any], key: str, value: str) -> bool:
|
|
115
|
+
"""Keep records where `key` equals `value`."""
|
|
116
|
+
return d[key] == value
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@rules.rule()
|
|
120
|
+
def string__regex(d: dict[str, Any], key: str, pattern: str) -> bool:
|
|
121
|
+
"""Keep records where `key` matches the regular expression `pattern`."""
|
|
122
|
+
return re.search(pattern, d[key]) is not None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@rules.rule()
|
|
126
|
+
def string__contains(d: dict[str, Any], key: str, value: str) -> bool:
|
|
127
|
+
"""Keep records where `key` contains `value`."""
|
|
128
|
+
return value in d[key]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@rules.rule()
|
|
132
|
+
def string__icontains(d: dict[str, Any], key: str, value: str) -> bool:
|
|
133
|
+
"""Keep records where `key` contains `value`, ignoring case."""
|
|
134
|
+
return value.lower() in d[key].lower()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@rules.rule()
|
|
138
|
+
def string__is_in(d: dict[str, Any], key: str, value: list[str]) -> bool:
|
|
139
|
+
"""Keep records where `key` is one of the `value` list."""
|
|
140
|
+
return d[key] in value
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@rules.rule()
|
|
144
|
+
def string__iis_in(d: dict[str, Any], key: str, value: list[str]) -> bool:
|
|
145
|
+
"""Keep records where `key` is one of the `value` list, ignoring case."""
|
|
146
|
+
return d[key].lower() in [item.lower() for item in value]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@rules.rule()
|
|
150
|
+
def string__startswith(d: dict[str, Any], key: str, value: str) -> bool:
|
|
151
|
+
"""Keep records where `key` starts with `value`."""
|
|
152
|
+
return d[key].startswith(value)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@rules.rule()
|
|
156
|
+
def string__istartswith(d: dict[str, Any], key: str, value: str) -> bool:
|
|
157
|
+
"""Keep records where `key` starts with `value`, ignoring case."""
|
|
158
|
+
return d[key].lower().startswith(value.lower())
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@rules.rule()
|
|
162
|
+
def string__endswith(d: dict[str, Any], key: str, value: str) -> bool:
|
|
163
|
+
"""Keep records where `key` ends with `value`."""
|
|
164
|
+
return d[key].endswith(value)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@rules.rule()
|
|
168
|
+
def string__iendswith(d: dict[str, Any], key: str, value: str) -> bool:
|
|
169
|
+
"""Keep records where `key` ends with `value`, ignoring case."""
|
|
170
|
+
return d[key].lower().endswith(value.lower())
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@rules.rule()
|
|
174
|
+
def string__length_eq(d: dict[str, Any], key: str, value: int) -> bool:
|
|
175
|
+
"""Keep records where the length of `key` equals `value`."""
|
|
176
|
+
return len(d[key]) == value
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@rules.rule()
|
|
180
|
+
def string__length_gt(d: dict[str, Any], key: str, value: int) -> bool:
|
|
181
|
+
"""Keep records where the length of `key` is greater than `value`."""
|
|
182
|
+
return len(d[key]) > value
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@rules.rule()
|
|
186
|
+
def string__length_ge(d: dict[str, Any], key: str, value: int) -> bool:
|
|
187
|
+
"""Keep records where the length of `key` is greater than or equal to `value`."""
|
|
188
|
+
return len(d[key]) >= value
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@rules.rule()
|
|
192
|
+
def string__length_lt(d: dict[str, Any], key: str, value: int) -> bool:
|
|
193
|
+
"""Keep records where the length of `key` is less than `value`."""
|
|
194
|
+
return len(d[key]) < value
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@rules.rule()
|
|
198
|
+
def string__length_le(d: dict[str, Any], key: str, value: int) -> bool:
|
|
199
|
+
"""Keep records where the length of `key` is less than or equal to `value`."""
|
|
200
|
+
return len(d[key]) <= value
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# -----------------------
|
|
204
|
+
# decimal
|
|
205
|
+
# -----------------------
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _process_decimal(value: str | float) -> Decimal:
|
|
209
|
+
if isinstance(value, (str, int)):
|
|
210
|
+
return Decimal(value)
|
|
211
|
+
|
|
212
|
+
if isinstance(value, float):
|
|
213
|
+
return Decimal.from_float(value).quantize(Decimal("0.01"))
|
|
214
|
+
|
|
215
|
+
msg = f"Unexpected type {type(value)}"
|
|
216
|
+
raise TypeError(msg)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@rules.rule(processors=_process_decimal)
|
|
220
|
+
def decimal__eq(d: dict[str, Any], key: str, value: Decimal) -> bool:
|
|
221
|
+
"""Keep records where `key` equals `value`."""
|
|
222
|
+
return d[key] == value
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@rules.rule(processors=_process_decimal)
|
|
226
|
+
def decimal__gt(d: dict[str, Any], key: str, value: Decimal) -> bool:
|
|
227
|
+
"""Keep records where `key` is greater than `value`."""
|
|
228
|
+
return d[key] > value
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@rules.rule(processors=_process_decimal)
|
|
232
|
+
def decimal__ge(d: dict[str, Any], key: str, value: Decimal) -> bool:
|
|
233
|
+
"""Keep records where `key` is greater than or equal to `value`."""
|
|
234
|
+
return d[key] >= value
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@rules.rule(processors=_process_decimal)
|
|
238
|
+
def decimal__lt(d: dict[str, Any], key: str, value: Decimal) -> bool:
|
|
239
|
+
"""Keep records where `key` is less than `value`."""
|
|
240
|
+
return d[key] < value
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@rules.rule(processors=_process_decimal)
|
|
244
|
+
def decimal__le(d: dict[str, Any], key: str, value: Decimal) -> bool:
|
|
245
|
+
"""Keep records where `key` is less than or equal to `value`."""
|
|
246
|
+
return d[key] <= value
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# -----------------------
|
|
250
|
+
# datetime
|
|
251
|
+
# -----------------------
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@rules.rule(processors=datetime.fromisoformat)
|
|
255
|
+
def datetime__eq(d: dict[str, Any], key: str, value: datetime) -> bool:
|
|
256
|
+
"""Keep records where `key` equals `value`."""
|
|
257
|
+
return d[key] == value
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@rules.rule(processors=datetime.fromisoformat)
|
|
261
|
+
def datetime__gt(d: dict[str, Any], key: str, value: datetime) -> bool:
|
|
262
|
+
"""Keep records where `key` is greater than `value`."""
|
|
263
|
+
return d[key] > value
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@rules.rule(processors=datetime.fromisoformat)
|
|
267
|
+
def datetime__ge(d: dict[str, Any], key: str, value: datetime) -> bool:
|
|
268
|
+
"""Keep records where `key` is greater than or equal to `value`."""
|
|
269
|
+
return d[key] >= value
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@rules.rule(processors=datetime.fromisoformat)
|
|
273
|
+
def datetime__lt(d: dict[str, Any], key: str, value: datetime) -> bool:
|
|
274
|
+
"""Keep records where `key` is less than `value`."""
|
|
275
|
+
return d[key] < value
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@rules.rule(processors=datetime.fromisoformat)
|
|
279
|
+
def datetime__le(d: dict[str, Any], key: str, value: datetime) -> bool:
|
|
280
|
+
"""Keep records where `key` is less than or equal to `value`."""
|
|
281
|
+
return d[key] <= value
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# -----------------------
|
|
285
|
+
# date
|
|
286
|
+
# -----------------------
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _to_date(value: date | str) -> date:
|
|
290
|
+
"""Accept dates, datetimes (like Excel cells) and ISO strings (like JSON values)."""
|
|
291
|
+
if isinstance(value, datetime):
|
|
292
|
+
return value.date()
|
|
293
|
+
|
|
294
|
+
if isinstance(value, date):
|
|
295
|
+
return value
|
|
296
|
+
|
|
297
|
+
if isinstance(value, str):
|
|
298
|
+
return datetime.fromisoformat(value).date()
|
|
299
|
+
|
|
300
|
+
msg = f"Can not convert {type(value).__name__} '{value}' to a date"
|
|
301
|
+
raise TypeError(msg)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@rules.rule(processors=_to_date)
|
|
305
|
+
def date__eq(d: dict[str, Any], key: str, value: date) -> bool:
|
|
306
|
+
"""Keep records where `key` equals `value`, comparing the day only."""
|
|
307
|
+
return _to_date(d[key]) == value
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@rules.rule(processors=_to_date)
|
|
311
|
+
def date__gt(d: dict[str, Any], key: str, value: date) -> bool:
|
|
312
|
+
"""Keep records where `key` is greater than `value`, comparing the day only."""
|
|
313
|
+
return _to_date(d[key]) > value
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@rules.rule(processors=_to_date)
|
|
317
|
+
def date__ge(d: dict[str, Any], key: str, value: date) -> bool:
|
|
318
|
+
"""Keep records where `key` is greater than or equal to `value`, comparing the day only."""
|
|
319
|
+
return _to_date(d[key]) >= value
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@rules.rule(processors=_to_date)
|
|
323
|
+
def date__lt(d: dict[str, Any], key: str, value: date) -> bool:
|
|
324
|
+
"""Keep records where `key` is less than `value`, comparing the day only."""
|
|
325
|
+
return _to_date(d[key]) < value
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@rules.rule(processors=_to_date)
|
|
329
|
+
def date__le(d: dict[str, Any], key: str, value: date) -> bool:
|
|
330
|
+
"""Keep records where `key` is less than or equal to `value`, comparing the day only."""
|
|
331
|
+
return _to_date(d[key]) <= value
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# -----------------------
|
|
335
|
+
# uuid
|
|
336
|
+
# -----------------------
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@rules.rule(processors=uuid.UUID)
|
|
340
|
+
def uuid__eq(d: dict[str, Any], key: str, value: uuid.UUID) -> bool:
|
|
341
|
+
"""Keep records where `key` equals `value`."""
|
|
342
|
+
return d[key] == value
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@rules.rule(processors=uuid.UUID)
|
|
346
|
+
def uuid__gt(d: dict[str, Any], key: str, value: uuid.UUID) -> bool:
|
|
347
|
+
"""Keep records where `key` is greater than `value`."""
|
|
348
|
+
return d[key] > value
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@rules.rule(processors=uuid.UUID)
|
|
352
|
+
def uuid__ge(d: dict[str, Any], key: str, value: uuid.UUID) -> bool:
|
|
353
|
+
"""Keep records where `key` is greater than or equal to `value`."""
|
|
354
|
+
return d[key] >= value
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@rules.rule(processors=uuid.UUID)
|
|
358
|
+
def uuid__lt(d: dict[str, Any], key: str, value: uuid.UUID) -> bool:
|
|
359
|
+
"""Keep records where `key` is less than `value`."""
|
|
360
|
+
return d[key] < value
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@rules.rule(processors=uuid.UUID)
|
|
364
|
+
def uuid__le(d: dict[str, Any], key: str, value: uuid.UUID) -> bool:
|
|
365
|
+
"""Keep records where `key` is less than or equal to `value`."""
|
|
366
|
+
return d[key] <= value
|
templify/models.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Types shared by readers, transformers and writers."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
type DataItem = dict[str, Any]
|
|
6
|
+
"""One record (a row), mapping column names to values."""
|
|
7
|
+
|
|
8
|
+
type Data = list[dict[str, Any]]
|
|
9
|
+
"""A list of records, as returned by a data reader."""
|
templify/py.typed
ADDED
|
File without changes
|
templify/readers.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Data readers: turn a data file into a list of records, picked by file extension."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TypeGuard
|
|
8
|
+
|
|
9
|
+
import openpyxl
|
|
10
|
+
|
|
11
|
+
from .models import Data
|
|
12
|
+
|
|
13
|
+
type ReadFn = Callable[[Path], Data]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ExtensionNotSupportedError(Exception):
|
|
17
|
+
"""Raised when no reader is registered for a data file extension."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, extension: str) -> None:
|
|
20
|
+
message = f"Extension '{extension}' is not supported for being a data file"
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ReaderRegistry:
|
|
25
|
+
"""Maps data file extensions (case-insensitive, without the dot) to read functions.
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
```python
|
|
29
|
+
readers = ReaderRegistry()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@readers.reader("csv")
|
|
33
|
+
def read_csv(filepath: Path) -> Data: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
data = readers["csv"](Path("people.csv"))
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
self._read_fns: dict[str, ReadFn] = {}
|
|
43
|
+
|
|
44
|
+
def __contains__(self, extension: str) -> bool:
|
|
45
|
+
"""Whether a reader is registered for `extension`."""
|
|
46
|
+
return extension.lower() in self._read_fns
|
|
47
|
+
|
|
48
|
+
def __getitem__(self, extension: str) -> ReadFn:
|
|
49
|
+
"""Return the reader registered for `extension`.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ExtensionNotSupportedError: No reader is registered for `extension`.
|
|
53
|
+
|
|
54
|
+
"""
|
|
55
|
+
if extension not in self:
|
|
56
|
+
raise ExtensionNotSupportedError(extension)
|
|
57
|
+
return self._read_fns[extension.lower()]
|
|
58
|
+
|
|
59
|
+
def reader(self, extension: str) -> Callable[[ReadFn], ReadFn]:
|
|
60
|
+
"""Register the decorated function as the reader for `extension`."""
|
|
61
|
+
|
|
62
|
+
def decorator(fn: ReadFn) -> ReadFn:
|
|
63
|
+
@wraps(fn)
|
|
64
|
+
def wrapper(filepath: Path) -> Data:
|
|
65
|
+
return fn(filepath)
|
|
66
|
+
|
|
67
|
+
self._add_reader(wrapper, extension)
|
|
68
|
+
|
|
69
|
+
return wrapper
|
|
70
|
+
|
|
71
|
+
return decorator
|
|
72
|
+
|
|
73
|
+
def add_reader(self, fn: ReadFn, extension: str) -> None:
|
|
74
|
+
"""Register `fn` as the reader for `extension`, replacing any existing one."""
|
|
75
|
+
self._add_reader(fn, extension)
|
|
76
|
+
|
|
77
|
+
def _add_reader(self, fn: ReadFn, extension: str) -> None:
|
|
78
|
+
self._read_fns[extension.lower()] = fn
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
readers = ReaderRegistry()
|
|
82
|
+
"""The default registry, used by the CLI `--data` option."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@readers.reader("xlsx")
|
|
86
|
+
def read_excel(filepath: Path) -> Data:
|
|
87
|
+
"""Read the active sheet of an Excel workbook.
|
|
88
|
+
|
|
89
|
+
The first row holds the column names, each following row is one record.
|
|
90
|
+
Fully blank rows are skipped, and formula cells give their last calculated value.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
ValueError: The workbook has no active sheet.
|
|
94
|
+
|
|
95
|
+
"""
|
|
96
|
+
wb = openpyxl.load_workbook(filepath, data_only=True)
|
|
97
|
+
sheet = wb.active
|
|
98
|
+
|
|
99
|
+
if sheet is None:
|
|
100
|
+
message = "No sheet found"
|
|
101
|
+
raise ValueError(message)
|
|
102
|
+
|
|
103
|
+
headers = []
|
|
104
|
+
|
|
105
|
+
for row in sheet.iter_rows(min_row=1, max_row=1):
|
|
106
|
+
for cell in row:
|
|
107
|
+
headers.append(cell.value) # noqa: PERF401
|
|
108
|
+
|
|
109
|
+
data = []
|
|
110
|
+
|
|
111
|
+
for row in sheet.iter_rows(min_row=2):
|
|
112
|
+
if all(cell.value is None for cell in row):
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
row_dict = {}
|
|
116
|
+
|
|
117
|
+
for idx, cell in enumerate(row):
|
|
118
|
+
row_dict[headers[idx]] = cell.value
|
|
119
|
+
|
|
120
|
+
data.append(row_dict)
|
|
121
|
+
|
|
122
|
+
return data
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@readers.reader("json")
|
|
126
|
+
def read_json(filepath: Path) -> Data:
|
|
127
|
+
"""Read a UTF-8 JSON file holding a list of objects.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
ValueError: The file is not valid JSON, or not a list of objects.
|
|
131
|
+
|
|
132
|
+
"""
|
|
133
|
+
with filepath.open(encoding="utf-8") as f:
|
|
134
|
+
data = json.load(f)
|
|
135
|
+
|
|
136
|
+
if _is_data(data):
|
|
137
|
+
return data
|
|
138
|
+
|
|
139
|
+
message = "JSON data must be a list of objects"
|
|
140
|
+
raise ValueError(message)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _is_data(d: object) -> TypeGuard[Data]:
|
|
144
|
+
return (
|
|
145
|
+
isinstance(d, list)
|
|
146
|
+
and all(isinstance(item, dict) for item in d)
|
|
147
|
+
and all(all(isinstance(key, str) for key in item) for item in d)
|
|
148
|
+
)
|
templify/transformers.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Data transformers applied before rendering: order, filter and group by."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import InitVar, dataclass, field
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from pyspecification import ExpressionWrapperDict, PredicateCompiler, PredicateDict
|
|
8
|
+
|
|
9
|
+
from .models import Data, DataItem
|
|
10
|
+
|
|
11
|
+
# -----------------------
|
|
12
|
+
# order
|
|
13
|
+
# -----------------------
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class OrderByKey:
|
|
18
|
+
"""A column to order by, parsed from `"column"` (ascending) or `"-column"` (descending).
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
```python
|
|
22
|
+
OrderByKey("-salary") # OrderByKey(name="salary", order="desc")
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
string: InitVar[str]
|
|
28
|
+
|
|
29
|
+
name: str = field(init=False)
|
|
30
|
+
order: Literal["asc", "desc"] = field(init=False)
|
|
31
|
+
|
|
32
|
+
def __post_init__(self, key_string: str) -> None:
|
|
33
|
+
self.name = key_string.removeprefix("-")
|
|
34
|
+
self.order = "desc" if key_string.startswith("-") else "asc"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def order_by_data(data: Data, keys: list[OrderByKey]) -> Data:
|
|
38
|
+
"""Return `data` sorted by `keys`, the first key having the highest priority.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
KeyError: A key is missing from a record.
|
|
42
|
+
|
|
43
|
+
"""
|
|
44
|
+
for key in reversed(keys):
|
|
45
|
+
data = sorted(data, key=_order_fn(key), reverse=key.order == "desc")
|
|
46
|
+
|
|
47
|
+
return data
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _order_fn(key: OrderByKey) -> Callable[[DataItem], Any]:
|
|
51
|
+
def fn(item: DataItem) -> Any:
|
|
52
|
+
return _get_value(item, key.name)
|
|
53
|
+
|
|
54
|
+
return fn
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _get_value(item: DataItem, key: str) -> Any:
|
|
58
|
+
if key not in item:
|
|
59
|
+
msg = f"Key '{key}' not found in data, Available keys: {', '.join(item.keys())}"
|
|
60
|
+
raise KeyError(msg)
|
|
61
|
+
|
|
62
|
+
return item[key]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# -----------------------
|
|
66
|
+
# filter
|
|
67
|
+
# -----------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def filter_data(
|
|
71
|
+
data: Data,
|
|
72
|
+
compiler: PredicateCompiler[DataItem, bool],
|
|
73
|
+
schema: ExpressionWrapperDict | PredicateDict,
|
|
74
|
+
) -> Data:
|
|
75
|
+
"""Return the records of `data` matching the filter `schema`.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
data: The records to filter.
|
|
79
|
+
compiler: Compiles `schema` into a predicate using the filter rules.
|
|
80
|
+
schema: A single rule (`PredicateDict`), or rules combined with
|
|
81
|
+
`all` / `any` (`ExpressionWrapperDict`).
|
|
82
|
+
|
|
83
|
+
"""
|
|
84
|
+
predicate = compiler.compile(schema)
|
|
85
|
+
return [row for row in data if predicate(row)]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# -----------------------
|
|
89
|
+
# group by
|
|
90
|
+
# -----------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def group_by_data(data: Data, keys: list[str], items_key: str = "items") -> Data:
|
|
94
|
+
"""Group rows sharing the same `keys` values, keeping their first-appearance order.
|
|
95
|
+
|
|
96
|
+
Each group is a row holding the `keys` values plus its rows under `items_key`:
|
|
97
|
+
|
|
98
|
+
[{"city": "Homs", "items": [{"name": "Rama", "city": "Homs"}, ...]}, ...]
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
KeyError: A key is missing from a record.
|
|
102
|
+
ValueError: `items_key` is one of `keys`.
|
|
103
|
+
TypeError: A key holds an unhashable value, like a list.
|
|
104
|
+
|
|
105
|
+
"""
|
|
106
|
+
if items_key in keys:
|
|
107
|
+
msg = f"Items key '{items_key}' can not be one of the group by keys"
|
|
108
|
+
raise ValueError(msg)
|
|
109
|
+
|
|
110
|
+
groups: dict[tuple[Any, ...], DataItem] = {}
|
|
111
|
+
|
|
112
|
+
for item in data:
|
|
113
|
+
values = tuple(_get_value(item, key) for key in keys)
|
|
114
|
+
|
|
115
|
+
if values not in groups:
|
|
116
|
+
groups[values] = {**dict(zip(keys, values, strict=True)), items_key: []}
|
|
117
|
+
|
|
118
|
+
groups[values][items_key].append(item)
|
|
119
|
+
|
|
120
|
+
return list(groups.values())
|