chronaxis.planning.checks
源码基线:8904c2f744951857659abd40aaa3535d57faf53c
1"""检查JSON/YAML解析后的配置值,并在错误中保存具体字段位置。23输入为原生字典、列表、标量及JSON路径,输出合法原值、冻结元数据、4时间坐标或模型实例;错误统一关联为PlanValidationError。5字段和类型严格检查,不把True当整数,不忽略未知键或自动纠正数值。6时间必须声明单位或时区,路径只转为绝对定位,不检查文件内容或存在性。7本模块不读日志或DBC;地区时区解析可能读取系统时区数据库。8"""910# 精确配置时间与确定身份只依赖标准库。11import json12import re13from datetime import timedelta, timezone14from math import isfinite15from pathlib import Path16from collections.abc import Mapping17from zoneinfo import ZoneInfo18from difflib import get_close_matches19from ..configuration.diagnostics import child_path, value_preview20# 单位和安全名称校验沿用已验核心。21from ..core.validation import require_identifier, require_text22from ..sources.models import freeze_metadata23from ..timebase import convert_time, parse_duration, parse_datetime_ns242526class PlanValidationError(ValueError):27 """保存配置字段路径、原因、机器错误码及可选的原文定位和修正建议。2829 不吞执行异常或数据读取失败;code区分无效配置与未接线能力。30 """3132 def __init__(self, path: str, message: str, code: str = 'invalid_config', *, hint=None,33 source=None, location=None, object_name=None, value=None):34 """建立明确路径错误,保留原异常链供load_analysis_plan诊断。3536 参数: path为原始字段路径;message为违反的约束;code默认invalid_config。37 hint为修正说明,source/location为文件及行列,object_name为所属声明名称,38 value为有长度限制的显示文本;均可省略,不保存整个原始配置。39 返回: None;异常文本含路径和原因。40 异常: 无;参数由校验器内部构造。41 """42 # 文件路径独立保存在source;path始终指配置字段。43 self.path = path44 # 机器码不以错误文本模糊匹配。45 self.code = code46 # 字段路径与文件分开;只保存有限诊断,不持有加载文档。47 self.message = message48 self.hint=hint49 self.source=source50 self.location=location51 self.object_name=object_name52 self.value_preview=value53 self._diagnostics = None54 self.blocked_checks = ()55 super().__init__()56 self.refresh_text()5758 def refresh_text(self):59 """重建人读消息。参数: 无。返回: None。异常: 无;附加定位后调用。"""60 if self._diagnostics is not None:61 text = '\n'.join(f'{index}. {item}' for index, item in enumerate(self._diagnostics, 1))62 if self.blocked_checks:63 text += '\n暂未检查:' + ';'.join(item['path'] + '(' + item['reason'] + ')' for item in self.blocked_checks)64 self.args = (text,)65 return66 place=self.source or ''67 if self.location is not None:68 place+=f':{self.location.line}:{self.location.column}(第{self.location.line}行,第{self.location.column}列)'69 text=(place+' ' if place else '')+self.path+f' [{self.code}] '+self.message70 if self.object_name is not None:71 text+=';对象:'+self.object_name72 if self.value_preview is not None:73 text+=';当前值:'+self.value_preview74 if self.hint is not None:75 text+=';修正:'+self.hint76 self.args=(text,)7778 def to_dict(self):79 """返回稳定机器诊断。参数: 无。返回: 原生dict。异常: 无。"""80 return dict(code=self.code,source=self.source,path=self.path,message=self.message,81 line=None if self.location is None else self.location.line,82 column=None if self.location is None else self.location.column,83 object_name=self.object_name,value=self.value_preview,hint=self.hint)8485 @property86 def diagnostics(self):87 """返回各项独立诊断。参数: 无。返回: tuple;单错也为一项。异常: 无。"""88 return self._diagnostics if self._diagnostics is not None else (self,)8990 @classmethod91 def from_diagnostics(cls, errors, blocked=()):92 """合并错误并保留原类型兼容。参数: 非空错误序列/阻断说明。返回: 异常。异常: 空序列ValueError。"""93 if not errors:94 raise ValueError('诊断集合不能为空')95 if len(errors) == 1 and not blocked:96 return errors[0]97 first = errors[0]98 result = cls(first.path, first.message, first.code)99 result._diagnostics = tuple(errors)100 result.blocked_checks = tuple(blocked)101 result.refresh_text()102 return result103104105def configuration_error(error):106 """把共享语法错误交给计划API的异常类型。参数: ConfigurationError。返回: PlanValidationError。107108 维持文件路径与具体语法代码,文本补实际字段和可靠行列;不读取或重解析配置。109 """110 from ..configuration import field_path_text111 hints={'duplicate_key':'每个对象内的字段名只能声明一次;删除或重命名重复项',112 'unsupported_yaml_feature':'使用普通映射、列表和标量;将公共值明确写入对应字段',113 'nonfinite_number':'使用有限数值,不能用NaN或Infinity代表缺失数据',114 'unsupported_format':'使用 .yaml、.yml 或 .json 文件',115 'invalid_json':'检查引号、逗号和括号;JSON字符串和键必须用双引号',116 'invalid_yaml':'检查缩进和冒号;缩进使用空格'}117 return PlanValidationError(field_path_text(error.field_path),error.message,error.code,118 source=error.source,location=error.location,hint=hints.get(error.code))119120121def object_at(value, path: str, required=(), optional=()) -> dict:122 """检查JSON对象的类型、必填项和允许字段。123124 参数:125 value: 待检查原值,必须是原生dict且全部键为字符串。126 path (str): 当前配置位置。127 required (tuple): 必填键,默认空元组。128 optional (tuple): 可选键,默认空元组。129 返回:130 dict。原字典只在解析中读取,独立快照由计划入口另行创建。131 异常:132 PlanValidationError: 对象类型错误、缺少必填项或含未知键。133 """134 # 不执行任意Mapping用户方法。135 if type(value) is not dict or any(type(key) is not str for key in value):136 # JSON对象键只能文本。137 raise PlanValidationError(path, '必须为字符串键对象','invalid_type',hint='使用字段名: 值组成的对象')138 # 未知键不静默忽略工程参数。139 unknown = sorted(set(value) - set(required) - set(optional))140 # 缺项不能用None隐式补齐。141 missing = sorted(set(required) - set(value))142 # 指向真实错误字段;仅提示候选名称,不改写用户输入。143 if unknown:144 key=unknown[0]145 choices=get_close_matches(key,sorted(set(required)|set(optional)),n=1,cutoff=0.65)146 hint=f'是否要写 {choices[0]}?请核对后修改字段名' if choices else '移除该字段;允许字段:'+', '.join((*required,*optional))147 raise PlanValidationError(child_path(path,key),f'未知字段{unknown};缺少字段{missing}',148 'unknown_field',hint=hint)149 if missing:150 key=missing[0]151 raise PlanValidationError(child_path(path,key),f'缺少必填字段{missing}',152 'missing_field',hint=f'在所属对象中补充 {key},值应来自实际输入或分析要求')153 # 深复制由AnalysisPlan入口统一负责。154 return value155156157def array_at(value, path: str) -> list:158 """要求明确JSON数组,不把文本或tuple当自由迭代器。159160 参数: value原值;path位置。161 返回: 原list,仅解析期间读取。162 异常: PlanValidationError为类型错误。163 """164 # from_dict也遵守JSON结构,与load_analysis_plan一致。165 if type(value) is not list:166 # 不接受集合的非确定顺序。167 raise PlanValidationError(path, '必须为数组','invalid_type',hint='使用列表,例如 []')168 # 顺序在后续tuple中保留。169 return value170171172def text_at(value, path: str, *, identifier=False) -> str:173 """检查非空文本或统一简单名称,保留原大小写。174175 参数: value原值;path位置;identifier是否安全标识符。176 返回: 原str,不改点号字段或semantic字符串。177 异常: PlanValidationError为类型/名称错误。178 """179 # 核心校验不需要任何输入I/O。180 try:181 # 字段和标识符采用不同规则。182 return require_identifier(value, path) if identifier else require_text(value, path)183 # 核心异常只在配置边界增加位置。184 except (TypeError, ValueError) as error:185 # 保留原校验原因,不吞异常。186 raise PlanValidationError(path, str(error)) from error187188189def choice_at(value, choices: tuple, path: str):190 """要求显式支持的原生类型值,禁止bool等价整数。191192 参数: value原值;choices允许值tuple;path位置。193 返回: 原值。194 异常: PlanValidationError为未知值/类型。195 """196 # True不能因==1成为config_version合法输入。197 if not any(type(value) is type(item) and value == item for item in choices):198 # 清楚列出真实支持版本/kind。199 raise PlanValidationError(path, f'必须为{choices}','invalid_choice',200 hint='使用允许值之一,例如 '+value_preview(choices[0]))201 # 不自动升级版本或猜别名。202 return value203204205def number_at(value, path: str, *, integer=False, minimum=None, maximum=None):206 """检查有限原生数值及包含端点的范围,保留整数精度。207208 参数:209 value: 原int或float数值,bool不被接受。210 path (str): 当前配置位置。211 integer (bool): 默认False;True时只接受原生int。212 minimum: 可选包含式下限,默认None,不限制下端。213 maximum: 可选包含式上限,默认None,不限制上端。214 返回:215 int或float。保持原类型和位值,不截断或转成浮点。216 异常:217 PlanValidationError: 类型错误、非有限浮点或超出范围。218 """219 # bool不属于数值工程参数。220 if type(value) not in ((int,) if integer else (int, float)) or type(value) is float and not isfinite(value):221 # JSON非有限扩展不进入产品配置。222 raise PlanValidationError(path, '必须为有限整数' if integer else '必须为有限数值','invalid_type')223 # 明确边界不执行隐式截断。224 if minimum is not None and value < minimum or maximum is not None and value > maximum:225 # 覆盖率与资源限额不能默默纠正。226 raise PlanValidationError(path, f'数值必须位于[{minimum}, {maximum}]','out_of_range')227 # 保留整数精度与原生类型。228 return value229230231def duration_at(value, path: str, *, positive=False) -> int:232 """把带单位的非负持续时间转为整数纳秒。233234 参数:235 value (str): 明确带时间单位的持续文本,不接受裸数。236 path (str): 当前配置位置。237 positive (bool): 默认False;True时要求纳秒量化后仍大于0。238 返回:239 int。符合int64范围的纳秒时长,允许零时长时返回0。240 异常:241 PlanValidationError: 语法、单位、范围错误,或正时长量化后为0。242 """243 # 原负值在核心入口量化前拒绝。244 try:245 # 不允许裸数或隐式秒。246 result = parse_duration(value).time_ns247 # 将精确转换异常关联配置位置。248 except (TypeError, ValueError, OverflowError) as error:249 # 不修改语义以允许错误参数。250 raise PlanValidationError(path, str(error),'invalid_duration',hint='使用带单位的非负时长,例如 "1s" 或 "500ms";按实际要求选择') from error251 # 0.1ns舍入0不能冒充正TTL。252 if positive and result <= 0:253 # TTL和抑制窗口必须有正时长。254 raise PlanValidationError(path, '持续量化后必须大于0ns','invalid_duration',hint='设置正时长,例如 "1s";按实际采样有效期选择')255 # 全部后续时间计算保持整数。256 return result257258259def coordinate_at(value, path: str, *, absolute=False, tz=None) -> int:260 """解释带单位相对坐标或完整绝对日期,返回整数纳秒。261262 参数:263 value (str): 相对数值加s、ms、us或ns单位,或完整绝对日期文本。264 path (str): 当前配置位置。265 absolute (bool): 默认False;True时按绝对日期解析。266 tz (tzinfo | None): 默认None;绝对日期没有自身时区时使用此声明。267 返回:268 int。int64纳秒坐标;相对坐标允许负数,区别于非负持续时间。269 异常:270 PlanValidationError: 文本、单位、时区、日期或数值范围错误。271 """272 # 完整文本保留精确十进制表达。273 text_at(value, path)274 # 核心日期不采用机器默认时区。275 try:276 # 绝对坐标明确完整日期,不猜文件名。277 if absolute:278 # 显式offset日期优先于配置tz。279 return parse_datetime_ns(value, tz).time_ns280 # 相对坐标语法允许负号,显式四种单位。281 match = re.fullmatch(r'\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*(ns|us|ms|s)\s*', value)282 # 不接受单位缺失或尾部任意文本。283 if match is None:284 # 负坐标不同于持续参数的非负门槛。285 raise ValueError('坐标须为数值+s/ms/us/ns')286 # 精确转换不先经float。287 return convert_time(match[1], match[2]).time_ns288 # 配置边界保留原转换异常。289 except (TypeError, ValueError, OverflowError) as error:290 # 错误具体关联当前坐标键。291 raise PlanValidationError(path, str(error)) from error292293294def timezone_at(value, path: str):295 """解析显式UTC、固定offset或IANA名称,拒绝机器时区推断。296297 参数: value为None或非空文本;path位置。298 返回: tzinfo或None,仅IANA可能读取系统时区数据库,不读数据输入。299 异常: PlanValidationError为非法offset/未知时区或依赖缺失。300 """301 # 无声明保留None,naive日期稍后必须报错。302 if value is None:303 # 不填机器当前地区。304 return None305 # 时区名称保留用户原文。306 text_at(value, path)307 # UTC常用表示不需外部时区文件。308 if value in ('UTC', 'Z'):309 # 固定零偏移明确UTC。310 return timezone.utc311 # 固定offset只接标准时分格式。312 match = re.fullmatch(r'([+-])(\d{2}):(\d{2})', value)313 # 验证分量,禁止datetime将+00:60自动归一化。314 if match is not None:315 # 各分量界限明确。316 hours, minutes = int(match[2]), int(match[3])317 # 偏移绝对值须小于24小时。318 if hours > 23 or minutes > 59:319 # 不接受非法offset自动变换。320 raise PlanValidationError(path, '固定时区须小于24小时且分钟<60')321 # 正负号只用于声明的固定偏移。322 return timezone(timedelta(minutes=(hours * 60 + minutes) * (1 if match[1] == '+' else -1)))323 # IANA解析可能缺本机tzdata,明确报告而非换机器默认。324 try:325 # 不读取日志/DBC,不凭地理位置猜时区。326 return ZoneInfo(value)327 # 环境缺项与未知名称都是配置可定位错误。328 except (KeyError, ValueError) as error:329 # 保留原依赖/名称异常供调用者修正。330 raise PlanValidationError(path, f'时区不可用:{value}') from error331332333def metadata_at(value, path: str):334 """深冻结唯一允许自由键的JSON元数据对象。335336 参数: value元数据dict;path位置。337 返回: 深复制的只读Mapping/tuple,不影响调用者输入。338 异常: PlanValidationError为非JSON/非有限/键类型错误。339 """340 # 自由键也必须属于JSON对象。341 if type(value) is not dict:342 # 不把数组当metadata根。343 raise PlanValidationError(path, 'metadata必须为对象')344 # 模型递归校验JSON类型且不保留调用者引用。345 try:346 # 自定义键不改变产品执行语义。347 return freeze_metadata(value)348 # 只在配置边界增加当前位置。349 except (TypeError, ValueError) as error:350 # 元数据也不允许未知Python对象进入计划。351 raise PlanValidationError(path, str(error)) from error352353354def require_json_value(value, path: str, active=None) -> None:355 """在冻结之前检查原生JSON结构,并定位当前递归路径上的循环。356357 参数:358 value: dict/list或原生str/int/float/bool/None,浮点必须有限。359 path (str): 当前配置位置,递归时补字段名或数组下标。360 active (set[int] | None): 当前祖先容器身份;根调用省略。361 返回:362 None,结构合法;不复制、转换或修改输入。363 异常:364 PlanValidationError: 非JSON对象、非文本对象键、非有限浮点或循环容器。365 处理流程:366 先区分原生容器与标量,逐层检查后由调用者冻结;共享但不循环的367 容器允许重复出现。此检查不打开文件或执行任意Mapping方法。368 """369 # 只允许JSON原生容器,避免tuple或自定义Mapping被通用冻结器归一化。370 if type(value) not in (dict, list):371 # bool与int都是合法JSON标量;未知对象不调用其转换方法。372 if type(value) not in (str, int, float, bool, type(None)):373 raise PlanValidationError(path, '必须为原生JSON对象、数组或标量')374 # NaN/Infinity不能成为预算、配置元数据或正常数值声明。375 if type(value) is float and not isfinite(value):376 raise PlanValidationError(path, 'JSON浮点必须有限')377 return378 # 当前祖先集合区别于全局访问集合,共享容器不等同于循环。379 ancestors = set() if active is None else active380 identity = id(value)381 if identity in ancestors:382 # 自引用定位在引用发生处,不能泄漏通用冻结器的RecursionError。383 raise PlanValidationError(path, 'JSON配置不能包含循环容器')384 ancestors.add(identity)385 try:386 # 对象只接受字符串键;数组严格保持原顺序和下标。387 if type(value) is dict:388 for key, item in value.items():389 if type(key) is not str:390 raise PlanValidationError(path, 'JSON对象键必须为字符串')391 require_json_value(item, child_path(path,key), ancestors)392 else:393 for index, item in enumerate(value):394 require_json_value(item, f'{path}[{index}]', ancestors)395 finally:396 # 无论成功或失败都退出当前祖先路径,不污染其他分支的循环判定。397 ancestors.remove(identity)398399400def json_value(value):401 """将已深冻结JSON快照转回可序列化新结构。402403 参数: value来自freeze_metadata的typed结构。404 返回: 新dict/list或原不可变标量。405 异常: 无;调用者保证已校验JSON类型。406 """407 # 只读映射不能直接交给json.dumps。408 if isinstance(value, Mapping):409 # 重建新dict,保持原计划不可变。410 return {key: json_value(item) for key, item in value.items()}411 # tuple表示原JSON数组,顺序必须保留。412 if type(value) is tuple:413 # 不将路径顺序排序。414 return [json_value(item) for item in value]415 # 原生标量不可变,可直接复用。416 return value417418419def canonical(value) -> str:420 """生成JSON配置的确定身份文本,不读取文件内容。421422 参数: value已校验JSON结构或其深冻结快照。423 返回: 规范JSON文本,字典排序、数组原序、整数精度保持。424 异常: TypeError/ValueError为内部非JSON结构。425 """426 # allow_nan=False拒绝非标准JSON,不以NaN身份混入配置。427 return json.dumps(json_value(value), sort_keys=True, separators=(',', ':'), ensure_ascii=False, allow_nan=False)428429430def absolute_path(value, base: Path, path: str) -> Path:431 """相对于明确基准解释路径,不打开目标或核实存在性。432433 参数:434 value (str): 非空路径文本,不得含NUL字符。435 base (Path): 相对路径的解释基准。436 path (str): 此字段在配置中的错误定位。437 返回:438 Path。绝对定位路径,不解析实际符号链接或读取文件内容。439 异常:440 PlanValidationError: 路径文本非法或含NUL。441 OSError: 获取当前目录或系统路径转换发生错误时可传播。442 """443 # 路径不采用字符串对象隐式repr。444 text_at(value, path)445 # NUL会让未来文件读取不可用,提前具体拒绝。446 if '\x00' in value:447 # 保持配置错误与读文件失败分别诊断。448 raise PlanValidationError(path, '路径含NUL')449 # absolute只调用词法路径操作,不解析符号链接或读取输入。450 return (base / value).absolute()451452453def model_at(factory, path: str, *args, **kwargs):454 """用内部指定构造器创建模型,将校验错误关联到配置位置。455456 参数:457 factory: 程序内部固定的模型构造器或校验函数,不来自配置反射。458 path (str): 当前配置位置。459 args: 已解析的位置参数。460 kwargs: 已解析的关键字参数。461 返回:462 构造器的实际返回值,通常是已检查的模型实例。463 异常:464 PlanValidationError: 包装TypeError、ValueError、OverflowError或LookupError,465 保留原异常链;其他异常原样传播。466 """467 # 构造器来自内部代码,不执行配置指定对象或反射。468 try:469 # 原模型契约直接生效,不隐式修正参数。470 return factory(*args, **kwargs)471 # 只有配置模型校验错误转换为计划异常。472 except (TypeError, ValueError, OverflowError, LookupError) as error:473 # 原单位/时间/字段原因保持可查。474 raise PlanValidationError(path, str(error)) from error475