smd-reports 0.1.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,29 @@
1
+ """JSON-based report rendering with embedded HTML assets and optional PDF export."""
2
+
3
+ import logging
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ from .errors import (
8
+ PDFUnavailableError,
9
+ ReportAssetError,
10
+ ReportInputError,
11
+ ReportRenderError,
12
+ format_validation_error,
13
+ )
14
+ from .loading import ReportLoader
15
+ from .rendering import ReportRenderer
16
+ from .themes import Theme
17
+
18
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
19
+
20
+ __all__ = [
21
+ "ReportLoader",
22
+ "ReportRenderer",
23
+ "ReportAssetError",
24
+ "ReportInputError",
25
+ "ReportRenderError",
26
+ "PDFUnavailableError",
27
+ "format_validation_error",
28
+ "Theme",
29
+ ]
@@ -0,0 +1,5 @@
1
+ """Allow the same commands through python -m smd_reports."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
smd_reports/_models.py ADDED
@@ -0,0 +1,80 @@
1
+ """Base models and field types for report JSON."""
2
+
3
+ from typing import Annotated, Literal
4
+
5
+ from pydantic import (
6
+ BaseModel,
7
+ ConfigDict,
8
+ Field,
9
+ StrictBool,
10
+ StrictFloat,
11
+ StrictInt,
12
+ StrictStr,
13
+ field_validator,
14
+ )
15
+
16
+ Language = Literal["en", "sv"]
17
+ Translations = Annotated[dict[Language, str], Field(min_length=1)]
18
+ LocalizedText = str | Translations
19
+ Scalar = StrictBool | StrictInt | StrictFloat | StrictStr | None
20
+ CellValue = Scalar | Translations
21
+
22
+
23
+ class Model(BaseModel):
24
+ """Reject unknown fields and non-finite numbers."""
25
+
26
+ model_config = ConfigDict(extra="forbid", allow_inf_nan=False, validate_assignment=True)
27
+
28
+
29
+ class FieldValue(Model):
30
+ """An ordered metadata label paired with a scalar or translated value."""
31
+
32
+ label: LocalizedText
33
+ value: CellValue
34
+
35
+
36
+ class ImageAsset(Model):
37
+ """A local or embedded plot and its display options.
38
+
39
+ Attributes:
40
+ source: Local path or embedded data URI.
41
+ caption: Literal or translated caption text.
42
+ alt: Alternative text, falling back to the caption when omitted.
43
+ frame: Zero-based frame or TIFF page index.
44
+ width_percent: Width relative to the content area.
45
+ max_height_mm: Optional additional physical height limit.
46
+ """
47
+
48
+ source: str
49
+ caption: LocalizedText = ""
50
+ alt: LocalizedText = ""
51
+ frame: int = Field(default=0, ge=0)
52
+ width_percent: float = Field(default=100, gt=0, le=100)
53
+ max_height_mm: float | None = Field(default=None, gt=0)
54
+
55
+ @field_validator("source")
56
+ @classmethod
57
+ def image_reference(cls, value):
58
+ """Validate the image path or embedded content.
59
+
60
+ Args:
61
+ value: Local image path or base64 data URI.
62
+
63
+ Returns:
64
+ The validated reference.
65
+
66
+ Raises:
67
+ ValueError: The reference or embedded image is unsupported.
68
+ """
69
+ from .assets import validate_image_reference
70
+
71
+ return validate_image_reference(value)
72
+
73
+
74
+ class SectionBase(Model):
75
+ """Shared section identity, heading, pagination, and inline image fields."""
76
+
77
+ key: str = Field(min_length=1)
78
+ heading: LocalizedText = ""
79
+ page_break_before: bool = False
80
+ images: list[ImageAsset] = Field(default_factory=list)
smd_reports/assets.py ADDED
@@ -0,0 +1,196 @@
1
+ """Embed local report artwork and validate static SVG without external resources."""
2
+
3
+ import base64
4
+ import re
5
+ from io import BytesIO
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ from defusedxml import ElementTree
10
+
11
+ from .errors import ReportInputError
12
+
13
+ ImageFormat = Literal["png", "jpeg", "svg", "webp", "gif", "tiff", "bmp"]
14
+ IMAGE_MIMES = {
15
+ "png": "image/png",
16
+ "jpeg": "image/jpeg",
17
+ "svg": "image/svg+xml",
18
+ "webp": "image/webp",
19
+ "gif": "image/gif",
20
+ "tiff": "image/tiff",
21
+ "bmp": "image/bmp",
22
+ }
23
+ IMAGE_EXTENSIONS = {
24
+ ".png": "png",
25
+ ".jpg": "jpeg",
26
+ ".jpeg": "jpeg",
27
+ ".svg": "svg",
28
+ ".webp": "webp",
29
+ ".gif": "gif",
30
+ ".tif": "tiff",
31
+ ".tiff": "tiff",
32
+ ".bmp": "bmp",
33
+ }
34
+
35
+
36
+ def validate_image_reference(value: str) -> str:
37
+ """Validate a local path or embedded image reference without fetching URLs.
38
+
39
+ Args:
40
+ value: An image path or base64 data URI.
41
+
42
+ Returns:
43
+ The validated reference.
44
+
45
+ Raises:
46
+ ValueError: The path is empty or remote, or embedded image data is invalid.
47
+ """
48
+ if value.startswith("data:"):
49
+ validate_image(value)
50
+ elif not value.strip() or (":" in value and not Path(value).is_absolute()) or "\x00" in value:
51
+ raise ReportInputError("Use a local image path or embedded image data URI")
52
+ return value
53
+
54
+
55
+ SVG_TAGS = {
56
+ "svg",
57
+ "g",
58
+ "path",
59
+ "rect",
60
+ "circle",
61
+ "ellipse",
62
+ "line",
63
+ "polyline",
64
+ "polygon",
65
+ "text",
66
+ "tspan",
67
+ "defs",
68
+ "use",
69
+ "symbol",
70
+ "clipPath",
71
+ "mask",
72
+ "linearGradient",
73
+ "radialGradient",
74
+ "stop",
75
+ "title",
76
+ "desc",
77
+ }
78
+
79
+
80
+ def validate_image(value: str) -> str:
81
+ """Validate raster content or a restricted static SVG document.
82
+
83
+ Args:
84
+ value: A supported base64 image data URI.
85
+
86
+ Returns:
87
+ The original data URI.
88
+
89
+ Raises:
90
+ ValueError: Content is empty, malformed, unsafe, or inconsistent with its MIME type.
91
+ """
92
+ prefix, separator, encoded = value.partition(",")
93
+ if not separator or prefix not in {f"data:{mime};base64" for mime in IMAGE_MIMES.values()}:
94
+ raise ReportInputError("Supply a supported base64 image data URI")
95
+ try:
96
+ raw = base64.b64decode(encoded, validate=True)
97
+ except ValueError as exc:
98
+ raise ReportInputError("Invalid base64 image data") from exc
99
+ if not raw:
100
+ raise ReportInputError("Empty image")
101
+ if prefix == "data:image/svg+xml;base64":
102
+ without_declaration = re.sub(rb"^\s*<\?xml\s[^?]*\?>", b"", raw)
103
+ if b"<?" in without_declaration or b"<!DOCTYPE" in raw.upper():
104
+ raise ReportInputError("SVG processing instructions and document types are prohibited")
105
+ try:
106
+ root = ElementTree.fromstring(raw)
107
+ except Exception as exc:
108
+ raise ReportInputError("Invalid or unsafe SVG") from exc
109
+ if root.tag.split("}")[-1] != "svg":
110
+ raise ReportInputError("Expected an SVG root")
111
+ for element in root.iter():
112
+ if element.tag.split("}")[-1] not in SVG_TAGS:
113
+ raise ReportInputError("SVG must contain static artwork only")
114
+ for name, attribute in element.attrib.items():
115
+ name = name.split("}")[-1].lower()
116
+ if name.startswith("on") or name in {"src", "base"}:
117
+ raise ReportInputError("Active SVG attributes are prohibited")
118
+ if name == "href" and not re.fullmatch(r"#[A-Za-z_][\w.-]*", attribute):
119
+ raise ReportInputError("SVG references must be local fragments")
120
+ # Disallow CSS escapes/imports and all URL functions, including encoded variants.
121
+ if "\\" in attribute or "@" in attribute or re.search(r"url\s*\(", attribute, re.I):
122
+ raise ReportInputError("SVG URL/CSS resources are prohibited")
123
+ else:
124
+ from PIL import Image
125
+
126
+ try:
127
+ with Image.open(BytesIO(raw)) as image:
128
+ expected = prefix.removeprefix("data:").removesuffix(";base64").split("/")[-1]
129
+ if image.format.lower() != expected:
130
+ raise ReportInputError("Image content does not match its MIME type")
131
+ image.verify()
132
+ except (OSError, SyntaxError, Image.DecompressionBombError) as exc:
133
+ raise ReportInputError("Invalid or unsupported raster image") from exc
134
+ return value
135
+
136
+
137
+ def prepare_image(value: str, *, frame: int = 0, accepted_formats=None) -> str:
138
+ """Select an image frame and produce portable HTML image data.
139
+
140
+ Args:
141
+ value: A supported embedded image data URI.
142
+ frame: Nonnegative frame or TIFF page index, starting at zero.
143
+ accepted_formats: Optional allowed format names; None permits every
144
+ format supported by the package.
145
+
146
+ Returns:
147
+ A PNG, JPEG, or static SVG data URI. Other supported raster formats
148
+ are converted to a static PNG.
149
+
150
+ Raises:
151
+ ValueError: The image format or requested frame is not accepted.
152
+ """
153
+ validate_image(value)
154
+ prefix, encoded = value.split(",", 1)
155
+ image_format = next(
156
+ name for name, mime in IMAGE_MIMES.items() if prefix == f"data:{mime};base64"
157
+ )
158
+ if accepted_formats is not None and image_format not in accepted_formats:
159
+ raise ReportInputError("Image format is not in accepted_image_formats")
160
+ if image_format == "svg":
161
+ if frame != 0:
162
+ raise ReportInputError("SVG images have only one frame")
163
+ return value
164
+ from PIL import Image
165
+
166
+ with Image.open(BytesIO(base64.b64decode(encoded))) as image:
167
+ if frame >= getattr(image, "n_frames", 1):
168
+ raise ReportInputError("The selected image frame does not exist")
169
+ image.seek(frame)
170
+ image.load()
171
+ if image_format in {"png", "jpeg"} and frame == 0 and getattr(image, "n_frames", 1) == 1:
172
+ return value
173
+ output = BytesIO()
174
+ image.convert("RGBA").save(output, format="PNG")
175
+ return "data:image/png;base64," + base64.b64encode(output.getvalue()).decode()
176
+
177
+
178
+ def embed_image(path: str | Path) -> str:
179
+ """Read a local artwork file and encode it as validated image data.
180
+
181
+ Args:
182
+ path: Path to a supported image file.
183
+
184
+ Returns:
185
+ A base64 image data URI.
186
+
187
+ Raises:
188
+ OSError: The file cannot be read.
189
+ ValueError: The extension or file contents are unsupported.
190
+ """
191
+ path = Path(path)
192
+ image_format = IMAGE_EXTENSIONS.get(path.suffix.lower())
193
+ if image_format is None:
194
+ raise ReportInputError("Unsupported image file extension")
195
+ mime = IMAGE_MIMES[image_format]
196
+ return validate_image(f"data:{mime};base64," + base64.b64encode(path.read_bytes()).decode())
smd_reports/cli.py ADDED
@@ -0,0 +1,167 @@
1
+ """Validate, normalize and render report JSON from the command line."""
2
+
3
+ import argparse
4
+ import json
5
+ import logging
6
+ import os
7
+ import stat
8
+ import sys
9
+ from contextlib import contextmanager
10
+ from pathlib import Path
11
+ from tempfile import TemporaryDirectory
12
+
13
+ from pydantic import ValidationError
14
+
15
+ from .errors import ReportInputError, ReportRenderError, format_validation_error
16
+ from .loading import APPLICATIONS, ReportLoader
17
+ from .rendering import ReportRenderer
18
+
19
+
20
+ @contextmanager
21
+ def _event_logging(enabled):
22
+ """Temporarily send package events to stderr when requested.
23
+
24
+ Args:
25
+ enabled: Whether the command was invoked with --verbose.
26
+
27
+ Yields:
28
+ Control to the command without changing application logging permanently.
29
+ """
30
+ logger = logging.getLogger("smd_reports")
31
+ if not enabled:
32
+ yield
33
+ return
34
+ level, propagate = logger.level, logger.propagate
35
+ handler = logging.StreamHandler(sys.stderr)
36
+ handler.setFormatter(logging.Formatter("%(levelname)s %(name)s: %(message)s"))
37
+ logger.addHandler(handler)
38
+ logger.setLevel(logging.INFO)
39
+ logger.propagate = False
40
+ try:
41
+ yield
42
+ finally:
43
+ logger.removeHandler(handler)
44
+ handler.close()
45
+ logger.setLevel(level)
46
+ logger.propagate = propagate
47
+
48
+
49
+ def _write_output(path: Path, output: str | bytes) -> None:
50
+ """Replace output only after a complete write succeeds.
51
+
52
+ Args:
53
+ path: Destination whose parent directory must exist.
54
+ output: Complete HTML, PDF, JSON or JSON Schema output.
55
+
56
+ Raises:
57
+ OSError: The destination cannot be written or replaced.
58
+ """
59
+ path = path.resolve()
60
+ with TemporaryDirectory(prefix=".smd-reports-", dir=path.parent) as directory:
61
+ temporary = Path(directory) / "output"
62
+ if isinstance(output, bytes):
63
+ temporary.write_bytes(output)
64
+ else:
65
+ temporary.write_text(output, encoding="utf-8")
66
+ if path.exists():
67
+ temporary.chmod(stat.S_IMODE(path.stat().st_mode))
68
+ os.replace(temporary, path)
69
+ logging.getLogger(__name__).info("Report output written")
70
+
71
+
72
+ def _run(args) -> int:
73
+ """Execute a parsed command and report recoverable failures.
74
+
75
+ Args:
76
+ args: Parsed command-line options.
77
+
78
+ Returns:
79
+ Zero on success, two for input/setup errors, or one for rendering failures.
80
+ """
81
+ loader = ReportLoader()
82
+ operation = "read the input JSON file"
83
+ try:
84
+ if args.command == "schema":
85
+ output = json.dumps(loader.schema(args.application), ensure_ascii=False, indent=2)
86
+ else:
87
+ if hasattr(args, "output") and (
88
+ args.output.resolve() == args.input.resolve()
89
+ or (
90
+ args.output.exists()
91
+ and args.input.exists()
92
+ and args.output.samefile(args.input)
93
+ )
94
+ ):
95
+ raise ReportInputError("Output must not overwrite input JSON; choose another file.")
96
+ report = loader.load(args.input)
97
+ if args.command == "validate":
98
+ print(f"Valid {report.application} report ({report.language})")
99
+ return 0
100
+ if args.command == "normalize":
101
+ output = report.model_dump_json(indent=2)
102
+ else:
103
+ renderer = ReportRenderer()
104
+ output = (
105
+ renderer.render_html(report)
106
+ if args.format == "html"
107
+ else renderer.render_pdf(report)
108
+ )
109
+ operation = "write the output file"
110
+ _write_output(args.output, output)
111
+ print(f"Written {args.output}")
112
+ return 0
113
+ except ValidationError as exc:
114
+ message = format_validation_error(exc)
115
+ except json.JSONDecodeError as exc:
116
+ message = f"Invalid JSON at line {exc.lineno}, column {exc.colno}; check the JSON syntax."
117
+ except UnicodeError:
118
+ message = "Cannot decode input JSON; save the file with UTF-8 encoding."
119
+ except ReportInputError as exc:
120
+ message = str(exc)
121
+ except ReportRenderError as exc:
122
+ print(str(exc), file=sys.stderr)
123
+ return 1
124
+ except FileNotFoundError:
125
+ message = f"Cannot {operation}: file or parent directory not found."
126
+ except PermissionError:
127
+ message = f"Cannot {operation}: permission denied. Check file and directory permissions."
128
+ except IsADirectoryError:
129
+ message = f"Cannot {operation}: the selected path is a directory; choose a file."
130
+ except OSError:
131
+ message = f"Cannot {operation}: filesystem error. Check the location and available space."
132
+ except Exception:
133
+ logging.getLogger(__name__).error("Report command failed unexpectedly")
134
+ print(
135
+ "Report command failed unexpectedly; check the installation and report format.",
136
+ file=sys.stderr,
137
+ )
138
+ return 1
139
+ print(message, file=sys.stderr)
140
+ return 2
141
+
142
+
143
+ def main(argv=None) -> int:
144
+ """Run a JSON validation, normalization, schema, or rendering command.
145
+
146
+ Args:
147
+ argv: Command arguments, excluding the executable name. None reads sys.argv.
148
+
149
+ Returns:
150
+ Zero on success, two for input/setup errors, or one for rendering failures.
151
+ """
152
+ parser = argparse.ArgumentParser(prog="smd-reports", description=__doc__)
153
+ parser.add_argument("--verbose", action="store_true", help="Log main events to stderr")
154
+ commands = parser.add_subparsers(dest="command", required=True)
155
+ schema = commands.add_parser("schema", help="Export a report JSON Schema")
156
+ schema.add_argument("--application", choices=APPLICATIONS)
157
+ schema.add_argument("--output", type=Path, required=True)
158
+ for name in ("validate", "normalize", "render"):
159
+ command = commands.add_parser(name)
160
+ command.add_argument("input", type=Path)
161
+ if name != "validate":
162
+ command.add_argument("--output", type=Path, required=True)
163
+ if name == "render":
164
+ command.add_argument("--format", choices=("html", "pdf"), required=True)
165
+ args = parser.parse_args(argv)
166
+ with _event_logging(args.verbose):
167
+ return _run(args)