python-calamine-reducto 0.7.14__tar.gz → 0.7.15__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 (64) hide show
  1. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/Cargo.lock +1 -1
  2. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/Cargo.toml +1 -1
  3. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/PKG-INFO +1 -1
  4. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/src/types/workbook.rs +82 -12
  5. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/test_band.py +86 -3
  6. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/.github/workflows/CI.yml +0 -0
  7. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/.github/workflows/release.yml +0 -0
  8. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/.gitignore +0 -0
  9. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/.pre-commit-config.yaml +0 -0
  10. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/.python-version +0 -0
  11. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/CLAUDE.md +0 -0
  12. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/LICENSE +0 -0
  13. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/README.md +0 -0
  14. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/build.rs +0 -0
  15. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/pyproject.toml +0 -0
  16. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/python/python_calamine/__init__.py +0 -0
  17. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/python/python_calamine/_python_calamine.pyi +0 -0
  18. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/python/python_calamine/py.typed +0 -0
  19. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/renovate.json +0 -0
  20. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/src/lib.rs +0 -0
  21. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/src/types/cell.rs +0 -0
  22. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/src/types/errors.rs +0 -0
  23. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/src/types/grid.rs +0 -0
  24. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/src/types/mod.rs +0 -0
  25. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/src/types/sheet.rs +0 -0
  26. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/src/types/table.rs +0 -0
  27. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/__init__.py +0 -0
  28. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/conftest.py +0 -0
  29. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/create_empty_merge_fixture.py +0 -0
  30. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/create_richtext_fixture.py +0 -0
  31. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/create_test_fixture.py +0 -0
  32. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/any_sheets.ods +0 -0
  33. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/any_sheets.xls +0 -0
  34. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/any_sheets.xlsb +0 -0
  35. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/any_sheets.xlsx +0 -0
  36. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/base.ods +0 -0
  37. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/base.xls +0 -0
  38. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/base.xlsb +0 -0
  39. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/base.xlsx +0 -0
  40. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/dropdown_test.xlsx +0 -0
  41. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/empty_file.ods +0 -0
  42. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/empty_file.xlsb +0 -0
  43. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/empty_file.xlsx +0 -0
  44. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/empty_merge_test.xlsx +0 -0
  45. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/formula_test.xlsx +0 -0
  46. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/grid_test.xlsx +0 -0
  47. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/image_test.xlsx +0 -0
  48. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/issue139.xlsx +0 -0
  49. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/multi_image_test.xlsx +0 -0
  50. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/password.ods +0 -0
  51. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/password.xls +0 -0
  52. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/password.xlsb +0 -0
  53. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/password.xlsx +0 -0
  54. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/richtext_test.xlsx +0 -0
  55. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/table-multiple.xlsb +0 -0
  56. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/table-multiple.xlsx +0 -0
  57. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/data/temperature-table.xlsb +0 -0
  58. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/test_base.py +0 -0
  59. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/test_grid.py +0 -0
  60. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/test_images.py +0 -0
  61. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/test_pandas.py +0 -0
  62. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/test_sheet_type.py +0 -0
  63. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/tests/test_tables.py +0 -0
  64. {python_calamine_reducto-0.7.14 → python_calamine_reducto-0.7.15}/uv.lock +0 -0
@@ -612,7 +612,7 @@ dependencies = [
612
612
 
613
613
  [[package]]
614
614
  name = "python-calamine-reducto"
615
- version = "0.7.14"
615
+ version = "0.7.15"
616
616
  dependencies = [
617
617
  "calamine",
618
618
  "chrono",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "python-calamine-reducto"
3
- version = "0.7.14"
3
+ version = "0.7.15"
4
4
  edition = "2021"
5
5
  readme = "README.md"
6
6
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-calamine-reducto
3
- Version: 0.7.14
3
+ Version: 0.7.15
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: Programming Language :: Python :: 3.11
@@ -700,6 +700,7 @@ impl CalamineWorkbook {
700
700
  trigger_cells=2_000_000,
701
701
  trigger_density=0.001,
702
702
  coverage=0.999,
703
+ propagate_merges=false,
703
704
  ))]
