pysyringe 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pysyringe-1.0.0/PKG-INFO +55 -0
- pysyringe-1.0.0/README.md +43 -0
- pysyringe-1.0.0/pyproject.toml +149 -0
- pysyringe-1.0.0/pysyringe/__init__.py +0 -0
- pysyringe-1.0.0/pysyringe/container.py +206 -0
- pysyringe-1.0.0/pysyringe/singleton.py +32 -0
pysyringe-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pysyringe
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: An opinionated dependency injection library for Python
|
|
5
|
+
Author: Hugo Chinchilla
|
|
6
|
+
Author-email: hugoasecas@gmail.com
|
|
7
|
+
Requires-Python: >=3.12,<3.13
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# PySyringe
|
|
13
|
+
|
|
14
|
+
An opinionated dependency injection library for Python.
|
|
15
|
+
|
|
16
|
+
A container that does not rely on adding decorators to your domain classes. It only wraps views in the infrastructure layer to keep your domain and app layer decoupled from the framework and the container.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
# container.py
|
|
23
|
+
from myapp.domain import CalendarInterface
|
|
24
|
+
from myapp.infra import LoggingEmailSender, SmtpEmailSender, Calendar
|
|
25
|
+
from django.core.http import HttpRequest, HttpResponse
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Factory:
|
|
29
|
+
def __init__(self, environment: str) -> None:
|
|
30
|
+
self.environment = environment
|
|
31
|
+
|
|
32
|
+
def get_mailer(self) -> EmailSender:
|
|
33
|
+
if self.environment == "production":
|
|
34
|
+
return SmtpEmailSender("mta.example.org", 25)
|
|
35
|
+
|
|
36
|
+
return LoggingEmailSender()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
factory = Factory(str(settings.ENVIRONMENT))
|
|
40
|
+
|
|
41
|
+
container = Container(factory)
|
|
42
|
+
container.never_provide(HttpRequest)
|
|
43
|
+
container.never_provide(HttpResponse)
|
|
44
|
+
container.alias(CalendarInterface, Calendar)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# views.py
|
|
48
|
+
from container import container
|
|
49
|
+
|
|
50
|
+
@container.inject
|
|
51
|
+
def my_view(request: HttpRequest, calendar: CalendarInterface) -> HttpResponse:
|
|
52
|
+
now = calendar.now()
|
|
53
|
+
return HttpResponse(f"Hello, World! The current time is {now}")
|
|
54
|
+
```
|
|
55
|
+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# PySyringe
|
|
2
|
+
|
|
3
|
+
An opinionated dependency injection library for Python.
|
|
4
|
+
|
|
5
|
+
A container that does not rely on adding decorators to your domain classes. It only wraps views in the infrastructure layer to keep your domain and app layer decoupled from the framework and the container.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
# container.py
|
|
12
|
+
from myapp.domain import CalendarInterface
|
|
13
|
+
from myapp.infra import LoggingEmailSender, SmtpEmailSender, Calendar
|
|
14
|
+
from django.core.http import HttpRequest, HttpResponse
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Factory:
|
|
18
|
+
def __init__(self, environment: str) -> None:
|
|
19
|
+
self.environment = environment
|
|
20
|
+
|
|
21
|
+
def get_mailer(self) -> EmailSender:
|
|
22
|
+
if self.environment == "production":
|
|
23
|
+
return SmtpEmailSender("mta.example.org", 25)
|
|
24
|
+
|
|
25
|
+
return LoggingEmailSender()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
factory = Factory(str(settings.ENVIRONMENT))
|
|
29
|
+
|
|
30
|
+
container = Container(factory)
|
|
31
|
+
container.never_provide(HttpRequest)
|
|
32
|
+
container.never_provide(HttpResponse)
|
|
33
|
+
container.alias(CalendarInterface, Calendar)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# views.py
|
|
37
|
+
from container import container
|
|
38
|
+
|
|
39
|
+
@container.inject
|
|
40
|
+
def my_view(request: HttpRequest, calendar: CalendarInterface) -> HttpResponse:
|
|
41
|
+
now = calendar.now()
|
|
42
|
+
return HttpResponse(f"Hello, World! The current time is {now}")
|
|
43
|
+
```
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "pysyringe"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "An opinionated dependency injection library for Python"
|
|
5
|
+
authors = ["Hugo Chinchilla <hugoasecas@gmail.com>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
|
|
8
|
+
[tool.poetry.dependencies]
|
|
9
|
+
python = "~3.12"
|
|
10
|
+
|
|
11
|
+
[tool.poetry.group.dev.dependencies]
|
|
12
|
+
coverage = "^7.3"
|
|
13
|
+
pre-commit = "^3.4.0"
|
|
14
|
+
ruff = "^0.2.0"
|
|
15
|
+
mypy = "^1.6.1"
|
|
16
|
+
pytest = "^8.3.5"
|
|
17
|
+
|
|
18
|
+
[tool.ruff]
|
|
19
|
+
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
|
|
20
|
+
lint.select = [
|
|
21
|
+
"E4",
|
|
22
|
+
"E7",
|
|
23
|
+
"E9",
|
|
24
|
+
"F",
|
|
25
|
+
"W",
|
|
26
|
+
"I",
|
|
27
|
+
"N",
|
|
28
|
+
"UP",
|
|
29
|
+
"YTT",
|
|
30
|
+
"ANN001",
|
|
31
|
+
"ANN2",
|
|
32
|
+
"ANN4",
|
|
33
|
+
"ASYNC",
|
|
34
|
+
"S",
|
|
35
|
+
"BLE",
|
|
36
|
+
"B",
|
|
37
|
+
"A001",
|
|
38
|
+
"A002",
|
|
39
|
+
"COM",
|
|
40
|
+
"C",
|
|
41
|
+
#"DTZ",
|
|
42
|
+
"T",
|
|
43
|
+
#"DJ",
|
|
44
|
+
"EXE",
|
|
45
|
+
"FA",
|
|
46
|
+
"ISC",
|
|
47
|
+
"ICN",
|
|
48
|
+
"INP",
|
|
49
|
+
"PIE",
|
|
50
|
+
"T20",
|
|
51
|
+
"PYI",
|
|
52
|
+
"Q",
|
|
53
|
+
"RSE",
|
|
54
|
+
"RET",
|
|
55
|
+
"SLF",
|
|
56
|
+
"SIM",
|
|
57
|
+
"TID",
|
|
58
|
+
"TCH",
|
|
59
|
+
"ARG",
|
|
60
|
+
"ERA",
|
|
61
|
+
"PGH",
|
|
62
|
+
"PL",
|
|
63
|
+
"TRY",
|
|
64
|
+
"FLY",
|
|
65
|
+
"PERF",
|
|
66
|
+
"RUF",
|
|
67
|
+
]
|
|
68
|
+
lint.ignore = ["ANN401", "UP015", "COM812", "TRY401", "PLR2004"]
|
|
69
|
+
|
|
70
|
+
# Allow fix for all enabled rules (when `--fix`) is provided.
|
|
71
|
+
lint.fixable = ["ALL"]
|
|
72
|
+
lint.unfixable = []
|
|
73
|
+
|
|
74
|
+
# Exclude a variety of commonly ignored directories.
|
|
75
|
+
exclude = [
|
|
76
|
+
".cache",
|
|
77
|
+
".direnv",
|
|
78
|
+
".git",
|
|
79
|
+
".git-rewrite",
|
|
80
|
+
".venv",
|
|
81
|
+
"venv",
|
|
82
|
+
"__pypackages__",
|
|
83
|
+
"__pycache__",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
# Same as Black.
|
|
87
|
+
line-length = 88
|
|
88
|
+
|
|
89
|
+
# Allow unused variables when underscore-prefixed.
|
|
90
|
+
lint.dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
|
91
|
+
|
|
92
|
+
# Assume Python 3.12
|
|
93
|
+
target-version = "py312"
|
|
94
|
+
|
|
95
|
+
cache-dir = ".cache/ruff"
|
|
96
|
+
|
|
97
|
+
[tool.ruff.lint.per-file-ignores]
|
|
98
|
+
"./tests/*" = ["ANN201", "S101"]
|
|
99
|
+
|
|
100
|
+
[tool.ruff.lint.mccabe]
|
|
101
|
+
# Unlike Flake8, default to a complexity level of 10.
|
|
102
|
+
max-complexity = 10
|
|
103
|
+
|
|
104
|
+
[tool.mypy]
|
|
105
|
+
python_version = "3.12"
|
|
106
|
+
ignore_missing_imports = true
|
|
107
|
+
no_implicit_optional = true
|
|
108
|
+
strict_equality = true
|
|
109
|
+
warn_redundant_casts = true
|
|
110
|
+
warn_return_any = true
|
|
111
|
+
warn_unreachable = true
|
|
112
|
+
warn_unused_configs = true
|
|
113
|
+
warn_unused_ignores = true
|
|
114
|
+
cache_dir = '.cache/mypy'
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
[tool.coverage.run]
|
|
118
|
+
branch = true
|
|
119
|
+
relative_files = true
|
|
120
|
+
source = ["./"]
|
|
121
|
+
omit = [
|
|
122
|
+
"tests/*",
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
[tool.coverage.report]
|
|
126
|
+
exclude_lines = [
|
|
127
|
+
"raise NotImplementedError",
|
|
128
|
+
"if __name__ == \"__main__\":",
|
|
129
|
+
"pass",
|
|
130
|
+
]
|
|
131
|
+
precision = 2
|
|
132
|
+
skip_covered = true
|
|
133
|
+
skip_empty = true
|
|
134
|
+
fail_under = 95
|
|
135
|
+
|
|
136
|
+
[build-system]
|
|
137
|
+
requires = ["poetry-core"]
|
|
138
|
+
build-backend = "poetry.core.masonry.api"
|
|
139
|
+
|
|
140
|
+
[tool.pytest.ini_options]
|
|
141
|
+
testpaths = "tests"
|
|
142
|
+
python_files = "test_*.py"
|
|
143
|
+
python_classes = "*Test"
|
|
144
|
+
python_functions = "test_*"
|
|
145
|
+
cache_dir = ".cache/pytest"
|
|
146
|
+
filterwarnings = []
|
|
147
|
+
|
|
148
|
+
[tool.isort]
|
|
149
|
+
profile = "black"
|
|
File without changes
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import typing
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from types import UnionType
|
|
5
|
+
from typing import Any, TypeVar, cast
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
NoneType = type(None)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _Unresolved:
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UnknownDependencyError(Exception):
|
|
16
|
+
def __init__(self, type_: type) -> None:
|
|
17
|
+
super().__init__(f"Container does not know how to provide {type_}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnresolvableUnionTypeError(Exception):
|
|
21
|
+
def __init__(self, type_: type) -> None:
|
|
22
|
+
super().__init__(
|
|
23
|
+
f"Cannot resolve [{type_}]: remove UnionType or define a factory",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _Resolver:
|
|
28
|
+
def __init__(self, factory: object) -> None:
|
|
29
|
+
self.factory = factory
|
|
30
|
+
self.mocks: dict = {}
|
|
31
|
+
self.aliases: dict = {}
|
|
32
|
+
self.never_provide: list[type] = []
|
|
33
|
+
|
|
34
|
+
def resolve(self, cls: type[T]) -> T | _Unresolved: # noqa: PLR0911
|
|
35
|
+
try:
|
|
36
|
+
if issubclass(cls, tuple(self.never_provide)):
|
|
37
|
+
return _Unresolved()
|
|
38
|
+
except TypeError:
|
|
39
|
+
return _Unresolved()
|
|
40
|
+
|
|
41
|
+
if cls in self.mocks:
|
|
42
|
+
return cast(T, self.mocks[cls])
|
|
43
|
+
|
|
44
|
+
if cls in self.aliases:
|
|
45
|
+
return self.resolve(self.aliases[cls])
|
|
46
|
+
|
|
47
|
+
instance = self.__make_from_factory(cls)
|
|
48
|
+
if instance:
|
|
49
|
+
return instance
|
|
50
|
+
|
|
51
|
+
instance = self.__make_from_inference(cls)
|
|
52
|
+
if instance:
|
|
53
|
+
return instance
|
|
54
|
+
|
|
55
|
+
return _Unresolved()
|
|
56
|
+
|
|
57
|
+
def __make_from_factory(self, cls: type[T]) -> T | None:
|
|
58
|
+
for factory in self.__get_factories():
|
|
59
|
+
if cls == _TypeHelper.get_return_type(factory):
|
|
60
|
+
return cast(T, factory())
|
|
61
|
+
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def __get_factories(self) -> list[Callable]:
|
|
65
|
+
attrs = [
|
|
66
|
+
getattr(self.factory, x) for x in dir(self.factory) if not x.startswith("_")
|
|
67
|
+
]
|
|
68
|
+
return [attr for attr in attrs if callable(attr)]
|
|
69
|
+
|
|
70
|
+
def __make_from_inference(self, cls: type[T]) -> T | None:
|
|
71
|
+
dependencies = {}
|
|
72
|
+
for arg_name, arg_type in _TypeHelper.get_constructor_arguments(cls):
|
|
73
|
+
resolved = self.resolve(arg_type)
|
|
74
|
+
if isinstance(resolved, _Unresolved):
|
|
75
|
+
return None
|
|
76
|
+
dependencies[arg_name] = resolved
|
|
77
|
+
|
|
78
|
+
return cls(**dependencies)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Container:
|
|
82
|
+
def __init__(self, factory: object) -> None:
|
|
83
|
+
self._resolver = _Resolver(factory)
|
|
84
|
+
|
|
85
|
+
def never_provide(self, cls: type[T]) -> None:
|
|
86
|
+
self._resolver.never_provide.append(cls)
|
|
87
|
+
|
|
88
|
+
def provide(self, cls: type[T]) -> T:
|
|
89
|
+
resolved = self._resolver.resolve(cls)
|
|
90
|
+
if isinstance(resolved, _Unresolved):
|
|
91
|
+
raise UnknownDependencyError(cls)
|
|
92
|
+
|
|
93
|
+
return resolved
|
|
94
|
+
|
|
95
|
+
def inject(self, function: Callable) -> Callable:
|
|
96
|
+
injector = _Injector(self._resolver)
|
|
97
|
+
return injector.inject(function)
|
|
98
|
+
|
|
99
|
+
def clear_mocks(self) -> None:
|
|
100
|
+
self._resolver.mocks = {}
|
|
101
|
+
|
|
102
|
+
def use_mock(self, cls: type[T], mock: T) -> None:
|
|
103
|
+
self._resolver.mocks[cls] = mock
|
|
104
|
+
|
|
105
|
+
def alias(self, interface: type, implementation: type) -> None:
|
|
106
|
+
self._resolver.aliases[interface] = implementation
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _Injector:
|
|
110
|
+
def __init__(self, _resolver: _Resolver) -> None:
|
|
111
|
+
self._resolver = _resolver
|
|
112
|
+
|
|
113
|
+
def inject(self, function: Callable) -> Callable:
|
|
114
|
+
injections = self.__get_injectable_arguments(function)
|
|
115
|
+
|
|
116
|
+
def partial_function(*args, **kwargs) -> Any:
|
|
117
|
+
injections = self.__get_injectable_arguments(function)
|
|
118
|
+
return function(*args, **kwargs, **injections)
|
|
119
|
+
|
|
120
|
+
partial_function.__signature__ = self.__create_new_signature( # type: ignore[attr-defined]
|
|
121
|
+
function,
|
|
122
|
+
injections,
|
|
123
|
+
)
|
|
124
|
+
partial_function.__name__ = function.__name__
|
|
125
|
+
|
|
126
|
+
return partial_function
|
|
127
|
+
|
|
128
|
+
def __get_injectable_arguments(self, function: Callable) -> dict[str, object]:
|
|
129
|
+
signature = inspect.signature(function)
|
|
130
|
+
resolved_arguments = {
|
|
131
|
+
(p.name, self._resolver.resolve(p.annotation))
|
|
132
|
+
for p in signature.parameters.values()
|
|
133
|
+
if p.name != "self"
|
|
134
|
+
}
|
|
135
|
+
only_resolved = {
|
|
136
|
+
(parameter_name, value)
|
|
137
|
+
for (parameter_name, value) in resolved_arguments
|
|
138
|
+
if not isinstance(value, _Unresolved)
|
|
139
|
+
}
|
|
140
|
+
return dict(only_resolved)
|
|
141
|
+
|
|
142
|
+
def __create_new_signature(
|
|
143
|
+
self,
|
|
144
|
+
function: Callable,
|
|
145
|
+
injections: dict[str, T],
|
|
146
|
+
) -> inspect.Signature:
|
|
147
|
+
remaining_parameters = [
|
|
148
|
+
p
|
|
149
|
+
for p in inspect.signature(function).parameters.values()
|
|
150
|
+
if p.name not in injections
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
return inspect.Signature(
|
|
154
|
+
parameters=remaining_parameters,
|
|
155
|
+
return_annotation=(_TypeHelper.get_return_type(function)),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class _TypeHelper:
|
|
160
|
+
@classmethod
|
|
161
|
+
def get_constructor_arguments(cls, subject: type[T]) -> list[tuple]:
|
|
162
|
+
try:
|
|
163
|
+
parameters = inspect.signature(subject).parameters.values()
|
|
164
|
+
except ValueError:
|
|
165
|
+
return []
|
|
166
|
+
|
|
167
|
+
return [
|
|
168
|
+
(p.name, cls._desambiguate(p.annotation))
|
|
169
|
+
for p in parameters
|
|
170
|
+
if p.name != "return"
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def _desambiguate(cls, type_: type[T]) -> type[T]:
|
|
175
|
+
if cls._is_union(type_):
|
|
176
|
+
if cls._is_optional(type_):
|
|
177
|
+
return cls._resolve_optional(type_)
|
|
178
|
+
raise UnresolvableUnionTypeError(type_)
|
|
179
|
+
return type_
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def _is_union(cls, type_: T) -> bool:
|
|
183
|
+
if typing.get_origin(type_) is typing.Union:
|
|
184
|
+
return True # Syntax using "Union[object, str]"
|
|
185
|
+
|
|
186
|
+
if isinstance(type_, UnionType):
|
|
187
|
+
return True # Syntax using "object | str"
|
|
188
|
+
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _is_optional(type_: object) -> bool:
|
|
193
|
+
types = set(typing.get_args(type_))
|
|
194
|
+
has_two_types = len(types) == 2
|
|
195
|
+
one_of_them_is_optional = NoneType in types
|
|
196
|
+
return has_two_types and one_of_them_is_optional
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def _resolve_optional(type_: type[T | None]) -> type[T]:
|
|
200
|
+
types = set(typing.get_args(type_))
|
|
201
|
+
types.remove(type(None))
|
|
202
|
+
return cast(type[T], types.pop())
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def get_return_type(method: Callable) -> type:
|
|
206
|
+
return cast(type, inspect.signature(method).return_annotation)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from typing import ClassVar, TypeVar
|
|
2
|
+
|
|
3
|
+
T = TypeVar("T")
|
|
4
|
+
CacheKey = tuple[T, ...]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _Cache:
|
|
8
|
+
_entries: ClassVar[dict] = {}
|
|
9
|
+
|
|
10
|
+
@classmethod
|
|
11
|
+
def has(cls, key: CacheKey[T]) -> bool:
|
|
12
|
+
return key in cls._entries
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def get(cls, key: CacheKey[T]) -> T:
|
|
16
|
+
instance: T = cls._entries[key]
|
|
17
|
+
return instance
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def set(cls, key: CacheKey[T], value: T) -> None:
|
|
21
|
+
cls._entries[key] = value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def singleton(type_: type[T], *type_args, **type_kwargs) -> T:
|
|
25
|
+
key = (*(type_,), *(type_args,), *tuple(*sorted(type_kwargs.items())))
|
|
26
|
+
|
|
27
|
+
if not _Cache.has(key):
|
|
28
|
+
instance: T = type_(*type_args, **type_kwargs)
|
|
29
|
+
_Cache.set(key, instance)
|
|
30
|
+
|
|
31
|
+
inst: T = _Cache.get(key)
|
|
32
|
+
return inst
|