Metadata-Version: 2.4
Name: gtfslint-cli
Version: 0.1.0
Summary: Fast, pure-Python GTFS Schedule validator and quality report for CI: coded rules, feed metrics, JSON/Markdown/HTML/GitHub output.
Author-email: Yusuf Eminoğlu <yusufeminoglu@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/YusufEminoglu/gtfslint_sdk
Project-URL: Repository, https://github.com/YusufEminoglu/gtfslint_sdk
Project-URL: Issues, https://github.com/YusufEminoglu/gtfslint_sdk/issues
Project-URL: Changelog, https://github.com/YusufEminoglu/gtfslint_sdk/blob/main/CHANGELOG.md
Keywords: gtfs,gtfs-schedule,transit,public-transport,validator,linter,data-quality,timetable,mobility,ci,github-actions,gis
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Classifier: Topic :: Scientific/Engineering :: GIS
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Topic :: Software Development :: Testing
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: tomli>=1.1.0; python_version < "3.11"
Requires-Dist: tzdata; sys_platform == "win32"
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
Requires-Dist: ruff>=0.1.0; extra == "dev"
Requires-Dist: mypy>=1.0.0; extra == "dev"
Dynamic: license-file

# gtfslint

**Fast, pure-Python GTFS Schedule validator and quality report that runs in CI.**