704
705
  fn py_for_each_band(
705
706
  &mut self,
@@ -711,6 +712,7 @@ impl CalamineWorkbook {
711
712
  trigger_cells: usize,
712
713
  trigger_density: f64,
713
714
  coverage: f64,
715
+ propagate_merges: bool,
714
716
  ) -> PyResult<(usize, usize, u64)> {
715
717
  self.for_each_band(
716
718
  py,
@@ -721,6 +723,7 @@ impl CalamineWorkbook {
721
723
  trigger_cells,
722
724
  trigger_density,
723
725
  coverage,
726
+ propagate_merges,
724
727
  )
725
728
  }
726
729
 
@@ -792,6 +795,36 @@ impl CalamineWorkbook {
792
795
  Ok((fp.frozen_rows, fp.frozen_cols))
793
796
  }
794
797
 
798
+ /// Merged cell ranges as absolute 0-based `(start_row, start_col, end_row,
799
+ /// end_col)`. Cheap (bounded by the merge count, not the cell count) and does
800
+ /// not materialize a grid — lets a `for_each_band(propagate_merges=True)`
801
+ /// caller know each merge's anchor so it can carry the anchor's formatted
802
+ /// value across bands. Empty for non-xlsx workbooks without merges.
803
+ #[pyo3(name = "get_sheet_merged_ranges")]
804
+ fn py_get_sheet_merged_ranges(
805
+ &mut self,
806
+ py: Python<'_>,
807
+ name: &str,
808
+ ) -> PyResult<Vec<(usize, usize, usize, usize)>> {
809
+ py.detach(|| -> PyResult<Vec<(usize, usize, usize, usize)>> {
810
+ let ranges = match self.sheets.worksheet_merge_cells(name)? {
811
+ Some(ranges) => ranges,
812
+ None => return Ok(Vec::new()),
813
+ };
814
+ Ok(ranges
815
+ .iter()
816
+ .map(|dim| {
817
+ (
818
+ dim.start.0 as usize,
819
+ dim.start.1 as usize,
820
+ dim.end.0 as usize,
821
+ dim.end.1 as usize,
822
+ )
823
+ })
824
+ .collect())
825
+ })
826
+ }
827
+
795
828
  /// Compute filled bounds for a sheet entirely in Rust.
796
829
  #[pyo3(name = "get_sheet_bounds")]
797
830
  fn py_get_sheet_bounds(
@@ -1107,6 +1140,16 @@ impl CalamineWorkbook {
1107
1140
  ///
1108
1141
  /// The callback must not call back into this workbook (its cell reader is
1109
1142
  /// borrowed for the duration); fetch formulas/layout/etc. beforehand.
1143
+ ///
1144
+ /// With `propagate_merges`, a band's merge map keeps every merge that
1145
+ /// intersects it (including children whose anchor is in an earlier band) and
1146
+ /// stores the anchor at its **absolute** coordinate, so each band's
1147
+ /// `is_merged`/`merge_parent`/`coord` are byte-identical to the same row
1148
+ /// range of the dense grid. The merged *value* still propagates only within a
1149
+ /// band here (the anchor's formatted value is not known at stream time); the
1150
+ /// caller carries it across bands. Without the flag, the legacy behavior
1151
+ /// applies: band-local anchors, dropping cross-band merges (matching
1152
+ /// `CellGrid::slice_copy`).
1110
1153
  fn for_each_band(
1111
1154
  &mut self,
1112
1155
  py: Python<'_>,
@@ -1117,6 +1160,7 @@ impl CalamineWorkbook {
1117
1160
  trigger_cells: usize,
1118
1161
  trigger_density: f64,
1119
1162
  coverage: f64,
1163
+ propagate_merges: bool,
1120
1164
  ) -> PyResult<(usize, usize, u64)> {
1121
1165
  if band_rows == 0 {
1122
1166
  return Err(PyValueError::new_err("band_rows must be >= 1"));
@@ -1151,7 +1195,8 @@ impl CalamineWorkbook {
1151
1195
  }
1152
1196
  }
1153
1197
 
1154
- let mut acc = BandAccumulator::new(rows, cols, band_rows, include_styles, merges);
1198
+ let mut acc =
1199
+ BandAccumulator::new(rows, cols, band_rows, include_styles, merges, propagate_merges);
1155
1200
  let mut cb_err: Option<PyErr> = None;
1156
1201
  self.sheets.for_each_cell(name, |(r, c), dref, style| {
1157
1202
  if cb_err.is_some() {
@@ -1353,6 +1398,9 @@ struct BandAccumulator {
1353
1398
  include_styles: bool,
1354
1399
  /// Merged ranges clipped to the trimmed bounds, absolute 0-based (sr, sc, er, ec).
1355
1400
  merges: Vec<(usize, usize, usize, usize)>,
1401
+ /// Keep cross-band merges with absolute anchors (dense-slice parity) instead
1402
+ /// of the legacy band-local + drop-cross-band behavior.
1403
+ propagate_merges: bool,
1356
1404
  /// Absolute first row of the in-progress band.
1357
1405
  top: usize,
1358
1406
  raw: Vec<RawValue>,
@@ -1367,6 +1415,7 @@ impl BandAccumulator {
1367
1415
  band_rows: usize,
1368
1416
  include_styles: bool,
1369
1417
  merges: Vec<(usize, usize, usize, usize)>,
1418
+ propagate_merges: bool,
1370
1419
  ) -> Self {
1371
1420
  let h = band_rows.min(rows);
1372
1421
  BandAccumulator {
@@ -1375,6 +1424,7 @@ impl BandAccumulator {
1375
1424
  cols,
1376
1425
  include_styles,
1377
1426
  merges,
1427
+ propagate_merges,
1378
1428
  top: 0,
1379
1429
  raw: vec![RawValue::Empty; h * cols],
1380
1430
  styles: if include_styles {
@@ -1463,20 +1513,40 @@ impl BandAccumulator {
1463
1513
  Ok(())
1464
1514
  }
1465
1515
 
1466
- /// Band-local merge map: ranges intersected with this band, remapped to
1467
- /// band-local coords. Cross-band merges (parent above the band) are dropped,
1468
- /// matching `CellGrid::slice_copy`.
1516
+ /// Merge map for the in-progress band. Keys are band-local; values are the
1517
+ /// merge anchor's coordinate.
1518
+ ///
1519
+ /// With `propagate_merges`, every merge that intersects the band is kept and
1520
+ /// the anchor is stored at its **absolute** coordinate — even when the anchor
1521
+ /// lies in an earlier band — so `is_merged`/`merge_parent` match a dense slice
1522
+ /// of the same rows (whose anchors are likewise absolute and may point above
1523
+ /// the slice). Otherwise the legacy behavior applies: the anchor is remapped
1524
+ /// band-local and merges anchored above the band are dropped, matching
1525
+ /// `CellGrid::slice_copy`.
1469
1526
  fn band_merge_map(&self, h: usize) -> HashMap<(usize, usize), (i32, i32)> {
1470
1527
  let mut merge_map = HashMap::new();
1528
+ let band_bottom = self.top + h - 1;
1471
1529
  for &(sr, sc, er, ec) in &self.merges {
1472
- let parent_r = sr as i32 - self.top as i32;
1473
- let parent_c = sc as i32;
1474
- if parent_r < 0 || parent_r as usize >= h || parent_c as usize >= self.cols {
1475
- continue;
1476
- }
1477
- for r in sr.max(self.top)..=er.min(self.top + h - 1) {
1478
- for c in sc..=ec {
1479
- merge_map.insert((r - self.top, c), (parent_r, parent_c));
1530
+ if self.propagate_merges {
1531
+ if er < self.top || sr > band_bottom || sc >= self.cols {
1532
+ continue;
1533
+ }
1534
+ let anchor = (sr as i32, sc as i32);
1535
+ for r in sr.max(self.top)..=er.min(band_bottom) {
1536
+ for c in sc..=ec.min(self.cols - 1) {
1537
+ merge_map.insert((r - self.top, c), anchor);
1538
+ }
1539
+ }
1540
+ } else {
1541
+ let parent_r = sr as i32 - self.top as i32;
1542
+ let parent_c = sc as i32;
1543
+ if parent_r < 0 || parent_r as usize >= h || parent_c as usize >= self.cols {
1544
+ continue;
1545
+ }
1546
+ for r in sr.max(self.top)..=er.min(band_bottom) {
1547
+ for c in sc..=ec {
1548
+ merge_map.insert((r - self.top, c), (parent_r, parent_c));
1549
+ }
1480
1550
  }
1481
1551
  }
1482
1552
  }
@@ -32,10 +32,14 @@ def _xml_escape(v: str) -> str:
32
32
  return v.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
33
33
 
34
34
 
35
- def _make_xlsx(rows_in_order: list[tuple[int, list[tuple[int, object]]]]) -> str:
35
+ def _make_xlsx(
36
+ rows_in_order: list[tuple[int, list[tuple[int, object]]]],
37
+ merges: list[str] | None = None,
38
+ ) -> str:
36
39
  """Write a minimal single-sheet xlsx (stdlib only — no openpyxl) and return
37
40
  its path. `rows_in_order` is [(row_1based, [(col_1based, value), ...]), ...]
38
- emitted in the given order, so callers can produce out-of-order rows."""
41
+ emitted in the given order, so callers can produce out-of-order rows.
42
+ `merges` is an optional list of A1-style ranges (e.g. "A2:A5")."""
39
43
  cells_xml = []
40
44
  for r, cols in rows_in_order:
41
45
  cs = []
@@ -48,10 +52,14 @@ def _make_xlsx(rows_in_order: list[tuple[int, list[tuple[int, object]]]]) -> str
48
52
  else:
49
53
  cs.append(f'<c r="{ref}"><v>{value}</v></c>')
50
54
  cells_xml.append(f'<row r="{r}">{"".join(cs)}</row>')
55
+ merge_xml = ""
56
+ if merges:
57
+ refs = "".join(f'<mergeCell ref="{m}"/>' for m in merges)
58
+ merge_xml = f'<mergeCells count="{len(merges)}">{refs}</mergeCells>'
51
59
  sheet = (
52
60
  '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
53
61
  '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
54
- f'<sheetData>{"".join(cells_xml)}</sheetData></worksheet>'
62
+ f'<sheetData>{"".join(cells_xml)}</sheetData>{merge_xml}</worksheet>'
55
63
  )
56
64
  content_types = (
57
65
  '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
@@ -175,6 +183,81 @@ def test_band_merges_match_dense_within_band() -> None:
175
183
  assert list(band[r, c]["merge_parent"]) == list(dense[r, c]["merge_parent"])
176
184
 
177
185
 
186
+ def _make_cross_band_merge_xlsx() -> str:
187
+ """6×4 sheet with two merges that both straddle a band boundary for any band
188
+ height < 4: a single-column vertical merge A2:A5 (anchor in row 2) and a
189
+ multi-column block merge C2:D4 (anchor in row 2, exercising the column loop)."""
190
+ return _make_xlsx(
191
+ [
192
+ (1, [(1, "h1"), (2, "h2"), (3, "h3"), (4, "h4")]),
193
+ (2, [(1, "Vert"), (3, "Block")]),
194
+ (3, [(2, "x3")]),
195
+ (4, [(2, "x4")]),
196
+ (5, [(2, "x5")]),
197
+ (6, [(1, "tail"), (2, "x6")]),
198
+ ],
199
+ merges=["A2:A5", "C2:D4"],
200
+ )
201
+
202
+
203
+ @pytest.mark.parametrize("band_rows", [1, 2, 3, 4, 6])
204
+ def test_band_merges_match_dense_cross_band(band_rows: int) -> None:
205
+ """With propagate_merges, a merge spanning a band boundary reproduces the
206
+ dense is_merged/merge_parent/coord in every band — including children whose
207
+ anchor lives in an earlier band (the anchor stays at its absolute coord, as
208
+ in a dense slice of the same rows)."""
209
+ wb = CalamineWorkbook.from_path(_make_cross_band_merge_xlsx())
210
+ name = wb.sheet_names[0]
211
+
212
+ dense, _, _, _ = wb.get_sheet_grid_streaming(name)
213
+ rows, cols = dense.shape
214
+
215
+ _, bands = _collect_bands(wb, name, band_rows, propagate_merges=True)
216
+ assert [top for top, _ in bands] == list(range(0, rows, band_rows))
217
+
218
+ for top, band in bands:
219
+ bh, _ = band.shape
220
+ for br in range(bh):
221
+ for c in range(cols):
222
+ assert band[br, c]["is_merged"] == dense[top + br, c]["is_merged"]
223
+ assert list(band[br, c]["merge_parent"]) == list(
224
+ dense[top + br, c]["merge_parent"]
225
+ )
226
+ assert list(band[br, c]["coord"]) == [top + br + 1, c + 1]
227
+
228
+
229
+ def test_band_merges_legacy_drops_cross_band() -> None:
230
+ """Without propagate_merges (default), a child whose anchor is in an earlier
231
+ band is dropped from the merge map — legacy CellGrid::slice_copy parity."""
232
+ wb = CalamineWorkbook.from_path(_make_cross_band_merge_xlsx())
233
+ name = wb.sheet_names[0]
234
+
235
+ _, bands = _collect_bands(wb, name, 2)
236
+ # band1 = rows 2–3 (0-based); A3/A4 are merge children whose anchor (row 1)
237
+ # is in band0, so the legacy path drops them.
238
+ top1, band1 = bands[1]
239
+ assert top1 == 2
240
+ assert not band1[0, 0]["is_merged"]
241
+ assert not band1[1, 0]["is_merged"]
242
+
243
+
244
+ def test_merged_ranges_reports_absolute_bounds() -> None:
245
+ """get_sheet_merged_ranges returns each merge as absolute 0-based
246
+ (start_row, start_col, end_row, end_col), so a propagate_merges caller can
247
+ find every anchor without materializing the grid."""
248
+ wb = CalamineWorkbook.from_path(_make_cross_band_merge_xlsx())
249
+ name = wb.sheet_names[0]
250
+ ranges = set(wb.get_sheet_merged_ranges(name))
251
+ # A2:A5 -> rows 1-4, col 0; C2:D4 -> rows 1-3, cols 2-3.
252
+ assert ranges == {(1, 0, 4, 0), (1, 2, 3, 3)}
253
+
254
+
255
+ def test_merged_ranges_empty_when_no_merges() -> None:
256
+ wb = CalamineWorkbook.from_path(_make_xlsx([(1, [(1, "a")])]))
257
+ name = wb.sheet_names[0]
258
+ assert wb.get_sheet_merged_ranges(name) == []
259
+
260
+
178
261
  def test_filled_count_matches_dense_grid() -> None:
179
262
  """get_sheet_filled_count reports the non-empty cell count + filled bounds
180
263
  without materializing a grid, so callers can route on it up front."""