Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
95 commits
Select commit Hold shift + click to select a range
46cd740
that condition shouldn't work
peterlavalle-nhs Sep 16, 2026
c96fa69
renamed the exception to be clearer
peterlavalle-nhs Sep 16, 2026
43bcfcb
[WIP] switching the old tests to use the new method
peterlavalle-nhs Sep 16, 2026
4863491
use parse instead of load to classify cell as date or not
peterlavalle-nhs Sep 17, 2026
6b40159
updated the older tests
peterlavalle-nhs Sep 17, 2026
3f04273
out with the old and updated the readme
peterlavalle-nhs Sep 17, 2026
69c6b65
renamed the parameter for clarity
peterlavalle-nhs Sep 28, 2026
ba6742d
always check sheets we're checking (for hidden dates et al)
peterlavalle-nhs Sep 28, 2026
ff2f4a2
detects the problem
peterlavalle-nhs Sep 28, 2026
1789a14
more detail for the error message
peterlavalle-nhs Sep 28, 2026
920b301
validates what it should
peterlavalle-nhs Sep 28, 2026
0547569
fixes ignores to skip temp xlsx files
peterlavalle-nhs Sep 28, 2026
34469d5
passes all tests again
peterlavalle-nhs Sep 28, 2026
45ab14d
checks for padded cell values
peterlavalle-nhs Sep 29, 2026
5250972
updates it to handle ignored values with spaces around them
peterlavalle-nhs Sep 29, 2026
d727465
Update log message and comments for clarity
g-pechorin Sep 29, 2026
592c130
might do the dates
peterlavalle-nhs Sep 29, 2026
32bc10c
Merge remote-tracking branch 'origin/shift_ignore' into shift_ignore
peterlavalle-nhs Sep 29, 2026
4b4479a
more corrections
peterlavalle-nhs Sep 29, 2026
19ff976
raises a specific error for missing patient columns
peterlavalle-nhs Oct 1, 2026
af53890
(WIP) need to digest/adapt the patient_id loading
peterlavalle-nhs Sep 30, 2026
3fa2186
updates the rest of the date shift
peterlavalle-nhs Oct 1, 2026
38ece50
improved function and comments
peterlavalle-nhs Oct 1, 2026
7247526
uses the new exception
peterlavalle-nhs Oct 1, 2026
db26a0c
changes to allow pip to access book_page
peterlavalle-nhs Oct 2, 2026
65de773
small logging messages
peterlavalle-nhs Oct 2, 2026
f470909
Merge remote-tracking branch 'origin/book_page' into book_page
peterlavalle-nhs Oct 2, 2026
77245b8
more logging
peterlavalle-nhs Oct 2, 2026
9e98b1e
log during id generation
peterlavalle-nhs Oct 2, 2026
dc1b873
remove the pd call
peterlavalle-nhs Oct 2, 2026
651c457
restored it - it wasn't the problem
peterlavalle-nhs Oct 2, 2026
57e1bd2
ignore the coverage database
peterlavalle-nhs Oct 5, 2026
117a386
removes duplicate parameters from _inplace
peterlavalle-nhs Oct 5, 2026
3fc3d9d
update docs (oops)
peterlavalle-nhs Oct 5, 2026
68309d8
removes duplicate parameters from _inplace
peterlavalle-nhs Oct 5, 2026
700a568
update docs (oops)
peterlavalle-nhs Oct 5, 2026
f3dcf66
Merge branch 'shift_ignore' into shift_ignore_yaml
peterlavalle-nhs Oct 5, 2026
83c87ef
shift ignore reading from YAML
peterlavalle-nhs Oct 5, 2026
5091e8e
detects old (bad) configs
peterlavalle-nhs Oct 5, 2026
9441cf4
don't count tests/ when checking coverage
peterlavalle-nhs Oct 5, 2026
be338b1
debug logging for the ignores
peterlavalle-nhs Oct 5, 2026
4215f13
tests all pass with the new yaml file approach
peterlavalle-nhs Oct 5, 2026
6e70a83
Merge branch 'shift_ignore' into book_page
peterlavalle-nhs Oct 5, 2026
de2be87
just-enough to see if it helps
peterlavalle-nhs Oct 5, 2026
fd76848
stacked out-with-the-old onto the book stream
peterlavalle-nhs Oct 8, 2026
1451435
test/uv updates
peterlavalle-nhs Oct 8, 2026
184230e
Merge branch 'book_page' into book_stream
peterlavalle-nhs Oct 8, 2026
77b5476
pointed to the live copy
peterlavalle-nhs Oct 8, 2026
cdc970d
WIP 93/111 tests pass
peterlavalle-nhs Oct 8, 2026
817acb3
implements "row is blank" test and skipping
peterlavalle-nhs Oct 9, 2026
e15626d
"cheats" and switches the ambiguous date to iso8061
peterlavalle-nhs Oct 9, 2026
9266d43
corrected overlap detection and testing
peterlavalle-nhs Oct 9, 2026
8e04437
fixes missing patient id column detection
peterlavalle-nhs Oct 9, 2026
1206f2d
added comments and checks for "skip holes"
peterlavalle-nhs Oct 12, 2026
357067c
Merge remote-tracking branch 'origin/main' into book_page
peterlavalle-nhs Oct 12, 2026
7801176
corrected the result of a rename
peterlavalle-nhs Oct 12, 2026
bc74743
fix again
peterlavalle-nhs Oct 12, 2026
bad242f
Merge branch 'book_page' into refactor.out-with-old
peterlavalle-nhs Oct 12, 2026
c3baf4f
updated many of the unit tests
peterlavalle-nhs Oct 12, 2026
a17e990
allow header default to 0 in patient page, and, allow date values in …
peterlavalle-nhs Oct 12, 2026
17de1f4
refactored the existing functions (as is) into separate files
peterlavalle-nhs Oct 12, 2026
4a5e407
brings book_page (and tests) into the project
peterlavalle-nhs Oct 12, 2026
8dd5540
raise different exception
peterlavalle-nhs Oct 12, 2026
cde02e7
refactors the shift_delta generation to be simpler and move checking …
peterlavalle-nhs Oct 12, 2026
7266555
try to separate the sheet/row checks to be a standalone thing
peterlavalle-nhs Oct 12, 2026
bca28c5
refactored the loop control and parameter lookups into a scanner class
peterlavalle-nhs Oct 12, 2026
1fc05bf
extracts the ignore rules to a subroutine
peterlavalle-nhs Oct 12, 2026
7f75ba7
idea for how to fixit
peterlavalle-nhs Oct 12, 2026
0f06412
more cleanup
peterlavalle-nhs Oct 13, 2026
93da71d
comments and removed a redundant check
peterlavalle-nhs Oct 13, 2026
15171ae
all old tests pass
peterlavalle-nhs Oct 13, 2026
9dd894e
Merge branch 'book_page' into refactor.out-with-old
peterlavalle-nhs Oct 13, 2026
ae4100e
don't need that
peterlavalle-nhs Oct 13, 2026
a45f7d6
out with the old
peterlavalle-nhs Oct 13, 2026
f191501
remove unused log
peterlavalle-nhs Oct 13, 2026
e36a4fd
removed more unused functions and tests for what was left
peterlavalle-nhs Oct 13, 2026
fe70510
"orchestration" changes
peterlavalle-nhs Oct 14, 2026
e15b3c5
csv and excel workbooks now both try to parse dates to be consistent …
peterlavalle-nhs Oct 14, 2026
65c4a53
Merge branch 'book_page' into refactor.out-with-old
peterlavalle-nhs Oct 15, 2026
fc8a0b8
(WIP) updates the test, but, it doesn't seem to ignore noted dates in…
peterlavalle-nhs Oct 15, 2026
e195a13
more consistent xlsx reading
peterlavalle-nhs Oct 15, 2026
54d8c3e
adds the hidden-date bypass to book_page
peterlavalle-nhs Oct 16, 2026
09e2e8a
always allow "NULL" in date columns
peterlavalle-nhs Oct 16, 2026
a4eb73b
option to skip dating date
peterlavalle-nhs Oct 16, 2026
c7d8776
add "skip" to sheet/page types
peterlavalle-nhs Oct 16, 2026
c17e023
cut out normalise in a different way
peterlavalle-nhs Oct 16, 2026
a17deb2
renamed and documented the bypasses
peterlavalle-nhs Oct 16, 2026
c623097
raise exceptions on abstract invocation
peterlavalle-nhs Oct 16, 2026
24bd638
Potential fix for pull request finding 'CodeQL / Clear-text logging o…
g-pechorin Oct 16, 2026
780682a
renamed self to self
peterlavalle-nhs Oct 16, 2026
10c96df
Merge remote-tracking branch 'origin/book_page' into book_page
peterlavalle-nhs Oct 16, 2026
85a5486
Merge branch 'book_page' into refactor.out-with-old
peterlavalle-nhs Oct 16, 2026
58f7c1e
restores the ... in abstract methods
peterlavalle-nhs Oct 16, 2026
89b59a2
removed the redundant context manager
peterlavalle-nhs Oct 16, 2026
4902c9e
Merge remote-tracking branch 'origin/main' into refactor.out-with-old
peterlavalle-nhs Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 65 additions & 43 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,64 +19,64 @@ Helper library for enabling data studies: utilities for study enablement such as
### Date shifting (basic example)

