Skip to content

Commit 995fa00

Browse files
authored
Merge pull request #1424 from genematx/fix-asset-size
Strengthen back-compatibility
2 parents 3c7f913 + 462d79c commit 995fa00

5 files changed

Lines changed: 155 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ Write the date in place of the "Unreleased" in the case a new version is release
77

88
### Fixed
99

10+
- Strengthen the server-side backcompatibility. Strip the newly added `Asset.size` field
11+
from metadata responses when the request comes from a `python-tiled` client older than
12+
v0.2.13, whose `Asset` dataclass has no `size` field and would otherwise crash
13+
in `DataSource.from_json` with an unexpected keyword argument.
14+
15+
16+
## v0.2.13 (2026-07-08)
17+
18+
### Fixed
19+
1020
- Expand the functionality of HDF5Adapter to handle `object`-dtyped data:
1121
variable-length strings are coerced to fixed-length bytes, non-string object
1222
dtypes (e.g. vlen arrays) fall back to an empty placeholder that preserves

tests/test_asset_access.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,39 @@ def test_do_not_expose_raw_assets(tmpdir):
125125
client.write_array([1, 2, 3], key="x")
126126
with fail_with_status_code(HTTP_403_FORBIDDEN):
127127
client["x"].raw_export(tmpdir / "exported")
128+
129+
130+
@pytest.mark.parametrize(
131+
"user_agent, expect_size_key",
132+
[
133+
# New client understands ``size``; server includes it.
134+
("python-tiled/0.2.13", True),
135+
# Old client (pre-``size`` field) would crash on unknown kwarg; server strips it.
136+
("python-tiled/0.2.12", False),
137+
],
138+
)
139+
def test_asset_size_stripped_for_old_python_clients(
140+
client, user_agent, expect_size_key
141+
):
142+
"""The server strips ``size`` from assets when talking to python-tiled clients
143+
older than 0.2.13, because their ``Asset`` dataclass has no ``size`` field
144+
and ``DataSource.from_json`` unpacks kwargs directly, which would raise
145+
``Asset.__init__() got an unexpected keyword argument 'size'``.
146+
"""
147+
client.write_array([1, 2, 3], key="x")
148+
149+
# GET path: /metadata/... with include_data_sources
150+
response = client.context.http_client.get(
151+
"/api/v1/metadata/x",
152+
params={"include_data_sources": "true"},
153+
headers={"user-agent": user_agent},
154+
)
155+
response.raise_for_status()
156+
data_sources = response.json()["data"]["attributes"]["data_sources"]
157+
assert data_sources, "expected at least one data_source"
158+
for ds in data_sources:
159+
for asset in ds["assets"]:
160+
assert ("size" in asset) is expect_size_key, (
161+
f"user-agent={user_agent!r}: expected size key "
162+
f"present={expect_size_key}, got asset={asset!r}"
163+
)

tiled/server/_backcompat.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Server-side back-compatibility helpers.
2+
3+
Utilities for adapting outgoing responses (and interpreting incoming requests)
4+
so that older ``python-tiled`` clients keep working when the server evolves.
5+
"""
6+
7+
from typing import Optional
8+
9+
import packaging.version
10+
from fastapi import Request
11+
12+
# Fields added to `tiled.structures.data_source.Asset` after a given version.
13+
# Older python-tiled clients unpack asset dicts as dataclass kwargs and crash
14+
# on unknown fields, so we strip these fields from responses to clients older
15+
# than the version listed here.
16+
ASSET_FIELDS_ADDED_IN = {
17+
"size": packaging.version.parse("0.2.13"),
18+
}
19+
20+
21+
def raw_python_tiled_client_version(request: Request) -> Optional[str]:
22+
"""Return the raw ``<version>`` from a ``python-tiled/<version>`` User-Agent, or None.
23+
24+
Returns None when the User-Agent header is absent or does not identify a
25+
Python Tiled client (i.e. it is some other client that we do not need to
26+
special-case for backward compatibility).
27+
"""
28+
user_agent = request.headers.get("user-agent", "")
29+
if not user_agent.startswith("python-tiled/"):
30+
return None
31+
_, _, raw_version = user_agent.partition("/")
32+
return raw_version
33+
34+
35+
def parse_python_tiled_client_version(
36+
request: Request,
37+
) -> Optional[packaging.version.Version]:
38+
"""Return the parsed version from a ``python-tiled/<version>`` User-Agent, or None.
39+
40+
Returns None when the User-Agent is missing, is not a Python Tiled client,
41+
or reports a version that cannot be parsed. Callers that need to
42+
distinguish "not a Python Tiled client" from "unparseable version" should
43+
use ``raw_python_tiled_client_version`` and parse the string themselves.
44+
"""
45+
raw_version = raw_python_tiled_client_version(request)
46+
if raw_version is None:
47+
return None
48+
try:
49+
return packaging.version.parse(raw_version)
50+
except Exception:
51+
return None
52+
53+
54+
def strip_asset_fields_for_client(
55+
data_sources: list, client_version: Optional[packaging.version.Version]
56+
) -> None:
57+
"""Remove asset fields that the given python-tiled client cannot accept.
58+
59+
Mutates the assets inside ``data_sources`` in place. A ``client_version`` of
60+
None means the request did not come from python-tiled, so we leave the
61+
payload untouched.
62+
"""
63+
if client_version is None:
64+
return
65+
for field, added_in in ASSET_FIELDS_ADDED_IN.items():
66+
if client_version >= added_in:
67+
continue
68+
for ds in data_sources:
69+
for asset in ds.get("assets", []) or []:
70+
asset.pop(field, None)

tiled/server/app.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
from ..type_aliases import AppTask, TaskMap
5959
from ..utils import SHARE_TILED_PATH, Conflicts, UnsupportedQueryType
6060
from ..validation_registration import ValidationRegistry, default_validation_registry
61+
from ._backcompat import raw_python_tiled_client_version
6162
from .authentication import move_api_key
6263
from .compression import CompressionMiddleware
6364
from .protocols import ExternalAuthenticator, InternalAuthenticator
@@ -807,9 +808,8 @@ async def double_submit_cookie_csrf_protection(
807808
async def client_compatibility_check(
808809
request: Request, call_next: RequestResponseEndpoint
809810
):
810-
user_agent = request.headers.get("user-agent", "")
811-
if user_agent.startswith("python-tiled/"):
812-
agent, _, raw_version = user_agent.partition("/")
811+
raw_version = raw_python_tiled_client_version(request)
812+
if raw_version is not None:
813813
try:
814814
parsed_version = packaging.version.parse(raw_version)
815815
except Exception as caught_exception:

tiled/server/router.py

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@
5656
from ..utils import BrokenLink, ensure_awaitable, patch_mimetypes, path_from_uri
5757
from ..validation_registration import ValidationError, ValidationRegistry
5858
from . import schemas
59+
from ._backcompat import (
60+
parse_python_tiled_client_version,
61+
strip_asset_fields_for_client,
62+
)
5963
from .authentication import (
6064
authenticate_websocket_first_message,
6165
check_scopes,
@@ -201,20 +205,16 @@ async def about(
201205
"required": not settings.allow_anonymous_access,
202206
}
203207
provider_specs = []
204-
user_agent = request.headers.get("user-agent", "")
205208
# The name of the "internal" mode used to be "password".
206209
# This ensures back-compat with older Python clients.
207210
internal_mode_name = "internal"
208211
MINIMUM_INTERNAL_PYTHON_CLIENT_VERSION = packaging.version.parse("0.1.0b17")
209-
if user_agent.startswith("python-tiled/"):
210-
agent, _, raw_version = user_agent.partition("/")
211-
try:
212-
parsed_version = packaging.version.parse(raw_version)
213-
except Exception:
214-
pass
215-
else:
216-
if parsed_version < MINIMUM_INTERNAL_PYTHON_CLIENT_VERSION:
217-
internal_mode_name = "password"
212+
client_version = parse_python_tiled_client_version(request)
213+
if (
214+
client_version is not None
215+
and client_version < MINIMUM_INTERNAL_PYTHON_CLIENT_VERSION
216+
):
217+
internal_mode_name = "password"
218218
for provider, authenticator in authenticators.items():
219219
if isinstance(authenticator, InternalAuthenticator):
220220
spec = {
@@ -1912,10 +1912,14 @@ async def _create_node(
19121912
links = links_for_node(
19131913
structure_family, structure, get_base_url(request), path + f"/{node.key}"
19141914
)
1915+
data_sources_dump = [ds.model_dump() for ds in node.data_sources]
1916+
strip_asset_fields_for_client(
1917+
data_sources_dump, parse_python_tiled_client_version(request)
1918+
)
19151919
response_data = {
19161920
"id": node.key,
19171921
"links": links,
1918-
"data_sources": [ds.model_dump() for ds in node.data_sources],
1922+
"data_sources": data_sources_dump,
19191923
}
19201924
if metadata_modified:
19211925
response_data["metadata"] = metadata
@@ -2808,25 +2812,29 @@ async def metrics(request: Request, _=Security(check_scopes, scopes=["metrics"])
28082812

28092813

28102814
def _model_dump_backcompat(request: Request, response: schemas.Response) -> dict:
2811-
"""Backwards compatibility for clients older than v0.2.4
2815+
"""Adjust the outgoing response payload to match older client expectations.
2816+
2817+
- Clients older than v0.2.4 crash on `properties` in data sources.
2818+
Issue: https://github.com/bluesky/tiled/issues/1300
2819+
- Clients older than v0.2.13 crash on `size` in assets, because their
2820+
`tiled.structures.data_source.Asset` dataclass has no `size` field and
2821+
`DataSource.from_json` unpacks kwargs directly.
28122822
2813-
Older clients expect "data_sources" in the response to not include "properties".
28142823
To be removed in a future major release.
2815-
Issue: https://github.com/bluesky/tiled/issues/1300
28162824
"""
28172825
response_dict = response.model_dump()
2818-
user_agent = request.headers.get("user-agent", "")
2819-
if user_agent.startswith("python-tiled/"):
2820-
agent, _, raw_version = user_agent.partition("/")
2821-
try:
2822-
parsed_version = packaging.version.parse(raw_version)
2823-
if parsed_version < packaging.version.parse("0.2.4"):
2824-
for ds in response_dict["data"]["attributes"]["data_sources"]:
2825-
ds.pop("properties", None)
2826-
2827-
return response_dict
2828-
2829-
except Exception:
2830-
pass
2831-
2826+
client_version = parse_python_tiled_client_version(request)
2827+
if client_version is None:
2828+
return response_dict
2829+
data = response_dict.get("data") or []
2830+
resources = data if isinstance(data, list) else [data]
2831+
all_data_sources = [
2832+
ds
2833+
for resource in resources
2834+
for ds in (resource.get("attributes", {}) or {}).get("data_sources") or []
2835+
]
2836+
if client_version < packaging.version.parse("0.2.4"):
2837+
for ds in all_data_sources:
2838+
ds.pop("properties", None)
2839+
strip_asset_fields_for_client(all_data_sources, client_version)
28322840
return response_dict

0 commit comments

Comments
 (0)