rappel 0.4.1__py3-none-win_amd64.whl → 0.4.5__py3-none-win_amd64.whl

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.

Potentially problematic release.


This version of rappel might be problematic. Click here for more details.

rappel/actions.py CHANGED
@@ -1,9 +1,11 @@
1
1
  import inspect
2
2
  from dataclasses import dataclass
3
+ from functools import wraps
3
4
  from typing import Any, Callable, Optional, TypeVar, overload
4
5
 
5
6
  from proto import messages_pb2 as pb2
6
7
 
8
+ from .dependencies import provide_dependencies
7
9
  from .registry import AsyncAction, registry
8
10
  from .serialization import dumps, loads
9
11
 
@@ -64,17 +66,42 @@ def action(
64
66
  *,
65
67
  name: Optional[str] = None,
66
68
  ) -> Callable[[TAsync], TAsync] | TAsync:
67
- """Decorator for registering async actions."""
69
+ """Decorator for registering async actions.
70
+
71
+ Actions decorated with @action will automatically resolve Depend() markers
72
+ when called directly (e.g., during pytest runs where workflows bypass the
73
+ gRPC bridge).
74
+ """
68
75
 
69
76
  def decorator(target: TAsync) -> TAsync:
70
77
  if not inspect.iscoroutinefunction(target):
71
78
  raise TypeError(f"action '{target.__name__}' must be defined with 'async def'")
72
79
  action_name = name or target.__name__
73
80
  action_module = target.__module__
81
+
82
+ @wraps(target)
83
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
84
+ # Convert positional args to kwargs based on the signature
85
+ sig = inspect.signature(target)
86
+ params = list(sig.parameters.keys())
87
+ for i, arg in enumerate(args):
88
+ if i < len(params):
89
+ kwargs[params[i]] = arg
90
+
91
+ # Resolve dependencies using the same mechanism as execute_action
92
+ async with provide_dependencies(target, kwargs) as call_kwargs:
93
+ return await target(**call_kwargs)
94
+
95
+ # Copy over the original function's attributes for introspection
96
+ wrapper.__wrapped__ = target # type: ignore[attr-defined]
97
+ wrapper.__rappel_action_name__ = action_name # type: ignore[attr-defined]
98
+ wrapper.__rappel_action_module__ = action_module # type: ignore[attr-defined]
99
+
100
+ # Register the original function (not the wrapper) so execute_action
101
+ # doesn't double-resolve dependencies
74
102
  registry.register(action_module, action_name, target)
75
- target.__rappel_action_name__ = action_name
76
- target.__rappel_action_module__ = action_module
77
- return target
103
+
104
+ return wrapper # type: ignore[return-value]
78
105
 
79
106
  if func is not None:
80
107
  return decorator(func)
Binary file
Binary file
Binary file
rappel/dependencies.py CHANGED
@@ -7,26 +7,40 @@ from typing import Annotated, Any, AsyncIterator, Callable, Optional, get_args,
7
7
 
8
8
 
9
9
  @dataclass(frozen=True)
10
- class Depend:
11
- """Marker for dependency injection, mirroring FastAPI's Depends syntax."""
10
+ class DependMarker:
11
+ """Internal marker for dependency injection."""
12
12
 
13
13
  dependency: Optional[Callable[..., Any]] = None
14
14
  use_cache: bool = True
15
15
 
16
16
 
17
- def _depend_from_annotation(annotation: Any) -> Depend | None:
17
+ def Depend( # noqa: N802
18
+ dependency: Optional[Callable[..., Any]] = None,
19
+ *,
20
+ use_cache: bool = True,
21
+ ) -> Any:
22
+ """Marker for dependency injection, mirroring FastAPI's Depends syntax.
23
+
24
+ Returns Any to allow usage as a default parameter value:
25
+ def my_func(service: MyService = Depend(get_service)):
26
+ ...
27
+ """
28
+ return DependMarker(dependency=dependency, use_cache=use_cache)
29
+
30
+
31
+ def _depend_from_annotation(annotation: Any) -> DependMarker | None:
18
32
  origin = get_origin(annotation)
19
33
  if origin is not Annotated:
20
34
  return None
21
35
  metadata = get_args(annotation)[1:]
22
36
  for meta in metadata:
23
- if isinstance(meta, Depend):
37
+ if isinstance(meta, DependMarker):
24
38
  return meta
25
39
  return None
26
40
 
27
41
 