```python
from nuh_helper import shift_excel_dates
# or: from nuh_helper.date_shift import shift_excel_dates
from nuh_helper import shift_excel_dates_inplace


source_file = Path(__file__).parent / (
"data/structural.with-blank.xlsx" if good else "data/structural.bad-blank.xlsx"
)
output_path = tmp_path / "target.xlsx"
linking_table_old = tmp_path / "linking_table_old.csv"
linking_table_out = tmp_path / "linking_table_out.csv"

# Configure which sheets and columns to shift
sheet_configs = {
"patients": {
"patient_id_col": "patient_id",
"date_columns": ["dob", "date_of_diagnosis"],
"header_row": 1, # Optional: zero-based row index for column names
},
"labs": {
"patient_id_col": "patient_id",
"date_columns": ["test_date"],
"header_row": 1,

# shift the dates in the "paige" sheet
"paige": {
# patient IDs are in the "ptid" column
"patient_id_col": "ptid",
"date_columns": [
# shift the dates in the DOB column
"dob",
],
"text_columns": [
# the food column has no dates
"food",
],
"header_row": 0, # Optional: zero-based row index for column names. defaults to 0
"skip_rows_after_header": [],
},

# ignore the "stuff" page
"stuff": "skip",
}


# Shift dates in the Excel file
shift_excel_dates(
shift_excel_dates_inplace(
# input / output files
input_file="input.xlsx",
output_file="output.xlsx",
patient_sheet="patients",
patient_id_col="patient_id",

# columns used to compute a list of patients
patient_sheet="paige",
patient_id_col="ptid",


sheet_configs=sheet_configs,
min_shift_days=-15, # Lower range
max_shift_days=15, # Upper range
seed=42, # For reproducibility
date_format="YYYY-MM-DD",
)
```

