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
1111It replaces the per-page ``logger.info`` calls that previously narrated the same
1212events one line at a time.
1313
1414The active reporter lives in a :class:`~contextvars.ContextVar` rather than being
1515threaded 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,
1717page/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
2727import contextvars
2828import os
2929import sys
30- import time
3130from collections .abc import Iterator
3231from contextlib import contextmanager
3332from 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
0 commit comments