justhtml 3.0.0__tar.gz → 3.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. {justhtml-3.0.0 → justhtml-3.2.0}/.github/workflows/ci.yml +9 -8
  2. {justhtml-3.0.0 → justhtml-3.2.0}/CHANGELOG.md +30 -0
  3. {justhtml-3.0.0 → justhtml-3.2.0}/CONTRIBUTING.md +3 -3
  4. {justhtml-3.0.0 → justhtml-3.2.0}/PKG-INFO +4 -4
  5. {justhtml-3.0.0 → justhtml-3.2.0}/README.md +3 -3
  6. {justhtml-3.0.0 → justhtml-3.2.0}/benchmarks/profile.py +34 -4
  7. {justhtml-3.0.0 → justhtml-3.2.0}/docs/api.md +9 -0
  8. {justhtml-3.0.0 → justhtml-3.2.0}/docs/building.md +27 -0
  9. {justhtml-3.0.0 → justhtml-3.2.0}/docs/correctness.md +17 -14
  10. {justhtml-3.0.0 → justhtml-3.2.0}/docs/playground/app.js +1 -0
  11. {justhtml-3.0.0 → justhtml-3.2.0}/docs/transforms.md +8 -3
  12. {justhtml-3.0.0 → justhtml-3.2.0}/docs/url-cleaning.md +5 -2
  13. {justhtml-3.0.0 → justhtml-3.2.0}/pyproject.toml +1 -1
  14. {justhtml-3.0.0 → justhtml-3.2.0}/run_tests.py +13 -7
  15. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/__init__.py +13 -1
  16. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/core/constants.py +51 -0
  17. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/core/entities.py +10 -1
  18. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/core/rawtext.py +2 -1
  19. justhtml-3.2.0/src/justhtml/core/text.py +30 -0
  20. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/dom/__init__.py +20 -1
  21. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/parser/encoding.py +6 -5
  22. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/parser/engine.py +207 -20
  23. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/parser/stream.py +5 -1
  24. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/selector/core.py +10 -7
  25. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/serializer/html.py +57 -23
  26. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/transforms/__init__.py +2 -0
  27. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/transforms/compile.py +7 -3
  28. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/transforms/runtime.py +38 -3
  29. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/transforms/spec.py +9 -2
  30. {justhtml-3.0.0 → justhtml-3.2.0}/test-summary.txt +14 -13
  31. justhtml-3.2.0/tests/README.md +44 -0
  32. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_encoding.py +19 -13
  33. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_entities.py +11 -0
  34. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_errors.py +19 -0
  35. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_node.py +19 -0
  36. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_parser_engine_coverage.py +66 -5
  37. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_parser_regressions.py +42 -0
  38. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_parser_tree.py +2 -4
  39. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_sanitize.py +30 -0
  40. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_serialize.py +44 -1
  41. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_stream.py +48 -0
  42. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_transforms.py +85 -0
  43. justhtml-3.0.0/tests/README.md +0 -40
  44. {justhtml-3.0.0 → justhtml-3.2.0}/.github/copilot-instructions.md +0 -0
  45. {justhtml-3.0.0 → justhtml-3.2.0}/.github/workflows/publish.yml +0 -0
  46. {justhtml-3.0.0 → justhtml-3.2.0}/.gitignore +0 -0
  47. {justhtml-3.0.0 → justhtml-3.2.0}/.pre-commit-config.yaml +0 -0
  48. {justhtml-3.0.0 → justhtml-3.2.0}/CODE_OF_CONDUCT.md +0 -0
  49. {justhtml-3.0.0 → justhtml-3.2.0}/LICENSE +0 -0
  50. {justhtml-3.0.0 → justhtml-3.2.0}/Makefile +0 -0
  51. {justhtml-3.0.0 → justhtml-3.2.0}/SECURITY.md +0 -0
  52. {justhtml-3.0.0 → justhtml-3.2.0}/assets/justhtml-readme-explainer.png +0 -0
  53. {justhtml-3.0.0 → justhtml-3.2.0}/benchmarks/chromium_parser_diff.py +0 -0
  54. {justhtml-3.0.0 → justhtml-3.2.0}/benchmarks/correctness.py +0 -0
  55. {justhtml-3.0.0 → justhtml-3.2.0}/benchmarks/fuzz.py +0 -0
  56. {justhtml-3.0.0 → justhtml-3.2.0}/benchmarks/html5lib_engine_diff.py +0 -0
  57. {justhtml-3.0.0 → justhtml-3.2.0}/benchmarks/parser_engine_gate.py +0 -0
  58. {justhtml-3.0.0 → justhtml-3.2.0}/benchmarks/performance.py +0 -0
  59. {justhtml-3.0.0 → justhtml-3.2.0}/docs/_config.yml +0 -0
  60. {justhtml-3.0.0 → justhtml-3.2.0}/docs/_layouts/default.html +0 -0
  61. {justhtml-3.0.0 → justhtml-3.2.0}/docs/assets/search.js +0 -0
  62. {justhtml-3.0.0 → justhtml-3.2.0}/docs/bleach-migration.md +0 -0
  63. {justhtml-3.0.0 → justhtml-3.2.0}/docs/cli.md +0 -0
  64. {justhtml-3.0.0 → justhtml-3.2.0}/docs/comparison.md +0 -0
  65. {justhtml-3.0.0 → justhtml-3.2.0}/docs/encoding.md +0 -0
  66. {justhtml-3.0.0 → justhtml-3.2.0}/docs/errors.md +0 -0
  67. {justhtml-3.0.0 → justhtml-3.2.0}/docs/fragments.md +0 -0
  68. {justhtml-3.0.0 → justhtml-3.2.0}/docs/html-cleaning.md +0 -0
  69. {justhtml-3.0.0 → justhtml-3.2.0}/docs/index.md +0 -0
  70. {justhtml-3.0.0 → justhtml-3.2.0}/docs/linkify.md +0 -0
  71. {justhtml-3.0.0 → justhtml-3.2.0}/docs/migration-examples.md +0 -0
  72. {justhtml-3.0.0 → justhtml-3.2.0}/docs/parser-engine-findings.md +0 -0
  73. {justhtml-3.0.0 → justhtml-3.2.0}/docs/playground/index.html +0 -0
  74. {justhtml-3.0.0 → justhtml-3.2.0}/docs/playground/py/__init__.py +0 -0
  75. {justhtml-3.0.0 → justhtml-3.2.0}/docs/playground/py/install_latest_justhtml.py +0 -0
  76. {justhtml-3.0.0 → justhtml-3.2.0}/docs/playground/py/render.py +0 -0
  77. {justhtml-3.0.0 → justhtml-3.2.0}/docs/playground/py/use_local_repo.py +0 -0
  78. {justhtml-3.0.0 → justhtml-3.2.0}/docs/quickstart.md +0 -0
  79. {justhtml-3.0.0 → justhtml-3.2.0}/docs/sanitization.md +0 -0
  80. {justhtml-3.0.0 → justhtml-3.2.0}/docs/selectors.md +0 -0
  81. {justhtml-3.0.0 → justhtml-3.2.0}/docs/streaming.md +0 -0
  82. {justhtml-3.0.0 → justhtml-3.2.0}/docs/text.md +0 -0
  83. {justhtml-3.0.0 → justhtml-3.2.0}/docs/unsafe-handling.md +0 -0
  84. {justhtml-3.0.0 → justhtml-3.2.0}/llms.txt +0 -0
  85. {justhtml-3.0.0 → justhtml-3.2.0}/scripts/release.py +0 -0
  86. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/__main__.py +0 -0
  87. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/core/__init__.py +0 -0
  88. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/core/doctype.py +0 -0
  89. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/core/errors.py +0 -0
  90. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/core/types.py +0 -0
  91. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/dom/builder.py +0 -0
  92. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/parser/__init__.py +0 -0
  93. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/parser/context.py +0 -0
  94. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/parser/options.py +0 -0
  95. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/parser/scanner.py +0 -0
  96. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/py.typed +0 -0
  97. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/sanitizer/__init__.py +0 -0
  98. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/sanitizer/css.py +0 -0
  99. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/sanitizer/dom.py +0 -0
  100. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/sanitizer/policy.py +0 -0
  101. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/sanitizer/policy_defaults.py +0 -0
  102. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/sanitizer/rawtext.py +0 -0
  103. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/sanitizer/url/__init__.py +0 -0
  104. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/sanitizer/url/policy.py +0 -0
  105. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/sanitizer/url/runtime.py +0 -0
  106. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/sanitizer/url/spec.py +0 -0
  107. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/selector/__init__.py +0 -0
  108. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/serializer/__init__.py +0 -0
  109. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/serializer/markdown.py +0 -0
  110. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/transforms/linkify.py +0 -0
  111. {justhtml-3.0.0 → justhtml-3.2.0}/src/justhtml/transforms/linkify_core.py +0 -0
  112. {justhtml-3.0.0 → justhtml-3.2.0}/tests/__init__.py +0 -0
  113. {justhtml-3.0.0 → justhtml-3.2.0}/tests/data/wikipedia.html +0 -0
  114. {justhtml-3.0.0 → justhtml-3.2.0}/tests/harness/__init__.py +0 -0
  115. {justhtml-3.0.0 → justhtml-3.2.0}/tests/harness/encoding.py +0 -0
  116. {justhtml-3.0.0 → justhtml-3.2.0}/tests/harness/regressions.py +0 -0
  117. {justhtml-3.0.0 → justhtml-3.2.0}/tests/harness/reporter.py +0 -0
  118. {justhtml-3.0.0 → justhtml-3.2.0}/tests/harness/serializer.py +0 -0
  119. {justhtml-3.0.0 → justhtml-3.2.0}/tests/harness/tree.py +0 -0
  120. {justhtml-3.0.0 → justhtml-3.2.0}/tests/justhtml-sanitize-tests/cases.json +0 -0
  121. {justhtml-3.0.0 → justhtml-3.2.0}/tests/justhtml-tests/branch_coverage.dat +0 -0
  122. {justhtml-3.0.0 → justhtml-3.2.0}/tests/justhtml-tests/coverage_gaps.test +0 -0
  123. {justhtml-3.0.0 → justhtml-3.2.0}/tests/justhtml-tests/empty_stack_edge_cases.dat +0 -0
  124. {justhtml-3.0.0 → justhtml-3.2.0}/tests/justhtml-tests/entities.test +0 -0
  125. {justhtml-3.0.0 → justhtml-3.2.0}/tests/justhtml-tests/iframe_srcdoc.dat +0 -0
  126. {justhtml-3.0.0 → justhtml-3.2.0}/tests/justhtml-tests/tokenizer_edge_cases.test +0 -0
  127. {justhtml-3.0.0 → justhtml-3.2.0}/tests/justhtml-tests/treebuilder_coverage.dat +0 -0
  128. {justhtml-3.0.0 → justhtml-3.2.0}/tests/justhtml-tests/xml_coercion.dat +0 -0
  129. {justhtml-3.0.0 → justhtml-3.2.0}/tests/justhtml-tests/xml_coercion_coverage.test +0 -0
  130. {justhtml-3.0.0 → justhtml-3.2.0}/tests/linkify-it/LICENSE.txt +0 -0
  131. {justhtml-3.0.0 → justhtml-3.2.0}/tests/linkify-it/README.md +0 -0
  132. {justhtml-3.0.0 → justhtml-3.2.0}/tests/linkify-it/fixtures/links.txt +0 -0
  133. {justhtml-3.0.0 → justhtml-3.2.0}/tests/linkify-it/fixtures/not_links.txt +0 -0
  134. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_builder.py +0 -0
  135. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_cli.py +0 -0
  136. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_docs_examples.py +0 -0
  137. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_linkify_internals.py +0 -0
  138. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_linkify_it.py +0 -0
  139. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_linkify_transform.py +0 -0
  140. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_playground_local_repo_file_list.py +0 -0
  141. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_sanitize_integration.py +0 -0
  142. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_sanitize_transform.py +0 -0
  143. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_selector.py +0 -0
  144. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_transforms_compiler.py +0 -0
  145. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_transforms_edge_cases.py +0 -0
  146. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_transforms_sanitize_integration.py +0 -0
  147. {justhtml-3.0.0 → justhtml-3.2.0}/tests/test_wikipedia.py +0 -0
