Skip to content

Commit c7d15d1

Browse files
Lint and tighten up code
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent dee7c2c commit c7d15d1

7 files changed

Lines changed: 107 additions & 201 deletions

File tree

src/mdio/core/config.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,6 @@
1717
SAVE_SEGY_FILE_HEADER_STRICT,
1818
SAVE_SEGY_FILE_HEADER_LENIENT,
1919
]
20-
"""Mode for ``MDIO__IMPORT__SAVE_SEGY_FILE_HEADER``.
21-
22-
* ``0`` (also accepts ``False`` / ``"false"``): do not save SEG-Y file headers.
23-
* ``1`` (also accepts ``True`` / ``"true"``): save SEG-Y file headers and raise
24-
on a malformed text header.
25-
* ``2``: save SEG-Y file headers and, on a malformed text header, log a
26-
warning and correct it (non-ASCII or non-printable characters become spaces
27-
and the header is padded to 80x40).
28-
"""
2920

3021
_SAVE_HEADER_TRUE_STRINGS = frozenset({"true", "yes", "on"})
3122
_SAVE_HEADER_FALSE_STRINGS = frozenset({"false", "no", "off"})

src/mdio/segy/creation.py

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -31,32 +31,18 @@
3131

3232

3333
def _ensure_exportable_text_header(text_header: str) -> str:
34-
"""Validate the stored text header and repair it if it cannot be encoded.
35-
36-
MDIO stores the text header as a wrapped 40x80 string. Stores written by
37-
older versions of MDIO may contain non-ASCII characters (typically
38-
``U+FFFD`` from a lossy EBCDIC import) that cannot be re-encoded to ASCII
39-
by the SEG-Y factory. To keep export usable for those stores this helper
40-
runs the validator and, on failure, sanitizes the header in place and logs
41-
a warning rather than aborting the export.
34+
"""Validate the stored text header; repair and warn if it cannot be ASCII-encoded.
4235
4336
Args:
4437
text_header: The ``textHeader`` attribute as stored on the MDIO dataset.
4538
4639
Returns:
47-
A text header string that satisfies
48-
:func:`mdio.segy.text_header.validate_text_header` and is therefore
49-
guaranteed to round-trip through ``factory.create_textual_header``.
40+
A text header string that satisfies :func:`validate_text_header`.
5041
"""
5142
try:
5243
validate_text_header(text_header)
5344
except ValueError as exc:
54-
logger.warning(
55-
"Stored MDIO text header is not exportable as-is and will be repaired: %s. "
56-
"The repair replaces non-ASCII or non-printable characters with spaces and "
57-
"forces the 80x40 card layout. Re-ingest the source SEG-Y to remove this warning.",
58-
exc,
59-
)
45+
logger.warning("Stored MDIO text header is not exportable as-is and will be repaired: %s", exc)
6046
return sanitize_text_header(text_header)
6147
return text_header
6248

src/mdio/segy/text_header.py

Lines changed: 14 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,22 @@
1-
"""SEG-Y textual file header validation and sanitization helpers.
2-
3-
The SEG-Y standard defines the textual file header as a 3200-byte block
4-
organized as 40 cards of 80 characters each, encoded as either ASCII or
5-
EBCDIC. Both encodings used by ``TGSAI/segy`` ultimately require the
6-
in-memory string to be 7-bit ASCII (``ord(c) <= 127``) before bytes can be
7-
written. The MDIO on-disk representation is the wrapped form: 40 lines of
8-
exactly 80 characters joined by ``"\\n"``.
9-
10-
When the source bytes were ingested through a lossy EBCDIC decode, MDIO
11-
typically receives ``U+FFFD`` (``"\uFFFD"``) replacement characters and other
12-
non-ASCII codepoints. Those characters round-trip through MDIO storage but
13-
fail when ``segy.factory.create_textual_header`` tries to re-encode the
14-
header to ASCII for SEG-Y export. The helpers in this module exist to detect
15-
that situation up-front and, when requested, repair it deterministically.
16-
17-
Repairs are conservative: any character that is either non-ASCII
18-
(``ord(c) > 127``) or non-printable per :py:meth:`str.isprintable` is replaced
19-
with an ASCII space, and the card grid is forced to exactly 40 rows of 80
20-
columns. Newlines (``"\\n"``) are treated only as row separators; other
21-
Unicode line-break characters (``"\\v"``, ``"\\f"``, ``"\\x85"``, ``"\u2028"``,
22-
``"\u2029"``) are treated as content and replaced rather than re-splitting
23-
the layout. Sanitization additionally collapses runs of two or more
24-
``"\\n"`` to one so headers that were written with ``"\\n\\n"`` between
25-
cards are not silently truncated to half their length.
26-
"""
1+
"""SEG-Y textual file header validation and sanitization helpers."""
272

283
from __future__ import annotations
294

305
import re
316

327
EXPECTED_ROWS = 40
338
EXPECTED_COLS = 80
34-
EXPECTED_LENGTH = EXPECTED_ROWS * EXPECTED_COLS
359
ASCII_MAX_ORD = 127
3610

3711
_REPORT_LIMIT = 5
3812
_NEWLINE_RUN = re.compile(r"\n{2,}")
3913

4014

4115
def _is_safe_char(char: str) -> bool:
42-
"""Return True if a char is safe to round-trip through SEG-Y ASCII/EBCDIC.
43-
44-
A char is "safe" when it is both 7-bit ASCII (``ord <= 127``) and printable
45-
per :py:meth:`str.isprintable`. ASCII space passes; ``U+FFFD``, accented
46-
Latin characters, control characters and tabs do not.
47-
"""
16+
"""Return True if char is 7-bit ASCII and printable."""
4817
return ord(char) <= ASCII_MAX_ORD and char.isprintable()
4918

5019

51-
def _split_rows(text_header: str) -> list[str]:
52-
"""Split a wrapped text header into rows on ``"\\n"`` only.
53-
54-
Other Unicode line-break characters (``"\\v"``, ``"\\f"``, ``"\u0085"``, etc.)
55-
are intentionally left in place so that lossy decodes do not silently
56-
re-shape the card grid. They will surface as unsafe characters during
57-
validation and be replaced during sanitization.
58-
"""
59-
return text_header.split("\n")
60-
61-
62-
def _find_unsafe(row: str) -> list[int]:
63-
"""Return positions of characters that are not :func:`_is_safe_char`."""
64-
return [i for i, c in enumerate(row) if not _is_safe_char(c)]
65-
66-
6720
def _summarize(mapping: dict[int, list[int]], limit: int = _REPORT_LIMIT) -> str:
6821
"""Format ``{row: [positions]}`` for an error message, capped for readability."""
6922
if not mapping:
@@ -80,18 +33,15 @@ def _summarize(mapping: dict[int, list[int]], limit: int = _REPORT_LIMIT) -> str
8033

8134

8235
def validate_text_header(text_header: str) -> None:
83-
"""Validate a SEG-Y textual file header is 40 rows of 80 ASCII-printable characters.
36+
r"""Validate a SEG-Y textual file header is 40 rows of 80 ASCII-printable characters.
8437
8538
Args:
86-
text_header: Decoded textual file header string in the wrapped form
87-
(40 rows of 80 characters joined by ``"\\n"``).
39+
text_header: Decoded text header in wrapped form (40 rows of 80 chars joined by ``\n``).
8840
8941
Raises:
90-
ValueError: If the header does not split into exactly 40 rows on
91-
``"\\n"``, any row is not 80 characters wide, or any character is
92-
not safe to encode as 7-bit ASCII (see :func:`_is_safe_char`).
42+
ValueError: If row count, row width, or any character fails the SEG-Y ASCII contract.
9343
"""
94-
rows = _split_rows(text_header)
44+
rows = text_header.split("\n")
9545

