From d8b1ef2e2c7ca3a7c60e448a14bce24e8ebc5683 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Wed, 3 Dec 2025 21:20:20 +0100 Subject: [PATCH] Added ProxyObject + CLAUDE.md --- CLAUDE.md | 154 +++++++++++++++++++++++ README.md | 158 ++++++++++++++++++++++- pyproject.toml | 2 +- src/myutils/ProxyObject.py | 69 ++++++++++ tests/test_proxyobject.py | 251 +++++++++++++++++++++++++++++++++++++ 5 files changed, 628 insertions(+), 6 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/myutils/ProxyObject.py create mode 100644 tests/test_proxyobject.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..267c4fc --- /dev/null +++ b/CLAUDE.md @@ -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_` or `test_` +- 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 `_` diff --git a/README.md b/README.md index a06a95f..ed85140 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,7 @@ ## Overview 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 -objects in an easy-to-use and modular way, making it more developer-friendly. +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. --- @@ -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 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 ├── src │ └── myutils -│ └── __init__.py # Main package initialization +│ ├── __init__.py # Main package initialization +│ ├── Dummy.py # +│ ├── Expando.py # +│ ├── observable.py # +│ └── ProxyObject.py # ├── tests │ ├── __init__.py # Test package initialization │ ├── test_dummy.py # Tests for Dummy 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 ├── main.py # Application entry point ├── 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.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.4.0 : Added `add_event_listener` and `remove_event_listener` \ No newline at end of file +* 0.4.0 : Added `add_event_listener` and `remove_event_listener` +* 0.5.0 : Added `ProxyObject` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 570c7f0..fd4a652 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "myutils" -version = "0.4.0" +version = "0.5.0" description = "Base useful classes." readme = "README.md" authors = [ diff --git a/src/myutils/ProxyObject.py b/src/myutils/ProxyObject.py new file mode 100644 index 0000000..81e533f --- /dev/null +++ b/src/myutils/ProxyObject.py @@ -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 diff --git a/tests/test_proxyobject.py b/tests/test_proxyobject.py new file mode 100644 index 0000000..a4241f0 --- /dev/null +++ b/tests/test_proxyobject.py @@ -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