usdata 0.2.0__tar.gz → 0.2.1__tar.gz

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.
Files changed (29) hide show
  1. {usdata-0.2.0 → usdata-0.2.1}/PKG-INFO +7 -5
  2. {usdata-0.2.0 → usdata-0.2.1}/README.md +6 -4
  3. {usdata-0.2.0 → usdata-0.2.1}/pyproject.toml +14 -1
  4. {usdata-0.2.0 → usdata-0.2.1}/pyproject.toml.orig +12 -2
  5. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/cache.py +3 -0
  6. usdata-0.2.1/src/usdata/cli/__init__.py +5 -0
  7. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/cli/app.py +3 -0
  8. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/fetch.py +4 -0
  9. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/manifest.py +11 -0
  10. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/models.py +5 -0
  11. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/protocols/http.py +2 -0
  12. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/protocols/s3.py +4 -0
  13. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/provenance.py +3 -0
  14. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/providers/base.py +3 -0
  15. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/providers/noaa/coastwatch.py +4 -0
  16. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/providers/noaa/ghcnd.py +4 -0
  17. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/providers/noaa/nexrad.py +5 -0
  18. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/providers/noaa/sites.py +7 -0
  19. usdata-0.2.1/src/usdata/py.typed +0 -0
  20. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/query.py +2 -0
  21. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/registry.py +11 -0
  22. usdata-0.2.0/src/usdata/cli/__init__.py +0 -3
  23. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/__init__.py +0 -0
  24. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/data/nexrad_sites.csv +0 -0
  25. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/data/places.yaml +0 -0
  26. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/data/registry.yaml +0 -0
  27. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/protocols/__init__.py +0 -0
  28. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/providers/__init__.py +0 -0
  29. {usdata-0.2.0 → usdata-0.2.1}/src/usdata/providers/noaa/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: usdata
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Unified Python SDK and CLI for discovering, fetching, and tracking provenance of U.S. public scientific data
5
5
  Keywords: noaa,usgs,nasa,open-data,scientific-data,provenance
6
6
  Author: Jake Van Slyke
@@ -102,11 +102,13 @@ just run search radar
102
102
 
103
103
  Integration tests that hit live services run with `just test-integration`.
104
104
 
105
- Releases are published to PyPI by tagging: create a GitHub release and the
106
- `publish.yml` workflow uploads via trusted publishing.
105
+ Releases: `just release minor` opens a version-bump PR; merging it publishes
106
+ to PyPI and creates the tag and GitHub release. See
107
+ [docs/versioning.md](docs/versioning.md).
107
108
 
108
- See [docs/architecture.md](docs/architecture.md) for how the pieces fit and
109
- [docs/adr/](docs/adr/) for why.
109
+ See [docs/architecture.md](docs/architecture.md) for how the pieces fit,
110
+ [docs/adr/](docs/adr/) for why, and [CONTRIBUTING.md](CONTRIBUTING.md) to add
111
+ a dataset.
110
112
 
111
113
  ## License
112
114
 
@@ -81,11 +81,13 @@ just run search radar
81
81
 
82
82
  Integration tests that hit live services run with `just test-integration`.
83
83
 
84
- Releases are published to PyPI by tagging: create a GitHub release and the
85
- `publish.yml` workflow uploads via trusted publishing.
84
+ Releases: `just release minor` opens a version-bump PR; merging it publishes
85
+ to PyPI and creates the tag and GitHub release. See
86
+ [docs/versioning.md](docs/versioning.md).
86
87
 
87
- See [docs/architecture.md](docs/architecture.md) for how the pieces fit and
88
- [docs/adr/](docs/adr/) for why.
88
+ See [docs/architecture.md](docs/architecture.md) for how the pieces fit,
89
+ [docs/adr/](docs/adr/) for why, and [CONTRIBUTING.md](CONTRIBUTING.md) to add
90
+ a dataset.
89
91
 
90
92
  ## License
91
93
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usdata"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Unified Python SDK and CLI for discovering, fetching, and tracking provenance of U.S. public scientific data"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -41,6 +41,7 @@ usdata = "usdata.cli:app"
41
41
  dev = [
42
42
  "pyright>=1.1.380",
43
43
  "pytest>=8.0",
44
+ "pytest-cov>=7.1.0",
44
45
  "respx>=0.23.1",
45
46
  "ruff>=0.6",
46
47
  ]
