Skip to content

Commit 7bd41a1

Browse files
thodson-usgsclaude
andcommitted
fix(waterdata): show requests as remaining/limit, drop unavailable reset; review fixes
The live USGS API returns X-Ratelimit-Limit + X-Ratelimit-Remaining but no reset header, so the earlier "resets in X" countdown read a header that never arrives. Replace it with the available "<remaining> / <limit> requests remaining" (e.g. "995 / 1,000"); the reset parsing/countdown and its tests are removed. Also fixes two issues from code review: - close() used current_chunk as a proxy for "drew something", but start_chunk sets it even when it doesn't render, so a query that failed before any page printed a stray newline and burned the once-per-process API-key hint. Now gated on a _rendered flag set only after a successful write. - _maybe_hint_api_key set its latch before the write, so a failed write burned the hint process-wide; the latch is now set only after a successful write. Plus doc-comment accuracy: the ContextVar isolates one query's reporter (it does not give concurrent queries on one stderr separate lines), and references to the removed ``filters.chunked`` decorator now point at ``chunking`` / ``_paginate``. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fcee959 commit 7bd41a1

4 files changed

Lines changed: 56 additions & 79 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,12 @@ for more information and examples on available services and input parameters.
111111

112112
**Tracking progress:** Paginated and chunked `waterdata` queries report their
113113
progress on a single, self-updating line on `stderr` — showing the chunk and
114-
page counts, rows retrieved so far, and the API requests remaining (with the
115-
time until the hourly limit resets, when the server reports it):
114+
page counts, rows retrieved so far, and the hourly API quota remaining (the
115+
``remaining / limit`` from the server's rate-limit headers, shown when you
116+
have an API key set):
116117

117118
```text
118-
Progress: chunk 2/5 · 14 pages · 8,421 rows · 4,870 requests remaining, resets in 47m
119+
Progress: chunk 2/5 · 14 pages · 8,421 rows · 4,870 / 5,000 requests remaining
119120
```
120121

121122
The line appears automatically when `stderr` is an interactive terminal.

dataretrieval/waterdata/_progress.py

