Skip to content

feat: filtering based on multiple variables - #151

Closed
maxrjones wants to merge 44 commits into
mainfrom
feat/where-parameter
Closed

maxrjones wants to merge 44 commits into
mainfrom
feat/where-parameter

Conversation

@maxrjones

Copy link
Copy Markdown
Member
  • Describe the change

This PR adds a where query parameter for filtering rendered data, which is necessary for visualizing TEMPO data in a similar way to worldview.

Testing

  • New tests ran with pytest

PR checks

  • Standard CI runs automatically on each push.
  • To run the CDK synth check, add the run-cdk-checks label to this PR.
  • If you push more commits after that run completes, remove and re-add the label to run it again.
  • To trigger a dev deployment, add the deploy-dev label. It smoke-tests tiles from the native MUR, virtual MUR, and virtual NLDAS Icechunk stores after deployment.

maxrjones and others added 19 commits August 25, 2026 04:05
Add an opt-in 'earthdata' access mode to the typed virtual chunk
authorization config: an entry like
{"s3://asdc-prod-protected/": {"earthdata": true}} exchanges the
service's Earthdata Login identity for the DAAC's temporary S3
credentials via earthaccess-auth's CMR-derived bucket registry and hands
icechunk a refreshable credential that renews as the ~1h STS credentials
expire. Earthdata credentials apply to virtual chunk containers only;
icechunk stores and zarr/NetCDF sources keep using the service's ambient
credentials. Entries are validated at startup (unregistered buckets fail
the deploy; 'earthdata' is exclusive with other access options).

EDL identity sources, in precedence order: usable ambient EARTHDATA_*
variables, .netrc, or TITILER_MULTIDIM_EARTHDATA_SECRET_ARN pointing at
a Secrets Manager secret (plain token string or JSON with EARTHDATA_*
keys; plain secret names accepted as well as ARNs). The secret is
resolved lazily at first use (SnapStart-safe) and re-read every 10
minutes, rebuilding the shared credential manager when it changed, so
rotating the ~60-day EDL token needs neither a redeploy nor a restart.
A failed fetch is retried rather than latched, and a failed refresh
keeps serving on the still-valid identity. Client-facing errors never
contain the ARN, AWS errors, or EDL responses; details go to logs.

Credentials are fetched eagerly in Python before icechunk's Rust layer
runs, so failures stay typed: an unaccepted DAAC EULA surfaces as HTTP
403 with the EULA URLs, a missing EDL identity as a clean 500. The
Redis dataset cache key now fingerprints the authorization config so
authorization changes take effect immediately, and cache hits for
earthdata-authorized datasets establish the EDL identity before
unpickling (fixes fresh Lambda environments sharing a cache).

Deployment: new earthdata_secret_arn stack setting (reader role needs
secretsmanager:GetSecretValue); must run in us-west-2 (Earthdata S3
credentials are region-locked). The dev deploy authorizes ASDC as the
first production use. New dependency: earthaccess-auth 0.2.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings on the earthdata feature (efda9ef), most-severe first:

- Scope earthdata coupling to containers a repo actually declares: EDL
  identity and credential priming move out of to_credential (now pure
  local construction) into opener_icechunk, which intersects the
  configured earthdata entries with repo.config.virtual_chunk_containers
  via the new chunk_access.earthdata_endpoints(). A public icechunk
  store — or any plain zarr/NetCDF request — no longer touches Secrets
  Manager, EDL, or EULA state just because an earthdata entry exists in
  the service config.
- Prime the cache-hit path: the opener records the endpoints it primed
  in ds.encoding (pickled with the dataset, never served in responses),
  and _open_cached re-primes exactly those on a hit, so a fresh worker
  unpickling from shared Redis surfaces typed 403/500 errors instead of
  opaque Rust-wrapped storage errors. Replaces the request-wide
  _has_earthdata_entries ensure.
- Never fail requests on a bad rotation: applying a rotated secret
  (parse, export, EDL re-login) now restores the previous identity and
  backs off _RETRY_INTERVAL on failure instead of raising with no
  backoff after having popped the warm identity.
- Swap EARTHDATA_* env vars write-before-remove (_swap_env), so a
  concurrent reader never observes an empty identity mid-rotation.
- Skip falsy secret values: an empty EARTHDATA_TOKEN no longer shadows
  a working username/password with an empty bearer token, and JSON null
  no longer exports the literal string "None"; blank secrets raise.
