python-calamine-reducto 0.7.19__tar.gz → 0.7.20__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 (73) hide show
  1. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/Cargo.lock +2 -2
  2. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/Cargo.toml +1 -1
  3. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/PKG-INFO +1 -1
  4. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/README.md +2 -1
  5. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/python/python_calamine/_python_calamine.pyi +28 -1
  6. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/src/types/workbook.rs +85 -12
  7. python_calamine_reducto-0.7.20/tests/create_hyperlink_fixture.py +30 -0
  8. python_calamine_reducto-0.7.20/tests/data/hyperlink_test.xlsx +0 -0
  9. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/test_images.py +83 -2
  10. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/.github/workflows/CI.yml +0 -0
  11. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/.github/workflows/release.yml +0 -0
  12. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/.gitignore +0 -0
  13. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/.pre-commit-config.yaml +0 -0
  14. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/.python-version +0 -0
  15. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/CLAUDE.md +0 -0
  16. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/LICENSE +0 -0
  17. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/build.rs +0 -0
  18. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/pyproject.toml +0 -0
  19. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/python/python_calamine/__init__.py +0 -0
  20. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/python/python_calamine/py.typed +0 -0
  21. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/renovate.json +0 -0
  22. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/src/lib.rs +0 -0
  23. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/src/types/cell.rs +0 -0
  24. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/src/types/errors.rs +0 -0
  25. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/src/types/grid.rs +0 -0
  26. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/src/types/mod.rs +0 -0
  27. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/src/types/sheet.rs +0 -0
  28. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/src/types/table.rs +0 -0
  29. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/__init__.py +0 -0
  30. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/conftest.py +0 -0
  31. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/create_empty_merge_fixture.py +0 -0
  32. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/create_hidden_col_range_fixture.py +0 -0
  33. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/create_richtext_fixture.py +0 -0
  34. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/create_test_fixture.py +0 -0
  35. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/any_sheets.ods +0 -0
  36. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/any_sheets.xls +0 -0
  37. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/any_sheets.xlsb +0 -0
  38. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/any_sheets.xlsx +0 -0
  39. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/base.ods +0 -0
  40. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/base.xls +0 -0
  41. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/base.xlsb +0 -0
  42. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/base.xlsx +0 -0
  43. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/dropdown_test.xlsx +0 -0
  44. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/empty_file.ods +0 -0
  45. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/empty_file.xlsb +0 -0
  46. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/empty_file.xlsx +0 -0
  47. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/empty_merge_test.xlsx +0 -0
  48. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/formula_test.xlsx +0 -0
  49. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/grid_test.xlsx +0 -0
  50. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/hidden_col_range_test.xlsx +0 -0
  51. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/image_test.xlsx +0 -0
  52. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/issue139.xlsx +0 -0
  53. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/multi_image_test.xlsx +0 -0
  54. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/password.ods +0 -0
  55. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/password.xls +0 -0
  56. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/password.xlsb +0 -0
  57. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/password.xlsx +0 -0
  58. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/richtext_test.xlsx +0 -0
  59. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/table-multiple.xlsb +0 -0
  60. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/table-multiple.xlsx +0 -0
  61. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/data/temperature-table.xlsb +0 -0
  62. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/test_band.py +0 -0
  63. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/test_base.py +0 -0
  64. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/test_bounded_reads.py +0 -0
  65. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/test_gap_compression.py +0 -0
  66. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/test_grid.py +0 -0
  67. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/test_pandas.py +0 -0
  68. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/test_sheet_type.py +0 -0
  69. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/test_tables.py +0 -0
  70. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/test_trim_header_only.py +0 -0
  71. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/test_uncached_formulas.py +0 -0
  72. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/tests/xlsx_builder.py +0 -0
  73. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.20}/uv.lock +0 -0
@@ -80,7 +80,7 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
80
80
  [[package]]
81
81
  name = "calamine"
82
82
  version = "0.32.0"
