Skip to content

Commit dec66d7

Browse files
committed
support type checking and IDE intellisense
1 parent 2fd0690 commit dec66d7

3 files changed

Lines changed: 244 additions & 22 deletions

File tree

src/herbie/v2/__init__.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,54 @@
3535
from herbie.v2._namespace import Herbie
3636
from herbie.v2.fast import FastHerbie
3737

38+
# ---------------------------------------------------------------------------
39+
# Explicit imports — required for IDE intellisense (Pylance / pyright / mypy)
40+
# ---------------------------------------------------------------------------
41+
# IDEs cannot resolve names added via globals()[name] = cls at runtime.
42+
# These imports are redundant at runtime but make every model class directly
43+
# visible to static analysers, enabling hover docs and parameter hints for:
44+
#
45+
# from herbie.v2 import HRRR
46+
# from herbie.v2 import IFS
47+
# etc.
48+
49+
# NOAA convection-allowing / mesoscale
50+
from herbie.v2.models.hrrr import HRRR, HRRRAK
51+
52+
# NOAA global
53+
from herbie.v2.models.gfs import GFS, GDAS, GFSWave
54+
55+
# NOAA regional / analysis
56+
from herbie.v2.models.noaa_models import GEFS, NAM, NBM, RRFS
57+
from herbie.v2.models.rap import RAP, RAPHistorical
58+
from herbie.v2.models.rtma import RTMA, RTMA_AK, URMA
59+
60+
# NOAA ensemble / specialty
61+
from herbie.v2.models.more_models import AIGFS, CFS, HGEFS, HREF, NBMQMD
62+
63+
# Hurricane / Navy
64+
from herbie.v2.models.hurricane_and_navy import (
65+
HAFSA,
66+
HAFSB,
67+
HIRESW,
68+
NavgemGODAE,
69+
NavgemNOMADS,
70+
)
71+
72+
# ECMWF
73+
from herbie.v2.models.ecmwf import AIFS, IFS
74+
75+
# Canadian MSC
76+
from herbie.v2.models.canada import GDPS, HRDPS, RDPS
77+
3878
# ---------------------------------------------------------------------------
3979
# Auto-discover and register all HerbieModel subclasses
4080
# ---------------------------------------------------------------------------
81+
# This loop still runs so that:
82+
# 1. Third-party models added via entry points are registered.
83+
# 2. Herbie.HRRR / Herbie["HRRR"] namespace access works.
84+
# 3. Any models added to herbie/v2/models/ without an explicit import
85+
# above are still available at runtime.
4186

4287
_registered: dict[str, type[HerbieModel]] = {}
4388

@@ -56,8 +101,8 @@
56101
# Attach to the Herbie namespace class
57102
setattr(Herbie, _cls_name, _cls)
58103

59-
# Also export at module level so `from herbie.v2 import HRRR` works
60-
globals()[_cls_name] = _cls
104+
# Export at module level (no-op if already imported explicitly above)
105+
globals().setdefault(_cls_name, _cls)
61106

62107
_registered[_cls_name] = _cls
63108

src/herbie/v2/_base.py

