okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
1f92415
main
1"""One honest output surface for captured local and SSH command-mode jobs.""" 2 3from __future__import annotations 4 5import os 6from pathlib import Path 7import tempfile 8 9from albedo_api import OUTPUT_PREVIEW ,RETAIN ,OutputCapture ,Text ,excerpt 10 11 12def _retention_error (capture :OutputCapture )-> ValueError : 13spill = getattr (capture ,"spill" ,None ) 14recovery = ( 15f"The retained spill file is { spill } ; it does not contain the discarded suffix. " 16if spill 17else "" 18 ) 19recovery += ( 20"For job artifacts beyond retention, rerun with " 21"job.pipe('tee', path) attached before awaiting." 22 ) 23return ValueError ( 24f"output has { capture . seen } bytes, but only the first { capture . retained } " 25"are retained; refusing incomplete output. " 26"Paginate the retained prefix with job.read(offset=0, limit=65536) " 27"or output.read(id, offset=0, limit=65536), advancing the byte offset. " 28"Bytes beyond retention cannot be recovered by pagination. " + recovery 29 ) 30 31 32def read_page (capture :OutputCapture ,offset :int ,limit :int )-> Text : 33if offset < 0 or not 0 <= limit <= OUTPUT_PREVIEW : 34raise ValueError ( 35f"offset >= 0 and 0 <= limit <= { OUTPUT_PREVIEW } ; page with offset=" 36 ) 37if not limit : 38return Text ("" ) 39if min (offset + limit ,capture .seen )> capture .retained : 40raise _retention_error (capture ) 41return Text (capture .read (offset ,limit )) 42 43 44class JobOutput : 45capture :OutputCapture 46duration :float | None 47exit_code :int | None 48_read :bool 49 50def tail (self ,n :int = 4000 ,* ,lines :int | None = None )-> Text : 51"""The last n characters or lines, within the 64 KiB preview window.""" 52data = self .capture .tail () 53result = excerpt ( 54data .decode ("utf-8" ,errors = "ignore" ), 55n , 56lines , 57end = True , 58clipped = self .capture .seen > len (data ), 59 ) 60if self .exit_code is not None : 61self ._read = True 62return result 63 64def head (self ,n :int = 4000 ,* ,lines :int | None = None )-> Text : 65"""The first n characters or lines, within the 64 KiB preview window.""" 66data = self .capture .data [:OUTPUT_PREVIEW ] 67result = excerpt ( 68data .decode ("utf-8" ,errors = "ignore" ), 69n , 70lines , 71end = False , 72clipped = self .capture .seen > len (data ), 73 ) 74if self .exit_code is not None : 75self ._read = True 76return result 77 78def _require_complete (self )-> None : 79if self .duration is None : 80raise RuntimeError ( 81"output is still running; await job before job.read() or job.save(path)" 82 ) 83if self .capture .seen > self .capture .retained : 84raise _retention_error (self .capture ) 85 86def read (self ,offset :int = 0 ,limit :int | None = None )-> Text : 87"""Complete text after completion, or a byte-offset page with explicit limit. 88 89Whole text reads are at most 1 MiB. Pages are at most 64 KiB, read spill 90files transparently, and may read a running job. save() preserves bytes. 91""" 92if limit is None : 93if offset < 0 : 94raise ValueError ("offset must be nonnegative" ) 95self ._require_complete () 96size = max (0 ,self .capture .seen - offset ) 97if size > RETAIN : 98raise ValueError ( 99"whole text reads are limited to 1 MiB; paginate with " 100"job.read(offset=0, limit=65536), advancing the byte offset, " 101"or job.save(path) for the complete artifact. " 102"Output past 1 MiB is automatically stored on disk (up to 16 MiB)." 103 ) 104result = Text ( 105self .capture .read_bytes (offset ,size ).decode ("utf-8" ,errors = "replace" ) 106 ) 107else : 108result = read_page (self .capture ,offset ,limit ) 109if self .duration is not None : 110self ._read = True 111return result 112 113def save (self ,path :str | os .PathLike [str ])-> Text : 114"""Atomically replace path with complete output bytes, or leave it untouched. 115 116The path is on the capture's host (local in SSH command mode). Returns 117its absolute path. Completion and full retention are required. 118""" 119self ._require_complete () 120target = Path (path ).expanduser ().absolute () 121target .parent .mkdir (parents = True ,exist_ok = True ) 122fd ,name = tempfile .mkstemp (dir = target .parent ) 123temporary = Path (name ) 124try : 125with os .fdopen (fd ,"wb" )as file : 126for offset in range (0 ,self .capture .seen ,OUTPUT_PREVIEW ): 127file .write (self .capture .read_bytes (offset ,OUTPUT_PREVIEW )) 128os .replace (temporary ,target ) 129finally : 130temporary .unlink (missing_ok = True ) 131self ._read = True 132return Text (str (target ))