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 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