title: kapro description: Class-level, mixed, and cached descriptors for Python
kapro
[ref: #kapro]
Overview
[ref: #overview]
kapro is a small, focused Python library of property descriptors. It extends the standard property idea with class-level descriptors, mixed class and instance descriptors, several caching strategies, parent-aware overrides, and attribute forwarding.
The public API has three main entry points:
class_property— class-level computed attributes.mixed_property— descriptors that receive either the class or the instance.pin— a family of instance and class cached properties.
kapro.special.proxy_to rounds out the library by delegating attributes to another object.
Contents
[ref: #contents]
- Overview
- Installation
- Quick start
- Descriptors
- Parent-aware overrides with
with_parent - Attribute forwarding with
proxy_to - Cache invalidation
- Common gotchas
- Development
Installation
[ref: #installation]
uv add kapro
Or with any PEP 517-compatible tool:
pip install kapro
kapro depends on kain.
Quick start
[ref: #quick-start]
from zx.prop import class_property, mixed_property, pin
class Config:
@class_property
def name(cls) -> str:
return "default"
@mixed_property
def label(node) -> str:
if isinstance(node, type):
return f"{node.__name__} model"
return f"instance of {type(node).__name__}"
@pin
def value(self) -> int:
return 42
assert Config.name == "default"
assert Config().label == "instance of Config"
assert Config().value == 42
Descriptors
[ref: #descriptors]
class_property
[ref: #class-property]
A class-level descriptor. The decorated function receives the class, and the result is computed every time it is accessed (no caching).
from zx.prop import class_property
class Config:
@class_property
def name(cls) -> str:
return cls.__name__.lower()
assert Config.name == "config"
assert Config().name == "config"
mixed_property
[ref: #mixed-property]
A descriptor that is invoked both on class access and on instance access. The decorated function receives either the class or the instance.
from zx.prop import mixed_property
class User:
def __init__(self, id: int) -> None:
self.id = id
@mixed_property
def label(node) -> str:
if isinstance(node, type):
return "User model"
return f"user #{node.id}"
assert User.label == "User model"
assert User(7).label == "user #7"
pin
[ref: #pin]
pin is the main entry point for cached properties. It is itself an instance-bound descriptor, and it exposes several flavors through class attributes:
| Decorator | Caches on | Access |
|---|---|---|
@pin |
instance __dict__ |
instance only |
@pin.native |
instance __instance_memoized__ |
instance only |
@pin.cls |
accessed class | class + instance |
@pin.any |
class or instance | class + instance |
@pin.pre |
class only | class + instance |
@pin.post |
instance only | class + instance |
from zx.prop import pin
class Service:
@pin
def config(self) -> dict:
return {"debug": True}
@pin.cls
def version(cls) -> str:
return "1.0"
s = Service()
assert s.config is s.config # computed once per instance
assert Service.version == "1.0" # computed once per accessed class
pin.native
[ref: #pin-native]
The standard instance-level cached property. Use it instead of plain @pin when you need the cache stored separately from the attribute name, or when the decorated function is a coroutine.
from zx.prop import pin
class Sample:
@pin.native
def value(self) -> int:
return expensive_computation()
pin.cls
[ref: #pin-cls]
A class-level cached property. Each accessed class gets its own cache, so subclasses do not share cached values with their parents.
from zx.prop import pin
class Base:
@pin.cls
def value(cls) -> int:
return 1
class Child(Base):
pass
_ = Base.value
_ = Child.value
assert "__class_memoized__" in Base.__dict__
assert "__class_memoized__" in Child.__dict__
pin.any
[ref: #pin-any]
Caches on the accessed node: the instance for instance access, the accessed class for class access.
from zx.prop import pin
class Sample:
@pin.any
def value(node) -> int:
return 1
assert Sample().value == 1
assert Sample.value == 1
pin.pre
[ref: #pin-pre]
An asymmetric cached descriptor: it caches only on class access. Instance access recomputes every time.
from zx.prop import pin
class Sample:
@pin.pre
def pre_value(cls) -> int:
return compute()
pin.post
[ref: #pin-post]
An asymmetric cached descriptor: it caches only on instance access. Class access recomputes every time.
from zx.prop import pin
class Sample:
@pin.post
def post_value(self) -> int:
return compute()
Choosing the cache owner with .here
[ref: #here]
By default, class-level pin.* flavors cache on the class that was accessed. Use .here when you want the cache to live on the class that defines the descriptor instead. This is useful when subclasses should share the parent's cached value.
pin.cls.here
[ref: #pin-cls-here]
@pin.cls caches on the accessed class; @pin.cls.here caches on the owner class.
from zx.prop import pin
class Base:
@pin.cls.here
def value(cls) -> int:
return 1
class Child(Base):
pass
_ = Base.value
_ = Child.value
assert "__class_memoized__" in Base.__dict__
assert "__class_memoized__" not in Child.__dict__ # shared with owner
pin.any.here
[ref: #pin-any-here]
Instance access caches on the instance, but class access caches on the owner class.
from zx.prop import pin
class Base:
@pin.any.here
def value(node) -> int:
return 1
class Child(Base):
pass
assert Base().value == 1
assert Child().value == 1
assert Base.value == 1
assert Child.value == 1
assert "__class_memoized__" in Base.__dict__
assert "__class_memoized__" not in Child.__dict__
pin.pre.here
[ref: #pin-pre-here]
Class access caches on the owner class, so subclasses share the same class-level cache.
from zx.prop import pin
class Base:
@pin.pre.here
def value(cls) -> int:
return compute()
class Child(Base):
pass
assert Base.value == Child.value
assert "__class_memoized__" in Base.__dict__
assert "__class_memoized__" not in Child.__dict__
pin.post.here
[ref: #pin-post-here]
Instance access caches on the instance. Class access always recomputes, so there is no class-level cache to share.
from zx.prop import pin
class Sample:
@pin.post.here
def value(self) -> int:
return compute()
s = Sample()
assert s.value == s.value # cached on the instance
assert Sample.value != Sample.value # recomputed on every class access
Parent-aware overrides with with_parent
[ref: #with-parent]
Every public descriptor supports .with_parent. Use it in a subclass to receive the parent descriptor's value as the second positional argument.
from zx.prop import pin
class Parent:
@pin
def value(self) -> int:
return 10
class Child(Parent):
@pin.with_parent
def value(self, parent_value: int) -> int:
return parent_value + 5
assert Child().value == 15
The same works for class_property, mixed_property, and all pin.* flavors.
Note: if both parent and child use
.with_parenton the same name, you can get aRecursionErrorwith the message "couldn't reach parent descriptor".
Attribute forwarding with proxy_to
[ref: #proxy-to]
proxy_to is a class decorator that forwards attribute access to a "pivot" object.
from zx.prop.special import proxy_to
class Engine:
def power(self) -> int:
return 42
@proxy_to("engine", "power")
class Car:
engine = Engine()
assert Car().power == 42
Arguments:
| Argument | Default | Meaning |
|---|---|---|
pivot |
required | A string attribute name on the decorated class, or an external object. |
names |
required | One or more attribute names to proxy from the pivot. |
binder |
bound_property |
Descriptor factory used to wrap the forwarding function. None attaches a raw lookup descriptor. |
getter |
operator.attrgetter |
(name) -> (obj) -> value used to fetch from the pivot. |
default |
Nothing |
Fallback value if the pivot or attribute is missing. |
post |
None |
value -> result post-processor applied after fetching. |
safe |
True |
Raise TypeError if the name already exists on the class. |
With the default binder, a proxied callable is invoked with no arguments and its return value is used:
@proxy_to("engine", "power")
class Car:
engine = Engine()
assert Car().power == 42 # result, not a bound method
Use binder=None when you need the raw attribute, for example to pass arguments:
@proxy_to("target", "scale", binder=None)
class Proxy:
class Target:
def scale(self, value: int) -> int:
return value * 3
target = Target()
assert Proxy().scale(5) == 15
Note: because attributes are attached dynamically, static analyzers will not infer forwarded attributes from the decorator call.
Cache invalidation
[ref: #cache-invalidation]
Cached pin.* descriptors expose two helpers for cache invalidation:
.by(callback)
Provide a custom freshness predicate:
def always_actual(_self, _node, _stamp) -> bool:
return True
class Sample:
value = pin.cls.by(always_actual)(lambda cls: 1)
.ttl(seconds)
Provide a numeric time-to-live (must be > 0):
class Sample:
value = pin.cls.ttl(60.0)(lambda cls: compute())
Common gotchas
[ref: #common-gotchas]
-
Class access on instance-only descriptors raises
ContextFaultError.class S: attr = pin.native(lambda self: 1) S.attr # ContextFaultError
-
pinandbound_propertyneed__dict__. Slotted classes without__dict__raiseTypeError("has no __dict__"). -
Frozen dataclasses.
pincannot cache on frozen instances and raisesFrozenInstanceError. Usebinder=Noneinproxy_toto forward raw attributes without caching. -
Async properties. Plain
pinrejects coroutine functions. Use@pin.nativefor async properties instead. -
proxy_towill not overwrite existing attributes unlesssafe=False. -
with_parentrelies on matching names. The overriding method must have the same name as the parent's property.
Development
[ref: #development]
Clone the repository and install dependencies:
uv sync
Run the test suite:
make test
Run the full lint pipeline:
make lint
kapro targets Python 3.12+.
Metadata
Release files for kapro 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| kapro-1.0.0.tar.gz | 40.9 kB | Without provenance | Details for kapro-1.0.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| kapro-1.0.0-py3-none-any.whl | Python 3 | none | any | Details for kapro-1.0.0-py3-none-any.whl |
Total release size: 68.9 kB
Release files / kapro-1.0.0.tar.gz
| Download URL | kapro-1.0.0.tar.gz |
|---|---|
| Size | 40.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9026fb943a56e4f1a952a1f67c0e39bcb293df5eaa0baa25b51c5f2c046a0a56
|
|
BLAKE2b-256 checksum How to use checksums |
9628d247da4f78760ea59a6d34f49d61f65e475ae8762a478962c9a76789646d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.2 {"installer":{"name":"uv","version":"0.12.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / kapro-1.0.0-py3-none-any.whl
| Download URL | kapro-1.0.0-py3-none-any.whl |
|---|---|
| Size | 28.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
39097b55944c360cc303038c6bd52f160ce5e12d6eff8c6efa0983d31d41298b
|
|
BLAKE2b-256 checksum How to use checksums |
bfcc030d9e6d43a080bdf16f4705ca2c8ecf7b6f9734ce5076e4ae1c50917cfd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.2 {"installer":{"name":"uv","version":"0.12.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for kapro 1.0.0
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.