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.
@@ -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
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.6
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any