figurelint 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.
- figurelint/__init__.py +3 -0
- figurelint/__main__.py +4 -0
- figurelint/checks.py +136 -0
- figurelint/cli.py +278 -0
- figurelint/github_annotations.py +46 -0
- figurelint/models.py +19 -0
- figurelint/pdf_checks.py +322 -0
- figurelint/presets.py +225 -0
- figurelint/svg_checks.py +679 -0
- figurelint-0.1.0.dist-info/METADATA +339 -0
- figurelint-0.1.0.dist-info/RECORD +14 -0
- figurelint-0.1.0.dist-info/WHEEL +4 -0
- figurelint-0.1.0.dist-info/entry_points.txt +2 -0
- figurelint-0.1.0.dist-info/licenses/LICENSE +21 -0
figurelint/__init__.py
ADDED
figurelint/__main__.py
ADDED
figurelint/checks.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from PIL import Image, UnidentifiedImageError
|
|
6
|
+
|
|
7
|
+
from .models import Finding, Severity
|
|
8
|
+
|
|
9
|
+
SUPPORTED_RASTER_EXTENSIONS = {".png", ".jpg", ".jpeg"}
|
|
10
|
+
SUPPORTED_VECTOR_EXTENSIONS = {".svg", ".pdf"}
|
|
11
|
+
SUPPORTED_EXTENSIONS = SUPPORTED_RASTER_EXTENSIONS | SUPPORTED_VECTOR_EXTENSIONS
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def collect_figure_files(target: Path) -> list[Path]:
|
|
15
|
+
"""Return supported figure files under target."""
|
|
16
|
+
if target.is_file():
|
|
17
|
+
return [target] if target.suffix.lower() in SUPPORTED_EXTENSIONS else []
|
|
18
|
+
|
|
19
|
+
if target.is_dir():
|
|
20
|
+
return sorted(
|
|
21
|
+
p
|
|
22
|
+
for p in target.rglob("*")
|
|
23
|
+
if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _extract_dpi(image: Image.Image) -> tuple[float, float] | None:
|
|
30
|
+
raw = image.info.get("dpi")
|
|
31
|
+
if not raw:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
x, y = raw
|
|
36
|
+
return float(x), float(y)
|
|
37
|
+
except (TypeError, ValueError):
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check_raster(
|
|
42
|
+
path: Path,
|
|
43
|
+
*,
|
|
44
|
+
min_dpi: int | None = 300,
|
|
45
|
+
min_short_side: int | None = 600,
|
|
46
|
+
) -> list[Finding]:
|
|
47
|
+
"""Inspect one raster image and return technical findings."""
|
|
48
|
+
findings: list[Finding] = []
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
with Image.open(path) as image:
|
|
52
|
+
image.verify()
|
|
53
|
+
|
|
54
|
+
with Image.open(path) as image:
|
|
55
|
+
width, height = image.size
|
|
56
|
+
mode = image.mode
|
|
57
|
+
dpi = _extract_dpi(image)
|
|
58
|
+
bands = image.getbands()
|
|
59
|
+
|
|
60
|
+
except (UnidentifiedImageError, OSError) as exc:
|
|
61
|
+
return [
|
|
62
|
+
Finding(
|
|
63
|
+
path=path,
|
|
64
|
+
severity=Severity.ERROR,
|
|
65
|
+
code="IMAGE_UNREADABLE",
|
|
66
|
+
message=f"Image could not be read: {exc}",
|
|
67
|
+
)
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
if min_short_side is not None and min(width, height) < min_short_side:
|
|
71
|
+
findings.append(
|
|
72
|
+
Finding(
|
|
73
|
+
path=path,
|
|
74
|
+
severity=Severity.WARNING,
|
|
75
|
+
code="DIM_SMALL",
|
|
76
|
+
message=(
|
|
77
|
+
f"Short side is {min(width, height)} px; "
|
|
78
|
+
f"configured minimum is {min_short_side} px."
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if min_dpi is not None and dpi is None:
|
|
84
|
+
findings.append(
|
|
85
|
+
Finding(
|
|
86
|
+
path=path,
|
|
87
|
+
severity=Severity.WARNING,
|
|
88
|
+
code="DPI_MISSING",
|
|
89
|
+
message=(
|
|
90
|
+
"No DPI metadata was found. Pixel dimensions may still be adequate, "
|
|
91
|
+
"but submission systems can rely on DPI metadata."
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
# PNG stores DPI through pixels-per-metre metadata, which can round a
|
|
96
|
+
# requested 300 DPI to values such as 299.9994 on read-back.
|
|
97
|
+
elif min_dpi is not None and min(dpi) < min_dpi - 0.5:
|
|
98
|
+
findings.append(
|
|
99
|
+
Finding(
|
|
100
|
+
path=path,
|
|
101
|
+
severity=Severity.WARNING,
|
|
102
|
+
code="DPI_LOW",
|
|
103
|
+
message=(
|
|
104
|
+
f"Reported DPI is {dpi[0]:.0f}×{dpi[1]:.0f}; "
|
|
105
|
+
f"configured minimum is {min_dpi} DPI."
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if "A" in bands or mode in {"LA", "PA"}:
|
|
111
|
+
findings.append(
|
|
112
|
+
Finding(
|
|
113
|
+
path=path,
|
|
114
|
+
severity=Severity.INFO,
|
|
115
|
+
code="ALPHA_CHANNEL",
|
|
116
|
+
message=(
|
|
117
|
+
"Image contains transparency. Some publication workflows require "
|
|
118
|
+
"flattened output."
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if path.suffix.lower() in {".jpg", ".jpeg"}:
|
|
124
|
+
findings.append(
|
|
125
|
+
Finding(
|
|
126
|
+
path=path,
|
|
127
|
+
severity=Severity.INFO,
|
|
128
|
+
code="JPEG_LOSSY",
|
|
129
|
+
message=(
|
|
130
|
+
"JPEG uses lossy compression. For line art or text-heavy figures, "
|
|
131
|
+
"PNG or a vector format is often preferable."
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return findings
|
figurelint/cli.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from .checks import check_raster, collect_figure_files
|
|
11
|
+
from .github_annotations import format_github_annotation
|
|
12
|
+
from .models import Severity
|
|
13
|
+
from .pdf_checks import check_pdf
|
|
14
|
+
from .presets import available_preset_names, get_preset, resolve_thresholds
|
|
15
|
+
from .svg_checks import check_svg
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
add_completion=False,
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
help="Lint academic figures before submission.",
|
|
21
|
+
)
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command("presets")
|
|
26
|
+
def list_presets() -> None:
|
|
27
|
+
"""List built-in FigureLint presets."""
|
|
28
|
+
table = Table(title="FigureLint presets")
|
|
29
|
+
table.add_column("Name", no_wrap=True)
|
|
30
|
+
table.add_column("Purpose", overflow="fold")
|
|
31
|
+
table.add_column("DPI", justify="right")
|
|
32
|
+
table.add_column("Raster min", justify="right")
|
|
33
|
+
table.add_column("SVG font", justify="right")
|
|
34
|
+
table.add_column("SVG stroke", justify="right")
|
|
35
|
+
table.add_column("Provenance")
|
|
36
|
+
|
|
37
|
+
def show(value: int | float | None, unit: str = "") -> str:
|
|
38
|
+
if value is None:
|
|
39
|
+
return "—"
|
|
40
|
+
if isinstance(value, float):
|
|
41
|
+
return f"{value:g}{unit}"
|
|
42
|
+
return f"{value}{unit}"
|
|
43
|
+
|
|
44
|
+
for name in available_preset_names():
|
|
45
|
+
preset = get_preset(name)
|
|
46
|
+
thresholds = preset.thresholds
|
|
47
|
+
provenance = (
|
|
48
|
+
preset.source_name
|
|
49
|
+
if preset.is_publisher_verified
|
|
50
|
+
else "FigureLint convenience preset"
|
|
51
|
+
)
|
|
52
|
+
font_range = (
|
|
53
|
+
f"{show(thresholds.min_font_size_pt)}–{show(thresholds.max_font_size_pt)}pt"
|
|
54
|
+
if thresholds.max_font_size_pt is not None
|
|
55
|
+
else show(thresholds.min_font_size_pt, "pt")
|
|
56
|
+
)
|
|
57
|
+
stroke_range = (
|
|
58
|
+
f"{show(thresholds.min_stroke_width_pt)}–{show(thresholds.max_stroke_width_pt)}pt"
|
|
59
|
+
if thresholds.max_stroke_width_pt is not None
|
|
60
|
+
else show(thresholds.min_stroke_width_pt, "pt")
|
|
61
|
+
)
|
|
62
|
+
table.add_row(
|
|
63
|
+
preset.name,
|
|
64
|
+
preset.description,
|
|
65
|
+
show(thresholds.min_dpi),
|
|
66
|
+
show(thresholds.min_short_side, "px"),
|
|
67
|
+
font_range,
|
|
68
|
+
stroke_range,
|
|
69
|
+
provenance,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
console.print(table)
|
|
73
|
+
console.print(
|
|
74
|
+
"[dim]Names: " + ", ".join(available_preset_names()) + "[/dim]"
|
|
75
|
+
)
|
|
76
|
+
console.print(
|
|
77
|
+
"[dim]Convenience presets are not publisher policies. "
|
|
78
|
+
"Source-verified publisher presets show official source metadata.[/dim]"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def check(
|
|
84
|
+
target: Path = typer.Argument(
|
|
85
|
+
...,
|
|
86
|
+
exists=True,
|
|
87
|
+
readable=True,
|
|
88
|
+
help="Figure file or directory to inspect.",
|
|
89
|
+
),
|
|
90
|
+
preset: str = typer.Option(
|
|
91
|
+
"default",
|
|
92
|
+
"--preset",
|
|
93
|
+
help="Named threshold preset. Run 'figurelint presets' to list profiles.",
|
|
94
|
+
),
|
|
95
|
+
min_dpi: Optional[int] = typer.Option(
|
|
96
|
+
None,
|
|
97
|
+
"--min-dpi",
|
|
98
|
+
min=1,
|
|
99
|
+
help="Override the preset minimum raster/effective PDF image DPI.",
|
|
100
|
+
),
|
|
101
|
+
min_short_side: Optional[int] = typer.Option(
|
|
102
|
+
None,
|
|
103
|
+
"--min-short-side",
|
|
104
|
+
min=1,
|
|
105
|
+
help="Override the preset minimum raster short side in pixels.",
|
|
106
|
+
),
|
|
107
|
+
min_font_size_pt: Optional[float] = typer.Option(
|
|
108
|
+
None,
|
|
109
|
+
"--min-font-size-pt",
|
|
110
|
+
min=0.1,
|
|
111
|
+
help="Override the preset minimum resolvable SVG font size in points.",
|
|
112
|
+
),
|
|
113
|
+
max_font_size_pt: Optional[float] = typer.Option(
|
|
114
|
+
None,
|
|
115
|
+
"--max-font-size-pt",
|
|
116
|
+
min=0.1,
|
|
117
|
+
help="Override the preset maximum resolvable SVG font size in points.",
|
|
118
|
+
),
|
|
119
|
+
min_stroke_width_pt: Optional[float] = typer.Option(
|
|
120
|
+
None,
|
|
121
|
+
"--min-stroke-width-pt",
|
|
122
|
+
min=0.01,
|
|
123
|
+
help="Override the preset minimum resolvable SVG stroke width in points.",
|
|
124
|
+
),
|
|
125
|
+
max_stroke_width_pt: Optional[float] = typer.Option(
|
|
126
|
+
None,
|
|
127
|
+
"--max-stroke-width-pt",
|
|
128
|
+
min=0.01,
|
|
129
|
+
help="Override the preset maximum resolvable SVG stroke width in points.",
|
|
130
|
+
),
|
|
131
|
+
min_pdf_short_side_in: Optional[float] = typer.Option(
|
|
132
|
+
None,
|
|
133
|
+
"--min-pdf-short-side-in",
|
|
134
|
+
min=0.01,
|
|
135
|
+
help="Override the preset minimum PDF page short side in inches.",
|
|
136
|
+
),
|
|
137
|
+
max_pdf_long_side_in: Optional[float] = typer.Option(
|
|
138
|
+
None,
|
|
139
|
+
"--max-pdf-long-side-in",
|
|
140
|
+
min=0.1,
|
|
141
|
+
help="Override the preset maximum PDF page long side in inches.",
|
|
142
|
+
),
|
|
143
|
+
github_annotations: bool = typer.Option(
|
|
144
|
+
False,
|
|
145
|
+
"--github-annotations",
|
|
146
|
+
help="Emit GitHub Actions workflow annotations for findings.",
|
|
147
|
+
),
|
|
148
|
+
strict: bool = typer.Option(
|
|
149
|
+
False,
|
|
150
|
+
"--strict",
|
|
151
|
+
help="Exit with code 1 when warnings are present.",
|
|
152
|
+
),
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Check one figure or every supported figure in a directory."""
|
|
155
|
+
try:
|
|
156
|
+
active_preset, thresholds = resolve_thresholds(
|
|
157
|
+
preset,
|
|
158
|
+
min_dpi=min_dpi,
|
|
159
|
+
min_short_side=min_short_side,
|
|
160
|
+
min_font_size_pt=min_font_size_pt,
|
|
161
|
+
max_font_size_pt=max_font_size_pt,
|
|
162
|
+
min_stroke_width_pt=min_stroke_width_pt,
|
|
163
|
+
max_stroke_width_pt=max_stroke_width_pt,
|
|
164
|
+
min_pdf_short_side_in=min_pdf_short_side_in,
|
|
165
|
+
max_pdf_long_side_in=max_pdf_long_side_in,
|
|
166
|
+
)
|
|
167
|
+
except ValueError as exc:
|
|
168
|
+
raise typer.BadParameter(str(exc), param_hint="--preset") from exc
|
|
169
|
+
|
|
170
|
+
files = collect_figure_files(target)
|
|
171
|
+
|
|
172
|
+
if not files:
|
|
173
|
+
console.print("[yellow]No supported PNG/JPEG/SVG/PDF figures found.[/yellow]")
|
|
174
|
+
raise typer.Exit(code=0)
|
|
175
|
+
|
|
176
|
+
override_values = (
|
|
177
|
+
min_dpi,
|
|
178
|
+
min_short_side,
|
|
179
|
+
min_font_size_pt,
|
|
180
|
+
max_font_size_pt,
|
|
181
|
+
min_stroke_width_pt,
|
|
182
|
+
max_stroke_width_pt,
|
|
183
|
+
min_pdf_short_side_in,
|
|
184
|
+
max_pdf_long_side_in,
|
|
185
|
+
)
|
|
186
|
+
override_suffix = (
|
|
187
|
+
" + CLI overrides"
|
|
188
|
+
if any(value is not None for value in override_values)
|
|
189
|
+
else ""
|
|
190
|
+
)
|
|
191
|
+
console.print(f"[dim]Preset: {active_preset.name}{override_suffix}[/dim]")
|
|
192
|
+
if active_preset.is_publisher_verified and active_preset.source_url:
|
|
193
|
+
console.print(f"[dim]Official source: {active_preset.source_url}[/dim]")
|
|
194
|
+
|
|
195
|
+
table = Table(title="FigureLint")
|
|
196
|
+
table.add_column("File", overflow="fold")
|
|
197
|
+
table.add_column("Severity")
|
|
198
|
+
table.add_column("Code", min_width=26, no_wrap=True)
|
|
199
|
+
table.add_column("Message", overflow="fold")
|
|
200
|
+
|
|
201
|
+
error_count = 0
|
|
202
|
+
warning_count = 0
|
|
203
|
+
|
|
204
|
+
for path in files:
|
|
205
|
+
suffix = path.suffix.lower()
|
|
206
|
+
if suffix == ".svg":
|
|
207
|
+
svg_policy = active_preset.svg_policy
|
|
208
|
+
findings = check_svg(
|
|
209
|
+
path,
|
|
210
|
+
min_font_size_pt=thresholds.min_font_size_pt,
|
|
211
|
+
max_font_size_pt=thresholds.max_font_size_pt,
|
|
212
|
+
min_stroke_width_pt=thresholds.min_stroke_width_pt,
|
|
213
|
+
max_stroke_width_pt=thresholds.max_stroke_width_pt,
|
|
214
|
+
panel_label_size_pt=(
|
|
215
|
+
svg_policy.panel_label_size_pt if svg_policy else None
|
|
216
|
+
),
|
|
217
|
+
panel_label_require_bold=(
|
|
218
|
+
svg_policy.panel_label_require_bold if svg_policy else False
|
|
219
|
+
),
|
|
220
|
+
panel_label_require_upright=(
|
|
221
|
+
svg_policy.panel_label_require_upright if svg_policy else False
|
|
222
|
+
),
|
|
223
|
+
preferred_font_families=(
|
|
224
|
+
svg_policy.preferred_font_families if svg_policy else ()
|
|
225
|
+
),
|
|
226
|
+
require_consistent_font_family=(
|
|
227
|
+
svg_policy.require_consistent_font_family
|
|
228
|
+
if svg_policy
|
|
229
|
+
else False
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
elif suffix == ".pdf":
|
|
233
|
+
findings = check_pdf(
|
|
234
|
+
path,
|
|
235
|
+
min_image_dpi=thresholds.min_dpi,
|
|
236
|
+
min_page_short_side_in=thresholds.min_pdf_short_side_in,
|
|
237
|
+
max_page_long_side_in=thresholds.max_pdf_long_side_in,
|
|
238
|
+
)
|
|
239
|
+
else:
|
|
240
|
+
findings = check_raster(
|
|
241
|
+
path,
|
|
242
|
+
min_dpi=thresholds.min_dpi,
|
|
243
|
+
min_short_side=thresholds.min_short_side,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if not findings:
|
|
247
|
+
table.add_row(str(path), "pass", "OK", "No findings.")
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
for finding in findings:
|
|
251
|
+
if finding.severity is Severity.ERROR:
|
|
252
|
+
error_count += 1
|
|
253
|
+
elif finding.severity is Severity.WARNING:
|
|
254
|
+
warning_count += 1
|
|
255
|
+
|
|
256
|
+
table.add_row(
|
|
257
|
+
str(finding.path),
|
|
258
|
+
finding.severity.value,
|
|
259
|
+
finding.code,
|
|
260
|
+
finding.message,
|
|
261
|
+
)
|
|
262
|
+
if github_annotations:
|
|
263
|
+
typer.echo(format_github_annotation(finding))
|
|
264
|
+
|
|
265
|
+
console.print(table)
|
|
266
|
+
console.print(
|
|
267
|
+
f"Checked {len(files)} file(s): "
|
|
268
|
+
f"{error_count} error(s), {warning_count} warning(s)."
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
if error_count:
|
|
272
|
+
raise typer.Exit(code=2)
|
|
273
|
+
if strict and warning_count:
|
|
274
|
+
raise typer.Exit(code=1)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
if __name__ == "__main__":
|
|
278
|
+
app()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .models import Finding, Severity
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _escape_data(value: str) -> str:
|
|
9
|
+
return (
|
|
10
|
+
value.replace("%", "%25")
|
|
11
|
+
.replace("\r", "%0D")
|
|
12
|
+
.replace("\n", "%0A")
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _escape_property(value: str) -> str:
|
|
17
|
+
return (
|
|
18
|
+
_escape_data(value)
|
|
19
|
+
.replace(":", "%3A")
|
|
20
|
+
.replace(",", "%2C")
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _annotation_level(severity: Severity) -> str:
|
|
25
|
+
if severity is Severity.ERROR:
|
|
26
|
+
return "error"
|
|
27
|
+
if severity is Severity.WARNING:
|
|
28
|
+
return "warning"
|
|
29
|
+
return "notice"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _annotation_path(path: Path) -> str:
|
|
33
|
+
try:
|
|
34
|
+
relative = path.resolve().relative_to(Path.cwd().resolve())
|
|
35
|
+
return relative.as_posix()
|
|
36
|
+
except (OSError, ValueError):
|
|
37
|
+
return path.as_posix()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def format_github_annotation(finding: Finding) -> str:
|
|
41
|
+
"""Format a finding as a GitHub Actions workflow command."""
|
|
42
|
+
level = _annotation_level(finding.severity)
|
|
43
|
+
path = _escape_property(_annotation_path(finding.path))
|
|
44
|
+
title = _escape_property(finding.code)
|
|
45
|
+
message = _escape_data(finding.message)
|
|
46
|
+
return f"::{level} file={path},title={title}::{message}"
|
figurelint/models.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Severity(str, Enum):
|
|
9
|
+
INFO = "info"
|
|
10
|
+
WARNING = "warning"
|
|
11
|
+
ERROR = "error"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Finding:
|
|
16
|
+
path: Path
|
|
17
|
+
severity: Severity
|
|
18
|
+
code: str
|
|
19
|
+
message: str
|