astree 0.1.0__py3-none-any.whl

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.
astree/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """Dependency-free HTML trees and extraction powered by html.parser."""
2
+
3
+ from .collectors import Link, LinkCollector, TextCollector
4
+ from .extract import ExtractionError, Field, extract, extract_all
5
+ from .nodes import Node
6
+ from .parser import Document, DocumentParser, ParseLimitError
7
+ from .selectors import SelectorSyntaxError
8
+
9
+ __version__ = '0.1.0'
10
+
11
+ __all__ = [
12
+ 'Document',
13
+ 'DocumentParser',
14
+ 'Node',
15
+ 'ParseLimitError',
16
+ 'SelectorSyntaxError',
17
+ 'Link',
18
+ 'LinkCollector',
19
+ 'TextCollector',
20
+ 'Field',
21
+ 'ExtractionError',
22
+ 'extract',
23
+ 'extract_all',
24
+ '__version__',
25
+ ]
astree/collectors.py ADDED
@@ -0,0 +1,154 @@
1
+ """Incremental extraction without retaining an HTML document tree."""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass, field
5
+ from urllib.parse import urljoin
6
+
7
+ from .parser import _BoundedParser, ParseLimitError, VOID_ELEMENTS
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class Link:
12
+ """An anchor's original href, visible text, and explicitly resolved URL."""
13
+
14
+ href: str
15
+ text: str
16
+ url: str
17
+
18
+
19
+ @dataclass(eq=False, slots=True)
20
+ class _Frame:
21
+ tag: str
22
+ href: str | None = None
23
+ pieces: list[str] = field(default_factory=list)
24
+
25
+
26
+ class _StreamParser(_BoundedParser):
27
+ def __init__(self, *, max_chars: int, max_nodes: int, max_depth: int) -> None:
28
+ super().__init__(max_chars=max_chars, max_nodes=max_nodes, max_depth=max_depth)
29
+ self._stack: list[_Frame] = []
30
+ self._last_was_data = False
31
+
32
+ def _open(self, tag: str, attrs: list[tuple[str, str | None]], *, closed: bool) -> None:
33
+ if len(self._stack) >= self.max_depth:
34
+ raise ParseLimitError('max_depth exceeded')
35
+ self._count()
36
+ self._last_was_data = False
37
+ frame = _Frame(tag, dict(attrs).get('href') if tag == 'a' else None)
38
+ if closed or tag in VOID_ELEMENTS:
39
+ self._end(frame)
40
+ else:
41
+ self._stack.append(frame)
42
+
43
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
44
+ self._open(tag, attrs, closed=False)
45
+
46
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
47
+ self._open(tag, attrs, closed=True)
48
+
49
+ def handle_endtag(self, tag: str) -> None:
50
+ for index in range(len(self._stack) - 1, -1, -1):
51
+ if self._stack[index].tag == tag:
52
+ frames = self._stack[index:]
53
+ del self._stack[index:]
54
+ for frame in reversed(frames):
55
+ self._end(frame)
56
+ self._last_was_data = False
57
+ break
58
+
59
+ def handle_data(self, data: str) -> None:
60
+ if not data:
61
+ return
62
+ if not self._last_was_data:
63
+ self._count()
64
+ self._last_was_data = True
65
+ if not any(frame.tag in {'script', 'style'} for frame in self._stack):
66
+ self._data(data)
67
+
68
+ def handle_comment(self, data: str) -> None:
69
+ self._count()
70
+ self._last_was_data = False
71
+
72
+ handle_decl = handle_comment
73
+ handle_pi = handle_comment
74
+ unknown_decl = handle_comment
75
+
76
+ def _data(self, data: str) -> None:
77
+ pass
78
+
79
+ def _end(self, frame: _Frame) -> None:
80
+ pass
81
+
82
+ def _finish(self) -> None:
83
+ frames, self._stack = self._stack, []
84
+ for frame in reversed(frames):
85
+ self._end(frame)
86
+
87
+
88
+ class LinkCollector(_StreamParser):
89
+ """Emit links when anchors close, including implicit closure at EOF.
90
+
91
+ Set collect=False with on_link to process output without retaining results.
92
+ URL resolution does not validate schemes or make URLs safe to fetch.
93
+ """
94
+
95
+ def __init__(self, *, base_url: str | None = None,
96
+ on_link: Callable[[Link], None] | None = None, collect: bool = True,
97
+ max_chars: int = 10_000_000, max_nodes: int = 100_000,
98
+ max_depth: int = 256) -> None:
99
+ if base_url is not None and not isinstance(base_url, str):
100
+ raise TypeError('base_url must be a string or None')
101
+ if on_link is not None and not callable(on_link):
102
+ raise TypeError('on_link must be callable or None')
103
+ if not isinstance(collect, bool):
104
+ raise TypeError('collect must be a bool')
105
+ super().__init__(max_chars=max_chars, max_nodes=max_nodes, max_depth=max_depth)
106
+ self._base_url = base_url
107
+ self._on_link = on_link
108
+ self._collect = collect
109
+ self._links: list[Link] = []
110
+
111
+ @property
112
+ def links(self) -> tuple[Link, ...]:
113
+ return tuple(self._links)
114
+
115
+ def _data(self, data: str) -> None:
116
+ for frame in self._stack:
117
+ if frame.href is not None:
118
+ frame.pieces.append(data)
119
+
120
+ def _end(self, frame: _Frame) -> None:
121
+ if frame.href is None:
122
+ return
123
+ url = urljoin(self._base_url, frame.href) if self._base_url is not None else frame.href
124
+ link = Link(frame.href, ''.join(frame.pieces), url)
125
+ if self._collect:
126
+ self._links.append(link)
127
+ if self._on_link is not None:
128
+ self._on_link(link)
129
+
130
+
131
+ class TextCollector(_StreamParser):
132
+ """Collect visible text or call on_text with incremental decoded chunks."""
133
+
134
+ def __init__(self, *, on_text: Callable[[str], None] | None = None,
135
+ collect: bool = True, max_chars: int = 10_000_000,
136
+ max_nodes: int = 100_000, max_depth: int = 256) -> None:
137
+ if on_text is not None and not callable(on_text):
138
+ raise TypeError('on_text must be callable or None')
139
+ if not isinstance(collect, bool):
140
+ raise TypeError('collect must be a bool')
141
+ super().__init__(max_chars=max_chars, max_nodes=max_nodes, max_depth=max_depth)
142
+ self._on_text = on_text
143
+ self._collect = collect
144
+ self._pieces: list[str] = []
145
+
146
+ @property
147
+ def text(self) -> str:
148
+ return ''.join(self._pieces)
149
+
150
+ def _data(self, data: str) -> None:
151
+ if self._collect:
152
+ self._pieces.append(data)
153
+ if self._on_text is not None:
154
+ self._on_text(data)
astree/extract.py ADDED
@@ -0,0 +1,138 @@
1
+ """Explicit, scoped HTML fields returning ordinary Python records."""
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from dataclasses import dataclass, field as dataclass_field
5
+ from typing import Literal
6
+
7
+ from .nodes import Node
8
+ from .selectors import _Grammar
9
+
10
+ __all__ = ['Field', 'ExtractionError', 'extract', 'extract_all']
11
+ _UNSET = object()
12
+ type ErrorReason = Literal['missing', 'multiple', 'attribute', 'conversion', 'default']
13
+
14
+
15
+ class ExtractionError(ValueError):
16
+ """A field failed; messages do not contain extracted source values."""
17
+
18
+ def __init__(self, field: str, reason: ErrorReason, *, record: int | None = None,
19
+ item: int | None = None) -> None:
20
+ self.field = field
21
+ self.reason = reason
22
+ self.record = record
23
+ self.item = item
24
+ context = f'record {record}, ' if record is not None else ''
25
+ super().__init__(f'{context}field {field!r}: {reason}')
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class Field:
30
+ """Read the receiver (selector=None) or scoped descendant elements.
31
+
32
+ Exactly one match is required unless many=True. Optional missing selections
33
+ use a default, a per-use factory, None for a scalar, or [] for a collection.
34
+ Optional missing/boolean attributes use the scalar fallback for that item.
35
+ Constants are returned by reference; use a factory for fresh mutable defaults.
36
+ """
37
+
38
+ selector: str | None = None
39
+ attribute: str | None = None
40
+ many: bool = False
41
+ required: bool = True
42
+ strip: bool = True
43
+ convert: Callable[[str], object] | None = None
44
+ default: object = dataclass_field(default=_UNSET, repr=False)
45
+ default_factory: Callable[[], object] | None = None
46
+
47
+ def __post_init__(self) -> None:
48
+ if self.selector is not None:
49
+ _Grammar(self.selector).parse()
50
+ if self.attribute is not None:
51
+ if not isinstance(self.attribute, str):
52
+ raise TypeError('attribute must be a string or None')
53
+ if not self.attribute:
54
+ raise ValueError('attribute must not be empty')
55
+ object.__setattr__(self, 'attribute', self.attribute.lower())
56
+ if any(not isinstance(getattr(self, name), bool) for name in ('many', 'required', 'strip')):
57
+ raise TypeError('many, required and strip must be bools')
58
+ for name in ('convert', 'default_factory'):
59
+ value = getattr(self, name)
60
+ if value is not None and not callable(value):
61
+ raise TypeError(f'{name} must be callable or None')
62
+ if self.default is not _UNSET and self.default_factory is not None:
63
+ raise ValueError('choose default or default_factory')
64
+ if self.required and (self.default is not _UNSET or self.default_factory is not None):
65
+ raise ValueError('defaults require required=False')
66
+
67
+
68
+ def _schema(fields: Mapping[str, Field]) -> dict[str, Field]:
69
+ if not isinstance(fields, Mapping):
70
+ raise TypeError('fields must be a mapping')
71
+ result = dict(fields)
72
+ if any(not isinstance(name, str) or not name or not isinstance(spec, Field)
73
+ for name, spec in result.items()):
74
+ raise TypeError('fields require nonempty string names and Field values')
75
+ return result
76
+
77
+
78
+ def _fallback(spec: Field, name: str, record: int | None, *, collection: bool = False) -> object:
79
+ if spec.default_factory is not None:
80
+ try:
81
+ return spec.default_factory()
82
+ except Exception as error:
83
+ raise ExtractionError(name, 'default', record=record) from error
84
+ if spec.default is not _UNSET:
85
+ return spec.default
86
+ return [] if collection else None
87
+
88
+
89
+ def _record(node: Node, fields: dict[str, Field], record: int | None) -> dict[str, object]:
90
+ result: dict[str, object] = {}
91
+ for name, spec in fields.items():
92
+ nodes = [node] if spec.selector is None else node.select(spec.selector)
93
+ if not nodes:
94
+ if spec.required:
95
+ raise ExtractionError(name, 'missing', record=record)
96
+ result[name] = _fallback(spec, name, record, collection=spec.many)
97
+ continue
98
+ if not spec.many and len(nodes) != 1:
99
+ raise ExtractionError(name, 'multiple', record=record)
100
+ values: list[object] = []
101
+ for index, target in enumerate(nodes):
102
+ item = index if spec.many else None
103
+ value = target.attrs.get(spec.attribute) if spec.attribute is not None else target.text
104
+ if value is None:
105
+ if spec.required:
106
+ raise ExtractionError(name, 'attribute', record=record, item=item)
107
+ values.append(_fallback(spec, name, record))
108
+ continue
109
+ if spec.strip:
110
+ value = value.strip()
111
+ if spec.convert is not None:
112
+ try:
113
+ values.append(spec.convert(value))
114
+ except Exception as error:
115
+ raise ExtractionError(name, 'conversion', record=record, item=item) from error
116
+ else:
117
+ values.append(value)
118
+ result[name] = values if spec.many else values[0]
119
+ return result
120
+
121
+
122
+ def extract(node: Node, fields: Mapping[str, Field]) -> dict[str, object]:
123
+ """Extract one record; no coercion occurs without a conversion callback."""
124
+ if not isinstance(node, Node):
125
+ raise TypeError('node must be a Node')
126
+ return _record(node, _schema(fields), None)
127
+
128
+
129
+ def extract_all(node: Node, selector: str, fields: Mapping[str, Field]) -> list[dict[str, object]]:
130
+ """Extract records from selected descendants; error.record is zero-based.
131
+
132
+ This collects every record into a list. Use explicit iteration and extract()
133
+ when the caller needs incremental consumption or custom error handling.
134
+ """
135
+ if not isinstance(node, Node):
136
+ raise TypeError('node must be a Node')
137
+ schema = _schema(fields)
138
+ return [_record(target, schema, index) for index, target in enumerate(node.select(selector))]
astree/nodes.py ADDED
@@ -0,0 +1,145 @@
1
+ """Read-only HTML tree views and iterative extraction."""
2
+
3
+ from collections.abc import Iterator, Mapping
4
+ from types import MappingProxyType
5
+ from typing import Literal
6
+
7
+ type NodeKind = Literal['document', 'element', 'text', 'comment', 'declaration', 'processing_instruction']
8
+ type AttributeFilter = str | bool | None
9
+
10
+
11
+ class Node:
12
+ """A parser-owned node; public child and attribute collections are read-only."""
13
+
14
+ __slots__ = ('_kind', '_tag', '_attrs', '_parts', '_parent', '_children')
15
+
16
+ def __init__(
17
+ self, kind: NodeKind, *, tag: str | None = None,
18
+ attrs: Mapping[str, str | None] | None = None, data: str = '',
19
+ parent: Node | None = None,
20
+ ) -> None:
21
+ self._kind = kind
22
+ self._tag = tag
23
+ self._attrs = MappingProxyType(dict(attrs or {}))
24
+ self._parts = [data] if data else []
25
+ self._parent = parent
26
+ self._children: list[Node] = []
27
+
28
+ @property
29
+ def kind(self) -> NodeKind:
30
+ return self._kind
31
+
32
+ @property
33
+ def tag(self) -> str | None:
34
+ return self._tag
35
+
36
+ @property
37
+ def attrs(self) -> Mapping[str, str | None]:
38
+ return self._attrs
39
+
40
+ @property
41
+ def data(self) -> str:
42
+ return ''.join(self._parts)
43
+
44
+ @property
45
+ def parent(self) -> Node | None:
46
+ return self._parent
47
+
48
+ @property
49
+ def children(self) -> tuple[Node, ...]:
50
+ return tuple(self._children)
51
+
52
+ def descendants(self) -> Iterator[Node]:
53
+ """Visit every descendant in source order, excluding this node."""
54
+ pending = list(reversed(self._children))
55
+ while pending:
56
+ node = pending.pop()
57
+ yield node
58
+ pending.extend(reversed(node._children))
59
+
60
+ @staticmethod
61
+ def _filters(
62
+ tag: str | None, attrs: Mapping[str, AttributeFilter] | None,
63
+ attributes: Mapping[str, AttributeFilter],
64
+ ) -> tuple[str | None, dict[str, AttributeFilter]]:
65
+ if tag is not None and not isinstance(tag, str):
66
+ raise TypeError('tag must be a string or None')
67
+ filters = dict(attrs or {})
68
+ filters.update(attributes)
69
+ for name, value in filters.items():
70
+ if not isinstance(name, str) or not isinstance(value, (str, bool, type(None))):
71
+ raise TypeError('attribute filters require string names and str, bool, or None values')
72
+ return tag.lower() if tag is not None else None, filters
73
+
74
+ @staticmethod
75
+ def _matches(node: Node, tag: str | None, filters: Mapping[str, AttributeFilter]) -> bool:
76
+ if node.kind != 'element' or (tag is not None and node.tag != tag):
77
+ return False
78
+ for name, expected in filters.items():
79
+ token_match = name == 'class_'
80
+ key = 'class' if token_match else name.lower()
81
+ present = key in node.attrs
82
+ if expected is True:
83
+ if not present:
84
+ return False
85
+ elif expected is False:
86
+ if present:
87
+ return False
88
+ elif not present:
89
+ return False
90
+ elif token_match and isinstance(expected, str):
91
+ if expected not in (node.attrs[key] or '').split():
92
+ return False
93
+ elif node.attrs[key] != expected:
94
+ return False
95
+ return True
96
+
97
+ def find_all(
98
+ self, tag: str | None = None, *, attrs: Mapping[str, AttributeFilter] | None = None,
99
+ **attributes: AttributeFilter,
100
+ ) -> list[Node]:
101
+ """Find descendant elements using exact attributes or class_ token matching."""
102
+ tag, filters = self._filters(tag, attrs, attributes)
103
+ return [node for node in self.descendants() if self._matches(node, tag, filters)]
104
+
105
+ def find(
106
+ self, tag: str | None = None, *, attrs: Mapping[str, AttributeFilter] | None = None,
107
+ **attributes: AttributeFilter,
108
+ ) -> Node | None:
109
+ """Return the first matching descendant element, or None."""
110
+ tag, filters = self._filters(tag, attrs, attributes)
111
+ return next((node for node in self.descendants() if self._matches(node, tag, filters)), None)
112
+
113
+ def get_text(self, separator: str = '', *, strip: bool = False, include_hidden: bool = False) -> str:
114
+ """Join text pieces, omitting script/style subtrees unless requested."""
115
+ if not isinstance(separator, str):
116
+ raise TypeError('separator must be a string')
117
+ pieces: list[str] = []
118
+ pending = [self]
119
+ while pending:
120
+ node = pending.pop()
121
+ if node.kind == 'text':
122
+ piece = node.data.strip() if strip else node.data
123
+ if piece:
124
+ pieces.append(piece)
125
+ elif include_hidden or node.tag not in {'script', 'style'}:
126
+ pending.extend(reversed(node._children))
127
+ return separator.join(pieces)
128
+
129
+ @property
130
+ def text(self) -> str:
131
+ return self.get_text()
132
+
133
+ def select(self, selector: str) -> list[Node]:
134
+ """Select descendants with the documented CSS subset."""
135
+ from .selectors import select
136
+ return select(self, selector)
137
+
138
+ def select_one(self, selector: str) -> Node | None:
139
+ """Return the first selected descendant, or None."""
140
+ from .selectors import select
141
+ result = select(self, selector, first=True)
142
+ return result[0] if result else None
143
+
144
+ def __repr__(self) -> str:
145
+ return f'Node(kind={self.kind!r}, tag={self.tag!r})'
astree/parser.py ADDED
@@ -0,0 +1,158 @@
1
+ """Bounded, incremental HTMLParser adapters."""
2
+
3
+ from html.parser import HTMLParser
4
+
5
+ from .nodes import Node
6
+
7
+ VOID_ELEMENTS = frozenset({'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
8
+ 'link', 'meta', 'param', 'source', 'track', 'wbr'})
9
+
10
+
11
+ class ParseLimitError(ValueError):
12
+ """An explicit parsing resource limit was exceeded."""
13
+
14
+
15
+ def _positive(name: str, value: int) -> int:
16
+ if isinstance(value, bool) or not isinstance(value, int):
17
+ raise TypeError(f'{name} must be a positive integer')
18
+ if value <= 0:
19
+ raise ValueError(f'{name} must be a positive integer')
20
+ return value
21
+
22
+
23
+ class _BoundedParser(HTMLParser):
24
+ """Shared single-use parser lifecycle; subclasses retain their own output."""
25
+
26
+ def __init__(self, *, max_chars: int = 10_000_000, max_nodes: int = 100_000,
27
+ max_depth: int = 256) -> None:
28
+ self.max_chars = _positive('max_chars', max_chars)
29
+ self.max_nodes = _positive('max_nodes', max_nodes)
30
+ self.max_depth = _positive('max_depth', max_depth)
31
+ self._chars = 0
32
+ self._events = 0
33
+ self._closed = False
34
+ self._failed = False
35
+ super().__init__(convert_charrefs=True)
36
+
37
+ def reset(self) -> None:
38
+ # HTMLParser calls reset during construction. Reuse would desynchronize
39
+ # subclass output and input buffers, so it is deliberately unsupported.
40
+ if hasattr(self, 'rawdata'):
41
+ raise RuntimeError('parsers are single-use; construct a new parser')
42
+ super().reset()
43
+
44
+ def _available(self) -> None:
45
+ if self._closed or self._failed:
46
+ raise RuntimeError('parser is closed or failed')
47
+
48
+ def _count(self) -> None:
49
+ self._events += 1
50
+ if self._events > self.max_nodes:
51
+ raise ParseLimitError('max_nodes exceeded')
52
+
53
+ def feed(self, data: str) -> None:
54
+ self._available()
55
+ if not isinstance(data, str):
56
+ raise TypeError('HTML input must be a string')
57
+ try:
58
+ if self._chars + len(data) > self.max_chars:
59
+ raise ParseLimitError('max_chars exceeded')
60
+ self._chars += len(data)
61
+ super().feed(data)
62
+ except BaseException:
63
+ self._failed = True
64
+ raise
65
+
66
+ def close(self) -> None:
67
+ if self._closed:
68
+ return
69
+ self._available()
70
+ try:
71
+ super().close()
72
+ self._finish()
73
+ except BaseException:
74
+ self._failed = True
75
+ raise
76
+ self._closed = True
77
+
78
+ def _finish(self) -> None:
79
+ pass
80
+
81
+
82
+ class Document(Node):
83
+ """A document tree that never performs network I/O or executes markup."""
84
+
85
+ def __init__(self) -> None:
86
+ super().__init__('document')
87
+
88
+ @classmethod
89
+ def from_html(cls, source: str, *, max_chars: int = 10_000_000,
90
+ max_nodes: int = 100_000, max_depth: int = 256) -> Document:
91
+ parser = DocumentParser(max_chars=max_chars, max_nodes=max_nodes, max_depth=max_depth)
92
+ parser.feed(source)
93
+ parser.close()
94
+ return parser.document
95
+
96
+ @property
97
+ def title(self) -> str | None:
98
+ node = self.find('title')
99
+ return node.text.strip() if node is not None else None
100
+
101
+
102
+ class DocumentParser(_BoundedParser):
103
+ """Incrementally build a read-only tree; inspect document after close()."""
104
+
105
+ def __init__(self, *, max_chars: int = 10_000_000, max_nodes: int = 100_000,
106
+ max_depth: int = 256) -> None:
107
+ super().__init__(max_chars=max_chars, max_nodes=max_nodes, max_depth=max_depth)
108
+ self.document = Document()
109
+ self._stack: list[Node] = [self.document]
110
+
111
+ def _append(self, kind, *, tag=None, attrs=None, data='') -> Node:
112
+ self._count()
113
+ parent = self._stack[-1]
114
+ node = Node(kind, tag=tag, attrs=attrs, data=data, parent=parent)
115
+ parent._children.append(node)
116
+ return node
117
+
118
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
119
+ if len(self._stack) > self.max_depth:
120
+ raise ParseLimitError('max_depth exceeded')
121
+ node = self._append('element', tag=tag, attrs=dict(attrs))
122
+ if tag not in VOID_ELEMENTS:
123
+ self._stack.append(node)
124
+
125
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
126
+ if len(self._stack) > self.max_depth:
127
+ raise ParseLimitError('max_depth exceeded')
128
+ self._append('element', tag=tag, attrs=dict(attrs))
129
+
130
+ def handle_endtag(self, tag: str) -> None:
131
+ for index in range(len(self._stack) - 1, 0, -1):
132
+ if self._stack[index].tag == tag:
133
+ del self._stack[index:]
134
+ break
135
+
136
+ def handle_data(self, data: str) -> None:
137
+ if not data:
138
+ return
139
+ children = self._stack[-1]._children
140
+ if children and children[-1].kind == 'text':
141
+ children[-1]._parts.append(data)
142
+ else:
143
+ self._append('text', data=data)
144
+
145
+ def handle_comment(self, data: str) -> None:
146
+ self._append('comment', data=data)
147
+
148
+ def handle_decl(self, decl: str) -> None:
149
+ self._append('declaration', data=decl)
150
+
151
+ def handle_pi(self, data: str) -> None:
152
+ self._append('processing_instruction', data=data)
153
+
154
+ def unknown_decl(self, data: str) -> None:
155
+ self._append('declaration', data=data)
156
+
157
+ def _finish(self) -> None:
158
+ self._stack = [self.document]
astree/py.typed ADDED
File without changes
astree/selectors.py ADDED
@@ -0,0 +1,172 @@
1
+ """A deliberately small, strictly validated CSS selector subset."""
2
+
3
+ from dataclasses import dataclass
4
+ import re
5
+
6
+ from .nodes import Node
7
+
8
+ _IDENT = re.compile(r'-?[_a-zA-Z][_a-zA-Z0-9-]*')
9
+
10
+
11
+ class SelectorSyntaxError(ValueError):
12
+ """A selector is malformed or uses syntax outside the supported subset."""
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class _Compound:
17
+ tag: str | None
18
+ ids: tuple[str, ...]
19
+ classes: tuple[str, ...]
20
+ attrs: tuple[tuple[str, str | None], ...]
21
+
22
+ def matches(self, node: Node) -> bool:
23
+ if node.kind != 'element' or (self.tag is not None and node.tag != self.tag):
24
+ return False
25
+ classes = (node.attrs.get('class') or '').split()
26
+ return (all(node.attrs.get('id') == value for value in self.ids)
27
+ and all(value in classes for value in self.classes)
28
+ and all(name in node.attrs and (value is None or node.attrs[name] == value)
29
+ for name, value in self.attrs))
30
+
31
+
32
+ class _Grammar:
33
+ def __init__(self, source: str) -> None:
34
+ if not isinstance(source, str):
35
+ raise TypeError('selector must be a string')
36
+ if len(source) > 4096:
37
+ raise SelectorSyntaxError('selectors are limited to 4096 characters')
38
+ if '\\' in source:
39
+ raise SelectorSyntaxError('CSS escapes are not supported')
40
+ self.source = source
41
+ self.pos = 0
42
+
43
+ def peek(self) -> str:
44
+ return self.source[self.pos:self.pos + 1]
45
+
46
+ def space(self) -> bool:
47
+ start = self.pos
48
+ while self.peek() and self.peek().isspace():
49
+ self.pos += 1
50
+ return self.pos > start
51
+
52
+ def identifier(self) -> str:
53
+ match = _IDENT.match(self.source, self.pos)
54
+ if match is None:
55
+ raise SelectorSyntaxError(f'expected an ASCII identifier at position {self.pos}')
56
+ self.pos = match.end()
57
+ return match.group()
58
+
59
+ def attribute(self) -> tuple[str, str | None]:
60
+ self.pos += 1
61
+ self.space()
62
+ name = self.identifier().lower()
63
+ self.space()
64
+ value = None
65
+ if self.peek() == '=':
66
+ self.pos += 1
67
+ self.space()
68
+ if self.peek() in {'"', "'"}:
69
+ quote = self.peek()
70
+ self.pos += 1
71
+ end = self.source.find(quote, self.pos)
72
+ if end == -1:
73
+ raise SelectorSyntaxError('unterminated attribute value')
74
+ value = self.source[self.pos:end]
75
+ self.pos = end + 1
76
+ if any(char in value for char in '\n\r\f'):
77
+ raise SelectorSyntaxError('newlines in attribute strings are not supported')
78
+ else:
79
+ value = self.identifier()
80
+ self.space()
81
+ if self.peek() != ']':
82
+ raise SelectorSyntaxError(f'expected ] at position {self.pos}')
83
+ self.pos += 1
84
+ return name, value
85
+
86
+ def compound(self) -> _Compound:
87
+ start = self.pos
88
+ tag = None
89
+ ids: list[str] = []
90
+ classes: list[str] = []
91
+ attrs: list[tuple[str, str | None]] = []
92
+ if self.peek() == '*':
93
+ self.pos += 1
94
+ elif _IDENT.match(self.source, self.pos):
95
+ tag = self.identifier().lower()
96
+ while self.peek() in {'#', '.', '['}:
97
+ prefix = self.peek()
98
+ if prefix == '[':
99
+ attrs.append(self.attribute())
100
+ else:
101
+ self.pos += 1
102
+ (ids if prefix == '#' else classes).append(self.identifier())
103
+ if self.pos == start:
104
+ raise SelectorSyntaxError(f'expected a selector at position {self.pos}')
105
+ return _Compound(tag, tuple(ids), tuple(classes), tuple(attrs))
106
+
107
+ def parse(self) -> list[list[tuple[str, _Compound]]]:
108
+ groups: list[list[tuple[str, _Compound]]] = []
109
+ self.space()
110
+ group = [('', self.compound())]
111
+ count = 1
112
+ while True:
113
+ spaced = self.space()
114
+ char = self.peek()
115
+ if not char:
116
+ groups.append(group)
117
+ return groups
118
+ if char == ',':
119
+ groups.append(group)
120
+ group = []
121
+ relation = ''
122
+ self.pos += 1
123
+ self.space()
124
+ elif char == '>':
125
+ relation = 'child'
126
+ self.pos += 1
127
+ self.space()
128
+ elif spaced:
129
+ relation = 'descendant'
130
+ else:
131
+ raise SelectorSyntaxError(f'unsupported syntax at position {self.pos}')
132
+ count += 1
133
+ if count > 64:
134
+ raise SelectorSyntaxError('selectors are limited to 64 compound parts')
135
+ group.append((relation, self.compound()))
136
+
137
+
138
+ def _matches(node: Node, group: list[tuple[str, _Compound]], scope: Node) -> bool:
139
+ # Backtrack descendant alternatives: the nearest matching ancestor may fail
140
+ # an earlier child constraint. Memoization bounds repeated ancestor states.
141
+ pending = [(node, len(group) - 1)]
142
+ visited: set[tuple[Node, int]] = set()
143
+ while pending:
144
+ current, index = pending.pop()
145
+ if (current, index) in visited:
146
+ continue
147
+ visited.add((current, index))
148
+ relation, compound = group[index]
149
+ if not compound.matches(current):
150
+ continue
151
+ if index == 0:
152
+ return True
153
+ if current is scope:
154
+ continue
155
+ parent = current.parent
156
+ while parent is not None:
157
+ pending.append((parent, index - 1))
158
+ if relation == 'child' or parent is scope:
159
+ break
160
+ parent = parent.parent
161
+ return False
162
+
163
+
164
+ def select(scope: Node, selector: str, *, first: bool = False) -> list[Node]:
165
+ groups = _Grammar(selector).parse()
166
+ result: list[Node] = []
167
+ for node in scope.descendants():
168
+ if node.kind == 'element' and any(_matches(node, group, scope) for group in groups):
169
+ result.append(node)
170
+ if first:
171
+ break
172
+ return result
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: astree
3
+ Version: 0.1.0
4
+ Summary: Dependency-free HTML trees and extraction powered by html.parser
5
+ Author: ellainng
6
+ Author-email: ellainng <299958882+ellainng@users.noreply.github.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.14
16
+ Project-URL: Homepage, https://github.com/wispsy/astree
17
+ Project-URL: Repository, https://github.com/wispsy/astree
18
+ Project-URL: Issues, https://github.com/wispsy/astree/issues
19
+ Description-Content-Type: text/markdown
20
+
21
+ # astree
22
+
23
+ Dependency-free HTML tree parsing, CSS selector querying, and structured record extraction powered by Python's `html.parser`.
24
+
25
+ Requires Python 3.14+.
26
+
27
+ ## Highlights
28
+
29
+ - **Dependency-Free**: Built strictly on Python standard library primitives.
30
+ - **Full DOM Navigation**: Navigable `Document` and `Node` tree with parent/child links and attributes.
31
+ - **CSS Selectors**: Fast CSS selector queries (`select`, `select_one`).
32
+ - **Streaming Collectors**: Single-pass `LinkCollector` and `TextCollector` with bounds protection.
33
+ - **Declarative Schema Extraction**: `extract` and `extract_all` using `Field` definitions with type converters and cardinality validation.
34
+ - **Configurable Limits**: Protect against deeply nested tags or resource exhaustion (`max_depth`, `max_nodes`, `max_chars`).
35
+
36
+ ## Installation
37
+
38
+ ```sh
39
+ pip install astree
40
+ ```
41
+
42
+ For development:
43
+
44
+ ```sh
45
+ git clone https://github.com/wispsy/astree.git
46
+ cd astree
47
+ uv sync
48
+ ```
49
+
50
+ ## Quick Start
51
+
52
+ ### Basic Parsing & Queries
53
+
54
+ ```python
55
+ from astree import Document
56
+
57
+ doc = Document.from_html('''
58
+ <!doctype html>
59
+ <html>
60
+ <head><title>My Article</title></head>
61
+ <body>
62
+ <article class="card" data-id="42">
63
+ <h2>Headline</h2>
64
+ <a href="/post/42">Read story</a>
65
+ </article>
66
+ </body>
67
+ </html>
68
+ ''')
69
+
70
+ assert doc.title == 'My Article'
71
+ card = doc.select_one('article.card')
72
+ assert card is not None
73
+ assert card.attrs['data-id'] == '42'
74
+
75
+ link = card.find('a', href=True)
76
+ assert link is not None
77
+ assert link.attrs['href'] == '/post/42'
78
+ assert card.get_text(' ', strip=True) == 'Headline Read story'
79
+ ```
80
+
81
+ ### Structured Record Extraction
82
+
83
+ ```python
84
+ from astree import Document, Field, extract_all
85
+
86
+ records = extract_all(doc, 'article.card', {
87
+ 'id': Field(attribute='data-id', convert=int),
88
+ 'headline': Field('h2'),
89
+ 'url': Field('a', attribute='href'),
90
+ })
91
+
92
+ # [{'id': 42, 'headline': 'Headline', 'url': '/post/42'}]
93
+ ```
94
+
95
+ ## Documentation
96
+
97
+ See [docs/api.md](docs/api.md) for full API details, selector capabilities, collectors, and structured extraction specifications.
98
+
99
+ ## Running Tests
100
+
101
+ ```sh
102
+ python -m unittest discover -s tests -v
103
+ ```
104
+
105
+ ## License
106
+
107
+ MIT
@@ -0,0 +1,11 @@
1
+ astree/__init__.py,sha256=cZnAe9MSHz3wQbf0m9zc7aRIajR_1wtWe7om3rSH3qw,608
2
+ astree/collectors.py,sha256=tHgPLtiWwwZHi7_eRsNXmDyhMVtrXKRP8MX6ENby2qY,5445
3
+ astree/extract.py,sha256=-N3gRqLiD4-XSGXgJGmIsz1bRjlen_t6BTN_m2AR6es,5997
4
+ astree/nodes.py,sha256=akufzADDO78Pzh-2QkXOgYvY5jGc4JFGwfy-iYI08EM,5490
5
+ astree/parser.py,sha256=UMcE3Ne4xDDslhs-oGBBOdN96SzJaeAzPcqTxk0HmA8,5489
6
+ astree/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ astree/selectors.py,sha256=HP7rxkhg1HMZEzVFe1H4JqIuzNAz56PD2AChryancMI,6082
8
+ astree-0.1.0.dist-info/licenses/LICENSE,sha256=pAeG0B2X4uxLV9iLuLxEEk4YSQamiNrWf4E6P5vch1Q,1069
9
+ astree-0.1.0.dist-info/WHEEL,sha256=MWx7psoF7KNST9G72pGtiBYuaXRXQXBk9pw5R6FzYko,80
10
+ astree-0.1.0.dist-info/METADATA,sha256=Vkgv-Jeb_2z39QOzfDzuIrt07MLmqb6jiWziZNT0DFE,2883
11
+ astree-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.7
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Outside Labs
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.