Chronaxis用户手册English
本页目录

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
Chronaxis 用户手册 · 离线阅读