|
| 1 | +"""Stage 7: canonicalize a raw scalar string per its FieldSpec. |
| 2 | +
|
| 3 | +Returns the ``MALFORMED`` sentinel when the value is present but uncoercible. |
| 4 | +
|
| 5 | +Cross-port number rules (parity with C#/TS, an accepted divergence from Java): |
| 6 | +
|
| 7 | +- Non-finite (NaN / ±Infinity) → MALFORMED. |
| 8 | +- Radix-prefixed strings (``0x..`` / ``0b..`` / ``0o..``) are REJECTED. Python's |
| 9 | + ``int(s, 0)`` would accept them but Java's ``Long.parseLong`` / C#'s |
| 10 | + ``long.TryParse`` reject them — so the C# and TS ports added this guard; we match. |
| 11 | +- We do NOT replicate Java's ``Double.parseDouble`` suffix tolerance |
| 12 | + (``"42d"`` / hex-float) — documented accepted divergence (same as C#/TS). |
| 13 | +""" |
| 14 | +from __future__ import annotations |
| 15 | + |
| 16 | +import math |
| 17 | +import re |
| 18 | +from typing import Final |
| 19 | + |
| 20 | +from metaobjects.render.recover.types import ( |
| 21 | + Coercion, |
| 22 | + FieldKind, |
| 23 | + FieldSpec, |
| 24 | + RecoverOptions, |
| 25 | + RecoveryReport, |
| 26 | + Tolerance, |
| 27 | +) |
| 28 | + |
| 29 | +# Sentinel: the value was present but could not be coerced to the declared kind/vocabulary. |
| 30 | +MALFORMED: Final = object() |
| 31 | + |
| 32 | +# A canonical ASCII numeric literal (int / decimal / scientific). Python's int()/float() |
| 33 | +# are far more permissive than Java/C# numeric parsing — they accept underscore digit |
| 34 | +# grouping ("1_000", PEP 515), Unicode digits ("123"), and radix prefixes ("0x10"). Gating |
| 35 | +# on this ASCII-only pattern rejects all of those → MALFORMED, matching the strict cross-port |
| 36 | +# behavior (C#'s TryParse). `[0-9]` (not `\d`) keeps it ASCII-only. |
| 37 | +_ASCII_NUMERIC = re.compile(r"^[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$") |
| 38 | + |
| 39 | + |
| 40 | +def value( |
| 41 | + raw: str | None, |
| 42 | + spec: FieldSpec, |
| 43 | + opts: RecoverOptions, |
| 44 | + field_path: str, |
| 45 | + report: RecoveryReport, |
| 46 | +) -> object: |
| 47 | + """Canonicalize ``raw`` to the native type described by ``spec``, or MALFORMED.""" |
| 48 | + if raw is None: |
| 49 | + return MALFORMED |
| 50 | + |
| 51 | + # OnField hook takes priority. |
| 52 | + if opts.on_field is not None: |
| 53 | + hooked = opts.on_field(field_path, raw, spec) |
| 54 | + if hooked is not None: |
| 55 | + report.add_coercion(Coercion(field_path, raw, str(hooked), "onField")) |
| 56 | + return hooked |
| 57 | + |
| 58 | + # Per-field runtime normalizer (bounded 20% surface). Keyed by path, then simple name. |
| 59 | + norm = opts.normalizers.get(field_path) |
| 60 | + if norm is None: |
| 61 | + norm = opts.normalizers.get(spec.name) |
| 62 | + if norm is not None: |
| 63 | + normalized = norm(raw) |
| 64 | + if normalized is not None: |
| 65 | + report.add_coercion(Coercion(field_path, raw, str(normalized), "normalizer")) |
| 66 | + return normalized |
| 67 | + |
| 68 | + ci = opts.tolerance != Tolerance.STRICT |
| 69 | + match spec.kind: |
| 70 | + case FieldKind.ENUM: |
| 71 | + return _coerce_enum(raw, spec, opts, field_path, report, ci) |
| 72 | + case FieldKind.INT | FieldKind.LONG: |
| 73 | + return _coerce_int(raw, spec, field_path, report) |
| 74 | + case FieldKind.DOUBLE: |
| 75 | + return _coerce_double(raw, spec, field_path, report) |
| 76 | + case FieldKind.BOOLEAN: |
| 77 | + return _coerce_bool(raw, ci) |
| 78 | + case _: |
| 79 | + return raw |
| 80 | + |
| 81 | + |
| 82 | +def _coerce_enum( |
| 83 | + raw: str, |
| 84 | + spec: FieldSpec, |
| 85 | + opts: RecoverOptions, |
| 86 | + path: str, |
| 87 | + report: RecoveryReport, |
| 88 | + ci: bool, |
| 89 | +) -> object: |
| 90 | + if spec.enum_values is not None: |
| 91 | + for v in spec.enum_values: |
| 92 | + if v == raw: |
| 93 | + return v |
| 94 | + if ci and v.lower() == raw.lower(): |
| 95 | + report.add_coercion(Coercion(path, raw, v, "case")) |
| 96 | + return v |
| 97 | + schema_target = None if spec.enum_alias is None else spec.enum_alias.get(raw) |
| 98 | + runtime_target = opts.aliases.get(raw) |
| 99 | + if runtime_target is not None: |
| 100 | + kind = ( |
| 101 | + "runtime-alias-override" |
| 102 | + if schema_target is not None and schema_target != runtime_target |
| 103 | + else "alias" |
| 104 | + ) |
| 105 | + report.add_coercion(Coercion(path, raw, runtime_target, kind)) |
| 106 | + return runtime_target |
| 107 | + if schema_target is not None: |
| 108 | + report.add_coercion(Coercion(path, raw, schema_target, "alias")) |
| 109 | + return schema_target |
| 110 | + return MALFORMED |
| 111 | + |
| 112 | + |
| 113 | +def _coerce_int(raw: str, spec: FieldSpec, path: str, report: RecoveryReport) -> object: |
| 114 | + trimmed = raw.strip() |
| 115 | + if not _ASCII_NUMERIC.match(trimmed): |
| 116 | + return MALFORMED |
| 117 | + # Integer parse first (matches Java Long.parseLong / C# long.TryParse), then a |
| 118 | + # float fallback (matches Java's Double.parseDouble fallback). |
| 119 | + try: |
| 120 | + return _clamp(float(int(trimmed)), spec, path, report, as_long=True) |
| 121 | + except ValueError: |
| 122 | + pass |
| 123 | + try: |
| 124 | + d = float(trimmed) |
| 125 | + except ValueError: |
| 126 | + return MALFORMED |
| 127 | + return _clamp(d, spec, path, report, as_long=True) |
| 128 | + |
| 129 | + |
| 130 | +def _coerce_double(raw: str, spec: FieldSpec, path: str, report: RecoveryReport) -> object: |
| 131 | + trimmed = raw.strip() |
| 132 | + if not _ASCII_NUMERIC.match(trimmed): |
| 133 | + return MALFORMED |
| 134 | + try: |
| 135 | + d = float(trimmed) |
| 136 | + except ValueError: |
| 137 | + return MALFORMED |
| 138 | + return _clamp(d, spec, path, report, as_long=False) |
| 139 | + |
| 140 | + |
| 141 | +def _clamp( |
| 142 | + n: float, spec: FieldSpec, path: str, report: RecoveryReport, as_long: bool |
| 143 | +) -> object: |
| 144 | + # Non-finite (NaN, ±Infinity) → MALFORMED (cross-port classification parity). |
| 145 | + if not math.isfinite(n): |
| 146 | + return MALFORMED |
| 147 | + c = n |
| 148 | + if spec.min is not None and c < spec.min: |
| 149 | + c = spec.min |
| 150 | + if spec.max is not None and c > spec.max: |
| 151 | + c = spec.max |
| 152 | + if c != n: |
| 153 | + report.add_coercion(Coercion(path, _num_str(n), _num_str(c), "clamp")) |
| 154 | + # Truncate toward zero for integer kinds (math.trunc / int()). |
| 155 | + return math.trunc(c) if as_long else c |
| 156 | + |
| 157 | + |
| 158 | +def _num_str(n: float) -> str: |
| 159 | + # Render integral floats without a trailing ".0" to read like Java/C# longs in notes. |
| 160 | + if n == math.trunc(n) and math.isfinite(n): |
| 161 | + return str(int(n)) |
| 162 | + return str(n) |
| 163 | + |
| 164 | + |
| 165 | +def _coerce_bool(raw: str, ci: bool) -> object: |
| 166 | + t = raw.strip().lower() if ci else raw.strip() |
| 167 | + if t in ("true", "yes", "1"): |
| 168 | + return True |
| 169 | + if t in ("false", "no", "0"): |
| 170 | + return False |
| 171 | + return MALFORMED |
0 commit comments