Coverage for core / src / sensorkit / data / filesys.py: 91%

198 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-02 00:03 +0000

1# SPDX-License-Identifier: Apache-2.0 

2import asyncio 

3import contextlib 

4import os 

5import pathlib 

6from collections.abc import AsyncGenerator 

7from fnmatch import fnmatch 

8from typing import Literal 

9 

10import aiofile 

11from loguru import logger 

12from pydantic import BaseModel 

13 

14from sensorkit.common.aio import cleanup_future 

15from sensorkit.common.filewatch import FileEventKind, wait_for_file, watch_dir 

16from sensorkit.common.keyword import declare_keyword 

17from sensorkit.data.graph import Context, DataFlow, DataOp, SourceOp 

18from sensorkit.data.streams import StreamReader, StreamWriter 

19 

20 

21@declare_keyword 

22class FileNameTemplate(BaseModel): 

23 """Template for file naming.""" 

24 template: str 

25 

26 

27@declare_keyword 

28class FileInfo(BaseModel): 

29 """Information about a file on disk.""" 

30 path: pathlib.Path 

31 size: int | None = None 

32 create_time: float | None = None 

33 access_time: float | None = None 

34 change_time: float | None = None 

35 

36 @classmethod 

37 async def from_path(cls, path: pathlib.Path, **other): 

38 """Create a FileInfo from a local path. 

39 

40 Populates: path, size, create_time, access_time, change_time. 

41 Additional fields can be provided via keyword arguments. 

42 """ 

43 stat = await asyncio.to_thread(path.stat) 

44 

45 return cls.model_construct( 

46 path=path, 

47 size=stat.st_size, 

48 create_time=stat.st_ctime, 

49 access_time=stat.st_atime, 

50 change_time=stat.st_mtime, 

51 **other, 

52 ) 

53 

54 

55async def wait_until_stable(path: pathlib.Path, settle: float) -> bool: 

56 """Wait until `path` stops growing, and report whether it still exists. 

57 

58 A file becomes visible when the producer creates it, not when it finishes writing it, 

59 so reading on first sight yields a truncated result. Polling `st_size` until two reads 

60 `settle` apart agree infers the producer is done. 

61 

62 This is best-effort and only covers producers that extend a file as they write. It 

63 cannot help against one that creates the file at its final size and fills it in place, 

64 where the size never changes. Producers that can publish atomically -- write to a 

65 temporary name, then rename -- should do so instead and leave `settle` at 0. 

66 

67 Returns: 

68 False if the file vanished while settling, True otherwise. 

69 """ 

70 if settle <= 0: 

71 return True 

72 

73 last = -1 

74 

75 while True: 

76 try: 

77 size = (await asyncio.to_thread(path.stat)).st_size 

78 except OSError: 

79 return False 

80 

81 if size == last: 

82 return True 

83 

84 last = size 

85 await asyncio.sleep(settle) 

86 

87 

88class WatchDirectory(SourceOp): 

89 """DataGraph source that triggers a run for each new file in a directory. 

90 

91 Watches a directory with a filesystem observer and, for every newly appeared file 

92 whose name matches, starts one graph run seeded with a fresh Context describing that 

93 file. 

94 

95 Attributes: 

96 directory: Path of the directory to watch. Created if it does not exist. 

97 match: Glob pattern a filename must match to trigger a run. 

98 recursive: Whether to watch subdirectories as well. 

99 

100 Supplies context: 

101 FileInfo: Names the appeared file. Only `path` is set: the file may still be 

102 being written, so stat metadata is left for whoever reads it (`ReadFile` 

103 populates it once the contents are settled). 

104 """ 

105 op: Literal["watch_directory"] = "watch_directory" 

106 directory: str 

107 match: str = "*" 

108 recursive: bool = False 

109 

110 async def graph_source(self) -> AsyncGenerator[None, DataFlow]: 

111 logger.debug(f"starting WatchDirectory source at {self.directory}") 

112 await asyncio.to_thread(pathlib.Path(self.directory).mkdir, parents=True, exist_ok=True) 

