sardine-cms-core 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.
@@ -0,0 +1,37 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .venv/
6
+ venv/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ .pytest_cache/
10
+ .coverage
11
+ htmlcov/
12
+ dist/
13
+ build/
14
+
15
+ # Node (admin panel UI, if applicable)
16
+ node_modules/
17
+ *.tsbuildinfo
18
+
19
+ # Environment and secrets
20
+ .env
21
+ .env.*
22
+ !.env.example
23
+
24
+ # Local databases
25
+ *.sqlite3
26
+ *.db
27
+
28
+ # Static build output
29
+ _site/
30
+ out/
31
+ export/
32
+
33
+ # OS / editors
34
+ .DS_Store
35
+ .idea/
36
+ .vscode/
37
+ !.vscode/settings.json
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: sardine-cms-core
3
+ Version: 0.1.0
4
+ Summary: Content model, versioned schemas and translation states for Sardine CMS
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: pydantic>=2.9
8
+ Provides-Extra: mssql
9
+ Requires-Dist: pymssql>=2.3; extra == 'mssql'
10
+ Provides-Extra: mysql
11
+ Requires-Dist: pymysql>=1.1; extra == 'mysql'
12
+ Provides-Extra: postgres
13
+ Requires-Dist: psycopg[binary]>=3.2; extra == 'postgres'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # cms-core
17
+
18
+ Content model, versioned schemas and translation states (EN source;
19
+ `missing / outdated / complete`) for Sardine CMS. See the repository
20
+ root README and docs for the full architecture.
@@ -0,0 +1,5 @@
1
+ # cms-core
2
+
3
+ Content model, versioned schemas and translation states (EN source;
4
+ `missing / outdated / complete`) for Sardine CMS. See the repository
5
+ root README and docs for the full architecture.
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "sardine-cms-core"
3
+ version = "0.1.0"
4
+ description = "Content model, versioned schemas and translation states for Sardine CMS"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "Apache-2.0"
8
+ dependencies = ["pydantic>=2.9"]
9
+
10
+ [project.optional-dependencies]
11
+ postgres = ["psycopg[binary]>=3.2"]
12
+ mysql = ["PyMySQL>=1.1"]
13
+ mssql = ["pymssql>=2.3"]
14
+
15
+ [build-system]
16
+ requires = ["hatchling"]
17
+ build-backend = "hatchling.build"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/cms_core"]
@@ -0,0 +1,49 @@
1
+ """Content model, versioned schemas and translation states."""
2
+
3
+ from cms_core.accounts import AdminSession, Role, User
4
+ from cms_core.languages import SOURCE_LANGUAGE, TARGET_LANGUAGES, Language
5
+ from cms_core.media import MediaAsset
6
+ from cms_core.models import (
7
+ SCHEMA_VERSION,
8
+ Article,
9
+ ArticleContent,
10
+ new_article,
11
+ )
12
+ from cms_core.pages import Page, PageContent, Section, SectionContent, new_page
13
+ from cms_core.states import ContentStatus, TranslationState
14
+ from cms_core.storage import StorageBackend, create_storage
15
+ from cms_core.translatable import (
16
+ ChecksummedContent,
17
+ TranslatableModel,
18
+ Translation,
19
+ worst_state,
20
+ )
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ __all__ = [
25
+ "SCHEMA_VERSION",
26
+ "SOURCE_LANGUAGE",
27
+ "TARGET_LANGUAGES",
28
+ "AdminSession",
29
+ "Article",
30
+ "ArticleContent",
31
+ "ChecksummedContent",
32
+ "ContentStatus",
33
+ "Language",
34
+ "MediaAsset",
35
+ "Page",
36
+ "PageContent",
37
+ "Role",
38
+ "Section",
39
+ "SectionContent",
40
+ "StorageBackend",
41
+ "TranslatableModel",
42
+ "Translation",
43
+ "TranslationState",
44
+ "User",
45
+ "create_storage",
46
+ "new_article",
47
+ "new_page",
48
+ "worst_state",
49
+ ]
@@ -0,0 +1,48 @@
1
+ """Admin accounts and sessions (Milestone 3).
2
+
3
+ Accounts are an operational concern of the admin panel: they live in the
4
+ storage database (shared migration history) but are **never** part of the
5
+ JSON/Markdown export — the portable source of truth stays content-only.
6
+ The domain layer only defines the shapes; hashing and session issuance are
7
+ the admin application's job.
8
+ """
9
+
10
+ from datetime import datetime
11
+ from enum import StrEnum
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+ USERNAME_PATTERN = r"^[a-z0-9][a-z0-9._-]{0,63}$"
16
+
17
+
18
+ class Role(StrEnum):
19
+ """Least-privilege ladder; each step includes the previous one's intent.
20
+
21
+ editor: create and edit drafts · reviewer: move drafts through review ·
22
+ publisher: publish and archive · admin: manage accounts and settings.
23
+ """
24
+
25
+ EDITOR = "editor"
26
+ REVIEWER = "reviewer"
27
+ PUBLISHER = "publisher"
28
+ ADMIN = "admin"
29
+
30
+
31
+ class User(BaseModel):
32
+ model_config = ConfigDict(frozen=True)
33
+
34
+ username: str = Field(pattern=USERNAME_PATTERN)
35
+ password_hash: str = Field(min_length=1)
36
+ role: Role
37
+ created_at: datetime
38
+
39
+
40
+ class AdminSession(BaseModel):
41
+ """A server-side session row. Only the token's hash is stored."""
42
+
43
+ model_config = ConfigDict(frozen=True)
44
+
45
+ token_hash: str = Field(min_length=1)
46
+ username: str = Field(pattern=USERNAME_PATTERN)
47
+ csrf_token: str = Field(min_length=1)
48
+ expires_at: datetime
@@ -0,0 +1,142 @@
1
+ """Deterministic portable export: a JSON index plus per-language Markdown files.
2
+
3
+ Same input always produces byte-identical output — articles are sorted by id,
4
+ JSON keys are sorted, and no timestamps are generated at export time.
5
+ """
6
+
7
+ import json
8
+ from collections.abc import Iterable
9
+
10
+ from cms_core.languages import SOURCE_LANGUAGE, Language
11
+ from cms_core.media import MediaAsset
12
+ from cms_core.models import SCHEMA_VERSION, Article, ArticleContent
13
+ from cms_core.pages import Page, Section
14
+
15
+
16
+ def article_to_portable(article: Article) -> dict[str, object]:
17
+ languages: dict[str, dict[str, str]] = {
18
+ SOURCE_LANGUAGE.value: {
19
+ "state": "complete",
20
+ "title": article.source.title,
21
+ "summary": article.source.summary,
22
+ }
23
+ }
24
+ for language, translation in sorted(
25
+ article.translations.items(), key=lambda item: item[0].value
26
+ ):
27
+ languages[language.value] = {
28
+ "state": article.translation_state(language).value,
29
+ "title": translation.content.title,
30
+ "summary": translation.content.summary,
31
+ "source_checksum": translation.source_checksum,
32
+ }
33
+ return {
34
+ "id": article.id,
35
+ "status": article.status.value,
36
+ "created_at": article.created_at.isoformat(),
37
+ "updated_at": article.updated_at.isoformat(),
38
+ "languages": languages,
39
+ }
40
+
41
+
42
+ def section_to_portable(section: Section) -> dict[str, object]:
43
+ languages: dict[str, dict[str, object]] = {
44
+ SOURCE_LANGUAGE.value: {
45
+ "state": "complete",
46
+ "fields": dict(sorted(section.source.fields.items())),
47
+ "media": list(section.source.media),
48
+ }
49
+ }
50
+ for language, translation in sorted(
51
+ section.translations.items(), key=lambda item: item[0].value
52
+ ):
53
+ languages[language.value] = {
54
+ "state": section.translation_state(language).value,
55
+ "fields": dict(sorted(translation.content.fields.items())),
56
+ "media": list(translation.content.media),
57
+ "source_checksum": translation.source_checksum,
58
+ }
59
+ return {"key": section.key, "kind": section.kind, "languages": languages}
60
+
61
+
62
+ def page_to_portable(page: Page) -> dict[str, object]:
63
+ languages: dict[str, dict[str, str]] = {
64
+ SOURCE_LANGUAGE.value: {
65
+ "state": "complete",
66
+ "title": page.source.title,
67
+ "description": page.source.description,
68
+ "slug": page.source.slug,
69
+ }
70
+ }
71
+ for language, translation in sorted(page.translations.items(), key=lambda item: item[0].value):
72
+ languages[language.value] = {
73
+ "state": page.translation_state(language).value,
74
+ "title": translation.content.title,
75
+ "description": translation.content.description,
76
+ "slug": translation.content.slug,
77
+ "source_checksum": translation.source_checksum,
78
+ }
79
+ return {
80
+ "id": page.id,
81
+ "status": page.status.value,
82
+ "created_at": page.created_at.isoformat(),
83
+ "updated_at": page.updated_at.isoformat(),
84
+ "languages": languages,
85
+ "sections": [section_to_portable(section) for section in page.sections],
86
+ }
87
+
88
+
89
+ def media_to_portable(asset: MediaAsset) -> dict[str, object]:
90
+ return {
91
+ "id": asset.id,
92
+ "path": asset.path,
93
+ "mime_type": asset.mime_type,
94
+ "width": asset.width,
95
+ "height": asset.height,
96
+ "alt": {
97
+ language.value: text
98
+ for language, text in sorted(asset.alt.items(), key=lambda item: item[0].value)
99
+ },
100
+ }
101
+
102
+
103
+ def export_content_json(
104
+ articles: Iterable[Article],
105
+ pages: Iterable[Page] = (),
106
+ media: Iterable[MediaAsset] = (),
107
+ ) -> str:
108
+ payload = {
109
+ "schema_version": SCHEMA_VERSION,
110
+ "articles": [
111
+ article_to_portable(article)
112
+ for article in sorted(articles, key=lambda article: article.id)
113
+ ],
114
+ "pages": [page_to_portable(page) for page in sorted(pages, key=lambda page: page.id)],
115
+ "media": [media_to_portable(asset) for asset in sorted(media, key=lambda asset: asset.id)],
116
+ }
117
+ return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
118
+
119
+
120
+ def export_markdown_files(articles: Iterable[Article]) -> dict[str, str]:
121
+ """Map of relative file path -> Markdown content, one file per language."""
122
+ files: dict[str, str] = {}
123
+ for article in sorted(articles, key=lambda article: article.id):
124
+ files[f"{article.id}/{SOURCE_LANGUAGE.value}.md"] = _render_markdown(article.source)
125
+ for language, translation in sorted(
126
+ article.translations.items(), key=lambda item: item[0].value
127
+ ):
128
+ files[f"{article.id}/{language.value}.md"] = _render_markdown(translation.content)
129
+ return files
130
+
131
+
132
+ def _render_markdown(content: ArticleContent) -> str:
133
+ lines = [f"# {content.title}", ""]
134
+ if content.summary:
135
+ lines.extend([f"> {content.summary}", ""])
136
+ lines.append(content.body_markdown)
137
+ return "\n".join(lines).rstrip() + "\n"
138
+
139
+
140
+ def languages_in_export(files: dict[str, str]) -> set[Language]:
141
+ """Languages present in a Markdown export (used by parity checks)."""
142
+ return {Language(path.rsplit("/", 1)[1].removesuffix(".md")) for path in files}
@@ -0,0 +1,17 @@
1
+ """Supported languages. English is the canonical source language."""
2
+
3
+ from enum import StrEnum
4
+
5
+
6
+ class Language(StrEnum):
7
+ EN = "en"
8
+ PT_PT = "pt-pt"
9
+ ES = "es"
10
+ FR = "fr"
11
+ DE = "de"
12
+
13
+
14
+ SOURCE_LANGUAGE = Language.EN
15
+ TARGET_LANGUAGES: tuple[Language, ...] = tuple(
16
+ language for language in Language if language is not SOURCE_LANGUAGE
17
+ )
@@ -0,0 +1,38 @@
1
+ """Media assets with mandatory, translatable alt text.
2
+
3
+ Editorial rules from the brief are enforced at the model level: images must
4
+ declare their dimensions, and alt text in the source language is mandatory.
5
+ """
6
+
7
+ from pydantic import BaseModel, Field, model_validator
8
+
9
+ from cms_core.languages import SOURCE_LANGUAGE, TARGET_LANGUAGES, Language
10
+ from cms_core.models import SLUG_PATTERN
11
+
12
+
13
+ class MediaAsset(BaseModel):
14
+ id: str = Field(pattern=SLUG_PATTERN)
15
+ path: str = Field(min_length=1)
16
+ mime_type: str = Field(min_length=1)
17
+ width: int | None = Field(default=None, gt=0)
18
+ height: int | None = Field(default=None, gt=0)
19
+ alt: dict[Language, str]
20
+
21
+ @property
22
+ def is_image(self) -> bool:
23
+ return self.mime_type.startswith("image/")
24
+
25
+ @model_validator(mode="after")
26
+ def _enforce_editorial_rules(self) -> "MediaAsset":
27
+ if self.path.startswith("/") or ".." in self.path.split("/"):
28
+ raise ValueError("media path must be relative and must not traverse upwards")
29
+ if self.is_image and (self.width is None or self.height is None):
30
+ raise ValueError("images must declare width and height")
31
+ if not self.alt.get(SOURCE_LANGUAGE, "").strip():
32
+ raise ValueError(f"alt text in the source language ({SOURCE_LANGUAGE}) is mandatory")
33
+ return self
34
+
35
+ def missing_alt_languages(self) -> tuple[Language, ...]:
36
+ return tuple(
37
+ language for language in TARGET_LANGUAGES if not self.alt.get(language, "").strip()
38
+ )
@@ -0,0 +1,51 @@
1
+ """Article content model with per-language translations.
2
+
3
+ Translation freshness is tracked by checksum (see :mod:`cms_core.translatable`):
4
+ every translation records the checksum of the source (EN) content it was
5
+ translated from, so a source edit automatically marks translations outdated.
6
+ """
7
+
8
+ import re
9
+ from datetime import UTC, datetime
10
+
11
+ from pydantic import Field, field_validator
12
+
13
+ from cms_core.states import ContentStatus
14
+ from cms_core.translatable import ChecksummedContent, TranslatableModel
15
+
16
+ SCHEMA_VERSION = 2
17
+
18
+ SLUG_PATTERN = r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
19
+
20
+
21
+ class ArticleContent(ChecksummedContent):
22
+ title: str = Field(min_length=1)
23
+ summary: str = ""
24
+ body_markdown: str = ""
25
+ slug: str | None = Field(default=None, pattern=SLUG_PATTERN)
26
+
27
+ def checksum_payload(self) -> tuple[str, ...]:
28
+ return (self.title, self.summary, self.body_markdown, self.slug or "")
29
+
30
+
31
+ class Article(TranslatableModel[ArticleContent]):
32
+ id: str = Field(pattern=SLUG_PATTERN)
33
+ status: ContentStatus = ContentStatus.DRAFT
34
+ created_at: datetime
35
+ updated_at: datetime
36
+ category: str | None = Field(default=None, pattern=SLUG_PATTERN)
37
+ cover: str | None = Field(default=None, pattern=SLUG_PATTERN)
38
+ tags: tuple[str, ...] = ()
39
+
40
+ @field_validator("tags")
41
+ @classmethod
42
+ def _tags_are_slugs(cls, value: tuple[str, ...]) -> tuple[str, ...]:
43
+ for tag in value:
44
+ if not re.fullmatch(SLUG_PATTERN, tag):
45
+ raise ValueError(f"tag {tag!r} is not a valid slug")
46
+ return tuple(sorted(set(value)))
47
+
48
+
49
+ def new_article(article_id: str, source: ArticleContent, *, now: datetime | None = None) -> Article:
50
+ timestamp = now or datetime.now(tz=UTC)
51
+ return Article(id=article_id, created_at=timestamp, updated_at=timestamp, source=source)
@@ -0,0 +1,65 @@
1
+ """Pages composed of typed sections; both are independently translatable.
2
+
3
+ A section's ``kind`` names the theme template that renders it — cms-build
4
+ never hardcodes HTML, per the extensibility contracts in docs/PLAN.md. A
5
+ page's translation state aggregates its own content and every section: the
6
+ least-complete state wins, so one missing section translation blocks the
7
+ whole page from publishing.
8
+ """
9
+
10
+ from datetime import UTC, datetime
11
+
12
+ from pydantic import Field
13
+
14
+ from cms_core.languages import Language
15
+ from cms_core.models import SLUG_PATTERN
16
+ from cms_core.states import ContentStatus, TranslationState
17
+ from cms_core.translatable import ChecksummedContent, TranslatableModel, worst_state
18
+
19
+
20
+ class PageContent(ChecksummedContent):
21
+ title: str = Field(min_length=1)
22
+ description: str = ""
23
+ slug: str = Field(pattern=SLUG_PATTERN)
24
+
25
+ def checksum_payload(self) -> tuple[str, ...]:
26
+ return (self.title, self.description, self.slug)
27
+
28
+
29
+ class SectionContent(ChecksummedContent):
30
+ fields: dict[str, str] = Field(default_factory=dict)
31
+ media: list[str] = Field(default_factory=list)
32
+
33
+ def checksum_payload(self) -> tuple[str, ...]:
34
+ field_parts = tuple(f"{name}\x1e{self.fields[name]}" for name in sorted(self.fields))
35
+ return (*field_parts, "\x1d", *self.media)
36
+
37
+
38
+ class Section(TranslatableModel[SectionContent]):
39
+ key: str = Field(pattern=SLUG_PATTERN)
40
+ kind: str = Field(pattern=SLUG_PATTERN)
41
+
42
+
43
+ class Page(TranslatableModel[PageContent]):
44
+ id: str = Field(pattern=SLUG_PATTERN)
45
+ status: ContentStatus = ContentStatus.DRAFT
46
+ created_at: datetime
47
+ updated_at: datetime
48
+ sections: list[Section] = Field(default_factory=list)
49
+
50
+ def translation_state(self, language: Language) -> TranslationState:
51
+ own = super().translation_state(language)
52
+ return worst_state(
53
+ (own, *(section.translation_state(language) for section in self.sections))
54
+ )
55
+
56
+ def section(self, key: str) -> Section | None:
57
+ for section in self.sections:
58
+ if section.key == key:
59
+ return section
60
+ return None
61
+
62
+
63
+ def new_page(page_id: str, source: PageContent, *, now: datetime | None = None) -> Page:
64
+ timestamp = now or datetime.now(tz=UTC)
65
+ return Page(id=page_id, created_at=timestamp, updated_at=timestamp, source=source)
File without changes
@@ -0,0 +1,16 @@
1
+ """Translation states and the editorial content workflow."""
2
+
3
+ from enum import StrEnum
4
+
5
+
6
+ class TranslationState(StrEnum):
7
+ MISSING = "missing"
8
+ OUTDATED = "outdated"
9
+ COMPLETE = "complete"
10
+
11
+
12
+ class ContentStatus(StrEnum):
13
+ DRAFT = "draft"
14
+ REVIEW = "review"
15
+ PUBLISHED = "published"
16
+ ARCHIVED = "archived"
@@ -0,0 +1,23 @@
1
+ """Multi-backend persistence behind one interface.
2
+
3
+ Backends register per URL scheme (``create_storage("sqlite:///content.db")``).
4
+ SQLite ships built in; PostgreSQL, SQL Server and MySQL/MariaDB are planned
5
+ and third parties can register custom engines via :func:`register_backend`.
6
+ """
7
+
8
+ from cms_core.storage.base import StorageBackend
9
+ from cms_core.storage.factory import (
10
+ available_schemes,
11
+ create_storage,
12
+ register_backend,
13
+ )
14
+ from cms_core.storage.sqlite import MIGRATIONS, SQLiteBackend
15
+
16
+ __all__ = [
17
+ "MIGRATIONS",
18
+ "SQLiteBackend",
19
+ "StorageBackend",
20
+ "available_schemes",
21
+ "create_storage",
22
+ "register_backend",
23
+ ]
@@ -0,0 +1,126 @@
1
+ """Backend-agnostic storage interface.
2
+
3
+ Every storage engine (SQLite today; PostgreSQL and SQL Server planned)
4
+ implements this one interface, so nothing above the storage layer knows which
5
+ database is in use. The database is always a working store — the portable
6
+ source of truth is the JSON/Markdown export (:mod:`cms_core.export`).
7
+ """
8
+
9
+ from abc import ABC, abstractmethod
10
+ from datetime import datetime
11
+ from types import TracebackType
12
+
13
+ from cms_core.accounts import AdminSession, User
14
+ from cms_core.media import MediaAsset
15
+ from cms_core.models import Article
16
+ from cms_core.pages import Page
17
+
18
+
19
+ class StorageBackend(ABC):
20
+ @abstractmethod
21
+ def schema_version(self) -> int: ...
22
+
23
+ @abstractmethod
24
+ def migrate(self) -> int: ...
25
+
26
+ @abstractmethod
27
+ def close(self) -> None: ...
28
+
29
+ # Articles
30
+
31
+ @abstractmethod
32
+ def save_article(self, article: Article) -> None: ...
33
+
34
+ @abstractmethod
35
+ def load_article(self, article_id: str) -> Article | None: ...
36
+
37
+ @abstractmethod
38
+ def delete_article(self, article_id: str) -> bool: ...
39
+
40
+ @abstractmethod
41
+ def list_article_ids(self) -> list[str]: ...
42
+
43
+ def load_all_articles(self) -> list[Article]:
44
+ articles = (self.load_article(article_id) for article_id in self.list_article_ids())
45
+ return [article for article in articles if article is not None]
46
+
47
+ # Pages
48
+
49
+ @abstractmethod
50
+ def save_page(self, page: Page) -> None: ...
51
+
52
+ @abstractmethod
53
+ def load_page(self, page_id: str) -> Page | None: ...
54
+
55
+ @abstractmethod
56
+ def delete_page(self, page_id: str) -> bool: ...
57
+
58
+ @abstractmethod
59
+ def list_page_ids(self) -> list[str]: ...
60
+
61
+ def load_all_pages(self) -> list[Page]:
62
+ pages = (self.load_page(page_id) for page_id in self.list_page_ids())
63
+ return [page for page in pages if page is not None]
64
+
65
+ # Media
66
+
67
+ @abstractmethod
68
+ def save_media_asset(self, asset: MediaAsset) -> None: ...
69
+
70
+ @abstractmethod
71
+ def load_media_asset(self, asset_id: str) -> MediaAsset | None: ...
72
+
73
+ @abstractmethod
74
+ def delete_media_asset(self, asset_id: str) -> bool: ...
75
+
76
+ @abstractmethod
77
+ def list_media_ids(self) -> list[str]: ...
78
+
79
+ def load_all_media_assets(self) -> list[MediaAsset]:
80
+ assets = (self.load_media_asset(asset_id) for asset_id in self.list_media_ids())
81
+ return [asset for asset in assets if asset is not None]
82
+
83
+ def has_content(self) -> bool:
84
+ """True when any article, page or media asset exists."""
85
+ return bool(self.list_article_ids() or self.list_page_ids() or self.list_media_ids())
86
+
87
+ # Admin accounts (never exported — see cms_core.accounts)
88
+
89
+ @abstractmethod
90
+ def save_user(self, user: User) -> None: ...
91
+
92
+ @abstractmethod
93
+ def load_user(self, username: str) -> User | None: ...
94
+
95
+ @abstractmethod
96
+ def delete_user(self, username: str) -> bool: ...
97
+
98
+ @abstractmethod
99
+ def list_usernames(self) -> list[str]: ...
100
+
101
+ # Admin sessions
102
+
103
+ @abstractmethod
104
+ def save_session(self, session: AdminSession) -> None: ...
105
+
106
+ @abstractmethod
107
+ def load_session(self, token_hash: str) -> AdminSession | None: ...
108
+
109
+ @abstractmethod
110
+ def delete_session(self, token_hash: str) -> bool: ...
111
+
112
+ @abstractmethod
113
+ def delete_expired_sessions(self, now: datetime) -> int: ...
114
+
115
+ # Context manager
116
+
117
+ def __enter__(self) -> "StorageBackend":
118
+ return self
119
+
120
+ def __exit__(
121
+ self,
122
+ exc_type: type[BaseException] | None,
123
+ exc_value: BaseException | None,
124
+ traceback: TracebackType | None,
125
+ ) -> None:
126
+ self.close()