Skip to content

Commit 6e7b21c

Browse files
committed
ENH: add tsurf writer
1 parent b91307b commit 6e7b21c

File tree

3 files changed

+171
-5
lines changed

3 files changed

+171
-5
lines changed

src/xtgeo/io/_file.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@
1313
from enum import Enum
1414
from os.path import join
1515
from tempfile import mkstemp
16-
from typing import TYPE_CHECKING, Generator, Literal, TextIO, Union
16+
from typing import (
17+
TYPE_CHECKING,
18+
Generator,
19+
Literal,
20+
TextIO,
21+
Union,
22+
)
1723

1824
from typing_extensions import Self
1925

@@ -674,3 +680,43 @@ def get_text_stream(self: Self) -> Generator[TextIO, None, None]:
674680
# StringIO is already a text stream
675681
self.file.seek(0)
676682
yield self.file
683+
684+
@contextlib.contextmanager
685+
def get_text_stream_write(self: Self) -> Generator[TextIO, None, None]:
686+
"""
687+
Context manager to handle both file paths and file-like objects for writing.
688+
689+
Args:
690+
encoding: Character encoding to use for text files. Defaults to "utf-8".
691+
692+
Yields:
693+
A text stream (TextIO) for writing.
694+
695+
Raises:
696+
OSError: If the parent folder does not exist or is not writable.
697+
698+
Example::
699+
>>> wrapper = FileWrapper("output.txt", mode="w")
700+
>>> with wrapper.get_text_stream_for_writing() as stream:
701+
... stream.write("Hello, world!\\n")
702+
"""
703+
704+
encoding: str = "utf-8"
705+
706+
if isinstance(self.file, pathlib.Path):
707+
self.check_folder(raiseerror=OSError)
708+
with open(self.file, "w", encoding=encoding) as stream:
709+
yield stream
710+
elif isinstance(self.file, io.BytesIO):
711+
# Wrap BytesIO in TextIOWrapper for text writing
712+
text_wrapper = io.TextIOWrapper(
713+
self.file, encoding=encoding, write_through=True
714+
)
715+
try:
716+
yield text_wrapper
717+
finally:
718+
# Detach the wrapper to prevent it from closing the underlying BytesIO
719+
text_wrapper.detach()
720+
else:
721+
# StringIO is already a text stream, just yield it
722+
yield self.file

