From da1afa6a038395ef7c0380709fccccb229231e6a Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Tue, 20 Oct 2026 16:20:59 +0200 Subject: [PATCH 1/3] Test writing item name and quantity edits to a dfs file Port the commented-out C# test of ExamplesDfs2.ModifyDfs2ItemInfo, the example from issue #38, and test name and quantity edits on every dfs file type and way of opening it for editing. They fail on master, which never writes item info. Co-Authored-By: Claude Opus 5.5 --- tests/test_dfs2.py | 57 +++---- tests/test_dfs_edit.py | 376 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 400 insertions(+), 33 deletions(-) create mode 100644 tests/test_dfs_edit.py diff --git a/tests/test_dfs2.py b/tests/test_dfs2.py index 7fa1706..ec30b4c 100644 --- a/tests/test_dfs2.py +++ b/tests/test_dfs2.py @@ -243,39 +243,30 @@ def test_CreateOresundBathy900Test(self): # # dfs2File.Close(); - # def ModifyLanduseItemInfoTest(): - # - # originalFilename = "testdata/Landuse.dfs2"; - # filename = "testdata/testtmp/test_create_Landuse.dfs2"; - # - # File.Copy(originalFilename, filename, true); - # File.SetAttributes(filename, FileAttributes.Normal); # remove read-only flag - # - # # Check the new file - # - # file = DfsFileFactory.Dfs2FileOpen(filename); - # - # # Check existing data - # itemInfo = file.ItemInfo[0]; - # assert_equal("Landuse", itemInfo.Name); - # assert_equal(eumItem.eumIIntegerCode, itemInfo.Quantity.Item); - # assert_equal(eumUnit.eumUintCode, itemInfo.Quantity.Unit); - # - # file.Close(); - # - # # Method that modifies the Landuse.dfs2 file - # ExamplesDfs2.ModifyDfs2ItemInfo(filename); - # - # # Check the new file - # file = DfsFileFactory.Dfs2FileOpen(filename); - # - # # Check existing data - # itemInfo = file.ItemInfo[0]; - # assert_equal("GroundU", itemInfo.Name); - # assert_equal(eumItem.eumIAreaFraction, itemInfo.Quantity.Item); - # assert_equal(eumUnit.eumUPerCent, itemInfo.Quantity.Unit); - # - # file.Close(); + def test_ModifyLanduseItemInfoTest(self): + originalFilename = "testdata/Landuse.dfs2" + filename = "testdata/testtmp/test_modifyItemInfo_Landuse.dfs2" + + testUtil.copy_file(originalFilename, filename) + + # Check existing data + file = DfsFileFactory.Dfs2FileOpen(filename) + itemInfo = file.ItemInfo[0] + assert_equal("Landuse", itemInfo.Name) + assert_equal(eumItem.eumIIntegerCode, itemInfo.Quantity.Item) + assert_equal(eumUnit.eumUintCode, itemInfo.Quantity.Unit) + file.Close() + + # Method that modifies the Landuse.dfs2 file + ExamplesDfs2.ModifyDfs2ItemInfo(filename) + + # Check the new file + file = DfsFileFactory.Dfs2FileOpen(filename) + itemInfo = file.ItemInfo[0] + assert_equal("GroundU", itemInfo.Name) + assert_equal(eumItem.eumIAreaFraction, itemInfo.Quantity.Item) + assert_equal(eumUnit.eumUPerCent, itemInfo.Quantity.Unit) + file.Close() def test_ReadReshapedPastEndReturnsNone(self): dfsFile = DfsFileFactory.Dfs2FileOpen("testdata/OresundHD.dfs2") diff --git a/tests/test_dfs_edit.py b/tests/test_dfs_edit.py new file mode 100644 index 0000000..c6240fa --- /dev/null +++ b/tests/test_dfs_edit.py @@ -0,0 +1,376 @@ +""" +Editing the name and quantity of the items of an existing dfs file, on every +dfs file type and every way of opening it for editing. +""" + +import copy +import os +import shutil +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np +import pytest + +from mikecore.DfsBuilder import DataValueType, DfsBuilder, DfsSimpleType +from mikecore.DfsFactory import DfsFactory +from mikecore.DfsFileFactory import DfsFileFactory +from mikecore.eum import eumItem, eumQuantity, eumUnit + + +@dataclass +class DfsType: + source: str + open: Callable[[Path], Any] + openEdit: Callable[[Path], Any] + openAppend: Callable[[Path], Any] + + +def createDfs1(filename): + # There is no dfs1 file in testdata, so the tests create one + factory = DfsFactory() + builder = DfsBuilder.Create("title", "application", 1) + builder.SetDataType(0) + builder.SetGeographicalProjection(factory.CreateProjectionUndefined()) + builder.SetTemporalAxis(factory.CreateTemporalEqTimeAxis(eumUnit.eumUsec, 0, 60)) + builder.SetSpatialAxis(factory.CreateAxisEqD1(eumUnit.eumUmeter, 5, 0, 10)) + for name in ("Level", "Depth"): + item = builder.CreateDynamicItemBuilder() + item.Set( + name, + eumQuantity(eumItem.eumIWaterLevel, eumUnit.eumUmeter), + DfsSimpleType.Float, + ) + item.SetValueType(DataValueType.Instantaneous) + item.SetAxis(factory.CreateAxisEqD1(eumUnit.eumUmeter, 5, 0, 10)) + builder.AddDynamicItem(item.GetDynamicItemInfo()) + builder.CreateFile(filename) + file = builder.GetFile() + for timeStep in range(3): + for itemNumber in (1, 2): + data = np.arange(5, dtype=np.float32) + 10 * timeStep + itemNumber + file.WriteItemTimeStepNext(60.0 * timeStep, data) + file.Close() + + +F = DfsFileFactory +DFS_TYPES = { + "dfs0": DfsType( + "testdata/TemporalEqTime.dfs0", + F.DfsGenericOpen, + F.DfsGenericOpenEdit, + F.DfsGenericOpenAppend, + ), + "dfs1": DfsType("", F.Dfs1FileOpen, F.Dfs1FileOpenEdit, F.Dfs1FileOpenAppend), + "dfs2": DfsType( + "testdata/OresundHD.dfs2", + F.Dfs2FileOpen, + F.Dfs2FileOpenEdit, + F.Dfs2FileOpenAppend, + ), + "dfs3": DfsType( + "testdata/OresundHD.dfs3", + F.Dfs3FileOpen, + F.Dfs3FileOpenEdit, + F.Dfs3FileOpenAppend, + ), + "dfsu": DfsType( + "testdata/OresundHD.dfsu", + F.DfsuFileOpen, + F.DfsuFileOpenEdit, + F.DfsuFileOpenAppend, + ), +} + + +@dataclass +class Case: + path: Path + dfsType: DfsType + + +@pytest.fixture(params=list(DFS_TYPES)) +def case(request, tmp_path): + dfsType = DFS_TYPES[request.param] + path = tmp_path / ("test." + request.param) + if dfsType.source: + shutil.copyfile(dfsType.source, path) + os.chmod(path, 0o644) + else: + createDfs1(str(path)) + return Case(path, dfsType) + + +# Every way to open a file for editing: Edit and Append mode through the +# factory method of the file type, and Edit mode as a generic dfs file +OPENERS = { + "edit": lambda case: case.dfsType.openEdit(case.path), + "append": lambda case: case.dfsType.openAppend(case.path), + "generic": lambda case: DfsFileFactory.DfsGenericOpenEdit(case.path), +} + +# A quantity that differs from that of every item in the test files +NEW_ITEM = eumItem.eumIAreaFraction +NEW_UNIT = eumUnit.eumUPerCent + + +def readItems(path): + """The name, eumItem and eumUnit of each item, as the file holds them.""" + file = DfsFileFactory.DfsGenericOpen(path) + items = [ + (itemInfo.Name, itemInfo.Quantity.Item, itemInfo.Quantity.Unit) + for itemInfo in file.ItemInfo + ] + file.Close() + return items + + +def readDynamicData(path): + """The time and data of every item time step.""" + file = DfsFileFactory.DfsGenericOpen(path) + data = [] + while (itemData := file.ReadItemTimeStepNext()) is not None: + data.append((itemData.Time, itemData.Data.copy())) + file.Close() + return data + + +def firstNameInFile(path): + """ + The name of the first item, its null terminated bytes, and their offset + in the file, where they are stored once. + """ + content = path.read_bytes() + name = readItems(path)[0][0] + nameBytes = name.encode("cp1252") + b"\0" + assert content.count(nameBytes) == 1 + return name, nameBytes, content.index(nameBytes) + + +@pytest.mark.parametrize("opener", list(OPENERS)) +def test_name_edit_is_written_to_that_item_only(case, opener): + """Checked for each item of the file, one at a time.""" + data = readDynamicData(case.path) + expected = readItems(case.path) + # With one item, there would be no other item to leave unchanged + assert len(expected) > 1 + + for itemNo, (name, item, unit) in enumerate(readItems(case.path)): + file = OPENERS[opener](case) + file.ItemInfo[itemNo].Name = "Q" * len(name) + file.Close() + + expected[itemNo] = ("Q" * len(name), item, unit) + assert expected == readItems(case.path) + np.testing.assert_equal(data, readDynamicData(case.path)) + + +@pytest.mark.parametrize("opener", list(OPENERS)) +def test_quantity_edit_is_written_to_that_item_only(case, opener): + """Checked for each item of the file, one at a time.""" + data = readDynamicData(case.path) + expected = readItems(case.path) + # With one item, there would be no other item to leave unchanged + assert len(expected) > 1 + + for itemNo, (name, _, _) in enumerate(readItems(case.path)): + file = OPENERS[opener](case) + file.ItemInfo[itemNo].Quantity = eumQuantity(NEW_ITEM, NEW_UNIT) + file.Close() + + expected[itemNo] = (name, NEW_ITEM, NEW_UNIT) + assert expected == readItems(case.path) + np.testing.assert_equal(data, readDynamicData(case.path)) + + +@pytest.mark.parametrize( + "length", + [ + pytest.param(lambda old: 0, id="empty"), + pytest.param(lambda old: 1, id="one"), + pytest.param(lambda old: old - 1, id="shorter"), + pytest.param(lambda old: old, id="same"), + pytest.param(lambda old: old + 5, id="longer"), + ], +) +def test_name_keeps_its_length_in_the_file(case, length): + """ + As in the C# library, a new name takes the place of the old one in the + file: it is padded with spaces or truncated to the old length. + """ + data = readDynamicData(case.path) + oldName, oldBytes, offset = firstNameInFile(case.path) + newName = ("Ab" * len(oldName))[: length(len(oldName))] + + file = case.dfsType.openEdit(case.path) + itemInfo = file.ItemInfo[0] + itemInfo.Name = newName + file.Close() + + expected = newName[: len(oldName)].ljust(len(oldName)) + written = case.path.read_bytes()[offset : offset + len(oldBytes)] + assert expected.encode("cp1252") + b"\0" == written + # The item shows the name the file holds + assert expected == itemInfo.Name + assert expected == readItems(case.path)[0][0] + np.testing.assert_equal(data, readDynamicData(case.path)) + + +@pytest.mark.parametrize( + "attribute, value, error", + [ + pytest.param("Name", "Ω", UnicodeEncodeError, id="not-cp1252"), + # The file would get only the part in front of the NUL + pytest.param("Name", "A\0B", ValueError, id="nul"), + pytest.param( + "Quantity", + eumQuantity(eumItem.eumIWaterLevel, eumUnit.eumUPerCent), + ValueError, + id="unit-of-another-item", + ), + ], +) +@pytest.mark.parametrize("closeWith", ["flush", "close"]) +def test_rejected_value_changes_no_item(case, attribute, value, error, closeWith): + """ + A value the file cannot hold raises on Flush or Close, before any item is + written: the valid edit of the other item is not written either. The file + is closed even when Close raises. + """ + original = case.path.read_bytes() + + file = case.dfsType.openEdit(case.path) + file.ItemInfo[0].Quantity = eumQuantity(NEW_ITEM, NEW_UNIT) + setattr(file.ItemInfo[1], attribute, value) + if closeWith == "flush": + with pytest.raises(error): + file.Flush() + with pytest.raises(error): + file.Close() + with pytest.raises(OSError): + file.Flush() + + assert original == case.path.read_bytes() + + +def test_read_mode_writes_nothing(case): + original = case.path.read_bytes() + + file = case.dfsType.open(case.path) + for itemInfo in file.ItemInfo: + itemInfo.Name = "abc" + itemInfo.Quantity = eumQuantity(NEW_ITEM, NEW_UNIT) + file.Close() + + # The name is not fitted to the length in the file + assert "abc" == file.ItemInfo[0].Name + assert original == case.path.read_bytes() + + +def test_edit_after_close_writes_nothing(case): + original = case.path.read_bytes() + + file = case.dfsType.openEdit(case.path) + file.Close() + for itemInfo in file.ItemInfo: + itemInfo.Name = "abc" + itemInfo.Quantity = eumQuantity(NEW_ITEM, NEW_UNIT) + file.Close() + with pytest.raises(OSError): + file.Flush() + + assert original == case.path.read_bytes() + + +def test_quantity_changed_in_place_is_not_written(case): + """As in the C# library, only assigning a new Quantity writes it.""" + original = case.path.read_bytes() + + file = case.dfsType.openEdit(case.path) + for itemInfo in file.ItemInfo: + itemInfo.Quantity.Item = NEW_ITEM + itemInfo.Quantity.Unit = NEW_UNIT + file.Close() + + assert original == case.path.read_bytes() + + +def test_item_put_in_item_info_is_not_written(case): + """Only the items the file read are written, not one put in their place.""" + original = case.path.read_bytes() + + file = case.dfsType.openEdit(case.path) + itemInfo = copy.copy(file.ItemInfo[0]) + file.ItemInfo[0] = itemInfo + itemInfo.Quantity = eumQuantity(NEW_ITEM, NEW_UNIT) + file.Close() + + assert original == case.path.read_bytes() + + +def test_flush_then_append(case): + """ + Item edits written on Flush do not move the file pointer: a time step + appended after Flush is added at the end, and the edits are kept. + """ + data = [values for _, values in readDynamicData(case.path)] + items = readItems(case.path) + name, item, unit = items[0] + + file = case.dfsType.openAppend(case.path) + file.ItemInfo[0].Name = "Q" * len(name) + file.Flush() + # Append a copy of the last time step. It differs from the first, so a + # time step written over the first would show. + appended = data[-len(items) :] + assert not np.array_equal(appended[0], data[0]) + for values in appended: + file.WriteItemTimeStepNext(0.0, values) + file.Close() + + newData = [values for _, values in readDynamicData(case.path)] + np.testing.assert_equal(data + appended, newData) + assert [("Q" * len(name), item, unit), *items[1:]] == readItems(case.path) + + +def test_quantity_edit_keeps_a_name_cp1252_cannot_decode(case): + """ + A name with a byte cp1252 leaves undefined, such as one written as UTF-8 + by another tool, is read with U+FFFD in it. Assigning only a new + Quantity must still work, and must write the name bytes back unchanged. + """ + _, oldBytes, offset = firstNameInFile(case.path) + # 0x81 is undefined in cp1252 + nameBytes = b"\x81" + oldBytes[1:] + with open(case.path, "r+b") as f: + f.seek(offset) + f.write(nameBytes) + assert "�" == readItems(case.path)[0][0][0] + data = readDynamicData(case.path) + + file = case.dfsType.openEdit(case.path) + file.ItemInfo[0].Quantity = eumQuantity(NEW_ITEM, NEW_UNIT) + file.Close() + + written = case.path.read_bytes()[offset : offset + len(nameBytes)] + assert nameBytes == written + assert (NEW_ITEM, NEW_UNIT) == readItems(case.path)[0][1:] + np.testing.assert_equal(data, readDynamicData(case.path)) + + +def test_flush_writes_the_edit_in_edit_mode(case): + """ + In Edit mode, Flush writes the edit to the file, which is still open. + In Append mode the native library writes the header only on Close. + """ + oldName, oldBytes, offset = firstNameInFile(case.path) + + file = case.dfsType.openEdit(case.path) + file.ItemInfo[0].Name = "Q" * len(oldName) + file.Flush() + written = case.path.read_bytes()[offset : offset + len(oldBytes)] + file.Close() + + assert ("Q" * len(oldName)).encode("cp1252") + b"\0" == written From b997d60e1e7a242d364c28e3e4ee31abbaac7faf Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Tue, 20 Oct 2026 16:21:00 +0200 Subject: [PATCH 2/3] Fix DfsuFile.Flush and let DfsuFileOpenAppend default its parameters DfsuFile.Flush passed itself to DfsFile.Flush, which takes no argument, so it raised TypeError. DfsuFileOpenAppend was the only Open method that required parameters. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ mikecore/DfsFileFactory.py | 2 +- mikecore/DfsuFile.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6840189..3023fe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `DfsuFile.Flush()` flushes the file instead of raising `TypeError`, and + `DfsFileFactory.DfsuFileOpenAppend` no longer requires `parameters`, like the + other `Open` methods. - Items read from a dfs file have the reference coordinates and orientation stored in the file. They had the `-1e-35` defaults, whatever the file held ([#31](https://github.com/DHI/mikecore-python/issues/31)). diff --git a/mikecore/DfsFileFactory.py b/mikecore/DfsFileFactory.py index b7a132d..302b136 100644 --- a/mikecore/DfsFileFactory.py +++ b/mikecore/DfsFileFactory.py @@ -71,7 +71,7 @@ def DfsuFileOpenEdit(filename, parameters=None): return DfsuFile(dfsFile) @staticmethod - def DfsuFileOpenAppend(filename, parameters): + def DfsuFileOpenAppend(filename, parameters=None): dfsFile = DfsFileFactory.DfsGenericOpenAppend(filename, parameters) return DfsuFile(dfsFile) diff --git a/mikecore/DfsuFile.py b/mikecore/DfsuFile.py index 9e8883d..9811f13 100644 --- a/mikecore/DfsuFile.py +++ b/mikecore/DfsuFile.py @@ -309,7 +309,7 @@ def Reset(self): self.dfsFile.Reset() def Flush(self): - self.dfsFile.Flush(self) + self.dfsFile.Flush() def FlushTimeStep(self): self.dfsFile.FlushTimeStep() From 237d24ffe0b57ca36f234ea6254af56c10918b5a Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Tue, 20 Oct 2026 16:21:01 +0200 Subject: [PATCH 3/3] Write item name and quantity edits on Flush and Close In a file opened for editing, the file remembers the dynamic items it read. Flush and Close write the ones whose Name changed or that were assigned a new Quantity, through dfsUpdateItemInfo. As in the C# library, a new name is truncated or padded to the stored length, and a Quantity changed in place is not written. Every change is checked before any is written, and Close still closes the file when it raises. Closes #38 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++++ mikecore/DfsDLL.py | 8 ++++ mikecore/DfsFile.py | 93 ++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 105 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3023fe5..dd4400a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- In a dfs file opened for editing, a dynamic item's changed `Name` or newly + assigned `Quantity` is written to the file on `Close`, and in Edit mode + also on `Flush`. As in the + C# library, a new name is truncated or padded with spaces to the length of + the old one, and changing `Quantity.Item` or `Quantity.Unit` in place is not + written. A name that cp1252 cannot encode raises `UnicodeEncodeError`, and + a name containing a NUL character or a unit that is not a unit of the item + raises `ValueError`, before any item is written + ([#38](https://github.com/DHI/mikecore-python/issues/38)). - `DfsuFile.Flush()` flushes the file instead of raising `TypeError`, and `DfsFileFactory.DfsuFileOpenAppend` no longer requires `parameters`, like the other `Open` methods. diff --git a/mikecore/DfsDLL.py b/mikecore/DfsDLL.py index a6f88fe..9a5d2d1 100644 --- a/mikecore/DfsDLL.py +++ b/mikecore/DfsDLL.py @@ -210,6 +210,14 @@ def Init(libfilepath: str | None = None): ctypes.c_void_p, ctypes.c_void_p, ] + lib.dfsUpdateItemInfo.argtypes = [ + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_int32, + ctypes.c_char_p, + ctypes.c_int32, + ] + lib.dfsUpdateItemInfo.restype = ctypes.c_int32 lib.dfsAddCustomBlock.argtypes = [ ctypes.c_void_p, diff --git a/mikecore/DfsFile.py b/mikecore/DfsFile.py index 1e987e3..e460ca4 100644 --- a/mikecore/DfsFile.py +++ b/mikecore/DfsFile.py @@ -779,6 +779,9 @@ def __init__(self): self.fpTimeStepIndex = 0 self.headPointer = ctypes.c_void_p(0) self.filePointer = ctypes.c_void_p(0) + # The dynamic items of an editable file, each with the Quantity it was + # read with. Flush and Close write their changes. + self.__itemsRead = [] def __del__(self): self.Close() @@ -855,6 +858,8 @@ def Open(self, filename, mode=DfsFileMode.Read, parameters=None): self.ItemInfo = [] for i in range(noOfItems): self.ItemInfo.append(self.__DynamicItemInfoReadAndCreate(i + 1, noOfItems)) + if mode != DfsFileMode.Read: + self.__itemsRead = [(item, item.Quantity) for item in self.ItemInfo] if mode == DfsFileMode.Read: # file pointer is after header part @@ -894,13 +899,68 @@ def Close(self): """ Close the file and release all ressources associated with it. The header information is still valid (for reading) even though the file has been closed. + + In a file opened for editing, a changed item Name or Quantity is + written first, see Flush. If a value is rejected, no item is written, + and the file is still closed. """ - if self.filePointer.value is not None: - DfsDLL.Wrapper.dfsFileClose( - self.headPointer, ctypes.byref(self.filePointer) + try: + self.__WriteItemInfo() + finally: + self.__itemsRead = [] + if self.filePointer.value is not None: + DfsDLL.Wrapper.dfsFileClose( + self.headPointer, ctypes.byref(self.filePointer) + ) + if self.headPointer.value is not None: + DfsDLL.Wrapper.dfsHeaderDestroy(ctypes.byref(self.headPointer)) + + def __WriteItemInfo(self): + """ + Write the Name and Quantity of the dynamic items that changed since + the file was opened. As in the C# library, assigning a new Quantity + writes it, changing its Item or Unit in place does not, and a new name + is truncated or padded with spaces to the length of the name in the + file. Every change is checked before any is written. + """ + changes = [] + for item, quantityRead in self.__itemsRead: + nameBytes = DfsDLLUtil.GetItemNameBytes(item.ItemPointer) + nameChanged = item.Name != nameBytes.decode("cp1252", "replace") + if not nameChanged and item.Quantity is quantityRead: + continue + if nameChanged: + # The native library reads the name up to the first NUL + if "\0" in item.Name: + raise ValueError( + f"Name of item {item.ItemNumber} contains a NUL character: " + f"{item.Name!r}" + ) + try: + newNameBytes = item.Name.encode("cp1252") + except UnicodeEncodeError as error: + error.add_note(f"Name of item {item.ItemNumber}: {item.Name!r}") + raise + length = len(nameBytes) + nameBytes = newNameBytes[:length].ljust(length, b" ") + quantity = item.Quantity + if not eumWrapper.eumItemUnitEqv(quantity.Item, quantity.Unit): + raise ValueError( + f"Item {item.ItemNumber} has a Quantity whose unit " + f"{quantity.Unit!r} is not a unit of {quantity.Item!r}" + ) + changes.append((item, nameBytes, quantity)) + for item, nameBytes, quantity in changes: + rok = DfsDLL.Wrapper.dfsUpdateItemInfo( + self.headPointer, + item.ItemPointer, + ctypes.c_int32(quantity.Item), + nameBytes, + ctypes.c_int32(quantity.Unit), ) - if self.headPointer.value is not None: - DfsDLL.Wrapper.dfsHeaderDestroy(ctypes.byref(self.headPointer)) + DfsDLL.CheckReturnCode(rok) + item.Name = nameBytes.decode("cp1252", "replace") + self.__itemsRead = [(item, item.Quantity) for item, _ in self.__itemsRead] def GetNextItemNumber(self): return self.fpItemNumber @@ -1296,8 +1356,14 @@ def Flush(self): Flush cached data to the file. This will especially update the header information, such that if another process is reading the file while it is being read, the new header information can be retrieved. + + In a file opened for editing, a dynamic item's changed Name or newly + assigned Quantity is written to the header first. If a value is + rejected, this raises without writing any item. In Append mode, the + native library writes the header to the file only on Close. """ self.__CheckIfOpen() + self.__WriteItemInfo() DfsDLL.Wrapper.dfsFileFlush(self.headPointer, self.filePointer) def FlushTimeStep(self): @@ -1572,6 +1638,23 @@ class DfsDLLUtil: """Utilities class, marshalling values to and from the DFS native library, and creating various Dfs classes based on pointers to DFS native data""" + @staticmethod + def GetItemNameBytes(itemPointer) -> bytes: + """The name of an item as stored in the header, without decoding.""" + itemNameP = ctypes.c_char_p() + DfsDLL.Wrapper.dfsGetItemInfo( + itemPointer, + ctypes.byref(ctypes.c_int32()), + ctypes.byref(ctypes.c_char_p()), + ctypes.byref(itemNameP), + ctypes.byref(ctypes.c_int32()), + ctypes.byref(ctypes.c_char_p()), + ctypes.byref(ctypes.c_int()), + ) + if itemNameP.value is None: + raise ValueError("Native library returned a null string") + return itemNameP.value + @staticmethod def EncodeFileName(filename: str | os.PathLike[str]) -> bytes: # The native library opens char* paths, which Windows interprets in the