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.
@@ -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
+ )
@@ -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())