pyprojection 0.1.2__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.
- pyprojection-0.1.2/PKG-INFO +389 -0
- pyprojection-0.1.2/README.md +365 -0
- pyprojection-0.1.2/pyproject.toml +120 -0
- pyprojection-0.1.2/pyproject.toml.orig +82 -0
- pyprojection-0.1.2/src/pyprojection/__init__.py +52 -0
- pyprojection-0.1.2/src/pyprojection/compilers.py +208 -0
- pyprojection-0.1.2/src/pyprojection/exceptions.py +65 -0
- pyprojection-0.1.2/src/pyprojection/json_schema.py +76 -0
- pyprojection-0.1.2/src/pyprojection/projection.py +111 -0
- pyprojection-0.1.2/src/pyprojection/py.typed +0 -0
- pyprojection-0.1.2/src/pyprojection/registry.py +153 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyprojection
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Output shapes as values: which fields, under which names, computed how; definable as JSON.
|
|
5
|
+
Keywords: projection,export,serialization,json,dsl,composition
|
|
6
|
+
Author: aaltatan
|
|
7
|
+
Author-email: aaltatan <a.altatan@gmail.com>
|
|
8
|
+
License-Expression: GPL-3.0-only
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: pyvocabulary>=0.1.0
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Project-URL: Homepage, https://github.com/aaltatan/pyprojection
|
|
21
|
+
Project-URL: Repository, https://github.com/aaltatan/pyprojection
|
|
22
|
+
Project-URL: Issues, https://github.com/aaltatan/pyprojection/issues
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# pyprojection
|
|
26
|
+
|
|
27
|
+
Output shapes as values: which fields, under which names, computed how. Composable with `&`
|
|
28
|
+
and definable as JSON.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
export = (
|
|
32
|
+
columns["name"]()
|
|
33
|
+
& columns["monthly_salary"](label="Monthly pay")
|
|
34
|
+
& computed("Yearly pay", salary * 12)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
export(employee)
|
|
38
|
+
# {'name': 'Alice', 'Monthly pay': Decimal('3000.00'), 'Yearly pay': Decimal('36000.00')}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
A `Projection` maps one subject to one dict. Columns come from a registry, which is the
|
|
42
|
+
**privacy boundary**: a field that is not registered (or is registered as hidden) can never be
|
|
43
|
+
selected, not from Python lookups and not from a JSON definition a user sent.
|
|
44
|
+
|
|
45
|
+
| Piece | What it does |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| [`ProjectionRegistry`](#registering-columns) | The whitelist: `@columns.column()` turns `fn(subject, ...)` into a column factory |
|
|
48
|
+
| [`Projection`](#composing-with-) | Ordered `(output name, callable)` pairs; callable, joined with `&` |
|
|
49
|
+
| [`computed`](#computed-columns) | A derived column from any `T -> R` callable, e.g. a pyformula `Formula` |
|
|
50
|
+
| [`ProjectionCompiler`](#from-json) | Builds a `Projection` from JSON (or `?fields=`), errors carry a JSON path |
|
|
51
|
+
| [`get_projection_json_schema`](#json-schema) | The JSON Schema of that JSON, generated from the registry |
|
|
52
|
+
|
|
53
|
+
**When not to use it.** If the output shape is fixed, write a dict comprehension. pyprojection
|
|
54
|
+
earns its place when a screen lets *users* choose their columns: an export dialog, a report
|
|
55
|
+
builder, a `?fields=` API parameter.
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv add pyprojection
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Python 3.12+. The only runtime dependency is
|
|
64
|
+
[pyvocabulary](https://github.com/aaltatan/pyvocabulary). [pyformula](https://github.com/aaltatan/pyformula)
|
|
65
|
+
is optional: `computed` takes any callable.
|
|
66
|
+
|
|
67
|
+
## Quick start
|
|
68
|
+
|
|
69
|
+
The examples in this README build on each other and are run by the test suite. A comment
|
|
70
|
+
directly under a line shows what it outputs or raises.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from dataclasses import dataclass
|
|
74
|
+
from decimal import Decimal
|
|
75
|
+
from pyformula import variable
|
|
76
|
+
from pyprojection import ProjectionRegistry, computed
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class Employee:
|
|
81
|
+
name: str
|
|
82
|
+
department: str
|
|
83
|
+
salary: Decimal
|
|
84
|
+
national_id: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
alice = Employee("Alice", "Sales", Decimal("3000.00"), "123-45")
|
|
88
|
+
|
|
89
|
+
bob = Employee("Bob", "IT", Decimal("4500.50"), "678-90")
|
|
90
|
+
|
|
91
|
+
columns = ProjectionRegistry[Employee]()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@columns.column()
|
|
95
|
+
def name(employee: Employee) -> str:
|
|
96
|
+
"""Full name."""
|
|
97
|
+
return employee.name
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@columns.column()
|
|
101
|
+
def monthly_salary(employee: Employee, places: int = 2) -> Decimal:
|
|
102
|
+
"""Monthly salary, rounded to `places` decimals."""
|
|
103
|
+
return round(employee.salary, places)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@variable()
|
|
107
|
+
def salary(employee: Employee) -> Decimal:
|
|
108
|
+
return employee.salary
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
export = (
|
|
112
|
+
columns["name"]()
|
|
113
|
+
& columns["monthly_salary"](label="Monthly pay")
|
|
114
|
+
& computed("Yearly pay", salary * 12)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
export
|
|
118
|
+
# Projection(name, Monthly pay, Yearly pay)
|
|
119
|
+
|
|
120
|
+
export(alice)
|
|
121
|
+
# {'name': 'Alice', 'Monthly pay': Decimal('3000.00'), 'Yearly pay': Decimal('36000.00')}
|
|
122
|
+
|
|
123
|
+
[export(e)["name"] for e in (alice, bob)]
|
|
124
|
+
# ['Alice', 'Bob']
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Registering columns
|
|
128
|
+
|
|
129
|
+
A column is a function whose first parameter is the subject. Any further parameters become
|
|
130
|
+
arguments of its factory, bound **when the projection is built**, so a wrong argument fails
|
|
131
|
+
before the first row is exported.
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
@columns.column(name="department", description="Department, optionally upper-cased.")
|
|
135
|
+
def dept(employee: Employee, *, upper: bool = False) -> str:
|
|
136
|
+
return employee.department.upper() if upper else employee.department
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
columns["department"](upper=True)(alice)
|
|
140
|
+
# {'department': 'SALES'}
|
|
141
|
+
|
|
142
|
+
columns["monthly_salary"](0)(bob)
|
|
143
|
+
# {'monthly_salary': Decimal('4500')}
|
|
144
|
+
|
|
145
|
+
columns["department"](lower=True)
|
|
146
|
+
# InvalidColumnArgumentsError: Column 'department' got invalid arguments: got an unexpected keyword argument 'lower'
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`label=` sets the output name. It is reserved on every factory, so a column function may not
|
|
150
|
+
have a `label` parameter of its own (that is rejected at registration).
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
columns["name"](label="Full name")(alice)
|
|
154
|
+
# {'Full name': 'Alice'}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The decorator returns the factory, so it can also be used directly:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
name()(bob)
|
|
161
|
+
# {'name': 'Bob'}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Register a lambda or an existing function without the decorator with `register_column`:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
columns.register_column(lambda e: e.name[0], name="initial")
|
|
168
|
+
|
|
169
|
+
columns["initial"]()(alice)
|
|
170
|
+
# {'initial': 'A'}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Hidden columns
|
|
174
|
+
|
|
175
|
+
`hidden=True` keeps a column usable from Python (through the factory the decorator returns)
|
|
176
|
+
but out of lookups, compilers and schemas. Use it for fields only internal code may export.
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
@columns.column(hidden=True)
|
|
180
|
+
def national_id(employee: Employee) -> str:
|
|
181
|
+
return employee.national_id
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
national_id()(alice)
|
|
185
|
+
# {'national_id': '123-45'}
|
|
186
|
+
|
|
187
|
+
list(columns.columns)
|
|
188
|
+
# ['name', 'monthly_salary', 'department', 'initial']
|
|
189
|
+
|
|
190
|
+
columns["national_id"]
|
|
191
|
+
# ColumnDoesNotExistError: Column 'national_id' does not exist. Available columns: name, monthly_salary, department, initial
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Composing with `&`
|
|
195
|
+
|
|
196
|
+
`&` returns a new projection with the fields of both, in order. Projections are frozen, so a
|
|
197
|
+
base projection can be shared and extended safely.
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
base = columns["name"]() & columns["department"]()
|
|
201
|
+
|
|
202
|
+
payroll = base & columns["monthly_salary"](label="Pay")
|
|
203
|
+
|
|
204
|
+
base.names, payroll.names
|
|
205
|
+
# (('name', 'department'), ('name', 'department', 'Pay'))
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`names` is the output header, e.g. for CSV. Two fields with the same output name are rejected
|
|
209
|
+
when built; give one a `label`:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
base & columns["name"]()
|
|
213
|
+
# DuplicateOutputNameError: Output name 'name' is used more than once. Give one of them another name with label=...
|
|
214
|
+
|
|
215
|
+
(base & columns["name"](label="Name again")).names
|
|
216
|
+
# ('name', 'department', 'Name again')
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Computed columns
|
|
220
|
+
|
|
221
|
+
`computed(name, fn)` adds a derived column. The output name can be any non-empty string, and
|
|
222
|
+
`fn` any callable: a pyformula `Formula`, a lambda, a method.
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
bonus = computed("Bonus", (salary * Decimal("0.10")))
|
|
226
|
+
|
|
227
|
+
(base & bonus)(bob)
|
|
228
|
+
# {'name': 'Bob', 'department': 'IT', 'Bonus': Decimal('450.0500')}
|
|
229
|
+
|
|
230
|
+
computed("Shouting", lambda e: e.name.upper())(alice)
|
|
231
|
+
# {'Shouting': 'ALICE'}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## From JSON
|
|
235
|
+
|
|
236
|
+
`ProjectionCompiler` builds a projection from a list of columns. Each item is one of:
|
|
237
|
+
|
|
238
|
+
| Item | Meaning |
|
|
239
|
+
| --- | --- |
|
|
240
|
+
| `"name"` | A column with no arguments |
|
|
241
|
+
| `{"name": ..., "args": [...], "kwargs": {...}, "label": ...}` | A column call; all keys but `name` are optional |
|
|
242
|
+
| `{"label": ..., "formula": ...}` | A computed column; `formula` goes to the formula compiler you inject |
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
from pyformula import FormulaCompiler, FormulaNotFoundError, decimal_literal
|
|
246
|
+
from pyprojection import ProjectionCompiler
|
|
247
|
+
|
|
248
|
+
compiler = ProjectionCompiler(
|
|
249
|
+
columns,
|
|
250
|
+
FormulaCompiler[Employee]({"salary": salary}, literal=decimal_literal),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
report = compiler.compile(
|
|
254
|
+
[
|
|
255
|
+
"name",
|
|
256
|
+
{"name": "department", "kwargs": {"upper": True}, "label": "Dept"},
|
|
257
|
+
{"name": "monthly_salary", "args": [0], "label": "Monthly pay"},
|
|
258
|
+
{"label": "Yearly pay", "formula": {"operator": "multiply", "expressions": ["salary", 12]}},
|
|
259
|
+
]
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
report
|
|
263
|
+
# Projection(name, Dept, Monthly pay, Yearly pay)
|
|
264
|
+
|
|
265
|
+
report(alice)
|
|
266
|
+
# {'name': 'Alice', 'Dept': 'SALES', 'Monthly pay': Decimal('3000'), 'Yearly pay': Decimal('36000.00')}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
`literal=decimal_literal` keeps JSON numbers exact in money formulas. Leave the formula compiler
|
|
270
|
+
out to allow registered columns only: a definition with a computed column is then rejected.
|
|
271
|
+
|
|
272
|
+
### Errors point at the JSON
|
|
273
|
+
|
|
274
|
+
Every problem is found by `compile`, before any row is exported. Errors keep their type and get
|
|
275
|
+
a note with their JSON path, so an API can answer `400` with exactly what is wrong.
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
try:
|
|
279
|
+
compiler.compile(["name", "national_id"])
|
|
280
|
+
except LookupError as error:
|
|
281
|
+
print(error, error.__notes__)
|
|
282
|
+
# Column 'national_id' does not exist. Available columns: name, monthly_salary, department, initial ['at $[1]']
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
compiler.compile(
|
|
286
|
+
["name", {"label": "x", "formula": {"operator": "add", "expressions": ["salry", 1]}}]
|
|
287
|
+
)
|
|
288
|
+
except FormulaNotFoundError as error: # pyformula's own error type is kept
|
|
289
|
+
print(error.__notes__)
|
|
290
|
+
# ['at $[1].formula.expressions[0]']
|
|
291
|
+
|
|
292
|
+
compiler.compile([{"name": "name", "kwarg": {}}])
|
|
293
|
+
# InvalidProjectionDefinitionError: Invalid projection at $[0]: unknown 'kwarg'. Allowed keys: args, kwargs, label, name
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
## Recipes
|
|
297
|
+
|
|
298
|
+
### `?fields=` in an API
|
|
299
|
+
|
|
300
|
+
A bare string is a column, so a comma-separated query parameter compiles as it is. Hidden and
|
|
301
|
+
unregistered fields are refused, which is the point.
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
fields = "name,department" # request.query_params["fields"]
|
|
305
|
+
|
|
306
|
+
compiler.compile(fields.split(","))(bob)
|
|
307
|
+
# {'name': 'Bob', 'department': 'IT'}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### CSV export
|
|
311
|
+
|
|
312
|
+
```python
|
|
313
|
+
import csv, io
|
|
314
|
+
|
|
315
|
+
out = io.StringIO()
|
|
316
|
+
|
|
317
|
+
writer = csv.DictWriter(out, fieldnames=report.names, lineterminator="\n")
|
|
318
|
+
|
|
319
|
+
writer.writeheader()
|
|
320
|
+
|
|
321
|
+
writer.writerows(report(e) for e in (alice, bob))
|
|
322
|
+
|
|
323
|
+
print(out.getvalue())
|
|
324
|
+
# name,Dept,Monthly pay,Yearly pay
|
|
325
|
+
# Alice,SALES,3000,36000.00
|
|
326
|
+
# Bob,IT,4500,54006.00
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### Saved report layouts
|
|
330
|
+
|
|
331
|
+
Store the JSON the user built (in a database row, a settings file) and compile it when the
|
|
332
|
+
report runs. A layout that refers to a column you have since removed fails with its path, not
|
|
333
|
+
with a half-written file.
|
|
334
|
+
|
|
335
|
+
## JSON Schema
|
|
336
|
+
|
|
337
|
+
`get_projection_json_schema(columns, formula_schema=None)` describes what `compile` accepts. It
|
|
338
|
+
is generated from the registry: column names, each column's arguments (from its signature) and
|
|
339
|
+
its description (from its docstring or `description=`). Hidden columns are left out.
|
|
340
|
+
|
|
341
|
+
```python
|
|
342
|
+
from pyprojection import get_projection_json_schema
|
|
343
|
+
|
|
344
|
+
schema = get_projection_json_schema(columns)
|
|
345
|
+
|
|
346
|
+
schema["$defs"]["column_name"]["enum"] # what a column picker lists
|
|
347
|
+
# ['name', 'monthly_salary', 'department', 'initial']
|
|
348
|
+
|
|
349
|
+
department = schema["$defs"]["call"]["anyOf"][2]
|
|
350
|
+
|
|
351
|
+
department["description"]
|
|
352
|
+
# 'Department, optionally upper-cased.'
|
|
353
|
+
|
|
354
|
+
department["properties"]["kwargs"]["properties"]
|
|
355
|
+
# {'upper': {'type': 'boolean', 'default': False}}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Pass `formula_schema` to allow computed columns; it is embedded under its own `$id`
|
|
359
|
+
(`urn:pyprojection:formula`) so `$ref`s inside it keep working.
|
|
360
|
+
|
|
361
|
+
## Errors
|
|
362
|
+
|
|
363
|
+
All errors derive from `PyprojectionError` and also from the matching built-in, so
|
|
364
|
+
`except LookupError` / `ValueError` / `TypeError` work too.
|
|
365
|
+
|
|
366
|
+
| Exception | Also a | Raised when |
|
|
367
|
+
| --- | --- | --- |
|
|
368
|
+
| `ColumnDoesNotExistError` | `LookupError` | A column is not registered or is hidden |
|
|
369
|
+
| `ColumnAlreadyRegisteredError` | | A column name is registered twice |
|
|
370
|
+
| `InvalidColumnNameError` | `ValueError` | A column name is not a Python identifier (a lambda needs `name=`) |
|
|
371
|
+
| `InvalidColumnError` | `TypeError` | A function takes no subject or has a `label` parameter; a computed value is not callable |
|
|
372
|
+
| `InvalidColumnArgumentsError` | `TypeError` | A factory gets arguments its function does not take |
|
|
373
|
+
| `InvalidOutputNameError` | `ValueError` | An output name is not a non-empty string |
|
|
374
|
+
| `DuplicateOutputNameError` | `ValueError` | Two fields of one projection share an output name |
|
|
375
|
+
| `InvalidProjectionDefinitionError` | `ValueError` | A JSON definition has the wrong shape |
|
|
376
|
+
|
|
377
|
+
## The family
|
|
378
|
+
|
|
379
|
+
pyprojection is one of a set of packages that wrap a `Callable[[T], R]` in an immutable value
|
|
380
|
+
with an algebra: [pyspecification](https://github.com/aaltatan/pyspecification) (`T -> bool`,
|
|
381
|
+
`& | ~`), [pyformula](https://github.com/aaltatan/pyformula) (`T -> number`, arithmetic),
|
|
382
|
+
[pyflowstep](https://github.com/aaltatan/pyflowstep) (`T -> T`, `>>`),
|
|
383
|
+
[pyruleset](https://github.com/aaltatan/pyruleset) (`T -> V`, first match wins) and
|
|
384
|
+
pyprojection (`T -> dict`, `&`). They share [pyvocabulary](https://github.com/aaltatan/pyvocabulary)
|
|
385
|
+
for registries, JSON Schemas and JSON-path errors.
|
|
386
|
+
|
|
387
|
+
## License
|
|
388
|
+
|
|
389
|
+
GPL-3.0-only.
|