Initial commit
This commit is contained in:
210
.gitignore
vendored
Normal file
210
.gitignore
vendored
Normal 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
8
.idea/.gitignore
generated
vendored
Normal 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
11
.idea/MyUtils.iml
generated
Normal 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>
|
||||
16
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
16
.idea/inspectionProfiles/Project_Default.xml
generated
Normal 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>
|
||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
21
LICENCE
Normal 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
18
Makefile
Normal 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
149
README.md
Normal 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
63
pyproject.toml
Normal 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
5
requirements.txt
Normal 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
0
src/__init__.py
Normal file
71
src/myutils/Dummy.py
Normal file
71
src/myutils/Dummy.py
Normal 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
76
src/myutils/Expando.py
Normal 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
0
src/myutils/__init__.py
Normal file
38
src/myutils/observable.py
Normal file
38
src/myutils/observable.py
Normal 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
0
tests/__init__.py
Normal file
98
tests/test_dummy.py
Normal file
98
tests/test_dummy.py
Normal 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
75
tests/test_expando.py
Normal 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
173
tests/test_observable.py
Normal 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)
|
||||
Reference in New Issue
Block a user