chronaxis.sources.raw_csv
源码基线:8904c2f744951857659abd40aaa3535d57faf53c
1"""直接把原始报文CSV/TXT读成带原行定位的CAN记录,不经过ASC中间文件。23CANCSVSpec使用不可变的原始报文列布局并声明时间/进制/通道起点。CANCSVRead只读打开一次,4逐记录经标准库CSV语法解析后提取所需列,严格核时间、ID、帧型、DLC和载荷。5CSV记录可以跨物理行,但整条记录字节数有界;任何错误停止并关闭,不静默跳过。6数字时间必须明确单位,日内时间默认禁止回退。clock-ms-us-v1明确把最后两段7分别解释为毫秒和微秒,例如.151.2为.151002秒;普通clock不自动采用这一规则。8模块不加载DBC/旧reader,不解码或保存整文件,已返回记录的长期保留由消费层计费。9"""1011import codecs12import csv13from dataclasses import dataclass, field14from datetime import timezone, timedelta15from decimal import Decimal, localcontext16from pathlib import Path17import re1819from ..legacy.records import LegacyRawFrame20from ..results.budget import ResourceBudget, _reserve_owned_ram, _release_owned_ram, _cleanup_owner21from ..timebase.conversion import convert_time, checked_add_ns, parse_datetime_ns, require_int64_ns22from .raw_can import RawCANRecord, CANTextError23from .raw_csv_layout import CANCSVLayout, MAX_COLUMNS2425# 同一表记录即使引号未闭合也不能无限累积;预算按整条记录的最坏文本/分列开销预约。26MAX_CSV_RECORD_BYTES = 6553627DAY_NS = 86400 * 10**928FD_LENGTHS = (0,1,2,3,4,5,6,7,8,12,16,20,24,32,48,64)293031@dataclass(frozen=True)32class CSVTimeSpec:33 """声明时间文字含义,固定偏移只施加一次,不用电脑日期或时区补值。"""3435 format: str = 'numeric' # numeric、clock、clock-ms-us-v1或iso8601,必须显式选择。36 unit: str = 's' # numeric的s/ms/us/ns,其他格式只接受默认s,避免无效配置。37 offset_ns: int = 0 # 数值固定平移;日内时间可据明确原点映射到会话。38 timezone_offset_minutes: int | None = None # 仅ISO无时区文本的显式补充。39 rollover_min_drop_ns: int | None = None # 两种日内格式可选:回退至少此纳秒数才按次日解释。4041 def __post_init__(self):42 """校验时间政策。参数: 无。返回: None。异常: 类型、格式、单位、时区或午夜规则非法。43 """44 # 每种写法只采用一种含义;不依文字长度猜秒/毫秒,也不默认跨午夜。45 if self.format not in ('numeric','clock','clock-ms-us-v1','iso8601') or self.unit not in ('s','ms','us','ns'):46 raise ValueError('CSV时间格式或单位不支持')47 # 日内和ISO文字已有单位,不能再额外乘一次numeric单位。48 if self.format != 'numeric' and self.unit != 's':49 raise ValueError('非数值时间不能额外声明数值单位')50 # 偏移必须能进入纳秒时间轴;只有无时区ISO文字可补显式时区。51 require_int64_ns(self.offset_ns)52 zone = self.timezone_offset_minutes53 if zone is not None and (self.format != 'iso8601' or type(zone) is not int or not -1439 <= zone <= 1439):54 raise ValueError('只有ISO时间可声明整数分钟时区偏移')55 # 午夜只适用于日内时钟;阈值为严格正值且小于一天,避免把任意乱序当作换日。56 drop = self.rollover_min_drop_ns57 if drop is not None and (self.format not in ('clock','clock-ms-us-v1') or type(drop) is not int or not 0 < drop < DAY_NS):58 raise ValueError('跨午夜阈值需日内时间格式的0到一天之间整数纳秒')596061@dataclass(frozen=True)62class CANCSVSpec:63 """冻结单文件原始报文来源;布局/时间声明不打开输入或加载DBC。"""6465 path: Path # CSV/TXT只是文件名,不用于推断格式。66 layout: CANCSVLayout # 编码、分隔符、列/中文映射及明确固定值。67 time: CSVTimeSpec = field(default_factory=CSVTimeSpec) # 时间解释单独声明。68 id_base: int | str = 'auto' # 0x十六进制、0b二进制、无前缀十进制;裸十六进制表可显式选16。69 length_base: int = 10 # DLC/字节数列的明确进制,原始CSV的0x08应设16。70 channel_origin: int = 0 # 原列从0或1开始,统一转换为0基通道。71 payload_style: str = 'spaced' # spaced逐字节,或contiguous连续偶数位十六进制。7273 def __post_init__(self):74 """核静态来源契约。参数: 无。返回: None。异常: 声明类型或不支持选项。75 """76 if not isinstance(self.path,Path) or type(self.layout) is not CANCSVLayout or type(self.time) is not CSVTimeSpec:77 raise TypeError('CANCSVSpec需要Path、CANCSVLayout及CSVTimeSpec')78 # 逐物理行字节定位要求LF可单字节分隔,其他编码不能假报准确行号。79 if codecs.lookup(self.layout.encoding).name not in ('utf-8','utf-8-sig','ascii','gbk','cp1252','iso8859-1'):80 raise ValueError('原始CAN CSV当前不支持该逐行编码')81 # 进制与通道起点由配置决定,bool不能借Python的整数继承关系混入。82 if not (type(self.id_base) is str and self.id_base == 'auto'83 or type(self.id_base) is int and self.id_base in (10,16)):84 raise ValueError('CSV ID进制需要auto、10或16')85 if type(self.length_base) is not int or self.length_base not in (10,16):86 raise ValueError('CSV长度进制需要10或16')87 if type(self.channel_origin) is not int or self.channel_origin not in (0,1) or self.payload_style not in ('spaced','contiguous'):88 raise ValueError('CSV通道起点或载荷布局错误')899091def _uint(value, base, maximum, label):92 """解析明确进制的非负整数。参数: value文字/int;base进制;maximum上限;label定位。9394 返回: int。异常: ValueError为符号/字符/隐式转换或越界,不允许bool/下划线。95 """96 # 已映射整数直接校范围;文字仅允许声明进制的数字,不隐式接受float或bool。97 if type(value) is int:98 result = value99 elif type(value) is str:100 if base == 'auto':101 prefix = value[:2].lower()102 base = 16 if prefix == '0x' else 2 if prefix == '0b' else 10103 if prefix == '0b':104 value = value[2:]105 text = value[2:] if base == 16 and value[:2].lower() == '0x' else value106 pattern = r'[0-9a-fA-F]+' if base == 16 else r'[01]+' if base == 2 else r'[0-9]+'107 if not re.fullmatch(pattern,text):108 raise ValueError(label+'字符或进制无效')109 result = int(text,base)110 else:111 raise ValueError(label+'需要文字或整数')112 # 不按ID大小改变标准/扩展标志,也不截断超出字段范围的数值。113 if not 0 <= result <= maximum:114 raise ValueError(label+'越界')115 return result116117118def _payload(text, style):119 """严格读取十六进制载荷,不补零/截短。参数: text原文字、style布局。返回: bytes。异常: 格式/长度错误。120 """121 # 连续格式必须完整匹配偶数字符数,不能把缺半字节的文字补成一个字节。122 if style == 'contiguous':123 if len(text) > 128 or not re.fullmatch(r'(?:[0-9a-fA-F]{2})*',text):124 raise ValueError('连续载荷需不超过64字节的偶数位十六进制')125 return bytes.fromhex(text)126 # 每字节允许一或两位hex,内部空白分隔;不吞0x前缀、控制字段或额外坏字节。127 parts = text.split()128 if len(parts) > 64 or any(not re.fullmatch(r'[0-9a-fA-F]{1,2}',value) for value in parts):129 raise ValueError('分隔载荷需不超过64个十六进制字节')130 return bytes(int(value,16) for value in parts)131132133class CSVClock:134 """保存一次来源的时间进度,默认拒绝回退,显式阈值才把大幅日内回退解释为次日。"""135136 def __init__(self, spec):137 """只建立状态。参数: spec为CSVTimeSpec。返回: 时钟。异常: 类型错误。138 """139 if type(spec) is not CSVTimeSpec:140 raise TypeError('CSVClock需要CSVTimeSpec')141 self.spec, self._last_ns, self._last_clock_ns, self._day_offset = spec, None, None, 0142143 def parse(self, value):144 """按声明转换并推进。参数: value映射后的时间文字/int。返回: (int64纳秒,量化bool)。异常: 格式/时区/回退/越界。145 """146 # 失败前只修改局部候选状态,错误时间不能污染下一次显式调用。147 if type(value) not in (str,int) or type(value) is str and len(value) > MAX_CSV_RECORD_BYTES:148 raise ValueError('CSV时间需要有限文字或整数')149 day_offset,last_clock = self._day_offset,self._last_clock_ns150 if self.spec.format == 'numeric':151 result = convert_time(value,self.spec.unit)152 ns, quantized = result.time_ns, result.quantized153 elif self.spec.format == 'iso8601':154 # 仅用声明的时区补充无时区ISO输入,不读取执行电脑的时区。155 zone = self.spec.timezone_offset_minutes156 tz = None if zone is None else timezone(timedelta(minutes=zone))157 result = parse_datetime_ns(value,source_timezone=tz)158 ns, quantized = result.time_ns, result.quantized159 else:160 # 两种日内格式各自严格匹配;不能根据一行文字临时切换时间含义。161 if type(value) is not str:162 raise ValueError('日内时间需要明确时分秒文字')163 grouped = self.spec.format == 'clock-ms-us-v1'164 pattern = (r'([0-9]{2}):([0-9]{2}):([0-9]{2})\.([0-9]{1,3})\.([0-9]{1,3})'165 if grouped else r'(\d{2}):(\d{2}):(\d{2})(\.\d+)?')166 match = re.fullmatch(pattern,value)167 if match is None:168 expected = 'HH:MM:SS.毫秒.微秒,各末段为0到999' if grouped else 'HH:MM:SS[.小数];多段小数需显式clock-ms-us-v1'169 raise ValueError('日内时间需'+expected)170 hour,minute,second = (int(match[index]) for index in (1,2,3))171 if hour > 23 or minute > 59 or second > 59:172 raise ValueError('日内时分秒越界')173 if grouped:174 # 两段是各自的整数单位,不按字符拼接小数;微秒规则不引入float舍入。175 clock_ns = (hour*3600+minute*60+second)*10**9 + int(match[4])*10**6 + int(match[5])*1000176 quantized = False177 else:178 # 普通小数秒仍沿既有Decimal转纳秒协议,保留更细输入的量化事实。179 with localcontext() as context:180 context.prec = MAX_CSV_RECORD_BYTES + 32181 seconds = Decimal(hour*3600+minute*60+second) + Decimal(match[4] or '0')182 result = convert_time(seconds,'s')183 clock_ns, quantized = result.time_ns, result.quantized184 # 只在明确允许且回退量达到阈值时进入次日;小幅乱序仍是错误。185 if self._last_clock_ns is not None and clock_ns < self._last_clock_ns:186 drop = self.spec.rollover_min_drop_ns187 if drop is None or self._last_clock_ns-clock_ns < drop:188 raise ValueError('CSV日内时间回退,未满足明确跨午夜规则')189 day_offset = checked_add_ns(day_offset,DAY_NS)190 ns = checked_add_ns(day_offset,clock_ns)191 last_clock = clock_ns192 ns = checked_add_ns(ns,self.spec.offset_ns)193 # 排序在公开纳秒时间轴核对;更细的文本精度由quantized和raw_time明确保留。194 if self._last_ns is not None and ns < self._last_ns:195 raise ValueError('CSV来源时间回退')196 self._last_ns,self._day_offset,self._last_clock_ns = ns,day_offset,last_clock197 return ns,quantized198199200def _csv_frame(values, spec, time_ns, flag_resolver=None):201 """把已明确映射的列转换成原帧,不按载荷/ID大小猜帧型。202203 参数: values规范字段映射、spec来源声明、time_ns精确纳秒;flag_resolver仅补缺少的帧型。204 返回: (LegacyRawFrame, DLC编码或None)。异常: ID/通道/载荷/标志/DLC不一致。205 """206 # 布局已校验身份枚举;ID范围按显式标准/扩展标志决定,通道转为0基。207 fd,extended,frame_type = values.get('is_fd'),values.get('is_extended'),values['frame_kind']208 identifier = _uint(values['frame_id'],spec.id_base,0x1fffffff,'CSV ID')209 channel = _uint(values['channel'],10,65536,'CSV通道') - spec.channel_origin210 if channel < 0:211 raise ValueError('CSV通道低于明确起点')212 if fd is None or extended is None:213 if flag_resolver is None:214 raise ValueError('CSV缺少is_extended或is_fd;请补列映射,或通过带DBC的分析入口自动识别')215 extended, fd = flag_resolver(channel, identifier, extended, fd)216 if type(extended) is not bool or type(fd) is not bool or not extended and identifier > 0x7ff:217 raise ValueError('CSV报文ID与标准/扩展帧声明不符')218 # DLC编码和载荷字节数分别解析,CAN FD的编码9对应12字节,不能混用。219 data = _payload(values['data'],spec.payload_style)220 code = (_uint(values['dlc_code'],spec.length_base,15 if fd else 8,'CSV DLC编码')221 if 'dlc_code' in values else None)222 length = (_uint(values['payload_length'],spec.length_base,64 if fd else 8,'CSV载荷长度')223 if 'payload_length' in values else len(data))224 if len(data) != length:225 raise ValueError('CSV声明载荷长度与实际字节数不一致')226 if frame_type == 'remote':227 # 远程请求的DLC可非零,实际载荷必须为空;FD协议没有远程帧。228 if fd or data or code is None:229 raise ValueError('远程帧需经典CAN、空载荷和明确DLC编码')230 dlc = code231 elif frame_type == 'error':232 # 当前错误帧格式不接受数据载荷;不伪造普通数据帧或用它参与DBC解码。233 if data or code not in (None,0):234 raise ValueError('当前CSV错误帧需空载荷/零长度')235 code,dlc = None,0236 else:237 # 数据帧必须同时满足实际载荷长度和协议DLC;缺编码时才由长度导出。238 expected = FD_LENGTHS[code] if fd and code is not None else code239 if code is not None and expected != length:240 raise ValueError('CSV DLC编码与载荷长度不一致')241 if fd and length not in FD_LENGTHS:242 raise ValueError('CAN FD字节数不能对应合法DLC编码')243 if code is None:244 code = FD_LENGTHS.index(length) if fd else length245 dlc = length246 brs,esi = values.get('bitrate_switch',False),values.get('error_state_indicator',False)247 # FD专用标志不能套在经典帧上;float秒仅为既有原帧模型的兼容表示。248 if not fd and (brs or esi):249 raise ValueError('经典CAN不能携带FD BRS/ESI标志')250 return LegacyRawFrame(timestamp=time_ns/10**9,arbitration_id=identifier,is_extended_id=extended,251 is_remote_frame=frame_type=='remote',is_error_frame=frame_type=='error',channel=channel,252 dlc=dlc,data=data,is_fd=fd,is_rx=values.get('is_rx'),bitrate_switch=brs,253 error_state_indicator=esi),code254255256class _CSVLines:257 """把一次有界表记录的物理行交给标准CSV解析器,位置/字节累计由父reader持有。"""258259 def __init__(self, owner):260 """借用父对象。参数: owner为CANCSVRead。返回: 行迭代器。异常: 无。261 """262 self.owner = owner263264 def __iter__(self):265 """不重新打开文件。参数: 无。返回: self。异常: 无。266 """267 return self268269 def __next__(self):270 """提供一个实际物理行。参数: 无。返回: 含换行str。异常: EOF、定位编码错、超限或取消。271 """272 owner = self.owner273 if owner._check_cancel is not None:274 owner._check_cancel()275 # 配额在CSV读取每条表记录前归零;跨引号的多行仍共用一个65536字节上限。276 remaining = MAX_CSV_RECORD_BYTES-owner._record_bytes277 raw = owner._file.readline(remaining+1)278 if not raw:279 raise StopIteration280 # 实际读到物理行才推进计数;超长记录定位其开始行,坏编码定位当前物理行。281 owner.line_number += 1282 owner._record_bytes += len(raw)283 if owner._record_bytes > MAX_CSV_RECORD_BYTES:284 raise CANTextError(owner.spec.path,owner._record_start,'CSV记录超过65536字节')285 encoding = owner._encoding286 # BOM只在文件第一行解释;后续单元格开头相同字节应保留为实际内容。287 if encoding == 'utf-8-sig' and owner.line_number != 1:288 encoding = 'utf-8'289 try:290 return raw.decode(encoding,errors='strict')291 except UnicodeError as error:292 raise CANTextError(owner.spec.path,owner.line_number,'CSV编码错误') from error293294295class CANCSVRead:296 """拥有一个文件、CSV语法迭代器和时钟,正常/失败/取消均关闭自有资源。"""297298 def __init__(self, spec, budget, *, check_cancel=None, retain_original=True, retain_source_time=True, flag_resolver=None, selected_ids=None):299 """预约并只读打开一次。参数: spec为CANCSVSpec、budget共享预算、check_cancel无参取消回调;selected_ids仅在不需全原帧/来源时间的入口限定ID。300301 返回: reader,不预读数据。异常: 类型/预算/I/O原错;清理失败保raw_csv_cleanup_owner。302 """303 if type(retain_original) is not bool or type(retain_source_time) is not bool:304 raise TypeError('原文/导出时间选择需要bool')305 self._retain_original=retain_original306 self._retain_source_time=retain_source_time307 if flag_resolver is not None and not callable(flag_resolver):308 raise TypeError('flag_resolver需要可调用对象')309 self._flag_resolver = flag_resolver310 if selected_ids is not None and (type(selected_ids) is not tuple or len(selected_ids)>4096311 or any(type(value) is not int or not 0 <= value <= 0x1fffffff for value in selected_ids)):312 raise ValueError('CSV按需ID需要有限合法整数tuple')313 self._selected_ids = None314 # 构造错误在取得文件和预算之前拒绝,不向外暴露半初始化来源。315 if type(spec) is not CANCSVSpec or not isinstance(budget,ResourceBudget):316 raise TypeError('CANCSVRead需要CANCSVSpec和ResourceBudget')317 if check_cancel is not None and not callable(check_cancel):318 raise TypeError('CSV取消检查需要可调用对象')319 # 先登记全部资源归属和进度,构造任意后续步骤失败都可由同一close回收。320 self.spec,self._budget,self._check_cancel = spec,budget,check_cancel321 self._encoding = codecs.lookup(spec.layout.encoding).name322 self._file,self._reader,self._bound,self._clock = None,None,None,None323 self._fixed_cost,self._held_cost,self._closed,self._initialized = 0,0,False,False324 self.line_number,self.count,self._record_bytes,self._record_start = 0,0,0,1325 self._time_position = None326 try:327 # 文本/分列/CSV引号缓存和有界表头一起计费;不加载或预分配整文件。328 _reserve_owned_ram(self,MAX_CSV_RECORD_BYTES*64+MAX_COLUMNS*256+8192+128*len(selected_ids or ()),'_fixed_cost')329 self._selected_ids = None if selected_ids is None else frozenset(selected_ids)330 self._file = spec.path.open('rb')331 self._reader = csv.reader(_CSVLines(self),delimiter=spec.layout.delimiter,strict=True)332 self._clock = CSVClock(spec.time)333 except BaseException as error:334 _cleanup_owner(self,error,'raw_csv_cleanup_owner','原始CSV读取')335 raise336337 @property338 def closed(self):339 """返回真实关闭事实。参数: 无。返回: bool。异常: 无。340 """341 return self._closed342343 def __iter__(self):344 """迭代同次扫描。参数: 无。返回: self。异常: 无,不另开文件。345 """346 return self347348 def _next_row(self):349 """取得一条有界CSV表记录。参数: 无。返回: str列表。异常: EOF/定位语法错/取消。350 """351 # 一条CSV记录可能跨多行,因此在请求首行前记住开始位置并重置整记录配额。352 self._record_bytes,self._record_start = 0,self.line_number+1353 try:354 row = next(self._reader)355 except csv.Error as error:356 # 保留CSV库的具体原因,同时提供文件与记录开始的物理行。357 raise CANTextError(self.spec.path,self._record_start,'CSV语法错误: '+str(error)) from error358 # 即便总字节有限,也限制空列造成的列表和字符串对象数量。359 if len(row) > MAX_COLUMNS:360 raise CANTextError(self.spec.path,self._record_start,'CSV列数超过4096')361 return row362363 def _initialize(self):364 """按声明跳过记录/绑定表头一次。参数: 无。返回: None。异常: 缺表头/缺列或EOF。365 """366 # 跳过的是显式声明的CSV记录数;输入不足是错误,不能当作正常空数据。367 for _ in range(self.spec.layout.skip_rows):368 try:369 self._next_row()370 except StopIteration as error:371 raise CANTextError(self.spec.path,self.line_number+1,'CSV声明跳过记录数超过实际输入') from error372 # 只有声明表头才消费这一条;绑定后所有数据行复用相同列位置。373 if self.spec.layout.has_header:374 try:375 header = self._next_row()376 except StopIteration as error:377 raise CANTextError(self.spec.path,self.line_number+1,'CSV缺少已声明的表头') from error378 else:379 header = None380 try:381 self._bound = self.spec.layout.bind_header(header)382 except (ValueError,TypeError) as error:383 raise CANTextError(self.spec.path,self._record_start,str(error)) from error384 # 时间原文取原始单元格,strip/值映射之后仍保留它的输入证据。385 self._time_position = next(position for name,_,position in self._bound.bindings if name=='timestamp')386 self._initialized = True387388 def __next__(self):389 """返回下一原始CAN记录。参数: 无。返回: RawCANRecord。异常: EOF/定位坏行/预算/取消原错。390 """391 # 已关闭来源不会重新打开或重放已产出的记录。392 if self._closed:393 raise StopIteration394 try:395 if not self._initialized:396 self._initialize()397 while True:398 try:399 row = self._next_row()400 except StopIteration:401 self.close()402 raise403 try:404 if self._selected_ids is not None:405 # 仅消息汇总且无需来源时间/原帧时,先看ID;无关行不补造帧型。406 identity = self._bound.map_row(row, names=('frame_id',))407 identifier = _uint(identity['frame_id'],self.spec.id_base,0x1fffffff,'CSV ID')408 if identifier not in self._selected_ids:409 # 显式跨午夜需所有日内时钟推进;只取这一个必要字段,仍不解析帧型和载荷。410 if self.spec.time.rollover_min_drop_ns is not None:411 clock = self._bound.map_row(row, names=('timestamp',))412 self._clock.parse(clock['timestamp'])413 self.count += 1414 continue415 values = self._bound.map_row(row)416 except (ValueError,TypeError,OverflowError) as error:417 raise CANTextError(self.spec.path,self._record_start,str(error)) from error418 break419 try:420 time_ns,quantized = self._clock.parse(values['timestamp'])421 frame,code = _csv_frame(values,self.spec,time_ns,self._flag_resolver)422 mapping = self.spec.layout.fields['timestamp']423 raw_time = (str(mapping.fixed) if self._time_position is None else row[self._time_position]) if self._retain_original else None424 # 时钟已完成换日/时区解释,撤去且仅撤去显式分析平移,保精确原坐标供导出。425 source_ns = require_int64_ns(time_ns-self.spec.time.offset_ns) if self._retain_source_time else None426 record = RawCANRecord(frame,time_ns,self.spec.path,self._record_start,self.count,427 raw_time,'raw-can-csv-v1',code,quantized,source_ns)428 except (ValueError,TypeError,OverflowError) as error:429 # 数据问题携带原行定位,不吞掉程序错误、取消或资源错误。430 raise CANTextError(self.spec.path,self._record_start,str(error)) from error431 # 序号包括按ID跳过的实际输入行;同刻不同原帧不合并。432 self.count += 1433 return record434 except StopIteration:435 raise436 except BaseException as error:437 # 保留原错,关闭持续失败时把真实reader附回异常,供调用方重试清理。438 _cleanup_owner(self,error,'raw_csv_cleanup_owner','原始CSV读取')439 raise440441 def close(self):442 """关闭文件和解析状态再归还自有预算,可重试。参数: 无。返回: None。异常: 关闭/预算原错。443 """444 # 关闭幂等;句柄关闭成功后才丢弃引用,失败时仍能对同一对象重试。445 if self._closed:446 return447 if self._file is not None:448 self._file.close()449 self._file = None450 # 文件已关闭才释放解析状态与本对象实际持有的预算,不能释放其他来源费用。451 self._reader,self._bound,self._clock = None,None,None452 if self._fixed_cost:453 _release_owned_ram(self,self._fixed_cost,'_fixed_cost')454 self._closed = True455 self._flag_resolver = self._selected_ids = None456457 def __enter__(self):458 """提供受控读取上下文。参数: 无。返回: self。异常: 无。459 """460 return self461462 def __exit__(self, exception_type, error, traceback):463 """保留原错并收尾。参数: exception_type/error/traceback异常信息。返回: False。异常: 无原错时关闭失败。464 """465 # 正常退出直接报告关闭错误;已有业务错误时保原错及必要的恢复owner。466 if error is None:467 self.close()468 else:469 _cleanup_owner(self,error,'raw_csv_cleanup_owner','原始CSV读取')470 return False471