justhtml 3.7.0__tar.gz → 3.9.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 (158) hide show
  1. {justhtml-3.7.0 → justhtml-3.9.0}/CHANGELOG.md +39 -0
  2. {justhtml-3.7.0 → justhtml-3.9.0}/PERFORMANCE.md +25 -0
  3. {justhtml-3.7.0 → justhtml-3.9.0}/PKG-INFO +8 -8
  4. {justhtml-3.7.0 → justhtml-3.9.0}/benchmarks/performance.py +10 -4
  5. {justhtml-3.7.0 → justhtml-3.9.0}/pyproject.toml +8 -8
  6. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/core/constants.py +2 -0
  7. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/dom/__init__.py +180 -74
  8. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/parser/__init__.py +24 -37
  9. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/parser/engine.py +1262 -1020
  10. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/parser/scanner.py +70 -10
  11. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/parser/stream.py +5 -12
  12. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/transforms/runtime.py +49 -3
  13. {justhtml-3.7.0 → justhtml-3.9.0}/test-summary.txt +21 -18
  14. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_node.py +61 -0
  15. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_parser_engine.py +320 -19
  16. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_parser_regressions.py +3 -3
  17. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_parser_tree.py +9 -0
  18. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_stream.py +38 -7
  19. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_transforms.py +186 -1
  20. {justhtml-3.7.0 → justhtml-3.9.0}/.github/copilot-instructions.md +0 -0
  21. {justhtml-3.7.0 → justhtml-3.9.0}/.github/workflows/ci.yml +0 -0
  22. {justhtml-3.7.0 → justhtml-3.9.0}/.github/workflows/publish.yml +0 -0
  23. {justhtml-3.7.0 → justhtml-3.9.0}/.gitignore +0 -0
  24. {justhtml-3.7.0 → justhtml-3.9.0}/.pre-commit-config.yaml +0 -0
  25. {justhtml-3.7.0 → justhtml-3.9.0}/AGENTS.md +0 -0
  26. {justhtml-3.7.0 → justhtml-3.9.0}/CODE_OF_CONDUCT.md +0 -0
  27. {justhtml-3.7.0 → justhtml-3.9.0}/CONTRIBUTING.md +0 -0
  28. {justhtml-3.7.0 → justhtml-3.9.0}/LICENSE +0 -0
  29. {justhtml-3.7.0 → justhtml-3.9.0}/Makefile +0 -0
  30. {justhtml-3.7.0 → justhtml-3.9.0}/README.md +0 -0
  31. {justhtml-3.7.0 → justhtml-3.9.0}/SECURITY.md +0 -0
  32. {justhtml-3.7.0 → justhtml-3.9.0}/assets/justhtml-readme-explainer.png +0 -0
  33. {justhtml-3.7.0 → justhtml-3.9.0}/benchmarks/chromium_parser_diff.py +0 -0
  34. {justhtml-3.7.0 → justhtml-3.9.0}/benchmarks/correctness.py +0 -0
  35. {justhtml-3.7.0 → justhtml-3.9.0}/benchmarks/fuzz.py +0 -0
  36. {justhtml-3.7.0 → justhtml-3.9.0}/benchmarks/html5lib_engine_diff.py +0 -0
  37. {justhtml-3.7.0 → justhtml-3.9.0}/benchmarks/parser_engine_gate.py +0 -0
  38. {justhtml-3.7.0 → justhtml-3.9.0}/benchmarks/profile.py +0 -0
  39. {justhtml-3.7.0 → justhtml-3.9.0}/docs/_config.yml +0 -0
  40. {justhtml-3.7.0 → justhtml-3.9.0}/docs/_layouts/default.html +0 -0
  41. {justhtml-3.7.0 → justhtml-3.9.0}/docs/api.md +0 -0
  42. {justhtml-3.7.0 → justhtml-3.9.0}/docs/assets/search.js +0 -0
  43. {justhtml-3.7.0 → justhtml-3.9.0}/docs/bleach-migration.md +0 -0
  44. {justhtml-3.7.0 → justhtml-3.9.0}/docs/building.md +0 -0
  45. {justhtml-3.7.0 → justhtml-3.9.0}/docs/cli.md +0 -0
  46. {justhtml-3.7.0 → justhtml-3.9.0}/docs/comparison.md +0 -0
  47. {justhtml-3.7.0 → justhtml-3.9.0}/docs/correctness.md +0 -0
  48. {justhtml-3.7.0 → justhtml-3.9.0}/docs/encoding.md +0 -0
  49. {justhtml-3.7.0 → justhtml-3.9.0}/docs/errors.md +0 -0
  50. {justhtml-3.7.0 → justhtml-3.9.0}/docs/fragments.md +0 -0
  51. {justhtml-3.7.0 → justhtml-3.9.0}/docs/html-cleaning.md +0 -0
  52. {justhtml-3.7.0 → justhtml-3.9.0}/docs/index.md +0 -0
  53. {justhtml-3.7.0 → justhtml-3.9.0}/docs/linkify.md +0 -0
  54. {justhtml-3.7.0 → justhtml-3.9.0}/docs/migration-examples.md +0 -0
  55. {justhtml-3.7.0 → justhtml-3.9.0}/docs/playground/app.js +0 -0
  56. {justhtml-3.7.0 → justhtml-3.9.0}/docs/playground/index.html +0 -0
  57. {justhtml-3.7.0 → justhtml-3.9.0}/docs/playground/py/__init__.py +0 -0
  58. {justhtml-3.7.0 → justhtml-3.9.0}/docs/playground/py/install_latest_justhtml.py +0 -0
  59. {justhtml-3.7.0 → justhtml-3.9.0}/docs/playground/py/render.py +0 -0
  60. {justhtml-3.7.0 → justhtml-3.9.0}/docs/playground/py/use_local_repo.py +0 -0
  61. {justhtml-3.7.0 → justhtml-3.9.0}/docs/quickstart.md +0 -0
  62. {justhtml-3.7.0 → justhtml-3.9.0}/docs/sanitization.md +0 -0
  63. {justhtml-3.7.0 → justhtml-3.9.0}/docs/selectors.md +0 -0
  64. {justhtml-3.7.0 → justhtml-3.9.0}/docs/streaming.md +0 -0
  65. {justhtml-3.7.0 → justhtml-3.9.0}/docs/text.md +0 -0
  66. {justhtml-3.7.0 → justhtml-3.9.0}/docs/transforms.md +0 -0
  67. {justhtml-3.7.0 → justhtml-3.9.0}/docs/unsafe-handling.md +0 -0
  68. {justhtml-3.7.0 → justhtml-3.9.0}/docs/url-cleaning.md +0 -0
  69. {justhtml-3.7.0 → justhtml-3.9.0}/llms.txt +0 -0
  70. {justhtml-3.7.0 → justhtml-3.9.0}/project_ideas.md +0 -0
  71. {justhtml-3.7.0 → justhtml-3.9.0}/run_tests.py +0 -0
  72. {justhtml-3.7.0 → justhtml-3.9.0}/scripts/release.py +0 -0
  73. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/__init__.py +0 -0
  74. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/__main__.py +0 -0
  75. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/core/__init__.py +0 -0
  76. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/core/doctype.py +0 -0
  77. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/core/entities.py +0 -0
  78. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/core/errors.py +0 -0
  79. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/core/rawtext.py +0 -0
  80. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/core/text.py +0 -0
  81. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/core/types.py +0 -0
  82. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/dom/builder.py +0 -0
  83. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/parser/context.py +0 -0
  84. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/parser/encoding.py +0 -0
  85. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/parser/options.py +0 -0
  86. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/py.typed +0 -0
  87. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/sanitizer/__init__.py +0 -0
  88. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/sanitizer/css.py +0 -0
  89. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/sanitizer/dom.py +0 -0
  90. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/sanitizer/policy.py +0 -0
  91. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/sanitizer/policy_defaults.py +0 -0
  92. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/sanitizer/rawtext.py +0 -0
  93. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/sanitizer/url/__init__.py +0 -0
  94. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/sanitizer/url/policy.py +0 -0
  95. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/sanitizer/url/runtime.py +0 -0
  96. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/sanitizer/url/spec.py +0 -0
  97. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/selector/__init__.py +0 -0
  98. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/selector/core.py +0 -0
  99. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/serializer/__init__.py +0 -0
  100. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/serializer/html.py +0 -0
  101. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/serializer/markdown.py +0 -0
  102. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/transforms/__init__.py +0 -0
  103. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/transforms/compile.py +0 -0
  104. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/transforms/linkify.py +0 -0
  105. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/transforms/linkify_core.py +0 -0
  106. {justhtml-3.7.0 → justhtml-3.9.0}/src/justhtml/transforms/spec.py +0 -0
  107. {justhtml-3.7.0 → justhtml-3.9.0}/tests/README.md +0 -0
  108. {justhtml-3.7.0 → justhtml-3.9.0}/tests/__init__.py +0 -0
  109. {justhtml-3.7.0 → justhtml-3.9.0}/tests/data/wikipedia.html +0 -0
  110. {justhtml-3.7.0 → justhtml-3.9.0}/tests/harness/__init__.py +0 -0
  111. {justhtml-3.7.0 → justhtml-3.9.0}/tests/harness/encoding.py +0 -0
  112. {justhtml-3.7.0 → justhtml-3.9.0}/tests/harness/regressions.py +0 -0
  113. {justhtml-3.7.0 → justhtml-3.9.0}/tests/harness/reporter.py +0 -0
  114. {justhtml-3.7.0 → justhtml-3.9.0}/tests/harness/serializer.py +0 -0
  115. {justhtml-3.7.0 → justhtml-3.9.0}/tests/harness/tree.py +0 -0
  116. {justhtml-3.7.0 → justhtml-3.9.0}/tests/justhtml-sanitize-tests/cases.json +0 -0
  117. {justhtml-3.7.0 → justhtml-3.9.0}/tests/justhtml-tests/character_preprocessing.dat +0 -0
  118. {justhtml-3.7.0 → justhtml-3.9.0}/tests/justhtml-tests/document_shell_recovery.dat +0 -0
  119. {justhtml-3.7.0 → justhtml-3.9.0}/tests/justhtml-tests/entities.test +0 -0
  120. {justhtml-3.7.0 → justhtml-3.9.0}/tests/justhtml-tests/foreign_content.dat +0 -0
  121. {justhtml-3.7.0 → justhtml-3.9.0}/tests/justhtml-tests/foreign_stack_recovery.dat +0 -0
  122. {justhtml-3.7.0 → justhtml-3.9.0}/tests/justhtml-tests/frameset_recovery.dat +0 -0
  123. {justhtml-3.7.0 → justhtml-3.9.0}/tests/justhtml-tests/head_noscript_recovery.dat +0 -0
  124. {justhtml-3.7.0 → justhtml-3.9.0}/tests/justhtml-tests/iframe_srcdoc.dat +0 -0
  125. {justhtml-3.7.0 → justhtml-3.9.0}/tests/justhtml-tests/in_body_recovery.dat +0 -0
  126. {justhtml-3.7.0 → justhtml-3.9.0}/tests/justhtml-tests/raw_text_recovery.dat +0 -0
  127. {justhtml-3.7.0 → justhtml-3.9.0}/tests/justhtml-tests/select_recovery.dat +0 -0
  128. {justhtml-3.7.0 → justhtml-3.9.0}/tests/justhtml-tests/selectedcontent.dat +0 -0
  129. {justhtml-3.7.0 → justhtml-3.9.0}/tests/justhtml-tests/table_recovery.dat +0 -0
  130. {justhtml-3.7.0 → justhtml-3.9.0}/tests/justhtml-tests/template_recovery.dat +0 -0
  131. {justhtml-3.7.0 → justhtml-3.9.0}/tests/justhtml-tests/tokenizer_recovery.dat +0 -0
  132. {justhtml-3.7.0 → justhtml-3.9.0}/tests/justhtml-tests/tokenizer_state_transitions.test +0 -0
  133. {justhtml-3.7.0 → justhtml-3.9.0}/tests/justhtml-tests/tokenizer_states.test +0 -0
  134. {justhtml-3.7.0 → justhtml-3.9.0}/tests/justhtml-tests/xml_coercion.dat +0 -0
  135. {justhtml-3.7.0 → justhtml-3.9.0}/tests/justhtml-tests/xml_coercion_invalid_characters.test +0 -0
  136. {justhtml-3.7.0 → justhtml-3.9.0}/tests/linkify-it/LICENSE.txt +0 -0
  137. {justhtml-3.7.0 → justhtml-3.9.0}/tests/linkify-it/README.md +0 -0
  138. {justhtml-3.7.0 → justhtml-3.9.0}/tests/linkify-it/fixtures/links.txt +0 -0
  139. {justhtml-3.7.0 → justhtml-3.9.0}/tests/linkify-it/fixtures/not_links.txt +0 -0
  140. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_builder.py +0 -0
  141. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_cli.py +0 -0
  142. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_docs_examples.py +0 -0
  143. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_encoding.py +0 -0
  144. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_entities.py +0 -0
  145. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_errors.py +0 -0
  146. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_linkify_internals.py +0 -0
  147. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_linkify_it.py +0 -0
  148. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_linkify_transform.py +0 -0
  149. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_playground_local_repo_file_list.py +0 -0
  150. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_sanitize.py +0 -0
  151. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_sanitize_integration.py +0 -0
  152. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_sanitize_transform.py +0 -0
  153. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_selector.py +0 -0
  154. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_serialize.py +0 -0
  155. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_transform_runtime.py +0 -0
  156. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_transforms_compiler.py +0 -0
  157. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_transforms_sanitize_integration.py +0 -0
  158. {justhtml-3.7.0 → justhtml-3.9.0}/tests/test_wikipedia.py +0 -0
