From 7a39986505b9bd789c0ffa28ccd71654bdd8f7e6 Mon Sep 17 00:00:00 2001 From: Nicolas Asanov Date: Thu, 5 Nov 2026 11:59:12 -0500 Subject: [PATCH 1/3] fix(VAPI-4457): scope SSML un-escape to SpeakSentence text --- bandwidth/models/bxml/nestable_verb.py | 1 - bandwidth/models/bxml/root.py | 6 ++--- bandwidth/models/bxml/verb.py | 18 +++++++++++---- bandwidth/models/bxml/verbs/speak_sentence.py | 5 ++--- test/unit/models/bxml/test_speak_sentence.py | 22 ++++++++++++++++++- 5 files changed, 39 insertions(+), 13 deletions(-) diff --git a/bandwidth/models/bxml/nestable_verb.py b/bandwidth/models/bxml/nestable_verb.py index 6dd0aa03..89bd0d74 100644 --- a/bandwidth/models/bxml/nestable_verb.py +++ b/bandwidth/models/bxml/nestable_verb.py @@ -16,7 +16,6 @@ class NestableVerb(Verb): """Base class for nestable BXML verbs """ - ssml_regex = r"<([a-zA-Z//].*?)>" def __init__(self, tag: str, content: str = None, nested_verbs: list[Verb] = None): """Initialize the verb model diff --git a/bandwidth/models/bxml/root.py b/bandwidth/models/bxml/root.py index a0a23cde..b1790b5a 100644 --- a/bandwidth/models/bxml/root.py +++ b/bandwidth/models/bxml/root.py @@ -5,17 +5,15 @@ @copyright Bandwidth INC """ -import re from typing import List import xml.etree.ElementTree as ET -from bandwidth.models.bxml.verb import Verb +from bandwidth.models.bxml.verb import Verb, unescape_ssml class Root: """Base class for BXML roots """ - ssml_regex = r"<([a-zA-Z//].*?)>" def __init__(self, tag: str, nested_verbs: List[Verb] = None): """Initialize instance of class @@ -76,4 +74,4 @@ def to_bxml(self) -> str: str: Serialized BXML string """ xml_document = self._generate_xml() - return re.sub(self.ssml_regex, r"<\1>", ET.tostring(xml_document._root, encoding='UTF-8', method='xml', xml_declaration=True).decode("utf8")) + return unescape_ssml(ET.tostring(xml_document._root, encoding='UTF-8', method='xml', xml_declaration=True).decode("utf8")) diff --git a/bandwidth/models/bxml/verb.py b/bandwidth/models/bxml/verb.py index e100bdfe..7b105deb 100644 --- a/bandwidth/models/bxml/verb.py +++ b/bandwidth/models/bxml/verb.py @@ -10,6 +10,19 @@ from typing import Union import xml.etree.ElementTree as ET +_SPEAK_SENTENCE_REGEX = re.compile(r"(]*(?)(.*?)()", re.DOTALL) +_SSML_REGEX = re.compile(r"<([a-zA-Z//].*?)>") + + +def unescape_ssml(bxml: str) -> str: + """Restore SSML tags inside SpeakSentence text content only. + + Attribute values and all other elements keep their XML escaping. + """ + return _SPEAK_SENTENCE_REGEX.sub( + lambda m: m.group(1) + _SSML_REGEX.sub(r"<\1>", m.group(2)) + m.group(3), bxml + ) + class Verb: """Base class for BXML verbs @@ -93,8 +106,5 @@ def to_bxml(self) -> str: Returns: str: Serialized BXML string """ - ssml_regex = r"<([a-zA-Z//].*?)>" - xml_document = self._generate_xml() - # if the root name is speakSentence, replace content with the SSML regex - return re.sub(ssml_regex, r"<\1>", ET.tostring(xml_document._root).decode('utf8')) + return unescape_ssml(ET.tostring(xml_document._root).decode('utf8')) diff --git a/bandwidth/models/bxml/verbs/speak_sentence.py b/bandwidth/models/bxml/verbs/speak_sentence.py index 2f02f975..5325bb2b 100644 --- a/bandwidth/models/bxml/verbs/speak_sentence.py +++ b/bandwidth/models/bxml/verbs/speak_sentence.py @@ -5,11 +5,10 @@ @copyright Bandwidth INC """ -import re - import xml.etree.ElementTree as ET from ..nestable_verb import NestableVerb +from ..verb import unescape_ssml class SpeakSentence(NestableVerb): @@ -47,4 +46,4 @@ def _attributes(self): def to_bxml(self) -> str: xml_document = self._generate_xml() - return re.sub(self.ssml_regex, r"<\1>", ET.tostring(xml_document._root).decode('utf8')) + return unescape_ssml(ET.tostring(xml_document._root).decode('utf8')) diff --git a/test/unit/models/bxml/test_speak_sentence.py b/test/unit/models/bxml/test_speak_sentence.py index 46cafb18..c6abf8ac 100644 --- a/test/unit/models/bxml/test_speak_sentence.py +++ b/test/unit/models/bxml/test_speak_sentence.py @@ -6,8 +6,9 @@ @copyright Bandwidth Inc. """ import unittest +import xml.etree.ElementTree as ET -from bandwidth.models.bxml import SpeakSentence, Response, Verb +from bandwidth.models.bxml import SpeakSentence, Response, Verb, Redirect class TestSpeakSentence(unittest.TestCase): @@ -33,3 +34,22 @@ def test_to_bxml_within_response(self): response = Response() response.add_verb(self.speak_sentence) assert expected == response.to_bxml() + + def test_ssml_unescaped_but_other_attributes_untouched(self): + redirect = Redirect(redirect_url="https://example.com/a?info=") + response = Response() + response.add_verb(redirect) + response.add_verb(self.speak_sentence) + bxml = response.to_bxml() + assert 'info=<https://example.com/c.pem>' in bxml + assert 'asdf' in bxml + ET.fromstring(bxml) + + def test_empty_speak_sentence_does_not_swallow_following_verbs(self): + response = Response() + response.add_verb(SpeakSentence(text="")) + response.add_verb(Redirect(redirect_url="https://example.com/a?info=")) + response.add_verb(SpeakSentence(text="hi ")) + bxml = response.to_bxml() + assert 'info=<b>' in bxml + assert 'hi ' in bxml From f11456f44cc1544a02ca815061f875416e945259 Mon Sep 17 00:00:00 2001 From: Nicolas Asanov Date: Thu, 5 Nov 2026 15:35:13 -0500 Subject: [PATCH 2/3] refactor(VAPI-4457): address review feedback on SSML un-escape --- bandwidth/models/bxml/nestable_verb.py | 1 - bandwidth/models/bxml/root.py | 4 ++-- bandwidth/models/bxml/verb.py | 4 ++-- bandwidth/models/bxml/verbs/speak_sentence.py | 7 ------ test/unit/models/bxml/test_gather.py | 8 +++++++ test/unit/models/bxml/test_response.py | 22 ++++++++++++++++++- test/unit/models/bxml/test_speak_sentence.py | 22 +------------------ 7 files changed, 34 insertions(+), 34 deletions(-) diff --git a/bandwidth/models/bxml/nestable_verb.py b/bandwidth/models/bxml/nestable_verb.py index 89bd0d74..12a18266 100644 --- a/bandwidth/models/bxml/nestable_verb.py +++ b/bandwidth/models/bxml/nestable_verb.py @@ -6,7 +6,6 @@ @copyright Bandwidth INC """ from __future__ import annotations -import re from typing import Union import xml.etree.ElementTree as ET diff --git a/bandwidth/models/bxml/root.py b/bandwidth/models/bxml/root.py index b1790b5a..57087e81 100644 --- a/bandwidth/models/bxml/root.py +++ b/bandwidth/models/bxml/root.py @@ -8,7 +8,7 @@ from typing import List import xml.etree.ElementTree as ET -from bandwidth.models.bxml.verb import Verb, unescape_ssml +from bandwidth.models.bxml.verb import Verb, _unescape_ssml class Root: @@ -74,4 +74,4 @@ def to_bxml(self) -> str: str: Serialized BXML string """ xml_document = self._generate_xml() - return unescape_ssml(ET.tostring(xml_document._root, encoding='UTF-8', method='xml', xml_declaration=True).decode("utf8")) + return _unescape_ssml(ET.tostring(xml_document._root, encoding='UTF-8', method='xml', xml_declaration=True).decode("utf8")) diff --git a/bandwidth/models/bxml/verb.py b/bandwidth/models/bxml/verb.py index 7b105deb..272e2de0 100644 --- a/bandwidth/models/bxml/verb.py +++ b/bandwidth/models/bxml/verb.py @@ -14,7 +14,7 @@ _SSML_REGEX = re.compile(r"<([a-zA-Z//].*?)>") -def unescape_ssml(bxml: str) -> str: +def _unescape_ssml(bxml: str) -> str: """Restore SSML tags inside SpeakSentence text content only. Attribute values and all other elements keep their XML escaping. @@ -107,4 +107,4 @@ def to_bxml(self) -> str: str: Serialized BXML string """ xml_document = self._generate_xml() - return unescape_ssml(ET.tostring(xml_document._root).decode('utf8')) + return _unescape_ssml(ET.tostring(xml_document._root).decode('utf8')) diff --git a/bandwidth/models/bxml/verbs/speak_sentence.py b/bandwidth/models/bxml/verbs/speak_sentence.py index 5325bb2b..66f54d8f 100644 --- a/bandwidth/models/bxml/verbs/speak_sentence.py +++ b/bandwidth/models/bxml/verbs/speak_sentence.py @@ -5,10 +5,7 @@ @copyright Bandwidth INC """ -import xml.etree.ElementTree as ET - from ..nestable_verb import NestableVerb -from ..verb import unescape_ssml class SpeakSentence(NestableVerb): @@ -43,7 +40,3 @@ def _attributes(self): "gender": self.gender, "locale": self.locale, } - - def to_bxml(self) -> str: - xml_document = self._generate_xml() - return unescape_ssml(ET.tostring(xml_document._root).decode('utf8')) diff --git a/test/unit/models/bxml/test_gather.py b/test/unit/models/bxml/test_gather.py index 41c37cdc..cfbe11ae 100644 --- a/test/unit/models/bxml/test_gather.py +++ b/test/unit/models/bxml/test_gather.py @@ -69,3 +69,11 @@ def test_add_verb(self): expected = 'https://audio.url/audio1.wavHello. Your number is asdf, lets play a game. What is 10 + 3. Press the pound key when finished.' self.gather.add_verb(self.speak_sentence) assert expected == self.gather.to_bxml() + + def test_ssml_unescaped_in_speak_sentence_but_not_in_gather_attributes(self): + expected = 'say this' + gather = Gather( + gather_url="https://example.com/?a=", + audio_verbs=[SpeakSentence(text="say this")] + ) + assert expected == gather.to_bxml() diff --git a/test/unit/models/bxml/test_response.py b/test/unit/models/bxml/test_response.py index 5d8a1320..b2f9f2f3 100644 --- a/test/unit/models/bxml/test_response.py +++ b/test/unit/models/bxml/test_response.py @@ -7,7 +7,7 @@ """ import unittest -from bandwidth.models.bxml import Response +from bandwidth.models.bxml import Response, Redirect, SpeakSentence class TestResponse(unittest.TestCase): @@ -23,3 +23,23 @@ def test_response_init(self): """ expected_bxml = "\n" assert self.response.to_bxml() == expected_bxml + + def test_ssml_unescaped_but_other_attributes_untouched(self): + """SSML in text is un-escaped; escaped characters in other elements' attributes are not + """ + expected_bxml = '\nHello. Your number is asdf, lets play a game.' + self.response.add_verb(Redirect(redirect_url="https://example.com/a?info=")) + self.response.add_verb(SpeakSentence( + text='Hello. Your number is asdf, lets play a game.', + voice="julie", + gender="female", + locale="en_US" + )) + assert expected_bxml == self.response.to_bxml() + + def test_empty_speak_sentence_does_not_swallow_following_verbs(self): + expected_bxml = '\nhi ' + self.response.add_verb(SpeakSentence(text="")) + self.response.add_verb(Redirect(redirect_url="https://example.com/a?info=")) + self.response.add_verb(SpeakSentence(text="hi ")) + assert expected_bxml == self.response.to_bxml() diff --git a/test/unit/models/bxml/test_speak_sentence.py b/test/unit/models/bxml/test_speak_sentence.py index c6abf8ac..46cafb18 100644 --- a/test/unit/models/bxml/test_speak_sentence.py +++ b/test/unit/models/bxml/test_speak_sentence.py @@ -6,9 +6,8 @@ @copyright Bandwidth Inc. """ import unittest -import xml.etree.ElementTree as ET -from bandwidth.models.bxml import SpeakSentence, Response, Verb, Redirect +from bandwidth.models.bxml import SpeakSentence, Response, Verb class TestSpeakSentence(unittest.TestCase): @@ -34,22 +33,3 @@ def test_to_bxml_within_response(self): response = Response() response.add_verb(self.speak_sentence) assert expected == response.to_bxml() - - def test_ssml_unescaped_but_other_attributes_untouched(self): - redirect = Redirect(redirect_url="https://example.com/a?info=") - response = Response() - response.add_verb(redirect) - response.add_verb(self.speak_sentence) - bxml = response.to_bxml() - assert 'info=<https://example.com/c.pem>' in bxml - assert 'asdf' in bxml - ET.fromstring(bxml) - - def test_empty_speak_sentence_does_not_swallow_following_verbs(self): - response = Response() - response.add_verb(SpeakSentence(text="")) - response.add_verb(Redirect(redirect_url="https://example.com/a?info=")) - response.add_verb(SpeakSentence(text="hi ")) - bxml = response.to_bxml() - assert 'info=<b>' in bxml - assert 'hi ' in bxml From 6825ec94427f680ec37bc0fede01767a1c385e6e Mon Sep 17 00:00:00 2001 From: Nicolas Asanov Date: Thu, 5 Nov 2026 16:09:43 -0500 Subject: [PATCH 3/3] test(VAPI-4457): tidy test_response docstring and quoting --- test/unit/models/bxml/test_response.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/unit/models/bxml/test_response.py b/test/unit/models/bxml/test_response.py index b2f9f2f3..b8cf180e 100644 --- a/test/unit/models/bxml/test_response.py +++ b/test/unit/models/bxml/test_response.py @@ -27,7 +27,7 @@ def test_response_init(self): def test_ssml_unescaped_but_other_attributes_untouched(self): """SSML in text is un-escaped; escaped characters in other elements' attributes are not """ - expected_bxml = '\nHello. Your number is asdf, lets play a game.' + expected_bxml = "\nHello. Your number is asdf, lets play a game." self.response.add_verb(Redirect(redirect_url="https://example.com/a?info=")) self.response.add_verb(SpeakSentence( text='Hello. Your number is asdf, lets play a game.', @@ -38,7 +38,9 @@ def test_ssml_unescaped_but_other_attributes_untouched(self): assert expected_bxml == self.response.to_bxml() def test_empty_speak_sentence_does_not_swallow_following_verbs(self): - expected_bxml = '\nhi ' + """A with no text must not capture the verbs that follow it + """ + expected_bxml = "\nhi " self.response.add_verb(SpeakSentence(text="")) self.response.add_verb(Redirect(redirect_url="https://example.com/a?info=")) self.response.add_verb(SpeakSentence(text="hi "))