Added ProxyObject + CLAUDE.md

This commit is contained in:
2025-12-03 21:20:20 +01:00
parent 0ef9523e6b
commit d8b1ef2e2c
5 changed files with 628 additions and 6 deletions

154
CLAUDE.md Normal file
View File

@@ -0,0 +1,154 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
MyUtils is a Python utility library providing dynamic object manipulation capabilities. The library focuses on three core utilities: Observable (attribute change tracking), Expando (dynamic dictionary wrapper), and Dummy (null-safe mock object).
## Development Workflow & Collaboration Guidelines
### Developer Profile
You are an experienced Python developer producing Python 3.12 code.
### Development Process
- All proposed code must be testable
- **Before writing any code**, explain the possible implementation options
- Wait for mutual validation and understanding of requirements before producing code
### Collaboration Style
- Ask clarifying questions to refine understanding or suggest alternative approaches
- **Ask questions one at a time** - wait for complete understanding of the answer before asking the next
- When you have multiple questions, indicate progress (e.g., "Question 1/5")
### Communication
- Conversations can be in French or English
- **All code, documentation, and comments must be exclusively in English**
### Code Standards
- Follow PEP 8 conventions for Python code style
- Use Google or NumPy format for docstrings
- Variable and function names must be explicit and in snake_case
- Never use emojis in code
### Dependency Management
- List all external dependencies required for installation
- When possible, propose alternatives using only Python standard library
### Unit Testing
- Use pytest library for unit tests
- **Before writing tests**, list the tests you plan to implement with explanations of why they're important
- Wait for validation before implementing tests
- Unless explicitly needed (e.g., inheritance), write tests as functions, not classes
- Test function naming patterns:
- `test_i_can_xxx` for tests that should pass
- `test_i_cannot_xxx` for edge cases that should raise errors/exceptions
### File Management
- When adding or modifying a file, always provide the complete file path
### Error Handling
- When presented with a code execution error:
1. First provide a clear explanation of the problem
2. **Do not** immediately propose a new version
3. Wait for validation of the bug analysis before proposing solutions
## Development Commands
### Testing
```bash
# Run all tests
pytest
# Run specific test file
pytest tests/test_observable.py
pytest tests/test_expando.py
pytest tests/test_dummy.py
# Run with verbose output
pytest -v
# Run specific test
pytest tests/test_observable.py::test_i_can_make_an_object_observable
```
### Package Management
```bash
# Install development dependencies
pip install -r requirements.txt
# Install package in development mode
pip install -e .
# Install with development extras
pip install -e ".[dev]"
```
## Architecture
### Observable Pattern Implementation
The Observable module uses **dynamic class substitution** to add observability to any Python object:
1. `make_observable(obj)` replaces the object's class with a dynamically created subclass
2. The subclass overrides `__setattr__` to intercept attribute changes
3. Listeners are stored in a `_listeners` dict on the object instance
4. Two types of callbacks exist:
- **Attribute callbacks**: Triggered on specific attribute changes via `bind()`
- **Event listeners**: Triggered via `add_event_listener()` for events like `AFTER_PROPERTY_CHANGE`
#### Event System
- `AFTER_PROPERTY_CHANGE` event fires after all attribute callbacks execute
- Event listeners receive: `(attr_name, old_value, new_value, callback_results)`
- Empty string `""` as attr_name registers listener for ALL attributes
- Return values from attribute callbacks are collected and passed to event listeners
### Expando Pattern
Expando wraps dictionaries to enable dot-notation access:
- Uses `__getattr__` to intercept attribute access and delegate to internal dict
- Nested dicts are automatically wrapped in Expando instances
- `get(path)` method supports path notation (`"a.b.c"`) and list traversal
- When path encounters a list, it collects values from all list items
### ProxyObject
ProxyObject (currently in development) maps object attributes to a different structure using a mappings dictionary. Note: Implementation appears incomplete - `_create_props()` is defined but never called.
## Testing Conventions
- Test files use pytest fixtures (`data`, `observable_data`)
- Test names follow pattern: `test_i_can_<action>` or `test_<behavior>`
- Tests are organized by feature with separator comments
- Each test is self-contained with its own callbacks/data
## Package Structure
```
src/myutils/ # Main package code
├── __init__.py # Package exports (currently empty)
├── observable.py # Observable implementation
├── Expando.py # Expando wrapper
├── Dummy.py # Dummy mock object
└── ProxyObject.py # ProxyObject (incomplete)
tests/ # Test suite
├── test_observable.py
├── test_expando.py
└── test_dummy.py
```
## Important Patterns
### Observable Callback Semantics
- Callbacks must be the **same object** to unbind (not just equivalent lambdas)
- `unbind()` and `unbind_all()` fail silently if callback/attribute doesn't exist
- Multiple instances have independent listeners
- `make_observable()` is idempotent (safe to call multiple times)
### Naming Convention
- Use English for persona names (per global CLAUDE.md)
- Class names use PascalCase
- Functions use snake_case
- Private attributes prefixed with `_`

158
README.md
View File

@@ -3,8 +3,7 @@
## Overview ## Overview
The **MyUtils** project is a Python library providing utilities to enhance Python objects, enabling features like The **MyUtils** project is a Python library providing utilities to enhance Python objects, enabling features like
dynamic property access and observability of attributes. The library is designed to modify and extend the behavior of dynamic property access, observability of attributes, and flexible property mapping. The library is designed to modify and extend the behavior of objects in an easy-to-use and modular way, making it more developer-friendly.
objects in an easy-to-use and modular way, making it more developer-friendly.
--- ---
@@ -335,6 +334,149 @@ print(exp.new_key) # Output: new_value
--- ---
### 3. **ProxyObject**
The `ProxyObject` module provides a way to create proxy objects that map properties from a source object to new property names, with support for nested properties and wildcards.
#### Key Features:
- Map object properties to different names using a mapping dictionary
- Access nested properties using dot notation (e.g., `"address.city"`)
- Support for wildcard patterns to copy multiple properties at once
- Automatically resolve property paths during initialization
#### Key Classes/Functions:
- **`ProxyObject`**:
A class that wraps an object and exposes its properties through a customizable mapping.
#### Example Usage:
Here are examples using the `ProxyObject` module:
##### Basic Property Mapping
```python
from myutils.ProxyObject import ProxyObject
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
person = Person("John", 30)
# Map 'name' to 'display_name' and 'age' to 'years'
mappings = {
"display_name": "name",
"years": "age"
}
proxy = ProxyObject(person, mappings)
print(proxy.display_name) # Output: John
print(proxy.years) # Output: 30
```
##### Nested Property Mapping
```python
from myutils.ProxyObject import ProxyObject
class Address:
def __init__(self, city, country):
self.city = city
self.country = country
class Employee:
def __init__(self, name, address):
self.name = name
self.address = address
address = Address("Paris", "France")
employee = Employee("Alice", address)
# Map nested properties using dot notation
mappings = {
"employee_name": "name",
"city_name": "address.city"
}
proxy = ProxyObject(employee, mappings)
print(proxy.employee_name) # Output: Alice
print(proxy.city_name) # Output: Paris
```
##### Wildcard Patterns
```python
from myutils.ProxyObject import ProxyObject
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
person = Person("John", 30)
# Copy all properties from the source object
mappings = {"*": ""}
proxy = ProxyObject(person, mappings)
print(proxy.name) # Output: John
print(proxy.age) # Output: 30
```
```python
# Copy all properties from a nested object
class Employee:
def __init__(self, name, address):
self.name = name
self.address = address
class Address:
def __init__(self, city, country):
self.city = city
self.country = country
address = Address("Paris", "France")
employee = Employee("Alice", address)
# Copy all properties from 'address' to the proxy
mappings = {"*": "address"}
proxy = ProxyObject(employee, mappings)
print(proxy.city) # Output: Paris
print(proxy.country) # Output: France
```
```python
# Flatten nested object properties
class City:
def __init__(self, name, zip_code):
self.name = name
self.zip_code = zip_code
class Address:
def __init__(self, city, country):
self.city = city
self.country = country
address = Address(City("Paris", "75001"), "France")
employee = Employee("Alice", address)
# Flatten all properties from address.* (expands city's properties and country)
mappings = {"*": "address.*"}
proxy = ProxyObject(employee, mappings)
print(proxy.name) # Output: Paris (from address.city.name)
print(proxy.zip_code) # Output: 75001 (from address.city.zip_code)
print(proxy.country) # Output: France (from address.country)
```
---
## Project Structure ## Project Structure
To understand the file organization, here's the structure of the project: To understand the file organization, here's the structure of the project:
@@ -344,12 +486,17 @@ To understand the file organization, here's the structure of the project:
MyUtils MyUtils
├── src ├── src
│ └── myutils │ └── myutils
── __init__.py # Main package initialization ── __init__.py # Main package initialization
│ ├── Dummy.py #
│ ├── Expando.py #
│ ├── observable.py #
│ └── ProxyObject.py #
├── tests ├── tests
│ ├── __init__.py # Test package initialization │ ├── __init__.py # Test package initialization
│ ├── test_dummy.py # Tests for Dummy module │ ├── test_dummy.py # Tests for Dummy module
│ ├── test_expando.py # Tests for Expando module │ ├── test_expando.py # Tests for Expando module
── test_observable.py # Tests for Observable module ── test_observable.py # Tests for Observable module
│ └── test_proxyobject.py # Tests for ProxyObject module
├── .gitignore # Git ignore file ├── .gitignore # Git ignore file
├── main.py # Application entry point ├── main.py # Application entry point
├── requirements.txt # Project dependencies ├── requirements.txt # Project dependencies
@@ -396,4 +543,5 @@ Special thanks to the Python and open-source community for their tools, inspirat
* 0.1.0 : Initial release * 0.1.0 : Initial release
* 0.2.0 : Observable results can be collected using `collect_return_values` * 0.2.0 : Observable results can be collected using `collect_return_values`
* 0.3.0 : Added `unbind`, `unbind_all`, `has_listeners` `get_listener_count` to Observable * 0.3.0 : Added `unbind`, `unbind_all`, `has_listeners` `get_listener_count` to Observable
* 0.4.0 : Added `add_event_listener` and `remove_event_listener` * 0.4.0 : Added `add_event_listener` and `remove_event_listener`
* 0.5.0 : Added `ProxyObject`

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "myutils" name = "myutils"
version = "0.4.0" version = "0.5.0"
description = "Base useful classes." description = "Base useful classes."
readme = "README.md" readme = "README.md"
authors = [ authors = [

View File

@@ -0,0 +1,69 @@
class ProxyObject:
def __init__(self, obj, mappings):
self._obj = obj
self._mappings = mappings
self._props = {}
self._create_props()
def __getattr__(self, item):
if item not in self._props:
raise AttributeError(item)
return self._props[item]
def __hasattr__(self, item):
return item in self._props
def __repr__(self):
if "key" in self._props:
return f"ProxyObject(key={self._props["key"]})"
props_as_str = str(self._props)
if len(props_as_str) > 50:
props_as_str = props_as_str[:50] + "..."
return f"ProxyObject({props_as_str})"
def __eq__(self, other):
if not isinstance(other, ProxyObject):
return False
return self._props == other._props
def __hash__(self):
return hash(tuple(sorted(self._props.items())))
def _create_props(self):
for prop_name, path in self._mappings.items():
attrs = path.split(".")
current = self._obj
# Check if path ends with wildcard
has_wildcard_in_path = attrs[-1] == "*"
# Navigate to the target object
for attr in attrs:
if attr == "*":
break
if hasattr(current, attr):
current = getattr(current, attr)
else:
break
# Handle wildcard cases
if prop_name == "*" or has_wildcard_in_path:
# Copy all properties from current object
if hasattr(current, '__dict__'):
for attr_name, attr_value in current.__dict__.items():
if not attr_name.startswith('_'):
# If attr_value is an object with properties, expand them
if has_wildcard_in_path and hasattr(attr_value, '__dict__'):
for sub_attr_name, sub_attr_value in attr_value.__dict__.items():
if not sub_attr_name.startswith('_'):
self._props[sub_attr_name] = sub_attr_value
else:
# Simple value or prop_name == "*"
self._props[attr_name] = attr_value
else:
# Normal mapping (no wildcard)
self._props[prop_name] = current

251
tests/test_proxyobject.py Normal file
View File

@@ -0,0 +1,251 @@
import pytest
from myutils.ProxyObject import ProxyObject
# Test fixtures
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
class City:
def __init__(self, name, zip_code):
self.name = name
self.zip_code = zip_code
class Address:
def __init__(self, city, country):
self.city = city
self.country = country
class Employee:
def __init__(self, name, age, address):
self.name = name
self.age = age
self.address = address
@pytest.fixture
def person():
return Person("John", 30)
@pytest.fixture
def employee():
address = Address("Paris", "France")
return Employee("Alice", 25, address)
# ============================================================================
# Basic tests
# ============================================================================
def test_i_can_create_proxy_object_with_simple_mapping(person):
"""
ProxyObject should be created successfully with a simple mapping.
"""
mappings = {"display_name": "name"}
proxy = ProxyObject(person, mappings)
assert isinstance(proxy, ProxyObject)
def test_i_can_access_mapped_property(person):
"""
Accessing a mapped property should return the value from the source object.
"""
mappings = {"display_name": "name"}
proxy = ProxyObject(person, mappings)
assert proxy.display_name == "John"
def test_i_can_map_nested_properties(employee):
"""
Nested properties using dot notation should be mapped correctly.
"""
mappings = {"city_name": "address.city"}
proxy = ProxyObject(employee, mappings)
assert proxy.city_name == "Paris"
def test_i_can_map_nested_nested_properties():
address = Address(City("Paris", "75001"), "France")
employee2 = Employee("Alice", 25, address)
mappings = {"city_zip_code": "address.city.zip_code"}
proxy = ProxyObject(employee2, mappings)
assert proxy.city_zip_code == "75001"
def test_i_can_map_multiple_properties(employee):
"""
Multiple mappings should work simultaneously.
"""
mappings = {
"full_name": "name",
"years": "age",
"city_name": "address.city"
}
proxy = ProxyObject(employee, mappings)
assert proxy.full_name == "Alice"
assert proxy.years == 25
assert proxy.city_name == "Paris"
def test_i_can_manage_wild_card(employee):
mappings = {"*": ""}
proxy = ProxyObject(employee, mappings)
assert proxy.name == employee.name
assert proxy.age == employee.age
assert proxy.address == employee.address
def test_i_can_manage_wild_card_2(employee):
mappings = {"*": "*"}
proxy = ProxyObject(employee, mappings)
assert proxy.name == employee.name
assert proxy.age == employee.age
assert proxy.city == employee.address.city
assert proxy.country == employee.address.country
def test_i_can_manage_wild_card_on_nested_properties():
address = Address(City("Paris", "75001"), "France")
employee2 = Employee("Alice", 25, address)
mappings = {"name": "name", "*": "address"}
proxy = ProxyObject(employee2, mappings)
assert proxy.name == employee2.name
assert proxy.city == employee2.address.city
assert proxy.country == employee2.address.country
def test_i_can_manage_wild_card_on_multiple_properties():
address = Address(City("Paris", "75001"), "France")
employee2 = Employee("Alice", 25, address)
mappings = {"*": "address.*"}
proxy = ProxyObject(employee2, mappings)
assert proxy.name == employee2.address.city.name
assert proxy.zip_code == employee2.address.city.zip_code
assert proxy.country == employee2.address.country
# ============================================================================
# Edge case tests
# ============================================================================
def test_i_cannot_access_unmapped_property(person):
"""
Accessing an unmapped property should raise AttributeError.
"""
mappings = {"display_name": "name"}
proxy = ProxyObject(person, mappings)
with pytest.raises(AttributeError):
_ = proxy.unknown_property
def test_i_can_handle_missing_source_property(person):
"""
When the source property doesn't exist, the proxy should handle it gracefully.
The current implementation breaks the loop and assigns the last valid value.
"""
mappings = {"missing": "non_existent_attr"}
proxy = ProxyObject(person, mappings)
# The property exists in _props (because of break behavior)
# but contains the original object
assert proxy.missing == person
def test_i_can_handle_none_values():
"""
None values should be handled correctly.
"""
person = Person(None, 30)
mappings = {"display_name": "name"}
proxy = ProxyObject(person, mappings)
assert proxy.display_name is None
# ============================================================================
# Special methods tests
# ============================================================================
def test_proxy_objects_with_same_props_are_equal(person):
"""
Two ProxyObject instances with the same _props should be equal.
"""
mappings = {"display_name": "name", "years": "age"}
proxy1 = ProxyObject(person, mappings)
proxy2 = ProxyObject(person, mappings)
assert proxy1 == proxy2
def test_proxy_objects_with_different_props_are_not_equal(person):
"""
Two ProxyObject instances with different _props should not be equal.
"""
mappings1 = {"display_name": "name"}
mappings2 = {"years": "age"}
proxy1 = ProxyObject(person, mappings1)
proxy2 = ProxyObject(person, mappings2)
assert proxy1 != proxy2
def test_i_can_hash_proxy_object(person):
"""
ProxyObject should be hashable and usable in sets/dicts.
"""
mappings = {"display_name": "name", "years": "age"}
proxy = ProxyObject(person, mappings)
# Should not raise an exception
proxy_hash = hash(proxy)
assert isinstance(proxy_hash, int)
# Should be usable in a set
proxy_set = {proxy}
assert proxy in proxy_set
def test_repr_shows_key_when_present():
"""
When 'key' is present in _props, __repr__ should show it.
"""
person = Person("key_value", 30)
mappings = {"key": "name"}
proxy = ProxyObject(person, mappings)
repr_str = repr(proxy)
assert "key=key_value" in repr_str
def test_repr_truncates_long_content(person):
"""
When _props content is longer than 50 chars, __repr__ should truncate it.
"""
mappings = {
"very_long_property_name_1": "name",
"very_long_property_name_2": "age"
}
proxy = ProxyObject(person, mappings)
repr_str = repr(proxy)
assert "..." in repr_str
assert len(repr_str) < 100 # Should be truncated