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.
@@ -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