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 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
+ ```
@@ -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
+ 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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+