- Sanitize client-visible auth errors: S3CredentialsRequestFailure (403)
  now returns a fixed message pointing at the EDL EULA/application pages
  instead of echoing up to 1000 chars of the DAAC's raw response body,
  and LoginAttemptFailure (raw EDL response body) is mapped to a
  sanitized 500 instead of falling through to the str(exc) catch-all.
- Drop the hand-rolled registry re-check in to_credential in favor of
  earthaccess-auth's own typed S3CredentialsEndpointUnresolved.
- Scope the icechunk-builder parity test's 'earthdata' exemption to
  S3ChunkAccess, so an earthdata field added to another model without a
  matching model_dump exclude fails CI instead of at request time.
- Repair tests/test_app.py: the merge of main (8562b7a) left
  test_errors_not_cacheable indented inside the previous test, an
  IndentationError that broke collection of the whole module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up to 122398b, from a second adversarial review pass:

- Reject partial-identity secrets: _parse_secret now requires a usable
  pairing (EARTHDATA_TOKEN, or both EARTHDATA_USERNAME and
  EARTHDATA_PASSWORD), mirroring _usable_env_identity. Previously a
  username-without-password secret exported cleanly, then latched a
  broken identity until the secret *content* changed (the unchanged
  secret short-circuits _apply_secret on every refresh), failing every
  request with a misleading error.
- Back off failed first loads: both first-load failure paths (Secrets
  Manager fetch failure, unusable secret) now set _RETRY_INTERVAL before
  raising, so a misconfigured deployment fails fast for 60s instead of
  issuing a fresh GetSecretValue per request serialized on the module
  lock. Calls inside the window return without fetching; credential use
  fails downstream with a typed, sanitized error.
- Don't stall concurrent requests behind a refresh fetch: the deadline
  is pushed to now+_RETRY_INTERVAL before the network call, so requests
  arriving mid-refresh take the pre-lock fast path on the still-valid
  warm identity instead of queueing behind a possibly-hung Secrets
  Manager call.
- Correct _swap_env's docstring: it guarantees a never-empty
  environment, not an atomic swap; a racing reader can briefly see a
  mixed identity (one transient, self-healing failure). Marked as a
  deliberate ceiling.
- Parse the chunk-access config once per open: opener_icechunk parses
  and hands the models to both build_virtual_chunk_access and
  earthdata_endpoints (which now takes parsed entries).
- Drop the dead prefix parameter from Gcs/Azure to_credential; only the
  S3 model resolves its prefix, and build_virtual_chunk_access branches
  at the single call site.
- Make the Redis cache entry an explicit (endpoints, dataset) pair: the
  cross-process re-prime contract lives in the cache layer instead of
  being smuggled through ds.encoding across pickle (encoding remains
  only the opener->reader in-process handoff, popped at cache write).
- Remove the [options.exclude-newer-package] earthaccess-auth override
  from uv.lock: it bypassed the package-release cooldown. The locked
  0.2.0 entry is unchanged and installs via `uv sync --locked`;
  re-locking will admit the release once the cooldown passes.
