From 2218cd3e7abc4f4620306bf60857b667df070e30 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Thu, 18 Dec 2025 20:21:07 +0100 Subject: [PATCH 1/5] Fix #327 -- Swith to autonomous custom element for Safari support Apple decided they don't want to support customized built-in elements, see also: https://github.com/WebKit/standards-positions/issues/97 --- .github/workflows/ci.yml | 5 - s3file/forms.py | 10 +- s3file/static/s3file/js/s3file.js | 223 +++++++++++++++++++++++++++--- tests/__tests__/s3file.test.js | 5 +- tests/test_forms.py | 44 +++--- 5 files changed, 242 insertions(+), 45 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bdb1bc9..acb9ec3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,11 +64,6 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 - - name: Install Selenium - run: | - curl -LsSfO https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb - sudo dpkg -i google-chrome-stable_current_amd64.deb || sudo apt-get -f install -y - if: matrix.os == 'ubuntu-latest' - uses: astral-sh/setup-uv@v7 - run: uv run pytest -m selenium - uses: codecov/codecov-action@v5 diff --git a/s3file/forms.py b/s3file/forms.py index a9f3453..0565602 100644 --- a/s3file/forms.py +++ b/s3file/forms.py @@ -7,6 +7,7 @@ from django.templatetags.static import static from django.utils.functional import cached_property from django.utils.html import format_html, html_safe +from django.utils.safestring import mark_safe from storages.utils import safe_join from s3file.middleware import S3FileMiddleware @@ -75,7 +76,6 @@ def client(self): def build_attrs(self, *args, **kwargs): attrs = super().build_attrs(*args, **kwargs) - attrs["is"] = "s3-file" accept = attrs.get("accept") response = self.client.generate_presigned_post( @@ -97,6 +97,14 @@ def build_attrs(self, *args, **kwargs): return defaults + def render(self, name, value, attrs=None, renderer=None): + """Render the widget as a custom element for Safari compatibility.""" + return mark_safe( # noqa: S308 + str(super().render(name, value, attrs=attrs, renderer=renderer)).replace( + f' { + this._files = this._fileInput.files + this.dispatchEvent(new Event("change", { bubbles: true })) + this.changeHandler() + }) + + // Append elements + this.appendChild(this._fileInput) + + // Setup form event listeners + this.form?.addEventListener("formdata", this.fromDataHandler.bind(this)) + this.form?.addEventListener("submit", this.submitHandler.bind(this), { + once: true, + }) + this.form?.addEventListener("upload", this.uploadHandler.bind(this)) + } + + /** + * Sync attributes from custom element to hidden input. + */ + _syncAttributesToHiddenInput() { + if (!this._fileInput) return + + S3FileInput.passThroughAttributes.forEach((attr) => { + if (this.hasAttribute(attr)) { + this._fileInput.setAttribute(attr, this.getAttribute(attr)) + } else { + this._fileInput.removeAttribute(attr) + } + }) + + this._fileInput.disabled = this.hasAttribute("disabled") + } + + /** + * Implement HTMLInputElement-like properties. + */ + get files() { + return this._files + } + + get type() { + return "file" + } + + get name() { + return this.getAttribute("name") || "" + } + + set name(value) { + this.setAttribute("name", value) + } + + get value() { + if (this._files && this._files.length > 0) { + return this._files[0].name + } + return "" + } + + set value(val) { + // Setting value on file inputs is restricted for security + if (val === "" || val === null) { + this._files = [] + if (this._fileInput) { + this._fileInput.value = "" + } + } + } + + get form() { + return this._internals?.form || this.closest("form") + } + + get disabled() { + return this.hasAttribute("disabled") + } + + set disabled(value) { + if (value) { + this.setAttribute("disabled", "") + } else { + this.removeAttribute("disabled") + } + } + + get required() { + return this.hasAttribute("required") + } + + set required(value) { + if (value) { + this.setAttribute("required", "") + } else { + this.removeAttribute("required") + } + } + + get validity() { + if (this._internals) { + return this._internals.validity + } + // Create a basic ValidityState-like object + const isValid = !this.required || (this._files && this._files.length > 0) + return { + valid: isValid && !this._validationMessage, + valueMissing: this.required && (!this._files || this._files.length === 0), + customError: !!this._validationMessage, + badInput: false, + patternMismatch: false, + rangeOverflow: false, + rangeUnderflow: false, + stepMismatch: false, + tooLong: false, + tooShort: false, + typeMismatch: false, + } + } + + get validationMessage() { + return this._validationMessage + } + + setCustomValidity(message) { + this._validationMessage = message || "" + if (this._internals && typeof this._internals.setValidity === "function") { + if (message) { + this._internals.setValidity({ customError: true }, message) + } else { + this._internals.setValidity({}) + } + } + } + + reportValidity() { + const validity = this.validity + if (validity && !validity.valid) { + this.dispatchEvent(new Event("invalid", { bubbles: false, cancelable: true })) + return false + } + return true + } + + checkValidity() { + return this.validity.valid + } + + click() { + if (this._fileInput) { + this._fileInput.click() + } } changeHandler() { this.keys = [] this.upload = null try { - this.form.removeEventListener("submit", this.submitHandler.bind(this)) + this.form?.removeEventListener("submit", this.submitHandler.bind(this)) } catch (error) { console.debug(error) } - this.form.addEventListener("submit", this.submitHandler.bind(this), { once: true }) + this.form?.addEventListener("submit", this.submitHandler.bind(this), { + once: true, + }) } /** @@ -48,15 +216,15 @@ export class S3FileInput extends globalThis.HTMLInputElement { */ async submitHandler(event) { event.preventDefault() - this.form.dispatchEvent(new window.CustomEvent("upload")) - await Promise.all(this.form.pendingRquests) - this.form.requestSubmit(event.submitter) + this.form?.dispatchEvent(new window.CustomEvent("upload")) + await Promise.all(this.form?.pendingRquests) + this.form?.requestSubmit(event.submitter) } uploadHandler() { if (this.files.length && !this.upload) { this.upload = this.uploadFiles() - this.form.pendingRquests = this.form.pendingRquests || [] + this.form.pendingRquests = this.form?.pendingRquests || [] this.form.pendingRquests.push(this.upload) } } @@ -99,7 +267,10 @@ export class S3FileInput extends globalThis.HTMLInputElement { s3Form.append("file", file) console.debug("uploading", this.dataset.url, file) try { - const response = await fetch(this.dataset.url, { method: "POST", body: s3Form }) + const response = await fetch(this.dataset.url, { + method: "POST", + body: s3Form, + }) if (response.status === 201) { this.keys.push(getKeyFromResponse(await response.text())) } else { @@ -108,11 +279,29 @@ export class S3FileInput extends globalThis.HTMLInputElement { } } catch (error) { console.error(error) - this.setCustomValidity(error) + this.setCustomValidity(String(error)) this.reportValidity() } } } + + /** + * Called when observed attributes change. + */ + static get observedAttributes() { + return this.passThroughAttributes.concat(["name", "id"]) + } + + attributeChangedCallback(name, oldValue, newValue) { + this._syncAttributesToHiddenInput() + } + + /** + * Declare this element as a form-associated custom element. + */ + static get formAssociated() { + return true + } } -globalThis.customElements.define("s3-file", S3FileInput, { extends: "input" }) +globalThis.customElements.define("s3-file", S3FileInput) diff --git a/tests/__tests__/s3file.test.js b/tests/__tests__/s3file.test.js index c5582d5..d63d794 100644 --- a/tests/__tests__/s3file.test.js +++ b/tests/__tests__/s3file.test.js @@ -26,7 +26,6 @@ describe("getKeyFromResponse", () => { describe("S3FileInput", () => { test("constructor", () => { const input = new s3file.S3FileInput() - assert.strictEqual(input.type, "file") assert.deepStrictEqual(input.keys, []) assert.strictEqual(input.upload, null) }) @@ -35,11 +34,11 @@ describe("S3FileInput", () => { const form = document.createElement("form") document.body.appendChild(form) const input = new s3file.S3FileInput() - input.addEventListener = mock.fn(input.addEventListener) form.addEventListener = mock.fn(form.addEventListener) form.appendChild(input) assert(form.addEventListener.mock.calls.length === 3) - assert(input.addEventListener.mock.calls.length === 1) + assert(input._fileInput !== null) + assert(input._fileInput.type === "file") }) test("changeHandler", () => { diff --git a/tests/test_forms.py b/tests/test_forms.py index e05d586..7afca9a 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -127,7 +127,6 @@ def test_clear(self, filemodel): def test_build_attr(self, freeze_upload_folder): assert set(ClearableFileInput().build_attrs({}).keys()) == { - "is", "data-url", "data-fields-x-amz-algorithm", "data-fields-x-amz-date", @@ -141,7 +140,6 @@ def test_build_attr(self, freeze_upload_folder): ClearableFileInput().build_attrs({})["data-s3f-signature"] == "VRIPlI1LCjUh1EtplrgxQrG8gSAaIwT48mMRlwaCytI" ) - assert ClearableFileInput().build_attrs({})["is"] == "s3-file" def test_get_conditions(self, freeze_upload_folder): conditions = ClearableFileInput().get_conditions(None) @@ -182,6 +180,12 @@ def test_accept(self): "application/pdf,image/*" ) + def test_render_wraps_in_s3_file_element(self, freeze_upload_folder): + widget = ClearableFileInput() + html = widget.render(name="file", value=None) + # Check that the output is the s3-file custom element + assert html.startswith(" Date: Thu, 18 Dec 2025 19:37:27 +0000 Subject: [PATCH 2/5] =?UTF-8?q?Fix=20typo:=20pendingRquests=20=E2=86=92=20?= =?UTF-8?q?pendingRequests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- s3file/static/s3file/js/s3file.js | 6 +++--- tests/__tests__/s3file.test.js | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/s3file/static/s3file/js/s3file.js b/s3file/static/s3file/js/s3file.js index a54917c..3e3b6dc 100644 --- a/s3file/static/s3file/js/s3file.js +++ b/s3file/static/s3file/js/s3file.js @@ -217,15 +217,15 @@ export class S3FileInput extends globalThis.HTMLElement { async submitHandler(event) { event.preventDefault() this.form?.dispatchEvent(new window.CustomEvent("upload")) - await Promise.all(this.form?.pendingRquests) + await Promise.all(this.form?.pendingRequests) this.form?.requestSubmit(event.submitter) } uploadHandler() { if (this.files.length && !this.upload) { this.upload = this.uploadFiles() - this.form.pendingRquests = this.form?.pendingRquests || [] - this.form.pendingRquests.push(this.upload) + this.form.pendingRequests = this.form?.pendingRequests || [] + this.form.pendingRequests.push(this.upload) } } diff --git a/tests/__tests__/s3file.test.js b/tests/__tests__/s3file.test.js index d63d794..d94513e 100644 --- a/tests/__tests__/s3file.test.js +++ b/tests/__tests__/s3file.test.js @@ -55,7 +55,7 @@ describe("S3FileInput", () => { test("submitHandler", async () => { const form = document.createElement("form") document.body.appendChild(form) - form.pendingRquests = [] + form.pendingRequests = [] form.requestSubmit = mock.fn(form.requestSubmit) form.dispatchEvent = mock.fn(form.dispatchEvent) const submitButton = document.createElement("button") @@ -82,7 +82,7 @@ describe("S3FileInput", () => { input.uploadHandler() console.log(input.upload) assert(input.upload) - assert(form.pendingRquests) + assert(form.pendingRequests) }) test("fromDataHandler", () => { From 2d36f16f5788fd9166453b8733f704409995e800 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 19:36:58 +0000 Subject: [PATCH 3/5] Implement disconnectedCallback and prevent duplicate inputs Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- s3file/static/s3file/js/s3file.js | 57 +++++++++++++++++++++++++------ tests/__tests__/s3file.test.js | 37 ++++++++++++++++++++ 2 files changed, 83 insertions(+), 11 deletions(-) diff --git a/s3file/static/s3file/js/s3file.js b/s3file/static/s3file/js/s3file.js index 3e3b6dc..0d237ae 100644 --- a/s3file/static/s3file/js/s3file.js +++ b/s3file/static/s3file/js/s3file.js @@ -34,6 +34,9 @@ export class S3FileInput extends globalThis.HTMLElement { } connectedCallback() { + // Prevent creating duplicate inputs if already connected + if (this._fileInput) return + // Create a hidden file input for the file picker functionality this._fileInput = document.createElement("input") this._fileInput.type = "file" @@ -41,22 +44,49 @@ export class S3FileInput extends globalThis.HTMLElement { // Sync attributes to hidden input this._syncAttributesToHiddenInput() - // Listen for file selection on hidden input - this._fileInput.addEventListener("change", () => { + // Create bound handler references for cleanup + this._changeHandler = () => { this._files = this._fileInput.files this.dispatchEvent(new Event("change", { bubbles: true })) this.changeHandler() - }) + } + this._boundFormDataHandler = this.fromDataHandler.bind(this) + this._boundSubmitHandler = this.submitHandler.bind(this) + this._boundUploadHandler = this.uploadHandler.bind(this) + + // Listen for file selection on hidden input + this._fileInput.addEventListener("change", this._changeHandler) // Append elements this.appendChild(this._fileInput) // Setup form event listeners - this.form?.addEventListener("formdata", this.fromDataHandler.bind(this)) - this.form?.addEventListener("submit", this.submitHandler.bind(this), { + this.form?.addEventListener("formdata", this._boundFormDataHandler) + this.form?.addEventListener("submit", this._boundSubmitHandler, { once: true, }) - this.form?.addEventListener("upload", this.uploadHandler.bind(this)) + this.form?.addEventListener("upload", this._boundUploadHandler) + } + + disconnectedCallback() { + // Clean up event listeners + if (this._fileInput) { + this._fileInput.removeEventListener("change", this._changeHandler) + } + + if (this.form) { + this.form.removeEventListener("formdata", this._boundFormDataHandler) + this.form.removeEventListener("submit", this._boundSubmitHandler) + this.form.removeEventListener("upload", this._boundUploadHandler) + } + + // Remove the file input from DOM + if (this._fileInput && this._fileInput.parentNode) { + this._fileInput.parentNode.removeChild(this._fileInput) + } + + // Clear reference to allow reconnection + this._fileInput = null } /** @@ -198,12 +228,17 @@ export class S3FileInput extends globalThis.HTMLElement { changeHandler() { this.keys = [] this.upload = null - try { - this.form?.removeEventListener("submit", this.submitHandler.bind(this)) - } catch (error) { - console.debug(error) + // Remove previous submit handler and add a new one + if (this._boundSubmitHandler) { + try { + this.form?.removeEventListener("submit", this._boundSubmitHandler) + } catch (error) { + console.debug(error) + } } - this.form?.addEventListener("submit", this.submitHandler.bind(this), { + // Create a new bound handler for this change + this._boundSubmitHandler = this.submitHandler.bind(this) + this.form?.addEventListener("submit", this._boundSubmitHandler, { once: true, }) } diff --git a/tests/__tests__/s3file.test.js b/tests/__tests__/s3file.test.js index d94513e..8fe8b79 100644 --- a/tests/__tests__/s3file.test.js +++ b/tests/__tests__/s3file.test.js @@ -41,6 +41,43 @@ describe("S3FileInput", () => { assert(input._fileInput.type === "file") }) + test("disconnectedCallback", () => { + const form = document.createElement("form") + document.body.appendChild(form) + const input = new s3file.S3FileInput() + form.addEventListener = mock.fn(form.addEventListener) + form.appendChild(input) + const fileInput = input._fileInput + assert(fileInput !== null) + // Mock removeEventListener to verify cleanup + form.removeEventListener = mock.fn(form.removeEventListener) + fileInput.removeEventListener = mock.fn(fileInput.removeEventListener) + // Manually call disconnectedCallback since jsdom doesn't trigger it + input.disconnectedCallback() + assert(form.removeEventListener.mock.calls.length === 3) + assert(fileInput.removeEventListener.mock.calls.length === 1) + assert(fileInput.parentNode === null) + assert(input._fileInput === null) + }) + + test("connectedCallback prevents duplicate inputs on reconnection", () => { + const form = document.createElement("form") + document.body.appendChild(form) + const input = new s3file.S3FileInput() + // First connection + form.appendChild(input) + assert(input._fileInput !== null) + assert(input.querySelectorAll('input[type="file"]').length === 1) + // Disconnect and clear state + input.disconnectedCallback() + // Reconnect - should create a new input since old one was cleaned up + input.connectedCallback() + assert(input._fileInput !== null) + // Check only one input element exists after reconnection + const inputElements = input.querySelectorAll('input[type="file"]') + assert(inputElements.length === 1) + }) + test("changeHandler", () => { const form = document.createElement("form") const input = new s3file.S3FileInput() From 5e843c682ca9171d11715ffb08ae7621389541ad Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 21:32:23 +0100 Subject: [PATCH 4/5] Replace string replacement with HTML parser in render method (#361) * Initial plan * Replace fragile string replacement with HTML parser for render method Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> * Refactor HTML parser to reduce code duplication and improve documentation Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> * Compress parser code and add comprehensive unit tests Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> * Add test for non-file input with self-closing tag to cover line 52 Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> * Update s3file/forms.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add entity and character reference handlers with comprehensive tests Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> * Add tests for comment, declaration, and processing instruction handlers Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> Co-authored-by: Johannes Maron Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- s3file/forms.py | 76 ++++++++++++++++++++++++++++--- tests/test_forms.py | 107 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 5 deletions(-) diff --git a/s3file/forms.py b/s3file/forms.py index 0565602..21e7976 100644 --- a/s3file/forms.py +++ b/s3file/forms.py @@ -1,7 +1,9 @@ import base64 +import html import logging import pathlib import uuid +from html.parser import HTMLParser from django.conf import settings from django.templatetags.static import static @@ -16,6 +18,71 @@ logger = logging.getLogger("s3file") +class InputToS3FileRewriter(HTMLParser): + """HTML parser that rewrites to custom elements.""" + + def __init__(self): + super().__init__() + self.output = [] + + def handle_starttag(self, tag, attrs): + if tag == "input" and dict(attrs).get("type") == "file": + self.output.append("") + else: + self.output.append(self.get_starttag_text()) + + def handle_endtag(self, tag): + self.output.append(f"") + + def handle_data(self, data): + self.output.append(data) + + def handle_startendtag(self, tag, attrs): + if tag == "input" and dict(attrs).get("type") == "file": + self.output.append("") + else: + self.output.append(self.get_starttag_text()) + + def handle_comment(self, data): + # Preserve HTML comments in the output + self.output.append(f"") + + def handle_decl(self, decl): + # Preserve declarations such as in the output + self.output.append(f"") + + def handle_pi(self, data): + # Preserve processing instructions such as in the output + self.output.append(f"") + + def handle_entityref(self, name): + # Preserve HTML entities like &, <, > + self.output.append(f"&{name};") + + def handle_charref(self, name): + # Preserve character references like ', ' + self.output.append(f"&#{name};") + + def get_html(self): + return "".join(self.output) + + @html_safe class Asset: """A generic asset that can be included in a template.""" @@ -99,11 +166,10 @@ def build_attrs(self, *args, **kwargs): def render(self, name, value, attrs=None, renderer=None): """Render the widget as a custom element for Safari compatibility.""" - return mark_safe( # noqa: S308 - str(super().render(name, value, attrs=attrs, renderer=renderer)).replace( - f'' +class TestInputToS3FileRewriter: + def test_transforms_file_input(self): + parser = forms.InputToS3FileRewriter() + parser.feed('') + assert parser.get_html() == '' + + def test_preserves_non_file_input(self): + parser = forms.InputToS3FileRewriter() + parser.feed('') + assert parser.get_html() == '' + + def test_handles_attribute_ordering(self): + parser = forms.InputToS3FileRewriter() + parser.feed('') + result = parser.get_html() + assert result.startswith("' + ) + result = parser.get_html() + assert result.startswith("') + result = parser.get_html() + assert 'data-value="test&value"' in result + + def test_preserves_existing_html_entities(self): + # Test that already-escaped entities in input are preserved (not double-escaped) + parser = forms.InputToS3FileRewriter() + parser.feed('') + result = parser.get_html() + # Should preserve the & entity, not convert to &amp; + assert 'data-value="test&value"' in result + assert '&amp;' not in result + + def test_preserves_character_references(self): + # Test that character references are preserved (may be in decimal or hex format) + parser = forms.InputToS3FileRewriter() + parser.feed('') + result = parser.get_html() + # The character reference should be preserved (either ' or ' both represent ') + assert ('data-value="test's"' in result or 'data-value="test's"' in result) + # Verify the actual apostrophe character is NOT directly in the output (should be a reference) + assert 'data-value="test\'s"' not in result or '&#' in result + + def test_handles_self_closing_tag(self): + parser = forms.InputToS3FileRewriter() + parser.feed('') + assert parser.get_html() == '' + + def test_preserves_non_file_self_closing_tag(self): + parser = forms.InputToS3FileRewriter() + parser.feed('') + assert parser.get_html() == '' + + def test_preserves_surrounding_elements(self): + parser = forms.InputToS3FileRewriter() + parser.feed('

