callish 0.1.0__tar.gz

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-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,189 @@
1
+ Metadata-Version: 2.3
2
+ Name: callish
3
+ Version: 0.1.0
4
+ Summary: Django QuerySet façade for user-written API adapters.
5
+ License: BSD-3-Clause
6
+ Keywords: django,api,queryset,modelform,adapter
7
+ Author: callish contributors
8
+ Requires-Python: >=3.10
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: Django
11
+ Classifier: Framework :: Django :: 5.0
12
+ Classifier: Framework :: Django :: 5.1
13
+ Classifier: Framework :: Django :: 5.2
14
+ Classifier: Framework :: Django :: 6.0
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: BSD License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Requires-Dist: django (>=5.0,<7.0)
26
+ Project-URL: Homepage, https://github.com/Technology-Company/callish
27
+ Project-URL: Issues, https://github.com/Technology-Company/callish/issues
28
+ Description-Content-Type: text/markdown
29
+
30
+ # callish
31
+
32
+ **Django QuerySet façade for user-written API adapters.**
33
+
34
+ `callish` lets a Django app expose data living behind an arbitrary API as if
35
+ it were a Django model — so `ModelForm`, generic class-based views, templates,
36
+ and admin register/instantiation keep working — **without** trying to
37
+ standardise the HTTP shape. You write the adapter (`list / retrieve / create /
38
+ update / delete`); `callish` wires it into Django.
39
+
40
+ ## Why
41
+
42
+ Existing libraries either lock you into a specific HTTP shape
43
+ (`wagtail/queryish` — REST-only, read-only) or rebuild Django machinery
44
+ from scratch. APIs vary too much for one base class to fit all of them.
45
+ callish takes the opposite stance: you write the five-method adapter, and
46
+ callish handles the Django integration around it.
47
+
48
+ The conformance suite is the spec — when the test battery is green, Django
49
+ integration works.
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ pip install callish # core
55
+ # or, with Poetry:
56
+ poetry add callish
57
+ ```
58
+
59
+ **Requires** Python ≥3.10, Django ≥5.0 (5.x or 6.x). No HTTP client
60
+ dependency — that's the adapter's job.
61
+
62
+ ## Quick start
63
+
64
+ ```python
65
+ from callish import APIModel, APIModelForm
66
+ from callish.fields import CharField, IntegerField, BooleanField
67
+
68
+
69
+ class Invoice(APIModel):
70
+ id = IntegerField(primary_key=True)
71
+ number = CharField(max_length=64)
72
+ amount_cents = IntegerField()
73
+ paid = BooleanField(default=False)
74
+
75
+ class Meta:
76
+ adapter = "myproject.adapters:InvoiceAdapter"
77
+ app_label = "myproject"
78
+
79
+
80
+ # Use it like a Django model:
81
+ qs = Invoice.objects.filter(paid=False).order_by("-amount_cents")[:25]
82
+ for invoice in qs: # → adapter.list(...)
83
+ print(invoice.number, invoice.amount_cents)
84
+
85
+ invoice = Invoice.objects.get(pk=42) # → adapter.retrieve(42)
86
+ invoice.paid = True
87
+ invoice.save() # → adapter.update(42, {...})
88
+
89
+ new = Invoice(number="INV-100", amount_cents=10_000, paid=False)
90
+ new.save() # → adapter.create({...})
91
+
92
+ invoice.delete() # → adapter.delete(42)
93
+ ```
94
+
95
+ ## Writing an adapter
96
+
97
+ Any object with these methods works:
98
+
99
+ ```python
100
+ class InvoiceAdapter:
101
+ def list(self, *, filters, ordering, offset, limit): ... # → Sequence[dict]
102
+ def retrieve(self, pk): ... # → dict
103
+ def create(self, data): ... # → dict (with pk)
104
+ def update(self, pk, data): ... # → dict
105
+ def delete(self, pk): ...
106
+ def count(self, *, filters): ... # optional
107
+ ```
108
+
109
+ Raise `callish.exceptions.NotFound / Unauthorized / RateLimited /
110
+ Upstream5xx / AdapterValidationError` for errors. `NotFound` is mapped to
111
+ the model's `DoesNotExist`; the rest surface as-is.
112
+
113
+ A complete reference implementation lives in
114
+ [`src/callish/testing/reference_adapter.py`](src/callish/testing/reference_adapter.py)
115
+ (`InMemoryAdapter` — dict-backed, used by callish's own tests).
116
+
117
+ ## Django integration
118
+
119
+ Everything you'd expect from a Django model works:
120
+
121
+ - **ModelForm** — subclass `APIModelForm`; `form.save()` dispatches to
122
+ `adapter.create` / `adapter.update`.
123
+ - **Generic class-based views** — `ListView`, `DetailView`, `CreateView`,
124
+ `UpdateView`, `DeleteView` all work unchanged.
125
+ - **Function-based views** — `Invoice.objects.filter(...)` inside any
126
+ `def view(request)` function.
127
+ - **Templates** — `{% for invoice in invoices %}{{ invoice.number }}`,
128
+ `{{ form.as_p }}` etc. all work.
129
+ - **Admin** — `admin.site.register([Invoice], InvoiceAdmin)` (note the list
130
+ wrap) registers fine and `ModelAdmin` instances construct. The
131
+ **changelist** is out of scope (admin assumes a real DB-backed Model).
132
+
133
+ ## The conformance suite
134
+
135
+ `callish` ships a milestone-organised pytest suite that exercises the adapter
136
+ contract. Downstream adapter authors add one fixture and run:
137
+
138
+ ```python
139
+ # your_project/conftest.py
140
+ import pytest
141
+ from your_project.adapters import StripeInvoiceAdapter
142
+
143
+ @pytest.fixture
144
+ def conform_adapter():
145
+ return StripeInvoiceAdapter(api_key="sk_test_...")
146
+ ```
147
+
148
+ ```bash
149
+ pytest --pyargs callish.testing.suite
150
+ ```
151
+
152
+ The pytest plugin auto-loads via the `pytest11` entry point. To opt out:
153
+ `pytest -p no:callish`.
154
+
155
+ Milestones:
156
+ - **M1** — list / retrieve / count / filter / order / slice
157
+ - **M2** — create / update / delete
158
+ - **M3** — error and timeout propagation
159
+
160
+ Use `--callish-skip-m3` to skip error-path tests during incremental adoption.
161
+
162
+ ## Non-goals (v1)
163
+
164
+ - Cross-source joins (API model ↔ ORM model)
165
+ - Relations between API models (FK, M2M, prefetch)
166
+ - Async adapters
167
+ - ModelAdmin changelist
168
+ - Code generation from OpenAPI / GraphQL schemas
169
+ - Wagtail Snippet / Chooser integration
170
+
171
+ callish coexists with Wagtail 6.x and 7.x without monkey-patching Django —
172
+ `callish.apps.CallishConfig` is intentionally inert.
173
+
174
+ ## Development
175
+
176
+ ```bash
177
+ poetry install # core + dev deps
178
+ poetry run pytest # library suite (94 tests)
179
+ poetry run pytest --pyargs callish.testing.suite # shipping conformance suite
180
+ poetry run ruff check src tests
181
+ poetry run mypy src/callish
182
+ ```
183
+
184
+ The full spec is in [`callish-spec.md`](callish-spec.md). Architecture and
185
+ internals are documented in [`CLAUDE.md`](CLAUDE.md).
186
+
187
+ ## License
188
+
189
+ BSD-3-Clause.
@@ -0,0 +1,160 @@
1
+ # callish
2
+
3
+ **Django QuerySet façade for user-written API adapters.**
4
+
5
+ `callish` lets a Django app expose data living behind an arbitrary API as if
6
+ it were a Django model — so `ModelForm`, generic class-based views, templates,
7
+ and admin register/instantiation keep working — **without** trying to
8
+ standardise the HTTP shape. You write the adapter (`list / retrieve / create /
9
+ update / delete`); `callish` wires it into Django.
10
+
11
+ ## Why
12
+
13
+ Existing libraries either lock you into a specific HTTP shape
14
+ (`wagtail/queryish` — REST-only, read-only) or rebuild Django machinery
15
+ from scratch. APIs vary too much for one base class to fit all of them.
16
+ callish takes the opposite stance: you write the five-method adapter, and
17
+ callish handles the Django integration around it.
18
+
19
+ The conformance suite is the spec — when the test battery is green, Django
20
+ integration works.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install callish # core
26
+ # or, with Poetry:
27
+ poetry add callish
28
+ ```
29
+
30
+ **Requires** Python ≥3.10, Django ≥5.0 (5.x or 6.x). No HTTP client
31
+ dependency — that's the adapter's job.
32
+
33
+ ## Quick start
34
+
35
+ ```python
36
+ from callish import APIModel, APIModelForm
37
+ from callish.fields import CharField, IntegerField, BooleanField
38
+
39
+
40
+ class Invoice(APIModel):
41
+ id = IntegerField(primary_key=True)
42
+ number = CharField(max_length=64)
43
+ amount_cents = IntegerField()
44
+ paid = BooleanField(default=False)
45
+
46
+ class Meta:
47
+ adapter = "myproject.adapters:InvoiceAdapter"
48
+ app_label = "myproject"
49
+
50
+
51
+ # Use it like a Django model:
52
+ qs = Invoice.objects.filter(paid=False).order_by("-amount_cents")[:25]
53
+ for invoice in qs: # → adapter.list(...)
54
+ print(invoice.number, invoice.amount_cents)
55
+
56
+ invoice = Invoice.objects.get(pk=42) # → adapter.retrieve(42)
57
+ invoice.paid = True
58
+ invoice.save() # → adapter.update(42, {...})
59
+
60
+ new = Invoice(number="INV-100", amount_cents=10_000, paid=False)
61
+ new.save() # → adapter.create({...})
62
+
63
+ invoice.delete() # → adapter.delete(42)
64
+ ```
65
+
66
+ ## Writing an adapter
67
+
68
+ Any object with these methods works:
69
+
70
+ ```python
71
+ class InvoiceAdapter:
72
+ def list(self, *, filters, ordering, offset, limit): ... # → Sequence[dict]
73
+ def retrieve(self, pk): ... # → dict
74
+ def create(self, data): ... # → dict (with pk)
75
+ def update(self, pk, data): ... # → dict
76
+ def delete(self, pk): ...
77
+ def count(self, *, filters): ... # optional
78
+ ```
79
+
80
+ Raise `callish.exceptions.NotFound / Unauthorized / RateLimited /
81
+ Upstream5xx / AdapterValidationError` for errors. `NotFound` is mapped to
82
+ the model's `DoesNotExist`; the rest surface as-is.
83
+
84
+ A complete reference implementation lives in
85
+ [`src/callish/testing/reference_adapter.py`](src/callish/testing/reference_adapter.py)
86
+ (`InMemoryAdapter` — dict-backed, used by callish's own tests).
87
+
88
+ ## Django integration
89
+
90
+ Everything you'd expect from a Django model works:
91
+
92
+ - **ModelForm** — subclass `APIModelForm`; `form.save()` dispatches to
93
+ `adapter.create` / `adapter.update`.
94
+ - **Generic class-based views** — `ListView`, `DetailView`, `CreateView`,
95
+ `UpdateView`, `DeleteView` all work unchanged.
96
+ - **Function-based views** — `Invoice.objects.filter(...)` inside any
97
+ `def view(request)` function.
98
+ - **Templates** — `{% for invoice in invoices %}{{ invoice.number }}`,
99
+ `{{ form.as_p }}` etc. all work.
100
+ - **Admin** — `admin.site.register([Invoice], InvoiceAdmin)` (note the list
101
+ wrap) registers fine and `ModelAdmin` instances construct. The
102
+ **changelist** is out of scope (admin assumes a real DB-backed Model).
103
+
104
+ ## The conformance suite
105
+
106
+ `callish` ships a milestone-organised pytest suite that exercises the adapter
107
+ contract. Downstream adapter authors add one fixture and run:
108
+
109
+ ```python
110
+ # your_project/conftest.py
111
+ import pytest
112
+ from your_project.adapters import StripeInvoiceAdapter
113
+
114
+ @pytest.fixture
115
+ def conform_adapter():
116
+ return StripeInvoiceAdapter(api_key="sk_test_...")
117
+ ```
118
+
119
+ ```bash
120
+ pytest --pyargs callish.testing.suite
121
+ ```
122
+
123
+ The pytest plugin auto-loads via the `pytest11` entry point. To opt out:
124
+ `pytest -p no:callish`.
125
+
126
+ Milestones:
127
+ - **M1** — list / retrieve / count / filter / order / slice
128
+ - **M2** — create / update / delete
129
+ - **M3** — error and timeout propagation
130
+
131
+ Use `--callish-skip-m3` to skip error-path tests during incremental adoption.
132
+
133
+ ## Non-goals (v1)
134
+
135
+ - Cross-source joins (API model ↔ ORM model)
136
+ - Relations between API models (FK, M2M, prefetch)
137
+ - Async adapters
138
+ - ModelAdmin changelist
139
+ - Code generation from OpenAPI / GraphQL schemas
140
+ - Wagtail Snippet / Chooser integration
141
+
142
+ callish coexists with Wagtail 6.x and 7.x without monkey-patching Django —
143
+ `callish.apps.CallishConfig` is intentionally inert.
144
+
145
+ ## Development
146
+
147
+ ```bash
148
+ poetry install # core + dev deps
149
+ poetry run pytest # library suite (94 tests)
150
+ poetry run pytest --pyargs callish.testing.suite # shipping conformance suite
151
+ poetry run ruff check src tests
152
+ poetry run mypy src/callish
153
+ ```
154
+
155
+ The full spec is in [`callish-spec.md`](callish-spec.md). Architecture and
156
+ internals are documented in [`CLAUDE.md`](CLAUDE.md).
157
+
158
+ ## License
159
+
160
+ BSD-3-Clause.
@@ -0,0 +1,92 @@
1
+ [project]
2
+ name = "callish"
3
+ version = "0.1.0"
4
+ description = "Django QuerySet façade for user-written API adapters."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "BSD-3-Clause" }
8
+ authors = [{ name = "callish contributors" }]
9
+ keywords = ["django", "api", "queryset", "modelform", "adapter"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Framework :: Django",
13
+ "Framework :: Django :: 5.0",
14
+ "Framework :: Django :: 5.1",
15
+ "Framework :: Django :: 5.2",
16
+ "Framework :: Django :: 6.0",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: BSD License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Software Development :: Libraries :: Python Modules",
27
+ ]
28
+ dependencies = ["django>=5.0,<7.0"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/Technology-Company/callish"
32
+ Issues = "https://github.com/Technology-Company/callish/issues"
33
+
34
+ [project.entry-points.pytest11]
35
+ callish = "callish.testing.plugin"
36
+
37
+ [build-system]
38
+ requires = ["poetry-core>=1.8.0"]
39
+ build-backend = "poetry.core.masonry.api"
40
+
41
+ [tool.poetry]
42
+ package-mode = true
43
+ packages = [{ include = "callish", from = "src" }]
44
+
45
+ [tool.poetry.dependencies]
46
+ python = ">=3.10,<4.0"
47
+ django = ">=5.0,<7.0"
48
+
49
+ [tool.poetry.group.dev.dependencies]
50
+ pytest = ">=8,<9"
51
+ pytest-django = ">=4.8"
52
+ ruff = ">=0.6"
53
+ mypy = ">=1.10"
54
+
55
+ # Opt-in: `poetry install --with compat` to run the Wagtail coexistence smoke test.
56
+ [tool.poetry.group.compat]
57
+ optional = true
58
+
59
+ [tool.poetry.group.compat.dependencies]
60
+ wagtail = ">=6.0,<8.0"
61
+
62
+ [tool.pytest.ini_options]
63
+ DJANGO_SETTINGS_MODULE = "tests.settings"
64
+ addopts = "-ra"
65
+ python_files = ["test_*.py"]
66
+ testpaths = ["tests"]
67
+ pythonpath = ["."]
68
+ filterwarnings = [
69
+ "error",
70
+ "ignore::DeprecationWarning:django.*",
71
+ ]
72
+
73
+ [tool.ruff]
74
+ line-length = 100
75
+ target-version = "py310"
76
+ src = ["src", "tests"]
77
+
78
+ [tool.ruff.lint]
79
+ select = ["E", "F", "I", "B", "UP", "W"]
80
+ ignore = ["E501"]
81
+
82
+ [tool.ruff.lint.per-file-ignores]
83
+ "tests/*" = ["B011"]
84
+
85
+ [tool.mypy]
86
+ python_version = "3.10"
87
+ strict = false
88
+ warn_unused_ignores = true
89
+ warn_redundant_casts = true
90
+ warn_unreachable = true
91
+ ignore_missing_imports = true
92
+ files = ["src/callish"]
@@ -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
+ ]
@@ -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}>"
@@ -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