Lines changed: 102 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,11 @@ def __init__(
156156

157157
# Build source map (string construction only — fast, no network)
158158
self.SOURCES: dict[str, Source] = self._build_sources()
159+
if self.priority:
160+
# Filter to only the requested sources, in priority order
161+
self.SOURCES = {
162+
k: self.SOURCES[k] for k in self.priority if k in self.SOURCES
163+
}
159164

160165
# ── Abstract interface ─────────────────────────────────────────────────
161166

@@ -212,10 +217,12 @@ def _resolve_params(self, kwargs: dict) -> dict:
212217
# ── Source ordering ────────────────────────────────────────────────────
213218

214219
def _ordered_sources(self) -> dict[str, Source]:
215-
"""Return sources in priority order (user-supplied or model default)."""
216-
if self.priority is None:
217-
return self.SOURCES
218-
return {k: self.SOURCES[k] for k in self.priority if k in self.SOURCES}
220+
"""Return sources in priority order (user-supplied or model default).
221+
222+
SOURCES is already reordered at construction time, so this is a
223+
simple passthrough kept for internal consistency.
224+
"""
225+
return self.SOURCES
219226

220227
# ── Local path helpers ─────────────────────────────────────────────────
221228

@@ -666,9 +673,9 @@ def xarray(
666673

667674
return ds
668675

669-
def find(self) -> dict[str, tuple]:
676+
def find(self) -> "HerbieModel":
670677
"""
671-
Resolve the first available GRIB source and index file.
678+
Resolve and display the first available GRIB source and index file.
672679
673680
Unlike ``status()``, which fires parallel HEAD requests to *every*
674681
source, ``find()`` walks sources in priority order and stops at the
@@ -677,11 +684,74 @@ def find(self) -> dict[str, tuple]:
677684
will actually be used before doing any real work.
678685
679686
Results are cached, so repeated calls are free.
687+
688+
Returns
689+
-------
690+
self
691+
The HerbieModel instance, enabling chaining::
692+
693+
ds = H.find().xarray("TMP:2 m above ground")
694+
695+
Examples
696+
--------
697+
>>> H = GFS("2025-01-01", fxx=6)
698+
>>> H.find() # display + return self
699+
>>> H.find().inventory("TMP:500 mb") # chain into inventory
680700
"""
681-
return {
682-
"grib": self._found_grib,
683-
"index": self._found_index,
684-
}
701+
# Trigger lazy resolution (cached after the first call)
702+
grib_src, grib_url = self._found_grib
703+
idx_src, idx_url = self._found_index
704+
705+
# ── Rich display ───────────────────────────────────────────────────
706+
logo = Text()
707+
logo.append("▌", style="bold red on white")
708+
logo.append("▌", style="bold blue on #f0ead2")
709+
logo.append("Herbie", style="bold black on #f0ead2")
710+
logo.append(f" {self.MODEL_NAME}", style="bold cyan")
711+
logo.append(f" — {self.MODEL_DESCRIPTION}", style="dim italic")
712+
713+
grid = Table.grid(padding=(0, 2))
714+
grid.add_column() # label
715+
grid.add_column() # source badge
716+
grid.add_column() # url / path
717+
718+
# Header row
719+
grid.add_row(
720+
f"[bold]Initialized:[/bold] [green]{self.date:%Y-%b-%d %H:%M UTC}[/green]"
721+
f" [bold]F{self.fxx:02d}[/bold]",
722+
"",
723+
f"[bold]Valid:[/bold] [green]{self.valid_date:%Y-%b-%d %H:%M UTC}[/green]",
724+
)
725+
726+
grid.add_row("", "", "") # spacer
727+
728+
# GRIB row
729+
if grib_url:
730+
badge = f"[[bold cyan]{grib_src}[/bold cyan]]"
731+
link = f"[link={grib_url}][dim]{grib_url}[/dim][/link]"
732+
grid.add_row("[bold]GRIB [/bold]", badge, link)
733+
else:
734+
grid.add_row("[bold]GRIB [/bold]", "[red]not found[/red]", "")
735+
736+
# Index row
737+
if idx_url:
738+
badge = f"[[bold cyan]{idx_src}[/bold cyan]]"
739+
link = f"[link={idx_url}][dim]{idx_url}[/dim][/link]"
740+
grid.add_row("[bold]Index[/bold]", badge, link)
741+
else:
742+
grid.add_row("[bold]Index[/bold]", "[red]not found[/red]", "")
743+
744+
console.print(
745+
Panel(
746+
grid,
747+
title=logo,
748+
title_align="left",
749+
border_style="cyan",
750+
box=box.ROUNDED,
751+
)
752+
)
753+
754+
return self
685755

686756
def status(self) -> None:
687757
"""
@@ -693,8 +763,8 @@ def status(self) -> None:
693763
694764
Sources are shown in three separate sections depending on type:
695765
Remote GRIB Sources, Remote Directory Sources, Remote Zarr Sources.
696-
The GRIB section includes an Index column showing whether the
697-
companion index file was also found.
766+
The GRIB section links to the index file (whichever suffix is
767+
found first) appended to the source URL.
698768
"""
699769
from rich.console import Group
700770

@@ -713,12 +783,14 @@ def status(self) -> None:
713783
}
714784

715785
# ── Parallel HEAD requests ─────────────────────────────────────────
716-
# For each GRIB source check both the GRIB file and its first index
717-
# suffix URL. For Zarr sources just check the store URL.
786+
# For each GRIB source check the GRIB file and every index suffix.
787+
# Index keys are (name, "idx:SUFFIX") so we know which suffix hit.
788+
# For Zarr sources just check the store URL.
718789
check_map: dict[tuple[str, str], str] = {}
719790
for name, src in grib_srcs.items():
720791
check_map[(name, "grib")] = src.url
721-
check_map[(name, "idx")] = src.url + src.index_suffixes[0]
792+
for suffix in src.index_suffixes:
793+
check_map[(name, f"idx:{suffix}")] = src.url + suffix
722794
for name, src in zarr_srcs.items():
723795
check_map[(name, "zarr")] = src.url
724796

@@ -761,20 +833,30 @@ def status(self) -> None:
761833
)
762834
grib_table.add_column("Source", style="bold cyan")
763835
grib_table.add_column("GRIB", justify="center")
764-
grib_table.add_column("Index", justify="center")
765836
grib_table.add_column("Size", justify="right")
766-
grib_table.add_column("URL", style="dim", overflow="fold", max_width=60)
837+
grib_table.add_column("URL", style="dim", overflow="fold", max_width=70)
767838

