apywire 1.0.4__tar.gz → 1.0.6__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.
Files changed (40) hide show
  1. {apywire-1.0.4/apywire.egg-info → apywire-1.0.6}/PKG-INFO +2 -2
  2. {apywire-1.0.4 → apywire-1.0.6}/apywire/__main__.py +4 -2
  3. {apywire-1.0.4 → apywire-1.0.6}/apywire/compiler.py +1 -1
  4. {apywire-1.0.4 → apywire-1.0.6}/apywire/constants.py +5 -0
  5. {apywire-1.0.4 → apywire-1.0.6}/apywire/formats.py +3 -6
  6. {apywire-1.0.4 → apywire-1.0.6}/apywire/generator.py +6 -3
  7. {apywire-1.0.4 → apywire-1.0.6}/apywire/runtime.py +72 -21
  8. {apywire-1.0.4 → apywire-1.0.6}/apywire/threads.py +30 -15
  9. {apywire-1.0.4 → apywire-1.0.6}/apywire/wiring.c +4436 -2735
  10. {apywire-1.0.4 → apywire-1.0.6}/apywire/wiring.py +90 -10
  11. {apywire-1.0.4 → apywire-1.0.6/apywire.egg-info}/PKG-INFO +2 -2
  12. {apywire-1.0.4 → apywire-1.0.6}/apywire.egg-info/requires.txt +1 -1
  13. {apywire-1.0.4 → apywire-1.0.6}/pyproject.toml +2 -2
  14. {apywire-1.0.4 → apywire-1.0.6}/tests/test_edge_cases.py +168 -0
  15. {apywire-1.0.4 → apywire-1.0.6}/tests/test_generator.py +7 -0
  16. {apywire-1.0.4 → apywire-1.0.6}/tests/test_internals.py +35 -0
  17. {apywire-1.0.4 → apywire-1.0.6}/tests/test_single.py +79 -0
  18. {apywire-1.0.4 → apywire-1.0.6}/tests/test_threading.py +106 -12
  19. {apywire-1.0.4 → apywire-1.0.6}/LICENSE +0 -0
  20. {apywire-1.0.4 → apywire-1.0.6}/MANIFEST.in +0 -0
  21. {apywire-1.0.4 → apywire-1.0.6}/README.md +0 -0
  22. {apywire-1.0.4 → apywire-1.0.6}/apywire/__init__.py +0 -0
  23. {apywire-1.0.4 → apywire-1.0.6}/apywire/exceptions.py +0 -0
  24. {apywire-1.0.4 → apywire-1.0.6}/apywire/py.typed +0 -0
  25. {apywire-1.0.4 → apywire-1.0.6}/apywire.egg-info/SOURCES.txt +0 -0
  26. {apywire-1.0.4 → apywire-1.0.6}/apywire.egg-info/dependency_links.txt +0 -0
  27. {apywire-1.0.4 → apywire-1.0.6}/apywire.egg-info/top_level.txt +0 -0
  28. {apywire-1.0.4 → apywire-1.0.6}/examples/basic_app/app.py +0 -0
  29. {apywire-1.0.4 → apywire-1.0.6}/examples/kv_store/main.py +0 -0
  30. {apywire-1.0.4 → apywire-1.0.6}/setup.cfg +0 -0
  31. {apywire-1.0.4 → apywire-1.0.6}/setup.py +0 -0
  32. {apywire-1.0.4 → apywire-1.0.6}/tests/__init__.py +0 -0
  33. {apywire-1.0.4 → apywire-1.0.6}/tests/test_cli.py +0 -0
  34. {apywire-1.0.4 → apywire-1.0.6}/tests/test_compile_aio.py +0 -0
  35. {apywire-1.0.4 → apywire-1.0.6}/tests/test_constant_placeholders.py +0 -0
  36. {apywire-1.0.4 → apywire-1.0.6}/tests/test_dataclasses.py +0 -0
  37. {apywire-1.0.4 → apywire-1.0.6}/tests/test_exceptions.py +0 -0
  38. {apywire-1.0.4 → apywire-1.0.6}/tests/test_factory_methods.py +0 -0
  39. {apywire-1.0.4 → apywire-1.0.6}/tests/test_formats.py +0 -0
  40. {apywire-1.0.4 → apywire-1.0.6}/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.4
3
+ Version: 1.0.6
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=["ini", "toml", "json"],
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=["ini", "toml", "json"],
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 __pconst__ module
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] is Union[X, None]
220
- if origin is Union:
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 = 10,
71
- lock_retry_sleep: float = 0.01,
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 = 10,
95
- lock_retry_sleep: float = 0.01,
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 self._parsed or name in self._values:
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 = importlib.import_module(entry.module_name)
212
- cls = cast(_Constructor, getattr(module, entry.class_name))
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
- # If a factory method is specified, get it from the class
215
- if entry.factory_method:
216
- constructor = cast(
217
- _Constructor, getattr(cls, entry.factory_method)
218
- )
219
- else:
220
- constructor = cls
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(self._wiring).__name__}' object has no attribute "
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(self._compiled, name)
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 CACHE_ATTR_PREFIX
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 = 10,
51
- lock_retry_sleep: float = 0.01,
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, UnknownPlaceholderError):
131
+ if isinstance(e, WiringError):
124
132
  raise
125
- # All other exceptions (including WiringError) are wrapped
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 the lock and fall
163
- # through to global path
164
- lock.release()
165
- held.clear()
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 lock before propagating exception
168
- lock.release()
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