confext 0.7.2__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.
- confext-0.7.2/LICENSE +21 -0
- confext-0.7.2/PKG-INFO +145 -0
- confext-0.7.2/README.md +121 -0
- confext-0.7.2/confext/__init__.py +34 -0
- confext-0.7.2/confext/config.py +238 -0
- confext-0.7.2/confext/engine.py +101 -0
- confext-0.7.2/confext/errors.py +19 -0
- confext-0.7.2/confext/reader.py +118 -0
- confext-0.7.2/confext/tree.py +146 -0
- confext-0.7.2/confext.egg-info/PKG-INFO +145 -0
- confext-0.7.2/confext.egg-info/SOURCES.txt +13 -0
- confext-0.7.2/confext.egg-info/dependency_links.txt +1 -0
- confext-0.7.2/confext.egg-info/top_level.txt +1 -0
- confext-0.7.2/pyproject.toml +31 -0
- confext-0.7.2/setup.cfg +4 -0
confext-0.7.2/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 confext
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
confext-0.7.2/PKG-INFO
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: confext
|
|
3
|
+
Version: 0.7.2
|
|
4
|
+
Summary: Stdlib HTML parsing into a tree model
|
|
5
|
+
Author-email: kinishuki <kinishuki@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: html,parser,tree
|
|
8
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Text Processing :: Markup :: HTML
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import confext
|
|
29
|
+
|
|
30
|
+
html = '''
|
|
31
|
+
<!DOCTYPE html>
|
|
32
|
+
<html><body>
|
|
33
|
+
<div id="main" class="wrap card">
|
|
34
|
+
<p class="lead">Hello <b>world</b></p>
|
|
35
|
+
<ul><li>1</li><li>2</li></ul>
|
|
36
|
+
</div>
|
|
37
|
+
</body></html>
|
|
38
|
+
'''
|
|
39
|
+
|
|
40
|
+
doc = confext.parse(html) # -> parse html
|
|
41
|
+
doc.doctype # '<!DOCTYPE html>'
|
|
42
|
+
doc.stats() # {'elements': 7, 'comments': 0, 'max_depth': 4}
|
|
43
|
+
doc.to_html() # serialized markup
|
|
44
|
+
doc.root.iter_elements() # walk every element node
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## API
|
|
48
|
+
|
|
49
|
+
### Functions
|
|
50
|
+
|
|
51
|
+
| Function | Description |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `parse(html)` | Parse `str` or `bytes` into a `ParsedDocument` using the default engine. |
|
|
54
|
+
|
|
55
|
+
That is the whole module-level API, confext is a parser, nothing else.
|
|
56
|
+
|
|
57
|
+
### ParsedDocument
|
|
58
|
+
|
|
59
|
+
| Member | Description |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `to_html()` | Re-serialize the tree. |
|
|
62
|
+
| `stats()` | `{'elements', 'comments', 'max_depth'}`. |
|
|
63
|
+
| `to_dict()` | `{'doctype', 'stats', 'html', 'errors'}`. |
|
|
64
|
+
| `doctype`, `root`, `comments`, `errors` | Raw parse artifacts. |
|
|
65
|
+
|
|
66
|
+
### Node
|
|
67
|
+
|
|
68
|
+
`tag`, `attrs`, `parent`, `children` (mix of `Node`s and text `str`s),
|
|
69
|
+
plus: `get_attr`, `has_class`, `append_child`, `remove`, `text_content`,
|
|
70
|
+
`iter_elements`, `to_html`, `to_dict`.
|
|
71
|
+
|
|
72
|
+
### Engine
|
|
73
|
+
|
|
74
|
+
For isolated runs with a custom config and no global state:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from confext import Engine, ConfextError
|
|
78
|
+
|
|
79
|
+
engine = Engine({'strict_mode': True})
|
|
80
|
+
doc = engine.parse('<div><p>ok</p></div>')
|
|
81
|
+
engine.parse('<div><p>oops</div>') # raises ConfextError in strict mode
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Also accepts `use_cache=False` to disable document memoization
|
|
85
|
+
(`cache_size` is clamped to a minimum of 1).
|
|
86
|
+
|
|
87
|
+
## Configuration
|
|
88
|
+
|
|
89
|
+
`DEFAULT_CONFIG` is a plain dict; `Engine(config)` merges over it.
|
|
90
|
+
Settings can also be persisted to and loaded from a `default.config`
|
|
91
|
+
file:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from confext import Config, load_config, save_config
|
|
95
|
+
|
|
96
|
+
cfg = load_config() # finds the caller's default.config
|
|
97
|
+
cfg.get('max_depth') # user setting layered over defaults
|
|
98
|
+
save_config(cfg, 'default.config', compression='xz')
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The binary layout is `[8-byte sha256][4-byte size][payload]`, where
|
|
102
|
+
the payload is a raw, lzma (xz), or zlib compressed payload of a
|
|
103
|
+
plain-data dict. Size and checksum are verified before decompression
|
|
104
|
+
and unpickling; both checks run before any decompression is
|
|
105
|
+
attempted. Failures raise `ConfigLoadError`, `ConfigChecksumError`,
|
|
106
|
+
or `ConfigDecompressError`.
|
|
107
|
+
|
|
108
|
+
| Key | Default | Used by |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `encoding` | `'utf-8'` | Fallback when bytes input has no BOM / `<meta charset>`. |
|
|
111
|
+
| `max_depth` | `100` | Element nesting cap (deeper nodes are dropped). |
|
|
112
|
+
| `max_file_size_mb` | `10` | Input size cap; parse raises `ConfextError` beyond it. |
|
|
113
|
+
| `cache_size` | `50` | LRU capacity for parsed documents. |
|
|
114
|
+
| `strict_mode` | `False` | If true, malformed HTML (unclosed/stray tags) raises. |
|
|
115
|
+
| `strip_whitespace` | `True` | Collapse whitespace runs in text nodes at parse time. |
|
|
116
|
+
| `preserve_comments` | `False` | Keep comments in the tree (always captured in `doc.comments`). |
|
|
117
|
+
|
|
118
|
+
Output and sanitize keys (`remove_comments`, `pretty_indent`,
|
|
119
|
+
`allowed_tags`, …) are consumed by paster, not confext.
|
|
120
|
+
|
|
121
|
+
## Errors
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
ConfextError parse failures, strict-mode violations (unclosed/
|
|
125
|
+
stray tags and depth-limit breaches — deeper nodes
|
|
126
|
+
are silently dropped outside strict mode) and
|
|
127
|
+
size-limit breaches
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Invalid selectors now raise paster's `SelectorSyntaxError`.
|
|
131
|
+
|
|
132
|
+
## Layout
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
confext/
|
|
136
|
+
├── __init__.py public API: parse; re-exports Engine,
|
|
137
|
+
│ DEFAULT_CONFIG, ConfextError, Node,
|
|
138
|
+
│ ParsedDocument, VOID_ELEMENTS, Config,
|
|
139
|
+
│ load_config/save_config, config errors
|
|
140
|
+
├── errors.py ConfextError, ConfigLoadError family
|
|
141
|
+
├── tree.py Node, ParsedDocument (tree model + stats)
|
|
142
|
+
├── reader.py encoding detection, TreeBuilder (stdlib tokenizer)
|
|
143
|
+
├── engine.py Engine, DEFAULT_CONFIG, LRU cache
|
|
144
|
+
└── config.py Config, load_config/save_config (default.config)
|
|
145
|
+
```
|
confext-0.7.2/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
## Quick start
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
import confext
|
|
5
|
+
|
|
6
|
+
html = '''
|
|
7
|
+
<!DOCTYPE html>
|
|
8
|
+
<html><body>
|
|
9
|
+
<div id="main" class="wrap card">
|
|
10
|
+
<p class="lead">Hello <b>world</b></p>
|
|
11
|
+
<ul><li>1</li><li>2</li></ul>
|
|
12
|
+
</div>
|
|
13
|
+
</body></html>
|
|
14
|
+
'''
|
|
15
|
+
|
|
16
|
+
doc = confext.parse(html) # -> parse html
|
|
17
|
+
doc.doctype # '<!DOCTYPE html>'
|
|
18
|
+
doc.stats() # {'elements': 7, 'comments': 0, 'max_depth': 4}
|
|
19
|
+
doc.to_html() # serialized markup
|
|
20
|
+
doc.root.iter_elements() # walk every element node
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## API
|
|
24
|
+
|
|
25
|
+
### Functions
|
|
26
|
+
|
|
27
|
+
| Function | Description |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `parse(html)` | Parse `str` or `bytes` into a `ParsedDocument` using the default engine. |
|
|
30
|
+
|
|
31
|
+
That is the whole module-level API, confext is a parser, nothing else.
|
|
32
|
+
|
|
33
|
+
### ParsedDocument
|
|
34
|
+
|
|
35
|
+
| Member | Description |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `to_html()` | Re-serialize the tree. |
|
|
38
|
+
| `stats()` | `{'elements', 'comments', 'max_depth'}`. |
|
|
39
|
+
| `to_dict()` | `{'doctype', 'stats', 'html', 'errors'}`. |
|
|
40
|
+
| `doctype`, `root`, `comments`, `errors` | Raw parse artifacts. |
|
|
41
|
+
|
|
42
|
+
### Node
|
|
43
|
+
|
|
44
|
+
`tag`, `attrs`, `parent`, `children` (mix of `Node`s and text `str`s),
|
|
45
|
+
plus: `get_attr`, `has_class`, `append_child`, `remove`, `text_content`,
|
|
46
|
+
`iter_elements`, `to_html`, `to_dict`.
|
|
47
|
+
|
|
48
|
+
### Engine
|
|
49
|
+
|
|
50
|
+
For isolated runs with a custom config and no global state:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from confext import Engine, ConfextError
|
|
54
|
+
|
|
55
|
+
engine = Engine({'strict_mode': True})
|
|
56
|
+
doc = engine.parse('<div><p>ok</p></div>')
|
|
57
|
+
engine.parse('<div><p>oops</div>') # raises ConfextError in strict mode
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Also accepts `use_cache=False` to disable document memoization
|
|
61
|
+
(`cache_size` is clamped to a minimum of 1).
|
|
62
|
+
|
|
63
|
+
## Configuration
|
|
64
|
+
|
|
65
|
+
`DEFAULT_CONFIG` is a plain dict; `Engine(config)` merges over it.
|
|
66
|
+
Settings can also be persisted to and loaded from a `default.config`
|
|
67
|
+
file:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from confext import Config, load_config, save_config
|
|
71
|
+
|
|
72
|
+
cfg = load_config() # finds the caller's default.config
|
|
73
|
+
cfg.get('max_depth') # user setting layered over defaults
|
|
74
|
+
save_config(cfg, 'default.config', compression='xz')
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The binary layout is `[8-byte sha256][4-byte size][payload]`, where
|
|
78
|
+
the payload is a raw, lzma (xz), or zlib compressed payload of a
|
|
79
|
+
plain-data dict. Size and checksum are verified before decompression
|
|
80
|
+
and unpickling; both checks run before any decompression is
|
|
81
|
+
attempted. Failures raise `ConfigLoadError`, `ConfigChecksumError`,
|
|
82
|
+
or `ConfigDecompressError`.
|
|
83
|
+
|
|
84
|
+
| Key | Default | Used by |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `encoding` | `'utf-8'` | Fallback when bytes input has no BOM / `<meta charset>`. |
|
|
87
|
+
| `max_depth` | `100` | Element nesting cap (deeper nodes are dropped). |
|
|
88
|
+
| `max_file_size_mb` | `10` | Input size cap; parse raises `ConfextError` beyond it. |
|
|
89
|
+
| `cache_size` | `50` | LRU capacity for parsed documents. |
|
|
90
|
+
| `strict_mode` | `False` | If true, malformed HTML (unclosed/stray tags) raises. |
|
|
91
|
+
| `strip_whitespace` | `True` | Collapse whitespace runs in text nodes at parse time. |
|
|
92
|
+
| `preserve_comments` | `False` | Keep comments in the tree (always captured in `doc.comments`). |
|
|
93
|
+
|
|
94
|
+
Output and sanitize keys (`remove_comments`, `pretty_indent`,
|
|
95
|
+
`allowed_tags`, …) are consumed by paster, not confext.
|
|
96
|
+
|
|
97
|
+
## Errors
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
ConfextError parse failures, strict-mode violations (unclosed/
|
|
101
|
+
stray tags and depth-limit breaches — deeper nodes
|
|
102
|
+
are silently dropped outside strict mode) and
|
|
103
|
+
size-limit breaches
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Invalid selectors now raise paster's `SelectorSyntaxError`.
|
|
107
|
+
|
|
108
|
+
## Layout
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
confext/
|
|
112
|
+
├── __init__.py public API: parse; re-exports Engine,
|
|
113
|
+
│ DEFAULT_CONFIG, ConfextError, Node,
|
|
114
|
+
│ ParsedDocument, VOID_ELEMENTS, Config,
|
|
115
|
+
│ load_config/save_config, config errors
|
|
116
|
+
├── errors.py ConfextError, ConfigLoadError family
|
|
117
|
+
├── tree.py Node, ParsedDocument (tree model + stats)
|
|
118
|
+
├── reader.py encoding detection, TreeBuilder (stdlib tokenizer)
|
|
119
|
+
├── engine.py Engine, DEFAULT_CONFIG, LRU cache
|
|
120
|
+
└── config.py Config, load_config/save_config (default.config)
|
|
121
|
+
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from .errors import (
|
|
2
|
+
ConfigChecksumError,
|
|
3
|
+
ConfigDecompressError,
|
|
4
|
+
ConfigLoadError,
|
|
5
|
+
ConfextError,
|
|
6
|
+
)
|
|
7
|
+
from .tree import VOID_ELEMENTS, Node, ParsedDocument
|
|
8
|
+
from .engine import DEFAULT_CONFIG, Engine
|
|
9
|
+
from .config import Config, find_default_config, load_config, save_config
|
|
10
|
+
|
|
11
|
+
_default_engine = Engine()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse(html):
|
|
15
|
+
"""Parse HTML into a ParsedDocument using the default engine."""
|
|
16
|
+
return _default_engine.parse(html)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
'Engine',
|
|
21
|
+
'Node',
|
|
22
|
+
'ParsedDocument',
|
|
23
|
+
'ConfextError',
|
|
24
|
+
'DEFAULT_CONFIG',
|
|
25
|
+
'VOID_ELEMENTS',
|
|
26
|
+
'parse',
|
|
27
|
+
'Config',
|
|
28
|
+
'load_config',
|
|
29
|
+
'save_config',
|
|
30
|
+
'find_default_config',
|
|
31
|
+
'ConfigLoadError',
|
|
32
|
+
'ConfigChecksumError',
|
|
33
|
+
'ConfigDecompressError',
|
|
34
|
+
]
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Configuration loading and persistence for the parser package.
|
|
2
|
+
|
|
3
|
+
Config files use a strict binary layout:
|
|
4
|
+
|
|
5
|
+
raw: [8-byte sha256 checksum][4-byte size][pickle payload]
|
|
6
|
+
xz: [8-byte sha256 checksum][4-byte size][lzma.compress(pickle payload)]
|
|
7
|
+
zlib: [8-byte sha256 checksum][4-byte size][zlib.compress(pickle payload)]
|
|
8
|
+
|
|
9
|
+
The size field is the big-endian length of the stored payload (compressed
|
|
10
|
+
length when xz/zlib is used). Load order: verify size -> verify checksum
|
|
11
|
+
-> detect compression -> decompress if needed -> unpickle. Both checks run
|
|
12
|
+
before decompression is attempted.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import importlib.util
|
|
17
|
+
import lzma
|
|
18
|
+
import os
|
|
19
|
+
import pickle
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
import zlib
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Dict, Optional, Tuple, Union
|
|
25
|
+
|
|
26
|
+
from .engine import DEFAULT_CONFIG
|
|
27
|
+
from .errors import ConfigChecksumError, ConfigDecompressError, ConfigLoadError
|
|
28
|
+
|
|
29
|
+
PathLike = Union[str, os.PathLike]
|
|
30
|
+
|
|
31
|
+
_CHECKSUM_SIZE = 8
|
|
32
|
+
_SIZE_SIZE = 4
|
|
33
|
+
_HEADER_SIZE = _CHECKSUM_SIZE + _SIZE_SIZE
|
|
34
|
+
|
|
35
|
+
CONFIG_NAME = 'default.config'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _checksum(payload: bytes) -> bytes:
|
|
39
|
+
"""First _CHECKSUM_SIZE bytes of the sha256 digest of payload."""
|
|
40
|
+
return hashlib.sha256(payload).digest()[:_CHECKSUM_SIZE]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _detect_compression(data: bytes) -> Optional[Tuple[str, Any]]:
|
|
44
|
+
"""Detect compression format by magic bytes. Returns (name, module) or None.
|
|
45
|
+
|
|
46
|
+
zlib has no magic number; it is detected last by validating its CMF/FLG
|
|
47
|
+
header (deflate method, CINFO <= 7, no preset dictionary, header value
|
|
48
|
+
divisible by 31).
|
|
49
|
+
"""
|
|
50
|
+
if data[:6] == b'\xfd7zXZ\x00':
|
|
51
|
+
return 'xz', lzma
|
|
52
|
+
if (
|
|
53
|
+
len(data) >= 2
|
|
54
|
+
and data[0] & 0x0F == 8
|
|
55
|
+
and data[0] >> 4 <= 7
|
|
56
|
+
and not data[1] & 0x20
|
|
57
|
+
and ((data[0] << 8) | data[1]) % 31 == 0
|
|
58
|
+
):
|
|
59
|
+
return 'zlib', zlib
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _verify_header(data: bytes, path: PathLike) -> bytes:
|
|
64
|
+
"""Verify the 8-byte sha256 and 4-byte size header; return the payload."""
|
|
65
|
+
if len(data) < _HEADER_SIZE:
|
|
66
|
+
raise ConfigLoadError(
|
|
67
|
+
f"config file '{path}' is too short to be a valid config file"
|
|
68
|
+
)
|
|
69
|
+
stored = data[:_CHECKSUM_SIZE]
|
|
70
|
+
size = int.from_bytes(data[_CHECKSUM_SIZE:_HEADER_SIZE], 'big')
|
|
71
|
+
payload = data[_HEADER_SIZE:]
|
|
72
|
+
if len(payload) != size:
|
|
73
|
+
raise ConfigLoadError(
|
|
74
|
+
f"size mismatch in config file '{path}': file truncated or "
|
|
75
|
+
f"tampered with; expected {size} payload bytes, got {len(payload)}"
|
|
76
|
+
)
|
|
77
|
+
if _checksum(payload) != stored:
|
|
78
|
+
raise ConfigChecksumError(
|
|
79
|
+
f"checksum mismatch in config file '{path}': file may be corrupted "
|
|
80
|
+
"or tampered with; files written by older versions are not accepted"
|
|
81
|
+
)
|
|
82
|
+
return payload
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _sanitize_value(value: Any) -> Any:
|
|
86
|
+
"""Recursively verify that a value only contains allowed plain types."""
|
|
87
|
+
if isinstance(value, (str, bytes, int, float, bool, type(None))):
|
|
88
|
+
return value
|
|
89
|
+
if isinstance(value, dict):
|
|
90
|
+
return {str(key): _sanitize_value(item) for key, item in value.items()}
|
|
91
|
+
if isinstance(value, (list, tuple)):
|
|
92
|
+
return [_sanitize_value(item) for item in value]
|
|
93
|
+
if isinstance(value, (set, frozenset)):
|
|
94
|
+
return {_sanitize_value(item) for item in value}
|
|
95
|
+
raise ConfigLoadError(
|
|
96
|
+
f'disallowed type {type(value).__name__!r} in config file; '
|
|
97
|
+
'only plain data types are allowed'
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Config:
|
|
102
|
+
"""Configuration holder that layers user settings over DEFAULT_CONFIG."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, settings: Optional[Dict[str, Any]] = None):
|
|
105
|
+
self._settings: Dict[str, Any] = {**DEFAULT_CONFIG, **(settings or {})}
|
|
106
|
+
|
|
107
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
108
|
+
return self._settings.get(key, default)
|
|
109
|
+
|
|
110
|
+
def update(self, settings: Dict[str, Any]) -> 'Config':
|
|
111
|
+
"""Return a new Config with extra settings layered on top of this one."""
|
|
112
|
+
return Config({**self._settings, **(settings or {})})
|
|
113
|
+
|
|
114
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
115
|
+
return dict(self._settings)
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def load_default(cls) -> 'Config':
|
|
119
|
+
"""Return a Config built from DEFAULT_CONFIG without reading any file."""
|
|
120
|
+
return cls()
|
|
121
|
+
|
|
122
|
+
def __eq__(self, other: Any) -> bool:
|
|
123
|
+
if isinstance(other, Config):
|
|
124
|
+
return self._settings == other._settings
|
|
125
|
+
return NotImplemented
|
|
126
|
+
|
|
127
|
+
def __repr__(self) -> str:
|
|
128
|
+
return f'Config({self._settings})'
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _read_config(path: PathLike) -> Dict[str, Any]:
|
|
132
|
+
"""Read config bytes from path."""
|
|
133
|
+
try:
|
|
134
|
+
with open(path, 'rb') as handle:
|
|
135
|
+
data = handle.read()
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
raise ConfigLoadError(f"failed to read config file '{path}': {exc}") from exc
|
|
138
|
+
|
|
139
|
+
blob = _verify_header(data, path)
|
|
140
|
+
|
|
141
|
+
detected = _detect_compression(blob)
|
|
142
|
+
if detected is not None:
|
|
143
|
+
name, module = detected
|
|
144
|
+
try:
|
|
145
|
+
blob = module.decompress(blob)
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
raise ConfigDecompressError(
|
|
148
|
+
f"failed to decompress {name} config file '{path}': {exc}"
|
|
149
|
+
) from exc
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
loaded = pickle.loads(blob)
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
raise ConfigLoadError(
|
|
155
|
+
f"failed to load pickle payload from '{path}': {exc}"
|
|
156
|
+
) from exc
|
|
157
|
+
if not isinstance(loaded, dict):
|
|
158
|
+
raise ConfigLoadError(
|
|
159
|
+
f"config file '{path}' must contain a dict, "
|
|
160
|
+
f"got {type(loaded).__name__}"
|
|
161
|
+
)
|
|
162
|
+
return _sanitize_value(loaded)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def load_config(path: Optional[PathLike] = None) -> Config:
|
|
166
|
+
"""Load a Config from an explicit file, or the caller's default.config."""
|
|
167
|
+
if path is None:
|
|
168
|
+
path = _locate_config(sys._getframe(1).f_globals)
|
|
169
|
+
return Config(_read_config(path))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def save_config(
|
|
173
|
+
config: Union[Config, Dict[str, Any]],
|
|
174
|
+
path: Optional[PathLike] = None,
|
|
175
|
+
compression: Optional[str] = None,
|
|
176
|
+
) -> Path:
|
|
177
|
+
"""Persist settings to path atomically; no path auto-finds default.config."""
|
|
178
|
+
if compression not in (None, 'xz', 'zlib'):
|
|
179
|
+
raise ValueError(
|
|
180
|
+
f"unsupported compression {compression!r}; expected 'xz', 'zlib' or None"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
if path is None:
|
|
184
|
+
path = _locate_config(sys._getframe(1).f_globals)
|
|
185
|
+
|
|
186
|
+
if isinstance(config, Config):
|
|
187
|
+
settings = config.to_dict()
|
|
188
|
+
else:
|
|
189
|
+
settings = Config(config).to_dict()
|
|
190
|
+
payload = pickle.dumps(settings, protocol=pickle.HIGHEST_PROTOCOL)
|
|
191
|
+
if compression == 'xz':
|
|
192
|
+
payload = lzma.compress(payload)
|
|
193
|
+
elif compression == 'zlib':
|
|
194
|
+
payload = zlib.compress(payload)
|
|
195
|
+
blob = _checksum(payload) + len(payload).to_bytes(_SIZE_SIZE, 'big') + payload
|
|
196
|
+
|
|
197
|
+
target = Path(path)
|
|
198
|
+
if not target.parent.exists():
|
|
199
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
200
|
+
|
|
201
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), suffix='.config.tmp')
|
|
202
|
+
try:
|
|
203
|
+
with os.fdopen(fd, 'wb') as handle:
|
|
204
|
+
handle.write(blob)
|
|
205
|
+
os.replace(tmp_name, str(target))
|
|
206
|
+
except Exception:
|
|
207
|
+
try:
|
|
208
|
+
os.unlink(tmp_name)
|
|
209
|
+
except OSError:
|
|
210
|
+
pass
|
|
211
|
+
raise
|
|
212
|
+
return target
|
|
213
|
+
|
|
214
|
+
def _locate_config(g: Dict[str, Any]) -> Path:
|
|
215
|
+
"""Resolve the default.config path for a module globals mapping."""
|
|
216
|
+
file = g.get('__file__')
|
|
217
|
+
if file:
|
|
218
|
+
candidate = Path(os.path.abspath(file)).parent / CONFIG_NAME
|
|
219
|
+
if candidate.exists():
|
|
220
|
+
return candidate
|
|
221
|
+
package = g.get('__package__') or g.get('__name__', '').partition('.')[0]
|
|
222
|
+
if package:
|
|
223
|
+
try:
|
|
224
|
+
spec = importlib.util.find_spec(package)
|
|
225
|
+
except (ImportError, AttributeError, ValueError):
|
|
226
|
+
spec = None
|
|
227
|
+
if spec and spec.submodule_search_locations:
|
|
228
|
+
return Path(spec.submodule_search_locations[0]) / CONFIG_NAME
|
|
229
|
+
raise ConfigLoadError(f'no {CONFIG_NAME} found for caller')
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def find_default_config() -> Path:
|
|
233
|
+
"""Locate the caller's default.config without loading it.
|
|
234
|
+
|
|
235
|
+
Searches the caller's module directory first, then the caller's
|
|
236
|
+
package directory. Raises ConfigLoadError when neither can be resolved.
|
|
237
|
+
"""
|
|
238
|
+
return _locate_config(sys._getframe(1).f_globals)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Engine: the configured parsing facade with document caching."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from typing import Any, Dict, Optional, Union
|
|
5
|
+
|
|
6
|
+
from collections import OrderedDict
|
|
7
|
+
|
|
8
|
+
from .errors import ConfextError
|
|
9
|
+
from .reader import TreeBuilder, detect_encoding, extract_doctype
|
|
10
|
+
from .tree import Node, ParsedDocument
|
|
11
|
+
|
|
12
|
+
DEFAULT_CONFIG: Dict[str, Any] = {
|
|
13
|
+
'encoding': 'utf-8',
|
|
14
|
+
'max_depth': 100,
|
|
15
|
+
'max_file_size_mb': 10,
|
|
16
|
+
'cache_size': 50,
|
|
17
|
+
'strict_mode': False,
|
|
18
|
+
'strip_whitespace': True,
|
|
19
|
+
'preserve_comments': False,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LRUCache:
|
|
24
|
+
"""Tiny LRU cache used to memoize parsed documents."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, max_size: int) -> None:
|
|
27
|
+
self._max_size = max(int(max_size), 1)
|
|
28
|
+
self._data: 'OrderedDict[str, Any]' = OrderedDict()
|
|
29
|
+
|
|
30
|
+
def get(self, key: str) -> Any:
|
|
31
|
+
if key in self._data:
|
|
32
|
+
self._data.move_to_end(key)
|
|
33
|
+
return self._data[key]
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
def set(self, key: str, value: Any) -> None:
|
|
37
|
+
self._data[key] = value
|
|
38
|
+
self._data.move_to_end(key)
|
|
39
|
+
while len(self._data) > self._max_size:
|
|
40
|
+
self._data.popitem(last=False)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Engine:
|
|
44
|
+
"""High-level HTML parsing engine configured by a plain dict."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, config: Optional[Dict[str, Any]] = None,
|
|
47
|
+
use_cache: bool = True) -> None:
|
|
48
|
+
self.config: Dict[str, Any] = dict(DEFAULT_CONFIG)
|
|
49
|
+
if config:
|
|
50
|
+
self.config.update(config)
|
|
51
|
+
self.cache: Optional[LRUCache] = None
|
|
52
|
+
if use_cache:
|
|
53
|
+
self.cache = LRUCache(
|
|
54
|
+
max_size=int(self.config.get('cache_size', 50) or 50)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def parse(self, html: Union[str, bytes]) -> ParsedDocument:
|
|
58
|
+
"""Parse HTML (str or bytes) into a ParsedDocument."""
|
|
59
|
+
if isinstance(html, bytes):
|
|
60
|
+
encoding = detect_encoding(
|
|
61
|
+
html,
|
|
62
|
+
default=str(self.config.get('encoding', 'utf-8') or 'utf-8'),
|
|
63
|
+
)
|
|
64
|
+
html = html.decode(encoding, errors='replace')
|
|
65
|
+
key = hashlib.sha256(html.encode('utf-8', errors='replace')).hexdigest()
|
|
66
|
+
if self.cache is not None:
|
|
67
|
+
cached = self.cache.get(key)
|
|
68
|
+
if cached is not None:
|
|
69
|
+
return cached
|
|
70
|
+
self._check_size(html)
|
|
71
|
+
builder = TreeBuilder(self.config)
|
|
72
|
+
try:
|
|
73
|
+
builder.feed(html)
|
|
74
|
+
builder.close()
|
|
75
|
+
except ConfextError:
|
|
76
|
+
raise
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
raise ConfextError(f'failed to parse HTML: {exc}') from exc
|
|
79
|
+
if bool(self.config.get('strict_mode', False)) and builder.errors:
|
|
80
|
+
summary = '; '.join(builder.errors[:5])
|
|
81
|
+
raise ConfextError(f'malformed HTML (strict mode): {summary}')
|
|
82
|
+
doc = ParsedDocument(
|
|
83
|
+
doctype=extract_doctype(html),
|
|
84
|
+
root=builder.root,
|
|
85
|
+
comments=builder.comments,
|
|
86
|
+
config=self.config,
|
|
87
|
+
errors=builder.errors,
|
|
88
|
+
)
|
|
89
|
+
if self.cache is not None:
|
|
90
|
+
self.cache.set(key, doc)
|
|
91
|
+
return doc
|
|
92
|
+
|
|
93
|
+
def _check_size(self, html: str) -> None:
|
|
94
|
+
cap = self.config.get('max_file_size_mb', 10)
|
|
95
|
+
if not cap:
|
|
96
|
+
return
|
|
97
|
+
size_mb = len(html.encode('utf-8', errors='replace')) / (1024 * 1024)
|
|
98
|
+
if size_mb > float(cap):
|
|
99
|
+
raise ConfextError(
|
|
100
|
+
f'input of {size_mb:.2f} MB exceeds max_file_size_mb={cap}'
|
|
101
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class ConfextError(Exception):
|
|
2
|
+
"""Raised when HTML cannot be parsed, a selector is invalid, or a
|
|
3
|
+
parse/sanitize limit is exceeded."""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ConfigLoadError(Exception):
|
|
7
|
+
"""Raised when a config file is missing, unreadable, malformed,
|
|
8
|
+
unsafe, or not discoverable."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfigChecksumError(ConfigLoadError):
|
|
12
|
+
"""Raised when a config payload does not match its stored checksum."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, message: str):
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigDecompressError(ConfigLoadError):
|
|
19
|
+
"""Raised when a compressed config payload cannot be decompressed."""
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""HTML tokenizing, encoding detection, and tree building."""
|
|
2
|
+
|
|
3
|
+
import codecs
|
|
4
|
+
import re
|
|
5
|
+
from html.parser import HTMLParser as _BaseHTMLParser
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from .tree import COMMENT_TAG, ROOT_TAG, VOID_ELEMENTS, Node
|
|
9
|
+
|
|
10
|
+
_META_CHARSET = re.compile(
|
|
11
|
+
r'<meta[^>]+charset\s*=\s*["\']?\s*([\w.:+-]+)', re.IGNORECASE
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def detect_encoding(data: bytes, default: str = 'utf-8') -> str:
|
|
16
|
+
"""Detect the encoding of raw HTML: BOM sniff, then <meta charset>."""
|
|
17
|
+
if data.startswith(codecs.BOM_UTF8):
|
|
18
|
+
return 'utf-8-sig'
|
|
19
|
+
if data.startswith((codecs.BOM_UTF32_LE, codecs.BOM_UTF32_BE)):
|
|
20
|
+
return 'utf-32'
|
|
21
|
+
if data.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
|
|
22
|
+
return 'utf-16'
|
|
23
|
+
head = data[:2048].decode('latin-1', errors='ignore')
|
|
24
|
+
match = _META_CHARSET.search(head)
|
|
25
|
+
if match:
|
|
26
|
+
try:
|
|
27
|
+
codecs.lookup(match.group(1))
|
|
28
|
+
return match.group(1)
|
|
29
|
+
except LookupError:
|
|
30
|
+
pass
|
|
31
|
+
return default
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def extract_doctype(html: str) -> Optional[str]:
|
|
35
|
+
match = re.search(r'<!DOCTYPE[^>]*>', html, re.IGNORECASE)
|
|
36
|
+
return match.group(0) if match else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TreeBuilder(_BaseHTMLParser):
|
|
40
|
+
"""Builds a Node tree from HTML using the stdlib tokenizer."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, config: Dict[str, Any]) -> None:
|
|
43
|
+
super().__init__(convert_charrefs=True)
|
|
44
|
+
self.config = config
|
|
45
|
+
self.root = Node(ROOT_TAG)
|
|
46
|
+
self._stack: List[Node] = [self.root]
|
|
47
|
+
self.comments: List[str] = []
|
|
48
|
+
self.errors: List[str] = []
|
|
49
|
+
self._max_depth = int(config.get('max_depth', 100) or 100)
|
|
50
|
+
self._preserve_comments = bool(config.get('preserve_comments', False))
|
|
51
|
+
self._strip_whitespace = bool(config.get('strip_whitespace', True))
|
|
52
|
+
self._strict = bool(config.get('strict_mode', False))
|
|
53
|
+
|
|
54
|
+
def _current(self) -> Node:
|
|
55
|
+
return self._stack[-1]
|
|
56
|
+
|
|
57
|
+
def _attach(self, node: Node) -> None:
|
|
58
|
+
parent = self._current()
|
|
59
|
+
if len(self._stack) <= self._max_depth:
|
|
60
|
+
parent.children.append(node)
|
|
61
|
+
node.parent = parent
|
|
62
|
+
elif self._strict:
|
|
63
|
+
self.errors.append(
|
|
64
|
+
f'<{node.tag}> exceeds max_depth={self._max_depth}'
|
|
65
|
+
)
|
|
66
|
+
self._stack.append(node)
|
|
67
|
+
|
|
68
|
+
def handle_starttag(self, tag, attrs) -> None:
|
|
69
|
+
node = Node(tag, {k: (v if v is not None else '') for k, v in attrs})
|
|
70
|
+
self._attach(node)
|
|
71
|
+
if tag in VOID_ELEMENTS:
|
|
72
|
+
self._stack.pop()
|
|
73
|
+
|
|
74
|
+
def handle_startendtag(self, tag, attrs) -> None:
|
|
75
|
+
node = Node(tag, {k: (v if v is not None else '') for k, v in attrs})
|
|
76
|
+
if len(self._stack) <= self._max_depth:
|
|
77
|
+
parent = self._current()
|
|
78
|
+
parent.children.append(node)
|
|
79
|
+
node.parent = parent
|
|
80
|
+
|
|
81
|
+
def handle_endtag(self, tag) -> None:
|
|
82
|
+
if tag in VOID_ELEMENTS:
|
|
83
|
+
return
|
|
84
|
+
for i in range(len(self._stack) - 1, 0, -1):
|
|
85
|
+
if self._stack[i].tag == tag:
|
|
86
|
+
if len(self._stack) - 1 > i:
|
|
87
|
+
unclosed = [n.tag for n in self._stack[i + 1:]]
|
|
88
|
+
self.errors.append(
|
|
89
|
+
f'unclosed tag(s) <{"> <".join(unclosed)}> before </{tag}>'
|
|
90
|
+
)
|
|
91
|
+
del self._stack[i:]
|
|
92
|
+
return
|
|
93
|
+
self.errors.append(f'closing tag </{tag}> has no matching open tag')
|
|
94
|
+
|
|
95
|
+
def handle_data(self, data) -> None:
|
|
96
|
+
if not data:
|
|
97
|
+
return
|
|
98
|
+
if self._strip_whitespace:
|
|
99
|
+
data = re.sub(r'\s+', ' ', data)
|
|
100
|
+
self._current().children.append(data)
|
|
101
|
+
|
|
102
|
+
def handle_comment(self, data) -> None:
|
|
103
|
+
self.comments.append(data)
|
|
104
|
+
if self._preserve_comments:
|
|
105
|
+
comment = Node(COMMENT_TAG)
|
|
106
|
+
comment.children.append(data)
|
|
107
|
+
if len(self._stack) <= self._max_depth:
|
|
108
|
+
parent = self._current()
|
|
109
|
+
parent.children.append(comment)
|
|
110
|
+
comment.parent = parent
|
|
111
|
+
|
|
112
|
+
def close(self) -> None:
|
|
113
|
+
super().close()
|
|
114
|
+
if len(self._stack) > 1:
|
|
115
|
+
unclosed = [n.tag for n in self._stack[1:]]
|
|
116
|
+
self.errors.append(
|
|
117
|
+
f'unclosed tag(s) at end of input: <{"> <".join(unclosed)}>'
|
|
118
|
+
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Tree data model: Node and ParsedDocument."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from html import escape as _escape
|
|
5
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
6
|
+
|
|
7
|
+
VOID_ELEMENTS = {
|
|
8
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
9
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
ROOT_TAG = ':root'
|
|
13
|
+
COMMENT_TAG = ':comment'
|
|
14
|
+
|
|
15
|
+
_WS = re.compile(r'\s+')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def normalize_ws(text: str) -> str:
|
|
19
|
+
return _WS.sub(' ', text)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def format_attrs(attrs: Dict[str, str]) -> str:
|
|
23
|
+
return ''.join(
|
|
24
|
+
f' {name}' if value == '' else f' {name}="{_escape(value, quote=True)}"'
|
|
25
|
+
for name, value in attrs.items()
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Node:
|
|
30
|
+
"""A single element or comment node in the parsed HTML tree."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, tag: str, attrs: Optional[Dict[str, str]] = None,
|
|
33
|
+
parent: Optional['Node'] = None) -> None:
|
|
34
|
+
self.tag = tag
|
|
35
|
+
self.attrs: Dict[str, str] = dict(attrs or {})
|
|
36
|
+
self.children: List[Any] = []
|
|
37
|
+
self.parent: Optional[Node] = parent
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def is_comment(self) -> bool:
|
|
41
|
+
return self.tag == COMMENT_TAG
|
|
42
|
+
|
|
43
|
+
def get_attr(self, name: str, default: Optional[str] = None) -> Optional[str]:
|
|
44
|
+
return self.attrs.get(name, default)
|
|
45
|
+
|
|
46
|
+
def has_class(self, name: str) -> bool:
|
|
47
|
+
return name in (self.get_attr('class') or '').split()
|
|
48
|
+
|
|
49
|
+
def append_child(self, node: 'Node') -> 'Node':
|
|
50
|
+
if node.parent is not None:
|
|
51
|
+
node.parent.children.remove(node)
|
|
52
|
+
self.children.append(node)
|
|
53
|
+
node.parent = self
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def remove(self) -> 'Node':
|
|
57
|
+
if self.parent is not None:
|
|
58
|
+
self.parent.children.remove(self)
|
|
59
|
+
self.parent = None
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def text_content(self) -> str:
|
|
63
|
+
parts: List[str] = []
|
|
64
|
+
for child in self.children:
|
|
65
|
+
parts.append(child if isinstance(child, str) else child.text_content())
|
|
66
|
+
return ''.join(parts)
|
|
67
|
+
|
|
68
|
+
def iter_elements(self) -> Iterator['Node']:
|
|
69
|
+
for child in self.children:
|
|
70
|
+
if isinstance(child, Node) and not child.is_comment:
|
|
71
|
+
yield child
|
|
72
|
+
yield from child.iter_elements()
|
|
73
|
+
|
|
74
|
+
def to_html(self) -> str:
|
|
75
|
+
if self.is_comment:
|
|
76
|
+
return f'<!--{self.text_content()}-->'
|
|
77
|
+
attrs = format_attrs(self.attrs)
|
|
78
|
+
if self.tag in VOID_ELEMENTS:
|
|
79
|
+
return f'<{self.tag}{attrs}>'
|
|
80
|
+
inner = ''.join(
|
|
81
|
+
child if isinstance(child, str) else child.to_html()
|
|
82
|
+
for child in self.children
|
|
83
|
+
)
|
|
84
|
+
return f'<{self.tag}{attrs}>{inner}</{self.tag}>'
|
|
85
|
+
|
|
86
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
87
|
+
return {
|
|
88
|
+
'tag': self.tag,
|
|
89
|
+
'attrs': dict(self.attrs),
|
|
90
|
+
'text': normalize_ws(self.text_content()).strip(),
|
|
91
|
+
'html': self.to_html(),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
def __repr__(self) -> str:
|
|
95
|
+
return f'<Node {self.tag}>'
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ParsedDocument:
|
|
99
|
+
"""Structured result of parsing an HTML document."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, doctype: Optional[str], root: Node,
|
|
102
|
+
comments: Optional[List[str]] = None,
|
|
103
|
+
config: Optional[Dict[str, Any]] = None,
|
|
104
|
+
errors: Optional[List[str]] = None) -> None:
|
|
105
|
+
self.doctype = doctype
|
|
106
|
+
self.root = root
|
|
107
|
+
self.comments: List[str] = comments or []
|
|
108
|
+
self.errors: List[str] = errors or []
|
|
109
|
+
self.config: Dict[str, Any] = config or {}
|
|
110
|
+
|
|
111
|
+
def to_html(self) -> str:
|
|
112
|
+
return ''.join(
|
|
113
|
+
child if isinstance(child, str) else child.to_html()
|
|
114
|
+
for child in self.root.children
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def stats(self) -> Dict[str, int]:
|
|
118
|
+
element_count = 0
|
|
119
|
+
max_depth = 0
|
|
120
|
+
|
|
121
|
+
def walk(node: Node, depth: int) -> None:
|
|
122
|
+
nonlocal element_count, max_depth
|
|
123
|
+
for child in node.children:
|
|
124
|
+
if isinstance(child, Node) and not child.is_comment:
|
|
125
|
+
element_count += 1
|
|
126
|
+
if depth + 1 > max_depth:
|
|
127
|
+
max_depth = depth + 1
|
|
128
|
+
walk(child, depth + 1)
|
|
129
|
+
|
|
130
|
+
walk(self.root, 0)
|
|
131
|
+
return {
|
|
132
|
+
'elements': element_count,
|
|
133
|
+
'comments': len(self.comments),
|
|
134
|
+
'max_depth': max_depth,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
138
|
+
return {
|
|
139
|
+
'doctype': self.doctype,
|
|
140
|
+
'stats': self.stats(),
|
|
141
|
+
'html': self.to_html(),
|
|
142
|
+
'errors': list(self.errors),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
def __repr__(self) -> str:
|
|
146
|
+
return f'<ParsedDocument {self.stats()["elements"]} elements>'
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: confext
|
|
3
|
+
Version: 0.7.2
|
|
4
|
+
Summary: Stdlib HTML parsing into a tree model
|
|
5
|
+
Author-email: kinishuki <kinishuki@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: html,parser,tree
|
|
8
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Text Processing :: Markup :: HTML
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import confext
|
|
29
|
+
|
|
30
|
+
html = '''
|
|
31
|
+
<!DOCTYPE html>
|
|
32
|
+
<html><body>
|
|
33
|
+
<div id="main" class="wrap card">
|
|
34
|
+
<p class="lead">Hello <b>world</b></p>
|
|
35
|
+
<ul><li>1</li><li>2</li></ul>
|
|
36
|
+
</div>
|
|
37
|
+
</body></html>
|
|
38
|
+
'''
|
|
39
|
+
|
|
40
|
+
doc = confext.parse(html) # -> parse html
|
|
41
|
+
doc.doctype # '<!DOCTYPE html>'
|
|
42
|
+
doc.stats() # {'elements': 7, 'comments': 0, 'max_depth': 4}
|
|
43
|
+
doc.to_html() # serialized markup
|
|
44
|
+
doc.root.iter_elements() # walk every element node
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## API
|
|
48
|
+
|
|
49
|
+
### Functions
|
|
50
|
+
|
|
51
|
+
| Function | Description |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `parse(html)` | Parse `str` or `bytes` into a `ParsedDocument` using the default engine. |
|
|
54
|
+
|
|
55
|
+
That is the whole module-level API, confext is a parser, nothing else.
|
|
56
|
+
|
|
57
|
+
### ParsedDocument
|
|
58
|
+
|
|
59
|
+
| Member | Description |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `to_html()` | Re-serialize the tree. |
|
|
62
|
+
| `stats()` | `{'elements', 'comments', 'max_depth'}`. |
|
|
63
|
+
| `to_dict()` | `{'doctype', 'stats', 'html', 'errors'}`. |
|
|
64
|
+
| `doctype`, `root`, `comments`, `errors` | Raw parse artifacts. |
|
|
65
|
+
|
|
66
|
+
### Node
|
|
67
|
+
|
|
68
|
+
`tag`, `attrs`, `parent`, `children` (mix of `Node`s and text `str`s),
|
|
69
|
+
plus: `get_attr`, `has_class`, `append_child`, `remove`, `text_content`,
|
|
70
|
+
`iter_elements`, `to_html`, `to_dict`.
|
|
71
|
+
|
|
72
|
+
### Engine
|
|
73
|
+
|
|
74
|
+
For isolated runs with a custom config and no global state:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from confext import Engine, ConfextError
|
|
78
|
+
|
|
79
|
+
engine = Engine({'strict_mode': True})
|
|
80
|
+
doc = engine.parse('<div><p>ok</p></div>')
|
|
81
|
+
engine.parse('<div><p>oops</div>') # raises ConfextError in strict mode
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Also accepts `use_cache=False` to disable document memoization
|
|
85
|
+
(`cache_size` is clamped to a minimum of 1).
|
|
86
|
+
|
|
87
|
+
## Configuration
|
|
88
|
+
|
|
89
|
+
`DEFAULT_CONFIG` is a plain dict; `Engine(config)` merges over it.
|
|
90
|
+
Settings can also be persisted to and loaded from a `default.config`
|
|
91
|
+
file:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from confext import Config, load_config, save_config
|
|
95
|
+
|
|
96
|
+
cfg = load_config() # finds the caller's default.config
|
|
97
|
+
cfg.get('max_depth') # user setting layered over defaults
|
|
98
|
+
save_config(cfg, 'default.config', compression='xz')
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The binary layout is `[8-byte sha256][4-byte size][payload]`, where
|
|
102
|
+
the payload is a raw, lzma (xz), or zlib compressed payload of a
|
|
103
|
+
plain-data dict. Size and checksum are verified before decompression
|
|
104
|
+
and unpickling; both checks run before any decompression is
|
|
105
|
+
attempted. Failures raise `ConfigLoadError`, `ConfigChecksumError`,
|
|
106
|
+
or `ConfigDecompressError`.
|
|
107
|
+
|
|
108
|
+
| Key | Default | Used by |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `encoding` | `'utf-8'` | Fallback when bytes input has no BOM / `<meta charset>`. |
|
|
111
|
+
| `max_depth` | `100` | Element nesting cap (deeper nodes are dropped). |
|
|
112
|
+
| `max_file_size_mb` | `10` | Input size cap; parse raises `ConfextError` beyond it. |
|
|
113
|
+
| `cache_size` | `50` | LRU capacity for parsed documents. |
|
|
114
|
+
| `strict_mode` | `False` | If true, malformed HTML (unclosed/stray tags) raises. |
|
|
115
|
+
| `strip_whitespace` | `True` | Collapse whitespace runs in text nodes at parse time. |
|
|
116
|
+
| `preserve_comments` | `False` | Keep comments in the tree (always captured in `doc.comments`). |
|
|
117
|
+
|
|
118
|
+
Output and sanitize keys (`remove_comments`, `pretty_indent`,
|
|
119
|
+
`allowed_tags`, …) are consumed by paster, not confext.
|
|
120
|
+
|
|
121
|
+
## Errors
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
ConfextError parse failures, strict-mode violations (unclosed/
|
|
125
|
+
stray tags and depth-limit breaches — deeper nodes
|
|
126
|
+
are silently dropped outside strict mode) and
|
|
127
|
+
size-limit breaches
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Invalid selectors now raise paster's `SelectorSyntaxError`.
|
|
131
|
+
|
|
132
|
+
## Layout
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
confext/
|
|
136
|
+
├── __init__.py public API: parse; re-exports Engine,
|
|
137
|
+
│ DEFAULT_CONFIG, ConfextError, Node,
|
|
138
|
+
│ ParsedDocument, VOID_ELEMENTS, Config,
|
|
139
|
+
│ load_config/save_config, config errors
|
|
140
|
+
├── errors.py ConfextError, ConfigLoadError family
|
|
141
|
+
├── tree.py Node, ParsedDocument (tree model + stats)
|
|
142
|
+
├── reader.py encoding detection, TreeBuilder (stdlib tokenizer)
|
|
143
|
+
├── engine.py Engine, DEFAULT_CONFIG, LRU cache
|
|
144
|
+
└── config.py Config, load_config/save_config (default.config)
|
|
145
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
confext/__init__.py
|
|
5
|
+
confext/config.py
|
|
6
|
+
confext/engine.py
|
|
7
|
+
confext/errors.py
|
|
8
|
+
confext/reader.py
|
|
9
|
+
confext/tree.py
|
|
10
|
+
confext.egg-info/PKG-INFO
|
|
11
|
+
confext.egg-info/SOURCES.txt
|
|
12
|
+
confext.egg-info/dependency_links.txt
|
|
13
|
+
confext.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
confext
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=74.1"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "confext"
|
|
7
|
+
version = "0.7.2"
|
|
8
|
+
description = "Stdlib HTML parsing into a tree model"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{name = "kinishuki", email = "kinishuki@gmail.com"}]
|
|
13
|
+
keywords = ["html", "parser", "tree"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.8",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: Text Processing :: Markup :: HTML",
|
|
27
|
+
]
|
|
28
|
+
dependencies = []
|
|
29
|
+
|
|
30
|
+
[tool.setuptools]
|
|
31
|
+
packages = ["confext"]
|
confext-0.7.2/setup.cfg
ADDED