9646
if len(rows) != EXPECTED_ROWS:
9747
err = f"Invalid text header line count: expected {EXPECTED_ROWS}, got {len(rows)}"
@@ -106,46 +56,30 @@ def validate_text_header(text_header: str) -> None:
10656

10757
bad_chars: dict[int, list[int]] = {}
10858
for i, row in enumerate(rows):
109-
positions = _find_unsafe(row)
59+
positions = [j for j, c in enumerate(row) if not _is_safe_char(c)]
11060
if positions:
11161
bad_chars[i] = positions
11262

11363
if bad_chars:
114-
err = (
115-
"Invalid text header characters: non-ASCII or non-printable at "
116-
f"{_summarize(bad_chars)}"
117-
)
64+
err = f"Invalid text header characters: non-ASCII or non-printable at {_summarize(bad_chars)}"
11865
raise ValueError(err)
11966

12067

12168
def sanitize_text_header(text_header: str) -> str:
122-
"""Coerce a SEG-Y textual file header into the 40x80 ASCII-printable card layout.
123-
124-
Pre-processing collapses runs of two or more ``"\\n"`` into one. Some SEG-Y
125-
writers terminate each card with ``"\\n\\n"``, which yields 80 rows on a
126-
naive ``split("\\n")`` and would silently drop cards 21-40 when the row
127-
list is sliced to 40. Collapsing runs of newlines recovers the intended
128-
card layout for that common case while leaving properly-wrapped headers
129-
untouched.
130-
131-
The normalized input is then split on ``"\\n"`` and each row is independently:
132-
133-
1. Stripped of unsafe characters (any non-ASCII or non-printable codepoint
134-
is replaced with a single ASCII space).
135-
2. Right-padded with spaces or truncated to exactly 80 characters.
69+
r"""Coerce a SEG-Y textual file header into the 40x80 ASCII-printable card layout.
13670
137-
Rows beyond 40 are dropped. Missing rows are appended as 80-space blanks
138-
so the result always contains exactly 40 lines.
71+
Runs of two or more ``\n`` collapse to one (some writers terminate cards with ``\n\n``).
72+
Each row gets unsafe characters replaced with spaces and is padded/truncated to 80 chars.
73+
The result always has exactly 40 rows.
13974
14075
Args:
14176
text_header: Decoded textual file header string.
14277
14378
Returns:
144-
Sanitized header string with rows joined by ``"\\n"``. The output is
145-
guaranteed to satisfy :func:`validate_text_header`.
79+
Sanitized header that satisfies :func:`validate_text_header`.
14680
"""
14781
normalized = _NEWLINE_RUN.sub("\n", text_header)
148-
rows = _split_rows(normalized)
82+
rows = normalized.split("\n")
14983

15084
sanitized: list[str] = []
15185
for row in rows[:EXPECTED_ROWS]:

tests/unit/ingestion/test_segy_file_headers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ def test_invalid_row_count_raises(self) -> None:
8181
ds = _empty_dataset()
8282
with (
8383
patch.dict(os.environ, {"MDIO__IMPORT__SAVE_SEGY_FILE_HEADER": "true"}),
84-
pytest.raises(ValueError, match="Invalid text header count"),
84+
pytest.raises(ValueError, match="Invalid text header line count"),
8585
):
8686
_add_segy_file_headers(ds, info)
8787

@@ -93,6 +93,6 @@ def test_invalid_column_count_raises(self) -> None:
9393
ds = _empty_dataset()
9494
with (
9595
patch.dict(os.environ, {"MDIO__IMPORT__SAVE_SEGY_FILE_HEADER": "true"}),
96-
pytest.raises(ValueError, match="Invalid text header columns"),
96+
pytest.raises(ValueError, match="Invalid text header line widths"),
9797
):
9898
_add_segy_file_headers(ds, info)

tests/unit/test_segy_export_text_header.py

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,26 @@
1-
"""Tests for export-side text-header guarding in ``mdio.segy.creation``.
2-
3-
These cover the second half of issue #814: existing MDIO stores written by an
4-
older version of MDIO may carry a malformed text header (typically scattered
5-
``U+FFFD`` characters from a lossy EBCDIC import). The export path must not
6-
crash on those stores; it should repair the header and warn instead.
7-
"""
1+
"""Tests for export-side text header guarding in ``mdio.segy.creation``."""
82

93
from __future__ import annotations
104

115
import logging
6+
from typing import TYPE_CHECKING
127

13-
import pytest
148
from segy.factory import SegyFactory
159
from segy.standards import get_segy_standard
1610

1711
from mdio.segy.creation import _ensure_exportable_text_header
1812

13+
if TYPE_CHECKING:
14+
import pytest
15+
1916

2017
def _well_formed_header() -> str:
18+
"""Build a 40x80 header where each row reads ``Cnn ...spaces``."""
2119
return "\n".join([f"C{i:02d}".ljust(80) for i in range(1, 41)])
2220

2321

2422
def _replacement_char_header() -> str:
23+
"""Build a 40x80 header with U+FFFD scattered through the last three cards."""
2524
rows = [f"C{i:02d}".ljust(80) for i in range(1, 41)]
2625
rows[37] = "\ufffdC38" + " " * 76
2726
rows[38] = "\ufffdC39" + " " * 76
@@ -33,21 +32,23 @@ class TestEnsureExportableTextHeader:
3332
"""The export guard repairs malformed headers and warns; otherwise no-op."""
3433

3534
def test_passthrough_when_well_formed(self, caplog: pytest.LogCaptureFixture) -> None:
35+
"""Well-formed input is returned unchanged with no warning."""
3636
header = _well_formed_header()
3737
with caplog.at_level(logging.WARNING, logger="mdio.segy.creation"):
3838
result = _ensure_exportable_text_header(header)
3939
assert result == header
4040
assert not any("repaired" in record.message for record in caplog.records)
4141

4242
def test_repairs_replacement_char_and_warns(self, caplog: pytest.LogCaptureFixture) -> None:
43+
"""U+FFFD is repaired and a warning is logged."""
4344
with caplog.at_level(logging.WARNING, logger="mdio.segy.creation"):
4445
result = _ensure_exportable_text_header(_replacement_char_header())
4546
assert "\ufffd" not in result
46-
result.replace("\n", "").encode("ascii") # raises if any non-ASCII char survived
47+
result.replace("\n", "").encode("ascii")
4748
assert any("repaired" in record.message for record in caplog.records)
4849

4950
def test_repairs_short_layout(self, caplog: pytest.LogCaptureFixture) -> None:
50-
"""A header with fewer than 40 cards is padded out so export can proceed."""
51+
"""Header with fewer than 40 cards is padded out to 40 rows of 80 chars."""
5152
short = "\n".join(["C01".ljust(80)] * 5)
5253
with caplog.at_level(logging.WARNING, logger="mdio.segy.creation"):
5354
result = _ensure_exportable_text_header(short)
@@ -57,12 +58,7 @@ def test_repairs_short_layout(self, caplog: pytest.LogCaptureFixture) -> None:
5758
assert any("repaired" in record.message for record in caplog.records)
5859

5960
def test_repaired_header_is_accepted_by_segy_factory(self) -> None:
60-
"""End-to-end proof that repair output is round-trippable via the SEG-Y factory.
61-
62-
Regression guard for issue #814: a malformed header that previously
63-
crashed ``factory.create_textual_header`` must produce a 3200-byte
64-
textual block after going through the export guard.
65-
"""
61+
"""Repair output round-trips through ``factory.create_textual_header`` to 3200 bytes."""
6662
spec = get_segy_standard(1.0)
6763
factory = SegyFactory(spec=spec, sample_interval=2000, samples_per_trace=1)
6864

@@ -72,14 +68,7 @@ def test_repaired_header_is_accepted_by_segy_factory(self) -> None:
7268
assert len(encoded) == 3200
7369

7470
def test_repairs_double_newline_wrapped(self, caplog: pytest.LogCaptureFixture) -> None:
75-
"""Legacy stores wrapped with ``\\n\\n`` per card must export with all 40 cards intact.
76-
77-
This is the second real-world malformed sample seen in the wild
78-
(file ``260418_A4_…``): each card is terminated with ``\\n\\n``, which
79-
previously caused naive splitting to lose cards 21-40 silently. The
80-
export guard must collapse the double newlines and emit a 3200-byte
81-
textual block whose 40 cards all carry their original ``Cnn`` prefix.
82-
"""
71+
r"""Cards terminated with ``\n\n`` keep all 40 ``Cnn`` prefixes after repair."""
8372
cards = [f"C{i:02d}".ljust(80) for i in range(1, 41)]
8473
wrapped = "\n\n".join(cards) + "\n"
8574

0 commit comments

Comments
 (0)