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 kapro 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 kapro 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 kapro 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 kapro 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 kapro 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 kapro 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 kapro 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 kapro 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 kapro 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 kapro 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 kapro 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 kapro 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 kapro 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 kapro 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 kapro.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+.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file kapro-0.1.0.tar.gz.
File metadata
- Download URL: kapro-0.1.0.tar.gz
- Upload date:
- Size: 37.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.3 {"installer":{"name":"uv","version":"0.12.3","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}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
37e0e20e1434e047891c94160b8797763c665d74427bd757d550d0e23f24ff34
|
|
| MD5 |
5cdc30ccde8dc3eb38c4dbd25760877b
|
|
| BLAKE2b-256 |
8b9bd9a8d40488af72cbbbc03324b45f6ff54d64032d1458a9ee772cd0ddee1e
|
File details
Details for the file kapro-0.1.0-py3-none-any.whl.
File metadata
- Download URL: kapro-0.1.0-py3-none-any.whl
- Upload date:
- Size: 27.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.3 {"installer":{"name":"uv","version":"0.12.3","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}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b78186a0f554c5ca98ad67a1782771d96d96972cfc49cbaa0c0cafdc78347d86
|
|
| MD5 |
283942cef33e2295db1c0d9dabaecc7a
|
|
| BLAKE2b-256 |
abf2a70fe14a60ea91cb86615b8bf9935bc120a18838f0f53e0e82a6d071583c
|