djangorestframework-services 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.
Files changed (66) hide show
  1. djangorestframework_services-0.1.0.dist-info/METADATA +586 -0
  2. djangorestframework_services-0.1.0.dist-info/RECORD +66 -0
  3. djangorestframework_services-0.1.0.dist-info/WHEEL +4 -0
  4. rest_framework_services/__init__.py +70 -0
  5. rest_framework_services/_compat/__init__.py +11 -0
  6. rest_framework_services/_compat/arun_service.py +37 -0
  7. rest_framework_services/_compat/is_async.py +23 -0
  8. rest_framework_services/_compat/run_service.py +21 -0
  9. rest_framework_services/conf.py +24 -0
  10. rest_framework_services/exceptions/__init__.py +15 -0
  11. rest_framework_services/exceptions/service_error.py +20 -0
  12. rest_framework_services/exceptions/service_validation_error.py +26 -0
  13. rest_framework_services/management/__init__.py +0 -0
  14. rest_framework_services/management/commands/__init__.py +0 -0
  15. rest_framework_services/management/commands/startserviceapp.py +35 -0
  16. rest_framework_services/management/templates/service_app/__init__.py-tpl +0 -0
  17. rest_framework_services/management/templates/service_app/admin.py-tpl +1 -0
  18. rest_framework_services/management/templates/service_app/apps.py-tpl +6 -0
  19. rest_framework_services/management/templates/service_app/migrations/__init__.py-tpl +0 -0
  20. rest_framework_services/management/templates/service_app/models/__init__.py-tpl +0 -0
  21. rest_framework_services/management/templates/service_app/selectors/__init__.py-tpl +0 -0
  22. rest_framework_services/management/templates/service_app/serializers/__init__.py-tpl +0 -0
  23. rest_framework_services/management/templates/service_app/services/__init__.py-tpl +0 -0
  24. rest_framework_services/management/templates/service_app/tests/__init__.py-tpl +0 -0
  25. rest_framework_services/management/templates/service_app/urls.py-tpl +7 -0
  26. rest_framework_services/management/templates/service_app/utils/__init__.py-tpl +0 -0
  27. rest_framework_services/management/templates/service_app/validators/__init__.py-tpl +0 -0
  28. rest_framework_services/management/templates/service_app/views/__init__.py-tpl +0 -0
  29. rest_framework_services/mutations/__init__.py +23 -0
  30. rest_framework_services/mutations/acreate_from_input.py +43 -0
  31. rest_framework_services/mutations/apply_input.py +39 -0
  32. rest_framework_services/mutations/aupdate_from_input.py +52 -0
  33. rest_framework_services/mutations/create_from_input.py +48 -0
  34. rest_framework_services/mutations/update_from_input.py +57 -0
  35. rest_framework_services/mutations/utils.py +201 -0
  36. rest_framework_services/py.typed +0 -0
  37. rest_framework_services/selectors/__init__.py +9 -0
  38. rest_framework_services/selectors/async_selector.py +12 -0
  39. rest_framework_services/selectors/selector.py +22 -0
  40. rest_framework_services/selectors/utils.py +27 -0
  41. rest_framework_services/types/__init__.py +16 -0
  42. rest_framework_services/types/change_result.py +39 -0
  43. rest_framework_services/types/field_change.py +19 -0
  44. rest_framework_services/types/unset.py +34 -0
  45. rest_framework_services/views/__init__.py +24 -0
  46. rest_framework_services/views/mutation/__init__.py +11 -0
  47. rest_framework_services/views/mutation/mutation_flow_mixin.py +57 -0
  48. rest_framework_services/views/mutation/service_create_view.py +53 -0
  49. rest_framework_services/views/mutation/service_delete_view.py +46 -0
  50. rest_framework_services/views/mutation/service_update_view.py +55 -0
  51. rest_framework_services/views/mutation/utils.py +155 -0
  52. rest_framework_services/views/query/__init__.py +11 -0
  53. rest_framework_services/views/query/selector_list_view.py +52 -0
  54. rest_framework_services/views/query/selector_retrieve_view.py +61 -0
  55. rest_framework_services/views/utils.py +47 -0
  56. rest_framework_services/viewsets/__init__.py +25 -0
  57. rest_framework_services/viewsets/decorators/__init__.py +7 -0
  58. rest_framework_services/viewsets/decorators/service_action.py +72 -0
  59. rest_framework_services/viewsets/multi_serializer_mixin.py +39 -0
  60. rest_framework_services/viewsets/selector_list_mixin.py +50 -0
  61. rest_framework_services/viewsets/selector_retrieve_mixin.py +63 -0
  62. rest_framework_services/viewsets/selector_viewset.py +25 -0
  63. rest_framework_services/viewsets/service_create_mixin.py +53 -0
  64. rest_framework_services/viewsets/service_destroy_mixin.py +49 -0
  65. rest_framework_services/viewsets/service_update_mixin.py +56 -0
  66. rest_framework_services/viewsets/service_viewset.py +33 -0
