telamon 0.1.0a0__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.
telamon/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ """Telamon Admin: framework-agnostic admin panel for SQLAlchemy and Tortoise ORM."""
2
+
3
+ from .core import (
4
+ Action,
5
+ AdminError,
6
+ AdminRequest,
7
+ AdminResponse,
8
+ AdminSite,
9
+ AuthProvider,
10
+ Backend,
11
+ FieldType,
12
+ FilterGroup,
13
+ ListQuery,
14
+ ModelAdmin,
15
+ ModelSchema,
16
+ NotFound,
17
+ PermissionDenied,
18
+ Repository,
19
+ SessionAuth,
20
+ SimpleAuth,
21
+ User,
22
+ ValidationError,
23
+ action,
24
+ hash_password,
25
+ )
26
+
27
+ __version__ = "0.1.0a0"
28
+
29
+ __all__ = [
30
+ "Action",
31
+ "AdminError",
32
+ "AdminRequest",
33
+ "AdminResponse",
34
+ "AdminSite",
35
+ "AuthProvider",
36
+ "Backend",
37
+ "FieldType",
38
+ "FilterGroup",
39
+ "ListQuery",
40
+ "ModelAdmin",
41
+ "ModelSchema",
42
+ "NotFound",
43
+ "PermissionDenied",
44
+ "Repository",
45
+ "SessionAuth",
46
+ "SimpleAuth",
47
+ "User",
48
+ "ValidationError",
49
+ "action",
50
+ "hash_password",
51
+ ]
File without changes
@@ -0,0 +1,3 @@
1
+ from .backend import BeanieBackend, BeanieRepository
2
+
3
+ __all__ = ["BeanieBackend", "BeanieRepository"]
@@ -0,0 +1,147 @@
1
+ """Beanie ODM backend. ``Link[T]`` -> many_to_one, ``list[Link[T]]`` -> many_to_many,
2
+ ``BackLink[T]`` -> one_to_many (read-only). Requires ``init_beanie()`` before the first request.
3
+
4
+ Documents are read and written through the raw pymongo collection that Beanie manages; links are
5
+ stored as DBRefs exactly as Beanie stores them, so both sides stay compatible.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import builtins
11
+ from typing import Any, cast, get_args, get_origin
12
+
13
+ from beanie import Document
14
+ from beanie.odm.fields import BackLink, Link
15
+ from bson import DBRef
16
+
17
+ from telamon.core.model_admin import ModelAdmin
18
+ from telamon.core.schema import FieldSchema, FieldType, ModelSchema, Relation
19
+
20
+ from ..mongo.backend import Driver, MongoBackend, MongoRepository
21
+ from ..mongo.common import Ref, _unwrap, field_from_annotation, parse_pk
22
+
23
+ _HIDDEN = {"revision_id"}
24
+
25
+
26
+ class BeanieBackend(MongoBackend):
27
+ name = "beanie"
28
+
29
+ def __init__(self) -> None: # database comes from init_beanie()
30
+ self._driver: Driver | None = None
31
+
32
+ @property
33
+ def driver(self) -> Driver:
34
+ if self._driver is None:
35
+ raise RuntimeError(
36
+ "Beanie is not initialised; call init_beanie() before using the admin"
37
+ )
38
+ return self._driver
39
+
40
+ @driver.setter
41
+ def driver(self, value: Driver) -> None:
42
+ self._driver = value
43
+
44
+ def supports(self, model: type) -> bool:
45
+ return isinstance(model, type) and issubclass(model, Document)
46
+
47
+ def introspect(self, model: type, admin: ModelAdmin) -> ModelSchema:
48
+ fields: list[FieldSchema] = []
49
+ doc = cast(type[Document], model)
50
+ for name, info in doc.model_fields.items():
51
+ if name in _HIDDEN:
52
+ continue
53
+ if name == "id":
54
+ f = field_from_annotation("id", info.annotation, info)
55
+ f.primary_key, f.read_only, f.has_default, f.required, f.nullable = (
56
+ True,
57
+ True,
58
+ True,
59
+ False,
60
+ False,
61
+ )
62
+ fields.insert(0, f)
63
+ continue
64
+ core, nullable, _ = _unwrap(info.annotation)
65
+ origin = get_origin(core)
66
+ inner = get_args(core)[0] if origin in (list, set, tuple) and get_args(core) else None
67
+ if origin is Link:
68
+ target = get_args(core)[0]
69
+ ann: Any = (str | None) if nullable else str
70
+ fields.append(field_from_annotation(name, ann, info, extra_metadata=[Ref(target)]))
71
+ elif inner is not None and get_origin(inner) is Link:
72
+ target = get_args(inner)[0]
73
+ fields.append(
74
+ field_from_annotation(name, list[str], info, extra_metadata=[Ref(target)])
75
+ )
76
+ elif origin is BackLink or (inner is not None and get_origin(inner) is BackLink):
77
+ link = core if origin is BackLink else inner
78
+ target = get_args(link)[0] if get_args(link) else Any
79
+ fields.append(
80
+ FieldSchema(
81
+ name,
82
+ FieldType.ONE_TO_MANY,
83
+ read_only=True,
84
+ relation=Relation(model=target, kind=FieldType.ONE_TO_MANY),
85
+ sortable=False,
86
+ filterable=False,
87
+ )
88
+ )
89
+ else:
90
+ fields.append(field_from_annotation(name, info.annotation, info))
91
+ label = model.__name__
92
+ return ModelSchema(
93
+ key=label.lower(), label=label, label_plural=f"{label}s", pk_field="id", fields=fields
94
+ )
95
+
96
+ def repository(self, model: type, schema: ModelSchema, admin: ModelAdmin) -> BeanieRepository:
97
+ return BeanieRepository(self, model, schema, admin)
98
+
99
+
100
+ class BeanieRepository(MongoRepository):
101
+ id_path = ".$id"
102
+
103
+ @property
104
+ def driver(self) -> Driver:
105
+ _ = self.coll # resolving the collection also wires the driver from init_beanie()
106
+ return self.backend.driver
107
+
108
+ @property
109
+ def coll(self) -> Any:
110
+ if self._coll is None:
111
+ doc = cast(type[Document], self.model)
112
+ self._coll = doc.get_pymongo_collection() # raises if init_beanie() was not run
113
+ self.backend.driver = Driver(self._coll.database)
114
+ return self._coll
115
+
116
+ @property
117
+ def collection_name(self) -> str:
118
+ return str(cast(type[Document], self.model).get_collection_name())
119
+
120
+ def _target_collection(self, f: FieldSchema) -> str:
121
+ assert f.relation is not None
122
+ return str(f.relation.model.get_collection_name())
123
+
124
+ def _rel_value(self, f: FieldSchema, value: Any) -> Any:
125
+ if value is None:
126
+ return None
127
+ coll = self._target_collection(f)
128
+ if f.type == FieldType.MANY_TO_MANY:
129
+ return [DBRef(coll, parse_pk(v)) for v in value]
130
+ return DBRef(coll, parse_pk(value))
131
+
132
+ def _rel_ids(self, f: FieldSchema, stored: Any) -> builtins.list[Any]:
133
+ if stored is None:
134
+ return []
135
+ items = stored if isinstance(stored, list) else [stored]
136
+ out = []
137
+ for i in items:
138
+ if isinstance(i, DBRef):
139
+ out.append(i.id)
140
+ elif isinstance(i, dict) and "$id" in i:
141
+ out.append(i["$id"])
142
+ else:
143
+ out.append(i)
144
+ return out
145
+
146
+
147
+ __all__ = ["BeanieBackend", "BeanieRepository"]
@@ -0,0 +1,201 @@
1
+ """In-memory backend: reference implementation of the Repository contract, used by core tests.
2
+
3
+ Models are plain classes; their schema is passed explicitly. Rows are dicts keyed by pk.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Sequence
9
+ from typing import Any
10
+
11
+ from telamon.core.exceptions import ValidationError
12
+ from telamon.core.filters import Condition, FilterGroup, Op
13
+ from telamon.core.model_admin import ModelAdmin
14
+ from telamon.core.query import ListQuery, Page
15
+ from telamon.core.schema import FieldType, ModelSchema
16
+
17
+
18
+ def matches(cond: Condition, value: Any) -> bool: # noqa: C901
19
+ return bool(_matches(cond, value))
20
+
21
+
22
+ def _matches(cond: Condition, value: Any) -> Any:
23
+ op, target = cond.op, cond.value
24
+ if op == Op.IS_NULL:
25
+ return (value is None) == bool(target)
26
+ if op == Op.EQ:
27
+ return value == target
28
+ if op == Op.NE:
29
+ return value != target
30
+ if value is None:
31
+ return False
32
+ if op == Op.LT:
33
+ return value < target
34
+ if op == Op.LTE:
35
+ return value <= target
36
+ if op == Op.GT:
37
+ return value > target
38
+ if op == Op.GTE:
39
+ return value >= target
40
+ if op == Op.IN:
41
+ if isinstance(value, list): # many_to_many: any overlap
42
+ return any(v in (target or []) for v in value)
43
+ return value in (target or [])
44
+ if op == Op.NOT_IN:
45
+ if isinstance(value, list):
46
+ return not any(v in (target or []) for v in value)
47
+ return value not in (target or [])
48
+ if op == Op.CONTAINS:
49
+ return str(target) in str(value)
50
+ if op == Op.ICONTAINS:
51
+ return str(target).lower() in str(value).lower()
52
+ if op == Op.STARTSWITH:
53
+ return str(value).startswith(str(target))
54
+ if op == Op.ENDSWITH:
55
+ return str(value).endswith(str(target))
56
+ if op == Op.BETWEEN:
57
+ lo, hi = target
58
+ return lo <= value <= hi
59
+ raise ValueError(op)
60
+
61
+
62
+ def matches_group(group: FilterGroup, row: dict[str, Any]) -> bool:
63
+ if group.is_empty():
64
+ return True
65
+ results = (
66
+ matches(i, row.get(i.field)) if isinstance(i, Condition) else matches_group(i, row)
67
+ for i in group.items
68
+ )
69
+ return all(results) if group.logic == "and" else any(results)
70
+
71
+
72
+ class MemoryRepository:
73
+ def __init__(
74
+ self, backend: MemoryBackend, model: type, schema: ModelSchema, admin: ModelAdmin
75
+ ) -> None:
76
+ self.backend = backend
77
+ self.model = model
78
+ self.schema = schema
79
+ self.admin = admin
80
+ self.rows: dict[Any, dict[str, Any]] = backend.storage.setdefault(model, {})
81
+
82
+ def _next_pk(self) -> int:
83
+ existing = [p for p in self.rows if isinstance(p, int)]
84
+ return (max(existing) + 1) if existing else 1
85
+
86
+ def _apply(self, query: ListQuery) -> list[dict[str, Any]]:
87
+ rows = [r for r in self.rows.values() if matches_group(query.filters, r)]
88
+ if query.search:
89
+ term = query.search.lower()
90
+ searchable = [f.name for f in self.schema.fields if f.searchable]
91
+ rows = [r for r in rows if any(term in str(r.get(n) or "").lower() for n in searchable)]
92
+ for name, direction in reversed(query.sort):
93
+ rows.sort(key=lambda r: (r.get(name) is None, r.get(name)), reverse=direction == "desc")
94
+ return rows
95
+
96
+ async def list(self, query: ListQuery) -> Page[dict[str, Any]]:
97
+ rows = self._apply(query)
98
+ return Page(items=rows[query.offset : query.offset + query.limit], total=len(rows))
99
+
100
+ async def count(self, query: ListQuery) -> int:
101
+ return len(self._apply(query))
102
+
103
+ async def get(self, pk: Any) -> dict[str, Any] | None:
104
+ return self.rows.get(pk)
105
+
106
+ async def get_many(self, pks: Sequence[Any]) -> Sequence[dict[str, Any]]:
107
+ return [self.rows[p] for p in pks if p in self.rows]
108
+
109
+ def _check_relations(self, data: dict[str, Any]) -> None:
110
+ for name, value in data.items():
111
+ f = self.schema.field(name)
112
+ if not f.is_relation or f.relation is None or value is None:
113
+ continue
114
+ rows = self.backend.storage.get(f.relation.model, {})
115
+ ids = value if isinstance(value, list) else [value]
116
+ if any(i not in rows for i in ids):
117
+ raise ValidationError({name: "related object does not exist"})
118
+
119
+ async def create(self, data: dict[str, Any]) -> dict[str, Any]:
120
+ self._check_relations(data)
121
+ pk_name = self.schema.pk_field
122
+ row: dict[str, Any] = {
123
+ f.name: None for f in self.schema.fields if f.type != FieldType.ONE_TO_MANY
124
+ }
125
+ row.update(data)
126
+ if row.get(pk_name) is None:
127
+ row[pk_name] = self._next_pk()
128
+ if row[pk_name] in self.rows:
129
+ raise ValidationError({pk_name: "already exists"})
130
+ self.rows[row[pk_name]] = row
131
+ return row
132
+
133
+ async def update(self, obj: dict[str, Any], data: dict[str, Any]) -> dict[str, Any]:
134
+ self._check_relations(data)
135
+ obj.update(data)
136
+ return obj
137
+
138
+ async def delete(self, objs: Sequence[dict[str, Any]]) -> int:
139
+ n = 0
140
+ for o in objs:
141
+ if self.rows.pop(o[self.schema.pk_field], None) is not None:
142
+ n += 1
143
+ return n
144
+
145
+ def pk_of(self, obj: dict[str, Any]) -> Any:
146
+ return obj[self.schema.pk_field]
147
+
148
+ def label_of(self, obj: dict[str, Any]) -> str:
149
+ for f in self.schema.fields:
150
+ if f.type == FieldType.STRING and obj.get(f.name):
151
+ return str(obj[f.name])
152
+ return f"{self.schema.label} #{self.pk_of(obj)}"
153
+
154
+ def _related(self, f: Any, pk: Any) -> dict[str, Any]:
155
+ rows = self.backend.storage.get(f.relation.model, {})
156
+ target_schema = self.backend.schemas.get(f.relation.model)
157
+ row = rows.get(pk)
158
+ if row is None or target_schema is None:
159
+ return {"id": pk, "label": str(pk)}
160
+ repo = MemoryRepository(self.backend, f.relation.model, target_schema, self.admin)
161
+ return {"id": pk, "label": repo.label_of(row)}
162
+
163
+ def to_dict(self, obj: dict[str, Any]) -> dict[str, Any]:
164
+ out: dict[str, Any] = {}
165
+ for f in self.schema.fields:
166
+ if f.type == FieldType.ONE_TO_MANY:
167
+ continue
168
+ v = obj.get(f.name)
169
+ if f.type == FieldType.MANY_TO_ONE:
170
+ out[f.name] = None if v is None else self._related(f, v)
171
+ elif f.type == FieldType.MANY_TO_MANY:
172
+ out[f.name] = [self._related(f, i) for i in (v or [])]
173
+ else:
174
+ out[f.name] = v
175
+ return out
176
+
177
+
178
+ class MemoryBackend:
179
+ name = "memory"
180
+
181
+ def __init__(self, schemas: dict[type, ModelSchema] | None = None) -> None:
182
+ self.schemas: dict[type, ModelSchema] = dict(schemas or {})
183
+ self.storage: dict[type, dict[Any, dict[str, Any]]] = {}
184
+
185
+ def add(self, model: type, schema: ModelSchema) -> None:
186
+ self.schemas[model] = schema
187
+
188
+ def supports(self, model: type) -> bool:
189
+ return model in self.schemas
190
+
191
+ def introspect(self, model: type, admin: ModelAdmin) -> ModelSchema:
192
+ import copy
193
+
194
+ return copy.deepcopy(self.schemas[model])
195
+
196
+ def repository(self, model: type, schema: ModelSchema, admin: ModelAdmin) -> MemoryRepository:
197
+ return MemoryRepository(self, model, schema, admin)
198
+
199
+ def seed(self, model: type, rows: list[dict[str, Any]]) -> None:
200
+ pk = self.schemas[model].pk_field
201
+ self.storage.setdefault(model, {}).update({r[pk]: dict(r) for r in rows})
@@ -0,0 +1,4 @@
1
+ from .backend import MongoBackend, MongoRepository
2
+ from .common import Ref
3
+
4
+ __all__ = ["MongoBackend", "MongoRepository", "Ref"]