Skip to content

feat(submitr): add opt-in ProtectedDonor workbook transformation - #341

Closed
aschroed wants to merge 11 commits into
masterfrom
fm/utils-protected-donor-review-fixes
Closed

aschroed wants to merge 11 commits into
masterfrom
fm/utils-protected-donor-review-fixes

Conversation

@aschroed

@aschroed aschroed commented Oct 2, 2026

Copy link
Copy Markdown
Member

Intent

Implement the approved fixes for the six review concerns on #339: preserve copied fields when an existing ProtectedDonor sheet lacks destination headers; keep generated rows visible by matching the reader's logical empty-row termination; accept supported UUID/accession identifiers by resolving identity and type rather than using submitted-ID spelling; scan references only within the same logical rows used for ingestion; distinguish permission, outage, and other lookup failures from definitive ProtectedDonor absence; and prevent default CustomExcel counting-pass saves from creating an output that breaks the parsing pass. Add regression tests for each behavior and validate the changes.

What Changed

  • Added dcicutils/submitr/donor_transformer.py with analyze_protected_donors and ProtectedDonorWorkbookTransformer. They convert plain Donor references in the Demographic, DeathCircumstances, FamilyHistory, MedicalHistory and TissueCollection sheets to ProtectedDonor, and add the matching ProtectedDonor rows. Only the logical rows the reader ingests (those before the first empty row) are scanned and appended to. If an existing ProtectedDonor sheet lacks the destination headers for copied fields, the transformer adds those headers so the fields are kept. UUID and accession identifiers are resolved through the portal by identity and type. A typed ProtectedDonorLookupError and per-reference kinds keep permission-denied (401/403) and other lookup failures, such as outages, separate from a definitive absence (404/410).
  • CustomExcel has new opt-in options: transform_protected_donor, transformed_workbook_path and allow_existing_staging_path, and CustomExcel.with_portal(portal, **options) now passes them through. Transformed workbooks are saved atomically through a temporary file. A save never overwrites the input workbook, and it only overwrites an existing path when the caller marks it as its own staging path.
  • StructuredDataSet._load_excel_file now opens the workbook once and uses it for both the progress-counting pass and the parsing pass, so a CustomExcel save on construction cannot break parsing. Multi-schema JSON files now follow the specified schema order. The branch also removes unused nonlocal/global declarations flagged by pyflakes F824, bumps the version to 8.19.1, adds a changelog entry and docs, and adds regression tests in test/test_protected_donor_transform.py and test/test_structured_data.py.

🤖 Generated with Claude Code

Risk Assessment

✅ Low: The fix round is small and bounded. The Donor-sheet safety check now runs in the validation phase before any mutation, and it uses the same logical rows and header width as the later column insertion. The protected_donor column is now added only to sheets with transformed rows. Numeric status patterns are checked before name patterns. The regression tests assert observable workbook and classification behavior, and they would fail against the pre-fix code.

Testing

I ran the focused ProtectedDonor and progress-bar tests (all pass). I confirmed the new regression tests fail on the pre-fix source (22 failures across all six concerns). I also ran an end-to-end StructuredDataSet/CustomExcel parse of a real workbook with progress turned on. In that run, the existing ProtectedDonor sheet gained the external_id/status headers. The generated rows stayed visible past the formatted empty rows. The reference after the blank-row terminator was ignored. The counting pass did not break the parse, and the staged output was written. UUID references resolved to ProtectedDonor through their type. 403, 503 and connection errors were reported as ProtectedDonorLookupError ("not known to be absent"), separately from a real 404 absence.

Evidence: End-to-end demo output (StructuredDataSet/CustomExcel parse + lookup failure classification)

ProtectedDonor rows parsed: [{'submitted_id': 'A_PROTECTED-DONOR_OLD'}, {'submitted_id': 'A_PROTECTED-DONOR_1', 'external_id': 'ext-1', 'status': 'in review'}] Demographic rows parsed: [{'donor': 'A_PROTECTED-DONOR_1'}] progress events seen (counting pass ran): True ; staged output exists: True Staged ProtectedDonor header: ['submitted_id', 'external_id', 'status'] UUID ref transform ok: True ; Demographic donors: ['11111111-...-555555555555', 'A_PROTECTED-DONOR_1'] [403] PERMISSION_DENIED -> ProtectedDonorLookupError (not known to be absent) [503] LOOKUP_FAILED -> ProtectedDonorLookupError [conn] LOOKUP_FAILED -> ProtectedDonorLookupError [404] MISSING -> ProtectedDonorTransformError: absent from the workbook and portal