`gtfslint` checks a [GTFS Schedule](https://gtfs.org/documentation/schedule/reference/) feed
(a `.zip` archive or a folder) against the specification, reports every problem with a stable
rule code, a severity and the exact file/line/field, and computes quality metrics such as
service span, trips per day and headways. It is a `pip install` away, has no compiled
dependencies, and validates a feed with one million `stop_times.txt` rows in about seven
seconds.

The canonical [MobilityData GTFS Validator](https://github.com/MobilityData/gtfs-validator)
is a Java application. `gtfslint` does not replace it. It covers the checks that catch most
broken feeds and fits where a Python tool works better: pre-commit hooks, GitHub Actions,
data pipelines and notebooks. When a rule matches a MobilityData notice, the notice name is
listed next to it, so you can compare the two reports.

## Installation

```bash
pip install gtfslint-cli
```

The distribution on PyPI is called `gtfslint-cli` (the name `gtfslint` is too close to an
existing project); the import name and the command are both `gtfslint`.

Python 3.9 or newer. The only dependencies are `tomli` on Python < 3.11 and `tzdata` on
Windows.

## Command line

```bash
gtfslint check feed.zip                       # text report, exit code 1 on errors
gtfslint check feed.zip --html report.html    # also write a self-contained HTML report
gtfslint check feed/ --format json > report.json
gtfslint check feed.zip --format github       # GitHub Actions annotations
gtfslint check feed.zip --select GL2,GL4 --ignore GL408 --fail-on warning
gtfslint stats feed.zip --html stats.html     # quality metrics
gtfslint rules                                # list every rule
```

`check` options:

| Option | Meaning |
|---|---|
| `-f, --format` | `text` (default), `json`, `markdown`, `html`, `github` |
| `-o, --output FILE` | write the `--format` output to a file instead of stdout |
| `--json / --markdown / --html FILE` | write extra reports in those formats |
| `--select`, `--ignore` | comma-separated rule codes, code prefixes (`GL4`) or names (`invalid_url`) |
| `--fail-on` | lowest severity that fails: `error` (default), `warning`, `info`, `never` |
| `--reference-date` | date used by the expiration rules (default: today) |
| `--max-notices-per-rule` | notices kept per rule (default 1000; counts are always complete) |
| `--stats` | add feed statistics to the report |
| `-q, --quiet` | print only the summary line |
| `--config FILE`, `--no-config` | choose or skip the `pyproject.toml` configuration |

Exit codes: **0** when no notice reaches `--fail-on`, **1** when at least one does, **2** for
usage errors, invalid configuration or an unreadable feed.

### GitHub Actions

```yaml
- run: pip install gtfslint-cli
- run: gtfslint check gtfs/ --format github --html gtfs-report.html
- uses: actions/upload-artifact@v4
  if: always()
  with:
    name: gtfs-report
    path: gtfs-report.html
```

When the feed is a folder, annotations point to the exact file and line. When it is a zip
archive, they are attached to the archive and the message gives the location inside it.

### pre-commit

```yaml
repos:
  - repo: https://github.com/YusufEminoglu/gtfslint_sdk
    rev: v0.1.0
    hooks:
      - id: gtfslint
```

The hook runs `gtfslint check` on every committed `.zip` feed.

## Configuration

Settings are read from the nearest `pyproject.toml`. Command-line options override them.

```toml
[tool.gtfslint]
select = ["ALL"]               # codes, prefixes or names
ignore = ["GL401", "GL606"]
fail-on = "warning"            # error | warning | info | never
max-notices-per-rule = 1000
reference-date = "2026-11-08"  # default: today
min-stop-distance-m = 2.0      # GL502 threshold
max-parent-distance-m = 1000.0 # GL504 threshold
max-stop-distance-km = 50      # GL503 threshold for every route type (default: per type)
speed-limits-kmh = { 3 = 120, 0 = 80 }   # GL408 limits per basic route_type
```

## Python API

```python
import gtfslint

report = gtfslint.validate("feed.zip")          # -> gtfslint.Report
print(report.errors, report.warnings, report.infos, report.ok)
for notice in report.notices:
    print(notice.code, notice.severity.label, notice.location, notice.message)

report.write("report.html")                     # format inferred from the suffix
data = report.to_dict()                         # also to_json/to_text/to_markdown/to_github
report.exit_code(fail_on="warning")

report = gtfslint.validate(
    "feed/",
    select=["GL2", "GL4"],
    ignore=["fast_travel_between_consecutive_stops"],
    reference_date="2026-11-08",
    with_stats=True,
)

st = gtfslint.stats("feed.zip")                 # -> gtfslint.FeedStats
st.trips_per_day["2026-11-09"], st.busiest_date, st.headways[0].median_headway_min
cfg = gtfslint.load_config("pyproject.toml")    # -> gtfslint.Config
gtfslint.validate("feed.zip", config=cfg)
```

`gtfslint.RULES` maps every code to a `Rule`, which has `code`, `name`, `severity`,
`description`, `reference` and `mobilitydata`. `gtfslint.FeedError` is raised when the
path does not exist or is not a readable zip archive.

## Quality metrics

`gtfslint stats` and `gtfslint.stats()` report:

- **Counts:** agencies, stops, stations, entrances, generic nodes, boarding areas, routes,
  trips, stop_times, shapes, services, frequency-based trips.
- **Route types:** number of routes per type. Extended types are listed separately.
- **Service span:** first and last service date, the number of active dates, and the first
  departure and last arrival times. Times past `24:00:00` are kept as they are.
- **Trips per day:** trip count for each service date, after combining `calendar.txt` and
  `calendar_dates.txt`. Trips in `frequencies.txt` are expanded into their departures. The
  busiest date is reported separately.
- **Headways per route and direction:** minimum, median, mean and maximum headway in
  minutes, and the number of departures, on the busiest date or on `--date`. Vehicle-hours
  for the same date.
- **Coverage:** bounding box of stops and shape points (lon/lat).
- **Shapes:** percentage of trips whose `shape_id` resolves to a shape.
- **Completeness:** percentage of stops with `wheelchair_boarding`, trips with
  `wheelchair_accessible`, trips with `bikes_allowed` (value 1 or 2), and stops with
  coordinates.

## Rules

Every rule has a code (`GL` + three digits), a snake_case name, a default severity and a
link to the part of the GTFS Schedule reference it enforces. The last column gives the
matching [MobilityData validator notice](https://gtfs-validator.mobilitydata.org/rules.html),
when there is one. Row numbers are physical line numbers, with the header on line 1.

| Code | Name | Severity | Description | MobilityData notice |
|---|---|---|---|---|
| [GL001](https://gtfs.org/documentation/schedule/reference/#dataset-files) | `missing_required_file` | error | A file required by the specification (agency, routes, trips, stop_times, stops) is missing. | `missing_required_file` |
| [GL002](https://gtfs.org/documentation/schedule/reference/#dataset-files) | `missing_calendar_and_calendar_date_files` | error | Neither calendar.txt nor calendar_dates.txt is present; at least one is required. | `missing_calendar_and_calendar_date_files` |
| [GL003](https://gtfs.org/documentation/schedule/reference/#file-requirements) | `empty_file` | error | A required file is empty or has a header but no records. | `empty_file` |
| [GL004](https://gtfs.org/documentation/schedule/reference/#file-requirements) | `missing_required_column` | error | A required column is absent from the header of a file. | `missing_required_column` |
| [GL005](https://gtfs.org/documentation/schedule/reference/#file-requirements) | `duplicated_column` | error | The same column name appears more than once in a header. | `duplicated_column` |
| [GL006](https://gtfs.org/documentation/schedule/reference/#file-requirements) | `empty_column_name` | error | A header contains an empty column name. | `empty_column_name` |
| [GL007](https://gtfs.org/documentation/schedule/reference/#file-requirements) | `invalid_row_length` | error | A row has a different number of fields than the header. | `invalid_row_length` |
| [GL008](https://gtfs.org/documentation/schedule/reference/#file-requirements) | `csv_parsing_failed` | error | The file could not be parsed as UTF-8 CSV; the rest of the file was skipped. | `csv_parsing_failed` |
| [GL009](https://gtfs.org/documentation/schedule/reference/#dataset-files) | `unknown_file` | info | The feed contains a file that is not part of the GTFS Schedule specification. | `unknown_file` |
| [GL010](https://gtfs.org/documentation/schedule/reference/#file-requirements) | `unknown_column` | info | A file has a column that is not defined by the specification. | `unknown_column` |
| [GL011](https://gtfs.org/documentation/schedule/reference/#file-requirements) | `leading_or_trailing_whitespaces` | warning | A value or column name has leading or trailing whitespace (it is trimmed before the other checks). | `leading_or_trailing_whitespaces` |
| [GL012](https://gtfs.org/documentation/schedule/reference/#dataset-attributes) | `duplicate_key` | error | Two records share the same primary key (e.g. stop_id, or trip_id + stop_sequence). | `duplicate_key` |
| [GL013](https://gtfs.org/documentation/schedule/reference/#field-definitions) | `missing_required_field` | error | A required or conditionally required field is empty. | `missing_required_field` |
| [GL014](https://gtfs.org/documentation/schedule/reference/#routestxt) | `route_both_short_and_long_name_missing` | error | A route has neither route_short_name nor route_long_name. | `route_both_short_and_long_name_missing` |
| [GL015](https://gtfs.org/documentation/schedule/reference/#file-requirements) | `empty_row` | warning | A row contains only empty fields. | `empty_row` |
| [GL016](https://gtfs.org/documentation/schedule/reference/#feed_infotxt) | `missing_recommended_file` | warning | feed_info.txt is missing; it is recommended so consumers know the publisher and validity window. | `missing_recommended_file` |
| [GL101](https://gtfs.org/documentation/schedule/reference/#field-types) | `invalid_color` | error | A color is not a six-digit hexadecimal value such as FFFFFF. | `invalid_color` |
| [GL102](https://gtfs.org/documentation/schedule/reference/#field-types) | `invalid_url` | error | A URL is not a fully qualified http:// or https:// URL. | `invalid_url` |
| [GL103](https://gtfs.org/documentation/schedule/reference/#field-types) | `invalid_timezone` | error | A timezone is not a valid IANA TZ database name such as Europe/Istanbul. | `invalid_timezone` |
| [GL104](https://gtfs.org/documentation/schedule/reference/#field-types) | `invalid_date` | error | A date is not a valid calendar date in YYYYMMDD format. | `invalid_date` |
| [GL105](https://gtfs.org/documentation/schedule/reference/#field-types) | `invalid_time` | error | A time is not in H:MM:SS or HH:MM:SS format (hours above 24 are allowed). | `invalid_time` |
| [GL106](https://gtfs.org/documentation/schedule/reference/#field-types) | `invalid_integer` | error | A value that must be an integer (or enumeration) could not be parsed. | `invalid_integer` |
| [GL107](https://gtfs.org/documentation/schedule/reference/#field-types) | `invalid_float` | error | A value that must be a number could not be parsed. | `invalid_float` |
| [GL108](https://gtfs.org/documentation/schedule/reference/#field-types) | `unexpected_enum_value` | warning | An enumeration field has a value not listed by the specification. | `unexpected_enum_value` |
| [GL109](https://gtfs.org/documentation/schedule/reference/#field-types) | `number_out_of_range` | error | A number is outside its allowed range (latitude, longitude, non-negative or positive fields). | `number_out_of_range` |
| [GL110](https://gtfs.org/documentation/schedule/reference/#field-types) | `invalid_email` | error | An e-mail address is malformed. | `invalid_email` |
| [GL111](https://gtfs.org/documentation/schedule/reference/#field-types) | `invalid_language_code` | error | A language code is not a well-formed IETF BCP 47 tag. | `invalid_language_code` |
| [GL112](https://gtfs.org/documentation/schedule/reference/#field-types) | `invalid_currency` | error | A currency code is not an ISO 4217 alphabetical code. | `invalid_currency` |
| [GL113](https://gtfs.org/documentation/schedule/reference/#stopstxt) | `point_near_origin` | error | A stop or shape point lies at (0, 0) ('null island'), usually a missing coordinate. | `point_near_origin` |
| [GL114](https://gtfs.org/documentation/schedule/reference/#agencytxt) | `inconsistent_agency_timezone` | error | Agencies in agency.txt use different agency_timezone values. | `inconsistent_agency_timezone` |
| [GL115](https://gtfs.org/documentation/schedule/reference/#routestxt) | `route_color_contrast` | warning | route_color and route_text_color have a WCAG contrast ratio below 3:1. | `route_color_contrast` |
| [GL116](https://gtfs.org/documentation/schedule/reference/#field-types) | `non_ascii_or_non_printable_char` | warning | An ID contains non-ASCII or non-printable characters. | `non_ascii_or_non_printable_char` |
| [GL201](https://gtfs.org/documentation/schedule/reference/#field-definitions) | `foreign_key_violation` | error | A reference points to a record that does not exist (e.g. trips.route_id, stop_times.stop_id, stops.parent_station, transfers, fare tables). | `foreign_key_violation` |
| [GL202](https://gtfs.org/documentation/schedule/reference/#stopstxt) | `wrong_parent_location_type` | error | parent_station references a location of the wrong type (a stop's parent must be a station, a boarding area's parent must be a stop). | `wrong_parent_location_type` |
| [GL203](https://gtfs.org/documentation/schedule/reference/#stopstxt) | `station_with_parent_station` | error | A station (location_type 1) has a parent_station. | `station_with_parent_station` |
| [GL204](https://gtfs.org/documentation/schedule/reference/#stop_timestxt) | `location_with_unexpected_stop_time` | error | stop_times references a station, entrance or generic node instead of a stop or boarding area. | `location_with_unexpected_stop_time` |
| [GL301](https://gtfs.org/documentation/schedule/reference/#calendartxt) | `start_and_end_range_out_of_order` | error | A start date/time is after its end (calendar, feed_info, frequencies). | `start_and_end_range_out_of_order` |
| [GL302](https://gtfs.org/documentation/schedule/reference/#calendartxt) | `service_never_active` | warning | A service_id is never active once calendar and calendar_dates are combined. | - |
| [GL303](https://gtfs.org/documentation/schedule/reference/#calendar_datestxt) | `redundant_calendar_date` | info | A calendar_dates exception adds a date that is already active or removes one that is not. | - |
| [GL304](https://gtfs.org/documentation/schedule/reference/#calendartxt) | `expired_calendar` | warning | A service has no active dates on or after the reference date. | `expired_calendar` |
| [GL401](https://gtfs.org/documentation/schedule/reference/#stop_timestxt) | `stop_times_not_sorted` | info | The rows of a trip are not listed in increasing stop_sequence order (allowed, but slows down consumers). | - |
| [GL402](https://gtfs.org/documentation/schedule/reference/#stop_timestxt) | `stop_time_with_arrival_before_previous_departure_time` | error | A stop's arrival_time is earlier than the departure_time at the previous stop. | `stop_time_with_arrival_before_previous_departure_time` |
| [GL403](https://gtfs.org/documentation/schedule/reference/#stop_timestxt) | `stop_time_arrival_after_departure` | error | A stop's arrival_time is later than its own departure_time. | - |
| [GL404](https://gtfs.org/documentation/schedule/reference/#stop_timestxt) | `stop_time_with_only_arrival_or_departure_time` | error | Only one of arrival_time and departure_time is set. | `stop_time_with_only_arrival_or_departure_time` |
| [GL405](https://gtfs.org/documentation/schedule/reference/#stop_timestxt) | `missing_trip_edge` | error | The first or last stop of a trip has no arrival_time/departure_time. | `missing_trip_edge` |
| [GL406](https://gtfs.org/documentation/schedule/reference/#stop_timestxt) | `decreasing_or_equal_stop_time_distance` | error | shape_dist_traveled does not increase along a trip. | `decreasing_or_equal_stop_time_distance` |
| [GL407](https://gtfs.org/documentation/schedule/reference/#stop_timestxt) | `stop_time_timepoint_without_times` | error | timepoint=1 but the stop has no arrival/departure time. | `stop_time_timepoint_without_times` |
| [GL408](https://gtfs.org/documentation/schedule/reference/#stop_timestxt) | `fast_travel_between_consecutive_stops` | warning | The speed between two timed stops exceeds the plausible maximum for the route type. | `fast_travel_between_consecutive_stops` |
| [GL409](https://gtfs.org/documentation/schedule/reference/#stop_timestxt) | `unusable_trip` | warning | A trip visits fewer than two stops. | `unusable_trip` |
| [GL501](https://gtfs.org/documentation/schedule/reference/#shapestxt) | `decreasing_shape_distance` | error | shape_dist_traveled decreases along a shape. | `decreasing_shape_distance` |
| [GL502](https://gtfs.org/documentation/schedule/reference/#stopstxt) | `stops_too_close` | warning | Two different stops (not in the same station) are closer than min-stop-distance-m; often an accidental duplicate. | - |
| [GL503](https://gtfs.org/documentation/schedule/reference/#stopstxt) | `stops_too_far` | warning | Two consecutive stops of a trip are farther apart than plausible for the route type; often a wrong coordinate. | - |
| [GL504](https://gtfs.org/documentation/schedule/reference/#stopstxt) | `stop_too_far_from_parent_station` | warning | A stop, entrance or node is farther than max-parent-distance-m from its parent station. | - |
| [GL601](https://gtfs.org/documentation/schedule/reference/#stopstxt) | `stop_without_stop_time` | warning | A stop (location_type 0) is never served by stop_times. | `stop_without_stop_time` |
| [GL602](https://gtfs.org/documentation/schedule/reference/#stopstxt) | `unused_station` | info | A station has no child stops. | `unused_station` |
| [GL603](https://gtfs.org/documentation/schedule/reference/#routestxt) | `route_without_trips` | warning | A route has no trips. | - |
| [GL604](https://gtfs.org/documentation/schedule/reference/#shapestxt) | `unused_shape` | warning | A shape is not referenced by any trip. | `unused_shape` |
| [GL605](https://gtfs.org/documentation/schedule/reference/#tripstxt) | `unused_trip` | warning | A trip has no stop_times. | `unused_trip` |
| [GL606](https://gtfs.org/documentation/schedule/reference/#calendartxt) | `unused_service` | info | A service_id from calendar/calendar_dates is not used by any trip. | - |
| [GL701](https://gtfs.org/documentation/schedule/reference/#tripstxt) | `block_trips_with_overlapping_stop_times` | error | Two trips with the same block_id run at overlapping times on a shared service date. | `block_trips_with_overlapping_stop_times` |
| [GL702](https://gtfs.org/documentation/schedule/reference/#feed_infotxt) | `missing_feed_info_date` | warning | feed_info.txt has no feed_start_date or no feed_end_date. | `missing_feed_info_date` |
| [GL703](https://gtfs.org/documentation/schedule/reference/#feed_infotxt) | `feed_expiration_date7_days` | warning | feed_end_date is less than 7 days after the reference date (or already passed). | `feed_expiration_date7_days` |
| [GL704](https://gtfs.org/documentation/schedule/reference/#feed_infotxt) | `feed_expiration_date30_days` | warning | feed_end_date is between 7 and 30 days after the reference date. | `feed_expiration_date30_days` |

### Notes on rule behaviour

- **Field checks (GL101-GL116)** apply to every field that the reference defines for
  agency, stops, routes, trips, stop_times, calendar, calendar_dates, fare_attributes,
  fare_rules, shapes, frequencies, transfers, pathways, levels, feed_info, translations and
  attributions. Newer files such as Fares v2, flex and networks are recognised, so they do
  not raise `unknown_file`, but their fields are not checked yet.
- **Required fields that depend on other fields (GL013):** `stop_name`, `stop_lat` and
  `stop_lon` for `location_type` 0-2; `parent_station` for `location_type` 2-4;
  `agency_id` when there are several agencies; `stop_id` in stop_times unless
  `location_group_id` or `location_id` is set; the transfer stops for `transfer_type`
  1-3 and the transfer trips for 4-5.
- **Foreign keys (GL201):** trips → routes, calendars and shapes; stop_times → trips and
  stops; stops → parent station and levels; routes and fare_attributes → agency;
  frequencies → trips; transfers → stops, routes and trips; fare_rules → fare_attributes,
  routes and `zone_id`; pathways → stops; attributions → agency, routes and trips.
- **Travel speed (GL408)** is checked between consecutive *timed* stops. Untimed stops in
  between add to the distance. A time difference under 60 s counts as 60 s, because most
  feeds round times to the minute. The default limits in km/h are: tram 100, subway 150,
  rail 500, bus 150, ferry 80, cable tram 30, aerial lift 50, funicular 50, trolleybus 150,
  monorail 150, air 1200.
- **Stop spacing (GL503)** default maximum gap in km: 20 for urban rail and cable modes,
  100 for bus, 500 for rail and ferry.
- **Block overlaps (GL701)** are reported when two trips with the same `block_id` overlap
  in time *and* their services share at least one date. Frequency-based trips are skipped.
- **Expiration (GL304, GL703, GL704)** is measured from `--reference-date`, which defaults
  to today. Set a fixed date in CI if builds must be reproducible.

## Performance

`stop_times.txt` and `shapes.txt` are streamed one trip or one shape at a time. Small tables
are kept in compact indexes, and parsed values are cached. The file is not loaded into
memory. Producers almost always write the rows of a trip together. If a trip's rows are
scattered through the file, gtfslint finds those trips and reads them again in a second pass,
so the results stay correct.

Benchmark (`benchmarks/synthetic_feed.py`, a synthetic zipped feed with 100 routes, 50,000
trips, 2,000 stops and **1,000,000 stop_times rows**, Python 3.12 on a Windows laptop):

| Task | Time |
|---|---|
| `gtfslint.validate()` (all rules) | 6.8 s (about 150,000 rows/s) |
| `gtfslint.stats()` | 6.5 s |

## Development

```bash
pip install -e ".[dev]"
python -m pytest --cov=gtfslint
python -m ruff check . && python -m ruff format --check src tests
python -m mypy src
python benchmarks/synthetic_feed.py /tmp/gtfs-bench
```

The tests use a small synthetic feed, `tests/data/valid_feed`, which passes every rule.
`tests/test_rules.py` has one broken variant of it per rule.

## License

MIT © Yusuf Eminoğlu. See [LICENSE](LICENSE).
