Coverage for python/lsst/images/fits/_input_archive.py: 72%

238 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-25 01:29 -0700

1# This file is part of lsst-images. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11 

12from __future__ import annotations 

13 

14__all__ = ( 

15 "DEFAULT_PAGE_SIZE", 

16 "READ_CACHE_MAX_BYTES", 

17 "FitsInputArchive", 

18 "FitsOpaqueMetadata", 

19) 

20 

21import io 

22import os 

23from collections.abc import Callable, Iterator 

24from contextlib import contextmanager 

25from functools import cached_property 

26from types import EllipsisType 

27from typing import IO, Any, Self 

28 

29import astropy.io.fits 

30import astropy.table 

31import fsspec 

32import numpy as np 

33 

34from lsst.resources import ResourcePath, ResourcePathExpression 

35 

36from .._transforms import FrameSet 

37from ..serialization import ( 

38 ArchiveInfo, 

39 ArchiveReadError, 

40 ArchiveTree, 

41 ArrayReferenceModel, 

42 InlineArrayModel, 

43 InputArchive, 

44 TableModel, 

45 no_header_updates, 

46 parameterize_tree, 

47 tree_class_for_info, 

48) 

49from ..serialization._common import _check_format_version 

50from ._common import ( 

51 JSON_COLUMN, 

52 JSON_EXTNAME, 

53 ExtensionHDU, 

54 ExtensionKey, 

55 FitsOpaqueMetadata, 

56 InvalidFitsArchiveError, 

57 PointerModel, 

58) 

59 

60_FITS_FORMAT_VERSION = 1 

61"""Container layout version this release of `FitsInputArchive` understands.""" 

62 

63DEFAULT_PAGE_SIZE = 2880 * 800 

64"""Default fsspec read-block size for partial (remote) reads, in bytes. 

65 

66This is the single place to tune the block size for remote-store performance. 

67On a buffered remote filesystem (e.g. GCS) each cache miss is one range 

68request, so the block size trades round trips against over-fetch: a component 

69read that touches scattered compressed tiles pulls one block per cluster of 

70nearby tiles, rounded up to this size. 

71 

72The optimum depends on the access pattern. Larger blocks favor reads that 

73touch most of the file (full planes, large cutouts); smaller blocks reduce 

74wasted bytes for small scattered cutouts. ``2880 * 800`` (~2.3 MB, and a 

75multiple of the 2880-byte FITS block) is a robust middle: across cutout sizes 

76and full reads it stays within ~1.5x of the per-pattern optimum, whereas the 

77historical 144 KB default was several times slower for all but the tiniest 

78cutout. Raise it (e.g. ``2880 * 1600``) when whole-file or large-cutout reads 

79dominate; lower it when many tiny cutouts across many files dominate. 

80 

81Local filesystems ignore this (their opener does no buffering), so it only 

82affects remote stores. 

83""" 

84 

85_READ_CACHE_TYPE = "blockcache" 

86"""fsspec cache strategy for partial reads. 

87 

88``blockcache`` keeps a bounded set of fixed-size blocks (so memory stays 

89capped) and reuses them across the multiple components of one file -- image, 

90mask, variance and so on often share blocks -- unlike the default unbounded 

91single-block ``readahead``. 

92""" 

93 

94READ_CACHE_MAX_BYTES = 64 * 1024 * 1024 

95"""Approximate memory budget for the partial-read block cache, per open file. 

96 

97The fsspec block cache evicts least-recently-used blocks once it holds more 

98than ``maxblocks``; we derive ``maxblocks`` from this budget and the block 

99size (`DEFAULT_PAGE_SIZE`) so the memory cap is expressed in bytes and stays 

100fixed even when the block size is retuned. Measured benefit saturates at two 

101cached blocks for the access patterns we care about, so this budget is purely 

102headroom plus a guard against unbounded growth; it is far below fsspec's 

103implicit default of ``32 * block_size``. 

104""" 

105 

106 

107class FitsInputArchive(InputArchive[PointerModel]): 

108 """An implementation of the `.serialization.InputArchive` interface that 

109 reads from FITS files. 

110 

111 Instances of this class should only be constructed via the `open` 

112 context manager. 

113 """ 

114 

115 @classmethod 

116 def get_basic_info(cls, path: ResourcePathExpression) -> ArchiveInfo: 

117 """Read ``DATAMODL`` (schema URL) and ``FMTVER`` (container version) 

118 from the primary header. 

119 

120 Every FITS file written by this package records the schema URL in 

121 the ``DATAMODL`` card, so the schema can be identified without 

122 reading the (potentially large) JSON tree HDU. 

123 """ 

124 with ResourcePath(path).open("rb") as stream: 

125 primary = astropy.io.fits.PrimaryHDU.readfrom(stream) 

126 header = primary.header 

127 format_version = int(header.get("FMTVER", 1)) 

128 schema_url = header.get("DATAMODL") 

129 if not schema_url: 129 ↛ 130line 129 didn't jump to line 130 because the condition on line 129 was never true

130 raise ArchiveReadError(f"{path!r} is not an lsst.images FITS archive (no DATAMODL card).") 

131 return ArchiveInfo.from_schema_url(schema_url, format_version=format_version) 

132 

133 @classmethod 

134 @contextmanager 

135 def open_tree( 

136 cls, 

137 path: ResourcePathExpression, 

138 *, 

139 partial: bool = True, 

140 **backend_kwargs: Any, 

141 ) -> Iterator[tuple[Self, ArchiveTree, ArchiveInfo]]: 

142 """Open the FITS file and yield ``(archive, tree, info)``. 

143 

144 Parameters 

145 ---------- 

146 path 

147 The file resource to open. 

148 partial 

149 If `True` the file is opened without reading it all into memory. 

150 **backend_kwargs 

151 Optional parameters for this backend. Currently supports 

152 ``page_size`` which can be used to override the default 

153 page size (which can be overridden globally by modifying 

154 `DEFAULT_PAGE_SIZE`). 

155 """ 

156 page_size = backend_kwargs.pop("page_size", DEFAULT_PAGE_SIZE) 

157 with cls.open(path, page_size=page_size, partial=partial) as archive: 

158 info = archive.info 

159 tree_cls = tree_class_for_info(info, path) 

160 parameterized = parameterize_tree(tree_cls, PointerModel) 

161 tree = archive.get_tree(parameterized) 

162 yield archive, tree, info 

163 

164 def __init__(self, stream: IO[bytes]) -> None: 

165 self._primary_hdu = astropy.io.fits.PrimaryHDU.readfrom(stream) 

166 on_disk_fmtver: int = self._primary_hdu.header.pop("FMTVER", 1) 

167 # DATAMODL is informational only on read; the JSON tree's 

168 # schema_version / min_read_version drive data-model checks. We 

169 # capture it here as ArchiveInfo so callers (e.g. open_tree) can 

170 # identify the schema from this open rather than reopening the file. 

171 # A schema-less file can still be opened directly; only callers that 

172 # need the schema (via the `info` property) require DATAMODL. 

173 schema_url = self._primary_hdu.header.pop("DATAMODL", None) 

174 self._info = ( 

175 ArchiveInfo.from_schema_url(schema_url, format_version=on_disk_fmtver) if schema_url else None 

176 ) 

177 _check_format_version("fits", on_disk_fmtver, _FITS_FORMAT_VERSION) 

178 # TODO: do some basic checks that the file format conforms to our 

179 # expectations (e.g. primary HDU should have no data). 

180 # 

181 # Read and strip the addresses and sizes from the headers. We don't 

182 # actually need the index address because we always want to read the 

183 # JSON HDU, too, and the index HDU is always the next one (but this 

184 # could change in the future). 

185 json_address: int = self._primary_hdu.header.pop("JSONADDR") 

186 json_size: int = self._primary_hdu.header.pop("JSONSIZE") 

187 del self._primary_hdu.header["INDXADDR"] 

188 index_size: int = self._primary_hdu.header.pop("INDXSIZE") 

189 # Save the remaining primary header keys so we can propagate them on 

190 # rewrite. 

191 self._opaque_metadata = FitsOpaqueMetadata() 

192 self._opaque_metadata.add_header(self._primary_hdu.header.copy(strip=True), name="", ver=1) 

193 # Read the JSON and index HDUs from the end. 

194 stream.seek(json_address) 

195 tail_data = stream.read(json_size + index_size) 

196 index_hdu = astropy.io.fits.BinTableHDU.fromstring(tail_data[json_size:]) 

197 # Initialize lazy readers for all of the regular HDUs and the JSON HDU. 

198 self._readers = { 

199 ExtensionKey.from_index_row(row): _ExtensionReader.from_index_row(row, stream) 

200 for row in index_hdu.data 

201 } 

202 self._readers[ExtensionKey(JSON_COLUMN)] = _ExtensionReader.from_bytes( 

203 astropy.io.fits.BinTableHDU, tail_data[:json_size] 

204 ) 

205 # Make any empty dictionary to cache deserialized objects. Keys are 

206 # the zero-indexed row in the JSON table. 

207 self._deserialized_pointer_cache: dict[int, Any] = {} 

208 

209 @classmethod 

210 @contextmanager 

211 def open( 

212 cls, 

213 path: ResourcePathExpression, 

214 *, 

215 page_size: int = DEFAULT_PAGE_SIZE, 

216 partial: bool = False, 

217 ) -> Iterator[Self]: 

218 """Create an output archive that writes to the given file. 

219 

220 Parameters 

221 ---------- 

222 path 

223 File to read; convertible to `lsst.resources.ResourcePath`. 

224 page_size 

225 Size of the fsspec read block for partial (remote) reads, in 

226 bytes; a multiple of the FITS block size (2880) is recommended. 

227 Defaults to `DEFAULT_PAGE_SIZE`; see it for the tuning tradeoff. 

228 partial 

229 Whether we will be reading only some of the archive, or if memory 

230 pressure forces us to read it only a little at a time. If `False` 

231 (default), the entire raw file may be read into memory up front. 

232 

233 Returns 

234 ------- 

235 `contextlib.AbstractContextManager` [`FitsInputArchive`] 

236 A context manager that returns a `FitsInputArchive` when entered. 

237 """ 

238 path = ResourcePath(path) 

239 stream: IO[bytes] 

240 if not partial: 

241 stream = io.BytesIO(path.read()) 

242 yield cls(stream) 

243 else: 

244 fs: fsspec.AbstractFileSystem 

245 fs, fp = path.to_fsspec() 

246 # Cap cached blocks from the byte budget so memory stays bounded as 

247 # the block size is retuned; keep at least two so the shared 

248 # header/index block survives between a file's components. 

