Skip to content

Commit 20d8537

Browse files
committed
ENH: tolerate unknown fields when decoding server JSON
When a client built against an older Tiled release talks to a newer server that has added a field to a client-side dataclass (Asset, DataSource, Spec, AwkwardStructure, TableStructure, ContainerStructure, ...), the client previously crashed with `TypeError: unexpected keyword argument` because `Structure.from_json`, `DataSource.from_json`, and `Spec(**...)` splatted the payload directly into the dataclass. Add a small `filter_known_kwargs` helper in `tiled.structures._compat` that drops unknown keys and logs them at DEBUG. Wire it into `Structure.from_json`, `Asset.from_json` (new), `DataSource.from_json`, and `Spec.from_json` (new), and update the two `Spec(**spec)` call sites in `tiled.client.base` to use `Spec.from_json`. The server continues to strip fields for known-broken client versions via the User-Agent gate, but this makes the client resilient to any future additions without requiring a server-side back-compat entry.
1 parent 462d79c commit 20d8537

7 files changed

Lines changed: 183 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ Write the date in place of the "Unreleased" in the case a new version is release
1111
from metadata responses when the request comes from a `python-tiled` client older than
1212
v0.2.13, whose `Asset` dataclass has no `size` field and would otherwise crash
1313
in `DataSource.from_json` with an unexpected keyword argument.
14+
- Tolerate unknown fields on the client side when decoding server JSON into
15+
`Asset`, `DataSource`, `Spec`, and structure dataclasses (`AwkwardStructure`,
16+
`TableStructure`, `ContainerStructure`). Unknown keys are dropped and logged
17+
at DEBUG so a client can talk to a newer server without crashing on
18+
fields it does not recognize.
1419

1520

1621
## v0.2.13 (2026-07-08)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Client-side tolerance for unknown fields in server JSON payloads."""
2+
3+
import logging
4+
5+
from tiled.structures.awkward import AwkwardStructure
6+
from tiled.structures.container import ContainerStructure
7+
from tiled.structures.core import Spec
8+
from tiled.structures.data_source import Asset, DataSource
9+
from tiled.structures.table import TableStructure
10+
11+
12+
def test_asset_from_json_tolerates_unknown_fields(caplog):
13+
payload = {
14+
"data_uri": "file:///tmp/x",
15+
"is_directory": False,
16+
"parameter": None,
17+
"num": 0,
18+
"id": 1,
19+
"size": 42,
20+
"future_field": "from a newer server",
21+
}
22+
with caplog.at_level(logging.DEBUG, logger="tiled.utils"):
23+
asset = Asset.from_json(payload)
24+
assert asset.data_uri == "file:///tmp/x"
25+
assert asset.size == 42
26+
assert "future_field" in caplog.text
27+
28+
29+
def test_data_source_from_json_tolerates_unknown_fields():
30+
payload = {
31+
"structure_family": "container",
32+
"structure": None,
33+
"id": 1,
34+
"mimetype": "application/x-tiled-container",
35+
"parameters": {},
36+
"properties": {},
37+
"assets": [
38+
{
39+
"data_uri": "file:///tmp/x",
40+
"is_directory": False,
41+
"parameter": None,
42+
"size": 7,
43+
"future_asset_field": "unknown",
44+
}
45+
],
46+
"management": "writable",
47+
"future_ds_field": "unknown",
48+
}
49+
ds = DataSource.from_json(payload)
50+
assert ds.mimetype == "application/x-tiled-container"
51+
assert ds.assets[0].size == 7
52+
53+
54+
def test_spec_from_json_tolerates_unknown_fields():
55+
spec = Spec.from_json({"name": "foo", "version": "1", "future_field": "unknown"})
56+
assert spec.name == "foo"
57+
assert spec.version == "1"
58+
59+
60+
def test_awkward_structure_from_json_tolerates_unknown_fields():
61+
payload = {"length": 10, "form": {}, "future_field": "unknown"}
62+
structure = AwkwardStructure.from_json(payload)
63+
assert structure.length == 10
64+
65+
66+
def test_table_structure_from_json_tolerates_unknown_fields():
67+
payload = {
68+
"arrow_schema": "",
69+
"npartitions": 1,
70+
"columns": [],
71+
"resizable": False,
72+
"future_field": "unknown",
73+
}
74+
structure = TableStructure.from_json(payload)
75+
assert structure.npartitions == 1
76+
77+
78+
def test_container_structure_from_json_tolerates_unknown_fields():
79+
payload = {"keys": ["a", "b"], "future_field": "unknown"}
80+
structure = ContainerStructure.from_json(payload)
81+
assert list(structure.keys) == ["a", "b"]

tiled/client/base.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ def metadata_copy(self):
259259
validating metadata (useful with update_metadata())
260260
"""
261261
metadata = deepcopy(self._item["attributes"]["metadata"])
262-
specs = [Spec(**spec) for spec in self._item["attributes"]["specs"]]
262+
specs = [Spec.from_json(spec) for spec in self._item["attributes"]["specs"]]
263263
access_tags = deepcopy(self._item["attributes"]["access_blob"].get("tags", []))
264264
return [
265265
md for md in [metadata, specs, access_tags] if md is not None
@@ -268,7 +268,9 @@ def metadata_copy(self):
268268
@property
269269
def specs(self) -> ListView[Spec]:
270270
"List of specifications describing the structure of the metadata and/or data."
271-
return ListView([Spec(**spec) for spec in self._item["attributes"]["specs"]])
271+
return ListView(
272+
[Spec.from_json(spec) for spec in self._item["attributes"]["specs"]]
273+
)
272274

273275
@property
274276
def access_blob(self) -> DictView[str, JSON_ITEM]:
@@ -418,7 +420,7 @@ def raw_export(self, destination=None, max_workers=4, **kwargs):
418420
destination = kwargs.pop("destination_directory")
419421
if kwargs:
420422
raise TypeError(
421-
f"raw_export() got unexpected keyword arguments: " f"{sorted(kwargs)!r}"
423+
f"raw_export() got unexpected keyword arguments: {sorted(kwargs)!r}"
422424
)
423425

424426
in_memory = isinstance(destination, MutableMapping)

tiled/structures/core.py

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@
66

77
import enum
88
import importlib
9+
from collections.abc import Mapping
910
from dataclasses import asdict, dataclass
10-
from typing import Dict, Optional
11+
from typing import Any, Dict, Optional
1112

1213
from pydantic import StringConstraints
1314
from typing_extensions import Annotated
1415

15-
from ..utils import OneShotCachedMap
16+
from ..utils import OneShotCachedMap, filter_known_kwargs
1617

1718

1819
class StructureFamily(str, enum.Enum):
@@ -50,30 +51,48 @@ def dict(self) -> Dict[str, Optional[str]]:
5051

5152
model_dump = dict # For easy interoperability with pydantic 2.x models
5253

54+
@classmethod
55+
def from_json(cls, data: Mapping[str, Any]) -> "Spec":
56+
return cls(**filter_known_kwargs(cls, data))
57+
5358

5459
# TODO: make type[Structure] after #1036
5560
STRUCTURE_TYPES = OneShotCachedMap[StructureFamily, type](
5661
{
57-
StructureFamily.array: lambda: importlib.import_module(
58-
"...structures.array", StructureFamily.__module__
59-
).ArrayStructure,
60-
StructureFamily.awkward: lambda: importlib.import_module(
61-
"...structures.awkward", StructureFamily.__module__
62-
).AwkwardStructure,
63-
StructureFamily.table: lambda: importlib.import_module(
64-
"...structures.table", StructureFamily.__module__
65-
).TableStructure,
66-
StructureFamily.sparse: lambda: importlib.import_module(
67-
"...structures.sparse", StructureFamily.__module__
68-
).SparseStructure,
69-
StructureFamily.ragged: lambda: importlib.import_module(
70-
"...structures.ragged", StructureFamily.__module__
71-
).RaggedStructure,
72-
StructureFamily.container: lambda: importlib.import_module(
73-
"...structures.container", StructureFamily.__module__
74-
).ContainerStructure,
75-
StructureFamily.bytes: lambda: importlib.import_module(
76-
"...structures.bytes", StructureFamily.__module__
77-
).BytesStructure,
62+
StructureFamily.array: lambda: (
63+
importlib.import_module(
64+
"...structures.array", StructureFamily.__module__
65+
).ArrayStructure
66+
),
67+
StructureFamily.awkward: lambda: (
68+
importlib.import_module(
69+
"...structures.awkward", StructureFamily.__module__
70+
).AwkwardStructure
71+
),
72+
StructureFamily.table: lambda: (
73+
importlib.import_module(
74+
"...structures.table", StructureFamily.__module__
75+
).TableStructure
76+
),
77+
StructureFamily.sparse: lambda: (
78+
importlib.import_module(
79+
"...structures.sparse", StructureFamily.__module__
80+
).SparseStructure
81+
),
82+
StructureFamily.ragged: lambda: (
83+
importlib.import_module(
84+
"...structures.ragged", StructureFamily.__module__
85+
).RaggedStructure
86+
),
87+
StructureFamily.container: lambda: (
88+
importlib.import_module(
89+
"...structures.container", StructureFamily.__module__
90+
).ContainerStructure
91+
),
92+
StructureFamily.bytes: lambda: (
93+
importlib.import_module(
94+
"...structures.bytes", StructureFamily.__module__
95+
).BytesStructure
96+
),
7897
}
7998
)

tiled/structures/data_source.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from tiled.structures.root import Structure
77

8+
from ..utils import filter_known_kwargs
89
from .core import StructureFamily
910

1011

@@ -24,6 +25,10 @@ class Asset:
2425
id: Optional[int] = None
2526
size: Optional[int] = None
2627

28+
@classmethod
29+
def from_json(cls, data: Mapping[str, Any]) -> "Asset":
30+
return cls(**filter_known_kwargs(cls, data))
31+
2732

2833
StructureT = TypeVar("StructureT", bound=Optional[Structure])
2934

@@ -41,6 +46,6 @@ class DataSource(Generic[StructureT]):
4146

4247
@classmethod
4348
def from_json(cls, structure: Mapping[str, Any]) -> "DataSource":
44-
d = structure.copy()
45-
assets = [Asset(**a) for a in d.pop("assets")]
46-
return cls(assets=assets, **d)
49+
d = dict(structure)
50+
assets = [Asset.from_json(a) for a in d.pop("assets", [])]
51+
return cls(assets=assets, **filter_known_kwargs(cls, d))

tiled/structures/root.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
from collections.abc import Mapping
33
from typing import Any
44

5+
from ..utils import filter_known_kwargs
6+
57

68
class Structure(ABC):
79
@classmethod
810
# TODO: When dropping support for Python 3.10 replace with -> Self
911
def from_json(cls, structure: Mapping[str, Any]) -> "Structure":
10-
return cls(**structure)
12+
return cls(**filter_known_kwargs(cls, structure))

tiled/utils.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
import builtins
33
import collections.abc
44
import contextlib
5+
import dataclasses
56
import functools
67
import importlib
78
import importlib.util
89
import inspect
10+
import logging
911
import operator
1012
import os
1113
import platform
@@ -21,6 +23,43 @@
2123
import anyio
2224
import yaml
2325

26+
logger = logging.getLogger(__name__)
27+
28+
29+
def filter_known_kwargs(target: Any, data: collections.abc.Mapping) -> dict:
30+
"""Return `data` restricted to keys that `target` accepts as kwargs.
31+
32+
`target` may be a dataclass (fields are consulted directly) or any other
33+
callable (its signature is introspected). Keys dropped from `data` are
34+
logged at DEBUG so a client talking to a newer server can be diagnosed
35+
without failing.
36+
"""
37+
if dataclasses.is_dataclass(target):
38+
known = {f.name for f in dataclasses.fields(target)}
39+
else:
40+
params = inspect.signature(target).parameters
41+
if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()):
42+
return dict(data)
43+
known = {
44+
name
45+
for name, p in params.items()
46+
if p.kind
47+
in (
48+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
49+
inspect.Parameter.KEYWORD_ONLY,
50+
)
51+
}
52+
filtered = {k: v for k, v in data.items() if k in known}
53+
if dropped := set(data) - set(filtered):
54+
name = getattr(target, "__name__", repr(target))
55+
logger.debug(
56+
"Ignoring unknown field(s) %s from server payload for %s; "
57+
"the server is likely newer than this client.",
58+
sorted(dropped),
59+
name,
60+
)
61+
return filtered
62+
2463
# helper for avoiding re-typing patch mimetypes
2564
# namedtuple for the lack of StrEnum in py<3.11
2665
patch_mimetypes = namedtuple(

0 commit comments

Comments
 (0)