sardine-cms-validation 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,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: sardine-cms-validation
3
+ Version: 0.1.0
4
+ Summary: Configurable content validation rules for Sardine CMS
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: sardine-cms-core
8
+ Description-Content-Type: text/markdown
9
+
10
+ # cms-validation
11
+
12
+ Configurable validation rules for Sardine CMS: language parity,
13
+ structure, editorial rules. Runs before every publish. See the repository
14
+ root README and docs for the full architecture.
@@ -0,0 +1,5 @@
1
+ # cms-validation
2
+
3
+ Configurable validation rules for Sardine CMS: language parity,
4
+ structure, editorial rules. Runs before every publish. See the repository
5
+ root README and docs for the full architecture.
@@ -0,0 +1,15 @@
1
+ [project]
2
+ name = "sardine-cms-validation"
3
+ version = "0.1.0"
4
+ description = "Configurable content validation rules for Sardine CMS"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "Apache-2.0"
8
+ dependencies = ["sardine-cms-core"]
9
+
10
+ [build-system]
11
+ requires = ["hatchling"]
12
+ build-backend = "hatchling.build"
13
+
14
+ [tool.hatch.build.targets.wheel]
15
+ packages = ["src/cms_validation"]
@@ -0,0 +1,37 @@
1
+ """Configurable content validation rules."""
2
+
3
+ from cms_validation.engine import (
4
+ Issue,
5
+ Report,
6
+ Rule,
7
+ RuleSet,
8
+ Severity,
9
+ SiteContent,
10
+ ValidationContext,
11
+ )
12
+ from cms_validation.rules import (
13
+ KnownCategoriesRule,
14
+ MediaAltCoverageRule,
15
+ MediaReferencesRule,
16
+ RequiredTranslationsRule,
17
+ UniqueSlugsRule,
18
+ default_ruleset,
19
+ )
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ __all__ = [
24
+ "Issue",
25
+ "KnownCategoriesRule",
26
+ "MediaAltCoverageRule",
27
+ "MediaReferencesRule",
28
+ "Report",
29
+ "RequiredTranslationsRule",
30
+ "Rule",
31
+ "RuleSet",
32
+ "Severity",
33
+ "SiteContent",
34
+ "UniqueSlugsRule",
35
+ "ValidationContext",
36
+ "default_ruleset",
37
+ ]
@@ -0,0 +1,89 @@
1
+ """Validation engine: composable, configurable rules over site content.
2
+
3
+ A rule is any object satisfying the :class:`Rule` protocol; a
4
+ :class:`RuleSet` runs the enabled rules and aggregates issues. Publishing is
5
+ gated on the absence of ``ERROR`` issues.
6
+ """
7
+
8
+ from collections.abc import Iterable, Iterator, Sequence
9
+ from dataclasses import dataclass, field
10
+ from enum import StrEnum
11
+ from typing import Protocol, runtime_checkable
12
+
13
+ from cms_core import Article, Language, MediaAsset, Page
14
+
15
+
16
+ class Severity(StrEnum):
17
+ ERROR = "error"
18
+ WARNING = "warning"
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class Issue:
23
+ code: str
24
+ severity: Severity
25
+ message: str
26
+ subject: str
27
+ language: Language | None = None
28
+
29
+ def __str__(self) -> str:
30
+ scope = f" [{self.language.value}]" if self.language else ""
31
+ return f"{self.severity.value}: {self.code}: {self.subject}{scope}: {self.message}"
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class SiteContent:
36
+ """The full content set a validation or build run operates on."""
37
+
38
+ articles: Sequence[Article] = ()
39
+ pages: Sequence[Page] = ()
40
+ media: Sequence[MediaAsset] = ()
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class ValidationContext:
45
+ """Configuration a rule may consult; never read from globals."""
46
+
47
+ required_languages: tuple[Language, ...]
48
+ known_categories: tuple[str, ...] | None = None
49
+ """None disables the check; a tuple restricts article categories to it."""
50
+
51
+
52
+ @runtime_checkable
53
+ class Rule(Protocol):
54
+ name: str
55
+
56
+ def check(self, content: SiteContent, context: ValidationContext) -> Iterable[Issue]: ...
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class Report:
61
+ issues: tuple[Issue, ...]
62
+
63
+ @property
64
+ def errors(self) -> tuple[Issue, ...]:
65
+ return tuple(issue for issue in self.issues if issue.severity is Severity.ERROR)
66
+
67
+ @property
68
+ def warnings(self) -> tuple[Issue, ...]:
69
+ return tuple(issue for issue in self.issues if issue.severity is Severity.WARNING)
70
+
71
+ @property
72
+ def ok(self) -> bool:
73
+ return not self.errors
74
+
75
+
76
+ @dataclass(slots=True)
77
+ class RuleSet:
78
+ rules: list[Rule] = field(default_factory=list)
79
+ disabled: set[str] = field(default_factory=set)
80
+
81
+ def enabled_rules(self) -> Iterator[Rule]:
82
+ return (rule for rule in self.rules if rule.name not in self.disabled)
83
+
84
+ def run(self, content: SiteContent, context: ValidationContext) -> Report:
85
+ issues: list[Issue] = []
86
+ for rule in self.enabled_rules():
87
+ issues.extend(rule.check(content, context))
88
+ issues.sort(key=lambda issue: (issue.subject, issue.code, issue.language or ""))
89
+ return Report(issues=tuple(issues))
@@ -0,0 +1,162 @@
1
+ """Core validation rules.
2
+
3
+ Each rule is small, pure and independently testable. Rules never read global
4
+ state — everything comes from the content set and the context.
5
+ """
6
+
7
+ from collections.abc import Iterable, Iterator
8
+
9
+ from cms_core import Article, ContentStatus, Language, Page, TranslationState
10
+
11
+ from cms_validation.engine import Issue, Rule, Severity, SiteContent, ValidationContext
12
+
13
+
14
+ def _publishable(status: ContentStatus) -> bool:
15
+ return status in (ContentStatus.REVIEW, ContentStatus.PUBLISHED)
16
+
17
+
18
+ class RequiredTranslationsRule:
19
+ """Published/review content must be complete in every required language."""
20
+
21
+ name = "required-translations"
22
+
23
+ def check(self, content: SiteContent, context: ValidationContext) -> Iterator[Issue]:
24
+ entries: Iterable[tuple[str, Article | Page]] = [
25
+ *((f"article:{article.id}", article) for article in content.articles),
26
+ *((f"page:{page.id}", page) for page in content.pages),
27
+ ]
28
+ for subject, entry in entries:
29
+ if not _publishable(entry.status):
30
+ continue
31
+ severity = (
32
+ Severity.ERROR if entry.status is ContentStatus.PUBLISHED else Severity.WARNING
33
+ )
34
+ for language in context.required_languages:
35
+ state = entry.translation_state(language)
36
+ if state is not TranslationState.COMPLETE:
37
+ yield Issue(
38
+ code=self.name,
39
+ severity=severity,
40
+ message=f"translation is {state.value}",
41
+ subject=subject,
42
+ language=language,
43
+ )
44
+
45
+
46
+ class UniqueSlugsRule:
47
+ """Within each language, generated URLs must not collide."""
48
+
49
+ name = "unique-slugs"
50
+
51
+ def check(self, content: SiteContent, context: ValidationContext) -> Iterator[Issue]:
52
+ for language in (Language.EN, *context.required_languages):
53
+ seen: dict[str, str] = {}
54
+ for article in content.articles:
55
+ slug = _article_slug(article, language)
56
+ subject = f"article:{article.id}"
57
+ if slug in seen:
58
+ yield self._collision(subject, seen[slug], slug, language)
59
+ else:
60
+ seen[slug] = subject
61
+ for page in content.pages:
62
+ slug = _page_slug(page, language)
63
+ subject = f"page:{page.id}"
64
+ if slug in seen:
65
+ yield self._collision(subject, seen[slug], slug, language)
66
+ else:
67
+ seen[slug] = subject
68
+
69
+ def _collision(self, subject: str, other: str, slug: str, language: Language) -> Issue:
70
+ return Issue(
71
+ code=self.name,
72
+ severity=Severity.ERROR,
73
+ message=f"slug {slug!r} collides with {other}",
74
+ subject=subject,
75
+ language=language,
76
+ )
77
+
78
+
79
+ class MediaReferencesRule:
80
+ """Sections may only reference media assets that exist."""
81
+
82
+ name = "media-references"
83
+
84
+ def check(self, content: SiteContent, context: ValidationContext) -> Iterator[Issue]:
85
+ known = {asset.id for asset in content.media}
86
+ for page in content.pages:
87
+ for section in page.sections:
88
+ for media_id in section.source.media:
89
+ if media_id not in known:
90
+ yield Issue(
91
+ code=self.name,
92
+ severity=Severity.ERROR,
93
+ message=f"unknown media asset {media_id!r}",
94
+ subject=f"page:{page.id}/section:{section.key}",
95
+ )
96
+
97
+
98
+ class MediaAltCoverageRule:
99
+ """Media used by publishable content must have alt text in required languages."""
100
+
101
+ name = "media-alt-coverage"
102
+
103
+ def check(self, content: SiteContent, context: ValidationContext) -> Iterator[Issue]:
104
+ for asset in content.media:
105
+ missing = [
106
+ language
107
+ for language in context.required_languages
108
+ if not asset.alt.get(language, "").strip()
109
+ ]
110
+ for language in missing:
111
+ yield Issue(
112
+ code=self.name,
113
+ severity=Severity.WARNING,
114
+ message="missing alt text",
115
+ subject=f"media:{asset.id}",
116
+ language=language,
117
+ )
118
+
119
+
120
+ def _article_slug(article: Article, language: Language) -> str:
121
+ if language is Language.EN:
122
+ return article.source.slug or article.id
123
+ translation = article.translations.get(language)
124
+ if translation is None or translation.content.slug is None:
125
+ return article.source.slug or article.id
126
+ return translation.content.slug
127
+
128
+
129
+ def _page_slug(page: Page, language: Language) -> str:
130
+ if language is Language.EN:
131
+ return page.source.slug
132
+ translation = page.translations.get(language)
133
+ return translation.content.slug if translation else page.source.slug
134
+
135
+
136
+ class KnownCategoriesRule:
137
+ """When the project declares categories, articles may only use those."""
138
+
139
+ name = "known-categories"
140
+
141
+ def check(self, content: SiteContent, context: ValidationContext) -> Iterator[Issue]:
142
+ if context.known_categories is None:
143
+ return
144
+ known = set(context.known_categories)
145
+ for article in content.articles:
146
+ if article.category is not None and article.category not in known:
147
+ yield Issue(
148
+ code=self.name,
149
+ severity=Severity.ERROR,
150
+ message=f"unknown category {article.category!r}",
151
+ subject=f"article:{article.id}",
152
+ )
153
+
154
+
155
+ def default_ruleset() -> list[Rule]:
156
+ return [
157
+ RequiredTranslationsRule(),
158
+ UniqueSlugsRule(),
159
+ MediaReferencesRule(),
160
+ MediaAltCoverageRule(),
161
+ KnownCategoriesRule(),
162
+ ]