pptx-cli 1.0.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.
@@ -0,0 +1,215 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from pptx import Presentation
6
+
7
+ from pptx_cli.models.manifest import (
8
+ ManifestDiffResult,
9
+ ManifestDocument,
10
+ ValidationIssue,
11
+ ValidationResult,
12
+ )
13
+
14
+
15
+ class ValidationError(ValueError):
16
+ code: str
17
+
18
+ def __init__(self, code: str, message: str) -> None:
19
+ super().__init__(message)
20
+ self.code = code
21
+
22
+
23
+ def validate_deck(
24
+ manifest_dir: Path,
25
+ manifest: ManifestDocument,
26
+ deck_path: Path,
27
+ *,
28
+ strict: bool,
29
+ ) -> ValidationResult:
30
+ if not deck_path.exists():
31
+ raise ValidationError("ERR_IO_NOT_FOUND", f"Deck not found: {deck_path}")
32
+
33
+ prs = Presentation(str(deck_path))
34
+ issues: list[ValidationIssue] = []
35
+ expected_size = manifest.presentation.get("page_size", {})
36
+ actual_width = int(prs.slide_width or 0)
37
+ actual_height = int(prs.slide_height or 0)
38
+ if actual_width != expected_size.get("width_emu") or actual_height != expected_size.get(
39
+ "height_emu"
40
+ ):
41
+ issues.append(
42
+ ValidationIssue(
43
+ code="ERR_VALIDATION_PAGE_SIZE",
44
+ severity="error",
45
+ message="Deck page size does not match the manifest",
46
+ details={
47
+ "expected": expected_size,
48
+ "actual": {"width_emu": actual_width, "height_emu": actual_height},
49
+ },
50
+ )
51
+ )
52
+
53
+ layouts_by_source_name = {layout.source_layout_name: layout for layout in manifest.layouts}
54
+ checked_layouts: set[str] = set()
55
+ for slide_index, slide in enumerate(prs.slides, start=1):
56
+ source_layout = getattr(slide, "slide_layout", None)
57
+ layout_name = getattr(source_layout, "name", None)
58
+ if layout_name not in layouts_by_source_name:
59
+ issues.append(
60
+ ValidationIssue(
61
+ code="ERR_VALIDATION_LAYOUT_UNKNOWN",
62
+ severity="error",
63
+ message="Slide uses a layout that is not present in the manifest",
64
+ details={"slide_index": slide_index, "layout_name": layout_name},
65
+ )
66
+ )
67
+ continue
68
+
69
+ layout = layouts_by_source_name[layout_name]
70
+ checked_layouts.add(layout.id)
71
+ expected_idxs = {placeholder.placeholder_idx for placeholder in layout.placeholders}
72
+ actual_idxs = {shape.placeholder_format.idx for shape in slide.placeholders}
73
+ missing_idxs = sorted(expected_idxs - actual_idxs)
74
+ if missing_idxs:
75
+ issues.append(
76
+ ValidationIssue(
77
+ code="ERR_VALIDATION_PLACEHOLDER_MISSING",
78
+ severity="error",
79
+ message="Slide is missing expected placeholders for its layout",
80
+ details={
81
+ "slide_index": slide_index,
82
+ "layout_id": layout.id,
83
+ "placeholder_idxs": missing_idxs,
84
+ },
85
+ )
86
+ )
87
+
88
+ if manifest.compatibility_report.findings:
89
+ for finding in manifest.compatibility_report.findings:
90
+ if finding.severity == "warning":
91
+ issues.append(
92
+ ValidationIssue(
93
+ code=finding.code,
94
+ severity="error" if strict else "warning",
95
+ message=f"Manifest compatibility warning: {finding.message}",
96
+ details=finding.details,
97
+ )
98
+ )
99
+
100
+ has_errors = any(issue.severity == "error" for issue in issues)
101
+ return ValidationResult(
102
+ manifest_path=str(manifest_dir),
103
+ deck_path=str(deck_path),
104
+ ok=not has_errors,
105
+ issues=issues,
106
+ checked_slides=len(prs.slides),
107
+ checked_layouts=len(checked_layouts),
108
+ )
109
+
110
+
111
+ def diff_manifests(left: ManifestDocument, right: ManifestDocument) -> ManifestDiffResult:
112
+ result = ManifestDiffResult()
113
+ left_layouts = {layout.id: layout for layout in left.layouts}
114
+ right_layouts = {layout.id: layout for layout in right.layouts}
115
+
116
+ removed_layouts = sorted(set(left_layouts) - set(right_layouts))
117
+ added_layouts = sorted(set(right_layouts) - set(left_layouts))
118
+
119
+ for layout_id in removed_layouts:
120
+ result.breaking_changes.append({"type": "layout.removed", "layout_id": layout_id})
121
+ for layout_id in added_layouts:
122
+ result.additive_changes.append({"type": "layout.added", "layout_id": layout_id})
123
+
124
+ shared_layouts = sorted(set(left_layouts) & set(right_layouts))
125
+ for layout_id in shared_layouts:
126
+ left_layout = left_layouts[layout_id]
127
+ right_layout = right_layouts[layout_id]
128
+ if left_layout.source_layout_name != right_layout.source_layout_name:
129
+ result.breaking_changes.append(
130
+ {
131
+ "type": "layout.renamed",
132
+ "layout_id": layout_id,
133
+ "before": left_layout.source_layout_name,
134
+ "after": right_layout.source_layout_name,
135
+ }
136
+ )
137
+ left_placeholders = {
138
+ placeholder.logical_name: placeholder for placeholder in left_layout.placeholders
139
+ }
140
+ right_placeholders = {
141
+ placeholder.logical_name: placeholder for placeholder in right_layout.placeholders
142
+ }
143
+ removed_placeholders = sorted(set(left_placeholders) - set(right_placeholders))
144
+ added_placeholders = sorted(set(right_placeholders) - set(left_placeholders))
145
+ for placeholder_name in removed_placeholders:
146
+ result.breaking_changes.append(
147
+ {
148
+ "type": "placeholder.removed",
149
+ "layout_id": layout_id,
150
+ "placeholder": placeholder_name,
151
+ }
152
+ )
153
+ for placeholder_name in added_placeholders:
154
+ result.additive_changes.append(
155
+ {
156
+ "type": "placeholder.added",
157
+ "layout_id": layout_id,
158
+ "placeholder": placeholder_name,
159
+ }
160
+ )
161
+ for placeholder_name in sorted(set(left_placeholders) & set(right_placeholders)):
162
+ left_placeholder = left_placeholders[placeholder_name]
163
+ right_placeholder = right_placeholders[placeholder_name]
164
+ left_geometry = (
165
+ left_placeholder.left_emu,
166
+ left_placeholder.top_emu,
167
+ left_placeholder.width_emu,
168
+ left_placeholder.height_emu,
169
+ )
170
+ right_geometry = (
171
+ right_placeholder.left_emu,
172
+ right_placeholder.top_emu,
173
+ right_placeholder.width_emu,
174
+ right_placeholder.height_emu,
175
+ )
176
+ if left_geometry != right_geometry:
177
+ result.breaking_changes.append(
178
+ {
179
+ "type": "placeholder.geometry_changed",
180
+ "layout_id": layout_id,
181
+ "placeholder": placeholder_name,
182
+ "before": left_geometry,
183
+ "after": right_geometry,
184
+ }
185
+ )
186
+ if (
187
+ left_placeholder.supported_content_types
188
+ != right_placeholder.supported_content_types
189
+ ):
190
+ result.breaking_changes.append(
191
+ {
192
+ "type": "placeholder.content_types_changed",
193
+ "layout_id": layout_id,
194
+ "placeholder": placeholder_name,
195
+ "before": left_placeholder.supported_content_types,
196
+ "after": right_placeholder.supported_content_types,
197
+ }
198
+ )
199
+
200
+ left_theme = left.presentation.get("theme", {})
201
+ right_theme = right.presentation.get("theme", {})
202
+ if left_theme != right_theme:
203
+ result.breaking_changes.append(
204
+ {
205
+ "type": "theme.changed",
206
+ "before": left_theme,
207
+ "after": right_theme,
208
+ }
209
+ )
210
+
211
+ if not result.breaking_changes and not result.additive_changes:
212
+ result.unchanged.append("layouts")
213
+ result.unchanged.append("theme")
214
+
215
+ return result
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ _VERSION_RE = re.compile(r'__version__\s*=\s*["\'](?P<version>\d+\.\d+\.\d+)["\']')
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class SemVer:
12
+ major: int
13
+ minor: int
14
+ patch: int
15
+
16
+ @classmethod
17
+ def parse(cls, value: str) -> SemVer:
18
+ parts = value.split(".")
19
+ if len(parts) != 3 or not all(part.isdigit() for part in parts):
20
+ raise ValueError(f"Invalid semantic version: {value}")
21
+ return cls(*(int(part) for part in parts))
22
+
23
+ def bump(self, part: str) -> SemVer:
24
+ if part == "major":
25
+ return SemVer(self.major + 1, 0, 0)
26
+ if part == "minor":
27
+ return SemVer(self.major, self.minor + 1, 0)
28
+ if part == "patch":
29
+ return SemVer(self.major, self.minor, self.patch + 1)
30
+ raise ValueError(f"Unsupported version part: {part}")
31
+
32
+ def __str__(self) -> str:
33
+ return f"{self.major}.{self.minor}.{self.patch}"
34
+
35
+
36
+ def read_version_from_init(init_file: Path) -> SemVer:
37
+ contents = init_file.read_text(encoding="utf-8")
38
+ match = _VERSION_RE.search(contents)
39
+ if match is None:
40
+ raise ValueError(f"Could not find __version__ in {init_file}")
41
+ return SemVer.parse(match.group("version"))
42
+
43
+
44
+ def write_version_to_init(init_file: Path, version: SemVer) -> None:
45
+ contents = init_file.read_text(encoding="utf-8")
46
+ updated = _VERSION_RE.sub(f'__version__ = "{version}"', contents, count=1)
47
+ init_file.write_text(updated, encoding="utf-8")
@@ -0,0 +1 @@
1
+ """Typed models for CLI contracts."""
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class CliMessage(BaseModel):
9
+ code: str
10
+ message: str
11
+ retryable: bool = False
12
+ retry_after_ms: int | None = None
13
+ suggested_action: str | None = None
14
+ details: dict[str, Any] = Field(default_factory=dict)
15
+
16
+
17
+ class Metrics(BaseModel):
18
+ duration_ms: int
19
+ operations_executed: int | None = None
20
+ bytes_written: int | None = None
21
+
22
+
23
+ class Envelope(BaseModel):
24
+ schema_version: str = "1.0"
25
+ request_id: str
26
+ ok: bool
27
+ command: str
28
+ result: dict[str, Any] | list[Any] | str | int | float | bool | None
29
+ warnings: list[CliMessage] = Field(default_factory=list)
30
+ errors: list[CliMessage] = Field(default_factory=list)
31
+ metrics: Metrics
32
+
33
+
34
+ class GuideCommand(BaseModel):
35
+ id: str
36
+ summary: str
37
+ mutates: bool
38
+ input_schema: dict[str, Any] | None = None
39
+ output_schema: dict[str, Any] | None = None
40
+ examples: list[str] = Field(default_factory=list)
41
+
42
+
43
+ class GuideDocument(BaseModel):
44
+ schema_version: Literal["1.0"] = "1.0"
45
+ compatibility: dict[str, str]
46
+ commands: list[GuideCommand]
47
+ exit_codes: dict[str, int]
48
+ error_codes: dict[str, dict[str, Any]] = Field(default_factory=dict)
49
+ identifier_conventions: dict[str, str] = Field(default_factory=dict)
50
+ concurrency: dict[str, Any] = Field(default_factory=dict)
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Any, Literal
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class PageSize(BaseModel):
10
+ width_emu: int
11
+ height_emu: int
12
+
13
+
14
+ class ThemeModel(BaseModel):
15
+ name: str | None = None
16
+ colors: dict[str, str] = Field(default_factory=dict)
17
+ fonts: dict[str, str] = Field(default_factory=dict)
18
+ effects: dict[str, str] = Field(default_factory=dict)
19
+
20
+
21
+ class AssetRef(BaseModel):
22
+ id: str
23
+ kind: Literal["image", "media", "embedded", "template", "theme", "xml"]
24
+ path: str
25
+ source_path: str | None = None
26
+ sha256: str
27
+ size_bytes: int
28
+
29
+
30
+ class ProtectedElement(BaseModel):
31
+ element_id: str
32
+ element_type: str
33
+ name: str
34
+ left_emu: int
35
+ top_emu: int
36
+ width_emu: int
37
+ height_emu: int
38
+ lock_policy: Literal["locked"] = "locked"
39
+ asset_ref: str | None = None
40
+ fingerprint: str
41
+
42
+
43
+ class PlaceholderContract(BaseModel):
44
+ logical_name: str
45
+ source_name: str
46
+ placeholder_idx: int
47
+ placeholder_type: str
48
+ guidance_text: str | None = None
49
+ guidance_lines: list[str] = Field(default_factory=list)
50
+ supported_content_types: list[str]
51
+ left_emu: int
52
+ top_emu: int
53
+ width_emu: int
54
+ height_emu: int
55
+ required: bool = False
56
+ overflow_policy: Literal["fit", "warn", "truncate"] = "warn"
57
+ text_defaults: dict[str, Any] = Field(default_factory=dict)
58
+ inheritance_chain: list[str] = Field(default_factory=list)
59
+ allowed_formatting_overrides: list[str] = Field(default_factory=list)
60
+
61
+
62
+ class LayoutContract(BaseModel):
63
+ id: str
64
+ name: str
65
+ aliases: list[str] = Field(default_factory=list)
66
+ source_master_id: str
67
+ source_layout_index: int
68
+ source_layout_name: str
69
+ description: str | None = None
70
+ preview_path: str
71
+ placeholders: list[PlaceholderContract] = Field(default_factory=list)
72
+ protected_static_elements: list[ProtectedElement] = Field(default_factory=list)
73
+ validation_rules: dict[str, Any] = Field(default_factory=dict)
74
+
75
+
76
+ class MasterContract(BaseModel):
77
+ id: str
78
+ name: str
79
+ layout_ids: list[str] = Field(default_factory=list)
80
+
81
+
82
+ class TemplateInfo(BaseModel):
83
+ name: str
84
+ source_file: str
85
+ source_hash: str
86
+ extracted_at: datetime
87
+ stored_template_path: str
88
+ locale: str | None = None
89
+ owner: str | None = None
90
+ version: str | None = None
91
+
92
+
93
+ class CompatibilityFinding(BaseModel):
94
+ code: str
95
+ severity: Literal["info", "warning", "error"]
96
+ message: str
97
+ details: dict[str, Any] = Field(default_factory=dict)
98
+
99
+
100
+ class CompatibilityReport(BaseModel):
101
+ status: Literal["ok", "warn", "error"] = "ok"
102
+ findings: list[CompatibilityFinding] = Field(default_factory=list)
103
+
104
+
105
+ class ManifestDocument(BaseModel):
106
+ manifest_version: Literal[1] = 1
107
+ template: TemplateInfo
108
+ presentation: dict[str, Any]
109
+ masters: list[MasterContract]
110
+ layouts: list[LayoutContract]
111
+ assets: list[AssetRef] = Field(default_factory=list)
112
+ rules: dict[str, Any] = Field(default_factory=dict)
113
+ capabilities: dict[str, Any] = Field(default_factory=dict)
114
+ compatibility_report: CompatibilityReport = Field(default_factory=CompatibilityReport)
115
+ fingerprints: dict[str, str] = Field(default_factory=dict)
116
+
117
+
118
+ class LayoutAnnotation(BaseModel):
119
+ layout_id: str
120
+ aliases: list[str] = Field(default_factory=list)
121
+ semantic_tags: list[str] = Field(default_factory=list)
122
+ usage_notes: str | None = None
123
+
124
+
125
+ class TemplateAnnotations(BaseModel):
126
+ semantic_tags: list[str] = Field(default_factory=list)
127
+ operator_notes: str | None = None
128
+
129
+
130
+ class AnnotationsDocument(BaseModel):
131
+ template_annotations: TemplateAnnotations = Field(default_factory=TemplateAnnotations)
132
+ layouts: list[LayoutAnnotation] = Field(default_factory=list)
133
+
134
+
135
+ class InitReport(BaseModel):
136
+ template: str
137
+ output_dir: str
138
+ manifest_path: str
139
+ findings: list[CompatibilityFinding] = Field(default_factory=list)
140
+ assets_copied: int = 0
141
+ layout_count: int = 0
142
+ placeholder_count: int = 0
143
+
144
+
145
+ class SlideSpec(BaseModel):
146
+ layout: str
147
+ content: dict[str, Any] = Field(default_factory=dict)
148
+
149
+
150
+ class DeckSpec(BaseModel):
151
+ manifest: str | None = None
152
+ metadata: dict[str, Any] = Field(default_factory=dict)
153
+ slides: list[SlideSpec]
154
+
155
+
156
+ class ValidationIssue(BaseModel):
157
+ code: str
158
+ severity: Literal["warning", "error"]
159
+ message: str
160
+ details: dict[str, Any] = Field(default_factory=dict)
161
+
162
+
163
+ class ValidationResult(BaseModel):
164
+ manifest_path: str
165
+ deck_path: str
166
+ ok: bool
167
+ issues: list[ValidationIssue] = Field(default_factory=list)
168
+ checked_slides: int = 0
169
+ checked_layouts: int = 0
170
+
171
+
172
+ class ManifestDiffResult(BaseModel):
173
+ breaking_changes: list[dict[str, Any]] = Field(default_factory=list)
174
+ additive_changes: list[dict[str, Any]] = Field(default_factory=list)
175
+ unchanged: list[str] = Field(default_factory=list)