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/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