Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/check_release_preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 35 additions & 1 deletion src/ml4t/specs/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
import os
import re
import stat
from collections.abc import Mapping
from pathlib import Path
Expand All @@ -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:
Expand Down Expand Up @@ -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)


Expand Down
2 changes: 1 addition & 1 deletion tests/test_release_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
62 changes: 62 additions & 0 deletions tests/test_spec_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down
Loading