adminsite 0.1.0a1__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.
Files changed (61) hide show
  1. adminsite/__init__.py +50 -0
  2. adminsite/actions/__init__.py +4 -0
  3. adminsite/actions/action.py +70 -0
  4. adminsite/actions/selection.py +105 -0
  5. adminsite/admin.py +202 -0
  6. adminsite/auth/__init__.py +4 -0
  7. adminsite/auth/passwords.py +46 -0
  8. adminsite/auth/provider.py +86 -0
  9. adminsite/backends/__init__.py +0 -0
  10. adminsite/backends/sqlalchemy/__init__.py +37 -0
  11. adminsite/backends/sqlalchemy/filters.py +355 -0
  12. adminsite/backends/sqlalchemy/inspector.py +161 -0
  13. adminsite/backends/sqlalchemy/loader.py +54 -0
  14. adminsite/backends/sqlalchemy/repository.py +355 -0
  15. adminsite/backends/sqlalchemy/session.py +243 -0
  16. adminsite/exceptions.py +68 -0
  17. adminsite/fields/__init__.py +39 -0
  18. adminsite/fields/base.py +81 -0
  19. adminsite/fields/choice.py +94 -0
  20. adminsite/fields/registry.py +82 -0
  21. adminsite/fields/relation.py +66 -0
  22. adminsite/fields/scalars.py +95 -0
  23. adminsite/fields/temporal.py +91 -0
  24. adminsite/filters/__init__.py +15 -0
  25. adminsite/filters/base.py +101 -0
  26. adminsite/http/__init__.py +0 -0
  27. adminsite/http/endpoints.py +419 -0
  28. adminsite/http/export.py +60 -0
  29. adminsite/http/forms.py +136 -0
  30. adminsite/http/listing.py +148 -0
  31. adminsite/http/templating.py +80 -0
  32. adminsite/http/urls.py +124 -0
  33. adminsite/protocols.py +15 -0
  34. adminsite/py.typed +0 -0
  35. adminsite/query.py +99 -0
  36. adminsite/schema.py +128 -0
  37. adminsite/security/__init__.py +10 -0
  38. adminsite/security/csrf.py +41 -0
  39. adminsite/security/permissions.py +16 -0
  40. adminsite/static/adminsite.css +2 -0
  41. adminsite/static/alpine.min.js +21 -0
  42. adminsite/static/htmx.min.js +1 -0
  43. adminsite/templates/adminsite/_field.html +84 -0
  44. adminsite/templates/adminsite/_lookup.html +9 -0
  45. adminsite/templates/adminsite/_table.html +129 -0
  46. adminsite/templates/adminsite/_toolbar.html +85 -0
  47. adminsite/templates/adminsite/base.html +92 -0
  48. adminsite/templates/adminsite/detail.html +31 -0
  49. adminsite/templates/adminsite/form.html +40 -0
  50. adminsite/templates/adminsite/index.html +20 -0
  51. adminsite/templates/adminsite/list.html +20 -0
  52. adminsite/templates/adminsite/login.html +35 -0
  53. adminsite/text.py +59 -0
  54. adminsite/views/__init__.py +4 -0
  55. adminsite/views/model_view.py +422 -0
  56. adminsite/views/registry.py +64 -0
  57. adminsite/views/writing.py +40 -0
  58. adminsite-0.1.0a1.dist-info/METADATA +231 -0
  59. adminsite-0.1.0a1.dist-info/RECORD +61 -0
  60. adminsite-0.1.0a1.dist-info/WHEEL +4 -0
  61. adminsite-0.1.0a1.dist-info/licenses/LICENSE +21 -0
