drf-parcel 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_parcel/__init__.py ADDED
File without changes
drf_parcel/apps.py ADDED
@@ -0,0 +1,15 @@
1
+ from django.apps import AppConfig
2
+ from django.core import checks
3
+ from django.utils.translation import gettext_lazy as _
4
+
5
+
6
+ class DrfParcelConfig(AppConfig):
7
+ """Installed so Django finds the library's catalogue and runs its checks."""
8
+
9
+ name = 'drf_parcel'
10
+ verbose_name = _('Response envelope')
11
+
12
+ def ready(self) -> None:
13
+ from drf_parcel.checks import check_settings
14
+
15
+ checks.register(check_settings)
drf_parcel/checks.py ADDED
@@ -0,0 +1,167 @@
1
+ """
2
+ What is wrong with the ``DRF_PARCEL`` setting, said when the project starts.
3
+
4
+ A setting that would only fail on the first request that reads it fails
5
+ instead on ``manage.py check`` and ``runserver`` - before a client finds it.
6
+ """
7
+
8
+ from collections.abc import Mapping, Sequence
9
+ from typing import Any
10
+
11
+ from django.apps import AppConfig
12
+ from django.core.checks import CheckMessage, Error
13
+ from django.core.exceptions import ImproperlyConfigured
14
+ from django.utils.module_loading import import_string
15
+
16
+ from drf_parcel import conf
17
+ from drf_parcel.handlers import STANDARD_CODES
18
+
19
+
20
+ def check_settings(
21
+ app_configs: Sequence[AppConfig] | None = None, **kwargs: Any
22
+ ) -> list[CheckMessage]:
23
+ """Every problem with the setting, each with what to do about it."""
24
+ try:
25
+ conf.raw()
26
+ conf.messages()
27
+ except ImproperlyConfigured as refused:
28
+ return [Error(str(refused), id='drf_parcel.E001')]
29
+
30
+ return _page_size_problems() + _error_problems()
31
+
32
+
33
+ def _page_size_problems() -> list[CheckMessage]:
34
+ problems: list[CheckMessage] = []
35
+ page_size = conf.page_size()
36
+ ceiling = conf.get('MAX_PAGE_SIZE')
37
+
38
+ # The page size may be DRF's own PAGE_SIZE, so it is named by what it is.
39
+ for name, value in (
40
+ ('The page size', page_size),
41
+ ("DRF_PARCEL['MAX_PAGE_SIZE']", ceiling),
42
+ ):
43
+ if not _is_positive_whole_number(value):
44
+ problems.append(
45
+ Error(
46
+ f'{name} must be a whole number above zero, not {value!r}.',
47
+ hint="It is DRF_PARCEL['PAGE_SIZE'], else REST_FRAMEWORK['PAGE_SIZE'].",
48
+ id='drf_parcel.E002',
49
+ )
50
+ )
51
+
52
+ if not problems and ceiling < page_size: # type: ignore[operator]
53
+ problems.append(
54
+ Error(
55
+ f"DRF_PARCEL['MAX_PAGE_SIZE'] ({ceiling}) is below the page size ({page_size}).",
56
+ hint='The ceiling on a size the client chooses cannot be below the default size.',
57
+ id='drf_parcel.E003',
58
+ )
59
+ )
60
+
61
+ if not isinstance(conf.get('CLIENT_PAGE_SIZE'), bool):
62
+ problems.append(
63
+ Error("DRF_PARCEL['CLIENT_PAGE_SIZE'] must be True or False.", id='drf_parcel.E004')
64
+ )
65
+
66
+ param = conf.get('PAGE_SIZE_QUERY_PARAM')
67
+
68
+ if not isinstance(param, str) or not param:
69
+ problems.append(
70
+ Error(
71
+ "DRF_PARCEL['PAGE_SIZE_QUERY_PARAM'] must be the name of a query parameter.",
72
+ id='drf_parcel.E005',
73
+ )
74
+ )
75
+
76
+ return problems
77
+
78
+
79
+ def _error_problems() -> list[CheckMessage]:
80
+ problems: list[CheckMessage] = []
81
+
82
+ if conf.get('FIELD_ERRORS') not in ('detailed', 'messages'):
83
+ problems.append(
84
+ Error(
85
+ "DRF_PARCEL['FIELD_ERRORS'] must be 'detailed' or 'messages'.",
86
+ id='drf_parcel.E006',
87
+ )
88
+ )
89
+
90
+ separator = conf.get('FIELD_PATH_SEPARATOR')
91
+
92
+ if not isinstance(separator, str) or not separator:
93
+ problems.append(
94
+ Error(
95
+ "DRF_PARCEL['FIELD_PATH_SEPARATOR'] must be a character or more.",
96
+ id='drf_parcel.E007',
97
+ )
98
+ )
99
+
100
+ problems.extend(_error_code_problems())
101
+
102
+ for name in ('HANDLE_INTEGRITY_ERRORS', 'RAISE_UNHANDLED_IN_DEBUG'):
103
+ if not isinstance(conf.get(name), bool):
104
+ problems.append(
105
+ Error(f"DRF_PARCEL['{name}'] must be True or False.", id='drf_parcel.E009')
106
+ )
107
+
108
+ problems.extend(_reporter_problems())
109
+ return problems
110
+
111
+
112
+ def _error_code_problems() -> list[CheckMessage]:
113
+ renamed = conf.get('ERROR_CODES')
114
+
115
+ if not isinstance(renamed, Mapping):
116
+ return [Error("DRF_PARCEL['ERROR_CODES'] must be a dictionary.", id='drf_parcel.E008')]
117
+
118
+ problems: list[CheckMessage] = []
119
+ unknown = sorted(set(renamed) - set(STANDARD_CODES))
120
+
121
+ if unknown:
122
+ problems.append(
123
+ Error(
124
+ f"DRF_PARCEL['ERROR_CODES'] renames codes the library does not send: "
125
+ f'{", ".join(unknown)}.',
126
+ hint=f'It sends {", ".join(STANDARD_CODES)}.',
127
+ id='drf_parcel.E008',
128
+ )
129
+ )
130
+
131
+ if not all(isinstance(new, str) and new for new in renamed.values()):
132
+ problems.append(
133
+ Error(
134
+ "Every new name in DRF_PARCEL['ERROR_CODES'] must be a word.",
135
+ id='drf_parcel.E008',
136
+ )
137
+ )
138
+
139
+ return problems
140
+
141
+
142
+ def _reporter_problems() -> list[CheckMessage]:
143
+ path = conf.get('REPORTER')
144
+
145
+ if path is None:
146
+ return []
147
+
148
+ try:
149
+ reporter = import_string(path) if isinstance(path, str) else None
150
+ except ImportError:
151
+ reporter = None
152
+
153
+ if not callable(reporter):
154
+ return [
155
+ Error(
156
+ f"DRF_PARCEL['REPORTER'] must be the dotted path to a function, not {path!r}.",
157
+ hint='It is called with each unexpected exception and its context.',
158
+ id='drf_parcel.E010',
159
+ )
160
+ ]
161
+
162
+ return []
163
+
164
+
165
+ def _is_positive_whole_number(value: object) -> bool:
166
+ # True is an int to Python, and is no page size.
167
+ return isinstance(value, int) and not isinstance(value, bool) and value > 0
drf_parcel/conf.py ADDED
@@ -0,0 +1,106 @@
1
+ """
2
+ The ``DRF_PARCEL`` setting, read and checked.
3
+
4
+ Every key lives in one dictionary in the project's settings, and the default
5
+ needs none of them::
6
+
7
+ DRF_PARCEL = {
8
+ 'PAGE_SIZE': 20,
9
+ 'MESSAGES': {'created': _('Saved successfully.')},
10
+ }
11
+
12
+ A key the library does not know is refused rather than ignored: a misspelt
13
+ ``PAGE_SZIE`` that is silently ignored is a page size that never changes, and
14
+ nobody can tell why.
15
+
16
+ The setting is read on every use rather than once, so a test that overrides
17
+ it - and a project that changes it - is answered by what it says now.
18
+ """
19
+
20
+ from collections.abc import Mapping
21
+ from types import MappingProxyType
22
+ from typing import Final
23
+
24
+ from django.conf import settings
25
+ from django.core.exceptions import ImproperlyConfigured
26
+ from rest_framework.settings import api_settings
27
+
28
+ #: Every key the setting may hold, and what it is when left out.
29
+ DEFAULTS: Final[Mapping[str, object]] = MappingProxyType(
30
+ {
31
+ 'PAGE_SIZE': 20,
32
+ 'MAX_PAGE_SIZE': 100,
33
+ 'CLIENT_PAGE_SIZE': False,
34
+ 'PAGE_SIZE_QUERY_PARAM': 'page_size',
35
+ 'FIELD_ERRORS': 'detailed',
36
+ 'FIELD_PATH_SEPARATOR': '.',
37
+ 'ERROR_CODES': MappingProxyType({}),
38
+ 'HANDLE_INTEGRITY_ERRORS': False,
39
+ 'RAISE_UNHANDLED_IN_DEBUG': True,
40
+ 'REPORTER': None,
41
+ 'MESSAGES': MappingProxyType({}),
42
+ }
43
+ )
44
+
45
+ #: The default success messages a project may replace, by key.
46
+ MESSAGE_KEYS: Final = ('created', 'saved', 'deleted', 'done')
47
+
48
+
49
+ def raw() -> Mapping[str, object]:
50
+ """The setting as written, refused when it is not a dictionary of known keys."""
51
+ written = getattr(settings, 'DRF_PARCEL', {})
52
+
53
+ if not isinstance(written, Mapping):
54
+ raise ImproperlyConfigured('DRF_PARCEL must be a dictionary.')
55
+
56
+ unknown = sorted(set(written) - set(DEFAULTS))
57
+
58
+ if unknown:
59
+ raise ImproperlyConfigured(
60
+ f'DRF_PARCEL has keys the library does not know: {", ".join(unknown)}. '
61
+ f'It knows {", ".join(DEFAULTS)}.'
62
+ )
63
+
64
+ return written
65
+
66
+
67
+ def get(key: str) -> object:
68
+ """One key of the setting, or its default when the project left it out."""
69
+ return raw().get(key, DEFAULTS[key])
70
+
71
+
72
+ def page_size() -> object:
73
+ """
74
+ The page size when a view names none.
75
+
76
+ ``DRF_PARCEL['PAGE_SIZE']`` when the project wrote it; else DRF's own
77
+ ``PAGE_SIZE``, so a project that already set it there keeps its size when
78
+ it adopts the library; else the library's default.
79
+ """
80
+ written = raw()
81
+
82
+ if 'PAGE_SIZE' in written:
83
+ return written['PAGE_SIZE']
84
+
85
+ if api_settings.PAGE_SIZE is not None:
86
+ return api_settings.PAGE_SIZE
87
+
88
+ return DEFAULTS['PAGE_SIZE']
89
+
90
+
91
+ def messages() -> Mapping[str, object]:
92
+ """The project's replacements for the default success messages, by key."""
93
+ replaced = get('MESSAGES')
94
+
95
+ if not isinstance(replaced, Mapping):
96
+ raise ImproperlyConfigured("DRF_PARCEL['MESSAGES'] must be a dictionary.")
97
+
98
+ unknown = sorted(set(replaced) - set(MESSAGE_KEYS))
99
+
100
+ if unknown:
101
+ raise ImproperlyConfigured(
102
+ f"DRF_PARCEL['MESSAGES'] has keys the library does not know: {', '.join(unknown)}. "
103
+ f'It knows {", ".join(MESSAGE_KEYS)}.'
104
+ )
105
+
106
+ return replaced
drf_parcel/envelope.py ADDED
@@ -0,0 +1,99 @@
1
+ """
2
+ The envelope - the one place its shape is assembled.
3
+
4
+ Every answer has the same top level, success and failure alike::
5
+
6
+ {"success": true, "status_code": 200, "message": "...", "data": {}}
7
+ {"success": true, "status_code": 200, "message": "...", "data": [], "pagination": {}}
8
+ {"success": false, "status_code": 409, "message": "...", "data": {}, "error_code": "..."}
9
+
10
+ Two places building the envelope become two shapes, so nothing else in the
11
+ library writes these keys.
12
+ """
13
+
14
+ from collections.abc import Mapping
15
+ from typing import Any
16
+
17
+ #: The keys every envelope carries, whatever it answers.
18
+ KEYS = ('success', 'status_code', 'message', 'data')
19
+
20
+
21
+ def build_envelope(
22
+ *,
23
+ success: bool,
24
+ status_code: int,
25
+ message: object,
26
+ data: Any = None,
27
+ pagination: Mapping[str, Any] | None = None,
28
+ error_code: str | None = None,
29
+ ) -> dict[str, Any]:
30
+ """
31
+ The envelope, from its parts.
32
+
33
+ ``message`` may be a lazy translation: it becomes text here, under the
34
+ language active when the answer is built. ``data`` that says nothing is
35
+ ``{}``, never ``null``.
36
+
37
+ A key is either always there or there for a reason, so a failure must
38
+ name its ``error_code`` and a success must not, and only a success is a
39
+ page.
40
+ """
41
+ if success and error_code is not None:
42
+ raise ValueError('A success carries no error_code.')
43
+
44
+ if not success and not error_code:
45
+ raise ValueError('A failure must name its error_code.')
46
+
47
+ if not success and pagination is not None:
48
+ raise ValueError('A failure is not a page.')
49
+
50
+ envelope: dict[str, Any] = {
51
+ 'success': success,
52
+ 'status_code': status_code,
53
+ 'message': str(message),
54
+ 'data': {} if data is None else data,
55
+ }
56
+
57
+ if pagination is not None:
58
+ envelope['pagination'] = dict(pagination)
59
+
60
+ if error_code is not None:
61
+ envelope['error_code'] = error_code
62
+
63
+ return envelope
64
+
65
+
66
+ def envelope_schema(
67
+ data: Mapping[str, Any],
68
+ *,
69
+ pagination: Mapping[str, Any] | None = None,
70
+ error: bool = False,
71
+ ) -> dict[str, Any]:
72
+ """
73
+ The OpenAPI schema of an envelope around ``data`` - the shape above, as a schema.
74
+
75
+ Kept beside ``build_envelope`` so that the shape and its description
76
+ change together.
77
+ """
78
+ properties: dict[str, Any] = {
79
+ # Not an enum: drf-spectacular would lift each into a component of its own.
80
+ 'success': {
81
+ 'type': 'boolean',
82
+ 'example': not error,
83
+ 'description': 'Always false on a failure.' if error else 'Always true on a success.',
84
+ },
85
+ 'status_code': {'type': 'integer'},
86
+ 'message': {'type': 'string'},
87
+ 'data': dict(data),
88
+ }
89
+ required = list(KEYS)
90
+
91
+ if pagination is not None:
92
+ properties['pagination'] = dict(pagination)
93
+ required.append('pagination')
94
+
95
+ if error:
96
+ properties['error_code'] = {'type': 'string'}
97
+ required.append('error_code')
98
+
99
+ return {'type': 'object', 'properties': properties, 'required': required}
@@ -0,0 +1,109 @@
1
+ """
2
+ A project's own errors, and the common ones the library ships.
3
+
4
+ A ``DomainError`` is DRF's ``APIException`` with one thing added: ``data``,
5
+ what a client can act on - which record is in the way, how long to wait::
6
+
7
+ class ProvinceInUse(DomainError):
8
+ status_code = 409
9
+ default_code = 'province_in_use'
10
+ default_detail = _('This province has branches and cannot be deleted.')
11
+
12
+ raise ProvinceInUse(data={'branches': 3})
13
+
14
+ Its names are DRF's own: ``default_code`` is sent as ``error_code``,
15
+ ``default_detail`` as ``message``. Being an ``APIException``, it answers
16
+ sensibly under DRF's own handler too, and a Celery task or a management
17
+ command catches it like any other exception.
18
+
19
+ Where DRF has an exception of the same meaning, the library's is also that
20
+ exception - ``NotFoundError`` is a ``NotFound`` - so code that catches DRF's,
21
+ and DRF's own handling of it, keep working.
22
+ """
23
+
24
+ from collections.abc import Mapping
25
+ from typing import Any
26
+
27
+ from django.utils.translation import gettext_lazy as _
28
+ from rest_framework import exceptions, status
29
+
30
+
31
+ class DomainError(exceptions.APIException):
32
+ """A rule of the project was broken, said in the envelope with ``data`` beside it."""
33
+
34
+ status_code: int = status.HTTP_400_BAD_REQUEST
35
+ default_code = 'error'
36
+ default_detail = _('The request could not be completed.')
37
+
38
+ def __init__(
39
+ self,
40
+ detail: Any = None,
41
+ code: str | None = None,
42
+ *,
43
+ data: Mapping[str, Any] | None = None,
44
+ ) -> None:
45
+ super().__init__(detail, code)
46
+ #: What a client can act on. Never what a person must not read elsewhere.
47
+ self.data: dict[str, Any] = dict(data or {})
48
+
49
+
50
+ class InvalidInputError(DomainError):
51
+ """What was sent cannot be used - said by a service, where no serializer is."""
52
+
53
+ default_code = 'validation_error'
54
+ default_detail = _('Some of the information provided is not valid.')
55
+
56
+
57
+ class InvalidCursorError(DomainError):
58
+ """A cursor the server cannot read: the client sent back something it was never given."""
59
+
60
+ default_code = 'invalid_cursor'
61
+ default_detail = _('The cursor is not valid.')
62
+
63
+
64
+ class AuthenticationFailedError(DomainError, exceptions.AuthenticationFailed):
65
+ """The credentials sent are wrong, or no longer good."""
66
+
67
+ status_code = status.HTTP_401_UNAUTHORIZED
68
+ default_code = 'authentication_failed'
69
+ default_detail = _('Sign in again to continue.')
70
+
71
+
72
+ class PermissionDeniedError(DomainError, exceptions.PermissionDenied):
73
+ """The caller is known, and may not do this."""
74
+
75
+ status_code = status.HTTP_403_FORBIDDEN
76
+ default_code = 'permission_denied'
77
+ default_detail = _('You do not have permission to do this.')
78
+
79
+
80
+ class NotFoundError(DomainError, exceptions.NotFound):
81
+ """The record does not exist - or does not exist for this caller."""
82
+
83
+ status_code = status.HTTP_404_NOT_FOUND
84
+ default_code = 'not_found'
85
+ default_detail = _('It was not found.')
86
+
87
+
88
+ class ConflictError(DomainError):
89
+ """The request is right, and the state of things refuses it."""
90
+
91
+ status_code = status.HTTP_409_CONFLICT
92
+ default_code = 'conflict'
93
+ default_detail = _('This conflicts with a change made in the meantime.')
94
+
95
+
96
+ class RateLimitedError(DomainError):
97
+ """Too many attempts, too quickly. Put ``retry_after`` in ``data`` when it is known."""
98
+
99
+ status_code = status.HTTP_429_TOO_MANY_REQUESTS
100
+ default_code = 'rate_limited'
101
+ default_detail = _('Too many attempts. Please wait and try again.')
102
+
103
+
104
+ class ServiceUnavailableError(DomainError):
105
+ """Something the server depends on is not answering."""
106
+
107
+ status_code = status.HTTP_503_SERVICE_UNAVAILABLE
108
+ default_code = 'service_unavailable'
109
+ default_detail = _('This service is not available right now. Please try again later.')