dynamicforms-fastapi-viewsets 0.3.4__tar.gz → 0.3.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 (35) hide show
  1. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/PKG-INFO +1 -1
  2. dynamicforms_fastapi_viewsets-0.3.6/fastapi_viewsets/__init__.py +1 -0
  3. dynamicforms_fastapi_viewsets-0.3.6/fastapi_viewsets/decorators/celery_viewset/__init__.py +82 -0
  4. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/decorators/celery_viewset/client.py +23 -5
  5. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +37 -20
  6. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/decorators/celery_viewset/server.py +1 -1
  7. dynamicforms_fastapi_viewsets-0.3.4/fastapi_viewsets/__init__.py +0 -1
  8. dynamicforms_fastapi_viewsets-0.3.4/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -51
  9. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/.gitignore +0 -0
  10. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/LICENSE +0 -0
  11. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/README.md +0 -0
  12. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/action_configuration.py +0 -0
  13. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/collection_viewset.py +0 -0
  14. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/conf.py +0 -0
  15. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/context/__init__.py +0 -0
  16. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/context/auth/__init__.py +0 -0
  17. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/context/auth/django.py +0 -0
  18. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/context/auth/jwt.py +0 -0
  19. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/context/auth/static.py +0 -0
  20. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/decorators/__init__.py +0 -0
  21. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/decorators/build_schema.py +0 -0
  22. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
  23. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
  24. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/decorators/route_viewset.py +0 -0
  25. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/middleware/__init__.py +0 -0
  26. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
  27. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
  28. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
  29. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/mixins.py +0 -0
  30. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/py.typed +0 -0
  31. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/response_classes.py +0 -0
  32. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/save_state/__init__.py +0 -0
  33. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/save_state/save_state.py +0 -0
  34. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/fastapi_viewsets/save_state/serialize_state.py +0 -0
  35. {dynamicforms_fastapi_viewsets-0.3.4 → dynamicforms_fastapi_viewsets-0.3.6}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dynamicforms-fastapi-viewsets
3
- Version: 0.3.4
3
+ Version: 0.3.6
4
4
  Summary: Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart.
5
5
  Project-URL: Homepage, https://github.com/dynamicforms/fastapi-viewsets
6
6
  Project-URL: Repository, https://github.com/dynamicforms/fastapi-viewsets
@@ -0,0 +1 @@
1
+ __version__ = "0.3.6"
@@ -0,0 +1,82 @@
1
+ import logging
2
+ import sys
3
+
4
+ from ..lifecycle_runner import LifecycleType
5
+ from .client import celery_viewset_client, get_registered_queue_keys
6
+ from .result_reader import get_result_queue_key, get_running_queue_keys, push_result, start_result_reader, stop_result_reader
7
+ from .server import celery_viewset_server
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # Allow explicit override via set_is_celery_worker()
12
+ _is_celery_worker: bool | None = None
13
+
14
+
15
+ def set_is_celery_worker(is_worker: bool) -> None:
16
+ """Explicitly set whether we are running in a Celery worker context."""
17
+ global _is_celery_worker
18
+ _is_celery_worker = is_worker
19
+
20
+
21
+ def _detect_is_celery_worker() -> bool:
22
+ """Auto-detect if we are running inside a Celery worker by inspecting sys.argv."""
23
+ if _is_celery_worker is not None:
24
+ return _is_celery_worker
25
+ return len(sys.argv) > 0 and "celery" in sys.argv[0].lower()
26
+
27
+
28
+ def celery_viewset(celery_app, task_prefix: str, lifecycle: LifecycleType = "singleton", redis_client=None):
29
+ """
30
+ Convenience decorator that auto-detects whether to use client or server mode.
31
+
32
+ - In a Celery worker (detected via sys.argv or set_is_celery_worker): uses celery_viewset_server
33
+ - In a FastAPI app: uses celery_viewset_client
34
+
35
+ For explicit control, use celery_viewset_client or celery_viewset_server directly.
36
+ """
37
+ if _detect_is_celery_worker():
38
+ return celery_viewset_server(celery_app, task_prefix, lifecycle=lifecycle, redis_client=redis_client)
39
+ else:
40
+ if redis_client is None:
41
+ raise ValueError("redis_client is required for celery_viewset in client (FastAPI) mode")
42
+ return celery_viewset_client(celery_app, task_prefix, redis_client=redis_client)
43
+
44
+
45
+ def check_result_readers() -> frozenset[str]:
46
+ """
47
+ Verify that every queue_key registered by a celery_viewset_client decorator (client/FastAPI
48
+ mode only) has a running result reader task, logging a warning for each one that doesn't.
49
+
50
+ celery_viewset_client decorators register their queue_key at import/decoration time, so by the
51
+ time FastAPI startup code runs, every queue_key in use is already known - call this once, right
52
+ after starting all result readers in the FastAPI lifespan. A queue_key with no running reader
53
+ means requests dispatched to that celery_viewset hang forever waiting for a result that never
54
+ arrives (the Celery task itself runs fine - only the FastAPI-side pickup is missing).
55
+
56
+ Returns the set of queue_keys that are missing a reader (empty if everything is running).
57
+ Never raises - this is a diagnostic aid, not a correctness check enforced by the library.
58
+ """
59
+ missing = get_registered_queue_keys() - get_running_queue_keys()
60
+ for queue_key in sorted(missing):
61
+ logger.warning(
62
+ "No result reader is running for queue_key=%s - requests dispatched to this "
63
+ "celery_viewset will hang waiting for a result that never arrives. Call "
64
+ "start_result_reader(redis_client, %r) in your FastAPI lifespan.",
65
+ queue_key, queue_key,
66
+ )
67
+ return frozenset(missing)
68
+
69
+
70
+ __all__ = [
71
+ "celery_viewset",
72
+ "celery_viewset_client",
73
+ "celery_viewset_server",
74
+ "set_is_celery_worker",
75
+ "start_result_reader",
76
+ "stop_result_reader",
77
+ "check_result_readers",
78
+ "push_result",
79
+ "get_result_queue_key",
80
+ "get_registered_queue_keys",
81
+ "get_running_queue_keys",
82
+ ]
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, TypeVar
8
8
  from fastapi.routing import APIRoute
