core-lens 0.1.dev191__tar.gz → 0.1.dev194__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/PKG-INFO +1 -1
  2. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/_version.py +2 -2
  3. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/base/entity.py +1 -0
  4. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/base/view.py +1 -0
  5. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/utils/polars_utils.py +28 -8
  6. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/utils/spatial.py +170 -7
  7. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
  8. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
  9. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
  10. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.github/pull_request_template.md +0 -0
  11. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.github/workflows/ci.yml +0 -0
  12. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.github/workflows/gh-pages.yml +0 -0
  13. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.github/workflows/pre-release.yml +0 -0
  14. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.github/workflows/release.yml +0 -0
  15. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.gitignore +0 -0
  16. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.gitmessage +0 -0
  17. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.pre-commit-config.yaml +0 -0
  18. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.python-version +0 -0
  19. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/CONTRIBUTING.md +0 -0
  20. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/LICENSE +0 -0
  21. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/README.md +0 -0
  22. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/SKILLS.md +0 -0
  23. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/README.md +0 -0
  24. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/bench_aoi.py +0 -0
  25. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/bench_entity.py +0 -0
  26. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/bench_export.py +0 -0
  27. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/bench_polars_utils.py +0 -0
  28. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/bench_result.py +0 -0
  29. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/bench_schema.py +0 -0
  30. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/bench_season.py +0 -0
  31. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/bench_spatial.py +0 -0
  32. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/bench_view.py +0 -0
  33. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/run_all.sh +0 -0
  34. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/Makefile +0 -0
  35. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/make.bat +0 -0
  36. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/concepts.md +0 -0
  37. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/conf.py +0 -0
  38. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/export.md +0 -0
  39. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/index.rst +0 -0
  40. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/intro.md +0 -0
  41. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/logging.md +0 -0
  42. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/plots.md +0 -0
  43. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/plugins.md +0 -0
  44. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/queries.md +0 -0
  45. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/quickstart.md +0 -0
  46. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/stats.md +0 -0
  47. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/examples/demo_mws.py +0 -0
  48. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/examples/demo_tehsil.py +0 -0
  49. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/hooks/mypy.sh +0 -0
  50. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/hooks/no-parquet-outside-fixtures.sh +0 -0
  51. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/hooks/pytest.sh +0 -0
  52. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/pyproject.toml +0 -0
  53. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/__init__.py +0 -0
  54. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/__main__.py +0 -0
  55. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/aoi.py +0 -0
  56. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/base/__init__.py +0 -0
  57. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/base/namespaces/__init__.py +0 -0
  58. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/base/namespaces/plot.py +0 -0
  59. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/base/namespaces/stats.py +0 -0
  60. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/base/result.py +0 -0
  61. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/entities/__init__.py +0 -0
  62. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/entities/farms.py +0 -0
  63. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/entities/mws.py +0 -0
  64. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/entities/tehsil.py +0 -0
  65. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/entities/waterbody.py +0 -0
  66. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/export/__init__.py +0 -0
  67. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/export/formats.py +0 -0
  68. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/py.typed +0 -0
  69. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/schema/__init__.py +0 -0
  70. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/schema/detection.py +0 -0
  71. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/schema/profile.py +0 -0
  72. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/utils/__init__.py +0 -0
  73. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/utils/paths.py +0 -0
  74. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/utils/season.py +0 -0
  75. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/fixtures/generate_fixtures.py +0 -0
  76. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/conftest.py +0 -0
  77. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_aoi.py +0 -0
  78. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_entities.py +0 -0
  79. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_entity.py +0 -0
  80. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_export.py +0 -0
  81. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_main.py +0 -0
  82. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_plot.py +0 -0
  83. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_polars_utils.py +0 -0
  84. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_profile.py +0 -0
  85. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_result.py +0 -0
  86. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_schema_detection.py +0 -0
  87. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_schema_profile.py +0 -0
  88. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_season.py +0 -0
  89. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_season_config.py +0 -0
  90. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_spatial.py +0 -0
  91. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_stats.py +0 -0
  92. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_view.py +0 -0
  93. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/usage.md +0 -0
  94. {core_lens-0.1.dev191 → core_lens-0.1.dev194}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: core-lens
