tractor/tests/msg/test_process_local.py

212 lines
5.7 KiB
Python

'''
Process-local struct wire-encoding guards.
'''
from __future__ import annotations
import msgspec
import pytest
from tractor.msg import (
FrozenProcessLocal,
ProcessLocal,
)
class LocalHandle(ProcessLocal):
'''
Default marker whose sentinel must fail direct and nested
encoding.
'''
resource_id: int
class FrozenLocalHandle(FrozenProcessLocal):
'''
Frozen outer fields whose referenced `labels` list remains
mutable.
Supplying `_process_local=None` would also replace the
unsupported sentinel with an encodable value if construction did
not reject it.
'''
resource_id: int
# Mutating this list distinguishes shallow struct freezing from
# recursively freezing every referenced object.
labels: list[str]
# This class option would omit the default-valued sentinel from
# msgpack.
class OmittingLocalHandle(
ProcessLocal,
omit_defaults=True,
):
'''
Encoder config which would drop the default-valued sentinel.
With `omit_defaults=True`, both `_process_local` and
`resource_id`
equal their defaults, so msgspec could encode this struct as an
empty map without ever traversing `_ProcessLocalToken`.
'''
resource_id: int = 1
class ValidatedLocalHandle(ProcessLocal):
'''
Subclass validator which would shadow the marker's post-init
hook.
`ProcessLocal.__init_subclass__()` must wrap this method so
sentinel validation still runs without requiring a cooperative
`super()` call, while retaining this resource-id validation.
'''
resource_id: int
# Defining this hook normally shadows an inherited
# `__post_init__`; `ProcessLocal` must wrap it rather than rely
# on a `super()` call.
def __post_init__(self) -> None:
if self.resource_id < 0:
raise ValueError('resource_id must be non-negative')
class MutatingFrozenLocalHandle(FrozenProcessLocal):
'''
Hook which uses msgspec's frozen-field escape hatch on the
sentinel.
A check only before this hook would miss the replacement and
leave the completed struct encodable, so the wrapper must check
again after subclass validation.
'''
resource_id: int
# Replacing the sentinel inside this hook defeats a pre-hook-only
# check even though the struct is frozen.
def __post_init__(self) -> None:
# This is msgspec's supported internal mutation path for
# frozen structs, and therefore the strongest sentinel-
# replacement case.
msgspec.structs.force_setattr(
self,
'_process_local',
None,
)
@pytest.mark.parametrize(
'nested',
(
pytest.param(
False,
id='direct',
),
pytest.param(
True,
id='nested',
),
),
)
def test_process_local_rejects_default_encoding(
nested: bool,
) -> None:
'''
Process-local values can appear directly or deep in a payload.
Embed the same marked struct at both depths and prove msgspec's
normal traversal reaches the unsupported sentinel without a
tractor-specific recursive payload scan.
'''
handle: LocalHandle = LocalHandle(resource_id=1)
value: object = (
{'nested': [handle]}
if nested
else handle
)
assert repr(handle) == 'LocalHandle(resource_id=1)'
with pytest.raises(
TypeError,
match='_ProcessLocalToken.*unsupported',
):
msgspec.msgpack.encode(value)
def test_frozen_process_local_contract() -> None:
'''
Preserve wire rejection while freezing process-local struct
fields.
A plain frozen msgspec struct could otherwise cross actor IPC.
Build one `FrozenProcessLocal` with a mutable referenced list,
then prove direct and nested default encoding still reach
`_ProcessLocalToken`. Reject field replacement while allowing
mutation owned by the nested list, demonstrating that the marker
provides shallow immutability.
'''
handle = FrozenLocalHandle(
resource_id=1,
labels=['initial'],
)
for value in (
handle,
{'nested': [handle]},
):
with pytest.raises(
TypeError,
match='_ProcessLocalToken.*unsupported',
):
msgspec.msgpack.encode(value)
with pytest.raises(AttributeError):
handle.resource_id = 2 # type: ignore[misc]
handle.labels.append('changed')
assert handle.labels == ['initial', 'changed']
def test_process_local_rejects_sentinel_bypasses() -> None:
'''
Prevent constructor and encoder options from removing the
sentinel.
`FrozenLocalHandle` supplies an encodable sentinel replacement;
`OmittingLocalHandle` asks msgspec to omit the default sentinel;
`ValidatedLocalHandle` shadows the inherited post-init hook; and
`MutatingFrozenLocalHandle` replaces the sentinel from inside
that hook. Prove construction rejects each wire-safety bypass
while the ordinary subclass value validator still executes.
'''
with pytest.raises(TypeError, match='internal sentinel'):
FrozenLocalHandle(
resource_id=1,
labels=[],
_process_local=None, # type: ignore[arg-type]
)
with pytest.raises(TypeError, match='omit_defaults'):
OmittingLocalHandle()
with pytest.raises(TypeError, match='internal sentinel'):
ValidatedLocalHandle(
resource_id=1,
_process_local=None, # type: ignore[arg-type]
)
with pytest.raises(ValueError, match='must be non-negative'):
ValidatedLocalHandle(resource_id=-1)
with pytest.raises(TypeError, match='internal sentinel'):
MutatingFrozenLocalHandle(resource_id=1)