=== 1+2+4: existing ProtectedDonor sheet lacking headers, formatted empty rows, ref after terminator ===
ProtectedDonor rows parsed: [{'submitted_id': 'A_PROTECTED-DONOR_OLD'}, {'submitted_id': 'A_PROTECTED-DONOR_1', 'external_id': 'ext-1', 'status': 'in review'}]
Demographic rows parsed: [{'donor': 'A_PROTECTED-DONOR_1'}]
progress events seen (counting pass ran): True ; staged output exists: True
Staged ProtectedDonor header: ['submitted_id', 'external_id', 'status']

=== 3: UUID reference typed as ProtectedDonor via portal ===
transform ok: True ; Demographic donors: ['11111111-2222-3333-4444-555555555555', 'A_PROTECTED-DONOR_1']

=== 5: lookup failure kinds ===
[403] kind=DonorReferenceKind.PERMISSION_DENIED -> ProtectedDonorLookupError: Cannot verify ProtectedDonor references (not known to be absent): permission denied looking up ProtectedDonor reference(s) 'A_PROTECTED-DONOR_P' [Exception: Bad status code for GET request for https://p/x: 403. Reason: Forbidden]
[503] kind=DonorReferenceKind.LOOKUP_FAILED -> ProtectedDonorLookupError: Cannot verify ProtectedDonor references (not known to be absent): lookup failed for ProtectedDonor reference(s) 'A_PROTECTED-DONOR_P' [Exception: Bad status code for GET request for https://p/x: 503. Reason: Unavailable]
[conn] kind=DonorReferenceKind.LOOKUP_FAILED -> ProtectedDonorLookupError: Cannot verify ProtectedDonor references (not known to be absent): lookup failed for ProtectedDonor reference(s) 'A_PROTECTED-DONOR_P' [ConnectionError: connection refused]
[404] kind=DonorReferenceKind.MISSING -> ProtectedDonorTransformError: ProtectedDonor reference(s) are absent from the workbook and portal: 'A_PROTECTED-DONOR_P'
Evidence: End-to-end demo script
import os, sys, openpyxl
from openpyxl.styles import PatternFill
from dcicutils.structured_data import StructuredDataSet
from dcicutils.submitr.custom_excel import CustomExcel
from dcicutils.submitr.donor_transformer import (ProtectedDonorWorkbookTransformer, ProtectedDonorLookupError,
                                                 ProtectedDonorTransformError)
E = sys.argv[1]
UUID = "11111111-2222-3333-4444-555555555555"

def wb(sheets):
    w = openpyxl.Workbook(); w.remove(w.active)
    for n, rows in sheets.items():
        s = w.create_sheet(n)
        for r in rows: s.append(r)
    return w

print("=== 1+2+4: existing ProtectedDonor sheet lacking headers, formatted empty rows, ref after terminator ===")
w = wb({"Donor": [["submitted_id", "external_id", "status"], ["A_DONOR_1", "ext-1", "released"]],
        "ProtectedDonor": [["submitted_id"], ["A_PROTECTED-DONOR_OLD"]],
        "Demographic": [["donor"], ["A_DONOR_1"], [None], ["A_DONOR_GHOST_AFTER_TERMINATOR"]]})
fill = PatternFill("solid", fgColor="FFFF00")
for r in range(3, 9): w["ProtectedDonor"].cell(r, 1).fill = fill
src = os.path.join(E, "e2e_input.xlsx"); w.save(src)
staged = os.path.join(E, "e2e_staged.xlsx")
if os.path.exists(staged): os.remove(staged)
progress = []
cls = CustomExcel.with_portal(None, transform_protected_donor=True, transformed_workbook_path=staged)
data = StructuredDataSet(file=src, portal=None, norefs=True, excel_class=cls, progress=progress.append).data
print("ProtectedDonor rows parsed:", data["ProtectedDonor"])
print("Demographic rows parsed:", data["Demographic"])
print("progress events seen (counting pass ran):", len(progress) > 0, "; staged output exists:", os.path.exists(staged))
s = openpyxl.load_workbook(staged)["ProtectedDonor"]
print("Staged ProtectedDonor header:", [c.value for c in s[1]])

print("\n=== 3: UUID reference typed as ProtectedDonor via portal ===")
class Portal:
    def __init__(self, items=None, error=None): self.items, self.error = items or {}, error
    def get_metadata(self, path, *a, **k):
        if self.error: raise self.error
        key = path.split("/")[-1]
        if key in self.items: return self.items[key]
        raise Exception(f"Bad status code for GET request for https://p{path}: 404. Reason: Not Found")
    def get(self, path, *a, **k): return self.get_metadata(path)
w = wb({"Donor": [["submitted_id"], ["A_DONOR_1"]], "Demographic": [["donor"], [UUID], ["A_DONOR_1"]]})
ok = ProtectedDonorWorkbookTransformer(portal=Portal({UUID: {"uuid": UUID, "@type": ["ProtectedDonor", "Item"]}})).transform(w)
print("transform ok:", ok, "; Demographic donors:", [r[0].value for r in w["Demographic"].iter_rows(min_row=2)])

print("\n=== 5: lookup failure kinds ===")
for label, err in [("403", Exception("Bad status code for GET request for https://p/x: 403. Reason: Forbidden")),
                   ("503", Exception("Bad status code for GET request for https://p/x: 503. Reason: Unavailable")),
                   ("conn", ConnectionError("connection refused")), ("404", None)]:
    w = wb({"Donor": [["submitted_id"], ["A_DONOR_1"]], "Demographic": [["donor"], ["A_PROTECTED-DONOR_P"]]})
    t = ProtectedDonorWorkbookTransformer(portal=Portal(error=err))
    print(f"[{label}] kind={t.analyze(w).references['A_PROTECTED-DONOR_P']}", end=" ")
    try: t.transform(w)
    except ProtectedDonorTransformError as e: print(f"-> {type(e).__name__}: {e}")
- Evidence: Demo input workbook and staged transformed output (local file: /var/folders/t8/8mk98wrj1j34nc5hhqxqdqq80000gr/T/no-mistakes-evidence/01M3YQEBAG4Z3V8EZ202WH4VA2/e2e_staged.xlsx)
Evidence: Regression tests against pre-fix code (22 fail)
                   "{'@type': ['HTTPNotFound', 'Error'], 'detail': 'sub-lookup failed'}"),
                   "{'@type': ['HTTPNotFound', 'Error'], 'detail': 'sub-lookup failed'}"),
                   "{'@type': ['HTTPNotFound', 'Error'], 'detail': 'sub-lookup failed'}"),
                   "{'@type': ['HTTPNotFound', 'Error'], 'detail': 'sub-lookup failed'}"),