768839
for name, src in grib_srcs.items():
769840
g_info = url_results.get(
770841
(name, "grib"), {"exists": False, "size": None}
771842
)
772-
ix_info = url_results.get((name, "idx"), {"exists": False})
773843
grib_str = "[green]✓[/green]" if g_info["exists"] else "[red]✗[/red]"
774-
idx_str = "[green]✓[/green]" if ix_info["exists"] else "[red]✗[/red]"
775844
size_str = _fmt_size(g_info["size"])
845+
# Find first index suffix that exists
846+
found_idx: tuple[str, str] | None = next(
847+
(
848+
(suffix, url_results[(name, f"idx:{suffix}")]["exists"])
849+
for suffix in src.index_suffixes
850+
if url_results.get((name, f"idx:{suffix}"), {}).get("exists")
851+
),
852+
None,
853+
)
854+
idx_url = src.url + found_idx[0] if found_idx else None
776855
url_str = f"[link={src.url}]{src.url}[/link]"
777-
grib_table.add_row(name, grib_str, idx_str, size_str, url_str)
856+
if idx_url:
857+
suffix_label = found_idx[0]
858+
url_str += f" [link={idx_url}]{suffix_label}[/link]"
859+
grib_table.add_row(name, grib_str, size_str, url_str)
778860

779861
renderables.append(grib_table)
780862

