diff --git a/README.md b/README.md index 490785b..cad3702 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/meteo/__init__.py b/meteo/__init__.py index 5036960..6e6060b 100644 --- a/meteo/__init__.py +++ b/meteo/__init__.py @@ -1,3 +1,3 @@ """Meteostat CLI — Access weather and climate data through the terminal.""" -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/meteo/cli.py b/meteo/cli.py index 6db26fc..da7c183 100644 --- a/meteo/cli.py +++ b/meteo/cli.py @@ -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) diff --git a/meteo/commands/contrib/__init__.py b/meteo/commands/contrib/__init__.py new file mode 100644 index 0000000..1d992a6 --- /dev/null +++ b/meteo/commands/contrib/__init__.py @@ -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 {}), + } + ) diff --git a/meteo/commands/contrib/add.py b/meteo/commands/contrib/add.py new file mode 100644 index 0000000..40228c0 --- /dev/null +++ b/meteo/commands/contrib/add.py @@ -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, + ) + 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)})") diff --git a/meteo/commands/contrib/build.py b/meteo/commands/contrib/build.py new file mode 100644 index 0000000..cf407b0 --- /dev/null +++ b/meteo/commands/contrib/build.py @@ -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) diff --git a/meteo/commands/contrib/delete.py b/meteo/commands/contrib/delete.py new file mode 100644 index 0000000..3f9e52f --- /dev/null +++ b/meteo/commands/contrib/delete.py @@ -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)})") diff --git a/meteo/commands/contrib/duplicates.py b/meteo/commands/contrib/duplicates.py new file mode 100644 index 0000000..5709319 --- /dev/null +++ b/meteo/commands/contrib/duplicates.py @@ -0,0 +1,64 @@ +"""Find duplicate stations command for the Meteostat CLI.""" + +import pandas as pd +import typer + +from meteo.commands.contrib import REPO_HELP +from meteo.contrib.duplicates import find_duplicates +from meteo.contrib.repo import load_stations, resolve_repo +from meteo.utils import detect_format, output_df + + +def duplicates_cmd( + station_ids: list[str] | None = typer.Option( + None, "--id", help="Only report duplicates of the given station (repeatable)." + ), + country: str | None = typer.Option( + None, "--country", "-c", help="Only check stations in the given country." + ), + radius: int = typer.Option( + 1000, "--radius", "-r", help="Maximum distance in meters for location matches." + ), + fmt: str | None = typer.Option(None, "--format", "-f", help="Output format."), + output: str | None = typer.Option(None, "--output", "-o", help="Output file path."), + no_header: bool = typer.Option( + False, "--no-header", help="Omit header row from CSV output." + ), + show_all: bool = typer.Option( + False, "--all", "-A", help="Print full table instead of truncated display." + ), + repo: str | None = typer.Option(None, "--repo", help=REPO_HELP), +) -> None: + """Find potential duplicate stations.""" + repo_path = resolve_repo(repo) + stations = load_stations(repo_path) + + if country: + stations = { + station_id: data + for station_id, data in stations.items() + if str(data.get("country", "")).upper() == country.upper() + } + + ids = {station_id.upper() for station_id in station_ids} if station_ids else None + duplicates = find_duplicates(stations, radius=radius, ids=ids) + + if not duplicates: + typer.echo("No potential duplicates found.", err=True) + return + + df = pd.DataFrame( + [ + { + "station_a": dup.station_a, + "station_b": dup.station_b, + "distance": dup.distance, + "reasons": ", ".join(dup.reasons), + } + for dup in duplicates + ] + ).set_index(["station_a", "station_b"]) + + actual_fmt = detect_format(fmt, output) + output_df(df, actual_fmt, output, no_header, show_all=show_all) + raise typer.Exit(1) diff --git a/meteo/commands/contrib/edit.py b/meteo/commands/contrib/edit.py new file mode 100644 index 0000000..fba0473 --- /dev/null +++ b/meteo/commands/contrib/edit.py @@ -0,0 +1,177 @@ +"""Edit station command for the Meteostat CLI.""" + +import json +import os +import shlex +import subprocess +import tempfile +from pathlib import Path +from typing import Any + +import typer + +from meteo.commands.contrib import REPO_HELP, echo_report +from meteo.contrib.repo import ( + load_stations, + require_station, + resolve_repo, + serialize, + write_station, +) +from meteo.contrib.validation import Report, Validator, load_schema + + +def schema_types(schema: dict[str, Any], key: str) -> set[str]: + """Get the allowed JSON types of a property in dot notation.""" + node = schema + for part in key.split("."): + properties = node.get("properties", {}) + if part in properties: + node = properties[part] + elif isinstance(node.get("additionalProperties"), dict): + node = node["additionalProperties"] + else: + return set() + types = node.get("type", []) + return {types} if isinstance(types, str) else set(types) + + +def parse_value(value: str, types: set[str]) -> Any: + """Convert a --set value to the type expected by the schema.""" + if value == "null" and "null" in types: + return None + if "string" in types: + return value + try: + return json.loads(value) + except ValueError: + return value + + +def _check_key(key: str, option: str) -> None: + if not key or "" in key.split("."): + raise typer.BadParameter(f"Invalid property '{key}'.", param_hint=option) + if key.split(".")[0] == "id": + raise typer.BadParameter( + "The Meteostat ID cannot be changed.", param_hint=option + ) + + +def set_property(data: dict[str, Any], key: str, value: Any) -> None: + """Set a property using dot notation, creating objects as needed.""" + *parents, last = key.split(".") + node = data + for part in parents: + node = node.setdefault(part, {}) + if not isinstance(node, dict): + raise typer.BadParameter(f"'{part}' is not an object.", param_hint="--set") + node[last] = value + + +def unset_property(data: dict[str, Any], key: str) -> None: + """Remove a property using dot notation.""" + *parents, last = key.split(".") + node: Any = data + for part in parents: + node = node.get(part) if isinstance(node, dict) else None + if not isinstance(node, dict) or last not in node: + raise typer.BadParameter(f"Property '{key}' is not set.", param_hint="--unset") + del node[last] + + +def launch_editor(text: str, filename: str, editor: str | None) -> str: + """Open text in an editor and return the edited text.""" + command = ( + editor + or os.environ.get("VISUAL") + or os.environ.get("EDITOR") + or ("notepad" if os.name == "nt" else "vi") + ) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / filename + path.write_text(text, encoding="utf-8") + try: + subprocess.run( + [*shlex.split(command, posix=os.name != "nt"), str(path)], check=True + ) + except (OSError, subprocess.CalledProcessError) as exc: + typer.echo(f"Error: Could not run editor '{command}': {exc}", err=True) + raise typer.Exit(1) from None + return path.read_text(encoding="utf-8") + + +def edit_cmd( + station_id: str = typer.Argument(..., help="Meteostat station ID."), + set_: list[str] | None = typer.Option( + None, + "--set", + "-s", + help="Set a property as KEY=VALUE using dot notation (repeatable).", + ), + unset: list[str] | None = typer.Option( + None, "--unset", "-u", help="Remove an optional property (repeatable)." + ), + editor: str | None = typer.Option( + None, "--editor", help="Editor command to use instead of $VISUAL/$EDITOR." + ), + dry_run: bool = typer.Option( + False, "--dry-run", help="Print the resulting JSON without writing the file." + ), + repo: str | None = typer.Option(None, "--repo", help=REPO_HELP), +) -> None: + """Edit an existing weather station.""" + repo_path = resolve_repo(repo) + path = require_station(repo_path, station_id) + station_id = path.stem + raw = path.read_text(encoding="utf-8") + validator = Validator(load_stations(repo_path)) + + if set_ or unset: + try: + data = json.loads(raw) + except ValueError as exc: + typer.echo(f"Error: {path.name} contains invalid JSON: {exc}", err=True) + raise typer.Exit(1) from None + schema = load_schema() + for item in set_ or []: + key, sep, value = item.partition("=") + if not sep: + raise typer.BadParameter( + f"Expected KEY=VALUE, got '{item}'.", param_hint="--set" + ) + _check_key(key, "--set") + set_property(data, key, parse_value(value, schema_types(schema, key))) + for key in unset or []: + _check_key(key, "--unset") + unset_property(data, key) + + report = validator.validate(station_id, data) + if not report.valid: + echo_report(report) + raise typer.Exit(1) + else: + text = raw + while True: + edited = launch_editor(text, path.name, editor) + if edited.strip() == raw.strip(): + typer.echo("No changes.") + return + try: + data = json.loads(edited) + report = validator.validate(station_id, data) + except ValueError as exc: + report = Report(station_id, errors=[f"Invalid JSON: {exc}"]) + if report.valid: + break + echo_report(report) + if not typer.confirm("Re-open the editor?", default=True): + typer.echo("Changes discarded.") + raise typer.Exit(1) + text = edited + + if dry_run: + typer.echo(serialize(data)) + return + + write_station(path, data) + typer.echo(f"Updated station {station_id} ({path.relative_to(repo_path)})") diff --git a/meteo/commands/contrib/validate.py b/meteo/commands/contrib/validate.py new file mode 100644 index 0000000..a37fc36 --- /dev/null +++ b/meteo/commands/contrib/validate.py @@ -0,0 +1,78 @@ +"""Validate stations command for the Meteostat CLI.""" + +import typer + +from meteo.commands.contrib import REPO_HELP, echo_report, plural +from meteo.contrib.repo import ( + changed_station_ids, + load_stations, + resolve_repo, + station_files, + station_path, +) +from meteo.contrib.validation import Report, Validator, load_schema, read_and_fix + + +def validate_cmd( + station_ids: list[str] | None = typer.Argument( + None, help="Station IDs to validate. Validates all stations if omitted." + ), + changed: bool = typer.Option( + False, + "--changed", + help="Only validate stations changed compared to main (requires Git).", + ), + fix: bool = typer.Option( + False, "--fix", help="Automatically fix issues where possible." + ), + quiet: bool = typer.Option( + False, "--quiet", "-q", help="Only print stations with errors or warnings." + ), + repo: str | None = typer.Option(None, "--repo", help=REPO_HELP), +) -> None: + """Check station records for errors.""" + repo_path = resolve_repo(repo) + + if station_ids or changed: + ids = {station_id.upper() for station_id in station_ids or []} + if changed: + ids.update(changed_station_ids(repo_path)) + if not ids: + typer.echo("No changed stations.") + return + paths = [station_path(repo_path, station_id) for station_id in sorted(ids)] + else: + paths = list(station_files(repo_path)) + + fixed = 0 + if fix: + schema = load_schema() + fixed = sum(read_and_fix(path, schema) for path in paths if path.is_file()) + + validator = Validator(load_stations(repo_path)) + + invalid = errors = warnings = 0 + for path in paths: + if path.is_file(): + report = validator.validate_file(path) + else: + report = Report(path.stem, errors=["Station not found"]) + echo_report(report, quiet) + invalid += not report.valid + errors += len(report.errors) + warnings += len(report.warnings) + + typer.echo() + if fix: + typer.echo(f"Fixed {plural(fixed, 'station')}") + details = ", ".join( + plural(count, word) + for count, word in ((errors, "error"), (warnings, "warning")) + if count + ) + details = f" ({details})" if details else "" + if invalid: + typer.echo(f"{invalid} of {plural(len(paths), 'station')} invalid{details}") + raise typer.Exit(1) + prefix = "All " if len(paths) > 1 else "" + typer.echo(f"{prefix}{plural(len(paths), 'station')} valid{details}") diff --git a/meteo/commands/station.py b/meteo/commands/station.py index 5aa290e..78622c5 100644 --- a/meteo/commands/station.py +++ b/meteo/commands/station.py @@ -1,11 +1,67 @@ """Station command for the Meteostat CLI.""" +from typing import Any + import pandas as pd import typer +from typer.core import TyperCommand, TyperGroup from meteo.utils import detect_format, output_df +class _LookupContext(typer.Context): + @property + def command_path(self) -> str: + # The lookup command has no name of its own + return super().command_path.rstrip() + + +class LookupCommand(TyperCommand): + """Station lookup, invoked as ``meteo station`` (see StationGroup).""" + + context_class = _LookupContext + + +class StationGroup(TyperGroup): + """Group of station commands. + + Runs the lookup command unless the first argument names a subcommand, so + ``meteo station [ID] [OPTIONS]`` and ``meteo station add`` can coexist. + """ + + default_command = "lookup" + + def parse_args(self, ctx: Any, args: list[str]) -> list[str]: + if not args or args[0] not in self.commands: + args = [self.default_command, *args] + return super().parse_args(ctx, args) + + def resolve_command( + self, ctx: Any, args: list[str] + ) -> tuple[str | None, Any, list[str]]: + name, cmd, rest = super().resolve_command(ctx, args) + # The lookup command is presented as `meteo station` itself + if name == self.default_command: + name = None + return name, cmd, rest + + +def print_metadata(data: dict[str, Any]) -> None: + """Print station metadata as a key/value table.""" + from rich.console import Console + from rich.table import Table + + table = Table(show_header=False, show_lines=True, padding=(0, 2, 0, 0)) + table.add_column(style="bold") + table.add_column() + for field, value in data.items(): + display = str(value) if value is not None else "" + if field == "elevation" and value is not None: + display = f"{value} m" + table.add_row(field, display) + Console().print(table) + + def station_cmd( station_id: str | None = typer.Argument( None, help="Meteostat station ID. If omitted, lists stations." @@ -64,18 +120,7 @@ def station_cmd( df = pd.DataFrame([data]).set_index("id") if actual_fmt == "text" and output is None: - from rich.console import Console - from rich.table import Table - - table = Table(show_header=False, show_lines=True, padding=(0, 2, 0, 0)) - table.add_column(style="bold") - table.add_column() - for field, value in data.items(): - display = str(value) if value is not None else "" - if field == "elevation" and value is not None: - display = f"{value} m" - table.add_row(field, display) - Console().print(table) + print_metadata(data) else: output_df(df, actual_fmt, output, no_header, show_all=show_all) else: diff --git a/meteo/config.py b/meteo/config.py index 2e57420..39d17c1 100644 --- a/meteo/config.py +++ b/meteo/config.py @@ -12,10 +12,11 @@ import yaml # CLI-specific config keys (not part of Meteostat library config) -CLI_CONFIG_KEYS = { +CLI_CONFIG_KEYS: dict[str, Any] = { "interpolation_radius": 25000, "interpolation_station_count": 10, "humanize": True, + "stations_repo": None, } @@ -113,7 +114,7 @@ def coerce_value(key: str, value: str) -> Any: if key in CLI_CONFIG_KEYS: # CLI-specific keys use their default type default = CLI_CONFIG_KEYS[key] - target_type: type | None = type(default) + target_type: type | None = str if default is None else type(default) else: target_type = get_config_type(key) diff --git a/meteo/contrib/__init__.py b/meteo/contrib/__init__.py new file mode 100644 index 0000000..b7108b3 --- /dev/null +++ b/meteo/contrib/__init__.py @@ -0,0 +1,4 @@ +"""Helpers for contributing to the Meteostat weather stations repository. + +See https://github.com/meteostat/weather-stations for the repository layout. +""" diff --git a/meteo/contrib/database.py b/meteo/contrib/database.py new file mode 100644 index 0000000..df6b92a --- /dev/null +++ b/meteo/contrib/database.py @@ -0,0 +1,103 @@ +"""Build a SQLite database from station files.""" + +import sqlite3 +from pathlib import Path +from typing import Any + +TABLES_SQL = """ +CREATE TABLE `stations` ( + `id` char(5) NOT NULL PRIMARY KEY, + `country` varchar(2) DEFAULT NULL, + `region` varchar(5) DEFAULT NULL, + `latitude` float(8,4) DEFAULT NULL, + `longitude` float(8,4) DEFAULT NULL, + `elevation` int(4) DEFAULT NULL, + `timezone` varchar(30) DEFAULT NULL +); + +CREATE TABLE `names` ( + `station` char(5) NOT NULL, + `language` char(2) NOT NULL, + `name` varchar(255) NOT NULL, + PRIMARY KEY (`station`, `language`) +); + +CREATE TABLE `identifiers` ( + `station` char(5) NOT NULL, + `key` varchar(255) NOT NULL, + `value` varchar(255) NOT NULL, + PRIMARY KEY (`station`, `key`) +); +""" + + +def build_database( + stations: dict[str, dict[str, Any]], output: Path +) -> tuple[int, list[tuple[str, str]]]: + """Write stations to a new SQLite database at ``output``. + + Inactive stations are left out, like in the official database. The file is + only replaced once the database was built successfully. + + Returns the number of stations written and a list of (id, reason) for + stations which could not be written. + """ + output.parent.mkdir(parents=True, exist_ok=True) + tmp = output.with_name(f"{output.name}.tmp") + tmp.unlink(missing_ok=True) + + count = 0 + failed: list[tuple[str, str]] = [] + conn = sqlite3.connect(tmp, isolation_level=None) + try: + conn.executescript(TABLES_SQL) + conn.execute("BEGIN") + for station_id, data in stations.items(): + if data.get("active", True) is False: + continue + # Roll back partial inserts of stations which can't be written + conn.execute("SAVEPOINT station") + try: + _insert(conn, data) + count += 1 + except (KeyError, TypeError, AttributeError, sqlite3.Error) as exc: + conn.execute("ROLLBACK TO station") + failed.append((station_id, _describe(exc))) + conn.execute("RELEASE station") + conn.execute("COMMIT") + finally: + conn.close() + + tmp.replace(output) + return count, failed + + +def _insert(conn: sqlite3.Connection, data: dict[str, Any]) -> None: + station_id = data["id"] + location = data["location"] + conn.execute( + "INSERT INTO `stations` VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + station_id, + data["country"], + data.get("region"), + location["latitude"], + location["longitude"], + location["elevation"], + data["timezone"], + ), + ) + conn.executemany( + "INSERT INTO `names` VALUES (?, ?, ?)", + [(station_id, lang, name) for lang, name in data["name"].items()], + ) + conn.executemany( + "INSERT INTO `identifiers` VALUES (?, ?, ?)", + [(station_id, key, value) for key, value in data["identifiers"].items()], + ) + + +def _describe(exc: Exception) -> str: + if isinstance(exc, KeyError): + return f"Missing property {exc}" + return f"Could not be written: {exc}" diff --git a/meteo/contrib/duplicates.py b/meteo/contrib/duplicates.py new file mode 100644 index 0000000..998f395 --- /dev/null +++ b/meteo/contrib/duplicates.py @@ -0,0 +1,133 @@ +"""Detection of potential duplicate stations.""" + +import math +import re +from dataclasses import dataclass +from difflib import SequenceMatcher +from itertools import combinations +from typing import Any + +from meteo.contrib.repo import index_identifiers + +EARTH_RADIUS = 6_371_000 # meters +NAME_SIMILARITY = 0.8 + + +@dataclass +class Duplicate: + """A pair of potentially duplicate stations.""" + + station_a: str + station_b: str + distance: int | None + reasons: list[str] + + +def distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Great-circle distance between two points in meters (Haversine).""" + phi1, phi2 = math.radians(lat1), math.radians(lat2) + dphi = phi2 - phi1 + dlambda = math.radians(lon2 - lon1) + a = ( + math.sin(dphi / 2) ** 2 + + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2 + ) + return 2 * EARTH_RADIUS * math.asin(math.sqrt(a)) + + +def _normalize(name: str) -> str: + return re.sub(r"[\W_]+", " ", name.lower()).strip() + + +def similar_names(a: dict[str, Any], b: dict[str, Any]) -> bool: + """Check whether two stations have a similar name in any common language.""" + names_a, names_b = a.get("name"), b.get("name") + if not isinstance(names_a, dict) or not isinstance(names_b, dict): + return False + for lang in names_a.keys() & names_b.keys(): + x, y = _normalize(str(names_a[lang])), _normalize(str(names_b[lang])) + if not x or not y: + continue + if x in y or y in x or SequenceMatcher(None, x, y).ratio() >= NAME_SIMILARITY: + return True + return False + + +def _coords(data: dict[str, Any]) -> tuple[float, float] | None: + location = data.get("location") + if not isinstance(location, dict): + return None + lat, lon = location.get("latitude"), location.get("longitude") + if not isinstance(lat, int | float) or not isinstance(lon, int | float): + return None + return float(lat), float(lon) + + +def _shared_identifiers( + stations: dict[str, dict[str, Any]], +) -> dict[tuple[str, str], list[str]]: + """Map pairs of stations to the identifier keys they share.""" + shared: dict[tuple[str, str], list[str]] = {} + for (key, _), ids in index_identifiers(stations).items(): + for pair in combinations(sorted(ids), 2): + shared.setdefault(pair, []).append(key) + return shared + + +def _nearby_pairs( + stations: dict[str, dict[str, Any]], radius: float +) -> dict[tuple[str, str], float]: + """Find all pairs of stations within the given radius (in meters).""" + points = sorted( + (coords, station_id) + for station_id, data in stations.items() + if (coords := _coords(data)) is not None + ) + # Latitude difference which corresponds to the radius + max_dlat = math.degrees(radius / EARTH_RADIUS) + + pairs = {} + for i, ((lat1, lon1), id1) in enumerate(points): + for (lat2, lon2), id2 in points[i + 1 :]: + if lat2 - lat1 > max_dlat: + break + dist = distance(lat1, lon1, lat2, lon2) + if dist <= radius: + pairs[tuple(sorted((id1, id2)))] = dist + return pairs + + +def find_duplicates( + stations: dict[str, dict[str, Any]], + radius: float = 1000, + ids: set[str] | None = None, +) -> list[Duplicate]: + """Find potential duplicates. + + Two stations are potential duplicates if they share an identifier, or if + they are within ``radius`` meters of each other and have similar names. + If ``ids`` is given, only duplicates involving those stations are returned. + """ + shared = _shared_identifiers(stations) + nearby = _nearby_pairs(stations, radius) + + duplicates = [] + for pair in sorted(shared.keys() | nearby.keys()): + if ids is not None and not ids.intersection(pair): + continue + a, b = (stations[station_id] for station_id in pair) + reasons = sorted(set(shared.get(pair, []))) + if pair in nearby: + reasons.append("location") + if similar_names(a, b): + reasons.append("name") + if pair not in shared and "name" not in reasons: + continue + coords_a, coords_b = _coords(a), _coords(b) + dist = ( + round(distance(*coords_a, *coords_b)) + if coords_a is not None and coords_b is not None + else None + ) + duplicates.append(Duplicate(*pair, dist, reasons)) + return duplicates diff --git a/meteo/contrib/repo.py b/meteo/contrib/repo.py new file mode 100644 index 0000000..8b22c13 --- /dev/null +++ b/meteo/contrib/repo.py @@ -0,0 +1,181 @@ +"""Access to a local clone of the weather stations repository.""" + +import json +import random +import re +import string +import subprocess +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import typer + +from meteo.config import load_config + +STATIONS_DIR = "stations" +ID_PATTERN = re.compile(r"^[A-Z0-9$]{5}$") +ID_ALPHABET = string.ascii_uppercase + string.digits + +# Canonical order of top-level properties in a station file +KEY_ORDER = ( + "id", + "active", + "name", + "country", + "region", + "identifiers", + "location", + "timezone", +) + + +def is_repo(path: Path) -> bool: + """Check whether a directory looks like a stations repository.""" + return (path / STATIONS_DIR).is_dir() + + +def resolve_repo(repo: str | None) -> Path: + """Resolve the stations repository from --repo, config or the working directory.""" + configured = load_config().get("stations_repo") + source = repo or configured + + if source: + path = Path(source).expanduser().resolve() + if not is_repo(path): + typer.echo( + f"Error: '{path}' is not a stations repository " + f"(no '{STATIONS_DIR}' directory found).", + err=True, + ) + raise typer.Exit(2) + return path + + cwd = Path.cwd() + if is_repo(cwd): + return cwd + + typer.echo( + "Error: No stations repository found. Pass --repo or set it with " + "'meteo config stations_repo PATH'.", + err=True, + ) + raise typer.Exit(2) + + +def station_path(repo: Path, station_id: str) -> Path: + """Get the file path of a station.""" + return repo / STATIONS_DIR / f"{station_id}.json" + + +def station_files(repo: Path) -> Iterator[Path]: + """Iterate over all station files in the repository.""" + yield from sorted((repo / STATIONS_DIR).glob("*.json")) + + +def require_station(repo: Path, station_id: str) -> Path: + """Get the file path of an existing station or exit with an error.""" + path = station_path(repo, station_id.upper()) + if not path.is_file(): + typer.echo( + f"Error: Station '{station_id}' not found in {repo / STATIONS_DIR}.", + err=True, + ) + raise typer.Exit(1) + return path + + +def serialize(data: dict[str, Any]) -> str: + """Serialize station data the same way as the files in the repository.""" + return json.dumps(data, indent=4, ensure_ascii=False) + + +def read_station(path: Path) -> dict[str, Any]: + """Read and parse a station file.""" + return json.loads(path.read_text(encoding="utf-8")) + + +def write_station(path: Path, data: dict[str, Any]) -> None: + """Write station data to a file.""" + path.write_text(serialize(data), encoding="utf-8") + + +def load_stations(repo: Path) -> dict[str, dict[str, Any]]: + """Load all parseable stations, keyed by their file name.""" + stations = {} + for path in station_files(repo): + try: + data = read_station(path) + except (OSError, ValueError): + continue + if isinstance(data, dict): + stations[path.stem] = data + return stations + + +def index_identifiers( + stations: dict[str, dict[str, Any]], +) -> dict[tuple[str, str], set[str]]: + """Map each (key, value) identifier to the stations using it.""" + index: dict[tuple[str, str], set[str]] = {} + for station_id, data in stations.items(): + identifiers = data.get("identifiers") + if not isinstance(identifiers, dict): + continue + for key, value in identifiers.items(): + index.setdefault((key, str(value)), set()).add(station_id) + return index + + +def order_keys(data: dict[str, Any]) -> dict[str, Any]: + """Sort top-level properties in canonical order, keeping unknown ones last.""" + known = {key: data[key] for key in KEY_ORDER if key in data} + return known | {key: value for key, value in data.items() if key not in known} + + +def generate_id(repo: Path) -> str: + """Generate a random, unused Meteostat ID. + + Numeric IDs are reserved for stations with a WMO ID, so generated IDs + always contain at least one letter. + """ + while True: + candidate = "".join(random.choices(ID_ALPHABET, k=5)) + if candidate.isdigit() or station_path(repo, candidate).exists(): + continue + return candidate + + +def changed_station_ids(repo: Path) -> list[str]: + """List stations which were changed compared to the main branch.""" + + def git(*args: str) -> str: + return subprocess.run( + ["git", "-C", str(repo), *args], + capture_output=True, + text=True, + check=True, + ).stdout + + try: + for ref in ("main", "origin/main"): + try: + base = git("merge-base", ref, "HEAD").strip() + break + except subprocess.CalledProcessError: + continue + else: + typer.echo("Error: Could not find a 'main' branch to compare to.", err=True) + raise typer.Exit(2) + # Committed and uncommitted changes, plus new files + changed = git( + "diff", "--name-only", "--diff-filter=d", base, "--", STATIONS_DIR + ).splitlines() + changed += git( + "ls-files", "--others", "--exclude-standard", "--", STATIONS_DIR + ).splitlines() + except FileNotFoundError: + typer.echo("Error: --changed requires Git to be installed.", err=True) + raise typer.Exit(2) from None + + return sorted({Path(f).stem for f in changed if f.endswith(".json")}) diff --git a/meteo/contrib/validation.py b/meteo/contrib/validation.py new file mode 100644 index 0000000..38624e6 --- /dev/null +++ b/meteo/contrib/validation.py @@ -0,0 +1,236 @@ +"""Validation of station records. + +Requires the ``contrib`` extra (jsonschema and pycountry). +""" + +import copy +import functools +import json +import urllib.request +import zoneinfo +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import typer + +from meteo.contrib.repo import ID_PATTERN, index_identifiers, order_keys, serialize + +SCHEMA_URL = ( + "https://raw.githubusercontent.com/meteostat/weather-stations/" + "refs/heads/main/schema.json" +) + + +@dataclass +class Report: + """Validation result of a single station.""" + + station_id: str + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + @property + def valid(self) -> bool: + return not self.errors + + +@functools.cache +def _fetch_schema() -> dict[str, Any]: + try: + with urllib.request.urlopen(SCHEMA_URL, timeout=30) as response: + return json.load(response) + except (OSError, ValueError) as exc: + typer.echo(f"Error: Could not load schema from {SCHEMA_URL}: {exc}", err=True) + raise typer.Exit(1) from None + + +def load_schema() -> dict[str, Any]: + """Load the station schema from the upstream weather-stations repository.""" + return copy.deepcopy(_fetch_schema()) + + +def _quote(value: Any) -> str: + return json.dumps(value, ensure_ascii=False) + + +def _schema_message(error: Any) -> str: + """Format a jsonschema validation error.""" + path = ".".join(str(p) for p in error.absolute_path) + schema = error.schema + if ( + error.validator in ("minimum", "maximum") + and "minimum" in schema + and "maximum" in schema + ): + message = ( + f"{error.instance} is out of range " + f"({schema['minimum']} to {schema['maximum']})" + ) + else: + message = error.message + return f"{path}: {message}" if path else message + + +class Validator: + """Validate stations against the schema and the rest of the directory.""" + + def __init__(self, stations: dict[str, dict[str, Any]]) -> None: + try: + import jsonschema + import pycountry + except ImportError: + typer.echo( + "Error: Additional dependencies are required for this command. " + 'Install with: uv tool install "meteostat-cli[contrib]"', + err=True, + ) + raise typer.Exit(1) from None + + self._pycountry = pycountry + self._schema = jsonschema.Draft7Validator(load_schema()) + self._timezones = zoneinfo.available_timezones() + self._identifiers = index_identifiers(stations) + + def validate_file(self, path: Path) -> Report: + """Validate a station file, including its formatting.""" + report = Report(path.stem) + try: + raw = path.read_text(encoding="utf-8") + data = json.loads(raw) + except (OSError, ValueError) as exc: + report.errors.append(f"Invalid JSON: {exc}") + return report + + report = self.validate(path.stem, data) + if isinstance(data, dict) and serialize(data) != raw.rstrip(): + report.warnings.append("File is not formatted consistently") + return report + + def validate(self, station_id: str, data: Any) -> Report: + """Validate station data which is stored under the given ID.""" + report = Report(station_id) + errors = report.errors + + if not ID_PATTERN.match(station_id): + errors.append(f"File name {_quote(station_id)} is not a valid Meteostat ID") + + errors.extend(_schema_message(e) for e in self._schema.iter_errors(data)) + if not isinstance(data, dict): + return report + + if "id" in data and data["id"] != station_id: + errors.append(f"id: {_quote(data['id'])} does not match the file name") + + names = data.get("name") + if isinstance(names, dict): + for lang, name in names.items(): + if not isinstance(name, str): + continue + if name != name.strip(): + errors.append( + f"name.{lang}: {_quote(name)} has leading or trailing spaces" + ) + if name[:1].islower(): + errors.append(f"name.{lang}: {_quote(name)} should be capitalized") + + country = data.get("country") + region = data.get("region") + if isinstance(country, str): + if self._pycountry.countries.get(alpha_2=country) is None: + errors.append( + f"country: {_quote(country)} is not a valid ISO 3166-1 alpha-2 code" + ) + elif ( + isinstance(region, str) + and self._pycountry.subdivisions.get(code=f"{country}-{region}") is None + ): + errors.append( + f"region: {_quote(region)} is not a valid " + f"ISO 3166-2 subdivision of {country}" + ) + + timezone = data.get("timezone") + if isinstance(timezone, str) and timezone not in self._timezones: + errors.append(f"timezone: {_quote(timezone)} is not a valid IANA time zone") + + identifiers = data.get("identifiers") + if isinstance(identifiers, dict): + for key, value in identifiers.items(): + others = self._identifiers.get((key, str(value)), set()) - {station_id} + if others: + errors.append( + f"identifiers.{key}: {_quote(value)} is also used by " + + ", ".join(sorted(others)) + ) + + return report + + +def _remove_additional_properties(data: Any, schema: dict[str, Any]) -> None: + """Remove properties which the schema does not allow, recursively.""" + if not isinstance(data, dict): + return + properties = schema.get("properties", {}) + if schema.get("additionalProperties") is False: + for key in [key for key in data if key not in properties]: + del data[key] + for key, subschema in properties.items(): + if key in data: + _remove_additional_properties(data[key], subschema) + + +def fix_station( + station_id: str, data: dict[str, Any], schema: dict[str, Any] +) -> dict[str, Any]: + """Fix common formatting issues of a station record.""" + data = copy.deepcopy(data) + _remove_additional_properties(data, schema) + + if ID_PATTERN.match(station_id): + data["id"] = station_id + + for key in ("country", "region", "timezone"): + if isinstance(data.get(key), str): + data[key] = data[key].strip() + for key in ("country", "region"): + if isinstance(data.get(key), str): + data[key] = data[key].upper() + + names = data.get("name") + if isinstance(names, dict): + for lang, name in names.items(): + if isinstance(name, str): + name = name.strip() + names[lang] = name[:1].upper() + name[1:] + + identifiers = data.get("identifiers") + if isinstance(identifiers, dict): + for key, value in identifiers.items(): + if isinstance(value, int | str) and not isinstance(value, bool): + identifiers[key] = str(value).strip() + data["identifiers"] = dict(sorted(identifiers.items())) + + location = data.get("location") + if isinstance(location, dict): + elevation = location.get("elevation") + if isinstance(elevation, float) and elevation.is_integer(): + location["elevation"] = int(elevation) + + return order_keys(data) + + +def read_and_fix(path: Path, schema: dict[str, Any]) -> bool: + """Fix a station file in place. Returns whether the file was changed.""" + try: + raw = path.read_text(encoding="utf-8") + data = json.loads(raw) + except (OSError, ValueError): + return False + if not isinstance(data, dict): + return False + fixed = serialize(fix_station(path.stem, data, schema)) + if fixed == raw: + return False + path.write_text(fixed, encoding="utf-8") + return True diff --git a/meteo/utils.py b/meteo/utils.py index 8809165..aaf6ca5 100644 --- a/meteo/utils.py +++ b/meteo/utils.py @@ -5,7 +5,7 @@ """ import sys -from datetime import date, datetime, timedelta +from datetime import date, datetime, time, timedelta from pathlib import Path from typing import Any, Literal @@ -68,13 +68,16 @@ def parse_date(value: str | None, is_end: bool = False) -> date | datetime | Non def parse_datetime(value: str | None, is_end: bool = False) -> datetime | None: - """Parse a date string and return a datetime object.""" + """Parse a date string and return a datetime object. + + Dates without a time component start at 00:00, or end at 23:59:59 if is_end. + """ result = parse_date(value, is_end) if result is None: return None if isinstance(result, datetime): return result - return datetime(result.year, result.month, result.day) + return datetime.combine(result, time.max if is_end else time.min) def resolve_station_or_point( diff --git a/pyproject.toml b/pyproject.toml index b66274e..d13f49a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "meteostat-cli" -version = "0.3.0" +version = "0.4.0" description = "Access weather and climate data through the terminal." authors = [{ name = "Meteostat", email = "info@meteostat.net" }] readme = "README.md" @@ -23,12 +23,20 @@ classifiers = [ ] dependencies = [ "typer>=0.15.0", - "meteostat>=2.1.4,<3", + "meteostat>=2.1.5,<3", "pyyaml>=6.0", ] [project.optional-dependencies] plot = ["matplotlib>=3.9.0"] +contrib = [ + "jsonschema>=4.20.0", + "pycountry>=24.6.1", +] +providers = [ + "metar>=1.11.0", + "lxml>=5.3.0", +] [project.scripts] meteo = "meteo.cli:main" @@ -58,6 +66,9 @@ line-length = 88 select = ["E", "F", "I", "B", "UP", "SIM", "C4", "PIE"] ignore = ["E501"] +[tool.ruff.lint.flake8-bugbear] +extend-immutable-calls = ["typer.Argument", "typer.Option"] + [tool.ruff.lint.isort] known-first-party = ["meteo"] diff --git a/tests/conftest.py b/tests/conftest.py index 2e183ab..9ccf19b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,8 +31,10 @@ def runner(): def invoke(runner): """Shortcut to invoke CLI commands and return the result.""" - def _invoke(*args: str, catch_exceptions: bool = False): - result = runner.invoke(app, list(args), catch_exceptions=catch_exceptions) + def _invoke(*args: str, catch_exceptions: bool = False, input: str | None = None): + result = runner.invoke( + app, list(args), catch_exceptions=catch_exceptions, input=input + ) return _StrippedResult(result) return _invoke diff --git a/tests/test_contrib.py b/tests/test_contrib.py new file mode 100644 index 0000000..ea6c6a5 --- /dev/null +++ b/tests/test_contrib.py @@ -0,0 +1,597 @@ +"""Tests for the contributor commands (meteo station add/edit/delete/...).""" + +import copy +import json +import shlex +import sqlite3 +import subprocess +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest +import typer + +from meteo.commands.contrib.edit import launch_editor +from meteo.config import coerce_value, save_config +from meteo.contrib.duplicates import distance, find_duplicates +from meteo.contrib.repo import generate_id, serialize +from meteo.contrib.validation import fix_station, load_schema + +FRANKFURT = { + "id": "10637", + "name": {"de": "Frankfurt Flughafen", "en": "Frankfurt Airport"}, + "country": "DE", + "region": "HE", + "identifiers": {"icao": "EDDF", "wmo": "10637"}, + "location": {"latitude": 50.05, "longitude": 8.6, "elevation": 111}, + "timezone": "Europe/Berlin", +} + +BERLIN = { + "id": "10384", + "name": {"en": "Berlin Tempelhof"}, + "country": "DE", + "region": "BE", + "identifiers": {"wmo": "10384"}, + "location": {"latitude": 52.4667, "longitude": 13.4, "elevation": 48}, + "timezone": "Europe/Berlin", +} + + +def write(repo: Path, data: dict, station_id: str | None = None) -> Path: + path = repo / "stations" / f"{station_id or data['id']}.json" + path.write_text(serialize(data), encoding="utf-8") + return path + + +def read(repo: Path, station_id: str) -> dict: + return json.loads((repo / "stations" / f"{station_id}.json").read_text()) + + +@pytest.fixture(autouse=True) +def config_path(tmp_path, monkeypatch): + """Isolate the CLI config file from the user's config.""" + path = tmp_path / "config" / "cli.yml" + monkeypatch.setattr("meteo.config._get_config_path", lambda: path) + return path + + +@pytest.fixture +def repo(tmp_path): + """A stations repository with two valid stations.""" + root = tmp_path / "weather-stations" + (root / "stations").mkdir(parents=True) + write(root, FRANKFURT) + write(root, BERLIN) + return root + + +@pytest.fixture +def run(invoke, repo): + """Invoke `meteo station ...` against the test repository.""" + + def _run(*args: str, input: str | None = None): + return invoke("station", *args, "--repo", str(repo), input=input) + + return _run + + +class TestRouting: + def test_lookup_still_works(self, invoke): + with patch("meteostat.stations") as mock_stations: + mock_stations.meta.return_value = None + result = invoke("station", "99999") + assert result.exit_code == 1 + assert "not found" in result.output + + def test_lookup_without_id(self, invoke): + with patch("meteostat.stations") as mock_stations: + mock_stations.query.return_value = None + result = invoke("station", "--country", "DE") + mock_stations.query.assert_called_once() + assert result.exit_code == 1 + + def test_help_lists_contributor_commands(self, invoke): + result = invoke("station", "--help") + assert result.exit_code == 0 + assert "Usage: meteo station [OPTIONS]" in result.output + assert "validate" in result.output + + @pytest.mark.parametrize( + "command", ["add", "edit", "delete", "validate", "duplicates", "build"] + ) + def test_subcommand_help(self, invoke, command): + result = invoke("s", command, "--help") + assert result.exit_code == 0 + assert f"Usage: meteo s {command}" in result.output + assert "--repo" in result.output + + +class TestRepository: + def test_config_key(self): + assert coerce_value("stations_repo", "~/code/weather-stations") == ( + "~/code/weather-stations" + ) + assert coerce_value("stations_repo", "123") == "123" + + def test_configured_repo(self, invoke, repo, config_path): + save_config({"stations_repo": str(repo)}) + result = invoke("station", "validate", "10637") + assert result.exit_code == 0 + + def test_working_directory(self, invoke, repo, monkeypatch): + monkeypatch.chdir(repo) + result = invoke("station", "validate", "10637") + assert result.exit_code == 0 + + def test_no_repo(self, invoke, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = invoke("station", "validate") + assert result.exit_code == 2 + assert "No stations repository found" in result.output + + def test_invalid_repo(self, invoke, tmp_path): + result = invoke("station", "validate", "--repo", str(tmp_path)) + assert result.exit_code == 2 + assert "not a stations repository" in result.output + + def test_generate_id(self, repo): + for _ in range(50): + station_id = generate_id(repo) + assert len(station_id) == 5 + assert not station_id.isdigit() + assert not (repo / "stations" / f"{station_id}.json").exists() + + def test_missing_extra(self, run): + with patch.dict(sys.modules, {"jsonschema": None}): + result = run("validate") + assert result.exit_code == 1 + assert "meteostat-cli[contrib]" in result.output + + +class TestValidate: + def test_all_valid(self, run): + result = run("validate") + assert result.exit_code == 0 + assert "✓ 10384" in result.output + assert "✓ 10637" in result.output + assert "All 2 stations valid" in result.output + + def test_errors(self, run, repo): + write( + repo, + FRANKFURT + | { + "id": "0A1B2", + "name": {"en": "frankfurt downtown"}, + "region": "XX", + "identifiers": {"wmo": "10637"}, + "location": {"latitude": 95.2, "longitude": 8.6, "elevation": 100}, + "timezone": "Europa/Frankfurt", + }, + ) + result = run("validate", "0A1B2", "10384") + assert result.exit_code == 1 + output = result.output + assert "✗ 0A1B2 location.latitude: 95.2 is out of range (-90 to 90)" in output + assert 'timezone: "Europa/Frankfurt" is not a valid IANA time zone' in output + assert 'name.en: "frankfurt downtown" should be capitalized' in output + assert 'region: "XX" is not a valid ISO 3166-2 subdivision of DE' in output + assert 'identifiers.wmo: "10637" is also used by 10637' in output + assert "✓ 10384" in output + assert "1 of 2 stations invalid (5 errors)" in output + + def test_schema_errors(self, run, repo): + data = dict(FRANKFURT) + del data["timezone"] + write(repo, data | {"country": "de", "foo": 1}) + result = run("validate", "10637") + assert result.exit_code == 1 + assert "'timezone' is a required property" in result.output + assert "country: 'de' does not match" in result.output + assert "Additional properties are not allowed" in result.output + + def test_id_mismatch_and_invalid_json(self, run, repo): + write(repo, FRANKFURT, "AAAAA") + (repo / "stations" / "BBBBB.json").write_text("{") + result = run("validate", "AAAAA", "BBBBB", "CCCCC") + assert result.exit_code == 1 + assert 'AAAAA id: "10637" does not match the file name' in result.output + assert "BBBBB Invalid JSON" in result.output + assert "CCCCC Station not found" in result.output + + def test_quiet_and_warnings(self, run, repo): + (repo / "stations" / "10637.json").write_text(json.dumps(FRANKFURT)) + result = run("validate", "-q") + assert result.exit_code == 0 + assert "! 10637 File is not formatted consistently" in result.output + assert "10384" not in result.output + assert "All 2 stations valid (1 warning)" in result.output + + def test_fix(self, run, repo): + broken = FRANKFURT | { + "name": {"en": " frankfurt Airport"}, + "country": "de", + "region": "he", + "identifiers": {"wmo": 10637, "icao": "EDDF"}, + "location": { + "latitude": 50.05, + "longitude": 8.6, + "elevation": 111.0, + "height": 2, + }, + "active": True, + } + (repo / "stations" / "10637.json").write_text( + json.dumps({"timezone": "Europe/Berlin"} | broken | {"id": "X"}) + ) + result = run("validate", "--fix") + assert result.exit_code == 0, result.output + assert "Fixed 1 station" in result.output + raw = (repo / "stations" / "10637.json").read_text() + assert raw == serialize(FRANKFURT | {"name": {"en": "Frankfurt Airport"}}) + + def test_fix_station_keeps_valid_data(self, repo): + assert fix_station("10637", FRANKFURT, load_schema()) == FRANKFURT + + def test_changed(self, run, repo): + def git(*args): + subprocess.run( + ["git", "-C", str(repo), *args], check=True, capture_output=True + ) + + git("init", "-q", "-b", "main") + git("add", ".") + git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "init") + git("checkout", "-qb", "feature") + + result = run("validate", "--changed") + assert result.exit_code == 0 + assert "No changed stations" in result.output + + write(repo, BERLIN | {"name": {"en": "berlin"}}) + write(repo, FRANKFURT | {"id": "0A1B2", "identifiers": {}}) + result = run("validate", "--changed") + assert result.exit_code == 1 + assert "✓ 0A1B2" in result.output + assert "✗ 10384" in result.output + assert "10637" not in result.output + + def test_changed_without_git(self, run): + result = run("validate", "--changed") + assert result.exit_code == 2 + + +class TestAdd: + ARGS = ( + "--name", + "Frankfurt Downtown", + "--name", + "de=Frankfurt Innenstadt", + "--country", + "de", + "--region", + "HE", + "--lat", + "50.11", + "--lon", + "8.68", + "--elevation", + "100", + "--timezone", + "Europe/Berlin", + "--id", + "national=1234", + ) + + def test_non_interactive(self, run, repo): + result = run("add", *self.ARGS, "--yes") + assert result.exit_code == 0, result.output + station_id = result.output.split()[2] + assert f"(stations/{station_id}.json)" in result.output + assert read(repo, station_id) == { + "id": station_id, + "name": {"de": "Frankfurt Innenstadt", "en": "Frankfurt Downtown"}, + "country": "DE", + "region": "HE", + "identifiers": {"national": "1234"}, + "location": {"latitude": 50.11, "longitude": 8.68, "elevation": 100}, + "timezone": "Europe/Berlin", + } + + def test_dry_run(self, run, repo): + result = run("add", *self.ARGS, "--yes", "--dry-run") + assert result.exit_code == 0 + assert json.loads(result.output)["country"] == "DE" + assert len(list((repo / "stations").iterdir())) == 2 + + def test_interactive(self, run, repo): + result = run( + "add", + input="Frankfurt Downtown\nDE\n\n50.11\n8.68\n100\nEurope/Berlin\nwmo=D1234\n", + ) + assert result.exit_code == 0, result.output + station_id = result.output.split("Created station ")[1].split()[0] + data = read(repo, station_id) + assert data["region"] is None + assert data["identifiers"] == {"wmo": "D1234"} + + def test_options_are_not_prompted(self, run, repo): + args = [a for a in self.ARGS if a not in ("--region", "HE")] + result = run("add", *args, input="\n") + assert result.exit_code == 0, result.output + assert "Region" in result.output + assert "Latitude" not in result.output + assert "Identifiers" not in result.output + + def test_missing_options(self, run): + result = run("add", "--name", "Test", "--yes") + assert result.exit_code == 2 + assert "--country, --lat, --lon, --elevation, --timezone" in result.output + + def test_invalid(self, run, repo): + args = list(self.ARGS) + args[args.index("Europe/Berlin")] = "Europe/Frankfurt" + result = run("add", *args, "--id", "wmo=10637", "--yes") + assert result.exit_code == 1 + assert "not a valid IANA time zone" in result.output + assert "is also used by 10637" in result.output + assert len(list((repo / "stations").iterdir())) == 2 + + def test_invalid_identifier(self, run): + result = run("add", *self.ARGS, "--id", "wmo", "--yes") + assert result.exit_code == 2 + + def test_duplicate_confirmation(self, run, repo): + args = [ + "--name", + "Frankfurt Airport West", + "--country", + "DE", + "--region", + "HE", + "--lat", + "50.051", + "--lon", + "8.601", + "--elevation", + "111", + "--timezone", + "Europe/Berlin", + "--id", + "icao=EDDX", + ] + result = run("add", *args, input="n\n") + assert result.exit_code == 1 + assert "10637 Frankfurt Airport (132 m, location, name)" in result.output + assert len(list((repo / "stations").iterdir())) == 2 + + result = run("add", *args, "--yes") + assert result.exit_code == 0 + assert len(list((repo / "stations").iterdir())) == 3 + + +EDITOR = "meteo.commands.contrib.edit.launch_editor" + + +class TestEdit: + def test_set_and_unset(self, run, repo): + result = run( + "edit", + "10637", + "--set", + "name.en=Frankfurt/Main Airport", + "--set", + "identifiers.wmo=10638", + "--set", + "location.elevation=112", + "--unset", + "identifiers.icao", + ) + assert result.exit_code == 0, result.output + assert "Updated station 10637 (stations/10637.json)" in result.output + data = read(repo, "10637") + assert data["name"]["en"] == "Frankfurt/Main Airport" + assert data["identifiers"] == {"wmo": "10638"} + assert data["location"]["elevation"] == 112 + + def test_set_null(self, run, repo): + result = run("edit", "10637", "--set", "region=null") + assert result.exit_code == 0 + assert read(repo, "10637")["region"] is None + + def test_invalid_change_is_not_written(self, run, repo): + result = run("edit", "10637", "--set", "location.latitude=95") + assert result.exit_code == 1 + assert "out of range" in result.output + assert read(repo, "10637") == FRANKFURT + + def test_id_cannot_be_changed(self, run): + result = run("edit", "10637", "--set", "id=10638") + assert result.exit_code == 2 + assert "cannot be changed" in result.output + + def test_unset_missing(self, run): + result = run("edit", "10637", "--unset", "identifiers.iata") + assert result.exit_code == 2 + assert "not set" in result.output + + def test_dry_run(self, run, repo): + result = run("edit", "10637", "--set", "name.fr=Aéroport", "--dry-run") + assert result.exit_code == 0 + assert json.loads(result.output)["name"]["fr"] == "Aéroport" + assert read(repo, "10637") == FRANKFURT + + def test_not_found(self, run): + result = run("edit", "ZZZZZ", "--set", "name.en=X") + assert result.exit_code == 1 + assert "not found" in result.output + + def test_editor(self, run, repo): + edited = serialize(FRANKFURT | {"timezone": "Europe/Paris"}) + "\n" + with patch(EDITOR, return_value=edited) as edit: + result = run("edit", "10637", "--editor", "nano") + assert result.exit_code == 0, result.output + assert edit.call_args.args[1:] == ("10637.json", "nano") + assert read(repo, "10637")["timezone"] == "Europe/Paris" + + def test_editor_no_changes(self, run): + with patch(EDITOR, side_effect=lambda text, *_: text): + result = run("edit", "10637") + assert result.exit_code == 0 + assert "No changes" in result.output + + def test_editor_reopen_and_discard(self, run, repo): + invalid = serialize(FRANKFURT | {"timezone": "Mars/Olympus"}) + with patch(EDITOR, return_value=invalid) as edit: + result = run("edit", "10637", input="y\nn\n") + assert result.exit_code == 1 + assert edit.call_count == 2 + # The second editor session starts with the previous edits + assert edit.call_args.args[0] == invalid + assert "Changes discarded" in result.output + assert read(repo, "10637") == FRANKFURT + + def test_launch_editor(self, tmp_path): + script = tmp_path / "editor.py" + script.write_text( + "import sys, pathlib\n" + "path = pathlib.Path(sys.argv[-1])\n" + "path.write_text(path.name + ':' + path.read_text())\n" + ) + editor = f"{shlex.quote(sys.executable)} {shlex.quote(str(script))}" + assert launch_editor("{}", "10637.json", editor) == "10637.json:{}" + + def test_launch_editor_fails(self): + with pytest.raises(typer.Exit): + launch_editor("{}", "10637.json", "/nonexistent/editor") + + +class TestDelete: + def test_confirm(self, run, repo): + result = run("delete", "10637", input="y\n") + assert result.exit_code == 0 + assert "Frankfurt Airport" in result.output + assert "Deleted station 10637" in result.output + assert not (repo / "stations" / "10637.json").exists() + + def test_abort(self, run, repo): + result = run("delete", "10637", input="n\n") + assert result.exit_code == 1 + assert (repo / "stations" / "10637.json").exists() + + def test_yes(self, run, repo): + result = run("delete", "10637", "--yes") + assert result.exit_code == 0 + assert not (repo / "stations" / "10637.json").exists() + + +class TestDuplicates: + @pytest.fixture + def duplicates(self, repo): + # Shares the WMO ID with 10637 and has a similar name + write(repo, FRANKFURT | {"id": "0A1B2", "identifiers": {"wmo": "10637"}}) + # Close to 10637 with a similar name + write( + repo, + FRANKFURT + | { + "id": "D4X9K", + "name": {"en": "Frankfurt Airport (Aut)"}, + "identifiers": {}, + "location": {"latitude": 50.052, "longitude": 8.6, "elevation": 1}, + }, + ) + # Close to 10637, but with a different name + write( + repo, + FRANKFURT + | { + "id": "E5Y0L", + "name": {"en": "Kelsterbach"}, + "identifiers": {}, + "location": {"latitude": 50.051, "longitude": 8.6, "elevation": 1}, + }, + ) + + def test_csv(self, run, duplicates): + result = run("duplicates", "-f", "csv") + assert result.exit_code == 1 + assert result.output.splitlines() == [ + "station_a,station_b,distance,reasons", + '0A1B2,10637,0,"wmo, location, name"', + '0A1B2,D4X9K,222,"location, name"', + '10637,D4X9K,222,"location, name"', + ] + + def test_radius(self, run, duplicates): + result = run("duplicates", "-r", "100", "-f", "json") + assert result.exit_code == 1 + assert json.loads(result.output) == [ + { + "station_a": "0A1B2", + "station_b": "10637", + "distance": 0, + "reasons": "wmo, location, name", + } + ] + + def test_filters(self, run, duplicates): + result = run("duplicates", "--id", "d4x9k", "-f", "csv", "--no-header") + assert len(result.output.splitlines()) == 2 + result = run("duplicates", "--country", "FR") + assert result.exit_code == 0 + + def test_none(self, run): + result = run("duplicates") + assert result.exit_code == 0 + assert "No potential duplicates found" in result.output + + def test_find_duplicates_same_name_far_away(self): + far = BERLIN | {"id": "AAAAA", "identifiers": {}, "name": FRANKFURT["name"]} + assert find_duplicates({"10637": FRANKFURT, "AAAAA": far}) == [] + + def test_distance(self): + assert distance(50.05, 8.6, 52.4667, 13.4) == pytest.approx(428_541, abs=1) + + +class TestBuild: + def test_build(self, run, repo, monkeypatch): + # Make sure the schema allows the `active` property + schema = load_schema() + schema["properties"]["active"] = {"type": "boolean"} + monkeypatch.setattr( + "meteo.contrib.validation.load_schema", lambda: copy.deepcopy(schema) + ) + write(repo, BERLIN | {"id": "INACT", "identifiers": {}, "active": False}) + write(repo, FRANKFURT | {"id": "ACTIV", "identifiers": {}, "active": True}) + write(repo, FRANKFURT | {"id": "BAD00", "identifiers": {}, "timezone": "X"}) + result = run("build") + assert result.exit_code == 0, result.output + assert "✗ BAD00" in result.output + assert "with 3 stations (1 skipped)" in result.output + + conn = sqlite3.connect(repo / "stations.db") + ids = [row[0] for row in conn.execute("SELECT id FROM stations ORDER BY id")] + assert ids == ["10384", "10637", "ACTIV"] + assert conn.execute( + "SELECT name FROM names WHERE station = '10637' AND language = 'de'" + ).fetchone() == ("Frankfurt Flughafen",) + assert conn.execute( + "SELECT value FROM identifiers WHERE station = '10637' AND key = 'icao'" + ).fetchone() == ("EDDF",) + conn.close() + + def test_skip_validation(self, run, repo, tmp_path): + write(repo, FRANKFURT | {"id": "BAD00", "identifiers": {}, "timezone": "X"}) + data = dict(BERLIN) + del data["location"] + write(repo, data | {"id": "BAD01"}) + output = tmp_path / "out" / "custom.db" + result = run("build", "--skip-validation", "-o", str(output)) + assert result.exit_code == 0, result.output + assert "✗ BAD01 Missing property 'location'" in result.output + assert "with 3 stations (1 skipped)" in result.output + assert output.exists() + assert not (repo / "stations.db").exists() diff --git a/tests/test_utils.py b/tests/test_utils.py index 5855cc4..0675f1a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -73,6 +73,14 @@ def test_returns_datetime(self): assert isinstance(result, datetime) assert result == datetime(2020, 1, 15) + def test_end_of_day(self): + result = parse_datetime("2020-01-15", is_end=True) + assert result == datetime(2020, 1, 15, 23, 59, 59, 999999) + + def test_end_keeps_time(self): + result = parse_datetime("2020-01-15T12:00:00", is_end=True) + assert result == datetime(2020, 1, 15, 12) + def test_none(self): assert parse_datetime(None) is None diff --git a/uv.lock b/uv.lock index 8b353f8..4c728f0 100644 --- a/uv.lock +++ b/uv.lock @@ -19,6 +19,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -400,6 +409,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "kiwisolver" version = "1.5.0" @@ -506,6 +542,138 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, ] +[[package]] +name = "lxml" +version = "6.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/ad/28ecd7cb894d172f3c9c80a075eeeb2017ac62e3632cee05a5f9493547eb/lxml-6.1.3.tar.gz", hash = "sha256:45222d94ddd511536f3b2f7d9deae3b2339b4ce0f075f1ca25703b07cad9dd21", size = 4211198, upload-time = "2026-09-02T14:48:02.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/f1/95133bde7af7afb1f5ba6090b674d826b7a518318bba54bbbb633b27865a/lxml-6.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c66f858b82497173f73366795fc6ee8171620e75a338506d6b2e7bc16f5fca11", size = 8563141, upload-time = "2026-09-02T14:46:42.334Z" }, + { url = "https://files.pythonhosted.org/packages/80/54/5a79ee2181ac773ee13e48205411845feec69e1c3d097e985c1343171712/lxml-6.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:032a0a97eed428bd143c75a11118238546424ceb2fa311cca5f073aa44658dc4", size = 4613690, upload-time = "2026-09-02T14:46:45.253Z" }, + { url = "https://files.pythonhosted.org/packages/ab/29/8c24672f56807f119312f073f24204368574bd16b384ede861b5104b3a2b/lxml-6.1.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4a579dfb9c835f8ab47f4b8ed33440cbc75b806b73297208e6ec2a33e903740b", size = 4935630, upload-time = "2026-09-02T14:46:48.071Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/ce2436d854c848c19fc9287143991f3fc76b8b4e9a0dbba8452e51dff264/lxml-6.1.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49fbc2682a9306135b7ec49e93f97f9c26689b9b7f96ed2742d8d6497e994d13", size = 5079033, upload-time = "2026-09-02T14:46:50.483Z" }, + { url = "https://files.pythonhosted.org/packages/91/ec/b66f66f6499ad800265d57540b51e6632e3232d3526f42f2f8fd4b14e0ea/lxml-6.1.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea2c01cdb16dc12156e455007c406dfaaece0c89aa4ba0e3b47586779f951d41", size = 5012298, upload-time = "2026-09-02T14:46:52.603Z" }, + { url = "https://files.pythonhosted.org/packages/94/2a/25d128872f4d51753542bfc3feb482c2ea7c8a2d6d81a0bc5c6a00779ed4/lxml-6.1.3-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:527195c188d7d0af748cd48d220ab8cdc5cb99be3d49ac4d9be7324d8abf9bc0", size = 5211431, upload-time = "2026-09-02T14:46:54.722Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/0a41bbef074a556110f84fafb6d8c2998293c7d3bfbe1ce74515bc65393b/lxml-6.1.3-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:20384c2bbcbf87180c8c61eb60869699c1ec0cd09b62cfd13804022d860b0867", size = 5343417, upload-time = "2026-09-02T14:46:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/7b/cd/16116c3f91791aeeeab1cbe6e7eb6e646f127be7b0158b262eb526a21a0c/lxml-6.1.3-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:424aa5657141d306ba9ad1baab4b2c0a0719040075ee6c66aee9bb2dea2b5054", size = 4673219, upload-time = "2026-09-02T14:46:59.604Z" }, + { url = "https://files.pythonhosted.org/packages/dd/bb/4dff849f443ef70221676aec938bc41e8bae6430aa2ca13b041319e14b98/lxml-6.1.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4736e6c87e603146d8949d8501da621ad20c31015060d3fcf95ace2859f3e3e6", size = 5281246, upload-time = "2026-09-02T14:47:02.375Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ac/4aa7dd059420bfd35278c7fe819e9d319ee36a0453b7bbde1907a7832d91/lxml-6.1.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6374e9e382e5a98c9c5e66d41b357b470da1c54bce30f17f9dc4bcc58436cc1c", size = 5055451, upload-time = "2026-09-02T14:47:05.883Z" }, + { url = "https://files.pythonhosted.org/packages/de/44/20d90cf6f4234de9cd9eeb4f519419885fdb087fa80d073c7b57be342021/lxml-6.1.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:22eec57e26c418cde02c051ce9914a365e52a7f135a565c6f0480242aeebab48", size = 4722694, upload-time = "2026-09-02T14:47:08.461Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0e/6bee12325e53dd6613fe1e107def07583b6182ade03e94bfef8976622e44/lxml-6.1.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8753b8d51dbc86fd335ee31fcf7f3658e9f5c016d4edfb23f76ad295f4b8c9d0", size = 5269179, upload-time = "2026-09-02T14:47:10.647Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5d/54d269ce5cd0787c0424d9cef449ee794d4097725d13dd2acd6181c44e9c/lxml-6.1.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:207dfc3d47cf0e575e643bbc140dacc8863b39abaa1e5307cd64c7f2365b8a12", size = 5235559, upload-time = "2026-09-02T14:47:13.932Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f7/5a3095f187f1bec293591616a1677781acc265c5b313c009f8a19c471a09/lxml-6.1.3-cp311-cp311-win32.whl", hash = "sha256:18293f8a8d8b6a8e71ef37706b659e3846a4261232158167b1ddf35f6994f633", size = 3600377, upload-time = "2026-09-02T14:47:15.957Z" }, + { url = "https://files.pythonhosted.org/packages/45/5a/15531a0d307c96282fe8b639b3d74e8bd783e4ab4cb2b0781146ac4161b8/lxml-6.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:7ae4949f212a53b007dbc355884fda122545c5764a54256c9217e419a62a6559", size = 4032700, upload-time = "2026-09-02T14:47:18.566Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/8de76314955545ceaaa7c0305017b8aaa217905dee59c62c0e2c1e44a68f/lxml-6.1.3-cp311-cp311-win_arm64.whl", hash = "sha256:2123e5aa075ac20d23c7af489255efd129cbfe190dbe88fd42598cc9df3199b6", size = 3674431, upload-time = "2026-09-02T14:47:22.186Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1f/a180b57d9eeabaab77f9d5aa30356898ea749c4795596a8f66d1eb6bef2e/lxml-6.1.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c0710ac085a157b593c38fbcacd950f15c4afa8e2057527185875ab302752bc", size = 8602094, upload-time = "2026-09-02T14:47:26.054Z" }, + { url = "https://files.pythonhosted.org/packages/a8/25/070c92013a1c029a602b03560d68772313d918268667fa993da7961759c9/lxml-6.1.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:623c8799c17128753c65699f1c3aa32402657393a9ad6db09ed8b98ddf76611d", size = 4638308, upload-time = "2026-09-02T14:47:29.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1c/722e88883173097a1a375153e3c2447eba3060d0231522cf6596e99f4195/lxml-6.1.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f683dc6300317700025e41d89a43e0276692ded16113a3c43eab704d605c58e5", size = 4939696, upload-time = "2026-09-02T14:47:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/db/36/aa413bc214dc4f785ad2b2ddd8cc99aae7062d49ab155e91e6011af00daf/lxml-6.1.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:379f8a75cf6eb7eef0af074b55f49ab73b868388a98de14646abcdfa4564bb11", size = 5105247, upload-time = "2026-09-02T14:47:36.734Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a0/a1f7f1313795bfec67b77f01ef3b1128d49f2d7f66a8413fa55d47f4e25f/lxml-6.1.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b37772102d44bb6628186accca3a121b1fa3a6b3d97518a8c29a5229ca4c0d0a", size = 5011915, upload-time = "2026-09-02T14:47:39.846Z" }, + { url = "https://files.pythonhosted.org/packages/b9/78/840e7e3f1d0cc7a5cfac5d8505b97e25b6427fd774ac4bae672aaebfb4b5/lxml-6.1.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddcf547bea2aee967d6a77779376a45e77e610e8465147a1f3d7e20d539d6e32", size = 5638175, upload-time = "2026-09-02T14:47:43.644Z" }, + { url = "https://files.pythonhosted.org/packages/0a/20/e022dbc6b4753a9bc9fc5fb28a27163430c1731b9913997f6544c1b2518c/lxml-6.1.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:909f4e927bb051f7740d6367285fc60cdcfdaf0258c2dba4ff5ba7eadadc250c", size = 5244675, upload-time = "2026-09-02T14:47:47.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/83/82cde81d2b5eb38d1539fdfdf318abdd014a7e604f4df01c9cd3deb18f2a/lxml-6.1.3-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:a5c18810318303ce9afb3f95e2ddb54834f96fa699a8600433fd5a93dcf44c56", size = 5358205, upload-time = "2026-09-02T14:47:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a1/f3b057371c8cb29f2a9c9c44ea320592446e40b74a4b0af68c3d8e65bc73/lxml-6.1.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:3e42265103fb385d8642a78672edf376c6f7e1d3598a7a4f9cb1278f2f6b5f6f", size = 4704495, upload-time = "2026-09-02T14:47:53.251Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a4/230eb28be5d412152ffc3c679b51fe1aeede5a53f3a8eb6e9748f2f4754f/lxml-6.1.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:21402998e4b78e7cce237d2788841aaa21ac9a4d1574d04dc2d12ee41ae807b5", size = 5255117, upload-time = "2026-09-02T14:47:55.963Z" }, + { url = "https://files.pythonhosted.org/packages/a3/18/1969f56763af24ce42ea156007b0b2d73fddea552e283b2010416394f0f4/lxml-6.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:38fc4e4e4e084e0bd491949482527d406788045c546d4f8789e93fc527b91385", size = 5054424, upload-time = "2026-09-02T14:47:58.131Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/2a90acc1f6fabaa3a8db9340437822bd8d041b205d626a4b3e8621aaa390/lxml-6.1.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5609efdb0d3c95499c00046bc53648b3482ec2175b5503d6e611b3f0555dc71d", size = 4785572, upload-time = "2026-09-02T14:48:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1e/b90e845b1dcd0f2f3f26b98283d857f25909223aacd265eee032c34ab8b1/lxml-6.1.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:97ce49699d87ebf8aad631b55d65b33219a4f1bfefbbf5bff19dc9af160aeaf9", size = 5656516, upload-time = "2026-09-02T14:48:03.419Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ab/0a1b802c57f3fba5c4efd77d5c6b78adaa8f7b681f0c90456b140fe8bf6c/lxml-6.1.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:48542c9acba9ff9450bd18d871d2c2c8787fdb283572b623d206f1b927cd7d9e", size = 5245982, upload-time = "2026-09-02T14:48:06.109Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/2c016fbceb3778137459292538d9dfa7e3ad9070fe409c15254ddd90d2cc/lxml-6.1.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c55e71a9b1db1f107efb60da49c093689b74c5c31a708e5379e2fd9439d4fbb5", size = 5267340, upload-time = "2026-09-02T14:48:08.374Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b1/736d18fd6f0835761923b7bac1f0c27d60c1200384e9093f05d8c5100525/lxml-6.1.3-cp312-cp312-win32.whl", hash = "sha256:b3ff39654f0ce6ebd4db154211136dbe7e8157bcc3bed2344c87f32c7c6ecb6c", size = 3602606, upload-time = "2026-09-02T14:48:10.384Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5b/6ed903e4e6278a020c8a6f0dbbe78030d041840a6b4a64ea441a1e414077/lxml-6.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:3e9a00d1c2c30936f7add097c41afc5da6556c580909104aafd382cac92a855c", size = 4005999, upload-time = "2026-09-02T14:48:12.51Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1b/7bcebb7b6332cb3ae85e9c13b139adb6f23f75c71d84041c56a5005d9a29/lxml-6.1.3-cp312-cp312-win_arm64.whl", hash = "sha256:1aeca87830c4fe649dcf93fe2b059525b71c72587f21be4ae4af7103082a79fa", size = 3666631, upload-time = "2026-09-02T14:48:14.567Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/3ef45db776baea068044c799bbba68f3ca00a440c0e930a17c572f3d9639/lxml-6.1.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3a48093cdb058a93af842ede9703520e810b05dcd0fc6d7190a06376c3bfb6bd", size = 8590357, upload-time = "2026-09-02T14:48:17.413Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a5/eee2fc77eee5ea68e4a4334b1def1781a3beaeefd3d98e81b4a38dc447b7/lxml-6.1.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:887c021d9a977cff89cb273047c1352997b772a8908a25c21836861f69b92be1", size = 4632616, upload-time = "2026-09-02T14:48:20.745Z" }, + { url = "https://files.pythonhosted.org/packages/35/42/df27b56848acd29d8a720acc28977911aab36f2a09df4208d5502e887415/lxml-6.1.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:611a51e61c92f62345a50b0035df6fc0d678f9299f33728826d831598862f59d", size = 4936186, upload-time = "2026-09-02T14:48:22.94Z" }, + { url = "https://files.pythonhosted.org/packages/ab/8d/8a7b91df0b54d09d25f5f44885d6b3e0a6d6643a8c070191580318d20c42/lxml-6.1.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b477912f42c5c33405a10c759d22f80cf5af043ae02d95b9d8e5e5bc555739ed", size = 5093324, upload-time = "2026-09-02T14:48:25.132Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/8f340ddcd43790332fb0de8a26628d571a492da3300cd191821698407c96/lxml-6.1.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cffe18571ccc51d742cd08cbb3f8b756de9311d18c7ea98f5d92f37b8fb60c2", size = 4998850, upload-time = "2026-09-02T14:48:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c1/9c5bb572f1f09ec9e4322bd4a4e9f4ad48347fc56ef94cf4df58a5279dc8/lxml-6.1.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75cc6569e86be5785b6188ef1642670c6adbc984e81ec35e224842ecd9eefcc8", size = 5626813, upload-time = "2026-09-02T14:48:29.61Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7d/8bf1fd8bae8247743968bb76d027a1ac5bd2c4b44495fba6a71b30d10706/lxml-6.1.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d85dfab42dd672f87a7f76e9de7172962aee69fa12044f0d6e1a23cbd53fb80e", size = 5232385, upload-time = "2026-09-02T14:48:31.969Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/6cef69ed81cb7df0d03b0dd09d08e6e2cf5061a743ff6f42f0b741548e9b/lxml-6.1.3-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:42632b4024ab24a6b488f559ac851312509888b6b80ae2aa11cf29a646a0d245", size = 5347088, upload-time = "2026-09-02T14:48:34.13Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e1/8e5fd8ddc8c7d685badb0f2db149e3c9da84eefc2827c01c658df2c4e3cb/lxml-6.1.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:febd35ef45f603c2d74b74655efdbf45e14f55fc0aef4ac82b663ca829b283e0", size = 4707227, upload-time = "2026-09-02T14:48:36.62Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7e/00041382a11be40a88bf405ebff11c8efabd3de79f2691e1638b1c47a8a0/lxml-6.1.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a43b3bdf11e477dc7770609d3477316f974354dfc8425d596f64f471cc8daf6e", size = 5240208, upload-time = "2026-09-02T14:48:38.893Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fe/316538b5cff0936fa63d45d421c655730fcbb5a28dcac728c175083002bc/lxml-6.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d582042c69857c364e8153de6e18e0da9b7b515a6a8113caf69a6ec8e0520f2", size = 5050271, upload-time = "2026-09-02T14:48:41.213Z" }, + { url = "https://files.pythonhosted.org/packages/c9/91/455bcccb3ac725373007344d351151810cd19762d1673b64b811f4359a42/lxml-6.1.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e49a646acfab83c68974f4aa1d0a2acca9e88d7d627ae0fc13201b14b76d310", size = 4780433, upload-time = "2026-09-02T14:48:43.779Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f6/580440e2f52cf00bba5c5e1080bfa88cdfcde73be71a11d95170ddbb663f/lxml-6.1.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0dee106e9aa97fb00541b1ed7827070564d0549c3d3fba8920e6b20fd980f748", size = 5645928, upload-time = "2026-09-02T14:48:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/f6/dc/d123c1f244306543d545f62443f794959e4f1ea709fe100f8740d514e74a/lxml-6.1.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd5e90f34cffcfed97f36cf066325773d2b6021c60c29942e53a18b028501b1d", size = 5231184, upload-time = "2026-09-02T14:48:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/c3/3c/fe55b2bd5c6113c906511cd88f6a470195c5fbff1124f19970ab706c3477/lxml-6.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9b3e7d71bf6acff341233417abbdface29c647e3113892d9aaedc02eb4aa2bc", size = 5255814, upload-time = "2026-09-02T14:48:50.948Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a7/485df55acf55dc35e4ca89d2f48f03889e5a3241826b18b85102b32ce9d8/lxml-6.1.3-cp313-cp313-win32.whl", hash = "sha256:160fcf381f76c3aeac28a756bec44f48942a8f7245a87aa28e3a523b4d90cd87", size = 3602214, upload-time = "2026-09-02T14:48:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/c0/28/e46a7702bd95e9043291f7c3539b6184cba66f96cea9936f20939b284eeb/lxml-6.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:e477aca0bc0d19f3b4ae9e4f2a1cfd687c31bf772d78734910658186b40b2477", size = 4004091, upload-time = "2026-09-02T14:48:55.699Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/154c78e20479a43916e63f19cb720d83f44f024b03228be44c92d9a97b24/lxml-6.1.3-cp313-cp313-win_arm64.whl", hash = "sha256:b1cc980905221a5d8b3c476330730b3adb40ff80add71ffbdb6215ba055656f1", size = 3665468, upload-time = "2026-09-02T14:48:57.703Z" }, + { url = "https://files.pythonhosted.org/packages/0c/15/fc75a70b0af6021d0ea16811f1fc71cc42cd06ce90fe10f007a69b2eed84/lxml-6.1.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2bec13085dc8ef48a3fe62f7dfcacfeda2c785cdf19cc8eeda2bb9ed081da165", size = 8609725, upload-time = "2026-09-02T14:49:00.156Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/398fcf9018f881ec9aeaafae1ddd6586dfb13314a35d35e899de373dcae0/lxml-6.1.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f4db7c7e954d289d71878938348b3d91b904a3e8210a11939359fb758a58e7d", size = 4639629, upload-time = "2026-09-02T14:49:02.81Z" }, + { url = "https://files.pythonhosted.org/packages/a7/2d/49b6a6ad7ce8f64b07b9fe852ff0c6d3fcbb26db61bee4f63d4120180a1c/lxml-6.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2cae5d5c90a62d9139c512a0cb1aad1d182b022b5740daea2617eb5bf7fc658e", size = 4965074, upload-time = "2026-09-02T14:49:05.133Z" }, + { url = "https://files.pythonhosted.org/packages/66/bc/6230cf80e4331c33383b0b6b73dc31a393dd76edd4cb73d761de5123034d/lxml-6.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c6c0c13128a32eb04a51357e56a094e13aa8e6d3d1884de2e9ae923f6915e1a8", size = 5099355, upload-time = "2026-09-02T14:49:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/ac/cf/d1143d9b7717e07a82f158a1fc9ce6e581fdad1226734950af869e3ffde4/lxml-6.1.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2221e88679d1351e9a40aaee54bc65679b9795bbd0160bc3d5e36b163344eb75", size = 5036795, upload-time = "2026-09-02T14:49:09.65Z" }, + { url = "https://files.pythonhosted.org/packages/31/6f/194bb00ffb89712c30f5a7e1b8e685590e140fad6c8261fec172c09a3dc0/lxml-6.1.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfb398886a7eb4c719161c3efcff2a1248febc53a4d8e5072d2d8a87fed84ac9", size = 5658740, upload-time = "2026-09-02T14:49:11.9Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/27e3cee3dcdb3b7bc09727b642bdbfcd098490ea77df04611db9060d7722/lxml-6.1.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7eb78ba28b187e1e9203a55c60fcf70df2d22cb205fe6d51b9383d6097419f0", size = 5245991, upload-time = "2026-09-02T14:49:14.154Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/8312560579fc980bbd2233a8a673cc46f7d613d3633f2bf08a21e8f4ad13/lxml-6.1.3-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:ea6b1e9105b4b24a34c722432d9fb578f9ed83af21fa1abda639011e0f22bbb6", size = 5354136, upload-time = "2026-09-02T14:49:16.459Z" }, + { url = "https://files.pythonhosted.org/packages/74/d8/eda60f4f73a9c780b5d6e1175484f66e6c81a2c93346e2906a1fec9c7a02/lxml-6.1.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:e8b17e23df3e827a69d25af70990ca2420e92668aaffaeeb3cd2351d7916a023", size = 4704379, upload-time = "2026-09-02T14:49:19.032Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c8/c9cc60057be78ac34bd2b842e45e6e88edbfe5e532e82c3b82381b7aab49/lxml-6.1.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b7c37339d7e75cab9a123a04248e243cefefb302ad6db566ea0c77cbcde421e", size = 5258676, upload-time = "2026-09-02T14:49:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/41/7b/66894008fee8d1785b8db129747ae963fd427b68f456918df7f2f24a8b98/lxml-6.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:83e3a51e7933db700a0da0db31849db3a24022d9970da9bb73001e1d0326fd92", size = 5090069, upload-time = "2026-09-02T14:49:23.562Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/c1b60404859f4c3cd1f41f29c65a24e25cea78fde822d9574a21f66810be/lxml-6.1.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9bde9ae026a55b9a192078dfa6e27dd0ca4a050171ab6272e92f97b757dfdf48", size = 4741958, upload-time = "2026-09-02T14:49:26.037Z" }, + { url = "https://files.pythonhosted.org/packages/23/b8/6285f0cf546f14da2554cabdeaf7c2c2ff3190c74807f0de2e8810a786f9/lxml-6.1.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a635e837b50a1819bebfedaac5916498ea024120969da8790500148fb0a894d", size = 5683245, upload-time = "2026-09-02T14:49:28.438Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f6/2168cab44336dcb15fed0f0b78577225b83297cdf0dee349c95420c3dcb0/lxml-6.1.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d0c5c362bc94f1929dc7e96e715bbe7bd17037f802e6d8f0d1545df9133c0559", size = 5246087, upload-time = "2026-09-02T14:49:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/f5/89/32f5de69a0a31f30e6164981851f87b37ecb2c4ee838e504b88d49d4818e/lxml-6.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c59e4265608da6a041f54646ecc0c9ecdbb19aaf14c4c684bb6c2114998cc415", size = 5269352, upload-time = "2026-09-02T14:49:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a1/741d952ed3a7ef7a50055c6415aec3f067015e97f72f4389ce77b09657ba/lxml-6.1.3-cp314-cp314-win32.whl", hash = "sha256:2e62c569ec7531b679b184cbfe335c501c1d13c4b363560013019962eb630e6d", size = 3662783, upload-time = "2026-09-02T14:50:23.751Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bc/5811cc73cac05e324e05ba9b0924e1a163a317a167ede8a9c748b11db30a/lxml-6.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:66299564c046bc7e0cc5de5106601eae907e9fa5904cd68a323380a8502f7861", size = 4073951, upload-time = "2026-09-02T14:50:26.348Z" }, + { url = "https://files.pythonhosted.org/packages/92/18/3768c8b01ac3a9bed1914715e6011711b00e2a11628ffa6f7fa37f8e0269/lxml-6.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:ebd054ad1737a68fb7c5c073d405cef2b88bb824e294de3b4a4e995b47f0e376", size = 3749279, upload-time = "2026-09-02T14:50:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/72/38/84684784738d9451db2b330de2483f496690c3a5c642071df24135739b37/lxml-6.1.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5a143e6207579de8baeded4eaac9134413200359f1969d636f0bfb98ee8c3c8f", size = 8860296, upload-time = "2026-09-02T14:49:36.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/b7/fc4c50bb1b38e864010ea396046cabe85129bf9e65b11edcfbc37d356241/lxml-6.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a1cec0f99b9b914d39176347a93b7610dc09324491aee1cbc57cd291a41a1d55", size = 4755190, upload-time = "2026-09-02T14:49:39.872Z" }, + { url = "https://files.pythonhosted.org/packages/94/e2/ee9aa6ed2b666b2db1f6f7fd48964ff9da39ebe827ef5eac0ab881f639d9/lxml-6.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f6b9d2aad499c769ee8287609ab0e6de99d8bcea99c6e6c2e64945259fd52fb2", size = 4979517, upload-time = "2026-09-02T14:49:42.153Z" }, + { url = "https://files.pythonhosted.org/packages/29/e3/e7763d1661b283ddd4fa36f91b9a497db6b8d2aff55028b16c7f642e0755/lxml-6.1.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a23fefdb345b2d4d0ff2860571b5ff9a89a28b6a120f720e8fb0324d346626", size = 5115270, upload-time = "2026-09-02T14:49:44.493Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cd/22205d5b4d177e3f4156f780412426ee7c7f8107809f119f0dcc40fa51e3/lxml-6.1.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:545ccc14fb05485f48b4439ec35beb16d5b5280eb6c81c658bd4707a2a119414", size = 5032449, upload-time = "2026-09-02T14:49:46.841Z" }, + { url = "https://files.pythonhosted.org/packages/da/43/06a4626c3bb79ef8c501b674afab8100d64e798665bb2a97d1c960636a49/lxml-6.1.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:93476b6514b373fc6ca67d26c442784f7807c86f00635bfe79f935c3eab2af17", size = 5603325, upload-time = "2026-09-02T14:49:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/733682a0c2de9f5779ba207bbb3f3f6be8c6bda863fc01739b186b38783a/lxml-6.1.3-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8db38ff3fb7aee7d6a82ae4da2eef1178656fe1216841fbd24870062a9d60473", size = 5229023, upload-time = "2026-09-02T14:49:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8a/e69cdaca3fd33a647942925664f01b20908d41a6968c182305be9c38fb11/lxml-6.1.3-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:25f4118c438f96bb466e83108506d03d5c31b1bd2387e83e5b070bda6ded9c37", size = 5317811, upload-time = "2026-09-02T14:49:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/0c397588174403c2ab68fc464abf97e03e7324f9c6cb6a99023104707195/lxml-6.1.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:1beb0f9909b26cee938df9ba56b15252a84429b1fc30ce6fca161390b9789a70", size = 4646516, upload-time = "2026-09-02T14:49:57.761Z" }, + { url = "https://files.pythonhosted.org/packages/56/7e/cfea25afafbe49db8b225764f7f74bb37c2a7f5e717d917d3d4a5e098ed4/lxml-6.1.3-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a27ac6c780c8b8a1cd231b58407634cafc1c4cc28cd6c7141362df0f36351e7", size = 5240626, upload-time = "2026-09-02T14:50:00.279Z" }, + { url = "https://files.pythonhosted.org/packages/a1/75/7a587771bb52ebb0e2c57b6dbe9fd96a70fbb54d72ddd97d54c5f8ec18d5/lxml-6.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a1932d7ce78a561367512c594fe66eac2b2ec9b9264cfd9b5f950622f4a116e2", size = 5086619, upload-time = "2026-09-02T14:50:03.245Z" }, + { url = "https://files.pythonhosted.org/packages/1e/01/94c0ebe6d831861542d251e038052e52bf6d33f1d18f1cfffdc82851065a/lxml-6.1.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7d0f5976aa2701996f759b30172925829867547bb073af0ae67d1307a0f0262c", size = 4758828, upload-time = "2026-09-02T14:50:05.873Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/938d67bd0e5b1fdfa52be28aefdffbad57e1f6b8e921c2aab88542c75f40/lxml-6.1.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e7ce578aa8a80910a72a8ca0bbea3baae10100827249001999726a788456d8", size = 5627083, upload-time = "2026-09-02T14:50:08.555Z" }, + { url = "https://files.pythonhosted.org/packages/d8/65/4e51522f6c214650db0abb7b16ccd11b1238b8a05a8d59aa4ebed59c9f67/lxml-6.1.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d97c5227621af74b111882a290b10f371780a38eef9d9e730408fba2259b52fb", size = 5235170, upload-time = "2026-09-02T14:50:11.255Z" }, + { url = "https://files.pythonhosted.org/packages/92/c2/e73d19365665f6b16ef84df21199befc3b06e4c539046ad2d9595f6fb9ea/lxml-6.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:da707f14ea3c35ee463d50acd596d6488e4b2b4ae7cf77a5bf93f55c023d63e8", size = 5252273, upload-time = "2026-09-02T14:50:13.782Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/7f386c84c9fe2854e1ca6e231c285e1c8f392971ac353c6865e6ec49faff/lxml-6.1.3-cp314-cp314t-win32.whl", hash = "sha256:9efe56a68179f3adc4de41861c9358931db03837c48dd5e1c78077b84dd07f3a", size = 3902712, upload-time = "2026-09-02T14:50:16.171Z" }, + { url = "https://files.pythonhosted.org/packages/82/a6/8a3eb793f7900ef01c7f99e6f5fcbcfbdff35251cfaef66b32a4c16352d6/lxml-6.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c9389b3784b56c58d933b5e0aecdf28f901b073ff385358d8a7d40907f6e14b2", size = 4400979, upload-time = "2026-09-02T14:50:18.621Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c4/3807bea283b4fe9e9d9f5dde46a73df91178472b335d2778e10b2a37aa22/lxml-6.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32a409be3190b088f960ac92bfedfbef2f86c49ff940765e1548177592d20026", size = 3823401, upload-time = "2026-09-02T14:50:21.119Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8e/4614fcd65496054cfb7172662f3576a59200278739506433b8c241ea422a/lxml-6.1.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:6ea2f13dce778ca072ccee598bca46a092ce192e8fd907b6c1f0e52c800529a0", size = 8609378, upload-time = "2026-09-02T14:50:31.772Z" }, + { url = "https://files.pythonhosted.org/packages/f2/51/2cdce3c65fa99a6195dd8fbd512d33407c1000ad99f63e0a285b63d7a8eb/lxml-6.1.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c581b1d68b3845fb86c6b2983e755b29bf001461c59fa411d2c26a911b6559a9", size = 4640022, upload-time = "2026-09-02T14:50:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/52/09/0b30084e9eb1c546a4be3d9c56df70058d116b1a320400a59b0f7da87bf0/lxml-6.1.3-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e01125896585139453cab8cb235893644d8815d7509520da95ae3ee8d1c1f79", size = 5037928, upload-time = "2026-09-02T14:50:37.007Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0e/5c37275a3e361f6138dc06db748ea565c1fe8a5f4ee5e2ddd80047c81a89/lxml-6.1.3-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:290f66b97ede0e552e1cb44a0fd8a74f9753ee635b50830a0b122fb72788d015", size = 5661932, upload-time = "2026-09-02T14:50:39.777Z" }, + { url = "https://files.pythonhosted.org/packages/70/c5/b71ffb289b15e2642e2a3cf6d468c44da39ea119061a99e5b05e3d10f217/lxml-6.1.3-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73fc05988ed20809450474ba760a87c8ad4e455fc09783c02195e56ec634b41a", size = 5249209, upload-time = "2026-09-02T14:50:42.141Z" }, + { url = "https://files.pythonhosted.org/packages/81/ea/9910da149a23932f9301652e57661cd9e42b0df18f12be21159b7255f92b/lxml-6.1.3-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:dc3a44689eea43eab836e5c98a8ab015dc2419987d1ea6eafc7c590cdff86bed", size = 4704543, upload-time = "2026-09-02T14:50:44.634Z" }, + { url = "https://files.pythonhosted.org/packages/76/07/9290329cd188c62e22021f79df04ee0cc33d9a93b0d38bd65ccd452ad9d0/lxml-6.1.3-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:209c3ccbfe35a04ac6d24f0611f9d1cbf8025d49991b14acd935236234d6c156", size = 5261298, upload-time = "2026-09-02T14:50:47.301Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0c/aba78bd3401cd99b73a0aed8e2b9b43e14be94fab3603d4bbc8a62365f2a/lxml-6.1.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2f5b2a2b9811b853b39bfa41367c6d78747b8e3e80e07fc5a24aae295c1a4d7d", size = 5090453, upload-time = "2026-09-02T14:50:49.952Z" }, + { url = "https://files.pythonhosted.org/packages/8d/dc/fa4426c3355aa0216cbeb3911495b5f65a26e0df85859a89928fe28f0396/lxml-6.1.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6a406d0b3cb207b0fa460ed4dc93e866f44f105da0169361cb18ff998a44c7f0", size = 4744709, upload-time = "2026-09-02T14:50:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/be/2b/224fe7918658ab7c532ac2412f3c1eb28f71e6364fb07566262d0cc6a7b6/lxml-6.1.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:53258656846f5c48996b882fb4b135885e088a3ad3d96b4bc0530f95124d1f69", size = 5685802, upload-time = "2026-09-02T14:50:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/21/44/7d480819b9adcae5f84dd8ac529132c6b7a578544398225cd20321adcd91/lxml-6.1.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:aa633613ff907ea91b9b0489a1f0da1b8725d8c6ccec6b77e8a1c9c235044bb0", size = 5249019, upload-time = "2026-09-02T14:50:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/72/83/385a267ea1b6b283f2249dd827ef360a295e9db14e13ef4665a120c60d64/lxml-6.1.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:90f709b9accab6b2e4d14f5c8718203877a0486bcb3afd74d8b539ecd1e961d4", size = 5271886, upload-time = "2026-09-02T14:51:01.667Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0d/f967b0eb172ae876855a402d6d9b11fa86e3e0c89ca9bbfeadf7ffbfa719/lxml-6.1.3-cp315-cp315-win32.whl", hash = "sha256:b4fc6b03b9d9d90557274f571ab30e7fbbfc527955536935d96f98b6817a86e4", size = 3662894, upload-time = "2026-09-02T14:51:45.173Z" }, + { url = "https://files.pythonhosted.org/packages/f4/48/d8a8c4160a29e663109ad520bac2deb37fcd014756d024561e8bc3e611ec/lxml-6.1.3-cp315-cp315-win_amd64.whl", hash = "sha256:33cadd956b667997e4de1635fce9541f2e8ede2038fcde8cf55aa14d571d1bad", size = 4074626, upload-time = "2026-09-02T14:51:47.77Z" }, + { url = "https://files.pythonhosted.org/packages/25/20/3e1395d34d19f9254625d0b567b81cf70d37d3417be074f4d63b94a2be3c/lxml-6.1.3-cp315-cp315-win_arm64.whl", hash = "sha256:8a330c0ee5fa318c7b5cbbaad882baeca3f570357e7eb25ab34bf31008150758", size = 3749495, upload-time = "2026-09-02T14:51:50.663Z" }, + { url = "https://files.pythonhosted.org/packages/8f/c6/7465ffd9c43883526a382df6fa4846c9d8d419214f7effbf65270e795471/lxml-6.1.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:0bf5a3e397df2ec4258eb5eea4c1ac6cf013ca1abd04a176903bff20a70021fe", size = 8857677, upload-time = "2026-09-02T14:51:05.109Z" }, + { url = "https://files.pythonhosted.org/packages/ed/eb/1f3a917e299df43c8162c3e6f64fc2cea3bcf277910f35bff5b8e5d39901/lxml-6.1.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:13d22c0d57355366b393936acf6b98a5e0edeadddd3fccbc6a846c50a76b8741", size = 4754522, upload-time = "2026-09-02T14:51:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f9/f81b4bdb6efb7a596be29603d8758154d00a5f545db9f3cef9d9041c8f64/lxml-6.1.3-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad7617727a96d189bd6f979d0fadf765198c7934e85f4edaba9bf3ad919a300", size = 5033744, upload-time = "2026-09-02T14:51:10.633Z" }, + { url = "https://files.pythonhosted.org/packages/c8/0f/26d9bfaacb319c86e0eca8a1a0bf1130d36a7afbd318883e23caea63763d/lxml-6.1.3-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cae82b5ca24b0c2beedb269f6e2a96f466acd926879ab00ae19f1a65cbf9ffb0", size = 5615269, upload-time = "2026-09-02T14:51:13.357Z" }, + { url = "https://files.pythonhosted.org/packages/5d/90/73675f3f4141350ed65d6fec533b107d4e802c5caa340cf111771edd86e0/lxml-6.1.3-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69cafd61aea04ebb3502c93c2aaa568b12931ca0802231e0b5de76bf8b6e74bd", size = 5236280, upload-time = "2026-09-02T14:51:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/fd/be/ed260767e7977de463a0f91f3f4fffcab85c0a2a024a21ffe1fa442c2c79/lxml-6.1.3-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:dc205732d593118cf701d986f40e9de7801bb2e371cb189ddbda9b7348f4d97e", size = 4650718, upload-time = "2026-09-02T14:51:19.102Z" }, + { url = "https://files.pythonhosted.org/packages/d0/fd/e9839d03b1e767f2725cf7d7d81b80d5f3f9fdc10ad8827e2479311b046e/lxml-6.1.3-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88e719b9437f148f7e1465df845c758dd1598618cbea3a2fd1e61a715542f2b2", size = 5243376, upload-time = "2026-09-02T14:51:21.606Z" }, + { url = "https://files.pythonhosted.org/packages/34/a5/4606e347e2788c301f677004aa83e28d24da9fe663a24380122af57be6fc/lxml-6.1.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40983eabefd13da003e68170928c7acc011f0d095eefce5871a3c71c9385fb9a", size = 5092340, upload-time = "2026-09-02T14:51:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/ea/99/3314a8661cdf30f493c55a87db283961dfaae08451976a2ca418958e1804/lxml-6.1.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:fad67b12ffe0f71e02b4932b04883cbc76a9072bbd30731409d3523cf058b011", size = 4758768, upload-time = "2026-09-02T14:51:26.813Z" }, + { url = "https://files.pythonhosted.org/packages/30/58/3bdc577f78ea8b7d72d39a84506f7001d5b28728f43e5b84891e3b7d9a4a/lxml-6.1.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6cd11e7550d89e551a87dcec30f04b1fca32e86b68708aa01a4daa455d8605e5", size = 5649546, upload-time = "2026-09-02T14:51:29.453Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e4/652633de1a2395949ebb7a8fc7d089aba12a2b45f0fefbc9d29e3e3ab3cf/lxml-6.1.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ca0ec532ad2f5ba1e5ec120ac157769c57f01855b3d8bf37213f5d88abd9ba0a", size = 5234874, upload-time = "2026-09-02T14:51:32.262Z" }, + { url = "https://files.pythonhosted.org/packages/65/a6/c4581d171de30449304b4859bbd3607e9b40da13c0f88b68e6097c8d785e/lxml-6.1.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e99e09ab7741f1281e2677f4c0058c7f5267d182530b09c87e4f6aa26adf3887", size = 5260043, upload-time = "2026-09-02T14:51:34.841Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/ed6ee6186a89e69ca4ea9658b2a278f46a5efe8b5d4db56c7197f18653fe/lxml-6.1.3-cp315-cp315t-win32.whl", hash = "sha256:ace1d2c83b2bd24db5940600541140e87a325e119cb32d5fa9ad720d7e76648e", size = 3901093, upload-time = "2026-09-02T14:51:37.234Z" }, + { url = "https://files.pythonhosted.org/packages/67/9d/11d10257a4a048d04195d638bb61f0246ce2448eb05f682bcbab25a257a8/lxml-6.1.3-cp315-cp315t-win_amd64.whl", hash = "sha256:b49638355ea3bebba70da783ccbc630fd72afa16bc46c54474bfa1f9a915bbc6", size = 4395446, upload-time = "2026-09-02T14:51:39.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/44edd7de434181c582892e68d1ffe6775ca403ce14aea07cb5a218a936cf/lxml-6.1.3-cp315-cp315t-win_arm64.whl", hash = "sha256:5a721a98c649855963811b59b55755b30566e7f7fc40bdc9803d66dee9f811cf", size = 3822836, upload-time = "2026-09-02T14:51:42.471Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c1/2433176de263cc3f51fd2c303f993d5bb7f1da3139a0f7d168116c0bfa7a/lxml-6.1.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d2765c18ce303149ee804b1f3dad11232726dd0a702d73a15cf19179ac8cc962", size = 3942969, upload-time = "2026-09-02T14:46:36.55Z" }, + { url = "https://files.pythonhosted.org/packages/7c/71/de7759096f480180fd9e43ff7c017860e2d2a9a43741ab093cbdf1820f07/lxml-6.1.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d5a748d12dd9b535e0a130f60dae9ddf0adafbabe61e7864f55c7436c84547a", size = 4213008, upload-time = "2026-09-02T14:46:38.784Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/c2d09af47a34fa6c0c27473083812b449a411680bd04bbe609cde291ddc8/lxml-6.1.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:41096ec0740a58dad03d3ae0c7486d306d20becefb13ceb1649835ab3eb64167", size = 4322012, upload-time = "2026-09-02T14:46:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/68/f3/bf56fee0403ebd995be8e78ec9aca566016487d1b3cbf755ebea8ccffbdb/lxml-6.1.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:415e3a115c0d510e329020012834d1c0aa1c581ee53a218603e38abbc1dea70a", size = 4257402, upload-time = "2026-09-02T14:46:43.134Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1d/6da9cc086a20d9dd6bcbf7c5d9575f0331cca9a05e67dab02d15e828170b/lxml-6.1.3-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20428910dae17a1a93152a3ff2c0441d2f4932992c0797d65651dd0561f1792f", size = 4410889, upload-time = "2026-09-02T14:46:46.975Z" }, + { url = "https://files.pythonhosted.org/packages/03/5c/91fe48856f9f8089be3096fa4dbe4b3fb5526f3bf3e852ea9497f399cb9f/lxml-6.1.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bc8dd3d9c93e70c3df974a201ac2958b6d77b465d813c51d1f15fa8e645763ae", size = 3511258, upload-time = "2026-09-02T14:46:49.046Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -591,23 +759,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "metar" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/30/bd939c3437391ea6a8e454c3b09139bd5fd06e242b9fbe4172c086d47300/metar-2.0.1.tar.gz", hash = "sha256:2f096e101aaf8e952bedf2d5c327a1589081e50fe42e33af72a006e56c47b913", size = 2570630, upload-time = "2026-03-20T15:34:52.548Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/1c/8ae7e8558c0f2e7b0325853460b26d58876867a8d351da0f34cda6f5341a/metar-2.0.1-py3-none-any.whl", hash = "sha256:947e7ea10108007a7499c46bac3fdeb7da0a2fad375467e85dd1ca4ffbf4f22e", size = 204910, upload-time = "2026-03-20T15:34:51.112Z" }, +] + [[package]] name = "meteostat" -version = "2.1.4" +version = "2.1.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pandas" }, { name = "pytz" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/a1/52a138270f770ff7a5ff5376a6f2b59bbb7d494897ebb87ed41cbec3e861/meteostat-2.1.4.tar.gz", hash = "sha256:186936378be1a8234a91530ccac2464023cc610a9c5db5f1b988a07883e0c5c6", size = 58599, upload-time = "2026-03-21T10:47:28.735Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/1b/f4e08a131b535e7f2a5bcfcc2fe9b5774d9670cbfccbc4c290ccf92223f2/meteostat-2.1.5.tar.gz", hash = "sha256:9a245313feb5e7b74f5be78cb9a2d2d6f700effc164c22732fc31da9782ad5fe", size = 59035, upload-time = "2026-09-17T08:11:27.378Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/12/2a86b1ffd84b77c9791c6b6a2f17bc0f8e6b9ae9b951aebe358e27e7d09d/meteostat-2.1.4-py3-none-any.whl", hash = "sha256:4a28a919aadf4e523b03baec1bdde883da5ff78dc84e1de4680be577a037dd84", size = 93773, upload-time = "2026-03-21T10:47:27.453Z" }, + { url = "https://files.pythonhosted.org/packages/47/25/695f01318f9fb2d4903ff9f681b9fe57cb4104d9a34dccb0f30866e3a499/meteostat-2.1.5-py3-none-any.whl", hash = "sha256:a9234959c3e9bc8f426231754198adddb1a8b7a1b9ecbabbcedbe889e7d8f93b", size = 94251, upload-time = "2026-09-17T08:11:26.143Z" }, ] [[package]] name = "meteostat-cli" -version = "0.3.0" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "meteostat" }, @@ -616,9 +793,17 @@ dependencies = [ ] [package.optional-dependencies] +contrib = [ + { name = "jsonschema" }, + { name = "pycountry" }, +] plot = [ { name = "matplotlib" }, ] +providers = [ + { name = "lxml" }, + { name = "metar" }, +] [package.dev-dependencies] dev = [ @@ -630,12 +815,16 @@ dev = [ [package.metadata] requires-dist = [ + { name = "jsonschema", marker = "extra == 'contrib'", specifier = ">=4.20.0" }, + { name = "lxml", marker = "extra == 'providers'", specifier = ">=5.3.0" }, { name = "matplotlib", marker = "extra == 'plot'", specifier = ">=3.9.0" }, - { name = "meteostat", specifier = ">=2.1.4,<3" }, + { name = "metar", marker = "extra == 'providers'", specifier = ">=1.11.0" }, + { name = "meteostat", specifier = ">=2.1.5,<3" }, + { name = "pycountry", marker = "extra == 'contrib'", specifier = ">=24.6.1" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "typer", specifier = ">=0.15.0" }, ] -provides-extras = ["plot"] +provides-extras = ["plot", "contrib", "providers"] [package.metadata.requires-dev] dev = [ @@ -889,6 +1078,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pycountry" +version = "26.2.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/061b9e7a48b85cfd69f33c33d2ef784a531c359399ad764243399673c8f5/pycountry-26.2.16.tar.gz", hash = "sha256:5b6027d453fcd6060112b951dd010f01f168b51b4bf8a1f1fc8c95c8d94a0801", size = 7711342, upload-time = "2026-02-17T03:42:52.367Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/42/7703bd45b62fecd44cd7d3495423097e2f7d28bc2e99e7c1af68892ab157/pycountry-26.2.16-py3-none-any.whl", hash = "sha256:115c4baf7cceaa30f59a4694d79483c9167dbce7a9de4d3d571c5f3ea77c305a", size = 8044600, upload-time = "2026-02-17T03:42:49.777Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -1013,6 +1211,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.33.0" @@ -1041,6 +1253,129 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + [[package]] name = "ruff" version = "0.15.7" @@ -1177,6 +1512,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, ] +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + [[package]] name = "tzdata" version = "2025.3"