apywire 1.0.4__tar.gz → 1.0.5__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.
- {apywire-1.0.4/apywire.egg-info → apywire-1.0.5}/PKG-INFO +2 -2
- {apywire-1.0.4 → apywire-1.0.5}/apywire/__main__.py +4 -2
- {apywire-1.0.4 → apywire-1.0.5}/apywire/compiler.py +1 -1
- {apywire-1.0.4 → apywire-1.0.5}/apywire/constants.py +5 -0
- {apywire-1.0.4 → apywire-1.0.5}/apywire/formats.py +3 -6
- {apywire-1.0.4 → apywire-1.0.5}/apywire/generator.py +6 -3
- {apywire-1.0.4 → apywire-1.0.5}/apywire/runtime.py +72 -21
- {apywire-1.0.4 → apywire-1.0.5}/apywire/threads.py +30 -15
- {apywire-1.0.4 → apywire-1.0.5}/apywire/wiring.c +4436 -2735
- {apywire-1.0.4 → apywire-1.0.5}/apywire/wiring.py +90 -10
- {apywire-1.0.4 → apywire-1.0.5/apywire.egg-info}/PKG-INFO +2 -2
- {apywire-1.0.4 → apywire-1.0.5}/apywire.egg-info/requires.txt +1 -1
- {apywire-1.0.4 → apywire-1.0.5}/pyproject.toml +2 -2
- {apywire-1.0.4 → apywire-1.0.5}/tests/test_edge_cases.py +168 -0
- {apywire-1.0.4 → apywire-1.0.5}/tests/test_generator.py +7 -0
- {apywire-1.0.4 → apywire-1.0.5}/tests/test_internals.py +35 -0
- {apywire-1.0.4 → apywire-1.0.5}/tests/test_single.py +79 -0
- {apywire-1.0.4 → apywire-1.0.5}/tests/test_threading.py +106 -12
- {apywire-1.0.4 → apywire-1.0.5}/LICENSE +0 -0
- {apywire-1.0.4 → apywire-1.0.5}/MANIFEST.in +0 -0
- {apywire-1.0.4 → apywire-1.0.5}/README.md +0 -0
- {apywire-1.0.4 → apywire-1.0.5}/apywire/__init__.py +0 -0
- {apywire-1.0.4 → apywire-1.0.5}/apywire/exceptions.py +0 -0
- {apywire-1.0.4 → apywire-1.0.5}/apywire/py.typed +0 -0
- {apywire-1.0.4 → apywire-1.0.5}/apywire.egg-info/SOURCES.txt +0 -0
- {apywire-1.0.4 → apywire-1.0.5}/apywire.egg-info/dependency_links.txt +0 -0
- {apywire-1.0.4 → apywire-1.0.5}/apywire.egg-info/top_level.txt +0 -0
- {apywire-1.0.4 → apywire-1.0.5}/examples/basic_app/app.py +0 -0
- {apywire-1.0.4 → apywire-1.0.5}/examples/kv_store/main.py +0 -0
- {apywire-1.0.4 → apywire-1.0.5}/setup.cfg +0 -0
- {apywire-1.0.4 → apywire-1.0.5}/setup.py +0 -0
- {apywire-1.0.4 → apywire-1.0.5}/tests/__init__.py +0 -0
- {apywire-1.0.4 → apywire-1.0.5}/tests/test_cli.py +0 -0
- {apywire-1.0.4 → apywire-1.0.5}/tests/test_compile_aio.py +0 -0
- {apywire-1.0.4 → apywire-1.0.5}/tests/test_constant_placeholders.py +0 -0
- {apywire-1.0.4 → apywire-1.0.5}/tests/test_dataclasses.py +0 -0
- {apywire-1.0.4 → apywire-1.0.5}/tests/test_exceptions.py +0 -0
- {apywire-1.0.4 → apywire-1.0.5}/tests/test_factory_methods.py +0 -0
- {apywire-1.0.4 → apywire-1.0.5}/tests/test_formats.py +0 -0
- {apywire-1.0.4 → apywire-1.0.5}/tests/test_stdlib_compat.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: apywire
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.5
|
|
4
4
|
Summary: A package to wire up objects
|
|
5
5
|
Author-email: Alexandre Gomes Gaigalas <alganet@gmail.com>
|
|
6
6
|
Maintainer-email: Alexandre Gomes Gaigalas <alganet@gmail.com>
|
|
@@ -33,7 +33,7 @@ Requires-Dist: mkdocs-autorefs; extra == "dev"
|
|
|
33
33
|
Requires-Dist: mkdocs-material; extra == "dev"
|
|
34
34
|
Requires-Dist: mkdocs; extra == "dev"
|
|
35
35
|
Requires-Dist: mkdocstrings[python]; extra == "dev"
|
|
36
|
-
Requires-Dist: mypy; extra == "dev"
|
|
36
|
+
Requires-Dist: mypy>=2.1; extra == "dev"
|
|
37
37
|
Requires-Dist: pygments>=2.20; extra == "dev"
|
|
38
38
|
Requires-Dist: pymdown-extensions>=10.21.2; extra == "dev"
|
|
39
39
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
@@ -23,6 +23,8 @@ from apywire.formats import (
|
|
|
23
23
|
from apywire.generator import Generator
|
|
24
24
|
from apywire.wiring import Spec
|
|
25
25
|
|
|
26
|
+
_FORMAT_CHOICES: tuple[str, ...] = ("ini", "toml", "json")
|
|
27
|
+
|
|
26
28
|
|
|
27
29
|
def cmd_generate(args: argparse.Namespace) -> int:
|
|
28
30
|
"""Handle the generate command."""
|
|
@@ -128,7 +130,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
128
130
|
generate_parser.add_argument(
|
|
129
131
|
"--format",
|
|
130
132
|
required=True,
|
|
131
|
-
choices=
|
|
133
|
+
choices=_FORMAT_CHOICES,
|
|
132
134
|
metavar="FORMAT",
|
|
133
135
|
help="Output format: ini, toml, or json (required)",
|
|
134
136
|
)
|
|
@@ -158,7 +160,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
158
160
|
compile_parser.add_argument(
|
|
159
161
|
"--format",
|
|
160
162
|
required=True,
|
|
161
|
-
choices=
|
|
163
|
+
choices=_FORMAT_CHOICES,
|
|
162
164
|
metavar="FORMAT",
|
|
163
165
|
help="Input format: ini, toml, or json (required)",
|
|
164
166
|
)
|
|
@@ -452,7 +452,7 @@ class WiringCompiler(WiringBase):
|
|
|
452
452
|
# Add import statements
|
|
453
453
|
modules = set()
|
|
454
454
|
for module_name, _, _, _ in self._parsed.values():
|
|
455
|
-
# Skip synthetic
|
|
455
|
+
# Skip the synthetic __sconst__ module (SYNTHETIC_CONST)
|
|
456
456
|
if module_name != SYNTHETIC_CONST:
|
|
457
457
|
modules.add(module_name)
|
|
458
458
|
if thread_safe:
|
|
@@ -23,3 +23,8 @@ PLACEHOLDER_REGEX = re.compile(PLACEHOLDER_PATTERN)
|
|
|
23
23
|
SYNTHETIC_CONST = "__sconst__" # Synthetic module for promoted constants
|
|
24
24
|
|
|
25
25
|
CACHE_ATTR_PREFIX = "_" # Prefix for cache attributes (_name)
|
|
26
|
+
|
|
27
|
+
# Thread-safety defaults: max retries and per-retry sleep when falling back
|
|
28
|
+
# to the global lock in thread_safe mode.
|
|
29
|
+
DEFAULT_MAX_LOCK_ATTEMPTS = 10
|
|
30
|
+
DEFAULT_LOCK_RETRY_SLEEP = 0.01
|
|
@@ -8,8 +8,8 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import configparser
|
|
10
10
|
import json
|
|
11
|
-
from types import ModuleType
|
|
12
11
|
from collections.abc import Mapping
|
|
12
|
+
from types import ModuleType
|
|
13
13
|
from typing import cast
|
|
14
14
|
|
|
15
15
|
from apywire.exceptions import FormatError
|
|
@@ -311,15 +311,12 @@ def json_to_spec(content: str) -> Spec:
|
|
|
311
311
|
try:
|
|
312
312
|
raw: object = json.loads(content)
|
|
313
313
|
except Exception as e:
|
|
314
|
-
raise FormatError(
|
|
315
|
-
"json", f"Failed to parse JSON content: {e}"
|
|
316
|
-
) from e
|
|
314
|
+
raise FormatError("json", f"Failed to parse JSON content: {e}") from e
|
|
317
315
|
|
|
318
316
|
if not isinstance(raw, dict):
|
|
319
317
|
raise FormatError(
|
|
320
318
|
"json",
|
|
321
|
-
"JSON root must be an object, "
|
|
322
|
-
f"got {type(raw).__name__}",
|
|
319
|
+
"JSON root must be an object, " f"got {type(raw).__name__}",
|
|
323
320
|
)
|
|
324
321
|
|
|
325
322
|
data: dict[str, object] = raw
|
|
@@ -12,7 +12,7 @@ from __future__ import annotations
|
|
|
12
12
|
|
|
13
13
|
import importlib
|
|
14
14
|
import inspect
|
|
15
|
-
from types import ModuleType, NoneType
|
|
15
|
+
from types import ModuleType, NoneType, UnionType
|
|
16
16
|
from typing import (
|
|
17
17
|
Union,
|
|
18
18
|
cast,
|
|
@@ -216,8 +216,11 @@ class Generator(SpecParser):
|
|
|
216
216
|
tuple[object, ...], get_args(annotation)
|
|
217
217
|
)
|
|
218
218
|
|
|
219
|
-
# Optional[X]
|
|
220
|
-
|
|
219
|
+
# Optional[X] / Union[X, None] (typing.Union) and the PEP 604 form
|
|
220
|
+
# X | None (types.UnionType). On Python < 3.14 get_origin returns a
|
|
221
|
+
# different object for each spelling, so both must be matched for
|
|
222
|
+
# `X | None` to be normalized like Optional[X].
|
|
223
|
+
if origin is Union or origin is UnionType:
|
|
221
224
|
# Filter out NoneType
|
|
222
225
|
non_none_args: list[object] = [
|
|
223
226
|
a for a in args if a is not type(None)
|
|
@@ -16,6 +16,8 @@ from typing import Awaitable, Callable, Protocol, cast, final
|
|
|
16
16
|
|
|
17
17
|
from apywire.constants import (
|
|
18
18
|
CACHE_ATTR_PREFIX,
|
|
19
|
+
DEFAULT_LOCK_RETRY_SLEEP,
|
|
20
|
+
DEFAULT_MAX_LOCK_ATTEMPTS,
|
|
19
21
|
PLACEHOLDER_REGEX,
|
|
20
22
|
SYNTHETIC_CONST,
|
|
21
23
|
)
|
|
@@ -67,8 +69,8 @@ class WiringRuntime(WiringBase, ThreadSafeMixin):
|
|
|
67
69
|
spec: Spec,
|
|
68
70
|
*,
|
|
69
71
|
thread_safe: bool = False,
|
|
70
|
-
max_lock_attempts: int =
|
|
71
|
-
lock_retry_sleep: float =
|
|
72
|
+
max_lock_attempts: int = DEFAULT_MAX_LOCK_ATTEMPTS,
|
|
73
|
+
lock_retry_sleep: float = DEFAULT_LOCK_RETRY_SLEEP,
|
|
72
74
|
) -> None:
|
|
73
75
|
"""Initialize a WiringRuntime container.
|
|
74
76
|
|
|
@@ -91,8 +93,8 @@ class WiringRuntime(WiringBase, ThreadSafeMixin):
|
|
|
91
93
|
|
|
92
94
|
def _init_thread_safety(
|
|
93
95
|
self,
|
|
94
|
-
max_lock_attempts: int =
|
|
95
|
-
lock_retry_sleep: float =
|
|
96
|
+
max_lock_attempts: int = DEFAULT_MAX_LOCK_ATTEMPTS,
|
|
97
|
+
lock_retry_sleep: float = DEFAULT_LOCK_RETRY_SLEEP,
|
|
96
98
|
) -> None:
|
|
97
99
|
"""Initialize thread safety mixin."""
|
|
98
100
|
ThreadSafeMixin._init_thread_safety(
|
|
@@ -101,8 +103,31 @@ class WiringRuntime(WiringBase, ThreadSafeMixin):
|
|
|
101
103
|
|
|
102
104
|
def __getattr__(self, name: str) -> Accessor:
|
|
103
105
|
"""Return a callable accessor for the named wired object."""
|
|
106
|
+
# Read internal state from __dict__ directly. __getattr__ only runs
|
|
107
|
+
# when normal lookup fails, so referencing self._parsed/self._values
|
|
108
|
+
# here would re-enter __getattr__ and recurse forever whenever they
|
|
109
|
+
# are absent (e.g. during copy/unpickle or a partially-failed
|
|
110
|
+
# __init__). Failing cleanly with AttributeError lets those protocols
|
|
111
|
+
# work and keeps errors meaningful.
|
|
112
|
+
# object.__getattribute__ does the normal C-level lookup and raises
|
|
113
|
+
# AttributeError directly if the attribute is absent, WITHOUT calling
|
|
114
|
+
# __getattr__ again -- so missing internal state fails cleanly instead
|
|
115
|
+
# of recursing.
|
|
116
|
+
try:
|
|
117
|
+
parsed = cast(
|
|
118
|
+
"dict[str, object]",
|
|
119
|
+
object.__getattribute__(self, "_parsed"),
|
|
120
|
+
)
|
|
121
|
+
values = cast(
|
|
122
|
+
"dict[str, object]",
|
|
123
|
+
object.__getattribute__(self, "_values"),
|
|
124
|
+
)
|
|
125
|
+
except AttributeError:
|
|
126
|
+
raise AttributeError(
|
|
127
|
+
f"'{type(self).__name__}' object has no attribute '{name}'"
|
|
128
|
+
) from None
|
|
104
129
|
# If the name is in our parsed spec or constants, return an accessor.
|
|
105
|
-
if name in
|
|
130
|
+
if name in parsed or name in values:
|
|
106
131
|
return Accessor(self, name)
|
|
107
132
|
raise AttributeError(
|
|
108
133
|
f"'{type(self).__name__}' object has no attribute '{name}'"
|
|
@@ -208,18 +233,29 @@ class WiringRuntime(WiringBase, ThreadSafeMixin):
|
|
|
208
233
|
self._values[name] = result
|
|
209
234
|
return result
|
|
210
235
|
|
|
211
|
-
module
|
|
212
|
-
|
|
236
|
+
# Resolving the module, class and factory method is part of THIS
|
|
237
|
+
# attribute's instantiation, so any failure here (missing module,
|
|
238
|
+
# class or factory method) is wrapped with this attribute's name.
|
|
239
|
+
# Wrapping these consistently makes thread-safe and non-thread-safe
|
|
240
|
+
# modes report identical errors for every failure mode.
|
|
241
|
+
try:
|
|
242
|
+
module = importlib.import_module(entry.module_name)
|
|
243
|
+
cls = cast(_Constructor, getattr(module, entry.class_name))
|
|
213
244
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
245
|
+
# If a factory method is specified, get it from the class
|
|
246
|
+
if entry.factory_method:
|
|
247
|
+
constructor = cast(
|
|
248
|
+
_Constructor, getattr(cls, entry.factory_method)
|
|
249
|
+
)
|
|
250
|
+
else:
|
|
251
|
+
constructor = cls
|
|
252
|
+
except Exception as e:
|
|
253
|
+
raise WiringError(f"failed to instantiate '{name}': {e}") from e
|
|
221
254
|
|
|
222
|
-
# Resolve arguments
|
|
255
|
+
# Resolve arguments. Nested instantiation failures surface here as
|
|
256
|
+
# WiringErrors that already name the dependency that failed; let them
|
|
257
|
+
# propagate unchanged rather than re-wrapping them with this
|
|
258
|
+
# attribute's name (which would bury the real origin).
|
|
223
259
|
kwargs = self._resolve_runtime(entry.data, context=name)
|
|
224
260
|
|
|
225
261
|
try:
|
|
@@ -360,13 +396,20 @@ class AioAccessor:
|
|
|
360
396
|
|
|
361
397
|
def __getattr__(self, name: str) -> Callable[[], Awaitable[object]]:
|
|
362
398
|
"""Return an async callable for the named wired object."""
|
|
399
|
+
# Read _wiring from __dict__ to avoid recursing through __getattr__
|
|
400
|
+
# if this accessor isn't fully initialized.
|
|
401
|
+
try:
|
|
402
|
+
wiring = cast(
|
|
403
|
+
WiringRuntime, object.__getattribute__(self, "_wiring")
|
|
404
|
+
)
|
|
405
|
+
except AttributeError:
|
|
406
|
+
raise AttributeError(
|
|
407
|
+
f"'{type(self).__name__}' object has no attribute '{name}'"
|
|
408
|
+
) from None
|
|
363
409
|
# Check if valid name
|
|
364
|
-
if
|
|
365
|
-
name not in self._wiring._parsed
|
|
366
|
-
and name not in self._wiring._values
|
|
367
|
-
):
|
|
410
|
+
if name not in wiring._parsed and name not in wiring._values:
|
|
368
411
|
raise AttributeError(
|
|
369
|
-
f"'{type(
|
|
412
|
+
f"'{type(wiring).__name__}' object has no attribute "
|
|
370
413
|
f"'{name}'"
|
|
371
414
|
)
|
|
372
415
|
|
|
@@ -404,8 +447,16 @@ class CompiledAio:
|
|
|
404
447
|
|
|
405
448
|
def __getattr__(self, name: str) -> Callable[[], Awaitable[object]]:
|
|
406
449
|
"""Return an async callable for the named accessor."""
|
|
450
|
+
# Read _compiled from __dict__ to avoid recursing through __getattr__
|
|
451
|
+
# if this wrapper isn't fully initialized.
|
|
452
|
+
try:
|
|
453
|
+
compiled = cast(object, object.__getattribute__(self, "_compiled"))
|
|
454
|
+
except AttributeError:
|
|
455
|
+
raise AttributeError(
|
|
456
|
+
f"'{type(self).__name__}' object has no attribute '{name}'"
|
|
457
|
+
) from None
|
|
407
458
|
method: Callable[[], object] = cast(
|
|
408
|
-
Callable[[], object], getattr(
|
|
459
|
+
Callable[[], object], getattr(compiled, name)
|
|
409
460
|
)
|
|
410
461
|
cache_attr = f"{CACHE_ATTR_PREFIX}{name}"
|
|
411
462
|
|
|
@@ -15,7 +15,11 @@ import threading
|
|
|
15
15
|
import time
|
|
16
16
|
from typing import Callable, Literal, NoReturn, cast
|
|
17
17
|
|
|
18
|
-
from apywire.constants import
|
|
18
|
+
from apywire.constants import (
|
|
19
|
+
CACHE_ATTR_PREFIX,
|
|
20
|
+
DEFAULT_LOCK_RETRY_SLEEP,
|
|
21
|
+
DEFAULT_MAX_LOCK_ATTEMPTS,
|
|
22
|
+
)
|
|
19
23
|
from apywire.exceptions import LockUnavailableError
|
|
20
24
|
|
|
21
25
|
|
|
@@ -47,8 +51,8 @@ class ThreadSafeMixin:
|
|
|
47
51
|
|
|
48
52
|
def _init_thread_safety(
|
|
49
53
|
self,
|
|
50
|
-
max_lock_attempts: int =
|
|
51
|
-
lock_retry_sleep: float =
|
|
54
|
+
max_lock_attempts: int = DEFAULT_MAX_LOCK_ATTEMPTS,
|
|
55
|
+
lock_retry_sleep: float = DEFAULT_LOCK_RETRY_SLEEP,
|
|
52
56
|
) -> None:
|
|
53
57
|
"""Initialize thread safety primitives.
|
|
54
58
|
|
|
@@ -113,16 +117,20 @@ class ThreadSafeMixin:
|
|
|
113
117
|
This helper method provides consistent error wrapping for all
|
|
114
118
|
instantiation paths, avoiding code duplication.
|
|
115
119
|
|
|
120
|
+
A `WiringError` (including its `UnknownPlaceholderError` and
|
|
121
|
+
`CircularWiringError` subtypes) is already wrapped with proper
|
|
122
|
+
context by the instantiation logic, so it is re-raised unchanged.
|
|
123
|
+
Re-wrapping it here would double-wrap the error and discard the
|
|
124
|
+
original message/cause, making thread-safe failures less informative
|
|
125
|
+
than their non-thread-safe equivalents.
|
|
126
|
+
|
|
116
127
|
This method always raises an exception and never returns.
|
|
117
128
|
"""
|
|
118
|
-
from apywire.exceptions import
|
|
119
|
-
UnknownPlaceholderError,
|
|
120
|
-
WiringError,
|
|
121
|
-
)
|
|
129
|
+
from apywire.exceptions import WiringError
|
|
122
130
|
|
|
123
|
-
if isinstance(e,
|
|
131
|
+
if isinstance(e, WiringError):
|
|
124
132
|
raise
|
|
125
|
-
#
|
|
133
|
+
# Any other exception is wrapped to add the attribute-name context.
|
|
126
134
|
raise WiringError(f"failed to instantiate '{name}'") from e
|
|
127
135
|
|
|
128
136
|
def _instantiate_attr(
|
|
@@ -159,13 +167,20 @@ class ThreadSafeMixin:
|
|
|
159
167
|
try:
|
|
160
168
|
inst = maker()
|
|
161
169
|
except LockUnavailableError:
|
|
162
|
-
# On optimistic failure, release
|
|
163
|
-
#
|
|
164
|
-
|
|
165
|
-
|
|
170
|
+
# On optimistic failure, release ALL locks held by
|
|
171
|
+
# this thread (this attribute's lock plus any
|
|
172
|
+
# per-attribute locks acquired by successful nested
|
|
173
|
+
# instantiations earlier in the chain), then fall
|
|
174
|
+
# through to the global path. Releasing only `lock`
|
|
175
|
+
# here would leak the nested locks and deadlock other
|
|
176
|
+
# threads.
|
|
177
|
+
self._release_held_locks()
|
|
166
178
|
except Exception as e:
|
|
167
|
-
# Release
|
|
168
|
-
|
|
179
|
+
# Release ALL locks held by this thread before
|
|
180
|
+
# propagating, for the same reason as above: nested
|
|
181
|
+
# instantiations that succeeded earlier in the chain
|
|
182
|
+
# still hold their per-attribute locks.
|
|
183
|
+
self._release_held_locks()
|
|
169
184
|
self._wrap_instantiation_error(e, name)
|
|
170
185
|
else:
|
|
171
186
|
# Store in cache
|