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 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
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m gtfslint``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__": # pragma: no cover
6
+ raise SystemExit(main())
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)