gbfs-validator 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.
- gbfs_validator/__init__.py +12 -0
- gbfs_validator/cli.py +168 -0
- gbfs_validator/conditionals.py +162 -0
- gbfs_validator/constants/__init__.py +0 -0
- gbfs_validator/constants/error_ids.py +56 -0
- gbfs_validator/data/PIN +2 -0
- gbfs_validator/data/schemas/v1.0/free_bike_status.json +60 -0
- gbfs_validator/data/schemas/v1.0/gbfs.json +56 -0
- gbfs_validator/data/schemas/v1.0/station_information.json +98 -0
- gbfs_validator/data/schemas/v1.0/station_status.json +84 -0
- gbfs_validator/data/schemas/v1.0/system_alerts.json +101 -0
- gbfs_validator/data/schemas/v1.0/system_calendar.json +69 -0
- gbfs_validator/data/schemas/v1.0/system_hours.json +62 -0
- gbfs_validator/data/schemas/v1.0/system_information.json +77 -0
- gbfs_validator/data/schemas/v1.0/system_pricing_plans.json +73 -0
- gbfs_validator/data/schemas/v1.0/system_regions.json +45 -0
- gbfs_validator/data/schemas/v1.1/free_bike_status.json +99 -0
- gbfs_validator/data/schemas/v1.1/gbfs.json +82 -0
- gbfs_validator/data/schemas/v1.1/gbfs_versions.json +67 -0
- gbfs_validator/data/schemas/v1.1/station_information.json +137 -0
- gbfs_validator/data/schemas/v1.1/station_status.json +105 -0
- gbfs_validator/data/schemas/v1.1/system_alerts.json +116 -0
- gbfs_validator/data/schemas/v1.1/system_calendar.json +78 -0
- gbfs_validator/data/schemas/v1.1/system_hours.json +77 -0
- gbfs_validator/data/schemas/v1.1/system_information.json +148 -0
- gbfs_validator/data/schemas/v1.1/system_pricing_plans.json +89 -0
- gbfs_validator/data/schemas/v1.1/system_regions.json +53 -0
- gbfs_validator/data/schemas/v2.0/free_bike_status.json +95 -0
- gbfs_validator/data/schemas/v2.0/gbfs.json +82 -0
- gbfs_validator/data/schemas/v2.0/gbfs_versions.json +67 -0
- gbfs_validator/data/schemas/v2.0/station_information.json +137 -0
- gbfs_validator/data/schemas/v2.0/station_status.json +98 -0
- gbfs_validator/data/schemas/v2.0/system_alerts.json +116 -0
- gbfs_validator/data/schemas/v2.0/system_calendar.json +78 -0
- gbfs_validator/data/schemas/v2.0/system_hours.json +77 -0
- gbfs_validator/data/schemas/v2.0/system_information.json +747 -0
- gbfs_validator/data/schemas/v2.0/system_pricing_plans.json +87 -0
- gbfs_validator/data/schemas/v2.0/system_regions.json +53 -0
- gbfs_validator/data/schemas/v2.1/free_bike_status.json +135 -0
- gbfs_validator/data/schemas/v2.1/gbfs.json +84 -0
- gbfs_validator/data/schemas/v2.1/gbfs_versions.json +67 -0
- gbfs_validator/data/schemas/v2.1/geofencing_zones.json +151 -0
- gbfs_validator/data/schemas/v2.1/station_information.json +192 -0
- gbfs_validator/data/schemas/v2.1/station_status.json +145 -0
- gbfs_validator/data/schemas/v2.1/system_alerts.json +116 -0
- gbfs_validator/data/schemas/v2.1/system_calendar.json +78 -0
- gbfs_validator/data/schemas/v2.1/system_hours.json +77 -0
- gbfs_validator/data/schemas/v2.1/system_information.json +747 -0
- gbfs_validator/data/schemas/v2.1/system_pricing_plans.json +87 -0
- gbfs_validator/data/schemas/v2.1/system_regions.json +53 -0
- gbfs_validator/data/schemas/v2.1/vehicle_types.json +82 -0
- gbfs_validator/data/schemas/v2.2/free_bike_status.json +140 -0
- gbfs_validator/data/schemas/v2.2/gbfs.json +84 -0
- gbfs_validator/data/schemas/v2.2/gbfs_versions.json +67 -0
- gbfs_validator/data/schemas/v2.2/geofencing_zones.json +151 -0
- gbfs_validator/data/schemas/v2.2/station_information.json +192 -0
- gbfs_validator/data/schemas/v2.2/station_status.json +145 -0
- gbfs_validator/data/schemas/v2.2/system_alerts.json +116 -0
- gbfs_validator/data/schemas/v2.2/system_calendar.json +78 -0
- gbfs_validator/data/schemas/v2.2/system_hours.json +77 -0
- gbfs_validator/data/schemas/v2.2/system_information.json +747 -0
- gbfs_validator/data/schemas/v2.2/system_pricing_plans.json +160 -0
- gbfs_validator/data/schemas/v2.2/system_regions.json +53 -0
- gbfs_validator/data/schemas/v2.2/vehicle_types.json +82 -0
- gbfs_validator/data/schemas/v2.3/free_bike_status.json +153 -0
- gbfs_validator/data/schemas/v2.3/gbfs.json +84 -0
- gbfs_validator/data/schemas/v2.3/gbfs_versions.json +67 -0
- gbfs_validator/data/schemas/v2.3/geofencing_zones.json +145 -0
- gbfs_validator/data/schemas/v2.3/station_information.json +216 -0
- gbfs_validator/data/schemas/v2.3/station_status.json +145 -0
- gbfs_validator/data/schemas/v2.3/system_alerts.json +116 -0
- gbfs_validator/data/schemas/v2.3/system_calendar.json +78 -0
- gbfs_validator/data/schemas/v2.3/system_hours.json +77 -0
- gbfs_validator/data/schemas/v2.3/system_information.json +808 -0
- gbfs_validator/data/schemas/v2.3/system_pricing_plans.json +160 -0
- gbfs_validator/data/schemas/v2.3/system_regions.json +53 -0
- gbfs_validator/data/schemas/v2.3/vehicle_types.json +204 -0
- gbfs_validator/data/schemas/v3.0/gbfs.json +69 -0
- gbfs_validator/data/schemas/v3.0/gbfs_versions.json +60 -0
- gbfs_validator/data/schemas/v3.0/geofencing_zones.json +202 -0
- gbfs_validator/data/schemas/v3.0/manifest.json +80 -0
- gbfs_validator/data/schemas/v3.0/station_information.json +278 -0
- gbfs_validator/data/schemas/v3.0/station_status.json +145 -0
- gbfs_validator/data/schemas/v3.0/system_alerts.json +161 -0
- gbfs_validator/data/schemas/v3.0/system_information.json +1451 -0
- gbfs_validator/data/schemas/v3.0/system_pricing_plans.json +191 -0
- gbfs_validator/data/schemas/v3.0/system_regions.json +68 -0
- gbfs_validator/data/schemas/v3.0/vehicle_status.json +153 -0
- gbfs_validator/data/schemas/v3.0/vehicle_types.json +268 -0
- gbfs_validator/data/schemas/v3.1-RC3/gbfs.json +70 -0
- gbfs_validator/data/schemas/v3.1-RC3/gbfs_versions.json +61 -0
- gbfs_validator/data/schemas/v3.1-RC3/geofencing_zones.json +202 -0
- gbfs_validator/data/schemas/v3.1-RC3/manifest.json +114 -0
- gbfs_validator/data/schemas/v3.1-RC3/station_information.json +282 -0
- gbfs_validator/data/schemas/v3.1-RC3/station_status.json +145 -0
- gbfs_validator/data/schemas/v3.1-RC3/system_alerts.json +161 -0
- gbfs_validator/data/schemas/v3.1-RC3/system_information.json +1451 -0
- gbfs_validator/data/schemas/v3.1-RC3/system_pricing_plans.json +222 -0
- gbfs_validator/data/schemas/v3.1-RC3/system_regions.json +68 -0
- gbfs_validator/data/schemas/v3.1-RC3/vehicle_availability.json +91 -0
- gbfs_validator/data/schemas/v3.1-RC3/vehicle_status.json +153 -0
- gbfs_validator/data/schemas/v3.1-RC3/vehicle_types.json +273 -0
- gbfs_validator/discovery.py +43 -0
- gbfs_validator/feed.py +281 -0
- gbfs_validator/feedhelpers.py +168 -0
- gbfs_validator/fetch.py +149 -0
- gbfs_validator/jstruth.py +16 -0
- gbfs_validator/jsvalues.py +79 -0
- gbfs_validator/partials.py +287 -0
- gbfs_validator/results.py +71 -0
- gbfs_validator/schema/__init__.py +0 -0
- gbfs_validator/schema/applicators.py +235 -0
- gbfs_validator/schema/engine.py +63 -0
- gbfs_validator/schema/errors.py +40 -0
- gbfs_validator/schema/formats.py +94 -0
- gbfs_validator/schema/jsontypes.py +79 -0
- gbfs_validator/schema/keywords.py +246 -0
- gbfs_validator/schema/patching.py +70 -0
- gbfs_validator/validate.py +41 -0
- gbfs_validator/version.py +1 -0
- gbfs_validator/versions.py +94 -0
- gbfs_validator-0.1.0.dist-info/METADATA +344 -0
- gbfs_validator-0.1.0.dist-info/RECORD +128 -0
- gbfs_validator-0.1.0.dist-info/WHEEL +5 -0
- gbfs_validator-0.1.0.dist-info/entry_points.txt +2 -0
- gbfs_validator-0.1.0.dist-info/licenses/LICENSE +21 -0
- gbfs_validator-0.1.0.dist-info/licenses/NOTICE +31 -0
- gbfs_validator-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from gbfs_validator.feed import GBFS
|
|
4
|
+
from gbfs_validator.version import __version__
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def validate_feed(url: str, **kwargs: Any) -> dict[str, Any]:
|
|
8
|
+
"""Validate a GBFS feed and return the report."""
|
|
9
|
+
return GBFS(url, **kwargs).validation()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
__all__ = ["GBFS", "__version__", "validate_feed"]
|
gbfs_validator/cli.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Command line entry point: validate a feed, print and/or save the JSON report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import pathlib
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from gbfs_validator.constants.error_ids import ErrorIds
|
|
13
|
+
from gbfs_validator.feed import GBFS
|
|
14
|
+
from gbfs_validator.version import __version__
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="gbfs-validator",
|
|
20
|
+
usage="%(prog)s [OPTIONS]...",
|
|
21
|
+
description="Validate a GBFS feed and report on every file it declares.",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument("-v", "--version", action="version", version=__version__)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"-u",
|
|
26
|
+
"--url",
|
|
27
|
+
metavar="FEED_URL",
|
|
28
|
+
required=True,
|
|
29
|
+
help="URL of the GBFS feed, or a local path to a directory or a gbfs.json file",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"-vb", "--verbose", action="store_true", help="print progress logs to stderr"
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"-s", "--save-report", metavar="REPORT_PATH", help="local path to write the report to"
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"-pr",
|
|
39
|
+
"--print-report",
|
|
40
|
+
choices=["yes", "no"],
|
|
41
|
+
default="yes",
|
|
42
|
+
help="print the report as JSON to standard output (default: yes)",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument("--docked", action="store_true", help="feed serves a docked system")
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--free-floating", action="store_true", help="feed serves a free floating system"
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--feed-version",
|
|
50
|
+
metavar="VERSION",
|
|
51
|
+
help="validate against this GBFS version instead of the detected one",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--include-schema", action="store_true", help="keep the compiled schema in the report"
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--fail-on-error", action="store_true", help="exit 1 when the feed has validation errors"
|
|
58
|
+
)
|
|
59
|
+
return parser
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve_url(url: str) -> str:
|
|
63
|
+
"""An existing local path becomes a file:// URL; a directory keeps a trailing slash."""
|
|
64
|
+
path = pathlib.Path(url)
|
|
65
|
+
if not path.exists():
|
|
66
|
+
return url
|
|
67
|
+
resolved = path.resolve()
|
|
68
|
+
# Without the trailing slash, urljoin drops the directory when it appends
|
|
69
|
+
# gbfs.json (feed.py:69).
|
|
70
|
+
return f"{resolved.as_uri()}/" if resolved.is_dir() else resolved.as_uri()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def strip_report(report: dict[str, Any], include_schema: bool) -> dict[str, Any]:
|
|
74
|
+
"""The CLI never echoes fetched bodies, and drops schemas unless asked for them."""
|
|
75
|
+
if "files" not in report:
|
|
76
|
+
return report
|
|
77
|
+
drop = {"body"} if include_schema else {"body", "schema"}
|
|
78
|
+
return {**report, "files": [_strip_entry(entry, drop) for entry in report["files"]]}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _strip_entry(entry: dict[str, Any], drop: set[str]) -> dict[str, Any]:
|
|
82
|
+
out = {key: value for key, value in entry.items() if key not in drop}
|
|
83
|
+
languages = out.get("languages")
|
|
84
|
+
if isinstance(languages, list):
|
|
85
|
+
out["languages"] = [
|
|
86
|
+
{key: value for key, value in lang.items() if key not in drop} for lang in languages
|
|
87
|
+
]
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _save(report_json: str, destination: str) -> None:
|
|
92
|
+
path = pathlib.Path(destination)
|
|
93
|
+
if path.parent != path:
|
|
94
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
path.write_text(report_json)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _discard_stdout() -> None:
|
|
99
|
+
"""Point fd 1 at /dev/null so the interpreter's shutdown flush cannot fail too."""
|
|
100
|
+
try:
|
|
101
|
+
fd = sys.stdout.fileno()
|
|
102
|
+
except (AttributeError, ValueError, OSError):
|
|
103
|
+
return
|
|
104
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
105
|
+
try:
|
|
106
|
+
os.dup2(devnull, fd)
|
|
107
|
+
finally:
|
|
108
|
+
os.close(devnull)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _critical(err: Exception) -> int:
|
|
112
|
+
"""Upstream's catch-all critical path (cli.js:93)."""
|
|
113
|
+
if isinstance(err, BrokenPipeError):
|
|
114
|
+
_discard_stdout()
|
|
115
|
+
print(f"Critical error while validating GBFS feed => {err}", file=sys.stderr)
|
|
116
|
+
return 1
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main(argv: list[str] | None = None) -> int:
|
|
120
|
+
parser = build_parser()
|
|
121
|
+
try:
|
|
122
|
+
args = parser.parse_args(argv)
|
|
123
|
+
except SystemExit as exit_request:
|
|
124
|
+
if exit_request.code:
|
|
125
|
+
raise
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
if not args.save_report and args.print_report == "no":
|
|
129
|
+
print(
|
|
130
|
+
f"[{ErrorIds.CLI_NO_OUTPUT_REQUESTED}] Please set at least one of the following "
|
|
131
|
+
f"options: --save-report or --print-report",
|
|
132
|
+
file=sys.stderr,
|
|
133
|
+
)
|
|
134
|
+
parser.print_help(sys.stderr)
|
|
135
|
+
return 1
|
|
136
|
+
|
|
137
|
+
url = resolve_url(args.url)
|
|
138
|
+
if args.verbose:
|
|
139
|
+
print(f"Started GBFS validation of {url} with options: {vars(args)}", file=sys.stderr)
|
|
140
|
+
|
|
141
|
+
# Printing and saving stay inside the catch, as upstream does (cli.js:85).
|
|
142
|
+
try:
|
|
143
|
+
report = GBFS(
|
|
144
|
+
url,
|
|
145
|
+
docked=args.docked,
|
|
146
|
+
freefloating=args.free_floating,
|
|
147
|
+
version=args.feed_version,
|
|
148
|
+
).validation()
|
|
149
|
+
report_json = json.dumps(strip_report(report, args.include_schema))
|
|
150
|
+
if args.print_report == "yes":
|
|
151
|
+
print(report_json)
|
|
152
|
+
# Without this, a closed stdout only fails at shutdown, past the catch.
|
|
153
|
+
sys.stdout.flush()
|
|
154
|
+
if args.save_report:
|
|
155
|
+
_save(report_json, args.save_report)
|
|
156
|
+
except Exception as err: # noqa: BLE001 - upstream's catch-all critical path (cli.js:93)
|
|
157
|
+
return _critical(err)
|
|
158
|
+
|
|
159
|
+
if args.verbose:
|
|
160
|
+
print("Validation completed", file=sys.stderr)
|
|
161
|
+
|
|
162
|
+
if args.fail_on_error and report["summary"].get("hasErrors"):
|
|
163
|
+
return 1
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
sys.exit(main())
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""The cross-file conditional rules, ported from the switch in gbfs.js:730.
|
|
2
|
+
|
|
3
|
+
Every test here is JavaScript truthiness on unvalidated feed data, so an empty
|
|
4
|
+
string id or a zero price triggers nothing while an empty array or object
|
|
5
|
+
triggers everything. That is upstream's behavior and the differential depends
|
|
6
|
+
on it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from gbfs_validator.feedhelpers import (
|
|
14
|
+
file_exist,
|
|
15
|
+
get_pricing_plans,
|
|
16
|
+
get_vehicle_types,
|
|
17
|
+
had_vehicle_type_id,
|
|
18
|
+
has_pricing_plan_id,
|
|
19
|
+
has_rental_uris,
|
|
20
|
+
prop,
|
|
21
|
+
)
|
|
22
|
+
from gbfs_validator.jstruth import truthy
|
|
23
|
+
from gbfs_validator.partials import get_partial_schema
|
|
24
|
+
|
|
25
|
+
Context = dict[str, Any]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _has_length(value: Any) -> bool:
|
|
29
|
+
"""`value && value.length`: only arrays and strings carry a length in JS."""
|
|
30
|
+
return isinstance(value, (list, str)) and len(value) > 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def find_type(files: list[dict[str, Any]], file_type: str) -> dict[str, Any] | None:
|
|
34
|
+
return next((f for f in files if f.get("type") == file_type), None)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def conditional_context(files: list[dict[str, Any]]) -> Context:
|
|
38
|
+
vehicle_types_file = find_type(files, "vehicle_types")
|
|
39
|
+
free_bike_status_file = find_type(files, "free_bike_status")
|
|
40
|
+
station_information_file = find_type(files, "station_information")
|
|
41
|
+
pricing_plans_file = find_type(files, "system_pricing_plans")
|
|
42
|
+
|
|
43
|
+
context: Context = {
|
|
44
|
+
"vehicleTypes": None,
|
|
45
|
+
"pricingPlans": None,
|
|
46
|
+
"freeBikeStatusHasVehicleTypeId": None,
|
|
47
|
+
"hasIosRentalUris": None,
|
|
48
|
+
"hasAndroidRentalUris": None,
|
|
49
|
+
"hasBikesPricingPlanId": None,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if vehicle_types_file is not None and file_exist(vehicle_types_file):
|
|
53
|
+
context["vehicleTypes"] = get_vehicle_types(vehicle_types_file)
|
|
54
|
+
|
|
55
|
+
if free_bike_status_file is not None and file_exist(free_bike_status_file):
|
|
56
|
+
context["freeBikeStatusHasVehicleTypeId"] = had_vehicle_type_id(free_bike_status_file)
|
|
57
|
+
context["hasIosRentalUris"] = has_rental_uris(free_bike_status_file, "bikes", "ios")
|
|
58
|
+
context["hasAndroidRentalUris"] = has_rental_uris(free_bike_status_file, "bikes", "android")
|
|
59
|
+
context["hasBikesPricingPlanId"] = has_pricing_plan_id(free_bike_status_file)
|
|
60
|
+
|
|
61
|
+
if station_information_file is not None and file_exist(station_information_file):
|
|
62
|
+
context["hasIosRentalUris"] = context["hasIosRentalUris"] or has_rental_uris(
|
|
63
|
+
station_information_file, "stations", "ios"
|
|
64
|
+
)
|
|
65
|
+
context["hasAndroidRentalUris"] = context["hasAndroidRentalUris"] or has_rental_uris(
|
|
66
|
+
station_information_file, "stations", "android"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if pricing_plans_file is not None and file_exist(pricing_plans_file):
|
|
70
|
+
context["pricingPlans"] = get_pricing_plans(pricing_plans_file)
|
|
71
|
+
|
|
72
|
+
return context
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def partials_for(
|
|
76
|
+
version: str, file: dict[str, Any], context: Context
|
|
77
|
+
) -> tuple[list[dict[str, Any]], Any]:
|
|
78
|
+
"""Schema patches for one file, plus its possibly promoted `required`."""
|
|
79
|
+
add_schema: list[dict[str, Any]] = []
|
|
80
|
+
required = file.get("required")
|
|
81
|
+
file_type = file.get("type")
|
|
82
|
+
vehicle_types = context["vehicleTypes"]
|
|
83
|
+
has_vehicle_types = _has_length(vehicle_types)
|
|
84
|
+
|
|
85
|
+
if file_type == "station_status" and has_vehicle_types:
|
|
86
|
+
_add(
|
|
87
|
+
add_schema,
|
|
88
|
+
version,
|
|
89
|
+
"station_status/required_vehicle_types_available",
|
|
90
|
+
{"vehicleTypes": vehicle_types},
|
|
91
|
+
)
|
|
92
|
+
elif file_type == "free_bike_status" and has_vehicle_types:
|
|
93
|
+
_add(
|
|
94
|
+
add_schema,
|
|
95
|
+
version,
|
|
96
|
+
"free_bike_status/required_vehicle_type_id",
|
|
97
|
+
{"vehicleTypes": vehicle_types},
|
|
98
|
+
)
|
|
99
|
+
elif file_type == "vehicle_status" and has_vehicle_types:
|
|
100
|
+
_add(
|
|
101
|
+
add_schema,
|
|
102
|
+
version,
|
|
103
|
+
"vehicle_status/required_vehicle_type_id",
|
|
104
|
+
{"vehicleTypes": vehicle_types},
|
|
105
|
+
)
|
|
106
|
+
elif file_type == "vehicle_types":
|
|
107
|
+
required = _vehicle_types_rules(add_schema, version, context, required)
|
|
108
|
+
elif file_type == "system_pricing_plans":
|
|
109
|
+
if context["hasBikesPricingPlanId"]:
|
|
110
|
+
required = True
|
|
111
|
+
elif file_type == "system_information":
|
|
112
|
+
_system_information_rules(add_schema, version, context)
|
|
113
|
+
|
|
114
|
+
return add_schema, required
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _vehicle_types_rules(
|
|
118
|
+
add_schema: list[dict[str, Any]], version: str, context: Context, required: Any
|
|
119
|
+
) -> Any:
|
|
120
|
+
if context["freeBikeStatusHasVehicleTypeId"]:
|
|
121
|
+
required = True
|
|
122
|
+
plans = context["pricingPlans"]
|
|
123
|
+
if not _has_length(plans):
|
|
124
|
+
return required
|
|
125
|
+
|
|
126
|
+
_add(add_schema, version, "vehicle_types/pricing_plan_id", {"pricingPlans": plans})
|
|
127
|
+
# Truthiness, so a zero price does not count as a reservation price while an
|
|
128
|
+
# empty object does.
|
|
129
|
+
reserved = [
|
|
130
|
+
prop(plan, "plan_id", "pricing plan")
|
|
131
|
+
for plan in plans
|
|
132
|
+
if truthy(prop(plan, "reservation_price_flat_rate", "pricing plan"))
|
|
133
|
+
or truthy(prop(plan, "reservation_price_per_min", "pricing plan"))
|
|
134
|
+
]
|
|
135
|
+
if reserved:
|
|
136
|
+
_add(
|
|
137
|
+
add_schema,
|
|
138
|
+
version,
|
|
139
|
+
"vehicle_types/default_reserve_time_require",
|
|
140
|
+
{"pricingPlansIdsWithReservationPrice": reserved},
|
|
141
|
+
)
|
|
142
|
+
return required
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _system_information_rules(
|
|
146
|
+
add_schema: list[dict[str, Any]], version: str, context: Context
|
|
147
|
+
) -> None:
|
|
148
|
+
ios = context["hasIosRentalUris"]
|
|
149
|
+
android = context["hasAndroidRentalUris"]
|
|
150
|
+
if android or ios:
|
|
151
|
+
_add(
|
|
152
|
+
add_schema,
|
|
153
|
+
version,
|
|
154
|
+
"system_information/required_store_uri",
|
|
155
|
+
{"ios": bool(ios), "android": bool(android)},
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _add(add_schema: list[dict[str, Any]], version: str, name: str, params: dict[str, Any]) -> None:
|
|
160
|
+
partial = get_partial_schema(version, name, params)
|
|
161
|
+
if partial:
|
|
162
|
+
add_schema.append(partial)
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Central error ID registry. See guides/error-id-registry.md in agent-starter.
|
|
2
|
+
|
|
3
|
+
Rules:
|
|
4
|
+
1. Never reuse a retired ID: mark it `# retired` and leave it in place.
|
|
5
|
+
2. One ID per distinct cause, not per raise site.
|
|
6
|
+
3. Numbers are stable; append, never renumber.
|
|
7
|
+
|
|
8
|
+
Raise via AppError(ErrorIds.X, "...", {...}). Log lines include the ID so
|
|
9
|
+
grep, telemetry, and agents can all find every occurrence with one search.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ErrorIds(StrEnum):
|
|
20
|
+
# Fetching a feed file (network, status, decode)
|
|
21
|
+
FETCH_UNREACHABLE = "E_FETCH_001"
|
|
22
|
+
FETCH_BAD_STATUS = "E_FETCH_002"
|
|
23
|
+
FETCH_BAD_JSON = "E_FETCH_003"
|
|
24
|
+
FETCH_AUTH_FAILED = "E_FETCH_004"
|
|
25
|
+
|
|
26
|
+
# Schema handling
|
|
27
|
+
SCHEMA_UNKNOWN_VERSION = "E_SCHEMA_001"
|
|
28
|
+
SCHEMA_MISSING_FILE = "E_SCHEMA_002"
|
|
29
|
+
SCHEMA_PATCH_SOURCE_MISMATCH = "E_SCHEMA_003"
|
|
30
|
+
SCHEMA_UNSUPPORTED_PATCH_OP = "E_SCHEMA_004"
|
|
31
|
+
|
|
32
|
+
# Feed structure encountered before validation could report on it. These
|
|
33
|
+
# mirror upstream crashes on purpose; see the spec's parity contract.
|
|
34
|
+
FEED_MISSING_URL = "E_FEED_001"
|
|
35
|
+
FEED_MALFORMED_CONTAINER = "E_FEED_002"
|
|
36
|
+
|
|
37
|
+
# CLI usage
|
|
38
|
+
CLI_NO_OUTPUT_REQUESTED = "E_CLI_001"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AppError(Exception):
|
|
42
|
+
"""Base error: every raise carries a stable ID plus structured context."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
error_id: ErrorIds,
|
|
47
|
+
message: str,
|
|
48
|
+
context: dict[str, Any] | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
super().__init__(message)
|
|
51
|
+
self.id = error_id
|
|
52
|
+
self.context = context or {}
|
|
53
|
+
|
|
54
|
+
def to_log_line(self) -> str:
|
|
55
|
+
ctx = " ".join(f"{k}={json.dumps(v, default=str)}" for k, v in self.context.items())
|
|
56
|
+
return f"[{self.id}] {self}{' ' + ctx if ctx else ''}"
|
gbfs_validator/data/PIN
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema",
|
|
3
|
+
"$id": "https://github.com/MobilityData/gbfs/blob/v1.0/gbfs.md#free_bike_statusjson",
|
|
4
|
+
"description": "Describes the vehicles that are available for rent (as of v2.1-RC2).",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"last_updated": {
|
|
8
|
+
"description": "Last time the data in the feed was updated in POSIX time.",
|
|
9
|
+
"type": "integer",
|
|
10
|
+
"minimum": 0,
|
|
11
|
+
"maximum": 1924988399
|
|
12
|
+
},
|
|
13
|
+
"ttl": {
|
|
14
|
+
"description": "Number of seconds before the data in the feed will be updated again (0 if the data should always be refreshed).",
|
|
15
|
+
"type": "integer",
|
|
16
|
+
"minimum": 0
|
|
17
|
+
},
|
|
18
|
+
"data": {
|
|
19
|
+
"description": "Array that contains one object per bike as defined below.",
|
|
20
|
+
"type": "object",
|
|
21
|
+
"properties": {
|
|
22
|
+
"bikes": {
|
|
23
|
+
"type": "array",
|
|
24
|
+
"items": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": {
|
|
27
|
+
"bike_id": {
|
|
28
|
+
"description": "Rotating (as of v2.0) identifier of a vehicle.",
|
|
29
|
+
"type": "string"
|
|
30
|
+
},
|
|
31
|
+
"lat": {
|
|
32
|
+
"description": "The latitude of the vehicle.",
|
|
33
|
+
"type": "number",
|
|
34
|
+
"minimum": -90,
|
|
35
|
+
"maximum": 90
|
|
36
|
+
},
|
|
37
|
+
"lon": {
|
|
38
|
+
"description": "The longitude of the vehicle.",
|
|
39
|
+
"type": "number",
|
|
40
|
+
"minimum": -180,
|
|
41
|
+
"maximum": 180
|
|
42
|
+
},
|
|
43
|
+
"is_reserved": {
|
|
44
|
+
"description": "Is the vehicle currently reserved?",
|
|
45
|
+
"oneOf": [{ "type": "boolean" }, { "type": "number" }]
|
|
46
|
+
},
|
|
47
|
+
"is_disabled": {
|
|
48
|
+
"description": "Is the vehicle currently disabled (broken)?",
|
|
49
|
+
"oneOf": [{ "type": "boolean" }, { "type": "number" }]
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"required": ["bike_id", "lat", "lon", "is_reserved", "is_disabled"]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"required": ["bikes"]
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"required": ["last_updated", "ttl", "data"]
|
|
60
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema",
|
|
3
|
+
"$id": "https://github.com/MobilityData/gbfs/blob/v1.0/gbfs.md#gbfsjson",
|
|
4
|
+
"description": "Auto-discovery file that links to all of the other files published by the system.",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"last_updated": {
|
|
8
|
+
"description": "Last time the data in the feed was updated in POSIX time.",
|
|
9
|
+
"type": "integer",
|
|
10
|
+
"minimum": 0,
|
|
11
|
+
"maximum": 1924988399
|
|
12
|
+
},
|
|
13
|
+
"ttl": {
|
|
14
|
+
"description": "Number of seconds before the data in the feed will be updated again (0 if the data should always be refreshed).",
|
|
15
|
+
"type": "integer",
|
|
16
|
+
"minimum": 0
|
|
17
|
+
},
|
|
18
|
+
"data": {
|
|
19
|
+
"description": "Response data in the form of name:value pairs.",
|
|
20
|
+
"type": "object",
|
|
21
|
+
"patternProperties": {
|
|
22
|
+
"^[a-zA-Z]{2}$": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": {
|
|
25
|
+
"feeds": {
|
|
26
|
+
"description": "An array of all of the feeds that are published by the auto-discovery file. Each element in the array is an object with the keys below.",
|
|
27
|
+
"type": "array",
|
|
28
|
+
"items": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"properties": {
|
|
31
|
+
"name": {
|
|
32
|
+
"type": "string"
|
|
33
|
+
},
|
|
34
|
+
"url": {
|
|
35
|
+
"type": "string"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"required": ["name", "url"]
|
|
39
|
+
},
|
|
40
|
+
"minItems": 1,
|
|
41
|
+
"contains": {
|
|
42
|
+
"properties": {
|
|
43
|
+
"name": { "const": "system_information" }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"required": ["feeds"]
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"minProperties": 1,
|
|
52
|
+
"additionalProperties": false
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"required": ["last_updated", "ttl", "data"]
|
|
56
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema",
|
|
3
|
+
"$id": "https://github.com/MobilityData/gbfs/blob/v1.0/gbfs.md#station_informationjson",
|
|
4
|
+
"description": "List of all stations, their capacities and locations. REQUIRED of systems utilizing docks.",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"last_updated": {
|
|
8
|
+
"description": "Last time the data in the feed was updated in POSIX time.",
|
|
9
|
+
"type": "integer",
|
|
10
|
+
"minimum": 0,
|
|
11
|
+
"maximum": 1924988399
|
|
12
|
+
},
|
|
13
|
+
"ttl": {
|
|
14
|
+
"description": "Number of seconds before the data in the feed will be updated again (0 if the data should always be refreshed).",
|
|
15
|
+
"type": "integer",
|
|
16
|
+
"minimum": 0
|
|
17
|
+
},
|
|
18
|
+
"data": {
|
|
19
|
+
"description": "Array that contains one object per station as defined below.",
|
|
20
|
+
"type": "object",
|
|
21
|
+
"properties": {
|
|
22
|
+
"stations": {
|
|
23
|
+
"type": "array",
|
|
24
|
+
"items": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": {
|
|
27
|
+
"station_id": {
|
|
28
|
+
"description": "Identifier of a station.",
|
|
29
|
+
"type": "string"
|
|
30
|
+
},
|
|
31
|
+
"name": {
|
|
32
|
+
"description": "Public name of the station.",
|
|
33
|
+
"type": "string"
|
|
34
|
+
},
|
|
35
|
+
"short_name": {
|
|
36
|
+
"description": "Short name or other type of identifier.",
|
|
37
|
+
"type": "string"
|
|
38
|
+
},
|
|
39
|
+
"lat": {
|
|
40
|
+
"description": "The latitude of the station.",
|
|
41
|
+
"type": "number",
|
|
42
|
+
"minimum": -90,
|
|
43
|
+
"maximum": 90
|
|
44
|
+
},
|
|
45
|
+
"lon": {
|
|
46
|
+
"description": "The longitude fo the station.",
|
|
47
|
+
"type": "number",
|
|
48
|
+
"minimum": -180,
|
|
49
|
+
"maximum": 180
|
|
50
|
+
},
|
|
51
|
+
"address": {
|
|
52
|
+
"description": "Address where station is located.",
|
|
53
|
+
"type": "string"
|
|
54
|
+
},
|
|
55
|
+
"cross_street": {
|
|
56
|
+
"description": "Cross street or landmark…the station is located.",
|
|
57
|
+
"type": "string"
|
|
58
|
+
},
|
|
59
|
+
"region_id": {
|
|
60
|
+
"description": "Identifier of the region…the station is located.",
|
|
61
|
+
"type": "string"
|
|
62
|
+
},
|
|
63
|
+
"post_code": {
|
|
64
|
+
"description": "Postal code where station is located.",
|
|
65
|
+
"type": "string"
|
|
66
|
+
},
|
|
67
|
+
"rental_methods": {
|
|
68
|
+
"description": "Payment methods accepted at this station.",
|
|
69
|
+
"type": "array",
|
|
70
|
+
"items": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"enum": [
|
|
73
|
+
"KEY",
|
|
74
|
+
"CREDITCARD",
|
|
75
|
+
"PAYPASS",
|
|
76
|
+
"APPLEPAY",
|
|
77
|
+
"ANDROIDPAY",
|
|
78
|
+
"TRANSITCARD",
|
|
79
|
+
"ACCOUNTNUMBER",
|
|
80
|
+
"PHONE"
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"capacity": {
|
|
85
|
+
"description": "Number of total docking …ilable and unavailable.",
|
|
86
|
+
"type": "integer",
|
|
87
|
+
"minimum": 0
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
"required": ["station_id", "name", "lat", "lon"]
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"required": ["stations"]
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"required": ["last_updated", "ttl", "data"]
|
|
98
|
+
}
|