drf-pollable 0.1.0__py3-none-any.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.
@@ -0,0 +1,82 @@
1
+ """drf-pollable -- auto-generate polling (status) APIs for Django REST Framework.
2
+
3
+ Point it at a model field and you get a ready-to-poll endpoint::
4
+
5
+ from drf_pollable import register_poll
6
+
7
+ register_poll(
8
+ "order-status",
9
+ source="shop.Order.status",
10
+ min_poll_interval=5,
11
+ done_when={"delivered", "cancelled"},
12
+ )
13
+
14
+ # GET /poll/order-status/42/
15
+ # 200 ETag: "a1b2" Retry-After: 5
16
+ # {"value": "shipped", "done": false, "next_poll_in": 5, ...}
17
+
18
+ See the README for the other declaration styles, the interval-negotiation rules,
19
+ and the rate-limiting layers.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any
25
+
26
+ __version__ = "0.1.0"
27
+
28
+ __all__ = [
29
+ "__version__",
30
+ "CallableSource",
31
+ "FieldSource",
32
+ "InstanceSource",
33
+ "PollEndpoint",
34
+ "PollRegistration",
35
+ "PollResult",
36
+ "PollRouter",
37
+ "PollSource",
38
+ "PollView",
39
+ "PollableConfigurationError",
40
+ "PollingMixin",
41
+ "QuerySetSource",
42
+ "poll_urls",
43
+ "register_poll",
44
+ "registry",
45
+ ]
46
+
47
+ #: Import lazily so that merely importing this package (which Django does while
48
+ #: populating INSTALLED_APPS) never pulls in DRF before settings are ready.
49
+ _LAZY: dict[str, tuple[str, str]] = {
50
+ "CallableSource": (".sources", "CallableSource"),
51
+ "FieldSource": (".sources", "FieldSource"),
52
+ "InstanceSource": (".sources", "InstanceSource"),
53
+ "PollResult": (".sources", "PollResult"),
54
+ "PollSource": (".sources", "PollSource"),
55
+ "QuerySetSource": (".sources", "QuerySetSource"),
56
+ "PollEndpoint": (".registration", "PollEndpoint"),
57
+ "PollRegistration": (".registration", "PollRegistration"),
58
+ "register_poll": (".registration", "register_poll"),
59
+ "registry": (".registration", "registry"),
60
+ "PollRouter": (".routers", "PollRouter"),
61
+ "poll_urls": (".routers", "poll_urls"),
62
+ "PollingMixin": (".mixins", "PollingMixin"),
63
+ "PollView": (".views", "PollView"),
64
+ "PollableConfigurationError": (".exceptions", "PollableConfigurationError"),
65
+ }
66
+
67
+
68
+ def __getattr__(name: str) -> Any:
69
+ try:
70
+ module_path, attribute = _LAZY[name]
71
+ except KeyError:
72
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
73
+
74
+ from importlib import import_module
75
+
76
+ value = getattr(import_module(module_path, __name__), attribute)
77
+ globals()[name] = value # cache so later lookups skip this path
78
+ return value
79
+
80
+
81
+ def __dir__() -> list[str]: # pragma: no cover - REPL nicety
82
+ return sorted(__all__)
drf_pollable/apps.py ADDED
@@ -0,0 +1,31 @@
1
+ """App config -- discovers every declaration style once the registry is ready."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from django.apps import AppConfig
6
+ from django.utils.module_loading import autodiscover_modules
7
+
8
+
9
+ class DrfPollableConfig(AppConfig):
10
+ name = "drf_pollable"
11
+ verbose_name = "DRF Pollable"
12
+ default_auto_field = "django.db.models.AutoField"
13
+
14
+ #: ``ready()`` can run more than once (notably under the test runner);
15
+ #: discovery is idempotent but we skip the repeat work anyway.
16
+ _discovered = False
17
+
18
+ def ready(self):
19
+ if self.__class__._discovered:
20
+ return
21
+ self.__class__._discovered = True
22
+
23
+ from .registration import registry
24
+
25
+ # Order matters, and later sources win:
26
+ # 1. polls.py in each app -- the common case
27
+ # 2. model PollingMeta -- declared next to the data
28
+ # 3. settings ENDPOINTS -- central, so ops can override the above
29
+ autodiscover_modules("polls")
30
+ registry.load_from_models()
31
+ registry.load_from_settings()
drf_pollable/conf.py ADDED
@@ -0,0 +1,154 @@
1
+ """Two-layer configuration for drf-pollable.
2
+
3
+ Effective value for any option resolves as::
4
+
5
+ per-endpoint value -> settings.DRF_POLLABLE["DEFAULTS"] -> BUILTIN_DEFAULTS
6
+
7
+ Projects configure globals in ``settings.py``::
8
+
9
+ DRF_POLLABLE = {
10
+ "DEFAULTS": {"min_poll_interval": 5, "jitter": 0.1},
11
+ "ENDPOINTS": {
12
+ "order-status": {"source": "shop.Order.status"},
13
+ },
14
+ }
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+ from django.conf import settings as django_settings
22
+ from django.core.signals import setting_changed
23
+ from django.dispatch import receiver
24
+
25
+ #: Built-in fallbacks. Every option a registration understands appears here so
26
+ #: that ``resolve_config()`` always returns a fully-populated mapping and call
27
+ #: sites never need ``.get(key, fallback)``.
28
+ BUILTIN_DEFAULTS: dict[str, Any] = {
29
+ # --- kill switch -------------------------------------------------------
30
+ "enabled": True,
31
+ # --- interval negotiation (see README "Who decides the interval?") -----
32
+ # Hard floor the server is willing to be polled at.
33
+ "min_poll_interval": 5,
34
+ # What we advertise via Retry-After. ``None`` -> falls back to the floor.
35
+ "suggested_poll_interval": None,
36
+ # Ceiling used when clamping a client-requested interval / backoff growth.
37
+ "max_poll_interval": 3600,
38
+ # When True a too-early poll is rejected with 429 instead of merely hinted.
39
+ "enforce_interval": False,
40
+ # Query param a client may use to request its own cadence. None disables.
41
+ "client_interval_param": "interval",
42
+ # "fixed" | "exponential" -- how the *suggested* interval grows while the
43
+ # value keeps coming back unchanged.
44
+ "backoff": "fixed",
45
+ "backoff_factor": 2.0,
46
+ # Ceiling for backoff growth. ``None`` -> max_poll_interval.
47
+ "backoff_max": None,
48
+ # Random spread applied to the advertised interval, as a fraction (0.1 =
49
+ # +/-10%). Prevents synchronised clients re-polling in lockstep.
50
+ "jitter": 0.0,
51
+ # --- response shape ----------------------------------------------------
52
+ # True -> {"value": ..., "done": ..., "next_poll_in": ...}
53
+ # False -> the bare serialized value, with the metadata in headers only.
54
+ "envelope": True,
55
+ "send_etag": True,
56
+ "send_last_modified": True,
57
+ # --- performance -------------------------------------------------------
58
+ # Seconds to cache the computed value+version. 0 disables.
59
+ "cache_timeout": 0,
60
+ # Keep each user's cached value separate. Only set False when the data is
61
+ # genuinely identical for every caller -- otherwise one user's value would
62
+ # be served to another.
63
+ "cache_vary_on_user": True,
64
+ # --- hints -------------------------------------------------------------
65
+ # After this many consecutive *changed* polls, add an X-Poll-Upgrade-Hint
66
+ # header suggesting websockets/SSE would suit this data better. 0 disables.
67
+ "upgrade_hint_after": 0,
68
+ # --- DRF plumbing ------------------------------------------------------
69
+ "throttle_scope": None,
70
+ "throttle_classes": None,
71
+ "permission_classes": None,
72
+ "authentication_classes": None,
73
+ # --- lifecycle ---------------------------------------------------------
74
+ # RFC 8594 Sunset header, e.g. "2027-01-01" or a date/datetime.
75
+ "sunset": None,
76
+ "deprecation_message": None,
77
+ # --- routing -----------------------------------------------------------
78
+ "url_prefix": "poll",
79
+ "lookup_field": "pk",
80
+ "lookup_url_kwarg": None,
81
+ }
82
+
83
+ _cache: dict[str, Any] = {}
84
+
85
+
86
+ def _source_keys() -> frozenset[str]:
87
+ """Keys that describe *what* to poll rather than *how*.
88
+
89
+ Imported lazily so ``conf`` stays importable before the app registry is up.
90
+ """
91
+ from .sources import SOURCE_ARG_NAMES
92
+
93
+ return SOURCE_ARG_NAMES | {"as_list"}
94
+
95
+
96
+ def _raw() -> dict[str, Any]:
97
+ return getattr(django_settings, "DRF_POLLABLE", None) or {}
98
+
99
+
100
+ def global_defaults() -> dict[str, Any]:
101
+ """BUILTIN_DEFAULTS overlaid with ``DRF_POLLABLE["DEFAULTS"]``."""
102
+ if "defaults" not in _cache:
103
+ merged = dict(BUILTIN_DEFAULTS)
104
+ merged.update(_raw().get("DEFAULTS") or {})
105
+ unknown = set(merged) - set(BUILTIN_DEFAULTS)
106
+ if unknown:
107
+ raise ImproperlyConfiguredDefaults(sorted(unknown))
108
+ _cache["defaults"] = merged
109
+ return _cache["defaults"]
110
+
111
+
112
+ def settings_endpoints() -> dict[str, dict[str, Any]]:
113
+ """Endpoints declared centrally via ``DRF_POLLABLE["ENDPOINTS"]``."""
114
+ return dict(_raw().get("ENDPOINTS") or {})
115
+
116
+
117
+ def resolve_config(overrides: dict[str, Any] | None = None) -> dict[str, Any]:
118
+ """Merge per-endpoint ``overrides`` over the global defaults.
119
+
120
+ Unknown keys are rejected loudly: a silently-ignored typo in
121
+ ``min_poll_intervall`` would otherwise look like the option simply had no
122
+ effect.
123
+ """
124
+ config = dict(global_defaults())
125
+ source_keys = _source_keys()
126
+ for key, value in (overrides or {}).items():
127
+ if key in source_keys:
128
+ continue
129
+ if key not in BUILTIN_DEFAULTS:
130
+ raise ImproperlyConfiguredDefaults([key])
131
+ config[key] = value
132
+
133
+ # Derived fallbacks, applied once so the view layer can read them directly.
134
+ if config["suggested_poll_interval"] is None:
135
+ config["suggested_poll_interval"] = config["min_poll_interval"]
136
+ if config["backoff_max"] is None:
137
+ config["backoff_max"] = config["max_poll_interval"]
138
+ return config
139
+
140
+
141
+ class ImproperlyConfiguredDefaults(Exception):
142
+ """Raised when DRF_POLLABLE contains options drf-pollable does not know."""
143
+
144
+ def __init__(self, keys: list[str]):
145
+ known = ", ".join(sorted(BUILTIN_DEFAULTS))
146
+ super().__init__(
147
+ f"Unknown drf-pollable option(s): {', '.join(keys)}. Valid options are: {known}"
148
+ )
149
+
150
+
151
+ @receiver(setting_changed)
152
+ def _reset_cache(sender, setting, **kwargs): # pragma: no cover - test plumbing
153
+ if setting == "DRF_POLLABLE":
154
+ _cache.clear()
@@ -0,0 +1,52 @@
1
+ """The JSON body a polling endpoint returns.
2
+
3
+ By default responses are *enveloped* so the client gets the polling metadata it
4
+ needs to pace itself alongside the value::
5
+
6
+ {
7
+ "value": "shipped",
8
+ "version": "9f2c...",
9
+ "done": false,
10
+ "next_poll_in": 5,
11
+ "polled_at": "2026-09-28T10:12:03.114Z"
12
+ }
13
+
14
+ Set ``envelope=False`` to return the bare value instead; the same metadata is
15
+ still available in the ``ETag`` and ``Retry-After`` headers.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any
21
+
22
+ from django.utils import timezone
23
+
24
+ #: Envelope keys the package owns. A source's ``extra`` may not overwrite these.
25
+ RESERVED_KEYS = frozenset({"value", "version", "done", "next_poll_in", "polled_at"})
26
+
27
+
28
+ def build_envelope(result, *, version: str, next_poll_in: int, config: dict[str, Any]) -> Any:
29
+ """Build the response body for ``result``."""
30
+ if not config["envelope"]:
31
+ return result.value
32
+
33
+ body: dict[str, Any] = {
34
+ "value": result.value,
35
+ "version": version,
36
+ "done": bool(result.done),
37
+ "next_poll_in": next_poll_in,
38
+ "polled_at": timezone.now().isoformat(),
39
+ }
40
+
41
+ if result.progress is not None:
42
+ body["progress"] = result.progress
43
+ if result.eta_seconds is not None:
44
+ body["eta_seconds"] = result.eta_seconds
45
+
46
+ # Source-supplied extras (display label, count, cursor, ...). Reserved keys
47
+ # are dropped rather than silently shadowing the envelope contract.
48
+ for key, value in (result.extra or {}).items():
49
+ if key not in RESERVED_KEYS:
50
+ body[key] = value
51
+
52
+ return body
@@ -0,0 +1,13 @@
1
+ """Exceptions raised by drf-pollable."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class PollableConfigurationError(Exception):
7
+ """Raised when a polling endpoint is declared with an invalid configuration.
8
+
9
+ This is a programming error (bad ``source`` string, unknown model, duplicate
10
+ endpoint name, missing field allow-list), so it deliberately is *not* a DRF
11
+ ``APIException`` -- it should blow up loudly at registration or first use
12
+ rather than quietly becoming a 500 for end users.
13
+ """
@@ -0,0 +1,101 @@
1
+ """HTTP conditional-request helpers.
2
+
3
+ Polling is only cheap if an unchanged poll is cheap. These helpers implement the
4
+ ``ETag`` / ``If-None-Match`` and ``Last-Modified`` / ``If-Modified-Since``
5
+ handshake that lets an unchanged poll return a bodyless ``304``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import time
13
+ from datetime import date, datetime, timezone
14
+ from typing import Any
15
+
16
+ from django.utils.http import http_date, parse_etags, parse_http_date_safe, quote_etag
17
+
18
+
19
+ def make_version(payload: Any) -> str:
20
+ """Derive a short, stable version token by hashing ``payload``.
21
+
22
+ Deliberately hashes the *value* rather than trusting a timestamp field. An
23
+ ``auto_now`` column does not fire on ``QuerySet.update()``, so a
24
+ timestamp-derived token would miss real changes and hand the client a stale
25
+ ``304``. Hashing costs almost nothing here -- the value is already loaded.
26
+ """
27
+ blob = json.dumps(payload, sort_keys=True, default=str, separators=(",", ":"))
28
+ return hashlib.blake2b(blob.encode("utf-8"), digest_size=10).hexdigest()
29
+
30
+
31
+ def etag_for(version: str) -> str:
32
+ """Return a correctly quoted strong ETag for ``version``."""
33
+ return quote_etag(str(version))
34
+
35
+
36
+ def if_none_match_matches(request, etag: str) -> bool:
37
+ """True when the client's ``If-None-Match`` already holds ``etag``."""
38
+ header = request.META.get("HTTP_IF_NONE_MATCH")
39
+ if not header:
40
+ return False
41
+ header = header.strip()
42
+ if header == "*":
43
+ return True
44
+ try:
45
+ client_etags = parse_etags(header)
46
+ except (ValueError, TypeError):
47
+ return False
48
+ # Compare weak-insensitively: W/"x" and "x" refer to the same version here.
49
+ target = _strip_weak(etag)
50
+ return any(_strip_weak(candidate) == target for candidate in client_etags)
51
+
52
+
53
+ def if_modified_since_satisfied(
54
+ request, last_modified: datetime | None, now: float | None = None
55
+ ) -> bool:
56
+ """True when the resource provably has not changed since the client's copy.
57
+
58
+ ``Last-Modified`` is a *weak* validator: HTTP dates carry only one-second
59
+ resolution, so a value that changes in the same second the client fetched
60
+ it is indistinguishable from one that did not. Requiring the modification
61
+ to sit in a strictly earlier second than this request closes that window --
62
+ the cost is a single full response for a value that just changed, instead
63
+ of a ``304`` that strands the client on a stale copy.
64
+ """
65
+ if last_modified is None:
66
+ return False
67
+ header = request.META.get("HTTP_IF_MODIFIED_SINCE")
68
+ if not header:
69
+ return False
70
+ since = parse_http_date_safe(header)
71
+ if since is None:
72
+ return False
73
+ modified_at = int(last_modified.timestamp())
74
+ if modified_at > since:
75
+ return False
76
+ # Within the current second the timestamp cannot be trusted yet.
77
+ return modified_at < int(time.time() if now is None else now)
78
+
79
+
80
+ def format_last_modified(value: datetime) -> str:
81
+ return http_date(value.timestamp())
82
+
83
+
84
+ def format_sunset(value: date | datetime | str) -> str:
85
+ """Format a ``Sunset`` header value (RFC 8594 -- an HTTP date)."""
86
+ if isinstance(value, str):
87
+ return value
88
+ if isinstance(value, datetime):
89
+ moment = value
90
+ else:
91
+ moment = datetime(value.year, value.month, value.day, tzinfo=timezone.utc)
92
+ if moment.tzinfo is None:
93
+ moment = moment.replace(tzinfo=timezone.utc)
94
+ return http_date(moment.timestamp())
95
+
96
+
97
+ def _strip_weak(etag: str) -> str:
98
+ etag = etag.strip()
99
+ if etag.startswith(("W/", "w/")):
100
+ etag = etag[2:]
101
+ return etag.strip('"')
@@ -0,0 +1,76 @@
1
+ """Interval negotiation between client and server.
2
+
3
+ The rule, in one line: **the client may always ask to poll more slowly, never
4
+ faster than the server allows.**
5
+
6
+ * ``min_poll_interval`` is the server's hard floor (self-protection).
7
+ * ``suggested_poll_interval`` is what we advertise via ``Retry-After``.
8
+ * Exponential backoff grows the suggestion while the value keeps coming back
9
+ unchanged, so idle clients quiet down on their own.
10
+ * Jitter spreads synchronised clients so they do not re-poll in lockstep.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import random
16
+ from typing import Any
17
+
18
+
19
+ def parse_requested_interval(request, config: dict[str, Any]) -> float | None:
20
+ """Read the client's preferred cadence from the query string.
21
+
22
+ Garbage input is ignored rather than raising -- a malformed ``?interval=``
23
+ should not break a status poll.
24
+ """
25
+ param = config.get("client_interval_param")
26
+ if not param:
27
+ return None
28
+ raw = request.query_params.get(param) if hasattr(request, "query_params") else None
29
+ if raw in (None, ""):
30
+ return None
31
+ try:
32
+ value = float(raw)
33
+ except (TypeError, ValueError):
34
+ return None
35
+ return value if value > 0 else None
36
+
37
+
38
+ def compute_next_interval(
39
+ config: dict[str, Any],
40
+ *,
41
+ unchanged_streak: int = 0,
42
+ requested: float | None = None,
43
+ apply_jitter: bool = True,
44
+ ) -> int:
45
+ """Return the interval (seconds) to advertise to the client.
46
+
47
+ ``unchanged_streak`` is how many consecutive polls have returned the same
48
+ version; it drives exponential backoff.
49
+ """
50
+ base = float(config["suggested_poll_interval"])
51
+
52
+ if config["backoff"] == "exponential" and unchanged_streak > 0:
53
+ factor = float(config["backoff_factor"]) ** unchanged_streak
54
+ base = min(base * factor, float(config["backoff_max"]))
55
+
56
+ # The client may request a *slower* cadence; we take the larger of the two
57
+ # so a client request can never undercut the server's current suggestion.
58
+ if requested is not None:
59
+ base = max(base, float(requested))
60
+
61
+ floor = float(config["min_poll_interval"])
62
+ ceiling = float(config["max_poll_interval"])
63
+ base = max(floor, min(base, ceiling))
64
+
65
+ if apply_jitter:
66
+ base = _jitter(base, float(config.get("jitter") or 0.0), floor=floor)
67
+
68
+ return max(1, int(round(base)))
69
+
70
+
71
+ def _jitter(value: float, fraction: float, *, floor: float) -> float:
72
+ """Spread ``value`` by +/- ``fraction`` without dropping below ``floor``."""
73
+ if fraction <= 0:
74
+ return value
75
+ spread = value * fraction
76
+ return max(floor, value + random.uniform(-spread, spread))
File without changes
File without changes
@@ -0,0 +1,73 @@
1
+ """``python manage.py list_polls`` -- show every registered polling endpoint.
2
+
3
+ Answers "what is pollable, at what URL, and with which limits?" without
4
+ grepping the codebase -- useful because endpoints can be declared five
5
+ different ways.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from django.core.management.base import BaseCommand
11
+
12
+ from ...registration import registry
13
+
14
+ #: Options worth showing by default. ``--verbose`` prints the full config.
15
+ HIGHLIGHT_KEYS = (
16
+ "min_poll_interval",
17
+ "suggested_poll_interval",
18
+ "max_poll_interval",
19
+ "enforce_interval",
20
+ "backoff",
21
+ "jitter",
22
+ "throttle_scope",
23
+ "cache_timeout",
24
+ "enabled",
25
+ )
26
+
27
+
28
+ class Command(BaseCommand):
29
+ help = "List every registered drf-pollable endpoint and its effective configuration."
30
+
31
+ def add_arguments(self, parser):
32
+ parser.add_argument(
33
+ "--verbose",
34
+ action="store_true",
35
+ help="Show every configuration option, not just the notable ones.",
36
+ )
37
+ parser.add_argument(
38
+ "--name",
39
+ help="Show only the endpoint with this name.",
40
+ )
41
+
42
+ def handle(self, *args, **options):
43
+ registrations = sorted(registry, key=lambda item: item.name)
44
+ if options.get("name"):
45
+ registrations = [r for r in registrations if r.name == options["name"]]
46
+
47
+ if not registrations:
48
+ self.stdout.write(self.style.WARNING("No polling endpoints are registered."))
49
+ return
50
+
51
+ for registration in registrations:
52
+ self._print(registration, verbose=options.get("verbose", False))
53
+
54
+ self.stdout.write("")
55
+ self.stdout.write(self.style.SUCCESS(f"{len(registrations)} polling endpoint(s)."))
56
+
57
+ def _print(self, registration, *, verbose: bool):
58
+ config = registration.config
59
+ prefix = (config["url_prefix"] or "").strip("/")
60
+ url = f"/{prefix}/{registration.url_path}" if prefix else f"/{registration.url_path}"
61
+
62
+ self.stdout.write("")
63
+ self.stdout.write(self.style.MIGRATE_HEADING(registration.name))
64
+ self.stdout.write(f" source {registration.source.describe()}")
65
+ # Relative to wherever poll_urls() is mounted in the root URLconf.
66
+ self.stdout.write(f" path {url}")
67
+ self.stdout.write(f" declared {registration.origin}")
68
+
69
+ keys = sorted(config) if verbose else HIGHLIGHT_KEYS
70
+ shown = [(key, config[key]) for key in keys if key in config]
71
+ width = max((len(key) for key, _ in shown), default=0)
72
+ for key, value in shown:
73
+ self.stdout.write(f" {key.ljust(width)} {value!r}")