diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..330beb2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +# .editorconfig +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.py] +indent_size = 4 +max_line_length = 88 + +[*.{yml,yaml}] +indent_size = 2 + +[*.{md,toml,json}] +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b67884c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,235 @@ +name: Release Pipeline + +on: + push: + tags: + - 'v*' # trigger on version tags + pull_request: + branches: [ master ] + +# Allow only one concurrent deployment to avoid conflicts +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate-version: + name: Validate tag version + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + outputs: + tag_version: ${{ steps.tag-version.outputs.TAG_VERSION }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Extract package version + id: package-version + run: | + # Extract version from pyproject.toml + PACKAGE_VERSION=$(python -c " + import tomli + with open('pyproject.toml', 'rb') as f: + data = tomli.load(f) + print(data['project']['version']) + ") + echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> $GITHUB_OUTPUT + + - name: Extract tag version + id: tag-version + run: | + TAG_VERSION="${GITHUB_REF#refs/tags/v}" + echo "TAG_VERSION=$TAG_VERSION" >> $GITHUB_OUTPUT + + - name: Validate versions match + run: | + PACKAGE_VERSION="${{ steps.package-version.outputs.PACKAGE_VERSION }}" + TAG_VERSION="${{ steps.tag-version.outputs.TAG_VERSION }}" + + if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then + echo "? CRITICAL: VERSION MISMATCH DETECTED!" + echo "::error::Git tag version does not match package version!" + echo "::error::Tag version: v$TAG_VERSION" + echo "::error::Package version: $PACKAGE_VERSION" + echo "::error::" + echo "::error::To fix this:" + echo "::error::1. Delete the wrong tag: git tag -d v$TAG_VERSION" + echo "::error::2. Delete remote tag: git push --delete origin v$TAG_VERSION" + echo "::error::3. Update pyproject.toml with correct version" + echo "::error::4. Create correct tag: git tag -a v$PACKAGE_VERSION -m 'Release v$PACKAGE_VERSION'" + echo "::error::5. Push correct tag: git push origin v$PACKAGE_VERSION" + exit 1 + else + echo "? Versions match: v$TAG_VERSION" + fi + + test: + name: Test on Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[dev] + + - name: Run tests with pytest + run: | + pytest -v --cov=xmlassert --cov-report=xml + + build: + name: Build package + runs-on: ubuntu-latest + needs: test # Only build if tests pass + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.8' + + - name: Install build tools + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build package + run: python -m build + + - name: Verify package + run: twine check dist/* + + - name: Inspect built packages (optional) + run: | + echo "Built packages:" + ls -la dist/ + echo "Wheel compatibility:" + for wheel in dist/*.whl; do + echo "$wheel:" + unzip -l "$wheel" | grep -E "(.py$|METADATA)" | head -5 + done + + - name: Upload build artifacts (tags only) + if: startsWith(github.ref, 'refs/tags/v') + uses: actions/upload-artifact@v3 + with: + name: distribution-packages + path: dist/ + + test-pypi: + name: Publish to TestPyPI + runs-on: ubuntu-latest + needs: [validate-version, build] + if: | + startsWith(github.ref, 'refs/tags/v') && + (contains(github.ref, 'alpha') || + contains(github.ref, 'beta') || + contains(github.ref, 'rc') || + github.ref == 'refs/tags/v0.0.0-test') + + steps: + - name: Download build artifacts + uses: actions/download-artifact@v3 + with: + name: distribution-packages + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.8' + + - name: Install twine + run: pip install twine + + - name: Publish to TestPyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} + run: twine upload --repository-url https://test.pypi.org/legacy/ dist/* + + - name: Verify TestPyPI installation + run: | + python -m pip install --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ \ + xmlassert==${{ needs.validate-version.outputs.tag_version }} + + pypi: + name: Publish to PyPI + runs-on: ubuntu-latest + needs: [validate-version, build] + if: | + startsWith(github.ref, 'refs/tags/v') && + !contains(github.ref, 'alpha') && + !contains(github.ref, 'beta') && + !contains(github.ref, 'rc') && + github.ref != 'refs/tags/v0.0.0-test' + + steps: + - name: Download build artifacts + uses: actions/download-artifact@v3 + with: + name: distribution-packages + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: Install twine + run: pip install twine + + - name: Publish to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: twine upload dist/* + + - name: Verify PyPI installation + run: | + # Wait a moment for PyPI to update + sleep 30 + pip install \ + xmlassert==${{ needs.validate-version.outputs.tag_version }} + + github-release: + name: Create GitHub Release + runs-on: ubuntu-latest + needs: [test-pypi, pypi] + if: always() && startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Download build artifacts + uses: actions/download-artifact@v3 + with: + name: distribution-packages + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + files: | + dist/*.whl + dist/*.tar.gz + generate_release_notes: true + body: | + Automated release for ${{ github.ref_name }} + + See CHANGELOG.md for details. + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index b7faf40..e3f54bd 100644 --- a/.gitignore +++ b/.gitignore @@ -140,6 +140,7 @@ celerybeat.pid .venv env/ venv/ +venv*/ ENV/ env.bak/ venv.bak/ @@ -182,9 +183,9 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..431180c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# CHANGELOG + +## v0.1.0 (2025-08-25) + +**Initial Release** + +- First release of `xmlassert` +- Includes `assert_xml_equal` function diff --git a/LICENSE b/LICENSE index d568108..7906bf7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,7 @@ MIT License +SPDX-License-Identifier: MIT + Copyright (c) 2025 Maxim Ivanov Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8adb835 --- /dev/null +++ b/Makefile @@ -0,0 +1,113 @@ +# Copyright (c) 2025 Maxim Ivanov +# SPDX-License-Identifier: MIT + +.PHONY: help tests lint format typecheck build publish clean install-dev + +# Default target +help: + @echo "Available commands:" + @echo " make install-dev - Install development dependencies" + @echo " make tests - Run tests with coverage" + @echo " make lint - Run linting checks" + @echo " make format - Format code" + @echo " make typecheck - Run type checking" + @echo " make check - Run all checks (lint, format, typecheck, tests)" + @echo " make build - Build package" + @echo " make publish - Build and publish to PyPI" + @echo " make clean - Clean build artifacts" + +# Install development dependencies +install-dev: + pip install -e ".[dev]" + +# Run tests with coverage +tests: + python -m pytest --cov=src/xmlassert --cov-report=term-missing --cov-report=html -v + +# Run linting checks +lint: + ruff check src/xmlassert tests + +# Format code +format: + ruff check --select I --fix # fix imports + ruff format src/xmlassert tests + +# Check formatting without making changes +format-check: + ruff format --check src/xmlassert tests + +# Run type checking +typecheck: + mypy src/xmlassert tests + +# Run all checks: lint, format check, typecheck, and tests +check: lint format-check typecheck tests + +# Build package +build: + python -m build + +# Build and publish to PyPI (requires TWINE_USERNAME and TWINE_PASSWORD) +publish: build + python -m twine upload dist/* + +# Clean build artifacts +clean: + rm -rf build/ + rm -rf dist/ + rm -rf *.egg-info/ + rm -rf .ruff_cache/ + rm -rf .mypy_cache/ + rm -rf .pytest_cache/ + rm -rf htmlcov/ + rm -rf .coverage + rm -rf coverage.xml + +# Install package in development mode +develop: + pip install -e . + +# Run tests in watch mode (requires pytest-watch) +watch: + ptw --onpass "echo ? Tests passed" --onfail "echo ? Tests failed" + +# Generate coverage report +coverage: + python -m pytest --cov=src/xmlassert --cov-report=html + @echo "Coverage report generated at htmlcov/index.html" + +# Check for security vulnerabilities +safety: + pip install safety + safety check + +# Update dependencies +update-deps: + pip install --upgrade pip + pip install --upgrade -e ".[dev]" + +# Show dependency tree +deps-tree: + pip install pipdeptree + pipdeptree + +# Run benchmarks (if you add benchmarks later) +benchmark: + @echo "Benchmarks not yet implemented" + +# Helpers for CI +ci-install: + pip install -e ".[dev]" + +ci-test: + python -m pytest --cov=src/xmlassert --cov-report=xml + +ci-lint: + ruff check src/xmlassert tests + +ci-format: + ruff format --check src/xmlassert tests + +ci-typecheck: + mypy src/xmlassert tests diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fb9ecd2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,137 @@ +[build-system] +requires = ['setuptools>=45', 'wheel'] +build-backend = 'setuptools.build_meta' + +[project] +name = 'xmlassert' +version = '0.1.0rc1' +description = 'Human-readable XML comparison for testing with clean diff output' +readme = 'README.md' +requires-python = '>=3.8' +license = {text = 'MIT'} +authors = [ + {name = 'Maxim Ivanov', email = 'ivanovmg@gmail.com'}, +] +keywords = ['xml', 'testing', 'assert', 'diff', 'comparison'] +classifiers = [ + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Topic :: Software Development :: Testing', + 'Topic :: Text Processing :: Markup :: XML', +] +dependencies = [ + 'defusedxml>=0.6.0', +] + +[project.urls] +Homepage = 'https://github.com/ivanovmg/xmlassert' +Changelog = 'https://github.com/ivanovmg/xmlassert/blob/main/CHANGELOG.md' +Issues = 'https://github.com/ivanovmg/xmlassert/issues' + +[tool.setuptools] +packages = {find = {where = ['src']}} +package-dir = {'' = 'src'} + +[tool.setuptools.package-data] +xmlassert = ['py.typed'] + +[project.optional-dependencies] +dev = [ + 'pytest>=7.0.0', + 'pytest-cov>=4.0.0', + 'ruff>=0.1.0', + 'mypy>=1.0.0', + 'types-setuptools', + 'build>=0.10.0', + 'twine>=4.0.0', + 'types-defusedxml', +] +docs = [ + 'sphinx>=7.0.0', + 'sphinx-rtd-theme>=1.0.0', +] + +[tool.ruff] +line-length = 79 +target-version = 'py38' + +# Linting rules (updated to new format) +[tool.ruff.lint] +select = [ + 'E', # pycodestyle errors + 'W', # pycodestyle warnings + 'F', # pyflakes + 'I', # isort + 'B', # flake8-bugbear + 'C4', # flake8-comprehensions + 'UP', # pyupgrade + 'YTT', # flake8-2020 + 'RUF', # ruff-specific rules +] +ignore = [ + 'B008', # do not perform function calls in argument defaults + 'B905', # zip() without an explicit strict= parameter + 'E501', # line too long (handled by formatter) + 'RUF012', # mutable class defaults +] + +# Import sorting (updated to new format) +[tool.ruff.lint.isort] +known-first-party = ['xmlassert'] +lines-after-imports = 2 +combine-as-imports = true + +# Per-file ignores (updated to new format) +[tool.ruff.lint.per-file-ignores] +'__init__.py' = ['F401'] # allow unused imports in __init__.py + +# Formatting configuration +[tool.ruff.format] +quote-style = 'single' +skip-magic-trailing-comma = false + +[tool.coverage.run] +source = ['src/xmlassert'] +branch = true +parallel = true + +[tool.coverage.paths] +source = ['src/xmlassert', '*/site-packages'] + +[tool.coverage.report] +show_missing = true +skip_covered = true +fail_under = 90 +exclude_lines = [ + 'pragma: no cover', + 'def __repr__', + 'if self\.debug', + 'raise AssertionError', + 'raise NotImplementedError', + 'if 0:', + 'if __name__ == .__main__.:', + 'if TYPE_CHECKING:', + 'class .*\bProtocol\):', + 'abc.abstractmethod', + 'abstractmethod', + 'pass', +] +ignore_errors = true + +[tool.pytest.ini_options] +testpaths = ['tests'] +addopts = '--cov=src/xmlassert --cov-report=term-missing --cov-report=html' +python_files = ['test_*.py'] +python_classes = ['Test*'] +python_functions = ['test_*'] + +[tool.mypy] +python_version = '3.8' +strict = true diff --git a/src/xmlassert/__init__.py b/src/xmlassert/__init__.py new file mode 100644 index 0000000..03caa94 --- /dev/null +++ b/src/xmlassert/__init__.py @@ -0,0 +1,9 @@ +# Copyright (c) 2025 Maxim Ivanov +# SPDX-License-Identifier: MIT + +from .equal import assert_xml_equal + + +__all__ = [ + 'assert_xml_equal', +] diff --git a/src/xmlassert/diffs.py b/src/xmlassert/diffs.py new file mode 100644 index 0000000..f03bdef --- /dev/null +++ b/src/xmlassert/diffs.py @@ -0,0 +1,24 @@ +# Copyright (c) 2025 Maxim Ivanov +# SPDX-License-Identifier: MIT + +import difflib + +from .formatting import pretty_format_xml + + +__all__ = [ + 'clean_diff', +] + + +def clean_diff(actual: str, expected: str) -> str: + actual_pretty = pretty_format_xml(actual) + expected_pretty = pretty_format_xml(expected) + diff = difflib.unified_diff( + expected_pretty.splitlines(), + actual_pretty.splitlines(), + fromfile='expected', + tofile='actual', + lineterm='', + ) + return '\n'.join(diff) diff --git a/src/xmlassert/equal.py b/src/xmlassert/equal.py new file mode 100644 index 0000000..0073088 --- /dev/null +++ b/src/xmlassert/equal.py @@ -0,0 +1,21 @@ +# Copyright (c) 2025 Maxim Ivanov +# SPDX-License-Identifier: MIT + +from .diffs import clean_diff +from .formatting import canonical + + +__all__ = [ + 'assert_xml_equal', +] + + +def assert_xml_equal(actual: str, expected: str) -> None: + """ + Securely compare XML strings with clean diff output. + """ + if canonical(actual) == canonical(expected): + return + + diff_text = clean_diff(actual, expected) + raise AssertionError(f'XML documents differ:\n{diff_text}') diff --git a/src/xmlassert/formatting.py b/src/xmlassert/formatting.py new file mode 100644 index 0000000..1fa5452 --- /dev/null +++ b/src/xmlassert/formatting.py @@ -0,0 +1,32 @@ +# Copyright (c) 2025 Maxim Ivanov +# SPDX-License-Identifier: MIT + +from xml.etree.ElementTree import canonicalize + +from defusedxml.ElementTree import fromstring, tostring + +from .indenting import indent + + +__all__ = [ + 'canonical', + 'pretty_format_xml', +] + + +def pretty_format_xml(xml_str: str) -> str: + """Securely format XML with clean, consistent indentation""" + try: + root = fromstring(xml_str) + indent(root) + return str(tostring(root, encoding='unicode')) + except Exception: + return canonicalize(xml_str, strip_text=False) + + +def canonical(content: str) -> str: + return canonicalize( + content, + strip_text=True, + with_comments=False, + ) diff --git a/src/xmlassert/indenting.py b/src/xmlassert/indenting.py new file mode 100644 index 0000000..b2e5667 --- /dev/null +++ b/src/xmlassert/indenting.py @@ -0,0 +1,43 @@ +# Copyright (c) 2025 Maxim Ivanov +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from xml.etree.ElementTree import Element + +__all__ = [ + 'indent', +] + + +def indent(elem: Element, level: int = 0) -> None: + """ + Recursively indent XML elements with consistent spacing. + Based on the standard ElementTree indentation approach. + """ + # Set indentation for current element + indent_str = '\n' + ' ' * level + + if len(elem): + # If element has children + if not elem.text or not elem.text.strip(): + elem.text = indent_str + ' ' + if not elem.tail or not elem.tail.strip(): + elem.tail = indent_str + + # Process children + for child in elem: + indent(child, level + 1) + + # Set tail for the last child + if not elem[-1].tail or not elem[-1].tail.strip(): + elem[-1].tail = indent_str + + else: + # Leaf element + if level and (not elem.tail or not elem.tail.strip()): + elem.tail = indent_str diff --git a/src/xmlassert/py.typed b/src/xmlassert/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_assert_xml_equal.py b/tests/test_assert_xml_equal.py new file mode 100644 index 0000000..af68e6b --- /dev/null +++ b/tests/test_assert_xml_equal.py @@ -0,0 +1,386 @@ +# Copyright (c) 2025 Maxim Ivanov +# SPDX-License-Identifier: MIT + +from textwrap import dedent +from typing import Any +from xml.etree.ElementTree import ParseError + +import pytest + +from xmlassert import assert_xml_equal + + +def test_IdenticalXmlStrings_PassWithoutError() -> None: + xml_string = 'text' + assert_xml_equal(xml_string, xml_string) + + +@pytest.mark.parametrize( + 'actual,expected', + [ + # Different formatting + ( + 'text', + '\n text\n', + ), + # Different whitespace + ('text', ' text '), + ( + 'text', + ' text ', + ), + (' text ', 'text'), + # Comments ignored + ('', ''), + # Self-closing vs explicit + ('', ''), + ('', ''), + # Multiple children + ( + '', + '', + ), + # Mixed content + ( + 'textmore text', + 'textmore text', + ), + # Quote styles + ( + "", + '', + ), + # Processing instructions + ( + '', + '', + ), + ], +) +def test_EquivalentXmlWithDifferentFormatting_Pass( + actual: str, expected: str +) -> None: + assert_xml_equal(actual, expected) + + +@pytest.mark.parametrize( + 'actual,expected', + [ + # Different structure + ( + 'text', + 'text', + ), + # Different content + ( + 'actual text', + 'expected text', + ), + ('one', 'two'), + # Different attributes + ( + '', + '', + ), + ('', ''), + # Different namespaces + ( + '', + '', + ), + ], +) +def test_DifferentXmlContent_RaisesAssertionError( + actual: str, expected: str +) -> None: + with pytest.raises(AssertionError, match='XML documents differ'): + assert_xml_equal(actual, expected) + + +@pytest.mark.parametrize( + 'xml1,xml2', + [ + ( + '', + '', + ), + ('', ''), + ], +) +def test_AttributeOrder_DoesNotAffectEquality(xml1: str, xml2: str) -> None: + assert_xml_equal(xml1, xml2) + + +def test_XmlWithIdenticalNamespaces_Pass() -> None: + actual = '' + expected = '' + assert_xml_equal(actual, expected) + + +def test_NestedXmlStructure_EquivalentRegardlessOfFormatting() -> None: + actual = """ + + + text + + + """ + expected = 'text' + assert_xml_equal(actual, expected) + + +def test_ComplexXmlStructure_EquivalentRegardlessOfFormatting() -> None: + complex_xml = """ + + + + + + + + + + + + """ + + compact_xml = '' + + assert_xml_equal(complex_xml, compact_xml) + + +def test_DifferentXml_ErrorMessageContainsReadableDiff() -> None: + actual = 'actual' + expected = 'expected' + + with pytest.raises(AssertionError) as exc_info: + assert_xml_equal(actual, expected) + + error_message = str(exc_info.value) + expected = dedent( + """\ + XML documents differ: + --- expected + +++ actual + @@ -1,3 +1,3 @@ + + - expected + + actual + + """ + ).strip() + assert error_message == expected + + +def test_DifferentXmlNested_ErrorMessageContainsReadableDiff() -> None: + actual = ( + ' one\r\n actual' + ) + expected = 'expected' + + with pytest.raises(AssertionError) as exc_info: + assert_xml_equal(actual, expected) + + error_message = str(exc_info.value) + expected = dedent( + """\ + XML documents differ: + --- expected + +++ actual + @@ -1,3 +1,6 @@ + + - expected + + one + + + + actual + + + + """ + ).strip() + assert error_message == expected + + +def test_XmlWithSpecialCharacters_HandledCorrectly() -> None: + actual = '& < >' + expected = '& < >' + assert_xml_equal(actual, expected) + + +def test_XmlWithCdataSections_Equivalent() -> None: + actual = 'content]]>' + expected = 'content]]>' + assert_xml_equal(actual, expected) + + +@pytest.mark.parametrize( + 'invalid_xml', + [ + '', + ' ', + '', + '', + ], +) +def test_InvalidXml_RaisesParseError(invalid_xml: str) -> None: + valid_xml = '' + + with pytest.raises(ParseError): + assert_xml_equal(invalid_xml, valid_xml) + + with pytest.raises(ParseError): + assert_xml_equal(valid_xml, invalid_xml) + + +@pytest.mark.parametrize( + 'invalid_input', + [ + None, + 123, + [], + {}, + ], +) +def test_NonStringInput_RaisesError(invalid_input: Any) -> None: + valid_xml = '' + + with pytest.raises((TypeError, ValueError)): + assert_xml_equal(invalid_input, valid_xml) + + with pytest.raises((TypeError, ValueError)): + assert_xml_equal(valid_xml, invalid_input) + + +def test_LargeXmlDocuments_HandledSuccessfully() -> None: + large_xml = '' + 'text' * 100 + '' + assert_xml_equal(large_xml, large_xml) + + +def test_XmlWithUnicodeCharacters_HandledCorrectly() -> None: + actual = 'caf\u00e9 na\u00efve' + expected = 'caf\u00e9 na\u00efve' + assert_xml_equal(actual, expected) + + +def test_MalformedXml_RaisesParseErrorWithMeaningfulMessage() -> None: + malformed_xml = '' + well_formed_xml = '' + + with pytest.raises(ParseError): + assert_xml_equal(malformed_xml, well_formed_xml) + + +def test_EmptyElementsWithDifferentSyntax_Equivalent() -> None: + test_cases = [ + ('', ''), + ('', ''), + ('', ''), + ] + + for actual, expected in test_cases: + assert_xml_equal(actual, expected) + + +def test_XmlWithAttributesInDifferentOrder_Equivalent() -> None: + actual = '' + expected = '' + assert_xml_equal(actual, expected) + + +def test_XmlWithNamespacePrefixes_HandledCorrectly() -> None: + actual = '' + expected = '' + assert_xml_equal(actual, expected) + + +def test_XmlWithDifferentNamespaceUris_RaisesAssertionError() -> None: + actual = '' + expected = '' + + with pytest.raises(AssertionError): + assert_xml_equal(actual, expected) + + +def test_XmlWithMixedContentAndFormatting_Equivalent() -> None: + actual = ' text value more text ' + expected = 'textvaluemore text' + assert_xml_equal(actual, expected) + + +@pytest.mark.parametrize( + 'xml_content', + [ + '', # Minimal self-closing + '', # Minimal with closing + 'text', # With text content + "", # With attribute + '', # With child + ], +) +def test_EdgeCaseXml_ThatTriggersBranchCoverage(xml_content: str) -> None: + """Test edge cases that might trigger different code paths""" + assert_xml_equal(xml_content, xml_content) + + +@pytest.mark.parametrize( + 'whitespace_xml', + [ + '\n\n\n', # Newlines only + ' ', # Spaces only + '\t\t\t', # Tabs only + '\n \t\n \t\n \t', # Mixed whitespace + ], +) +def test_XmlWithVariousWhitespacePatterns(whitespace_xml: str) -> None: + """Test XML with different whitespace patterns""" + assert_xml_equal(whitespace_xml, whitespace_xml) + + +@pytest.mark.parametrize( + 'empty_element_format', + [ + '', + '', + '\n\n', + '\n\n', + ], +) +def test_XmlWithEmptyElements_DifferentFormats( + empty_element_format: str, +) -> None: + """Test empty elements in various formats""" + canonical = '' + assert_xml_equal(empty_element_format, canonical) + + +def test_XmlWithDeepNesting_CoversRecursivePaths() -> None: + """Test deeply nested XML to cover recursive formatting paths""" + deep_xml = 'deep' + assert_xml_equal(deep_xml, deep_xml) + + +def test_XmlWithMixedContentAndFormatting_CoversAllBranches() -> None: + """Test XML with mixed content types""" + mixed_xml = """ + + Text content + child text + More text + + + + """ + assert_xml_equal(mixed_xml, mixed_xml) + + +def test_XmlWithSpecialCharacters_InAttributesAndText() -> None: + """Test XML with special characters in different contexts""" + special_chars_xml = ( + '<content>' + ) + assert_xml_equal(special_chars_xml, special_chars_xml) + + +def test_XmlWithNamespace_AndAttributes_CoversComplexPaths() -> None: + """Test XML with namespaces and attributes""" + ns_xml = '' + assert_xml_equal(ns_xml, ns_xml)