error = Exception("Bad status code for GET request for https://p/x: 500. Reason: Internal Server Error - {'@type': ['HTTPNotFound', 'Error'], 'detail': 'sub-lookup failed'}")
                   "{'@type': ['HTTPNotFound', 'Error'], 'detail': 'sub-lookup failed'}"),
                   "{'@type': ['HTTPNotFound', 'Error'], 'detail': 'sub-lookup failed'}"),
                   "{'@type': ['HTTPNotFound', 'Error'], 'detail': 'sub-lookup failed'}"),
FAILED test/test_protected_donor_transform.py::test_existing_protected_sheet_gains_headers_for_copied_fields
FAILED test/test_protected_donor_transform.py::test_existing_protected_sheet_with_unsafe_extra_columns_is_rejected_unchanged
FAILED test/test_protected_donor_transform.py::test_donor_sheet_with_content_beyond_headers_is_rejected_unchanged[donor_rows0]
FAILED test/test_protected_donor_transform.py::test_donor_sheet_with_content_beyond_headers_is_rejected_unchanged[donor_rows1]
FAILED test/test_protected_donor_transform.py::test_donor_sheet_without_transformed_rows_is_not_given_a_protected_donor_column
FAILED test/test_protected_donor_transform.py::test_generated_rows_are_appended_before_formatted_empty_rows_and_stay_visible
FAILED test/test_protected_donor_transform.py::test_content_after_the_empty_row_terminator_blocks_append_and_leaves_workbook_unchanged
FAILED test/test_protected_donor_transform.py::test_references_after_the_empty_row_terminator_are_not_scanned
FAILED test/test_protected_donor_transform.py::test_uuid_and_accession_references_to_existing_protected_donors_are_accepted[11111111-2222-3333-4444-555555555555]
FAILED test/test_protected_donor_transform.py::test_uuid_and_accession_references_to_existing_protected_donors_are_accepted[SMAPD1234567]
FAILED test/test_protected_donor_transform.py::test_workbook_protected_donor_uuid_and_accession_are_resolved_without_the_portal
FAILED test/test_protected_donor_transform.py::test_lookup_failures_are_distinct_from_absence[error0-MISSING_PERMISSION_DENIED]
FAILED test/test_protected_donor_transform.py::test_lookup_failures_are_distinct_from_absence[error1-MISSING_PERMISSION_DENIED]
FAILED test/test_protected_donor_transform.py::test_lookup_failures_are_distinct_from_absence[error2-MISSING_PERMISSION_DENIED]
FAILED test/test_protected_donor_transform.py::test_lookup_failures_are_distinct_from_absence[error3-MISSING_LOOKUP_FAILED]
FAILED test/test_protected_donor_transform.py::test_lookup_failures_are_distinct_from_absence[error4-MISSING_LOOKUP_FAILED]
FAILED test/test_protected_donor_transform.py::test_lookup_failures_are_distinct_from_absence[error5-MISSING_LOOKUP_FAILED]
FAILED test/test_protected_donor_transform.py::test_lookup_failures_are_distinct_from_absence[error6-MISSING_LOOKUP_FAILED]
FAILED test/test_protected_donor_transform.py::test_definitive_not_found_is_reported_as_absent[portal2]
FAILED test/test_protected_donor_transform.py::test_error_result_with_forbidden_type_is_a_permission_failure
FAILED test/test_protected_donor_transform.py::test_lookup_identifier_is_path_quoted
FAILED test/test_protected_donor_transform.py::test_progress_loading_does_not_save_during_the_counting_pass
22 failed, 18 passed in 9.92s
Evidence: Focused pytest run on the change (42 passed)
('Configuration file exists at /Users/andrew2/Library/Application Support/pypoetry, reusing this directory.\n\nConsider moving TOML configuration files to /Users/andrew2/Library/Preferences/pypoetry, as support for the legacy directory will be removed in an upcoming release.',)
============================= test session starts ==============================
platform darwin -- Python 3.12.12, pytest-7.4.4, pluggy-1.6.0 -- /Users/andrew2/.pyenv/versions/3.12.12/bin/python3.12
rootdir: /Users/andrew2/.no-mistakes/worktrees/f6d4ab33d8c8/01M3YQEBAG4Z3V8EZ202WH4VA2
configfile: pytest.ini
plugins: mock-3.15.1, instafail-0.5.0, cov-7.1.0, flaky-3.8.1, timeout-2.4.0, xdist-3.5.0
collecting ... collected 42 items

test/test_protected_donor_transform.py::test_analysis_uses_only_actual_rows_and_the_five_protected_sheets PASSED [  2%]
test/test_protected_donor_transform.py::test_transform_is_selective_and_supports_mixed_references PASSED [  4%]
test/test_protected_donor_transform.py::test_invalid_donor_reference_is_a_clear_transform_error PASSED [  7%]
test/test_protected_donor_transform.py::test_missing_plain_donor_is_a_clear_transform_error PASSED [  9%]
test/test_protected_donor_transform.py::test_portal_only_protected_donor_is_accepted PASSED [ 11%]
test/test_protected_donor_transform.py::test_existing_protected_sheet_is_extended_incrementally PASSED [ 14%]
test/test_protected_donor_transform.py::test_custom_excel_opt_in_integrates_transform_before_structured_data PASSED [ 16%]
test/test_protected_donor_transform.py::test_custom_excel_transform_preserves_custom_column_mapping PASSED [ 19%]
test/test_protected_donor_transform.py::test_save_transformed_workbook_protects_existing_target PASSED [ 21%]
test/test_protected_donor_transform.py::test_save_transformed_workbook_allows_owned_staging_path PASSED [ 23%]
test/test_protected_donor_transform.py::test_save_transformed_workbook_repeated_save_requires_explicit_overwrite PASSED [ 26%]
test/test_protected_donor_transform.py::test_failed_save_does_not_damage_existing_target PASSED [ 28%]
test/test_protected_donor_transform.py::test_hidden_and_unlisted_sheets_are_not_transformed PASSED [ 30%]
test/test_protected_donor_transform.py::test_existing_protected_sheet_gains_headers_for_copied_fields PASSED [ 33%]
test/test_protected_donor_transform.py::test_existing_protected_sheet_with_unsafe_extra_columns_is_rejected_unchanged PASSED [ 35%]
test/test_protected_donor_transform.py::test_donor_sheet_with_content_beyond_headers_is_rejected_unchanged[donor_rows0] PASSED [ 38%]
test/test_protected_donor_transform.py::test_donor_sheet_with_content_beyond_headers_is_rejected_unchanged[donor_rows1] PASSED [ 40%]
test/test_protected_donor_transform.py::test_donor_sheet_without_transformed_rows_is_not_given_a_protected_donor_column PASSED [ 42%]
test/test_protected_donor_transform.py::test_generated_rows_are_appended_before_formatted_empty_rows_and_stay_visible PASSED [ 45%]
test/test_protected_donor_transform.py::test_content_after_the_empty_row_terminator_blocks_append_and_leaves_workbook_unchanged PASSED [ 47%]
test/test_protected_donor_transform.py::test_references_after_the_empty_row_terminator_are_not_scanned PASSED [ 50%]
test/test_protected_donor_transform.py::test_uuid_and_accession_references_to_existing_protected_donors_are_accepted[11111111-2222-3333-4444-555555555555] PASSED [ 52%]
test/test_protected_donor_transform.py::test_uuid_and_accession_references_to_existing_protected_donors_are_accepted[SMAPD1234567] PASSED [ 54%]
test/test_protected_donor_transform.py::test_workbook_protected_donor_uuid_and_accession_are_resolved_without_the_portal PASSED [ 57%]
test/test_protected_donor_transform.py::test_identifier_resolving_to_a_different_type_is_not_a_protected_donor PASSED [ 59%]
test/test_protected_donor_transform.py::test_unresolvable_identifier_without_portal_stays_invalid PASSED [ 61%]
test/test_protected_donor_transform.py::test_lookup_failures_are_distinct_from_absence[error0-permission_denied] PASSED [ 64%]
test/test_protected_donor_transform.py::test_lookup_failures_are_distinct_from_absence[error1-permission_denied] PASSED [ 66%]
test/test_protected_donor_transform.py::test_lookup_failures_are_distinct_from_absence[error2-permission_denied] PASSED [ 69%]
test/test_protected_donor_transform.py::test_lookup_failures_are_distinct_from_absence[error3-lookup_failed] PASSED [ 71%]
test/test_protected_donor_transform.py::test_lookup_failures_are_distinct_from_absence[error4-lookup_failed] PASSED [ 73%]
test/test_protected_donor_transform.py::test_lookup_failures_are_distinct_from_absence[error5-lookup_failed] PASSED [ 76%]
test/test_protected_donor_transform.py::test_lookup_failures_are_distinct_from_absence[error6-lookup_failed] PASSED [ 78%]
test/test_protected_donor_transform.py::test_definitive_not_found_is_reported_as_absent[portal0] PASSED [ 80%]
test/test_protected_donor_transform.py::test_definitive_not_found_is_reported_as_absent[portal1] PASSED [ 83%]
test/test_protected_donor_transform.py::test_definitive_not_found_is_reported_as_absent[portal2] PASSED [ 85%]
test/test_protected_donor_transform.py::test_error_result_with_forbidden_type_is_a_permission_failure PASSED [ 88%]
test/test_protected_donor_transform.py::test_lookup_identifier_is_path_quoted PASSED [ 90%]
test/test_protected_donor_transform.py::test_progress_loading_does_not_save_during_the_counting_pass PASSED [ 92%]
test/test_protected_donor_transform.py::test_repeated_construction_still_cannot_clobber_the_staged_path PASSED [ 95%]
test/test_progress_bar.py::test_progress_bar_a PASSED                    [ 97%]
test/test_progress_bar.py::test_progress_bar_b PASSED                    [100%]

=============================== warnings summary ===============================
../../../../.pyenv/versions/3.12.12/lib/python3.12/site-packages/webob/compat.py:5
  /Users/andrew2/.pyenv/versions/3.12.12/lib/python3.12/site-packages/webob/compat.py:5: DeprecationWarning: 'cgi' is deprecated and slated for removal in Python 3.13
    from cgi import parse_header

../../../../.pyenv/versions/3.12.12/lib/python3.12/site-packages/pyramid/path.py:2
  /Users/andrew2/.pyenv/versions/3.12.12/lib/python3.12/site-packages/pyramid/path.py:2: DeprecationWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html
    import pkg_resources

../../../../.pyenv/versions/3.12.12/lib/python3.12/site-packages/pkg_resources/__init__.py:3147
  /Users/andrew2/.pyenv/versions/3.12.12/lib/python3.12/site-packages/pkg_resources/__init__.py:3147: DeprecationWarning: Deprecated call to `pkg_resources.declare_namespace('paste')`.
  Implementing implicit namespace packages (as specified in PEP 420) is preferred to `pkg_resources.declare_namespace`. See https://setuptools.pypa.io/en/latest/references/keywords.html#keyword-namespace-packages
    declare_namespace(pkg)

../../../../.pyenv/versions/3.12.12/lib/python3.12/site-packages/pkg_resources/__init__.py:3147
  /Users/andrew2/.pyenv/versions/3.12.12/lib/python3.12/site-packages/pkg_resources/__init__.py:3147: DeprecationWarning: Deprecated call to `pkg_resources.declare_namespace('repoze')`.
  Implementing implicit namespace packages (as specified in PEP 420) is preferred to `pkg_resources.declare_namespace`. See https://setuptools.pypa.io/en/latest/references/keywords.html#keyword-namespace-packages
    declare_namespace(pkg)

../../../../.pyenv/versions/3.12.12/lib/python3.12/site-packages/pkg_resources/__init__.py:3147
../../../../.pyenv/versions/3.12.12/lib/python3.12/site-packages/pkg_resources/__init__.py:3147
../../../../.pyenv/versions/3.12.12/lib/python3.12/site-packages/pkg_resources/__init__.py:3147
  /Users/andrew2/.pyenv/versions/3.12.12/lib/python3.12/site-packages/pkg_resources/__init__.py:3147: DeprecationWarning: Deprecated call to `pkg_resources.declare_namespace('zope')`.
  Implementing implicit namespace packages (as specified in PEP 420) is preferred to `pkg_resources.declare_namespace`. See https://setuptools.pypa.io/en/latest/references/keywords.html#keyword-namespace-packages
    declare_namespace(pkg)

test/test_protected_donor_transform.py::test_progress_loading_does_not_save_during_the_counting_pass
test/test_protected_donor_transform.py::test_progress_loading_does_not_save_during_the_counting_pass
  /Users/andrew2/.no-mistakes/worktrees/f6d4ab33d8c8/01M3YQEBAG4Z3V8EZ202WH4VA2/dcicutils/submitr/progress_constants.py:82: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
    return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%fZ")

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
======================== 42 passed, 9 warnings in 8.77s ========================

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 3 issues found → auto-fixed ✅
  • ⚠️ dcicutils/submitr/donor_transformer.py:366 - The append-target safety check added for the ProtectedDonor sheet is not applied to the Donor sheet. _add_protected_donor_columns calls _ensure_column(sheet, headers, 'protected_donor'), which writes the new header at len(headers)+1 without first checking _has_content(sheet, min_col=len(headers)+1). Example: a Donor header row submitted_id, age, <blank>, notes, or data rows with values past the last header. The reader ignores those cells today because it stops at the first blank header. Putting protected_donor into the blank column C makes the reader ingest notes as a Donor property, and any stray column-C values in non-transformed rows become protected_donor values. It also happens after the ProtectedDonor rows were already appended, so a failure here would not leave the workbook unchanged. Fix: run the same 'no content beyond the header columns' check on every Donor sheet that needs the new column, during the validation phase before any mutation.
  • ℹ️ dcicutils/submitr/donor_transformer.py:81 - _http_status_of checks the name patterns (HTTPNotFound, ...) before the explicit numeric status in the ff_utils message (: NNN. Reason:). The ff_utils error text embeds the URL and the response JSON. So a 5xx response whose body or URL mentions HTTPNotFound (for example, a server error caused by a failed sub-lookup) is classified ABSENT. It is then reported as definitive ProtectedDonor absence rather than a lookup failure, which is the confusion the intent says to prevent. Fix: check the numeric-status patterns first and use the name patterns only as a fallback.
  • ℹ️ dcicutils/submitr/donor_transformer.py:547 - The transformer always skips hidden and bracketed sheets via its own _is_hidden_sheet. If CustomExcel is built with include_hidden_sheets=True (possible through with_portal(**options)), the reader ingests sheets the transformer never scanned, so the 'same rows as ingestion' guarantee no longer holds. No current caller in the repo passes that option; it would only matter if one did.

🔧 Fix: Guard Donor column insertion; prefer numeric HTTP status
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • poetry run pytest --noconftest test/test_protected_donor_transform.py test/test_progress_bar.py -v (42 passed; --noconftest because test/conftest.py needs AWS identity/network that isn't available locally, and these tests use no conftest fixtures)
  • Pre-fix regression check: the current test file run against git archive 4c2d59d dcicutils in a temp dir, with missing new symbols (ProtectedDonorLookupError, DonorReferenceKind.PERMISSION_DENIED/LOOKUP_FAILED) shimmed so it could collect -> 22 failed / 18 passed, covering header extension, empty-row termination, UUID/accession typing, logical-row reference scanning, permission/outage vs absence, and the counting-pass save
  • End-to-end script e2e_demo.py: StructuredDataSet with CustomExcel.with_portal(None, transform_protected_donor=True, transformed_workbook_path=...) and a progress callback, on an xlsx where the existing ProtectedDonor sheet has only a submitted_id header, has formatted empty rows, and has a Demographic reference after a blank-row terminator
  • End-to-end ProtectedDonorWorkbookTransformer with a stub portal: a UUID reference typed as ProtectedDonor, and 403 / 503 / ConnectionError / 404 lookups
🔧 **Document** - 1 issue found → auto-fixed ✅
  • ℹ️ CHANGELOG.rst:10 - pyproject.toml is at beta version 8.19.0.1b2, but CHANGELOG.rst has no entry for the ProtectedDonor transformer, the new CustomExcel options (transform_protected_donor, transformed_workbook_path, allow_existing_staging_path), or the StructuredDataSet change that opens the workbook once and orders sheets/JSON keys. The repo usually adds the changelog entry together with the final version bump (e.g. commit d390ccf), so I left it alone. Please confirm the release version heading and add the entry before releasing.

🔧 Fix: Bump version to 8.19.1 and add changelog entry
✅ Re-checked - no issues remain.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

aschroed and others added 11 commits August 19, 2026 16:01
The lock refresh in 4c2d59d floated flake8 7.1 -> 7.3 (pyflakes 3.2 -> 3.4),
which added F824. These declarations were for names only read, never assigned,
in the inner scope, so removing them does not change behavior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…extension, typed lookups, single Excel open

- Scan, rewrite, and append only within the reader's logical rows (before the first empty row).
- Extend an existing ProtectedDonor sheet's headers for copied fields, or reject unsafe shapes before mutating.
- Resolve non-token references (UUID/accession) as ProtectedDonors via workbook identifiers or typed portal lookup.
- Report permission/outage lookup failures distinctly from definitive absence.
- Open the Excel workbook once in StructuredDataSet so the progress counting pass cannot pre-create the staged output.
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 37038186504

Coverage increased (+0.9%) to 75.501%

Details

  • Coverage increased (+0.9%) from the base build.
  • Patch coverage: 31 uncovered changes across 2 files (388 of 419 lines covered, 92.6%).
  • 3 coverage regressions across 3 files.

Uncovered Changes

File Changed Covered %
dcicutils/submitr/donor_transformer.py 365 341 93.42%
dcicutils/submitr/custom_excel.py 44 37 84.09%
Total (3 files) 419 388 92.6%

Coverage Regressions

3 previously-covered lines in 3 files lost coverage.

File Lines Losing Coverage Coverage
dcicutils/command_utils.py 1 58.57%
dcicutils/progress_bar.py 1 74.61%
dcicutils/structured_data.py 1 71.96%

Coverage Stats

Coverage Status
Relevant Lines: 16025
Covered Lines: 12099
Line Coverage: 75.5%
Coverage Strength: 0.76 hits per line

💛 - Coveralls

@aschroed

aschroed commented Oct 2, 2026

Copy link
Copy Markdown
Member Author

Closing as a duplicate: the validated changes (head 0b8d9eb) were moved onto the original PR #339 by a fast-forward push of fm/protected-donor-dcicutils, so review continues there.

@aschroed aschroed closed this Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants