Initial commit

This commit is contained in:
2025-10-22 08:32:05 +02:00
commit b4df7ac063
21 changed files with 1052 additions and 0 deletions

210
.gitignore vendored Normal file
View File

@@ -0,0 +1,210 @@
__pycache__
app.egg-info
*.pyc
.mypy_cache
.coverage
htmlcov
.cache
.venv
tests/settings_from_unit_testing.json
tests/TestDBEngineRoot
tests/*.png
src/*.png
tests/*.html
tests/*.txt
test-results
.sesskey
tools.db
.mytools_db
.idea/MyManagingTools.iml
.idea/misc.xml
.idea/dataSources.xml
.idea/sqldialects.xml
.idea_bak
**/*.prof
# Created by .ignore support plugin (hsz.mobi)
### Python template
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
.hypothesis/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# IPython Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# dotenv
.env
# virtualenv
venv/
ENV/
# Spyder project settings
.spyderproject
# Rope project settings
.ropeproject
### VirtualEnv template
# Virtualenv
# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/
[Bb]in
[Ii]nclude
[Ll]ib
[Ll]ib64
[Ll]ocal
[Ss]cripts
pyvenv.cfg
.venv
pip-selfcheck.json
### JetBrains template
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
# idea folder, uncomment if you don't need it
# .idea

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

11
.idea/MyUtils.iml generated Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
</content>
<orderEntry type="jdk" jdkName="Python 3.12.3 WSL (Ubuntu-24.04): (/home/kodjo/.virtualenvs/MyUtils/bin/python)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -0,0 +1,16 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="PyInitNewSignatureInspection" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<list>
<option value="bson" />
<option value="argon2-cffi" />
<option value="argon2-cffi-bindings" />
</list>
</option>
</inspection_tool>
</profile>
</component>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/MyUtils.iml" filepath="$PROJECT_DIR$/.idea/MyUtils.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

21
LICENCE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Kodjo Sossouvi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

18
Makefile Normal file
View File

