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.
- smd_reports/__init__.py +29 -0
- smd_reports/__main__.py +5 -0
- smd_reports/_models.py +80 -0
- smd_reports/assets.py +196 -0
- smd_reports/cli.py +167 -0
- smd_reports/core.py +505 -0
- smd_reports/errors.py +146 -0
- smd_reports/loading.py +198 -0
- smd_reports/localization.py +68 -0
- smd_reports/pages.py +45 -0
- smd_reports/py.typed +0 -0
- smd_reports/rendering.py +348 -0
- smd_reports/static/SourceSans3-LICENSE.md +93 -0
- smd_reports/static/fonts.css +19 -0
- smd_reports/static/report.css +131 -0
- smd_reports/templates/clinical.html +40 -0
- smd_reports/templates/components/details.html +23 -0
- smd_reports/templates/components/document_heading.html +5 -0
- smd_reports/templates/components/grid.html +12 -0
- smd_reports/templates/components/header.html +18 -0
- smd_reports/templates/components/image.html +2 -0
- smd_reports/templates/components/metadata.html +19 -0
- smd_reports/templates/components/table.html +7 -0
- smd_reports/templates/components/versions.html +1 -0
- smd_reports/themes/__init__.py +65 -0
- smd_reports/themes/versions.json +18 -0
- smd_reports-0.1.0.dist-info/METADATA +167 -0
- smd_reports-0.1.0.dist-info/RECORD +33 -0
- smd_reports-0.1.0.dist-info/WHEEL +5 -0
- smd_reports-0.1.0.dist-info/entry_points.txt +2 -0
- smd_reports-0.1.0.dist-info/licenses/COPYRIGHT +12 -0
- smd_reports-0.1.0.dist-info/licenses/LICENSE +674 -0
- smd_reports-0.1.0.dist-info/top_level.txt +1 -0
smd_reports/__init__.py
ADDED
|
@@ -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
|
+
]
|
smd_reports/__main__.py
ADDED
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)
|