@@ -5,6 +5,45 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [3.9.0] - 2026-07-20
11
+
12
+ ### Performance
13
+
14
+ Combined, the changes below reduce default-constructor parse time by about 9.5% relative to 3.8.0, on top of that release's 40% reduction from 3.7.1.
15
+
16
+ - Speed up the default `JustHTML()` constructor by about 3% on the web100k parser benchmark by moving the common text and compiled-attribute paths into their callers and avoiding full active-formatting bookkeeping for uncomplicated first entries.
17
+ - Reduce another 1.2% of default-constructor parse time by dispatching start and end tags before uncommon markup and removing redundant CDATA probes and regular-expression offset lookups while skipping dropped subtrees.
18
+ - Reduce default-constructor parse time by another 2% on a 1,000-document web100k sample by clearing stale foreign-context scan state, using C-level membership for ASCII tag dispatch, and inlining bounded case-insensitive raw-text matching.
19
+ - Reduce default-constructor parse time by another 3.6% on a 1,000-document web100k sample and shrink ordinary element nodes by one third by lazily allocating source-location metadata and bypassing adaptive stack bookkeeping for shallow end-tag mutations.
20
+
21
+ ## [3.8.0] - 2026-07-19
22
+
23
+ ### Performance
24
+
25
+ On the web100k parser benchmark (1,000 documents, three iterations), the default `JustHTML()` constructor now completes in 13.020 seconds rather than 21.648 seconds in 3.7.1: a 40% reduction in parse time, or about 1.7× higher throughput.
26
+
27
+ - Speed up the default `JustHTML()` constructor by compiling common start/end-tag and URL-sanitization decisions, and by folding only names that the parser encounters instead of copying and lowercasing the full input.
28
+ - Reduce tree-construction overhead by adaptively tracking open elements, removing completed formatting entries without leaving tombstones, and deferring formatting-attribute signatures until duplicate tracking actually needs them.
29
+ - Remove redundant constructor-path work for discarded end-tag attributes, canonical attribute names, shallow non-paragraph stack updates, active-formatting bookkeeping, and parser state that could never become active.
30
+ - Cut fixed default-constructor overhead by reusing built-in execution plans, skipping no-op finalization and error-list work, lazily allocating formatting indexes, and collapsing duplicate shell, mode, and end-of-input state.
31
+ - Collapse common start-tag and text insertion-mode checks into synchronized state flags, avoiding repeated condition ladders in the default `JustHTML()` constructor.
32
+ - Avoid open-elements membership scans for ordinary HTML insertions and batch large disallowed-wrapper projections instead of repeatedly searching and shifting the same child lists.
33
+ - Remove already-resolved sanitizer insertion, unknown-tag condition ladders, template-mode lookups, and wrapper-list copies from the default `JustHTML()` constructor path.
34
+
35
+ ### Security
36
+
37
+ - (Severity: Low) Keep sanitization projection linear for large runs of disallowed sibling wrappers and avoid depth-proportional parent validation for nested wrappers. Previously, crafted wrapper-heavy HTML could make the default `JustHTML()` constructor take quadratic time.
38
+
39
+ ## [3.7.1] - 2026-07-17
40
+
41
+ ### Fixed
42
+
43
+ - Make `CollapseWhitespace(...)` trim text at block-sibling and line-break boundaries, including `<pre>`, `<hr>`, and `<br>`.
44
+ - Preserve visible spacing around closed dialogs and hidden elements while ignoring comments, processing instructions, empty text nodes, and native non-rendering elements when locating rendered siblings.
45
+ - Collapse whitespace across adjacent text runs exposed by transforms such as `Unwrap(...)` and `DropComments(...)` without merging the text nodes.
46
+
8
47
  ## [3.7.0] - 2026-07-15
9
48
 
10
49
  ### Fixed
@@ -22,6 +22,31 @@ Set `WEB100K_DIR` to use another location, or pass `--batches-dir` and
22
22
  pip install -e ".[benchmark]"
23
23
  ```
24
24
 
25
+ `html5-parser` (the Gumbo benchmark) and `lxml` must use the same libxml2
26
+ implementation. The `html5-parser` wheel uses the system libxml2, while a
27
+ current `lxml` wheel embeds a newer one. Remove that wheel and rebuild only
28
+ `lxml` against the local library:
29
+
30
+ ```bash
31
+ pip uninstall -y lxml
32
+ pip install --no-cache-dir --no-binary=lxml 'lxml==6.1.1'
33
+ ```
34
+
35
+ Confirm that they agree before running the benchmark:
36
+
37
+ ```bash
38
+ python -c 'from lxml import etree; import html5_parser; print(etree.LIBXML_VERSION)'
39
+ ```
40
+
41
+ If pip cannot build lxml, install your platform's libxml2 development package
42
+ and C compiler, then repeat the second command. Reinstalling the project extra
43
+ after this step is unnecessary and can reselect the binary lxml wheel.
44
+
45
+ The latest MarkupEver and TurboHTML wheels do not currently load on Python
46
+ 3.15 pre-releases. On that interpreter they are reported as unavailable while
47
+ the remaining benchmark parsers, including Gumbo, still run. Use a supported
48
+ stable Python release when those comparisons are required.
49
+
25
50
  ## Measure the right pipeline
26
51
 
27
52
  The benchmark has separate modes for parsing alone and parsing followed by
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: justhtml
3
- Version: 3.7.0
3
+ Version: 3.9.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
@@ -35,14 +35,14 @@ Classifier: Operating System :: OS Independent
35
35
  Classifier: Programming Language :: Python :: 3
36
36
  Requires-Python: >=3.10
37
37
  Provides-Extra: benchmark
38
- Requires-Dist: beautifulsoup4; extra == 'benchmark'
39
- Requires-Dist: html5-parser; extra == 'benchmark'
40
- Requires-Dist: html5lib; extra == 'benchmark'
41
- Requires-Dist: lxml; extra == 'benchmark'
42
- Requires-Dist: markupever; extra == 'benchmark'
38
+ Requires-Dist: beautifulsoup4>=4.14.3; extra == 'benchmark'
39
+ Requires-Dist: html5-parser>=0.4.12; extra == 'benchmark'
40
+ Requires-Dist: html5lib>=1.1; extra == 'benchmark'
41
+ Requires-Dist: lxml>=6.1.1; extra == 'benchmark'
42
+ Requires-Dist: markupever>=0.3.4; extra == 'benchmark'
43
43
  Requires-Dist: selectolax>=0.4.11; extra == 'benchmark'
44
- Requires-Dist: turbohtml>=1.3.1; extra == 'benchmark'
45
- Requires-Dist: zstandard; extra == 'benchmark'
44
+ Requires-Dist: turbohtml>=1.4.0; extra == 'benchmark'
45
+ Requires-Dist: zstandard>=0.25.0; extra == 'benchmark'
46
46
  Provides-Extra: dev
47
47
  Requires-Dist: build; extra == 'dev'
48
48
  Requires-Dist: coverage; extra == 'dev'
@@ -454,6 +454,8 @@ def benchmark_gumbo(html_source, iterations=1):
454
454
  import html5_parser
455
455
  except ImportError:
456
456
  return {"error": "html5-parser not installed (pip install html5-parser)"}
457
+ except RuntimeError as e:
458
+ return {"error": f"html5-parser unavailable ({e})"}
457
459
  times = []
458
460
  errors = 0
459
461
  total_bytes = 0
@@ -493,8 +495,10 @@ def benchmark_markupever(html_source, iterations=1):
493
495
  """Benchmark markupever parser."""
494
496
  try:
495
497
  from markupever import parse
496
- except ImportError:
497
- return {"error": "markupever not installed (pip install markupever)"}
498
+ except ImportError as e:
499
+ return {"error": f"markupever unavailable ({e})"}
500
+ except (RuntimeError, SystemError) as e:
501
+ return {"error": f"markupever unavailable ({e})"}
498
502
  times = []
499
503
  errors = 0
500
504
  total_bytes = 0
@@ -534,8 +538,10 @@ def benchmark_turbohtml(html_source, iterations=1):
534
538
  """Benchmark TurboHTML parser."""
535
539
  try:
536
540
  from turbohtml import parse
537
- except ImportError:
538
- return {"error": "turbohtml not installed (pip install turbohtml)"}
541
+ except ImportError as e:
542
+ return {"error": f"turbohtml unavailable ({e})"}
543
+ except (RuntimeError, SystemError) as e:
544
+ return {"error": f"turbohtml unavailable ({e})"}
539
545
  times = []
540
546
  errors = 0
541
547
  total_bytes = 0
@@ -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.7.0"
4
+ version = "3.9.0"
5
5
  description = "A pure Python HTML5 parser that just works."
6
6
  readme = "README.md"
7
7
  license = { file = "LICENSE" }
@@ -14,14 +14,14 @@ classifiers = [
14
14
 
15
15
  [project.optional-dependencies]
16
16
  benchmark = [
17
- "zstandard",
18
- "html5lib",
19
- "lxml",
20
- "beautifulsoup4",
17
+ "zstandard>=0.25.0",
18
+ "html5lib>=1.1",
19
+ "lxml>=6.1.1",
20
+ "beautifulsoup4>=4.14.3",
21
21
  "selectolax>=0.4.11",
22
- "html5-parser",
23
- "markupever",
24
- "turbohtml>=1.3.1",
22
+ "html5-parser>=0.4.12",
23
+ "markupever>=0.3.4",
24
+ "turbohtml>=1.4.0",
25
25
  ]
26
26
  dev = [
27
27
  "ruff==0.14.7",
@@ -230,6 +230,7 @@ WHITESPACE_TRIMMING_BLOCK_ELEMENTS: Final[frozenset[str]] = frozenset(
230
230
  "header",
231
231
  "hgroup",
232
232
  "html",
233
+ "hr",
233
234
  "li",
234
235
  "listing",
235
236
  "main",
@@ -237,6 +238,7 @@ WHITESPACE_TRIMMING_BLOCK_ELEMENTS: Final[frozenset[str]] = frozenset(
237
238
  "nav",
238
239
  "ol",
239
240
  "p",
241
+ "pre",
240
242
  "search",
241
243
  "section",
242
244
  "summary",
@@ -132,10 +132,7 @@ def _to_text_collect_block_chunks(node: NodeType, chunks: list[list[str]], strip
132
132
 
133
133
  class Node:
134
134
  __slots__ = (
135
- "_origin_col",
136
- "_origin_line",
137
- "_origin_pos",
138
- "_source_html",
135
+ "_metadata",
139
136
  "attrs",
140
137
  "children",
141
138
  "data",
@@ -150,10 +147,7 @@ class Node:
150
147
  children: list[Any] | None
151
148
  data: str | Doctype | None
152
149
  namespace: str | None
153
- _origin_pos: int | None
154
- _origin_line: int | None
155
- _origin_col: int | None
156
- _source_html: str | None
150
+ _metadata: list[str | int | None] | None
157
151
 
158
152
  def __init__(
159
153
  self,
@@ -165,10 +159,7 @@ class Node:
165
159
  self.name = name
166
160
  self.parent = None
167
161
  self.data = data
168
- self._source_html = None
169
- self._origin_pos = None
170
- self._origin_line = None
171
- self._origin_col = None
162
+ self._metadata = None
172
163
 
173
164
  if name.startswith("#") or name == "!doctype":
174
165
  self.namespace = namespace
@@ -220,6 +211,66 @@ class Node:
220
211
  node.parent = None
221
212
  return old_parent, old_index
222
213
 
214
+ @property
215
+ def _source_html(self) -> str | None:
216
+ metadata = self._metadata
217
+ return None if metadata is None else cast("str | None", metadata[0])
218
+
219
+ @_source_html.setter
220
+ def _source_html(self, value: str | None) -> None:
221
+ metadata = self._metadata
222
+ if metadata is None:
223
+ if value is None:
224
+ return
225
+ metadata = [None] * 8
226
+ self._metadata = metadata
227
+ metadata[0] = value
228
+
229
+ @property
230
+ def _origin_pos(self) -> int | None:
231
+ metadata = self._metadata
232
+ return None if metadata is None else cast("int | None", metadata[1])
233
+
234
+ @_origin_pos.setter
235
+ def _origin_pos(self, value: int | None) -> None:
236
+ metadata = self._metadata
237
+ if metadata is None:
238
+ if value is None:
239
+ return
240
+ metadata = [None] * 8
241
+ self._metadata = metadata
242
+ metadata[1] = value
243
+
244
+ @property
245
+ def _origin_line(self) -> int | None:
246
+ metadata = self._metadata
247
+ return None if metadata is None else cast("int | None", metadata[2])
248
+
249
+ @_origin_line.setter
250
+ def _origin_line(self, value: int | None) -> None:
251
+ metadata = self._metadata
252
+ if metadata is None:
253
+ if value is None:
254
+ return
255
+ metadata = [None] * 8
256
+ self._metadata = metadata
257
+ metadata[2] = value
258
+
259
+ @property
260
+ def _origin_col(self) -> int | None:
261
+ metadata = self._metadata
262
+ return None if metadata is None else cast("int | None", metadata[3])
263
+
264
+ @_origin_col.setter
265
+ def _origin_col(self, value: int | None) -> None:
266
+ metadata = self._metadata
267
+ if metadata is None:
268
+ if value is None:
269
+ return
270
+ metadata = [None] * 8
271
+ self._metadata = metadata
272
+ metadata[3] = value
273
+
223
274
  @property
224
275
  def origin_offset(self) -> int | None:
225
276
  """Best-effort origin offset (0-indexed) in the source HTML, if known."""
@@ -429,10 +480,7 @@ class Node:
429
480
  self.data,
430
481
  self.namespace,
431
482
  )
432
- clone._source_html = self._source_html
433
- clone._origin_pos = self._origin_pos
434
- clone._origin_line = self._origin_line
435
- clone._origin_col = self._origin_col
483
+ clone._metadata = self._metadata.copy() if self._metadata is not None else None
436
484
  if deep:
437
485
  return _clone_subtree_iterative(self)
438
486
  return clone
@@ -447,10 +495,7 @@ class Document(Node):
447
495
  def clone_node(self, deep: bool = False, override_attrs: dict[str, str | None] | None = None) -> Document:
448
496
  _ = override_attrs
449
497
  clone = Document()
450
- clone._source_html = self._source_html
451
- clone._origin_pos = self._origin_pos
452
- clone._origin_line = self._origin_line
453
- clone._origin_col = self._origin_col
498
+ clone._metadata = self._metadata.copy() if self._metadata is not None else None
454
499
  if deep:
455
500
  return cast("Document", _clone_subtree_iterative(self))
456
501
  return clone
@@ -465,10 +510,7 @@ class DocumentFragment(Node):
465
510
  def clone_node(self, deep: bool = False, override_attrs: dict[str, str | None] | None = None) -> DocumentFragment:
466
511
  _ = override_attrs
467
512
  clone = DocumentFragment()
468
- clone._source_html = self._source_html
469
- clone._origin_pos = self._origin_pos
470
- clone._origin_line = self._origin_line
471
- clone._origin_col = self._origin_col
513
+ clone._metadata = self._metadata.copy() if self._metadata is not None else None
472
514
  if deep:
473
515
  return cast("DocumentFragment", _clone_subtree_iterative(self))
474
516
  return clone
@@ -484,10 +526,7 @@ class Comment(Node):
484
526
  _ = override_attrs
485
527
  _ = deep
486
528
  clone = Comment(self.data if isinstance(self.data, str) else None)
487
- clone._source_html = self._source_html
488
- clone._origin_pos = self._origin_pos
489
- clone._origin_line = self._origin_line
490
- clone._origin_col = self._origin_col
529
+ clone._metadata = self._metadata.copy() if self._metadata is not None else None
491
530
  return clone
492
531
 
493
532
 
@@ -503,31 +542,20 @@ class ProcessingInstruction(Node):
503
542
  _ = override_attrs
504
543
  _ = deep
505
544
  clone = ProcessingInstruction(self.data if isinstance(self.data, str) else None)
506
- clone._source_html = self._source_html
507
- clone._origin_pos = self._origin_pos
508
- clone._origin_line = self._origin_line
509
- clone._origin_col = self._origin_col
545
+ clone._metadata = self._metadata.copy() if self._metadata is not None else None
510
546
  return clone
511
547
 
512
548
 
513
549
  class Element(Node):
514
550
  __slots__ = (
515
- "_end_tag_end",
516
551
  "_end_tag_present",
517
- "_end_tag_start",
518
552
  "_self_closing",
519
- "_start_tag_end",
520
- "_start_tag_start",
521
553
  "template_content",
522
554
  )
523
555
 
524
556
  template_content: Node | None
525
557
  children: list[Any]
526
558
  attrs: dict[str, str | None]
527
- _start_tag_start: int | None
528
- _start_tag_end: int | None
529
- _end_tag_start: int | None
530
- _end_tag_end: int | None
531
559
  _end_tag_present: bool
532
560
  _self_closing: bool
533
561
 
@@ -539,28 +567,74 @@ class Element(Node):
539
567
  self.children = []
540
568
  self.attrs = attrs if attrs is not None else {}
541
569
  self.template_content = None
542
- self._source_html = None
543
- self._origin_pos = None
544
- self._origin_line = None
545
- self._origin_col = None
546
- self._start_tag_start = None
547
- self._start_tag_end = None
548
- self._end_tag_start = None
549
- self._end_tag_end = None
570
+ self._metadata = None
550
571
  self._end_tag_present = False
551
572
  self._self_closing = False
552
573
 
574
+ @property
575
+ def _start_tag_start(self) -> int | None:
576
+ metadata = self._metadata
577
+ return None if metadata is None else cast("int | None", metadata[4])
578
+
579
+ @_start_tag_start.setter
580
+ def _start_tag_start(self, value: int | None) -> None:
581
+ metadata = self._metadata
582
+ if metadata is None:
583
+ if value is None:
584
+ return
585
+ metadata = [None] * 8
586
+ self._metadata = metadata
587
+ metadata[4] = value
588
+
589
+ @property
590
+ def _start_tag_end(self) -> int | None:
591
+ metadata = self._metadata
592
+ return None if metadata is None else cast("int | None", metadata[5])
593
+
594
+ @_start_tag_end.setter
595
+ def _start_tag_end(self, value: int | None) -> None:
596
+ metadata = self._metadata
597
+ if metadata is None:
598
+ if value is None:
599
+ return
600
+ metadata = [None] * 8
601
+ self._metadata = metadata
602
+ metadata[5] = value
603
+
604
+ @property
605
+ def _end_tag_start(self) -> int | None:
606
+ metadata = self._metadata
607
+ return None if metadata is None else cast("int | None", metadata[6])
608
+
609
+ @_end_tag_start.setter
610
+ def _end_tag_start(self, value: int | None) -> None:
611
+ metadata = self._metadata
612
+ if metadata is None:
613
+ if value is None:
614
+ return
615
+ metadata = [None] * 8
616
+ self._metadata = metadata
617
+ metadata[6] = value
618
+
619
+ @property
620
+ def _end_tag_end(self) -> int | None:
621
+ metadata = self._metadata
622
+ return None if metadata is None else cast("int | None", metadata[7])
623
+
624
+ @_end_tag_end.setter
625
+ def _end_tag_end(self, value: int | None) -> None:
626
+ metadata = self._metadata
627
+ if metadata is None:
628
+ if value is None:
629
+ return
630
+ metadata = [None] * 8
631
+ self._metadata = metadata
632
+ metadata[7] = value
633
+
553
634
  def clone_node(self, deep: bool = False, override_attrs: dict[str, str | None] | None = None) -> Element:
554
635
  attrs = override_attrs.copy() if override_attrs is not None else (self.attrs.copy() if self.attrs else {})
555
636
  clone = Element(self.name, attrs, self.namespace)
556
- clone._source_html = self._source_html
557
- clone._origin_pos = self._origin_pos
558
- clone._origin_line = self._origin_line
559
- clone._origin_col = self._origin_col
560
- clone._start_tag_start = self._start_tag_start
561
- clone._start_tag_end = self._start_tag_end
562
- clone._end_tag_start = self._end_tag_start
563
- clone._end_tag_end = self._end_tag_end
637
+ clone._metadata = self._metadata.copy() if self._metadata is not None else None
564
638
  clone._end_tag_present = self._end_tag_present
565
639
  clone._self_closing = self._self_closing
566
640
  if deep:
@@ -593,14 +667,7 @@ class Template(Element):
593
667
  None,
594
668
  self.namespace,
595
669
  )
596
- clone._source_html = self._source_html
597
- clone._origin_pos = self._origin_pos
598
- clone._origin_line = self._origin_line
599
- clone._origin_col = self._origin_col
600
- clone._start_tag_start = self._start_tag_start
601
- clone._start_tag_end = self._start_tag_end
602
- clone._end_tag_start = self._end_tag_start
603
- clone._end_tag_end = self._end_tag_end
670
+ clone._metadata = self._metadata.copy() if self._metadata is not None else None
604
671
  clone._end_tag_present = self._end_tag_present
605
672
  clone._self_closing = self._self_closing
606
673
  if deep:
@@ -637,24 +704,65 @@ def _clone_subtree_iterative(root: Node) -> Node:
637
704
 
638
705
 
639
706
  class Text:
640
- __slots__ = ("_origin_col", "_origin_line", "_origin_pos", "data", "name", "namespace", "parent")
707
+ __slots__ = ("_metadata", "data", "name", "namespace", "parent")
641
708
 
642
709
  data: str | None
643
710
  name: str
644
711
  namespace: None
645
712
  parent: Node | None
646
- _origin_pos: int | None
647
- _origin_line: int | None
648
- _origin_col: int | None
713
+ _metadata: list[int | None] | None
649
714
 
650
715
  def __init__(self, data: str | None) -> None:
651
716
  self.data = data
652
717
  self.parent = None
653
718
  self.name = "#text"
654
719
  self.namespace = None
655
- self._origin_pos = None
656
- self._origin_line = None
657
- self._origin_col = None
720
+ self._metadata = None
721
+
722
+ @property
723
+ def _origin_pos(self) -> int | None:
724
+ metadata = self._metadata
725
+ return None if metadata is None else metadata[0]
726
+
727
+ @_origin_pos.setter
728
+ def _origin_pos(self, value: int | None) -> None:
729
+ metadata = self._metadata
730
+ if metadata is None:
731
+ if value is None:
732
+ return
733
+ metadata = [None, None, None]
734
+ self._metadata = metadata
735
+ metadata[0] = value
736
+
737
+ @property
738
+ def _origin_line(self) -> int | None:
739
+ metadata = self._metadata
740
+ return None if metadata is None else metadata[1]
741
+
742
+ @_origin_line.setter
743
+ def _origin_line(self, value: int | None) -> None:
744
+ metadata = self._metadata
745
+ if metadata is None:
746
+ if value is None:
747
+ return
748
+ metadata = [None, None, None]
749
+ self._metadata = metadata
750
+ metadata[1] = value
751
+
752
+ @property
753
+ def _origin_col(self) -> int | None:
754
+ metadata = self._metadata
755
+ return None if metadata is None else metadata[2]
756
+
757
+ @_origin_col.setter
758
+ def _origin_col(self, value: int | None) -> None:
759
+ metadata = self._metadata
760
+ if metadata is None:
761
+ if value is None:
762
+ return
763
+ metadata = [None, None, None]
764
+ self._metadata = metadata
765
+ metadata[2] = value
658
766
 
659
767
  @property
660
768
  def origin_offset(self) -> int | None:
@@ -712,9 +820,7 @@ class Text:
712
820
  def clone_node(self, deep: bool = False) -> Text:
713
821
  _ = deep
714
822
  clone = Text(self.data)
715
- clone._origin_pos = self._origin_pos
716
- clone._origin_line = self._origin_line
717
- clone._origin_col = self._origin_col
823
+ clone._metadata = self._metadata.copy() if self._metadata is not None else None
718
824
  return clone
719
825
 
720
826
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import TYPE_CHECKING, Any, cast
5
+ from typing import TYPE_CHECKING, Any
6
6
 
7
7
  from justhtml.core.types import ParseError
8
8
  from justhtml.dom import Document, DocumentFragment, Node, QueryMatch, Text
@@ -20,7 +20,6 @@ from .engine import (
20
20
  ParseEngine,
21
21
  can_compile_engine_plan,
22
22
  compile_default_engine_plan,
23
- compile_engine_plan,
24
23
  compile_raw_engine_plan,
25
24
  )
26
25
 
@@ -150,7 +149,9 @@ class JustHTML:
150
149
  self.encoding = None
151
150
 
152
151
  html_str: str
153
- if isinstance(html, (Node, Text)):
152
+ if type(html) is str:
153
+ html_str = html
154
+ elif isinstance(html, (Node, Text)):
154
155
  html_for_serialization = html
155
156
  if sanitize_enabled or has_sanitize_transform or has_harden_rawtext_transform:
156
157
  from justhtml.sanitizer import ( # noqa: PLC0415
@@ -179,40 +180,28 @@ class JustHTML:
179
180
  # Node location tracking is opt-in to avoid slowing down the common case.
180
181
  should_collect = collect_errors or strict
181
182
 
182
- engine_policy: SanitizationPolicy | None = None
183
- if sanitize_enabled and not transforms:
184
- if policy is not None:
185
- engine_policy = policy
186
- else:
187
- from justhtml.sanitizer import DEFAULT_DOCUMENT_POLICY, DEFAULT_POLICY # noqa: PLC0415
188
-
189
- engine_policy = DEFAULT_POLICY if fragment else DEFAULT_DOCUMENT_POLICY
190
-
191
- xml_coercion = False
192
- emit_bogus_markup_as_text = False
193
- discard_bom = bool(getattr(_parser_opts, "discard_bom", True))
194
- if discard_bom and html_str.startswith("\ufeff"):
195
- html_str = html_str[1:]
196
- if _parser_opts is not None:
197
- xml_coercion = bool(getattr(_parser_opts, "xml_coercion", False))
198
- emit_bogus_markup_as_text = bool(getattr(_parser_opts, "emit_bogus_markup_as_text", False))
183
+ if _parser_opts is None:
184
+ discard_bom = True
185
+ xml_coercion = False
186
+ emit_bogus_markup_as_text = False
187
+ else:
188
+ discard_bom = _parser_opts.discard_bom
189
+ xml_coercion = _parser_opts.xml_coercion
190
+ emit_bogus_markup_as_text = _parser_opts.emit_bogus_markup_as_text
199
191
  if emit_bogus_markup_as_text:
200
192
  track_tag_spans = True
193
+ if discard_bom and html_str.startswith("\ufeff"):
194
+ html_str = html_str[1:]
201
195
 
202
196
  use_compiled_safe_engine = (
203
- engine_policy is not None and not transforms and can_compile_engine_plan(engine_policy, fragment=fragment)
197
+ sanitize_enabled
198
+ and not transforms
199
+ and (policy is None or can_compile_engine_plan(policy, fragment=fragment))
204
200
  )
205
201
  if use_compiled_safe_engine:
206
- if policy is None:
207
- engine_plan = compile_default_engine_plan(fragment=fragment, scripting_enabled=scripting_enabled)
208
- else:
209
- engine_plan = compile_engine_plan(
210
- policy=cast("SanitizationPolicy", engine_policy),
211
- fragment=fragment,
212
- scripting_enabled=scripting_enabled,
213
- )
202
+ engine_plan = compile_default_engine_plan(fragment, scripting_enabled)
214
203
  else:
215
- engine_plan = compile_raw_engine_plan(fragment=fragment, scripting_enabled=scripting_enabled)
204
+ engine_plan = compile_raw_engine_plan(fragment, scripting_enabled)
216
205
 
217
206
  engine = ParseEngine(
218
207
  html_str,
@@ -223,14 +212,14 @@ class JustHTML:
223
212
  collect_errors=should_collect,
224
213
  max_errors=max_errors,
225
214
  iframe_srcdoc=iframe_srcdoc,
226
- track_node_locations=bool(track_node_locations),
227
- track_tag_spans=bool(track_node_locations) or track_tag_spans,
215
+ track_node_locations=track_node_locations,
216
+ track_tag_spans=track_node_locations or track_tag_spans,
228
217
  emit_bogus_markup_as_text=emit_bogus_markup_as_text,
229
218
  xml_coercion=xml_coercion,
230
219
  )
231
220
  self.root = engine.parse()
232
221
 
233
- transform_errors: list[ParseError] = _BoundedErrorList(max_errors - len(engine.errors))
222
+ errors = engine.errors
234
223
  if not use_compiled_safe_engine:
235
224
  transform_errors = self._apply_constructor_transforms(
236
225
  transforms=transforms,
@@ -239,11 +228,9 @@ class JustHTML:
239
228
  has_sanitize_transform=has_sanitize_transform,
240
229
  max_errors=max_errors - len(engine.errors),
241
230
  )
231
+ errors = engine.errors + transform_errors if should_collect else transform_errors
242
232
 
243
- if should_collect:
244
- self.errors = self._sorted_errors(engine.errors + transform_errors)
245
- else:
246
- self.errors = transform_errors
233
+ self.errors = self._sorted_errors(errors) if should_collect else errors
247
234
  if strict and self.errors:
248
235
  raise StrictModeError(self.errors[0])
249
236