From 51efe128b873113086c66006ee25e0d8e2c8aad1 Mon Sep 17 00:00:00 2001 From: Michael Olmos Date: Tue, 21 Jul 2026 14:34:03 -0500 Subject: [PATCH 01/10] test: add golden compatibility coverage --- MANIFEST.in | 1 + .../golden/python311_compatibility.json | 257 ++++++++++++++++++ tests/test_golden_compatibility.py | 116 ++++++++ 3 files changed, 374 insertions(+) create mode 100644 MANIFEST.in create mode 100644 tests/fixtures/golden/python311_compatibility.json create mode 100644 tests/test_golden_compatibility.py diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..10b4fbc --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +recursive-include tests/fixtures *.json *.md diff --git a/tests/fixtures/golden/python311_compatibility.json b/tests/fixtures/golden/python311_compatibility.json new file mode 100644 index 0000000..4b5e4fc --- /dev/null +++ b/tests/fixtures/golden/python311_compatibility.json @@ -0,0 +1,257 @@ +{ + "block_html_and_comment": { + "errors": [], + "html": [ + "
\n\n\n
\nEnglish\n
\n
", + "
\n\n\n
\nDeutsch\n
\n
" + ], + "left_structure": "xx", + "link_errors": [], + "right_structure": "xx", + "text": [ + "\n\n
\nEnglish\n
", + "\n\n
\nDeutsch\n
" + ] + }, + "extra_paragraph": { + "errors": [ + { + "message": "There is a missing element `paragraph`.", + "style": "ins", + "symbol": "p", + "type": "InsertError" + }, + { + "message": "There is a missing element `text`.", + "style": "ins", + "symbol": "t", + "type": "InsertError" + } + ], + "html": [ + "
\none\n
", + "
\neins\n\nzwei\n\n\n
" + ], + "left_structure": "pt", + "link_errors": [ + { + "message": "There is a missing element `paragraph`.", + "style": "ins", + "symbol": "p", + "type": "InsertError" + } + ], + "right_structure": "ptpt", + "text": [ + "one", + "eins\n\nzwei" + ] + }, + "hard_break_and_direction_marks": { + "errors": [], + "html": [ + "
\none  \u00b6\ntwo\n\nthree\n
", + "
\neins  \u00b6\nzwei\n\ndrei\n
" + ], + "left_structure": "ptntpt", + "link_errors": [], + "right_structure": "ptntpt", + "text": [ + "one \u00b6\ntwo\n\nthree", + "eins \u00b6\nzwei\n\ndrei" + ] + }, + "heading_styles": { + "errors": [], + "html": [ + "
\n#Heading\n\n##Setext heading\n
", + "
\n#Ueberschrift\n\n##Setext Ueberschrift\n
" + ], + "left_structure": "1t2t", + "link_errors": [], + "right_structure": "1t2t", + "text": [ + "#Heading\n\n##Setext heading", + "#Ueberschrift\n\n##Setext Ueberschrift" + ] + }, + "image_and_reference_link": { + "errors": [], + "html": [ + "
\n![English alt](image.png)\n\nRead [the guide][docs].\n\n[docs]: https://example.test/docs\n
", + "
\n![Deutscher Alt-Text](image.png)\n\nLies [die Anleitung][docs].\n\n[docs]: https://example.test/docs\n
" + ], + "left_structure": "piptatpt", + "link_errors": [], + "right_structure": "piptatpt", + "text": [ + "![English alt](image.png)\n\nRead [the guide][docs].\n\n[docs]: https://example.test/docs", + "![Deutscher Alt-Text](image.png)\n\nLies [die Anleitung][docs].\n\n[docs]: https://example.test/docs" + ] + }, + "inline_html": { + "errors": [], + "html": [ + "
\nbefore <sub>one</sub> after\n
", + "
\nvor <sub>eins</sub> danach\n
" + ], + "left_structure": "pt", + "link_errors": [], + "right_structure": "pt", + "text": [ + "before <sub>one</sub> after", + "vor <sub>eins</sub> danach" + ] + }, + "link_count_change": { + "errors": [], + "html": [ + "
\nRead [one](https://example.test/one).\n
", + "
\nLies [eins](https://example.test/one) und [zwei](https://example.test/two).\n
" + ], + "left_structure": "ptat", + "link_errors": [ + { + "message": "There is a missing element `link`.", + "style": "ins", + "symbol": "a", + "type": "InsertError" + } + ], + "right_structure": "ptatat", + "text": [ + "Read [one](https://example.test/one).", + "Lies [eins](https://example.test/one) und [zwei](https://example.test/two)." + ] + }, + "list_variants": { + "errors": [], + "html": [ + "
\n* one\n* two\n\n\n0. first\n1. second\n
", + "
\n* eins\n* zwei\n\n\n0. erste\n1. zweite\n
" + ], + "left_structure": "lmtmtlmtmt", + "link_errors": [], + "right_structure": "lmtmtlmtmt", + "text": [ + "* one\n* two\n\n\n0. first\n1. second", + "* eins\n* zwei\n\n\n0. erste\n1. zweite" + ] + }, + "removed_paragraph": { + "errors": [ + { + "message": "There is an additional element `paragraph`.", + "style": "del", + "symbol": "p", + "type": "DeleteError" + }, + { + "message": "There is an additional element `text`.", + "style": "del", + "symbol": "t", + "type": "DeleteError" + } + ], + "html": [ + "
\none\n\ntwo\n\n\n
", + "
\neins\n
" + ], + "left_structure": "ptpt", + "link_errors": [ + { + "message": "There is an additional element `paragraph`.", + "style": "del", + "symbol": "p", + "type": "DeleteError" + } + ], + "right_structure": "pt", + "text": [ + "one\n\ntwo", + "eins" + ] + }, + "same_links": { + "errors": [], + "html": [ + "
\nA [link](https://example.test)\n
", + "
\nB [translated](https://example.test)\n
" + ], + "left_structure": "pta", + "link_errors": [], + "right_structure": "pta", + "text": [ + "A [link](https://example.test)", + "B [translated](https://example.test)" + ] + }, + "zendesk_callout_style": { + "errors": [ + { + "message": "There is an additional element `callout`.", + "style": "del", + "symbol": "C", + "type": "DeleteError" + }, + { + "message": "There is a missing element `callout`.", + "style": "ins", + "symbol": "C", + "type": "InsertError" + } + ], + "html": [ + "
\n\n\n#Title\n\nbody\n\n\n\n
", + "
\n\n\n#Titel\n\ninhalt\n\n\n\n
" + ], + "left_structure": "C1tpt", + "link_errors": [ + { + "message": "There is an additional element `callout`.", + "style": "del", + "symbol": "C", + "type": "DeleteError" + }, + { + "message": "There is a missing element `callout`.", + "style": "ins", + "symbol": "C", + "type": "InsertError" + } + ], + "right_structure": "C1tpt", + "text": [ + "\n\n#Title\n\nbody\n\n", + "\n\n#Titel\n\ninhalt\n\n" + ] + }, + "zendesk_steps_in_tabs": { + "errors": [], + "html": [ + "
\n\n\n#Steps\n\n\n\n0. one\n1. two\n\n\n\n\n
", + "
\n\n\n#Schritte\n\n\n\n0. eins\n1. zwei\n\n\n\n\n
" + ], + "left_structure": "T1tSlmtmt", + "link_errors": [], + "right_structure": "T1tSlmtmt", + "text": [ + "\n\n#Steps\n\n\n\n0. one\n1. two\n\n\n\n", + "\n\n#Schritte\n\n\n\n0. eins\n1. zwei\n\n\n\n" + ] + }, + "zendesk_unstyled_callout_in_tabs": { + "errors": [], + "html": [ + "
\n\n\n#Topic\n\n\n\nbody\n\n\n\n
", + "
\n\n\n#Thema\n\n\n\ninhalt\n\n\n\n
" + ], + "left_structure": "T1tCpt", + "link_errors": [], + "right_structure": "T1tCpt", + "text": [ + "\n\n#Topic\n\n\n\nbody\n\n\n", + "\n\n#Thema\n\n\n\ninhalt\n\n\n" + ] + } +} diff --git a/tests/test_golden_compatibility.py b/tests/test_golden_compatibility.py new file mode 100644 index 0000000..dca31fa --- /dev/null +++ b/tests/test_golden_compatibility.py @@ -0,0 +1,116 @@ +import json +from pathlib import Path +from unittest import TestCase + +import sdiff +from sdiff import parser +from sdiff.renderer import HtmlRenderer, TextRenderer + + +GOLDEN_PATH = Path(__file__).parent / 'fixtures' / 'golden' / 'python311_compatibility.json' + +CASES = { + 'block_html_and_comment': ( + '\n\n
\nEnglish\n
', + '\n\n
\nDeutsch\n
', + sdiff.MdParser, + ), + 'extra_paragraph': ('one', 'eins\n\nzwei', sdiff.MdParser), + 'hard_break_and_direction_marks': ( + '\u200eone \ntwo\n \nthree\u200f', + '\u200eeins \nzwei\n \ndrei\u200f', + sdiff.MdParser, + ), + 'heading_styles': ( + '# Heading\n\nSetext heading\n--------------', + '# Ueberschrift\n\nSetext Ueberschrift\n-------------------', + sdiff.MdParser, + ), + 'image_and_reference_link': ( + '![English alt](image.png)\n\nRead [the guide][docs].\n\n[docs]: https://example.test/docs', + '![Deutscher Alt-Text](image.png)\n\nLies [die Anleitung][docs].\n\n[docs]: https://example.test/docs', + sdiff.MdParser, + ), + 'inline_html': ('before one after', 'vor eins danach', sdiff.MdParser), + 'link_count_change': ( + 'Read [one](https://example.test/one).', + 'Lies [eins](https://example.test/one) und [zwei](https://example.test/two).', + sdiff.MdParser, + ), + 'list_variants': ( + '* one\n* two\n\n1. first\n2. second', + '* eins\n* zwei\n\n1. erste\n2. zweite', + sdiff.MdParser, + ), + 'removed_paragraph': ('one\n\ntwo', 'eins', sdiff.MdParser), + 'same_links': ( + 'A [link](https://example.test)', + 'B [translated](https://example.test)', + sdiff.MdParser, + ), + 'zendesk_callout_style': ( + '\n# Title\nbody\n', + '\n# Titel\ninhalt\n', + sdiff.ZendeskHelpMdParser, + ), + 'zendesk_steps_in_tabs': ( + '\n# Steps\n\n1. one\n2. two\n\n', + '\n# Schritte\n\n1. eins\n2. zwei\n\n', + sdiff.ZendeskHelpMdParser, + ), + 'zendesk_unstyled_callout_in_tabs': ( + '\n# Topic\n\nbody\n\n', + '\n# Thema\n\ninhalt\n\n', + sdiff.ZendeskHelpMdParser, + ), +} + + +def _serialize_errors(errors): + return [ + { + 'type': type(error).__name__, + 'symbol': error.node.symbol, + 'style': error.node.meta.get('style'), + 'message': error.message, + } + for error in errors + ] + + +def _snapshot(left, right, parser_cls): + left_tree = parser.parse(left, parser_cls=parser_cls) + right_tree = parser.parse(right, parser_cls=parser_cls) + _, _, link_errors = sdiff.diff_links(left_tree, right_tree) + left_text, right_text, errors = sdiff.diff( + left, + right, + renderer=TextRenderer(), + parser_cls=parser_cls, + ) + left_html, right_html, _ = sdiff.diff( + left, + right, + renderer=HtmlRenderer(), + parser_cls=parser_cls, + ) + return { + 'left_structure': left_tree.print_all(), + 'right_structure': right_tree.print_all(), + 'text': [left_text, right_text], + 'html': [left_html, right_html], + 'errors': _serialize_errors(errors), + 'link_errors': _serialize_errors(link_errors), + } + + +class TestGoldenCompatibility(TestCase): + maxDiff = None + + def test_parser_renderer_and_diff_outputs_match_python311_baseline(self): + expected = json.loads(GOLDEN_PATH.read_text(encoding='utf-8')) + actual = { + name: _snapshot(left, right, parser_cls) + for name, (left, right, parser_cls) in CASES.items() + } + self.assertEqual(expected, actual) From 7c78ad35a71e7aadf413c21bb179a1bab7283f02 Mon Sep 17 00:00:00 2001 From: Michael Olmos Date: Tue, 21 Jul 2026 14:35:07 -0500 Subject: [PATCH 02/10] refactor: apply Python syntax upgrades --- sdiff/model.py | 2 +- sdiff/parser.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sdiff/model.py b/sdiff/model.py index 734ef8d..98499b2 100644 --- a/sdiff/model.py +++ b/sdiff/model.py @@ -138,7 +138,7 @@ def original(self, renderer): result = '' for idx, node in enumerate(self.nodes): if self.ordered: - result += '{}. {}'.format(idx, node.original(renderer)) + result += f'{idx}. {node.original(renderer)}' else: result += '* ' + node.original(renderer) result = result + '\n\n' diff --git a/sdiff/parser.py b/sdiff/parser.py index 93a4736..3d8d2e3 100644 --- a/sdiff/parser.py +++ b/sdiff/parser.py @@ -78,8 +78,8 @@ def __init__(self): self.grammar_class.block_html = re.compile( r'^\s* *(?:{}|{}|{}) *(?:\n{{1,}}|\s*$)'.format( r'', - r'<({})((?:{})*?)>([\s\S]+?)<\/\1>'.format(mistune._block_tag, mistune._valid_attr), - r'<{}(?:{})*?>'.format(mistune._block_tag, mistune._valid_attr), + fr'<({mistune._block_tag})((?:{mistune._valid_attr})*?)>([\s\S]+?)<\/\1>', + fr'<{mistune._block_tag}(?:{mistune._valid_attr})*?>', ) ) From 73a4f4b467696e211439bf7d32c2f6f439e8a182 Mon Sep 17 00:00:00 2001 From: Michael Olmos Date: Tue, 21 Jul 2026 14:36:15 -0500 Subject: [PATCH 03/10] chore: migrate packaging to pyproject --- .python-version | 2 +- pyproject.toml | 65 ++++++++++++++++++++++++++++++++++++++++++++ requirements-dev.txt | 11 ++++++-- requirements.txt | 2 +- setup.cfg | 12 -------- setup.py | 49 --------------------------------- 6 files changed, 75 insertions(+), 66 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/.python-version b/.python-version index 2c07333..c70edfa 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.11 +3.11.13 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6e6d516 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,65 @@ +[build-system] +requires = ["setuptools>=82.0.1", "wheel>=0.47.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "sdiff" +version = "1.0.0" +description = "Compare Markdown documents by structure while ignoring translated content" +readme = "README.md" +requires-python = ">=3.11,<3.12" +license = "Apache-2.0" +authors = [ + { name = "Keepsafe", email = "support@getkeepsafe.com" }, +] +classifiers = [ + "Intended Audience :: Developers", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", +] +dependencies = [ + "mistune==0.8.4", +] + +[project.optional-dependencies] +dev = [ + "build==1.5.0", + "coverage==7.6.1", + "flake8==7.1.1", + "flake8-pyproject==1.2.4", + "pipdeptree==2.35.2", + "pytest==9.1.1", + "pyupgrade==3.21.2", + "twine==6.2.0", +] +tests = [ + "coverage==7.6.1", + "flake8==7.1.1", + "flake8-pyproject==1.2.4", + "pytest==9.1.1", +] +devtools = [ + "build==1.5.0", + "twine==6.2.0", +] + +[project.urls] +Homepage = "https://github.com/KeepSafe/html-structure-diff/" + +[tool.setuptools.packages.find] +include = ["sdiff*"] +exclude = ["tests*"] + +[tool.flake8] +max-line-length = 120 +ignore = ["F403", "F405"] + +[tool.coverage.run] +branch = true + +[tool.coverage.report] +fail_under = 96 + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/requirements-dev.txt b/requirements-dev.txt index 86d13ec..7ed568f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,9 @@ -r requirements.txt -nose -flake8 -autopep8 \ No newline at end of file +build==1.5.0 +coverage==7.6.1 +flake8==7.1.1 +flake8-pyproject==1.2.4 +pipdeptree==2.35.2 +pytest==9.1.1 +pyupgrade==3.21.2 +twine==6.2.0 diff --git a/requirements.txt b/requirements.txt index 1f202e5..f0c8b7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -mistune==0.8.1 +mistune==0.8.4 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index ed862e5..0000000 --- a/setup.cfg +++ /dev/null @@ -1,12 +0,0 @@ -[flake8] -max-line-length = 120 -ignore = F403, F405 - -[pep8] -max-line-length = 120 - -[coverage:run] -branch = True - -[coverage:report] -fail_under = 96 diff --git a/setup.py b/setup.py deleted file mode 100644 index 8cc3c15..0000000 --- a/setup.py +++ /dev/null @@ -1,49 +0,0 @@ -import os -from setuptools import setup, find_packages - - -version = '1.0.0' - - -def read(f): - return open(os.path.join(os.path.dirname(__file__), f)).read().strip() - - -install_requires = [ - 'mistune <= 1', -] - -tests_require = [ - 'pytest >= 8', - 'coverage==7.6.1', - 'flake8==7.1.1', - 'autopep8', -] - -devtools_require = [ - 'twine', - 'build', -] - -setup(name='sdiff', - version=version, - python_requires='>=3.11', - description=('sdiff compares the structure of two markdown texts'), - classifiers=[ - 'License :: OSI Approved :: BSD License', - 'Intended Audience :: Developers', - 'Programming Language :: Python'], - author='Keepsafe', - author_email='support@getkeepsafe.com', - url='https://github.com/KeepSafe/html-structure-diff/', - license='Apache', - packages=find_packages(exclude=['tests']), - package_data={}, - namespace_packages=[], - install_requires=install_requires, - tests_require=tests_require, - extras_require={ - 'tests': tests_require, - 'devtools': devtools_require, - }, - include_package_data=False) From 23e2e6f07c1103fa775ca7ce350bdec14fea3dff Mon Sep 17 00:00:00 2001 From: Michael Olmos Date: Tue, 21 Jul 2026 14:37:02 -0500 Subject: [PATCH 04/10] chore: normalize Python 3.11 workflows --- .circleci/config.yml | 94 ++++++++++++++++++++++++++++++ Makefile | 133 +++++++++++++++++++++++++++++++------------ README.md | 31 ++++++++++ git_hooks/pre-push | 5 ++ 4 files changed, 226 insertions(+), 37 deletions(-) create mode 100644 .circleci/config.yml create mode 100755 git_hooks/pre-push diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..3678aca --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,94 @@ +version: 2 + +job_options: + - &default_job + docker: + - image: cimg/python:3.11.13 + +step_options: + - &pip_cache + key: v3-pip-{{ arch }}-{{ checksum "pyproject.toml" }} + keys: + - v3-pip-{{ arch }}-{{ checksum "pyproject.toml" }} + - v3-pip-{{ arch }} + - v3-pip- + paths: + - ~/.local + - ~/.cache + - &venv_cache + key: v3-venv-{{ arch }}-{{ checksum "pyproject.toml" }} + keys: + - v3-venv-{{ arch }}-{{ checksum "pyproject.toml" }} + - v3-venv-{{ arch }} + - v3-venv- + paths: + - /home/circleci/project/venv + +steps: + - &restore_pip_cache + restore_cache: + <<: *pip_cache + - &save_pip_cache + save_cache: + <<: *pip_cache + - &restore_venv_cache + restore_cache: + <<: *venv_cache + - &save_venv_cache + save_cache: + <<: *venv_cache + +jobs: + prepare_cache: + <<: *default_job + steps: + - checkout + - *restore_pip_cache + - *restore_venv_cache + - run: + name: Install dependencies + command: make ci-dev-install + - *save_pip_cache + - *save_venv_cache + + lint: + <<: *default_job + steps: + - checkout + - *restore_pip_cache + - *restore_venv_cache + - run: + name: Run the linter + command: make lint + - store_test_results: + path: build/test + + test: + <<: *default_job + steps: + - checkout + - *restore_pip_cache + - *restore_venv_cache + - run: + name: Run unit and golden fixture tests + command: make test-only + - store_test_results: + path: build/test + - store_artifacts: + path: build/coverage/coverage.xml + destination: coverage + - run: + name: Upload coverage to Codecov + command: bash <(curl -s https://codecov.io/bash) -t ${CODECOV_TOKEN} -f build/coverage/coverage.xml || echo "Codecov did not collect coverage reports" + +workflows: + version: 2 + test: + jobs: + - prepare_cache + - lint: + requires: + - prepare_cache + - test: + requires: + - prepare_cache diff --git a/Makefile b/Makefile index 6eeb1e2..c62eb92 100644 --- a/Makefile +++ b/Makefile @@ -1,60 +1,119 @@ -# Some simple testing tasks (sorry, UNIX only). +# Package development and verification tasks (UNIX only). -PYTHON=venv/bin/python3 +PYTHON=venv/bin/python PIP=venv/bin/pip COVERAGE=venv/bin/coverage -TEST_RUNNER=venv/bin/pytest -TEST_RUNNER_FLAGS=-s --durations=3 --durations-min=0.005 +PYTEST=venv/bin/pytest FLAKE=venv/bin/flake8 -FLAGS= +PIPDEPTREE=venv/bin/pipdeptree +PYUPGRADE=venv/bin/pyupgrade +RG=rg + +PYTHON_FILES=$(shell rg --files sdiff tests -g '*.py') +PYTEST_SHARED_FLAGS=-s --durations=3 --durations-min=0.005 +PYTEST_FLAGS=$(PYTEST_SHARED_FLAGS) +CI_COVERAGE_REPORT= + PYPICLOUD_HOST=pypicloud.getkeepsafe.local -TWINE=./venv/bin/twine +TWINE=venv/bin/twine +HOOK_PATH=$(shell git rev-parse --git-path hooks/pre-push) -update: - $(PIP) install -U pip - $(PIP) install -U . +ifdef CI +PYTEST_FLAGS += --junitxml=build/test/results.xml +CI_COVERAGE_REPORT=$(COVERAGE) xml -o build/coverage/coverage.xml +endif + +build-dir: + mkdir -p build/test build/coverage env: - test -d venv || python3 -m venv venv + test -d venv || python3.11 -m venv venv + $(PIP) install -U pip "setuptools>=82.0.1" "wheel>=0.47.0" + $(PIP) install -e . + +dev: env + $(PIP) install -e '.[dev]' -dev: env update - $(PIP) install .[tests,devtools] +update: + $(PIP) install -U . -install: env update +install: env -publish: - rm -rf dist - $(PYTHON) -m build . - $(TWINE) upload --verbose --sign --username developer --repository-url http://$(PYPICLOUD_HOST)/simple/ dist/*.whl +ci-env: + @if [ -d "venv" ] && $(PIP) --version >/dev/null 2>&1; then \ + echo "Reusing cached CI venv, no need to recreate when it has not changed"; \ + else \ + echo "No valid cached venv found, creating a fresh venv"; \ + if [ -d "venv" ]; then rm -rf venv; fi; \ + python3.11 -m venv venv; \ + $(PIP) install -U pip "setuptools>=82.0.1" "wheel>=0.47.0"; \ + fi + +ci-dev-install: ci-env + $(PIP) install -e '.[dev]' flake: $(FLAKE) sdiff tests -test: flake - $(COVERAGE) run -m pytest $(TEST_RUNNER_FLAGS) +check-msgpack: + @echo "Checking for direct msgpack imports..." + @! $(RG) -n --glob '*.py' '^(import msgpack|from msgpack)' sdiff tests \ + || (echo "ERROR: Unexpected direct msgpack import found." && exit 1) + +lint: build-dir flake check-msgpack + +test-only: build-dir + $(COVERAGE) erase + $(COVERAGE) run -m pytest $(PYTEST_FLAGS) + $(CI_COVERAGE_REPORT) -vtest: - $(COVERAGE) run -m pytest -v $(TEST_RUNNER_FLAGS) +test: lint test-only -testloop: - while sleep 1; do $(TEST_RUNNER) -s --lf $(TEST_RUNNER_FLAGS); done +vtest vtests: build-dir + $(COVERAGE) erase + $(COVERAGE) run -m pytest -v $(PYTEST_FLAGS) + $(CI_COVERAGE_REPORT) -cov cover coverage: +fixture-smoke: + $(PYTEST) -q tests/test_golden_compatibility.py tests/test_sdiff.py + +import-smoke: + $(PYTHON) -c 'import importlib.metadata as m; import sdiff; from sdiff import MdParser, ZendeskHelpMdParser, diff, diff_links, diff_struct, renderer; print(m.version("sdiff"), MdParser.__name__, ZendeskHelpMdParser.__name__, renderer.TextRenderer.__name__)' + +smoke: fixture-smoke import-smoke + +depcheck: + $(PIP) check + $(PIPDEPTREE) --warn fail + +pyupgrade: + $(PYUPGRADE) --py311-plus --keep-percent-format $(PYTHON_FILES) + +coverage: $(COVERAGE) report -m +cov cover: + $(COVERAGE) html --directory coverage + @echo "Coverage HTML written to coverage/index.html" + +package: + $(PYTHON) -m build + +publish: package + $(TWINE) upload --verbose --sign --username developer --repository-url http://$(PYPICLOUD_HOST)/simple/ dist/*.whl + +hooks: + cp git_hooks/pre-push $(HOOK_PATH) + chmod +x $(HOOK_PATH) + +unhooks: + rm -f $(HOOK_PATH) + clean: - rm -rf `find . -name __pycache__` - rm -f `find . -type f -name '*.py[co]' ` - rm -f `find . -type f -name '*~' ` - rm -f `find . -type f -name '.*~' ` - rm -f `find . -type f -name '@*' ` - rm -f `find . -type f -name '#*#' ` - rm -f `find . -type f -name '*.orig' ` - rm -f `find . -type f -name '*.rej' ` + find . -name __pycache__ -type d -prune -exec rm -rf {} + + find . -type f \( -name '*.py[co]' -o -name '*~' -o -name '.*~' -o -name '*.orig' -o -name '*.rej' \) -delete rm -f .coverage - rm -rf coverage - rm -rf build - rm -rf venv - + rm -rf build coverage dist sdiff.egg-info venv -.PHONY: all build env linux run pep test vtest testloop cov clean +.PHONY: build-dir check-msgpack ci-dev-install ci-env clean cov cover coverage depcheck dev env fixture-smoke \ + flake hooks import-smoke install lint package publish pyupgrade smoke test test-only unhooks update vtest vtests diff --git a/README.md b/README.md index b8bb2a8..3bffa58 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,33 @@ # md-sdiff + Diffs to markdown texts only based on their structure. Ignores content. Helpful to diff 2 files that contain the same content in different languages. + +## Python API + +```python +import sdiff +from sdiff import ZendeskHelpMdParser + +left, right, errors = sdiff.diff( + '# English title', + '# Deutscher Titel', + parser_cls=ZendeskHelpMdParser, +) +``` + +The package intentionally has no command-line or service entry point. Public behavior is exposed through +`sdiff.diff`, the parser classes, renderer classes, and the model/error objects returned by comparisons. + +## Python 3.11 development + +```sh +make dev +make lint +make test +make fixture-smoke +make depcheck +``` + +Exact parser, renderer, and diff behavior is protected by +`tests/fixtures/golden/python311_compatibility.json`. Migration scope and proof results are recorded in +`docs/python311-migration-contract.md`. diff --git a/git_hooks/pre-push b/git_hooks/pre-push new file mode 100755 index 0000000..04ed983 --- /dev/null +++ b/git_hooks/pre-push @@ -0,0 +1,5 @@ +#!/bin/sh +set -e + +make lint +make test From 5519cd741598204fdcaf7c75f5493f503471ac6c Mon Sep 17 00:00:00 2001 From: Michael Olmos Date: Tue, 21 Jul 2026 14:40:19 -0500 Subject: [PATCH 05/10] ci(item 1): migrate CircleCI config to 2.1 --- .circleci/config.yml | 85 +++++++++++++++++++------------------------- Makefile | 9 +++-- 2 files changed, 42 insertions(+), 52 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 3678aca..ab714e8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,62 +1,51 @@ -version: 2 +version: 2.1 -job_options: - - &default_job - docker: - - image: cimg/python:3.11.13 +executors: + python311: + docker: + - image: cimg/python:3.11.13 -step_options: - - &pip_cache - key: v3-pip-{{ arch }}-{{ checksum "pyproject.toml" }} - keys: - - v3-pip-{{ arch }}-{{ checksum "pyproject.toml" }} - - v3-pip-{{ arch }} - - v3-pip- - paths: - - ~/.local - - ~/.cache - - &venv_cache - key: v3-venv-{{ arch }}-{{ checksum "pyproject.toml" }} - keys: - - v3-venv-{{ arch }}-{{ checksum "pyproject.toml" }} - - v3-venv-{{ arch }} - - v3-venv- - paths: - - /home/circleci/project/venv - -steps: - - &restore_pip_cache - restore_cache: - <<: *pip_cache - - &save_pip_cache - save_cache: - <<: *pip_cache - - &restore_venv_cache - restore_cache: - <<: *venv_cache - - &save_venv_cache - save_cache: - <<: *venv_cache +commands: + restore_dependency_caches: + steps: + - restore_cache: + keys: + - v3-pip-{{ arch }}-{{ checksum "pyproject.toml" }} + - v3-pip-{{ arch }} + - v3-pip- + - restore_cache: + keys: + - v3-venv-{{ arch }}-{{ checksum "pyproject.toml" }} + - v3-venv-{{ arch }} + - v3-venv- + save_dependency_caches: + steps: + - save_cache: + key: v3-pip-{{ arch }}-{{ checksum "pyproject.toml" }} + paths: + - ~/.local + - ~/.cache + - save_cache: + key: v3-venv-{{ arch }}-{{ checksum "pyproject.toml" }} + paths: + - /home/circleci/project/venv jobs: prepare_cache: - <<: *default_job + executor: python311 steps: - checkout - - *restore_pip_cache - - *restore_venv_cache + - restore_dependency_caches - run: name: Install dependencies command: make ci-dev-install - - *save_pip_cache - - *save_venv_cache + - save_dependency_caches lint: - <<: *default_job + executor: python311 steps: - checkout - - *restore_pip_cache - - *restore_venv_cache + - restore_dependency_caches - run: name: Run the linter command: make lint @@ -64,11 +53,10 @@ jobs: path: build/test test: - <<: *default_job + executor: python311 steps: - checkout - - *restore_pip_cache - - *restore_venv_cache + - restore_dependency_caches - run: name: Run unit and golden fixture tests command: make test-only @@ -82,7 +70,6 @@ jobs: command: bash <(curl -s https://codecov.io/bash) -t ${CODECOV_TOKEN} -f build/coverage/coverage.xml || echo "Codecov did not collect coverage reports" workflows: - version: 2 test: jobs: - prepare_cache diff --git a/Makefile b/Makefile index c62eb92..7069f00 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,7 @@ # Package development and verification tasks (UNIX only). +PYTHON_VERSION=3.11.13 +BOOTSTRAP_PYTHON?=python3.11 PYTHON=venv/bin/python PIP=venv/bin/pip COVERAGE=venv/bin/coverage @@ -27,7 +29,7 @@ build-dir: mkdir -p build/test build/coverage env: - test -d venv || python3.11 -m venv venv + test -d venv || $(BOOTSTRAP_PYTHON) -m venv venv $(PIP) install -U pip "setuptools>=82.0.1" "wheel>=0.47.0" $(PIP) install -e . @@ -40,12 +42,13 @@ update: install: env ci-env: - @if [ -d "venv" ] && $(PIP) --version >/dev/null 2>&1; then \ + @if [ -d "venv" ] && $(PIP) --version >/dev/null 2>&1 \ + && $(PYTHON) -c 'import platform, sys; sys.exit(platform.python_version() != "$(PYTHON_VERSION)")'; then \ echo "Reusing cached CI venv, no need to recreate when it has not changed"; \ else \ echo "No valid cached venv found, creating a fresh venv"; \ if [ -d "venv" ]; then rm -rf venv; fi; \ - python3.11 -m venv venv; \ + $(BOOTSTRAP_PYTHON) -m venv venv; \ $(PIP) install -U pip "setuptools>=82.0.1" "wheel>=0.47.0"; \ fi From abfd52c1238a1f5b3c575311f1581ae3b075089c Mon Sep 17 00:00:00 2001 From: Michael Olmos Date: Tue, 21 Jul 2026 14:41:54 -0500 Subject: [PATCH 06/10] chore(item 2): remove redundant dependency tree tooling --- Makefile | 2 -- pyproject.toml | 1 - requirements-dev.txt | 1 - 3 files changed, 4 deletions(-) diff --git a/Makefile b/Makefile index 7069f00..f1e178b 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,6 @@ PIP=venv/bin/pip COVERAGE=venv/bin/coverage PYTEST=venv/bin/pytest FLAKE=venv/bin/flake8 -PIPDEPTREE=venv/bin/pipdeptree PYUPGRADE=venv/bin/pyupgrade RG=rg @@ -87,7 +86,6 @@ smoke: fixture-smoke import-smoke depcheck: $(PIP) check - $(PIPDEPTREE) --warn fail pyupgrade: $(PYUPGRADE) --py311-plus --keep-percent-format $(PYTHON_FILES) diff --git a/pyproject.toml b/pyproject.toml index 6e6d516..17a2561 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,6 @@ dev = [ "coverage==7.6.1", "flake8==7.1.1", "flake8-pyproject==1.2.4", - "pipdeptree==2.35.2", "pytest==9.1.1", "pyupgrade==3.21.2", "twine==6.2.0", diff --git a/requirements-dev.txt b/requirements-dev.txt index 7ed568f..bb68470 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,7 +3,6 @@ build==1.5.0 coverage==7.6.1 flake8==7.1.1 flake8-pyproject==1.2.4 -pipdeptree==2.35.2 pytest==9.1.1 pyupgrade==3.21.2 twine==6.2.0 From 7207600f5357ae37b3fa31ccffed91f57d973a71 Mon Sep 17 00:00:00 2001 From: Michael Olmos Date: Tue, 21 Jul 2026 14:42:18 -0500 Subject: [PATCH 07/10] chore(item 3): bump package version to 1.1.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 17a2561..5c7d1b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sdiff" -version = "1.0.0" +version = "1.1.0" description = "Compare Markdown documents by structure while ignoring translated content" readme = "README.md" requires-python = ">=3.11,<3.12" From 5b6bb3dd95d857722c4564370c10f03f1f41865b Mon Sep 17 00:00:00 2001 From: Michael Olmos Date: Tue, 21 Jul 2026 14:51:38 -0500 Subject: [PATCH 08/10] build(item 4): generate requirements with pip-compile --- Makefile | 12 ++++++--- README.md | 4 +++ pyproject.toml | 1 + requirements-dev.txt | 60 ++++++++++++++++++++++++++++++++++++++------ requirements.txt | 8 +++++- 5 files changed, 73 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index f1e178b..e5b2fba 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,7 @@ PIP=venv/bin/pip COVERAGE=venv/bin/coverage PYTEST=venv/bin/pytest FLAKE=venv/bin/flake8 +PIP_COMPILE=venv/bin/pip-compile PYUPGRADE=venv/bin/pyupgrade RG=rg @@ -29,7 +30,7 @@ build-dir: env: test -d venv || $(BOOTSTRAP_PYTHON) -m venv venv - $(PIP) install -U pip "setuptools>=82.0.1" "wheel>=0.47.0" + $(PIP) install -U "pip<26" "setuptools>=82.0.1" "wheel>=0.47.0" $(PIP) install -e . dev: env @@ -48,7 +49,7 @@ ci-env: echo "No valid cached venv found, creating a fresh venv"; \ if [ -d "venv" ]; then rm -rf venv; fi; \ $(BOOTSTRAP_PYTHON) -m venv venv; \ - $(PIP) install -U pip "setuptools>=82.0.1" "wheel>=0.47.0"; \ + $(PIP) install -U "pip<26" "setuptools>=82.0.1" "wheel>=0.47.0"; \ fi ci-dev-install: ci-env @@ -87,6 +88,10 @@ smoke: fixture-smoke import-smoke depcheck: $(PIP) check +requirements: dev + $(PIP_COMPILE) --annotation-style=line --output-file=requirements.txt pyproject.toml + $(PIP_COMPILE) --annotation-style=line --output-file=requirements-dev.txt --extra=dev pyproject.toml + pyupgrade: $(PYUPGRADE) --py311-plus --keep-percent-format $(PYTHON_FILES) @@ -117,4 +122,5 @@ clean: rm -rf build coverage dist sdiff.egg-info venv .PHONY: build-dir check-msgpack ci-dev-install ci-env clean cov cover coverage depcheck dev env fixture-smoke \ - flake hooks import-smoke install lint package publish pyupgrade smoke test test-only unhooks update vtest vtests + flake hooks import-smoke install lint package publish pyupgrade requirements smoke test test-only unhooks update \ + vtest vtests diff --git a/README.md b/README.md index 3bffa58..b44bfdf 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,12 @@ make lint make test make fixture-smoke make depcheck +make requirements ``` +`make requirements` uses `pip-compile` to regenerate `requirements.txt` and `requirements-dev.txt` from +`pyproject.toml`. + Exact parser, renderer, and diff behavior is protected by `tests/fixtures/golden/python311_compatibility.json`. Migration scope and proof results are recorded in `docs/python311-migration-contract.md`. diff --git a/pyproject.toml b/pyproject.toml index 5c7d1b6..10fcd7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dev = [ "coverage==7.6.1", "flake8==7.1.1", "flake8-pyproject==1.2.4", + "pip-tools==7.5.3", "pytest==9.1.1", "pyupgrade==3.21.2", "twine==6.2.0", diff --git a/requirements-dev.txt b/requirements-dev.txt index bb68470..e004d47 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,8 +1,52 @@ --r requirements.txt -build==1.5.0 -coverage==7.6.1 -flake8==7.1.1 -flake8-pyproject==1.2.4 -pytest==9.1.1 -pyupgrade==3.21.2 -twine==6.2.0 +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --annotation-style=line --extra=dev --output-file=requirements-dev.txt pyproject.toml +# +backports-tarfile==1.2.0 # via jaraco-context +build==1.5.0 # via pip-tools, sdiff (pyproject.toml) +certifi==2026.6.17 # via requests +charset-normalizer==3.4.9 # via requests +click==8.4.2 # via pip-tools +coverage==7.6.1 # via sdiff (pyproject.toml) +docutils==0.23 # via readme-renderer +flake8==7.1.1 # via flake8-pyproject, sdiff (pyproject.toml) +flake8-pyproject==1.2.4 # via sdiff (pyproject.toml) +id==1.6.1 # via twine +idna==3.18 # via requests +importlib-metadata==9.0.0 # via keyring +iniconfig==2.3.0 # via pytest +jaraco-classes==3.4.0 # via keyring +jaraco-context==6.1.2 # via keyring +jaraco-functools==4.6.0 # via keyring +keyring==25.7.0 # via twine +markdown-it-py==4.2.0 # via rich +mccabe==0.7.0 # via flake8 +mdurl==0.1.2 # via markdown-it-py +mistune==0.8.4 # via sdiff (pyproject.toml) +more-itertools==11.1.0 # via jaraco-classes, jaraco-functools +nh3==0.3.6 # via readme-renderer +packaging==26.2 # via build, pytest, twine, wheel +pip-tools==7.5.3 # via sdiff (pyproject.toml) +pluggy==1.6.0 # via pytest +pycodestyle==2.12.1 # via flake8 +pyflakes==3.2.0 # via flake8 +pygments==2.20.0 # via pytest, readme-renderer, rich +pyproject-hooks==1.2.0 # via build, pip-tools +pytest==9.1.1 # via sdiff (pyproject.toml) +pyupgrade==3.21.2 # via sdiff (pyproject.toml) +readme-renderer==45.0 # via twine +requests==2.34.2 # via requests-toolbelt, twine +requests-toolbelt==1.0.0 # via twine +rfc3986==2.0.0 # via twine +rich==15.0.0 # via twine +tokenize-rt==6.2.0 # via pyupgrade +twine==6.2.0 # via sdiff (pyproject.toml) +urllib3==2.7.0 # via id, requests, twine +wheel==0.47.0 # via pip-tools +zipp==4.1.0 # via importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/requirements.txt b/requirements.txt index f0c8b7d..748a8a3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,7 @@ -mistune==0.8.4 +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --annotation-style=line --output-file=requirements.txt pyproject.toml +# +mistune==0.8.4 # via sdiff (pyproject.toml) From 3f7c7cc1fb0d563e17665b20810032bb7c576069 Mon Sep 17 00:00:00 2001 From: Michael Olmos Date: Tue, 21 Jul 2026 14:54:00 -0500 Subject: [PATCH 09/10] chore(item 6): remove completed pyupgrade tooling --- Makefile | 8 +------- pyproject.toml | 1 - requirements-dev.txt | 2 -- 3 files changed, 1 insertion(+), 10 deletions(-) diff --git a/Makefile b/Makefile index e5b2fba..c58d1aa 100644 --- a/Makefile +++ b/Makefile @@ -8,10 +8,8 @@ COVERAGE=venv/bin/coverage PYTEST=venv/bin/pytest FLAKE=venv/bin/flake8 PIP_COMPILE=venv/bin/pip-compile -PYUPGRADE=venv/bin/pyupgrade RG=rg -PYTHON_FILES=$(shell rg --files sdiff tests -g '*.py') PYTEST_SHARED_FLAGS=-s --durations=3 --durations-min=0.005 PYTEST_FLAGS=$(PYTEST_SHARED_FLAGS) CI_COVERAGE_REPORT= @@ -92,9 +90,6 @@ requirements: dev $(PIP_COMPILE) --annotation-style=line --output-file=requirements.txt pyproject.toml $(PIP_COMPILE) --annotation-style=line --output-file=requirements-dev.txt --extra=dev pyproject.toml -pyupgrade: - $(PYUPGRADE) --py311-plus --keep-percent-format $(PYTHON_FILES) - coverage: $(COVERAGE) report -m @@ -122,5 +117,4 @@ clean: rm -rf build coverage dist sdiff.egg-info venv .PHONY: build-dir check-msgpack ci-dev-install ci-env clean cov cover coverage depcheck dev env fixture-smoke \ - flake hooks import-smoke install lint package publish pyupgrade requirements smoke test test-only unhooks update \ - vtest vtests + flake hooks import-smoke install lint package publish requirements smoke test test-only unhooks update vtest vtests diff --git a/pyproject.toml b/pyproject.toml index 10fcd7c..5077249 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,6 @@ dev = [ "flake8-pyproject==1.2.4", "pip-tools==7.5.3", "pytest==9.1.1", - "pyupgrade==3.21.2", "twine==6.2.0", ] tests = [ diff --git a/requirements-dev.txt b/requirements-dev.txt index e004d47..912b286 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -35,13 +35,11 @@ pyflakes==3.2.0 # via flake8 pygments==2.20.0 # via pytest, readme-renderer, rich pyproject-hooks==1.2.0 # via build, pip-tools pytest==9.1.1 # via sdiff (pyproject.toml) -pyupgrade==3.21.2 # via sdiff (pyproject.toml) readme-renderer==45.0 # via twine requests==2.34.2 # via requests-toolbelt, twine requests-toolbelt==1.0.0 # via twine rfc3986==2.0.0 # via twine rich==15.0.0 # via twine -tokenize-rt==6.2.0 # via pyupgrade twine==6.2.0 # via sdiff (pyproject.toml) urllib3==2.7.0 # via id, requests, twine wheel==0.47.0 # via pip-tools From 7cac22038c90708296789197d8492d8aa16087be Mon Sep 17 00:00:00 2001 From: Michael Olmos Date: Tue, 21 Jul 2026 16:00:11 -0500 Subject: [PATCH 10/10] docs(item 8): record clean migration proof --- docs/python311-migration-contract.md | 243 +++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 docs/python311-migration-contract.md diff --git a/docs/python311-migration-contract.md b/docs/python311-migration-contract.md new file mode 100644 index 0000000..17a0904 --- /dev/null +++ b/docs/python311-migration-contract.md @@ -0,0 +1,243 @@ +# html-structure-diff Python 3.11 Migration Contract + +## Scope and Repo Shape + +`html-structure-diff` is a no-stack Python library, not a service. + +- Distribution: `sdiff` +- Import package: `sdiff` +- CLI surface: none +- Service/runtime surfaces: none; the audit found no PasteDeploy app factory, Gunicorn configuration, service INI, + health endpoint, worker, Docker runtime, backing-service dependency, or network client +- Migration lane: one lightweight `python311-upgrade` branch, not the six-PR service stack + +The `python311-service-upgrade-stack` skill is the convention source for the applicable packaging, dependency, +workflow, and verification work. Service-only tasks are explicitly excluded below. + +## Branch and Write Scope + +- Source checkout: `/Users/olmos/keepsafe/repos/html-structure-diff` +- Edit root: `/Users/olmos/keepsafe/repos/worktrees/html-structure-diff-python-upgrade` +- Base: clean `master` at `12e7782208e4b458c8c4242882fda2377d9cba6b` +- Branch: `python311-upgrade` +- First write scope: this contract and golden parser/renderer/diff compatibility proof +- Follow-on scope: packaging metadata, exact dependency pins, Makefile/CI workflow, and proof documentation + +## Public and Behavior-Sensitive Surfaces + +Public imports exercised by this migration: + +- `sdiff.diff` +- `sdiff.diff_struct` +- `sdiff.diff_links` +- `sdiff.MdParser` +- `sdiff.ZendeskHelpMdParser` +- `sdiff.renderer.TextRenderer` +- `sdiff.renderer.HtmlRenderer` +- model and error objects exposed through `sdiff.model` and diff results + +Behavior that must remain stable: + +- Mistune 0.8 Markdown tokenization and structural symbols +- translated documents with matching structure +- link structure independent of translated link text +- inline HTML escaping +- Zendesk ``, ``, and styled `` parsing/rendering +- insert/delete error ordering, node symbols, styles, and user-facing messages + +The existing Markdown fixture pairs under `tests/fixtures/same` and `tests/fixtures/different` remain the broad +classification proof. `tests/fixtures/golden/python311_compatibility.json` records exact parser, renderer, +structural-diff, and link-diff outputs for 12 compact Mistune 0.8.4 scenarios. The oracle covers block and inline +HTML, heading styles, lists, images and reference links, hard breaks and directional marks, link-count changes, +insert/delete symmetry, and nested Zendesk constructs. + +## Downstream Consumers + +Local source and requirements scans found: + +- `content-validator` directly imports `diff`, `renderer`, and `MdParser`. +- The migrated `content-validator` branch targets + `sdiff @ git+https://github.com/KeepSafe/html-structure-diff.git@1.0.0`. +- `email-service` and `translation-real-time-validaton` consume `sdiff` transitively through + `content-validator`; their legacy requirements still reference tag `0.4.1`. + +This library migration unblocks downstream requirement refresh and service proof. It does not claim those service +flows are safe until each downstream repo reruns its own integration proof with the migrated package. + +## Python and Packaging Target + +- Interpreter target: CPython 3.11, with `.python-version` normalized to `3.11.13` +- Packaging source of truth: `pyproject.toml` +- Package version: `1.1.0` +- Runtime dependencies: exact compatible pins +- Build/test/dev dependencies: exact pins in a `dev` optional dependency group +- Requirements artifacts: generated with `pip-compile` from `pyproject.toml` + +`master` already contains the prior Python 3.11 work and is tagged `1.0.0`. This compatibility-focused follow-up +therefore uses a minor release, `1.1.0`, rather than treating the already-established runtime baseline as another +major-version break. + +## Baseline Audit + +Before migration edits: + +- `.python-version` was `3.11`; local resolution in the worktree was Python 3.11.9. +- `setup.py` and `setup.cfg` were the packaging/config sources. +- Package metadata already declared `python_requires='>=3.11'` and version `1.0.0`. +- Runtime metadata allowed `mistune <= 1`; `requirements.txt` separately pinned `mistune==0.8.1`. +- The untouched dev install resolved `mistune==0.8.4`. +- The repo already used `pytest`, `coverage`, and Python 3.11 Travis. +- There was no CircleCI config on `master`, no pre-push hook, no explicit import smoke target, and no exact-output + golden fixture. +- Source history already included a pyupgrade-to-Python-3.11 pass; the final branch reran the complete ladder. + +Untouched baseline proof on 2026-07-16: + +| Command | Result | +| --- | --- | +| `make dev` | Pass; installed `sdiff==1.0.0` and `mistune==0.8.4`. | +| `make test` | Pass; 53 tests on Python 3.11.9. | +| `make coverage` | Pass; 96% total coverage. | +| `venv/bin/pip check` | Pass; no broken requirements. | +| public import smoke | Pass; `sdiff==1.0.0` and documented imports loaded. | +| `venv/bin/python -m compileall -q sdiff tests` | Pass. | + +## Task Mapping from python311-service-upgrade-stack + +| Skill task | Status | Repo mapping | +| --- | --- | --- | +| Task 1: pyproject, Python 3.11, dependency audit, pyupgrade | Applicable: complete | Replaced legacy packaging, normalized the interpreter target, hard-pinned Mistune, completed and reconfirmed every pyupgrade ladder stage, then removed the one-time tool. | +| Task 2a: formatting and Flake8 alignment | Applicable: complete | Moved Flake8 and coverage config into `pyproject.toml`, added `flake8-pyproject`, and retained the 120-character convention. | +| Task 2b: hooks, CI, Makefile, README | Applicable: complete | Normalized package-oriented Makefile targets, retained Python 3.11 Travis, added native CircleCI 2.1 package CI and a pre-push hook. Service launch targets are excluded. | +| Task 2c: mypy stabilization | Not applicable | The repo has no mypy baseline; introducing a typing program is not required to preserve this library's Python 3.11 behavior. | +| Task 3: msgpack, redis, asynctest, and nose compatibility | Not applicable | The repo uses pytest and has no msgpack, redis, aioredis, asynctest, or nose dependency/call site. | +| Task 4: asyncio/aiohttp modernization | Not applicable | No asyncio or aiohttp code exists. | +| Task 4c: async test harness modernization | Not applicable | No async tests or custom loop harness exists. | +| Task 5: Gunicorn and Docker local infrastructure | Not applicable | The audit proves library-only shape with no runtime service or local dependencies. | +| Task 6: deterministic requirements pipeline | Applicable: complete | `make requirements` uses `pip-tools==7.5.3` with `pip<26` to compile runtime and dev artifacts from `pyproject.toml`; architecture service lockfiles and Ansible artifacts do not apply. | + +## Service-Only Tasks Intentionally Skipped + +- Docker daemon preflight, Docker Compose dependencies, and runtime smoke +- Gunicorn configuration and PasteDeploy launcher wiring +- service INI, health endpoint, worker, or queue startup proof +- `ks-local-e2e` profile and production-egress guard +- local backing-service fixtures or paid-provider fakes +- `libks==1.0.0`, `LIBKS_VERSION`, and private-index handling +- architecture-specific service requirements and Ansible deployment artifacts +- service contract checker and migration gate, whose assertions require service-template files + +No external or paid service call is needed by this package. Dependency installation may use package indexes; all +behavior proof is local and fixture-backed. + +## Required Proof and Artifacts + +Required commands on the final branch: + +- `make clean` +- `make env` +- `make dev` +- `make ci-dev-install` +- `make requirements` +- the skill's full `--py36-plus` through `--py311-plus` pyupgrade ladder with `--keep-percent-format` +- `make lint` +- `make test` +- `make fixture-smoke` +- `CI=1 make test-only` +- public import/API smoke +- `make depcheck` +- `venv/bin/python -m compileall -q sdiff tests` +- `make package` +- built-wheel install and import smoke in an isolated CPython 3.11.13 venv +- `circleci config validate .circleci/config.yml` when the local CircleCI CLI can validate without a paid service +- `git diff --check` + +Expected evidence: + +- exact golden snapshot at `tests/fixtures/golden/python311_compatibility.json` +- golden compatibility test at `tests/test_golden_compatibility.py` +- coverage XML at `build/coverage/coverage.xml` in CI mode +- xUnit output at `build/test/results.xml` in CI mode +- local sdist and wheel under ignored `dist/` +- source distribution containing all Markdown and JSON compatibility fixtures +- command results in this contract and the umbrella ExecPlan + +## Dependency and Package Notes + +- Runtime metadata and `requirements.txt` now agree on `mistune==0.8.4`. The untouched editable-install baseline + already resolved 0.8.4 from the old `mistune <= 1` range, so this is a deterministic pin of proven behavior rather + than a broad parser upgrade. +- Mistune 3.3.3 is intentionally deferred to a separate, approval-gated commit. Its API/changelog audit and any + compatibility implementation will be performed against the committed golden oracle. +- Existing compatible hard pins are retained: `coverage==7.6.1` and `flake8==7.1.1`. +- The previously ranged `pytest >= 8` dependency is pinned to the proven environment's `pytest==9.1.1`. +- Added exact workflow pins: `build==1.5.0`, `flake8-pyproject==1.2.4`, `pip-tools==7.5.3`, and `twine==6.2.0`. +- `pipdeptree` was removed because `pip check` is the required integrity gate for this one-runtime-dependency + library. `pyupgrade` was removed after the official six-stage ladder was rerun with zero remaining changes. +- `make requirements` generated stable runtime/dev outputs on two consecutive runs. The compile environment uses + `pip==25.3` to satisfy the skill's `pip<26` compatibility guardrail. +- Removed unused legacy `nose` and `autopep8` entries from `requirements-dev.txt`; this repo uses pytest and does + not have an autopep8 target. +- The built wheel metadata declares `Requires-Python: >=3.11,<3.12` and `Requires-Dist: mistune==0.8.4`. +- `MANIFEST.in` narrowly includes Markdown and JSON fixture data in the sdist; wheel contents remain package-only. +- No direct or transitive `msgpack` or `libks` dependency is present. + +## Migration Result and Proof Log + +Date: 2026-07-21 + +Branch: `python311-upgrade` + +Ten reviewable commits, including this evidence record, were created on `python311-upgrade`; no pull request or push +was created. + +Completed applicable work: + +- Replaced `setup.py` and `setup.cfg` with `pyproject.toml`. +- Normalized `.python-version` and package/CI policy to Python 3.11.13. +- Bumped the package from `1.0.0` to the compatible minor release `1.1.0` and preserved public behavior. +- Hard-pinned the runtime dependency and generated deterministic requirements artifacts with `pip-compile`. +- Moved Flake8 and coverage configuration into `pyproject.toml`. +- Added package-oriented `env`, `dev`, `lint`, `test-only`, `fixture-smoke`, `import-smoke`, `depcheck`, + `requirements`, package-build, CI-install, and hook targets. +- Added native CircleCI 2.1 package jobs with reusable executors/commands for dependency preparation, lint, tests, + xUnit, and coverage XML. +- Added exact golden parser/renderer/structural-diff/link-diff snapshots for 12 behavior-sensitive scenarios. +- Ran the full skill ladder. The `--py36-plus` stage converted one list-rendering format call and two parser regex + format calls to f-strings; Python 3.7 through 3.11 stages were no-ops. Golden outputs remained unchanged. +- Review found that the first sdist omitted fixture data; `MANIFEST.in` fixed the package artifact before handoff. +- Removed `pipdeptree` and the completed one-time `pyupgrade` tool from the final developer environment. + +Proof results: + +| Command | Result | Evidence | +| --- | --- | --- | +| `make clean`; `make env`; `make dev` | Pass | Fresh venv is CPython 3.11.13 with editable `sdiff==1.1.0`, `mistune==0.8.4`, and exact dev tooling. | +| `make ci-dev-install` | Pass | CI bootstrap installed/reused the exact-version venv and final `.[dev]` dependency shape. | +| full skill pyupgrade ladder over `sdiff` and `tests` | Pass | All six stages were reconfirmed live with zero remaining changes; the earlier Python 3.6 stage changes are isolated in commit `7c78ad3`. | +| `make requirements` twice | Pass | Both pip-compile outputs were byte-stable; compile tooling is `pip-tools==7.5.3` with `pip==25.3`. | +| `make test` | Pass | Flake8 and msgpack guard passed; 54 tests passed on Python 3.11.13. | +| `make coverage` | Pass | Total branch-aware coverage remained 96%. | +| `make fixture-smoke` | Pass | 3 test methods and 12 fixture subtests passed, including the exact golden snapshot. | +| `make import-smoke` | Pass | Imported documented public API and printed `1.1.0 MdParser ZendeskHelpMdParser TextRenderer`. | +| `make depcheck` | Pass | `pip check` found no broken requirements. | +| `CI=1 make test-only` | Pass | 54 tests passed and wrote `build/test/results.xml` plus `build/coverage/coverage.xml`. | +| `venv/bin/python -m compileall -q sdiff tests` | Pass | Source and tests compiled on Python 3.11.13. | +| `make package` | Pass | Built isolated `sdiff-1.1.0.tar.gz` and `sdiff-1.1.0-py3-none-any.whl`. | +| `venv/bin/twine check dist/*` | Pass | Both distribution artifacts passed metadata/README validation. | +| sdist fixture listing | Pass | Archive contains all same/different Markdown fixtures and `python311_compatibility.json`. | +| isolated wheel install/import/diff smoke | Pass | A separate CPython 3.11.13 venv installed the wheel with `mistune==0.8.4` and ran `sdiff.diff()`. | +| CircleCI validate, `--next`, and config process | Pass | CircleCI accepted the native source `version: 2.1`, strict upcoming-compiler validation, and reusable-config expansion. | +| `make hooks`, installed-file comparison, `make unhooks` | Pass | Executable pre-push hook installed exactly and was removed after verification. | +| `git diff --check` | Pass | No whitespace errors. | + +An additional pre-final run passed all 54 tests on Python 3.11.9 before the exact 3.11.13 environment was selected. + +## Known Gaps + +- Remote CI is not exercised by default local proof. +- Downstream `content-validator`, `email-service`, and `translation-real-time-validaton` still need their own + requirements refresh and integration proof after consuming the merged package state. +- Mistune 3.3.3 research and implementation have not started; they require explicit user approval and a separate + commit after this checkpoint. +- No package release, tag, push, or pull request is part of this local checkpoint.