src/xtgeo/io/tsurf/_tsurf_reader.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from io import BytesIO, StringIO
2+
import pathlib
13
import warnings
24
from dataclasses import dataclass
35
from typing import Any, Generator, TextIO
@@ -790,4 +792,61 @@ def to_file(
790792
FileNotFoundError: If file path doesn't exist or isn't a regular file
791793
"""
792794

793-
raise NotImplementedError("TSurfData.to_file() is not yet implemented.")
795+
wrapped_file = FileWrapper(file)
796+
wrapped_file.check_folder(raiseerror=OSError)
797+
798+
encoding = "utf-8"
799+
800+
# TODO: remove commented code below
801+
# Looked in polygons.py, only need to open the file and write to it
802+
# But this is when wrapped_file is a str
803+
# with open(wrapped_file, "w") as fout:
804+
# for col in use_attributes:
805+
# if col in df.columns:
806+
# fout.write(transl[xyz._attrs[col]] + " " + col + "\n")
807+
808+
lines = []
809+
810+
# TSurf signature line
811+
lines.append("GOCAD TSurf 1\n")
812+
813+
lines.append("HEADER {\n")
814+
lines.append(f"name: {self.header.name}\n")
815+
lines.append("}\n")
816+
817+
# Optional: coordinate system
818+
if self.coord_sys:
819+
lines.append("GOCAD_ORIGINAL_COORDINATE_SYSTEM\n")
820+
lines.append(f"NAME {self.coord_sys.name}\n")
821+
822+
axis_name_str = " ".join([f'"{name}"' for name in self.coord_sys.axis_name])
823+
lines.append(f"AXIS_NAME {axis_name_str}\n")
824+
825+
axis_unit_str = " ".join([f'"{unit}"' for unit in self.coord_sys.axis_unit])
826+
lines.append(f"AXIS_UNIT {axis_unit_str}\n")
827+
828+
lines.append(f"ZPOSITIVE {self.coord_sys.zpositive}\n")
829+
lines.append("END_ORIGINAL_COORDINATE_SYSTEM\n")
830+
831+
# TFACE section with vertices and triangles
832+
lines.append("TFACE\n")
833+
for i, vertex in enumerate(self.vertices, start=1):
834+
lines.append(f"VRTX {i} {vertex[0]} {vertex[1]} {vertex[2]} CNXYZ\n")
835+
for triangle in self.triangles:
836+
lines.append(f"TRGL {triangle[0]} {triangle[1]} {triangle[2]}\n")
837+
lines.append("END\n")
838+
839+
with wrapped_file.get_text_stream_write() as stream:
840+
stream.writelines(lines)
841+
842+
# TODO: remove this
843+
# Alternative way of writing:
844+
# if isinstance(wrapped_file.file, pathlib.Path):
845+
# with open(str(wrapped_file.name), "w", encoding=encoding) as f_out:
846+
# f_out.writelines(lines)
847+
# else:
848+
# wrapped_file.file.seek(0)
849+
# if isinstance(wrapped_file.file, BytesIO):
850+
# wrapped_file.file.write("".join(lines).encode(encoding))
851+
# else: # isinstance(wrapped_file.file, StringIO):
852+
# wrapped_file.file.write("".join(lines))

tests/test_io/test_tsurf/test_tsurf_reader.py

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from dataclasses import FrozenInstanceError
2-
from io import StringIO
2+
from io import BytesIO, StringIO
33
from pathlib import Path
44

55
import numpy as np
@@ -113,8 +113,8 @@ def test_file_string_input(tmp_path: str, complete_tsurf_file: str) -> None:
113113
with open(filepath, "w") as f:
114114
f.write(complete_tsurf_file)
115115

116-
result_path = TSurfData.from_file(filepath)
117-
assert result_path is not None
116+
result = TSurfData.from_file(filepath)
117+
assert result is not None
118118

119119

120120
def test_file_unusual_suffix(minimal_tsurf_file: str, tmp_path: Path) -> None:
@@ -1041,3 +1041,64 @@ def test_tsurfdata_get_vertices_return_same_reference() -> None:
10411041
)
10421042

10431043
assert data.get_vertices is data.vertices
1044+
1045+
1046+
def test_tsurf_data_roundtrip_write_to_file(
1047+
complete_tsurf_file: str, tmp_path: Path
1048+
) -> None:
1049+
"""Test writing TSurfData."""
1050+
result = TSurfData.from_file(tsurf_stream(complete_tsurf_file))
1051+
assert result is not None
1052+
1053+
output_filepath = tmp_path / "output.ts"
1054+
result.to_file(output_filepath)
1055+
1056+
result_written = TSurfData.from_file(output_filepath)
1057+
assert result_written is not None
1058+
1059+
assert result.header == result_written.header
1060+
assert result.coord_sys == result_written.coord_sys
1061+
assert np.array_equal(result.vertices, result_written.vertices)
1062+
assert np.array_equal(result.triangles, result_written.triangles)
1063+
1064+
1065+
def test_tsurf_data_roundtrip_string_io(complete_tsurf_file: str) -> None:
1066+
"""Test writing and reading TSurfData using StringIO."""
1067+
result = TSurfData.from_file(tsurf_stream(complete_tsurf_file))
1068+
assert result is not None
1069+
1070+
output_stream = StringIO()
1071+
result.to_file(output_stream)
1072+
1073+
output_stream.seek(0)
1074+
result_written = TSurfData.from_file(output_stream)
1075+
assert result_written is not None
1076+
1077+
assert result.header == result_written.header
1078+
assert result.coord_sys == result_written.coord_sys
1079+
assert np.array_equal(result.vertices, result_written.vertices)
1080+
assert np.array_equal(result.triangles, result_written.triangles)
1081+
1082+
1083+
def test_tsurf_data_roundtrip_bytes_io(complete_tsurf_file: str) -> None:
1084+
"""Test writing and reading TSurfData using BytesIO."""
1085+
result = TSurfData.from_file(tsurf_stream(complete_tsurf_file))
1086+
assert result is not None
1087+
1088+
output_stream = BytesIO()
1089+
result.to_file(output_stream)
1090+
1091+
output_stream.seek(0)
1092+
result_written = TSurfData.from_file(output_stream)
1093+
assert result_written is not None
1094+
1095+
assert result.header == result_written.header
1096+
assert result.coord_sys == result_written.coord_sys
1097+
assert np.array_equal(result.vertices, result_written.vertices)
1098+
assert np.array_equal(result.triangles, result_written.triangles)
1099+
1100+
# TODO: see dataio writer tests
1101+
# TODO: test with invalid filepaths/streams, non-existing folders, etc.
1102+
# TODO: test with a couple of different encodings
1103+
# TODO: write erroneous files and check errors
1104+
# TODO: dataio: test_tsurf_reader_invalid_lines()

0 commit comments

Comments
 (0)