Skip to content

Commit 6cbd06e

Browse files
improve style across library
1 parent 2a6b084 commit 6cbd06e

20 files changed

Lines changed: 413 additions & 907 deletions

File tree

Makefile

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,30 @@
22

33
sources = python/ tests/python/ bench/
44

5-
# Preserve colour in cargo output when running from a tty
5+
# Preserve color in cargo output when running from a tty
66
export CARGO_TERM_COLOR=$(shell (test -t 0 && echo "always") || echo "auto")
77

88
.PHONY: setup ## Create .venv and install dev dependencies from uv.lock
99
setup:
1010
uv sync --group dev
1111

12-
.PHONY: format ## Auto-format Rust and Python source files
12+
.PHONY: format
1313
format:
1414
cargo fmt
1515
uv run ruff check --fix $(sources)
1616
uv run ruff format $(sources)
1717

18-
.PHONY: lint-python ## Lint Python source files
18+
.PHONY: lint-python
1919
lint-python:
2020
uv run ruff check $(sources)
2121
uv run ruff format --check $(sources)
2222

23-
.PHONY: lint-rust ## Lint Rust source files (fmt check + clippy over all code incl. tests)
23+
.PHONY: lint-rust
2424
lint-rust:
2525
cargo fmt --all -- --check
2626
cargo clippy --tests -- -D warnings
2727

28-
.PHONY: lint ## Lint Rust and Python source files
28+
.PHONY: lint
2929
lint: lint-python lint-rust
3030

3131
.PHONY: build ## Debug build
@@ -39,26 +39,24 @@ build-prod:
3939
uv run maturin develop --release
4040

4141
# Build first so clippy and cargo nextest reuse compiled objects from maturin
42-
# instead of each triggering a second Rust compile pass.
43-
# sccache (via RUSTC_WRAPPER in .cargo/config.toml) caches across runs.
44-
.PHONY: check ## Format, build, lint, and test — run before every commit
42+
.PHONY: check
4543
check: format build lint
4644
cargo nextest run
4745
uv run pytest tests/python/ --durations=5
4846

49-
.PHONY: test ## Build and run all tests without formatting or linting
47+
.PHONY: test
5048
test: build
5149
cargo nextest run
5250
uv run pytest tests/python/
5351

