Skip to content

Commit

Permalink
feat(#5): remove django from project
Browse files Browse the repository at this point in the history
  • Loading branch information
sepgh committed Oct 31, 2023
1 parent 1c3e30e commit c042bc4
Show file tree
Hide file tree
Showing 15 changed files with 114 additions and 124 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/project-code-standards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ jobs:
run: |
python -m pip install --upgrade pip
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Run pre-commit hooks
- name: Install pre-commit pipeline
run: |
pre-commit install
- name: Run Tests
- name: Run pre-commit pipeline
run: |
pre-commit run --all-files
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ repos:
]
- repo: local
hooks:
- id: django-test
name: django-test
entry: python runtests.py --noinput
- id: unittest
name: unittest
entry: python -m unittest
always_run: true
pass_filenames: false
language: system
54 changes: 14 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,28 @@
![Static Badge](https://img.shields.io/badge/Status-Under%20Development-yellow?style=flat-square&cacheSeconds=120)


A _Dependency Injection and IoC container_ library for Django Framework.
A _Dependency Injection and IoC container_ library for Python.

_This library was initially written only for Django framework, only to realize it does nothing Django specific!_
_For Django, check [Django Boot Core Starter](https://github.com/django-boot/django-boot-core-starter)._

## Versions and Requirements

Written for Django 4.2 using python 3.9.

_Other python versions (3.6+) should also be supported. It may work with other Django versions as well (but requires changes being applied to `setup.cfg`)._
Written and tested using python 3.9, should work on 3.6+.


## How it works

Rhazes works by _scanning_ for bean (AKA services) classes available in the project. Ideally to make the scan faster, you shall define the packages you want to scan in a configuration inside settings.py (explained in usage section).

Scanning for bean classes works by creating graphs of bean classes and their dependencies and choose random nodes to do DFS traversal in order to find edges and possible dependency cycles. Then from edges back to to top, there will be `builder` functions created and registered in `ApplicationContext` (which is a static class or a singleton) for a specific bean class or interface. A builder is a function that accepts `ApplicationContext` and returns an instance of a bean class (possibly singleton instance). The builder can use `ApplicationContext` to access other dependent beans, and this is only possible since we cover dependency graph edges first and then go up in the graph.
Scanning for bean classes works by creating graphs of bean classes and their dependencies and choose random nodes to do DFS traversal in order to find edges and possible dependency cycles. Then from edges back to the top, there will be `builder` functions created and registered in `ApplicationContext` (which is a static class or a singleton) for a specific bean class or interface. A builder is a function that accepts `ApplicationContext` and returns an instance of a bean class (possibly singleton instance). The builder can use `ApplicationContext` to access other dependent beans, and this is only possible since we cover dependency graph edges first and then go up in the graph.

Eventually, all bean classes will have a builder registered in `ApplicationContext`. You can directly ask `ApplicationContext` for a bean instance of specific type, or you can use `@inject` decorator so they are automatically injected into your classes/functions.
Eventually, all bean classes will have a builder registered in `ApplicationContext`. You can directly ask `ApplicationContext` for a bean instance of specific type, or you can use `@inject` decorator, so they are automatically injected into your classes/functions.


## Usage and Example

Let's assume we have bean classes like below in a package named `app1.services`:
Let's assume we have `bean` classes like below in a package named `app1.services`:

```python
from abc import ABC, abstractmethod
Expand Down Expand Up @@ -68,13 +68,6 @@ class ProductManager:

```

To make Rhazes scan these packages, we should define `RHAZES_PACKAGES` in our `settings.py`:

```python
RHAZES_PACKAGES = [
"app1.services"
]
```

Now assuming you have the above classes defined user some packages that will be scanned by Rhazes, you can access them like this:

Expand All @@ -84,8 +77,10 @@ from somepackage import UserStorage, DatabaseUserStorage, CacheUserStorage, Pro


application_context = ApplicationContext
# scan packages at settings.INSTALLED_APPS or settings.RHAZES_PACKAGES
application_context.initialize()
# scan packages and initialize beans
application_context.initialize([
"app1.services"
])

# Get ProductManager bean using its class
product_manager: ProductManager = application_context.get_bean(ProductManager)
Expand Down Expand Up @@ -225,33 +220,12 @@ def function(bean1: SomeBean1, bean2: SomeBean2, random_input: str):
function(bean2=SomeBean2(), random_input="something") # `bean1` will be injected automatically
```

### When to initialize `ApplicationContext`

### Inject into Django views

At this stage only injection into class views are tested. Example:

```python
@inject()
class NameGeneratorView(APIView):
# You could optionally use @inject() here or at .setup()
def __init__(self, string_generator: StringGeneratorService, **kwargs):
self.string_generator = string_generator
super(NameGeneratorView, self).__init__(**kwargs)

def get(self, request, *args, **kwargs):
qs: dict = request.GET.dict()
return Response(data={
"name": self.string_generator.generate(
int(qs.get("length", 10))
)
})
```

This example is taken [from here](https://github.com/django-boot/Rhazes-Test/blob/main/app1/views.py).
It really depends on what sort of application you are writing.

### When to initialize `ApplicationContext`

Application Context can be initialized either in a `.ready()` method of an app in your Django project, or in main `urls.py`.
For example in Django, Application Context can be initialized either in a `.ready()` method of an app in your Django project, or in main `urls.py`.

### Dependency Cycles

Expand Down
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
Django==4.2
pre-commit
37 changes: 12 additions & 25 deletions rhazes/context.py
Original file line number Diff line number Diff line change
@@ -1,50 +1,37 @@
from typing import Optional, List

from django.conf import settings
from django.utils.functional import SimpleLazyObject

from rhazes.dependency import DependencyResolver
from rhazes.protocol import BeanProtocol
from rhazes.scanner import ModuleScanner, class_scanner
from rhazes.utils import LazyObject


class ApplicationContext:
_initialized = False
_builder_registry = {}
_additional_roots_to_scan = []

@classmethod
def get_roots_to_scan(cls):
roots_to_scan = cls._roots_to_scan()
return cls._additional_roots_to_scan + roots_to_scan

@classmethod
def _initialize_beans(cls):
def _initialize_beans(cls, packages_to_scan: List[str]):
beans = set()
modules = ModuleScanner(cls.get_roots_to_scan()).scan()
modules = ModuleScanner(packages_to_scan).scan()
for module in modules:
scanned_classes = class_scanner(module)
for scanned_class in scanned_classes:
if issubclass(scanned_class, (BeanProtocol,)):
beans.add(scanned_class)
for bean in class_scanner(
module, lambda clz: issubclass(clz, (BeanProtocol,))
):
beans.add(bean)

for clazz, obj in DependencyResolver(beans).resolve().items():
cls.register_bean(clazz, obj)

@classmethod
def _roots_to_scan(cls):
if hasattr(settings, "RHAZES_PACKAGES"):
return settings.RHAZES_PACKAGES
return settings.INSTALLED_APPS

@classmethod
def initialize(cls, additional_roots_to_scan: List[str] = None):
if additional_roots_to_scan is not None:
cls._additional_roots_to_scan = additional_roots_to_scan
def initialize(cls, packages_to_scan: List[str]):
if packages_to_scan is not None:
cls.packages_to_scan = packages_to_scan

if cls._initialized:
return
cls._initialize_beans()
cls._initialize_beans(packages_to_scan)
cls._initialized = True

@classmethod
Expand All @@ -61,4 +48,4 @@ def get_bean(cls, of: type) -> Optional:

@classmethod
def get_lazy_bean(cls, of: type) -> Optional:
return SimpleLazyObject(lambda: cls.get_bean(of))
return LazyObject(lambda: cls.get_bean(of))
4 changes: 2 additions & 2 deletions rhazes/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
from typing import List, Set


def class_scanner(module: str):
def class_scanner(module: str, selector=lambda x: True):
result = []
for _, cls in inspect.getmembers(importlib.import_module(module), inspect.isclass):
if cls.__module__ == module:
if cls.__module__ == module and selector(cls):
result.append(cls)
return result

Expand Down
64 changes: 64 additions & 0 deletions rhazes/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import operator


class LazyObject:

_wrapped = None
_is_init = False

def __init__(self, factory):
# Assign using __dict__ to avoid the setattr method.
self.__dict__["_factory"] = factory

def _setup(self):
self._wrapped = self._factory()
self._is_init = True

def new_method_proxy(func):
"""
Util function to help us route functions
to the nested object.
"""

def inner(self, *args):
if not self._is_init:
self._setup()
return func(self._wrapped, *args)

return inner

def __setattr__(self, name, value):
# These are special names that are on the LazyObject.
# every other attribute should be on the wrapped object.
if name in {"_is_init", "_wrapped"}:
self.__dict__[name] = value
else:
if not self._is_init:
self._setup()
setattr(self._wrapped, name, value)

def __delattr__(self, name):
if name == "_wrapped":
raise TypeError("can't delete _wrapped.")
if not self._is_init:
self._setup()
delattr(self._wrapped, name)

__getattr__ = new_method_proxy(getattr)
__bytes__ = new_method_proxy(bytes)
__str__ = new_method_proxy(str)
__bool__ = new_method_proxy(bool)
__dir__ = new_method_proxy(dir)
__hash__ = new_method_proxy(hash)
__class__ = property(new_method_proxy(operator.attrgetter("__class__")))
__eq__ = new_method_proxy(operator.eq)
__lt__ = new_method_proxy(operator.lt)
__gt__ = new_method_proxy(operator.gt)
__ne__ = new_method_proxy(operator.ne)
__hash__ = new_method_proxy(hash)
__getitem__ = new_method_proxy(operator.getitem)
__setitem__ = new_method_proxy(operator.setitem)
__delitem__ = new_method_proxy(operator.delitem)
__iter__ = new_method_proxy(iter)
__len__ = new_method_proxy(len)
__contains__ = new_method_proxy(operator.contains)
Empty file removed rhazes/utils/__init__.py
Empty file.
14 changes: 0 additions & 14 deletions runtests.py

This file was deleted.

6 changes: 1 addition & 5 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ author = Django Boot
author_email = djangoboot@proton.me
classifiers =
Environment :: Web Environment
Framework :: Django
Framework :: Django :: 3.2
Intended Audience :: Developers
Operating System :: OS Independent
Programming Language :: Python
Expand All @@ -25,9 +23,7 @@ classifiers =
[options]
include_package_data = true
packages = find:
python_requires = >=3.9
install_requires =
Django >= 4.2
python_requires = >=3.6


[tool.black]
Expand Down
20 changes: 0 additions & 20 deletions tests/settings.py

This file was deleted.

17 changes: 10 additions & 7 deletions tests/test_application_context.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from django.test import TestCase, override_settings
from django.utils.functional import SimpleLazyObject
from unittest import TestCase

from rhazes.context import ApplicationContext
from rhazes.test.context import TemporaryContext, TemporaryContextManager
from rhazes.utils import LazyObject

from tests.data.di.context.di_context import (
SomeABC,
DepAI1,
Expand All @@ -15,11 +16,12 @@
from tests.data.di.factory.di_factory import SomeInterface, TestStringGeneratorBean


@override_settings(RHAZES_PACKAGES=["tests.data.di.context"])
class ApplicationContextTestCase(TestCase):
def setUp(self) -> None:
self.application_context = ApplicationContext
self.application_context.initialize(["tests.data.di.factory"])
self.application_context.initialize(
["tests.data.di.context", "tests.data.di.factory"]
)

def test_bean_context(self):
"""
Expand All @@ -41,7 +43,7 @@ def test_singleton_beans(self):
def test_lazy_dependencies(self):
dep_e: DepE = self.application_context.get_bean(DepE)
dep_d: DepD = self.application_context.get_bean(DepD)
self.assertTrue(isinstance(dep_e.dep_d, SimpleLazyObject))
self.assertTrue(isinstance(dep_e.dep_d, LazyObject))
self.assertTrue(isinstance(dep_e, DepE))
self.assertEqual(dep_e.dep_d.name(), dep_d.name())

Expand All @@ -62,11 +64,12 @@ def test_factory_context(self):
self.assertEqual(si.name(), test_string_generator.get_string())


@override_settings(RHAZES_PACKAGES=["tests.data.di.context", "tests.data.di.factory"])
class TemporaryContextTestCase(TestCase):
def setUp(self) -> None:
self.application_context = ApplicationContext
self.application_context.initialize()
self.application_context.initialize(
["tests.data.di.context", "tests.data.di.factory"]
)

def test_temporary_context(self):
self.assertTrue(isinstance(self.application_context.get_bean(SomeABC), DepAI1))
Expand Down
2 changes: 1 addition & 1 deletion tests/test_dependency.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from django.test import TestCase
from unittest import TestCase

from rhazes.dependency import DependencyResolver
from rhazes.exceptions import DependencyCycleException, MissingDependencyException
Expand Down
Loading

0 comments on commit c042bc4

Please sign in to comment.