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 +25 -0
- astree/collectors.py +154 -0
- astree/extract.py +138 -0
- astree/nodes.py +145 -0
- astree/parser.py +158 -0
- astree/py.typed +0 -0
- astree/selectors.py +172 -0
- astree-0.1.0.dist-info/METADATA +107 -0
- astree-0.1.0.dist-info/RECORD +11 -0
- astree-0.1.0.dist-info/WHEEL +4 -0
- astree-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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.
|