@@ -66,7 +67,19 @@ select = [
66
67
  "B",
67
68
  "SIM",
68
69
  "RUF",
70
+ "D",
69
71
  ]
72
+ ignore = [
73
+ "D105",
74
+ "D107",
75
+ ]
76
+
77
+ [tool.ruff.lint.pydocstyle]
78
+ convention = "google"
79
+
80
+ [tool.ruff.lint.per-file-ignores]
81
+ "tests/**" = ["D"]
82
+ "scripts/**" = ["D"]
70
83
 
71
84
  [tool.pyright]
72
85
  include = [
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usdata"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Unified Python SDK and CLI for discovering, fetching, and tracking provenance of U.S. public scientific data"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -33,6 +33,7 @@ usdata = "usdata.cli:app"
33
33
  dev = [
34
34
  "pyright>=1.1.380",
35
35
  "pytest>=8.0",
36
+ "pytest-cov>=7.1.0",
36
37
  "respx>=0.23.1",
37
38
  "ruff>=0.6",
38
39
  ]
@@ -47,7 +48,16 @@ target-version = "py311"
47
48
  src = ["src", "tests"]
48
49
 
49
50
  [tool.ruff.lint]
50
- select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
51
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF", "D"]
52
+ # Docstrings are required on the public API only; magic methods and __init__ are exempt.
53
+ ignore = ["D105", "D107"]
54
+
55
+ [tool.ruff.lint.pydocstyle]
56
+ convention = "google"
57
+
58
+ [tool.ruff.lint.per-file-ignores]
59
+ "tests/**" = ["D"]
60
+ "scripts/**" = ["D"]
51
61
 
52
62
  [tool.pyright]
53
63
  include = ["src", "tests"]
@@ -12,6 +12,7 @@ ENV_VAR = "USDATA_CACHE_DIR"
12
12
 
13
13
 
14
14
  def cache_dir() -> Path:
15
+ """Cache root: $USDATA_CACHE_DIR, else $XDG_CACHE_HOME/usdata, else ~/.cache/usdata."""
15
16
  if override := os.environ.get(ENV_VAR):
16
17
  return Path(override).expanduser()
17
18
  xdg = os.environ.get("XDG_CACHE_HOME")
@@ -20,12 +21,14 @@ def cache_dir() -> Path:
20
21
 
21
22
 
22
23
  def asset_path(asset: Asset, root: Path | None = None) -> Path:
24
+ """Where an asset lives in the cache: <root>/<provider>/<dataset>/<asset id>."""
23
25
  provider, name = asset.dataset_id.split(":", 1)
24
26
  safe_id = asset.id.replace("/", "_")
25
27
  return (root or cache_dir()) / provider / name / safe_id
26
28
 
27
29
 
28
30
  def sha256_file(path: Path, chunk_size: int = 1 << 20) -> str:
31
+ """Hex sha256 of a file, prefixed 'sha256:' to match Asset.checksum."""
29
32
  h = hashlib.sha256()
30
33
  with path.open("rb") as f:
31
34
  while chunk := f.read(chunk_size):
@@ -0,0 +1,5 @@
1
+ """Command-line interface. The Typer app lives in ``usdata.cli.app``."""
2
+
3
+ from usdata.cli.app import app
4
+
5
+ __all__ = ["app"]
@@ -1,3 +1,5 @@
1
+ """Typer application: argument parsing and exit codes only; logic lives in the library."""
2
+
1
3
  from __future__ import annotations
2
4
 
3
5
  from pathlib import Path
@@ -34,6 +36,7 @@ def main(
34
36
  typer.Option("--version", callback=_version_callback, is_eager=True, help="Show version."),
35
37
  ] = None,
36
38
  ) -> None:
39
+ """usdata: discover, fetch, and track provenance of U.S. public scientific data."""
37
40
  pass
38
41
 
39
42
 
@@ -13,10 +13,14 @@ from usdata.providers import load_adapter
13
13
 