@@ -36,20 +36,21 @@ jobs:
36
36
  - name: Checkout html5lib-tests
37
37
  run: |
38
38
  cd ..
39
- git clone https://github.com/html5lib/html5lib-tests.git
39
+ git clone --no-checkout https://github.com/html5lib/html5lib-tests.git
40
+ git -C html5lib-tests fetch origin 9fb614afaa42ce8787840f057b32084308e76549
41
+ git -C html5lib-tests checkout 9fb614afaa42ce8787840f057b32084308e76549
40
42
  git -C html5lib-tests rev-parse HEAD
41
43
 
42
- - name: Setup test symlinks
44
+ - name: Setup test fixtures
43
45
  run: |
44
- cd tests
45
- ln -s ../../html5lib-tests/tokenizer html5lib-tests-tokenizer
46
- ln -s ../../html5lib-tests/tree-construction html5lib-tests-tree
47
- ln -s ../../html5lib-tests/serializer html5lib-tests-serializer
48
- ln -s ../../html5lib-tests/encoding html5lib-tests-encoding
46
+ cp -a ../html5lib-tests/tokenizer tests/html5lib-tests-tokenizer
47
+ cp -a ../html5lib-tests/tree-construction tests/html5lib-tests-tree
48
+ cp -a ../html5lib-tests/serializer tests/html5lib-tests-serializer
49
+ cp -a ../html5lib-tests/encoding tests/html5lib-tests-encoding
49
50
 