') + result = parser.get_html() + assert result == '

' + + def test_preserves_html_comments(self): + parser = forms.InputToS3FileRewriter() + parser.feed('') + result = parser.get_html() + assert result == '' + + def test_preserves_declarations(self): + parser = forms.InputToS3FileRewriter() + parser.feed('') + result = parser.get_html() + assert result == '' + + def test_preserves_processing_instructions(self): + parser = forms.InputToS3FileRewriter() + parser.feed('') + result = parser.get_html() + assert result == '' + + @contextmanager def wait_for_page_load(driver, timeout=30): old_page = driver.find_element(By.TAG_NAME, "html") @@ -186,6 +278,21 @@ def test_render_wraps_in_s3_file_element(self, freeze_upload_folder): # Check that the output is the s3-file custom element assert html.startswith(" Date: Thu, 18 Dec 2025 20:33:02 +0000 Subject: [PATCH 5/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_forms.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_forms.py b/tests/test_forms.py index eab1b5c..b330ffb 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -117,7 +117,7 @@ def test_preserves_existing_html_entities(self): result = parser.get_html() # Should preserve the & entity, not convert to &amp; assert 'data-value="test&value"' in result - assert '&amp;' not in result + assert "&amp;" not in result def test_preserves_character_references(self): # Test that character references are preserved (may be in decimal or hex format) @@ -125,9 +125,11 @@ def test_preserves_character_references(self): parser.feed('') result = parser.get_html() # The character reference should be preserved (either ' or ' both represent ') - assert ('data-value="test's"' in result or 'data-value="test's"' in result) + assert ( + 'data-value="test's"' in result or 'data-value="test's"' in result + ) # Verify the actual apostrophe character is NOT directly in the output (should be a reference) - assert 'data-value="test\'s"' not in result or '&#' in result + assert 'data-value="test\'s"' not in result or "&#" in result def test_handles_self_closing_tag(self): parser = forms.InputToS3FileRewriter()