28
- def _dependency_marker(parameter: inspect.Parameter) -> Depend | None:
29
- if isinstance(parameter.default, Depend):
42
+ def _dependency_marker(parameter: inspect.Parameter) -> DependMarker | None:
43
+ if isinstance(parameter.default, DependMarker):
30
44
  return parameter.default
31
45
  return _depend_from_annotation(parameter.annotation)
32
46
 
@@ -69,7 +83,7 @@ class _DependencyResolver:
69
83
  raise TypeError(f"Missing required parameter '{name}' for {func_name}")
70
84
  return call_kwargs
71
85
 
72
- async def _resolve_dependency(self, marker: Depend) -> Any:
86
+ async def _resolve_dependency(self, marker: DependMarker) -> Any:
73
87
  dependency = marker.dependency
74
88
  if dependency is None:
75
89
  raise TypeError("Depend requires a dependency callable")
rappel/registry.py CHANGED
@@ -29,6 +29,32 @@ class ActionRegistry:
29
29
  self._actions: dict[str, _ActionEntry] = {}
30
30
  self._lock = RLock()
31
31
 
32
+ def _source_fingerprint(self, func: AsyncAction) -> tuple[str | None, str | None]:
33
+ func_any: Any = func
34
+ try:
35
+ code = func_any.__code__
36
+ except AttributeError:
37
+ return (None, None)
38
+ try:
39
+ qualname = func_any.__qualname__
40
+ except AttributeError:
41
+ qualname = None
42
+ filename = code.co_filename
43
+ if not isinstance(filename, str):
44
+ filename = None
45
+ if qualname is not None and not isinstance(qualname, str):
46
+ qualname = None
47
+ return (filename, qualname)
48
+
49
+ def _is_same_action_definition(self, existing: AsyncAction, new: AsyncAction) -> bool:
50
+ if existing is new:
51
+ return True
52
+ existing_fingerprint = self._source_fingerprint(existing)
53
+ new_fingerprint = self._source_fingerprint(new)
54
+ if existing_fingerprint == (None, None) or new_fingerprint == (None, None):
55
+ return False
56
+ return existing_fingerprint == new_fingerprint
57
+
32
58
  def register(self, module: str, name: str, func: AsyncAction) -> None:
33
59
  """Register an action with its module and name.
34
60
 
@@ -38,11 +64,16 @@ class ActionRegistry:
38
64
  func: The async function to execute.
39
65
 
40
66
  Raises:
41
- ValueError: If an action with the same module:name is already registered.
67
+ ValueError: If an action with the same module:name is already registered
68
+ with a different implementation.
42
69
  """
43
70
  key = _make_key(module, name)
44
71
  with self._lock:
45
- if key in self._actions:
72
+ existing = self._actions.get(key)
73
+ if existing is not None:
74
+ if self._is_same_action_definition(existing.func, func):
75
+ self._actions[key] = _ActionEntry(module=module, name=name, func=func)
76
+ return
46
77
  raise ValueError(f"action '{module}:{name}' already registered")
47
78
  self._actions[key] = _ActionEntry(module=module, name=name, func=func)
48
79
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rappel
3
- Version: 0.4.1
3
+ Version: 0.4.5
4
4
  Summary: Distributed & durable background events in Python
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: googleapis-common-protos>=1.72.0
@@ -155,19 +155,26 @@ If you have a particular workflow that you think should be working but isn't yet
155
155
 
156
156
  The main rappel configuration is done through env vars, which is what you'll typically use in production when using a docker deployment pipeline. If we can't find an environment parameter we will fallback to looking for an .env that specifies it within your local filesystem.
157
157
 
158
+ These are the primary environment parameters that you'll likely want to customize for your deployment:
159
+
158
160
  | Environment Variable | Description | Default | Example |
159
161
  |---------------------|-------------|---------|---------|
160
- | `DATABASE_URL` | PostgreSQL connection string for the rappel server | (required) | `postgresql://user:pass@localhost:5433/rappel` |
161
- | `RAPPEL_HTTP_ADDR` | HTTP bind address for `rappel-bridge` | `127.0.0.1:24117` | `0.0.0.0:24117` |
162
- | `RAPPEL_GRPC_ADDR` | gRPC bind address for `rappel-bridge` | HTTP port + 1 | `0.0.0.0:24118` |
162
+ | `RAPPEL_DATABASE_URL` | PostgreSQL connection string for the rappel server | (required on bridge &workers ) | `postgresql://user:pass@localhost:5433/rappel` |
163
163
  | `RAPPEL_WORKER_COUNT` | Number of Python worker processes | `num_cpus` | `8` |
