xmlstreamer-ng 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xmlstreamer_ng-1.0.0/LICENSE +22 -0
- xmlstreamer_ng-1.0.0/PKG-INFO +481 -0
- xmlstreamer_ng-1.0.0/README.md +451 -0
- xmlstreamer_ng-1.0.0/assets/banner.png +0 -0
- xmlstreamer_ng-1.0.0/native/Cargo.lock +1175 -0
- xmlstreamer_ng-1.0.0/native/Cargo.toml +28 -0
- xmlstreamer_ng-1.0.0/native/src/lib.rs +591 -0
- xmlstreamer_ng-1.0.0/native/src/parser.rs +271 -0
- xmlstreamer_ng-1.0.0/native/src/scan.rs +274 -0
- xmlstreamer_ng-1.0.0/native/src/splitter.rs +138 -0
- xmlstreamer_ng-1.0.0/native/src/stream_parser.rs +171 -0
- xmlstreamer_ng-1.0.0/native/src/tokenizer.rs +891 -0
- xmlstreamer_ng-1.0.0/native/src/xml.rs +1396 -0
- xmlstreamer_ng-1.0.0/pyproject.toml +70 -0
- xmlstreamer_ng-1.0.0/xmlstreamer/__init__.py +1757 -0
- xmlstreamer_ng-1.0.0/xmlstreamer/__main__.py +156 -0
- xmlstreamer_ng-1.0.0/xmlstreamer/py.typed +0 -0
- xmlstreamer_ng-1.0.0/xmlstreamer/rypipe_adapter.py +85 -0
- xmlstreamer_ng-1.0.0/xmlstreamer/sinks.py +19 -0
- xmlstreamer_ng-1.0.0/xmlstreamer/source.py +182 -0
- xmlstreamer_ng-1.0.0/xmlstreamer/stages.py +21 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Carlos A. Planchón
|
|
4
|
+
Copyright (c) 2026 emiliano-go (xmlstreamer-ng fork)
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xmlstreamer-ng
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
5
|
+
Classifier: Intended Audience :: Developers
|
|
6
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
7
|
+
Classifier: Topic :: Text Processing :: Markup :: XML
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
14
|
+
Requires-Dist: chardet>=5.2
|
|
15
|
+
Requires-Dist: requests>=2.33
|
|
16
|
+
Requires-Dist: rypipe>=0.4.0
|
|
17
|
+
Requires-Dist: pyarrow>=15
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Summary: Stream large or malformed XML feeds from HTTP, HTTPS, FTP, local files or standard input without loading them into memory.
|
|
20
|
+
Keywords: xml,streaming,parser,lightweight,http,https,ftp,stdin
|
|
21
|
+
Home-Page: https://github.com/emiliano-go/xmlstreamer-ng
|
|
22
|
+
Author-email: "Carlos A. Planchón" <carlosandresplanchonprestes@gmail.com>
|
|
23
|
+
License-Expression: MIT
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
26
|
+
Project-URL: Homepage, https://github.com/emiliano-go/xmlstreamer-ng
|
|
27
|
+
Project-URL: Issues, https://github.com/emiliano-go/xmlstreamer-ng/issues
|
|
28
|
+
Project-URL: Repository, https://github.com/emiliano-go/xmlstreamer-ng
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
# xmlstreamer-ng
|
|
33
|
+
|
|
34
|
+
[](https://pypi.org/project/xmlstreamer-ng/)
|
|
35
|
+
[](https://pypi.org/project/xmlstreamer-ng/)
|
|
36
|
+
[](https://opensource.org/licenses/MIT)
|
|
37
|
+
|
|
38
|
+
**Stream huge, messy XML feeds into Python records - without loading the entire document into memory or hiding partial failures.**
|
|
39
|
+
|
|
40
|
+
**xmlstreamer-ng** is a Python library designed for efficient, memory-friendly streaming and parsing of large XML feeds from various sources, including compressed formats. It supports decompression, character encoding detection, tag-based itemization, and optional item filtering, making it ideal for handling real-time XML data feeds, large datasets, and complex structured content.
|
|
41
|
+
|
|
42
|
+
## Origins
|
|
43
|
+
|
|
44
|
+
xmlstreamer-ng is based on [carlosplanchon/xmlstreamer](https://github.com/carlosplanchon/xmlstreamer), the original project by Carlos A. Planchón (MIT), and keeps its streaming, item-based approach to parsing large or malformed XML feeds. Its parsing engine is built on [emiliano-go/rypipe](https://github.com/emiliano-go/rypipe) ([rypipe-core](https://crates.io/crates/rypipe-core)).
|
|
45
|
+
|
|
46
|
+
## Why xmlstreamer-ng?
|
|
47
|
+
|
|
48
|
+
Large XML feeds retrieved from remote systems can be malformed, incorrectly encoded, or interrupted in transit. xmlstreamer-ng processes them incrementally and isolates failures when corruption is contained within an item, while reporting partial failures explicitly.
|
|
49
|
+
|
|
50
|
+
With a strict parser, one broken record invalidates the entire document. With an aggressive recovery parser, malformed input may be silently rewritten into data that was never actually there. xmlstreamer-ng is built for the space between those two behaviors:
|
|
51
|
+
|
|
52
|
+
- process large XML documents without loading them entirely into memory;
|
|
53
|
+
- deliver every intact item, before and after localized corruption;
|
|
54
|
+
- never repair damaged XML into records that were not in the feed (with one judgement call that cannot be made for you - markup that opens and never closes - left in your hands and documented below);
|
|
55
|
+
- expose partial failures explicitly instead of presenting incomplete processing as success.
|
|
56
|
+
|
|
57
|
+
It is intended for XML ingestion, archival processing, ETL and migration workflows where bounded memory, conservative recovery and explicit failure semantics matter.
|
|
58
|
+
|
|
59
|
+
### When not to use it
|
|
60
|
+
|
|
61
|
+
- If your XML is trusted, consistently well-formed and raw speed is the primary concern, `lxml.etree.iterparse` is the simpler, battle-tested tool - though on item-shaped feeds the benchmark below shows it no longer ahead.
|
|
62
|
+
- If your data lives in XML attributes (`<book id="123" year="1998"/>`), this library will not extract it: items are built from element text only.
|
|
63
|
+
- If your separator tag legitimately nests inside itself, that is outside this library's documented contract.
|
|
64
|
+
|
|
65
|
+
xmlstreamer-ng is for large or unreliable inputs where conservative recovery, bounded memory and explicit partial-failure semantics matter more than maximum parser throughput.
|
|
66
|
+
|
|
67
|
+
## Features
|
|
68
|
+
|
|
69
|
+
- **Streamed Data Parsing**: Parses XML data directly from HTTP, HTTPS, or FTP sources - or from a local file or any binary stream (`source=`) - without loading the entire file into memory.
|
|
70
|
+
- **GZIP Decompression Support**: Seamlessly handles GZIP-compressed XML feeds, including multi-member (concatenated) gzip.
|
|
71
|
+
- **Encoding Detection**: Automatically detects character encodings on both plain and GZIP streams (UTF-16, Latin-1, etc.) and transcodes them to UTF-8 on the fly. Valid UTF-8 input is never altered. Detection reads the head of the feed, so bytes that contradict it much later cannot be transcoded: those are replaced with `U+FFFD`, warned once per run and counted in the closing stats.
|
|
72
|
+
- **Customizable Item Tokenization**: Uses a byte-level tokenizer to isolate items and parses each item independently in the Rust engine. Separator tags inside comments, CDATA, processing instructions or DOCTYPE declarations are ignored, so markup can never open or close an item - nor invent one. Attribute values may contain `>` (only `<` and `&` are illegal there) without confusing the scan, and buffer or chunk sizes never change what comes out. One contract: the separator tag must not nest inside itself (no `<item>` containing another `<item>`). One knob for the one case with no universal answer, a section that never closes: `Sections(on_limit=...)` chooses between reading it as content past `max_size` (the default: bounded damage) and discarding it whatever follows (never a record, at the price of the rest of the feed).
|
|
73
|
+
- **Configurable Runtime and Buffering**: Includes configurable buffer sizes and runtime limits, allowing you to tailor performance to your application's needs. `buffer_size` is the initial read buffer, not a size limit: items larger than the buffer are still parsed (the buffer grows as needed to fit the current item). `max_running_time` is a per-run budget measured on a monotonic clock, so an NTP step or a daylight saving shift can never cut a run short. It covers the whole run, acquisition included: the download, the encoding sample and the scan all check it. What it cannot do is interrupt a read or a parse already in flight, so a run can overshoot by one read (up to 64 KiB of network, however long that takes to arrive) or by one item's parse.
|
|
74
|
+
- **Download Timeouts**: `Transport(timeout=(connect, read))` in seconds, default `(30, 300)`. The read timeout is the maximum silence between bytes on the streamed download, not a total duration, so huge-but-flowing feeds are never cut while stalled connections fail fast. Pass `Transport(timeout=None)` to disable.
|
|
75
|
+
- **Authentication and Proxies**: Basic, Digest, Bearer and API-key-header authentication, plus requests-style proxy support.
|
|
76
|
+
- **Two Download Modes**: spool to a temporary file (default) or stream straight from the network, with explicit error semantics (`FeedInterruptedError`) and deterministic resource release via `close()` / context manager.
|
|
77
|
+
- **Flexible Filtering**: pass any callable as `item_filter`; it receives each flat item dict and its truthy/falsy return keeps or drops the item. Note that items are built from element text only: XML attributes (like `id` in `<book id="123">`) are not extracted.
|
|
78
|
+
- **Typed**: ships PEP 561 type hints (`py.typed`), so type checkers and editors see the whole public API.
|
|
79
|
+
- **Property-tested**: beyond the example-based suite, generative tests (Hypothesis) verify the core invariants over thousands of generated feeds - item isolation under corruption, chunking invariance, and crash-freedom on arbitrary bytes.
|
|
80
|
+
|
|
81
|
+
## Installation
|
|
82
|
+
|
|
83
|
+
Install with uv or pip:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
uv add xmlstreamer-ng # or: pip install xmlstreamer-ng
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Requires Python 3.11 or newer. The parsing engine is a Rust extension
|
|
90
|
+
(built on [rypipe-core](https://crates.io/crates/rypipe-core)): prebuilt wheels
|
|
91
|
+
need no toolchain, but building from source needs a Rust toolchain (1.78+).
|
|
92
|
+
|
|
93
|
+
## Usage
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from xmlstreamer import StreamInterpreter
|
|
97
|
+
|
|
98
|
+
interpreter = StreamInterpreter(
|
|
99
|
+
url="https://example.com/large-feed.xml",
|
|
100
|
+
separator_tag="item",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
for item in interpreter:
|
|
104
|
+
print(item) # each item is a flat {path: text} dict
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
That is the whole program: gzip, character encodings and malformed items are handled by default, and the feed is never loaded whole into memory. Tuning knobs (`buffer_size`, `max_running_time`, `Transport`, filters, nested output) exist when you need them and are documented below.
|
|
108
|
+
|
|
109
|
+
The interpreter is reusable configuration; iterating it opens a run (`FeedRun`) that owns one pass over the feed. Runs are independent, so nested loops and retries over the same interpreter are ordinary code, and the interpreter answers for the last one through `last_run` and the `stats_*` properties.
|
|
110
|
+
|
|
111
|
+
### Columnar and parallel ingestion (rypipe adapter)
|
|
112
|
+
|
|
113
|
+
For local files, the same Rust engine also exposes a columnar/parallel API
|
|
114
|
+
built on [rypipe](https://github.com/emiliano-go/rypipe), producing Arrow
|
|
115
|
+
tables:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from xmlstreamer import XmlSource, CastTypes, FilterRows, col
|
|
119
|
+
|
|
120
|
+
table = XmlSource("feed.xml", separator_tag="item").to_arrow()
|
|
121
|
+
|
|
122
|
+
df = (
|
|
123
|
+
XmlSource("feed.xml", separator_tag="item", schema=["id", "title"])
|
|
124
|
+
| CastTypes({"id": int})
|
|
125
|
+
| FilterRows(col("title").is_not_null())
|
|
126
|
+
).to_pandas()
|
|
127
|
+
|
|
128
|
+
for batch in XmlSource("big.xml", separator_tag="item").iter_record_batches(threads=8):
|
|
129
|
+
writer.write_batch(batch)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`XmlSource` follows the rypipe adapter contract (`schema`, `field_types`,
|
|
133
|
+
`drop_fields`, `filter`, `engine`/`threads`, `memory`), and importing
|
|
134
|
+
`xmlstreamer` registers it so `rypipe.read("feed.xml", separator_tag="item")`
|
|
135
|
+
works too.
|
|
136
|
+
|
|
137
|
+
Local data uses the same pipeline: pass `source=` instead of `url=` - a
|
|
138
|
+
filesystem path or any binary file-like object (`io.BytesIO(...)` in a unit
|
|
139
|
+
test, `sys.stdin.buffer` in a pipe). Gzip and encoding detection still apply,
|
|
140
|
+
and no temporary file is involved. A path can be iterated again like a URL
|
|
141
|
+
(each run reopens it); a file-like object is consumed by its first run, and a
|
|
142
|
+
second run raises instead of yielding zero items.
|
|
143
|
+
|
|
144
|
+
## Command line
|
|
145
|
+
|
|
146
|
+
The package doubles as a diagnostic tool: stream any feed to JSON Lines without writing code.
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
uvx xmlstreamer https://example.com/large-feed.xml --tag item --limit 5
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The positional argument is a URL, a local file path, or `-` for stdin, so the same tool replays a saved feed and composes in shell pipelines:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
xmlstreamer saved-feed.xml --tag item
|
|
156
|
+
curl -s https://example.com/large-feed.xml | xmlstreamer - --tag item
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
One JSON object per line on stdout, ready to pipe into `jq`, a file, or whatever sits downstream. In [Nushell](https://www.nushell.sh/) that stream becomes a native table in one pipeline - type the fields, filter, group and export from there (values arrive as strings, so promote what you need):
|
|
160
|
+
|
|
161
|
+
```nushell
|
|
162
|
+
xmlstreamer https://example.com/large-feed.xml --tag item
|
|
163
|
+
| from json --objects
|
|
164
|
+
| update price {|r| $r.price | into float}
|
|
165
|
+
| where price < 10
|
|
166
|
+
| to csv
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The useful flags mirror the API: `--mode stream` (no temp file), `--nested` (nested dicts), `--raw` (each item's bytes instead of JSON; add `--print0` for NUL-delimited framing), `--limit N` (sample cheaply and release the source), `--max-seconds S` (time budget) and `-v` (progress and stats on stderr). Errors exit non-zero with a one-line message, and a mid-feed cut in stream mode reports how many items were delivered before dying.
|
|
170
|
+
|
|
171
|
+
## Cookbook
|
|
172
|
+
|
|
173
|
+
[`COOKBOOK.md`](COOKBOOK.md) has task-oriented recipes: surviving mid-feed connection cuts without duplicating work, feeds bigger than RAM, cheap sampling, JSONL pipelines, production logging, processing many feeds in parallel, and more. Every snippet in it is extracted and executed by the test suite against a local feed server, so the recipes cannot drift from the real API.
|
|
174
|
+
|
|
175
|
+
## Performance
|
|
176
|
+
|
|
177
|
+
Honest numbers against the obvious alternatives. Everything comes from the
|
|
178
|
+
reproducible harness in [`benchmarks/`](benchmarks/README.md); one command
|
|
179
|
+
re-measures it on your machine:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
uv run --group bench python benchmarks/run_all.py
|
|
183
|
+
uv run --group bench python benchmarks/bench_rypipe.py --feed-mb 100
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
*Measured 2026-10-12 on a Ryzen 7 5800X, Python 3.14.8, lxml 6.1.3, xmltodict 1.0.4.
|
|
187
|
+
Absolute numbers track the machine; the ratios are stable across runs.*
|
|
188
|
+
|
|
189
|
+
The engine is Rust. The per-item streaming API (`StreamInterpreter`) keeps the
|
|
190
|
+
recovery semantics; the adapter API (`XmlSource`) adds Arrow, projection and
|
|
191
|
+
parallelism. The streaming API used to be ~3.5x slower than the stdlib; it is now
|
|
192
|
+
on par with or faster than both the stdlib and lxml.
|
|
193
|
+
|
|
194
|
+
**Speed (per-item API).** Parse-only throughput over the same in-memory bytes,
|
|
195
|
+
best of N interleaved rounds:
|
|
196
|
+
|
|
197
|
+
| feed | xmlstreamer | lxml iterparse (recover=True) | stdlib ET iterparse | xmltodict |
|
|
198
|
+
|---|---|---|---|---|
|
|
199
|
+
| flat items | 341k items/s | 317k | 252k | 88k |
|
|
200
|
+
| CDATA-heavy | 278k items/s | 201k | 216k | 78k |
|
|
201
|
+
|
|
202
|
+
**Columnar / parallel adapter.** Same 128 MiB file, every parser reading it:
|
|
203
|
+
|
|
204
|
+
| engine | throughput | peak RSS |
|
|
205
|
+
|---|---|---|
|
|
206
|
+
| XmlSource columnar | 132 MB/s | 292 MB |
|
|
207
|
+
| XmlSource parallel (8 threads) | 278 MB/s | 298 MB |
|
|
208
|
+
| XmlSource bounded parallel streaming | 533 MB/s | 211 MB |
|
|
209
|
+
| XmlSource projected (`schema=[...]`) | 134 MB/s | 231 MB |
|
|
210
|
+
| XmlSource projected, 8 threads | 558 MB/s | 255 MB |
|
|
211
|
+
| stdlib ET iterparse | 30 MB/s | 18 MB |
|
|
212
|
+
| lxml iterparse (recover=True) | 63 MB/s | 23 MB |
|
|
213
|
+
|
|
214
|
+
For bulk local ingestion the adapter is roughly 4x stdlib single-threaded and
|
|
215
|
+
~9x in parallel; a projection that lets the scanner skip unwanted fields adds
|
|
216
|
+
another ~2x on top of parallel in this run (on other feeds and CPUs the
|
|
217
|
+
projection win is larger - it depends on how much of each item is skipped).
|
|
218
|
+
Parallel and bounded modes are where the engine pays off; the per-item API is
|
|
219
|
+
for feeds that arrive over the network or need the recovery contract.
|
|
220
|
+
|
|
221
|
+
**Peak memory.** Each parser processes the same 121 MB feed alone, in a fresh
|
|
222
|
+
subprocess, and reports its peak RSS - measured by the kernel, not estimated.
|
|
223
|
+
|
|
224
|
+
| parser | peak RSS |
|
|
225
|
+
|---|---|
|
|
226
|
+
| xmlstreamer per-item | 88 MB |
|
|
227
|
+
| XmlSource bounded streaming | 209 MB |
|
|
228
|
+
| lxml iterparse | 22 MB |
|
|
229
|
+
| stdlib ET iterparse | 16 MB |
|
|
230
|
+
| xmltodict (streaming mode) | 24 MB |
|
|
231
|
+
| xmltodict (as commonly used) | 444 MB - grows with the feed |
|
|
232
|
+
|
|
233
|
+
Encoding detection only runs when the first 64 KiB are not already valid UTF-8,
|
|
234
|
+
so the common case never pays for it. The bounded modes stay flat as the feed
|
|
235
|
+
grows; the columnar/parallel modes hold the materialized table.
|
|
236
|
+
|
|
237
|
+
**Broken feeds.** This table is the reason the library exists: what each parser does when the input is damaged. The cells summarize verdicts generated by code, not written by hand - run the harness for the full, unabridged table.
|
|
238
|
+
|
|
239
|
+
| input damage | xmlstreamer | lxml (recover=True) | stdlib ET / xmltodict |
|
|
240
|
+
|---|---|---|---|
|
|
241
|
+
| unclosed tag in one item | keeps the healthy items, warns | fabricates an item that does not exist | whole feed lost (exception) |
|
|
242
|
+
| raw `&` or `<` in item text | drops that item with a warning | silently deletes text around it | whole feed lost |
|
|
243
|
+
| encoding declaration lies | decodes correctly (content wins over the declaration) | silent mojibake | silent mojibake or feed lost |
|
|
244
|
+
| download truncated mid-item | delivers intact items only | emits the truncated fragment as a real item | feed lost |
|
|
245
|
+
| separator tag nested in itself | unsupported (documented contract) | parses it | parses it |
|
|
246
|
+
|
|
247
|
+
lxml and the stdlib are fast, but xmlstreamer is the only column with no silent
|
|
248
|
+
failure in the broken-feed table. That is the point of the library: damaged input
|
|
249
|
+
is never repaired into records that were not in the feed, and what cannot be
|
|
250
|
+
decoded or read as markup is reported, never passed off as data. The adapter API
|
|
251
|
+
adds the raw throughput without giving that up.
|
|
252
|
+
|
|
253
|
+
## Transport: authentication, proxies, timeouts
|
|
254
|
+
|
|
255
|
+
Everything about how the feed is reached lives in a single `Transport` object: user agent, auth, requests-style proxy dict, timeouts and download mode. Omit it entirely and the defaults apply.
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
from xmlstreamer import BearerAuth, StreamInterpreter, Transport
|
|
259
|
+
|
|
260
|
+
interpreter = StreamInterpreter(
|
|
261
|
+
url=url,
|
|
262
|
+
separator_tag="book",
|
|
263
|
+
transport=Transport(
|
|
264
|
+
auth=BearerAuth(token="..."),
|
|
265
|
+
proxy={"http": "http://proxy:8080", "https": "http://proxy:8080"},
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
- `BasicAuth(username, password)` / `DigestAuth(username, password)`: HTTP(S) basic and digest auth. On FTP URLs the username and password are used for login.
|
|
271
|
+
- `BearerAuth(token)`: sends `Authorization: Bearer <token>`.
|
|
272
|
+
- `ApiKeyAuth(header, value)`: sends the key in a custom header, e.g. `ApiKeyAuth(header="X-Api-Key", value="...")`.
|
|
273
|
+
|
|
274
|
+
## Download modes
|
|
275
|
+
|
|
276
|
+
By default the feed is spooled to a temporary file before parsing (`Transport(download_mode="temp")`): network errors surface before the first item, and a failed download yields nothing. With `Transport(download_mode="stream")` the bytes flow straight from the network to the parser - no temp file (nothing in TMPDIR), memory bounded by the largest single item (or the largest tag or markup section, whichever is bigger, the latter capped by `Sections(max_size=...)`) plus fixed buffers, rather than by the feed size, and first items before the download finishes - in exchange for a different error contract: a connection lost mid-feed raises `FeedInterruptedError` from the iteration, carrying `items_delivered` and the original network error chained as `__cause__`. Failures before the first item (connecting, HTTP errors, the initial encoding-detection read) keep their raw exception type in both modes.
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
from xmlstreamer import FeedInterruptedError, StreamInterpreter, Transport
|
|
280
|
+
|
|
281
|
+
interpreter = StreamInterpreter(
|
|
282
|
+
url=url, separator_tag="book", transport=Transport(download_mode="stream")
|
|
283
|
+
)
|
|
284
|
+
try:
|
|
285
|
+
for item in interpreter:
|
|
286
|
+
process(item)
|
|
287
|
+
except FeedInterruptedError as exc:
|
|
288
|
+
log.warning("feed cut after %s items: %s", exc.items_delivered, exc.__cause__)
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Stopping early (a `break`) keeps the source open until the interpreter is garbage collected. Call `interpreter.close()` (or use the interpreter as a context manager) to release it right away - the socket in stream mode, the temporary file in temp mode. Closing ends the run: it logs its stats, calls `run_finished(run)` and delivers nothing more, so what was left in its buffer never leaks out afterwards:
|
|
292
|
+
|
|
293
|
+
```python
|
|
294
|
+
with StreamInterpreter(
|
|
295
|
+
url=url, separator_tag="book", transport=Transport(download_mode="stream")
|
|
296
|
+
) as interpreter:
|
|
297
|
+
for item in interpreter:
|
|
298
|
+
if enough(item):
|
|
299
|
+
break
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
## Errors
|
|
303
|
+
|
|
304
|
+
Every failure of a feed or of the network xmlstreamer raises on its own descends from `XMLStreamerError`, so a single `except` clause covers the library:
|
|
305
|
+
|
|
306
|
+
- `FeedInterruptedError`: stream mode only, the connection died mid-feed. Carries `items_delivered` and the original network error as `__cause__`.
|
|
307
|
+
- `UnsupportedSchemeError` (also a `ValueError`): the URL scheme is not http, https or ftp. The message names the scheme, never the URL, which can carry credentials.
|
|
308
|
+
|
|
309
|
+
Network errors are not wrapped while the connection is being established: a 404 is `requests.exceptions.HTTPError` and a stalled server is `requests.exceptions.Timeout`, in both download modes. A `source=` path that does not open raises its raw `OSError` (`FileNotFoundError` and friends) at the same point. In stream mode, network errors after that point surface as `FeedInterruptedError` instead. Malformed items are never raised: they are logged at `WARNING` and skipped.
|
|
310
|
+
|
|
311
|
+
Programming mistakes stay plain Python and are deliberately outside that family, because they are not something a caller handles at runtime: bad arguments are `TypeError` / `ValueError` at the constructor, and opening a run while `close()` is releasing everything is a `RuntimeError`.
|
|
312
|
+
|
|
313
|
+
## Nested output (opt-in)
|
|
314
|
+
|
|
315
|
+
Items are flat `{path: text}` dicts by default (`item["publisher/city"]`), which is the stable, primitive form. An item made of bare text rather than fields (`<isbn>978-0-1</isbn>` with `separator_tag="isbn"`) comes out keyed by that same tag: `{"isbn": "978-0-1"}`. If you prefer nested dicts, pass `output=Nested()` (or call `xmlstreamer.to_nested(item)` on any flat item):
|
|
316
|
+
|
|
317
|
+
```python
|
|
318
|
+
from xmlstreamer import Nested, StreamInterpreter
|
|
319
|
+
|
|
320
|
+
interpreter = StreamInterpreter(url=url, separator_tag="book", output=Nested())
|
|
321
|
+
for item in interpreter:
|
|
322
|
+
print(item["publisher"]["city"])
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Numbered siblings keep their numbering by default (`item["authors"]["author_1"]` - never implicit lists, so the shape cannot depend on how many siblings an item happened to have), mixed-content text lands under `"#text"`, and item filters always operate on the flat form.
|
|
326
|
+
|
|
327
|
+
To get real lists, declare which paths are collections with `Nested(force_list=...)`. Declared elements are ALWAYS lists, in document order, with one element or many. `Nested(force_list=True)` declares every path: fully uniform output where every element is a list, in the strict XML-to-JSON style. The declaration is validated and frozen when `Nested` is built, so a one-shot iterable cannot reshape later items:
|
|
328
|
+
|
|
329
|
+
```python
|
|
330
|
+
interpreter = StreamInterpreter(
|
|
331
|
+
url=url,
|
|
332
|
+
separator_tag="book",
|
|
333
|
+
output=Nested(force_list={"authors/author"}),
|
|
334
|
+
)
|
|
335
|
+
for item in interpreter:
|
|
336
|
+
for author in item["authors"]["author"]:
|
|
337
|
+
print(author["name"])
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
## Raw output (opt-in)
|
|
341
|
+
|
|
342
|
+
`output=Raw()` delivers each item as bytes instead of a dict: the slice between the item's separator tags, exactly as the parser would receive it - after transparent gunzip, after stream-level normalization to UTF-8, before any per-item repair, and never re-serialized. Selection does not change - an item that does not parse is still dropped with a warning, and `item_filter` still sees the flat dict - only the representation does. The separator tags themselves (and any attributes on them) are not part of the slice.
|
|
343
|
+
|
|
344
|
+
```python
|
|
345
|
+
from xmlstreamer import Raw, StreamInterpreter
|
|
346
|
+
|
|
347
|
+
interpreter = StreamInterpreter(
|
|
348
|
+
source="saved-feed.xml", separator_tag="book", output=Raw()
|
|
349
|
+
)
|
|
350
|
+
for item_bytes in interpreter:
|
|
351
|
+
... # b"<title>The Cartographer</title><year>2011</year>"
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
Two uses stand out: splitting a huge feed into per-item work units without re-serializing them, and forensics - item-level `U+FFFD` replacement can only happen when the stream needed no transcoding, so in exactly that case the parsed dict carries the replacement while the raw bytes keep the stream's original bytes. On the command line this is `--raw`; items are newline-delimited by default (readable, but an item may itself span lines), and `--print0` switches to NUL - which cannot appear in XML, so that framing is unambiguous.
|
|
355
|
+
|
|
356
|
+
Type checkers follow the mode: with `output=Raw()` the interpreter is a `StreamInterpreter[bytes]`, with `Nested()` or the default it is a `StreamInterpreter[dict[str, Any]]` - so existing typed code keeps checking clean after upgrading.
|
|
357
|
+
|
|
358
|
+
## Markup that never closes
|
|
359
|
+
|
|
360
|
+
Comments, CDATA, processing instructions and DOCTYPE declarations are skipped whole: a separator tag written inside one is markup, not an item. What happens when one of them never closes is the single question this library will not answer for you, because both answers are defensible and they fail in opposite directions:
|
|
361
|
+
|
|
362
|
+
```python
|
|
363
|
+
from xmlstreamer import Sections, StreamInterpreter
|
|
364
|
+
|
|
365
|
+
interpreter = StreamInterpreter(
|
|
366
|
+
url=url,
|
|
367
|
+
separator_tag="book",
|
|
368
|
+
sections=Sections(max_size=16 * 1024 * 1024, on_limit="text"),
|
|
369
|
+
)
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
`on_limit="text"` (the default) concludes that an opener still unterminated after `max_size` bytes was never markup, and re-reads it as content. That is what a stray `<?` in a description usually is - pasted code, or the `<?xml:namespace ...>` a word processor left behind - and it keeps every item after it. The cost: a genuine section larger than `max_size` would be read as content, and records could come out of it.
|
|
373
|
+
|
|
374
|
+
`on_limit="markup"` keeps discarding instead, in constant memory. No record can ever come from inside a section, and the price is everything that follows an opener that never closes.
|
|
375
|
+
|
|
376
|
+
Either way the decision is announced with a WARNING, and it is measured from the opener over the stream, so buffer and chunk sizes never change it. At end of feed neither policy re-reads: a cut download is a cut download, and re-reading a truncated comment full of commented-out items would invent them.
|
|
377
|
+
|
|
378
|
+
## Logging
|
|
379
|
+
|
|
380
|
+
xmlstreamer logs through the standard `logging` module under the `xmlstreamer` logger name (per-feed progress and encoding detection at `DEBUG`, end-of-feed stats at `INFO`, everything lossy at `WARNING`). The `WARNING` level is where the library reports what it could not keep: discarded malformed items, corrupt or truncated gzip, a feed that ends inside an item, a section that never terminates, a key collision that reshaped an item, and invalid UTF-8 replaced with `U+FFFD` (warned once per run, then counted in the closing stats line). Nothing is dropped or altered without a line in the log. To see its output:
|
|
381
|
+
|
|
382
|
+
```python
|
|
383
|
+
import logging
|
|
384
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
## Filtering
|
|
388
|
+
|
|
389
|
+
Pass any callable as `item_filter`: it receives each parsed item as the flat `{path: text}` dict, and its return value decides - truthy keeps the item, falsy drops it. Filters run before the optional `Nested` conversion (they always see flat keys) and may mutate the dict in place. Three per-run counters form a funnel, `stats_total_items >= stats_parsed_items >= stats_delivered_items`, and the gaps have different owners: total minus parsed is items broken in the *feed* (dropped with a WARNING), parsed minus delivered is items dropped by *your* filter. (The example uses the third-party `dateparser` package; `datetime.fromisoformat` covers well-formed dates with the stdlib.)
|
|
390
|
+
|
|
391
|
+
```python
|
|
392
|
+
from datetime import datetime, timedelta
|
|
393
|
+
|
|
394
|
+
import dateparser
|
|
395
|
+
|
|
396
|
+
from xmlstreamer import StreamInterpreter
|
|
397
|
+
|
|
398
|
+
def keep_recent(item: dict) -> bool:
|
|
399
|
+
parsed = dateparser.parse(item.get("pubDate", ""))
|
|
400
|
+
if parsed is None:
|
|
401
|
+
return True # unparseable or missing date: keep the item
|
|
402
|
+
return parsed > datetime.now() - timedelta(days=7)
|
|
403
|
+
|
|
404
|
+
interpreter = StreamInterpreter(
|
|
405
|
+
url=url,
|
|
406
|
+
separator_tag="item",
|
|
407
|
+
item_filter=keep_recent,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
for item in interpreter:
|
|
411
|
+
print(item) # only items from the last 7 days
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
For parameterized filters, close over the config (or use `functools.partial`):
|
|
415
|
+
|
|
416
|
+
```python
|
|
417
|
+
import functools
|
|
418
|
+
|
|
419
|
+
def keep_recent(days: int, item: dict) -> bool:
|
|
420
|
+
parsed = dateparser.parse(item.get("pubDate", ""))
|
|
421
|
+
return parsed is None or parsed > datetime.now() - timedelta(days=days)
|
|
422
|
+
|
|
423
|
+
interpreter = StreamInterpreter(
|
|
424
|
+
url=url,
|
|
425
|
+
separator_tag="item",
|
|
426
|
+
item_filter=functools.partial(keep_recent, 30),
|
|
427
|
+
)
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
Filters chain with plain Python - `all()` short-circuits like a pipeline
|
|
431
|
+
of predicates, and the order is the order of the list:
|
|
432
|
+
|
|
433
|
+
```python
|
|
434
|
+
FILTERS = [has_isbn, keep_recent, in_stock]
|
|
435
|
+
|
|
436
|
+
item_filter=lambda item: all(f(item) for f in FILTERS)
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
**If your filter raises, the exception propagates raw and the run stops
|
|
440
|
+
there.** This is deliberate, and the mirror image of how broken items are
|
|
441
|
+
treated: damage in the *data* (a malformed item) is dropped with a
|
|
442
|
+
WARNING and the run continues, but a bug in *your* code is never
|
|
443
|
+
absorbed - the library will not silently turn a filter typo into kept or
|
|
444
|
+
dropped items, and the traceback points at your line. (This contract is
|
|
445
|
+
pinned by a test.) If you want a different policy for a flaky filter,
|
|
446
|
+
wrap it - the choice of what a failing filter means belongs to you:
|
|
447
|
+
|
|
448
|
+
```python
|
|
449
|
+
def tolerant(f, on_error=False):
|
|
450
|
+
def wrapped(item):
|
|
451
|
+
try:
|
|
452
|
+
return f(item)
|
|
453
|
+
except Exception:
|
|
454
|
+
logging.warning("filter failed on item: %r", item)
|
|
455
|
+
return on_error # False drops the item, True keeps it.
|
|
456
|
+
return wrapped
|
|
457
|
+
|
|
458
|
+
item_filter=tolerant(keep_recent)
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
## Subclassing for alerts
|
|
462
|
+
|
|
463
|
+
`run_finished(run)` is called once when a run ends, however it ends: the feed ended, its time budget ran out, or the caller closed it. It runs after that run released its source and logged its stats, and it receives the run that ended, so its counters are the right ones even if another run is already open. Nothing needs to be raised or chained, and nothing should: the hook reports, so an exception it raises is logged at `ERROR` with its traceback and goes no further - a failed alert never undoes items already delivered:
|
|
464
|
+
|
|
465
|
+
```python
|
|
466
|
+
from xmlstreamer import StreamInterpreter
|
|
467
|
+
|
|
468
|
+
class AlertingStreamInterpreter(StreamInterpreter):
|
|
469
|
+
def run_finished(self, run):
|
|
470
|
+
if run.stats_parsed_items == 0:
|
|
471
|
+
print("--- ZERO ITEMS ALERT ---")
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
## Contributing and security
|
|
475
|
+
|
|
476
|
+
Bug reports with a reproducible broken feed are the most valuable contribution this project can receive - [`CONTRIBUTING.md`](CONTRIBUTING.md) explains how to file one safely and what pull requests need (including the DCO sign-off). For vulnerabilities, never open a public issue: use the private channel described in [`SECURITY.md`](SECURITY.md).
|
|
477
|
+
|
|
478
|
+
## Technical Acknowledgments
|
|
479
|
+
|
|
480
|
+
The shape of this library - a byte-level tokenizer feeding a per-item interpreter - descends from Ruslan Spivak's [Let's Build A Simple Interpreter](https://ruslanspivak.com/lsbasi-part1/) series, which taught its author to think in lexers and interpreters. If you want to understand this codebase deeply, that series is the best prerequisite there is.
|
|
481
|
+
|