Skip to content

Commit

Permalink
Add ignore_manual_init option (#53)
Browse files Browse the repository at this point in the history
* Add tests

* Add `ignore_manual_init` option

* Bump version
  • Loading branch information
yakimka authored Jun 18, 2024
1 parent 347ec84 commit bfc0bd4
Show file tree
Hide file tree
Showing 7 changed files with 106 additions and 11 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ We follow [Semantic Versions](https://semver.org/).

-

## Version 0.16.0

- Added `ignore_manual_init` option to `dependency` decorator

## Version 0.15.0

- Added ability to pass custom `scope_class` to `init_dependencies` and `shutdown_dependencies`
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ and offers features like lifecycle management.
- [Scopes](#scopes)
- [NullScope](#nullscope)
- [SingletonScope](#singletonscope)
- [ContextVarScope](#contextvarscope)
- [Defining custom scopes](#defining-custom-scopes)
- [Resolving async dependencies in sync functions](#resolving-async-dependencies-in-sync-functions)
- [Overriding dependencies](#overriding-dependencies)
Expand Down Expand Up @@ -503,7 +504,7 @@ Should be placed first in the decorator chain (on bottom).
- **Parameters**:
- `fn`: The function into which dependencies will be injected.

### `dependency(*, scope_class)`
### `dependency(*, scope_class, ignore_manual_init)`

Decorator to declare a dependency with a specific scope.

Expand All @@ -512,6 +513,9 @@ Should be placed first in the decorator chain (on top).
- **Parameters**:
- `scope_class`: A class that defines the scope of the dependency.
Available scopes are `NullScope` (default), `SingletonScope`, and `ContextVarScope`.
- `ignore_manual_init`: A flag to ignore manual initialization of the dependency.
If set to `True`, the dependency will not be initialized with `init_dependencies`.
Default is `False`.

### `Scope` class

Expand Down
26 changes: 20 additions & 6 deletions picodi/_picodi.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

try:
import fastapi.params
except ImportError:
except ImportError: # pragma: no cover
fastapi = None # type: ignore[assignment] # pragma: no cover


Expand Down Expand Up @@ -63,6 +63,7 @@ def add(
dependency: DependencyCallable,
scope_class: type[ScopeType] = NullScope,
override_scope: bool = False,
ignore_manual_init: bool = False,
) -> None:
"""
Add a dependency to the registry.
Expand All @@ -79,6 +80,7 @@ def add(
self._storage.deps[dependency] = Provider.from_dependency(
dependency=dependency,
scope_class=scope_class,
ignore_manual_init=ignore_manual_init,
)

def get(self, dependency: DependencyCallable) -> Provider:
Expand Down Expand Up @@ -296,7 +298,9 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
return wrapper # type: ignore[return-value]


def dependency(*, scope_class: type[ScopeType] = NullScope) -> Callable[[TC], TC]:
def dependency(
*, scope_class: type[ScopeType] = NullScope, ignore_manual_init: bool = False
) -> Callable[[TC], TC]:
"""
Decorator to declare a dependency. You don't need to use it with default arguments,
use it only if you want to change the scope of the dependency.
Expand All @@ -307,7 +311,12 @@ def dependency(*, scope_class: type[ScopeType] = NullScope) -> Callable[[TC], TC
_scopes[scope_class] = scope_class()

def decorator(fn: TC) -> TC:
_internal_registry.add(fn, scope_class=scope_class, override_scope=True)
_internal_registry.add(
fn,
scope_class=scope_class,
override_scope=True,
ignore_manual_init=ignore_manual_init,
)
return fn

return decorator
Expand All @@ -322,7 +331,7 @@ def init_dependencies(
"""
async_deps = []
filtered_providers = _internal_registry.filter(
lambda p: issubclass(p.scope_class, scope_class)
lambda p: not p.ignore_manual_init and issubclass(p.scope_class, scope_class)
)
for provider in filtered_providers:
resolver = LazyResolver(provider)
Expand Down Expand Up @@ -365,10 +374,14 @@ class Provider:
dependency: DependencyCallable
is_async: bool
scope_class: type[ScopeType]
ignore_manual_init: bool

@classmethod
def from_dependency(
cls, dependency: DependencyCallable, scope_class: type[ScopeType]
cls,
dependency: DependencyCallable,
scope_class: type[ScopeType],
ignore_manual_init: bool,
) -> Provider:
is_async = inspect.iscoroutinefunction(
dependency
Expand All @@ -377,6 +390,7 @@ def from_dependency(
dependency=dependency,
is_async=is_async,
scope_class=scope_class,
ignore_manual_init=ignore_manual_init,
)

def replace(self, scope_class: type[ScopeType] | None = None) -> Provider:
Expand Down Expand Up @@ -546,7 +560,7 @@ def _extract_and_register_dependency_from_parameter(
return value.default

if fastapi is None:
return None # type: ignore[unreachable]
return None # type: ignore[unreachable] # pragma: no cover
fastapi_dependency = None
if isinstance(value.default, fastapi.params.Depends):
fastapi_dependency = value.default.dependency
Expand Down
4 changes: 2 additions & 2 deletions picodi/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,11 @@ def _get_item(obj: Any, key: str) -> Any:
class _Lifespan:
@overload
def __call__(self, fn: Callable[P, T]) -> Callable[P, T]:
pass
"""Decorator for functions"""

@overload
def __call__(self, fn: None = None) -> Callable[[Callable[P, T]], Callable[P, T]]:
pass
"""Sync and Async context manager"""

def __call__(
self, fn: Callable[P, T] | None = None
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry]
name = "picodi"
description = "Simple Dependency Injection for Python"
version = "0.15.0"
version = "0.16.0"
license = "MIT"
authors = [
"yakimka"
Expand Down
57 changes: 56 additions & 1 deletion tests/test_dependency_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@

from typing import TYPE_CHECKING, Any

from picodi import AutoScope, Provide, dependency, inject
from picodi import (
AutoScope,
Provide,
SingletonScope,
dependency,
init_dependencies,
inject,
)

if TYPE_CHECKING:
from collections.abc import Hashable
Expand Down Expand Up @@ -59,3 +66,51 @@ async def service(num: int = Provide(get_num)) -> int:
result = await service()

assert result == 42 * 2 * 2


def test_can_optionally_ignore_manual_initialization():
# Arrange
inited = False

@dependency(scope_class=SingletonScope, ignore_manual_init=True)
def get_num():
nonlocal inited
inited = True
yield 42

@inject
def service(num: int = Provide(get_num)) -> int:
return num

# Act
init_dependencies()

# Assert
assert inited is False

assert service() == 42
assert inited is True


async def test_can_optionally_ignore_manual_initialization_async():
# Arrange
inited = False

@dependency(scope_class=SingletonScope, ignore_manual_init=True)
async def get_num():
nonlocal inited
inited = True
yield 42

@inject
async def service(num: int = Provide(get_num)) -> int:
return num

# Act
await init_dependencies()

# Assert
assert inited is False

assert await service() == 42
assert inited is True
18 changes: 18 additions & 0 deletions tests/test_scopes/test_user_defined_scopes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from contextlib import nullcontext

import pytest

from picodi import ManualScope


@pytest.fixture()
def manual_scope():
class MyManualScope(ManualScope):
pass

return MyManualScope()


async def test_manual_scope_enter_shutdown(manual_scope):
assert await manual_scope.enter(nullcontext()) is None
assert await manual_scope.shutdown() is None

0 comments on commit bfc0bd4

Please sign in to comment.