Skip to content

Commit 597b989

Browse files
adamtheturtleclaude
andcommitted
Preserve reco fields through to_dict and from_dict round trips
``CloudDatabase.to_dict`` omitted ``reco_threshold``, ``total_recos``, ``current_month_recos`` and ``previous_month_recos``, and ``ImageTarget.to_dict`` omitted the three reco counts plus ``reco_rating``, so ``from_dict`` restored those fields to their class defaults. Add round trip tests which assert that every field of both dataclasses survives, so that a new field cannot quietly fall out of the serialisation. Fixes #3374. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1340e91 commit 597b989

4 files changed

Lines changed: 174 additions & 1 deletion

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Preserve the recognition count fields, the reco rating and the reco threshold when dumping a ``CloudDatabase`` or an ``ImageTarget`` to a dictionary and loading it back.

src/mock_vws/database.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ class CloudDatabaseDict(TypedDict):
3636
database_type_name: str
3737
targets: Iterable[ImageTargetDict]
3838
request_quota: NotRequired[int]
39+
reco_threshold: NotRequired[int]
40+
current_month_recos: NotRequired[int]
41+
previous_month_recos: NotRequired[int]
42+
total_recos: NotRequired[int]
3943
target_quota: NotRequired[int]
4044
requests_per_second_limit: NotRequired[int | None]
4145
request_rate_limits: NotRequired[RequestRateLimitsDict | None]
@@ -144,6 +148,10 @@ def to_dict(self) -> CloudDatabaseDict:
144148
"database_type_name": self.database_type.name,
145149
"targets": targets,
146150
"request_quota": self.request_quota,
151+
"reco_threshold": self.reco_threshold,
152+
"current_month_recos": self.current_month_recos,
153+
"previous_month_recos": self.previous_month_recos,
154+
"total_recos": self.total_recos,
147155
"target_quota": self.target_quota,
148156
"requests_per_second_limit": self.requests_per_second_limit,
149157
"request_rate_limits": request_rate_limits,
@@ -183,6 +191,10 @@ def from_dict(cls, database_dict: CloudDatabaseDict) -> Self:
183191
database_type=DatabaseType[database_dict["database_type_name"]],
184192
targets=targets,
185193
request_quota=database_dict.get("request_quota", 100000),
194+
reco_threshold=database_dict.get("reco_threshold", 1000),
195+
current_month_recos=database_dict.get("current_month_recos", 0),
196+
previous_month_recos=database_dict.get("previous_month_recos", 0),
197+
total_recos=database_dict.get("total_recos", 0),
186198
target_quota=database_dict.get("target_quota", 1000),
187199
requests_per_second_limit=database_dict.get(
188200
"requests_per_second_limit"

src/mock_vws/target.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import statistics
77
import uuid
88
from dataclasses import dataclass, field
9-
from typing import Self, TypedDict
9+
from typing import NotRequired, Self, TypedDict
1010
from zoneinfo import ZoneInfo
1111

1212
from beartype import BeartypeConf, beartype
@@ -43,6 +43,10 @@ class ImageTargetDict(TypedDict):
4343
delete_date_optional: str | None
4444
upload_date: str
4545
tracking_rating: int
46+
current_month_recos: NotRequired[int]
47+
previous_month_recos: NotRequired[int]
48+
total_recos: NotRequired[int]
49+
reco_rating: NotRequired[str]
4650

4751

4852
@beartype
@@ -193,6 +197,10 @@ def from_dict(cls, target_dict: ImageTargetDict) -> Self:
193197
last_modified_date=last_modified_date,
194198
upload_date=upload_date,
195199
target_tracking_rater=target_tracking_rater,
200+
current_month_recos=target_dict.get("current_month_recos", 0),
201+
previous_month_recos=target_dict.get("previous_month_recos", 0),
202+
total_recos=target_dict.get("total_recos", 0),
203+
reco_rating=target_dict.get("reco_rating", ""),
196204
)
197205

198206
def to_dict(self) -> ImageTargetDict:
@@ -215,6 +223,10 @@ def to_dict(self) -> ImageTargetDict:
215223
"delete_date_optional": delete_date,
216224
"upload_date": self.upload_date.isoformat(),
217225
"tracking_rating": self.tracking_rating,
226+
"current_month_recos": self.current_month_recos,
227+
"previous_month_recos": self.previous_month_recos,
228+
"total_recos": self.total_recos,
229+
"reco_rating": self.reco_rating,
218230
}
219231

220232

tests/mock_vws/test_requests_mock_usage.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for the usage of the mock for ``requests``."""
22

3+
import dataclasses
34
import datetime
45
import email.utils
56
import io
@@ -8,6 +9,7 @@
89
import zipfile
910
from http import HTTPStatus
1011
from urllib.parse import urlparse
12+
from zoneinfo import ZoneInfo
1113

1214
import httpx
1315
import pytest
@@ -33,6 +35,7 @@
3335
RequestRateLimiter,
3436
)
3537
from mock_vws.database import CloudDatabase, VuMarkDatabase
38+
from mock_vws.database_type import DatabaseType
3639
from mock_vws.image_matchers import ExactMatcher, StructuralSimilarityMatcher
3740
from mock_vws.request_rate_limits import (
3841
DOCUMENTED_REQUEST_RATE_LIMITS,
@@ -42,6 +45,7 @@
4245
)
4346
from mock_vws.states import States
4447
from mock_vws.target import ImageTarget, VuMarkTarget
48+
from mock_vws.target_raters import HardcodedTargetTrackingRater
4549
from tests.mock_vws.utils import Endpoint
4650
from tests.mock_vws.utils.assertions import assert_vws_failure
4751
from tests.mock_vws.utils.usage_test_helpers import (
@@ -974,6 +978,74 @@ def test_to_dict_deleted(high_quality_image: io.BytesIO) -> None:
974978
new_target = ImageTarget.from_dict(target_dict=target_dict)
975979
assert new_target.delete_date == target.delete_date
976980

981+
@staticmethod
982+
def test_round_trip_non_default_fields(
983+
high_quality_image: io.BytesIO,
984+
) -> None:
985+
"""Every field of a target survives a dictionary round trip.
986+
987+
The target tracking rater is deliberately not preserved:
988+
``to_dict`` writes the computed tracking rating and ``from_dict``
989+
rebuilds the target with a hardcoded rater which gives that
990+
rating.
991+
"""
992+
gmt = ZoneInfo(key="GMT")
993+
target = ImageTarget(
994+
active_flag=False,
995+
application_metadata="example-metadata",
996+
current_month_recos=1,
997+
delete_date=datetime.datetime(
998+
year=2020, month=1, day=4, tzinfo=gmt
999+
),
1000+
image_value=high_quality_image.getvalue(),
1001+
last_modified_date=datetime.datetime(
1002+
year=2020, month=1, day=3, tzinfo=gmt
1003+
),
1004+
name="example",
1005+
previous_month_recos=2,
1006+
processing_time_seconds=0.5,
1007+
reco_rating="example-reco-rating",
1008+
target_id="example-target-id",
1009+
target_tracking_rater=HardcodedTargetTrackingRater(rating=4),
1010+
total_recos=3,
1011+
upload_date=datetime.datetime(
1012+
year=2020, month=1, day=2, tzinfo=gmt
1013+
),
1014+
width=1.5,
1015+
)
1016+
# Adding a field to ``ImageTarget`` must mean adding it to this
1017+
# test, and therefore to the round trip.
1018+
expected_field_names = {
1019+
"active_flag",
1020+
"application_metadata",
1021+
"current_month_recos",
1022+
"delete_date",
1023+
"image_value",
1024+
"last_modified_date",
1025+
"name",
1026+
"previous_month_recos",
1027+
"processing_time_seconds",
1028+
"reco_rating",
1029+
"target_id",
1030+
"target_tracking_rater",
1031+
"total_recos",
1032+
"upload_date",
1033+
"width",
1034+
}
1035+
field_names = {
1036+
field.name
1037+
for field in dataclasses.fields(class_or_instance=ImageTarget)
1038+
}
1039+
assert field_names == expected_field_names
1040+
1041+
target_dict = target.to_dict()
1042+
# The dictionary is JSON dump-able
1043+
assert json.dumps(obj=target_dict)
1044+
1045+
new_target = ImageTarget.from_dict(target_dict=target_dict)
1046+
assert new_target == target
1047+
assert new_target.tracking_rating == target.tracking_rating
1048+
9771049
@staticmethod
9781050
def test_vumark_target_to_dict() -> None:
9791051
"""It is possible to dump a VuMark target to a dictionary and
@@ -1078,6 +1150,82 @@ def test_custom_request_rate_limits() -> None:
10781150
new_database.request_rate_limits == DOCUMENTED_REQUEST_RATE_LIMITS
10791151
)
10801152