54-
.PHONY: clean ## Remove build artifacts and caches
52+
.PHONY: clean
5553
clean:
5654
rm -rf `find . -name __pycache__`
5755
rm -f `find . -type f -name '*.py[co]'`
5856
rm -rf .pytest_cache .ruff_cache
5957
rm -f python/pycanopy/*.so
6058

61-
.PHONY: help ## Display this help message
59+
.PHONY: help
6260
help:
6361
@grep -E '^\.PHONY: .*?## .*$$' $(MAKEFILE_LIST) | \
6462
awk 'BEGIN {FS = ".PHONY: |## "}; {printf "\033[36m%-15s\033[0m %s\n", $$2, $$3}'

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ result = sf.lazy().filter(pl.col("population") > 100_000).range_query(-10.0, 35.
4545

4646
## Why PyCanopy
4747

48-
The only spatial engine with a Polars-native API, cost-model-driven index selection, and a full spatial query planner. The driving motivator behind creating this library was to provide the optimizations of relational DBs (query planning, indexing) in a performant dataframe interface that abstracts away the complexity of doing so from users working with spatial data.
48+
PyCanopy is the only spatial engine with a Polars-native API, cost-model-driven index selection, and a full spatial query planner.
49+
50+
The driving motivator behind creating this library was to provide the optimizations of relational DBs (query planning, indexing, etc) in a performant dataframe interface that abstracts away the complexity of doing so from users working with spatial data.
4951

5052
| | PyCanopy | GeoPandas | DuckDB | SedonaDB | Spatial Polars |
5153
|:--|:--------:|:---------:|:------:|:--------:|:--------------:|

python/pycanopy/agg.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
"""Aggregation specs for the fused aggregate-join (SpatialGroupBy.agg).
2-
1+
"""
2+
Aggregation specs for the fused aggregate-join (SpatialGroupBy.agg).
33
Specs are associative so partials fold over the streamed join without materialising the full pair frame.
44
"""
55

@@ -9,7 +9,7 @@
99

1010
import polars as pl
1111

12-
# Prefix for intermediate (partial) columns, kept distinct from user output names
12+
# Prefix for intermediate (partial) columns
1313
_P = "__pc_agg__"
1414

1515

python/pycanopy/engine.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
"""High-level Python engine wrapping the Rust core.
1+
"""
2+
High-level Python engine wrapping the Rust core.
23
34
Normalizes varied point input to contiguous float64 arrays, with standard 2D LE WKB decoding via a strided numpy view.
45
"""
@@ -201,8 +202,7 @@ def _wkb_binary_buffers(column) -> tuple[np.ndarray, np.ndarray] | None:
201202
def _extract_polygon_rings(
202203
geometries,
203204
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray | None]:
204-
# Return (xs, ys, ring_offsets, poly_offsets, part_poly) from Polygons/MultiPolygons via
205-
# two-level GeoArrow-compatible offsets, with part_poly mapping parts to logical polygons.
205+
# Return (xs, ys, ring_offsets, poly_offsets, part_poly) from Polygons/MultiPolygons
206206
geoms = np.asarray(geometries)
207207
type_ids = shapely.get_type_id(geoms)
208208
invalid = (type_ids != _SHAPELY_POLYGON_TYPE_ID) & (type_ids != _SHAPELY_MULTIPOLYGON_TYPE_ID)
@@ -296,7 +296,7 @@ def from_polygons(cls, geometries) -> Engine:
296296
as one logical polygon spanning all of its parts.
297297
298298
Returns:
299-
Engine ready to answer range and contains queries over polygon data.
299+
Engine object over polygon data.
300300
"""
301301
xs, ys, ring_offsets, poly_offsets, part_poly = _extract_polygon_rings(geometries)
302302
eng = cls.__new__(cls)
@@ -312,8 +312,7 @@ def from_wkb_polygons(cls, column) -> Engine:
312312
Polygon / MultiPolygon geometries.
313313
314314
Returns:
315-
Engine over the polygon data. Falls back to the shapely path for nulls or
316-
WKB variants the fast decoder does not recognise.
315+
Engine object over polygon data.
317316
"""
318317
eng = cls.__new__(cls)
319318
buffers = _wkb_binary_buffers(column)
@@ -338,7 +337,7 @@ def from_wkb_points(cls, points) -> Engine:
338337
numpy object array of WKB byte strings.
339338
340339
Returns:
341-
Engine ready to answer point queries.
340+
Engine object over coord data.
342341
"""
343342
xs, ys = wkb_points_to_xy(points)
344343
return cls.from_coords(xs, ys)
@@ -352,7 +351,7 @@ def from_coords(cls, xs: Sequence[float], ys: Sequence[float]) -> Engine:
352351
ys: Sequence of y coordinates.
353352
354353
Returns:
355-
Engine ready to answer point queries.
354+
Engine object over coord data.
356355
"""
357356
eng = cls.__new__(cls)
358357
eng._core = _CoreEngine.from_points(
@@ -375,7 +374,7 @@ def set_index_mode(self, mode: str) -> str:
375374
mode: "auto", "eager", or "none".
376375
377376
Returns:
378-
The mode in effect before this call.
377+
The previous index mode.
379378
"""
380379
return self._core.set_index_mode(mode)
381380

python/pycanopy/executor.py

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
"""Define SpatialExecutor which walks the optimised plan and emits a Polars LazyFrame chain."""
1+
"""
2+
Define SpatialExecutor which walks the optimised plan and emits a Polars LazyFrame chain.
3+
"""
24

35
from __future__ import annotations
46

@@ -39,7 +41,7 @@
3941
PolygonKnnJoinNode,
4042
)
4143

42-
# Probe rows per morsel for a streamed join. Conservative fixed cap (bounds rows not pairs), overridable via batch_size
44+
# Probe rows per morsel for a streamed join
4345
MORSEL_ROWS = 262_144
4446

4547

@@ -185,24 +187,21 @@ def _execute_body(
185187
) -> pl.DataFrame:
186188
# Run the optimised plan, dispatching scalar / IO / EXPR / streamed-join paths
187189

188-
# Scalar-only fast path with no spatial ops. Feed filters straight to Polars as a
189-
# zero-copy lazy chain (no _ROW_IDX, no dispatch, no bitmap).
190+
# Scalar-only fast path with no spatial ops
190191
if all(isinstance(n, ScalarNode) for n in plan):
191192
lf = sf.df.lazy()
192193
for node in plan:
193194
lf = lf.filter(node.expr)
194195
return lf.collect()
195196

196-
# Intersects self-join is terminal and produces a pair frame, not a row
197-
# subset, so it is handled directly rather than through the filter chain.
197+
# Intersects self-join is terminal and produces a pair frame
198198
if any(isinstance(n, IntersectsSelfJoinNode) for n in plan):
199199
return self._execute_intersects(plan, sf)
200200

201201
has_joins = any(isinstance(n, _JOIN_TYPES) for n in plan)
202202

203203
# Large-probe joins stream the probe in morsels and concatenate, so the join
204-
# intermediate is bounded by one morsel rather than the full result. Small
205-
# probes fall through to the single-shot path below (no slicing overhead).
204+
# intermediate is bounded by one morsel rather than the full result.
206205
if has_joins:
207206
morsel = batch_size if batch_size is not None else MORSEL_ROWS
208207
join_node = next(n for n in plan if isinstance(n, _JOIN_TYPES))
@@ -213,8 +212,7 @@ def _execute_body(
213212
return pl.concat(frames, how="vertical", rechunk=False)
214213

215214
# EXPR needs x_col/y_col as real columns (point datasets). Polygon frames use
216-
# synthetic coord names absent from df and degrade to IO. Joins bypass the plugin
217-
# machinery and stay on EXPR.
215+
# synthetic coord names absent from df and degrade to IO.
218216
if plugin_path == PluginPath.EXPR and sf.x_col not in sf.df.columns and not has_joins:
219217
plugin_path = PluginPath.IO
220218

@@ -262,8 +260,8 @@ def _stream_join_frames(
262260
join_pos = next(i for i, n in enumerate(plan) if isinstance(n, _JOIN_TYPES))
263261
join_node = plan[join_pos]
264262
post_nodes = plan[join_pos + 1 :]
265-
# Join emitters ignore the incoming lf (they gather from sf.df directly), so a
266-
# placeholder is fine, post-join nodes receive the real joined lf.
263+
264+
# Join emitters ignore the incoming lf (they gather from sf.df directly)
267265
placeholder = sf.df.lazy()
268266
for chunk in join_node.query_df.iter_slices(morsel_rows):
269267
sub = dataclasses.replace(join_node, query_df=chunk)
@@ -322,7 +320,6 @@ def _execute_io(self, plan: Plan, sf) -> pl.DataFrame:
322320

323321
def _execute_intersects(self, plan: Plan, sf) -> pl.DataFrame:
324322
# Build the polygon intersects pair frame, then apply any trailing scalar filters.
325-
# The self-join takes no probe side, so only scalar nodes after it constrain the result.
326323
pos = next(i for i, n in enumerate(plan) if isinstance(n, IntersectsSelfJoinNode))
327324
lf = sf.intersects_pairs().lazy()
328325
for node in plan[pos + 1 :]:

python/pycanopy/frame.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,9 +180,7 @@ def range_filter(
180180
return SpatialFrame.from_wkb_polygons(filtered, self._wkb_col, self._x_col, self._y_col)
181181
return SpatialFrame(self._df[idx_s], self._x_col, self._y_col)
182182

183-
# Geometry aggregations and transforms (polygon datasets). These produce new
184-
# tables or scalar columns rather than filtering, so they live on the frame
185-
# rather than the lazy plan.
183+
# Geometry aggregations and transforms (polygon datasets)
186184

187185
def polygon_areas(self) -> pl.DataFrame:
188186
"""Append an unsigned 'area' column to this frame's DataFrame (polygon datasets).

python/pycanopy/lazy.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
"""Define SpatialLazyFrame, an immutable plan builder that does not execute until .collect()."""
1+
"""
2+
Define SpatialLazyFrame, an immutable plan builder that does not execute until .collect().
3+
"""
24

35
from __future__ import annotations
46

@@ -527,7 +529,7 @@ def collect_all(frames: list[SpatialLazyFrame]) -> list[pl.DataFrame]:
527529
if prefix_len == 0:
528530
return [f.collect() for f in frames]
529531

530-
# Optimise the shared prefix as a standalone plan and cache its Polars chain
532+
# Optimize the shared prefix as a standalone plan and cache its Polars chain
531533
prefix_plan = plans[0][:prefix_len]
532534
optimized_prefix = optimizer.optimize(prefix_plan, sf.engine)
533535
base_lf = sf.df.with_row_index(_ROW_IDX).lazy()

python/pycanopy/nodes.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
"""Plan node types for the SpatialOptimizer.
1+
"""
2+
Plan node types for the SpatialOptimizer.
23
34
Declaration order is not execution order, the optimizer reorders by selectivity before emitting the final chain.
45
"""

python/pycanopy/optimizer.py

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
"""SpatialOptimizer: cost-based plan transformer.
1+
"""
2+
SpatialOptimizer: cost-based plan transformer.
23
34
Passes (in order):
45
1. _assign_selectivity: estimate selectivity for each node from engine stats
56
2. _cost_sort: reorder scalar vs. spatial based on selectivity
67
3. _fusion_pass: merge consecutive fusable spatial nodes
78
4. _join_side_pass: set flip=True on symmetric joins where query side is larger
89
5. _detect_fanout: find the longest shared plan prefix across branches
9-
(used by collect_all to insert a Polars .cache() barrier).
1010
"""
1111

1212
from __future__ import annotations
@@ -31,17 +31,13 @@
3131
WithinJoinNode,
3232
)
3333

34-
# Spatial nodes with selectivity below this threshold are too selective to fuse. Polars'
35-
# cascade leaves so few rows that a fresh index build on survivors beats the full M rows.
34+
# Spatial nodes with selectivity below this threshold are too selective to fuse
3635
_FUSION_SELECTIVITY_FLOOR = 0.05
3736

38-
# Datasets smaller than this always use BruteForce, fusion overhead isn't worth it
37+
# Datasets smaller than this always use BruteForce
3938
_FUSION_MIN_N = 500
4039

41-
# Spatial selectivity below this threshold means the spatial filter is tighter than
42-
# 5% of the dataset. The pre-built Engine index on N rows returns so few candidates
43-
# that slicing sf.df directly (IO path) is cheaper than rebuilding a local index on
44-
# M post-scalar rows and running a map_batches expression (EXPR path).
40+
# Spatial selectivity threshold where slicing sf.df directly (IO path) is cheaper
4541
_IO_SELECTIVITY_THRESHOLD = 0.05
4642

4743

@@ -152,8 +148,7 @@ def optimize(self, plan: Plan, engine) -> Plan:
152148
return plan
153149

154150
def _assign_selectivity(self, plan: Plan, engine) -> Plan:
155-
# Estimate and attach selectivity to each node. Spatial nodes use area-ratio or k/N
156-
# estimates from engine stats, and scalar nodes default to 1.0.
151+
# Estimate and attach selectivity to each node
157152
n = engine.n
158153
extent = engine.extent
159154
result = []

src/index/brute.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,6 @@ impl BruteForce {
7878

7979
/// Build from two-level polygon ring arrays. Computes per-polygon MBRs and centroids
8080
/// from exterior rings only. Holes do not expand the MBR.
81-
/// These are derived allocations (N = n_polygons, not N = n_ring_vertices).
8281
pub fn build_polygons(
8382
xs: &[f64],
8483
ys: &[f64],

0 commit comments

Comments
 (0)