chronaxis.configuration.file_tools
源码基线:8904c2f744951857659abd40aaa3535d57faf53c
1"""当前配置的格式转换和新文件发布;不推断业务参数,不执行分析。"""2from io import StringIO3import json4import math5from pathlib import Path67from . import load_configuration, parse_configuration8from .diagnostics import locate_error91011def configuration_type(data):12 """识别当前分析或批量入口。参数: 原生配置。返回: analysis/batch。异常: ValueError。1314 只接受当前分析及批量格式,不猜测或迁移输入版本。15 """16 from ..planning.checks import PlanValidationError17 versions={'config_version':('analysis',(4,)),'batch_version':('batch',(1,))}18 present=[key for key in versions if type(data) is dict and key in data]19 if len(present)!=1:raise PlanValidationError('$','需要且只能有一种配置版本字段','invalid_version')20 key=present[0];name,version=versions[key]21 if type(data[key]) is not int or data[key] not in version:22 raise PlanValidationError('$.'+key,'仅接受当前版本'+'或'.join(map(str, version))+',不转换旧结构','invalid_version')23 return name242526def validate_document(document, base, *, return_plan=False):27 """调用现有模型;return_plan=True时同时返回计划,避免配置工具重复加载DBC。2829 参数: document为解析配置,base为相对路径基准,return_plan控制是否返回计划。30 返回: 入口类别,或(类别, 已校验计划)。31 异常: PlanValidationError附带文件位置;文件与依赖错误按原入口传播。32 批量不加载子任务;未知格式在读取业务数据前拒绝。33 """34 from ..planning import AnalysisPlan35 from ..planning.checks import PlanValidationError36 from .imports import expand_document_imports37 try:38 document=expand_document_imports(document)39 name=configuration_type(document.data)40 if name=='analysis':plan=AnalysisPlan.from_dict(document.data,base_dir=base)41 elif name=='batch':42 from ..batch.models import BatchPlan43 plan=BatchPlan.from_dict(document.data,base_dir=base)44 return (name,plan) if return_plan else name45 except PlanValidationError as error:raise locate_error(error,document)464748def path_fields(data, config_type):49 """枚举声明的文件路径位置。参数: 已校验配置及类型。返回: (容器,键)迭代器。异常: 内部结构错误。5051 只认来源文件/DBC、批量子计划和公共定义;metadata与表达式中的path文字保持原样。52 """53 if config_type=='batch':54 for job in data.get('jobs',[]):yield job,'plan'55 if 'template' in data:56 yield data,'template'57 from ..planning.input_paths import source_path_fields58 holder={'paths':data['inputs']}59 for owner,key in source_path_fields(holder):60 yield (data,'inputs') if owner is holder else (owner,key)61 return62 # 非批量入口已校验为当前分析配置,路径直接位于根级来源和公共定义中。63 for source in data.get('sources',[]):64 from ..planning.input_paths import source_path_fields65 yield from source_path_fields(source)66 for channel in source.get('dbc_by_channel',{}):yield source['dbc_by_channel'],channel67 for index in range(len(data.get('imports',[]))):yield data['imports'],index686970def same_native_value(left,right):71 """核类型/顺序/数值不漂移,包含负零和大整数。参数: 原生值。返回: bool。异常: 无。"""72 if type(left) is not type(right):return False73 if type(left) is dict:74 return list(left)==list(right) and all(same_native_value(left[key],right[key]) for key in left)75 if type(left) is list:76 return len(left)==len(right) and all(same_native_value(a,b) for a,b in zip(left,right))77 if type(left) is float and left==0:return left==right and math.copysign(1,left)==math.copysign(1,right)78 return left==right798081def serialize_configuration(data, format):82 """输出可被受限解析器再读取的文本。参数: 配置及json/yaml。返回: str。异常: ValueError。8384 输出后独立重解析并核类型和值,避免YAML隐式类型或浮点表示改写含义。注释不参与互转。85 """86 if format=='json':text=json.dumps(data,ensure_ascii=False,allow_nan=False,indent=2)+'\n'87 elif format=='yaml':88 from ruamel.yaml import YAML89 yaml=YAML(typ='safe',pure=True);yaml.version=(1,2);yaml.default_flow_style=False90 yaml.sort_base_mapping_type_on_output=False91 yaml.representer.ignore_aliases=lambda value:True92 stream=StringIO();yaml.dump(data,stream);text=stream.getvalue()93 else:raise ValueError('输出格式仅支持json或yaml')94 if not same_native_value(data,parse_configuration(text,format=format).data):95 raise ValueError('格式转换不能保持原配置类型、顺序和值;未写文件')96 return text979899def output_format(path):100 """由扩展名选择格式。参数: Path。返回: json/yaml。异常: ValueError。"""101 suffix=path.suffix.lower()102 if suffix not in ('.json','.yaml','.yml'):raise ValueError('新文件需要.json、.yaml或.yml扩展名')103 return 'json' if suffix=='.json' else 'yaml'104105106def write_new_configuration(path, text):107 """独占写新文件。参数: 路径及已校验文本。返回: Path。异常: IO/取消。108109 已存在文件、硬链接和符号链接都由独占打开拒绝;部分写失败仅清理本次创建的文件。110 """111 target=Path(path).absolute()112 stream=target.open('x',encoding='utf-8',newline='\n')113 try:114 with stream:stream.write(text);stream.flush()115 except BaseException as error:116 try:stream.close()117 except OSError as cleanup:error.add_note('未能关闭部分配置 '+str(target)+': '+str(cleanup))118 try:target.unlink()119 except OSError as cleanup:error.add_note('未能清理部分配置 '+str(target)+': '+str(cleanup))120 raise121 return target122123124def convert_configuration(source, output):125 """仅改变当前配置的JSON/YAML表示。参数: 输入、新文件。返回: 输出Path。异常: 配置/IO错误。126127 相对路径存在时只允许同目录输出;跨目录需使用resolve显式重定位,不静默修改原值。128 """129 source=Path(source).absolute();target=Path(output).absolute()130 document=load_configuration(source);name=validate_document(document,source.parent)131 if source.parent!=target.parent and any(not Path(owner[key]).is_absolute() for owner,key in path_fields(document.data,name)):132 raise ValueError('配置含相对路径,convert只能写入同目录;跨目录请用config resolve --output重新定位')133 text=serialize_configuration(document.data,output_format(target))134 return write_new_configuration(target,text)135