From 457b9fd2b2a7ed5aa3702fad335864172c042be0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 07:34:21 +0000 Subject: [PATCH 1/2] fix: load spec YAML as JSON-compatible scalars PyYAML's YAML 1.1 loader turned unquoted ISO dates into datetime objects, so fromisoformat crashed, and treated yes/no/on/off as booleans, so an asset id such as NO or created_by no never reached validation as text. Co-authored-by: quantum-like --- src/ml4t/specs/io.py | 36 ++++++++++++++++++++++++- tests/test_spec_io.py | 62 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/ml4t/specs/io.py b/src/ml4t/specs/io.py index 1f7ab6e..0cf233d 100644 --- a/src/ml4t/specs/io.py +++ b/src/ml4t/specs/io.py @@ -4,6 +4,7 @@ import json import os +import re import stat from collections.abc import Mapping from pathlib import Path @@ -15,6 +16,39 @@ from .base import ArtifactSpec _SUPPORTED_SUFFIXES = frozenset({".json", ".yaml", ".yml"}) +_YAML_BOOL_TAG = "tag:yaml.org,2002:bool" +_YAML_TIMESTAMP_TAG = "tag:yaml.org,2002:timestamp" + + +class _SpecYamlLoader(yaml.SafeLoader): + """Load YAML spec payloads as JSON-compatible scalars. + + PyYAML defaults to YAML 1.1. Unquoted ISO dates become ``datetime`` objects, + which then crash ``fromisoformat``, and ``yes``/``no``/``on``/``off`` become + booleans, so an asset id such as ``NO`` never arrives as the string the + contract validates. + """ + + +def _json_compatible_yaml_resolvers() -> dict[str | None, list[tuple[str, re.Pattern[str]]]]: + resolvers: dict[str | None, list[tuple[str, re.Pattern[str]]]] = {} + for key, entries in yaml.SafeLoader.yaml_implicit_resolvers.items(): + kept = [ + (tag, regexp) + for tag, regexp in entries + if tag not in {_YAML_BOOL_TAG, _YAML_TIMESTAMP_TAG} + ] + if kept: + resolvers[key] = kept + return resolvers + + +_SpecYamlLoader.yaml_implicit_resolvers = _json_compatible_yaml_resolvers() +_SpecYamlLoader.add_implicit_resolver( + _YAML_BOOL_TAG, + re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$"), + list("tTfF"), +) def _validated_suffix(path: Path) -> str: @@ -79,7 +113,7 @@ def read_spec_payload(path_or_mapping: str | Path | Mapping[Any, Any]) -> dict[s path = Path(path_or_mapping) suffix = _validated_suffix(path) with path.open(encoding="utf-8") as f: - data = json.load(f) if suffix == ".json" else yaml.safe_load(f) + data = json.load(f) if suffix == ".json" else yaml.load(f, Loader=_SpecYamlLoader) return _normalize_mapping({} if data is None else data) diff --git a/tests/test_spec_io.py b/tests/test_spec_io.py index 785f0ad..f148235 100644 --- a/tests/test_spec_io.py +++ b/tests/test_spec_io.py @@ -3,13 +3,16 @@ import json import os import stat +from datetime import UTC, date, datetime from pathlib import Path import pytest +import yaml import ml4t.specs.io as spec_io from ml4t.specs import ( ArtifactStorage, + CanonicalTargetIntent, MarketDataSemantics, MarketDataSpec, TimestampSemantics, @@ -103,6 +106,65 @@ def test_read_spec_payload_rejects_non_mapping_documents( read_spec_payload(path) +def test_read_spec_payload_keeps_json_compatible_yaml_scalars(tmp_path) -> None: + intent_path = tmp_path / "intent.yaml" + intent_path.write_text( + """\ +intent_id: target-1 +decision_time: 2026-08-08T13:00:00+00:00 +information_cutoff: 2026-08-08T12:59:59+00:00 +effective_session: 2026-08-10 +effective_phase: pre_open +targets: +- asset: NO + measure: weight + value: 0.5 +idempotency_key: target-key +measure: weight +cash_buffer: 0.05 +rounding: toward_zero +residual: keep_cash +reason: rebalance +lifecycle_version: '1' +""", + encoding="utf-8", + ) + payload = read_spec_payload(intent_path) + assert isinstance(payload["decision_time"], str) + assert payload["effective_session"] == "2026-08-10" + assert payload["targets"][0]["asset"] == "NO" + intent = CanonicalTargetIntent.from_mapping(payload) + assert intent.decision_time == datetime(2026, 8, 8, 13, 0, tzinfo=UTC) + assert intent.information_cutoff == datetime(2026, 8, 8, 12, 59, 59, tzinfo=UTC) + assert intent.effective_session == date(2026, 8, 10) + assert intent.targets[0].asset == "NO" + + market_path = tmp_path / "market.yaml" + market_path.write_text( + """\ +artifact_id: prices +provenance: + created_by: no +semantics: + calendar: NYSE +""", + encoding="utf-8", + ) + market = read_spec_payload(market_path) + assert market["provenance"]["created_by"] == "no" + assert MarketDataSpec.from_mapping(market).provenance.created_by == "no" + + flags = tmp_path / "flags.yaml" + flags.write_text("enabled: false\nabsent: null\ncount: 2\ntoken: yes\n", encoding="utf-8") + assert read_spec_payload(flags) == { + "enabled": False, + "absent": None, + "count": 2, + "token": "yes", + } + assert yaml.safe_load("asset: NO\n") == {"asset": False} + + def test_read_spec_payload_normalizes_mapping_keys(tmp_path) -> None: path = tmp_path / "numeric-key.yaml" path.write_text("1: value\n") From b1276839ac821a09ec2345eda5761a6338d37243 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 07:34:49 +0000 Subject: [PATCH 2/2] fix: reject epoch versions in release preflight version_failure accepted PEP 440 epochs such as 1!1.2.3 even though a stable release must be a normalized X.Y.Z or X.Y.Z.postN version. Co-authored-by: quantum-like --- scripts/check_release_preflight.py | 1 + tests/test_release_pipeline.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/check_release_preflight.py b/scripts/check_release_preflight.py index 4a248cc..21377e3 100644 --- a/scripts/check_release_preflight.py +++ b/scripts/check_release_preflight.py @@ -70,6 +70,7 @@ def version_failure(value: str) -> str | None: return "release version is not PEP 440 compliant" if ( str(version) != value + or version.epoch != 0 or len(version.release) != 3 or version.is_prerelease or version.is_devrelease diff --git a/tests/test_release_pipeline.py b/tests/test_release_pipeline.py index 1544cc8..bfb7942 100644 --- a/tests/test_release_pipeline.py +++ b/tests/test_release_pipeline.py @@ -44,7 +44,7 @@ REPOSITORY_ROOT = Path(__file__).parents[1] -@pytest.mark.parametrize("version", ["1.2", "v1.2.3", "1.2.3rc1", "1.2.3+local"]) +@pytest.mark.parametrize("version", ["1.2", "v1.2.3", "1.2.3rc1", "1.2.3+local", "1!1.2.3"]) def test_release_preflight_rejects_nonstable_versions(version: str) -> None: assert version_failure(version) is not None