14
14
 
15
15
  class ChecksumMismatch(RuntimeError):
16
+ """A fetched file's sha256 did not match the checksum the adapter declared."""
17
+
16
18
  pass
17
19
 
18
20
 
19
21
  class FetchedAsset(BaseModel):
22
+ """One asset on disk with its provenance and whether the cache satisfied it."""
23
+
20
24
  asset: Asset
21
25
  path: Path
22
26
  provenance: Provenance
@@ -26,6 +26,7 @@ class SourceSpec(BaseModel):
26
26
  params: dict[str, Any] = Field(default_factory=dict)
27
27
 
28
28
  def to_query(self) -> Query:
29
+ """Build the Query this source resolves to."""
29
30
  return build_query(
30
31
  location=self.location,
31
32
  bbox=self.bbox,
@@ -37,12 +38,15 @@ class SourceSpec(BaseModel):
37
38
 
38
39
 
39
40
  class Manifest(BaseModel):
41
+ """A declarative list of inputs a project needs: usdata pull fetches them."""
42
+
40
43
  name: str
41
44
  version: str = "1.0"
42
45
  sources: list[SourceSpec] = Field(min_length=1)
43
46
 
44
47
  @classmethod
45
48
  def load(cls, path: Path) -> Manifest:
49
+ """Parse a manifest YAML file."""
46
50
  return cls.model_validate(yaml.safe_load(path.read_text()) or {})
47
51
 
48
52
  def validate_against(self, registry: Registry | None = None) -> list[str]:
@@ -52,22 +56,29 @@ class Manifest(BaseModel):
52
56
 
53
57
 
54
58
  class LockedAsset(BaseModel):
59
+ """One resolved asset and the provenance of the copy that was fetched."""
60
+
55
61
  asset: Asset
56
62
  provenance: Provenance
57
63
 
58
64
 
59
65
  class Lockfile(BaseModel):
66
+ """Exactly what a manifest resolved to, with checksums, so it can be reproduced."""
67
+
60
68
  manifest: str
61
69
  generated_at: datetime
62
70
  assets: list[LockedAsset] = Field(default_factory=list)
63
71
 
64
72
  @classmethod
65
73
  def load(cls, path: Path) -> Lockfile:
74
+ """Read a lockfile written by ``save``."""
66
75
  return cls.model_validate_json(path.read_text())
67
76
 
68
77
  def save(self, path: Path) -> None:
78
+ """Write the lockfile as indented JSON."""
69
79
  path.write_text(self.model_dump_json(indent=2))
70
80
 
71
81
 
72
82
  def lockfile_path(manifest_path: Path) -> Path:
83
+ """The lockfile that pairs with a manifest: <manifest stem>.lock.json."""
73
84
  return manifest_path.with_suffix(".lock.json")
@@ -56,6 +56,7 @@ class BBox(BaseModel):
56
56
  )
57
57
 
58
58
  def intersects(self, other: BBox) -> bool:
59
+ """True if the boxes share any area, edges included."""
59
60
  return not (
60
61
  other.west > self.east
61
62
  or other.east < self.west
@@ -64,9 +65,11 @@ class BBox(BaseModel):
64
65
  )
65
66
 
66
67
  def contains_point(self, lat: float, lon: float) -> bool:
68
+ """True if the point lies inside or on the edge of the box."""
67
69
  return self.west <= lon <= self.east and self.south <= lat <= self.north
68
70
 
69
71
  def as_tuple(self) -> tuple[float, float, float, float]:
72
+ """The box as (west, south, east, north)."""
70
73
  return (self.west, self.south, self.east, self.north)
71
74
 
72
75
 
@@ -85,6 +88,7 @@ class TimeRange(BaseModel):
85
88
  return self
86
89
 
87
90
  def overlaps(self, other: TimeRange) -> bool:
91
+ """True if the ranges share any instant; open bounds match everything on that side."""
88
92
  starts_after = self.start is not None and other.end is not None and self.start > other.end
89
93
  ends_before = self.end is not None and other.start is not None and self.end < other.start
90
94
  return not (starts_after or ends_before)
@@ -125,6 +129,7 @@ class Dataset(BaseModel):
125
129
 
