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.
- drf_pollable/__init__.py +82 -0
- drf_pollable/apps.py +31 -0
- drf_pollable/conf.py +154 -0
- drf_pollable/envelope.py +52 -0
- drf_pollable/exceptions.py +13 -0
- drf_pollable/headers.py +101 -0
- drf_pollable/intervals.py +76 -0
- drf_pollable/management/__init__.py +0 -0
- drf_pollable/management/commands/__init__.py +0 -0
- drf_pollable/management/commands/list_polls.py +73 -0
- drf_pollable/mixins.py +101 -0
- drf_pollable/registration.py +311 -0
- drf_pollable/routers.py +55 -0
- drf_pollable/sources.py +570 -0
- drf_pollable/state.py +173 -0
- drf_pollable/testing.py +85 -0
- drf_pollable/throttling.py +172 -0
- drf_pollable/views.py +278 -0
- drf_pollable-0.1.0.dist-info/METADATA +439 -0
- drf_pollable-0.1.0.dist-info/RECORD +23 -0
- drf_pollable-0.1.0.dist-info/WHEEL +5 -0
- drf_pollable-0.1.0.dist-info/licenses/LICENSE +21 -0
- drf_pollable-0.1.0.dist-info/top_level.txt +1 -0
drf_pollable/__init__.py
ADDED
|
@@ -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()
|
drf_pollable/envelope.py
ADDED
|
@@ -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
|
+
"""
|
drf_pollable/headers.py
ADDED
|
@@ -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}")
|