1153+
@staticmethod
1154+
def test_round_trip_non_default_fields(
1155+
high_quality_image: io.BytesIO,
1156+
) -> None:
1157+
"""Every field of a database survives a dictionary round trip."""
1158+
gmt = ZoneInfo(key="GMT")
1159+
target = ImageTarget(
1160+
active_flag=True,
1161+
application_metadata=None,
1162+
image_value=high_quality_image.getvalue(),
1163+
last_modified_date=datetime.datetime(
1164+
year=2020, month=1, day=3, tzinfo=gmt
1165+
),
1166+
name="example",
1167+
processing_time_seconds=0.5,
1168+
target_tracking_rater=HardcodedTargetTrackingRater(rating=4),
1169+
upload_date=datetime.datetime(
1170+
year=2020, month=1, day=2, tzinfo=gmt
1171+
),
1172+
width=1.5,
1173+
)
1174+
database = CloudDatabase(
1175+
client_access_key="example-client-access-key",
1176+
client_secret_key="example-client-secret-key",
1177+
current_month_recos=1,
1178+
database_id="example-database-id",
1179+
database_name="example-database-name",
1180+
# ``CLOUD_RECO`` is the only database type, so it is not
1181+
# possible to use a non-default value here.
1182+
database_type=DatabaseType.CLOUD_RECO,
1183+
previous_month_recos=2,
1184+
reco_threshold=3,
1185+
request_quota=4,
1186+
request_rate_limits=DOCUMENTED_REQUEST_RATE_LIMITS,
1187+
requests_per_second_limit=5,
1188+
server_access_key="example-server-access-key",
1189+
server_secret_key="example-server-secret-key",
1190+
state=States.PROJECT_SUSPENDED,
1191+
target_quota=6,
1192+
targets={target},
1193+
total_recos=7,
1194+
)
1195+
# Adding a field to ``CloudDatabase`` must mean adding it to this
1196+
# test, and therefore to the round trip.
1197+
expected_field_names = {
1198+
"client_access_key",
1199+
"client_secret_key",
1200+
"current_month_recos",
1201+
"database_id",
1202+
"database_name",
1203+
"database_type",
1204+
"previous_month_recos",
1205+
"reco_threshold",
1206+
"request_quota",
1207+
"request_rate_limits",
1208+
"requests_per_second_limit",
1209+
"server_access_key",
1210+
"server_secret_key",
1211+
"state",
1212+
"target_quota",
1213+
"targets",
1214+
"total_recos",
1215+
}
1216+
field_names = {
1217+
field.name
1218+
for field in dataclasses.fields(class_or_instance=CloudDatabase)
1219+
}
1220+
assert field_names == expected_field_names
1221+
1222+
database_dict = database.to_dict()
1223+
# The dictionary is JSON dump-able
1224+
assert json.dumps(obj=database_dict)
1225+
1226+
new_database = CloudDatabase.from_dict(database_dict=database_dict)
1227+
assert new_database == database
1228+
10811229
@staticmethod
10821230
def test_vumark_database_to_dict() -> None:
10831231
"""It is possible to dump a VuMark database to a dictionary and

0 commit comments

Comments
 (0)