diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..aa26b41 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,39 @@ +name: Python CI + +on: + pull_request: + paths: + - '**.py' + +jobs: + lint-and-test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 isort + + - name: Run flake8 + run: | + flake8 . + + - name: Check import formatting with isort + run: | + isort . --check-only + + - name: Run unittest + run: | + python -m unittest discover + + - name: Run doctest + run: | + python -m doctest -v *.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..bebfb48 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,43 @@ +name: Build and Publish + +on: + push: + tags: + - v* # This will trigger the workflow only for tags with a v prefix + +jobs: + build-and-publish: + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + + - name: Install toml + run: pip install toml + + - name: Get version from pyproject.toml + id: get_version + run: | + VERSION=$(python -c "import toml; print(toml.load('pyproject.toml')['tool']['poetry']['version'])") + echo "VERSION=$VERSION" >> $GITHUB_ENV + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build package + run: | + python -m build + + - name: Publish package to PyPI + run: twine upload dist/* + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a28b16c --- /dev/null +++ b/.gitignore @@ -0,0 +1,269 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,linux,macos,windows +# Edit at https://www.toptal.com/developers/gitignore?templates=python,visualstudiocode,linux,macos,windows + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# 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/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,linux,macos,windows \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5996e72 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Evgenii Uvarov + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8976a44 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Project Title + +Regis is a compact yet powerful Python library designed to manage Singleton Registries with added permission checks. + +Aimed at offering a blend of simplicity and robustness, Regis allows seamless registration and deregistration of classes while ensuring thread-safety, making it an ideal choice for applications where concurrent access to resources is prevalent. + +[![GitHub license](https://img.shields.io/github/license/iamthen0ise/regis)](https://github.com/iamthen0ise/regis/blob/main/LICENSE) +[![GitHub issues](https://img.shields.io/github/issues/iamthen0ise/regis)](https://github.com/iamthen0ise/regis/issues) + +## Table of Contents + +- [Project Title](#project-title) + - [Table of Contents](#table-of-contents) + - [Installation](#installation) + - [Usage](#usage) + +## Installation +```sh + +pip install regis-py + +``` + + +## Usage + + +```python +from regis import Registry, RegistryMixin + +# Define a class that will use the registry +class ExampleClass(RegistryMixin): + def __init__(self, name): + self.name = name + self.register() + +# Create an instance of the class +example_instance = ExampleClass('ExampleInstance') + +# Set an item in the registry +key = 'example_key' +value = 'example_value' +example_instance.set_item(key, value) + +# Retrieve the item from the registry +retrieved_value = example_instance.get_item(key) +print(f'Retrieved value from registry: {retrieved_value}') # Output: Retrieved value from registry: example_value + +# Unregister the class instance from the registry +example_instance.unregister() + +# Attempting to retrieve the item again will raise a PermissionError since the class instance is unregistered +try: + example_instance.get_item(key) +except PermissionError: + print(f'{example_instance.name} does not have permission to access the registry.') +``` diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..14462b8 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,72 @@ +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. + +[[package]] +name = "flake8" +version = "3.9.2" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +files = [ + {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, + {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, +] + +[package.dependencies] +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.7.0,<2.8.0" +pyflakes = ">=2.3.0,<2.4.0" + +[[package]] +name = "isort" +version = "5.12.0" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"}, + {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"}, +] + +[package.extras] +colors = ["colorama (>=0.4.3)"] +pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"] +plugins = ["setuptools"] +requirements-deprecated-finder = ["pip-api", "pipreqs"] + +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = "*" +files = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] + +[[package]] +name = "pycodestyle" +version = "2.7.0" +description = "Python style guide checker" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, + {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, +] + +[[package]] +name = "pyflakes" +version = "2.3.1" +description = "passive checker of Python programs" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, + {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.8" +content-hash = "d5bb25fbbcecea4965969950da66d4d1066cc6348ab8d4a15cf1b17b3639bca5" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ce77960 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] +name = "regis-py" +version = "0.0.3" +description = "Global registry for your objects." +authors = ["Your Name "] + +[tool.poetry.dependencies] +python = "^3.8" + +[tool.poetry.dev-dependencies] +flake8 = "^3.9.0" +isort = "^5.8.0" + +[[tool.poetry.packages]] +include = "regis" + +[tool.flake8] +exclude = [ + ".git", + "__pycache__", + "build", + "dist" +] +max-complexity = 10 +max-line-length = 79 + +[tool.isort] +profile = "black" +line_length = 79 diff --git a/regis/__init__.py b/regis/__init__.py new file mode 100644 index 0000000..5357649 --- /dev/null +++ b/regis/__init__.py @@ -0,0 +1,308 @@ +""" +A module for managing a Singleton Registry with permission checking. +Provides thread-safety and allows registration and deregistration +of classes for access to the registry. +""" + +import logging +import threading +import weakref +from collections.abc import Hashable +from functools import wraps +from typing import Callable, Type, Union + +logger = logging.getLogger(__name__) +logger.setLevel(logging.WARNING) + +ValueType = Union[Type, Callable, object] + + +class RegistryError(Exception): + """Base class for exceptions in this library.""" + + +class RegistrationError(RegistryError): + """Raised when registration of a class fails.""" + + +class PermissionError(RegistryError): + """Raised when a class does not have permission to access the registry.""" + + +def _threaded_safe(method): + """ + A decorator to make methods thread-safe. + + :param method: The method to be decorated. + :type method: Callable + :return: Wrapped method. + :rtype: Callable + """ + + @wraps(method) + def wrapper(self, *args, **kwargs): + with self._lock: + return method(self, *args, **kwargs) + return wrapper + + +def _check_permissions(method): + """ + A decorator for checking if the calling class has permission + to access the registry. + + :param method: The method to be decorated. + :type method: Callable + :return: Wrapped method. + :rtype: Callable + """ + @wraps(method) + def wrapper(self, caller, *args, **kwargs): + if caller not in self._registered_classes: + raise PermissionError( + f"Class {caller} has no permission to access the registry.", + ) + return method(self, caller, *args, **kwargs) + return wrapper + + +class SingletonMeta(type): + """ + A metaclass for creating Singleton classes. + + Usage: + + >>> class MyClass(metaclass=SingletonMeta): + ... pass + >>> a = MyClass() + >>> b = MyClass() + >>> a is b + True + """ + _instances = weakref.WeakKeyDictionary() + + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + instance = super().__call__(*args, **kwargs) + cls._instances[cls] = instance + return cls._instances[cls] + + +class Registry(metaclass=SingletonMeta): + """ + A thread-safe Singleton registry class for managing access + to specific resources with permission checking. + + >>> registry = Registry() + >>> class ExampleClass: + ... pass + >>> registry.register_class(ExampleClass) + >>> registry.unregister_class(ExampleClass) + """ + + def __init__(self): + self._items = {} + self._registered_classes = weakref.WeakSet() + self._lock = threading.Lock() + + @_threaded_safe + def register_class(self, caller: Type): + """ + Register a class with permission to access the registry. + + :param caller: The class to be registered. + :type caller: Type[Any] + + >>> registry = Registry() + >>> class ExampleClass: + ... pass + >>> registry.register_class(ExampleClass) + """ + try: + self._registered_classes.add(caller) + except Exception as e: + error_msg = f"Failed to register the class due to error: {e}" + logger.exception(e) + raise RegistrationError(error_msg) + + @_threaded_safe + def unregister_class(self, caller: Type): + """ + Unregister a class, revoking its permission to access the registry. + + :param caller: The class to be unregistered. + :type caller: Type[Any] + + >>> registry = Registry() + >>> class ExampleClass: + ... pass + >>> registry.register_class(ExampleClass) + >>> registry.unregister_class(ExampleClass) + """ + try: + self._registered_classes.discard(caller) + except Exception as e: + logger.exception(e) + error_msg = f"Failed to unregister the class due to error:{e}" + raise RegistrationError(error_msg) + + @_threaded_safe + @_check_permissions + def set_item(self, caller: Type, key: Hashable, item: ValueType): + """ + Set an item in the registry with a given key. + + :param caller: The calling class. + It should have permission to access the registry. + :type caller: Type + :param key: The key used to store the item in the registry. + :type key: Hashable + :param item: The item to be stored in the registry. + :type item: Any + + >>> registry = Registry() + >>> class ExampleClass: + ... pass + >>> registry.register_class(ExampleClass) + >>> registry.set_item(ExampleClass, 'test_key', 'test_item') + >>> registry.get_item(ExampleClass, 'test_key') + 'test_item' + """ + if not isinstance(key, Hashable): + raise TypeError( + f"Key must be hashable, but got {type(key).__name__}", + ) + self._items[key] = item + + @_threaded_safe + @_check_permissions + def get_item(self, caller: Type, key: Hashable) -> ValueType: + """ + Retrieve an item from the registry using a given key. + + :param caller: The calling class. + It should have permission to access the registry. + :type caller: Type[Any] + :param key: The key used to retrieve the item from the registry. + :type key: Hashable + :return: The item retrieved from the registry. + :rtype: Any + + >>> registry = Registry() + >>> class ExampleClass: + ... pass + >>> registry.register_class(ExampleClass) + >>> registry.set_item(ExampleClass, 'test_key', 'test_item') + >>> registry.get_item(ExampleClass, 'test_key') + 'test_item' + """ + if not isinstance(key, Hashable): + raise TypeError( + f"Key must be hashable, but got {type(key).__name__}", + ) + return self._items[key] + + +class RegistryMixin: + """ + A mixin class for interacting with the registry. + + >>> class ExampleClass(RegistryMixin): + ... pass + >>> example_instance = ExampleClass() + >>> example_instance.register() + >>> example_instance.set_item('key', 'value') + >>> example_instance.get_item('key') + 'value' + """ + + def register(self): + """ + Register the class instance with the registry for access. + + >>> class ExampleClass(RegistryMixin): + ... pass + >>> example_instance = ExampleClass() + >>> example_instance.register() + """ + self.registry = Registry() + self.registry.register_class(self.__class__) + + def set_item(self, key, item): + """ + Set an item in the registry with a given key. + + :param key: The key used to store the item in the registry. + :type key: Hashable + :param item: The item to be stored in the registry. + :type item: Any + + >>> class ExampleClass(RegistryMixin): + ... pass + >>> example_instance = ExampleClass() + >>> example_instance.register() + >>> example_instance.set_item('test_key', 'test_item') + >>> example_instance.get_item('test_key') + 'test_item' + """ + if not hasattr(self, 'registry'): + raise PermissionError( + "Instance is not registered with the registry.", + ) + self.registry.set_item(self.__class__, key, item) + + def get_item(self, key): + """ + Retrieve an item from the registry using a given key. + + :param key: The key used to retrieve the item from the registry. + :type key: Hashable + :return: The item retrieved from the registry. + :rtype: Any + + >>> class ExampleClass(RegistryMixin): + ... pass + >>> example_instance = ExampleClass() + >>> example_instance.register() + >>> example_instance.set_item('test_key', 'test_item') + >>> example_instance.get_item('test_key') + 'test_item' + """ + if not hasattr(self, 'registry'): + raise PermissionError( + "Instance is not registered with the registry.", + ) + return self.registry.get_item(self.__class__, key) + + def unregister(self): + """ + Unregister the class instance from the registry, revoking its access. + + >>> class ExampleClass(RegistryMixin): + ... pass + >>> example_instance = ExampleClass() + >>> example_instance.register() + >>> example_instance.unregister() + """ + self.registry.unregister_class(self.__class__) + + def __del__(self): + """ + Unregister the class instance from the registry upon deletion, + if it is registered. + + Method reserved for GC. For manual deletion please call `.unregister`. + Note: Relying on `__del__` for cleanup is not guaranteed to be called + in every Python implementation and, therefore, + using `.unregister` is recommended. + >>> registry = Registry() + >>> class ExampleClass(RegistryMixin): + ... pass + >>> example_instance = ExampleClass() + >>> example_instance.register() + >>> del example_instance # This should unregister the instance + """ + if hasattr(self, 'registry') and ( + self.__class__ in self.registry._registered_classes + ): + self.unregister() diff --git a/tests.py b/tests.py new file mode 100644 index 0000000..34525bd --- /dev/null +++ b/tests.py @@ -0,0 +1,56 @@ +import unittest + +from regis import PermissionError, Registry, RegistryMixin + + +class TestRegistry(unittest.TestCase): + + def test_register_class(self): + registry = Registry() + + class ExampleClass(RegistryMixin): + pass + example_instance = ExampleClass() + example_instance.register() + self.assertIn(ExampleClass, registry._registered_classes) + + def test_unregister_class(self): + registry = Registry() + + class ExampleClass(RegistryMixin): + pass + example_instance = ExampleClass() + example_instance.register() + example_instance.unregister() + self.assertNotIn(ExampleClass, registry._registered_classes) + + def test_set_item(self): + registry = Registry() + + class ExampleClass(RegistryMixin): + pass + example_instance = ExampleClass() + example_instance.register() + example_instance.set_item('key', 'value') + self.assertEqual(registry._items['key'], 'value') + + def test_get_item(self): + + class ExampleClass(RegistryMixin): + pass + example_instance = ExampleClass() + example_instance.register() + example_instance.set_item('key', 'value') + self.assertEqual(example_instance.get_item('key'), 'value') + + def test_permission_error(self): + + class ExampleClass(RegistryMixin): + pass + example_instance = ExampleClass() + with self.assertRaises(PermissionError): + example_instance.set_item('key', 'value') + + +if __name__ == '__main__': + unittest.main()