Skip to content

Fix remote file handling and config; stream remote NetCDF - #741

Merged
d-chambers merged 5 commits into
devfrom
dev_review
Jul 4, 2026
Merged

Fix remote file handling and config; stream remote NetCDF#741
d-chambers merged 5 commits into
devfrom
dev_review

Conversation

@d-chambers

@d-chambers d-chambers commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Description

Polishes the remote-file and config work on dev and enables true remote NetCDF streaming. Found while reviewing dev against master; all changes target dev.

Remote / universal-path handling

  • file:// URLs now work in read, scan, and get_format — previously only spool accepted them, because local coercion did Path("file:///…") and kept the scheme as a literal path segment. Added paths.coerce_to_local_path (strips the scheme via UPath) and used it at every local-coercion site.
  • HTTP credentials now survive the blocking urllib download fallback: nested storage_options["headers"] are forwarded, and basic auth (auth=(user, pass) or client_kwargs={"auth": aiohttp.BasicAuth(...)}) is translated into an Authorization header. Previously the whole storage_options dict was passed as headers, dropping auth.

Config

  • Exposed set_config / get_config / reset_config / DascoreConfig on the top-level dascore namespace (the DASDAE unpickle error message told users to call set_config, which wasn't importable there). Removed a dead validate_assignment (contradicts frozen=True) and a stale # pragma: no cover; documented both set_config usage forms.
  • The active config stays a process-global singleton (matching pandas.option_context / matplotlib.rc_context). We prototyped a ContextVar backing for thread-scoped overrides but reverted it: config is read-mostly and, because a new thread starts from a fresh context, a ContextVar override would not reach worker threads unless the caller does contextvars.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, the ContextVar was not the cause of the CI issue below.)

NetCDF remote streaming

  • Remote NetCDF now streams instead of downloading. Since NetCDF-4 is HDF5, scan/read hand the existing streaming h5py handle to xarray's h5netcdf engine (utils.hdf5.get_h5py_file + engine="h5netcdf") rather than reopening by path. Verified over a memory:// filesystem: a 4.8 MB remote file yields 0 bytes written to the cache for both scan and read, with no allow_remote_cache_for_metadata opt-in required, and the no-range HTTP fallback still applies. Adds h5netcdf to extras. Read throughput matches the netCDF4 engine; metadata scan carries a small (~8 ms/file) fixed overhead.

CI: Windows guard for localhost-HTTP remote tests

  • The localhost HTTP + fsspec/aiohttp streaming path intermittently deadlocks on Windows (the async read stalls while h5py probes remote HDF5 metadata; pytest-timeout then aborts the session). This is a known Windows flakiness in that fallback path — the repo already had a per-test win32 skip for it — and it can land on any localhost-HTTP remote test depending on timing. Extended that skip to module level on test_remote_common_io.py and test_remote_http.py so 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_code and test_code_min_deps combinations.

Related: opened #739 to settle the fbe dB convention (left unchanged here).

Changelog

none

Checklist

I have (if applicable):

  • referenced the GitHub issue this PR closes.
  • documented the new feature with docstrings and/or appropriate doc page.
  • included tests. See testing guidelines.
  • added the "ready_for_review" tag once the PR is ready to be reviewed.

🤖 Generated with Claude Code

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.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This 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 h5netcdf dependency.

Changes

Path coercion, config, NetCDF streaming, and HTTP headers

Layer / File(s) Summary
Local path coercion utility and adoption
dascore/utils/paths.py, dascore/io/core.py, dascore/io/rsf/core.py, dascore/io/wav/core.py, dascore/utils/io.py, tests/test_utils/test_paths.py, tests/test_io/test_io_core.py
Adds coerce_to_local_path for local and URI-like paths, uses it in IO path handling in place of direct Path(...) construction, and adds tests covering file:// and local:// behavior.
ContextVar-backed configuration
dascore/config.py, dascore/__init__.py, dascore/io/dasdae/utils.py
Updates the configuration model and runtime semantics in dascore/config.py, exports config helpers from the package namespace, and adjusts a related legacy error message.
Streaming NetCDF reads
dascore/utils/hdf5.py, dascore/io/netcdf/core.py, pyproject.toml, tests/test_utils/test_hdf_utils.py, tests/test_io/test_netcdf/test_netcdf.py
Adds get_h5py_file, opens NetCDF datasets through an h5py-backed h5netcdf path, updates NetCDFCFV18.read and scan, adds h5netcdf as an optional dependency, and extends tests for the streaming path.
HTTP header/auth extraction
dascore/utils/remote_io.py, tests/test_utils/test_io_utils.py, tests/test_io/test_remote_common_io.py, tests/test_io/test_remote_http.py
Builds request headers from nested storage_options values, derives Basic auth when present, wires the result into remote HTTP downloads, and adds tests plus Windows-only skips for the remote IO matrices.

Possibly related PRs

  • DASDAE/dascore#645: Shares the same local/remote path normalization area in dascore/io/core.py and related IO helpers.
  • DASDAE/dascore#655: Shares the same NetCDF read/scan implementation area in dascore/io/netcdf/core.py.
  • DASDAE/dascore#652: Shares the same remote download header-building area in dascore/utils/remote_io.py.

Suggested labels: IO, bug, packaging

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main changes to remote file handling, config, and NetCDF streaming.
Description check ✅ Passed The description follows the template with a clear summary and completed checklist items, and it covers the key changes and testing.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev_review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@d-chambers d-chambers added the ready_for_review PR is ready for review label Jul 4, 2026
@codecov

codecov Bot commented Jul 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (422e77f) to head (b28c949).

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     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

- 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.
@coderabbitai coderabbitai Bot added IO Work for reading/writing different formats packaging labels Jul 4, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
dascore/config.py (1)

145-151: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Static analysis flags mutable ContextVar default (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: B039 or 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 win

Consider 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), and dascore/io/wav/core.py (write) — plus dascore/utils/io.py's _normalize_resource_identity per 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

📥 Commits

Reviewing files that changed from the base of the PR and between 422e77f and 9de45b8.

📒 Files selected for processing (17)
  • dascore/__init__.py
  • dascore/config.py
  • dascore/io/core.py
  • dascore/io/dasdae/utils.py
  • dascore/io/netcdf/core.py
  • dascore/io/rsf/core.py
  • dascore/io/wav/core.py
  • dascore/utils/hdf5.py
  • dascore/utils/io.py
  • dascore/utils/paths.py
  • dascore/utils/remote_io.py
  • pyproject.toml
  • tests/test_io/test_io_core.py
  • tests/test_io/test_netcdf/test_netcdf.py
  • tests/test_utils/test_hdf_utils.py
  • tests/test_utils/test_io_utils.py
  • tests/test_utils/test_paths.py

Comment on lines +170 to +174
out = {
str(key): str(value)
for key, value in headers.items()
if isinstance(value, str | bytes | int | float)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
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.

Comment on lines +175 to +181
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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:


🏁 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:


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.
@coderabbitai coderabbitai Bot added the bug Something isn't working label Jul 4, 2026
…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.
@d-chambers
d-chambers merged commit a8ae85a into dev Jul 4, 2026
24 checks passed
@d-chambers
d-chambers deleted the dev_review branch July 4, 2026 17:42
@d-chambers d-chambers removed the ready_for_review PR is ready for review label Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working IO Work for reading/writing different formats packaging

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant