From 93daec728f076fcee8740922a551640386ac755a Mon Sep 17 00:00:00 2001 From: Zakhar Putivtsev Date: Thu, 20 Aug 2026 13:35:59 +0300 Subject: [PATCH 1/7] Add strip_namespaces argument to Image.getxmp() --- Tests/test_image.py | 31 +++++++++++++++++++++++++++++++ docs/releasenotes/13.0.0.rst | 12 ++++++++---- src/PIL/Image.py | 7 +++++-- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/Tests/test_image.py b/Tests/test_image.py index ab0b9e6b769..bc289d02a9e 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -1012,6 +1012,37 @@ def test_empty_xmp(self) -> None: xmp = im.getxmp() assert xmp == {} + def test_getxmp_strip_namespaces(self) -> None: + im = Image.new("RGB", (1, 1)) + im.info["xmp"] = ( + b'\n' + b'' + b'' + b'' + b"from-a" + b"from-b" + b"" + b"" + b'\n\x00\x00 ' + ) + if ElementTree is None: + pytest.skip("defusedxml is not installed") + + stripped = im.getxmp() + desc = stripped["xmpmeta"]["RDF"]["Description"] + assert desc["id"] == ["from-a", "from-b"] + + a_namespace = "http://example.com/ns/a/" + b_namespace = "http://example.com/ns/b/" + full = im.getxmp(strip_namespaces=False) + desc_full = full["{adobe:ns:meta/}xmpmeta"][ + "{http://www.w3.org/1999/02/22-rdf-syntax-ns#}RDF" + ]["{http://www.w3.org/1999/02/22-rdf-syntax-ns#}Description"] + assert desc_full[f"{{{a_namespace}}}id"] == "from-a" + assert desc_full[f"{{{b_namespace}}}id"] == "from-b" + def test_getxmp_padded(self) -> None: im = Image.new("RGB", (1, 1)) im.info["xmp"] = ( diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index 45667d9ec07..331cec95eb3 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -85,10 +85,14 @@ TODO API additions ============= -TODO -^^^^ - -TODO +Added ``strip_namespaces`` argument to ``Image.getxmp()`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:meth:`~PIL.Image.Image.getxmp` now accepts an optional keyword argument of +``strip_namespaces``. By default, this remains ``True``, stripping each tag's XML +namespace prefix as before. If set to ``False``, each tag's full +``{namespace-uri}local-name`` form is kept instead, avoiding collisions between tags +that share a local name across different namespaces. Other changes ============= diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 3493df1e467..a86b7f4e44f 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1570,16 +1570,19 @@ def getextrema(self) -> tuple[float, float] | tuple[tuple[int, int], ...]: return tuple(self.im.getband(i).getextrema() for i in range(self.im.bands)) return self.im.getextrema() - def getxmp(self) -> dict[str, Any]: + def getxmp(self, strip_namespaces: bool = True) -> dict[str, Any]: """ Returns a dictionary containing the XMP tags. Requires defusedxml to be installed. + :param strip_namespaces: If ``False``, keep each tag's full + ``{namespace-uri}local-name`` form instead of stripping the + namespace prefix. :returns: XMP tags in a dictionary. """ def get_name(tag: str) -> str: - return re.sub("^{[^}]+}", "", tag) + return re.sub("^{[^}]+}", "", tag) if strip_namespaces else tag def get_value(element: Element) -> str | dict[str, Any] | None: value: dict[str, Any] = {get_name(k): v for k, v in element.attrib.items()} From 27077a71ccf6788e1f14289db0df0d69a7f43b42 Mon Sep 17 00:00:00 2001 From: Zakhar Putivtsev Date: Thu, 20 Aug 2026 16:01:04 +0300 Subject: [PATCH 2/7] Only check strip_namespaces once per getxmp() call --- src/PIL/Image.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index a86b7f4e44f..cee7c4bc4b0 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1581,8 +1581,15 @@ def getxmp(self, strip_namespaces: bool = True) -> dict[str, Any]: :returns: XMP tags in a dictionary. """ - def get_name(tag: str) -> str: - return re.sub("^{[^}]+}", "", tag) if strip_namespaces else tag + if strip_namespaces: + + def get_name(tag: str) -> str: + return re.sub("^{[^}]+}", "", tag) + + else: + + def get_name(tag: str) -> str: + return tag def get_value(element: Element) -> str | dict[str, Any] | None: value: dict[str, Any] = {get_name(k): v for k, v in element.attrib.items()} From 5ec0de095188e8cdb95cbdccd447d7104b142e0a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 21 Aug 2026 04:03:56 +1000 Subject: [PATCH 3/7] Do not retest trimming zero-bytes and whitespace --- Tests/test_image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_image.py b/Tests/test_image.py index bc289d02a9e..9879ab2f89c 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -1025,7 +1025,7 @@ def test_getxmp_strip_namespaces(self) -> None: b"from-b" b"" b"" - b'\n\x00\x00 ' + b'\n' ) if ElementTree is None: pytest.skip("defusedxml is not installed") From 4d06d224804d71e58055c2c71b80ac61abcf8b55 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 21 Aug 2026 04:09:43 +1000 Subject: [PATCH 4/7] Use @pytest.mark.skipif --- Tests/test_image.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Tests/test_image.py b/Tests/test_image.py index 9879ab2f89c..7f3ae008d15 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -1012,6 +1012,7 @@ def test_empty_xmp(self) -> None: xmp = im.getxmp() assert xmp == {} + @pytest.mark.skipif(ElementTree is None, reason="defusedxml is not installed") def test_getxmp_strip_namespaces(self) -> None: im = Image.new("RGB", (1, 1)) im.info["xmp"] = ( @@ -1027,8 +1028,6 @@ def test_getxmp_strip_namespaces(self) -> None: b"" b'\n' ) - if ElementTree is None: - pytest.skip("defusedxml is not installed") stripped = im.getxmp() desc = stripped["xmpmeta"]["RDF"]["Description"] From 014c59247dbc60f933ebac2069ad8715fc142e88 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 21 Aug 2026 04:10:08 +1000 Subject: [PATCH 5/7] Make strip_namespaces a keyword argument --- src/PIL/Image.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index cee7c4bc4b0..e7145c6fa40 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1570,14 +1570,14 @@ def getextrema(self) -> tuple[float, float] | tuple[tuple[int, int], ...]: return tuple(self.im.getband(i).getextrema() for i in range(self.im.bands)) return self.im.getextrema() - def getxmp(self, strip_namespaces: bool = True) -> dict[str, Any]: + def getxmp(self, *, strip_namespaces: bool = True) -> dict[str, Any]: """ Returns a dictionary containing the XMP tags. Requires defusedxml to be installed. :param strip_namespaces: If ``False``, keep each tag's full - ``{namespace-uri}local-name`` form instead of stripping the - namespace prefix. + ``{namespace-uri}local-name`` form instead of stripping the namespace + prefix. :returns: XMP tags in a dictionary. """ From 698a376693da6f6e674e97a38e74e2a4e7ac50ba Mon Sep 17 00:00:00 2001 From: Zakhar Putivtsev Date: Fri, 21 Aug 2026 11:20:31 +0300 Subject: [PATCH 6/7] Inline namespace URIs in getxmp() test assertions --- Tests/test_image.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Tests/test_image.py b/Tests/test_image.py index 7f3ae008d15..c895eeeaace 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -1033,14 +1033,12 @@ def test_getxmp_strip_namespaces(self) -> None: desc = stripped["xmpmeta"]["RDF"]["Description"] assert desc["id"] == ["from-a", "from-b"] - a_namespace = "http://example.com/ns/a/" - b_namespace = "http://example.com/ns/b/" full = im.getxmp(strip_namespaces=False) desc_full = full["{adobe:ns:meta/}xmpmeta"][ "{http://www.w3.org/1999/02/22-rdf-syntax-ns#}RDF" ]["{http://www.w3.org/1999/02/22-rdf-syntax-ns#}Description"] - assert desc_full[f"{{{a_namespace}}}id"] == "from-a" - assert desc_full[f"{{{b_namespace}}}id"] == "from-b" + assert desc_full["{http://example.com/ns/a/}id"] == "from-a" + assert desc_full["{http://example.com/ns/b/}id"] == "from-b" def test_getxmp_padded(self) -> None: im = Image.new("RGB", (1, 1)) From 95ef63490f4ee5ebc00a850b0c548b4aede20f30 Mon Sep 17 00:00:00 2001 From: Zakhar Putivtsev Date: Fri, 21 Aug 2026 12:13:04 +0300 Subject: [PATCH 7/7] Clarify getxmp() release notes wording [ci skip] --- docs/releasenotes/13.0.0.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index 331cec95eb3..4db2f496701 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -89,8 +89,8 @@ Added ``strip_namespaces`` argument to ``Image.getxmp()`` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ :py:meth:`~PIL.Image.Image.getxmp` now accepts an optional keyword argument of -``strip_namespaces``. By default, this remains ``True``, stripping each tag's XML -namespace prefix as before. If set to ``False``, each tag's full +``strip_namespaces``. It is set to ``True`` by default, stripping each tag's XML +namespace URI prefix as before. If set to ``False``, each tag's full ``{namespace-uri}local-name`` form is kept instead, avoiding collisions between tags that share a local name across different namespaces.