[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/pythonnative/pythonnative/main/tests/test_native_values.py [Back]  [Original]

"""Public Python values survive the native transport without type erasure."""

import asyncio
import json
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, ClassVar, Iterator, Literal, Mapping, NotRequired, Protocol, Sequence, TypedDict

import pytest

import pythonnative as pn
from pythonnative.native_modules.registry import BridgeModule, PythonModule
from pythonnative.sdk.schema import COMPONENTS, MODULES, ModuleSchema
from pythonnative.sdk.types import decode_value, encode_value, type_schema


class Accuracy(Enum):
    BALANCED = "balanced"
    HIGH = "high"


@dataclass(frozen=True)
class Coordinate:
    latitude: float
    longitude: float
    accuracy: Accuracy = Accuracy.BALANCED
    labels: list[str] = field(default_factory=list)


class Note(TypedDict):
    title: str
    body: NotRequired[str]


class NullableNote(TypedDict):
    required: str | None
    optional: NotRequired[str | None]


class LocationAPI(Protocol):
    async def locate(self, *, hint: Coordinate, timeout: float = 10) -> Coordinate: ...


@pytest.fixture
def location_contract() -> Iterator[ModuleSchema]:
    old = MODULES.get("TypedLocationTest")
    schema = ModuleSchema.from_protocol("TypedLocationTest", LocationAPI)
    MODULES[schema.name] = schema
    yield schema
    if old is None:
        MODULES.pop(schema.name, None)
    else:
        MODULES[schema.name] = old


def test_nested_record_enum_and_collection_round_trip() -> None:
    value = {"home": (Coordinate(12.5, -70.25, Accuracy.HIGH, ["favorite"]), None)}
    schema = type_schema(Mapping[str, tuple[Coordinate, str | None]])
    wire = json.loads(json.dumps(encode_value(value, schema)))
    assert wire["home"][0]["accuracy"] == "high"
    assert decode_value(wire, schema) == value
    assert isinstance(decode_value(wire, schema)["home"][0], Coordinate)


def test_record_defaults_and_typed_dict_absence() -> None:
    result = decode_value({"latitude": 1, "longitude": 2}, type_schema(Coordinate))
    assert result == Coordinate(1, 2)
    assert decode_value({"title": "Draft"}, type_schema(Note)) == {"title": "Draft"}
    with pytest.raises(TypeError, match="requires title"):
        decode_value({}, type_schema(Note))


def test_nullable_record_fields_preserve_presence() -> None:
    schema = type_schema(NullableNote)
    for value in ({"required": None}, {"required": None, "optional": None}, {"required": "yes", "optional": "yes"}):
        assert decode_value(encode_value(value, schema), schema) == value
    with pytest.raises(TypeError, match="requires required"):
        decode_value({"optional": None}, schema)


def test_record_field_names_fail_before_native_compilation() -> None:
    Invalid = TypedDict("Invalid", {"user-name": str})
    with pytest.raises(TypeError, match="Python identifier"):
        type_schema(Invalid)


def test_sequences_and_dataclass_class_variables_keep_python_semantics() -> None:
    @dataclass
    class Batch:
        version: ClassVar[int] = 1
        values: Sequence[int]

    schema = type_schema(Batch)
    assert encode_value(Batch(range(3)), schema) == {"values": [0, 1, 2]}
    assert decode_value({"values": [0, 1, 2]}, schema) == Batch([0, 1, 2])
    with pytest.raises(TypeError, match="must be number"):
        decode_value(10**1000, type_schema(float))


def test_custom_component_defaults_and_platforms_survive_factory_creation() -> None:
    from pythonnative.platform import _set_platform_for_test
    from pythonnative.sdk import define_component, unregister_component

    @dataclass(frozen=True)
    class MarkerProps:
        coordinate: Coordinate = field(default_factory=lambda: Coordinate(1, 2))

    try:
        marker = define_component("TypedMarkerTest", MarkerProps, platforms=("ios",))
        element = marker(style={"padding": 4, "opacity": 0.5})
        assert element.props["coordinate"] == Coordinate(1, 2)
        assert element.props["padding"] == 4
        COMPONENTS["TypedMarkerTest"].validate(element.props)
        assert COMPONENTS["TypedMarkerTest"].defaults["coordinate"]["accuracy"] == "balanced"
        _set_platform_for_test("ios")
        assert pn.Platform.supports("TypedMarkerTest")
        _set_platform_for_test("android")
        assert not pn.Platform.supports("TypedMarkerTest")
    finally:
        unregister_component("TypedMarkerTest")
        _set_platform_for_test(None)


def test_literal_discriminated_records() -> None:
    class Success(TypedDict):
        status: Literal["ok"]
        value: int

    class Failure(TypedDict):
        status: Literal["error"]
        message: str

    schema = type_schema(Success | Failure)
    assert decode_value({"status": "error", "message": "offline"}, schema) == {"status": "error", "message": "offline"}
    with pytest.raises(TypeError, match="annotation"):
        decode_value({"status": "ok", "message": "wrong shape"}, schema)


@pytest.mark.parametrize("annotation", [object, dict[int, str], complex])
def test_unsupported_annotations_fail_during_declaration(annotation: Any) -> None:
    with pytest.raises(TypeError):
        type_schema(annotation)


@pytest.mark.parametrize("value", [{1: "numeric key"}, object(), float("nan"), 2**53])
def test_values_cannot_be_silently_dropped_or_coerced(value: Any) -> None:
    with pytest.raises(TypeError):
        encode_value(value)


def test_typed_native_call_preserves_defaults_and_results(location_contract: ModuleSchema) -> None:
    class Transport:
        def call(self, module: str, method: str, envelope: str) -> str:
            request = json.loads(envelope)
            assert request["args"]["timeout"] == 10
            assert request["args"]["hint"]["accuracy"] == "balanced"
            return json.dumps({"ok": True, "value": request["args"]["hint"]})

    result = asyncio.run(BridgeModule("TypedLocationTest", Transport()).call_async("locate", hint=Coordinate(1, 2)))
    assert type(result) is Coordinate
    assert result == Coordinate(1, 2)


def test_fallback_receives_same_typed_arguments(location_contract: ModuleSchema) -> None:
    class Implementation:
        async def locate(self, *, hint: Coordinate, timeout: float) -> Coordinate:
            assert type(hint) is Coordinate
            assert timeout == 10
            return hint

    result = asyncio.run(
        PythonModule("TypedLocationTest", Implementation()).call_async("locate", hint={"latitude": 1, "longitude": 2})
    )
    assert result == Coordinate(1, 2)


def test_media_event_shapes_match_real_native_payloads() -> None:
    image = COMPONENTS["Image"].decode_event("on_load", [{"width": 64, "height": 32}])
    assert image == [pn.ImageLoadEvent(64, 32)]
    web = COMPONENTS["WebView"].decode_event(
        "on_navigation_state_change",
        [
            {
                "url": "https://example.com",
                "loading": False,
                "can_go_back": True,
                "can_go_forward": False,
                "title": "Example",
            }
        ],
    )
    assert web[0].can_go_back is True
    assert web[0].title == "Example"
    for property_name in ("max_lines", "text_transform"):
        assert COMPONENTS["Text"].props[property_name]["native"]["invalidates_layout"]


def test_null_and_property_removal_have_distinct_wire_and_native_state() -> None:
    from pythonnative.bridge import codec
    from pythonnative.bridge.commits import PROTOCOL_VERSION, CommitError, CommitState
    from pythonnative.mutations import UNSET, CreateOp, UpdateOp

    operations, _ = codec.encode_transaction(
        [
            CreateOp(1, "Picker", {"value": "draft"}),
            UpdateOp(1, {"value": None}),
        ]
    )
    state = (
        CommitState()
        .prepare(
            {
                "version": PROTOCOL_VERSION,
                "application": "null-test",
                "surface": 1,
                "revision": 1,
                "ops": json.loads(operations),
            }
        )
        .publish()
    )
    assert state.views[1].props["value"] is None
    operations, _ = codec.encode_transaction([UpdateOp(1, {"value": UNSET})])
    assert json.loads(operations) == [["u", 1, {}, ["value"]]]
    envelope = {
        "version": PROTOCOL_VERSION,
        "application": "null-test",
        "surface": 1,
        "revision": 2,
        "ops": json.loads(operations),
    }
    state = state.prepare(envelope).publish()
    assert "value" not in state.views[1].props
    with pytest.raises(CommitError, match="both set and removed"):
        state.prepare({**envelope, "revision": 3, "ops": [["u", 1, {"value": "conflict"}, ["value"]]]})


def test_unserializable_native_props_are_errors_unless_declared_python_only() -> None:
    from pythonnative.bridge.codec import split_props

    value = object()
    with pytest.raises(TypeError, match="bridge-serializable"):
        split_props({"value": value})
    assert split_props({"value": value}, frozenset({"value"})) == ({}, {"value": value})


def test_typed_module_events_decode_before_delivery(monkeypatch: pytest.MonkeyPatch) -> None:
    from pythonnative.native_modules import registry

    schema = ModuleSchema.from_protocol("TypedLocationTest", LocationAPI, events={"changed": Coordinate})
    monkeypatch.setitem(MODULES, schema.name, schema)
    module = PythonModule(schema.name)
    monkeypatch.setitem(registry._modules, schema.name, module)
    received: list[Coordinate] = []
    unsubscribe = module.add_listener("changed", received.append)
    registry.emit(schema.name, "changed", {"latitude": 2, "longitude": 3})
    assert received == [Coordinate(2, 3)]
    with pytest.raises(TypeError):
        registry.emit(schema.name, "changed", {"latitude": "bad", "longitude": 3})
    assert len(received) == 1
    unsubscribe()
    registry.emit(schema.name, "changed", {"latitude": 4, "longitude": 5})
    assert len(received) == 1


def test_cancelling_typed_native_call_releases_native_work(location_contract: ModuleSchema) -> None:
    calls: list[tuple[str, dict[str, Any]]] = []

    class Transport:
        def call(self, module: str, method: str, envelope: str) -> str:
            request = json.loads(envelope)
            calls.append((method, request))
            return json.dumps({"pending": True} if method == "locate" else {"ok": True})

    async def exercise() -> None:
        task = asyncio.create_task(
            BridgeModule("TypedLocationTest", Transport()).call_async("locate", hint=Coordinate(1, 2))
        )
        await asyncio.sleep(0)
        task.cancel()
        with pytest.raises(asyncio.CancelledError):
            await task

    asyncio.run(exercise())
    assert [method for method, _ in calls] == ["locate", "_pn_cancel"]
    assert calls[1][1]["args"]["call_id"] == calls[0][1]["call_id"]

Web Proxy Viewer  |  New URL  |  Original Page