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)