callish 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.
- callish/__init__.py +36 -0
- callish/_meta.py +117 -0
- callish/adapter.py +102 -0
- callish/apps.py +18 -0
- callish/exceptions.py +51 -0
- callish/fields.py +54 -0
- callish/forms.py +58 -0
- callish/manager.py +74 -0
- callish/models.py +244 -0
- callish/queryset.py +254 -0
- callish/testing/__init__.py +10 -0
- callish/testing/helpers.py +33 -0
- callish/testing/plugin.py +62 -0
- callish/testing/reference_adapter.py +270 -0
- callish/testing/suite.py +250 -0
- callish-0.1.0.dist-info/METADATA +189 -0
- callish-0.1.0.dist-info/RECORD +19 -0
- callish-0.1.0.dist-info/WHEEL +4 -0
- callish-0.1.0.dist-info/entry_points.txt +3 -0
callish/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""callish — Django QuerySet façade for user-written API adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .adapter import AdapterProtocol, BaseAdapter
|
|
6
|
+
from .exceptions import (
|
|
7
|
+
AdapterError,
|
|
8
|
+
AdapterValidationError,
|
|
9
|
+
NotFound,
|
|
10
|
+
RateLimited,
|
|
11
|
+
Unauthorized,
|
|
12
|
+
Upstream5xx,
|
|
13
|
+
)
|
|
14
|
+
from .forms import APIModelForm
|
|
15
|
+
from .manager import APIManager
|
|
16
|
+
from .models import APIModel
|
|
17
|
+
from .queryset import APIQuerySet
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
default_app_config = "callish.apps.CallishConfig"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"AdapterError",
|
|
24
|
+
"AdapterProtocol",
|
|
25
|
+
"AdapterValidationError",
|
|
26
|
+
"APIManager",
|
|
27
|
+
"APIModel",
|
|
28
|
+
"APIModelForm",
|
|
29
|
+
"APIQuerySet",
|
|
30
|
+
"BaseAdapter",
|
|
31
|
+
"NotFound",
|
|
32
|
+
"RateLimited",
|
|
33
|
+
"Unauthorized",
|
|
34
|
+
"Upstream5xx",
|
|
35
|
+
"__version__",
|
|
36
|
+
]
|
callish/_meta.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""``_meta`` shim — exposes the surface Django introspects on real models.
|
|
2
|
+
|
|
3
|
+
Django's ``fields_for_model``, ``ModelForm``, generic class-based views and
|
|
4
|
+
URL reverse helpers all reach into ``model._meta`` for things like
|
|
5
|
+
``concrete_fields``, ``get_field``, ``app_label``, ``model_name``,
|
|
6
|
+
``verbose_name`` etc. This shim provides exactly those — and no more.
|
|
7
|
+
|
|
8
|
+
Anything Django reaches for that isn't here either lives outside callish's
|
|
9
|
+
v1 scope (relations, indexes, constraints, db_table) or signals a bug.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Iterable
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
from django.core.exceptions import FieldDoesNotExist
|
|
19
|
+
from django.db.models import Field
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .models import APIModel
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_CAMEL_RE = re.compile(r"(?<!^)(?=[A-Z])")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _humanize(name: str) -> str:
|
|
29
|
+
return _CAMEL_RE.sub(" ", name).strip().lower()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Options:
|
|
33
|
+
"""Drop-in for ``django.db.models.options.Options`` — minimum viable surface."""
|
|
34
|
+
|
|
35
|
+
# Marker so external code can tell us apart from a real Options.
|
|
36
|
+
callish_shim = True
|
|
37
|
+
|
|
38
|
+
# Real Django Options uses these to opt out of DB/admin behaviour we never want.
|
|
39
|
+
proxy = False
|
|
40
|
+
abstract = False
|
|
41
|
+
swapped = False
|
|
42
|
+
auto_created = False
|
|
43
|
+
managed = False # we have no DB table
|
|
44
|
+
is_composite_pk = False # Django 5.2+ admin compatibility
|
|
45
|
+
|
|
46
|
+
# Empty collections that Django iterates blindly.
|
|
47
|
+
many_to_many: list[Field] = []
|
|
48
|
+
private_fields: list[Field] = []
|
|
49
|
+
parents: dict[Any, Any] = {}
|
|
50
|
+
local_many_to_many: list[Field] = []
|
|
51
|
+
|
|
52
|
+
# Admin / changelist hint — not supported for v1.
|
|
53
|
+
default_permissions: tuple[str, ...] = ()
|
|
54
|
+
permissions: tuple[Any, ...] = ()
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
model: type[APIModel],
|
|
60
|
+
fields: Iterable[Field],
|
|
61
|
+
pk_field: Field,
|
|
62
|
+
app_label: str,
|
|
63
|
+
object_name: str,
|
|
64
|
+
verbose_name: str | None = None,
|
|
65
|
+
verbose_name_plural: str | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
self.model = model
|
|
68
|
+
self.concrete_fields: list[Field] = list(fields)
|
|
69
|
+
self.local_fields: list[Field] = list(self.concrete_fields)
|
|
70
|
+
self.local_concrete_fields: list[Field] = list(self.concrete_fields)
|
|
71
|
+
self.pk: Field = pk_field
|
|
72
|
+
|
|
73
|
+
self.app_label = app_label
|
|
74
|
+
self.object_name = object_name
|
|
75
|
+
self.model_name = object_name.lower()
|
|
76
|
+
self.verbose_name = verbose_name or _humanize(object_name)
|
|
77
|
+
self.verbose_name_plural = verbose_name_plural or f"{self.verbose_name}s"
|
|
78
|
+
|
|
79
|
+
# Build a name -> field map for fast lookup.
|
|
80
|
+
self._field_map: dict[str, Field] = {f.name: f for f in self.concrete_fields}
|
|
81
|
+
|
|
82
|
+
# ----- Django Options surface -----------------------------------------
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def fields(self) -> tuple[Field, ...]:
|
|
86
|
+
return tuple(self.concrete_fields)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def label(self) -> str:
|
|
90
|
+
return f"{self.app_label}.{self.object_name}"
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def label_lower(self) -> str:
|
|
94
|
+
return f"{self.app_label}.{self.model_name}"
|
|
95
|
+
|
|
96
|
+
def get_field(self, field_name: str) -> Field:
|
|
97
|
+
if field_name == "pk":
|
|
98
|
+
return self.pk
|
|
99
|
+
try:
|
|
100
|
+
return self._field_map[field_name]
|
|
101
|
+
except KeyError as exc:
|
|
102
|
+
raise FieldDoesNotExist(
|
|
103
|
+
f"{self.object_name} has no field named {field_name!r}"
|
|
104
|
+
) from exc
|
|
105
|
+
|
|
106
|
+
def get_fields(
|
|
107
|
+
self, include_parents: bool = True, include_hidden: bool = False
|
|
108
|
+
) -> tuple[Field, ...]:
|
|
109
|
+
return self.fields
|
|
110
|
+
|
|
111
|
+
# Django sometimes calls this to canonicalise the verbose name with case.
|
|
112
|
+
@property
|
|
113
|
+
def verbose_name_raw(self) -> str:
|
|
114
|
+
return str(self.verbose_name)
|
|
115
|
+
|
|
116
|
+
def __repr__(self) -> str:
|
|
117
|
+
return f"<callish Options for {self.label}>"
|
callish/adapter.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Adapter contract — the user-written layer between callish and an HTTP API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from typing import Any, Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@runtime_checkable
|
|
10
|
+
class AdapterProtocol(Protocol):
|
|
11
|
+
"""The structural contract every adapter must satisfy.
|
|
12
|
+
|
|
13
|
+
Adapters are plain Python objects. They MAY also subclass :class:`BaseAdapter`
|
|
14
|
+
for convenience, but it is not required — duck typing is sufficient.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def list(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
filters: Mapping[str, Any],
|
|
21
|
+
ordering: Sequence[str],
|
|
22
|
+
offset: int,
|
|
23
|
+
limit: int | None,
|
|
24
|
+
) -> Sequence[Mapping[str, Any]]: ...
|
|
25
|
+
|
|
26
|
+
def retrieve(self, pk: Any) -> Mapping[str, Any]: ...
|
|
27
|
+
|
|
28
|
+
def create(self, data: Mapping[str, Any]) -> Mapping[str, Any]: ...
|
|
29
|
+
|
|
30
|
+
def update(self, pk: Any, data: Mapping[str, Any]) -> Mapping[str, Any]: ...
|
|
31
|
+
|
|
32
|
+
def delete(self, pk: Any) -> None: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BaseAdapter:
|
|
36
|
+
"""Optional convenience base for adapters.
|
|
37
|
+
|
|
38
|
+
Provides NotImplementedError stubs and a ``supported_lookups`` class attribute
|
|
39
|
+
that the conformance suite uses to skip lookups the adapter doesn't claim.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
#: Field-lookup suffixes (e.g. ``"in"``, ``"gte"``) the adapter promises to honour
|
|
43
|
+
#: when passed in ``filters``. Equality (no suffix) is assumed always supported.
|
|
44
|
+
supported_lookups: tuple[str, ...] = ("in", "gte", "lte", "contains", "icontains")
|
|
45
|
+
|
|
46
|
+
def list(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
filters: Mapping[str, Any],
|
|
50
|
+
ordering: Sequence[str],
|
|
51
|
+
offset: int,
|
|
52
|
+
limit: int | None,
|
|
53
|
+
) -> Sequence[Mapping[str, Any]]:
|
|
54
|
+
raise NotImplementedError
|
|
55
|
+
|
|
56
|
+
def retrieve(self, pk: Any) -> Mapping[str, Any]:
|
|
57
|
+
raise NotImplementedError
|
|
58
|
+
|
|
59
|
+
def create(self, data: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
|
|
62
|
+
def update(self, pk: Any, data: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
63
|
+
raise NotImplementedError
|
|
64
|
+
|
|
65
|
+
def delete(self, pk: Any) -> None:
|
|
66
|
+
raise NotImplementedError
|
|
67
|
+
|
|
68
|
+
def count(self, *, filters: Mapping[str, Any]) -> int | None:
|
|
69
|
+
"""Optional. Return ``None`` to fall back to ``len(list(...))``."""
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def resolve_adapter(spec: Any) -> Any:
|
|
74
|
+
"""Resolve an ``Meta.adapter`` declaration into an instance.
|
|
75
|
+
|
|
76
|
+
Accepts:
|
|
77
|
+
* An adapter instance — returned as-is.
|
|
78
|
+
* An adapter class — instantiated with no args.
|
|
79
|
+
* A dotted string ``"pkg.mod:ClassName"`` or ``"pkg.mod.ClassName"`` —
|
|
80
|
+
imported then instantiated with no args.
|
|
81
|
+
"""
|
|
82
|
+
if spec is None:
|
|
83
|
+
raise ValueError("Meta.adapter is required on APIModel subclasses")
|
|
84
|
+
|
|
85
|
+
if isinstance(spec, str):
|
|
86
|
+
from importlib import import_module
|
|
87
|
+
|
|
88
|
+
if ":" in spec:
|
|
89
|
+
module_path, attr = spec.split(":", 1)
|
|
90
|
+
elif "." in spec:
|
|
91
|
+
module_path, attr = spec.rsplit(".", 1)
|
|
92
|
+
else:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"Adapter spec {spec!r} must be 'pkg.mod:Class' or 'pkg.mod.Class'"
|
|
95
|
+
)
|
|
96
|
+
module = import_module(module_path)
|
|
97
|
+
spec = getattr(module, attr)
|
|
98
|
+
|
|
99
|
+
if isinstance(spec, type):
|
|
100
|
+
return spec()
|
|
101
|
+
|
|
102
|
+
return spec
|
callish/apps.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Django app config for callish.
|
|
2
|
+
|
|
3
|
+
Intentionally inert: no signals, no monkey-patching, no admin-site mutations.
|
|
4
|
+
This keeps callish coexistence-safe with Wagtail and any other framework that
|
|
5
|
+
expects to own its corners of Django.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from django.apps import AppConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CallishConfig(AppConfig):
|
|
14
|
+
name = "callish"
|
|
15
|
+
label = "callish"
|
|
16
|
+
verbose_name = "callish"
|
|
17
|
+
# Required for Django to instantiate AppConfig — we have no real models.
|
|
18
|
+
default_auto_field = "django.db.models.BigAutoField"
|
callish/exceptions.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Exceptions raised by adapters and propagated through the callish façade."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AdapterError(Exception):
|
|
7
|
+
"""Base class for all adapter-raised errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NotFound(AdapterError):
|
|
11
|
+
"""The requested record does not exist upstream.
|
|
12
|
+
|
|
13
|
+
The façade maps this to the model's ``DoesNotExist`` for Django parity.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Unauthorized(AdapterError):
|
|
18
|
+
"""Authentication or authorization failed upstream."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RateLimited(AdapterError):
|
|
22
|
+
"""The upstream API is rate-limiting the caller."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, *args: object, retry_after: float | None = None) -> None:
|
|
25
|
+
super().__init__(*args)
|
|
26
|
+
self.retry_after = retry_after
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Upstream5xx(AdapterError):
|
|
30
|
+
"""The upstream API returned a server error."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, *args: object, status: int | None = None) -> None:
|
|
33
|
+
super().__init__(*args)
|
|
34
|
+
self.status = status
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AdapterValidationError(AdapterError):
|
|
38
|
+
"""The upstream rejected the payload with field-level validation errors.
|
|
39
|
+
|
|
40
|
+
``errors`` is a mapping of field name to a list of error strings,
|
|
41
|
+
matching Django's ``ValidationError.message_dict`` shape.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
message: str = "Adapter validation failed",
|
|
47
|
+
*,
|
|
48
|
+
errors: dict[str, list[str]] | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
super().__init__(message)
|
|
51
|
+
self.errors: dict[str, list[str]] = errors or {}
|
callish/fields.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Field wrappers — thin shims around ``django.db.models.fields.*`` instances.
|
|
2
|
+
|
|
3
|
+
These are declared on :class:`callish.APIModel` subclasses but never attached
|
|
4
|
+
to a real DB table. The metaclass calls :meth:`set_attributes_from_name` on
|
|
5
|
+
each so ``field.name`` / ``field.attname`` are populated, which is enough for
|
|
6
|
+
Django's ``fields_for_model`` to introspect them and produce form fields.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from django.db import models as dj_models
|
|
12
|
+
|
|
13
|
+
# Re-export Django field classes verbatim. We intentionally do not subclass:
|
|
14
|
+
# subclasses would inherit ``contribute_to_class`` behaviour that assumes a real
|
|
15
|
+
# Model, and the metaclass already handles attachment.
|
|
16
|
+
CharField = dj_models.CharField
|
|
17
|
+
TextField = dj_models.TextField
|
|
18
|
+
IntegerField = dj_models.IntegerField
|
|
19
|
+
BigIntegerField = dj_models.BigIntegerField
|
|
20
|
+
SmallIntegerField = dj_models.SmallIntegerField
|
|
21
|
+
PositiveIntegerField = dj_models.PositiveIntegerField
|
|
22
|
+
FloatField = dj_models.FloatField
|
|
23
|
+
DecimalField = dj_models.DecimalField
|
|
24
|
+
BooleanField = dj_models.BooleanField
|
|
25
|
+
DateField = dj_models.DateField
|
|
26
|
+
DateTimeField = dj_models.DateTimeField
|
|
27
|
+
TimeField = dj_models.TimeField
|
|
28
|
+
EmailField = dj_models.EmailField
|
|
29
|
+
URLField = dj_models.URLField
|
|
30
|
+
SlugField = dj_models.SlugField
|
|
31
|
+
UUIDField = dj_models.UUIDField
|
|
32
|
+
JSONField = dj_models.JSONField
|
|
33
|
+
BinaryField = dj_models.BinaryField
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"BigIntegerField",
|
|
37
|
+
"BinaryField",
|
|
38
|
+
"BooleanField",
|
|
39
|
+
"CharField",
|
|
40
|
+
"DateField",
|
|
41
|
+
"DateTimeField",
|
|
42
|
+
"DecimalField",
|
|
43
|
+
"EmailField",
|
|
44
|
+
"FloatField",
|
|
45
|
+
"IntegerField",
|
|
46
|
+
"JSONField",
|
|
47
|
+
"PositiveIntegerField",
|
|
48
|
+
"SlugField",
|
|
49
|
+
"SmallIntegerField",
|
|
50
|
+
"TextField",
|
|
51
|
+
"TimeField",
|
|
52
|
+
"URLField",
|
|
53
|
+
"UUIDField",
|
|
54
|
+
]
|
callish/forms.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""``APIModelForm`` — a ``ModelForm`` that talks to adapters instead of the DB.
|
|
2
|
+
|
|
3
|
+
This subclasses Django's ``ModelForm`` directly: ``ModelFormMetaclass`` only
|
|
4
|
+
needs ``model._meta`` to expose the surface ``fields_for_model`` walks. Our
|
|
5
|
+
``_meta`` shim does. ``_post_clean`` is overridden to skip DB-level validation,
|
|
6
|
+
and ``save`` is overridden to dispatch to ``adapter.create/update``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from django import forms
|
|
14
|
+
from django.core.exceptions import ValidationError
|
|
15
|
+
from django.forms.models import construct_instance
|
|
16
|
+
|
|
17
|
+
from .exceptions import AdapterValidationError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class APIModelForm(forms.ModelForm):
|
|
21
|
+
"""ModelForm replacement that round-trips through an adapter."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
24
|
+
super().__init__(*args, **kwargs)
|
|
25
|
+
|
|
26
|
+
def _post_clean(self) -> None:
|
|
27
|
+
"""Skip the DB-constraint pass; keep the field-level construct_instance."""
|
|
28
|
+
opts = self._meta
|
|
29
|
+
try:
|
|
30
|
+
self.instance = construct_instance(
|
|
31
|
+
self,
|
|
32
|
+
self.instance, # type: ignore[has-type]
|
|
33
|
+
opts.fields,
|
|
34
|
+
opts.exclude,
|
|
35
|
+
)
|
|
36
|
+
except ValidationError as e:
|
|
37
|
+
self._update_errors(e)
|
|
38
|
+
# Intentionally NOT calling self.instance.full_clean() — the adapter is
|
|
39
|
+
# the source of truth for upstream validation (and would raise
|
|
40
|
+
# AdapterValidationError on failure during save()).
|
|
41
|
+
|
|
42
|
+
def save(self, commit: bool = True) -> Any:
|
|
43
|
+
"""Send the instance through the adapter.
|
|
44
|
+
|
|
45
|
+
Adapter errors propagate. :class:`AdapterValidationError` is translated
|
|
46
|
+
into a form-level :class:`~django.core.exceptions.ValidationError` so
|
|
47
|
+
downstream code can display per-field messages.
|
|
48
|
+
"""
|
|
49
|
+
if not commit:
|
|
50
|
+
# Caller wants to inspect/mutate the instance before persisting.
|
|
51
|
+
return self.instance
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
self.instance.save()
|
|
55
|
+
except AdapterValidationError as exc:
|
|
56
|
+
self._update_errors(ValidationError(exc.errors or {"__all__": [str(exc)]}))
|
|
57
|
+
raise
|
|
58
|
+
return self.instance
|
callish/manager.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Manager for API-backed models — Django ``.objects`` analogue."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from .queryset import APIQuerySet
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .models import APIModel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class APIManager:
|
|
14
|
+
"""Minimal manager: spawns querysets and proxies the common terminal methods."""
|
|
15
|
+
|
|
16
|
+
# Django generic views look at this attribute (``model._default_manager``).
|
|
17
|
+
use_in_migrations = False
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self.model: type[APIModel] | None = None
|
|
21
|
+
self.name: str = "objects"
|
|
22
|
+
|
|
23
|
+
def contribute_to_class(self, model: type[APIModel], name: str) -> None:
|
|
24
|
+
self.model = model
|
|
25
|
+
self.name = name
|
|
26
|
+
setattr(model, name, self)
|
|
27
|
+
# Django generic views look for ``_default_manager`` and ``_meta.base_manager``.
|
|
28
|
+
if getattr(model, "_default_manager", None) is None:
|
|
29
|
+
model._default_manager = self
|
|
30
|
+
|
|
31
|
+
# ----- queryset spawners ----------------------------------------------
|
|
32
|
+
|
|
33
|
+
def get_queryset(self) -> APIQuerySet:
|
|
34
|
+
if self.model is None:
|
|
35
|
+
raise RuntimeError("APIManager is not attached to a model.")
|
|
36
|
+
return APIQuerySet(self.model)
|
|
37
|
+
|
|
38
|
+
def all(self) -> APIQuerySet:
|
|
39
|
+
return self.get_queryset()
|
|
40
|
+
|
|
41
|
+
def none(self) -> APIQuerySet:
|
|
42
|
+
return self.get_queryset().none()
|
|
43
|
+
|
|
44
|
+
def filter(self, **kwargs: Any) -> APIQuerySet:
|
|
45
|
+
return self.get_queryset().filter(**kwargs)
|
|
46
|
+
|
|
47
|
+
def exclude(self, **kwargs: Any) -> APIQuerySet:
|
|
48
|
+
return self.get_queryset().exclude(**kwargs)
|
|
49
|
+
|
|
50
|
+
def order_by(self, *fields: str) -> APIQuerySet:
|
|
51
|
+
return self.get_queryset().order_by(*fields)
|
|
52
|
+
|
|
53
|
+
def get(self, **kwargs: Any) -> APIModel:
|
|
54
|
+
return self.get_queryset().get(**kwargs)
|
|
55
|
+
|
|
56
|
+
def first(self) -> APIModel | None:
|
|
57
|
+
return self.get_queryset().first()
|
|
58
|
+
|
|
59
|
+
def last(self) -> APIModel | None:
|
|
60
|
+
return self.get_queryset().last()
|
|
61
|
+
|
|
62
|
+
def exists(self) -> bool:
|
|
63
|
+
return self.get_queryset().exists()
|
|
64
|
+
|
|
65
|
+
def count(self) -> int:
|
|
66
|
+
return self.get_queryset().count()
|
|
67
|
+
|
|
68
|
+
# ----- write helpers ---------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def create(self, **kwargs: Any) -> APIModel:
|
|
71
|
+
assert self.model is not None
|
|
72
|
+
instance = self.model(**kwargs)
|
|
73
|
+
instance.save()
|
|
74
|
+
return instance
|