126
130
  @property
127
131
  def name(self) -> str:
132
+ """The dataset name without the provider prefix."""
128
133
  return self.id.split(":", 1)[1]
129
134
 
130
135
 
@@ -1,3 +1,5 @@
1
+ """Shared HTTP client configuration and streaming download."""
2
+
1
3
  from __future__ import annotations
2
4
 
3
5
  from pathlib import Path
@@ -21,6 +21,8 @@ NS = "{http://s3.amazonaws.com/doc/2006-03-01/}"
21
21
 
22
22
 
23
23
  class S3Object(BaseModel):
24
+ """One key from a bucket listing."""
25
+
24
26
  key: str
25
27
  size: int
26
28
  etag: str | None = None
@@ -28,6 +30,7 @@ class S3Object(BaseModel):
28
30
 
29
31
 
30
32
  def parse_s3_url(url: str) -> tuple[str, str]:
33
+ """Split 's3://bucket/key' into (bucket, key)."""
31
34
  if not url.startswith("s3://"):
32
35
  raise ValueError(f"not an s3:// URL: {url}")
33
36
  bucket, _, key = url[5:].partition("/")
@@ -35,6 +38,7 @@ def parse_s3_url(url: str) -> tuple[str, str]:
35
38
 
36
39
 
37
40
  def https_url(bucket: str, key: str = "") -> str:
41
+ """The virtual-hosted HTTPS URL for a bucket key, with the key percent-encoded."""
38
42
  return f"https://{bucket}.s3.amazonaws.com/{quote(key)}"
39
43
 
40
44
 
@@ -27,14 +27,17 @@ def record(dataset: Dataset, asset: Asset, path: Path) -> Provenance:
27
27
 
28
28
 
29
29
  def sidecar_path(path: Path) -> Path:
30
+ """The provenance JSON file that sits beside a cached file."""
30
31
  return path.with_name(path.name + SIDECAR_SUFFIX)
31
32
 
32
33
 
33
34
  def write(prov: Provenance, path: Path) -> Path:
35
+ """Write a provenance record beside ``path`` and return the sidecar path."""
34
36
  out = sidecar_path(path)
35
37
  out.write_text(prov.model_dump_json(indent=2))
36
38
  return out
37
39
 
38
40
 
39
41
  def read(path: Path) -> Provenance:
42
+ """Load the provenance record stored beside ``path``."""
40
43
  return Provenance.model_validate_json(sidecar_path(path).read_text())
@@ -1,3 +1,5 @@
1
+ """The Provider interface every dataset adapter implements, and how adapters are loaded."""
2
+
1
3
  from __future__ import annotations
2
4
 
3
5
  import importlib
@@ -31,6 +33,7 @@ class Provider(ABC):
31
33
 
32
34
 
33
35
  def load_adapter(dataset: Dataset) -> Provider:
36
+ """Instantiate the Provider named by a dataset's ``adapter`` dotted path."""
34
37
  module_name, _, class_name = dataset.adapter.partition(":")
35
38
  module = importlib.import_module(module_name)
36
39
  cls = getattr(module, class_name)
@@ -9,8 +9,12 @@ from usdata.providers.base import NotImplementedProvider, Provider
9
9
 
10
10
 
11
11
  class CoastwatchSst(Provider):
12
+ """Placeholder adapter; see issue #5."""
13
+
12
14
  def list_assets(self, query: Query) -> list[Asset]:
15
+ """Not implemented yet."""
13
16
  raise NotImplementedProvider(f"{self.dataset.id} adapter is not implemented yet")
14
17
 
15
18
  def fetch(self, asset: Asset, dest: Path) -> Path:
19
+ """Not implemented yet."""
16
20
  raise NotImplementedProvider(f"{self.dataset.id} adapter is not implemented yet")
@@ -39,6 +39,8 @@ def _stations_param(raw: Any) -> list[str]:
39
39
 
40
40
 
41
41
  class GhcnDaily(Provider):
42
+ """GHCN-Daily adapter. Params: ``stations`` (list or comma string), ``units``."""
43
+
42
44
  def __init__(self, dataset: Dataset, client: httpx.Client | None = None) -> None:
