Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,20 @@ jobs:
- run: pip install -e ".[dev]"
- run: pytest -q

mcp-import:
mcp-protocol:
runs-on: ubuntu-latest
strategy:
matrix:
mcp-requirement: ["mcp==1.12.0", "mcp<2"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[mcp]"
- run: python -c "import pitstop.mcp_server; print('mcp server imports OK')"
- run: pip install ".[mcp]" "$MCP_REQUIREMENT"
env:
MCP_REQUIREMENT: ${{ matrix.mcp-requirement }}
- run: python scripts/smoke-installed.py

build:
runs-on: ubuntu-latest
Expand All @@ -39,6 +44,8 @@ jobs:
python-version: "3.12"
- run: pip install build
- run: python -m build
- run: pip install --no-deps dist/*.whl
- run: python scripts/smoke-installed.py

# Offline half of the eval suite: the eval files match the shipped parser, so a
# recipe or task can never name a command the CLI cannot run. The live half
Expand Down
29 changes: 22 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,52 @@ on:
workflow_dispatch:
inputs:
version:
description: Existing tag to release, for example v1.0.2
description: Existing tag to release, for example v1.2.0
required: true
type: string

permissions:
contents: write
id-token: write

env:
RELEASE_TAG: ${{ inputs.version || github.ref_name }}

jobs:
build:
name: Build distributions
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.version || github.ref_name }}
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.version) || github.ref }}

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Check tag and package versions agree
run: |
python - <<'PY'
import os, runpy, tomllib
from pathlib import Path
tag = os.environ["RELEASE_TAG"]
package = tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]
runtime = runpy.run_path("src/pitstop/version.py")["__version__"]
if tag != f"v{package}" or runtime != package:
raise SystemExit(f"Version mismatch: tag={tag}, package={package}, runtime={runtime}")
PY

- run: python -m pip install --upgrade build twine

- run: python -m build

- run: python -m twine check dist/*

- run: python -m pip install --no-deps dist/*.whl

- run: python scripts/smoke-installed.py

- uses: actions/upload-artifact@v4
with:
name: pitstop-cli-dist
Expand All @@ -51,11 +70,7 @@ jobs:

- name: Resolve release tag
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "VERSION=${{ inputs.version }}" >> "$GITHUB_ENV"
else
echo "VERSION=${GITHUB_REF_NAME}" >> "$GITHUB_ENV"
fi
echo "VERSION=$RELEASE_TAG" >> "$GITHUB_ENV"

- name: Publish release
env:
Expand Down
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,37 @@ All notable changes to this project are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project aims
to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.2.0] - 2026-09-10

### Added

- Municipality discovery through `pitstop places` and the `find_places` MCP tool;
charger searches accept province and ISTAT ID and report the resolved location.
- Search coverage counts and cache timestamps in JSON and GeoJSON envelopes.
- Read-only MCP annotations and structured results; installed CLI/MCP checks
cover the minimum supported SDK (1.12) and the current 1.x SDK.
- Standard `--version` shortcut and charger download timeout/cache-age flags.

### Fixed

- Replace Alperia and Neogy tariff links that returned HTTP 404 with their
current official charging-offer and direct-payment pages.
- CLI and MCP searches reject non-finite or out-of-range coordinates,
nonpositive radii, and negative limits before fetching data. Invalid CLI
searches return exit code 2 instead of empty results or invalid JSON.
- Freshness filters exclude prices with missing, unparseable, or future update
dates. Unfiltered queries still include those reported prices.
- Charger municipality centers come from OSM administrative relations keyed by
ISTAT ID, correcting the misplaced Bolzano reference. Duplicate town names
require disambiguation instead of silently selecting a different province.
- Fuel queries no longer mutate shared station/price objects. Parsed datasets
and statistics are reused between MCP calls and invalidated after a refresh.
- Cache writes use unique temporary files; malformed Overpass caches are retried
and valid empty caches retain their provenance during an upstream failure.
- Charger results exclude invalid coordinates and centers outside the radius.
- Release workflows check out the requested tag and verify that tag, package,
and runtime versions agree before publication.

## [1.1.1] - 2026-09-07

### Fixed
Expand Down
34 changes: 24 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@

A JSON-first CLI and MCP server for **Italian fuel-station prices** and **EV charging stations**, designed for AI agents, scripts, and humans.

Italy publishes per-station fuel prices daily (MIMIT *Osservaprezzi Carburanti* open data) and OpenStreetMap maps every EV charger in the country, but raw access means downloading multi-megabyte CSVs, joining files, sorting through misreports, and translating between Italian comune names and the ones a user actually types. `pitstop` does all of that locally and returns a small, well-formed JSON envelope.
Italy publishes per-station fuel prices daily (MIMIT *Osservaprezzi Carburanti* open data), and OpenStreetMap provides EV charger locations and capabilities. Raw access means downloading multi-megabyte CSVs, joining files, sorting through misreports, and translating municipality names. `pitstop` handles those steps locally and returns JSON or a readable table.

Built for questions like:
- *"What's the cheapest diesel near Rome right now?"* → `pitstop stations --fuel Gasolio --near 41.9,12.5 --cheapest`
- *"Where can I fast-charge my EV in Bolzano?"* → `pitstop chargers --comune Bozen --fast`
- *"Where can I fast-charge my EV in Bolzano?"* → `pitstop chargers --near 46.498,11.354 --fast`
- *"Are these station prices statistically reliable?"* → every price says whether it was screened against its local market; screened ones carry a `regional_median`, a `deviation_pct`, and `outlier: true` when the price looks like a misreport.

International city names work out of the box (`Rome`, `Milan`, `Bozen`, `Mailand`, `Venise`, …).
Expand All @@ -26,8 +26,9 @@ Unofficial community project. Not affiliated with or endorsed by MIMIT. Fuel dat
- **Coverage:** Italy only (by design, for now).
- **Known caveats:**
- Some operators report placeholder values (e.g. `1.000`); use `--min-price` (e.g. `1.2`) to drop them.
- Some price records are **stale** (a few were last updated years ago); use `--fresh-within-days` and check the `UPDATED` column / `updated` field.
- Some stations are **mis-geocoded** in the registry. As of v0.4.0 `pitstop` joins a second data source (ISTAT-derived comune coordinates from [opendatasicilia/comuni-italiani](https://github.com/opendatasicilia/comuni-italiani), 97.5% match) to validate each station's coordinate against its declared comune's *true* location. Stations >30 km off are flagged `coordinate_suspect` (`*` in the table), and `--near` excludes stations whose declared comune is geographically too far from the query point — even single-station comuni like RASUN-ANTERSELVA. Pass `--no-comune-validate` to disable.
- Some price records are **stale** (a few were last updated years ago); use `--fresh-within-days` and check the `UPDATED` column / `updated` field. When enabled, the freshness filter also excludes missing, unparseable, and future update dates.
- Some stations are **mis-geocoded** in the registry. `pitstop` uses station clusters and a second coordinate reference from [opendatasicilia/comuni-italiani](https://github.com/opendatasicilia/comuni-italiani) to flag discrepancies over 30 km as `coordinate_suspect` (`*` in the table). `--near` also excludes stations whose declared comune is geographically too far from the query point. Pass `--no-comune-validate` to disable the second reference.
- **Municipality reference coordinates can also be wrong.** Charger searches resolve a municipality's ISTAT ID to its mapped OpenStreetMap administrative center. JSON includes the selected `location`, its source link, cache age, and warnings when it differs from the reference coordinates. Ambiguous names require `--provincia` or `--comune-id`. If no mapped center is available, supply `--near lat,lon`.

## Install

Expand Down Expand Up @@ -67,18 +68,27 @@ pitstop stations --near 46.498,11.354 --radius 5 --fuel Benzina --self --json
pitstop fuels

# Fast EV chargers (≥50 kW) within 5 km of Bolzano
pitstop chargers --near 46.498,11.354 --radius 5 --fast --json
pitstop chargers --comune Bozen --radius 5 --fast --public --json

# Discover municipality IDs and disambiguate duplicate names
pitstop places Livo --json
pitstop chargers --comune Livo --provincia TN --json
pitstop chargers --comune-id 021008 --radius 5 --fast --json
```

`stations` flags: `--comune`, `--provincia`, `--brand`, `--near "lat,lon"`, `--radius`, `--fuel` (substring, case-insensitive), `--self`, `--served`, `--cheapest` (needs `--fuel`), `--min-price` (drop values below a floor; e.g. `1.2` to skip placeholders), `--fresh-within-days` (drop stale prices), `--max-deviation-pct` (drop prices more than N% below their fuel's provincial median — catches misreports), `--no-comune-validate`, `--limit`, `--json`. Loading flags (`--refresh`, `--max-age`, `--timeout`) apply to the MIMIT data commands (`stations`, `fuels`, `stats`); `chargers` uses its own OSM cache and takes only `--refresh`.
`stations` flags: `--comune`, `--provincia`, `--brand`, `--near "lat,lon"`, `--radius`, `--fuel` (substring, case-insensitive), `--self`, `--served`, `--cheapest` (needs `--fuel`), `--min-price`, `--fresh-within-days`, `--max-deviation-pct`, `--no-comune-validate`, `--limit`, `--json`, `--geojson`. Choose one output format. `--limit 0` returns every match.

Distances and radii are straight-line measurements, not driving distances or travel times.

Loading flags (`--refresh`, `--max-age` in seconds, `--timeout` in seconds) apply to MIMIT commands and charger searches. The default caches last 24 hours for MIMIT, seven days for OSM, and 30 days for the municipality reference. `--refresh` also refreshes a charger's municipality lookup; `--max-age` controls its charger results. `--max-age 0` accepts cached files of any age. `places` supports `--refresh` and `--timeout`.

Every returned price carries a `median_basis`. A `screened` price also carries `regional_median` and `deviation_pct`, plus `outlier: true` when it is >15% below the local median **or** below the Tukey lower fence Q1−1.5·IQR (the Tukey rule catches misreports in tight markets that the percent rule alone misses). The `outlier` key is emitted **only when it is true**, so read it as optional. Pass `--drop-outliers` to remove flagged prices entirely.

A price is `unscreened` when its (fuel, provincia) bucket holds too few samples for a median, so **no outlier check ran on it** and it is returned as reported (the table marks these `~`). The `--json` envelope's `quality` block counts screened vs unscreened prices for the answer you got.

## MCP server

For agents that speak MCP, the same data is exposed as tools (`list_fuels`, `find_stations`, `find_cheapest`, `find_chargers`, `get_stats`) over the shared core:
For agents that speak MCP, the same data is exposed as six tools (`list_fuels`, `find_stations`, `find_cheapest`, `find_chargers`, `find_places`, `get_stats`) over the shared core. Tools advertise read-only behavior and return structured JSON alongside text. Repeated fuel searches reuse parsed files and provincial statistics until the cached files change.

```bash
pip install "pitstop-cli[mcp]" # or: uv tool install "pitstop-cli[mcp]"
Expand All @@ -98,19 +108,23 @@ Machine-readable command recipes, with the caveats that belong with each answer,
```bash
pip install -e ".[dev]"
pytest -q
python scripts/smoke-installed.py # installed entry points and stdio MCP, offline
```

## Automation contract

- `stdout` is command output; `stderr` is diagnostics.
- `--json` emits a stable object with `source`, `*_extraction_date`, `generated_at`, `query`, `count`, `quality`, `stations[]`, and `disclaimer`.
- `stations --json` emits a stable object with `source`, `*_extraction_date`, `generated_at`, `query`, `count`, `quality`, `stations[]`, and `disclaimer`.
- Fuel and charger search envelopes include `coverage`: `fetched_count`, `matched_count` before the limit, `returned_count`, and `truncated`. These counts describe the downloaded data; OSM coverage is not an exhaustive charger inventory.
- `freshness` records local `fetched_at` and `age_seconds`: separately for the fuel registry and prices, or for the charger response. Charger `cache_status` is `hit`, `miss`, `stale_fallback`, `partial`, or `unavailable`. A partial/unavailable response has no successful fetch timestamp; an `error` explains degraded results. Fetch time does not establish current prices or charger availability.
- Charger municipality searches add `location` with the selected name, province, ISTAT ID, coordinates, provenance, and warnings. GeoJSON carries these envelope fields under `metadata`.
- Exit codes: `0` success, `1` runtime error, `2` usage error.
- Source files are cached (default 24h) under `$XDG_CACHE_HOME/pitstop`; use `--refresh` to bypass.
- Source files are cached under `$XDG_CACHE_HOME/pitstop` (or `~/.cache/pitstop`); use `--refresh` to bypass.
- Non-interactive; no hidden browser state or scraping.

## Status & roadmap

v1.1.1 — stable public release: fuel-price core (registry+price join, filters, proximity, cheapest, `--min-price` floor, `--fresh-within-days` freshness, combined 15% + Tukey IQR outlier rule, ISTAT comune-coordinate validation, JSON) + **EV charging stations via OSM Overpass** (operator, plug types, max kW, fee, access — `pitstop chargers`) + **operator tariff-page URLs** attached to each EV result. Includes **multi-fuel query support**, **international municipality mapping** (EN/FR/DE), **macro price statistics** (`pitstop stats`), and **navigation/GeoJSON support**. MCP server, agent skill, tests, CI.
v1.2.0 adds municipality discovery, province and ISTAT-ID selection for chargers, search coverage and cache timestamps, and structured MCP results. Fuel prices, EV charger locations, statistics, navigation links, and GeoJSON remain available through the CLI and MCP server. See [CHANGELOG.md](CHANGELOG.md) for fixes and compatibility details.

Planned, roughly in order:
- per-station **EV tariff data** if a source `pitstop` can read starts publishing per-kWh prices (today it parses only OSM's `fee` yes/no flag, no price field);
Expand Down
Loading
Loading