249 maxblocks = max(2, READ_CACHE_MAX_BYTES // page_size) 

250 with fs.open( 

251 fp, 

252 block_size=page_size, 

253 cache_type=_READ_CACHE_TYPE, 

254 cache_options={"maxblocks": maxblocks}, 

255 ) as stream: 

256 yield cls(stream) 

257 

258 @property 

259 def info(self) -> ArchiveInfo: 

260 """Schema/format info read from the primary header on open. 

261 (`.serialization.ArchiveInfo`) 

262 """ 

263 if self._info is None: 263 ↛ 264line 263 didn't jump to line 264 because the condition on line 263 was never true

264 raise ArchiveReadError("This is not an lsst.images FITS archive (no DATAMODL card).") 

265 return self._info 

266 

267 def get_tree[T: ArchiveTree](self, model_type: type[T]) -> T: 

268 """Read the JSON tree from the archive. 

269 

270 Parameters 

271 ---------- 

272 model_type 

273 A Pydantic model type to use to validate the JSON. 

274 

275 Returns 

276 ------- 

277 T 

278 The validated Pydantic model. 

279 """ 

280 json_bytes = self._readers[ExtensionKey(JSON_EXTNAME)].data[0][JSON_COLUMN].tobytes() 

281 return model_type.model_validate_json(json_bytes) 

282 

283 def deserialize_pointer[U: ArchiveTree, V]( 

284 self, 

285 pointer: PointerModel, 

286 model_type: type[U], 

287 deserializer: Callable[[U, InputArchive[PointerModel]], V], 

288 ) -> V: 

289 # Docstring inherited. 

290 if (cached := self._deserialized_pointer_cache.get(pointer.row)) is not None: 

291 return cached 

292 if not isinstance(pointer.column.data, ArrayReferenceModel): 

293 raise ArchiveReadError(f"Invalid pointer with inline array:\n{pointer.model_dump_json(indent=2)}") 

294 _, reader = self._get_source_reader(pointer.column.data.source, is_table=True) 

295 try: 

296 json_bytes = reader.data[pointer.row][JSON_COLUMN].tobytes() 

297 except Exception as err: 

298 raise InvalidFitsArchiveError( 

299 f"Failed to access the table cell referenced by {pointer.model_dump_json()}." 

300 ) from err 

301 result = deserializer(model_type.model_validate_json(json_bytes), self) 

302 self._deserialized_pointer_cache[pointer.row] = result 

303 return result 

304 

305 def get_frame_set(self, ref: PointerModel) -> FrameSet: 

306 try: 

307 result = self._deserialized_pointer_cache[ref.row] 

308 except KeyError: 

309 raise AssertionError( 

310 f"Frame set at {ref.model_dump_json(indent=2)} must be deserialized " 

311 "before any dependent transform can be." 

312 ) from None 

313 if not isinstance(result, FrameSet): 

314 raise InvalidFitsArchiveError(f"Expected a FrameSet instance at {ref.model_dump_json(indent=2)}.") 

315 return result 

316 

317 def get_array( 

318 self, 

319 model: ArrayReferenceModel | InlineArrayModel, 

320 *, 

321 slices: tuple[slice, ...] | EllipsisType = ..., 

322 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates, 

323 ) -> np.ndarray: 

324 if not isinstance(model, ArrayReferenceModel): 324 ↛ 325line 324 didn't jump to line 325 because the condition on line 324 was never true

325 raise ArchiveReadError("Inline array found where a reference array was expected.") 

326 key, reader = self._get_source_reader(model.source, is_table=False) 

327 if slices is not ...: 

328 array = reader.section[slices] 

329 else: 

330 array = reader.data 

331 if key not in self._opaque_metadata.headers: 331 ↛ 335line 331 didn't jump to line 335 because the condition on line 331 was always true

332 opaque_header = reader.header.copy(strip=True) 

333 strip_header(opaque_header) 

334 self._opaque_metadata.add_header(opaque_header, key=key) 

335 return array 

336 

337 def get_table( 

338 self, 

339 model: TableModel, 

340 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates, 

341 ) -> astropy.table.Table: 

342 # Docstring inherited. 

343 array = self.get_structured_array(model, strip_header) 

344 table = astropy.table.Table(array) 

345 for c in model.columns: 

346 c.update_table(table) 

347 return table 

348 

349 def get_structured_array( 

350 self, 

351 model: TableModel, 

352 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates, 

353 ) -> np.ndarray: 

354 # Docstring inherited. 

355 if not isinstance(model.columns[0].data, ArrayReferenceModel): 

356 raise ArchiveReadError("Inline array found where a reference array was expected.") 

357 # All columns should have the same data.source; just use the first. 

358 key, reader = self._get_source_reader(model.columns[0].data.source, is_table=True) 

359 if key not in self._opaque_metadata.headers: 

360 opaque_header = reader.header.copy(strip=True) 

361 strip_header(opaque_header) 

362 self._opaque_metadata.add_header(opaque_header, key=key) 

363 return reader.hdu.data 

364 

365 def get_opaque_metadata(self) -> FitsOpaqueMetadata: 

366 # Docstring inherited. 

367 return self._opaque_metadata 

368 

369 def _get_source_reader(self, source: str | int, is_table: bool) -> tuple[ExtensionKey, _ExtensionReader]: 

370 """Get a reader for the extension referenced by a serialiation model's 

371 ``source`` field. 

372 

373 Parameters 

374 ---------- 

375 source 

376 A ``source`` field of the form ``fits:${hdu}`` or 

377 ``fits:${hdu}[${col}]``. 

378 is_table 

379 Whether the source should be for a table HDU. 

380 

381 Returns 

382 ------- 

383 key 

384 Identifier pair for the HDU (EXTNAME, EXTVER). 

385 reader 

386 A reader object for the extension. 

387 """ 

388 if not isinstance(source, str): 388 ↛ 389line 388 didn't jump to line 389 because the condition on line 388 was never true

389 raise InvalidFitsArchiveError(f"Reference with source={source!r} is not a string.") 

390 if not source.startswith("fits:"): 390 ↛ 391line 390 didn't jump to line 391 because the condition on line 390 was never true

391 raise InvalidFitsArchiveError(f"Reference with source={source!r} does not start with 'fits:'.") 

392 key = ExtensionKey.from_str(source) 

393 try: 

394 reader = self._readers[key] 

395 except KeyError: 

396 raise InvalidFitsArchiveError(f"Unrecognized source value {key}.") from None 

397 if is_table and not reader.is_table: 397 ↛ 398line 397 didn't jump to line 398 because the condition on line 397 was never true

398 raise InvalidFitsArchiveError( 

399 f"Extension with source={key} was expected to be be a binary table, not an image." 

400 ) 

401 elif not is_table and reader.is_table: 401 ↛ 402line 401 didn't jump to line 402 because the condition on line 401 was never true

402 raise InvalidFitsArchiveError( 

403 f"Extension with source={key} was expected to be be an image, not a binary table." 

404 ) 

405 return key, reader 

406 

407 

408class _ExtensionReader: 

409 """A lazy-load reader for a single extension HDU. 

410 

411 Parameters 

412 ---------- 

413 hdu_cls 

414 The type of the astropy HDU instance to construct. 

415 stream 

416 The file-like object to read from. 

417 """ 

418 

419 def __init__(self, hdu_cls: type[ExtensionHDU], stream: IO[bytes]) -> None: 

420 self._hdu_cls = hdu_cls 

421 self._stream = stream 

422 

423 @classmethod 

424 def from_index_row(cls, index_row: np.void, stream: IO[bytes]) -> _ExtensionReader: 

425 """Construct from a row of the binary table index HDU. 

426 

427 Parameters 

428 ---------- 

429 index_row 

430 A record array row from the index HDU. 

431 stream 

432 The file-like object being used to read the full FITS file. 

433 

434 Returns 

435 ------- 

436 reader 

437 A reader object for the extension. 

438 """ 

439 match index_row["XTENSION"].strip(): 

440 case "IMAGE": 

441 hdu_cls = astropy.io.fits.ImageHDU 

442 case "BINTABLE": 442 ↛ 447line 442 didn't jump to line 447 because the pattern on line 442 always matched

443 if index_row["ZIMAGE"]: 

444 hdu_cls = astropy.io.fits.CompImageHDU 

445 else: 

446 hdu_cls = astropy.io.fits.BinTableHDU 

447 case other: 

448 raise AssertionError(f"Unsupported HDU type {other!r}.") 

449 return _ExtensionReader( 

450 hdu_cls, 

451 _RangeStreamProxy( 

452 stream, 

453 start=int(index_row["HDRADDR"]), 

454 ), 

455 ) 

456 

457 @classmethod 

458 def from_bytes(cls, hdu_cls: type[ExtensionHDU], data: bytes) -> _ExtensionReader: 

459 """Construct from already-read `bytes` 

460 

461 Parameters 

462 ---------- 

463 hdu_cls 

464 The HDU type to instantiate. 

465 data 

466 Raw data for the HDU. 

467 

468 Returns 

469 ------- 

470 reader 

471 A reader object for extension. 

472 """ 

473 return _ExtensionReader(hdu_cls, io.BytesIO(data)) 

474 

475 @property 

476 def is_table(self) -> bool: 

477 """Whether this is logically a table HDU. 

478 

479 This is `False` for compressed image HDUs, even though they are 

480 represented in FITS as a binary table. 

481 """ 

482 return issubclass(self._hdu_cls, astropy.io.fits.BinTableHDU) and not issubclass( 

483 self._hdu_cls, astropy.io.fits.CompImageHDU 

484 ) 

485 

486 @cached_property 

487 def hdu(self) -> ExtensionHDU: 

488 """The Astropy HDU object.""" 

489 self._stream.seek(0) 

490 if self._hdu_cls is astropy.io.fits.CompImageHDU: 

491 # CompImageHDU.readfrom doesn't work; we need to make a minimal 

492 # example and report it upstream. Happily this workaround does 

493 # work. 

494 bintable_hdu = astropy.io.fits.BinTableHDU.readfrom(self._stream, memmap=False, cache=False) 

495 return self._hdu_cls(bintable=bintable_hdu) 

496 else: 

497 return self._hdu_cls.readfrom(self._stream, memmap=False, cache=False, uint=True) 

498 

499 @property 

500 def header(self) -> astropy.io.fits.Header: 

501 """The header of the HDU.""" 

502 return self.hdu.header 

503 

504 @property 

505 def data(self) -> np.ndarray: 

506 """The data for the HDU.""" 

507 return self.hdu.data 

508 

509 @property 

510 def section(self) -> astropy.io.fits.Section | astropy.io.fits.CompImageSection: 

511 """An Astropy expression object that reads a subset of the data when 

512 sliced. 

513 """ 

514 return self.hdu.section 

515 

516 

517class _RangeStreamProxy(IO[bytes]): 

518 """A readable IO proxy object that makes the beginning of the file appear 

519 at a custom position. 

520 

521 Parameters 

522 ---------- 

523 base 

524 Underlying readable, seekable buffer to proxy. 

525 start 

526 Offset into the base stream that will be considered the start of the 

527 proxy stream. 

528 

529 Notes 

530 ----- 

531 This class exists because Astropy doesn't seem to provide a way to read a 

532 single HDU that starts at the current seek position of a file-like object. 

533 It does provide ``readfrom`` methods on its HDU objects that take a 

534 file-like object, but these assume (possibly unintentionally; it only 

535 happens when Astropy is trying to see whether the file was opened for 

536 appending) that ``seek(0)`` will set the file-like object to the start of 

537 the HDU. 

538 """ 

539 

540 def __init__(self, base: IO[bytes], start: int) -> None: 

541 self._base = base 

542 self._start = start 

543 

544 @property 

545 def mode(self) -> str: 

546 return "rb" 

547 

548 def __enter__(self) -> Self: 

549 raise AssertionError("This proxy should not be used as a context manager.") 

550 

551 def __exit__(self, type: Any, value: Any, traceback: Any) -> None: 

552 raise AssertionError("This proxy should not be used as a context manager.") 

553 

554 def __iter__(self) -> Iterator[bytes]: 

555 return self._base.__iter__() 

556 

557 def __next__(self) -> bytes: 

558 return self._base.__next__() 

559 

560 def close(self) -> None: 

561 raise AssertionError("This proxy should not ever be closed.") 

562 

563 @property 

564 def closed(self) -> bool: 

565 return False 

566 

567 def fileno(self) -> int: 

568 raise OSError() 

569 

570 def flush(self) -> None: 

571 pass 

572 

573 def isatty(self) -> bool: 

574 return False 

575 

576 def read(self, n: int = -1, /) -> bytes: 

577 result = self._base.read(n) 

578 return result 

579 

580 def readable(self) -> bool: 

581 return True 

582 

583 def readline(self, limit: int = -1, /) -> bytes: 

584 return self._base.readline(limit) 

585 

586 def readlines(self, hint: int = -1, /) -> list[bytes]: 

587 return self._base.readlines(hint) 

588 

589 def seek(self, offset: int, whence: int = 0) -> int: 

590 match whence: 

591 case os.SEEK_SET: 

592 return self._base.seek(offset + self._start, os.SEEK_SET) - self._start 

593 case os.SEEK_CUR: 593 ↛ 594line 593 didn't jump to line 594 because the pattern on line 593 never matched

594 return self._base.seek(offset, os.SEEK_CUR) - self._start 

595 case os.SEEK_END: 595 ↛ 597line 595 didn't jump to line 597 because the pattern on line 595 always matched

596 return self._base.seek(offset, os.SEEK_END) - self._start 

597 raise TypeError(f"Invalid value for 'whence': {whence}.") 

598 

599 def seekable(self) -> bool: 

600 return True 

601 

602 def tell(self) -> int: 

603 return self._base.tell() - self._start 

604 

605 def truncate(self, size: int | None = None, /) -> int: 

606 raise OSError() 

607 

608 def writable(self) -> bool: 

609 return False 

610 

611 def write(self, arg: Any, /) -> int: 

612 raise OSError() 

613 

614 def writelines(self, arg: Any, /) -> None: 

615 raise OSError()