windguru 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
guru/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from guru.models.blend import BestForecast, BlendWeight
4
+ from guru.models.forecast import Forecast, ForecastHour, Spot
5
+ from guru.search.blend import get_best_forecast
6
+ from guru.search.forecast import get_forecast
7
+ from guru.search.near import spots_near
8
+ from guru.search.spots import get_spot, resolve_spot, search_spots
9
+
10
+ try:
11
+ __version__ = version("windguru")
12
+ except PackageNotFoundError: # pragma: no cover
13
+ __version__ = "0.2.0"
14
+
15
+ __all__ = [
16
+ "BestForecast",
17
+ "BlendWeight",
18
+ "Forecast",
19
+ "ForecastHour",
20
+ "Spot",
21
+ "get_best_forecast",
22
+ "get_forecast",
23
+ "get_spot",
24
+ "resolve_spot",
25
+ "search_spots",
26
+ "spots_near",
27
+ "__version__",
28
+ ]
guru/cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from guru.cli.main import app, cli
2
+
3
+ __all__ = ["app", "cli"]
guru/cli/console.py ADDED
@@ -0,0 +1,3 @@
1
+ from rich.console import Console
2
+
3
+ console = Console()
guru/cli/errors.py ADDED
@@ -0,0 +1,34 @@
1
+ """CLI emit / fail helpers (stdout JSON + Rich)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import Any
8
+
9
+ import typer
10
+
11
+ from guru.cli.console import console
12
+ from guru.core.envelope import error_payload, success_payload
13
+ from guru.core.errors import classify_error
14
+
15
+
16
+ def emit_json(data: Any) -> None:
17
+ """Write JSON to stdout (success or pre-built envelope)."""
18
+ sys.stdout.write(json.dumps(data, default=str) + "\n")
19
+
20
+
21
+ def print_ok(data: Any) -> None:
22
+ """Emit a success envelope."""
23
+ emit_json(success_payload(data))
24
+
25
+
26
+ def fail(exc: BaseException, *, as_json: bool) -> None:
27
+ """Print error (JSON or Rich) and exit with a stable code."""
28
+ classified = classify_error(exc)
29
+ code = 2 if classified.retryable else 1
30
+ if as_json:
31
+ emit_json(error_payload(exc))
32
+ raise typer.Exit(code) from exc
33
+ console.print(f"[red]{exc}[/red]")
34
+ raise typer.Exit(code) from exc
guru/cli/main.py ADDED
@@ -0,0 +1,209 @@
1
+ """Typer CLI — agent-friendly Windguru client (fli-style)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import typer
8
+
9
+ from guru import __version__
10
+ from guru.cli.console import console
11
+ from guru.cli.errors import fail, print_ok
12
+ from guru.cli.render import (
13
+ print_best,
14
+ print_forecast,
15
+ print_models_table,
16
+ print_near_table,
17
+ print_spots_table,
18
+ )
19
+ from guru.core.envelope import ERROR_SCHEMA, dump_model
20
+ from guru.core.instruct import instruct_payload
21
+ from guru.models.aliases import list_models
22
+ from guru.search.blend import get_best_forecast
23
+ from guru.search.exceptions import GuruError
24
+ from guru.search.forecast import get_forecast
25
+ from guru.search.near import spots_near
26
+ from guru.search.spots import resolve_spot, search_spots
27
+
28
+ _CATCH = (GuruError, ValueError, OSError)
29
+
30
+ app = typer.Typer(
31
+ name="guru",
32
+ help=(
33
+ "Windguru CLI for humans and AI agents. "
34
+ "Prefer `guru best <spot> --json` (WINDGURU_DEFAULT top 3). "
35
+ "Run `guru instruct --json` for the agent recipe."
36
+ ),
37
+ no_args_is_help=True,
38
+ add_completion=False,
39
+ )
40
+
41
+
42
+ @app.command("version")
43
+ def version_cmd(
44
+ as_json: bool = typer.Option(False, "--json", help="Machine-readable envelope"),
45
+ ) -> None:
46
+ if as_json:
47
+ print_ok({"version": __version__})
48
+ else:
49
+ console.print(__version__)
50
+
51
+
52
+ @app.command("instruct")
53
+ def instruct_cmd(
54
+ as_json: bool = typer.Option(True, "--json/--no-json", help="JSON recipe (default on)"),
55
+ ) -> None:
56
+ """Explain the free WINDGURU_DEFAULT → top-3 workflow for agents."""
57
+ payload = instruct_payload()
58
+ if as_json:
59
+ print_ok(payload)
60
+ return
61
+ console.print(payload["summary"])
62
+ for step in payload["steps"]:
63
+ console.print(f"{step['step']}. {step['action']}: {step['detail']}")
64
+ if step.get("command"):
65
+ console.print(f" [cyan]{step['command']}[/cyan]")
66
+
67
+
68
+ @app.command("spots")
69
+ def spots_cmd(
70
+ query: str = typer.Argument(..., help="Spot name search"),
71
+ limit: int = typer.Option(20, "--limit", "-n"),
72
+ as_json: bool = typer.Option(False, "--json"),
73
+ ) -> None:
74
+ """Search Windguru spots by name (live API)."""
75
+ try:
76
+ spots = search_spots(query, limit=limit)
77
+ except _CATCH as exc:
78
+ fail(exc, as_json=as_json)
79
+ if as_json:
80
+ print_ok([dump_model(s) for s in spots])
81
+ return
82
+ print_spots_table(spots, title=f"Spots matching {query!r}")
83
+
84
+
85
+ @app.command("near")
86
+ def near_cmd(
87
+ lat: float = typer.Option(..., "--lat", help="Latitude"),
88
+ lon: float = typer.Option(..., "--lon", help="Longitude"),
89
+ radius: float = typer.Option(50.0, "--radius", help="Search radius km"),
90
+ limit: int = typer.Option(20, "--limit", "-n"),
91
+ as_json: bool = typer.Option(False, "--json"),
92
+ ) -> None:
93
+ """Named spots near a point (free map markers — not PRO click-forecast)."""
94
+ try:
95
+ spots = spots_near(lat, lon, radius_km=radius, limit=limit)
96
+ except _CATCH as exc:
97
+ fail(exc, as_json=as_json)
98
+ if as_json:
99
+ print_ok([dump_model(s) for s in spots])
100
+ return
101
+ print_near_table(spots, lat=lat, lon=lon, radius=radius)
102
+
103
+
104
+ @app.command("best")
105
+ def best_cmd(
106
+ spot: str = typer.Argument(..., help="Spot id or unique name"),
107
+ top: int = typer.Option(3, "--top", help="How many WINDGURU_DEFAULT models to keep"),
108
+ hours: int = typer.Option(
109
+ 48, "--hours", "-H", help="Max forecast steps to return per model"
110
+ ),
111
+ pick: int | None = typer.Option(None, "--pick", help="Disambiguate by spot id"),
112
+ as_json: bool = typer.Option(False, "--json"),
113
+ ) -> None:
114
+ """WINDGURU DEFAULT Tune → top models by weight → those forecasts."""
115
+ try:
116
+ resolved = resolve_spot(spot, pick=pick)
117
+ best = get_best_forecast(resolved.id, top=top, hours=hours)
118
+ except _CATCH as exc:
119
+ fail(exc, as_json=as_json)
120
+ if as_json:
121
+ print_ok(dump_model(best))
122
+ return
123
+ print_best(best)
124
+
125
+
126
+ @app.command("forecast")
127
+ def forecast_cmd(
128
+ spot: str = typer.Argument(..., help="Spot id or unique name"),
129
+ model: str = typer.Option("gfs", "--model", "-m", help="Model alias or id"),
130
+ hours: int = typer.Option(
131
+ 48, "--hours", "-H", help="Max forecast steps to return"
132
+ ),
133
+ pick: int | None = typer.Option(None, "--pick", help="Disambiguate by spot id"),
134
+ as_json: bool = typer.Option(False, "--json"),
135
+ ) -> None:
136
+ """Single-model forecast (escape hatch; prefer `guru best`)."""
137
+ try:
138
+ resolved = resolve_spot(spot, pick=pick)
139
+ fc = get_forecast(resolved.id, model=model, hours=hours)
140
+ except _CATCH as exc:
141
+ fail(exc, as_json=as_json)
142
+ if as_json:
143
+ print_ok(dump_model(fc))
144
+ return
145
+ print_forecast(fc)
146
+
147
+
148
+ @app.command("models")
149
+ def models_cmd(
150
+ as_json: bool = typer.Option(False, "--json"),
151
+ ) -> None:
152
+ """List known model aliases (extend from live captures)."""
153
+ rows = list_models()
154
+ if as_json:
155
+ print_ok(rows)
156
+ return
157
+ print_models_table(rows) # type: ignore[arg-type]
158
+
159
+
160
+ @app.command("schema")
161
+ def schema_cmd(
162
+ name: str = typer.Argument("forecast", help="spot | forecast | best | error | instruct"),
163
+ ) -> None:
164
+ """Dump JSON Schema for agent payloads."""
165
+ from guru.models.blend import BestForecast
166
+ from guru.models.forecast import Forecast, Spot
167
+
168
+ schemas = {
169
+ "spot": Spot.model_json_schema(),
170
+ "forecast": Forecast.model_json_schema(),
171
+ "best": BestForecast.model_json_schema(),
172
+ "instruct": {"type": "object", "description": "see guru instruct --json"},
173
+ "error": ERROR_SCHEMA,
174
+ }
175
+ key = name.lower().strip()
176
+ if key not in schemas:
177
+ fail(
178
+ ValueError(f"Unknown schema {name!r}. Choose: {', '.join(sorted(schemas))}"),
179
+ as_json=True,
180
+ )
181
+ print_ok({"name": key, "schema": schemas[key]})
182
+
183
+
184
+ @app.command("doctor")
185
+ def doctor_cmd(
186
+ as_json: bool = typer.Option(False, "--json"),
187
+ ) -> None:
188
+ """Env + version sanity check (no live Windguru call)."""
189
+ info = {
190
+ "version": __version__,
191
+ "GURU_TIMEOUT": os.environ.get("GURU_TIMEOUT", "30"),
192
+ "GURU_IMPERSONATE": os.environ.get("GURU_IMPERSONATE", "chrome"),
193
+ "models": list_models(),
194
+ }
195
+ if as_json:
196
+ print_ok(info)
197
+ return
198
+ console.print(f"guru {__version__}")
199
+ console.print(f"GURU_TIMEOUT={info['GURU_TIMEOUT']}")
200
+ console.print(f"GURU_IMPERSONATE={info['GURU_IMPERSONATE']}")
201
+ console.print(f"{len(info['models'])} known model aliases")
202
+
203
+
204
+ def cli() -> None:
205
+ app()
206
+
207
+
208
+ if __name__ == "__main__":
209
+ cli()
guru/cli/render.py ADDED
@@ -0,0 +1,88 @@
1
+ """Rich human-readable tables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.table import Table
6
+
7
+ from guru.cli.console import console
8
+ from guru.models.blend import BestForecast
9
+ from guru.models.forecast import Forecast, Spot, wind_dir_cardinal
10
+
11
+
12
+ def print_spots_table(spots: list[Spot], *, title: str) -> None:
13
+ table = Table(title=title)
14
+ table.add_column("ID", style="cyan")
15
+ table.add_column("Name")
16
+ table.add_column("Country")
17
+ table.add_column("Nick")
18
+ for s in spots:
19
+ table.add_row(str(s.id), s.name, s.country or "", s.nickname or "")
20
+ console.print(table)
21
+
22
+
23
+ def print_near_table(spots: list[Spot], *, lat: float, lon: float, radius: float) -> None:
24
+ table = Table(title=f"Spots within {radius:g} km of {lat:g},{lon:g}")
25
+ table.add_column("ID", style="cyan")
26
+ table.add_column("Name")
27
+ table.add_column("Lat")
28
+ table.add_column("Lon")
29
+ for s in spots:
30
+ lat_s = f"{s.lat:.4f}" if s.lat is not None else ""
31
+ lon_s = f"{s.lon:.4f}" if s.lon is not None else ""
32
+ table.add_row(str(s.id), s.name, lat_s, lon_s)
33
+ console.print(table)
34
+
35
+
36
+ def print_models_table(rows: list[dict[str, object]]) -> None:
37
+ table = Table(title="Models")
38
+ table.add_column("ID")
39
+ table.add_column("Primary")
40
+ table.add_column("Aliases")
41
+ for row in rows:
42
+ aliases = row.get("aliases") or []
43
+ alias_s = ", ".join(str(a) for a in aliases) # type: ignore[arg-type]
44
+ table.add_row(str(row["id"]), str(row["primary"]), alias_s)
45
+ console.print(table)
46
+
47
+
48
+ def print_best(best: BestForecast) -> None:
49
+ console.print(
50
+ f"[bold]{best.spot.name}[/bold] · preset {best.preset} · top {len(best.models)}"
51
+ )
52
+ table = Table(title="WINDGURU DEFAULT weights")
53
+ table.add_column("#")
54
+ table.add_column("Model")
55
+ table.add_column("ID")
56
+ table.add_column("%", justify="right")
57
+ for m in best.models:
58
+ table.add_row(str(m.rank), m.name, str(m.id_model), f"{m.weight_pct:.1f}")
59
+ console.print(table)
60
+ for fc in best.forecasts:
61
+ print_forecast(fc)
62
+
63
+
64
+ def print_forecast(fc: Forecast) -> None:
65
+ title = f"{fc.spot.name} · {fc.model} ({fc.model_id})"
66
+ if fc.init:
67
+ title += f" · init {fc.init.strftime('%Y-%m-%d %HZ')}"
68
+ table = Table(title=title)
69
+ table.add_column("UTC", style="dim")
70
+ table.add_column("kt", justify="right")
71
+ table.add_column("Gust", justify="right")
72
+ table.add_column("Dir")
73
+ table.add_column("°C", justify="right")
74
+ table.add_column("mm", justify="right")
75
+ for row in fc.hours:
76
+ table.add_row(
77
+ row.time.strftime("%a %d %H:%M"),
78
+ _fmt(row.wind_kn, 1),
79
+ _fmt(row.gust_kn, 1),
80
+ f"{wind_dir_cardinal(row.wind_dir_deg)} {_fmt(row.wind_dir_deg, 0)}".strip(),
81
+ _fmt(row.temp_c, 1),
82
+ _fmt(row.precip_mm, 1),
83
+ )
84
+ console.print(table)
85
+
86
+
87
+ def _fmt(v: float | None, digits: int) -> str:
88
+ return "-" if v is None else f"{v:.{digits}f}"
guru/core/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """Shared core utilities (CLI + MCP)."""
2
+
3
+ from guru.core.envelope import (
4
+ API_VERSION,
5
+ ERROR_SCHEMA,
6
+ dump_model,
7
+ error_payload,
8
+ success_payload,
9
+ )
10
+ from guru.core.errors import ErrorClassification, classify_error
11
+ from guru.core.instruct import instruct_payload
12
+
13
+ __all__ = [
14
+ "API_VERSION",
15
+ "ERROR_SCHEMA",
16
+ "ErrorClassification",
17
+ "classify_error",
18
+ "dump_model",
19
+ "error_payload",
20
+ "instruct_payload",
21
+ "success_payload",
22
+ ]
guru/core/envelope.py ADDED
@@ -0,0 +1,45 @@
1
+ """Shared JSON envelope for CLI and MCP (fli-style agent contract)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from guru.core.errors import classify_error
8
+ from guru.search.exceptions import GuruAmbiguousError
9
+
10
+ API_VERSION = 1
11
+
12
+ ERROR_SCHEMA: dict[str, Any] = {
13
+ "type": "object",
14
+ "properties": {
15
+ "ok": {"const": False},
16
+ "api_version": {"type": "integer"},
17
+ "error": {"type": "string"},
18
+ "error_type": {"type": "string"},
19
+ "retryable": {"type": "boolean"},
20
+ "http_status": {"type": "integer"},
21
+ "candidates": {"type": "array"},
22
+ },
23
+ }
24
+
25
+
26
+ def success_payload(data: Any) -> dict[str, Any]:
27
+ return {"ok": True, "api_version": API_VERSION, "data": data}
28
+
29
+
30
+ def error_payload(exc: BaseException) -> dict[str, Any]:
31
+ classified = classify_error(exc)
32
+ payload: dict[str, Any] = {
33
+ "ok": False,
34
+ "api_version": API_VERSION,
35
+ "error": str(exc),
36
+ **classified.as_fields(),
37
+ }
38
+ if isinstance(exc, GuruAmbiguousError):
39
+ payload["candidates"] = [c.model_dump(mode="json") for c in exc.candidates]
40
+ return payload
41
+
42
+
43
+ def dump_model(model: Any) -> Any:
44
+ """Pydantic model → JSON-ready dict."""
45
+ return model.model_dump(mode="json")
guru/core/errors.py ADDED
@@ -0,0 +1,60 @@
1
+ """Shared error classification for CLI and MCP (fli-style)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from guru.search.exceptions import (
9
+ GuruAmbiguousError,
10
+ GuruError,
11
+ GuruHTTPError,
12
+ GuruNotFoundError,
13
+ GuruParseError,
14
+ )
15
+
16
+ _RETRYABLE_HTTP = {429}
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ErrorClassification:
21
+ error_type: str
22
+ retryable: bool
23
+ http_status: int | None = None
24
+
25
+ def as_fields(self) -> dict[str, Any]:
26
+ fields: dict[str, Any] = {
27
+ "error_type": self.error_type,
28
+ "retryable": self.retryable,
29
+ }
30
+ if self.http_status is not None:
31
+ fields["http_status"] = self.http_status
32
+ return fields
33
+
34
+
35
+ def classify_error(exc: BaseException) -> ErrorClassification:
36
+ """Map an exception to a stable ``error_type`` + ``retryable`` pair."""
37
+ if isinstance(exc, GuruAmbiguousError):
38
+ return ErrorClassification("ambiguous", retryable=False)
39
+ if isinstance(exc, GuruNotFoundError):
40
+ return ErrorClassification("not_found", retryable=False)
41
+ if isinstance(exc, GuruParseError):
42
+ return ErrorClassification("parse_error", retryable=False)
43
+ if isinstance(exc, GuruHTTPError):
44
+ status = getattr(exc, "status_code", None)
45
+ retryable = status is not None and (status in _RETRYABLE_HTTP or status >= 500)
46
+ return ErrorClassification("http_error", retryable=retryable, http_status=status)
47
+ if isinstance(exc, TimeoutError):
48
+ return ErrorClassification("timeout", retryable=True)
49
+ if isinstance(exc, ConnectionError | OSError):
50
+ return ErrorClassification("connection_error", retryable=True)
51
+ if isinstance(exc, ValueError):
52
+ return ErrorClassification("validation_error", retryable=False)
53
+ if isinstance(exc, GuruError):
54
+ return ErrorClassification("search_error", retryable=False)
55
+ msg = str(exc).lower()
56
+ if "timeout" in msg:
57
+ return ErrorClassification("timeout", retryable=True)
58
+ if "429" in msg or "rate" in msg:
59
+ return ErrorClassification("rate_limited", retryable=True, http_status=429)
60
+ return ErrorClassification("unexpected_error", retryable=False)
guru/core/instruct.py ADDED
@@ -0,0 +1,66 @@
1
+ """Agent recipe text (CLI ``instruct`` + MCP)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ INSTRUCT_STEPS: list[dict[str, Any]] = [
8
+ {
9
+ "step": 1,
10
+ "action": "search_spots",
11
+ "command": 'guru spots "<name>" --json',
12
+ "detail": (
13
+ "Search named Windguru spots. If multiple matches, pick an id "
14
+ "(do not guess)."
15
+ ),
16
+ },
17
+ {
18
+ "step": 2,
19
+ "action": "near_spots",
20
+ "command": "guru near --lat <lat> --lon <lon> --json",
21
+ "detail": (
22
+ "Or find free named spots near coordinates (map markers). "
23
+ "Never use PRO lat/lon click-forecast."
24
+ ),
25
+ },
26
+ {
27
+ "step": 3,
28
+ "action": "best_forecast",
29
+ "command": "guru best <id_spot> --json",
30
+ "detail": (
31
+ "Uses Tune preset WINDGURU_DEFAULT only: compute model weights "
32
+ "(resolution × freshness × preference), keep top 3, fetch those forecasts."
33
+ ),
34
+ },
35
+ {
36
+ "step": 4,
37
+ "action": "ignore_rest",
38
+ "command": None,
39
+ "detail": (
40
+ "Ignore lower-weighted models unless the user asks for a specific "
41
+ "model via guru forecast -m."
42
+ ),
43
+ },
44
+ ]
45
+
46
+ INSTRUCT_SUMMARY = (
47
+ "For any place: resolve a named Windguru spot, then run guru best. "
48
+ "Always WINDGURU_DEFAULT Tune; trust only the top 3 models by weight. "
49
+ "Do not scrape windguru.cz HTML or the map canvas. Do not require PRO."
50
+ )
51
+
52
+
53
+ def instruct_payload() -> dict[str, Any]:
54
+ return {
55
+ "summary": INSTRUCT_SUMMARY,
56
+ "preset": "WINDGURU_DEFAULT",
57
+ "top_models": 3,
58
+ "steps": INSTRUCT_STEPS,
59
+ "examples": [
60
+ 'guru spots "castelldefels" --json',
61
+ "guru best 201 --json",
62
+ 'guru spots "De Slufter" --json',
63
+ "guru best 48309 -H 24 --json",
64
+ "guru near --lat 51.9 --lon 4.1 --radius 40 --json",
65
+ ],
66
+ }
guru/mcp/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """MCP package."""
guru/mcp/_entry.py ADDED
@@ -0,0 +1,31 @@
1
+ """MCP console-script entry points."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+
8
+ def run() -> None:
9
+ try:
10
+ from guru.mcp.server import mcp
11
+ except ModuleNotFoundError:
12
+ print(
13
+ "MCP dependencies are not installed.\n"
14
+ "Install them with: pip install 'windguru[mcp]'",
15
+ file=sys.stderr,
16
+ )
17
+ sys.exit(1)
18
+ mcp.run()
19
+
20
+
21
+ def run_http() -> None:
22
+ try:
23
+ from guru.mcp.server import mcp
24
+ except ModuleNotFoundError:
25
+ print(
26
+ "MCP dependencies are not installed.\n"
27
+ "Install them with: pip install 'windguru[mcp]'",
28
+ file=sys.stderr,
29
+ )
30
+ sys.exit(1)
31
+ mcp.run(transport="http", host="127.0.0.1", port=8000)