@@ -0,0 +1,586 @@
1
+ Metadata-Version: 2.4
2
+ Name: djangorestframework-services
3
+ Version: 0.1.0
4
+ Summary: A service-oriented layer for Django REST Framework: precise, controllable side effects for mutating endpoints.
5
+ Project-URL: Homepage, https://github.com/Artui/djangorestframework-services
6
+ Project-URL: Repository, https://github.com/Artui/djangorestframework-services
7
+ Project-URL: Issues, https://github.com/Artui/djangorestframework-services/issues
8
+ Author-email: Artur Veres <artur8118@gmail.com>
9
+ License: MIT
10
+ Keywords: django,djangorestframework,drf,service-layer,services
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Framework :: Django
14
+ Classifier: Framework :: Django :: 4.2
15
+ Classifier: Framework :: Django :: 5.0
16
+ Classifier: Framework :: Django :: 5.1
17
+ Classifier: Framework :: Django :: 5.2
18
+ Classifier: Framework :: Django :: 6.0
19
+ Classifier: Intended Audience :: Developers
20
+ Classifier: License :: OSI Approved :: MIT License
21
+ Classifier: Operating System :: OS Independent
22
+ Classifier: Programming Language :: Python
23
+ Classifier: Programming Language :: Python :: 3
24
+ Classifier: Programming Language :: Python :: 3.10
25
+ Classifier: Programming Language :: Python :: 3.11
26
+ Classifier: Programming Language :: Python :: 3.12
27
+ Classifier: Programming Language :: Python :: 3.13
28
+ Classifier: Programming Language :: Python :: 3.14
29
+ Classifier: Topic :: Internet :: WWW/HTTP
30
+ Requires-Python: >=3.10
31
+ Requires-Dist: django>=4.2
32
+ Requires-Dist: djangorestframework-dataclasses>=1.3.1
33
+ Requires-Dist: djangorestframework>=3.14
34
+ Description-Content-Type: text/markdown
35
+
36
+ # djangorestframework-services
37
+
38
+ A service / selector layer for Django REST Framework.
39
+
40
+ DRF's default mode for mutating endpoints is "the serializer is the business
41
+ logic". That's fine for thin CRUD, but it falls apart the moment you need
42
+ to compose with an external system, fan out side effects, or write logic
43
+ that doesn't belong on a model. `djangorestframework-services` keeps DRF's
44
+ routing, validation, and serialization for what they're good at, and gives
45
+ you a precise, well-typed seam for the bits in the middle.
46
+
47
+ - **Services** — plain callables. The library does not define a `Service`
48
+ base class or prescribe a signature.
49
+ - **Selectors** — plain callables that override `get_queryset()` /
50
+ `get_object()`. Filter backends, pagination, and serialization stay
51
+ vanilla DRF.
52
+ - **Mutation helpers** — `create_from_input`, `update_from_input`,
53
+ `apply_input` (and async siblings) — DRF-style attribute application
54
+ with change tracking, no surprises.
55
+ - **Sync and async** services and selectors, transparently dispatched.
56
+ - **Atomic by default**, opt-out per view.
57
+ - **Framework-agnostic exceptions** — services don't import from DRF.
58
+ - **100% test coverage**, type-checked, Python 3.10–3.14, Django 4.2–6.0.
59
+
60
+ ```bash
61
+ pip install djangorestframework-services
62
+ ```
63
+
64
+ ---
65
+
66
+ ## Quick start
67
+
68
+ A `POST /authors/` endpoint that creates an `Author`. Input is validated
69
+ by a dataclass, the service is a plain function, and the response is
70
+ shaped by another dataclass — both halves rendered through
71
+ `DataclassSerializer` from
72
+ [`djangorestframework-dataclasses`](https://pypi.org/project/djangorestframework-dataclasses/),
73
+ which the library already depends on.
74
+
75
+ ```python
76
+ from dataclasses import dataclass
77
+
78
+ from rest_framework_dataclasses.serializers import DataclassSerializer
79
+ from rest_framework_services import ServiceCreateView, create_from_input
80
+
81
+ from myapp.models import Author
82
+
83
+
84
+ # 1. Input — validated at the view boundary; the service receives a
85
+ # typed instance.
86
+ @dataclass
87
+ class CreateAuthorInput:
88
+ name: str
89
+ bio: str = ""
90
+
91
+
92
+ # 2. Output — a dataclass that shapes the JSON response. The service
93
+ # can return either the matching dataclass or a model instance with
94
+ # the same attribute names; DataclassSerializer reads via getattr.
95
+ @dataclass
96
+ class AuthorOutput:
97
+ id: int
98
+ name: str
99
+ bio: str
100
+
101
+
102
+ class AuthorOutputSerializer(DataclassSerializer):
103
+ class Meta:
104
+ dataclass = AuthorOutput
105
+
106
+
107
+ # 3. Service — a plain callable. No DRF imports; raises framework-
108
+ # agnostic exceptions if it needs to.
109
+ def create_author(*, data: CreateAuthorInput) -> Author:
110
+ result = create_from_input(Author, data)
111
+ return result.instance
112
+
113
+
114
+ # 4. View — wires it all together.
115
+ class CreateAuthorView(ServiceCreateView):
116
+ service = create_author
117
+ input_dataclass = CreateAuthorInput
118
+ output_serializer = AuthorOutputSerializer
119
+ ```
120
+
121
+ ```python
122
+ # urls.py
123
+ from django.urls import path
124
+ from myapp.views import CreateAuthorView
125
+
126
+ urlpatterns = [path("authors/", CreateAuthorView.as_view())]
127
+ ```
128
+
129
+ POST `{"name": "Ada"}` → `201` with `{"id": 1, "name": "Ada", "bio": ""}`.
130
+
131
+ ### Returning the output dataclass directly
132
+
133
+ If you want the service's return type to *be* the response shape — useful
134
+ when the API surface diverges from your model (computed fields, hidden
135
+ columns, denormalised joins) — have the service build and return the
136
+ output dataclass:
137
+
138
+ ```python
139
+ def create_author(*, data: CreateAuthorInput) -> AuthorOutput:
140
+ author = Author.objects.create(name=data.name, bio=data.bio)
141
+ return AuthorOutput(id=author.id, name=author.name, bio=author.bio)
142
+ ```
143
+
144
+ `AuthorOutputSerializer` renders it the same way; the view doesn't care.
145
+
146
+ ### Alternative: `ModelSerializer`
147
+
148
+ When the response mirrors the model exactly, DRF's `ModelSerializer` is
149
+ the shorter path and gets the full DRF feature set (relations, nested
150
+ serializers, etc.):
151
+
152
+ ```python
153
+ from rest_framework import serializers
154
+
155
+
156
+ class AuthorSerializer(serializers.ModelSerializer):
157
+ class Meta:
158
+ model = Author
159
+ fields = ("id", "name", "bio")
160
+
161
+
162
+ class CreateAuthorView(ServiceCreateView):
163
+ service = create_author
164
+ input_dataclass = CreateAuthorInput
165
+ output_serializer = AuthorSerializer
166
+ ```
167
+
168
+ Both patterns are first-class — the library doesn't care which kind of
169
+ DRF serializer you use for output. Pick `DataclassSerializer` when you
170
+ want the API contract to live alongside the service signature; pick
171
+ `ModelSerializer` when the response mirrors the model and you want
172
+ DRF's relational machinery.
173
+
174
+ ---
175
+
176
+ ## Mental model
177
+
178
+ There are three building blocks:
179
+
180
+ | Block | What it is | Where it lives |
181
+ |---|---|---|
182
+ | **Service** | A plain callable that performs a mutation | Your code |
183
+ | **Selector** | A plain callable that returns data to read | Your code |
184
+ | **View / Viewset** | DRF view that wires a service or selector to an HTTP method | Provided by this library |
185
+
186
+ Views inspect the service / selector signature with `inspect.signature` and
187
+ pass only the arguments they declare from a known pool: `data`, `instance`,
188
+ `request`, `user`, `view`, plus extras from `get_service_kwargs()` /
189
+ `get_selector_kwargs()`. If a callable declares `**kwargs`, the entire pool
190
+ is forwarded.
191
+
192
+ ```python
193
+ def create_author(*, data, user): # the view passes only data + user
194
+ return Author.objects.create(name=data.name, created_by=user)
195
+
196
+ def list_authors(*, request): # request is in the pool
197
+ return Author.objects.filter(...)
198
+ ```
199
+
200
+ ---
201
+
202
+ ## Mutation helpers
203
+
204
+ The library doesn't run your services for you, but it does ship the helpers
205
+ that DRF's `serializer.save()` quietly performs — minus the surprises and
206
+ plus a typed change record.
207
+
208
+ ```python
209
+ from rest_framework_services import update_from_input, UNSET
210
+
211
+ def update_author(*, instance, data):
212
+ result = update_from_input(instance, data, exclude_fields=["created_by"])
213
+ if result.get_field_change("email"):
214
+ send_email_changed_notice(instance)
215
+ return result.instance
216
+ ```
217
+
218
+ What you get:
219
+
220
+ - **`apply_input(instance, data)`** — set attributes in memory, no save.
221
+ - **`create_from_input(Model, data)`** — build, save, optional M2M.
222
+ - **`update_from_input(instance, data)`** — diff in-memory state vs. input,
223
+ call `save(update_fields=[...])` with only the fields that actually
224
+ changed.
225
+ - **`acreate_from_input` / `aupdate_from_input`** — async equivalents
226
+ using Django 4.2+ `asave()` / `aset()`.
227
+
228
+ All of them accept:
229
+
230
+ - `data` — a dataclass, plain dict, or any object with `__dict__`.
231
+ - `field_map: dict[str, str]` — translate input keys to model attribute
232
+ names.
233
+ - `exclude_fields: list[str]` — fields to drop from the input before
234
+ applying.
235
+ - `m2m: dict[str, Any]` — many-to-many assignments applied post-save
236
+ (create/update only).
237
+
238
+ All of them return a `ChangeResult`:
239
+
240
+ ```python
241
+ @dataclass(frozen=True)
242
+ class ChangeResult:
243
+ instance: Model
244
+ created: bool
245
+ changes: tuple[FieldChange, ...]
246
+
247
+ @property
248
+ def changed_fields(self) -> tuple[str, ...]: ...
249
+ def get_field_change(self, field_name: str) -> FieldChange | None: ...
250
+ def __bool__(self) -> bool: ... # True iff any change
251
+ ```
252
+
253
+ The `UNSET` sentinel distinguishes "field omitted from input" from "field
254
+ explicitly set to `None`" — critical for correct `PATCH` semantics.
255
+
256
+ ---
257
+
258
+ ## Views
259
+
260
+ | Class | Method | Purpose |
261
+ |---|---|---|
262
+ | `ServiceCreateView` | `POST` | runs `service` to create |
263
+ | `ServiceUpdateView` | `PUT` / `PATCH` | runs `service` to update; instance from `get_object()` |
264
+ | `ServiceDeleteView` | `DELETE` | runs `service` to delete |
265
+ | `SelectorListView` | `GET` | uses `selector` (or `queryset`) for list |
266
+ | `SelectorRetrieveView` | `GET` | uses `selector` (or `queryset` + `lookup_field`) for retrieve |
267
+
268
+ Mutation views configure: `service`, `input_dataclass`, `output_serializer`,
269
+ `output_selector`, `atomic`, `success_status`. Selector views configure:
270
+ `selector` and DRF's standard `serializer_class`.
271
+
272
+ ```python
273
+ @dataclass
274
+ class UpdateAuthorInput:
275
+ name: str | None = None
276
+ bio: str | None = None
277
+
278
+
279
+ class UpdateAuthorView(ServiceUpdateView):
280
+ queryset = Author.objects.all()
281
+ service = update_author
282
+ input_dataclass = UpdateAuthorInput
283
+ output_serializer = AuthorOutputSerializer # DataclassSerializer
284
+ ```
285
+
286
+ A `ModelSerializer` can be dropped in just as cleanly:
287
+
288
+ ```python
289
+ class UpdateAuthorView(ServiceUpdateView):
290
+ queryset = Author.objects.all()
291
+ service = update_author
292
+ input_dataclass = UpdateAuthorInput
293
+ output_serializer = AuthorSerializer # DRF ModelSerializer
294
+ ```
295
+
296
+ When a service returns `None` and the view has an instance in scope (update
297
+ or delete), the in-memory instance is rendered — matching DRF's
298
+ `UpdateAPIView` shape without you having to wire it up.
299
+
300
+ ---
301
+
302
+ ## Viewsets
303
+
304
+ `ServiceViewSet` is a router-compatible viewset composed of per-action
305
+ mixins. Each action picks its own serializer; below the read side uses
306
+ two `DataclassSerializer` shapes (terse list, full detail) and the
307
+ mutations reuse the detail one:
308
+
309
+ ```python
310
+ from dataclasses import dataclass
311
+
312
+ from rest_framework_dataclasses.serializers import DataclassSerializer
313
+ from rest_framework_services import ServiceViewSet
314
+
315
+
316
+ @dataclass
317
+ class AuthorListItem:
318
+ id: int
319
+ name: str
320
+
321
+
322
+ @dataclass
323
+ class AuthorDetail:
324
+ id: int
325
+ name: str
326
+ bio: str
327
+
328
+
329
+ class AuthorListItemSerializer(DataclassSerializer):
330
+ class Meta:
331
+ dataclass = AuthorListItem
332
+
333
+
334
+ class AuthorDetailSerializer(DataclassSerializer):
335
+ class Meta:
336
+ dataclass = AuthorDetail
337
+
338
+
339
+ class AuthorViewSet(ServiceViewSet):
340
+ queryset = Author.objects.all()
341
+ serializer_classes = {
342
+ "list": AuthorListItemSerializer,
343
+ "retrieve": AuthorDetailSerializer,
344
+ }
345
+ list_selector = list_authors
346
+ retrieve_selector = get_author
347
+ create_service = create_author
348
+ create_input_dataclass = CreateAuthorInput
349
+ create_output_serializer = AuthorDetailSerializer
350
+ update_service = update_author
351
+ update_input_dataclass = UpdateAuthorInput
352
+ update_output_serializer = AuthorDetailSerializer
353
+ destroy_service = delete_author
354
+ ```
355
+
356
+ Mix `ModelSerializer` and `DataclassSerializer` per action freely — the
357
+ viewset doesn't distinguish between them.
358
+
359
+ Register it with a router as usual:
360
+
361
+ ```python
362
+ router = DefaultRouter()
363
+ router.register("authors", AuthorViewSet, basename="author")
364
+ ```
365
+
366
+ Per-action mixins (`ServiceCreateMixin`, `ServiceUpdateMixin`,
367
+ `ServiceDestroyMixin`, `SelectorListMixin`, `SelectorRetrieveMixin`,
368
+ `MultiSerializerMixin`) are exported so you can compose only the actions
369
+ you need:
370
+
371
+ ```python
372
+ class AuthorReadOnly(SelectorListMixin, SelectorRetrieveMixin, GenericViewSet):
373
+ queryset = Author.objects.all()
374
+ serializer_class = AuthorDetailSerializer # or any DRF Serializer
375
+ ```
376
+
377
+ `SelectorViewSet` is a pre-built read-only composition.
378
+
379
+ ### `MultiSerializerMixin`
380
+
381
+ Per-action serializer dispatch via a single mapping:
382
+
383
+ ```python
384
+ serializer_classes = {
385
+ "list": ListSerializer,
386
+ "retrieve": DetailSerializer,
387
+ "my_custom_action": CustomSerializer,
388
+ }
389
+ ```
390
+
391
+ `get_serializer_class()` consults the map first, falls back to
392
+ `serializer_class`.
393
+
394
+ ### `@service_action`
395
+
396
+ Custom viewset actions wrapped in the same plumbing as the standard
397
+ mutation flow:
398
+
399
+ ```python
400
+ from dataclasses import dataclass
401
+
402
+ from rest_framework_dataclasses.serializers import DataclassSerializer
403
+ from rest_framework_services import service_action
404
+
405
+
406
+ @dataclass
407
+ class ApproveInput:
408
+ note: str = ""
409
+
410
+
411
+ @dataclass
412
+ class InvoiceDetail:
413
+ id: int
414
+ customer: str
415
+ amount_cents: int
416
+ status: str
417
+
418
+
419
+ class InvoiceDetailSerializer(DataclassSerializer):
420
+ class Meta:
421
+ dataclass = InvoiceDetail
422
+
423
+
424
+ class InvoiceViewSet(ServiceViewSet):
425
+ @service_action(
426
+ detail=True,
427
+ methods=["post"],
428
+ service=approve_invoice,
429
+ input_dataclass=ApproveInput,
430
+ output_serializer=InvoiceDetailSerializer,
431
+ )
432
+ def approve(self, request, pk=None):
433
+ """Approve an invoice."""
434
+ ```
435
+
436
+ The decorated method body is *not* executed — the decorator supplies the
437
+ handler. The body is there so the action has a docstring, a name (used by
438
+ the router), and a place for `@action`-compatible metadata.
439
+
440
+ ---
441
+
442
+ ## Errors
443
+
444
+ Services raise framework-agnostic exceptions. The view boundary translates
445
+ them to DRF responses.
446
+
447
+ ```python
448
+ from rest_framework_services import ServiceError, ServiceValidationError
449
+
450
+ def withdraw(*, instance, data):
451
+ if data.amount > instance.balance:
452
+ raise ServiceValidationError({"amount": ["insufficient funds"]})
453
+ if instance.locked:
454
+ raise ServiceError("account is locked")
455
+ instance.balance -= data.amount
456
+ instance.save(update_fields=["balance"])
457
+ return instance
458
+ ```
459
+
460
+ | Raised | Becomes | HTTP |
461
+ |---|---|---|
462
+ | `ServiceValidationError` | `rest_framework.exceptions.ValidationError` | 400 |
463
+ | `ServiceError` | `rest_framework.exceptions.APIException` | 422 |
464
+
465
+ ---
466
+
467
+ ## Atomic transactions
468
+
469
+ Every service call is wrapped in `transaction.atomic()` by default. Opt out
470
+ on a view-by-view basis:
471
+
472
+ ```python
473
+ class ImportView(ServiceCreateView):
474
+ service = run_import
475
+ atomic = False # the import service handles its own savepoints
476
+ ```
477
+
478
+ ---
479
+
480
+ ## Async services
481
+
482
+ Services and selectors can be `async def`. The dispatcher detects this via
483
+ `inspect.iscoroutinefunction` and runs them via `asgiref.sync.async_to_sync`
484
+ under sync views, or directly under async views. Atomic wrapping works for
485
+ both.
486
+
487
+ ```python
488
+ async def fetch_remote(*, request):
489
+ async with httpx.AsyncClient() as client:
490
+ return await client.get("https://...").json()
491
+
492
+ class FetchView(ServiceCreateView):
493
+ service = fetch_remote
494
+ ```
495
+
496
+ ---
497
+
498
+ ## `startserviceapp`
499
+
500
+ A management command that scaffolds a service-oriented Django app:
501
+
502
+ ```bash
503
+ python manage.py startserviceapp billing
504
+ ```
505
+
506
+ Produces:
507
+
508
+ ```
509
+ billing/
510
+ ├── __init__.py
511
+ ├── apps.py
512
+ ├── admin.py
513
+ ├── urls.py
514
+ ├── models/__init__.py
515
+ ├── views/__init__.py
516
+ ├── services/__init__.py
517
+ ├── selectors/__init__.py
518
+ ├── validators/__init__.py
519
+ ├── serializers/__init__.py
520
+ ├── utils/__init__.py
521
+ ├── migrations/__init__.py
522
+ └── tests/__init__.py
523
+ ```
524
+
525
+ Add `"rest_framework_services"` to `INSTALLED_APPS` to make the command
526
+ discoverable.
527
+
528
+ ### A note on `validators/`
529
+
530
+ The `validators/` package is a stylistic convention, not a library
531
+ feature. The library doesn't import from it or look it up by name. It
532
+ exists to give business-level validation a home of its own — rules like
533
+ *"a draft invoice can only be sent if the customer has a verified email"*
534
+ or *"refunds beyond 30 days require manager approval"*. These belong
535
+ neither in the model nor in the serializer.
536
+
537
+ The split this layout suggests:
538
+
539
+ | Concern | Lives in |
540
+ |---|---|
541
+ | Type validation, required fields, format checks | DRF serializers (`serializers/`), including `DataclassSerializer` for service inputs |
542
+ | Business rules / cross-record invariants / external-state checks | Functions in `validators/`, called from services |
543
+ | Side effects, persistence, orchestration | `services/` |
544
+
545
+ Use it, ignore it, or rename it — the scaffolding is a starting point,
546
+ not a contract.
547
+
548
+ ---
549
+
550
+ ## Examples
551
+
552
+ A minimal but runnable example project lives in
553
+ [`examples/`](examples/). It demonstrates the create / list / retrieve /
554
+ update / destroy flows on a single resource and one custom action via
555
+ `@service_action`.
556
+
557
+ ```bash
558
+ cd examples
559
+ python manage.py migrate
560
+ python manage.py runserver
561
+ ```
562
+
563
+ ---
564
+
565
+ ## Compatibility
566
+
567
+ | Axis | Range |
568
+ |---|---|
569
+ | Python | 3.10 – 3.14 |
570
+ | Django | 4.2, 5.0, 5.1, 5.2, 6.0 |
571
+ | DRF | ≥ 3.14 |
572
+
573
+ CI runs the full Python × Django matrix with 100% coverage gating.
574
+
575
+ ---
576
+
577
+ ## Status
578
+
579
+ Pre-1.0. Public API is stable but may shift. See
580
+ [`CHANGELOG.md`](CHANGELOG.md) for the full release history.
581
+
582
+ ---
583
+
584
+ ## License
585
+
586
+ MIT.
@@ -0,0 +1,66 @@
1
+ rest_framework_services/__init__.py,sha256=OSbiDluOfNrvQjiuiWK2iaHzPjO6gjyiOYZphDWM26Q,2033
2
+ rest_framework_services/conf.py,sha256=thdxegDusmp8GxJebvat7hFalh34cECr_N_iy2CefHg,733
3
+ rest_framework_services/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ rest_framework_services/_compat/__init__.py,sha256=b6i4f5pzwl5eICY6h9WVRBOw0i2iM9uNb9nOd5Tt3u0,348
5
+ rest_framework_services/_compat/arun_service.py,sha256=FwfrJHT28rRxmExZKoQstHBijhXUFbOtHGlqHFd_c84,1188
6
+ rest_framework_services/_compat/is_async.py,sha256=tzmfIhR-pWyGuU0jjU0IbxIsCWnKGXkseZZID3Ruj1s,908
7
+ rest_framework_services/_compat/run_service.py,sha256=S5Ey8vOIzMMGCWKDB1qsGncM2mWMyMPQ3nYPQTcEtz4,491
8
+ rest_framework_services/exceptions/__init__.py,sha256=N5LXUOa2zwufcyJsF5U9wjTXhkuYKB-0MkWlYFraKrI,417
9
+ rest_framework_services/exceptions/service_error.py,sha256=GqNWnr1fjUoWEzUxpxYatp3mDSFXlvKr2NVhfI0QLGI,672
10
+ rest_framework_services/exceptions/service_validation_error.py,sha256=4DvcaIA6cUdPGFGcSn9g0NdrwU_Zwy9Z_0N7RSsl_Wk,922
11
+ rest_framework_services/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ rest_framework_services/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ rest_framework_services/management/commands/startserviceapp.py,sha256=1sfsZTwKC29OmOMAuVZBsDm8Dlw9-9lFvakXJAxjpeA,1400
14
+ rest_framework_services/management/templates/service_app/__init__.py-tpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ rest_framework_services/management/templates/service_app/admin.py-tpl,sha256=ImQU7Q0SJNTigjgz1o6mizJwFEepZle5JSCBJXheLMw,29
16
+ rest_framework_services/management/templates/service_app/apps.py-tpl,sha256=6VwM2ponNKuvJqE5ISV9sJ1idAta8va_tkHJzPTONIY,171
17
+ rest_framework_services/management/templates/service_app/urls.py-tpl,sha256=J5UH975fNjVwz7IhwRn3Bjft0cLkxPip05WxvPuAkfI,124
18
+ rest_framework_services/management/templates/service_app/migrations/__init__.py-tpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
+ rest_framework_services/management/templates/service_app/models/__init__.py-tpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ rest_framework_services/management/templates/service_app/selectors/__init__.py-tpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
+ rest_framework_services/management/templates/service_app/serializers/__init__.py-tpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
+ rest_framework_services/management/templates/service_app/services/__init__.py-tpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
+ rest_framework_services/management/templates/service_app/tests/__init__.py-tpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
24
+ rest_framework_services/management/templates/service_app/utils/__init__.py-tpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
+ rest_framework_services/management/templates/service_app/validators/__init__.py-tpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
+ rest_framework_services/management/templates/service_app/views/__init__.py-tpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
27
+ rest_framework_services/mutations/__init__.py,sha256=8l0ZIhYhUzQmOZyaMfb8vHbm407cFl4LkcQL1nNPxq8,940
28
+ rest_framework_services/mutations/acreate_from_input.py,sha256=vKFejgXG6p47yrNKcMHrTUq1ivNJJRpdDL-wKU3CIc0,1235
29
+ rest_framework_services/mutations/apply_input.py,sha256=tfvb-8ol8ddAAGaa16MgkBT2POacIX43aBbDYxLBI8A,1168
30
+ rest_framework_services/mutations/aupdate_from_input.py,sha256=1_SPMZjcerSDPkgLtIDq-HKrpdrAACxuIqzi9fbJIP4,1689
31
+ rest_framework_services/mutations/create_from_input.py,sha256=bd9Bm4r69HgJFRR6Z8SIsQzUxasx-o2_kBHIgBRUGTQ,1431
32
+ rest_framework_services/mutations/update_from_input.py,sha256=Urv_y0l_8YAKvpzQ8YKkaprbATKQdZdeaN2sg1ymURA,1891
33
+ rest_framework_services/mutations/utils.py,sha256=fgbetWLmffR0A0_lElihij6Vi15-KdQHXrK8DkeUsXg,6896
34
+ rest_framework_services/selectors/__init__.py,sha256=NBEYd6d1FklCw7cOywArfsY9WBluQxr-JdZbM42s_M8,255
35
+ rest_framework_services/selectors/async_selector.py,sha256=FjsgPtoFq1vsgBh1Q90vuNiIzj1t8AytWZIJthLJmXY,294
36
+ rest_framework_services/selectors/selector.py,sha256=nPali1_YtmSNB-kMH0Z9RyELEvn8wBXZKCLXTyqx0Dg,742
37
+ rest_framework_services/selectors/utils.py,sha256=jkScLQtG7Fhs2PdeOCYVIie8iz94PXfhgIXarxS_0oQ,777
38
+ rest_framework_services/types/__init__.py,sha256=qm2MDVSr2WOLT8-goYgJD-RYBGIjKfzAVfiP8k3-ghc,553
39
+ rest_framework_services/types/change_result.py,sha256=IXrXbvVYhzphp1kwN1G-bthfZy2Fa4ZWNUpD5z_ebRM,1229
40
+ rest_framework_services/types/field_change.py,sha256=mDR8OndlIE2zEWBFGqxFU6sN2t3tboRiERkmQyyo80s,393
41
+ rest_framework_services/types/unset.py,sha256=jXLfJPgZ0Lewgy5Jy6ylTTTe12VTE9Um4UwhtS5OoKo,790
42
+ rest_framework_services/views/__init__.py,sha256=GRg_h6Vn2qPDDgB00REY0yiSbVhj4YYDpocbKcCxltQ,586
43
+ rest_framework_services/views/utils.py,sha256=NKIAMy6LnOnSdiof_UBA0_XgMY79A3WNFZ_550oqkXU,1506
44
+ rest_framework_services/views/mutation/__init__.py,sha256=kSw67HTv6KaQTN-RTeUVZQ8tR07poCCpAgGuQQo1Phg,438
45
+ rest_framework_services/views/mutation/mutation_flow_mixin.py,sha256=MZO2Zabnb9k9ua15LV9eSRfQDTwyrBgSTtMzUvMlbwM,1954
46
+ rest_framework_services/views/mutation/service_create_view.py,sha256=zsVVzya3-iuPT2d5qumoLSqsCNBQ6jvKMixc42TLB5I,2196
47
+ rest_framework_services/views/mutation/service_delete_view.py,sha256=IB1-Bjp17Gd--F-gGHqwz_RvcKihRdNGJLL5FqlKK8U,1920
48
+ rest_framework_services/views/mutation/service_update_view.py,sha256=ZzkI5fgKnY7Ma5ZiETzS74G3mLtmhPANgC6vYDhcW3I,2237
49
+ rest_framework_services/views/mutation/utils.py,sha256=l9xxJnP_EP49Xup1upO5YljG3ePFI7f15WNl4I8FkS0,5602
50
+ rest_framework_services/views/query/__init__.py,sha256=ltbrUPeKXsMEGDNMzqcSTVM20BiXnYvu412vHTZ8aBc,331
51
+ rest_framework_services/views/query/selector_list_view.py,sha256=12oPsLCbWqBpvq6VwmFXvYeIsF76zsdMb15VKnTEYhk,1947
52
+ rest_framework_services/views/query/selector_retrieve_view.py,sha256=ts78GnFKOl8piQoyDHkrcS4Nznqeod2rtZPWHrT3DeQ,2194
53
+ rest_framework_services/viewsets/__init__.py,sha256=qa218RZoGkHZgJhp2u9-oSYbWBT6shljszqIeNv57BM,1086
54
+ rest_framework_services/viewsets/multi_serializer_mixin.py,sha256=FmT_rAvJlrb7OxVDJanCBzOlauXGoH5A7zxHb69odZ4,1297
55
+ rest_framework_services/viewsets/selector_list_mixin.py,sha256=UP_dUZqVfsR5UkZqdXoUSXGBoy236Xgrw_1faobqsYA,1790
56
+ rest_framework_services/viewsets/selector_retrieve_mixin.py,sha256=RQyLSijrIbJLn_cfjbdPKBcD0S0NCWdfqztEhVhFLnE,2336
57
+ rest_framework_services/viewsets/selector_viewset.py,sha256=RZ0-sQubEngOtpx0uYVb2mfdeWNOK3SDtLCPtiuL2mk,790
58
+ rest_framework_services/viewsets/service_create_mixin.py,sha256=9ZoFfRX4DXrOFW_bcrj8ulZT2qUcz3v7P_UNwz4o74s,2207
59
+ rest_framework_services/viewsets/service_destroy_mixin.py,sha256=MWG0Vl7Q9X3X0EIpQB3tmGl1HRPDq-unHHymAMZcEMI,1948
60
+ rest_framework_services/viewsets/service_update_mixin.py,sha256=1J91QyH1cPaAEXwEz96NS-UsTppvbLBhCzavDS5XmUs,2304
61
+ rest_framework_services/viewsets/service_viewset.py,sha256=NzVYi4BR2wec9n7vjaiF_sUEtVTiVEa_KgKwJaez7sw,1259
62
+ rest_framework_services/viewsets/decorators/__init__.py,sha256=t7M66kvrCUTsMsgkstSp_Zs4JZTBYzfVggeCn20pN30,150
63
+ rest_framework_services/viewsets/decorators/service_action.py,sha256=0QwA45N_iU59Q85mbbYVSmWb-PN6GMSO4FNuZnGtRjE,2606
64
+ djangorestframework_services-0.1.0.dist-info/METADATA,sha256=IjXsIOwx-gnq_Nkt4yAn24p9PQ6OCYliH2aPTCU5dpA,17438
65
+ djangorestframework_services-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
66
+ djangorestframework_services-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any