### Excluding fixed study dates with `shift_exceptions`

Some columns contain a mix of patient-specific dates (which should be shifted) and fixed study-wide dates (e.g. an end-of-study date) that must remain unchanged. Use `shift_exceptions` in `sheet_configs` to list any date values that should never be shifted:

```python
sheet_configs = {
"patients": {
"patient_id_col": "patient_id",
"date_columns": ["last_alive"],
"shift_exceptions": {
"last_alive": ["2024-12-31"], # end-of-study date — never shift
},
},
}

shift_excel_dates(
input_file="input.xlsx",
output_file="output.xlsx",
patient_sheet="patients",
patient_id_col="patient_id",
sheet_configs=sheet_configs,
seed=42,
# optional - csv files saving how many days each patient was shifted
# ... do not distribute these
# ... and probably best to keep them somewhere
linking_table_path="linking_table_old.csv",
linking_table_output="linking_table_new.csv",
)
```

The exception date strings are parsed with the same flexible parser used for all date values (supports multiple formats and placeholder strings). Exceptions are matched against the parsed date, so `"2024-12-31"` and `"31-12-2024"` both match the same calendar date.

### Reproducible Shifts with Linking Table

To use the same shifts across multiple runs, save and reuse a linking table:
Expand Down Expand Up @@ -118,8 +118,30 @@ shift_excel_dates_inplace(
output_file="output.xlsx",
patient_sheet="patients",
sheet_configs=sheet_configs,
seed=42,
linking_table_output="shift_mappings.csv",
min_shift_days=-15, # Lower range
max_shift_days=15, # Upper range
seed=42, # For reproducibility

# optional - csv files saving how many days each patient was shifted
# ... do not distribute these
# ... and probably best to keep them somewhere specific
linking_table_output="linking_table_first.csv",
)

