usdata 0.31.0__tar.gz → 0.32.0__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 (85) hide show
  1. {usdata-0.31.0 → usdata-0.32.0}/PKG-INFO +1 -1
  2. {usdata-0.31.0 → usdata-0.32.0}/pyproject.toml +1 -1
  3. {usdata-0.31.0 → usdata-0.32.0}/pyproject.toml.orig +1 -1
  4. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/__init__.py +2 -1
  5. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/_grib.py +23 -2
  6. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/cli/app.py +80 -1
  7. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/doctor.py +3 -2
  8. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/pull.py +136 -6
  9. {usdata-0.31.0 → usdata-0.32.0}/README.md +0 -0
  10. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/__main__.py +0 -0
  11. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/_aqs.py +0 -0
  12. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/_census.py +0 -0
  13. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/_fetch.py +0 -0
  14. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/_files.py +0 -0
  15. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/_hurdat2.py +0 -0
  16. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/_netcdf.py +0 -0
  17. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/_progress.py +0 -0
  18. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/_radar.py +0 -0
  19. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/cache.py +0 -0
  20. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/cache_ops.py +0 -0
  21. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/cite.py +0 -0
  22. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/cli/__init__.py +0 -0
  23. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/cli/cache.py +0 -0
  24. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/cli/cite.py +0 -0
  25. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/cli/doctor.py +0 -0
  26. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/cli/inspect.py +0 -0
  27. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/cli/progress.py +0 -0
  28. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/data/nexrad_sites.csv +0 -0
  29. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/data/places.csv +0 -0
  30. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/data/places.sources.json +0 -0
  31. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/data/registry.yaml +0 -0
  32. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/inspect.py +0 -0
  33. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/manifest.py +0 -0
  34. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/mirror.py +0 -0
  35. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/models.py +0 -0
  36. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/protocols/__init__.py +0 -0
  37. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/protocols/erddap.py +0 -0
  38. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/protocols/http.py +0 -0
  39. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/protocols/listing.py +0 -0
  40. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/protocols/s3.py +0 -0
  41. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/provenance.py +0 -0
  42. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/__init__.py +0 -0
  43. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/base.py +0 -0
  44. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/census/__init__.py +0 -0
  45. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/census/acs.py +0 -0
  46. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/credentials.py +0 -0
  47. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/epa/__init__.py +0 -0
  48. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/epa/aqs.py +0 -0
  49. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/fema/__init__.py +0 -0
  50. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/fema/declarations.py +0 -0
  51. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/http.py +0 -0
  52. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/__init__.py +0 -0
  53. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/coastwatch.py +0 -0
  54. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/coops.py +0 -0
  55. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/gfs.py +0 -0
  56. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/ghcnd.py +0 -0
  57. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/glm.py +0 -0
  58. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/goes.py +0 -0
  59. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/grib_index.py +0 -0
  60. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/gsom.py +0 -0
  61. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/gsoy.py +0 -0
  62. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/hrrr.py +0 -0
  63. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/hurdat2.py +0 -0
  64. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/ibtracs.py +0 -0
  65. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/lcd.py +0 -0
  66. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/mrms.py +0 -0
  67. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/nbm.py +0 -0
  68. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/nexrad.py +0 -0
  69. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/nexrad_level3.py +0 -0
  70. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/normals.py +0 -0
  71. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/nws_vtec.py +0 -0
  72. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/rap.py +0 -0
  73. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/sites.py +0 -0
  74. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/spc.py +0 -0
  75. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/noaa/storm_events.py +0 -0
  76. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/params.py +0 -0
  77. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/usgs/__init__.py +0 -0
  78. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/usgs/daily.py +0 -0
  79. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/providers/usgs/earthquakes.py +0 -0
  80. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/py.typed +0 -0
  81. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/query.py +0 -0
  82. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/readers.py +0 -0
  83. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/registry.py +0 -0
  84. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/selection.py +0 -0
  85. {usdata-0.31.0 → usdata-0.32.0}/src/usdata/testing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: usdata
