Metadata-Version: 2.4
Name: contextualproperties
Version: 0.1.4
Summary: Toolkit for dynamic data modeling
Author-email: Mason Logan <contextualproperties@masonlogan.com>
Maintainer-email: Mason Logan <contextualproperties@masonlogan.com>
License-Expression: MIT
Project-URL: Homepage, https://codeberg.org/malogan/contextual-properties
Project-URL: Repository, https://codeberg.org/malogan/contextual-properties.git
Project-URL: Bug Tracker, https://codeberg.org/malogan/contextual-properties/issues
Keywords: property,data management
Classifier: Development Status :: 3 - Alpha
Classifier: Programming Language :: Python
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# contextualproperties

Toolkit for creating dynamic data layers for python classes

## Installation

contextualproperties can be added to any existing python project via pip:

```
pip install contextualproperties
```

## How-To Guide

### The Basics
You can do a lot with contextualproperties, but the most useful feature is
being able to change the output value of an attribute by changing the object's
context.

```python3
from contextualproperties import contextualobject, contextualproperty

@contextualobject
class ExampleClass:
    
    @contextualproperty
    def property0(self):
        return 'output of property 0, default context'

    @property0.getter(context="context1")
    def property0(self):
        return 'output of property 0, context 1'


if __name__ == '__main__':
    obj = ExampleClass()
    print(obj.property0)
    obj.push_context('context1')
    print(obj.property0)
```

```
>>> output of property 0, default context
>>> output of property 0, context 1
```

### Intermediate Example

Contextual properties are very useful if what you are working with involves a 
complex data structure where sometimes you need to interact with an object,
and other times you need to get the raw data.

In this example, let's assume we have an object for keeping track of what
accounts belong to the same user.

```python3
from typing import List
from dataclasses import dataclass
from contextualproperties import contextualobject, contextualproperty

@dataclass
class Account:
  """
  Takes an account type, username, and link to profile. Produces markdown
  formatted information as a string
  """
  type: str
  user: str
  url: str

@contextualobject
class Accounts:
    """
    Returns aliases from an underlying data structure
    """ 
    @contextualproperty
    def accounts(self) -> List[Account]:
        return [
          Account(**account) for account in self._data.get("accounts", [])
        ]
    
    @accounts.getter(context="raw")
    def accounts(self) -> List[dict]:
        return self._data.get("accounts", [])
    
    def __init__(self, **data):
        self._data = data

if __name__ == '__main__':
    example_data = {
        "uuid": "9a3d7e79-4e24-467d-8444-eaff377bb56d",
        "accessed": "2026-03-31",
        "accounts": [
            {
                "type": "mastodon",
                "user": "malogan@mastodon.social",
                "url": "https://mastodon.social/@malogan"
            },
            {
                "type": "codeberg",
                "user": "malogan",
                "url": "https://codeberg.org/malogan"
            }
        ]
    }
    
    obj = Accounts(**example_data)
    
    print("Object form:")
    for account in obj.accounts:
        print(account)
    print("Raw form:")
    obj.push_context('raw')
    for account in obj.accounts:
        print(account)
```

```
>>> Object form:
>>> Alias(type='mastodon', user='malogan@mastodon.social', url='https://mastodon.social/@malogan')
>>> Alias(type='codeberg', user='malogan', url='https://codeberg.org/malogan')
>>> Raw form:
>>> {'type': 'mastodon', 'user': 'malogan@mastodon.social', 'url': 'https://mastodon.social/@malogan'}
>>> {'type': 'codeberg', 'user': 'malogan', 'url': 'https://codeberg.org/malogan'}
```

### Layering

Contexts work as layers and properties will use the most recent layer they have
a recognized context for. For example, if your object has five active context
layers and you try to access a property that has two relevant matches, it will
return the value of the **most recent** match for that property.

In this example, `property0` has contexts 1 and 3, and `property1` has contexts
1 and 2. When the properties are called, the **most recent relevant context**
will be selected for each.

```python3
from typing import List
from dataclasses import dataclass
from contextualproperties import contextualobject, contextualproperty


@contextualobject
class ExampleClass:
    """
    Property 0 has contexts 1 and 3, Property 1 has contexts 1 and 2
    """
    
    @contextualproperty
    def property0(self):
        return "property 0 default context"
    
    @property0.getter(context="context1")
    def property0(self):
        return "property 0 context 1"
    
    @property0.getter(context="context3")
    def property0(self):
        return "property 0 context 3"
    
    @contextualproperty
    def property1(self):
        return "property 1 default context"
    
    @property1.getter(context="context1")
    def property1(self):
        return "property 1 context 1"
    
    @property1.getter(context="context2")
    def property1(self):
        return "property 1 context 2"


if __name__ == '__main__':
    obj = ExampleClass()
    print(f"{obj.property0} | {obj.property1}")
    
    obj.push_context("context1")
    print(f"{obj.property0} | {obj.property1}")

    obj.push_context("context2")
    print(f"{obj.property0} | {obj.property1}")

    obj.push_context("context3")
    print(f"{obj.property0} | {obj.property1}")
```

