From b4df7ac06330d9ea865ba5c6614c1826646bf388 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Wed, 22 Oct 2025 08:32:05 +0200 Subject: [PATCH] Initial commit --- .gitignore | 210 ++++++++++++++++++ .idea/.gitignore | 8 + .idea/MyUtils.iml | 11 + .idea/inspectionProfiles/Project_Default.xml | 16 ++ .../inspectionProfiles/profiles_settings.xml | 6 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + LICENCE | 21 ++ Makefile | 18 ++ README.md | 149 +++++++++++++ pyproject.toml | 63 ++++++ requirements.txt | 5 + src/__init__.py | 0 src/myutils/Dummy.py | 71 ++++++ src/myutils/Expando.py | 76 +++++++ src/myutils/__init__.py | 0 src/myutils/observable.py | 38 ++++ tests/__init__.py | 0 tests/test_dummy.py | 98 ++++++++ tests/test_expando.py | 75 +++++++ tests/test_observable.py | 173 +++++++++++++++ 21 files changed, 1052 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/MyUtils.iml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 LICENCE create mode 100644 Makefile create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/myutils/Dummy.py create mode 100644 src/myutils/Expando.py create mode 100644 src/myutils/__init__.py create mode 100644 src/myutils/observable.py create mode 100644 tests/__init__.py create mode 100644 tests/test_dummy.py create mode 100644 tests/test_expando.py create mode 100644 tests/test_observable.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d06a41 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..1c2fda5 --- /dev/null +++ b/.idea/.gitignore @@ -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 diff --git a/.idea/MyUtils.iml b/.idea/MyUtils.iml new file mode 100644 index 0000000..05b8e51 --- /dev/null +++ b/.idea/MyUtils.iml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..2bcd2f0 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..3f2f4f8 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..9661ac7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..9333491 --- /dev/null +++ b/LICENCE @@ -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. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d4147ca --- /dev/null +++ b/Makefile @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c46335 --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..72726ba --- /dev/null +++ b/pyproject.toml @@ -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"] + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9d9ff9d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +iniconfig==2.3.0 +packaging==25.0 +pluggy==1.6.0 +Pygments==2.19.2 +pytest==8.4.2 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/myutils/Dummy.py b/src/myutils/Dummy.py new file mode 100644 index 0000000..389a24f --- /dev/null +++ b/src/myutils/Dummy.py @@ -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 "" + + 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 \ No newline at end of file diff --git a/src/myutils/Expando.py b/src/myutils/Expando.py new file mode 100644 index 0000000..390fe79 --- /dev/null +++ b/src/myutils/Expando.py @@ -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()))) diff --git a/src/myutils/__init__.py b/src/myutils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/myutils/observable.py b/src/myutils/observable.py new file mode 100644 index 0000000..ef053dc --- /dev/null +++ b/src/myutils/observable.py @@ -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) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dummy.py b/tests/test_dummy.py new file mode 100644 index 0000000..1a775b3 --- /dev/null +++ b/tests/test_dummy.py @@ -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) == "" + assert str(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 \ No newline at end of file diff --git a/tests/test_expando.py b/tests/test_expando.py new file mode 100644 index 0000000..64e4f62 --- /dev/null +++ b/tests/test_expando.py @@ -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 diff --git a/tests/test_observable.py b/tests/test_observable.py new file mode 100644 index 0000000..b9255b6 --- /dev/null +++ b/tests/test_observable.py @@ -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) \ No newline at end of file