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