113 

114 async with watch_dir( 

115 self.directory, 

116 recursive=self.recursive, 

117 kinds=(FileEventKind.CREATED, FileEventKind.MOVED), 

118 ) as events: 

119 while True: 

120 # Wait for the graph to be ready. 

121 edge = yield 

122 

123 # Wait for a matching file to appear. 

124 async for event in events: 

125 if fnmatch(event.path.name, self.match): 

126 path = event.path 

127 break 

128 

129 logger.debug(f"file {path} appeared") 

130 

131 try: 

132 await edge.send( 

133 Context(FileInfo(path=path)), 

134 b"", 

135 ) 

136 except Exception: 

137 logger.exception("error sending file path to graph") 

138 

139 

140class WriteFile(DataOp): 

141 """DataGraph node that writes incoming stream data to a file on disk. 

142 

143 If an outgoing edge is connected, the data is forwarded to it as well. 

144 

145 Attributes: 

146 directory: Base output directory, resolved against context (e.g. 

147 "{program_name}"). When None, relative paths and templates resolve under the 

148 current working directory. 

149 max_chunk_size: Maximum number of bytes read from the stream per chunk. 

150 

151 Expects context: 

152 FileInfo (optional): An explicit or pre-resolved output path. A relative path is 

153 taken under `directory`; an absolute path is used as-is, and is an error if 

154 `directory` is also set. 

155 FileNameTemplate (optional): A naming template resolved into a name under 

156 `directory`, used only when no FileInfo is present. 

157 

158 One of FileInfo or FileNameTemplate must be present. 

159 

160 Supplies context: 

161 FileInfo: Created from FileNameTemplate when no FileInfo was present. 

162 

163 Mutates context: 

164 FileInfo: Path rewritten to the resolved output location. 

165 

166 Raises: 

167 ValueError: If neither FileInfo nor FileNameTemplate is present, or if 

168 the resolved path is absolute while `directory` is also set. 

169 """ 

170 op: Literal["write_file"] = "write_file" 

171 directory: str | None = None 

172 max_chunk_size: int = 2**16 

173 

174 async def process(self, incoming: list[DataFlow], outgoing: list[DataFlow]): 

175 assert len(outgoing) <= 1 

176 

177 # Read incoming data as a stream. 

178 context, reader = await incoming[0].receive("stream") 

179 

180 # Resolve the configured base directory against context values (e.g. {program_name}). 

181 base = ( 

182 pathlib.Path(context.resolve(self.directory, as_type=str)) 

183 if self.directory is not None 

184 else pathlib.Path.cwd() 

185 ) 

186 info = context.get(FileInfo) 

187 

188 if info is None: 

189 # No FileInfo, so look for a FileNameTemplate to resolve the name. 

190 template = context.get(FileNameTemplate) 

191 

192 if template is None: 

193 raise ValueError("WriteFile requires a FileInfo or FileNameTemplate in context") 

194 

195 info = FileInfo(path=pathlib.Path(context.resolve(template.template, as_type=str))) 

196 context.set(info) 

197 

198 # Make sure we don't have two absolute paths. 

199 if info.path.is_absolute(): 

200 if self.directory is not None: 

201 raise ValueError( 

202 f"ambiguous write location: FileInfo.path is absolute ({info.path}) " 

203 f"but WriteFile.directory is also set ({self.directory!r})" 

204 ) 

205 else: 

206 # Update the path, applying our base directory. 

207 info.path = base / info.path 

208 

209 # Ensure the output directory exists before opening the writer. 

210 await asyncio.to_thread(info.path.parent.mkdir, parents=True, exist_ok=True) 

211 

212 # Get the file writer. 

213 writers: list[StreamWriter] = [await get_file_writer(info.path)] 

214 

215 if outgoing: 

216 # If there is a single outgoing edge, write to that too. 

217 writers.append(await outgoing[0].send(context)) 

218 

219 while not reader.at_eof(): 

220 chunk = await reader.read(self.max_chunk_size) 

221 

