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/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""gtfslint: a fast, pure-Python GTFS Schedule validator and quality report for CI.
|
|
2
|
+
|
|
3
|
+
>>> import gtfslint
|
|
4
|
+
>>> report = gtfslint.validate("feed.zip") # doctest: +SKIP
|
|
5
|
+
>>> report.errors, report.warnings # doctest: +SKIP
|
|
6
|
+
>>> gtfslint.stats("feed.zip").trips_per_day # doctest: +SKIP
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .api import stats, validate
|
|
10
|
+
from .config import Config, load_config
|
|
11
|
+
from .notices import RULES, Notice, Rule, Severity
|
|
12
|
+
from .reader import FeedError
|
|
13
|
+
from .report import Report
|
|
14
|
+
from .stats import FeedStats, RouteHeadway
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"RULES",
|
|
20
|
+
"Config",
|
|
21
|
+
"FeedError",
|
|
22
|
+
"FeedStats",
|
|
23
|
+
"Notice",
|
|
24
|
+
"Report",
|
|
25
|
+
"RouteHeadway",
|
|
26
|
+
"Rule",
|
|
27
|
+
"Severity",
|
|
28
|
+
"__version__",
|
|
29
|
+
"load_config",
|
|
30
|
+
"stats",
|
|
31
|
+
"validate",
|
|
32
|
+
]
|
gtfslint/__main__.py
ADDED
gtfslint/api.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Public entry points: :func:`validate` and :func:`stats`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as _dt
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Iterable, Optional, Union
|
|
10
|
+
|
|
11
|
+
from .config import Config, parse_reference_date
|
|
12
|
+
from .reader import FeedSource
|
|
13
|
+
from .report import Report
|
|
14
|
+
from .stats import FeedStats, compute_stats
|
|
15
|
+
from .validator import Engine
|
|
16
|
+
|
|
17
|
+
__all__ = ["stats", "validate"]
|
|
18
|
+
|
|
19
|
+
PathLike = Union[str, "os.PathLike[str]"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate(
|
|
23
|
+
path: PathLike,
|
|
24
|
+
*,
|
|
25
|
+
config: Optional[Config] = None,
|
|
26
|
+
select: Optional[Iterable[str]] = None,
|
|
27
|
+
ignore: Optional[Iterable[str]] = None,
|
|
28
|
+
reference_date: Union[str, _dt.date, None] = None,
|
|
29
|
+
with_stats: bool = False,
|
|
30
|
+
) -> Report:
|
|
31
|
+
"""Validate a GTFS Schedule feed (``.zip`` or folder) and return a :class:`Report`.
|
|
32
|
+
|
|
33
|
+
``select``/``ignore`` take rule codes, code prefixes or rule names and override the
|
|
34
|
+
corresponding ``config`` settings. ``reference_date`` (default: today) anchors the
|
|
35
|
+
expiration rules. Raises :class:`gtfslint.FeedError` when the path cannot be read.
|
|
36
|
+
"""
|
|
37
|
+
cfg = (config or Config()).replace(
|
|
38
|
+
select=list(select) if select is not None else None,
|
|
39
|
+
ignore=list(ignore) if ignore is not None else None,
|
|
40
|
+
reference_date=parse_reference_date(reference_date),
|
|
41
|
+
)
|
|
42
|
+
from . import __version__
|
|
43
|
+
|
|
44
|
+
t0 = time.perf_counter()
|
|
45
|
+
with FeedSource(path) as src:
|
|
46
|
+
engine = Engine(src, cfg).run()
|
|
47
|
+
report = Report(
|
|
48
|
+
feed=Path(path).as_posix(),
|
|
49
|
+
notices=engine.out.notices,
|
|
50
|
+
counts=dict(engine.out.counts),
|
|
51
|
+
feed_kind=src.kind,
|
|
52
|
+
files=engine.ctx.files,
|
|
53
|
+
row_counts=engine.ctx.row_counts,
|
|
54
|
+
reference_date=engine.ref_date.isoformat(),
|
|
55
|
+
version=__version__,
|
|
56
|
+
)
|
|
57
|
+
if with_stats:
|
|
58
|
+
report.stats = compute_stats(engine.ctx, report.feed)
|
|
59
|
+
report.duration_s = time.perf_counter() - t0
|
|
60
|
+
return report
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def stats(path: PathLike, *, date: Union[str, _dt.date, None] = None) -> FeedStats:
|
|
64
|
+
"""Compute quality metrics for a feed without collecting notices.
|
|
65
|
+
|
|
66
|
+
``date`` (``YYYY-MM-DD``) selects the day used for headways and vehicle hours; by
|
|
67
|
+
default the busiest service date is used.
|
|
68
|
+
"""
|
|
69
|
+
with FeedSource(path) as src:
|
|
70
|
+
engine = Engine(src, Config(), collect=False).run()
|
|
71
|
+
return compute_stats(engine.ctx, Path(path).as_posix(), parse_reference_date(date))
|
gtfslint/cli.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Command line interface: ``gtfslint check``, ``gtfslint stats`` and ``gtfslint rules``.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 = no notice at or above ``--fail-on``; 1 = at least one such notice;
|
|
4
|
+
2 = usage error, invalid configuration or unreadable feed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .api import stats as compute_feed_stats
|
|
17
|
+
from .api import validate
|
|
18
|
+
from .config import FAIL_ON_CHOICES, Config, load_config
|
|
19
|
+
from .notices import RULES
|
|
20
|
+
from .reader import FeedError
|
|
21
|
+
from .report import FORMATS, stats_to_html, stats_to_markdown, stats_to_text
|
|
22
|
+
|
|
23
|
+
EXIT_OK, EXIT_FAIL, EXIT_USAGE = 0, 1, 2
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _csv_list(value: str) -> List[str]:
|
|
27
|
+
return [v.strip() for v in value.split(",") if v.strip()]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
31
|
+
parser = argparse.ArgumentParser(
|
|
32
|
+
prog="gtfslint",
|
|
33
|
+
description="Fast GTFS Schedule validator and quality report.",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument("--version", action="version", version=f"gtfslint {__version__}")
|
|
36
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
37
|
+
|
|
38
|
+
check = sub.add_parser("check", help="validate one or more feeds (.zip or folder)")
|
|
39
|
+
check.add_argument("feeds", nargs="+", help="GTFS .zip archive(s) or folder(s)")
|
|
40
|
+
check.add_argument("-f", "--format", choices=FORMATS, default="text", help="stdout format")
|
|
41
|
+
check.add_argument("-o", "--output", help="write the --format output to this file")
|
|
42
|
+
check.add_argument("--json", dest="json_out", metavar="FILE", help="also write a JSON report")
|
|
43
|
+
check.add_argument("--markdown", metavar="FILE", help="also write a Markdown report")
|
|
44
|
+
check.add_argument("--html", metavar="FILE", help="also write a self-contained HTML report")
|
|
45
|
+
check.add_argument("--select", type=_csv_list, help="rules to enable (codes, prefixes, names)")
|
|
46
|
+
check.add_argument("--ignore", type=_csv_list, help="rules to disable (codes, prefixes, names)")
|
|
47
|
+
check.add_argument("--fail-on", choices=FAIL_ON_CHOICES, help="lowest severity that fails")
|
|
48
|
+
check.add_argument("--reference-date", help="date for expiration rules (default: today)")
|
|
49
|
+
check.add_argument("--max-notices-per-rule", type=int, help="notices kept per rule")
|
|
50
|
+
check.add_argument("--stats", action="store_true", help="include feed statistics")
|
|
51
|
+
check.add_argument("-q", "--quiet", action="store_true", help="print only the summary line")
|
|
52
|
+
_config_args(check)
|
|
53
|
+
|
|
54
|
+
st = sub.add_parser("stats", help="print quality metrics of a feed")
|
|
55
|
+
st.add_argument("feed", help="GTFS .zip archive or folder")
|
|
56
|
+
st.add_argument("-f", "--format", choices=("text", "json", "markdown", "html"), default="text")
|
|
57
|
+
st.add_argument("-o", "--output", help="write the --format output to this file")
|
|
58
|
+
st.add_argument("--json", dest="json_out", metavar="FILE", help="also write JSON")
|
|
59
|
+
st.add_argument("--markdown", metavar="FILE", help="also write Markdown")
|
|
60
|
+
st.add_argument("--html", metavar="FILE", help="also write a self-contained HTML page")
|
|
61
|
+
st.add_argument("--date", help="analysis date YYYY-MM-DD (default: busiest date)")
|
|
62
|
+
|
|
63
|
+
rules = sub.add_parser("rules", help="list every rule")
|
|
64
|
+
rules.add_argument("-f", "--format", choices=("text", "json", "markdown"), default="text")
|
|
65
|
+
return parser
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _config_args(p: argparse.ArgumentParser) -> None:
|
|
69
|
+
p.add_argument("--config", help="pyproject.toml to read [tool.gtfslint] from")
|
|
70
|
+
p.add_argument("--no-config", action="store_true", help="ignore pyproject.toml")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _write(path: Optional[str], text: str) -> None:
|
|
74
|
+
if path:
|
|
75
|
+
Path(path).write_text(text, encoding="utf-8")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _emit(text: str) -> None:
|
|
79
|
+
text = text if text.endswith("\n") else text + "\n"
|
|
80
|
+
try:
|
|
81
|
+
sys.stdout.write(text)
|
|
82
|
+
except UnicodeEncodeError: # legacy console code pages
|
|
83
|
+
encoding = sys.stdout.encoding or "utf-8"
|
|
84
|
+
sys.stdout.write(text.encode(encoding, "replace").decode(encoding))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _cmd_check(args: argparse.Namespace) -> int:
|
|
88
|
+
cfg = Config() if args.no_config else load_config(args.config)
|
|
89
|
+
cfg = cfg.replace(
|
|
90
|
+
select=args.select,
|
|
91
|
+
ignore=args.ignore,
|
|
92
|
+
fail_on=args.fail_on,
|
|
93
|
+
reference_date=args.reference_date,
|
|
94
|
+
max_notices_per_rule=args.max_notices_per_rule,
|
|
95
|
+
)
|
|
96
|
+
files = [args.output, args.json_out, args.markdown, args.html]
|
|
97
|
+
if len(args.feeds) > 1 and any(files):
|
|
98
|
+
print("gtfslint: file outputs require a single feed", file=sys.stderr)
|
|
99
|
+
return EXIT_USAGE
|
|
100
|
+
worst = EXIT_OK
|
|
101
|
+
for feed in args.feeds:
|
|
102
|
+
try:
|
|
103
|
+
report = validate(feed, config=cfg, with_stats=args.stats)
|
|
104
|
+
except FeedError as exc:
|
|
105
|
+
print(f"gtfslint: {exc}", file=sys.stderr)
|
|
106
|
+
return EXIT_USAGE
|
|
107
|
+
text = report.render(args.format)
|
|
108
|
+
if args.output:
|
|
109
|
+
_write(args.output, text)
|
|
110
|
+
elif args.quiet:
|
|
111
|
+
_emit(f"{'FAILED' if not report.ok else 'OK'}: {report.summary()}")
|
|
112
|
+
else:
|
|
113
|
+
_emit(text)
|
|
114
|
+
_write(args.json_out, report.to_json())
|
|
115
|
+
_write(args.markdown, report.to_markdown())
|
|
116
|
+
_write(args.html, report.to_html())
|
|
117
|
+
worst = max(worst, report.exit_code(cfg.fail_on))
|
|
118
|
+
return worst
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _cmd_stats(args: argparse.Namespace) -> int:
|
|
122
|
+
try:
|
|
123
|
+
st = compute_feed_stats(args.feed, date=args.date)
|
|
124
|
+
except FeedError as exc:
|
|
125
|
+
print(f"gtfslint: {exc}", file=sys.stderr)
|
|
126
|
+
return EXIT_USAGE
|
|
127
|
+
renderers = {
|
|
128
|
+
"text": lambda: stats_to_text(st),
|
|
129
|
+
"json": lambda: json.dumps(st.to_dict(), indent=2, ensure_ascii=False),
|
|
130
|
+
"markdown": lambda: stats_to_markdown(st),
|
|
131
|
+
"html": lambda: stats_to_html(st),
|
|
132
|
+
}
|
|
133
|
+
text = renderers[args.format]()
|
|
134
|
+
if args.output:
|
|
135
|
+
_write(args.output, text)
|
|
136
|
+
else:
|
|
137
|
+
_emit(text)
|
|
138
|
+
if args.json_out:
|
|
139
|
+
_write(args.json_out, renderers["json"]())
|
|
140
|
+
if args.markdown:
|
|
141
|
+
_write(args.markdown, renderers["markdown"]())
|
|
142
|
+
if args.html:
|
|
143
|
+
_write(args.html, renderers["html"]())
|
|
144
|
+
return EXIT_OK
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _cmd_rules(args: argparse.Namespace) -> int:
|
|
148
|
+
if args.format == "json":
|
|
149
|
+
_emit(json.dumps([r.to_dict() for r in RULES.values()], indent=2))
|
|
150
|
+
elif args.format == "markdown":
|
|
151
|
+
lines = ["| Code | Name | Severity | Description |", "|---|---|---|---|"]
|
|
152
|
+
lines += [
|
|
153
|
+
f"| [{r.code}]({r.reference}) | `{r.name}` | {r.severity.label} | {r.description} |"
|
|
154
|
+
for r in RULES.values()
|
|
155
|
+
]
|
|
156
|
+
_emit("\n".join(lines))
|
|
157
|
+
else:
|
|
158
|
+
_emit("\n".join(f"{r.code} {r.severity.label:<7} {r.name}" for r in RULES.values()))
|
|
159
|
+
return EXIT_OK
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
163
|
+
"""Run the CLI and return its exit code."""
|
|
164
|
+
parser = _build_parser()
|
|
165
|
+
args = parser.parse_args(argv)
|
|
166
|
+
try:
|
|
167
|
+
if args.command == "check":
|
|
168
|
+
return _cmd_check(args)
|
|
169
|
+
if args.command == "stats":
|
|
170
|
+
return _cmd_stats(args)
|
|
171
|
+
return _cmd_rules(args)
|
|
172
|
+
except ValueError as exc:
|
|
173
|
+
print(f"gtfslint: {exc}", file=sys.stderr)
|
|
174
|
+
return EXIT_USAGE
|
|
175
|
+
except OSError as exc:
|
|
176
|
+
print(f"gtfslint: {exc}", file=sys.stderr)
|
|
177
|
+
return EXIT_USAGE
|
gtfslint/config.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Configuration: defaults, ``[tool.gtfslint]`` in ``pyproject.toml`` and overrides."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import datetime as _dt
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, Mapping, Optional, Union
|
|
11
|
+
|
|
12
|
+
from .notices import Severity, resolve_selection
|
|
13
|
+
|
|
14
|
+
if sys.version_info >= (3, 11): # pragma: no cover - version dependent
|
|
15
|
+
import tomllib as _toml
|
|
16
|
+
else: # pragma: no cover - version dependent
|
|
17
|
+
import tomli as _toml
|
|
18
|
+
|
|
19
|
+
__all__ = ["Config", "find_pyproject", "load_config", "parse_reference_date"]
|
|
20
|
+
|
|
21
|
+
FAIL_ON_CHOICES = ("error", "warning", "info", "never")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_reference_date(value: Union[str, _dt.date, None]) -> Optional[_dt.date]:
|
|
25
|
+
"""Accept ``YYYY-MM-DD``, ``YYYYMMDD`` or a :class:`datetime.date`."""
|
|
26
|
+
if value is None or isinstance(value, _dt.date):
|
|
27
|
+
return value
|
|
28
|
+
text = str(value).strip()
|
|
29
|
+
for fmt in ("%Y-%m-%d", "%Y%m%d"):
|
|
30
|
+
try:
|
|
31
|
+
return _dt.datetime.strptime(text, fmt).date()
|
|
32
|
+
except ValueError:
|
|
33
|
+
continue
|
|
34
|
+
raise ValueError(f"invalid date {value!r}; expected YYYY-MM-DD")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Config:
|
|
39
|
+
"""Validator settings. Every field can be set in ``[tool.gtfslint]``.
|
|
40
|
+
|
|
41
|
+
TOML keys use dashes (``fail-on``); underscores are accepted too.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
select: Optional[List[str]] = None
|
|
45
|
+
ignore: List[str] = field(default_factory=list)
|
|
46
|
+
fail_on: str = "error"
|
|
47
|
+
max_notices_per_rule: int = 1000
|
|
48
|
+
reference_date: Optional[_dt.date] = None
|
|
49
|
+
min_stop_distance_m: float = 2.0
|
|
50
|
+
max_parent_distance_m: float = 1000.0
|
|
51
|
+
max_stop_distance_km: Optional[float] = None
|
|
52
|
+
speed_limits_kmh: Dict[int, float] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
def __post_init__(self) -> None:
|
|
55
|
+
if isinstance(self.select, str):
|
|
56
|
+
self.select = [s for s in self.select.split(",") if s.strip()]
|
|
57
|
+
if isinstance(self.ignore, str):
|
|
58
|
+
self.ignore = [s for s in self.ignore.split(",") if s.strip()]
|
|
59
|
+
if self.fail_on not in FAIL_ON_CHOICES:
|
|
60
|
+
raise ValueError(f"fail-on must be one of {', '.join(FAIL_ON_CHOICES)}")
|
|
61
|
+
self.reference_date = parse_reference_date(self.reference_date)
|
|
62
|
+
self.speed_limits_kmh = {int(k): float(v) for k, v in self.speed_limits_kmh.items()}
|
|
63
|
+
resolve_selection(self.select, self.ignore) # validates selectors early
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def enabled_rules(self) -> frozenset:
|
|
67
|
+
return resolve_selection(self.select, self.ignore)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def fail_threshold(self) -> Optional[Severity]:
|
|
71
|
+
return None if self.fail_on == "never" else Severity.parse(self.fail_on)
|
|
72
|
+
|
|
73
|
+
def replace(self, **changes: Any) -> Config:
|
|
74
|
+
"""Return a copy with the non-``None`` ``changes`` applied."""
|
|
75
|
+
clean = {k: v for k, v in changes.items() if v is not None}
|
|
76
|
+
return dataclasses.replace(self, **clean)
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_mapping(cls, data: Mapping[str, Any]) -> Config:
|
|
80
|
+
"""Build a config from a ``[tool.gtfslint]`` table; unknown keys raise."""
|
|
81
|
+
names = {f.name for f in dataclasses.fields(cls)}
|
|
82
|
+
kwargs: Dict[str, Any] = {}
|
|
83
|
+
for key, value in data.items():
|
|
84
|
+
attr = key.replace("-", "_")
|
|
85
|
+
if attr not in names:
|
|
86
|
+
raise ValueError(f"unknown [tool.gtfslint] option {key!r}")
|
|
87
|
+
kwargs[attr] = value
|
|
88
|
+
return cls(**kwargs)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def find_pyproject(start: Union[str, Path, None] = None) -> Optional[Path]:
|
|
92
|
+
"""Return the nearest ``pyproject.toml`` at or above ``start`` (default: cwd)."""
|
|
93
|
+
here = Path(start or Path.cwd()).resolve()
|
|
94
|
+
for folder in (here, *here.parents):
|
|
95
|
+
candidate = folder / "pyproject.toml"
|
|
96
|
+
if candidate.is_file():
|
|
97
|
+
return candidate
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_config(path: Union[str, Path, None] = None) -> Config:
|
|
102
|
+
"""Load ``[tool.gtfslint]`` from ``path`` (or the nearest ``pyproject.toml``).
|
|
103
|
+
|
|
104
|
+
Returns default settings when there is no file or no ``[tool.gtfslint]`` table.
|
|
105
|
+
"""
|
|
106
|
+
target = Path(path) if path is not None else find_pyproject()
|
|
107
|
+
if target is None:
|
|
108
|
+
return Config()
|
|
109
|
+
with open(target, "rb") as fh:
|
|
110
|
+
data = _toml.load(fh)
|
|
111
|
+
table = data.get("tool", {}).get("gtfslint", {})
|
|
112
|
+
return Config.from_mapping(table)
|
gtfslint/fieldtypes.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Parsers and validators for GTFS field types (colors, URLs, dates, times, ...)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as _dt
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from typing import FrozenSet, Optional
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"contrast_ratio",
|
|
14
|
+
"is_color",
|
|
15
|
+
"is_currency",
|
|
16
|
+
"is_email",
|
|
17
|
+
"is_language",
|
|
18
|
+
"is_timezone",
|
|
19
|
+
"is_url",
|
|
20
|
+
"parse_date",
|
|
21
|
+
"parse_float",
|
|
22
|
+
"parse_int",
|
|
23
|
+
"parse_time",
|
|
24
|
+
"format_time",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
_COLOR_RE = re.compile(r"[0-9A-Fa-f]{6}\Z")
|
|
28
|
+
_TIME_RE = re.compile(r"(\d{1,3}):([0-5]\d):([0-5]\d)\Z")
|
|
29
|
+
_INT_RE = re.compile(r"[+-]?\d+\Z")
|
|
30
|
+
_EMAIL_RE = re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+\Z")
|
|
31
|
+
_LANG_RE = re.compile(r"(?:[A-Za-z]{2,3}|[A-Za-z]{5,8})(?:-[A-Za-z0-9]{1,8})*\Z")
|
|
32
|
+
_TZ_RE = re.compile(r"(?:UTC|GMT|[A-Z][A-Za-z_\-]+(?:/[A-Za-z0-9_+\-]+){1,2})\Z")
|
|
33
|
+
|
|
34
|
+
# ISO 4217 alphabetic codes (active codes plus funds and precious metals).
|
|
35
|
+
CURRENCIES: FrozenSet[str] = frozenset(
|
|
36
|
+
"""
|
|
37
|
+
AED AFN ALL AMD ANG AOA ARS AUD AWG AZN BAM BBD BDT BGN BHD BIF BMD BND BOB BOV BRL
|
|
38
|
+
BSD BTN BWP BYN BZD CAD CDF CHE CHF CHW CLF CLP CNY COP COU CRC CUC CUP CVE CZK DJF
|
|
39
|
+
DKK DOP DZD EGP ERN ETB EUR FJD FKP GBP GEL GHS GIP GMD GNF GTQ GYD HKD HNL HRK HTG
|
|
40
|
+
HUF IDR ILS INR IQD IRR ISK JMD JOD JPY KES KGS KHR KMF KPW KRW KWD KYD KZT LAK LBP
|
|
41
|
+
LKR LRD LSL LYD MAD MDL MGA MKD MMK MNT MOP MRU MUR MVR MWK MXN MXV MYR MZN NAD NGN
|
|
42
|
+
NIO NOK NPR NZD OMR PAB PEN PGK PHP PKR PLN PYG QAR RON RSD RUB RWF SAR SBD SCR SDG
|
|
43
|
+
SEK SGD SHP SLE SLL SOS SRD SSP STN SVC SYP SZL THB TJS TMT TND TOP TRY TTD TWD TZS
|
|
44
|
+
UAH UGX USD USN UYI UYU UYW UZS VED VES VND VUV WST XAF XAG XAU XBA XBB XBC XBD XCD
|
|
45
|
+
XCG XDR XOF XPD XPF XPT XSU XTS XUA XXX YER ZAR ZMW ZWG ZWL
|
|
46
|
+
""".split()
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_int(value: str) -> Optional[int]:
|
|
51
|
+
"""Parse a GTFS integer; return ``None`` when malformed."""
|
|
52
|
+
if _INT_RE.match(value):
|
|
53
|
+
return int(value)
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_float(value: str) -> Optional[float]:
|
|
58
|
+
"""Parse a finite GTFS float; return ``None`` when malformed, NaN or infinite."""
|
|
59
|
+
try:
|
|
60
|
+
out = float(value)
|
|
61
|
+
except ValueError:
|
|
62
|
+
return None
|
|
63
|
+
if math.isnan(out) or math.isinf(out) or "_" in value:
|
|
64
|
+
return None
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_date(value: str) -> Optional[_dt.date]:
|
|
69
|
+
"""Parse ``YYYYMMDD``; return ``None`` when malformed or not a real date."""
|
|
70
|
+
if len(value) != 8 or not value.isdigit():
|
|
71
|
+
return None
|
|
72
|
+
try:
|
|
73
|
+
return _dt.date(int(value[:4]), int(value[4:6]), int(value[6:]))
|
|
74
|
+
except ValueError:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@lru_cache(maxsize=200_000)
|
|
79
|
+
def parse_time(value: str) -> Optional[int]:
|
|
80
|
+
"""Parse ``H:MM:SS``/``HH:MM:SS`` into seconds since midnight of the service day.
|
|
81
|
+
|
|
82
|
+
Hours above 23 are valid (trips running past midnight). Returns ``None`` when the
|
|
83
|
+
value is malformed.
|
|
84
|
+
"""
|
|
85
|
+
m = _TIME_RE.match(value)
|
|
86
|
+
if m is None:
|
|
87
|
+
return None
|
|
88
|
+
return int(m.group(1)) * 3600 + int(m.group(2)) * 60 + int(m.group(3))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def format_time(seconds: int) -> str:
|
|
92
|
+
"""Format seconds since midnight as ``HH:MM:SS`` (hours may exceed 23)."""
|
|
93
|
+
h, rem = divmod(int(seconds), 3600)
|
|
94
|
+
m, s = divmod(rem, 60)
|
|
95
|
+
return f"{h:02d}:{m:02d}:{s:02d}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def is_color(value: str) -> bool:
|
|
99
|
+
return _COLOR_RE.match(value) is not None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def is_url(value: str) -> bool:
|
|
103
|
+
"""True for a fully qualified http(s) URL with a host and no whitespace."""
|
|
104
|
+
if not value or any(c.isspace() for c in value):
|
|
105
|
+
return False
|
|
106
|
+
try:
|
|
107
|
+
parts = urlsplit(value)
|
|
108
|
+
host = parts.hostname
|
|
109
|
+
except ValueError:
|
|
110
|
+
return False
|
|
111
|
+
if parts.scheme.lower() not in ("http", "https") or not host:
|
|
112
|
+
return False
|
|
113
|
+
return "." in host or host == "localhost" or ":" in host
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def is_email(value: str) -> bool:
|
|
117
|
+
return _EMAIL_RE.match(value) is not None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def is_language(value: str) -> bool:
|
|
121
|
+
"""Well-formedness check for an IETF BCP 47 language tag."""
|
|
122
|
+
return _LANG_RE.match(value) is not None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_currency(value: str) -> bool:
|
|
126
|
+
return value in CURRENCIES
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@lru_cache(maxsize=1)
|
|
130
|
+
def _timezones() -> FrozenSet[str]:
|
|
131
|
+
try:
|
|
132
|
+
import zoneinfo
|
|
133
|
+
|
|
134
|
+
return frozenset(zoneinfo.available_timezones())
|
|
135
|
+
except Exception: # pragma: no cover - Python without zoneinfo data
|
|
136
|
+
return frozenset()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@lru_cache(maxsize=4096)
|
|
140
|
+
def is_timezone(value: str) -> bool:
|
|
141
|
+
"""True for an IANA TZ database name.
|
|
142
|
+
|
|
143
|
+
Uses the system/``tzdata`` database when available and falls back to a syntactic
|
|
144
|
+
check (``Area/Location``) otherwise.
|
|
145
|
+
"""
|
|
146
|
+
zones = _timezones()
|
|
147
|
+
if zones:
|
|
148
|
+
return value in zones
|
|
149
|
+
return _TZ_RE.match(value) is not None # pragma: no cover
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _luminance(color: str) -> float:
|
|
153
|
+
def channel(c: int) -> float:
|
|
154
|
+
x = c / 255.0
|
|
155
|
+
return x / 12.92 if x <= 0.03928 else ((x + 0.055) / 1.055) ** 2.4
|
|
156
|
+
|
|
157
|
+
r, g, b = (int(color[i : i + 2], 16) for i in (0, 2, 4))
|
|
158
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def contrast_ratio(color_a: str, color_b: str) -> float:
|
|
162
|
+
"""WCAG 2 contrast ratio between two six-digit hex colors (1.0 to 21.0)."""
|
|
163
|
+
la, lb = _luminance(color_a), _luminance(color_b)
|
|
164
|
+
hi, lo = max(la, lb), min(la, lb)
|
|
165
|
+
return (hi + 0.05) / (lo + 0.05)
|