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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ For plotting capabilities (`png` and `svg` output), install the `plot` extra:
uv tool install "meteostat-cli[plot]"
```

To [contribute to the weather stations directory](https://dev.meteostat.net/cli/stations/contributing) using `meteo station add`, `edit`, `validate` and `build`, install the `contrib` extra:

```bash
uv tool install "meteostat-cli[contrib]"
```

For full provider support, use:

```bash
uv tool install "meteostat-cli[providers]"
```

Alternatively, you can use `uvx`:

```bash
Expand Down Expand Up @@ -81,6 +93,18 @@ Please refer to the [official documentation](https://dev.meteostat.net/cli) for

Please read our [contributing guidelines](https://dev.meteostat.net/contributing) for details on how to contribute to Meteostat.

### Local Development

```bash
uv tool install --force -e ".[plot,contrib,providers]"
```

You can now run the CLI locally using:

```bash
meteo [YOUR_COMMAND]
```

## 📄 License

The Meteostat CLI is licensed under the [**MIT License**](https://github.com/meteostat/cli/blob/main/LICENSE). Data provided by Meteostat is licensed under [**Creative Commons Attribution 4.0 International (CC BY 4.0)**](https://creativecommons.org/licenses/by/4.0). See the [documentation](https://dev.meteostat.net/license) for details.
Expand Down
2 changes: 1 addition & 1 deletion meteo/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Meteostat CLI — Access weather and climate data through the terminal."""

__version__ = "0.3.0"
__version__ = "0.4.0"
32 changes: 29 additions & 3 deletions meteo/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,46 @@ def callback(
apply_config()


STATION_EPILOG = (
"Contributor commands: add, edit, delete, validate, duplicates, build. "
"Run 'meteo station COMMAND --help' for details."
)


def _register_commands() -> None:
"""Register all command modules with the Typer app."""
from meteo.commands.config import config_cmd
from meteo.commands.contrib.add import add_cmd
from meteo.commands.contrib.build import build_cmd
from meteo.commands.contrib.delete import delete_cmd
from meteo.commands.contrib.duplicates import duplicates_cmd
from meteo.commands.contrib.edit import edit_cmd
from meteo.commands.contrib.validate import validate_cmd
from meteo.commands.daily import daily_cmd, daily_cmd_alias
from meteo.commands.hourly import hourly_cmd, hourly_cmd_alias
from meteo.commands.inventory import inventory_cmd, inventory_cmd_alias
from meteo.commands.monthly import monthly_cmd, monthly_cmd_alias
from meteo.commands.nearby import nearby_cmd
from meteo.commands.normals import normals_cmd, normals_cmd_alias
from meteo.commands.station import station_cmd
from meteo.commands.station import LookupCommand, StationGroup, station_cmd

station_app = typer.Typer(cls=StationGroup)
station_app.command(
StationGroup.default_command,
cls=LookupCommand,
hidden=True,
epilog=STATION_EPILOG,
)(station_cmd)
station_app.command("add")(add_cmd)
station_app.command("edit")(edit_cmd)
station_app.command("delete")(delete_cmd)
station_app.command("validate")(validate_cmd)
station_app.command("duplicates")(duplicates_cmd)
station_app.command("build")(build_cmd)

app.command("config")(config_cmd)
app.command("station")(station_cmd)
app.command("s", hidden=True)(station_cmd)
app.add_typer(station_app, name="station", help=station_cmd.__doc__)
app.add_typer(station_app, name="s", hidden=True)
app.command("nearby")(nearby_cmd)
app.command("inventory")(inventory_cmd)
app.command("i", hidden=True)(inventory_cmd_alias)
Expand Down
44 changes: 44 additions & 0 deletions meteo/commands/contrib/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Contributor commands for the weather stations repository."""

from typing import Any

import typer

from meteo.contrib.validation import Report

REPO_HELP = "Path to the stations repository (overrides stations_repo)."


def echo_report(report: Report, quiet: bool = False) -> None:
"""Print the validation result of a station."""
for error in report.errors:
typer.echo(f"{typer.style('✗', fg='red')} {report.station_id} {error}")
for warning in report.warnings:
typer.echo(f"{typer.style('!', fg='yellow')} {report.station_id} {warning}")
if report.valid and not report.warnings and not quiet:
typer.echo(f"{typer.style('✓', fg='green')} {report.station_id}")


def plural(count: int, word: str) -> str:
return f"{count} {word}" if count == 1 else f"{count} {word}s"


def print_station(data: dict[str, Any]) -> None:
"""Print a station record in the same way as `meteo station ID`."""
from meteo.commands.station import print_metadata

location = data.get("location") or {}
names = data.get("name") or {}
print_metadata(
{
"id": data.get("id"),
"name": names.get("en"),
"country": data.get("country"),
"region": data.get("region"),
"latitude": location.get("latitude"),
"longitude": location.get("longitude"),
"elevation": location.get("elevation"),
"timezone": data.get("timezone"),
**(data.get("identifiers") or {}),
}
)
171 changes: 171 additions & 0 deletions meteo/commands/contrib/add.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
"""Add station command for the Meteostat CLI."""

import re
from typing import Any

import typer

from meteo.commands.contrib import REPO_HELP, echo_report
from meteo.contrib.duplicates import find_duplicates
from meteo.contrib.repo import (
generate_id,
load_stations,
resolve_repo,
serialize,
station_path,
write_station,
)
from meteo.contrib.validation import Validator

LOCALIZED_NAME = re.compile(r"^([a-z]{2})=(.+)$")


def parse_names(values: list[str]) -> dict[str, str]:
"""Parse --name values. `LANG=NAME` sets a localized name, anything else English."""
names = {}
for value in values:
match = LOCALIZED_NAME.match(value)
lang, name = match.groups() if match else ("en", value)
names[lang] = name.strip()
return names


def parse_identifiers(values: list[str]) -> dict[str, str]:
"""Parse KEY=VALUE identifiers."""
identifiers = {}
for value in values:
key, sep, ident = value.partition("=")
if not sep or not key.strip() or not ident.strip():
raise typer.BadParameter(
f"Identifiers must be KEY=VALUE, got '{value}'.", param_hint="--id"
)
identifiers[key.strip().lower()] = ident.strip()
return identifiers


def add_cmd(
name: list[str] | None = typer.Option(
None,
"--name",
"-n",
help="Station name. Use LANG=NAME for a localized name (repeatable).",
),
country: str | None = typer.Option(
None, "--country", "-c", help="ISO 3166-1 alpha-2 country code."
),
region: str | None = typer.Option(
None, "--region", help="ISO 3166-2 state or region code."
),
lat: float | None = typer.Option(None, "--lat", help="Latitude."),
lon: float | None = typer.Option(None, "--lon", help="Longitude."),
elevation: int | None = typer.Option(
None, "--elevation", "-e", help="Elevation in meters."
),
timezone: str | None = typer.Option(
None, "--timezone", "-t", help="IANA time zone (e.g. Europe/Berlin)."
),
identifier: list[str] | None = typer.Option(
None,
"--id",
"-i",
help="Station identifier as KEY=VALUE, e.g. wmo=10637 (repeatable).",
),
yes: bool = typer.Option(
False,
"--yes",
"-y",
help="Don't prompt. Fail on missing properties and skip duplicate confirmation.",
),
dry_run: bool = typer.Option(
False, "--dry-run", help="Print the resulting JSON without writing any file."
),
repo: str | None = typer.Option(None, "--repo", help=REPO_HELP),
) -> None:
"""Add a new weather station."""
repo_path = resolve_repo(repo)

names = parse_names(name or [])
identifiers = parse_identifiers(identifier or [])

if yes:
missing = [
option
for option, value in (
("--name", names.get("en")),
("--country", country),
("--lat", lat),
("--lon", lon),
("--elevation", elevation),
("--timezone", timezone),
)
if value is None
]
if missing:
typer.echo(f"Error: Missing option(s): {', '.join(missing)}", err=True)
raise typer.Exit(2)
else:
if "en" not in names:
names["en"] = typer.prompt("Name (English)").strip()
if country is None:
country = typer.prompt("Country (ISO 3166-1 alpha-2)")
if region is None:
region = typer.prompt(
"Region (ISO 3166-2, optional)", default="", show_default=False
)
if lat is None:
lat = typer.prompt("Latitude", type=float)
if lon is None:
lon = typer.prompt("Longitude", type=float)
if elevation is None:
elevation = typer.prompt("Elevation (m)", type=int)
if timezone is None:
timezone = typer.prompt("Time zone (e.g. Europe/Berlin)")
if not identifier:
value = typer.prompt(
"Identifiers (KEY=VALUE, comma-separated, optional)",
default="",
show_default=False,
)
identifiers = parse_identifiers(
[item for item in value.split(",") if item.strip()]
)

station_id = generate_id(repo_path)
data: dict[str, Any] = {
"id": station_id,
"name": dict(sorted(names.items())),
"country": (country or "").strip().upper(),
"region": region.strip().upper() if region and region.strip() else None,
"identifiers": identifiers,
"location": {"latitude": lat, "longitude": lon, "elevation": elevation},
"timezone": (timezone or "").strip(),
}

stations = load_stations(repo_path)
report = Validator(stations).validate(station_id, data)
if not report.valid:
echo_report(report)
raise typer.Exit(1)

stations[station_id] = data
duplicates = find_duplicates(stations, ids={station_id})
if duplicates:
typer.echo("Potential duplicates:", err=True)
for dup in duplicates:
other = dup.station_b if dup.station_a == station_id else dup.station_a
other_name = stations[other].get("name", {}).get("en", "")
dist = f"{dup.distance} m, " if dup.distance is not None else ""
typer.echo(
f" {other} {other_name} ({dist}{', '.join(dup.reasons)})",
err=True,
)
Comment thread
Copilot marked this conversation as resolved.
if not yes and not dry_run:
typer.confirm("Create station anyway?", abort=True, err=True)

if dry_run:
typer.echo(serialize(data))
return

path = station_path(repo_path, station_id)
write_station(path, data)
typer.echo(f"Created station {station_id} ({path.relative_to(repo_path)})")
48 changes: 48 additions & 0 deletions meteo/commands/contrib/build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Build station database command for the Meteostat CLI."""

from pathlib import Path

import typer

from meteo.commands.contrib import REPO_HELP, echo_report, plural
from meteo.contrib.database import build_database
from meteo.contrib.repo import load_stations, resolve_repo, station_files
from meteo.contrib.validation import Validator


def build_cmd(
output: str | None = typer.Option(
None,
"--output",
"-o",
help="Output file path (default: stations.db in the repository root).",
),
skip_validation: bool = typer.Option(
False, "--skip-validation", help="Don't validate stations before building."
),
repo: str | None = typer.Option(None, "--repo", help=REPO_HELP),
) -> None:
"""Build a SQLite database from the station files."""
repo_path = resolve_repo(repo)
output_path = Path(output).expanduser() if output else repo_path / "stations.db"
stations = load_stations(repo_path)

skipped = 0
if not skip_validation:
validator = Validator(stations)
for path in station_files(repo_path):
report = validator.validate_file(path)
if not report.valid:
echo_report(report, quiet=True)
stations.pop(path.stem, None)
skipped += 1

count, failed = build_database(stations, output_path)
for station_id, reason in failed:
typer.echo(f"{typer.style('✗', fg='red')} {station_id} {reason}")

skipped += len(failed)
summary = f"Built {output_path} with {plural(count, 'station')}"
if skipped:
summary += f" ({skipped} skipped)"
typer.echo(summary)
28 changes: 28 additions & 0 deletions meteo/commands/contrib/delete.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Delete station command for the Meteostat CLI."""

import typer

from meteo.commands.contrib import REPO_HELP, print_station
from meteo.contrib.repo import read_station, require_station, resolve_repo


def delete_cmd(
station_id: str = typer.Argument(..., help="Meteostat station ID."),
yes: bool = typer.Option(
False, "--yes", "-y", help="Skip the confirmation prompt."
),
repo: str | None = typer.Option(None, "--repo", help=REPO_HELP),
) -> None:
"""Remove a weather station."""
repo_path = resolve_repo(repo)
path = require_station(repo_path, station_id)

if not yes:
try:
print_station(read_station(path))
except ValueError:
typer.echo(f"Warning: {path.name} contains invalid JSON.", err=True)
typer.confirm(f"Delete station {path.stem}?", abort=True)

path.unlink()
typer.echo(f"Deleted station {path.stem} ({path.relative_to(repo_path)})")
Loading
Loading