43
45
  super().__init__(dataset)
44
46
  self._client = client
@@ -83,6 +85,7 @@ class GhcnDaily(Provider):
83
85
  return found
84
86
 
85
87
  def list_assets(self, query: Query) -> list[Asset]:
88
+ """One CSV asset per chunk of up to STATIONS_PER_ASSET stations for the query window."""
86
89
  if query.time is None or query.time.start is None or query.time.end is None:
87
90
  raise QueryError(f"{self.dataset.id} requires both start and end dates")
88
91
  if "stations" in query.params:
@@ -125,4 +128,5 @@ class GhcnDaily(Provider):
125
128
  return assets
126
129
 
127
130
  def fetch(self, asset: Asset, dest: Path) -> Path:
131
+ """Stream the CSV response to ``dest``."""
128
132
  return http.download(asset.href, dest, self._http())
@@ -42,6 +42,8 @@ def scan_time(key: str) -> datetime | None:
42
42
 
43
43
 
44
44
  class NexradLevel2(Provider):
45
+ """NEXRAD Level II adapter. Params: ``site``/``sites`` (ICAO ids), ``nearest`` (int)."""
46
+
45
47
  def __init__(self, dataset: Dataset, client: httpx.Client | None = None) -> None:
46
48
  super().__init__(dataset)
47
49
  self._client = client
@@ -52,6 +54,7 @@ class NexradLevel2(Provider):
52
54
  return self._client
53
55
 
54
56
  def select_sites(self, query: Query) -> list[str]:
57
+ """Radar site ids the query refers to; see the module docstring for the rules."""
55
58
  raw = query.params.get("sites", query.params.get("site"))
56
59
  if raw is not None:
57
60
  ids = _sites_param(raw)
@@ -70,6 +73,7 @@ class NexradLevel2(Provider):
70
73
  return [s.id for s in sites.nearest(lat, lon, 1)]
71
74
 
72
75
  def list_assets(self, query: Query) -> list[Asset]:
76
+ """Every volume scan for the selected sites inside the query's UTC time window."""
73
77
  if query.time is None or query.time.start is None or query.time.end is None:
74
78
  raise QueryError(f"{self.dataset.id} requires both start and end times")
75
79
  start = query.time.start.astimezone(UTC)
@@ -99,4 +103,5 @@ class NexradLevel2(Provider):
99
103
  return assets
100
104
 
101
105
  def fetch(self, asset: Asset, dest: Path) -> Path:
106
+ """Download one scan object anonymously to ``dest``."""
102
107
  return s3.download(asset.href, dest, self._http())
@@ -15,6 +15,8 @@ EARTH_RADIUS_KM = 6371.0
15
15
 
16
16
 
17
17
  class RadarSite(BaseModel):
18
+ """A radar site from the bundled table. ``type`` is NEXRAD, TDWR, or TEST."""
19
+
18
20
  model_config = ConfigDict(frozen=True)
19
21
 
20
22
  id: str
@@ -26,6 +28,7 @@ class RadarSite(BaseModel):
26
28
  type: str
27
29
 
28
30
  def distance_km(self, lat: float, lon: float) -> float:
31
+ """Great-circle distance from this site to a point, in kilometres."""
29
32
  p1, p2 = math.radians(self.lat), math.radians(lat)
30
33
  dphi = p2 - p1
31
34
  dlam = math.radians(lon - self.lon)
@@ -35,6 +38,7 @@ class RadarSite(BaseModel):
35
38
 
36
39
  @lru_cache(maxsize=1)
37
40
  def all_sites() -> dict[str, RadarSite]:
41
+ """The bundled site table keyed by ICAO id."""
38
42
  text = (resources.files("usdata.data") / "nexrad_sites.csv").read_text()
39
43
  sites = {}
40
44
  for row in csv.DictReader(text.splitlines()):
@@ -45,6 +49,7 @@ def all_sites() -> dict[str, RadarSite]:
45
49
 
46
50
 
47
51
  def get_site(site_id: str) -> RadarSite:
52
+ """Look up a site by ICAO id, case-insensitively."""
48
53
  try:
49
54
  return all_sites()[site_id.upper()]