83
- source = "git+https://github.com/reductoai/calamine.git?branch=main#297ee32c41c7449e22f9fb94e93c0291b0b4d15d"
83
+ source = "git+https://github.com/reductoai/calamine.git?branch=main#5a6418cb25f78e47a841f51c42c85363ee2cd06b"
84
84
  dependencies = [
85
85
  "atoi_simd",
86
86
  "byteorder",
@@ -612,7 +612,7 @@ dependencies = [
612
612
 
613
613
  [[package]]
614
614
  name = "python-calamine-reducto"
615
- version = "0.7.19"
615
+ version = "0.7.20"
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.19"
3
+ version = "0.7.20"
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.19
3
+ Version: 0.7.20
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: Programming Language :: Python :: 3.11
@@ -25,7 +25,8 @@ Our additions live on `main`.
25
25
  - **`get_sheet_grid(name, rows, cols, include_styles=True)`** → `CellGrid` from the dense range.
26
26
  - **`get_sheet_layout(name)`** → dict with row heights, column widths, defaults
27
27
  - **`get_sheet_formulas`**, **`get_sheet_merged_ranges`**, **`get_sheet_images`**,
28
- **`get_sheet_data_validations`**, **`get_sheet_freeze_panes`**,
28
+ **`get_sheet_data_validations`**, **`get_sheet_hyperlinks`**, **`get_sheet_hyperlink_formulas`**,
29
+ **`get_sheet_freeze_panes`**,
29
30
  **`get_sheet_filled_count`**, **`get_sheet_uncached_formula_count`**,
30
31
  **`get_all_tables_metadata`**
31
32
 
@@ -204,6 +204,21 @@ class DataValidation(typing.TypedDict):
204
204
  formula2: str | None
205
205
  allow_blank: bool
206
206
 
207
+ @typing.type_check_only
208
+ class Hyperlink(typing.TypedDict):
209
+ ref: str
210
+ """Cell or range reference, e.g. `A1` or `A1:C3`."""
211
+ rel_id: str | None
212
+ """The `r:id` of the link, if any."""
213
+ target: str | None
214
+ """Relationship target, e.g. a URL. `None` for a link with only a `location`, and for an
215
+ `r:id` that has no hyperlink relationship."""
216
+ location: str | None
217
+ """Place inside the target or the workbook, e.g. `Sheet2!A1` or a URL fragment."""
218
+ display: str | None
219
+ external: bool
220
+ """`True` when the relationship has `TargetMode="External"`."""
221
+
207
222
  @typing.final
208
223
  class CellProxy:
209
224
  """One cell of a `CellGrid`. Read a field with `cell["value"]`."""
@@ -261,7 +276,9 @@ class CellGrid:
261
276
  def apply_formatting_rust(self) -> None:
262
277
  """Format each value with its number format (SSF, in Rust)."""
263
278
 
264
- def apply_dateutil_reformat(self, date_parse: Callable[[str], float | None]) -> None:
279
+ def apply_dateutil_reformat(
280
+ self, date_parse: Callable[[str], float | None]
281
+ ) -> None:
265
282
  """Reformat text cells that have a date format. `date_parse` gives a date serial."""
266
283
 
267
284
  def apply_raw_as_formatted(self) -> None: ...
@@ -428,6 +445,16 @@ class CalamineWorkbook:
428
445
  """`(data, extension, anchor_row, anchor_col)` for each image."""
429
446
 
430
447
  def get_sheet_data_validations(self, name: str) -> list[DataValidation]: ...
448
+ def get_sheet_hyperlinks(
449
+ self, name: str, limit: int | None = None
450
+ ) -> list[Hyperlink]:
451
+ """The links of the sheet's `<hyperlinks>` element, at most `limit`. Empty for xlsb, xls, and ods."""
452
+
453
+ def get_sheet_hyperlink_formulas(
454
+ self, name: str, limit: int | None = None
455
+ ) -> list[tuple[int, int, str]]:
456
+ """`(row, col, formula)` for each formula that contains `HYPERLINK`, at most `limit`. Empty for xlsb, xls, and ods."""
457
+
431
458
  def get_sheet_freeze_panes(self, name: str) -> tuple[int, int]:
432
459
  """`(frozen_rows, frozen_cols)`."""
433
460
 
@@ -133,6 +133,14 @@ impl SheetsEnum {
133
133
  with_xlsx!(self, x => Ok(x.worksheet_data_validations(name)?), else Ok(Vec::new()))
134
134
  }
135
135
 
136
+ fn worksheet_hyperlinks(
137
+ &mut self,
138
+ name: &str,
139
+ limit: Option<usize>,
140
+ ) -> Result<Vec<calamine::Hyperlink>, Error> {
141
+ with_xlsx!(self, x => Ok(x.worksheet_hyperlinks(name, limit)?), else Ok(Vec::new()))
142
+ }
143
+
136
144
  fn worksheet_freeze_panes(&mut self, name: &str) -> Result<calamine::FreezePanes, Error> {
137
145
  with_xlsx!(
138
146
  self,
@@ -183,10 +191,14 @@ impl SheetsEnum {
183
191
  }, else Ok(None))
184
192
  }
185
193
 
186
- /// Stream the formulas of an xlsx sheet with `XlsxCellReader::next_formula`, with no dense
187
- /// `Range<String>`. Keyed by position, the last duplicate kept, as `worksheet_formula`
188
- /// gives them. `Ok(None)` for non-xlsx workbooks.
189
- fn xlsx_formulas(&mut self, name: &str) -> Result<Option<Formulas>, Error> {
194
+ /// Stream the xlsx formulas that `keep` accepts, at most `limit`, with no dense `Range<String>`.
195
+ /// Keyed by position, the last duplicate kept, as `worksheet_formula` gives them. `Ok(None)` for non-xlsx.
196
+ fn xlsx_formulas(
197
+ &mut self,
198
+ name: &str,
199
+ keep: fn(&str) -> bool,
200
+ limit: usize,
201
+ ) -> Result<Option<Formulas>, Error> {
190
202
  with_xlsx!(self, x => {
191
203
  let mut formulas = BTreeMap::new();
192
204
  let mut reader = match x.worksheet_cells_reader(name) {
@@ -194,9 +206,13 @@ impl SheetsEnum {
194
206
  Err(calamine::XlsxError::NotAWorksheet(_)) => return Ok(Some(formulas)),
195
207
  Err(e) => return Err(e.into()),
196
208
  };
197
- while let Some(cell) = reader.next_formula()? {
198
- if !cell.get_value().is_empty() {
199
- formulas.insert(cell.get_position(), cell.get_value().clone());
209
+ while formulas.len() < limit {
210
+ let Some(cell) = reader.next_formula()? else {
211
+ break;
212
+ };
213
+ let formula = cell.get_value();
214
+ if !formula.is_empty() && keep(formula) {
215
+ formulas.insert(cell.get_position(), formula.clone());
200
216
  }
201
217
  }
202
218
  Ok(Some(formulas))
@@ -577,6 +593,50 @@ impl CalamineWorkbook {
577
593
  .collect()
578
594
  }
579
595
 
596
+ /// Get the hyperlinks of a sheet as a list of dicts, at most `limit` of them.
597
+ /// Each dict has: ref, rel_id, target, location, display, external. Empty for xlsb, xls, and ods.
598
+ #[pyo3(name = "get_sheet_hyperlinks", signature = (name, limit = None))]
599
+ fn py_get_sheet_hyperlinks(
600
+ &mut self,
601
+ py: Python<'_>,
602
+ name: &str,
603
+ limit: Option<usize>,
604
+ ) -> PyResult<Vec<Py<PyAny>>> {
605
+ let links = py.detach(|| -> PyResult<Vec<calamine::Hyperlink>> {
606
+ Ok(self.sheets.worksheet_hyperlinks(name, limit)?)
607
+ })?;
608
+ links
609
+ .into_iter()
610
+ .map(|link| {
611
+ let dict = pyo3::types::PyDict::new(py);
612
+ dict.set_item("ref", &link.cell_ref)?;
613
+ dict.set_item("rel_id", link.rel_id.as_deref())?;
614
+ dict.set_item("target", link.target.as_deref())?;
615
+ dict.set_item("location", link.location.as_deref())?;
616
+ dict.set_item("display", link.display.as_deref())?;
617
+ dict.set_item("external", link.external)?;
618
+ Ok(dict.into_any().unbind())
619
+ })
620
+ .collect()
621
+ }
622
+
623
+ /// Get `(row, col, formula)` for each formula that contains `HYPERLINK` (any case), at most
624
+ /// `limit` of them. The read streams the cells. Empty for xlsb, xls, and ods.
625
+ #[pyo3(name = "get_sheet_hyperlink_formulas", signature = (name, limit = None))]
626
+ fn py_get_sheet_hyperlink_formulas(
627
+ &mut self,
628
+ py: Python<'_>,
629
+ name: &str,
630
+ limit: Option<usize>,
631
+ ) -> PyResult<Vec<(usize, usize, String)>> {
632
+ py.detach(|| {
633
+ let formulas =
634
+ self.sheets
635
+ .xlsx_formulas(name, contains_hyperlink, limit.unwrap_or(usize::MAX))?;
636
+ Ok(formula_triples(formulas.unwrap_or_default()))
637
+ })
638
+ }
639
+
580
640
  /// Get freeze pane info for a sheet as (frozen_rows, frozen_cols).
581
641
  #[pyo3(name = "get_sheet_freeze_panes")]
582
642
  fn py_get_sheet_freeze_panes(&mut self, py: Python<'_>, name: &str) -> PyResult<(u32, u32)> {
@@ -743,11 +803,8 @@ impl CalamineWorkbook {
743
803
 
744
804
  /// Get formulas as (row, col, formula_string) triples.
745
805
  fn get_sheet_formulas(&mut self, name: &str) -> PyResult<Vec<(usize, usize, String)>> {
746
- if let Some(formulas) = self.sheets.xlsx_formulas(name)? {
747
- return Ok(formulas
748
- .into_iter()
749
- .map(|((row, col), f)| (row as usize, col as usize, with_leading_eq(f)))
750
- .collect());
806
+ if let Some(formulas) = self.sheets.xlsx_formulas(name, |_| true, usize::MAX)? {
807
+ return Ok(formula_triples(formulas));
751
808
  }
752
809
  let range = self.sheets.worksheet_formula(name)?;
753
810
  let range_start = range.start().unwrap_or((0, 0));
@@ -1740,6 +1797,22 @@ fn coverage_cut(fill: &HashMap<usize, u64>, max: usize, filled: u64, coverage: f
1740
1797
  max
1741
1798
  }
1742
1799
 
1800
+ /// `(row, col, formula)` for each formula, each with a leading `=`.
1801
+ fn formula_triples(formulas: Formulas) -> Vec<(usize, usize, String)> {
1802
+ formulas
1803
+ .into_iter()
1804
+ .map(|((row, col), f)| (row as usize, col as usize, with_leading_eq(f)))
1805
+ .collect()
1806
+ }
1807
+
1808
+ /// Whether a formula contains `HYPERLINK` in any letter case.
1809
+ fn contains_hyperlink(formula: &str) -> bool {
1810
+ formula
1811
+ .as_bytes()
1812
+ .windows(b"HYPERLINK".len())
1813
+ .any(|w| w.eq_ignore_ascii_case(b"HYPERLINK"))
1814
+ }
1815
+
1743
1816
  /// Calamine formula strings have no leading `=`, but the API expects one.
1744
1817
  fn with_leading_eq(formula: String) -> String {
1745
1818
  if formula.starts_with('=') {
@@ -0,0 +1,30 @@
1
+ """Write tests/data/hyperlink_test.xlsx: links, an internal link, HYPERLINK formulas, and a sheet with no links.
2
+ Run once: python tests/create_hyperlink_fixture.py"""
3
+
4
+ from pathlib import Path
5
+
6
+ import openpyxl
7
+ from openpyxl.worksheet.hyperlink import Hyperlink
8
+
9
+ wb = openpyxl.Workbook()
10
+
11
+ ws = wb.active
12
+ ws.title = "Links"
13
+ ws["A1"] = "Reducto"
14
+ ws["A1"].hyperlink = "https://reducto.ai/"
15
+ ws["A2"] = "Search"
16
+ ws["A2"].hyperlink = "https://example.com/search?q=a&page=2"
17
+ ws["A3"] = "Go to Other"
18
+ ws["A3"].hyperlink = Hyperlink(ref="A3", location="Other!B2", display="Go to Other")
19
+ ws["B1"] = "no link"
20
+ ws["C1"] = '=HYPERLINK("https://example.com/f","F")'
21
+ ws["C2"] = "=SUM(1,2)"
22
+ ws["C3"] = '=hyperlink("https://example.com/g")'
23
+
24
+ other = wb.create_sheet("Other")
25
+ other["B2"] = "target"
26
+ other["A1"] = "=SUM(1,2)"
27
+
28
+ out = Path(__file__).parent / "data" / "hyperlink_test.xlsx"
29
+ wb.save(out)
30
+ print(f"Created {out}")
@@ -1,4 +1,4 @@
1
- """Tests for native image, formula, and data validation extraction via calamine bindings.
1
+ """Tests for native image, formula, data validation, and hyperlink extraction via calamine bindings.
2
2
 
3
3
  Uses pre-built xlsx fixtures in tests/data/ (created with openpyxl).
4
4
  """
@@ -6,7 +6,6 @@ Uses pre-built xlsx fixtures in tests/data/ (created with openpyxl).
6
6
  from pathlib import Path
7
7
 
8
8
  import pytest
9
-
10
9
  from python_calamine import CalamineWorkbook
11
10
 
12
11
  DATA = Path(__file__).parent / "data"
@@ -14,6 +13,7 @@ IMAGE_TEST = DATA / "image_test.xlsx"
14
13
  MULTI_IMAGE_TEST = DATA / "multi_image_test.xlsx"
15
14
  FORMULA_TEST = DATA / "formula_test.xlsx"
16
15
  DROPDOWN_TEST = DATA / "dropdown_test.xlsx"
16
+ HYPERLINK_TEST = DATA / "hyperlink_test.xlsx"
17
17
 
18
18
 
19
19
  class TestGetSheetImages:
@@ -126,3 +126,84 @@ class TestGetSheetDataValidations:
126
126
  wb = CalamineWorkbook.from_path(IMAGE_TEST)
127
127
  dvs = wb.get_sheet_data_validations("Images")
128
128
  assert dvs == []
129
+
130
+
131
+ class TestGetSheetHyperlinks:
132
+ def test_returns_external_and_internal_links(self) -> None:
133
+ """Catches a lost target, an escaped `&amp;` in a URL, or a dropped location-only link."""
134
+ wb = CalamineWorkbook.from_path(HYPERLINK_TEST)
135
+ links = wb.get_sheet_hyperlinks("Links")
136
+
137
+ assert links == [
138
+ {
139
+ "ref": "A1",
140
+ "rel_id": "rId1",
141
+ "target": "https://reducto.ai/",
142
+ "location": None,
143
+ "display": None,
144
+ "external": True,
145
+ },
146
+ {
147
+ "ref": "A2",
148
+ "rel_id": "rId2",
149
+ "target": "https://example.com/search?q=a&page=2",
150
+ "location": None,
151
+ "display": None,
152
+ "external": True,
153
+ },
154
+ {
155
+ "ref": "A3",
156
+ "rel_id": None,
157
+ "target": None,
158
+ "location": "Other!B2",
159
+ "display": "Go to Other",
160
+ "external": False,
161
+ },
162
+ ]
163
+
164
+ def test_limit_stops_the_read(self) -> None:
165
+ """Catches a limit that the reader ignores, which lets a hostile sheet fill the memory."""
166
+ wb = CalamineWorkbook.from_path(HYPERLINK_TEST)
167
+ links = wb.get_sheet_hyperlinks("Links", limit=2)
168
+ assert [link["ref"] for link in links] == ["A1", "A2"]
169
+
170
+ def test_sheet_without_links_returns_empty(self) -> None:
171
+ """Catches links of one sheet that leak into another sheet."""
172
+ wb = CalamineWorkbook.from_path(HYPERLINK_TEST)
173
+ assert wb.get_sheet_hyperlinks("Other") == []
174
+
175
+ @pytest.mark.parametrize(
176
+ "name", ["any_sheets.xlsb", "any_sheets.ods", "any_sheets.xls"]
177
+ )
178
+ def test_non_xlsx_returns_empty(self, name: str) -> None:
179
+ """Catches an error, not an empty list, for formats with no hyperlink reader."""
180
+ wb = CalamineWorkbook.from_path(DATA / name)
181
+ assert wb.get_sheet_hyperlinks(wb.sheet_names[0]) == []
182
+ assert wb.get_sheet_hyperlink_formulas(wb.sheet_names[0]) == []
183
+
184
+
185
+ class TestGetSheetHyperlinkFormulas:
186
+ def test_returns_only_hyperlink_formulas_in_any_case(self) -> None:
187
+ """Catches other formulas that cross into Python, or a lower-case `hyperlink(` that is lost."""
188
+ wb = CalamineWorkbook.from_path(HYPERLINK_TEST)
189
+ assert wb.get_sheet_hyperlink_formulas("Links") == [
190
+ (0, 2, '=HYPERLINK("https://example.com/f","F")'),
191
+ (2, 2, '=hyperlink("https://example.com/g")'),
192
+ ]
193
+ assert wb.get_sheet_hyperlink_formulas("Other") == []
194
+
195
+ def test_limit_stops_the_read(self) -> None:
196
+ """Catches a limit that the reader ignores, which lets a formula-heavy sheet fill the memory."""
197
+ wb = CalamineWorkbook.from_path(HYPERLINK_TEST)
198
+ assert wb.get_sheet_hyperlink_formulas("Links", limit=1) == [
199
+ (0, 2, '=HYPERLINK("https://example.com/f","F")'),
200
+ ]
201
+
202
+ def test_limit_counts_only_hyperlink_formulas(self) -> None:
203
+ """Catches a limit that counts the other formulas, which cuts a link after many plain formulas."""
204
+ wb = CalamineWorkbook.from_path(HYPERLINK_TEST)
205
+ assert wb.get_sheet_hyperlink_formulas("Links", limit=2) == [
206
+ (0, 2, '=HYPERLINK("https://example.com/f","F")'),
207
+ (2, 2, '=hyperlink("https://example.com/g")'),
208
+ ]
209
+ assert wb.get_sheet_hyperlink_formulas("Links", limit=0) == []