tods-validate 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.
- tods_validate/__init__.py +3 -0
- tods_validate/cli.py +71 -0
- tods_validate/findings.py +56 -0
- tods_validate/gtfs_companion.py +170 -0
- tods_validate/loader.py +183 -0
- tods_validate/report.py +107 -0
- tods_validate/rules/__init__.py +110 -0
- tods_validate/rules/fields.py +278 -0
- tods_validate/rules/references.py +593 -0
- tods_validate/rules/semantics.py +408 -0
- tods_validate/rules/structure.py +252 -0
- tods_validate/runner.py +35 -0
- tods_validate/schema.py +368 -0
- tods_validate-0.1.0.dist-info/METADATA +151 -0
- tods_validate-0.1.0.dist-info/RECORD +18 -0
- tods_validate-0.1.0.dist-info/WHEEL +4 -0
- tods_validate-0.1.0.dist-info/entry_points.txt +2 -0
- tods_validate-0.1.0.dist-info/licenses/LICENSE +202 -0
tods_validate/cli.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 when no errors were found (warnings and infos do not fail the
|
|
4
|
+
run unless --fail-on warning is set), 1 when errors were found, 2 when the
|
|
5
|
+
package could not be read at all.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .findings import Severity
|
|
16
|
+
from .loader import PackageNotFoundError
|
|
17
|
+
from .report import RENDERERS, summarize
|
|
18
|
+
from .runner import run
|
|
19
|
+
from .schema import SPEC_VERSION
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.command(name="tods-validate")
|
|
23
|
+
@click.version_option(__version__, message=f"%(prog)s %(version)s (TODS v{SPEC_VERSION})")
|
|
24
|
+
@click.argument("path", type=click.Path(exists=False))
|
|
25
|
+
@click.option(
|
|
26
|
+
"--gtfs",
|
|
27
|
+
"gtfs_path",
|
|
28
|
+
type=click.Path(exists=False),
|
|
29
|
+
default=None,
|
|
30
|
+
help=(
|
|
31
|
+
"Companion GTFS feed (directory or .zip) to resolve trip, stop, service, and "
|
|
32
|
+
"block references against. If omitted and GTFS files sit next to the TODS "
|
|
33
|
+
"files, those are used."
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
@click.option(
|
|
37
|
+
"--format",
|
|
38
|
+
"output_format",
|
|
39
|
+
type=click.Choice(sorted(RENDERERS)),
|
|
40
|
+
default="text",
|
|
41
|
+
show_default=True,
|
|
42
|
+
help="Report format: human-readable text, JSON, or GitHub Actions annotations.",
|
|
43
|
+
)
|
|
44
|
+
@click.option(
|
|
45
|
+
"--fail-on",
|
|
46
|
+
type=click.Choice(["error", "warning"]),
|
|
47
|
+
default="error",
|
|
48
|
+
show_default=True,
|
|
49
|
+
help="Exit non-zero if findings at or above this severity exist.",
|
|
50
|
+
)
|
|
51
|
+
def main(path: str, gtfs_path: str | None, output_format: str, fail_on: str) -> None:
|
|
52
|
+
"""Validate a TODS (Transit Operational Data Standard) feed at PATH.
|
|
53
|
+
|
|
54
|
+
PATH is a directory or .zip file containing the TODS .txt files, with or
|
|
55
|
+
without the GTFS feed alongside them.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
package, findings = run(path, gtfs_path)
|
|
59
|
+
except PackageNotFoundError as exc:
|
|
60
|
+
click.echo(f"tods-validate: error: {exc}", err=True)
|
|
61
|
+
sys.exit(2)
|
|
62
|
+
|
|
63
|
+
click.echo(RENDERERS[output_format](findings, package.source))
|
|
64
|
+
|
|
65
|
+
counts = summarize(findings)
|
|
66
|
+
failed = counts[Severity.ERROR] > 0 or (fail_on == "warning" and counts[Severity.WARNING] > 0)
|
|
67
|
+
sys.exit(1 if failed else 0)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
main()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Finding and severity types shared by rules and reporting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import IntEnum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Severity(IntEnum):
|
|
10
|
+
"""Ordered so that max() picks the worst severity."""
|
|
11
|
+
|
|
12
|
+
INFO = 0
|
|
13
|
+
WARNING = 1
|
|
14
|
+
ERROR = 2
|
|
15
|
+
|
|
16
|
+
def __str__(self) -> str:
|
|
17
|
+
return self.name
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Finding:
|
|
22
|
+
"""One validation result.
|
|
23
|
+
|
|
24
|
+
Messages are written for transit schedulers, not programmers: say what is
|
|
25
|
+
wrong, where, and what good looks like.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
rule_id: str
|
|
29
|
+
severity: Severity
|
|
30
|
+
message: str
|
|
31
|
+
file: str | None = None
|
|
32
|
+
# 1-based line number in the CSV file, counting the header as line 1.
|
|
33
|
+
row: int | None = None
|
|
34
|
+
field: str | None = None
|
|
35
|
+
suggestion: str | None = None
|
|
36
|
+
|
|
37
|
+
def location(self) -> str:
|
|
38
|
+
parts = []
|
|
39
|
+
if self.file:
|
|
40
|
+
parts.append(self.file)
|
|
41
|
+
if self.row is not None:
|
|
42
|
+
parts.append(f"row {self.row}")
|
|
43
|
+
if self.field:
|
|
44
|
+
parts.append(f"field {self.field!r}")
|
|
45
|
+
return ", ".join(parts)
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict[str, object]:
|
|
48
|
+
return {
|
|
49
|
+
"rule_id": self.rule_id,
|
|
50
|
+
"severity": str(self.severity),
|
|
51
|
+
"file": self.file,
|
|
52
|
+
"row": self.row,
|
|
53
|
+
"field": self.field,
|
|
54
|
+
"message": self.message,
|
|
55
|
+
"suggestion": self.suggestion,
|
|
56
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Load the companion GTFS feed and apply TODS supplement files.
|
|
2
|
+
|
|
3
|
+
TODS IDs resolve against the GTFS feed *after* supplements are applied (the
|
|
4
|
+
spec calls this "TODS-Supplemented GTFS"). Supplement evaluation follows the
|
|
5
|
+
spec's "Supplement Files > Evaluation" section:
|
|
6
|
+
|
|
7
|
+
1. PK matches and TODS_delete == "1": remove the GTFS row.
|
|
8
|
+
2. PK matches otherwise: non-empty supplement values overwrite GTFS values.
|
|
9
|
+
3. PK does not match: add the whole row.
|
|
10
|
+
|
|
11
|
+
Only the slices of GTFS that TODS references are modeled here (trips, stops,
|
|
12
|
+
calendars). This is not a GTFS validator; for that, use MobilityData's
|
|
13
|
+
gtfs-validator on the supplemented feed.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import date, timedelta
|
|
20
|
+
|
|
21
|
+
from .loader import FeedFile, Package
|
|
22
|
+
from .schema import GTFS_PRIMARY_KEYS
|
|
23
|
+
|
|
24
|
+
_WEEKDAY_FIELDS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_gtfs_date(value: str) -> date | None:
|
|
28
|
+
"""Parse a GTFS YYYYMMDD date, returning None if malformed."""
|
|
29
|
+
if len(value) != 8 or not value.isdigit():
|
|
30
|
+
return None
|
|
31
|
+
try:
|
|
32
|
+
return date(int(value[0:4]), int(value[4:6]), int(value[6:8]))
|
|
33
|
+
except ValueError:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def merge_supplement(
|
|
38
|
+
base: FeedFile | None,
|
|
39
|
+
supplement: FeedFile | None,
|
|
40
|
+
primary_key: tuple[str, ...],
|
|
41
|
+
) -> dict[tuple[str, ...], dict[str, str]]:
|
|
42
|
+
"""Compute effective rows keyed by primary key.
|
|
43
|
+
|
|
44
|
+
Rows whose primary-key fields are blank or missing are skipped here; the
|
|
45
|
+
field rules report those problems on the supplement file itself.
|
|
46
|
+
"""
|
|
47
|
+
effective: dict[tuple[str, ...], dict[str, str]] = {}
|
|
48
|
+
if base is not None:
|
|
49
|
+
for row in base.rows:
|
|
50
|
+
key = tuple(row.values.get(f, "") for f in primary_key)
|
|
51
|
+
if any(v == "" for v in key):
|
|
52
|
+
continue
|
|
53
|
+
effective[key] = dict(row.values)
|
|
54
|
+
if supplement is not None:
|
|
55
|
+
for row in supplement.rows:
|
|
56
|
+
key = tuple(row.values.get(f, "") for f in primary_key)
|
|
57
|
+
if any(v == "" for v in key):
|
|
58
|
+
continue
|
|
59
|
+
if row.values.get("TODS_delete", "") == "1":
|
|
60
|
+
effective.pop(key, None)
|
|
61
|
+
continue
|
|
62
|
+
if key in effective:
|
|
63
|
+
target = effective[key]
|
|
64
|
+
for name, value in row.values.items():
|
|
65
|
+
if name == "TODS_delete" or value == "":
|
|
66
|
+
continue
|
|
67
|
+
target[name] = value
|
|
68
|
+
else:
|
|
69
|
+
effective[key] = {k: v for k, v in row.values.items() if k != "TODS_delete"}
|
|
70
|
+
return effective
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class CompanionGTFS:
|
|
75
|
+
"""The supplemented GTFS slices that TODS references resolve against."""
|
|
76
|
+
|
|
77
|
+
source: str
|
|
78
|
+
# Which GTFS base files were actually present (affects what can be checked).
|
|
79
|
+
present: set[str] = field(default_factory=set)
|
|
80
|
+
trip_service: dict[str, str] = field(default_factory=dict)
|
|
81
|
+
trip_block: dict[str, str] = field(default_factory=dict)
|
|
82
|
+
stop_ids: set[str] = field(default_factory=set)
|
|
83
|
+
route_ids: set[str] = field(default_factory=set)
|
|
84
|
+
service_ids: set[str] = field(default_factory=set)
|
|
85
|
+
block_services: dict[str, set[str]] = field(default_factory=dict)
|
|
86
|
+
# Operating dates per service_id, from calendar + calendar_dates after
|
|
87
|
+
# supplements. Only populated for services whose calendar rows parse.
|
|
88
|
+
service_dates: dict[str, frozenset[date]] = field(default_factory=dict)
|
|
89
|
+
# Primary keys present in each GTFS base file *before* supplements, used
|
|
90
|
+
# to check that TODS_delete rows target something that exists.
|
|
91
|
+
base_keys: dict[str, set[tuple[str, ...]]] = field(default_factory=dict)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def block_ids(self) -> set[str]:
|
|
95
|
+
return set(self.block_services)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _calendar_dates_for(
|
|
99
|
+
calendar: dict[tuple[str, ...], dict[str, str]],
|
|
100
|
+
calendar_dates: dict[tuple[str, ...], dict[str, str]],
|
|
101
|
+
) -> dict[str, frozenset[date]]:
|
|
102
|
+
dates: dict[str, set[date]] = {}
|
|
103
|
+
for row in calendar.values():
|
|
104
|
+
service_id = row.get("service_id", "")
|
|
105
|
+
start = parse_gtfs_date(row.get("start_date", ""))
|
|
106
|
+
end = parse_gtfs_date(row.get("end_date", ""))
|
|
107
|
+
if not service_id or start is None or end is None or end < start:
|
|
108
|
+
continue
|
|
109
|
+
active = {i for i, name in enumerate(_WEEKDAY_FIELDS) if row.get(name, "") == "1"}
|
|
110
|
+
days = dates.setdefault(service_id, set())
|
|
111
|
+
current = start
|
|
112
|
+
while current <= end:
|
|
113
|
+
if current.weekday() in active:
|
|
114
|
+
days.add(current)
|
|
115
|
+
current += timedelta(days=1)
|
|
116
|
+
for row in calendar_dates.values():
|
|
117
|
+
service_id = row.get("service_id", "")
|
|
118
|
+
day = parse_gtfs_date(row.get("date", ""))
|
|
119
|
+
if not service_id or day is None:
|
|
120
|
+
continue
|
|
121
|
+
exception = row.get("exception_type", "")
|
|
122
|
+
if exception == "1":
|
|
123
|
+
dates.setdefault(service_id, set()).add(day)
|
|
124
|
+
elif exception == "2":
|
|
125
|
+
dates.setdefault(service_id, set()).discard(day)
|
|
126
|
+
return {k: frozenset(v) for k, v in dates.items()}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def build_companion(gtfs: Package | None, tods: Package, source: str) -> CompanionGTFS:
|
|
130
|
+
"""Build the supplemented GTFS view.
|
|
131
|
+
|
|
132
|
+
``gtfs`` is the package holding the GTFS base files (may be the same
|
|
133
|
+
package as ``tods`` when the feed ships both together); supplements always
|
|
134
|
+
come from the TODS package.
|
|
135
|
+
"""
|
|
136
|
+
companion = CompanionGTFS(source=source)
|
|
137
|
+
|
|
138
|
+
def effective(base_name: str) -> dict[tuple[str, ...], dict[str, str]]:
|
|
139
|
+
base = gtfs.get(base_name) if gtfs is not None else None
|
|
140
|
+
supplement = tods.get(base_name.removesuffix(".txt") + "_supplement.txt")
|
|
141
|
+
pk = GTFS_PRIMARY_KEYS[base_name]
|
|
142
|
+
if base is not None:
|
|
143
|
+
companion.present.add(base_name)
|
|
144
|
+
keys = companion.base_keys.setdefault(base_name, set())
|
|
145
|
+
for row in base.rows:
|
|
146
|
+
key = tuple(row.values.get(f, "") for f in pk)
|
|
147
|
+
if all(key):
|
|
148
|
+
keys.add(key)
|
|
149
|
+
return merge_supplement(base, supplement, pk)
|
|
150
|
+
|
|
151
|
+
trips = effective("trips.txt")
|
|
152
|
+
for (trip_id,), row in trips.items():
|
|
153
|
+
companion.trip_service[trip_id] = row.get("service_id", "")
|
|
154
|
+
block_id = row.get("block_id", "")
|
|
155
|
+
companion.trip_block[trip_id] = block_id
|
|
156
|
+
if block_id:
|
|
157
|
+
companion.block_services.setdefault(block_id, set()).add(row.get("service_id", ""))
|
|
158
|
+
|
|
159
|
+
companion.stop_ids = {key[0] for key in effective("stops.txt")}
|
|
160
|
+
companion.route_ids = {key[0] for key in effective("routes.txt")}
|
|
161
|
+
|
|
162
|
+
calendar = effective("calendar.txt")
|
|
163
|
+
calendar_dates = effective("calendar_dates.txt")
|
|
164
|
+
companion.service_ids = {row.get("service_id", "") for row in calendar.values()} | {
|
|
165
|
+
row.get("service_id", "") for row in calendar_dates.values()
|
|
166
|
+
}
|
|
167
|
+
companion.service_ids.discard("")
|
|
168
|
+
companion.service_dates = _calendar_dates_for(calendar, calendar_dates)
|
|
169
|
+
|
|
170
|
+
return companion
|
tods_validate/loader.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Read a TODS package from a directory or .zip file.
|
|
2
|
+
|
|
3
|
+
Loading is deliberately forgiving: structural problems (bad encoding, ragged
|
|
4
|
+
rows, duplicate headers) are recorded as load problems for the structure rules
|
|
5
|
+
to report, rather than raised, so one bad file does not hide findings in the
|
|
6
|
+
rest of the package.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
import io
|
|
13
|
+
import zipfile
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PackageNotFoundError(Exception):
|
|
19
|
+
"""The path does not exist or is not a directory or .zip file."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Row:
|
|
24
|
+
"""One data row of a CSV file.
|
|
25
|
+
|
|
26
|
+
``line`` is the 1-based line number in the file, counting the header as
|
|
27
|
+
line 1, so the first data row is line 2. ``values`` maps header name to
|
|
28
|
+
the raw cell value ('' for cells missing from a short row).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
line: int
|
|
32
|
+
values: dict[str, str]
|
|
33
|
+
# Cells beyond the header width, kept so rules can report them.
|
|
34
|
+
extra_cells: tuple[str, ...] = ()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class LoadProblem:
|
|
39
|
+
"""A structural defect found while reading a file."""
|
|
40
|
+
|
|
41
|
+
code: str # "encoding" | "empty" | "ragged" | "duplicate_header" | "csv_error"
|
|
42
|
+
message: str
|
|
43
|
+
line: int | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class FeedFile:
|
|
48
|
+
name: str
|
|
49
|
+
headers: tuple[str, ...] = ()
|
|
50
|
+
rows: list[Row] = field(default_factory=list)
|
|
51
|
+
problems: list[LoadProblem] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
def column(self, name: str) -> bool:
|
|
54
|
+
return name in self.headers
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Package:
|
|
59
|
+
"""All files found in the package, parsed where possible."""
|
|
60
|
+
|
|
61
|
+
source: str
|
|
62
|
+
files: dict[str, FeedFile] = field(default_factory=dict)
|
|
63
|
+
# Names of entries that are present but were not parsed (non-CSV, nested
|
|
64
|
+
# directories inside a zip, etc.).
|
|
65
|
+
unparsed: list[str] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
def get(self, name: str) -> FeedFile | None:
|
|
68
|
+
return self.files.get(name)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parse_csv(name: str, data: bytes) -> FeedFile:
|
|
72
|
+
feed = FeedFile(name=name)
|
|
73
|
+
try:
|
|
74
|
+
# utf-8-sig transparently strips a BOM if present.
|
|
75
|
+
text = data.decode("utf-8-sig")
|
|
76
|
+
except UnicodeDecodeError as exc:
|
|
77
|
+
feed.problems.append(
|
|
78
|
+
LoadProblem(
|
|
79
|
+
code="encoding",
|
|
80
|
+
message=(
|
|
81
|
+
f"{name} is not valid UTF-8 (byte {exc.start}). TODS files must be "
|
|
82
|
+
"UTF-8 encoded, like GTFS. Re-export the file as UTF-8."
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
return feed
|
|
87
|
+
|
|
88
|
+
if text.strip() == "":
|
|
89
|
+
feed.problems.append(LoadProblem(code="empty", message=f"{name} is empty (no header row)."))
|
|
90
|
+
return feed
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
reader = csv.reader(io.StringIO(text))
|
|
94
|
+
raw_rows = list(reader)
|
|
95
|
+
except csv.Error as exc:
|
|
96
|
+
feed.problems.append(
|
|
97
|
+
LoadProblem(code="csv_error", message=f"{name} could not be parsed as CSV: {exc}.")
|
|
98
|
+
)
|
|
99
|
+
return feed
|
|
100
|
+
|
|
101
|
+
header = [h.strip() for h in raw_rows[0]]
|
|
102
|
+
feed.headers = tuple(header)
|
|
103
|
+
|
|
104
|
+
seen: set[str] = set()
|
|
105
|
+
for h in header:
|
|
106
|
+
if h in seen:
|
|
107
|
+
feed.problems.append(
|
|
108
|
+
LoadProblem(
|
|
109
|
+
code="duplicate_header",
|
|
110
|
+
message=(
|
|
111
|
+
f"{name} declares the column {h!r} more than once. Each column "
|
|
112
|
+
"may appear only once; values in the duplicate column are ignored."
|
|
113
|
+
),
|
|
114
|
+
line=1,
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
seen.add(h)
|
|
118
|
+
|
|
119
|
+
width = len(header)
|
|
120
|
+
for i, raw in enumerate(raw_rows[1:], start=2):
|
|
121
|
+
if raw == [] or all(cell == "" for cell in raw):
|
|
122
|
+
continue # skip blank lines
|
|
123
|
+
if len(raw) != width:
|
|
124
|
+
feed.problems.append(
|
|
125
|
+
LoadProblem(
|
|
126
|
+
code="ragged",
|
|
127
|
+
message=(
|
|
128
|
+
f"{name} row {i} has {len(raw)} values but the header declares "
|
|
129
|
+
f"{width} columns. Every row must have one value per column "
|
|
130
|
+
"(use empty values for blanks)."
|
|
131
|
+
),
|
|
132
|
+
line=i,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
values = {h: (raw[j] if j < len(raw) else "") for j, h in enumerate(header)}
|
|
136
|
+
extra = tuple(raw[width:])
|
|
137
|
+
feed.rows.append(Row(line=i, values=values, extra_cells=extra))
|
|
138
|
+
|
|
139
|
+
return feed
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def load_package(path: str | Path) -> Package:
|
|
143
|
+
"""Load all top-level files from a directory or .zip file."""
|
|
144
|
+
p = Path(path)
|
|
145
|
+
if p.is_dir():
|
|
146
|
+
pkg = Package(source=str(p))
|
|
147
|
+
for entry in sorted(p.iterdir()):
|
|
148
|
+
if entry.name.startswith("."):
|
|
149
|
+
continue
|
|
150
|
+
if entry.is_dir():
|
|
151
|
+
pkg.unparsed.append(entry.name + "/")
|
|
152
|
+
continue
|
|
153
|
+
if entry.suffix.lower() in (".txt", ".csv"):
|
|
154
|
+
pkg.files[entry.name] = _parse_csv(entry.name, entry.read_bytes())
|
|
155
|
+
else:
|
|
156
|
+
pkg.unparsed.append(entry.name)
|
|
157
|
+
return pkg
|
|
158
|
+
|
|
159
|
+
if p.is_file() and zipfile.is_zipfile(p):
|
|
160
|
+
pkg = Package(source=str(p))
|
|
161
|
+
with zipfile.ZipFile(p) as zf:
|
|
162
|
+
for info in sorted(zf.infolist(), key=lambda i: i.filename):
|
|
163
|
+
if info.is_dir():
|
|
164
|
+
continue
|
|
165
|
+
name = info.filename
|
|
166
|
+
base = Path(name).name
|
|
167
|
+
if "/" in name.strip("/"):
|
|
168
|
+
# Files nested in subdirectories are outside the spec's
|
|
169
|
+
# flat-package shape; surface them rather than guessing.
|
|
170
|
+
pkg.unparsed.append(name)
|
|
171
|
+
continue
|
|
172
|
+
if base.startswith("."):
|
|
173
|
+
continue
|
|
174
|
+
if Path(base).suffix.lower() in (".txt", ".csv"):
|
|
175
|
+
pkg.files[base] = _parse_csv(base, zf.read(info))
|
|
176
|
+
else:
|
|
177
|
+
pkg.unparsed.append(name)
|
|
178
|
+
return pkg
|
|
179
|
+
|
|
180
|
+
raise PackageNotFoundError(
|
|
181
|
+
f"{p} is not a directory or a .zip file. Pass the folder or zip that "
|
|
182
|
+
"contains the TODS .txt files."
|
|
183
|
+
)
|
tods_validate/report.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Render findings as human text, JSON, or GitHub Actions annotations.
|
|
2
|
+
|
|
3
|
+
Accessibility note: severity is always carried by a word (ERROR, WARNING,
|
|
4
|
+
INFO), never by color alone, so reports stay readable when piped to a file.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from collections import Counter
|
|
11
|
+
|
|
12
|
+
from .findings import Finding, Severity
|
|
13
|
+
from .schema import SPEC_VERSION
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def summarize(findings: list[Finding]) -> Counter[Severity]:
|
|
17
|
+
return Counter(f.severity for f in findings)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def render_text(findings: list[Finding], source: str) -> str:
|
|
21
|
+
lines: list[str] = [f"tods-validate: {source} (TODS v{SPEC_VERSION})", ""]
|
|
22
|
+
if not findings:
|
|
23
|
+
lines.append("No problems found.")
|
|
24
|
+
return "\n".join(lines)
|
|
25
|
+
|
|
26
|
+
for severity in (Severity.ERROR, Severity.WARNING, Severity.INFO):
|
|
27
|
+
group = [f for f in findings if f.severity == severity]
|
|
28
|
+
if not group:
|
|
29
|
+
continue
|
|
30
|
+
plural = "s" if len(group) != 1 else ""
|
|
31
|
+
lines.append(f"{len(group)} {severity.name.lower()}{plural}:")
|
|
32
|
+
for f in group:
|
|
33
|
+
location = f.location()
|
|
34
|
+
prefix = f" {severity.name} {f.rule_id}"
|
|
35
|
+
lines.append(f"{prefix} [{location}]" if location else prefix)
|
|
36
|
+
lines.append(f" {f.message}")
|
|
37
|
+
if f.suggestion:
|
|
38
|
+
lines.append(f" Fix: {f.suggestion}")
|
|
39
|
+
lines.append("")
|
|
40
|
+
|
|
41
|
+
counts = summarize(findings)
|
|
42
|
+
lines.append(
|
|
43
|
+
"Summary: "
|
|
44
|
+
f"{counts[Severity.ERROR]} error(s), "
|
|
45
|
+
f"{counts[Severity.WARNING]} warning(s), "
|
|
46
|
+
f"{counts[Severity.INFO]} info."
|
|
47
|
+
)
|
|
48
|
+
return "\n".join(lines)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def render_json(findings: list[Finding], source: str) -> str:
|
|
52
|
+
counts = summarize(findings)
|
|
53
|
+
payload = {
|
|
54
|
+
"validator": "tods-validate",
|
|
55
|
+
"specVersion": SPEC_VERSION,
|
|
56
|
+
"source": source,
|
|
57
|
+
"summary": {
|
|
58
|
+
"errors": counts[Severity.ERROR],
|
|
59
|
+
"warnings": counts[Severity.WARNING],
|
|
60
|
+
"infos": counts[Severity.INFO],
|
|
61
|
+
},
|
|
62
|
+
"findings": [f.to_dict() for f in findings],
|
|
63
|
+
}
|
|
64
|
+
return json.dumps(payload, indent=2)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_GITHUB_COMMANDS = {
|
|
68
|
+
Severity.ERROR: "error",
|
|
69
|
+
Severity.WARNING: "warning",
|
|
70
|
+
Severity.INFO: "notice",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _escape_annotation(text: str) -> str:
|
|
75
|
+
return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def render_github(findings: list[Finding], source: str) -> str:
|
|
79
|
+
"""Workflow command format; one annotation per finding.
|
|
80
|
+
|
|
81
|
+
See https://docs.github.com/actions/reference/workflow-commands-for-github-actions
|
|
82
|
+
"""
|
|
83
|
+
lines = []
|
|
84
|
+
for f in findings:
|
|
85
|
+
command = _GITHUB_COMMANDS[f.severity]
|
|
86
|
+
properties = []
|
|
87
|
+
if f.file:
|
|
88
|
+
properties.append(f"file={_escape_annotation(f.file)}")
|
|
89
|
+
if f.row is not None:
|
|
90
|
+
properties.append(f"line={f.row}")
|
|
91
|
+
properties.append(f"title={f.rule_id}")
|
|
92
|
+
message = f.message if not f.suggestion else f"{f.message} Fix: {f.suggestion}"
|
|
93
|
+
lines.append(f"::{command} {','.join(properties)}::{_escape_annotation(message)}")
|
|
94
|
+
counts = summarize(findings)
|
|
95
|
+
lines.append(
|
|
96
|
+
f"tods-validate: {counts[Severity.ERROR]} error(s), "
|
|
97
|
+
f"{counts[Severity.WARNING]} warning(s), {counts[Severity.INFO]} info "
|
|
98
|
+
f"in {source}."
|
|
99
|
+
)
|
|
100
|
+
return "\n".join(lines)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
RENDERERS = {
|
|
104
|
+
"text": render_text,
|
|
105
|
+
"json": render_json,
|
|
106
|
+
"github": render_github,
|
|
107
|
+
}
|