gtfslint-cli 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.
- gtfslint/__init__.py +32 -0
- gtfslint/__main__.py +6 -0
- gtfslint/api.py +71 -0
- gtfslint/cli.py +177 -0
- gtfslint/config.py +112 -0
- gtfslint/fieldtypes.py +165 -0
- gtfslint/notices.py +682 -0
- gtfslint/py.typed +0 -0
- gtfslint/reader.py +83 -0
- gtfslint/report.py +451 -0
- gtfslint/spec.py +418 -0
- gtfslint/stats.py +207 -0
- gtfslint/validator.py +1560 -0
- gtfslint_cli-0.1.0.dist-info/METADATA +327 -0
- gtfslint_cli-0.1.0.dist-info/RECORD +19 -0
- gtfslint_cli-0.1.0.dist-info/WHEEL +5 -0
- gtfslint_cli-0.1.0.dist-info/entry_points.txt +2 -0
- gtfslint_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- gtfslint_cli-0.1.0.dist-info/top_level.txt +1 -0
gtfslint/notices.py
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
"""Rule catalogue, severities and the :class:`Notice` record.
|
|
2
|
+
|
|
3
|
+
Every check gtfslint performs is a *rule* with a stable code (``GL101``), a
|
|
4
|
+
snake_case name, a default severity, a one-line description and a link to the
|
|
5
|
+
part of the GTFS Schedule reference it enforces. Where a rule mirrors a notice
|
|
6
|
+
of the canonical MobilityData validator, the MobilityData notice name is kept
|
|
7
|
+
in :attr:`Rule.mobilitydata` (it is usually identical to :attr:`Rule.name`).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from dataclasses import field as _dc_field
|
|
14
|
+
from enum import IntEnum
|
|
15
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"RULES",
|
|
19
|
+
"Notice",
|
|
20
|
+
"Rule",
|
|
21
|
+
"Severity",
|
|
22
|
+
"resolve_selection",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
_REF = "https://gtfs.org/documentation/schedule/reference/"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Severity(IntEnum):
|
|
29
|
+
"""Notice severity. Higher values are more severe."""
|
|
30
|
+
|
|
31
|
+
INFO = 1
|
|
32
|
+
WARNING = 2
|
|
33
|
+
ERROR = 3
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def label(self) -> str:
|
|
37
|
+
"""Lower-case name used in every output format (``"error"``)."""
|
|
38
|
+
return self.name.lower()
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def parse(cls, value: str) -> Severity:
|
|
42
|
+
"""Parse ``"error"``, ``"warning"`` or ``"info"`` (case-insensitive)."""
|
|
43
|
+
try:
|
|
44
|
+
return cls[value.strip().upper()]
|
|
45
|
+
except KeyError:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"unknown severity {value!r}; expected error, warning or info"
|
|
48
|
+
) from None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Rule:
|
|
53
|
+
"""Static description of one validation rule."""
|
|
54
|
+
|
|
55
|
+
code: str
|
|
56
|
+
name: str
|
|
57
|
+
severity: Severity
|
|
58
|
+
description: str
|
|
59
|
+
reference: str
|
|
60
|
+
mobilitydata: Optional[str] = None
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
63
|
+
return {
|
|
64
|
+
"code": self.code,
|
|
65
|
+
"name": self.name,
|
|
66
|
+
"severity": self.severity.label,
|
|
67
|
+
"description": self.description,
|
|
68
|
+
"reference": self.reference,
|
|
69
|
+
"mobilitydata": self.mobilitydata,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_E, _W, _I = Severity.ERROR, Severity.WARNING, Severity.INFO
|
|
74
|
+
|
|
75
|
+
# (code, name, severity, description, reference anchor, mirrors MobilityData notice?)
|
|
76
|
+
_CATALOGUE: Tuple[Tuple[str, str, Severity, str, str, bool], ...] = (
|
|
77
|
+
# --- GL0xx: files, columns and rows -------------------------------------------------
|
|
78
|
+
(
|
|
79
|
+
"GL001",
|
|
80
|
+
"missing_required_file",
|
|
81
|
+
_E,
|
|
82
|
+
"A file required by the specification (agency, routes, trips, stop_times, stops) is "
|
|
83
|
+
"missing.",
|
|
84
|
+
"#dataset-files",
|
|
85
|
+
True,
|
|
86
|
+
),
|
|
87
|
+
(
|
|
88
|
+
"GL002",
|
|
89
|
+
"missing_calendar_and_calendar_date_files",
|
|
90
|
+
_E,
|
|
91
|
+
"Neither calendar.txt nor calendar_dates.txt is present; at least one is required.",
|
|
92
|
+
"#dataset-files",
|
|
93
|
+
True,
|
|
94
|
+
),
|
|
95
|
+
(
|
|
96
|
+
"GL003",
|
|
97
|
+
"empty_file",
|
|
98
|
+
_E,
|
|
99
|
+
"A required file is empty or has a header but no records.",
|
|
100
|
+
"#file-requirements",
|
|
101
|
+
True,
|
|
102
|
+
),
|
|
103
|
+
(
|
|
104
|
+
"GL004",
|
|
105
|
+
"missing_required_column",
|
|
106
|
+
_E,
|
|
107
|
+
"A required column is absent from the header of a file.",
|
|
108
|
+
"#file-requirements",
|
|
109
|
+
True,
|
|
110
|
+
),
|
|
111
|
+
(
|
|
112
|
+
"GL005",
|
|
113
|
+
"duplicated_column",
|
|
114
|
+
_E,
|
|
115
|
+
"The same column name appears more than once in a header.",
|
|
116
|
+
"#file-requirements",
|
|
117
|
+
True,
|
|
118
|
+
),
|
|
119
|
+
(
|
|
120
|
+
"GL006",
|
|
121
|
+
"empty_column_name",
|
|
122
|
+
_E,
|
|
123
|
+
"A header contains an empty column name.",
|
|
124
|
+
"#file-requirements",
|
|
125
|
+
True,
|
|
126
|
+
),
|
|
127
|
+
(
|
|
128
|
+
"GL007",
|
|
129
|
+
"invalid_row_length",
|
|
130
|
+
_E,
|
|
131
|
+
"A row has a different number of fields than the header.",
|
|
132
|
+
"#file-requirements",
|
|
133
|
+
True,
|
|
134
|
+
),
|
|
135
|
+
(
|
|
136
|
+
"GL008",
|
|
137
|
+
"csv_parsing_failed",
|
|
138
|
+
_E,
|
|
139
|
+
"The file could not be parsed as UTF-8 CSV; the rest of the file was skipped.",
|
|
140
|
+
"#file-requirements",
|
|
141
|
+
True,
|
|
142
|
+
),
|
|
143
|
+
(
|
|
144
|
+
"GL009",
|
|
145
|
+
"unknown_file",
|
|
146
|
+
_I,
|
|
147
|
+
"The feed contains a file that is not part of the GTFS Schedule specification.",
|
|
148
|
+
"#dataset-files",
|
|
149
|
+
True,
|
|
150
|
+
),
|
|
151
|
+
(
|
|
152
|
+
"GL010",
|
|
153
|
+
"unknown_column",
|
|
154
|
+
_I,
|
|
155
|
+
"A file has a column that is not defined by the specification.",
|
|
156
|
+
"#file-requirements",
|
|
157
|
+
True,
|
|
158
|
+
),
|
|
159
|
+
(
|
|
160
|
+
"GL011",
|
|
161
|
+
"leading_or_trailing_whitespaces",
|
|
162
|
+
_W,
|
|
163
|
+
"A value or column name has leading or trailing whitespace (it is trimmed before the "
|
|
164
|
+
"other checks).",
|
|
165
|
+
"#file-requirements",
|
|
166
|
+
True,
|
|
167
|
+
),
|
|
168
|
+
(
|
|
169
|
+
"GL012",
|
|
170
|
+
"duplicate_key",
|
|
171
|
+
_E,
|
|
172
|
+
"Two records share the same primary key (e.g. stop_id, or trip_id + stop_sequence).",
|
|
173
|
+
"#dataset-attributes",
|
|
174
|
+
True,
|
|
175
|
+
),
|
|
176
|
+
(
|
|
177
|
+
"GL013",
|
|
178
|
+
"missing_required_field",
|
|
179
|
+
_E,
|
|
180
|
+
"A required or conditionally required field is empty.",
|
|
181
|
+
"#field-definitions",
|
|
182
|
+
True,
|
|
183
|
+
),
|
|
184
|
+
(
|
|
185
|
+
"GL014",
|
|
186
|
+
"route_both_short_and_long_name_missing",
|
|
187
|
+
_E,
|
|
188
|
+
"A route has neither route_short_name nor route_long_name.",
|
|
189
|
+
"#routestxt",
|
|
190
|
+
True,
|
|
191
|
+
),
|
|
192
|
+
("GL015", "empty_row", _W, "A row contains only empty fields.", "#file-requirements", True),
|
|
193
|
+
(
|
|
194
|
+
"GL016",
|
|
195
|
+
"missing_recommended_file",
|
|
196
|
+
_W,
|
|
197
|
+
"feed_info.txt is missing; it is recommended so consumers know the publisher and "
|
|
198
|
+
"validity window.",
|
|
199
|
+
"#feed_infotxt",
|
|
200
|
+
True,
|
|
201
|
+
),
|
|
202
|
+
# --- GL1xx: field values -----------------------------------------------------------
|
|
203
|
+
(
|
|
204
|
+
"GL101",
|
|
205
|
+
"invalid_color",
|
|
206
|
+
_E,
|
|
207
|
+
"A color is not a six-digit hexadecimal value such as FFFFFF.",
|
|
208
|
+
"#field-types",
|
|
209
|
+
True,
|
|
210
|
+
),
|
|
211
|
+
(
|
|
212
|
+
"GL102",
|
|
213
|
+
"invalid_url",
|
|
214
|
+
_E,
|
|
215
|
+
"A URL is not a fully qualified http:// or https:// URL.",
|
|
216
|
+
"#field-types",
|
|
217
|
+
True,
|
|
218
|
+
),
|
|
219
|
+
(
|
|
220
|
+
"GL103",
|
|
221
|
+
"invalid_timezone",
|
|
222
|
+
_E,
|
|
223
|
+
"A timezone is not a valid IANA TZ database name such as Europe/Istanbul.",
|
|
224
|
+
"#field-types",
|
|
225
|
+
True,
|
|
226
|
+
),
|
|
227
|
+
(
|
|
228
|
+
"GL104",
|
|
229
|
+
"invalid_date",
|
|
230
|
+
_E,
|
|
231
|
+
"A date is not a valid calendar date in YYYYMMDD format.",
|
|
232
|
+
"#field-types",
|
|
233
|
+
True,
|
|
234
|
+
),
|
|
235
|
+
(
|
|
236
|
+
"GL105",
|
|
237
|
+
"invalid_time",
|
|
238
|
+
_E,
|
|
239
|
+
"A time is not in H:MM:SS or HH:MM:SS format (hours above 24 are allowed).",
|
|
240
|
+
"#field-types",
|
|
241
|
+
True,
|
|
242
|
+
),
|
|
243
|
+
(
|
|
244
|
+
"GL106",
|
|
245
|
+
"invalid_integer",
|
|
246
|
+
_E,
|
|
247
|
+
"A value that must be an integer (or enumeration) could not be parsed.",
|
|
248
|
+
"#field-types",
|
|
249
|
+
True,
|
|
250
|
+
),
|
|
251
|
+
(
|
|
252
|
+
"GL107",
|
|
253
|
+
"invalid_float",
|
|
254
|
+
_E,
|
|
255
|
+
"A value that must be a number could not be parsed.",
|
|
256
|
+
"#field-types",
|
|
257
|
+
True,
|
|
258
|
+
),
|
|
259
|
+
(
|
|
260
|
+
"GL108",
|
|
261
|
+
"unexpected_enum_value",
|
|
262
|
+
_W,
|
|
263
|
+
"An enumeration field has a value not listed by the specification.",
|
|
264
|
+
"#field-types",
|
|
265
|
+
True,
|
|
266
|
+
),
|
|
267
|
+
(
|
|
268
|
+
"GL109",
|
|
269
|
+
"number_out_of_range",
|
|
270
|
+
_E,
|
|
271
|
+
"A number is outside its allowed range (latitude, longitude, non-negative or positive "
|
|
272
|
+
"fields).",
|
|
273
|
+
"#field-types",
|
|
274
|
+
True,
|
|
275
|
+
),
|
|
276
|
+
("GL110", "invalid_email", _E, "An e-mail address is malformed.", "#field-types", True),
|
|
277
|
+
(
|
|
278
|
+
"GL111",
|
|
279
|
+
"invalid_language_code",
|
|
280
|
+
_E,
|
|
281
|
+
"A language code is not a well-formed IETF BCP 47 tag.",
|
|
282
|
+
"#field-types",
|
|
283
|
+
True,
|
|
284
|
+
),
|
|
285
|
+
(
|
|
286
|
+
"GL112",
|
|
287
|
+
"invalid_currency",
|
|
288
|
+
_E,
|
|
289
|
+
"A currency code is not an ISO 4217 alphabetical code.",
|
|
290
|
+
"#field-types",
|
|
291
|
+
True,
|
|
292
|
+
),
|
|
293
|
+
(
|
|
294
|
+
"GL113",
|
|
295
|
+
"point_near_origin",
|
|
296
|
+
_E,
|
|
297
|
+
"A stop or shape point lies at (0, 0) ('null island'), usually a missing coordinate.",
|
|
298
|
+
"#stopstxt",
|
|
299
|
+
True,
|
|
300
|
+
),
|
|
301
|
+
(
|
|
302
|
+
"GL114",
|
|
303
|
+
"inconsistent_agency_timezone",
|
|
304
|
+
_E,
|
|
305
|
+
"Agencies in agency.txt use different agency_timezone values.",
|
|
306
|
+
"#agencytxt",
|
|
307
|
+
True,
|
|
308
|
+
),
|
|
309
|
+
(
|
|
310
|
+
"GL115",
|
|
311
|
+
"route_color_contrast",
|
|
312
|
+
_W,
|
|
313
|
+
"route_color and route_text_color have a WCAG contrast ratio below 3:1.",
|
|
314
|
+
"#routestxt",
|
|
315
|
+
True,
|
|
316
|
+
),
|
|
317
|
+
(
|
|
318
|
+
"GL116",
|
|
319
|
+
"non_ascii_or_non_printable_char",
|
|
320
|
+
_W,
|
|
321
|
+
"An ID contains non-ASCII or non-printable characters.",
|
|
322
|
+
"#field-types",
|
|
323
|
+
True,
|
|
324
|
+
),
|
|
325
|
+
# --- GL2xx: references -------------------------------------------------------------
|
|
326
|
+
(
|
|
327
|
+
"GL201",
|
|
328
|
+
"foreign_key_violation",
|
|
329
|
+
_E,
|
|
330
|
+
"A reference points to a record that does not exist (e.g. trips.route_id, "
|
|
331
|
+
"stop_times.stop_id, stops.parent_station, transfers, fare tables).",
|
|
332
|
+
"#field-definitions",
|
|
333
|
+
True,
|
|
334
|
+
),
|
|
335
|
+
(
|
|
336
|
+
"GL202",
|
|
337
|
+
"wrong_parent_location_type",
|
|
338
|
+
_E,
|
|
339
|
+
"parent_station references a location of the wrong type (a stop's parent must be a "
|
|
340
|
+
"station, a boarding area's parent must be a stop).",
|
|
341
|
+
"#stopstxt",
|
|
342
|
+
True,
|
|
343
|
+
),
|
|
344
|
+
(
|
|
345
|
+
"GL203",
|
|
346
|
+
"station_with_parent_station",
|
|
347
|
+
_E,
|
|
348
|
+
"A station (location_type 1) has a parent_station.",
|
|
349
|
+
"#stopstxt",
|
|
350
|
+
True,
|
|
351
|
+
),
|
|
352
|
+
(
|
|
353
|
+
"GL204",
|
|
354
|
+
"location_with_unexpected_stop_time",
|
|
355
|
+
_E,
|
|
356
|
+
"stop_times references a station, entrance or generic node instead of a stop or "
|
|
357
|
+
"boarding area.",
|
|
358
|
+
"#stop_timestxt",
|
|
359
|
+
True,
|
|
360
|
+
),
|
|
361
|
+
# --- GL3xx: calendars --------------------------------------------------------------
|
|
362
|
+
(
|
|
363
|
+
"GL301",
|
|
364
|
+
"start_and_end_range_out_of_order",
|
|
365
|
+
_E,
|
|
366
|
+
"A start date/time is after its end (calendar, feed_info, frequencies).",
|
|
367
|
+
"#calendartxt",
|
|
368
|
+
True,
|
|
369
|
+
),
|
|
370
|
+
(
|
|
371
|
+
"GL302",
|
|
372
|
+
"service_never_active",
|
|
373
|
+
_W,
|
|
374
|
+
"A service_id is never active once calendar and calendar_dates are combined.",
|
|
375
|
+
"#calendartxt",
|
|
376
|
+
False,
|
|
377
|
+
),
|
|
378
|
+
(
|
|
379
|
+
"GL303",
|
|
380
|
+
"redundant_calendar_date",
|
|
381
|
+
_I,
|
|
382
|
+
"A calendar_dates exception adds a date that is already active or removes one that is not.",
|
|
383
|
+
"#calendar_datestxt",
|
|
384
|
+
False,
|
|
385
|
+
),
|
|
386
|
+
(
|
|
387
|
+
"GL304",
|
|
388
|
+
"expired_calendar",
|
|
389
|
+
_W,
|
|
390
|
+
"A service has no active dates on or after the reference date.",
|
|
391
|
+
"#calendartxt",
|
|
392
|
+
True,
|
|
393
|
+
),
|
|
394
|
+
# --- GL4xx: stop_times and trips ---------------------------------------------------
|
|
395
|
+
(
|
|
396
|
+
"GL401",
|
|
397
|
+
"stop_times_not_sorted",
|
|
398
|
+
_I,
|
|
399
|
+
"The rows of a trip are not listed in increasing stop_sequence order (allowed, but "
|
|
400
|
+
"slows down consumers).",
|
|
401
|
+
"#stop_timestxt",
|
|
402
|
+
False,
|
|
403
|
+
),
|
|
404
|
+
(
|
|
405
|
+
"GL402",
|
|
406
|
+
"stop_time_with_arrival_before_previous_departure_time",
|
|
407
|
+
_E,
|
|
408
|
+
"A stop's arrival_time is earlier than the departure_time at the previous stop.",
|
|
409
|
+
"#stop_timestxt",
|
|
410
|
+
True,
|
|
411
|
+
),
|
|
412
|
+
(
|
|
413
|
+
"GL403",
|
|
414
|
+
"stop_time_arrival_after_departure",
|
|
415
|
+
_E,
|
|
416
|
+
"A stop's arrival_time is later than its own departure_time.",
|
|
417
|
+
"#stop_timestxt",
|
|
418
|
+
False,
|
|
419
|
+
),
|
|
420
|
+
(
|
|
421
|
+
"GL404",
|
|
422
|
+
"stop_time_with_only_arrival_or_departure_time",
|
|
423
|
+
_E,
|
|
424
|
+
"Only one of arrival_time and departure_time is set.",
|
|
425
|
+
"#stop_timestxt",
|
|
426
|
+
True,
|
|
427
|
+
),
|
|
428
|
+
(
|
|
429
|
+
"GL405",
|
|
430
|
+
"missing_trip_edge",
|
|
431
|
+
_E,
|
|
432
|
+
"The first or last stop of a trip has no arrival_time/departure_time.",
|
|
433
|
+
"#stop_timestxt",
|
|
434
|
+
True,
|
|
435
|
+
),
|
|
436
|
+
(
|
|
437
|
+
"GL406",
|
|
438
|
+
"decreasing_or_equal_stop_time_distance",
|
|
439
|
+
_E,
|
|
440
|
+
"shape_dist_traveled does not increase along a trip.",
|
|
441
|
+
"#stop_timestxt",
|
|
442
|
+
True,
|
|
443
|
+
),
|
|
444
|
+
(
|
|
445
|
+
"GL407",
|
|
446
|
+
"stop_time_timepoint_without_times",
|
|
447
|
+
_E,
|
|
448
|
+
"timepoint=1 but the stop has no arrival/departure time.",
|
|
449
|
+
"#stop_timestxt",
|
|
450
|
+
True,
|
|
451
|
+
),
|
|
452
|
+
(
|
|
453
|
+
"GL408",
|
|
454
|
+
"fast_travel_between_consecutive_stops",
|
|
455
|
+
_W,
|
|
456
|
+
"The speed between two timed stops exceeds the plausible maximum for the route type.",
|
|
457
|
+
"#stop_timestxt",
|
|
458
|
+
True,
|
|
459
|
+
),
|
|
460
|
+
("GL409", "unusable_trip", _W, "A trip visits fewer than two stops.", "#stop_timestxt", True),
|
|
461
|
+
# --- GL5xx: geometry ---------------------------------------------------------------
|
|
462
|
+
(
|
|
463
|
+
"GL501",
|
|
464
|
+
"decreasing_shape_distance",
|
|
465
|
+
_E,
|
|
466
|
+
"shape_dist_traveled decreases along a shape.",
|
|
467
|
+
"#shapestxt",
|
|
468
|
+
True,
|
|
469
|
+
),
|
|
470
|
+
(
|
|
471
|
+
"GL502",
|
|
472
|
+
"stops_too_close",
|
|
473
|
+
_W,
|
|
474
|
+
"Two different stops (not in the same station) are closer than min-stop-distance-m; "
|
|
475
|
+
"often an accidental duplicate.",
|
|
476
|
+
"#stopstxt",
|
|
477
|
+
False,
|
|
478
|
+
),
|
|
479
|
+
(
|
|
480
|
+
"GL503",
|
|
481
|
+
"stops_too_far",
|
|
482
|
+
_W,
|
|
483
|
+
"Two consecutive stops of a trip are farther apart than plausible for the route type; "
|
|
484
|
+
"often a wrong coordinate.",
|
|
485
|
+
"#stopstxt",
|
|
486
|
+
False,
|
|
487
|
+
),
|
|
488
|
+
(
|
|
489
|
+
"GL504",
|
|
490
|
+
"stop_too_far_from_parent_station",
|
|
491
|
+
_W,
|
|
492
|
+
"A stop, entrance or node is farther than max-parent-distance-m from its parent station.",
|
|
493
|
+
"#stopstxt",
|
|
494
|
+
False,
|
|
495
|
+
),
|
|
496
|
+
# --- GL6xx: unused entities --------------------------------------------------------
|
|
497
|
+
(
|
|
498
|
+
"GL601",
|
|
499
|
+
"stop_without_stop_time",
|
|
500
|
+
_W,
|
|
501
|
+
"A stop (location_type 0) is never served by stop_times.",
|
|
502
|
+
"#stopstxt",
|
|
503
|
+
True,
|
|
504
|
+
),
|
|
505
|
+
("GL602", "unused_station", _I, "A station has no child stops.", "#stopstxt", True),
|
|
506
|
+
("GL603", "route_without_trips", _W, "A route has no trips.", "#routestxt", False),
|
|
507
|
+
("GL604", "unused_shape", _W, "A shape is not referenced by any trip.", "#shapestxt", True),
|
|
508
|
+
("GL605", "unused_trip", _W, "A trip has no stop_times.", "#tripstxt", True),
|
|
509
|
+
(
|
|
510
|
+
"GL606",
|
|
511
|
+
"unused_service",
|
|
512
|
+
_I,
|
|
513
|
+
"A service_id from calendar/calendar_dates is not used by any trip.",
|
|
514
|
+
"#calendartxt",
|
|
515
|
+
False,
|
|
516
|
+
),
|
|
517
|
+
# --- GL7xx: blocks and feed_info ---------------------------------------------------
|
|
518
|
+
(
|
|
519
|
+
"GL701",
|
|
520
|
+
"block_trips_with_overlapping_stop_times",
|
|
521
|
+
_E,
|
|
522
|
+
"Two trips with the same block_id run at overlapping times on a shared service date.",
|
|
523
|
+
"#tripstxt",
|
|
524
|
+
True,
|
|
525
|
+
),
|
|
526
|
+
(
|
|
527
|
+
"GL702",
|
|
528
|
+
"missing_feed_info_date",
|
|
529
|
+
_W,
|
|
530
|
+
"feed_info.txt has no feed_start_date or no feed_end_date.",
|
|
531
|
+
"#feed_infotxt",
|
|
532
|
+
True,
|
|
533
|
+
),
|
|
534
|
+
(
|
|
535
|
+
"GL703",
|
|
536
|
+
"feed_expiration_date7_days",
|
|
537
|
+
_W,
|
|
538
|
+
"feed_end_date is less than 7 days after the reference date (or already passed).",
|
|
539
|
+
"#feed_infotxt",
|
|
540
|
+
True,
|
|
541
|
+
),
|
|
542
|
+
(
|
|
543
|
+
"GL704",
|
|
544
|
+
"feed_expiration_date30_days",
|
|
545
|
+
_W,
|
|
546
|
+
"feed_end_date is between 7 and 30 days after the reference date.",
|
|
547
|
+
"#feed_infotxt",
|
|
548
|
+
True,
|
|
549
|
+
),
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
RULES: Dict[str, Rule] = {
|
|
553
|
+
code: Rule(code, name, sev, desc, _REF + anchor, name if md else None)
|
|
554
|
+
for code, name, sev, desc, anchor, md in _CATALOGUE
|
|
555
|
+
}
|
|
556
|
+
"""All rules, keyed by code, in catalogue order."""
|
|
557
|
+
|
|
558
|
+
_BY_NAME: Dict[str, str] = {rule.name: code for code, rule in RULES.items()}
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _matches(code: str, selector: str) -> bool:
|
|
562
|
+
sel = selector.strip()
|
|
563
|
+
if not sel:
|
|
564
|
+
return False
|
|
565
|
+
if sel.upper() == "ALL":
|
|
566
|
+
return True
|
|
567
|
+
if _BY_NAME.get(sel) == code:
|
|
568
|
+
return True
|
|
569
|
+
return code.startswith(sel.upper())
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def resolve_selection(
|
|
573
|
+
select: Optional[Iterable[str]] = None, ignore: Optional[Iterable[str]] = None
|
|
574
|
+
) -> frozenset:
|
|
575
|
+
"""Return the set of enabled rule codes.
|
|
576
|
+
|
|
577
|
+
``select`` and ``ignore`` accept full codes (``GL101``), code prefixes (``GL1``,
|
|
578
|
+
``GL``), rule names (``invalid_color``) or ``ALL``. ``select=None`` enables every
|
|
579
|
+
rule; ``ignore`` is applied afterwards. Unknown selectors raise :class:`ValueError`.
|
|
580
|
+
"""
|
|
581
|
+
sel = list(select) if select is not None else ["ALL"]
|
|
582
|
+
ign = list(ignore) if ignore is not None else []
|
|
583
|
+
for item in sel + ign:
|
|
584
|
+
if item.strip() and not any(_matches(code, item) for code in RULES):
|
|
585
|
+
raise ValueError(f"unknown rule selector {item!r}")
|
|
586
|
+
enabled = {c for c in RULES if any(_matches(c, s) for s in sel)}
|
|
587
|
+
enabled -= {c for c in RULES if any(_matches(c, s) for s in ign)}
|
|
588
|
+
return frozenset(enabled)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
@dataclass
|
|
592
|
+
class Notice:
|
|
593
|
+
"""One finding: a rule violated at a location in the feed.
|
|
594
|
+
|
|
595
|
+
``row`` is the 1-based physical line number in the file (the header is line 1).
|
|
596
|
+
"""
|
|
597
|
+
|
|
598
|
+
code: str
|
|
599
|
+
severity: Severity
|
|
600
|
+
message: str
|
|
601
|
+
file: Optional[str] = None
|
|
602
|
+
row: Optional[int] = None
|
|
603
|
+
field: Optional[str] = None
|
|
604
|
+
value: Optional[str] = None
|
|
605
|
+
context: Dict[str, str] = _dc_field(default_factory=dict)
|
|
606
|
+
|
|
607
|
+
@property
|
|
608
|
+
def name(self) -> str:
|
|
609
|
+
return RULES[self.code].name
|
|
610
|
+
|
|
611
|
+
@property
|
|
612
|
+
def rule(self) -> Rule:
|
|
613
|
+
return RULES[self.code]
|
|
614
|
+
|
|
615
|
+
@property
|
|
616
|
+
def location(self) -> str:
|
|
617
|
+
"""Human-readable location such as ``stops.txt:12 [stop_lat]``."""
|
|
618
|
+
out = self.file or "<feed>"
|
|
619
|
+
if self.row is not None:
|
|
620
|
+
out += f":{self.row}"
|
|
621
|
+
if self.field:
|
|
622
|
+
out += f" [{self.field}]"
|
|
623
|
+
return out
|
|
624
|
+
|
|
625
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
626
|
+
return {
|
|
627
|
+
"code": self.code,
|
|
628
|
+
"name": self.name,
|
|
629
|
+
"severity": self.severity.label,
|
|
630
|
+
"message": self.message,
|
|
631
|
+
"file": self.file,
|
|
632
|
+
"row": self.row,
|
|
633
|
+
"field": self.field,
|
|
634
|
+
"value": self.value,
|
|
635
|
+
"context": dict(self.context),
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
class Collector:
|
|
640
|
+
"""Stores notices, honouring the rule selection and a per-rule cap.
|
|
641
|
+
|
|
642
|
+
Counts always include every occurrence; only the first ``max_per_rule`` notices of
|
|
643
|
+
each rule are kept in memory.
|
|
644
|
+
"""
|
|
645
|
+
|
|
646
|
+
def __init__(self, enabled: frozenset, max_per_rule: int = 1000) -> None:
|
|
647
|
+
self.enabled = enabled
|
|
648
|
+
self.max_per_rule = max_per_rule
|
|
649
|
+
self.notices: List[Notice] = []
|
|
650
|
+
self.counts: Dict[str, int] = {}
|
|
651
|
+
|
|
652
|
+
def emit(
|
|
653
|
+
self,
|
|
654
|
+
code: str,
|
|
655
|
+
message: str,
|
|
656
|
+
file: Optional[str] = None,
|
|
657
|
+
row: Optional[int] = None,
|
|
658
|
+
field: Optional[str] = None,
|
|
659
|
+
value: Optional[str] = None,
|
|
660
|
+
**context: str,
|
|
661
|
+
) -> None:
|
|
662
|
+
if code not in self.enabled:
|
|
663
|
+
return
|
|
664
|
+
n = self.counts.get(code, 0) + 1
|
|
665
|
+
self.counts[code] = n
|
|
666
|
+
if self.max_per_rule <= 0 or n <= self.max_per_rule:
|
|
667
|
+
self.notices.append(
|
|
668
|
+
Notice(code, RULES[code].severity, message, file, row, field, value, context)
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
def discard(self, codes: Iterable[str], key: str, values: Iterable[str]) -> None:
|
|
672
|
+
"""Drop stored notices of ``codes`` whose ``context[key]`` is in ``values``."""
|
|
673
|
+
codes_set, vals = set(codes), set(values)
|
|
674
|
+
kept: List[Notice] = []
|
|
675
|
+
for n in self.notices:
|
|
676
|
+
if n.code in codes_set and n.context.get(key) in vals:
|
|
677
|
+
self.counts[n.code] -= 1
|
|
678
|
+
if not self.counts[n.code]:
|
|
679
|
+
del self.counts[n.code]
|
|
680
|
+
else:
|
|
681
|
+
kept.append(n)
|
|
682
|
+
self.notices = kept
|
gtfslint/py.typed
ADDED
|
File without changes
|