usdata 0.30.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.
- {usdata-0.30.0 → usdata-0.32.0}/PKG-INFO +2 -2
- {usdata-0.30.0 → usdata-0.32.0}/README.md +1 -1
- {usdata-0.30.0 → usdata-0.32.0}/pyproject.toml +1 -1
- {usdata-0.30.0 → usdata-0.32.0}/pyproject.toml.orig +1 -1
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/__init__.py +2 -1
- usdata-0.32.0/src/usdata/_census.py +51 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/_fetch.py +1 -1
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/_grib.py +23 -2
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/cli/app.py +80 -1
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/data/registry.yaml +43 -8
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/doctor.py +3 -2
- usdata-0.32.0/src/usdata/providers/census/__init__.py +1 -0
- usdata-0.32.0/src/usdata/providers/census/acs.py +243 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/pull.py +136 -6
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/query.py +18 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/readers.py +15 -4
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/testing.py +2 -1
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/__main__.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/_aqs.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/_files.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/_hurdat2.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/_netcdf.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/_progress.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/_radar.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/cache.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/cache_ops.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/cite.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/cli/__init__.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/cli/cache.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/cli/cite.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/cli/doctor.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/cli/inspect.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/cli/progress.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/data/nexrad_sites.csv +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/data/places.csv +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/data/places.sources.json +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/inspect.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/manifest.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/mirror.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/models.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/protocols/__init__.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/protocols/erddap.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/protocols/http.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/protocols/listing.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/protocols/s3.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/provenance.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/__init__.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/base.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/credentials.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/epa/__init__.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/epa/aqs.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/fema/__init__.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/fema/declarations.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/http.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/__init__.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/coastwatch.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/coops.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/gfs.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/ghcnd.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/glm.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/goes.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/grib_index.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/gsom.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/gsoy.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/hrrr.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/hurdat2.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/ibtracs.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/lcd.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/mrms.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/nbm.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/nexrad.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/nexrad_level3.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/normals.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/nws_vtec.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/rap.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/sites.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/spc.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/noaa/storm_events.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/params.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/usgs/__init__.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/usgs/daily.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/providers/usgs/earthquakes.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/py.typed +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/registry.py +0 -0
- {usdata-0.30.0 → usdata-0.32.0}/src/usdata/selection.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: usdata
|
|
3
|
-
Version: 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
|
|
@@ -113,7 +113,7 @@ breaking changes.
|
|
|
113
113
|
| [docs.usdata.dev](https://docs.usdata.dev/) | How to use it: [install](https://docs.usdata.dev/install/), [getting started](https://docs.usdata.dev/getting-started/), guides, dataset notes, and reference |
|
|
114
114
|
| [Severe-weather case study](https://usdata.dev/studies/severe-weather-case-study/) | One tornado, six sources, one manifest and lockfile, ending in a citation |
|
|
115
115
|
|
|
116
|
-
Twenty-
|
|
116
|
+
Twenty-eight datasets are available today and eighteen more are planned, grouped
|
|
117
117
|
by agency and product family in the [catalog](docs/providers/README.md).
|
|
118
118
|
|
|
119
119
|
## How this compares
|
|
@@ -68,7 +68,7 @@ breaking changes.
|
|
|
68
68
|
| [docs.usdata.dev](https://docs.usdata.dev/) | How to use it: [install](https://docs.usdata.dev/install/), [getting started](https://docs.usdata.dev/getting-started/), guides, dataset notes, and reference |
|
|
69
69
|
| [Severe-weather case study](https://usdata.dev/studies/severe-weather-case-study/) | One tornado, six sources, one manifest and lockfile, ending in a citation |
|
|
70
70
|
|
|
71
|
-
Twenty-
|
|
71
|
+
Twenty-eight datasets are available today and eighteen more are planned, grouped
|
|
72
72
|
by agency and product family in the [catalog](docs/providers/README.md).
|
|
73
73
|
|
|
74
74
|
## How this compares
|
|
@@ -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",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Local reading of Census Data API tables into a pandas DataFrame, behind pandas.
|
|
2
|
+
|
|
3
|
+
A fetched ``census:acs-5year`` file is the service's response as served: a JSON
|
|
4
|
+
array whose first row names the columns and whose other rows are one geography
|
|
5
|
+
each, every value a string. The table is those rows under those names, with the
|
|
6
|
+
estimate and margin-of-error columns as numbers and everything else, geography
|
|
7
|
+
codes and their leading zeros included, as text.
|
|
8
|
+
|
|
9
|
+
Negative annotation codes such as ``-555555555`` stay the numbers they are.
|
|
10
|
+
Each means something different (too few sample cases, not applicable, a
|
|
11
|
+
controlled estimate), so they are not turned into missing values (ADR 0045).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
from importlib import import_module
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
from usdata.readers import MissingReaderDependency, source_attrs
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from usdata._fetch import FetchedAsset
|
|
25
|
+
|
|
26
|
+
NUMERIC = re.compile(r"[A-Z]\d{5}[A-Z0-9]*_\d{3}[EM]")
|
|
27
|
+
"""An estimate (``...E``) or margin of error (``...M``) variable, such as ``B01003_001E``.
|
|
28
|
+
|
|
29
|
+
Annotation columns (``...EA``, ``...MA``) and ``NAME`` do not match and stay text.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def open_acs(fetched: FetchedAsset) -> Any:
|
|
34
|
+
"""Parse a fetched ACS table into a pandas DataFrame, one row per geography."""
|
|
35
|
+
try:
|
|
36
|
+
pandas = import_module("pandas")
|
|
37
|
+
except ModuleNotFoundError as error:
|
|
38
|
+
if error.name != "pandas":
|
|
39
|
+
raise
|
|
40
|
+
raise MissingReaderDependency(
|
|
41
|
+
'ACS reading requires pandas; install it with: pip install "usdata[pandas]" '
|
|
42
|
+
'(or uv add "usdata[pandas]")'
|
|
43
|
+
) from error
|
|
44
|
+
# Reading a fetched asset is strictly local; the cached file is never rewritten.
|
|
45
|
+
header, *rows = json.loads(fetched.path.read_text(encoding="utf-8"))
|
|
46
|
+
frame = pandas.DataFrame(rows, columns=header, dtype="string")
|
|
47
|
+
for column in header:
|
|
48
|
+
if NUMERIC.fullmatch(column):
|
|
49
|
+
frame[column] = pandas.to_numeric(frame[column])
|
|
50
|
+
frame.attrs["usdata"] = source_attrs(fetched)
|
|
51
|
+
return frame
|
|
@@ -39,7 +39,7 @@ class FetchedAsset(BaseModel):
|
|
|
39
39
|
def open(self) -> Any:
|
|
40
40
|
"""Open local data with the reader its format implies, with that reader's defaults.
|
|
41
41
|
|
|
42
|
-
CSV and ERDDAP CSV, HURDAT2
|
|
42
|
+
CSV and ERDDAP CSV, HURDAT2, AQS daily JSON, and ACS tables return a pandas DataFrame;
|
|
43
43
|
NetCDF4 and GRIB2 a loaded xarray Dataset; NEXRAD Level II an xarray
|
|
44
44
|
DataTree. Provenance is kept in the result's ``attrs["usdata"]``. A file
|
|
45
45
|
that needs options, or whose metadata leaves its format ambiguous, is
|
|
@@ -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 =
|
|
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
|
|
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)
|
|
@@ -1556,20 +1556,55 @@ datasets:
|
|
|
1556
1556
|
# ---------------------------------------------------------------- Census
|
|
1557
1557
|
- id: census:acs-5year
|
|
1558
1558
|
provider: census
|
|
1559
|
-
status:
|
|
1559
|
+
status: available
|
|
1560
1560
|
domain: demographics
|
|
1561
|
-
|
|
1561
|
+
since: "0.31"
|
|
1562
1562
|
title: American Community Survey 5-Year Estimates
|
|
1563
1563
|
description: >-
|
|
1564
|
-
Population, housing, income, and demographic estimates
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1564
|
+
Population, housing, income, and demographic estimates from the American
|
|
1565
|
+
Community Survey's detailed tables, through the Census Data API: one row
|
|
1566
|
+
per geography, for one to fifty API variables, including margins of error
|
|
1567
|
+
and annotations. A query names a vintage, the last year of the five-year
|
|
1568
|
+
period, rather than a window, and a state or county rather than a box;
|
|
1569
|
+
a state can return its own row or one row per county. The delivered
|
|
1570
|
+
variable set is open, tens of thousands of variables per vintage.
|
|
1571
|
+
Requires a free API key on every data request; the response is stored as
|
|
1572
|
+
served, since it carries no copy of the key.
|
|
1573
|
+
keywords: [population, demographics, housing, income, acs, census, american community survey]
|
|
1569
1574
|
protocol: http
|
|
1570
1575
|
homepage: https://www.census.gov/data/developers/data-sets/acs-5year.html
|
|
1571
1576
|
license: US Government Work (public domain)
|
|
1572
|
-
capabilities: { spatial_subset: false, place_subset: true, temporal_subset:
|
|
1577
|
+
capabilities: { spatial_subset: false, place_subset: true, temporal_subset: false, variable_subset: true }
|
|
1578
|
+
credentials:
|
|
1579
|
+
variables: [USDATA_CENSUS_KEY]
|
|
1580
|
+
signup: https://api.census.gov/data/key_signup.html
|
|
1581
|
+
summary: Five-year population, housing, and income estimates by state or county
|
|
1582
|
+
formats: [JSON]
|
|
1583
|
+
selection: One vintage's estimates for up to fifty variables over a state, its counties, or one county
|
|
1584
|
+
inputs: A vintage; one to fifty variables; a state or county location
|
|
1585
|
+
reader: pandas
|
|
1586
|
+
guide: docs/providers/census-acs-5year.md
|
|
1587
|
+
examples:
|
|
1588
|
+
- examples/datasets/census-acs-5year/census-acs-5year.ipynb
|
|
1589
|
+
resolution:
|
|
1590
|
+
spatial: Detailed tables are available down to the block group; usdata selects states and counties
|
|
1591
|
+
temporal: Five-year period estimates, one vintage per year
|
|
1592
|
+
update_frequency: >-
|
|
1593
|
+
Released once a year; the 2020-2024 estimates were released on 8 January 2026
|
|
1594
|
+
citation: >-
|
|
1595
|
+
U.S. Census Bureau, American Community Survey 5-Year Estimates, Detailed Tables, Census Data
|
|
1596
|
+
API, accessed via usdata
|
|
1597
|
+
terms: https://www.census.gov/data/developers/about/terms-of-service.html
|
|
1598
|
+
variables:
|
|
1599
|
+
- { name: "NAME", description: "Geographic area name, such as Osage County, Oklahoma" }
|
|
1600
|
+
- { name: "B01003_001E", description: "Total population, estimate" }
|
|
1601
|
+
- { name: "B01003_001M", description: "Total population, margin of error" }
|
|
1602
|
+
- { name: "B25001_001E", description: "Housing units, estimate" }
|
|
1603
|
+
- { name: "B25001_001M", description: "Housing units, margin of error" }
|
|
1604
|
+
- { name: "B19013_001E", units: "US dollars of the vintage year", description: "Median household income in the past 12 months, inflation-adjusted to the vintage year, estimate" }
|
|
1605
|
+
- { name: "state", description: "FIPS code of the state, appended by the service" }
|
|
1606
|
+
- { name: "county", description: "FIPS code of the county within the state, appended by the service for county rows" }
|
|
1607
|
+
adapter: usdata.providers.census.acs:Acs5Year
|
|
1573
1608
|
|
|
1574
1609
|
# ---------------------------------------------------------------- USDA
|
|
1575
1610
|
- id: usda:cropland-data-layer
|
|
@@ -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
|
-
|
|
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(
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Census Bureau dataset adapters."""
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""American Community Survey 5-year estimates from the Census Data API, which needs a free key.
|
|
2
|
+
|
|
3
|
+
The service answers one query per request with a JSON array of arrays: a header
|
|
4
|
+
row naming the columns, then one row per geography, every value a string. The
|
|
5
|
+
requested variables come first and the geography columns (``state``,
|
|
6
|
+
``county``) follow. Every data request carries a ``key`` query parameter, which
|
|
7
|
+
usdata reads from ``USDATA_CENSUS_KEY`` and adds only as each request is sent
|
|
8
|
+
(ADR 0039).
|
|
9
|
+
|
|
10
|
+
Selection (ADR 0045):
|
|
11
|
+
|
|
12
|
+
- ``vintage`` names the estimates by the last year of their five-year period, so
|
|
13
|
+
2023 is 2019 through 2023. A time window is refused: a five-year estimate
|
|
14
|
+
describes a period, and choosing one from a window would be a guess.
|
|
15
|
+
- The query's ``variables`` name one to fifty API variables, ``NAME``
|
|
16
|
+
included, the service's limit per request. Margins of error and annotations
|
|
17
|
+
are variables too, such as ``B01003_001M`` and ``B01003_001EA``.
|
|
18
|
+
- A ``location`` names one state or county. ``geography`` says what each row
|
|
19
|
+
is: the place itself by default, or, for a state, every county in it. A box
|
|
20
|
+
names no place and is refused.
|
|
21
|
+
- Connecticut's codes depend on the vintage: its eight counties through 2021,
|
|
22
|
+
its nine planning regions from 2022. A place the vintage does not have is
|
|
23
|
+
refused with the ones that it does.
|
|
24
|
+
|
|
25
|
+
The response is written as served. It carries no copy of the key and identical
|
|
26
|
+
requests return identical bytes, so there is nothing to canonicalize. The
|
|
27
|
+
service answers a geography it does not have with an empty ``204`` and a bad
|
|
28
|
+
key with a redirect; both are refused rather than written.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
import re
|
|
36
|
+
from datetime import UTC, datetime, time
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Annotated
|
|
39
|
+
|
|
40
|
+
import httpx
|
|
41
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
42
|
+
|
|
43
|
+
from usdata.models import Asset, Place, Protocol, Query, TimeRange
|
|
44
|
+
from usdata.protocols import http
|
|
45
|
+
from usdata.providers.base import QueryError
|
|
46
|
+
from usdata.providers.http import HttpProvider
|
|
47
|
+
from usdata.providers.params import choice, int_range
|
|
48
|
+
from usdata.query import legacy_counties, planning_regions
|
|
49
|
+
|
|
50
|
+
SERVICE_URL = "https://api.census.gov/data"
|
|
51
|
+
KEY = "USDATA_CENSUS_KEY"
|
|
52
|
+
FIRST_VINTAGE = 2009
|
|
53
|
+
"""The first ACS 5-year vintage the API lists, covering 2005 through 2009."""
|
|
54
|
+
|
|
55
|
+
REGIONS_FROM = 2022
|
|
56
|
+
"""The first vintage keyed by Connecticut's planning regions rather than its old counties."""
|
|
57
|
+
|
|
58
|
+
MAX_VARIABLES = 50
|
|
59
|
+
"""The most variables, ``NAME`` included, the service returns for one request."""
|
|
60
|
+
|
|
61
|
+
_VARIABLE = re.compile(r"[A-Z][A-Z0-9_]*")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class CensusError(QueryError):
|
|
65
|
+
"""The Census Data API refused the request and said why, or refused the key."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AcsParams(BaseModel):
|
|
69
|
+
"""Which estimates one ACS query names, and what each of its rows is."""
|
|
70
|
+
|
|
71
|
+
model_config = ConfigDict(extra="forbid")
|
|
72
|
+
|
|
73
|
+
vintage: Annotated[int, int_range(FIRST_VINTAGE, 9999)] = Field(
|
|
74
|
+
description=(
|
|
75
|
+
"Required last year of the five-year period, such as 2023 for 2019-2023; "
|
|
76
|
+
"the estimates are selected by vintage, not by a date window."
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
geography: Annotated[str, choice("state", "county")] | None = Field(
|
|
80
|
+
default=None,
|
|
81
|
+
description=(
|
|
82
|
+
"What each row is: state or county. Defaults to the kind of place the location "
|
|
83
|
+
"names; county with a state location gives every county in it."
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _variables(names: list[str], dataset_id: str) -> list[str]:
|
|
89
|
+
"""The query's variables upper-cased, repeats dropped, in the order given."""
|
|
90
|
+
ordered = list(dict.fromkeys(name.strip().upper() for name in names))
|
|
91
|
+
if not ordered or not all(ordered):
|
|
92
|
+
raise QueryError(
|
|
93
|
+
f"{dataset_id} needs variables naming the estimates to return, such as "
|
|
94
|
+
"NAME,B01003_001E,B01003_001M"
|
|
95
|
+
)
|
|
96
|
+
if bad := [name for name in ordered if not _VARIABLE.fullmatch(name)]:
|
|
97
|
+
raise QueryError(f"{dataset_id} variable {bad[0]!r} is not a Census API variable name")
|
|
98
|
+
if len(ordered) > MAX_VARIABLES:
|
|
99
|
+
raise QueryError(
|
|
100
|
+
f"{dataset_id} was given {len(ordered)} variables; the service returns at most "
|
|
101
|
+
f"{MAX_VARIABLES} per request, NAME included, so split them across manifest sources"
|
|
102
|
+
)
|
|
103
|
+
return ordered
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _labels(places: tuple[Place, ...]) -> str:
|
|
107
|
+
return "; ".join(place.label for place in places)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Acs5Year(HttpProvider):
|
|
111
|
+
"""ACS 5-year detailed-table estimates for one vintage, variable list, and place."""
|
|
112
|
+
|
|
113
|
+
params_model = AcsParams
|
|
114
|
+
|
|
115
|
+
def list_assets(self, query: Query) -> list[Asset]:
|
|
116
|
+
"""One JSON asset for the query's vintage, variables, and place; nothing is requested."""
|
|
117
|
+
params = self.parse_params(query, AcsParams)
|
|
118
|
+
self.reject(
|
|
119
|
+
query,
|
|
120
|
+
"text",
|
|
121
|
+
"time",
|
|
122
|
+
hint="choose the five-year period with vintage, such as vintage=2023 for 2019-2023",
|
|
123
|
+
)
|
|
124
|
+
variables = _variables(query.variables, self.dataset.id)
|
|
125
|
+
place = self.place_of(query, hint="the estimates are keyed by state and county")
|
|
126
|
+
if place is None:
|
|
127
|
+
raise QueryError(f"{self.dataset.id} needs a location naming a state or county")
|
|
128
|
+
self._check_connecticut(place, params.vintage)
|
|
129
|
+
geography = params.geography or place.kind
|
|
130
|
+
if geography == "state" and place.kind == "county":
|
|
131
|
+
raise QueryError(
|
|
132
|
+
f"{self.dataset.id} geography=state needs a state location, not {place.label}"
|
|
133
|
+
)
|
|
134
|
+
selection: dict[str, str]
|
|
135
|
+
if place.kind == "state" and geography == "state":
|
|
136
|
+
selection, label = {"for": f"state:{place.state_fips}"}, place.geoid
|
|
137
|
+
elif place.kind == "state":
|
|
138
|
+
selection = {"for": "county:*", "in": f"state:{place.state_fips}"}
|
|
139
|
+
label = f"{place.geoid}-counties"
|
|
140
|
+
else:
|
|
141
|
+
selection = {"for": f"county:{place.county_fips}", "in": f"state:{place.state_fips}"}
|
|
142
|
+
label = place.geoid
|
|
143
|
+
url = httpx.URL(
|
|
144
|
+
f"{SERVICE_URL}/{params.vintage}/acs/acs5",
|
|
145
|
+
params={"get": ",".join(variables), **selection},
|
|
146
|
+
)
|
|
147
|
+
digest = hashlib.sha256(",".join(variables).encode()).hexdigest()[:12]
|
|
148
|
+
first = params.vintage - 4
|
|
149
|
+
return [
|
|
150
|
+
Asset(
|
|
151
|
+
id=f"acs5_{params.vintage}_{label}_{digest}.json",
|
|
152
|
+
dataset_id=self.dataset.id,
|
|
153
|
+
href=str(url),
|
|
154
|
+
protocol=Protocol.HTTP,
|
|
155
|
+
media_type="application/json",
|
|
156
|
+
time=TimeRange(
|
|
157
|
+
start=datetime(first, 1, 1, tzinfo=UTC),
|
|
158
|
+
end=datetime.combine(datetime(params.vintage, 12, 31), time.max, UTC),
|
|
159
|
+
),
|
|
160
|
+
bbox=query.bbox,
|
|
161
|
+
properties={"vintage": str(params.vintage), "period": f"{first}-{params.vintage}"},
|
|
162
|
+
)
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
def _check_connecticut(self, place: Place, vintage: int) -> None:
|
|
166
|
+
"""Refuse a Connecticut place the vintage's geography does not have (ADR 0045)."""
|
|
167
|
+
if vintage < REGIONS_FROM and (counties := legacy_counties(place)):
|
|
168
|
+
raise QueryError(
|
|
169
|
+
f"{self.dataset.id} vintage {vintage} keys Connecticut by its eight counties "
|
|
170
|
+
f"before 2022, and {place.label} is a planning region that replaced them; name "
|
|
171
|
+
f"a county it overlaps with location: {_labels(counties)}"
|
|
172
|
+
)
|
|
173
|
+
if vintage >= REGIONS_FROM and (regions := planning_regions(place)):
|
|
174
|
+
raise QueryError(
|
|
175
|
+
f"{self.dataset.id} vintage {vintage} keys Connecticut by the planning regions "
|
|
176
|
+
f"that replaced its counties in 2022, and {place.label} is one of those "
|
|
177
|
+
f"counties; name a region it overlaps with location: {_labels(regions)}"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def fetch(self, asset: Asset, dest: Path) -> Path:
|
|
181
|
+
"""Request the estimates with this adapter's key and write the response as served."""
|
|
182
|
+
vintage = asset.properties.get("vintage", "?")
|
|
183
|
+
with self.redacted_errors():
|
|
184
|
+
keyed = httpx.URL(asset.href).copy_merge_params({"key": self.credentials[KEY]})
|
|
185
|
+
try:
|
|
186
|
+
# A bad key is a redirect to an explanation page, which is refused, not followed.
|
|
187
|
+
response = http.get(keyed, self._http(), follow_redirects=False)
|
|
188
|
+
except httpx.HTTPStatusError as error:
|
|
189
|
+
# Any other status, such as a 503, stays an upstream failure.
|
|
190
|
+
if (refusal := _refusal(error.response, vintage)) is None:
|
|
191
|
+
raise
|
|
192
|
+
raise refusal from error
|
|
193
|
+
if response.status_code == 204:
|
|
194
|
+
raise CensusError(
|
|
195
|
+
f"the Census Data API has no rows for {_where(asset.href)} in ACS 5-year "
|
|
196
|
+
f"vintage {vintage}; the place's code may differ in that vintage"
|
|
197
|
+
)
|
|
198
|
+
_check_table(response)
|
|
199
|
+
dest.write_bytes(response.content)
|
|
200
|
+
return dest
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _refusal(response: httpx.Response, vintage: str) -> CensusError | None:
|
|
204
|
+
"""The service's refusal as an error that says why, or None for an upstream failure."""
|
|
205
|
+
status = response.status_code
|
|
206
|
+
location = response.headers.get("location", "")
|
|
207
|
+
if response.is_redirect and "invalid_key" in location:
|
|
208
|
+
return CensusError(
|
|
209
|
+
f"the Census Data API refused the key in {KEY} as invalid; check it, and that the "
|
|
210
|
+
"activation link in the signup email was followed"
|
|
211
|
+
)
|
|
212
|
+
if response.is_redirect and "missing_key" in location:
|
|
213
|
+
return CensusError(f"the Census Data API answered as if no key was sent; check {KEY}")
|
|
214
|
+
if status == 400 and response.headers.get("content-type", "").startswith("text/plain"):
|
|
215
|
+
reason = response.text.strip().removeprefix("error:").strip()
|
|
216
|
+
return CensusError(f"the Census Data API refused the request: {reason}")
|
|
217
|
+
if status == 404:
|
|
218
|
+
return CensusError(f"the Census Data API publishes no ACS 5-year vintage {vintage}")
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _where(href: str) -> str:
|
|
223
|
+
"""The geography a request names, as the service's ``for`` and ``in`` spell it."""
|
|
224
|
+
params = httpx.URL(href).params
|
|
225
|
+
place = params.get("for", "")
|
|
226
|
+
return f"{place} in {params['in']}" if "in" in params else place
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _check_table(response: httpx.Response) -> None:
|
|
230
|
+
"""Raise unless the body is the array of rows, header first, that the service promises."""
|
|
231
|
+
try:
|
|
232
|
+
body = json.loads(response.content)
|
|
233
|
+
except ValueError:
|
|
234
|
+
body = None
|
|
235
|
+
if not (
|
|
236
|
+
isinstance(body, list)
|
|
237
|
+
and body
|
|
238
|
+
and all(isinstance(row, list) and len(row) == len(body[0]) for row in body)
|
|
239
|
+
):
|
|
240
|
+
raise httpx.DecodingError(
|
|
241
|
+
"the Census Data API answered with something other than a table of rows",
|
|
242
|
+
request=response.request,
|
|
243
|
+
)
|
|
@@ -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
|
-
|
|
173
|
-
for
|
|
174
|
-
|
|
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)
|
|
@@ -89,6 +89,24 @@ def legacy_counties(place: Place) -> tuple[Place, ...]:
|
|
|
89
89
|
return tuple(table.places[geoid][0] for geoid in table.legacy.get(place.geoid, ()))
|
|
90
90
|
|
|
91
91
|
|
|
92
|
+
def planning_regions(place: Place) -> tuple[Place, ...]:
|
|
93
|
+
"""The Connecticut planning regions a pre-2022 county overlaps, or () for any other place.
|
|
94
|
+
|
|
95
|
+
The reverse of ``legacy_counties``, for a source that switched to the
|
|
96
|
+
regions in 2022, such as Census geography from that vintage on, and so has
|
|
97
|
+
no rows under an old county's code.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
place: Any place from ``find_place`` or ``build_query``.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The planning regions, in FIPS order, when ``place`` is a legacy county.
|
|
104
|
+
"""
|
|
105
|
+
table = _places()
|
|
106
|
+
regions = sorted(region for region, counties in table.legacy.items() if place.geoid in counties)
|
|
107
|
+
return tuple(table.places[geoid][0] for geoid in regions)
|
|
108
|
+
|
|
109
|
+
|
|
92
110
|
def resolve_place(name: str) -> BBox:
|
|
93
111
|
"""Resolve a state, qualified county name, or quoted two/five-digit FIPS code to its box."""
|
|
94
112
|
return find_place(name)[1]
|
|
@@ -47,6 +47,12 @@ AQS_DATASET = "epa:aqs-daily"
|
|
|
47
47
|
AQS_PREFIX = "aqs-daily_"
|
|
48
48
|
"""How an ``epa:aqs-daily`` asset id begins, so a copied file is still recognized."""
|
|
49
49
|
|
|
50
|
+
ACS_DATASET = "census:acs-5year"
|
|
51
|
+
"""The dataset whose Census Data API tables the ``acs`` reader opens."""
|
|
52
|
+
|
|
53
|
+
ACS_PREFIX = "acs5_"
|
|
54
|
+
"""How a ``census:acs-5year`` asset id begins, so a copied file is still recognized."""
|
|
55
|
+
|
|
50
56
|
STORM_EVENTS_DATASET = "noaa:storm-events"
|
|
51
57
|
"""The dataset whose local timestamps the CSV reader pairs with UTC columns."""
|
|
52
58
|
|
|
@@ -388,12 +394,13 @@ def open_asset(fetched: FetchedAsset) -> Any:
|
|
|
388
394
|
|
|
389
395
|
The reader is inferred from the asset's dataset, media type, and id:
|
|
390
396
|
``noaa:nexrad-level2`` volumes open as with ``open_nexrad``, HURDAT2 best
|
|
391
|
-
tracks
|
|
397
|
+
tracks, AQS daily-summary JSON, and ACS tables by their dataset or filename, NetCDF4 as
|
|
392
398
|
with ``open_netcdf``, GRIB2 as with ``open_grib2``, and CSV as with
|
|
393
399
|
``open_csv``, with its units row detected. Each takes its defaults; a file
|
|
394
400
|
that needs options is opened with the function for its format instead.
|
|
395
|
-
HURDAT2 best-track text returns one row per track point,
|
|
396
|
-
JSON one row per monitor, day, and standard
|
|
401
|
+
HURDAT2 best-track text returns one row per track point, AQS daily-summary
|
|
402
|
+
JSON one row per monitor, day, and standard, and an ACS table one row per
|
|
403
|
+
geography; none takes options.
|
|
397
404
|
|
|
398
405
|
Raises:
|
|
399
406
|
UnsupportedFormat: Nothing about the asset names a format usdata reads.
|
|
@@ -411,6 +418,10 @@ def open_asset(fetched: FetchedAsset) -> Any:
|
|
|
411
418
|
from usdata._aqs import open_aqs
|
|
412
419
|
|
|
413
420
|
return open_aqs(fetched)
|
|
421
|
+
if dataset_id == ACS_DATASET or (name.startswith(ACS_PREFIX) and name.endswith(".json")):
|
|
422
|
+
from usdata._census import open_acs
|
|
423
|
+
|
|
424
|
+
return open_acs(fetched)
|
|
414
425
|
if media_type in NETCDF_MEDIA_TYPES:
|
|
415
426
|
return open_netcdf(fetched)
|
|
416
427
|
if media_type in GRIB2_MEDIA_TYPES or (
|
|
@@ -424,7 +435,7 @@ def open_asset(fetched: FetchedAsset) -> Any:
|
|
|
424
435
|
raise UnsupportedFormat(
|
|
425
436
|
f"no reader for {fetched.asset.media_type!r}; supported formats are CSV, "
|
|
426
437
|
"ERDDAP CSV, NetCDF4, GRIB2, NEXRAD Level II, HURDAT2 best tracks, "
|
|
427
|
-
"
|
|
438
|
+
"AQS daily JSON, and ACS tables. For a CSV with ambiguous metadata, use open_csv; "
|
|
428
439
|
"otherwise use fetched.path with a format-specific reader"
|
|
429
440
|
)
|
|
430
441
|
|
|
@@ -335,7 +335,8 @@ def check_declared_capabilities(
|
|
|
335
335
|
variable = dataset.variables[0].name
|
|
336
336
|
with _patched_client(unexpected_client), adapter_factory() as adapter:
|
|
337
337
|
base = scenario_query
|
|
338
|
-
|
|
338
|
+
# A scenario may name a place of its own; the bare-box probe must not keep it.
|
|
339
|
+
boxed = base.model_copy(update={"bbox": PROBE_BBOX, "place": None})
|
|
339
340
|
placed = boxed.model_copy(update={"place": PROBE_PLACE})
|
|
340
341
|
bare_box_refused = _refuses_bare_box(adapter, boxed)
|
|
341
342
|
bbox_refused = bare_box_refused or _refuses(adapter, boxed, "bbox")
|
|
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
|
|
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
|
|
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
|