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.
@@ -0,0 +1,3 @@
1
+ """tods-validate: a validator for Transit Operational Data Standard (TODS) feeds."""
2
+
3
+ __version__ = "0.1.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
@@ -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
+ )
@@ -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
+ }