50
55
  except KeyError:
@@ -52,6 +57,7 @@ def get_site(site_id: str) -> RadarSite:
52
57
 
53
58
 
54
59
  def sites_in(bbox: BBox, *, types: tuple[str, ...] = ("NEXRAD",)) -> list[RadarSite]:
60
+ """Sites of the given types whose location lies inside ``bbox``, sorted by id."""
55
61
  return sorted(
56
62
  (s for s in all_sites().values() if s.type in types and bbox.contains_point(s.lat, s.lon)),
57
63
  key=lambda s: s.id,
@@ -61,5 +67,6 @@ def sites_in(bbox: BBox, *, types: tuple[str, ...] = ("NEXRAD",)) -> list[RadarS
61
67
  def nearest(
62
68
  lat: float, lon: float, n: int = 1, *, types: tuple[str, ...] = ("NEXRAD",)
63
69
  ) -> list[RadarSite]:
70
+ """The ``n`` closest sites of the given types to a point, nearest first."""
64
71
  candidates = [s for s in all_sites().values() if s.type in types]
65
72
  return sorted(candidates, key=lambda s: s.distance_km(lat, lon))[:n]
File without changes
@@ -13,6 +13,8 @@ from usdata.models import BBox, Query, TimeRange
13
13
 
14
14
 
15
15
  class UnknownPlace(ValueError):
16
+ """A place name that is not in the bundled place table."""
17
+
16
18
  pass
17
19
 
18
20
 
@@ -21,10 +21,14 @@ _TOKEN = re.compile(r"[a-z0-9]+")
21
21
 
22
22
 
23
23
  class DatasetNotFound(KeyError):
24
+ """No registry entry has the requested id."""
25
+
24
26
  pass
25
27
 
26
28
 
27
29
  class SearchResult(BaseModel):
30
+ """A dataset and its keyword-match score."""
31
+
28
32
  model_config = ConfigDict(frozen=True)
29
33
 
30
34
  dataset: Dataset
@@ -56,6 +60,8 @@ def _score(dataset: Dataset, terms: set[str]) -> float:
56
60
 
57
61
 
58
62
  class Registry:
63
+ """An in-memory collection of datasets addressable by id and searchable by keyword."""
64
+
59
65
  def __init__(self, datasets: Iterable[Dataset]) -> None:
60
66
  self._by_id: dict[str, Dataset] = {}
61
67
  for ds in datasets:
@@ -65,11 +71,13 @@ class Registry:
65
71
 
66
72
  @classmethod
67
73
  def from_yaml(cls, path: Path) -> Registry:
74
+ """Load a registry from a YAML file with a top-level ``datasets`` list."""
68
75
  raw = yaml.safe_load(path.read_text()) or {}
69
76
  return cls(Dataset.model_validate(d) for d in raw.get("datasets", []))
70
77
 
71
78
  @classmethod
72
79
  def bundled(cls) -> Registry:
80
+ """The registry shipped inside the package."""
73
81
  with resources.as_file(resources.files("usdata.data") / "registry.yaml") as p:
74
82
  return cls.from_yaml(p)
75
83
 
@@ -83,12 +91,14 @@ class Registry:
83
91
  return dataset_id in self._by_id
84
92
 
85
93
  def get(self, dataset_id: str) -> Dataset:
94
+ """Return the dataset with this id or raise DatasetNotFound."""
86
95
  try:
87
96
  return self._by_id[dataset_id]
88
97
  except KeyError:
89
98
  raise DatasetNotFound(dataset_id) from None
90
99
 
91
100
  def providers(self) -> set[str]:
101
+ """The set of provider names present in the registry."""
92
102
  return {ds.provider for ds in self}
93
103
 
94
104
  def search(self, query: Query) -> list[SearchResult]:
@@ -111,4 +121,5 @@ class Registry:
111
121
 
112
122
  @lru_cache(maxsize=1)
113
123
  def default_registry() -> Registry:
124
+ """The bundled registry, loaded once per process."""
114
125
  return Registry.bundled()
@@ -1,3 +0,0 @@
1
- from usdata.cli.app import app
2
-
3
- __all__ = ["app"]
File without changes