222 # FIXME: aiofile requires bytes, which forces us into an unnecessary copy 

223 if not isinstance(chunk, bytes): 

224 chunk = bytes(chunk) 

225 

226 for writer in writers: 

227 writer.write(chunk) 

228 

229 await asyncio.gather(*(writer.drain() for writer in writers)) 

230 for writer in writers: 

231 writer.close() 

232 

233 await asyncio.gather(*(writer.wait_closed() for writer in writers)) 

234 

235 

236class ReadFile(DataOp): 

237 """DataGraph node that reads a file from disk into a downstream stream. 

238 

239 Opens the file named by FileInfo.path and streams its contents to the single 

240 outgoing edge. The incoming data is expected to be empty. 

241 

242 Attributes: 

243 max_chunk_size: Maximum number of bytes read from the file per chunk. 

244 wait_for_file: Whether to wait for the file to appear before reading. 

245 wait_for_file_timeout: Seconds to wait for the file when `wait_for_file` is set. 

246 settle_seconds: Interval between the size polls that decide a file still being 

247 written has finished. Costs one interval of latency per read. Set to 0 when 

248 the producer publishes atomically, or when reading files known to be complete. 

249 

250 Expects context: 

251 FileInfo: Provides path, the location of the file to read. 

252 

253 Mutates context: 

254 FileInfo: Replaced with stat metadata (size and timestamps) read from disk when 

255 FileInfo.size was not already populated. 

256 """ 

257 op: Literal["read_file"] = "read_file" 

258 max_chunk_size: int = 2**16 

259 wait_for_file: bool = False 

260 wait_for_file_timeout: float = 5.0 

261 settle_seconds: float = 0.2 

262 

263 async def process(self, incoming: list[DataFlow], outgoing: list[DataFlow]): 

264 assert len(outgoing) == 1 

265 

266 # We expect input data to be empty. 

267 context, buffer = await incoming[0].receive("buffer") 

268 assert len(buffer) == 0 

269 

270 # The path to the file must be present in context. 

271 info = context[FileInfo] 

272 

273 # Get the file reader. 

274 reader = await get_file_reader( 

275 info.path, 

276 self.max_chunk_size, 

277 wait_until_exists=self.wait_for_file_timeout if self.wait_for_file else None, 

278 settle=self.settle_seconds, 

279 ) 

280 

281 # Populate stat metadata if it has not been determined yet. The file is known to 

282 # exist at this point, since get_file_reader has opened (or waited for) it. 

283 if info.size is None: 

284 context.set(await FileInfo.from_path(info.path)) 

285 

286 # Send to the graph. 

287 writer = await outgoing[0].send(context) 

288 read_count = 0 

289 

290 while not reader.at_eof(): 

291 chunk = await reader.read(self.max_chunk_size) 

292 read_count += len(chunk) 

293 writer.write(chunk) 

294 

295 logger.debug(f"ReadFile read {read_count//1024} KB from {info.path}") 

296 writer.close() 

297 await writer.wait_closed() 

298 

299 

300async def get_file_reader( 

301 path: pathlib.Path, 

302 read_size: int, 

303 wait_until_exists: float | None = None, 

304 settle: float = 0.0, 

305) -> StreamReader: 

306 """Return a StreamReader that asynchronously feeds file content in the background. 

307 

308 If *wait_until_exists* is set, waits up to that many seconds for the file to appear. 

309 If *settle* is set, waits for the contents to stop growing before opening, so a file 

310 caught mid-write is not read truncated (see `wait_until_stable`). 

311 """ 

312 if wait_until_exists is not None: 

313 async with asyncio.timeout(wait_until_exists): 

314 # Wait until the file exists before opening. 

315 await wait_for_file(path) 

316 

317 await wait_until_stable(path, settle) 

318 

319 ctx = contextlib.AsyncExitStack() 

320 f = await ctx.enter_async_context(aiofile.async_open(path, "rb")) 

321 reader = asyncio.StreamReader() 

322 

323 async def feed_data(): 

324 async with ctx: 