# Second run: reuse the same shifts
shift_excel_dates_inplace(
input_file="input_final.xlsx",
output_file="output_second.xlsx",
patient_sheet="paige",
patient_id_col="ptid",
sheet_configs=sheet_configs,
min_shift_days=-15, # Lower range
max_shift_days=15, # Upper range
seed=42, # For reproducibility

# write to a new file this time
linking_table_path="linking_table_first.csv",
linking_table_output="linking_table_second.csv",
)
```

Expand Down
12 changes: 2 additions & 10 deletions nuh_helper/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,11 @@
logging.getLogger(__name__).addHandler(logging.NullHandler())

from nuh_helper.date_shift import ( # noqa: E402
apply_date_shifts,
generate_shift_mappings,
load_shift_mappings,
shift_excel_dates,
shift_excel_dates_inplace,
)
from nuh_helper.profile import generate_scan_report # noqa: E402

__all__ = [
"shift_excel_dates",
"shift_excel_dates_inplace",
"apply_date_shifts",
"generate_shift_mappings",
"load_shift_mappings",
"generate_scan_report",
generate_scan_report,
shift_excel_dates_inplace,
]
3 changes: 2 additions & 1 deletion nuh_helper/book_page/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from nuh_helper.book_page.book import BasePage, BaseWorkBook, normalize_row
from nuh_helper.book_page.book import BasePage, BaseWorkBook, normalize_row, row_val_t
from nuh_helper.book_page.csv_book import CSVWorkBook
from nuh_helper.book_page.generic import book_open
from nuh_helper.book_page.xlsx_book import XLSXWorkBook
Expand All @@ -10,4 +10,5 @@
BasePage,
book_open,
normalize_row,
row_val_t,
]
22 changes: 11 additions & 11 deletions nuh_helper/book_page/book.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from abc import ABC, abstractmethod
from collections.abc import Generator as gen
from collections.abc import Iterable as iterator
from collections.abc import Iterable as iterable
from datetime import date, datetime
from functools import cached_property
from pathlib import Path
Expand All @@ -17,7 +17,7 @@ def __init__(self, name: str, page: object) -> None:
@abstractmethod
def _stream_rows_impl(self) -> gen[tuple[int, row_val_t]]:
"""performs the workbook specific yield/write of the row data"""
raise NotImplementedError()
...
Comment thread
g-pechorin marked this conversation as resolved.
Dismissed

def stream_rows(self) -> gen[tuple[int, row_val_t]]:
"""public method for reading a RO stream of rows from a specific page"""
Expand All @@ -41,17 +41,15 @@ def __contains__(self, name: str) -> bool:
return name in self.keys()

@abstractmethod
def __getitem__(self, name: str) -> gen[BasePage]:
raise NotImplementedError()
def __getitem__(self, name: str) -> BasePage: ...
Comment thread
g-pechorin marked this conversation as resolved.
Dismissed

@abstractmethod
def keys(self) -> iterator[str]:
raise NotImplementedError()
def keys(self) -> iterable[str]: ...
Comment thread
g-pechorin marked this conversation as resolved.
Dismissed

@abstractmethod
def _stream_copy_impl(self, into: Path) -> gen[tuple[str, int, row_val_t]]:
"""performs the workbook specific yield/write of the row data"""
raise NotImplementedError()
...
Comment thread
g-pechorin marked this conversation as resolved.
Dismissed

def stream_copy(
self,
Expand All @@ -71,6 +69,9 @@ def stream_copy(
date_format = date_format if date_format else "%Y-%m-%d"

for page_name, row_idx, row_data in self._stream_copy_impl(into):
if BaseWorkBook.normalize_values:
for raw_idx, raw_val in enumerate(row_data):
row_data[raw_idx] = normalize_val(raw_val)
yield page_name, row_idx, row_data
if BaseWorkBook.normalize_values:
for raw_idx, raw_val in enumerate(row_data):
Expand All @@ -81,11 +82,10 @@ def stream_copy(

def __iter__(self) -> gen[BasePage]:
for name in self.keys():
with self[name] as page:
yield page
yield self[name]


def normalize_val(src: object) -> object:
def normalize_val(src: object) -> cell_val_t:
"""parse to a datetime, .strip(), turn "" into None, retain bool"""

if (
Expand Down Expand Up @@ -131,7 +131,7 @@ def normalize_val(src: object) -> object:
return src


def normalize_row(row: list[str]) -> list[str]:
def normalize_row(row: iterable[str]) -> row_val_t:
"""modifies (and returns) the passed array to drop extra whitespace and replace
fully blank entries with None. doesn't touch non-strings"""

Expand Down
7 changes: 3 additions & 4 deletions nuh_helper/book_page/csv_book.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import contextlib
import csv
from _collections_abc import dict_keys
from collections.abc import Generator as gen
Expand All @@ -16,8 +15,7 @@ def __init__(self, path: str | Path | list[Path]) -> None:
def keys(self) -> dict_keys:
return self._page_files.keys()

@contextlib.contextmanager
def __getitem__(self, name: str) -> gen[BasePage]:
def __getitem__(self, name: str) -> BasePage:

class CSVPage(BasePage):
def _stream_rows_impl(self) -> gen[tuple[int, row_val_t]]:
Expand All @@ -32,6 +30,7 @@ def _stream_rows_impl(self) -> gen[tuple[int, row_val_t]]:
# strip bom
if (
row == 0
and line
and isinstance(line[0], str)
and line[0][:1] == "\ufeff"
):
Expand All @@ -40,7 +39,7 @@ def _stream_rows_impl(self) -> gen[tuple[int, row_val_t]]:
yield (row, line)

assert isinstance(self._page_files[name], Path)
yield CSVPage(name, self._page_files[name])
return CSVPage(name, self._page_files[name])

def _stream_copy_impl(self, into: Path) -> gen[tuple[str, int, row_val_t]]:

Expand Down
19 changes: 9 additions & 10 deletions nuh_helper/book_page/xlsx_book.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import contextlib
import shutil
from collections.abc import Generator as gen
from pathlib import Path

import openpyxl
from openpyxl.cell.read_only import EmptyCell

from .book import BasePage, BaseWorkBook
from .book import BasePage, BaseWorkBook, row_val_t


class XLSXWorkBook(BaseWorkBook):
Expand All @@ -15,12 +15,12 @@ def __init__(self, file: str | Path, read_only: bool = True) -> None:
if not file.is_file():
raise RuntimeError(f"xlsx file not found {file}")
self._file = file
self._book = openpyxl.load_workbook(file, read_only=read_only)
self._book = openpyxl.load_workbook(file, read_only=read_only, data_only=True)

def keys(self) -> list[str]:
return [page.title for page in self._book.worksheets]

def _stream_copy_impl(self, into: str | Path) -> gen[tuple[str, int, list[str]]]:
def _stream_copy_impl(self, into: str | Path) -> gen[tuple[str, int, row_val_t]]:
if not isinstance(into, Path):
into = Path(into)

Expand All @@ -41,21 +41,20 @@ def _stream_copy_impl(self, into: str | Path) -> gen[tuple[str, int, list[str]]]
page.cell(r + 1, c + 1).value = val
the_copy.save(into)

@contextlib.contextmanager
def __getitem__(self, name: str) -> gen[BasePage]:

if len(name) > 31:
raise KeyError("excel limits you to 31 character page names")

class XLSXPage(BasePage):
def _stream_rows_impl(self) -> gen[tuple[int, list[str]]]:
for row in range(self._page.max_row):
for row_idx, row_data in enumerate(self._page.iter_rows()):
yield (
row,
row_idx,
[
self._page.cell(row + 1, col + 1).value
for col in range(self._page.max_column)
None if isinstance(cell, EmptyCell) else cell.internal_value
for cell in row_data
],
)

yield XLSXPage(name, self._book[name])
return XLSXPage(name, self._book[name])
Loading
Loading