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.
Files changed (128) hide show
  1. gbfs_validator/__init__.py +12 -0
  2. gbfs_validator/cli.py +168 -0
  3. gbfs_validator/conditionals.py +162 -0
  4. gbfs_validator/constants/__init__.py +0 -0
  5. gbfs_validator/constants/error_ids.py +56 -0
  6. gbfs_validator/data/PIN +2 -0
  7. gbfs_validator/data/schemas/v1.0/free_bike_status.json +60 -0
  8. gbfs_validator/data/schemas/v1.0/gbfs.json +56 -0
  9. gbfs_validator/data/schemas/v1.0/station_information.json +98 -0
  10. gbfs_validator/data/schemas/v1.0/station_status.json +84 -0
  11. gbfs_validator/data/schemas/v1.0/system_alerts.json +101 -0
  12. gbfs_validator/data/schemas/v1.0/system_calendar.json +69 -0
  13. gbfs_validator/data/schemas/v1.0/system_hours.json +62 -0
  14. gbfs_validator/data/schemas/v1.0/system_information.json +77 -0
  15. gbfs_validator/data/schemas/v1.0/system_pricing_plans.json +73 -0
  16. gbfs_validator/data/schemas/v1.0/system_regions.json +45 -0
  17. gbfs_validator/data/schemas/v1.1/free_bike_status.json +99 -0
  18. gbfs_validator/data/schemas/v1.1/gbfs.json +82 -0
  19. gbfs_validator/data/schemas/v1.1/gbfs_versions.json +67 -0
  20. gbfs_validator/data/schemas/v1.1/station_information.json +137 -0
  21. gbfs_validator/data/schemas/v1.1/station_status.json +105 -0
  22. gbfs_validator/data/schemas/v1.1/system_alerts.json +116 -0
  23. gbfs_validator/data/schemas/v1.1/system_calendar.json +78 -0
  24. gbfs_validator/data/schemas/v1.1/system_hours.json +77 -0
  25. gbfs_validator/data/schemas/v1.1/system_information.json +148 -0
  26. gbfs_validator/data/schemas/v1.1/system_pricing_plans.json +89 -0
  27. gbfs_validator/data/schemas/v1.1/system_regions.json +53 -0
  28. gbfs_validator/data/schemas/v2.0/free_bike_status.json +95 -0
  29. gbfs_validator/data/schemas/v2.0/gbfs.json +82 -0
  30. gbfs_validator/data/schemas/v2.0/gbfs_versions.json +67 -0
  31. gbfs_validator/data/schemas/v2.0/station_information.json +137 -0
  32. gbfs_validator/data/schemas/v2.0/station_status.json +98 -0
  33. gbfs_validator/data/schemas/v2.0/system_alerts.json +116 -0
  34. gbfs_validator/data/schemas/v2.0/system_calendar.json +78 -0
  35. gbfs_validator/data/schemas/v2.0/system_hours.json +77 -0
  36. gbfs_validator/data/schemas/v2.0/system_information.json +747 -0
  37. gbfs_validator/data/schemas/v2.0/system_pricing_plans.json +87 -0
  38. gbfs_validator/data/schemas/v2.0/system_regions.json +53 -0
  39. gbfs_validator/data/schemas/v2.1/free_bike_status.json +135 -0
  40. gbfs_validator/data/schemas/v2.1/gbfs.json +84 -0
  41. gbfs_validator/data/schemas/v2.1/gbfs_versions.json +67 -0
  42. gbfs_validator/data/schemas/v2.1/geofencing_zones.json +151 -0
  43. gbfs_validator/data/schemas/v2.1/station_information.json +192 -0
  44. gbfs_validator/data/schemas/v2.1/station_status.json +145 -0
  45. gbfs_validator/data/schemas/v2.1/system_alerts.json +116 -0
  46. gbfs_validator/data/schemas/v2.1/system_calendar.json +78 -0
  47. gbfs_validator/data/schemas/v2.1/system_hours.json +77 -0
  48. gbfs_validator/data/schemas/v2.1/system_information.json +747 -0
  49. gbfs_validator/data/schemas/v2.1/system_pricing_plans.json +87 -0
  50. gbfs_validator/data/schemas/v2.1/system_regions.json +53 -0
  51. gbfs_validator/data/schemas/v2.1/vehicle_types.json +82 -0
  52. gbfs_validator/data/schemas/v2.2/free_bike_status.json +140 -0
  53. gbfs_validator/data/schemas/v2.2/gbfs.json +84 -0
  54. gbfs_validator/data/schemas/v2.2/gbfs_versions.json +67 -0
  55. gbfs_validator/data/schemas/v2.2/geofencing_zones.json +151 -0
  56. gbfs_validator/data/schemas/v2.2/station_information.json +192 -0
  57. gbfs_validator/data/schemas/v2.2/station_status.json +145 -0
  58. gbfs_validator/data/schemas/v2.2/system_alerts.json +116 -0
  59. gbfs_validator/data/schemas/v2.2/system_calendar.json +78 -0
  60. gbfs_validator/data/schemas/v2.2/system_hours.json +77 -0
  61. gbfs_validator/data/schemas/v2.2/system_information.json +747 -0
  62. gbfs_validator/data/schemas/v2.2/system_pricing_plans.json +160 -0
  63. gbfs_validator/data/schemas/v2.2/system_regions.json +53 -0
  64. gbfs_validator/data/schemas/v2.2/vehicle_types.json +82 -0
  65. gbfs_validator/data/schemas/v2.3/free_bike_status.json +153 -0
  66. gbfs_validator/data/schemas/v2.3/gbfs.json +84 -0
  67. gbfs_validator/data/schemas/v2.3/gbfs_versions.json +67 -0
  68. gbfs_validator/data/schemas/v2.3/geofencing_zones.json +145 -0
  69. gbfs_validator/data/schemas/v2.3/station_information.json +216 -0
  70. gbfs_validator/data/schemas/v2.3/station_status.json +145 -0
  71. gbfs_validator/data/schemas/v2.3/system_alerts.json +116 -0
  72. gbfs_validator/data/schemas/v2.3/system_calendar.json +78 -0
  73. gbfs_validator/data/schemas/v2.3/system_hours.json +77 -0
  74. gbfs_validator/data/schemas/v2.3/system_information.json +808 -0
  75. gbfs_validator/data/schemas/v2.3/system_pricing_plans.json +160 -0
  76. gbfs_validator/data/schemas/v2.3/system_regions.json +53 -0
  77. gbfs_validator/data/schemas/v2.3/vehicle_types.json +204 -0
  78. gbfs_validator/data/schemas/v3.0/gbfs.json +69 -0
  79. gbfs_validator/data/schemas/v3.0/gbfs_versions.json +60 -0
  80. gbfs_validator/data/schemas/v3.0/geofencing_zones.json +202 -0
  81. gbfs_validator/data/schemas/v3.0/manifest.json +80 -0
  82. gbfs_validator/data/schemas/v3.0/station_information.json +278 -0
  83. gbfs_validator/data/schemas/v3.0/station_status.json +145 -0
  84. gbfs_validator/data/schemas/v3.0/system_alerts.json +161 -0
  85. gbfs_validator/data/schemas/v3.0/system_information.json +1451 -0
  86. gbfs_validator/data/schemas/v3.0/system_pricing_plans.json +191 -0
  87. gbfs_validator/data/schemas/v3.0/system_regions.json +68 -0
  88. gbfs_validator/data/schemas/v3.0/vehicle_status.json +153 -0
  89. gbfs_validator/data/schemas/v3.0/vehicle_types.json +268 -0
  90. gbfs_validator/data/schemas/v3.1-RC3/gbfs.json +70 -0
  91. gbfs_validator/data/schemas/v3.1-RC3/gbfs_versions.json +61 -0
  92. gbfs_validator/data/schemas/v3.1-RC3/geofencing_zones.json +202 -0
  93. gbfs_validator/data/schemas/v3.1-RC3/manifest.json +114 -0
  94. gbfs_validator/data/schemas/v3.1-RC3/station_information.json +282 -0
  95. gbfs_validator/data/schemas/v3.1-RC3/station_status.json +145 -0
  96. gbfs_validator/data/schemas/v3.1-RC3/system_alerts.json +161 -0
  97. gbfs_validator/data/schemas/v3.1-RC3/system_information.json +1451 -0
  98. gbfs_validator/data/schemas/v3.1-RC3/system_pricing_plans.json +222 -0
  99. gbfs_validator/data/schemas/v3.1-RC3/system_regions.json +68 -0
  100. gbfs_validator/data/schemas/v3.1-RC3/vehicle_availability.json +91 -0
  101. gbfs_validator/data/schemas/v3.1-RC3/vehicle_status.json +153 -0
  102. gbfs_validator/data/schemas/v3.1-RC3/vehicle_types.json +273 -0
  103. gbfs_validator/discovery.py +43 -0
  104. gbfs_validator/feed.py +281 -0
  105. gbfs_validator/feedhelpers.py +168 -0
  106. gbfs_validator/fetch.py +149 -0
  107. gbfs_validator/jstruth.py +16 -0
  108. gbfs_validator/jsvalues.py +79 -0
  109. gbfs_validator/partials.py +287 -0
  110. gbfs_validator/results.py +71 -0
  111. gbfs_validator/schema/__init__.py +0 -0
  112. gbfs_validator/schema/applicators.py +235 -0
  113. gbfs_validator/schema/engine.py +63 -0
  114. gbfs_validator/schema/errors.py +40 -0
  115. gbfs_validator/schema/formats.py +94 -0
  116. gbfs_validator/schema/jsontypes.py +79 -0
  117. gbfs_validator/schema/keywords.py +246 -0
  118. gbfs_validator/schema/patching.py +70 -0
  119. gbfs_validator/validate.py +41 -0
  120. gbfs_validator/version.py +1 -0
  121. gbfs_validator/versions.py +94 -0
  122. gbfs_validator-0.1.0.dist-info/METADATA +344 -0
  123. gbfs_validator-0.1.0.dist-info/RECORD +128 -0
  124. gbfs_validator-0.1.0.dist-info/WHEEL +5 -0
  125. gbfs_validator-0.1.0.dist-info/entry_points.txt +2 -0
  126. gbfs_validator-0.1.0.dist-info/licenses/LICENSE +21 -0
  127. gbfs_validator-0.1.0.dist-info/licenses/NOTICE +31 -0
  128. 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 ''}"
@@ -0,0 +1,2 @@
1
+ b734086ccdf93b5acd72bb349c6fda0c9f64fd39
2
+ 916327fe43ea79683f57c87bc54fdfc24797862f
@@ -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
+ }