325 while True: 

326 chunk = await f.read(read_size) 

327 

328 if not chunk: 

329 break 

330 

331 reader.feed_data(chunk) 

332 

333 # Send the EOF signal after the file has closed. 

334 reader.feed_eof() 

335 

336 # Start feeding data in the background. 

337 task = asyncio.create_task(feed_data()) 

338 task.add_done_callback(cleanup_future) 

339 ctx.callback(lambda: task) # Prevent task GC until done 

340 

341 return reader 

342 

343 

344async def get_file_writer(path: pathlib.Path) -> StreamWriter: 

345 """Return a StreamWriter that asynchronously writes to a file, renaming from a .tmp path on close.""" 

346 return await FileAsyncWriter.open(path) 

347 

348 

349class FileAsyncWriter: 

350 """Write a file asynchronously using the StreamWriter protocol.""" 

351 

352 @classmethod 

353 async def open(cls, path: pathlib.Path): 

354 """Open a new FileAsyncWriter that writes to *path* via a temporary .tmp file.""" 

355 temp_path = path.with_suffix(".tmp") 

356 file = await aiofile.async_open(temp_path, mode="wb") 

357 writer = FileAsyncWriter(temp_path, file, path) 

358 writer.start() 

359 return writer 

360 

361 def __init__( 

362 self, 

363 path: pathlib.Path, 

364 file: aiofile.FileIOWrapperBase, 

365 target_path: pathlib.Path | None = None, 

366 ): 

367 self._path = path 

368 self._target_path = target_path 

369 self._file = file 

370 self._queue: asyncio.Queue[bytes] = asyncio.Queue() 

371 self._drained = asyncio.Event() 

372 self._close_requested = False 

373 

374 def start(self): 

375 """Start the background write task.""" 

376 self._task = asyncio.create_task(self._write_task()) 

377 

378 async def _write_task(self): 

379 try: 

380 while True: 

381 # Get the next data chunk to write and write it. 

382 data = await self._queue.get() 

383 await self._file.write(data) 

384 self._queue.task_done() 

385 

386 # Set the drained signal as appropriate. 

387 if self._queue.empty(): 

388 self._drained.set() 

389 except Exception: 

390 logger.exception("error writing to file") 

391 finally: 

392 # When we reach here, either: 

393 # 

394 # 1. The task was cancelled due a call to close() -- this is the happy path. 

395 # 2. The task was cancelled externally, e.g. interpreter shutdown. 

396 # 3. An error occurred while writing data. 

397 # 

398 # In all cases we want to try to close the file (which may be partially written in the 

399 # error case) and move it to its target path. 

400 self._close_requested = True 

401 

402 with contextlib.suppress(asyncio.QueueEmpty): 

403 while data := self._queue.get_nowait(): 

404 await self._file.write(data) 

405 self._queue.task_done() 

406 

407 self._queue.shutdown() 

408 self._drained.set() 

409 

410 try: 

411 async with asyncio.timeout(5.0): 

412 logger.debug(f"closing file {self._target_path or self._path}") 

413 await self._file.close() 

414 

415 if self._target_path: 

416 await asyncio.to_thread( 

417 os.rename, 

418 self._path, 

419 self._target_path, 

420 ) 

421 except BaseException as e: 

422 final_path = self._target_path or self._path 

423 logger.warning(f"error finalizing {final_path}: {str(e)}") 

424 

425 def write(self, data: bytes): 

426 if self._close_requested: 

427 raise RuntimeError("writer is closing or closed") 

428 

429 self._queue.put_nowait(data) 

430 self._drained.clear() 

431 

432 async def drain(self): 

433 await self._drained.wait() 

434 

435 def close(self): 

436 self._close_requested = True 

437 

438 # We leverage task cancellation to trigger closing and finalization of the output file. 

439 self._task.cancel() 

440 

441 def is_closing(self): 

442 return self._close_requested and not self._task.done() 

443 

444 async def wait_closed(self): 

445 with contextlib.suppress(asyncio.CancelledError): 

446 await self._task