Lines changed: 44 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
"""A single self-updating status line for paginated / chunked Water Data queries.
22
3-
Water Data getters fan out two ways the caller can't see: long CQL filters are
4-
split into URL-length-safe *chunks* (``filters.chunked``), and each request
5-
follows ``next`` links across an unknown number of *pages* (``utils._walk_pages``
6-
and ``utils.get_stats_data``). This module surfaces that work as one line on
7-
stderr, rewritten in place as data arrives::
3+
Water Data getters fan out two ways the caller can't see: large multi-value
4+
requests are split into URL-length-safe *chunks* (``chunking`` module), and each
5+
request follows ``next`` links across an unknown number of *pages*
6+
(``utils._paginate``). This module surfaces that work as one line on stderr,
7+
rewritten in place as data arrives::
88
9-
Progress: chunk 2/5 · 14 pages · 8,421 rows · 4,870 requests remaining
9+
Progress: chunk 2/5 · 14 pages · 8,421 rows · 4,870 / 5,000 requests remaining
1010
1111
It replaces the per-page ``logger.info`` calls that previously narrated the same
1212
events one line at a time.
1313
1414
The active reporter lives in a :class:`~contextvars.ContextVar` rather than being
1515
threaded through every signature: progress is a cross-cutting concern that the
16-
``chunked`` decorator (outer, chunk counts) and the page-walking loops (inner,
16+
chunk orchestrator (outer, chunk counts) and the page-walking loop (inner,
1717
page/row/rate-limit counts) both update without knowing about each other. Call
1818
:func:`progress_context` to activate one and :func:`current` to reach it.
1919
@@ -27,27 +27,25 @@
2727
import contextvars
2828
import os
2929
import sys
30-
import time
3130
from collections.abc import Iterator
3231
from contextlib import contextmanager
3332
from typing import TextIO
3433

3534

36-
def _format_duration(seconds: float) -> str:
37-
"""Compact human duration: ``45s``, ``12m``, ``1h03m`` (clamped at 0)."""
38-
secs = int(max(0, seconds))
39-
if secs < 60:
40-
return f"{secs}s"
41-
if secs < 3600:
42-
return f"{secs // 60}m"
43-
hours, rem = divmod(secs, 3600)
44-
minutes = rem // 60
45-
return f"{hours}h{minutes:02d}m" if minutes else f"{hours}h"
35+
def _group_int(value: str) -> str:
36+
"""Comma-group a plain ASCII integer string; pass anything else through.
37+
38+
(``str.isdigit`` alone is True for non-decimal unicode digits that ``int``
39+
rejects, hence the ``isascii`` guard.)
40+
"""
41+
return f"{int(value):,}" if value.isascii() and value.isdigit() else value
4642

4743

4844
# The reporter active for the current query. A ContextVar (not a module global)
49-
# so concurrent queries — threads or async tasks sharing a client — each track
50-
# their own progress line.
45+
# so the chunk orchestrator and the page loop resolve to the same reporter
46+
# within one query, and an unrelated query in another context can't clobber its
47+
# state. (It does not give concurrent queries sharing one stderr separate
48+
# lines — they would still interleave.)
5149
_active: contextvars.ContextVar[ProgressReporter | None] = contextvars.ContextVar(
5250
"waterdata_progress", default=None
5351
)
@@ -88,10 +86,14 @@ def __init__(
8886
self.pages = 0
8987
self.rows = 0
9088
self.rate_remaining: str | None = None
91-
# Absolute epoch second when the rate-limit window resets, derived from
92-
# the server's reset header so the rendered countdown stays live.
93-
self._reset_at: float | None = None
89+
# The hourly request quota (``x-ratelimit-limit``), shown as the
90+
# denominator when the server reports it.
91+
self.rate_limit: str | None = None
9492
self._last_len = 0
93+
# Whether anything was actually written to the stream — drives whether
94+
# close() needs a terminating newline. (``current_chunk`` is a poor
95+
# proxy: ``start_chunk`` sets it even when it doesn't render.)
96+
self._rendered = False
9597
self._closed = False
9698

9799
def set_chunks(self, total: int) -> None:
@@ -116,25 +118,19 @@ def add_page(self, rows: int = 0) -> None:
116118
self._render()
117119

118120
def set_rate_remaining(
119-
self, value: str | int | None, reset: str | int | None = None
121+
self, value: str | int | None, limit: str | int | None = None
120122
) -> None:
121123
"""Update the rate-limit display from the response headers.
122124
123-
``value`` is ``x-ratelimit-remaining``; ``reset`` is the optional
124-
``x-ratelimit-reset`` companion. Empty/missing values are ignored so a
125-
page that omits a header doesn't blank out the last known value. The
126-
reset value is interpreted as an absolute epoch second when large
127-
(the conventional form) and as seconds-until-reset otherwise; either
128-
way it's stored as an absolute deadline so the countdown stays live.
125+
``value`` is ``x-ratelimit-remaining``; ``limit`` is the optional
126+
``x-ratelimit-limit`` quota, shown as the denominator. Empty/missing
127+
values are ignored so a page that omits a header doesn't blank out the
128+
last known value.
129129
"""
130130
if value not in (None, ""):
131131
self.rate_remaining = str(value)
132-
if reset not in (None, ""):
133-
try:
134-
secs = float(reset)
135-
except (TypeError, ValueError):
136-
return
137-
self._reset_at = secs if secs > 1_000_000 else time.time() + secs
132+
if limit not in (None, ""):
133+
self.rate_limit = str(limit)
138134

139135
def _format(self) -> str:
140136
parts: list[str] = []
@@ -144,15 +140,12 @@ def _format(self) -> str:
144140
if self.rows:
145141
parts.append(f"{self.rows:,} rows")
146142
if self.rate_remaining is not None:
147-
# The header is a string; group it like the row count when it's a
148-
# plain ASCII integer, otherwise show it verbatim. (``str.isdigit``
149-
# alone is True for non-decimal unicode digits that ``int`` rejects.)
150-
rate = self.rate_remaining
151-
rate = f"{int(rate):,}" if rate.isascii() and rate.isdigit() else rate
152-
segment = f"{rate} requests remaining"
153-
if self._reset_at is not None:
154-
eta = _format_duration(self._reset_at - time.time())
155-
segment += f", resets in {eta}"
143+
remaining = _group_int(self.rate_remaining)
144+
if self.rate_limit is not None:
145+
limit = _group_int(self.rate_limit)
146+
segment = f"{remaining} / {limit} requests remaining"
147+
else:
148+
segment = f"{remaining} requests remaining"
156149
parts.append(segment)
157150
return "Progress: " + " · ".join(parts)
158151

@@ -165,6 +158,7 @@ def _render(self) -> None:
165158
self._stream.write("\r" + line + " " * pad)
166159
self._stream.flush()
167160
self._last_len = len(line)
161+
self._rendered = True
168162
except Exception: # noqa: BLE001
169163
# Progress output is best-effort cosmetics; a broken pipe (output
170164
# piped to ``head``), a closed stream, or an encoding error must
@@ -182,7 +176,7 @@ def close(self) -> None:
182176
if self._closed:
183177
return
184178
self._closed = True
185-
if not (self.enabled and (self.pages or self.current_chunk)):
179+
if not (self.enabled and self._rendered):
186180
return
187181
try:
188182
self._stream.write("\n")
@@ -195,10 +189,13 @@ def _maybe_hint_api_key(self) -> None:
195189
global _api_key_hint_shown
196190
if _api_key_hint_shown or os.getenv("API_USGS_PAT"):
197191
return
198-
_api_key_hint_shown = True
192+
# Set the once-per-process latch only after a successful write, so a
193+
# failed write (broken pipe) doesn't silently burn the hint for every
194+
# later query in the process.
199195
self._stream.write(
200196
f"No API key detected — register for higher rate limits at {SIGNUP_URL}\n"
201197
)
198+
_api_key_hint_shown = True
202199

203200

204201
@contextmanager

dataretrieval/waterdata/utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -938,7 +938,7 @@ def _paginate(
938938
if reporter is not None:
939939
reporter.set_rate_remaining(
940940
resp.headers.get(_QUOTA_HEADER),
941-
reset=resp.headers.get("x-ratelimit-reset"),
941+
limit=resp.headers.get("x-ratelimit-limit"),
942942
)
943943
reporter.add_page(rows=len(df))
944944
while cursor is not None:
@@ -951,7 +951,7 @@ def _paginate(
951951
if reporter is not None:
952952
reporter.set_rate_remaining(
953953
resp.headers.get(_QUOTA_HEADER),
954-
reset=resp.headers.get("x-ratelimit-reset"),
954+
limit=resp.headers.get("x-ratelimit-limit"),
955955
)
956956
reporter.add_page(rows=len(df))
957957
except Exception as e: # noqa: BLE001

tests/waterdata_progress_test.py

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -87,43 +87,22 @@ def test_missing_rate_limit_does_not_blank_last_known_value():
8787
assert "500 requests remaining" in stream.getvalue()
8888

8989

90-
def test_format_duration():
91-
assert _progress._format_duration(0) == "0s"
92-
assert _progress._format_duration(45) == "45s"
93-
assert _progress._format_duration(600) == "10m"
94-
assert _progress._format_duration(3600) == "1h"
95-
assert _progress._format_duration(3700) == "1h01m"
96-
assert _progress._format_duration(-5) == "0s" # clamped
97-
98-
99-
def test_reset_countdown_rendered(monkeypatch):
100-
# Pin the clock so the countdown is deterministic.
101-
monkeypatch.setattr(_progress.time, "time", lambda: 1_000_000_000.0)
90+
def test_renders_remaining_over_limit():
10291
stream = io.StringIO()
10392
reporter = ProgressReporter(stream=stream, enabled=True)
104-
reporter.set_rate_remaining("4870", reset="600") # delta seconds -> 10m
93+
reporter.set_rate_remaining("952", limit="1000")
10594
reporter.add_page(rows=1)
106-
out = stream.getvalue()
107-
assert "4,870 requests remaining, resets in 10m" in out
108-
109-
110-
def test_reset_accepts_absolute_epoch(monkeypatch):
111-
monkeypatch.setattr(_progress.time, "time", lambda: 1_000_000_000.0)
112-
stream = io.StringIO()
113-
reporter = ProgressReporter(stream=stream, enabled=True)
114-
reporter.set_rate_remaining("10", reset="1000000900") # epoch: 900s out -> 15m
115-
reporter.add_page()
116-
assert "resets in 15m" in stream.getvalue()
95+
assert "952 / 1,000 requests remaining" in stream.getvalue()
11796

11897

119-
def test_no_reset_segment_without_reset_header():
98+
def test_no_slash_when_limit_absent():
12099
stream = io.StringIO()
121100
reporter = ProgressReporter(stream=stream, enabled=True)
122-
reporter.set_rate_remaining("4870") # remaining only, no reset
101+
reporter.set_rate_remaining("4870") # remaining only, no limit header
123102
reporter.add_page()
124103
out = stream.getvalue()
125104
assert "4,870 requests remaining" in out
126-
assert "resets in" not in out
105+
assert "/" not in out
127106

128107

129108
def test_close_terminates_active_line_with_newline():

0 commit comments

Comments
 (0)