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 +189 -0
- callish-0.1.0/README.md +160 -0
- callish-0.1.0/pyproject.toml +92 -0
- callish-0.1.0/src/callish/__init__.py +36 -0
- callish-0.1.0/src/callish/_meta.py +117 -0
- callish-0.1.0/src/callish/adapter.py +102 -0
- callish-0.1.0/src/callish/apps.py +18 -0
- callish-0.1.0/src/callish/exceptions.py +51 -0
- callish-0.1.0/src/callish/fields.py +54 -0
- callish-0.1.0/src/callish/forms.py +58 -0
- callish-0.1.0/src/callish/manager.py +74 -0
- callish-0.1.0/src/callish/models.py +244 -0
- callish-0.1.0/src/callish/queryset.py +254 -0
- callish-0.1.0/src/callish/testing/__init__.py +10 -0
- callish-0.1.0/src/callish/testing/helpers.py +33 -0
- callish-0.1.0/src/callish/testing/plugin.py +62 -0
- callish-0.1.0/src/callish/testing/reference_adapter.py +270 -0
- callish-0.1.0/src/callish/testing/suite.py +250 -0
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.
|
callish-0.1.0/README.md
ADDED
|
@@ -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
|