164
164
  | `RAPPEL_CONCURRENT_PER_WORKER` | Max concurrent actions per worker | `10` | `20` |
165
165
  | `RAPPEL_USER_MODULE` | Python module preloaded into each worker | none | `my_app.actions` |
166
166
  | `RAPPEL_POLL_INTERVAL_MS` | Poll interval for the dispatch loop (ms) | `100` | `50` |
167
- | `RAPPEL_BATCH_SIZE` | Max actions fetched per poll | `workers * concurrent_per_worker` | `200` |
168
167
  | `RAPPEL_WEBAPP_ENABLED` | Enable the web dashboard | `false` | `true` |
169
168
  | `RAPPEL_WEBAPP_ADDR` | Web dashboard bind address | `0.0.0.0:24119` | `0.0.0.0:8080` |
170
169
 
170
+ We expect that you won't need to modify the following env parameters, but we provide them for convenience:
171
+
172
+ | Environment Variable | Description | Default | Example |
173
+ |---------------------|-------------|---------|---------|
174
+ | `RAPPEL_HTTP_ADDR` | HTTP bind address for `rappel-bridge` | `127.0.0.1:24117` | `0.0.0.0:24117` |
175
+ | `RAPPEL_GRPC_ADDR` | gRPC bind address for `rappel-bridge` | HTTP port + 1 | `0.0.0.0:24118` |
176
+ | `RAPPEL_BATCH_SIZE` | Max actions fetched per poll | `workers * concurrent_per_worker` | `200` |
177
+
171
178
  ## Philosophy
172
179
 
173
180
  Background jobs in webapps are so frequently used that they should really be a primitive of your fullstack library: database, backend, frontend, _and_ background jobs. Otherwise you're stuck in a situation where users either have to always make blocking requests to an API or you spin up ephemeral tasks that will be killed during re-deployments or an accidental docker crash.
@@ -7,26 +7,26 @@ proto/messages_pb2.pyi,sha256=lzv_RubGC8ZNVwh70FQr6DI9MECKHbvmVrL0hcBUDPc,39801
7
7
  proto/messages_pb2_grpc.py,sha256=6MhQxDSSTSiW55qZh7nIRtiEsuBp5t9tCbcZxiuWcF4,16252
8
8
  proto/messages_pb2_grpc.pyi,sha256=dAauApqvlu5Ij7JncrOIhKgcQZJEeV8FrdoJydMbfRo,12702
9
9
  rappel/__init__.py,sha256=2jnekFrCK42PbFxBiXKLa5wzzYcIy7VQ1zFizv1ZUpo,1391
10
- rappel/actions.py,sha256=wUcmBCLjMb-406T7N5S-uR73p9grKbfomXLNjsQvFpo,2735
10
+ rappel/actions.py,sha256=Zf3klKHNNl9UjgEkETMQWbn4wS2Ya-6JGnaRxUo-yW8,3955
11
11
  rappel/bridge.py,sha256=t-j6k3rHO84qIoOB_VFCUJ87RHKwDDHejxXkNsfQfi0,8219
12
- rappel/dependencies.py,sha256=40NiWV9fsn-_G5XivCUz2OMNoYC2H6cdVAQG7ziEhS0,5347
12
+ rappel/dependencies.py,sha256=OkgxNG1mXMGepxmDMPxBwqd8Y4ebyIMoxOTbb33FQ9M,5789
13
13
  rappel/exceptions.py,sha256=mLkNf1a44rbRxuFZp9saiSp0Ec3Y4gXPXI7Ozkhdt1U,348
14
14
  rappel/formatter.py,sha256=mNLJ24nkl5zN-zXe_ObpZGeC__Pyv-7J-iPde-wLvbM,3223
15
15
  rappel/ir_builder.py,sha256=wBLdp0pndpBSmzLXGIVGZJ6p4k4OlNi30xErY2Z7ZDk,128070
16
16
  rappel/logger.py,sha256=auFfr0gdVP9J53OiADhJlCSdS9As-1DA14WNtAqgv3Y,1157
