pyprojection 0.1.2__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.
- pyprojection/__init__.py +52 -0
- pyprojection/compilers.py +208 -0
- pyprojection/exceptions.py +65 -0
- pyprojection/json_schema.py +76 -0
- pyprojection/projection.py +111 -0
- pyprojection/py.typed +0 -0
- pyprojection/registry.py +153 -0
- pyprojection-0.1.2.dist-info/METADATA +389 -0
- pyprojection-0.1.2.dist-info/RECORD +10 -0
- pyprojection-0.1.2.dist-info/WHEEL +4 -0
pyprojection/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""pyprojection: output shapes as values.
|
|
2
|
+
|
|
3
|
+
`columns["name"]() & computed("Yearly pay", salary * 12)` is a `Projection`:
|
|
4
|
+
it maps one subject to one dict. Columns come from a registry, the whitelist,
|
|
5
|
+
so a field that is not registered can never be selected, from Python or JSON.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .compilers import (
|
|
9
|
+
ColumnCallDict,
|
|
10
|
+
ColumnDefinition,
|
|
11
|
+
Compiler,
|
|
12
|
+
ComputedDict,
|
|
13
|
+
ProjectionCompiler,
|
|
14
|
+
)
|
|
15
|
+
from .exceptions import (
|
|
16
|
+
ColumnAlreadyRegisteredError,
|
|
17
|
+
ColumnDoesNotExistError,
|
|
18
|
+
DuplicateOutputNameError,
|
|
19
|
+
InvalidColumnArgumentsError,
|
|
20
|
+
InvalidColumnError,
|
|
21
|
+
InvalidColumnNameError,
|
|
22
|
+
InvalidOutputNameError,
|
|
23
|
+
InvalidProjectionDefinitionError,
|
|
24
|
+
PyprojectionError,
|
|
25
|
+
)
|
|
26
|
+
from .json_schema import FORMULA_SCHEMA_ID, get_projection_json_schema
|
|
27
|
+
from .projection import Field, Projection, computed
|
|
28
|
+
from .registry import ColumnFactory, ProjectionRegistry
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"FORMULA_SCHEMA_ID",
|
|
32
|
+
"ColumnAlreadyRegisteredError",
|
|
33
|
+
"ColumnCallDict",
|
|
34
|
+
"ColumnDefinition",
|
|
35
|
+
"ColumnDoesNotExistError",
|
|
36
|
+
"ColumnFactory",
|
|
37
|
+
"Compiler",
|
|
38
|
+
"ComputedDict",
|
|
39
|
+
"DuplicateOutputNameError",
|
|
40
|
+
"Field",
|
|
41
|
+
"InvalidColumnArgumentsError",
|
|
42
|
+
"InvalidColumnError",
|
|
43
|
+
"InvalidColumnNameError",
|
|
44
|
+
"InvalidOutputNameError",
|
|
45
|
+
"InvalidProjectionDefinitionError",
|
|
46
|
+
"Projection",
|
|
47
|
+
"ProjectionCompiler",
|
|
48
|
+
"ProjectionRegistry",
|
|
49
|
+
"PyprojectionError",
|
|
50
|
+
"computed",
|
|
51
|
+
"get_projection_json_schema",
|
|
52
|
+
]
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Compile a projection from JSON: a list of columns, in output order.
|
|
2
|
+
|
|
3
|
+
```json
|
|
4
|
+
[
|
|
5
|
+
"name",
|
|
6
|
+
{"name": "salary", "args": [2], "kwargs": {}, "label": "Monthly pay"},
|
|
7
|
+
{"label": "Yearly pay", "formula": {"operator": "multiply", "expressions": ["salary", 12]}}
|
|
8
|
+
]
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
A bare string selects a column with no arguments, so `?fields=name,salary` is
|
|
12
|
+
`compiler.compile(fields.split(","))`. Formulas are delegated to an injected
|
|
13
|
+
compiler, e.g. pyformula's `FormulaCompiler`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from collections.abc import Callable, Mapping
|
|
17
|
+
from typing import Any, NotRequired, Protocol, TypedDict, cast
|
|
18
|
+
|
|
19
|
+
from pyvocabulary import index_path, key_path, located
|
|
20
|
+
|
|
21
|
+
from .exceptions import (
|
|
22
|
+
ColumnDoesNotExistError,
|
|
23
|
+
DuplicateOutputNameError,
|
|
24
|
+
InvalidColumnArgumentsError,
|
|
25
|
+
InvalidProjectionDefinitionError,
|
|
26
|
+
)
|
|
27
|
+
from .projection import Projection, computed
|
|
28
|
+
from .registry import ProjectionRegistry
|
|
29
|
+
|
|
30
|
+
_ROOT = "$"
|
|
31
|
+
_CALL_KEYS = {"name", "args", "kwargs", "label"}
|
|
32
|
+
_COMPUTED_KEYS = {"label", "formula"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ColumnCallDict(TypedDict):
|
|
36
|
+
name: str
|
|
37
|
+
args: NotRequired[list[Any]]
|
|
38
|
+
kwargs: NotRequired[dict[str, Any]]
|
|
39
|
+
label: NotRequired[str]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ComputedDict(TypedDict):
|
|
43
|
+
label: str
|
|
44
|
+
formula: Any
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
type ColumnDefinition = str | ColumnCallDict | ComputedDict
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Compiler[F](Protocol):
|
|
51
|
+
"""Anything with a `compile(definition) -> F` method."""
|
|
52
|
+
|
|
53
|
+
def compile(self, definition: Any, /) -> F: ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ProjectionCompiler[T]:
|
|
57
|
+
"""Compile projection definitions against a registry of columns.
|
|
58
|
+
|
|
59
|
+
Every problem is reported here, before any subject is exported. Errors from
|
|
60
|
+
the registry and the formula compiler keep their type and get a note with
|
|
61
|
+
their JSON path.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
columns: The registry; only its visible columns can be selected.
|
|
65
|
+
formulas: Compiles the `formula` of computed columns. Without it, a
|
|
66
|
+
definition with a computed column is rejected.
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
```python
|
|
70
|
+
>>> columns = ProjectionRegistry[dict[str, str]]()
|
|
71
|
+
>>> @columns.column()
|
|
72
|
+
... def name(user: dict[str, str]) -> str:
|
|
73
|
+
... return user["name"]
|
|
74
|
+
>>> compiler = ProjectionCompiler(columns)
|
|
75
|
+
>>> compiler.compile(["name", {"name": "name", "label": "Name again"}])
|
|
76
|
+
Projection(name, Name again)
|
|
77
|
+
>>> compiler.compile(["name", "email"])
|
|
78
|
+
Traceback (most recent call last):
|
|
79
|
+
...
|
|
80
|
+
pyprojection.exceptions.ColumnDoesNotExistError: Column 'email' does not exist. ...
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
__slots__ = ("_columns", "_formulas")
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
columns: ProjectionRegistry[T],
|
|
91
|
+
formulas: Compiler[Callable[[T], Any]] | None = None,
|
|
92
|
+
/,
|
|
93
|
+
) -> None:
|
|
94
|
+
self._columns = columns
|
|
95
|
+
self._formulas = formulas
|
|
96
|
+
|
|
97
|
+
def compile(self, definition: list[ColumnDefinition] | Any, /) -> Projection[T]:
|
|
98
|
+
"""Compile `definition` into a `Projection`.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
InvalidProjectionDefinitionError: If the definition has the wrong shape.
|
|
102
|
+
ColumnDoesNotExistError: If a column is not registered or is hidden.
|
|
103
|
+
InvalidColumnArgumentsError: If a column's `args`/`kwargs` do not fit it.
|
|
104
|
+
DuplicateOutputNameError: If two columns share an output name.
|
|
105
|
+
|
|
106
|
+
"""
|
|
107
|
+
if not isinstance(definition, list):
|
|
108
|
+
raise _invalid(_ROOT, f"expected a list, received {definition!r}")
|
|
109
|
+
|
|
110
|
+
if not definition:
|
|
111
|
+
raise _invalid(_ROOT, "expected at least one column")
|
|
112
|
+
|
|
113
|
+
projection: Projection[T] = Projection(())
|
|
114
|
+
|
|
115
|
+
for index, item in enumerate(cast("list[Any]", definition)):
|
|
116
|
+
path = index_path(_ROOT, index)
|
|
117
|
+
column = self._compile_column(item, path)
|
|
118
|
+
|
|
119
|
+
with located(path, DuplicateOutputNameError):
|
|
120
|
+
projection &= column
|
|
121
|
+
|
|
122
|
+
return projection
|
|
123
|
+
|
|
124
|
+
def _compile_column(self, item: Any, path: str) -> Projection[T]:
|
|
125
|
+
match item:
|
|
126
|
+
case str():
|
|
127
|
+
with located(path, ColumnDoesNotExistError):
|
|
128
|
+
return self._columns[item]()
|
|
129
|
+
case Mapping():
|
|
130
|
+
item = cast("Mapping[str, Any]", item)
|
|
131
|
+
|
|
132
|
+
if "name" in item and "formula" in item:
|
|
133
|
+
raise _invalid(path, "use either 'name' or 'formula', not both")
|
|
134
|
+
|
|
135
|
+
if "formula" in item:
|
|
136
|
+
return self._compile_computed(item, path)
|
|
137
|
+
|
|
138
|
+
if "name" in item:
|
|
139
|
+
return self._compile_call(item, path)
|
|
140
|
+
|
|
141
|
+
raise _invalid(path, "missing 'name' or 'formula'")
|
|
142
|
+
case _:
|
|
143
|
+
raise _invalid(path, f"expected a column name or an object, received {item!r}")
|
|
144
|
+
|
|
145
|
+
def _compile_call(self, item: Mapping[str, Any], path: str) -> Projection[T]:
|
|
146
|
+
_check_unknown_keys(item, _CALL_KEYS, path)
|
|
147
|
+
name, args, kwargs = item["name"], item.get("args", []), item.get("kwargs", {})
|
|
148
|
+
|
|
149
|
+
if not isinstance(name, str):
|
|
150
|
+
raise _invalid(key_path(path, "name"), f"expected a string, received {name!r}")
|
|
151
|
+
|
|
152
|
+
if not isinstance(args, list):
|
|
153
|
+
raise _invalid(key_path(path, "args"), f"expected a list, received {args!r}")
|
|
154
|
+
|
|
155
|
+
if not isinstance(kwargs, Mapping):
|
|
156
|
+
raise _invalid(key_path(path, "kwargs"), f"expected an object, received {kwargs!r}")
|
|
157
|
+
|
|
158
|
+
if "label" in kwargs:
|
|
159
|
+
raise _invalid(key_path(path, "kwargs"), "'label' goes beside 'name', not in 'kwargs'")
|
|
160
|
+
|
|
161
|
+
label = _label(item, path) if "label" in item else None
|
|
162
|
+
|
|
163
|
+
with located(path, ColumnDoesNotExistError, InvalidColumnArgumentsError):
|
|
164
|
+
return self._columns[name](
|
|
165
|
+
*cast("list[Any]", args), label=label, **cast("Mapping[str, Any]", kwargs)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def _compile_computed(self, item: Mapping[str, Any], path: str) -> Projection[T]:
|
|
169
|
+
_check_unknown_keys(item, _COMPUTED_KEYS, path)
|
|
170
|
+
|
|
171
|
+
if "label" not in item:
|
|
172
|
+
raise _invalid(path, "missing 'label', a computed column needs an output name")
|
|
173
|
+
|
|
174
|
+
label = _label(item, path)
|
|
175
|
+
|
|
176
|
+
if self._formulas is None:
|
|
177
|
+
raise _invalid(path, "computed columns are not allowed: no formula compiler was given")
|
|
178
|
+
|
|
179
|
+
with located(key_path(path, "formula"), Exception):
|
|
180
|
+
formula = self._formulas.compile(item["formula"])
|
|
181
|
+
|
|
182
|
+
return computed(label, formula)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# -----------------------
|
|
186
|
+
# helpers
|
|
187
|
+
# -----------------------
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _label(item: Mapping[str, Any], path: str) -> str:
|
|
191
|
+
label = item["label"]
|
|
192
|
+
|
|
193
|
+
if not isinstance(label, str) or not label:
|
|
194
|
+
raise _invalid(key_path(path, "label"), f"expected a non-empty string, received {label!r}")
|
|
195
|
+
|
|
196
|
+
return label
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _check_unknown_keys(item: Mapping[str, Any], allowed: set[str], path: str) -> None:
|
|
200
|
+
if unknown := sorted(set(item) - allowed):
|
|
201
|
+
problem = (
|
|
202
|
+
f"unknown {', '.join(map(repr, unknown))}. Allowed keys: {', '.join(sorted(allowed))}"
|
|
203
|
+
)
|
|
204
|
+
raise _invalid(path, problem)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _invalid(path: str, problem: str) -> InvalidProjectionDefinitionError:
|
|
208
|
+
return InvalidProjectionDefinitionError(f"Invalid projection at {path}: {problem}")
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Exceptions raised by pyprojection.
|
|
2
|
+
|
|
3
|
+
Errors raised by an injected formula compiler keep their own type (e.g.
|
|
4
|
+
pyformula's `FormulaNotFoundError`); pyprojection only adds a note with their
|
|
5
|
+
JSON path, e.g. `at $[3].formula`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
|
|
10
|
+
from pyvocabulary import InvalidNameError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PyprojectionError(Exception):
|
|
14
|
+
"""Base class for every exception raised by pyprojection."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ColumnDoesNotExistError(PyprojectionError, LookupError):
|
|
18
|
+
"""Raised when a column is not registered, or is hidden.
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
```python
|
|
22
|
+
>>> str(ColumnDoesNotExistError("salary", ["name", "department"]))
|
|
23
|
+
"Column 'salary' does not exist. Available columns: name, department"
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, name: str, available: Iterable[str], /) -> None:
|
|
30
|
+
names = ", ".join(available) or "(none)"
|
|
31
|
+
super().__init__(f"Column '{name}' does not exist. Available columns: {names}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ColumnAlreadyRegisteredError(PyprojectionError):
|
|
35
|
+
"""Raised when a column name is registered twice in the same registry."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, name: str, /) -> None:
|
|
38
|
+
super().__init__(f"Column '{name}' is already registered.")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class InvalidColumnNameError(PyprojectionError, InvalidNameError):
|
|
42
|
+
"""Raised when a column name is not a valid ASCII Python identifier."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, name: object, /) -> None:
|
|
45
|
+
super().__init__(name, kind="column")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InvalidColumnError(PyprojectionError, TypeError):
|
|
49
|
+
"""Raised when a function cannot be used as a column (checked when registered)."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class InvalidColumnArgumentsError(PyprojectionError, TypeError):
|
|
53
|
+
"""Raised when a column factory is called with arguments its function does not take."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class InvalidOutputNameError(PyprojectionError, ValueError):
|
|
57
|
+
"""Raised when an output name (a label) is not a non-empty string."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DuplicateOutputNameError(PyprojectionError, ValueError):
|
|
61
|
+
"""Raised when two fields of one projection share an output name."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class InvalidProjectionDefinitionError(PyprojectionError, ValueError):
|
|
65
|
+
"""Raised when a JSON projection definition does not have the expected shape."""
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""The JSON Schema of a projection definition, as accepted by `ProjectionCompiler.compile`.
|
|
2
|
+
|
|
3
|
+
Generated from the registry, so a column picker can list exactly the selectable
|
|
4
|
+
columns (`$defs.column_name.enum`) and an editor can check arguments as typed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pyvocabulary import JSON_SCHEMA_DIALECT, call_json_schema
|
|
11
|
+
|
|
12
|
+
from .registry import ProjectionRegistry
|
|
13
|
+
|
|
14
|
+
FORMULA_SCHEMA_ID = "urn:pyprojection:formula"
|
|
15
|
+
|
|
16
|
+
_LABEL_SCHEMA = {"type": "string", "minLength": 1}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_projection_json_schema(
|
|
20
|
+
columns: ProjectionRegistry[Any],
|
|
21
|
+
formula_schema: Mapping[str, Any] | None = None,
|
|
22
|
+
/,
|
|
23
|
+
) -> dict[str, Any]:
|
|
24
|
+
"""Return the JSON Schema of a list of column names, column calls and computed columns.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
columns: The registry; hidden columns are left out.
|
|
28
|
+
formula_schema: The schema of one `formula`. Without it, computed columns
|
|
29
|
+
are not allowed, matching a `ProjectionCompiler` without a formula compiler.
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
```python
|
|
33
|
+
>>> columns = ProjectionRegistry[dict[str, int]]()
|
|
34
|
+
>>> @columns.column()
|
|
35
|
+
... def age(user: dict[str, int], *, in_months: bool = False) -> int:
|
|
36
|
+
... '''Age in years.'''
|
|
37
|
+
... return user["age"] * 12 if in_months else user["age"]
|
|
38
|
+
>>> schema = get_projection_json_schema(columns)
|
|
39
|
+
>>> schema["$defs"]["column_name"]
|
|
40
|
+
{'type': 'string', 'enum': ['age']}
|
|
41
|
+
>>> call = schema["$defs"]["call"]["anyOf"][0]
|
|
42
|
+
>>> call["properties"]["kwargs"]["properties"], call["description"]
|
|
43
|
+
({'in_months': {'type': 'boolean', 'default': False}}, 'Age in years.')
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
"""
|
|
48
|
+
calls = [
|
|
49
|
+
call_json_schema(name, factory, extra_properties={"label": _LABEL_SCHEMA})
|
|
50
|
+
for name, factory in columns.columns.items()
|
|
51
|
+
]
|
|
52
|
+
items: list[dict[str, Any]] = [{"$ref": "#/$defs/column_name"}, {"$ref": "#/$defs/call"}]
|
|
53
|
+
defs: dict[str, Any] = {
|
|
54
|
+
"column_name": {"type": "string", "enum": list(columns.columns)},
|
|
55
|
+
# `anyOf` must not be empty; with no columns, no call is valid
|
|
56
|
+
"call": {"anyOf": calls} if calls else False,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if formula_schema is not None:
|
|
60
|
+
items.append({"$ref": "#/$defs/computed"})
|
|
61
|
+
defs["computed"] = {
|
|
62
|
+
"type": "object",
|
|
63
|
+
"properties": {"label": _LABEL_SCHEMA, "formula": {"$ref": FORMULA_SCHEMA_ID}},
|
|
64
|
+
"required": ["label", "formula"],
|
|
65
|
+
"additionalProperties": False,
|
|
66
|
+
}
|
|
67
|
+
# its own `$id`, so `#/$defs/...` refs inside it keep pointing into itself
|
|
68
|
+
defs["formula"] = {**formula_schema, "$id": FORMULA_SCHEMA_ID}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
"$schema": JSON_SCHEMA_DIALECT,
|
|
72
|
+
"type": "array",
|
|
73
|
+
"minItems": 1,
|
|
74
|
+
"items": {"anyOf": items},
|
|
75
|
+
"$defs": defs,
|
|
76
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Output shapes as values: which fields, under which names, computed how.
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
export = (
|
|
5
|
+
columns["name"]()
|
|
6
|
+
& columns["salary"](label="Monthly pay")
|
|
7
|
+
& computed("Yearly pay", salary * 12)
|
|
8
|
+
)
|
|
9
|
+
export(employee) # {"name": ..., "Monthly pay": ..., "Yearly pay": ...}
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
A projection is an ordered tuple of `(output name, callable)` pairs. `&` joins
|
|
13
|
+
two of them; a repeated output name is rejected when the projection is built,
|
|
14
|
+
not when the first row is exported.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from .exceptions import DuplicateOutputNameError, InvalidColumnError, InvalidOutputNameError
|
|
22
|
+
|
|
23
|
+
type Field[T] = tuple[str, Callable[[T], Any]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Projection[T]:
|
|
28
|
+
"""Fields in output order. Calling it maps one subject to one dict.
|
|
29
|
+
|
|
30
|
+
Build projections with a `ProjectionRegistry` and `computed`, then join them with `&`.
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
```python
|
|
34
|
+
>>> person = computed("name", lambda p: p["name"]) & computed("age", lambda p: p["age"])
|
|
35
|
+
>>> person
|
|
36
|
+
Projection(name, age)
|
|
37
|
+
>>> person({"name": "Alice", "age": 30, "password": "hunter2"})
|
|
38
|
+
{'name': 'Alice', 'age': 30}
|
|
39
|
+
>>> person.names
|
|
40
|
+
('name', 'age')
|
|
41
|
+
>>> person & computed("age", lambda p: p["age"] + 1)
|
|
42
|
+
Traceback (most recent call last):
|
|
43
|
+
...
|
|
44
|
+
pyprojection.exceptions.DuplicateOutputNameError: Output name 'age' is used more than once. ...
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
fields: tuple[Field[T], ...]
|
|
51
|
+
|
|
52
|
+
def __post_init__(self) -> None:
|
|
53
|
+
seen: set[str] = set()
|
|
54
|
+
|
|
55
|
+
for name, value in self.fields:
|
|
56
|
+
_check_output_name(name)
|
|
57
|
+
|
|
58
|
+
if not callable(value):
|
|
59
|
+
msg = f"Column '{name}' must be callable, received {value!r}"
|
|
60
|
+
raise InvalidColumnError(msg)
|
|
61
|
+
|
|
62
|
+
if name in seen:
|
|
63
|
+
msg = (
|
|
64
|
+
f"Output name '{name}' is used more than once. "
|
|
65
|
+
"Give one of them another name with label=..."
|
|
66
|
+
)
|
|
67
|
+
raise DuplicateOutputNameError(msg)
|
|
68
|
+
|
|
69
|
+
seen.add(name)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def names(self) -> tuple[str, ...]:
|
|
73
|
+
"""The output names in order, e.g. a CSV header."""
|
|
74
|
+
return tuple(name for name, _ in self.fields)
|
|
75
|
+
|
|
76
|
+
def __call__(self, subject: T, /) -> dict[str, Any]:
|
|
77
|
+
return {name: value(subject) for name, value in self.fields}
|
|
78
|
+
|
|
79
|
+
def __and__(self, other: "Projection[T]", /) -> "Projection[T]":
|
|
80
|
+
match other:
|
|
81
|
+
case Projection(fields=fields):
|
|
82
|
+
return Projection((*self.fields, *fields))
|
|
83
|
+
case _:
|
|
84
|
+
return NotImplemented
|
|
85
|
+
|
|
86
|
+
def __repr__(self) -> str:
|
|
87
|
+
return f"Projection({', '.join(self.names)})"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def computed[T](name: str, value: Callable[[T], Any], /) -> Projection[T]:
|
|
91
|
+
"""Return a one-field projection that outputs `value(subject)` under `name`.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
name: The output name, any non-empty string (`"Yearly pay"` is fine).
|
|
95
|
+
value: Any `T -> R` callable, typically a pyformula `Formula`.
|
|
96
|
+
|
|
97
|
+
Example:
|
|
98
|
+
```python
|
|
99
|
+
>>> computed("Initial", lambda name: name[0])("Alice")
|
|
100
|
+
{'Initial': 'A'}
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
"""
|
|
105
|
+
return Projection(((name, value),))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _check_output_name(name: object) -> None:
|
|
109
|
+
if not isinstance(name, str) or not name:
|
|
110
|
+
msg = f"Invalid output name: {name!r}, expected a non-empty string"
|
|
111
|
+
raise InvalidOutputNameError(msg)
|
pyprojection/py.typed
ADDED
|
File without changes
|
pyprojection/registry.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""The columns a projection may select: the whitelist, and so the privacy boundary.
|
|
2
|
+
|
|
3
|
+
A registered function `fn(subject, *args, **kwargs)` becomes a factory: calling
|
|
4
|
+
`columns["salary"](2, label="Monthly pay")` binds the arguments right away and
|
|
5
|
+
returns a one-field `Projection`. A field that is not registered cannot be
|
|
6
|
+
selected at all, from Python lookups or from JSON.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable, Mapping
|
|
10
|
+
from functools import update_wrapper
|
|
11
|
+
from inspect import Parameter, Signature, signature
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
from pyvocabulary import Vocabulary
|
|
15
|
+
|
|
16
|
+
from .exceptions import (
|
|
17
|
+
ColumnAlreadyRegisteredError,
|
|
18
|
+
ColumnDoesNotExistError,
|
|
19
|
+
InvalidColumnArgumentsError,
|
|
20
|
+
InvalidColumnError,
|
|
21
|
+
InvalidColumnNameError,
|
|
22
|
+
)
|
|
23
|
+
from .projection import Projection
|
|
24
|
+
|
|
25
|
+
_SUBJECT_KINDS = (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD)
|
|
26
|
+
_LABEL = "label"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ColumnFactory[T](Protocol):
|
|
30
|
+
"""Builds a one-field `Projection` from the column's arguments and an optional label."""
|
|
31
|
+
|
|
32
|
+
def __call__(self, *args: Any, label: str | None = None, **kwargs: Any) -> Projection[T]: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ProjectionRegistry[T]:
|
|
36
|
+
"""Named columns over subjects of type `T`, registered with `@registry.column()`.
|
|
37
|
+
|
|
38
|
+
Example:
|
|
39
|
+
```python
|
|
40
|
+
>>> columns = ProjectionRegistry[dict[str, str]]()
|
|
41
|
+
>>> @columns.column()
|
|
42
|
+
... def name(user: dict[str, str], *, upper: bool = False) -> str:
|
|
43
|
+
... return user["name"].upper() if upper else user["name"]
|
|
44
|
+
>>> @columns.column(hidden=True)
|
|
45
|
+
... def password(user: dict[str, str]) -> str:
|
|
46
|
+
... return user["password"]
|
|
47
|
+
>>> list(columns.columns)
|
|
48
|
+
['name']
|
|
49
|
+
>>> columns["name"](upper=True, label="Name")({"name": "Alice", "password": "x"})
|
|
50
|
+
{'Name': 'ALICE'}
|
|
51
|
+
>>> columns["password"]
|
|
52
|
+
Traceback (most recent call last):
|
|
53
|
+
...
|
|
54
|
+
pyprojection.exceptions.ColumnDoesNotExistError: Column 'password' does not exist. ...
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
__slots__ = ("_columns",)
|
|
61
|
+
|
|
62
|
+
def __init__(self) -> None:
|
|
63
|
+
self._columns: Vocabulary[ColumnFactory[T]] = Vocabulary(
|
|
64
|
+
"column",
|
|
65
|
+
does_not_exist=ColumnDoesNotExistError,
|
|
66
|
+
already_registered=ColumnAlreadyRegisteredError,
|
|
67
|
+
invalid_name=InvalidColumnNameError,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def columns(self) -> Mapping[str, ColumnFactory[T]]:
|
|
72
|
+
"""A read-only view of the visible columns, the ones a compiler may select."""
|
|
73
|
+
return self._columns
|
|
74
|
+
|
|
75
|
+
def __getitem__(self, name: str, /) -> ColumnFactory[T]:
|
|
76
|
+
return self._columns[name]
|
|
77
|
+
|
|
78
|
+
def column(
|
|
79
|
+
self,
|
|
80
|
+
*,
|
|
81
|
+
name: str | None = None,
|
|
82
|
+
description: str | None = None,
|
|
83
|
+
hidden: bool = False,
|
|
84
|
+
) -> Callable[[Callable[..., Any]], ColumnFactory[T]]:
|
|
85
|
+
"""Return a decorator that registers a function as a column and returns its factory.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
name: The column name. Defaults to the function's name.
|
|
89
|
+
description: Documents the column in its JSON Schema. Defaults to the docstring.
|
|
90
|
+
hidden: Keep the column usable from Python but out of lookups, compilers
|
|
91
|
+
and schemas, e.g. for a field only internal reports may show.
|
|
92
|
+
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def decorator(fn: Callable[..., Any]) -> ColumnFactory[T]:
|
|
96
|
+
return self.register_column(fn, name=name, description=description, hidden=hidden)
|
|
97
|
+
|
|
98
|
+
return decorator
|
|
99
|
+
|
|
100
|
+
def register_column(
|
|
101
|
+
self,
|
|
102
|
+
fn: Callable[..., Any],
|
|
103
|
+
/,
|
|
104
|
+
*,
|
|
105
|
+
name: str | None = None,
|
|
106
|
+
description: str | None = None,
|
|
107
|
+
hidden: bool = False,
|
|
108
|
+
) -> ColumnFactory[T]:
|
|
109
|
+
"""Register `fn(subject, *args, **kwargs)` as a column and return its factory.
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
InvalidColumnNameError: If the name is not a Python identifier (a lambda needs `name=`).
|
|
113
|
+
ColumnAlreadyRegisteredError: If the name is taken, hidden or not.
|
|
114
|
+
InvalidColumnError: If `fn` takes no subject, or has a `label` parameter.
|
|
115
|
+
|
|
116
|
+
"""
|
|
117
|
+
column_name: str = name or getattr(fn, "__name__", "")
|
|
118
|
+
factory: ColumnFactory[T] = _factory(fn, column_name, description)
|
|
119
|
+
return self._columns.add(column_name, factory, hidden=hidden)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _factory(fn: Callable[..., Any], name: str, description: str | None) -> ColumnFactory[Any]:
|
|
123
|
+
parameters = list(signature(fn).parameters.values())
|
|
124
|
+
|
|
125
|
+
if not parameters or parameters[0].kind not in _SUBJECT_KINDS:
|
|
126
|
+
msg = f"Column '{name}' must take the subject as its first positional parameter."
|
|
127
|
+
raise InvalidColumnError(msg)
|
|
128
|
+
|
|
129
|
+
if any(parameter.name == _LABEL for parameter in parameters):
|
|
130
|
+
msg = (
|
|
131
|
+
f"Column '{name}' has a 'label' parameter, but 'label' is reserved for the output name."
|
|
132
|
+
)
|
|
133
|
+
raise InvalidColumnError(msg)
|
|
134
|
+
|
|
135
|
+
arguments = Signature(parameters[1:])
|
|
136
|
+
|
|
137
|
+
def factory(*args: Any, label: str | None = None, **kwargs: Any) -> Projection[Any]:
|
|
138
|
+
try:
|
|
139
|
+
bound = arguments.bind(*args, **kwargs)
|
|
140
|
+
except TypeError as error:
|
|
141
|
+
msg = f"Column '{name}' got invalid arguments: {error}"
|
|
142
|
+
raise InvalidColumnArgumentsError(msg) from error
|
|
143
|
+
|
|
144
|
+
def value(subject: Any) -> Any:
|
|
145
|
+
return fn(subject, *bound.args, **bound.kwargs)
|
|
146
|
+
|
|
147
|
+
return Projection(((name if label is None else label, value),))
|
|
148
|
+
|
|
149
|
+
update_wrapper(factory, fn)
|
|
150
|
+
# the JSON Schema describes the arguments JSON sends, so the subject is left out
|
|
151
|
+
factory.__signature__ = arguments # pyright: ignore[reportFunctionMemberAccess]
|
|
152
|
+
factory.__doc__ = description or fn.__doc__
|
|
153
|
+
return factory
|
|
@@ -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.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pyprojection/__init__.py,sha256=_y8GKarc9UUtdp8sm3oDvsGQ56e1Wi6-rFdJ3AVrKrM,1493
|
|
2
|
+
pyprojection/compilers.py,sha256=XHS_FCRowUSSTxNMJdu1O4kdYPvH2G9sA2CqiW_5wcU,7247
|
|
3
|
+
pyprojection/exceptions.py,sha256=1WzpOAnP_ENkbS5Xn8QlL5XInBGmx6ffOi79eiSFqXQ,2232
|
|
4
|
+
pyprojection/json_schema.py,sha256=ydQnjp19A5f77gIPG7Zr_SYL178SS9fUe-BZqnViies,2856
|
|
5
|
+
pyprojection/projection.py,sha256=uCnozYrLWF4crNqrh0PR-X7vploDOzX2yr1bXs4QyUU,3540
|
|
6
|
+
pyprojection/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
pyprojection/registry.py,sha256=b6Bv06ea_Ou08dh__qrhA0buZN6T9s5shGTmA3q7GQc,5744
|
|
8
|
+
pyprojection-0.1.2.dist-info/WHEEL,sha256=3NO1oYsF7mANFUWVdyEQLM-zmecczWWEmmF5rEbXrWk,80
|
|
9
|
+
pyprojection-0.1.2.dist-info/METADATA,sha256=XK9Cu5tZpTYhh7-2p_IBpO0A8Os_p7r5LeSXWKJ6tfI,13251
|
|
10
|
+
pyprojection-0.1.2.dist-info/RECORD,,
|