- Extract _secret_arn_unless_latched from ensure_earthdata_credentials
  (complexity limit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Third review round found seven correctness issues surviving from the
original feature commit (efda9ef), all in paths the earlier rounds never
exercised, plus three cleanups. This commit, with the companion
earthaccess-auth branch fix/s3credentials-status-and-lock-granularity
(status-aware S3CredentialsRequestFailure; per-endpoint fetch locks in
S3CredentialManager), addresses all ten:

- Sanitize the Rust-side error path: icechunk stringifies exceptions
  raised inside its credential callbacks (the steady-state refresh at
  chunk-read time), chaining raw DAAC bodies into IcechunkError. A new
  handler logs the full text and serves fixed messages, keeping wrapped
  S3CredentialsRequestFailure identifiable (EULA 403; "status 401" ->
  service-credential 500).
- Distinguish service-side credential rejection from EULA problems: a
  401 from the s3credentials endpoint (expired ~60-day token, bad
  secret) now returns a sanitized 500 that alarms as a server error,
  instead of telling end users to accept EULAs.
- Validate token-shaped rotations: EDL marks any non-empty token
  authenticated without a network call, so _rebuild_default_auth now
  probes one configured earthdata endpoint; a definitive 401 rejects
  the identity (rotation rolls back to the previous one; first load
  raises with backoff). EULA 403s, outages, or no earthdata entries
  never reject.
- Make the configured secret authoritative: a stale-but-truthy ambient
  EARTHDATA_TOKEN no longer latches permanently and silently disables
  rotation; ambient credentials remain the fallback while the secret is
  unreachable and win outright when no ARN is configured.
- Rebuild the default manager on every successful load (first load
  included), so an identity cached during a backoff window (netrc,
  ambient) can never keep shadowing the secret's identity.
- Treat SecretBinary-only secrets as a typed failure with backoff (the
  SecretString subscript moved inside the guarded fetch).
- Decode JSON string-scalar secrets to the intended token instead of
  exporting quote-wrapped garbage that latches.
- Pop the ds.encoding endpoints marker unconditionally so it never
  rides datasets served with caching disabled.
- build_virtual_chunk_access now takes parsed entries (config parsed
  exactly once per open, matching the comment), and the process-constant
  default authorization fingerprint is computed once, not per request.
- ensure_earthdata_credentials fetch-failure handling extracted to
  _on_fetch_failure (complexity limit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_probe_identity validated a candidate identity with a raw, uncached
fetch_s3_credentials call, then set_default_auth built a cold manager —
so every cold process hit the DAAC s3credentials endpoint twice (probe,
then prime). Build the S3CredentialManager first, probe through
manager.get_credentials, and install that same manager via the new
earthaccess-auth set_default_manager: a successful probe now leaves the
credentials cached where prime_earthdata_endpoints and icechunk's
refresh callable read them — one fetch per endpoint per cold process.

Probe-before-install ordering is unchanged, so a 401-rejected identity
still never becomes the default manager. Requires earthaccess-auth with
set_default_manager (branch fix/s3credentials-status-and-lock-granularity).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The branch depends on 0.3.0's set_default_manager and the status_code
attribute on S3CredentialsRequestFailure; with 0.2.x the app fails at
identity-rebuild time with an ImportError. 0.3.0 is now on PyPI, so pin
it instead of shimming around the gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The existing smoke cases read public, from_env, or anonymous stores, so
the earthdata chain this branch adds (Secrets Manager secret -> EDL
login -> identity probe -> s3credentials -> virtual chunk reads) was
never exercised after a deploy. One TEMPO NO2 tile from the ASDC
protected bucket covers it end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers what to check by hand once a deploy-dev deployment is live:
finding the endpoint, the ordered TEMPO checks (metadata needs IAM only,
tiles need the full EDL chain), how to read the 403/500 failure
mappings, the two cache layers behind freshness, and the map viewer.
Linked from the README's deployment section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two dev-deployment failures triaged this session were both config, not
code, and neither was findable from the guide:

- an empty-extension NotImplementedError, which is really
  identify_storage_backend listing a prefix and finding nothing
- LoginStrategyUnavailable, which is really a Lambda deployed without
  TITILER_MULTIDIM_EARTHDATA_SECRET_ARN (an unset Actions variable
  expands to "" and model_post_init then omits the env var)

Adds both to the failure-mode table, plus a section on verifying the
externally managed reader role's grants: assume-role (the only check
that sees bucket and KMS key policies), simulate-principal-policy with
its blind spots, and CMK detection. Records that the role needs nothing
on asdc-prod-protected -- those chunk reads use DAAC temporary
credentials, not ambient IAM.

Also notes that /variables never uses the Redis dataset cache, so it
disagrees with /tiles when a store moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The TEMPO smoke case and the manual smoke-testing guide passed
`sel_method=nearest` alongside an exact `sel=time=...`. That parameter was
removed in the titiler 2.2 upgrade (c17dfa7); FastAPI drops unknown query
parameters silently, so the requests were asking for an exact timestamp
match rather than the nearest scan.

Fold the method into the selector as `sel=time=nearest::<value>`, matching
the syntax documented in the README and already used by the MUR SST zarr
case.
main removed the Redis dataset cache and VPC (#149) and added mosaic
support (#140, #138 release 0.8.0). Conflict resolutions:

- reader.py: kept the earthdata priming in opener_icechunk (now also
  reusing main's `containers` local for its per-container logging) and
  main's timing/structured logs. Dropped everything that existed only to
  serve the Redis cache: `_open_cached`, the cache-key fingerprint
  helpers, and the `ds.encoding["earthdata_endpoints"]` handoff, whose
  sole consumer was the cache-hit re-prime path.
- main.py: kept the earthdata exception handlers; dropped the now-unused
  `Depends` import, which only fed the removed `/clear_cache` endpoint.
- tests/test_reader.py: main deleted the file (it was all cache tests);
  kept it with this branch's non-cache tests and dropped the cache and
  encoding-marker assertions with the code they covered.
- deploy-dev.yml: union of both authorized-chunk-access entries
  (main's noaa-nws-naqfc-pds, this branch's asdc-prod-protected) plus
  the earthdata secret ARN env var.
- README/docs: removed the Redis cache and /clear_cache paragraphs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a66aba1 raised the floor to >=0.3.0 but left uv.lock pinning 0.2.0, so
`uv lock --check` failed. Regenerated with the release visible to the
resolver; only the earthaccess-auth entry and its specifier move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ror bodies

Switch the walkthrough to the HCHO trial store with a store-derived scan
time instead of a hardcoded one, add warm-path/second-tile and
credential-refresh checks, correct the empty-extension failure to the
501 it actually returns, and make test_deployment.py include the
response body when a request fails (str(HTTPError) is only the status).
0.3.1 turns the bare JSONDecodeError from a TEA s3credentials endpoint
serving a non-JSON body (observed: ASDC returning its EDL login page
with a 200) into S3CredentialsRequestFailure carrying the status code
and the first 500 bytes of the body, so the deployed service logs what
the endpoint actually sent instead of an opaque
'Expecting value: line 1 column 1 (char 0)' 500.
The automated smoke test exercised the earthdata credential chain
through the NO2 store only; HCHO is a separate icechunk repository with
its own time axis and variable set, so a regression limited to one
store could pass unnoticed. Request a tile from both.
The text-block URL invited pasting into a shell, where unquoted & is a
parse error and <T> is redirection. Reuse the $TILE query from step 3
in a quoted echo/open command instead.
…ON handoff

Parameterize the TEMPO walkthrough over both stores (TEMPO/VAR/CMAP
blocks, run once per store) instead of HCHO-only prose. Switch rendering
to rescale=0,3e16 with viridis (HCHO) / magma_r (NO2), matching the GIBS
palettes Worldview uses (both run 0-3.0e16 molecules/cm2), so tiles are
visually comparable to the reference. Add a Worldview sanity-check
section with verified layer IDs and a permalink built from the tested
scan time, and a TileJSON section with the tilejson.json request, an
example response, and the caveats frontends need (baked-in sel time,
store prefix lifetime).
Multidim stores carry companion variables that qualify the science
variable (TEMPO L3: main_data_quality_flag, eff_cloud_fraction next to
vertical_column), but the API rendered the raw variable with no way to
filter by them -- so tiles show data where quality-filtered reference
imagery (GIBS/Worldview) shows gaps.

Add where={variable}{op}{number} (repeatable, ANDed) to the reader
dependency: each condition variable is extracted with the same sel as
the main variable, compared against the numeric literal, and the
combined mask applied with DataArray.where, so failing pixels follow
the existing nodata path (transparent tiles, null points, excluded from
statistics). Malformed conditions, unknown variables, sel-incompatible
or extra-dimensioned condition variables all raise BadRequestError
(400). Applied in the reader so every endpoint gets it uniformly, and
tilejson.json forwards it into the tile template for map clients.

Spec: 2026-08-31-1746-where-parameter-spec (session deliverables).
Base automatically changed from feat/earthdata-virtual-chunk-access to main September 1, 2026 17:59
@maxrjones
maxrjones marked this pull request as ready for review September 1, 2026 18:01

@hrodmn hrodmn 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.

@maxrjones thanks for adding this feature - it is a cool solution to a problem that I have grappled with in the past: how to properly apply a custom mask in a titiler request! I have tried a few things and the only workaround I have found with the current state of things in rio-tiler/titiler is to use the expression parameter to recode the values that I want to mask with numpy.where then provide a custom colormap that excludes the recoded mask values (example notebook). This works for some limited use-cases but is not very robust because the mask happens after warping/resampling so should really just be used with the nearest resampling method.

The where parameter is great because it applies the mask before warping/resampling. I would like to see this feature ported upstream to rio-tiler or titiler someday!

I don't love all of the dask/xarray glue code to keep everything lazy 😎 but I had to do some similar gymnastics in titiler-cmr's custom expression parser for the XarrayBackend so I get it. I don't have a great understanding of how/when to bring dask in to maintain laziness so this was helpful to see.

I will do a line-by-line review but just want to provide my initial thoughts.

Comment on lines +197 to +206
# Prune botocore's service models (~18 MB of JSON for 400+ services) down to
# the services this app uses: s3 (s3fs/aiobotocore), sts (role assumption),
# secretsmanager (Earthdata credentials). Top-level files (endpoints.json etc.)
# are kept — the loaders need them. A new boto3/aiobotocore client for another
# service raises UnknownServiceError at runtime until its model is re-added here.
find botocore/data -mindepth 1 -maxdepth 1 -type d \
! -name s3 ! -name sts ! -name secretsmanager -print0 | xargs -0 rm -rf
# dask[array] ships the full dask wheel; the dataframe and bag subpackages
# (~1.7 MB) are never imported by the array-only chunk manager xarray uses.
rm -rf dask/dataframe dask/bag

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.

did you hit Lambda package size limitations that spurred these changes?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

yes, just adding dask put it ~5MB above the limit 😞

@chuckwondo

Copy link
Copy Markdown
Contributor

Hi @maxrjones. Sorry for the delay, but I'm reviewing now. I have some suggestions, so give me some time to construct some clear comments.

@chuckwondo chuckwondo 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.

@maxrjones, I put a few suggestions inline, but my main suggestion is too complex to describe across inline PR comments, so I created a gist: https://gist.github.com/chuckwondo/b151f7f92d54cf01c62da2939238d62b

Comment thread src/titiler/multidim/reader.py Outdated
Comment thread src/titiler/multidim/reader.py Outdated
Comment thread src/titiler/multidim/reader.py Outdated
Co-authored-by: Chuck Daniels <cjdaniels4@gmail.com>
maxrjones and others added 3 commits September 14, 2026 18:44
Rename _WHERE_OPS -> _WHERE_PREDICATE_BY_OP (it is a mapping, not a
list) and _WHERE_CONDITION -> _WHERE_CONDITION_RE (it is a compiled
regex, not a condition), per review. Update the leftover
_FALLBACK_CHUNK references (reader and test) missed by the
_FALLBACK_CHUNK_SIZE rename, and import Callable, which the new
_WHERE_PREDICATE_BY_OP annotation uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adopts most of chuckwondo's PR #151 review gist, minus the
opener-wrapper closure:

- parse_where() + frozen WhereCondition dataclass: syntax errors 400
  before any I/O (no store open, no EDL priming), all invalid
  conditions reported at once, and _apply_where works with named
  fields instead of a 4-tuple.
- The try in __attrs_post_init__ now covers super(), which can raise
  after opening the dataset (bad variable/sel, missing spatial
  metadata) — previously those paths leaked the open dataset.
- chunk_for_lazy_ops() at module level centralizes the chunking rule;
  it mutates in place because Dataset.copy() drops the closer, and
  _apply_where uses it on self.ds instead of a copy (one re-extract
  remains, since self.input predates the chunking).
- opener_zarr's docstring points at the public chunk_for_lazy_ops
  instead of the private _apply_where.
- tests: closed-on-invalid test now uses a semantic failure (syntax
  errors no longer open the store); new test asserts a syntax error
  400s with the opener never called.

The gist's opener-wrapper (chunking inside a closure re-binding
self.opener) is deliberately not taken: it only removes a lazy
metadata-level re-extraction, at the cost of request-dependent opener
behavior. See refactor/where-opener-wrapper for the comparison branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@maxrjones

Copy link
Copy Markdown
Member Author

@maxrjones, I put a few suggestions inline, but my main suggestion is too complex to describe across inline PR comments, so I created a gist: gist.github.com/chuckwondo/b151f7f92d54cf01c62da2939238d62b

thanks, @chuckwondo! I took most of your suggestion in 9e26c15 (this PR), with the exception of Make __attrs_post_init__ wrap the opener. I thought it was simpler calling chunk_for_lazy_ops in _apply_where, and the performance difference should be negligable.

@maxrjones

Copy link
Copy Markdown
Member Author

I don't love all of the dask/xarray glue code to keep everything lazy 😎 but I had to do some similar gymnastics in titiler-cmr's custom expression parser for the XarrayBackend so I get it. I don't have a great understanding of how/when to bring dask in to maintain laziness so this was helpful to see.

I definitely agree with you here! I think in the future we should see about using native Zarr laziness and leaving xarray/dask out of the picture for regularly gridded Icechunk stores. That said, I'd rather have an MVP out and refactor later, as long as we're happy with the API design.

@chuckwondo

Copy link
Copy Markdown
Contributor

@maxrjones, I put a few suggestions inline, but my main suggestion is too complex to describe across inline PR comments, so I created a gist: gist.github.com/chuckwondo/b151f7f92d54cf01c62da2939238d62b

thanks, @chuckwondo! I took most of your suggestion in 9e26c15 (this PR), with the exception of Make __attrs_post_init__ wrap the opener. I thought it was simpler calling chunk_for_lazy_ops in _apply_where, and the performance difference should be negligable.

Hi @maxrjones, just to clarify, in my gist suggestions, performance isn't the concern. Rather, the reason to drop the re-extraction is provenance/"drift avoidance".

Right now bounds / transform / height / width come from the parent instance's un-chunked extraction, but the pixels come from a second (needless), chunked extraction (both via get_variable). Both extractions happen to align at this point, but there is nothing that guarantees it, so a future change (not in our hands) could cause a drift/provenance problem. Therefore, collapsing them to a single extraction guarantees that drift cannot occur.

In other words, this guarantees that bounds / transform / height / width AND the pixels come from exactly the same extraction, rather than pixels coming from a second extraction that should be effectively the same as the first one, but there is no guarantee that the 2 extractions are truly aligned.

However, that has to happen in the opener, and the reason for that is upstream, not anything in this PR. The inherited titiler.xarray.io.Reader.__attrs_post_init__ opens and extracts back to back:

self.ds = self.opener(self.src_path, **opener_options)
self.input = get_variable(self.ds, self.variable, sel=self.sel)

There is no hook between those two statements that we can leverage from a subclass. The only things a subclass can influence beforehand are self.opener and self.opener_options. That leaves the closure I proposed as a simple, clean way to essentially insert behavior between the 2 lines above by influencing self.opener to guard against any possible future drift between 2 calls to get_variable (by eliminating one of them).

Here are the adjustments I suggest:

  1. In chunk_for_lazy_ops, change the return type of chunk_for_lazy_ops to None, rather than xr.Dataset, and remove the return ds at the end, so the function is clearly mutating. (I should have suggested this in the gist to begin with. See the reasoning below.)

  2. Then, wrap the opener in __attrs_post_init__, calling chunk_for_lazy_ops purely for the side effect, returning the mutated ds:

    if conditions:
        names = {self.variable, *(c.variable for c in conditions)}
        opener = self.opener
    
        def opener_(src_path: str, **kwargs: Any) -> xr.Dataset:
            ds = opener(src_path, **kwargs)
            chunk_for_lazy_ops(ds, [n for n in names if n in ds])
            return ds
    
        self.opener = opener_
  3. In _apply_where, drop the call to chunk_for_lazy_ops, and replace data = self.get_variable(...) with data = self.input. This eliminates the second, unnecessary extraction (get_variable), leaving only the extraction that happens in super().__attrs_post_init__.

Why point 1 is important: my gist has the wrapper return chunk_for_lazy_ops(...) and assigning to self.ds. However, the problem with that is that if chunk_for_lazy_ops gains a call to ds.copy() in the future (for some unforeseen reason), there would be a real leak because the copied value would be returned, but as mentioned in the gist, the copy loses the _close method, becoming uncloseable (the leak). Removing the return value entirely means that nothing can rebind self.ds to a possibly leaky value. That is what makes the closure safe to adopt.

@chuckwondo

chuckwondo commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

@maxrjones, if you would prefer not to take my latest suggestion in full, then the very least I suggest you do is the bit that removes return ds from chunk_for_lazy_ops and change the return type to None (along with the requisite tweak to the call site that currently expects it to return a value).

@chuckwondo

Copy link
Copy Markdown
Contributor

@maxrjones, if you would prefer not to take my latest suggestion in full, then the very least I suggest you do is the bit that removes return ds from chunk_for_lazy_ops and change the return type to None (along with the requisite tweak to the call site that currently expects it to return a value).

Note, however, doing this does not address the provenance/drift issue, so I'm encouraging you to take the suggestion in full.

@maxrjones maxrjones changed the title feat: cross-variable masking via a repeatable where query parameter feat: filtering based on multiple variables Sep 17, 2026
@maxrjones

Copy link
Copy Markdown
Member Author

thanks for pushing back @chuckwondo. I'll make those changes. I'm also going to break off the independent changes to some smaller PRs.

@maxrjones

Copy link
Copy Markdown
Member Author

Closing in favor of #166. We can always re-open if others disagree with this decision.

This branch was successfully deployed

1 active deployment
dev — 9e26c156 Deployed Sep 14, 2026 by maxrjones via Deploy to dev #240
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants