From 2985afde753bc1ce3da38a818fe3db04ddcafecf Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Sat, 8 Aug 2026 12:42:48 -0700 Subject: [PATCH 1/2] Don't mutate the shared schema in validate_object_dict() The Person.OTHER gender-max workaround patched the dict returned by get_schema() in place. Gramps core may return the same cached object to every caller, so this could corrupt it for every other caller (or raise, if the cached object is made read-only). Copy it first. Co-Authored-By: Claude Sonnet 5 --- gramps_webapi/api/resources/util.py | 6 +++++ tests/test_util.py | 37 +++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/gramps_webapi/api/resources/util.py b/gramps_webapi/api/resources/util.py index 82590994..779abfb8 100644 --- a/gramps_webapi/api/resources/util.py +++ b/gramps_webapi/api/resources/util.py @@ -22,6 +22,7 @@ from __future__ import annotations +import copy import gzip import logging import os @@ -1283,6 +1284,11 @@ def validate_object_dict(obj_dict: dict[str, Any]) -> bool: and schema.get("properties", {}).get("gender", {}).get("maximum") is not None and other > schema["properties"]["gender"]["maximum"] ): + # `get_schema()` may return an object shared across calls (e.g. a + # cached class-level schema); never mutate it in place, since that + # would leak this one-off patch into every other caller. Copy it + # first, since we only need a locally patched view for validation. + schema = copy.deepcopy(schema) schema["properties"]["gender"]["maximum"] = other obj_dict_fixed = {k: v for k, v in obj_dict.items() if k != "complete"} diff --git a/tests/test_util.py b/tests/test_util.py index fbd3c8f1..85ed2638 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -323,3 +323,40 @@ def test_recalc_date_sortvals_year_only(): date_dict["sortval"] = 0 recalc_date_sortvals({"_class": "Event", "date": date_dict}) assert date_dict["sortval"] == correct + + +def test_validate_object_dict_does_not_mutate_shared_schema(): + """validate_object_dict() must not mutate a shared/cached get_schema() result. + + Gramps core may cache Person.get_schema() and return the same object to + every caller. The gender-max patch here (for Person.OTHER, added in + Gramps 5.2) previously mutated that returned schema in place, which + would corrupt it for every other caller of get_schema() once Gramps + starts sharing/caching it, or raise outright if the shared object is + made read-only. + """ + from gramps.gen.lib import Person + + from gramps_webapi.api.resources.util import validate_object_dict + + # Simulate a schema whose declared gender maximum hasn't caught up with + # Person.OTHER yet -- the exact case this patch exists to handle. + shared_schema = { + "type": "object", + "properties": { + "_class": {"enum": ["Person"]}, + "gender": {"type": "integer", "maximum": Person.OTHER - 1}, + }, + } + + with patch.object(Person, "get_schema", return_value=shared_schema): + obj_dict = {"_class": "Person", "gender": Person.OTHER} + assert validate_object_dict(obj_dict) is True + + # The object returned by get_schema() is shared across every call; + # it must come back untouched. + assert shared_schema["properties"]["gender"]["maximum"] == Person.OTHER - 1 + + # And a second call must still succeed -- it can't rely on a + # mutation left behind by the first call. + assert validate_object_dict(obj_dict) is True From 358b1d297a6f1dcd0416068a882352c6a2cba20b Mon Sep 17 00:00:00 2001 From: Doug Blank Date: Mon, 10 Aug 2026 16:48:10 -0700 Subject: [PATCH 2/2] Only deep-copy the schema when the object actually needs the gender patch Gate the Person.OTHER gender-max schema patch on the object being validated actually having gender == other, instead of copying the schema for every Person validated. Avoids avoidable deepcopy overhead in bulk/batch validation. Co-Authored-By: Claude Sonnet 5 --- gramps_webapi/api/resources/util.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gramps_webapi/api/resources/util.py b/gramps_webapi/api/resources/util.py index 779abfb8..a15d44f3 100644 --- a/gramps_webapi/api/resources/util.py +++ b/gramps_webapi/api/resources/util.py @@ -1274,6 +1274,8 @@ def validate_object_dict(obj_dict: dict[str, Any]) -> bool: return False schema = obj_cls.get_schema() + obj_dict_fixed = {k: v for k, v in obj_dict.items() if k != "complete"} + # Gramps 5.2 added Person.OTHER = 3, but the JSON schema still caps gender # at 2. Patch the schema to allow the actual maximum value. # This patch can be removed once https://github.com/gramps-project/gramps/pull/2213 @@ -1281,6 +1283,7 @@ def validate_object_dict(obj_dict: dict[str, Any]) -> bool: other = getattr(obj_cls, "OTHER", None) if ( other is not None + and obj_dict_fixed.get("gender") == other and schema.get("properties", {}).get("gender", {}).get("maximum") is not None and other > schema["properties"]["gender"]["maximum"] ): @@ -1291,7 +1294,6 @@ def validate_object_dict(obj_dict: dict[str, Any]) -> bool: schema = copy.deepcopy(schema) schema["properties"]["gender"]["maximum"] = other - obj_dict_fixed = {k: v for k, v in obj_dict.items() if k != "complete"} try: jsonschema.validate(obj_dict_fixed, schema) except jsonschema.exceptions.ValidationError as exc: