Fix remote file handling and config; stream remote NetCDF - #741
Conversation
Remote / universal-path: - Accept file:// URLs in read/scan/get_format (add paths.coerce_to_local_path to strip the scheme; previously only spool handled them). - Forward nested HTTP headers and translate basic auth (auth=(user, pass) or client_kwargs BasicAuth) to an Authorization header on the urllib download fallback, instead of passing the whole storage_options dict as headers. Config: - Back the active config with a ContextVar so scoped set_config overrides are thread/async-safe (matches the remote_io scope). - Export set_config/get_config/reset_config/DascoreConfig on the top-level namespace; drop dead validate_assignment and a stale no-cover pragma. NetCDF: - Stream remote NetCDF via the h5netcdf engine over the existing streaming h5py handle (utils.hdf5.get_h5py_file) instead of reopening by path, so scan/read no longer download the file. Add h5netcdf to extras.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
📝 WalkthroughWalkthroughThis PR adds local-path coercion across IO code, updates runtime configuration semantics and exports, streams NetCDF reads through an h5py-backed handle, and derives HTTP request headers from remote storage options. It also adds tests and an optional ChangesPath coercion, config, NetCDF streaming, and HTTP headers
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## dev #741 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 151 151
Lines 14101 14151 +50
=========================================
+ Hits 14101 14151 +50
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
- test_paths CoerceToLocalPath: use tmp_path + as_uri() round-trips instead of hardcoded POSIX paths (fixes Windows failures where file:// anchors to a drive). - Cover _basic_auth_header string/partial-auth branches and get_h5py_file unwrap/passthrough.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
dascore/config.py (1)
145-151: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueStatic analysis flags mutable
ContextVardefault (B039).
DascoreConfig()instances are frozen/immutable, so the practical risk of state leaking across contexts is low, but the shared singleton default is still the pattern Ruff is warning against. Consider silencing with a justified# noqa: B039or switching to a factory-based default to make the intent explicit and keep the linter clean.♻️ Optional refactor: factory-based default
-_CONFIG: ContextVar[DascoreConfig] = ContextVar( - "dascore_config", default=DascoreConfig() -) +_CONFIG: ContextVar[DascoreConfig] = ContextVar("dascore_config")def get_config() -> DascoreConfig: """Return the active runtime configuration.""" try: return _CONFIG.get() except LookupError: return DascoreConfig()🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@dascore/config.py` around lines 145 - 151, The _CONFIG ContextVar in config.py is triggering Ruff B039 because it uses a shared DascoreConfig() default; update the ContextVar declaration to avoid the mutable-default warning, either by adding a justified noqa on the _CONFIG definition or by changing the access pattern around get_config/set_config to use an explicit factory-style fallback. Keep the intent clear that DascoreConfig is an immutable config object and preserve the existing behavior of the active config lookup used by set_config and remote_io-scoped overrides.Source: Linters/SAST tools
dascore/utils/paths.py (1)
40-53: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider consolidating the repeated local/remote coercion ternary.
The exact same 3-line pattern
coerce_to_local_path(x) if is_local_path(x) else coerce_to_upath(x)is duplicated at least 5 times across the files in this PR:dascore/io/core.py(_get_format,_updated_after,_iterate_scan_inputs),dascore/io/rsf/core.py(_coerce_output_path), anddascore/io/wav/core.py(write) — plusdascore/utils/io.py's_normalize_resource_identityper the graph context. Extracting a single helper here (e.g.coerce_to_local_or_remote_path) would remove this duplication and centralize the local/remote dispatch logic in one place.♻️ Proposed helper
+def coerce_to_local_or_remote_path(resource): + """Return a local `Path` for local resources, else a `UPath`.""" + return coerce_to_local_path(resource) if is_local_path(resource) else coerce_to_upath(resource) + + def requires_local_directory(resource, *, label: str):🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@dascore/utils/paths.py` around lines 40 - 53, The local/remote path coercion logic is duplicated across several call sites, so extract it into a single helper in the paths utilities and reuse it everywhere. Add a centralized function such as coerce_to_local_or_remote_path that encapsulates the current is_local_path/coerce_to_local_path vs coerce_to_upath dispatch, then update _get_format, _updated_after, _iterate_scan_inputs, _coerce_output_path, write, and _normalize_resource_identity to call that helper instead of repeating the ternary pattern.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@dascore/utils/remote_io.py`:
- Around line 170-174: The header normalization in remote_io.py currently lets
bytes through but converts them with str(), which turns values like b"tok" into
a repr string instead of the real text. Update the header conversion logic in
the code that builds out from headers.items() so bytes values are explicitly
decoded before being stored, while keeping the existing str/int/float handling
intact. Use the same header-processing block and its out dictionary
comprehension as the target for the fix.
- Around line 175-181: The Authorization header handling in the remote download
flow can leak Basic auth across redirects. Update the request/redirect logic
around the header-building code in remote_io.py so redirected requests do not
reuse Authorization when the origin changes, either by adding a redirect handler
that strips auth on host/origin changes or by disabling automatic redirects for
authenticated downloads. Keep the fix centered on the logic that sets
out["Authorization"] and the urllib.request path that follows it.
---
Nitpick comments:
In `@dascore/config.py`:
- Around line 145-151: The _CONFIG ContextVar in config.py is triggering Ruff
B039 because it uses a shared DascoreConfig() default; update the ContextVar
declaration to avoid the mutable-default warning, either by adding a justified
noqa on the _CONFIG definition or by changing the access pattern around
get_config/set_config to use an explicit factory-style fallback. Keep the intent
clear that DascoreConfig is an immutable config object and preserve the existing
behavior of the active config lookup used by set_config and remote_io-scoped
overrides.
In `@dascore/utils/paths.py`:
- Around line 40-53: The local/remote path coercion logic is duplicated across
several call sites, so extract it into a single helper in the paths utilities
and reuse it everywhere. Add a centralized function such as
coerce_to_local_or_remote_path that encapsulates the current
is_local_path/coerce_to_local_path vs coerce_to_upath dispatch, then update
_get_format, _updated_after, _iterate_scan_inputs, _coerce_output_path, write,
and _normalize_resource_identity to call that helper instead of repeating the
ternary pattern.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 4a7ae4bd-13a4-4d41-b45e-22899ebe584d
📒 Files selected for processing (17)
dascore/__init__.pydascore/config.pydascore/io/core.pydascore/io/dasdae/utils.pydascore/io/netcdf/core.pydascore/io/rsf/core.pydascore/io/wav/core.pydascore/utils/hdf5.pydascore/utils/io.pydascore/utils/paths.pydascore/utils/remote_io.pypyproject.tomltests/test_io/test_io_core.pytests/test_io/test_netcdf/test_netcdf.pytests/test_utils/test_hdf_utils.pytests/test_utils/test_io_utils.pytests/test_utils/test_paths.py
| out = { | ||
| str(key): str(value) | ||
| for key, value in headers.items() | ||
| if isinstance(value, str | bytes | int | float) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
str() on a bytes header value produces its repr, not the intended value.
The filter admits bytes, but str(b"tok") yields "b'tok'", so a bytes-valued header is forwarded with b'...' wrapping. Only str/int/float are exercised by the current tests, so this path is untested. Decode bytes explicitly.
🐛 Proposed fix
- out = {
- str(key): str(value)
- for key, value in headers.items()
- if isinstance(value, str | bytes | int | float)
- }
+ out = {}
+ for key, value in headers.items():
+ if isinstance(value, bytes):
+ out[str(key)] = value.decode("latin-1")
+ elif isinstance(value, str | int | float):
+ out[str(key)] = str(value)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| out = { | |
| str(key): str(value) | |
| for key, value in headers.items() | |
| if isinstance(value, str | bytes | int | float) | |
| } | |
| out = {} | |
| for key, value in headers.items(): | |
| if isinstance(value, bytes): | |
| out[str(key)] = value.decode("latin-1") | |
| elif isinstance(value, str | int | float): | |
| out[str(key)] = str(value) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@dascore/utils/remote_io.py` around lines 170 - 174, The header normalization
in remote_io.py currently lets bytes through but converts them with str(), which
turns values like b"tok" into a repr string instead of the real text. Update the
header conversion logic in the code that builds out from headers.items() so
bytes values are explicitly decoded before being stored, while keeping the
existing str/int/float handling intact. Use the same header-processing block and
its out dictionary comprehension as the target for the fix.
| # Derive an Authorization header from basic-auth options unless one is set. | ||
| if not any(key.lower() == "authorization" for key in out): | ||
| client_kwargs = storage_options.get("client_kwargs") or {} | ||
| auth = storage_options.get("auth", client_kwargs.get("auth")) | ||
| if auth is not None and (auth_header := _basic_auth_header(auth)): | ||
| out["Authorization"] = auth_header | ||
| return out |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
Does Python urllib.request urlopen forward Authorization header on cross-host redirects
💡 Result:
Yes, Python's urllib.request module can forward the Authorization header during redirects, but its behavior depends on how the header was added and whether the redirect is to a different host. When you add a header using the Request.add_header method, it is treated as a normal header and is typically included in redirected requests, regardless of whether the host changes [1][2]. However, for specific authentication mechanisms handled by urllib (such as Basic or Digest authentication), the library implements logic to prevent the leakage of sensitive credentials to third-party hosts [3][4]. Specifically: 1. Authentication Handlers: When using handlers like HTTPBasicAuthHandler or HTTPDigestAuthHandler, the library is designed to manage credentials securely [3][4]. Historically, issues were addressed to ensure that sensitive Authorization headers are not automatically sent to redirected hosts if they are not explicitly authorized for that domain [3][4]. 2. Manual Headers: If you manually set an 'Authorization' header using Request.add_header (e.g., for a Bearer token), the library does not automatically strip it upon redirection [5][1]. This can lead to the security risk of sending sensitive credentials to an unintended third-party server if a redirect occurs [6]. In summary, while manual headers added via Request.add_header will persist across redirects [1][2], the built-in authentication handlers in urllib include safeguards to avoid sending sensitive credentials across cross-host redirects [3][4]. Developers should be cautious when manually adding Authorization headers and ensure they are only applied when expected.
Citations:
- 1: https://docs.python.org/3.14/library/urllib.request.html
- 2: https://bugs.python.org/issue46439
- 3: urllib2 sends Basic auth across redirects python/cpython#48069
- 4: https://bugs.python.org/issue3819
- 5: https://stackoverflow.com/questions/79642829/python-urllib-request-urlopen-with-bearer-authentication-in-redirected-request
- 6: urllib may leak sensitive HTTP headers to a third-party web site python/cpython#77842
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the relevant file and the request construction around the cited lines.
FILE="dascore/utils/remote_io.py"
wc -l "$FILE"
sed -n '150,230p' "$FILE"
# Find any redirect-specific handling or custom opener logic in the repo.
rg -n "urlopen|Request\\(|Authorization|HTTPRedirect|redirect" dascore -g '!**/__pycache__/**'Repository: DASDAE/dascore
Length of output: 4743
🌐 Web query:
Python urllib.request redirect handler Authorization header copied to redirect target cross host Request(headers=...)
💡 Result:
Yes, the Python urllib.request library's default behavior is to copy headers, including the Authorization header, when following HTTP redirects, even if the redirect target is on a different host [1][2]. When you create a Request object and use add_header to include sensitive information like an Authorization header, that header is added to the request's internal header store [2][3]. The default HTTPRedirectHandler, which processes 30x status codes, creates a new Request object for the redirected URL and propagates these headers to it [4][2]. Consequently, if a server redirects a client to a third-party host, urllib will automatically pass those same credentials to that new destination [2]. This behavior has been a documented security concern, as it can lead to the unintended leakage of sensitive credentials to third-party servers [1][2]. While some specific authentication handlers (like those for Digest authentication) have been modified in the past to avoid leaking information across hosts, headers added manually via Request.add_header (or passed in the headers dictionary during Request initialization) remain subject to this automatic propagation [5][6][2][3]. To mitigate this risk, you should: 1. Verify if the target host is trusted before following a redirect. 2. If you must use redirects, implement a custom redirect handler by subclassing HTTPRedirectHandler and overriding the redirect_request method to strip sensitive headers when the host changes [4][7]. 3. Alternatively, handle redirects manually by disabling the default redirect handler and implementing your own logic to manage requests and headers explicitly [7].
Citations:
- 1: urllib may leak sensitive HTTP headers to a third-party web site python/cpython#77842
- 2: https://bugs.python.org/msg317793
- 3: https://bugs.python.org/issue46439
- 4: https://github.com/python/cpython/blob/main/Lib/urllib/request.py
- 5: https://bugs.python.org/issue3819
- 6: urllib2 sends Basic auth across redirects python/cpython#48069
- 7: https://docs.python.org/3.14/library/urllib.request.html
Strip Authorization on redirects. urllib.request can carry request headers into redirected requests, so this Basic Authorization header may be sent to a different host. Use a redirect handler that drops auth on origin changes or disable auto-redirects for authenticated downloads.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@dascore/utils/remote_io.py` around lines 175 - 181, The Authorization header
handling in the remote download flow can leak Basic auth across redirects.
Update the request/redirect logic around the header-building code in
remote_io.py so redirected requests do not reuse Authorization when the origin
changes, either by adding a redirect handler that strips auth on host/origin
changes or by disabling automatic redirects for authenticated downloads. Keep
the fix centered on the logic that sets out["Authorization"] and the
urllib.request path that follows it.
…hang) The ContextVar-backed config introduced a deterministic deadlock in test_remote_common_io on windows-latest/3.14 min-deps (fsspec async read hangs). dev is green on the same combo; reverting the config mechanism to the prior process-global (keeping the dc.set_config/get_config/reset_config exports) restores known-good behavior. Thread-safety can be revisited alongside parallel loading with dedicated 3.14 coverage.
…lakiness) test_remote_common_io deadlocks intermittently on Windows CI in the localhost HTTP + fsspec/aiohttp streaming path (pytest-timeout aborts). This is a known Windows flakiness in that fallback path (see the existing win32 skip in test_remote_http.py), not a DASCore logic issue. Guard the module on Windows so CI is deterministic; Linux and macOS still exercise it fully.
The fsspec/aiohttp async deadlock is flaky and can land on any localhost-HTTP remote test (it hit test_remote_http on full-deps Windows too), so extend the module-level win32 skip here and drop the now-redundant per-test skip.
Description
Polishes the remote-file and config work on
devand enables true remote NetCDF streaming. Found while reviewingdevagainstmaster; all changes targetdev.Remote / universal-path handling
file://URLs now work inread,scan, andget_format— previously onlyspoolaccepted them, because local coercion didPath("file:///…")and kept the scheme as a literal path segment. Addedpaths.coerce_to_local_path(strips the scheme viaUPath) and used it at every local-coercion site.urllibdownload fallback: nestedstorage_options["headers"]are forwarded, and basic auth (auth=(user, pass)orclient_kwargs={"auth": aiohttp.BasicAuth(...)}) is translated into anAuthorizationheader. Previously the wholestorage_optionsdict was passed as headers, dropping auth.Config
set_config/get_config/reset_config/DascoreConfigon the top-leveldascorenamespace (the DASDAE unpickle error message told users to callset_config, which wasn't importable there). Removed a deadvalidate_assignment(contradictsfrozen=True) and a stale# pragma: no cover; documented bothset_configusage forms.pandas.option_context/matplotlib.rc_context). We prototyped aContextVarbacking for thread-scoped overrides but reverted it: config is read-mostly and, because a new thread starts from a fresh context, aContextVaroverride would not reach worker threads unless the caller doescontextvars.copy_context()— extra API complexity that works against the parallel-loading use case rather than for it. The global "just works" for propagating an override into workers. (For the record, theContextVarwas not the cause of the CI issue below.)NetCDF remote streaming
scan/readhand the existing streamingh5pyhandle to xarray'sh5netcdfengine (utils.hdf5.get_h5py_file+engine="h5netcdf") rather than reopening by path. Verified over amemory://filesystem: a 4.8 MB remote file yields 0 bytes written to the cache for both scan and read, with noallow_remote_cache_for_metadataopt-in required, and the no-range HTTP fallback still applies. Addsh5netcdftoextras. Read throughput matches the netCDF4 engine; metadata scan carries a small (~8 ms/file) fixed overhead.CI: Windows guard for localhost-HTTP remote tests
win32skip for it — and it can land on any localhost-HTTP remote test depending on timing. Extended that skip to module level ontest_remote_common_io.pyandtest_remote_http.pyso CI is deterministic; these remain fully exercised on Linux and macOS. No product code changed for this.Full suite passes; each fix has dedicated tests. CI is green across all
test_codeandtest_code_min_depscombinations.Related: opened #739 to settle the
fbedB convention (left unchanged here).Changelog
none
Checklist
I have (if applicable):
🤖 Generated with Claude Code