forked from ChelseaKR/tods-validate
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathstructure.py
More file actions
282 lines (262 loc) · 10.4 KB
/
Copy pathstructure.py
File metadata and controls
282 lines (262 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
"""Package and file structure rules (TODS-x1xx)."""
from __future__ import annotations
from collections.abc import Iterator
from ..findings import Finding, Severity
from ..loader import BLOCKING_PROBLEM_CODES
from ..schema import GTFS_FILENAMES, SPEC_URL, Presence, spec_link
from . import ValidationContext, rule
_FILES_SECTION = f"{SPEC_URL}#files"
@rule(
id="TODS-W101",
severity=Severity.WARNING,
title="No TODS files in package",
description=(
"The package contains none of the files defined by the TODS spec version being "
"validated against. Every TODS file is optional, but a package with none of them "
"has nothing to validate."
),
spec_section=_FILES_SECTION,
)
def no_tods_files(context: ValidationContext) -> Iterator[Finding]:
if not any(name in context.tables for name in context.package.files):
yield Finding(
rule_id="TODS-W101",
severity=Severity.WARNING,
message=(
"No TODS files were found in this package. Expected at least one of: "
+ ", ".join(sorted(context.tables))
+ "."
),
suggestion="Check that the TODS files are at the top level, not in a subfolder.",
data={"expected": ",".join(sorted(context.tables))},
)
@rule(
id="TODS-I102",
severity=Severity.INFO,
title="File is not part of TODS or GTFS",
description=(
"A file in the package is neither a TODS file nor a standard GTFS file. It is "
"ignored by this validator."
),
spec_section=_FILES_SECTION,
)
def unknown_file(context: ValidationContext) -> Iterator[Finding]:
for name in context.package.files:
if name not in context.tables and name not in GTFS_FILENAMES:
yield Finding(
rule_id="TODS-I102",
severity=Severity.INFO,
file=name,
message=(
f"{name} is not a TODS file and not a standard GTFS file; it was not validated."
),
suggestion=(
"If this was meant to be a TODS file, check the spelling against the "
"file list in the spec."
),
data={"value": name},
)
for name in context.package.unparsed:
yield Finding(
rule_id="TODS-I102",
severity=Severity.INFO,
file=name,
message=f"{name} is not a CSV text file and was not validated.",
data={"value": name},
)
@rule(
id="TODS-E103",
severity=Severity.ERROR,
title="File could not be read",
description=(
"A TODS file is empty, not UTF-8 encoded, or not parseable as CSV. The file's "
"contents were not validated."
),
spec_section=_FILES_SECTION,
)
def file_unreadable(context: ValidationContext) -> Iterator[Finding]:
for name, feed in context.package.files.items():
if name not in context.tables:
continue
for problem in feed.problems:
if problem.code in BLOCKING_PROBLEM_CODES:
yield Finding(
rule_id="TODS-E103",
severity=Severity.ERROR,
file=name,
row=problem.line,
message=problem.message,
data={"value": name, "code": problem.code},
)
@rule(
id="TODS-E104",
severity=Severity.ERROR,
title="Row has the wrong number of values",
description=(
"A row has more or fewer values than the file's header declares columns. "
"Values after the mismatch may be attributed to the wrong field."
),
spec_section=_FILES_SECTION,
example=(
"Before: header is `trip_id,stop_sequence,arrival_time` but a data row is "
"`T-1,1,08:00,extra`. After: quote fields containing commas, or remove the "
"stray trailing value so the row has exactly 3 fields."
),
)
def ragged_row(context: ValidationContext) -> Iterator[Finding]:
for name, feed in context.package.files.items():
if name not in context.tables:
continue
for problem in feed.problems:
if problem.code == "ragged":
yield Finding(
rule_id="TODS-E104",
severity=Severity.ERROR,
file=name,
row=problem.line,
message=problem.message,
suggestion=(
"Open the file in a text editor (not a spreadsheet) and check for "
"unquoted commas or missing trailing commas on this row."
),
data={
"value": str(problem.actual),
"expected": str(problem.expected),
},
)
@rule(
id="TODS-E105",
severity=Severity.ERROR,
title="Duplicate column name",
description="A column name appears more than once in a file's header row.",
spec_section=_FILES_SECTION,
)
def duplicate_column(context: ValidationContext) -> Iterator[Finding]:
for name, feed in context.package.files.items():
if name not in context.tables:
continue
for problem in feed.problems:
if problem.code == "duplicate_header":
yield Finding(
rule_id="TODS-E105",
severity=Severity.ERROR,
file=name,
row=1,
field=problem.column,
message=problem.message,
data={"field": problem.column or ""},
)
@rule(
id="TODS-E106",
severity=Severity.ERROR,
title="Required column is missing",
description=(
"A TODS file does not declare a column the spec marks Required (for supplement "
"files: a primary-key column of the GTFS file being supplemented). Rows cannot "
"be interpreted without it."
),
spec_section=SPEC_URL,
example=(
"Before: `stop_time_overrides.txt` header is `trip_id,stop_sequence`. After: "
"add the required key column — `trip_id,stop_id,stop_sequence`."
),
)
def missing_required_column(context: ValidationContext) -> Iterator[Finding]:
for name, table in context.tables.items():
feed = context.package.get(name)
if feed is None or not feed.headers:
continue
if table.kind == "supplement":
required = table.primary_key or ()
why = f"it is the primary key used to match rows against GTFS {table.gtfs_base}"
else:
required = tuple(f.name for f in table.fields if f.presence is Presence.REQUIRED)
why = "the spec marks it Required"
for column in required:
if column not in feed.headers:
yield Finding(
rule_id="TODS-E106",
severity=Severity.ERROR,
file=name,
row=1,
field=column,
message=(f"{name} is missing the required column {column!r} ({why})."),
suggestion=f"Add a {column!r} column. See {spec_link(table)}.",
data={"field": column},
)
@rule(
id="TODS-W107",
severity=Severity.WARNING,
title="Column is not defined by TODS",
description=(
"A TODS-specific file declares a column the spec does not define. Consumers "
"will ignore it; it is often a misspelled field name."
),
spec_section=SPEC_URL,
)
def unknown_column_tods(context: ValidationContext) -> Iterator[Finding]:
for name, table in context.tables.items():
if table.kind != "tods":
continue
feed = context.package.get(name)
if feed is None:
continue
known = {f.name for f in table.fields}
for column in feed.headers:
if column and column not in known:
yield Finding(
rule_id="TODS-W107",
severity=Severity.WARNING,
file=name,
row=1,
field=column,
message=(
f"{name} has a column {column!r} that is not defined in TODS "
f"{name.removesuffix('.txt')}. Consumers will ignore it."
),
suggestion=(
"Check the spelling against the field list in the spec: "
f"{spec_link(table)}."
),
data={"value": column, "field": column},
)
@rule(
id="TODS-I108",
severity=Severity.INFO,
title="Supplement column is not defined by GTFS or TODS",
description=(
"A supplement file declares a column that is neither a field of the GTFS file "
"being supplemented nor a TODS_ field. It is carried through to the merged feed "
"as a GTFS extension field."
),
spec_section=f"{SPEC_URL}#supplement-files",
)
def unknown_column_supplement(context: ValidationContext) -> Iterator[Finding]:
for name, table in context.tables.items():
if table.kind != "supplement":
continue
feed = context.package.get(name)
if feed is None:
continue
known = {f.name for f in table.fields}
for column in feed.headers:
# A TODS_-prefixed column is, by the naming convention itself, a
# TODS-only extension field (see the rule's own description and
# its worked example in rules/__init__.py's EXAMPLES, which fixes
# an unknown column precisely by adding this prefix) -- it should
# not be flagged as unrecognized just because it isn't literally
# one of this table's declared fields.
if column and column not in known and not column.startswith("TODS_"):
yield Finding(
rule_id="TODS-I108",
severity=Severity.INFO,
file=name,
row=1,
field=column,
message=(
f"{name} has a column {column!r} that is not a GTFS "
f"{table.gtfs_base} field or a TODS_ field. It will be treated "
"as an extension field."
),
data={"value": column, "field": column},
)