9
9
  from pydantic import BaseModel
10
10
 
11
- from ...context import serialize_context
11
+ from ...context import Context, serialize_context
12
12
  from ..build_schema import build_schema
13
13
  from . import result_reader
14
14
  from .result_reader import get_result_queue_key
@@ -21,6 +21,15 @@ if TYPE_CHECKING:
21
21
  T = TypeVar("T")
22
22
  logger = logging.getLogger(__name__)
23
23
 
24
+ # queue_keys registered by celery_viewset_client so far - populated at decoration (import) time,
25
+ # used by check_result_readers() to spot a queue_key with no running result reader.
26
+ _registered_queue_keys: set[str] = set()
27
+
28
+
29
+ def get_registered_queue_keys() -> frozenset[str]:
30
+ """Return the queue_keys registered by celery_viewset_client decorators applied so far."""
31
+ return frozenset(_registered_queue_keys)
32
+
24
33
 
25
34
  def celery_viewset_client(
26
35
  celery_app: "Celery",
@@ -53,12 +62,22 @@ def celery_viewset_client(
53
62
  "queue_key": queue_key,
54
63
  "mode": "client",
55
64
  }
65
+ _registered_queue_keys.add(queue_key)
56
66
 
57
67
  return cls
58
68
 
59
69
  return decorator
60
70
 
61
71
 
72
+ async def _serialize_value(value):
73
+ """Convert a Context or BaseModel value into a JSON-safe structure for Celery/Kombu transport."""
74
+ if isinstance(value, Context):
75
+ return await serialize_context(value.raw())
76
+ if isinstance(value, BaseModel):
77
+ return value.model_dump(mode="json")
78
+ return value
79
+
80
+
62
81
  def _patch_method(cls: type, original_endpoint, task_name: str, celery_app, redis_client, queue_key: str):
63
82
  """Replace the method on cls with an async version that sends a Celery task and awaits the result."""
64
83
  method_name = original_endpoint.__name__
@@ -71,18 +90,17 @@ def _patch_method(cls: type, original_endpoint, task_name: str, celery_app, redi
71
90
  result_reader.register_future(correlation_id, future)
72
91
 
73
92
  try:
93
+ serializable_args = [await _serialize_value(v) for v in args]
74
94
  serializable_kwargs = {}
75
95
  for k, v in kwargs.items():
76
96
  if k == "context":
77
97
  serializable_kwargs[k] = await serialize_context(v.raw())
78
- elif isinstance(v, BaseModel):
79
- serializable_kwargs[k] = v.model_dump()
80
98
  else:
81
- serializable_kwargs[k] = v
99
+ serializable_kwargs[k] = await _serialize_value(v)
82
100
  logger.info("Celery task scheduling: %s (correlation_id=%s)", task_name, correlation_id)
83
101
  celery_app.send_task(
84
102
  task_name,
85
- args=args,
103
+ args=serializable_args,
86
104
  kwargs={**serializable_kwargs, "_correlation_id": correlation_id, "_result_queue_key": queue_key},
87
105
  )
88
106
  return await future
@@ -14,8 +14,8 @@ logger = logging.getLogger(__name__)
14
14
  # Global registry: correlation_id -> asyncio.Future
15
15
  _pending_futures: dict[str, asyncio.Future] = {}
16
16
 
17
- # Global reader task reference
18
- _reader_task: asyncio.Task | None = None
17
+ # Global reader task registry, keyed by queue_key - one reader per Celery viewset task_prefix
18
+ _reader_tasks: dict[str, asyncio.Task] = {}
19
19
 
20
20
 
21
21
  def register_future(correlation_id: str, future: asyncio.Future) -> None:
@@ -78,22 +78,39 @@ def push_result(redis_client: "redis.Redis", queue_key: str, correlation_id: str
78
78
  redis_client.rpush(queue_key, json.dumps(data))
79
79
 
80
80
 
81
+ def get_running_queue_keys() -> frozenset[str]:
82
+ """Return the queue_keys that currently have a running (not done) reader task."""
83
+ return frozenset(key for key, task in _reader_tasks.items() if not task.done())
84
+
85
+
81
86
  async def start_result_reader(redis_client: "redis.Redis", queue_key: str, poll_interval: float = 0.05) -> asyncio.Task:
82
- """Start the background result reader task. Should be called on FastAPI startup."""
83
- global _reader_task
84
- if _reader_task is not None and not _reader_task.done():
85
- return _reader_task
86
- _reader_task = asyncio.create_task(result_reader_loop(redis_client, queue_key, poll_interval))
87
- return _reader_task
88
-
89
-
90
- async def stop_result_reader() -> None:
91
- """Stop the background result reader task. Should be called on FastAPI shutdown."""
92
- global _reader_task
93
- if _reader_task is not None and not _reader_task.done():
94
- _reader_task.cancel()
95
- try:
96
- await _reader_task
97
- except asyncio.CancelledError:
98
- pass
99
- _reader_task = None
87
+ """
88
+ Start the background result reader task for the given queue_key.
89
+
90
+ Should be called once per queue_key (i.e. once per celery_viewset task_prefix) on FastAPI
91
+ startup. Calling it again with the same queue_key while its reader is still running returns
92
+ the existing task; a different queue_key gets its own independent reader task.
93
+ """
94
+ existing = _reader_tasks.get(queue_key)
95
+ if existing is not None and not existing.done():
96
+ return existing
97
+ task = asyncio.create_task(result_reader_loop(redis_client, queue_key, poll_interval))
98
+ _reader_tasks[queue_key] = task
99
+ return task
100
+
101
+
102
+ async def stop_result_reader(queue_key: str | None = None) -> None:
103
+ """
104
+ Stop background result reader task(s). Should be called on FastAPI shutdown.
105
+
106
+ If queue_key is given, stop only that reader; otherwise stop all running readers.
107
+ """
108
+ keys = [queue_key] if queue_key is not None else list(_reader_tasks.keys())
109
+ for key in keys:
110
+ task = _reader_tasks.pop(key, None)
111
+ if task is not None and not task.done():
112
+ task.cancel()
113
+ try:
114
+ await task
115
+ except asyncio.CancelledError:
116
+ pass
@@ -27,7 +27,7 @@ T = TypeVar("T")
27
27
  def _to_jsonable(value):
28
28
  """Recursively convert Pydantic models and lists to JSON-serializable structures."""
29
29
  if isinstance(value, BaseModel):
30
- return value.model_dump()
30
+ return value.model_dump(mode="json")
31
31
  if isinstance(value, list):
32
32
  return [_to_jsonable(v) for v in value]
33
33
  return value
@@ -1 +0,0 @@
1
- __version__ = "0.3.4"
@@ -1,51 +0,0 @@
1
- import sys
2
-
3
- from ..lifecycle_runner import LifecycleType
4
- from .client import celery_viewset_client
5
- from .result_reader import get_result_queue_key, push_result, start_result_reader, stop_result_reader
6
- from .server import celery_viewset_server
7
-
8
- # Allow explicit override via set_is_celery_worker()
9
- _is_celery_worker: bool | None = None
10
-
11
-
12
- def set_is_celery_worker(is_worker: bool) -> None:
13
- """Explicitly set whether we are running in a Celery worker context."""
14
- global _is_celery_worker
15
- _is_celery_worker = is_worker
16
-
17
-
18
- def _detect_is_celery_worker() -> bool:
19
- """Auto-detect if we are running inside a Celery worker by inspecting sys.argv."""
20
- if _is_celery_worker is not None:
21
- return _is_celery_worker
22
- return len(sys.argv) > 0 and "celery" in sys.argv[0].lower()
23
-
24
-
25
- def celery_viewset(celery_app, task_prefix: str, lifecycle: LifecycleType = "singleton", redis_client=None):
26
- """
27
- Convenience decorator that auto-detects whether to use client or server mode.
28
-
29
- - In a Celery worker (detected via sys.argv or set_is_celery_worker): uses celery_viewset_server
30
- - In a FastAPI app: uses celery_viewset_client
31
-
32
- For explicit control, use celery_viewset_client or celery_viewset_server directly.
33
- """
34
- if _detect_is_celery_worker():
35
- return celery_viewset_server(celery_app, task_prefix, lifecycle=lifecycle, redis_client=redis_client)
36
- else:
37
- if redis_client is None:
38
- raise ValueError("redis_client is required for celery_viewset in client (FastAPI) mode")
39
- return celery_viewset_client(celery_app, task_prefix, redis_client=redis_client)
40
-
41
-
42
- __all__ = [
43
- "celery_viewset",
44
- "celery_viewset_client",
45
- "celery_viewset_server",
46
- "set_is_celery_worker",
47
- "start_result_reader",
48
- "stop_result_reader",
49
- "push_result",
50
- "get_result_queue_key",
51
- ]