```
>>> property 0 default context | property 1 default context
>>> property 0 context 1 | property 1 context 1
>>> property 0 context 1 | property 1 context 2
>>> property 0 context 3 | property 1 context 2
```

### Caching

Contextual properties have an optional cache on a per-context basis. You can
set a context to be cached by providing `cache=True` in the decorator and
the value will be stored.

In this example, we have a getter function that reconstructs a Person object
whenever it is called. To prevent this unnecessary work, rather than 
reconstructing the dataclass every time the property is used, the cache will
give us the same object that was previously created.

Notice that the UUID in `contact` and `contact2` is the same. Had the object
been regenerated, the UUID would have changed in the second object.

```python3
from dataclasses import dataclass, field
from contextualproperties import contextualobject, contextualproperty
from uuid import uuid4

@dataclass
class Person:
    name: str
    uuid: uuid4 = field(default_factory = uuid4)
    address: str = None
    phone: str = None


@contextualobject
class User:
    
    @contextualproperty(cache=True)
    def contact_card(self) -> Person:
        # in an actual setup you would probably pull this from a db.
        # the uuid has been left off intentionally for demo purposes
        return Person(name = self._data.get('name'), **self._data.get('contact'))
    
    def __init__(self, **data):
      self._data = data

if __name__ == '__main__':
    obj = User(
      **{
        'username': 'malogan',
        'name': 'Mason Logan',
        'contact': {
          'address': '123 Road St, Townplace, NC 12345',
          'phone': '+1 123-456-7890'
        }
      }
    )

    contact = obj.contact_card
    print(contact)

    contact2 = obj.contact_card
    print(contact)
    print(f"Same object: {contact is contact2}")
```

```
>>> Person(name='Mason Logan', uuid=UUID('15a1d938-8fff-484c-8291-9966eacfeef9'), address='123 Road St, Townplace, NC 12345', phone='+1 123-456-7890')
>>> Person(name='Mason Logan', uuid=UUID('15a1d938-8fff-484c-8291-9966eacfeef9'), address='123 Road St, Townplace, NC 12345', phone='+1 123-456-7890')
>>> Same object: True
```


### Cache Invalidation

Property setters can be used to invalidate a cache by providing them with one
or more context values in the `invalidate` parameter

```python3
from dataclasses import dataclass, field
from contextualproperties import contextualobject, contextualproperty
from uuid import uuid4

@dataclass
class Person:
    name: str
    uuid: uuid4 = field(default_factory = uuid4)
    address: str = None
    phone: str = None


@contextualobject
class User:
  
    @contextualproperty
    def name(self) -> str:
        return getattr(self, '__User_name', None)
    
    @name.setter
    def name(self, name):
        setattr(self, '__User_name', name)
    
    @contextualproperty(cache=True)
    def contact_card(self) -> Person:
        # in an actual setup you would probably pull this from a db.
        # the uuid has been left off intentionally for demo purposes
        return Person(name=self.name, **self._data.get('contact'))
    
    def __init__(self, name, **data):
      self.name = name
      self._data = data

if __name__ == '__main__':
    obj = User(
      **{
        'username': 'malogan',
        'name': 'Mason Logan',
        'contact': {
          'address': '123 Road St, Townplace, NC 12345',
          'phone': '+1 123-456-7890'
        }
      }
    )

    contact = obj.contact_card
    print(contact)

    contact2 = obj.contact_card
    print(contact)
    print(f"Same object: {contact is contact2}")
```

```

```

### Cache Access

Sometimes your setter may need to interact with objects in your cache, like
if you are modifying an underlying data layer and want to update the objects
representing those parts (i.e avoid full reevaluation of complex objects every 
time a single value changes). By passing `cache_aware=True` into the setter
decorator, you can add a new parameter to the setter function to access the
cache.

***NOTE:** directly modifying the cache is discouraged for most purposes, but
it is a very powerful tool when done right.*

### More Examples

Check the "examples" folder for a recipe book on how to use contextual
properties to put together dynamic classes quickly

## Full Docs
As soon as I get MKDocs or a similar static doc generator running, I will make
the pages available

**TODO: set up a static docs page**

## Licensing
This project is licensed under the LGPL 3.0. If you need a different license for
some specific purpose, please reach out.

## Roadmap
Check ROADMAP.md for full details, but the following are features planned
in the near term:
- timeouts and maximum uses for cached values
  - setting up for passing in user-defined functions for condition-based decache
- ability to pass a dict into `invalidate` to clear cached values for other
  properties
- `PropertyTemplate` class for passing into `@contextualobject` class to
  avoid messy inheritance trees and jungles of boilerplate copy-paste
  - definition of base properties that can be expanded on
  - proper MRO modification for class to put `ContextualObject` and anything
    pulled from templates at bottom of heirarchy
- context grouping
  - pass multiple context values to duplicate functionality
- setter return value caching
  - allow setter functions to return a value that replaces cached values under 
    the same property