3
- Version: 0.1.dev191
3
+ Version: 0.1.dev194
4
4
  Summary: Query, analyse, and visualise CoreStack's microwatershed and Earth science data through a clean, composable Python API.
5
5
  Project-URL: Homepage, https://github.com/ApoorvaKashyap/core-lens
6
6
  Project-URL: Issues, https://github.com/ApoorvaKashyap/core-lens/issues
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.dev191'
22
- __version_tuple__ = version_tuple = (0, 1, 'dev191')
21
+ __version__ = version = '0.1.dev194'
22
+ __version_tuple__ = version_tuple = (0, 1, 'dev194')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -650,6 +650,7 @@ class BaseEntity(ABC):
650
650
  aoi_geometry=geometry,
651
651
  relationship=relationship,
652
652
  threshold=threshold,
653
+ storage_options=self._storage_options,
653
654
  )
654
655
 
655
656
  entity_name = _entity_name(type(self))
@@ -215,6 +215,7 @@ class View:
215
215
  aoi_geometry=geometry,
216
216
  relationship=relationship,
217
217
  threshold=threshold,
218
+ storage_options=self.entity._storage_options,
218
219
  )
219
220
 
220
221
  keys_lf = keys_df.lazy()
@@ -137,12 +137,25 @@ def scan_with_key_filter(
137
137
 
138
138
  1. **Key filter** — restricts to entity instances whose key column(s) are
139
139
  in ``key_values``. For a single-column key this is an ``is_in``
140
- predicate pushed down to the Parquet reader. For composite keys each
141
- column is filtered independently (over-selects slightly, then pruned
142
- by the join at collect time).
140
+ predicate; for composite keys each column is filtered independently
141
+ (slight over-select, pruned by the semi-join at collect time).
142
+
143
+ **S3 / cloud note:** ``is_in`` pushdown skips row groups only when the
144
+ Parquet file was written with Bloom filters on the key column (e.g.
145
+ ``pyarrow.parquet.write_table(..., bloom_filter_columns=[key])``). Without
146
+ Bloom filters the Parquet reader downloads all row groups and filters
147
+ in-memory regardless. Use the semi-join path (> 50 000 keys) or add
148
+ Bloom filters at write time to avoid full-file S3 reads.
143
149
 
144
150
  2. **Time filter** — an optional Polars expression appended with ``&``,
145
- also pushed down if the Parquet file carries column statistics.
151
+ pushed down when the Parquet file carries min/max column statistics.
152
+
153
+ ``use_statistics=True`` and ``parallel="columns"`` are set explicitly on
154
+ the underlying ``pl.scan_parquet`` call so that:
155
+
156
+ * Row-group skipping via Parquet min/max statistics is always active.
157
+ * On wide Parquet files, column chunks within each row group are fetched
158
+ concurrently instead of serially — reduces round trips on S3.
146
159
 
147
160
  Args:
148
161
  path (str): Absolute path or cloud URI to a Parquet file.
@@ -159,7 +172,13 @@ def scan_with_key_filter(
159
172
 
160
173
  """
161
174
  _so = storage_options or {}
162
- lf = pl.scan_parquet(path, hive_partitioning=True, storage_options=_so or None)
175
+ lf = pl.scan_parquet(
176
+ path,
177
+ hive_partitioning=True,
178
+ use_statistics=True,
179
+ parallel="columns",
180
+ storage_options=_so or None,
181
+ )
163
182
 
164
183
  # Use an inner join to filter the parquet file down to the exact requested keys.
165
184
  # This avoids building a massive literal expression tree (which consumes gigabytes
@@ -169,9 +188,10 @@ def scan_with_key_filter(
169
188
 
170
189
  kv_df = key_values.collect() if isinstance(key_values, pl.LazyFrame) else key_values
171
190
  if len(key_cols) == 1 and kv_df.height < 50000:
172
- # Fast path: For reasonably sized AoIs, `is_in` enables perfect row-group
173
- # predicate pushdown in the Parquet reader, avoiding a full table scan.
174
- # The literal tree RAM issue only occurs with hundreds of thousands of keys.
191
+ # Fast path: `is_in` keeps the plan simple and enables row-group skipping
192
+ # via Parquet Bloom filters when the key column was written with them.
193
+ # Without Bloom filters on S3, all row groups are downloaded regardless;
194
+ # see docstring note above.
175
195
  key = key_cols[0]
176
196
  lf = lf.filter(pl.col(key).is_in(kv_df[key].to_list()))
177
197
  else:
@@ -25,9 +25,16 @@ if TYPE_CHECKING:
25
25
  # dataset (not once per process). Subsequent runs load the index directly
26
26
  # from the sidecar — no geometry decoding required.
27
27
  #
28
- # Sidecar location priority:
28
+ # Local sidecar location priority:
29
29
  # 1. Alongside the static parquet directory/file (requires write access).
30
30
  # 2. ~/.cache/core_lens/ (fallback for read-only data roots).
31
+ #
32
+ # Cloud (S3 / GCS / ABFS) sidecar:
33
+ # Always stored in ~/.cache/core_lens/ keyed on a SHA-256 hash of the URI.
34
+ # A companion <fingerprint>.etag file records the ETag returned by the last
35
+ # HeadObject call. On cache hit the ETag is re-fetched (one cheap metadata
36
+ # call) and compared; a mismatch triggers a full rebuild. This approach
37
+ # avoids streaming the entire GeoParquet from S3 on every cold process start.
31
38
  # ---------------------------------------------------------------------------
32
39
 
33
40
  _SIDECAR_SUFFIX = ".bbox_index.parquet"
@@ -41,6 +48,8 @@ def _bbox_sidecar_path(static_path: str) -> pathlib.Path | None:
41
48
  not writable (e.g. read-only data root), falls back to
42
49
  ``~/.cache/core_lens/``.
43
50
 
51
+ For cloud URIs use :func:`_cloud_sidecar_path` instead.
52
+
44
53
  Args:
45
54
  static_path (str): Absolute local path to the static parquet file/directory.
46
55
 
@@ -66,6 +75,136 @@ def _bbox_sidecar_path(static_path: str) -> pathlib.Path | None:
66
75
  return sidecar
67
76
 
68
77
 
78
+ def _cloud_sidecar_path(static_path: str) -> pathlib.Path:
79
+ """Return the ``~/.cache/core_lens/`` sidecar path for a cloud URI.
80
+
81
+ The path is keyed on a SHA-256 hash of the full URI so different buckets,
82
+ prefixes, or regions never collide. The companion ``.etag`` file (same
83
+ stem, ``.etag`` suffix) stores the last-seen ETag for staleness detection.
84
+
85
+ Args:
86
+ static_path (str): Cloud URI (e.g. ``s3://bucket/prefix/``).
87
+
88
+ Returns:
89
+ pathlib.Path: Local cache path for the sidecar Parquet.
90
+
91
+ """
92
+ import hashlib
93
+
94
+ fingerprint = hashlib.sha256(static_path.encode()).hexdigest()[:24]
95
+ _CACHE_DIR.mkdir(parents=True, exist_ok=True)
96
+ return _CACHE_DIR / (fingerprint + _SIDECAR_SUFFIX)
97
+
98
+
99
+ def _get_cloud_etag(
100
+ static_path: str,
101
+ storage_options: dict[str, Any],
102
+ ) -> str | None:
103
+ """Return the ETag (or size-based fingerprint) for a cloud object, or ``None``.
104
+
105
+ Performs a single metadata call (``HeadObject`` on S3) — no data is
106
+ transferred. The ETag is used as the staleness key for the cloud bbox
107
+ sidecar: if the ETag matches the stored value the sidecar is fresh.
108
+
109
+ Falls back to the object size when the ETag is not exposed by the
110
+ filesystem implementation, and returns ``None`` on any error so the caller
111
+ can safely skip the cache.
112
+
113
+ Args:
114
+ static_path (str): Cloud URI to the static GeoParquet file or directory.
115
+ storage_options (dict[str, Any]): Cloud credential / configuration options.
116
+
117
+ Returns:
118
+ str | None: An opaque fingerprint string, or ``None`` on failure.
119
+
120
+ """
121
+ try:
122
+ import pyarrow.fs as pafs
123
+
124
+ fs, arrow_path = resolve_fs_and_path(static_path)
125
+ # For a directory (partitioned dataset) inspect the first child file.
126
+ info = fs.get_file_info(arrow_path)
127
+ if info.type == pafs.FileType.Directory:
128
+ selector = pafs.FileSelector(arrow_path, recursive=False)
129
+ children = fs.get_file_info(selector)
130
+ parquet_children = [c for c in children if c.base_name.endswith(".parquet")]
131
+ if not parquet_children:
132
+ return None
133
+ info = parquet_children[0]
134
+
135
+ # pyarrow S3FileSystem exposes etag via the metadata dict.
136
+ if hasattr(info, "metadata") and info.metadata:
137
+ etag = info.metadata.get("ETag") or info.metadata.get("etag")
138
+ if etag:
139
+ return str(etag)
140
+
141
+ # Fallback: use mtime + size as a combined fingerprint.
142
+ if info.size is not None and info.size > 0:
143
+ mtime = getattr(info, "mtime", None)
144
+ return f"{info.size}-{mtime}"
145
+
146
+ return None
147
+ except Exception as exc:
148
+ logger.debug(
149
+ "_get_cloud_etag: could not fetch ETag for {!r}: {}", static_path, exc
150
+ )
151
+ return None
152
+
153
+
154
+ def _read_cloud_bbox_sidecar(
155
+ sidecar: pathlib.Path,
156
+ etag: str,
157
+ ) -> pl.DataFrame | None:
158
+ """Read the cloud bbox sidecar if it exists and its stored ETag matches *etag*.
159
+
160
+ Args:
161
+ sidecar (pathlib.Path): Local cache path returned by :func:`_cloud_sidecar_path`.
162
+ etag (str): Current ETag / fingerprint of the remote object.
163
+
164
+ Returns:
165
+ pl.DataFrame | None: Cached index DataFrame, or ``None`` if absent or stale.
166
+
167
+ """
168
+ etag_file = sidecar.with_suffix(".etag")
169
+ if not sidecar.exists() or not etag_file.exists():
170
+ return None
171
+ try:
172
+ stored_etag = etag_file.read_text(encoding="utf-8").strip()
173
+ if stored_etag != etag:
174
+ logger.debug(
175
+ "Cloud bbox sidecar {} is stale (ETag mismatch), rebuilding.",
176
+ sidecar,
177
+ )
178
+ return None
179
+ logger.debug("Loading cloud bbox index from sidecar: {}", sidecar)
180
+ return pl.read_parquet(str(sidecar))
181
+ except Exception as exc:
182
+ logger.warning("Failed to read cloud bbox sidecar {}: {}", sidecar, exc)
183
+ return None
184
+
185
+
186
+ def _write_cloud_bbox_sidecar(
187
+ df: pl.DataFrame,
188
+ sidecar: pathlib.Path,
189
+ etag: str,
190
+ ) -> None:
191
+ """Persist ``df`` and ``etag`` to the cloud bbox sidecar, silently ignoring errors.
192
+
193
+ Args:
194
+ df (pl.DataFrame): The bbox index DataFrame to persist.
195
+ sidecar (pathlib.Path): Destination path for the Parquet sidecar.
196
+ etag (str): ETag / fingerprint to store alongside the sidecar.
197
+
198
+ """
199
+ try:
200
+ sidecar.parent.mkdir(parents=True, exist_ok=True)
201
+ df.write_parquet(str(sidecar))
202
+ sidecar.with_suffix(".etag").write_text(etag, encoding="utf-8")
203
+ logger.debug("Wrote cloud bbox index sidecar: {}", sidecar)
204
+ except Exception as exc:
205
+ logger.debug("Could not write cloud bbox index sidecar {}: {}", sidecar, exc)
206
+
207
+
69
208
  def _read_bbox_sidecar(sidecar: pathlib.Path, static_path: str) -> pl.DataFrame | None:
70
209
  """Read the sidecar if it exists and is newer than the static data.
71
210
 
@@ -212,10 +351,20 @@ def build_bbox_index(
212
351
  + "Cannot compute bounds from separate lat/lon columns without bbox hints."
213
352
  )
214
353
 
215
- # --- Sidecar fast path (local paths only) --------------------------------
354
+ # --- Sidecar fast path ---------------------------------------------------
216
355
  # Check for a pre-built index sidecar before doing the expensive WKB loop.
356
+ # Local paths use mtime-based invalidation; cloud URIs use ETag.
217
357
  sidecar: pathlib.Path | None = None
218
- if not is_cloud_uri(static_path):
358
+ cloud_etag: str | None = None
359
+ if is_cloud_uri(static_path):
360
+ # One cheap HeadObject call to get the ETag — no data transferred.
361
+ cloud_etag = _get_cloud_etag(static_path, _so)
362
+ if cloud_etag is not None:
363
+ sidecar = _cloud_sidecar_path(static_path)
364
+ cached = _read_cloud_bbox_sidecar(sidecar, cloud_etag)
365
+ if cached is not None:
366
+ return cached
367
+ else:
219
368
  sidecar = _bbox_sidecar_path(static_path)
220
369
  if sidecar is not None:
221
370
  cached = _read_bbox_sidecar(sidecar, static_path)
@@ -266,9 +415,14 @@ def build_bbox_index(
266
415
  pl.Series("maxy", [], dtype=pl.Float64),
267
416
  )
268
417
 
269
- # Persist the sidecar for future runs (local paths only, errors silenced).
418
+ # Persist the sidecar for future runs (errors silenced in both helpers).
270
419
  if sidecar is not None:
271
- _write_bbox_sidecar(result, sidecar)
420
+ if cloud_etag is not None:
421
+ # Cloud path — store alongside the ETag fingerprint.
422
+ _write_cloud_bbox_sidecar(result, sidecar, cloud_etag)
423
+ else:
424
+ # Local path — plain mtime-checked sidecar.
425
+ _write_bbox_sidecar(result, sidecar)
272
426
 
273
427
  return result
274
428
 
@@ -310,6 +464,7 @@ def exact_spatial_filter(
310
464
  aoi_geometry: "shapely.Geometry",
311
465
  relationship: str = "centroid",
312
466
  threshold: float = 0.5,
467
+ storage_options: dict[str, Any] | None = None,
313
468
  ) -> pl.DataFrame:
314
469
  """Refine a bbox candidate set to rows that match ``aoi_geometry``.
315
470
 
@@ -319,7 +474,7 @@ def exact_spatial_filter(
319
474
  Args:
320
475
  candidates (pl.DataFrame): The DataFrame from :func:`bbox_intersects_geometry` —
321
476
  only the key columns are used here; the bbox columns are ignored.
322
- static_path (str): Absolute path to the static GeoParquet file.
477
+ static_path (str): Absolute path or cloud URI to the static GeoParquet file.
323
478
  key_cols (list[str]): Column name(s) that form the entity's unique key.
324
479
  geometry_col (str): Name of the geometry column in the static file.
325
480
  geometry_type (str): One of ``"wkb"`` or ``"wkt"``.
@@ -333,6 +488,10 @@ def exact_spatial_filter(
333
488
 
334
489
  threshold (float, optional): Minimum intersection-to-entity area ratio used in
335
490
  ``"area"`` mode. Ignored in ``"centroid"`` mode. Default 0.5.
491
+ storage_options (dict[str, Any] | None, optional): Cloud credential /
492
+ configuration options forwarded to ``pl.scan_parquet``. Required
493
+ when ``static_path`` is a cloud URI; ``None`` uses ambient
494
+ credentials.
336
495
 
337
496
  Returns:
338
497
  pl.DataFrame: A ``pl.DataFrame`` containing only the key columns for entities that
@@ -361,8 +520,12 @@ def exact_spatial_filter(
361
520
  return candidates.select(key_cols)
362
521
 
363
522
  # Build a lazy scan and push down an inner join to load only required geometries.
523
+ _so = storage_options or {}
364
524
  full_df = (
365
- pl.scan_parquet(parquet_scan_path(static_path))
525
+ pl.scan_parquet(
526
+ parquet_scan_path(static_path),
527
+ storage_options=_so or None,
528
+ )
366
529
  .select(key_cols + [geometry_col])
367
530
  .join(candidates.select(key_cols).lazy(), on=key_cols, how="semi")
368
531
  .collect()
File without changes
File without changes
File without changes
File without changes
File without changes