Skip to main content

pyprojection

Output shapes as values: which fields, under which names, computed how. Composable with & and definable as JSON.

export = (
    columns["name"]()
    & columns["monthly_salary"](label="Monthly pay")
    & computed("Yearly pay", salary * 12)
)

export(employee)
# {'name': 'Alice', 'Monthly pay': Decimal('3000.00'), 'Yearly pay': Decimal('36000.00')}

A Projection maps one subject to one dict. Columns come from a registry, which is the privacy boundary: a field that is not registered (or is registered as hidden) can never be selected, not from Python lookups and not from a JSON definition a user sent.

Piece What it does
ProjectionRegistry The whitelist: @columns.column() turns fn(subject, ...) into a column factory
Projection Ordered (output name, callable) pairs; callable, joined with &
computed A derived column from any T -> R callable, e.g. a pyformula Formula
ProjectionCompiler Builds a Projection from JSON (or ?fields=), errors carry a JSON path
get_projection_json_schema The JSON Schema of that JSON, generated from the registry

When not to use it. If the output shape is fixed, write a dict comprehension. pyprojection earns its place when a screen lets users choose their columns: an export dialog, a report builder, a ?fields= API parameter.

Installation

uv add pyprojection

Python 3.12+. The only runtime dependency is pyvocabulary. pyformula is optional: computed takes any callable.

Quick start

The examples in this README build on each other and are run by the test suite. A comment directly under a line shows what it outputs or raises.

from dataclasses import dataclass
from decimal import Decimal
from pyformula import variable
from pyprojection import ProjectionRegistry, computed


@dataclass(frozen=True)
class Employee:
    name: str
    department: str
    salary: Decimal
    national_id: str


alice = Employee("Alice", "Sales", Decimal("3000.00"), "123-45")

bob = Employee("Bob", "IT", Decimal("4500.50"), "678-90")

columns = ProjectionRegistry[Employee]()


@columns.column()
def name(employee: Employee) -> str:
    """Full name."""
    return employee.name


@columns.column()
def monthly_salary(employee: Employee, places: int = 2) -> Decimal:
    """Monthly salary, rounded to `places` decimals."""
    return round(employee.salary, places)


@variable()
def salary(employee: Employee) -> Decimal:
    return employee.salary


export = (
    columns["name"]()
    & columns["monthly_salary"](label="Monthly pay")
    & computed("Yearly pay", salary * 12)
)

export
# Projection(name, Monthly pay, Yearly pay)

export(alice)
# {'name': 'Alice', 'Monthly pay': Decimal('3000.00'), 'Yearly pay': Decimal('36000.00')}

[export(e)["name"] for e in (alice, bob)]
# ['Alice', 'Bob']

Registering columns

A column is a function whose first parameter is the subject. Any further parameters become arguments of its factory, bound when the projection is built, so a wrong argument fails before the first row is exported.

@columns.column(name="department", description="Department, optionally upper-cased.")
def dept(employee: Employee, *, upper: bool = False) -> str:
    return employee.department.upper() if upper else employee.department


columns["department"](upper=True)(alice)
# {'department': 'SALES'}

columns["monthly_salary"](0)(bob)
# {'monthly_salary': Decimal('4500')}

columns["department"](lower=True)
# InvalidColumnArgumentsError: Column 'department' got invalid arguments: got an unexpected keyword argument 'lower'

label= sets the output name. It is reserved on every factory, so a column function may not have a label parameter of its own (that is rejected at registration).

columns["name"](label="Full name")(alice)
# {'Full name': 'Alice'}

The decorator returns the factory, so it can also be used directly:

name()(bob)
# {'name': 'Bob'}

Register a lambda or an existing function without the decorator with register_column:

columns.register_column(lambda e: e.name[0], name="initial")

columns["initial"]()(alice)
# {'initial': 'A'}

Hidden columns

hidden=True keeps a column usable from Python (through the factory the decorator returns) but out of lookups, compilers and schemas. Use it for fields only internal code may export.

@columns.column(hidden=True)
def national_id(employee: Employee) -> str:
    return employee.national_id


national_id()(alice)
# {'national_id': '123-45'}

list(columns.columns)
# ['name', 'monthly_salary', 'department', 'initial']

columns["national_id"]
# ColumnDoesNotExistError: Column 'national_id' does not exist. Available columns: name, monthly_salary, department, initial

Composing with &

& returns a new projection with the fields of both, in order. Projections are frozen, so a base projection can be shared and extended safely.

base = columns["name"]() & columns["department"]()

payroll = base & columns["monthly_salary"](label="Pay")

base.names, payroll.names
# (('name', 'department'), ('name', 'department', 'Pay'))

names is the output header, e.g. for CSV. Two fields with the same output name are rejected when built; give one a label:

base & columns["name"]()
# DuplicateOutputNameError: Output name 'name' is used more than once. Give one of them another name with label=...

(base & columns["name"](label="Name again")).names
# ('name', 'department', 'Name again')

Computed columns

computed(name, fn) adds a derived column. The output name can be any non-empty string, and fn any callable: a pyformula Formula, a lambda, a method.

bonus = computed("Bonus", (salary * Decimal("0.10")))

(base & bonus)(bob)
# {'name': 'Bob', 'department': 'IT', 'Bonus': Decimal('450.0500')}

computed("Shouting", lambda e: e.name.upper())(alice)
# {'Shouting': 'ALICE'}

From JSON

ProjectionCompiler builds a projection from a list of columns. Each item is one of:

Item Meaning
"name" A column with no arguments
{"name": ..., "args": [...], "kwargs": {...}, "label": ...} A column call; all keys but name are optional
{"label": ..., "formula": ...} A computed column; formula goes to the formula compiler you inject
from pyformula import FormulaCompiler, FormulaNotFoundError, decimal_literal
from pyprojection import ProjectionCompiler

compiler = ProjectionCompiler(
    columns,
    FormulaCompiler[Employee]({"salary": salary}, literal=decimal_literal),
)

report = compiler.compile(
    [
        "name",
        {"name": "department", "kwargs": {"upper": True}, "label": "Dept"},
        {"name": "monthly_salary", "args": [0], "label": "Monthly pay"},
        {"label": "Yearly pay", "formula": {"operator": "multiply", "expressions": ["salary", 12]}},
    ]
)

report
# Projection(name, Dept, Monthly pay, Yearly pay)

report(alice)
# {'name': 'Alice', 'Dept': 'SALES', 'Monthly pay': Decimal('3000'), 'Yearly pay': Decimal('36000.00')}

literal=decimal_literal keeps JSON numbers exact in money formulas. Leave the formula compiler out to allow registered columns only: a definition with a computed column is then rejected.

Errors point at the JSON

Every problem is found by compile, before any row is exported. Errors keep their type and get a note with their JSON path, so an API can answer 400 with exactly what is wrong.

try:
    compiler.compile(["name", "national_id"])
except LookupError as error:
    print(error, error.__notes__)
# Column 'national_id' does not exist. Available columns: name, monthly_salary, department, initial ['at $[1]']

try:
    compiler.compile(
        ["name", {"label": "x", "formula": {"operator": "add", "expressions": ["salry", 1]}}]
    )
except FormulaNotFoundError as error:  # pyformula's own error type is kept
    print(error.__notes__)
# ['at $[1].formula.expressions[0]']

compiler.compile([{"name": "name", "kwarg": {}}])
# InvalidProjectionDefinitionError: Invalid projection at $[0]: unknown 'kwarg'. Allowed keys: args, kwargs, label, name

Recipes

?fields= in an API

A bare string is a column, so a comma-separated query parameter compiles as it is. Hidden and unregistered fields are refused, which is the point.

fields = "name,department"  # request.query_params["fields"]

compiler.compile(fields.split(","))(bob)
# {'name': 'Bob', 'department': 'IT'}

CSV export

import csv, io

out = io.StringIO()

writer = csv.DictWriter(out, fieldnames=report.names, lineterminator="\n")

writer.writeheader()

writer.writerows(report(e) for e in (alice, bob))

print(out.getvalue())
# name,Dept,Monthly pay,Yearly pay
# Alice,SALES,3000,36000.00
# Bob,IT,4500,54006.00

Saved report layouts

Store the JSON the user built (in a database row, a settings file) and compile it when the report runs. A layout that refers to a column you have since removed fails with its path, not with a half-written file.

JSON Schema

get_projection_json_schema(columns, formula_schema=None) describes what compile accepts. It is generated from the registry: column names, each column's arguments (from its signature) and its description (from its docstring or description=). Hidden columns are left out.

from pyprojection import get_projection_json_schema

schema = get_projection_json_schema(columns)

schema["$defs"]["column_name"]["enum"]  # what a column picker lists
# ['name', 'monthly_salary', 'department', 'initial']

department = schema["$defs"]["call"]["anyOf"][2]

department["description"]
# 'Department, optionally upper-cased.'

department["properties"]["kwargs"]["properties"]
# {'upper': {'type': 'boolean', 'default': False}}

Pass formula_schema to allow computed columns; it is embedded under its own $id (urn:pyprojection:formula) so $refs inside it keep working.

Errors

All errors derive from PyprojectionError and also from the matching built-in, so except LookupError / ValueError / TypeError work too.

Exception Also a Raised when
ColumnDoesNotExistError LookupError A column is not registered or is hidden
ColumnAlreadyRegisteredError A column name is registered twice
InvalidColumnNameError ValueError A column name is not a Python identifier (a lambda needs name=)
InvalidColumnError TypeError A function takes no subject or has a label parameter; a computed value is not callable
InvalidColumnArgumentsError TypeError A factory gets arguments its function does not take
InvalidOutputNameError ValueError An output name is not a non-empty string
DuplicateOutputNameError ValueError Two fields of one projection share an output name
InvalidProjectionDefinitionError ValueError A JSON definition has the wrong shape

The family

pyprojection is one of a set of packages that wrap a Callable[[T], R] in an immutable value with an algebra: pyspecification (T -> bool, & | ~), pyformula (T -> number, arithmetic), pyflowstep (T -> T, >>), pyruleset (T -> V, first match wins) and pyprojection (T -> dict, &). They share pyvocabulary for registries, JSON Schemas and JSON-path errors.

License

GPL-3.0-only.

Metadata

Release files for pyprojection 0.1.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pyprojection 0.1.3
File Size Uploaded Provenance Actions
pyprojection-0.1.3.tar.gz 13.1 kB Without provenance Details for pyprojection-0.1.3.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for pyprojection 0.1.3
File Interpreter ABI Platform Actions
pyprojection-0.1.3-py3-none-any.whl Python 3 none any Details for pyprojection-0.1.3-py3-none-any.whl

Total release size: 28.1 kB

Release files / pyprojection-0.1.3.tar.gz

Download URL pyprojection-0.1.3.tar.gz
Size 13.1 kB
Tags Source
SHA-256 checksum
How to use checksums
a7501cb9e889e7e4853469dd14acf1a43020bf9336350f491d3041e68c720378
BLAKE2b-256 checksum
How to use checksums
6ddb75e7c4b098d5e054fe228213f6cc5f8b69a6c7f345038bd0717b36c4a09d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / pyprojection-0.1.3-py3-none-any.whl

Download URL pyprojection-0.1.3-py3-none-any.whl
Size 15.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
93c98de9cbcd77d0ddc7e48d9d72895c65b6e46231e93466fccf3d85da37febc
BLAKE2b-256 checksum
How to use checksums
b0e6cf72cb46afa6049704ea4695ae00d1b2ad7f0096b213857158613d7ef92b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Security for pyprojection 0.1.3

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.3 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page