17
- rappel/registry.py,sha256=AZftT8r-Lsb0JgX6vbbnydqegheYXViYsCzF_NoqX40,2302
17
+ rappel/registry.py,sha256=xrUNZqeKtwuqKkHzvzCmNsMtO34m-REQBWdcZyIirMQ,3632
18
18
  rappel/schedule.py,sha256=k_DD2BZBnJCK60VoOLlIJh6QHIIMiEnSOKsUSRved_I,9464
19
19
  rappel/serialization.py,sha256=k4uq_Wka5ptVYp0SIjXD5zflbmgxUV05pNmQs-b5i8o,8028
20
20
  rappel/worker.py,sha256=0iZkruFbGBXLLZNmENqxGF4f-ot5v9m0HzWNt8pT24s,7027
21
21
  rappel/workflow.py,sha256=Jv6SzPhLxS6oNfiKFCbp7GroWK8rGY1pMd8JqoVEPDM,8270
22
22
  rappel/workflow_runtime.py,sha256=GBLaAi1MB7rQmTtL7adAJt9rDLoWS9n52aD1YcJA-ZI,4403
23
- rappel/bin/boot-rappel-singleton.exe,sha256=_qbWkDd4tfd4WkfMW6-m1Z1SxFtGE06UXgZO024TVrU,5104128
24
- rappel/bin/rappel-bridge.exe,sha256=kOGR0ENIJ_c3qjpz85yFEhaVnBmvTezclXchCk-Z79g,9680896
25
- rappel/bin/start-workers.exe,sha256=wKdShH7oKt0Gmhkvkhx0xBOR1TvPD-CytRBvyli0cAw,15662592
26
- rappel-0.4.1.dist-info/METADATA,sha256=xUr4y0umv-V16uvqR9UO3LmaswP-eGo-DTiUjYl5Lbk,15381
27
- rappel-0.4.1.dist-info/entry_points.txt,sha256=h9D-AufOUWpdE7XjnyZyQCc-kER-ZIKj1Jryc1JNL_I,53
28
- rappel-0.4.1.dist-info/RECORD,,
29
- rappel-0.4.1.data/scripts/boot-rappel-singleton.exe,sha256=_qbWkDd4tfd4WkfMW6-m1Z1SxFtGE06UXgZO024TVrU,5104128
30
- rappel-0.4.1.data/scripts/rappel-bridge.exe,sha256=kOGR0ENIJ_c3qjpz85yFEhaVnBmvTezclXchCk-Z79g,9680896
31
- rappel-0.4.1.data/scripts/start-workers.exe,sha256=wKdShH7oKt0Gmhkvkhx0xBOR1TvPD-CytRBvyli0cAw,15662592
32
- rappel-0.4.1.dist-info/WHEEL,sha256=phIoPJnECdbLLKrTiGU1mv92w_v6wBxRMAejLbbrKno,94
23
+ rappel/bin/boot-rappel-singleton.exe,sha256=FeJdZiFVoesqrpG8_ESSyXGrVkSDB4dsqdXta51fygI,5104128
24
+ rappel/bin/rappel-bridge.exe,sha256=JpnSY7RYd1-NO3kPfuiS__g6tTMdUjQMe18rmsf-Wls,9680896
25
+ rappel/bin/start-workers.exe,sha256=nCvJWiNIrGAwlxzd0hFVJjI6MEaGDQlRXwPwmjxmj8A,15667712
26
+ rappel-0.4.5.dist-info/METADATA,sha256=ddaik4XZC1PT3HUbybTfnCyu9lUINfi4fd8JmBbcIEA,15738
27
+ rappel-0.4.5.dist-info/entry_points.txt,sha256=h9D-AufOUWpdE7XjnyZyQCc-kER-ZIKj1Jryc1JNL_I,53
28
+ rappel-0.4.5.dist-info/RECORD,,
29
+ rappel-0.4.5.data/scripts/boot-rappel-singleton.exe,sha256=FeJdZiFVoesqrpG8_ESSyXGrVkSDB4dsqdXta51fygI,5104128
30
+ rappel-0.4.5.data/scripts/rappel-bridge.exe,sha256=JpnSY7RYd1-NO3kPfuiS__g6tTMdUjQMe18rmsf-Wls,9680896
31
+ rappel-0.4.5.data/scripts/start-workers.exe,sha256=nCvJWiNIrGAwlxzd0hFVJjI6MEaGDQlRXwPwmjxmj8A,15667712
32
+ rappel-0.4.5.dist-info/WHEEL,sha256=phIoPJnECdbLLKrTiGU1mv92w_v6wBxRMAejLbbrKno,94
File without changes