adminsite/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ from importlib.metadata import version
2
+
3
+ from adminsite.admin import Admin
4
+ from adminsite.exceptions import (
5
+ AdminSiteError,
6
+ InvalidPathError,
7
+ NotAModelError,
8
+ PermissionDeniedError,
9
+ RecordNotFoundError,
10
+ RefusedError,
11
+ UnknownFieldError,
12
+ )
13
+ from adminsite.protocols import ModelInspector
14
+ from adminsite.query import CountMode, Page, QuerySpec, Sort
15
+ from adminsite.schema import (
16
+ FieldPath,
17
+ FieldSchema,
18
+ ModelSchema,
19
+ RelationDirection,
20
+ RelationSchema,
21
+ )
22
+ from adminsite.security import Permission
23
+ from adminsite.views import ModelView, ViewRegistry
24
+
25
+ __version__ = version("adminsite")
26
+
27
+ __all__ = [
28
+ "Admin",
29
+ "AdminSiteError",
30
+ "CountMode",
31
+ "FieldPath",
32
+ "FieldSchema",
33
+ "InvalidPathError",
34
+ "ModelInspector",
35
+ "ModelSchema",
36
+ "ModelView",
37
+ "NotAModelError",
38
+ "Page",
39
+ "Permission",
40
+ "PermissionDeniedError",
41
+ "QuerySpec",
42
+ "RecordNotFoundError",
43
+ "RefusedError",
44
+ "RelationDirection",
45
+ "RelationSchema",
46
+ "Sort",
47
+ "UnknownFieldError",
48
+ "ViewRegistry",
49
+ "__version__",
50
+ ]
@@ -0,0 +1,4 @@
1
+ from adminsite.actions.action import Action, action, action_of
2
+ from adminsite.actions.selection import Selection
3
+
4
+ __all__ = ["Action", "Selection", "action", "action_of"]
@@ -0,0 +1,70 @@
1
+ from collections.abc import Awaitable, Callable
2
+ from dataclasses import dataclass
3
+ from typing import Any, TypeVar
4
+
5
+ from adminsite.security import Permission
6
+ from adminsite.text import humanize
7
+
8
+ MARKER = "__adminsite_action__"
9
+
10
+ Handler = TypeVar("Handler", bound=Callable[..., Awaitable[Any]])
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class Action:
15
+ """A button that runs over the rows the user picked."""
16
+
17
+ name: str
18
+ label: str
19
+ method: str
20
+ confirm: str = ""
21
+ permission: str = Permission.EDIT
22
+ dangerous: bool = False
23
+
24
+ @property
25
+ def needs_confirming(self) -> bool:
26
+ """Whether the user is asked before it runs."""
27
+ return bool(self.confirm)
28
+
29
+
30
+ def action(
31
+ label: str = "",
32
+ *,
33
+ name: str = "",
34
+ confirm: str = "",
35
+ permission: str = Permission.EDIT,
36
+ dangerous: bool = False,
37
+ ) -> Callable[[Handler], Handler]:
38
+ """Mark a method as a bulk action.
39
+
40
+ ```python
41
+ @action("Mark as shipped", confirm="Mark these orders as shipped?")
42
+ async def ship(self, selection: Selection) -> str:
43
+ await selection.update(status="shipped")
44
+ return f"{await selection.count()} orders marked as shipped."
45
+ ```
46
+ """
47
+
48
+ def mark(handler: Handler) -> Handler:
49
+ chosen = name or handler.__name__
50
+ setattr(
51
+ handler,
52
+ MARKER,
53
+ Action(
54
+ name=chosen,
55
+ label=label or humanize(chosen),
56
+ method=handler.__name__,
57
+ confirm=confirm,
58
+ permission=permission,
59
+ dangerous=dangerous,
60
+ ),
61
+ )
62
+ return handler
63
+
64
+ return mark
65
+
66
+
67
+ def action_of(candidate: object) -> Action | None:
68
+ """The action a method carries, if it was marked as one."""
69
+ found = getattr(candidate, MARKER, None)
70
+ return found if isinstance(found, Action) else None
@@ -0,0 +1,105 @@
1
+ from collections.abc import Sequence
2
+ from dataclasses import dataclass
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from sqlalchemy import Select, delete, func, select, update
6
+ from sqlalchemy.sql import Executable
7
+
8
+ from adminsite.backends.sqlalchemy.session import SessionAdapter
9
+ from adminsite.query import QuerySpec
10
+
11
+ if TYPE_CHECKING:
12
+ from adminsite.views import ModelView
13
+
14
+
15
+ @dataclass
16
+ class Selection:
17
+ """The rows an action runs over.
18
+
19
+ Either the rows that were ticked, or every row the current search and
20
+ filters match. The second kind is never loaded into memory, so an
21
+ action over a large table stays one statement.
22
+ """
23
+
24
+ view: "ModelView"
25
+ session: SessionAdapter
26
+ spec: QuerySpec
27
+ keys: Sequence[str] = ()
28
+ everything: bool = False
29
+ request: Any = None
30
+
31
+ @property
32
+ def repository(self) -> Any:
33
+ """The repository of the view this selection belongs to."""
34
+ return self.view.repository
35
+
36
+ def statement(self) -> Select[Any]:
37
+ """A statement selecting the primary keys this covers."""
38
+ columns = [
39
+ getattr(self.view.model, name) for name in self.view.schema.primary_key
40
+ ]
41
+ rows: Select[Any] = select(*columns).select_from(self.view.model)
42
+ rows = self.view.scope_for(self.request)(rows)
43
+ rows = self.repository.narrow(rows, self.spec)
44
+
45
+ if not self.everything:
46
+ rows = rows.where(self._key_condition())
47
+ return rows
48
+
49
+ async def count(self) -> int:
50
+ """How many rows this covers."""
51
+ counted = select(func.count()).select_from(self.statement().subquery())
52
+ return int(await self.session.scalar(counted) or 0)
53
+
54
+ async def records(self) -> list[Any]:
55
+ """Load the records, for work that needs each one in turn."""
56
+ statement = self.repository.base_statement(
57
+ self.view.scope_for(self.request)
58
+ ).where(
59
+ self._primary_key_column().in_(select(self.statement().subquery().c[0]))
60
+ )
61
+ return list((await self.session.scalars(statement)).unique().all())
62
+
63
+ async def update(self, **values: Any) -> int:
64
+ """Change every row this covers, in one statement.
65
+
66
+ This does not run the save hooks, because it never loads the
67
+ records. Use `records` when the hooks matter.
68
+ """
69
+ if not values:
70
+ return 0
71
+ statement = (
72
+ update(self.view.model)
73
+ .where(
74
+ self._primary_key_column().in_(select(self.statement().subquery().c[0]))
75
+ )
76
+ .values(**values)
77
+ )
78
+ return await self._run(statement)
79
+
80
+ async def delete(self) -> int:
81
+ """Delete every row this covers, in one statement."""
82
+ statement = delete(self.view.model).where(
83
+ self._primary_key_column().in_(select(self.statement().subquery().c[0]))
84
+ )
85
+ return await self._run(statement)
86
+
87
+ async def _run(self, statement: Executable) -> int:
88
+ """Run a write and report how many rows it touched."""
89
+ result = await self.session.execute(statement)
90
+ return int(getattr(result, "rowcount", 0) or 0)
91
+
92
+ def _primary_key_column(self) -> Any:
93
+ return getattr(self.view.model, self.view.schema.primary_key[0])
94
+
95
+ def _key_condition(self) -> Any:
96
+ column = self._primary_key_column()
97
+ field = self.view.schema.field_named(self.view.schema.primary_key[0])
98
+ wanted = [self._as_key(field.python_type, key) for key in self.keys]
99
+ return column.in_(wanted)
100
+
101
+ def _as_key(self, python_type: type[Any], key: str) -> Any:
102
+ try:
103
+ return python_type(key)
104
+ except (TypeError, ValueError, ArithmeticError):
105
+ return key
adminsite/admin.py ADDED
@@ -0,0 +1,202 @@
1
+ from collections.abc import Sequence
2
+ from functools import partial
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from starlette.applications import Starlette
7
+ from starlette.middleware import Middleware
8
+ from starlette.middleware.sessions import SessionMiddleware
9
+ from starlette.requests import Request
10
+ from starlette.responses import RedirectResponse, Response
11
+ from starlette.routing import Mount, Route
12
+ from starlette.staticfiles import StaticFiles
13
+
14
+ from adminsite.auth import AuthProvider
15
+ from adminsite.backends.sqlalchemy.inspector import SQLAlchemyInspector
16
+ from adminsite.backends.sqlalchemy.session import Database, SessionSource
17
+ from adminsite.exceptions import AdminSiteError
18
+ from adminsite.fields import FieldRegistry, default_registry
19
+ from adminsite.http import endpoints
20
+ from adminsite.http.templating import Templates
21
+ from adminsite.http.urls import Urls
22
+ from adminsite.views import ModelView, ViewRegistry
23
+
24
+ STATIC_DIR = Path(__file__).parent / "static"
25
+
26
+
27
+ class Admin:
28
+ """The admin itself: a small ASGI app you mount into your own.
29
+
30
+ ```python
31
+ admin = Admin(engine, title="Acme")
32
+ admin.add_view(OrderView)
33
+ app.mount("/admin", admin)
34
+ ```
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ source: Database | SessionSource,
40
+ *,
41
+ title: str = "Admin",
42
+ views: Sequence[ModelView | type[ModelView]] = (),
43
+ inspector: SQLAlchemyInspector | None = None,
44
+ fields: FieldRegistry | None = None,
45
+ template_dirs: Sequence[str | Path] = (),
46
+ auth: AuthProvider | None = None,
47
+ secret_key: str = "",
48
+ session_cookie: str = "adminsite_session",
49
+ ) -> None:
50
+ if auth is not None and not secret_key:
51
+ raise AdminSiteError(
52
+ "Signing in needs a secret_key to sign the session cookie."
53
+ )
54
+ self.database = source if isinstance(source, Database) else Database(source)
55
+ self.title = title
56
+ self.inspector = inspector or SQLAlchemyInspector()
57
+ self.fields = fields or default_registry
58
+ self.views = ViewRegistry()
59
+ self.templates = Templates(template_dirs)
60
+ self.auth = auth
61
+ self.secret_key = secret_key
62
+ self.session_cookie = session_cookie
63
+ self._app: Starlette | None = None
64
+
65
+ for view in views:
66
+ self.add_view(view)
67
+
68
+ def add_view(self, view: ModelView | type[ModelView]) -> ModelView:
69
+ """Register a model with the admin."""
70
+ built = view(self.inspector, self.fields) if isinstance(view, type) else view
71
+ return self.views.add(built)
72
+
73
+ @property
74
+ def app(self) -> Starlette:
75
+ """The Starlette app behind the admin, built once."""
76
+ if self._app is None:
77
+ self._app = Starlette(routes=self.routes(), middleware=self.middleware())
78
+ return self._app
79
+
80
+ def middleware(self) -> list[Middleware]:
81
+ """The middleware the admin runs behind, if it needs any."""
82
+ if not self.secret_key:
83
+ return []
84
+ return [
85
+ Middleware(
86
+ SessionMiddleware,
87
+ secret_key=self.secret_key,
88
+ session_cookie=self.session_cookie,
89
+ same_site="lax",
90
+ https_only=False,
91
+ )
92
+ ]
93
+
94
+ def routes(self) -> list[Route | Mount]:
95
+ """Every route the admin answers."""
96
+ # The fixed paths come first, so a record cannot be called "new".
97
+ return [
98
+ Route("/", self._handler(endpoints.index), name="index"),
99
+ Mount(
100
+ "/static",
101
+ app=StaticFiles(directory=STATIC_DIR),
102
+ name="static",
103
+ ),
104
+ Route(
105
+ "/login",
106
+ self._handler(endpoints.login_form, guarded=False),
107
+ methods=["GET"],
108
+ name="login_form",
109
+ ),
110
+ Route(
111
+ "/login",
112
+ self._handler(endpoints.login, guarded=False),
113
+ methods=["POST"],
114
+ name="login",
115
+ ),
116
+ Route(
117
+ "/logout",
118
+ self._handler(endpoints.logout, guarded=False),
119
+ methods=["POST"],
120
+ name="logout",
121
+ ),
122
+ Route("/{view}", self._handler(endpoints.list_records), name="list"),
123
+ Route(
124
+ "/{view}/new",
125
+ self._handler(endpoints.create_form),
126
+ methods=["GET"],
127
+ name="create_form",
128
+ ),
129
+ Route(
130
+ "/{view}/new",
131
+ self._handler(endpoints.create_record),
132
+ methods=["POST"],
133
+ name="create",
134
+ ),
135
+ Route(
136
+ "/{view}/export",
137
+ self._handler(endpoints.export_records),
138
+ name="export",
139
+ ),
140
+ Route(
141
+ "/{view}/action/{name}",
142
+ self._handler(endpoints.run_action),
143
+ methods=["POST"],
144
+ name="action",
145
+ ),
146
+ Route(
147
+ "/{view}/lookup/{path}",
148
+ self._handler(endpoints.lookup),
149
+ name="lookup",
150
+ ),
151
+ Route(
152
+ "/{view}/{key}",
153
+ self._handler(endpoints.detail),
154
+ name="detail",
155
+ ),
156
+ Route(
157
+ "/{view}/{key}/edit",
158
+ self._handler(endpoints.edit_form),
159
+ methods=["GET"],
160
+ name="edit_form",
161
+ ),
162
+ Route(
163
+ "/{view}/{key}/edit",
164
+ self._handler(endpoints.edit_record),
165
+ methods=["POST"],
166
+ name="edit",
167
+ ),
168
+ Route(
169
+ "/{view}/{key}/delete",
170
+ self._handler(endpoints.delete_record),
171
+ methods=["POST"],
172
+ name="delete",
173
+ ),
174
+ ]
175
+
176
+ async def render(
177
+ self,
178
+ name: str,
179
+ request: Request,
180
+ context: dict[str, Any] | None = None,
181
+ status_code: int = 200,
182
+ ) -> Response:
183
+ """Render one of the admin templates."""
184
+ return await self.templates.render(name, request, self, context, status_code)
185
+
186
+ def _handler(self, endpoint: Any, guarded: bool = True) -> Any:
187
+ if not guarded or self.auth is None:
188
+ return partial(endpoint, self)
189
+
190
+ async def guard(request: Request) -> Response:
191
+ user = await self.auth.current_user(request) if self.auth else None
192
+ if user is None:
193
+ return RedirectResponse(Urls(request).login(), status_code=303)
194
+ request.scope["user_record"] = user
195
+ answer: Response = await endpoint(self, request)
196
+ return answer
197
+
198
+ return guard
199
+
200
+ async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
201
+ """Let the admin be mounted like any other ASGI app."""
202
+ await self.app(scope, receive, send)
@@ -0,0 +1,4 @@
1
+ from adminsite.auth.passwords import hash_password, verify_password
2
+ from adminsite.auth.provider import AuthProvider, PasswordAuth
3
+
4
+ __all__ = ["AuthProvider", "PasswordAuth", "hash_password", "verify_password"]
@@ -0,0 +1,46 @@
1
+ import hashlib
2
+ import secrets
3
+
4
+ ALGORITHM = "pbkdf2_sha256"
5
+ ITERATIONS = 600_000
6
+ SALT_BYTES = 16
7
+
8
+
9
+ def hash_password(password: str, *, iterations: int = ITERATIONS) -> str:
10
+ """Hash a password for storing.
11
+
12
+ ```python
13
+ from adminsite.auth import hash_password
14
+
15
+ hash_password("letmein")
16
+ ```
17
+
18
+ Keep the result, not the password. The format is
19
+ `pbkdf2_sha256$iterations$salt$hash`.
20
+ """
21
+ salt = secrets.token_hex(SALT_BYTES)
22
+ digest = _digest(password, salt, iterations)
23
+ return f"{ALGORITHM}${iterations}${salt}${digest}"
24
+
25
+
26
+ def verify_password(password: str, stored: str) -> bool:
27
+ """Check a password against a stored hash, in constant time."""
28
+ try:
29
+ algorithm, rounds, salt, digest = stored.split("$")
30
+ iterations = int(rounds)
31
+ except ValueError:
32
+ return False
33
+ if algorithm != ALGORITHM:
34
+ return False
35
+ return secrets.compare_digest(digest, _digest(password, salt, iterations))
36
+
37
+
38
+ def looks_hashed(value: str) -> bool:
39
+ """Whether a value is one of our hashes rather than a plain password."""
40
+ return value.startswith(f"{ALGORITHM}$")
41
+
42
+
43
+ def _digest(password: str, salt: str, iterations: int) -> str:
44
+ return hashlib.pbkdf2_hmac(
45
+ "sha256", password.encode(), salt.encode(), iterations
46
+ ).hex()
@@ -0,0 +1,86 @@
1
+ from collections.abc import Mapping
2
+ from typing import Any
3
+
4
+ from starlette.requests import Request
5
+
6
+ from adminsite.auth.passwords import looks_hashed, verify_password
7
+ from adminsite.exceptions import AdminSiteError
8
+
9
+ SESSION_KEY = "adminsite_user"
10
+
11
+
12
+ class AuthProvider:
13
+ """Decides who may use the admin.
14
+
15
+ Subclass it and write `verify`. The session handling here is enough
16
+ for most projects, and `load_user` is where you turn the key kept in
17
+ the session back into whatever your application calls a user.
18
+ """
19
+
20
+ async def verify(self, username: str, password: str) -> Any | None:
21
+ """Return the user for these details, or nothing."""
22
+ raise NotImplementedError
23
+
24
+ async def load_user(self, key: str) -> Any | None:
25
+ """Turn the key kept in the session back into a user."""
26
+ return key
27
+
28
+ def identity(self, user: Any) -> str:
29
+ """The key to keep in the session for this user."""
30
+ return str(user)
31
+
32
+ async def current_user(self, request: Request) -> Any | None:
33
+ """Who is signed in, if anyone."""
34
+ session = request.scope.get("session")
35
+ if not session:
36
+ return None
37
+ key = session.get(SESSION_KEY)
38
+ return await self.load_user(key) if key else None
39
+
40
+ async def sign_in(
41
+ self, request: Request, username: str, password: str
42
+ ) -> Any | None:
43
+ """Check the details and remember the user."""
44
+ user = await self.verify(username, password)
45
+ if user is None:
46
+ return None
47
+ request.session[SESSION_KEY] = self.identity(user)
48
+ return user
49
+
50
+ async def sign_out(self, request: Request) -> None:
51
+ """Forget the user."""
52
+ session = request.scope.get("session")
53
+ if session:
54
+ session.pop(SESSION_KEY, None)
55
+
56
+
57
+ class PasswordAuth(AuthProvider):
58
+ """Signs in against a fixed set of usernames and password hashes.
59
+
60
+ ```python
61
+ from adminsite.auth import PasswordAuth, hash_password
62
+
63
+ PasswordAuth({"nima": hash_password("letmein")})
64
+ ```
65
+
66
+ Passwords have to be hashed with `hash_password`, so a plain one
67
+ never ends up in your settings or your repository. This suits a
68
+ small internal tool. Anything larger should subclass `AuthProvider`
69
+ and check its own user table.
70
+ """
71
+
72
+ def __init__(self, users: Mapping[str, str]) -> None:
73
+ for username, stored in users.items():
74
+ if not looks_hashed(stored):
75
+ raise AdminSiteError(
76
+ f"The password for {username!r} is not hashed. "
77
+ "Use adminsite.auth.hash_password to hash it first."
78
+ )
79
+ self.users = dict(users)
80
+
81
+ async def verify(self, username: str, password: str) -> Any | None:
82
+ """Check the password against the stored hash."""
83
+ stored = self.users.get(username)
84
+ if stored is None:
85
+ return None
86
+ return username if verify_password(password, stored) else None
File without changes
@@ -0,0 +1,37 @@
1
+ from adminsite.backends.sqlalchemy.filters import (
2
+ BooleanFilter,
3
+ ChoiceFilter,
4
+ DateRangeFilter,
5
+ NumberRangeFilter,
6
+ RelationFilter,
7
+ SQLFilter,
8
+ SQLFilterContext,
9
+ TextFilter,
10
+ filter_for,
11
+ )
12
+ from adminsite.backends.sqlalchemy.inspector import SQLAlchemyInspector
13
+ from adminsite.backends.sqlalchemy.repository import SQLAlchemyRepository
14
+ from adminsite.backends.sqlalchemy.session import (
15
+ AsyncSessionAdapter,
16
+ Database,
17
+ SessionAdapter,
18
+ SyncSessionAdapter,
19
+ )
20
+
21
+ __all__ = [
22
+ "AsyncSessionAdapter",
23
+ "BooleanFilter",
24
+ "ChoiceFilter",
25
+ "Database",
26
+ "DateRangeFilter",
27
+ "NumberRangeFilter",
28
+ "RelationFilter",
29
+ "SQLAlchemyInspector",
30
+ "SQLAlchemyRepository",
31
+ "SQLFilter",
32
+ "SQLFilterContext",
33
+ "SessionAdapter",
34
+ "SyncSessionAdapter",
35
+ "TextFilter",
36
+ "filter_for",
37
+ ]