src/herbie/v2/_namespace.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,55 @@
1414
1515
import herbie.v2 as herbie
1616
H = herbie.HRRR("2025-01-01", fxx=6) # module attribute style
17+
18+
IDE / type-checker support
19+
--------------------------
20+
The ``TYPE_CHECKING`` block below declares all built-in model classes as
21+
class-level attributes. At runtime these lines are never executed (so
22+
there is no import overhead and no circular-import risk), but Pylance,
23+
pyright, mypy, and any other static analyser will see them and provide
24+
full autocomplete, hover documentation, and parameter hints.
25+
26+
Third-party models registered via the ``herbie.v2.models`` entry-point
27+
group will not appear here, but they can add their own stubs in the same
28+
way by patching ``Herbie`` inside a ``TYPE_CHECKING`` block in their own
29+
package.
1730
"""
1831

1932
from __future__ import annotations
2033

34+
from typing import TYPE_CHECKING, ClassVar
35+
36+
if TYPE_CHECKING:
37+
# ── NOAA convection-allowing / mesoscale ──────────────────────────────
38+
from herbie.v2.models.hrrr import HRRR, HRRRAK
39+
40+
# ── NOAA global ───────────────────────────────────────────────────────
41+
from herbie.v2.models.gfs import GFS, GDAS, GFSWave
42+
43+
# ── NOAA regional / analysis ──────────────────────────────────────────
44+
from herbie.v2.models.noaa_models import GEFS, NAM, NBM, RRFS
45+
from herbie.v2.models.rap import RAP, RAPHistorical
46+
from herbie.v2.models.rtma import RTMA, RTMA_AK, URMA
47+
48+
# ── NOAA ensemble / specialty ─────────────────────────────────────────
49+
from herbie.v2.models.more_models import AIGFS, CFS, HGEFS, HREF, NBMQMD
50+
51+
# ── Hurricane / Navy ──────────────────────────────────────────────────
52+
from herbie.v2.models.hurricane_and_navy import (
53+
HAFSA,
54+
HAFSB,
55+
HIRESW,
56+
NavgemGODAE,
57+
NavgemNOMADS,
58+
)
59+
60+
# ── ECMWF ─────────────────────────────────────────────────────────────
61+
from herbie.v2.models.ecmwf import AIFS, IFS
62+
63+
# ── Canadian MSC ──────────────────────────────────────────────────────
64+
from herbie.v2.models.canada import GDPS, HRDPS, RDPS
65+
2166

2267
class Herbie:
2368
"""
@@ -48,6 +93,56 @@ class Herbie:
4893
H = HRRR("2025-01-01", fxx=6)
4994
"""
5095

96+
# ── Static type annotations for IDE support ───────────────────────────
97+
# These are only evaluated by type checkers (TYPE_CHECKING=True); at
98+
# runtime __init__.py populates these via setattr(). Keeping them here
99+
# means Pylance / pyright / mypy resolve the full class signature and
100+
# surface docstrings, parameter hints, and autocomplete for all methods.
101+
102+
if TYPE_CHECKING:
103+
# NOAA convection-allowing / mesoscale
104+
HRRR: ClassVar[type[HRRR]]
105+
HRRRAK: ClassVar[type[HRRRAK]]
106+
107+
# NOAA global
108+
GFS: ClassVar[type[GFS]]
109+
GDAS: ClassVar[type[GDAS]]
110+
GFSWave: ClassVar[type[GFSWave]]
111+
112+
# NOAA regional / analysis
113+
NAM: ClassVar[type[NAM]]
114+
NBM: ClassVar[type[NBM]]
115+
GEFS: ClassVar[type[GEFS]]
116+
RRFS: ClassVar[type[RRFS]]
117+
RAP: ClassVar[type[RAP]]
118+
RAPHistorical: ClassVar[type[RAPHistorical]]
119+
RTMA: ClassVar[type[RTMA]]
120+
RTMA_AK: ClassVar[type[RTMA_AK]]
121+
URMA: ClassVar[type[URMA]]
122+
123+
# NOAA ensemble / specialty
124+
AIGFS: ClassVar[type[AIGFS]]
125+
CFS: ClassVar[type[CFS]]
126+
HGEFS: ClassVar[type[HGEFS]]
127+
HREF: ClassVar[type[HREF]]
128+
NBMQMD: ClassVar[type[NBMQMD]]
129+
130+
# Hurricane / Navy
131+
HAFSA: ClassVar[type[HAFSA]]
132+
HAFSB: ClassVar[type[HAFSB]]
133+
HIRESW: ClassVar[type[HIRESW]]
134+
NavgemNOMADS: ClassVar[type[NavgemNOMADS]]
135+
NavgemGODAE: ClassVar[type[NavgemGODAE]]
136+
137+
# ECMWF
138+
IFS: ClassVar[type[IFS]]
139+
AIFS: ClassVar[type[AIFS]]
140+
141+
# Canadian MSC
142+
HRDPS: ClassVar[type[HRDPS]]
143+
RDPS: ClassVar[type[RDPS]]
144+
GDPS: ClassVar[type[GDPS]]
145+
51146
@classmethod
52147
def available_models(cls) -> list[str]:
53148
"""Return a sorted list of all registered model names."""

0 commit comments

Comments
 (0)