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.
@@ -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.