3
- Version: 0.31.0
3
+ Version: 0.32.0
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,open-data,scientific-data,provenance,reproducible-research,weather,climate,meteorology
6
6
  Author: Jake Van Slyke
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usdata"
3
- version = "0.31.0"
3
+ version = "0.32.0"
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"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usdata"
3
- version = "0.31.0"
3
+ version = "0.32.0"
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"
@@ -23,7 +23,7 @@ from usdata.models import (
23
23
  TemporalSelection,
24
24
  TimeRange,
25
25
  )
26
- from usdata.pull import pull, verify
26
+ from usdata.pull import compare_listing, pull, verify
27
27
  from usdata.query import build_query
28
28
  from usdata.registry import DatasetNotFound, Registry, SearchResult, default_registry
29
29
  from usdata.selection import select_by_time
@@ -47,6 +47,7 @@ __all__ = [
47
47
  "build_query",
48
48
  "cite_dataset",
49
49
  "cite_lockfile",
50
+ "compare_listing",
50
51
  "datasets",
51
52
  "default_registry",
52
53
  "fetch",
@@ -19,9 +19,10 @@ import gzip
19
19
  import re
20
20
  import warnings
21
21
  from collections.abc import Iterator, Mapping, Sequence
22
- from contextlib import contextmanager
22
+ from contextlib import contextmanager, suppress
23
23
  from datetime import UTC, datetime
24
24
  from importlib import import_module
25
+ from importlib.util import find_spec
25
26
  from pathlib import Path
26
27
  from typing import TYPE_CHECKING, Any, NamedTuple
27
28
 
@@ -86,9 +87,29 @@ PROJECTION_KEYS = (
86
87
  )
87
88
 
88
89
 
90
+ def import_eccodes() -> Any:
91
+ """Import the ecCodes bindings, loading pyproj first when it is installed.
92
+
93
+ The eckitlib wheel that the ecCodes library wheel depends on bundles its own
94
+ copy of the PROJ library, and so does pyproj's wheel, which xradar and many
95
+ analysis stacks bring in. When eckitlib's copy is loaded first and pyproj is
96
+ imported later in the same process, both copies free the same memory as the
97
+ interpreter exits, which aborts it with "double free or corruption" and exit
98
+ status 134 after the work is done. Loading pyproj's copy first avoids it.
99
+ A pyproj that fails to import is left for whoever uses it to report.
100
+
101
+ Raises:
102
+ ModuleNotFoundError: eccodes is not installed.
103
+ """
104
+ if find_spec("pyproj") is not None:
105
+ with suppress(ImportError, OSError):
106
+ import_module("pyproj")
107
+ return import_module("eccodes")
108
+
109
+
89
110
  def _modules() -> tuple[Any, Any, Any]:
90
111
  try:
91
- eccodes = import_module("eccodes")
112
+ eccodes = import_eccodes()
92
113
  xarray = import_module("xarray")
93
114
  numpy = import_module("numpy")
94
115
  except ModuleNotFoundError as error:
@@ -23,7 +23,15 @@ from usdata.manifest import lockfile_path
23
23
  from usdata.models import READER_EXTRAS_TEXT, Dataset, Status, describe_duration
24
24
  from usdata.providers import adapter_class, load_adapter
25
25
  from usdata.providers.base import NotImplementedProvider
26
- from usdata.pull import EmptySource, ManifestChanged, Plan, UnknownDatasets, UpstreamChanged
26
+ from usdata.pull import (
27
+ EmptySource,
28
+ ListingComparison,
29
+ ManifestChanged,
30
+ Plan,
31
+ UnknownDatasets,
32
+ UpstreamChanged,
33
+ )
34
+ from usdata.pull import compare_listing as compare_manifest_listing
27
35
  from usdata.pull import plan as plan_manifest
28
36
  from usdata.pull import pull as pull_manifest
29
37
  from usdata.pull import verify as verify_manifest
@@ -554,12 +562,32 @@ def pull(
554
562
  def verify(
555
563
  manifest: Annotated[Path, typer.Argument(exists=True, dir_okay=False)],
556
564
  cache_dir: Annotated[Path | None, typer.Option(help="Override the cache directory.")] = None,
565
+ listing: Annotated[
566
+ bool,
567
+ typer.Option(
568
+ "--listing",
569
+ help="Instead of the cache, compare the lockfile with what each source lists today; "
570
+ "downloads nothing, but asks every source.",
571
+ ),
572
+ ] = False,
573
+ as_json: Annotated[
574
+ bool, typer.Option("--json", help="With --listing, emit the comparison as JSON on stdout.")
575
+ ] = False,
557
576
  ) -> None:
558
577
  """Check cached files against a manifest's lockfile. Exit 1 on any drift."""
578
+ if as_json and not listing:
579
+ typer.secho("--json applies to --listing only", err=True, fg="red")
580
+ raise typer.Exit(code=2)
581
+ if listing and cache_dir is not None:
582
+ typer.secho("--listing reads no cache; drop --cache-dir", err=True, fg="red")
583
+ raise typer.Exit(code=2)
559
584
  lock = lockfile_path(manifest)
560
585
  if not lock.exists():
561
586
  typer.secho(f"no lockfile at {lock}; run pull first", err=True, fg="red")
562
587
  raise typer.Exit(code=2)
588
+ if listing:
589
+ _verify_listing(manifest, as_json=as_json)
590
+ return
563
591
  try:
564
592
  drift = verify_manifest(manifest, root=cache_dir)
565
593
  except (ManifestChanged, ValueError, OSError) as e:
@@ -573,4 +601,55 @@ def verify(
573
601
  typer.echo(f"all assets match {lock.name}", err=True)
574
602
 
575
603
 
604
+ def _verify_listing(manifest: Path, *, as_json: bool) -> None:
605
+ """Compare the lockfile with today's listings; exit 1 when a re-resolve would differ."""
606
+ try:
607
+ comparison = compare_manifest_listing(manifest)
608
+ except EmptySource as e:
609
+ typer.secho(str(e), err=True, fg="yellow")
610
+ raise typer.Exit(code=1) from None
611
+ except (DatasetNotFound, UnknownDatasets, ManifestChanged, UnknownPlace, ValueError) as e:
612
+ typer.secho(str(e), err=True, fg="red")
613
+ raise typer.Exit(code=2) from None
614
+ except NotImplementedProvider as e:
615
+ typer.secho(str(e), err=True, fg="yellow")
616
+ raise typer.Exit(code=3) from None
617
+ except httpx.HTTPError as e:
618
+ typer.secho(f"listing failed: {e}", err=True, fg="red")
619
+ raise typer.Exit(code=4) from None
620
+ _print_listing(comparison, as_json=as_json)
621
+ if not comparison.matches:
622
+ raise typer.Exit(code=1)
623
+
624
+
625
+ def _print_listing(comparison: ListingComparison, *, as_json: bool) -> None:
626
+ """One line per difference, a summary on stderr; or the whole comparison as JSON."""
627
+ name = comparison.lockfile.name
628
+ if as_json:
629
+ typer.echo(comparison.model_dump_json(indent=2))
630
+ else:
631
+ for source in comparison.sources:
632
+ for asset in source.added:
633
+ typer.echo(f"{source.source}\tadded\t{asset.id}\t{asset.href}")
634
+ for asset in source.removed:
635
+ typer.echo(f"{source.source}\tremoved\t{asset.id}\t{asset.href}")
636
+ for change in source.changed:
637
+ sizes = f"{_size(change.locked_size)} -> {_size(change.listed_size)} bytes"
638
+ typer.echo(f"{source.source}\tchanged\t{change.asset_id}\t{sizes}")
639
+ differing = [source.source for source in comparison.sources if not source.matches]
640
+ if differing:
641
+ typer.secho(
642
+ f"{len(differing)} source(s) list something other than {name} pins: "
643
+ f"{', '.join(differing)}. Pass pull --force to re-resolve.",
644
+ err=True,
645
+ fg="red",
646
+ )
647
+ else:
648
+ typer.echo(f"every source lists what {name} pins", err=True)
649
+
650
+
651
+ def _size(value: int | None) -> str:
652
+ return "?" if value is None else str(value)
653
+
654
+
576
655
  app.command()(doctor)
@@ -28,7 +28,7 @@ import httpx
28
28
  from pydantic import BaseModel
29
29
 
30
30
  from usdata import __version__
31
- from usdata._grib import LIBRARY_HINT
31
+ from usdata._grib import LIBRARY_HINT, import_eccodes
32
32
  from usdata.cache import ENV_VAR, cache_dir
33
33
  from usdata.mirror import ENV_VAR as MIRROR_ENV_VAR
34
34
  from usdata.models import READER_EXTRAS, Status
@@ -145,7 +145,8 @@ def _reader_check(extra: str) -> Check:
145
145
  modules: list[Any] = []
146
146
  for module in READER_MODULES[extra]:
147
147
  try:
148
- modules.append(import_module(module))
148
+ # ecCodes goes through the reader's import, which orders it after pyproj.
149
+ modules.append(import_eccodes() if module == "eccodes" else import_module(module))
149
150
  except (ImportError, RuntimeError, OSError) as error:
150
151
  # eccodes imports and then fails to load its shared library.
151
152
  return Check(
@@ -157,6 +157,22 @@ class PullResult(BaseModel):
157
157
  return items[0]
158
158
 
159
159
 
160
+ def _source_keys(entries: Iterable[LockedAsset]) -> list[str]:
161
+ """The manifest source key each lockfile entry belongs to, in entry order.
162
+
163
+ A lockfile written before sources had keys records none; such an entry falls
164
+ back to the one-based position of its dataset id among the datasets seen.
165
+ """
166
+ positions: dict[str, str] = {}
167
+ keys: list[str] = []
168
+ for entry in entries:
169
+ dataset_id = entry.asset.dataset_id
170
+ if dataset_id not in positions:
171
+ positions[dataset_id] = str(len(positions) + 1)
172
+ keys.append(entry.source or positions[dataset_id])
173
+ return keys
174
+
175
+
160
176
  def _by_source(pairs: Iterable[tuple[LockedAsset, FetchedAsset]]) -> dict[str, list[FetchedAsset]]:
161
177
  """Group fetched assets by the source key their lockfile entry records.
162
178
 
@@ -169,12 +185,9 @@ def _by_source(pairs: Iterable[tuple[LockedAsset, FetchedAsset]]) -> dict[str, l
169
185
  them and a forced pull rewrites the lockfile.
170
186
  """
171
187
  grouped: dict[str, list[FetchedAsset]] = {}
172
- positions: dict[str, str] = {}
173
- for entry, item in pairs:
174
- dataset_id = entry.asset.dataset_id
175
- if dataset_id not in positions:
176
- positions[dataset_id] = str(len(positions) + 1)
177
- grouped.setdefault(entry.source or positions[dataset_id], []).append(item)
188
+ pairs = list(pairs)
189
+ for key, (_, item) in zip(_source_keys([entry for entry, _ in pairs]), pairs, strict=True):
190
+ grouped.setdefault(key, []).append(item)
178
191
  by_id: dict[str, FetchedAsset] = {}
179
192
  for items in grouped.values():
180
193
  by_id.update({item.asset.id: item for item in items})
@@ -381,6 +394,123 @@ def plan(manifest_path: str | os.PathLike[str], *, registry: Registry | None = N
381
394
  return Plan(manifest=manifest.name, sources=sources)
382
395
 
383
396
 
397
+ class AssetChange(BaseModel):
398
+ """A pinned asset that today's listing still names, but describes differently."""
399
+
400
+ asset_id: str
401
+ locked_size: int | None = Field(description="Size the listing gave when the pin was written")
402
+ listed_size: int | None = Field(description="Size today's listing gives")
403
+ locked_href: str
404
+ listed_href: str
405
+
406
+
407
+ class SourceComparison(BaseModel):
408
+ """How one source's pinned assets compare with what its adapter lists today."""
409
+
410
+ source: str = Field(description="The source's manifest key: its name, or its position")
411
+ dataset_id: str
412
+ added: list[Asset] = Field(description="Listed today and not pinned")
413
+ removed: list[Asset] = Field(description="Pinned and no longer listed")
414
+ changed: list[AssetChange] = Field(
415
+ description="Pinned and listed, with a different href, or a different size where both "
416
+ "the pin and the listing give one"
417
+ )
418
+ unchanged: int = Field(description="How many pinned assets are listed exactly as pinned")
419
+
420
+ @computed_field(description="Whether a re-resolve of this source would pin the same assets")
421
+ @property
422
+ def matches(self) -> bool:
423
+ """True when nothing was added, removed, or changed."""
424
+ return not (self.added or self.removed or self.changed)
425
+
426
+
427
+ class ListingComparison(BaseModel):
428
+ """A lockfile against today's listings: whether a re-resolve would pin the same assets.
429
+
430
+ Nothing is downloaded, so the bytes are not compared: restore checks those,
431
+ and ``verify`` checks the cache. What this sees is the set of files, an
432
+ asset added, removed, or re-described since the lockfile was written.
433
+ """
434
+
435
+ manifest: str
436
+ lockfile: Path
437
+ sources: list[SourceComparison] = Field(description="One entry per manifest source, in order")
438
+
439
+ @computed_field(description="Whether a re-resolve of every source would pin the same assets")
440
+ @property
441
+ def matches(self) -> bool:
442
+ """True when every source matches."""
443
+ return all(source.matches for source in self.sources)
444
+
445
+
446
+ def _changed(pinned: Asset, listed: Asset) -> AssetChange | None:
447
+ """How ``listed`` differs from the pin of the same id, or None when it does not."""
448
+ sizes_differ = None not in (pinned.size, listed.size) and pinned.size != listed.size
449
+ if not sizes_differ and pinned.href == listed.href:
450
+ return None
451
+ return AssetChange(
452
+ asset_id=pinned.id,
453
+ locked_size=pinned.size,
454
+ listed_size=listed.size,
455
+ locked_href=pinned.href,
456
+ listed_href=listed.href,
457
+ )
458
+
459
+
460
+ def compare_listing(
461
+ manifest_path: str | os.PathLike[str], *, registry: Registry | None = None
462
+ ) -> ListingComparison:
463
+ """Compare a manifest's lockfile with what its sources list today; download nothing.
464
+
465
+ Every source is listed as ``plan`` lists it, ``select`` rules included, and
466
+ its assets are matched to the lockfile's entries for that source by id. An
467
+ asset only in the listing is ``added``, one only in the lockfile ``removed``,
468
+ and one in both with a different href, or a different size where both give
469
+ one, ``changed``. A listing that reports no size cannot show a size change;
470
+ restoring the pin checks the bytes themselves.
471
+
472
+ Args:
473
+ manifest_path: Path of the manifest YAML file, as a string or any ``os.PathLike``.
474
+ registry: Registry to resolve dataset ids against; the default one if omitted.
475
+
476
+ Returns:
477
+ A ``ListingComparison`` with one entry per manifest source.
478
+
479
+ Raises:
480
+ FileNotFoundError: The manifest has no lockfile yet.
481
+ ManifestChanged: The manifest changed since its lockfile was written.
482
+ """
483
+ manifest_path = Path(manifest_path)
484
+ lock_path = lockfile_path(manifest_path)
485
+ if not lock_path.exists():
486
+ raise FileNotFoundError(f"no lockfile at {lock_path}; run pull first")
487
+ lock = Lockfile.load(lock_path)
488
+ _check_manifest(manifest_path, lock)
489
+ pinned: dict[str, dict[str, Asset]] = {}
490
+ for key, entry in zip(_source_keys(lock.assets), lock.assets, strict=True):
491
+ pinned.setdefault(key, {})[entry.asset.id] = entry.asset
492
+ sources: list[SourceComparison] = []
493
+ for listed in plan(manifest_path, registry=registry).sources:
494
+ locked = pinned.get(listed.source, {})
495
+ today = {asset.id: asset for asset in listed.assets}
496
+ changes = [
497
+ change
498
+ for asset_id, asset in locked.items()
499
+ if asset_id in today and (change := _changed(asset, today[asset_id])) is not None
500
+ ]
501
+ sources.append(
502
+ SourceComparison(
503
+ source=listed.source,
504
+ dataset_id=listed.dataset_id,
505
+ added=[asset for asset in listed.assets if asset.id not in locked],
506
+ removed=[asset for asset_id, asset in locked.items() if asset_id not in today],
507
+ changed=changes,
508
+ unchanged=sum(1 for asset_id in locked if asset_id in today) - len(changes),
509
+ )
510
+ )
511
+ return ListingComparison(manifest=lock.manifest, lockfile=lock_path, sources=sources)
512
+
513
+
384
514
  def _selected(lock: Lockfile, update: Iterable[str]) -> set[str]:
385
515
  """Ids of the locked assets that ``update`` selectors (asset or dataset ids) name."""
386
516
  selectors = set(update)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes