Skip to content

feat(hla_string): port generateHLAString.sh to native Python - #4

Merged
nikhil merged 5 commits into
mainfrom
feature/hla-string-python-port
Aug 13, 2026
Merged

feat(hla_string): port generateHLAString.sh to native Python#4
nikhil merged 5 commits into
mainfrom
feature/hla-string-python-port

Conversation

@johnoooh

@johnoooh johnoooh commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

feat(hla_string): port generateHLAString.sh to native Python

Summary

Replaces the vendored bash generateHLAString.sh — including a second, silently
unpatched copy bundled as package data — with a real Python module. Same output
contract, now with actual unit tests instead of a subprocess shim.

Why

modules PR #256 fixes a truncation bug in generateHLAString.sh (three-digit
POLYSOLVER second fields, e.g. hla_b_18_177, were being cut to HLA-B18:17
a real allele the patient may not carry, silently mis-scoring the sample) and
adds HLA-HD support. That fix only touched the copy of the script vendored in
the modules repo.

This package bundled a second, independent copy of the same script
(src/neoantigen_utils/data/generateHLAString.sh, exposed via a
subprocess-exec'ing shim) — still at VERSION=1.0.0, never patched. Once the
R2 keystone fix lands (containers stop using the vendored modules-repo script
and start using this package instead), that bundled copy would have quietly
reintroduced the exact bug we just fixed, from a different vendored location.

Rather than patch two bash scripts and hope they stay in sync, this moves the
one real implementation here, in the CI-tested package, and retires the bash
script entirely (both copies).

Treated as a hotfix: 1.6.0 -> 1.6.1.

What changed

  • hla_string.py: generate_hla_string(), parse_polysolver(),
    parse_hlahd() — ported 1:1 from the fixed bash logic (verified against the
    same real POLYSOLVER/HLA-HD fixtures used in the modules PR). main() /
    console() preserve the exact CLI contract (-f FILE, -v, stdout output,
    exit 1 + stderr on unparseable input).
  • src/neoantigen_utils/data/ (bundled script + package-data config): removed.
  • generateHLAString.sh console-script name unchanged — no update needed on
    the calling side once containers point at this package.
  • tests/test_hla_string.py: 13 tests covering both formats, the three-digit
    fix, duplicate-allele preservation for homozygous samples, unparseable-entry
    warnings, empty-input errors, and the CLI entry point.

Tests

137 passed, 1 skipped (pre-existing, unrelated)

Also verified end-to-end against a built wheel in an isolated venv — installed
generateHLAString.sh console entry point resolves and produces byte-identical
output to the fixed bash script for both POLYSOLVER and HLA-HD fixtures.

Follow-up (not in this PR)

modules PR #256 and the R2 keystone item still need to point the
NEOANTIGENUTILS_GENERATEHLASTRING module at this package (once a container
build bakes it in — see containers PR #64) instead of shipping its own copy of
the script.

Replaces the vendored bash script (and its second, still-VERSION-1.0.0-unpatched
copy bundled as package data) with a real Python module: generate_hla_string(),
parse_polysolver(), parse_hlahd(), tested directly instead of via subprocess.

This closes the gap the bash-only fix (modules PR #256) left open: even after
the keystone fix lands (container uses the real neoantigen-utils package instead
of a vendored script), the bundled copy of generateHLAString.sh in this package
would have kept the original cut -c 1-11 truncation bug, silently reintroducing
it. There is now exactly one implementation of this parsing, and it is
CI-tested.

Behavior is unchanged from the fixed bash version (verified against the same
POLYSOLVER/HLA-HD fixtures): three-digit second fields are preserved instead of
truncated, HLA-HD *_final.result.txt is supported, unparseable entries warn and
are skipped, and an unparseable file exits 1.

The generateHLAString.sh console-script name is preserved so existing
containers/modules invocations don't need to change.

Treated as a hotfix: 1.6.0 -> 1.6.1.
@johnoooh
johnoooh force-pushed the feature/hla-string-python-port branch from 27848a3 to 1250d2d Compare August 13, 2026 17:05
johnoooh and others added 4 commits August 13, 2026 15:37
- `-f ""` now errors instead of silently printing usage and exiting 0 --
  only an omitted `-f` (args.file is None) does that. An explicitly-passed
  empty path is an unusable argument, not "no flag given".
- A missing/unreadable file now exits 1 with a structured `ERROR: ...`
  message instead of an unguarded traceback, matching the existing
  ValueError-on-no-alleles handling.
- parse_hlahd now warns (instead of silently dropping) on a line with no
  allele columns at all -- that can't be a real HLA-HD row, so treating it
  like an expected, routine class II locus skip (DRB1, DQB1, ...) would
  hide a misdetected format or corrupted file with no diagnostic trail.
  Expected class II skips remain silent.
- Added docstrings to parse_args, _is_hlahd_format, main, console.

4 new tests (17 total in this file, all passing).
- hla_string: distinguish an omitted -f from an explicit -f "" so the
  latter fails loudly instead of printing USAGE and exiting 0
- hla_string: wrap open() in try/except so a missing/unreadable file
  produces the module's structured ERROR message instead of a traceback
- hla_string: warn (once, aggregated) when parse_hlahd drops every line
  via the locus filter, to surface a misdetected/malformed file
- hla_string: add missing docstrings on _is_hlahd_format, parse_args,
  main, console
- generate_input: give compute_purity and _assign_frequencies
  contextualized ValueErrors instead of bare KeyError/IndexError on a
  malformed summ.json; deliberately still fail fast rather than
  reintroducing the silent per-node degradation removed in 26df863
- generate_input: derive root-ness in _assign_frequencies from
  build_topology's `start` flag instead of re-deriving it from the
  clone id, removing a duplicated signal that could desync
- generate_input: extract shared parse_polysolver_allele in
  hla_string.py and have generate_input's convert_polysolver_hla reuse
  it instead of reimplementing POLYSOLVER field parsing
- generate_input: drop dead X/x/new_x placeholders in build_topology's
  dict literal (immediately overwritten by _assign_frequencies)
- generate_input: document the intentional double validation of
  top_n_trees between the CLI type and select_top_trees

All existing tests pass (137 passed, 1 skipped).
…-port

Resolves the hla_string.py conflict by keeping both defenses in parse_hlahd
rather than picking one: the per-line "no allele column at all" warning
(this branch) and the aggregated "every line dropped by the locus filter"
warning (PR #5) catch different failure classes -- a single garbage line
mixed into an otherwise-good file vs. a wholly misdetected/empty file -- and
neither subsumes the other.

Also brings in PR #5's generate_input.py fixes unchanged: contextualized
ValueErrors in compute_purity/_assign_frequencies instead of bare
KeyError/IndexError, root-ness threaded through build_topology's existing
`start` flag instead of re-derived and risking desync, POLYSOLVER field
parsing shared via the new hla_string.parse_polysolver_allele instead of
reimplemented, and the dead X/x/new_x placeholder removed.

Added one test (test_hlahd_total_misdetection_warns_once_aggregated) for
the aggregated-warning behavior merged in from PR #5, which had no test
coverage. generate_input.py's fixes remain untested -- flagged for Nikhil
separately, not a merge-conflict-resolution concern.

142 passed, 1 skipped (pre-existing, unrelated).
PR #5 fixed 5 findings in generate_input.py (contextualized ValueErrors on
malformed populations data, root-ness threaded through build_topology's
`start` flag instead of re-derived, shared POLYSOLVER field parsing, a dead
placeholder removed, documented double-validation) with zero test coverage.
Adds it:

- compute_purity: root child missing from populations / with an empty
  cellular_prevalence list now raises the contextualized ValueError, not a
  bare KeyError/IndexError.
- _assign_frequencies: same, for a non-root clone. Also a direct regression
  test for the root-ness fix itself -- root gets X=1.0 purely from the
  `start` flag even when clone "0" has no populations entry at all, proving
  root-ness is no longer re-derived from the clone id.
- convert_polysolver_hla: was a closure nested in main(), untestable as
  written -- hoisted to module level (pure function, no closure state, no
  behavior change) so it can be exercised directly. Covers the shared-parser
  path and confirms unparseable input now raises a clear ValueError instead
  of the old bare IndexError.

150 passed, 1 pre-existing skip.

@nikhil nikhil left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great!

@nikhil
nikhil merged commit 876b8b1 into main Aug 13, 2026
4 checks passed
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