50
51
  - name: Run pre-commit
51
52
  id: precommit
52
- uses: pre-commit/action@v3.0.1
53
+ run: pre-commit run --show-diff-on-failure --color=always --all-files
53
54
  env:
54
55
  SKIP: mypy
55
56
 
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.2.0] - 2026-07-04
11
+
12
+ ### Changed
13
+ - Use the web platform html5 treebuilder tests from WPT for tree-construction compliance fixtures.
14
+
15
+ ### Fixed
16
+ - Preserve HTML processing-instruction nodes in unsanitized parsing while keeping default-safe sanitization comment-like and conservative, and neutralize `>` in serialized processing-instruction data so a hand-built DOM or a policy that preserves comment-like nodes cannot serialize a processing instruction that a browser parses as a bogus comment terminating early and exposing following markup.
17
+ - Keep raw-text, RCDATA, doctype, and foreign-content CDATA scans aligned after Unicode characters whose Python lowercase mapping changes length or becomes ASCII. Previously, content such as `İ` before a later `<script>` close tag could make the parser miss the real end tag and treat following markup as raw text.
18
+
19
+ ### Performance
20
+ - Short-circuit open-elements-stack scope checks (`<p>`, `<li>`, `<dd>`/`<dt>`, table-cell scoping, and similar) when the target element isn't open anywhere on the stack, avoiding quadratic parse time for deeply nested common block elements such as `<div>`, `<table>`, `<section>`, and heading tags.
21
+
22
+ ### Security
23
+ - (Severity: Low) Reject numeric character references with more digits than any valid Unicode code point could have before converting them to an integer. Previously, a decimal reference such as `&#` followed by thousands of digits could crash parsing with an uncaught `ValueError` before sanitization, since Python limits decimal string-to-integer conversion length.
24
+ - (Severity: Moderate) Prioritize a byte-order mark over a caller-declared transport encoding when decoding byte input, matching the HTML encoding-sniffing algorithm. Previously, passing an explicit `encoding=` alongside untrusted bytes that began with a BOM for a different encoding could make JustHTML decode and sanitize content differently than a browser does, letting markup such as `<script>` pass through undetected while a browser executes it under the BOM-indicated encoding.
25
+
26
+ ## [3.1.0] - 2026-07-02
27
+
28
+ ### Changed
29
+ - `CollapseWhitespace(...)` now trims leading and trailing whitespace at block-container edges by default. Pass `trim_blocks=False` to keep the previous html5lib-like behavior, or customize `block_tags=(...)` for project-specific block containers.
30
+ - Document that DOM insertion methods adopt existing nodes, and show how to clone nodes before appending when using parsed DOMs as templates.
31
+
32
+ ### Fixed
33
+ - Apply explicit `UrlPolicy.allow_rules` to custom URL attributes in constructor sanitization and `DropUrlAttrs(...)`. Previously, rules such as `("el-custom", "data-url")` were ignored unless the attribute name was one of JustHTML's built-in URL sinks.
34
+ - Restore strict-mode reporting for unexpected `</p>` end tags after implicit paragraph closure.
35
+
36
+ ### Performance
37
+ - Speed up pretty HTML serialization for direct text-only elements, repeated tag/attribute names, and common empty-attribute elements; reduce repeated selector name normalization and hot parser namespace checks; and fix the real-world profiler script when run directly.
38
+ - Speed up parser URL sanitization for common lowercase `http://` and `https://` attributes.
39
+
10
40
  ## [3.0.0] - 2026-06-21
11
41
 
12
42
  JustHTML 3.0.0 ships a new parser core. The old tokenizer and tree-builder are now one integrated system, which makes the parser much faster while keeping browser-style HTML5 recovery and default-safe behavior intact. In practice, this release is mainly about getting materially better performance without asking most users to change how they use the library.
@@ -24,9 +24,9 @@ Thanks for considering contributing to JustHTML! This document explains how to s
24
24
 
25
25
  ## Running Tests
26
26
 
27
- The test suite uses the html5lib test cases plus additional tests for selector functionality.
27
+ The test suite uses the web platform html5 treebuilder tests plus additional tests for selector functionality.
28
28
 
29
- If you want to run the full html5lib test suite locally, clone `html5lib-tests` next to this repository and create the symlinks described in [tests/README.md](tests/README.md) (tokenizer, tree-construction, and serializer).
29
+ If you want to run the full external fixture suite locally, clone the web platform html5 treebuilder tests and `html5lib-tests` serializer/encoding fixtures next to this repository, then create the symlinks described in [tests/README.md](tests/README.md).
30
30
 
31
31
  ```bash
32
32
  # Run all tests
@@ -58,7 +58,7 @@ Pre-commit runs automatically on every commit and checks:
58
58
  - **Ruff check** - linting with auto-fix
59
59
  - **Ruff format** - code formatting
60
60
  - **Tests & Coverage** - full test suite with 100% coverage requirement
61
- - **Parser Differential** - exact agreement with the reference parser path across scored html5lib tree cases
61
+ - **Parser Differential** - exact agreement with the reference parser path across scored web platform html5 treebuilder cases
62
62
 
63
63
  Run manually:
64
64
  ```bash
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: justhtml
3
- Version: 3.0.0
3
+ Version: 3.2.0
4
4
  Summary: A pure Python HTML5 parser that just works.
5
5
  Project-URL: Homepage, https://github.com/emilstenstrom/justhtml
6
6
  Project-URL: Issues, https://github.com/emilstenstrom/justhtml/issues
@@ -82,7 +82,7 @@ Most Python HTML libraries optimize for one part of the problem.
82
82
 
83
83
  JustHTML is for applications that want a boring, inspectable, pure-Python default:
84
84
 
85
- - **Correct parsing:** browser-style HTML5 recovery, tested against the official html5lib fixtures.
85
+ - **Correct parsing:** browser-style HTML5 recovery, tested against the web platform html5 treebuilder tests.
86
86
  - **Safe by default:** `JustHTML(html)` sanitizes before you query or serialize.
87
87
  - **One DOM:** parse once, then sanitize, query, transform, serialize, extract text, or produce Markdown.
88
88
  - **Easy deployment:** zero runtime dependencies, no compiler, works on PyPy and Pyodide.
@@ -164,10 +164,10 @@ justhtml index.html --selector "article" --format markdown
164
164
 
165
165
  ## Correctness
166
166
 
167
- JustHTML is tested against the official html5lib tree-construction, serializer, and encoding fixtures, plus project-specific sanitizer, selector, transform, CLI, and regression tests.
167
+ JustHTML is tested against the web platform html5 treebuilder tests, plus html5lib serializer/encoding fixtures and project-specific sanitizer, selector, transform, CLI, and regression tests.
168
168
 
169
169
  The current test suite enforces 100% combined line and branch coverage, including the parser engine.
170
- The parser engine additionally requires exact agreement with the reference path across the html5lib tree suite.
170
+ The parser engine additionally requires exact agreement with the reference path across the web platform html5 treebuilder suite.
171
171
  See [Correctness Testing](docs/correctness.md) for details.
172
172
 
173
173
  ## Documentation
@@ -28,7 +28,7 @@ Most Python HTML libraries optimize for one part of the problem.
28
28
 
29
29
  JustHTML is for applications that want a boring, inspectable, pure-Python default:
30
30
 
31
- - **Correct parsing:** browser-style HTML5 recovery, tested against the official html5lib fixtures.
31
+ - **Correct parsing:** browser-style HTML5 recovery, tested against the web platform html5 treebuilder tests.
32
32
  - **Safe by default:** `JustHTML(html)` sanitizes before you query or serialize.
33
33
  - **One DOM:** parse once, then sanitize, query, transform, serialize, extract text, or produce Markdown.
34
34
  - **Easy deployment:** zero runtime dependencies, no compiler, works on PyPy and Pyodide.
@@ -110,10 +110,10 @@ justhtml index.html --selector "article" --format markdown
110
110
 
111
111
  ## Correctness
112
112
 
113
- JustHTML is tested against the official html5lib tree-construction, serializer, and encoding fixtures, plus project-specific sanitizer, selector, transform, CLI, and regression tests.
113
+ JustHTML is tested against the web platform html5 treebuilder tests, plus html5lib serializer/encoding fixtures and project-specific sanitizer, selector, transform, CLI, and regression tests.
114
114
 
115
115
  The current test suite enforces 100% combined line and branch coverage, including the parser engine.
116
- The parser engine additionally requires exact agreement with the reference path across the html5lib tree suite.
116
+ The parser engine additionally requires exact agreement with the reference path across the web platform html5 treebuilder suite.
117
117
  See [Correctness Testing](docs/correctness.md) for details.
118
118
 
119
119
  ## Documentation
@@ -2,9 +2,10 @@
2
2
  """Profile JustHTML on real-world HTML."""
3
3
 
4
4
  import argparse
5
- import cProfile
5
+ import importlib
6
6
  import pathlib
7
7
  import pstats
8
+ import sys
8
9
  import tarfile
9
10
 
10
11
  import zstandard as zstd
@@ -49,15 +50,30 @@ def load_html_files(batch_path: pathlib.Path, dict_bytes: bytes, limit: int = 10
49
50
 
50
51
  def parse_args() -> argparse.Namespace:
51
52
  parser = argparse.ArgumentParser(description=__doc__)
53
+ parser.add_argument(
54
+ "--mode",
55
+ choices=["parse", "compact-html", "pretty-html", "text", "fragment"],
56
+ default="parse",
57
+ help="Pipeline to profile (default: parse)",
58
+ )
52
59
  parser.add_argument(
53
60
  "--to-html",
54
61
  action="store_true",
55
- help="Profile parse + serialization via to_html() (pretty=True by default)",
62
+ help="Deprecated alias for --mode pretty-html",
56
63
  )
57
64
  return parser.parse_args()
58
65
 
59
66
 
67
+ def remove_script_dir_from_import_path() -> None:
68
+ script_dir = str(pathlib.Path(__file__).resolve().parent)
69
+ if sys.path and sys.path[0] == script_dir:
70
+ sys.path.pop(0)
71
+
72
+
60
73
  def main() -> None:
74
+ remove_script_dir_from_import_path()
75
+ cprofile = importlib.import_module("cProfile")
76
+
61
77
  args = parse_args()
62
78
 
63
79
  dict_bytes = load_dict(pathlib.Path("/home/emilstenstrom/Projects/web100k/html.dict"))
@@ -69,12 +85,26 @@ def main() -> None:
69
85
 
70
86
  print(f"Loaded {len(html_files)} files")
71
87
 
72
- profiler = cProfile.Profile()
88
+ mode = "pretty-html" if args.to_html else args.mode
89
+
90
+ profiler = cprofile.Profile()
73
91
  profiler.enable()
74
92
 
75
93
  for _filename, html in html_files:
94
+ if mode == "fragment":
95
+ parser = JustHTML(html, fragment=True)
96
+ _ = parser.root
97
+ continue
98
+
76
99
  parser = JustHTML(html)
77
- _ = parser.to_html() if args.to_html else parser.root
100
+ if mode == "compact-html":
101
+ _ = parser.to_html(pretty=False)
102
+ elif mode == "pretty-html":
103
+ _ = parser.to_html()
104
+ elif mode == "text":
105
+ _ = parser.to_text()
106
+ else:
107
+ _ = parser.root
78
108
 
79
109
  profiler.disable()
80
110
 
@@ -408,10 +408,16 @@ When you build documents with `JustHTML(..., sanitize=True)` (the default), this
408
408
 
409
409
  Append a child node to this node.
410
410
 
411
+ If `node` already has a parent, it is moved from that parent to this node. Use
412
+ `node.clone_node(deep=True)` before appending when you want to keep the source
413
+ DOM unchanged.
414
+
411
415
  #### `insert_before(node, reference_node)`
412
416
 
413
417
  Insert `node` before `reference_node` (or append if `reference_node` is `None`).
414
418
 
419
+ If `node` already has a parent, it is moved from that parent to this node.
420
+
415
421
  #### `remove_child(node)`
416
422
 
417
423
  Remove a direct child node.
@@ -420,6 +426,9 @@ Remove a direct child node.
420
426
 
421
427
  Replace a direct child node with a new node.
422
428
 
429
+ If `new_node` already has a parent, it is moved from that parent before it
430
+ replaces `old_node`.
431
+
423
432
  #### `clone_node(deep=False, override_attrs=None)`
424
433
 
425
434
  Clone this node. If `deep=True`, children are cloned recursively.
@@ -435,6 +435,33 @@ lead = doc.query_one("p.lead")
435
435
  lead.to_html(pretty=False) # => <p class="lead">Hello</p>
436
436
  ```
437
437
 
438
+ ### Clone from template DOMs before appending
439
+
440
+ DOM insertion methods adopt existing nodes. If you append a node that already
441
+ belongs to another tree, it is removed from its old parent and moved into the new
442
+ tree.
443
+
444
+ When you use one parsed DOM as a template for another document, clone the source
445
+ node before appending it:
446
+
447
+ ```python
448
+ from justhtml import JustHTML
449
+ from justhtml.dom.builder import element
450
+
451
+ template = JustHTML('<section><p class="lead">Hello</p></section>', fragment=True, sanitize=False)
452
+ output = JustHTML(element("main"), fragment=True, sanitize=False)
453
+
454
+ lead = template.query_one("p.lead")
455
+ assert lead is not None
456
+ main = output.query_one("main")
457
+ assert main is not None
458
+
459
+ main.append_child(lead.clone_node(deep=True))
460
+
461
+ template.to_html(pretty=False) # => <section><p class="lead">Hello</p></section>
462
+ output.to_html(pretty=False) # => <main><p class="lead">Hello</p></main>
463
+ ```
464
+
438
465
  ## When Not to Use the Builder
439
466
 
440
467
  Don’t use the builder just because it exists.
@@ -2,18 +2,18 @@
2
2
 
3
3
  # Correctness Testing
4
4
 
5
- JustHTML is the only pure-Python HTML5 parser that passes 100% of the official html5lib test suite. This page explains how we verify and maintain that compliance.
5
+ JustHTML is tested against the web platform html5 treebuilder tests. This page explains how we verify and maintain that compliance.
6
6
 
7
- ## The html5lib Test Suite
7
+ ## The Web Platform Tests
8
8
 
9
- The [html5lib-tests](https://github.com/html5lib/html5lib-tests) repository is the gold standard for HTML5 parsing compliance. It's used by browser vendors to verify their implementations against the [WHATWG HTML5 specification](https://html.spec.whatwg.org/).
9
+ The web platform html5 treebuilder tests live in [web-platform-tests/wpt](https://github.com/web-platform-tests/wpt/tree/master/html/syntax/parsing/resources). Serializer and encoding fixtures remain in [html5lib-tests](https://github.com/html5lib/html5lib-tests).
10
10
 
11
- Our checked-in test inputs contain:
11
+ The external fixture inputs contain:
12
12
 
13
- - **56 tree-construction test files** - Testing how the parser builds the DOM tree
13
+ - **61 treebuilder test files** - Testing how the parser builds the DOM tree
14
14
  - **5 serializer fixture files** - Testing how token streams are serialized back to HTML
15
15
  - **Encoding sniffing tests** - Testing BOM/meta charset/transport overrides and legacy fallbacks
16
- - **1,791 enabled tree-construction cases** - Covering edge cases, error recovery, and spec compliance
16
+ - **1,914 enabled treebuilder cases** - Covering edge cases, error recovery, and spec compliance
17
17
 
18
18
  ### What the Tests Cover
19
19
 
@@ -54,12 +54,11 @@ This tests the adoption agency algorithm - when `</b>` is encountered inside `<p
54
54
  ## Compliance Comparison
55
55
 
56
56
  We run the same test suite against other Python parsers to compare compliance.
57
- The cross-parser snapshot below used the 1,743 cases available when it was
58
- recorded; the current JustHTML gate covers 1,791 enabled cases.
57
+ The cross-parser snapshot below used the 1,743 cases available when it was recorded.
59
58
 
60
59
  | Parser | Tests Passed | Compliance | Notes |
61
60
  |--------|-------------|------------|-------|
62
- | **JustHTML** | 1743/1743 | **100%** | Full spec compliance in this comparison snapshot; current gate: 1791/1791 |
61
+ | **JustHTML** | 1743/1743 | **100%** | Full spec compliance in this comparison snapshot |
63
62
  | selectolax | 1743/1743 | 100% | C-based (Lexbor), fast and spec-compliant with dev `html5test` output API |
64
63
  | markupever | 1545/1743 | 89% | Rust-based (html5ever), mostly correct |
65
64
  | html5lib | 1496/1743 | 86% | Reference implementation, but incomplete |
@@ -68,9 +67,9 @@ recorded; the current JustHTML gate covers 1,791 enabled cases.
68
67
  | html.parser | 6/1743 | <1% | Python stdlib, basic error recovery only |
69
68
  | lxml | 5/1743 | <1% | XML-based, not HTML5 compliant |
70
69
 
71
- *Run `python benchmarks/correctness.py` to reproduce these results. The selectolax score requires its dev `html5test` output and fragment-context APIs. These scores were refreshed against html5lib-tests `e446320`.*
70
+ *Run `python benchmarks/correctness.py` to reproduce these results. The selectolax score requires its dev `html5test` output and fragment-context APIs. These historical scores were refreshed against html5lib-tests `e446320`.*
72
71
 
73
- These numbers come from a strict tree comparison against the expected output in the `html5lib-tests` tree-construction fixtures (excluding `#script-on` / `#script-off` cases). Unsupported parser capabilities count as failures for this compliance table. The numbers will not match the `html5lib` project’s own reported totals, because `html5lib` runs the suite in multiple configurations and also has its own skip/xfail lists.
72
+ These numbers come from a strict tree comparison against the expected output in the treebuilder fixtures available at the time (excluding `#script-on` / `#script-off` cases). Unsupported parser capabilities count as failures for this compliance table. The numbers will not match the `html5lib` project’s own reported totals, because `html5lib` runs the suite in multiple configurations and also has its own skip/xfail lists.
74
73
 
75
74
  ## Our Testing Strategy
76
75
 
@@ -123,7 +122,7 @@ PYTHONPATH=src python benchmarks/html5lib_engine_diff.py \
123
122
  --fail-on-current-exceptions
124
123
  ```
125
124
 
126
- This requires exact agreement with the reference parser path across every scored html5lib tree-construction case.
125
+ This requires exact agreement with the reference parser path across every scored web platform html5 treebuilder case.
127
126
 
128
127
  ### 3. Fuzz Testing (millions of cases)
129
128
 
@@ -169,14 +168,18 @@ We maintain additional tests in `tests/justhtml-tests/` for:
169
168
  ### Quick Start
170
169
 
171
170
  ```bash
172
- # Clone the test suite (one-time setup)
171
+ # Clone the test suites (one-time setup)
172
+ cd ..
173
+ git clone --filter=blob:none --sparse https://github.com/web-platform-tests/wpt.git
174
+ cd wpt
175
+ git sparse-checkout set html/syntax/parsing/resources
173
176
  cd ..
174
177
  git clone https://github.com/html5lib/html5lib-tests.git
175
178
  cd justhtml
176
179
 
177
180
  # Create symlinks
178
181
  cd tests
179
- ln -s ../../html5lib-tests/tree-construction html5lib-tests-tree
182
+ ln -s ../../wpt/html/syntax/parsing/resources html5lib-tests-tree
180
183
  ln -s ../../html5lib-tests/serializer html5lib-tests-serializer
181
184
  ln -s ../../html5lib-tests/encoding html5lib-tests-encoding
182
185
  cd ..
@@ -65,6 +65,7 @@ async function installJusthtmlFromLocalRepo(pyodideInstance) {
65
65
  "parser/encoding.py",
66
66
  "core/entities.py",
67
67
  "core/errors.py",
68
+ "core/text.py",
68
69
  "core/types.py",
69
70
  "core/doctype.py",
70
71
  "serializer/markdown.py",
@@ -268,11 +268,16 @@ Advanced building blocks (useful for policy-driven pipelines):
268
268
 
269
269
  See [`Linkify(...)`](linkify.md) for full documentation and examples.
270
270
 
271
- ### `CollapseWhitespace(skip_tags=(...), enabled=True, callback=None, report=None)`
271
+ ### `CollapseWhitespace(skip_tags=(...), trim_blocks=True, block_tags=(...), enabled=True, callback=None, report=None)`
272
272
 
273
273
  Collapses runs of HTML whitespace characters in text nodes to a single space.
274
+ By default, it also trims leading and trailing whitespace at the edges of block
275
+ containers such as `<p>`, `<div>`, and `<li>`.
274
276
 
275
- This is similar to `html5lib.filters.whitespace.Filter`.
277
+ Pass `trim_blocks=False` for html5lib-like whitespace filtering without edge
278
+ trimming. Use `block_tags=(...)` to define which containers trim their edge
279
+ whitespace; `skip_tags` still wins and preserves whitespace inside matching
280
+ tags.
276
281
 
277
282
  By default it skips `<pre>`, `<textarea>`, `<code>`, `<title>`, `<script>`, and `<style>`.
278
283
 
@@ -280,7 +285,7 @@ By default it skips `<pre>`, `<textarea>`, `<code>`, `<title>`, `<script>`, and
280
285
  from justhtml import CollapseWhitespace, JustHTML
281
286
 
282
287
  doc = JustHTML(
283
- "<p>Hello \n\t world</p><pre>a b</pre>",
288
+ "<p>\n Hello \n\t world\n</p><pre>a b</pre>",
284
289
  fragment=True,
285
290
  transforms=[CollapseWhitespace()],
286
291
  )
@@ -85,7 +85,8 @@ Output:
85
85
 
86
86
  ## How URL cleaning works (in order)
87
87
 
88
- For a URL-like attribute (like `img[src]` or `a[href]`), JustHTML applies these steps:
88
+ For a URL-like attribute (like `img[src]` or `a[href]`) or a custom attribute with an explicit
89
+ `UrlPolicy(allow_rules=...)` entry, JustHTML applies these steps:
89
90
 
90
91
  1. The tag must be allowed by `SanitizationPolicy.allowed_tags`.
91
92
  2. The attribute name must be allowed by `SanitizationPolicy.allowed_attributes`.
@@ -103,7 +104,9 @@ URL behavior is controlled by `UrlPolicy`:
103
104
  - `default_handling`: the default post-validation action for URL-like attributes that match an allow rule and do not set `UrlRule.handling`.
104
105
  - `default_allow_relative`: whether **relative** URLs (like `/path`, `./path`, `../path`, `?q`) are allowed by default.
105
106
 
106
- For URL-like attributes that match an explicit `(tag, attr)` rule in `allow_rules`, validated URLs use `UrlPolicy.default_handling` unless that specific `UrlRule` overrides it with `handling=...`.
107
+ For URL-like attributes, or custom attributes that match an explicit `(tag, attr)` or `("*", attr)` rule in
108
+ `allow_rules`, validated URLs use `UrlPolicy.default_handling` unless that specific `UrlRule` overrides it with
109
+ `handling=...`.
107
110
 
108
111
  Note: URL validation is always enforced by `UrlRule`.
109
112
 
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "justhtml"
3
3
  authors = [{ name = "Emil Stenström", email = "emil@emilstenstrom.se" }]
4
- version = "3.0.0"
4
+ version = "3.2.0"
5
5
  description = "A pure Python HTML5 parser that just works."
6
6
  readme = "README.md"
7
7
  license = { file = "LICENSE" }
@@ -114,7 +114,7 @@ def parse_args():
114
114
  "--check-errors",
115
115
  action="store_true",
116
116
  help=(
117
- "Enable additional error validation. For html5lib tree-construction .dat tests, "
117
+ "Enable additional error validation. For web platform html5 treebuilder .dat tests, "
118
118
  "validates the number of parse errors; for tests/justhtml-tests, validates the exact "
119
119
  "ordered list of error codes."
120
120
  "parse errors (code+line+col) when provided by the fixture."
@@ -295,7 +295,7 @@ def main():
295
295
  run_encoding = suite in {"all", "encoding"}
296
296
  run_unit = suite in {"all", "unit"}
297
297
 
298
- # Check that html5lib-tests symlinks exist (only for the selected suites)
298
+ # Check that external fixture symlinks exist (only for the selected suites)
299
299
  tree_tests = test_dir / "html5lib-tests-tree"
300
300
  serializer_tests = test_dir / "html5lib-tests-serializer"
301
301
  encoding_tests = test_dir / "html5lib-tests-encoding"
@@ -307,13 +307,19 @@ def main():
307
307
  if run_encoding and not encoding_tests.exists():
308
308
  missing.append(str(encoding_tests))
309
309
  if len(missing) > 0:
310
- print("ERROR: html5lib-tests not found. Please create symlinks:", file=sys.stderr)
310
+ print("ERROR: external test fixtures not found. Please create symlinks:", file=sys.stderr)
311
311
  for path in missing:
312
312
  print(f" {path}", file=sys.stderr)
313
- print("\nTo set up, clone html5lib-tests and create symlinks:", file=sys.stderr)
314
- print(" git clone https://github.com/html5lib/html5lib-tests.git ../html5lib-tests", file=sys.stderr)
313
+ print("\nTo set up, clone the fixture repositories and create symlinks:", file=sys.stderr)
315
314
  if run_tree:
316
- print(" ln -s ../../html5lib-tests/tree-construction tests/html5lib-tests-tree", file=sys.stderr)
315
+ print(
316
+ " git clone --filter=blob:none --sparse https://github.com/web-platform-tests/wpt.git ../wpt",
317
+ file=sys.stderr,
318
+ )
319
+ print(" (cd ../wpt && git sparse-checkout set html/syntax/parsing/resources)", file=sys.stderr)
320
+ print(" ln -s ../../wpt/html/syntax/parsing/resources tests/html5lib-tests-tree", file=sys.stderr)
321
+ if run_serializer or run_encoding:
322
+ print(" git clone https://github.com/html5lib/html5lib-tests.git ../html5lib-tests", file=sys.stderr)
317
323
  if run_serializer:
318
324
  print(" ln -s ../../html5lib-tests/serializer tests/html5lib-tests-serializer", file=sys.stderr)
319
325
  if run_encoding:
@@ -343,7 +349,7 @@ def main():
343
349
  combined_results.update(runner.file_results)
344
350
 
345
351
  if run_justhtml_tree:
346
- # Run JustHTML-specific tree-construction tests (custom .dat fixtures).
352
+ # Run JustHTML-specific treebuilder tests (custom .dat fixtures).
347
353
  # These live outside the upstream html5lib-tests checkout.
348
354
  justhtml_tree_tests = test_dir / "justhtml-tests"
349
355
  justhtml_runner = TestRunner(justhtml_tree_tests, config)
@@ -1,5 +1,16 @@
1
1
  from .core.types import ParseError
2
- from .dom import Comment, Document, DocumentFragment, Element, Node, NodeType, QueryMatch, Template, Text
2
+ from .dom import (
3
+ Comment,
4
+ Document,
5
+ DocumentFragment,
6
+ Element,
7
+ Node,
8
+ NodeType,
9
+ ProcessingInstruction,
10
+ QueryMatch,
11
+ Template,
12
+ Text,
13
+ )
3
14
  from .parser import JustHTML, StrictModeError
4
15
  from .parser.stream import stream
5
16
  from .sanitizer import (
@@ -70,6 +81,7 @@ __all__ = [
70
81
  "Node",
71
82
  "NodeType",
72
83
  "ParseError",
84
+ "ProcessingInstruction",
73
85
  "PruneEmpty",
74
86
  "QueryMatch",
75
87
  "SanitizationPolicy",
@@ -200,6 +200,57 @@ WHITESPACE_PRESERVING_ELEMENTS: Final[frozenset[str]] = frozenset(
200
200
  }
201
201
  )
202
202
 
203
+ WHITESPACE_TRIMMING_BLOCK_ELEMENTS: Final[frozenset[str]] = frozenset(
204
+ {
205
+ "address",
206
+ "article",
207
+ "aside",
208
+ "blockquote",
209
+ "body",
210
+ "caption",
211
+ "center",
212
+ "dd",
213
+ "details",
214
+ "dialog",
215
+ "dir",
216
+ "div",
217
+ "dl",
218
+ "dt",
219
+ "fieldset",
220
+ "figcaption",
221
+ "figure",
222
+ "footer",
223
+ "form",
224
+ "h1",
225
+ "h2",
226
+ "h3",
227
+ "h4",
228
+ "h5",
229
+ "h6",
230
+ "header",
231
+ "hgroup",
232
+ "html",
233
+ "li",
234
+ "listing",
235
+ "main",
236
+ "menu",
237
+ "nav",
238
+ "ol",
239
+ "p",
240
+ "search",
241
+ "section",
242
+ "summary",
243
+ "table",
244
+ "tbody",
245
+ "td",
246
+ "tfoot",
247
+ "th",
248
+ "thead",
249
+ "tr",
250
+ "ul",
251
+ }
252
+ )
253
+
203
254
  FORMATTING_ELEMENTS = {
204
255
  "a",
205
256
  "b",
@@ -199,7 +199,16 @@ def decode_numeric_entity(
199
199
  The decoded character, or None if invalid
200
200
  """
201
201
  base = 16 if is_hex else 10
202
- codepoint = int(text, base)
202
+
203
+ # A numeric character reference this long is already guaranteed to exceed
204
+ # the valid Unicode range, so treat it as out-of-range without calling
205
+ # int() on an attacker-controlled digit string long enough to exceed
206
+ # Python's integer-string conversion limit (see sys.set_int_max_str_digits).
207
+ digits = text.lstrip("0") or "0"
208
+ if len(digits) > 16:
209
+ return "\ufffd" # REPLACEMENT CHARACTER
210
+
211
+ codepoint = int(digits, base)
203
212
 
204
213
  # Invalid ranges per HTML5 spec
205
214
  if codepoint > 0x10FFFF:
@@ -1,13 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from .constants import HTML_SPACE_OR_TAG_END_CHARACTERS
4
+ from .text import ascii_lower
4
5
 
5
6
 
6
7
  def neutralize_rawtext_end_tag_sequences(text: str, tag_name: str) -> tuple[str, bool]:
7
8
  if not text:
8
9
  return text, False
9
10
 
10
- lower_text = text.lower()
11
+ lower_text = ascii_lower(text)
11
12
  needle = f"</{tag_name}"
12
13
  needle_len = len(needle)
13
14
  out: list[str] = []
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import string
4
+
5
+ _ASCII_LOWER_TABLE = str.maketrans(string.ascii_uppercase, string.ascii_lowercase)
6
+
7
+
8
+ def ascii_lower(text: str) -> str:
9
+ """Fold A-Z for ASCII marker searches that reuse original offsets.
10
+
11
+ Some parser and serializer paths search a folded copy of full text for
12
+ ASCII HTML markers such as ``</script``, ``<!doctype``, and ``<![cdata[``.
13
+ Match offsets are then reused against the original string, so the folded
14
+ copy must preserve length, and it must not invent ASCII marker characters
15
+ from non-ASCII text content. HTML markup matching is ASCII
16
+ case-insensitive; it is not Unicode case-insensitive.
17
+
18
+ ``str.lower()`` is still the fast path when it is provably equivalent for
19
+ these searches. In Python's Unicode data, U+0130 LATIN CAPITAL LETTER I
20
+ WITH DOT ABOVE is the only character whose lowercase mapping changes
21
+ length, and U+212A KELVIN SIGN is the only non-ASCII character whose
22
+ lowercase mapping becomes an ASCII letter (``k``). Inputs containing either
23
+ shape take the strict A-Z translation path. That path deliberately keeps
24
+ U+212A as U+212A; it is not a tag-name fold.
25
+ """
26
+ if text.isascii():
27
+ return text.lower()
28
+ if "\u0130" in text or "\u212a" in text:
29
+ return text.translate(_ASCII_LOWER_TABLE)
30
+ return text.lower()