@@ -0,0 +1,18 @@
# Makefile for cleaning packaging files and directories
.PHONY: clean-package clean-build clean
# Clean distribution artifacts (dist/ and *.egg-info)
clean-package:
rm -rf dist
rm -rf *.egg-info
rm -rf src/*.egg-info
# Clean all Python build artifacts (dist, egg-info, pyc, and cache files)
clean-build: clean-package
find . -name "__pycache__" -type d -exec rm -rf {} +
find . -name "*.pyc" -exec rm -f {} +
find . -name "*.pyo" -exec rm -f {} +
# Alias to clean everything
clean: clean-build

149
README.md Normal file
View File

@@ -0,0 +1,149 @@
# MyUtils Project
## 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.
---
## Modules
### 1. **Observable**
The `Observable` module provides functionality to track changes to object attributes and notify observers when a change
occurs.
#### Key Features:
- Transform ordinary objects into observable objects using `make_observable`.
- Attach one or more callbacks to specific attributes using `bind`.
- Callbacks receive both the old and new values of the attribute.
- Supports multiple observers on the same attribute and independent observers on different instances.
#### Key Classes/Functions:
- `make_observable(object)`:
Makes an object observable by internally managing attribute listeners.
- `bind(object, attribute, callback)`:
Adds a callback function to an observable object's attribute. The callback is triggered when the attribute value
changes.
- Exception: `NotObservableError` is raised if you attempt to bind a callback to a non-observable object.
#### Example Usage:
Here is an example using the `Observable` module:
```python
class Demo:
def __init__(self):
self.number = 1
demo = Demo()
make_observable(demo)
# Bind a callback to 'number'
bind(demo, 'number', lambda old, new: print(f"Changed from {old} to {new}"))
# Updating an attribute triggers the callback
demo.number = 42 # Output: Changed from 1 to 42
```
---
### 2. **Expando**
The `Expando` module provides a dynamic wrapper for dictionaries, enabling access to dictionary values as object-style
properties.
#### Key Features:
- Access nested dictionary keys using dot notation (`.`).
- Dynamically add new properties.
- Handle `None` values seamlessly without breaking functionality.
- Gather lists of values from arrays in nested data structures.
#### Key Classes/Functions:
- **`Expando`**:
A class-based wrapper for dictionaries allowing dynamic access and property management.
#### Example Usage:
Here is an example using the `Expando` module:
```python
from myutils.Expando import Expando
data = {
"key1": 10,
"key2": {"subkey": "value"}
}
exp = Expando(data)
# Access properties dynamically
print(exp.key1) # Output: 10
print(exp.key2.subkey) # Output: value
# Dynamically add a new key
exp.new_key = "new_value"
print(exp.new_key) # Output: new_value
```
---
## Project Structure
To understand the file organization, here's the structure of the project:
```python
"""
MyUtils
├── src
│ └── myutils
│ └── __init__.py # Main package initialization
├── tests
│ ├── __init__.py # Test package initialization
│ ├── test_observable.py # Tests for Observable module
│ └── test_expando.py # Tests for Expando module
├── .gitignore # Git ignore file
├── main.py # Application entry point
├── requirements.txt # Project dependencies
└── README.md # Project description (this file)
"""
```
---
## Contributing
If you'd like to contribute:
1. Fork the repository.
2. Create a feature branch (`git checkout -b feature/your-feature`).
3. Commit your changes (`git commit -m "Add some feature"`).
4. Push to the branch (`git push origin feature/your-feature`).
5. Open a Pull Request.
---
## License
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
---
## Maintainers
For any inquiries or support, feel free to contact the maintainer:
- **Name**: [Your Name Here]
- **Email**: [your-email@example.com]
- **GitHub**: [Your GitHub Profile](https://github.com/your-profile)
---
## Acknowledgments
Special thanks to the Python and open-source community for their tools, inspiration, and support.

63
pyproject.toml Normal file
View File

@@ -0,0 +1,63 @@
[build-system]
requires = ["setuptools>=80.9", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "myutils"
version = "0.1.0"
description = "Base useful classes."
readme = "README.md"
authors = [
{ name = "Kodjo Sossouvi", email = "kodjo.sossouvi@gmail.com" },
]
maintainers = [
{ name = "Kodjo Sossouvi", email = "kodjo.sossouvi@gmail.com" }
]
license = "MIT"
requires-python = ">=3.8"
classifiers = [
"Operating System :: OS Independent",
"Framework :: FastAPI",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Auth",
]
# -------------------------------------------------------------------
# Core dependencies from your README
# Note: 'requirements.txt' is for development, this is for the package
# You can use pipdeptree to get the tree of required packages `pip install pipdeptree`
# -------------------------------------------------------------------
dependencies = [
]
[project.urls]
# Optional: Link to your internal repository or documentation
Homepage = "https://gitea.sheerka.synology.me/kodjo/MyUtils"
Documentation = "https://gitea.sheerka.synology.me/kodjo/MyUtils#readme"
Repository = "https://gitea.sheerka.synology.me/kodjo/MyUtils"
Issues = "https://gitea.sheerka.synology.me/kodjo/MyUtils/issues"
# -------------------------------------------------------------------
# Optional dependencies ("extras")
# This allows users to install only what they need, e.g.:
# pip install myauth[mongodb,email]
# -------------------------------------------------------------------
[project.optional-dependencies]
# For development and testing (from your requirements.txt)
dev = [
"pytest",
]
# -------------------------------------------------------------------
# Setuptools configuration
# This section tells the build system where to find your package code
# -------------------------------------------------------------------
[tool.setuptools]
package-dir = {"" = "src"}
packages = ["myutils"]

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
iniconfig==2.3.0
packaging==25.0
pluggy==1.6.0
Pygments==2.19.2
pytest==8.4.2

0
src/__init__.py Normal file
View File

71
src/myutils/Dummy.py Normal file
View File

@@ -0,0 +1,71 @@
class Dummy:
"""
A dummy object that accepts any method call or attribute access without raising errors.
This class is useful for mocking purposes when you want to avoid checking if an instance
exists or is not None. All method calls and attribute accesses will return a new Dummy
instance, allowing for infinite chaining.
Example:
dummy = Dummy()
dummy.call_any_function(arg) # Returns None
dummy.any_attribute.nested_call() # Returns None
dummy.property = "value" # Silently accepts assignment
"""
def __getattr__(self, name):
"""
Called when an attribute is accessed that doesn't exist.
Returns a new Dummy instance to allow chaining.
Args:
name: The name of the attribute being accessed
Returns:
A new Dummy instance
"""
return Dummy()
def __call__(self, *args, **kwargs):
"""
Called when the Dummy instance is called as a function.
Returns None as the function call does nothing.
Args:
*args: Any positional arguments
**kwargs: Any keyword arguments
Returns:
None
"""
return None
def __repr__(self):
"""
String representation of the Dummy object.
Returns:
A string indicating this is a Dummy object
"""
return "<Dummy>"
def __bool__(self):
"""
Boolean evaluation of the Dummy object.
Always returns False to match None-like behavior in conditionals.
Returns:
False
"""
return False
def __setattr__(self, name, value):
"""
Called when an attribute is set.
Silently accepts the assignment without storing anything.
Args:
name: The name of the attribute being set
value: The value being assigned
"""
pass

76
src/myutils/Expando.py Normal file
View File

@@ -0,0 +1,76 @@
class Expando:
"""
Readonly dynamic class that eases the access to attributes and sub attributes
It is initialized with a dict
You can then access the property using dot '.' (ex. obj.prop1.prop2)
"""
def __init__(self, props):
self._props = props
def __getattr__(self, item):
if item not in self._props:
raise AttributeError(item)
current = self._props[item]
return Expando(current) if isinstance(current, dict) else current
def __setitem__(self, key, value):
self._props[key] = value
def get(self, path):
"""
returns the value, from a string with represents the path
:param path:
:return:
"""
current = self._props
for attr in path.split("."):
if isinstance(current, list):
temp = []
for value in current:
if value and attr in value:
temp.append(value[attr])
current = temp
else:
if current is None or attr not in current:
return None
current = current[attr]
return current
def as_dict(self):
"""
Return the information as a dictionary
:return:
"""
return self._props.copy()
def to_dict(self, mappings: dict) -> dict:
"""
Return the information as a dictionary, with the given mappings
"""
return {prop_name: self.get(path) for path, prop_name in mappings.items() if prop_name is not None}
def __hasattr__(self, item):
return item in self._props
def __repr__(self):
if "key" in self._props:
return f"Expando(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"Expando({props_as_str})"
def __eq__(self, other):
if not isinstance(other, Expando):
return False
return self._props == other._props
def __hash__(self):
return hash(tuple(sorted(self._props.items())))

0
src/myutils/__init__.py Normal file
View File

38
src/myutils/observable.py Normal file
View File

@@ -0,0 +1,38 @@
class NotObservableError(Exception):
def __init__(self, obj):
super().__init__(f'{obj} is not observable')
def make_observable(obj):
if hasattr(obj, '_listeners'):
return obj # Already observable
# Create a dynamic subclass for this instance only
original_class = obj.__class__
class ObservableVersion(original_class):
def __setattr__(self, name, value):
if name == '_listeners':
super().__setattr__(name, value)
return
old_value = getattr(self, name, None)
super().__setattr__(name, value)
if hasattr(self, '_listeners') and name in self._listeners:
for callback in self._listeners[name]:
callback(old_value, value)
obj.__class__ = ObservableVersion
obj._listeners = {}
return obj
def bind(obj, attr_name, callback):
if not hasattr(obj, '_listeners'):
raise NotObservableError(
f"Object must be made observable with make_observable() before binding"
)
if attr_name not in obj._listeners:
obj._listeners[attr_name] = []
obj._listeners[attr_name].append(callback)

0
tests/__init__.py Normal file
View File

98
tests/test_dummy.py Normal file
View File

@@ -0,0 +1,98 @@
import pytest
from myutils.Dummy import Dummy
def test_i_can_call_any_method():
"""Test that calling any non-existent method does not raise an error."""
dummy = Dummy()
dummy.any_method()
dummy.some_random_function()
dummy.whatever_i_want()
def test_i_can_call_method_with_arguments():
"""Test that methods can be called with positional and keyword arguments."""
dummy = Dummy()
dummy.method_with_args(1, 2, 3)
dummy.method_with_kwargs(foo="bar", baz=123)
dummy.method_with_both(1, 2, key="value", another=True)
def test_i_can_access_any_attribute():
"""Test that accessing any non-existent attribute returns a Dummy instance."""
dummy = Dummy()
result = dummy.any_attribute
assert isinstance(result, Dummy)
result2 = dummy.another_property
assert isinstance(result2, Dummy)
def test_i_can_chain_attributes():
"""Test that chaining multiple attributes works correctly."""
dummy = Dummy()
result = dummy.foo.bar.baz
assert isinstance(result, Dummy)
result2 = dummy.level1.level2.level3.level4
assert isinstance(result2, Dummy)
def test_i_can_set_any_attribute():
"""Test that setting any attribute does not raise an error."""
dummy = Dummy()
dummy.property = "value"
dummy.number = 42
dummy.nested = {"key": "value"}
def test_method_call_returns_none():
"""Test that calling a method returns None (no chaining)."""
dummy = Dummy()
result = dummy.some_method()
assert result is None
result2 = dummy.another_method(1, 2, 3)
assert result2 is None
def test_dummy_evaluates_to_false():
"""Test that Dummy evaluates to False in boolean context."""
dummy = Dummy()
assert not dummy
assert bool(dummy) is False
if dummy:
pytest.fail("Dummy should evaluate to False")
def test_dummy_repr():
"""Test the string representation of Dummy object."""
dummy = Dummy()
assert repr(dummy) == "<Dummy>"
assert str(dummy) == "<Dummy>"
def test_attribute_is_not_stored():
"""Test that assigned attributes are not actually stored."""
dummy = Dummy()
dummy.stored_value = 123
# Accessing the attribute should return a new Dummy, not the stored value
result = dummy.stored_value
assert isinstance(result, Dummy)
assert result is not 123
def test_i_can_chain_and_call():
"""Test that chaining attributes and then calling works correctly."""
dummy = Dummy()
result = dummy.foo.bar()
assert result is None
result2 = dummy.level1.level2.level3()
assert result2 is None
result3 = dummy.attr1.attr2.method(1, 2, key="value")
assert result3 is None

75
tests/test_expando.py Normal file
View File

@@ -0,0 +1,75 @@
import pytest
from myutils.Expando import Expando
def test_i_can_get_properties():
props = {"a": 10,
"b": {
"c": "value",
"d": 20
}}
dynamic = Expando(props)
assert dynamic.a == 10
assert dynamic.b.c == "value"
with pytest.raises(AttributeError):
assert dynamic.unknown == "some_value"
def test_i_can_get():
props = {"a": 10,
"b": {
"c": "value",
"d": 20
}}
dynamic = Expando(props)
assert dynamic.get("a") == 10
assert dynamic.get("b.c") == "value"
assert dynamic.get("unknown") is None
def test_i_can_get_from_list():
props = {"a": [{"c": "value1", "d": 1}, {"c": "value2", "d": 2}]}
dynamic = Expando(props)
assert dynamic.get("a.c") == ["value1", "value2"]
def test_none_is_returned_when_get_from_list_and_property_does_not_exist():
props = {"a": [{"c": "value1", "d": 1},
{"a": "value2", "d": 2} # 'c' does not exist in the second row
]}
dynamic = Expando(props)
assert dynamic.get("a.c") == ["value1"]
def test_i_can_manage_none_values():
props = {"a": 10,
"b": None}
dynamic = Expando(props)
assert dynamic.get("b.c") is None
def test_i_can_manage_none_values_in_list():
props = {"a": [{"b": {"c": "value"}},
{"b": None}
]}
dynamic = Expando(props)
assert dynamic.get("a.b.c") == ["value"]
def test_i_can_add_new_properties():
props = {"a": 10,
"b": 20}
dynamic = Expando(props)
dynamic["c"] = 30
assert dynamic.a == 10
assert dynamic.b == 20
assert dynamic.c == 30

173
tests/test_observable.py Normal file
View File

@@ -0,0 +1,173 @@
import pytest
from myutils.observable import NotObservableError, make_observable, bind
# Test fixtures
class Demo:
def __init__(self):
self.number = 1
def get_double(self):
return self.number * 2
# Tests
def test_i_can_make_an_object_observable():
demo = Demo()
result = make_observable(demo)
assert hasattr(demo, '_listeners')
assert isinstance(demo._listeners, dict)
assert result is demo
def test_i_can_bind_a_callback_to_an_attribute():
demo = Demo()
make_observable(demo)
callback = lambda old, new: None
bind(demo, 'number', callback)
assert 'number' in demo._listeners
assert callback in demo._listeners['number']
def test_i_can_receive_notification_when_attribute_changes():
demo = Demo()
make_observable(demo)
called = []
bind(demo, 'number', lambda old, new: called.append(True))
demo.number = 5
assert len(called) == 1
def test_i_can_receive_old_and_new_values_in_callback():
demo = Demo()
make_observable(demo)
values = []
bind(demo, 'number', lambda old, new: values.append((old, new)))
demo.number = 5
demo.number = 10
assert values == [(1, 5), (5, 10)]
def test_i_can_bind_multiple_callbacks_to_same_attribute():
demo = Demo()
make_observable(demo)
calls1 = []
calls2 = []
bind(demo, 'number', lambda old, new: calls1.append(new))
bind(demo, 'number', lambda old, new: calls2.append(new))
demo.number = 5
assert calls1 == [5]
assert calls2 == [5]
def test_i_can_bind_callbacks_to_different_attributes():
demo = Demo()
make_observable(demo)
number_calls = []
name_calls = []
bind(demo, 'number', lambda old, new: number_calls.append(new))
bind(demo, 'name', lambda old, new: name_calls.append(new))
demo.number = 5
demo.name = "test"
assert number_calls == [5]
assert name_calls == ["test"]
def test_i_can_modify_non_observed_attributes_without_notification():
demo = Demo()
make_observable(demo)
called = []
bind(demo, 'number', lambda old, new: called.append(True))
demo.other_attr = "value"
assert len(called) == 0
assert demo.other_attr == "value"
def test_i_can_have_multiple_instances_with_independent_observers():
demo1 = Demo()
demo2 = Demo()
make_observable(demo1)
calls1 = []
bind(demo1, 'number', lambda old, new: calls1.append(new))
demo1.number = 5
demo2.number = 10
# Only demo1 should trigger callback
assert calls1 == [5]
assert demo1.number == 5
assert demo2.number == 10
def test_i_can_call_make_observable_multiple_times_safely():
demo = Demo()
result1 = make_observable(demo)
result2 = make_observable(demo)
assert result1 is result2
assert hasattr(demo, '_listeners')
# Should still work normally
called = []
bind(demo, 'number', lambda old, new: called.append(new))
demo.number = 5
assert len(called) == 1
def test_i_cannot_bind_before_making_observable():
demo = Demo()
with pytest.raises(NotObservableError) as exc_info:
bind(demo, 'number', lambda old, new: None)
assert "must be made observable" in str(exc_info.value).lower()
def test_i_can_set_attribute_that_does_not_exist_yet():
demo = Demo()
make_observable(demo)
values = []
bind(demo, 'new_attr', lambda old, new: values.append((old, new)))
demo.new_attr = "first_value"
assert values == [(None, "first_value")]
assert demo.new_attr == "first_value"
def test_i_can_preserve_original_class_behavior():
demo = Demo()
make_observable(demo)
# Test that original methods still work
assert demo.get_double() == 2
demo.number = 5
assert demo.get_double() == 10
# Test that isinstance still works
assert isinstance(demo, Demo)