xml2arrow 0.16.0__tar.gz → 0.18.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.
@@ -31,9 +31,37 @@ jobs:
31
31
  - name: Run Ruff format
32
32
  run: ruff format python/ --check
33
33
 
34
+ rust-lint:
35
+ name: Rust Lint
36
+ runs-on: ubuntu-latest
37
+ steps:
38
+ - uses: actions/checkout@v4
39
+ - uses: dtolnay/rust-toolchain@stable
40
+ with:
41
+ components: rustfmt,clippy
42
+ - uses: Swatinem/rust-cache@v2
43
+ - name: Run rustfmt
44
+ run: cargo fmt --check
45
+ - name: Run clippy
46
+ run: cargo clippy --all-targets -- -D warnings
47
+
48
+ type-check:
49
+ name: Type Check
50
+ needs: lint
51
+ runs-on: ubuntu-latest
52
+ steps:
53
+ - uses: actions/checkout@v4
54
+ - uses: actions/setup-python@v5
55
+ with:
56
+ python-version: "3.12"
57
+ - name: Install dependencies
58
+ run: pip install mypy pyarrow
59
+ - name: Run mypy
60
+ run: mypy
61
+
34
62
  test:
35
63
  name: Test
36
- needs: lint # Only run tests if linting passes
64
+ needs: [lint, rust-lint] # Only run tests if linting passes
37
65
  runs-on: ${{ matrix.os }}
38
66
  strategy:
39
67
  fail-fast: false
@@ -48,11 +76,13 @@ jobs:
48
76
  - name: Install Rust toolchain
49
77
  uses: dtolnay/rust-toolchain@stable
50
78
  - name: Install dependencies
51
- run: pip install maturin pytest pytest-cov
79
+ run: pip install maturin pytest pytest-cov pyarrow
52
80
  - name: Build wheel
53
81
  run: maturin build --release --out dist
54
82
  - name: Install wheel
55
- run: pip install --find-links=dist xml2arrow
83
+ # --no-index forces the just-built wheel; without it pip may pick a
84
+ # released version from PyPI and silently test the wrong artifact.
85
+ run: pip install --no-index --find-links=dist xml2arrow
56
86
  - name: Run tests with coverage
57
87
  run: pytest tests/ -v --cov=xml2arrow --cov-report=xml --cov-report=term
58
88
  - name: Upload coverage to Codecov
@@ -4,7 +4,7 @@ version = 4
4
4
 
5
5
  [[package]]
6
6
  name = "_xml2arrow"
7
- version = "0.16.0"
7
+ version = "0.18.0"
8
8
  dependencies = [
9
9
  "arrow",
10
10
  "pyo3",
@@ -51,9 +51,9 @@ dependencies = [
51
51
 
52
52
  [[package]]
53
53
  name = "arrow"
54
- version = "58.1.0"
54
+ version = "59.0.0"
55
55
  source = "registry+https://github.com/rust-lang/crates.io-index"
56
- checksum = "d441fdda254b65f3e9025910eb2c2066b6295d9c8ed409522b8d2ace1ff8574c"
56
+ checksum = "ffaaa3e009861fd829d0a24dd6f115aa8e4634324bb092147d43baafe69ca4a7"
57
57
  dependencies = [
58
58
  "arrow-arith",
59
59
  "arrow-array",
@@ -73,9 +73,9 @@ dependencies = [
73
73
 
74
74
  [[package]]
75
75
  name = "arrow-arith"
76
- version = "58.1.0"
76
+ version = "59.0.0"
77
77
  source = "registry+https://github.com/rust-lang/crates.io-index"
78
- checksum = "ced5406f8b720cc0bc3aa9cf5758f93e8593cda5490677aa194e4b4b383f9a59"
78
+ checksum = "3ac95125e1d71c4a252b5a9c729aef111e80418f08aaa6dbabd1ba66918247fc"
79
79
  dependencies = [
80
80
  "arrow-array",
81
81
  "arrow-buffer",
@@ -87,9 +87,9 @@ dependencies = [
87
87
 
88
88
  [[package]]
89
89
  name = "arrow-array"
90
- version = "58.1.0"
90
+ version = "59.0.0"
91
91
  source = "registry+https://github.com/rust-lang/crates.io-index"
92
- checksum = "772bd34cacdda8baec9418d80d23d0fb4d50ef0735685bd45158b83dfeb6e62d"
92
+ checksum = "0c60c79628e9a97cb90d7a0dc3e944f216a902f837d4ecabc14d524bddbbc137"
93
93
  dependencies = [
94
94
  "ahash",
95
95
  "arrow-buffer",
@@ -97,7 +97,7 @@ dependencies = [
97
97
  "arrow-schema",
98
98
  "chrono",
99
99
  "half",
100
- "hashbrown 0.16.0",
100
+ "hashbrown",
101
101
  "num-complex",
102
102
  "num-integer",
103
103
  "num-traits",
@@ -105,9 +105,9 @@ dependencies = [
105
105
 
106
106
  [[package]]
107
107
  name = "arrow-buffer"
108
- version = "58.1.0"
108
+ version = "59.0.0"
109
109
  source = "registry+https://github.com/rust-lang/crates.io-index"
110
- checksum = "898f4cf1e9598fdb77f356fdf2134feedfd0ee8d5a4e0a5f573e7d0aec16baa4"
110
+ checksum = "6026f638c400e9878c1b1cc05c3cfd46fbf381285916ab408678701c1df46c1a"
111
111
  dependencies = [
112
112
  "bytes",
113
113
  "half",
@@ -117,9 +117,9 @@ dependencies = [
117
117
 
118
118
  [[package]]
119
119
  name = "arrow-cast"
120
- version = "58.1.0"
120
+ version = "59.0.0"
121
121
  source = "registry+https://github.com/rust-lang/crates.io-index"
122
- checksum = "b0127816c96533d20fc938729f48c52d3e48f99717e7a0b5ade77d742510736d"
122
+ checksum = "c82c236c3caf8df5664284f3f1fbe89938852163998c3fdbf37e84ac220445e9"
123
123
  dependencies = [
124
124
  "arrow-array",
125
125
  "arrow-buffer",
@@ -138,9 +138,9 @@ dependencies = [
138
138
 
139
139
  [[package]]
140
140
  name = "arrow-csv"
141
- version = "58.1.0"
141
+ version = "59.0.0"
142
142
  source = "registry+https://github.com/rust-lang/crates.io-index"
143
- checksum = "ca025bd0f38eeecb57c2153c0123b960494138e6a957bbda10da2b25415209fe"
143
+ checksum = "12714e5fb7954159af1e26d4e0d37108bcf1a2ad5ee5c5bf02a944d564d588b7"
144
144
  dependencies = [
145
145
  "arrow-array",
146
146
  "arrow-cast",
@@ -153,9 +153,9 @@ dependencies = [
153
153
 
154
154
  [[package]]
155
155
  name = "arrow-data"
156
- version = "58.1.0"
156
+ version = "59.0.0"
157
157
  source = "registry+https://github.com/rust-lang/crates.io-index"
158
- checksum = "42d10beeab2b1c3bb0b53a00f7c944a178b622173a5c7bcabc3cb45d90238df4"
158
+ checksum = "7bd568aa70c4ec5947027b0d5caee94877433b661a0bb9e8ddceeeb5f0c9b1ab"
159
159
  dependencies = [
160
160
  "arrow-buffer",
161
161
  "arrow-schema",
@@ -166,9 +166,9 @@ dependencies = [
166
166
 
167
167
  [[package]]
168
168
  name = "arrow-ipc"
169
- version = "58.1.0"
169
+ version = "59.0.0"
170
170
  source = "registry+https://github.com/rust-lang/crates.io-index"
171
- checksum = "609a441080e338147a84e8e6904b6da482cefb957c5cdc0f3398872f69a315d0"
171
+ checksum = "e57ee4d470eab1a021bc4b63fa2b2c15d572892bf227b0a982d3b755a6c662b5"
172
172
  dependencies = [
173
173
  "arrow-array",
174
174
  "arrow-buffer",
@@ -180,15 +180,16 @@ dependencies = [
180
180
 
181
181
  [[package]]
182
182
  name = "arrow-json"
183
- version = "58.1.0"
183
+ version = "59.0.0"
184
184
  source = "registry+https://github.com/rust-lang/crates.io-index"
185
- checksum = "6ead0914e4861a531be48fe05858265cf854a4880b9ed12618b1d08cba9bebc8"
185
+ checksum = "38f47e0e7a284e1f3707a780dc8cd5451b1614e9e398ea2d9ca03c7a2fe9a9ed"
186
186
  dependencies = [
187
187
  "arrow-array",
188
188
  "arrow-buffer",
189
189
  "arrow-cast",
190
- "arrow-data",
190
+ "arrow-ord",
191
191
  "arrow-schema",
192
+ "arrow-select",
192
193
  "chrono",
193
194
  "half",
194
195
  "indexmap",
@@ -204,9 +205,9 @@ dependencies = [
204
205
 
205
206
  [[package]]
206
207
  name = "arrow-ord"
207
- version = "58.1.0"
208
+ version = "59.0.0"
208
209
  source = "registry+https://github.com/rust-lang/crates.io-index"
209
- checksum = "763a7ba279b20b52dad300e68cfc37c17efa65e68623169076855b3a9e941ca5"
210
+ checksum = "a79cf73ad2eba8686ec2aa9bbf8671208e509025f166afc040cedbd94ffe4983"
210
211
  dependencies = [
211
212
  "arrow-array",
212
213
  "arrow-buffer",
@@ -217,9 +218,9 @@ dependencies = [
217
218
 
218
219
  [[package]]
219
220
  name = "arrow-pyarrow"
220
- version = "58.1.0"
221
+ version = "59.0.0"
221
222
  source = "registry+https://github.com/rust-lang/crates.io-index"
222
- checksum = "e63351dc11981a316c828a6032a5021345bba882f68bc4a36c36825a50725089"
223
+ checksum = "b9e66bd74e4a47c6df9a2af16d9cca97e37cdfe5b3207d5e3558646e07957a27"
223
224
  dependencies = [
224
225
  "arrow-array",
225
226
  "arrow-data",
@@ -229,9 +230,9 @@ dependencies = [
229
230
 
230
231
  [[package]]
231
232
  name = "arrow-row"
232
- version = "58.1.0"
233
+ version = "59.0.0"
233
234
  source = "registry+https://github.com/rust-lang/crates.io-index"
234
- checksum = "e14fe367802f16d7668163ff647830258e6e0aeea9a4d79aaedf273af3bdcd3e"
235
+ checksum = "cea0f7d8ed6182f14952761e2c0f989852d5aa334fcbc49f73a9f2247c25b879"
235
236
  dependencies = [
236
237
  "arrow-array",
237
238
  "arrow-buffer",
@@ -242,18 +243,18 @@ dependencies = [
242
243
 
243
244
  [[package]]
244
245
  name = "arrow-schema"
245
- version = "58.1.0"
246
+ version = "59.0.0"
246
247
  source = "registry+https://github.com/rust-lang/crates.io-index"
247
- checksum = "c30a1365d7a7dc50cc847e54154e6af49e4c4b0fddc9f607b687f29212082743"
248
+ checksum = "80b3e786a0dd9103acd583a6fb486dbf2f3268466cc0bd571dcf34cef231c1f1"
248
249
  dependencies = [
249
250
  "bitflags",
250
251
  ]
251
252
 
252
253
  [[package]]
253
254
  name = "arrow-select"
254
- version = "58.1.0"
255
+ version = "59.0.0"
255
256
  source = "registry+https://github.com/rust-lang/crates.io-index"
256
- checksum = "78694888660a9e8ac949853db393af2a8b8fc82c19ce333132dfa2e72cc1a7fe"
257
+ checksum = "067a67e0361f6c31f4a7248759f36ca4ca71b187a941ed4d49da1c7d3d4db624"
257
258
  dependencies = [
258
259
  "ahash",
259
260
  "arrow-array",
@@ -265,9 +266,9 @@ dependencies = [
265
266
 
266
267
  [[package]]
267
268
  name = "arrow-string"
268
- version = "58.1.0"
269
+ version = "59.0.0"
269
270
  source = "registry+https://github.com/rust-lang/crates.io-index"
270
- checksum = "61e04a01f8bb73ce54437514c5fd3ee2aa3e8abe4c777ee5cc55853b1652f79e"
271
+ checksum = "99bc95847f3ff62a2b03d6f8ce2e3e78f01362060549a2a311898dd442f6256d"
271
272
  dependencies = [
272
273
  "arrow-array",
273
274
  "arrow-buffer",
@@ -479,12 +480,6 @@ dependencies = [
479
480
  "num-traits",
480
481
  ]
481
482
 
482
- [[package]]
483
- name = "hashbrown"
484
- version = "0.16.0"
485
- source = "registry+https://github.com/rust-lang/crates.io-index"
486
- checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
487
-
488
483
  [[package]]
489
484
  name = "hashbrown"
490
485
  version = "0.17.0"
@@ -527,7 +522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
527
522
  checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
528
523
  dependencies = [
529
524
  "equivalent",
530
- "hashbrown 0.17.0",
525
+ "hashbrown",
531
526
  ]
532
527
 
533
528
  [[package]]
@@ -752,9 +747,9 @@ dependencies = [
752
747
 
753
748
  [[package]]
754
749
  name = "quick-xml"
755
- version = "0.39.2"
750
+ version = "0.41.0"
756
751
  source = "registry+https://github.com/rust-lang/crates.io-index"
757
- checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
752
+ checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
758
753
  dependencies = [
759
754
  "memchr",
760
755
  ]
@@ -1051,9 +1046,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
1051
1046
 
1052
1047
  [[package]]
1053
1048
  name = "xml2arrow"
1054
- version = "0.16.0"
1049
+ version = "0.18.0"
1055
1050
  source = "registry+https://github.com/rust-lang/crates.io-index"
1056
- checksum = "dbf8199409730f9f76a71d4ee945b2e5eead8f1aece63e4aa907313083e4f1e2"
1051
+ checksum = "966e16865b1dc8abf5f1f4c4cb1600a5f36fc3382ddcf053ba2ae65def571ff3"
1057
1052
  dependencies = [
1058
1053
  "arrow",
1059
1054
  "atoi",
@@ -0,0 +1,25 @@
1
+ [package]
2
+ name = "_xml2arrow"
3
+ version = "0.18.0"
4
+ edition = "2024"
5
+ readme = "README.md"
6
+
7
+ # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
8
+ [lib]
9
+ name = "_xml2arrow"
10
+ crate-type = ["cdylib"]
11
+
12
+ [dependencies]
13
+ xml2arrow = { version = "0.18.0", features = ["python"] }
14
+ arrow = { version = "59.0.0", features = ["pyarrow"] }
15
+
16
+ [dependencies.pyo3]
17
+ version = "0.28"
18
+ features = ["extension-module", "abi3-py310"]
19
+
20
+ # Wheel builds go through this profile (maturin build --release); the
21
+ # size/speed win is worth the slower release compile.
22
+ [profile.release]
23
+ lto = true
24
+ codegen-units = 1
25
+ strip = "symbols"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xml2arrow
3
- Version: 0.16.0
3
+ Version: 0.18.0
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -48,7 +48,10 @@ Rust crate for high performance.
48
48
  - 🎯 **Type conversion** including automatic scale and offset transforms for float fields
49
49
  - 💡 **Attribute and element extraction** using `@`-prefixed path segments for attributes
50
50
  - ⏹️ **Early termination** via `stop_at_paths` for efficiently reading only part of a file
51
- - 🐍 **Flexible input** — accepts file paths, path-like objects, or any file-like object
51
+ - 🐍 **Flexible input** — accepts file paths, path-like objects, readable file-like
52
+ objects, or in-memory `bytes`/`bytearray` (parsed zero-copy, no intermediate buffering)
53
+ - 🧵 **Thread-friendly** — the GIL is released while parsing, so threads sharing one
54
+ parser instance can parse multiple documents in parallel
52
55
 
53
56
  ## Installation
54
57
 
@@ -137,7 +140,8 @@ import polars as pl
137
140
  from xml2arrow import XmlToArrowParser
138
141
 
139
142
  parser = XmlToArrowParser("config.yaml")
140
- record_batches = parser.parse("data.xml") # also accepts pathlib.Path or any file-like object
143
+ record_batches = parser.parse("data.xml") # also accepts pathlib.Path, bytes,
144
+ # bytearray, or any file-like object
141
145
 
142
146
  # Access a table by name
143
147
  batch = record_batches["measurements"] # pyarrow.RecordBatch
@@ -158,6 +162,19 @@ names defined in your config. Because the values are standard PyArrow
158
162
  `RecordBatch` objects they integrate directly with pandas, Polars, DuckDB,
159
163
  and any other tool in the Arrow ecosystem.
160
164
 
165
+ > **Tip:** Constructing an `XmlToArrowParser` validates the config and compiles
166
+ > its path lookup table once, up front. When processing many files with the same
167
+ > config, build the parser **once** and reuse it across `parse()` calls — this
168
+ > amortizes that fixed setup cost and is noticeably faster than creating a new
169
+ > parser per file, especially for many small documents.
170
+ >
171
+ > ```python
172
+ > parser = XmlToArrowParser("config.yaml") # validate + compile once
173
+ > for path in xml_files:
174
+ > record_batches = parser.parse(path) # reused for every file
175
+ > ...
176
+ > ```
177
+
161
178
  ## Example
162
179
 
163
180
  This example extracts meteorological station data from a nested XML document into
@@ -19,7 +19,10 @@ Rust crate for high performance.
19
19
  - 🎯 **Type conversion** including automatic scale and offset transforms for float fields
20
20
  - 💡 **Attribute and element extraction** using `@`-prefixed path segments for attributes
21
21
  - ⏹️ **Early termination** via `stop_at_paths` for efficiently reading only part of a file
22
- - 🐍 **Flexible input** — accepts file paths, path-like objects, or any file-like object
22
+ - 🐍 **Flexible input** — accepts file paths, path-like objects, readable file-like
23
+ objects, or in-memory `bytes`/`bytearray` (parsed zero-copy, no intermediate buffering)
24
+ - 🧵 **Thread-friendly** — the GIL is released while parsing, so threads sharing one
25
+ parser instance can parse multiple documents in parallel
23
26
 
24
27
  ## Installation
25
28
 
@@ -108,7 +111,8 @@ import polars as pl
108
111
  from xml2arrow import XmlToArrowParser
109
112
 
110
113
  parser = XmlToArrowParser("config.yaml")
111
- record_batches = parser.parse("data.xml") # also accepts pathlib.Path or any file-like object
114
+ record_batches = parser.parse("data.xml") # also accepts pathlib.Path, bytes,
115
+ # bytearray, or any file-like object
112
116
 
113
117
  # Access a table by name
114
118
  batch = record_batches["measurements"] # pyarrow.RecordBatch
@@ -129,6 +133,19 @@ names defined in your config. Because the values are standard PyArrow
129
133
  `RecordBatch` objects they integrate directly with pandas, Polars, DuckDB,
130
134
  and any other tool in the Arrow ecosystem.
131
135
 
136
+ > **Tip:** Constructing an `XmlToArrowParser` validates the config and compiles
137
+ > its path lookup table once, up front. When processing many files with the same
138
+ > config, build the parser **once** and reuse it across `parse()` calls — this
139
+ > amortizes that fixed setup cost and is noticeably faster than creating a new
140
+ > parser per file, especially for many small documents.
141
+ >
142
+ > ```python
143
+ > parser = XmlToArrowParser("config.yaml") # validate + compile once
144
+ > for path in xml_files:
145
+ > record_batches = parser.parse(path) # reused for every file
146
+ > ...
147
+ > ```
148
+
132
149
  ## Example
133
150
 
134
151
  This example extracts meteorological station data from a nested XML document into
@@ -61,6 +61,14 @@ requires-python = ">=3.10"
61
61
  python-source = "python"
62
62
  module-name = "xml2arrow._xml2arrow"
63
63
 
64
+ [tool.mypy]
65
+ strict = true
66
+ files = ["python/xml2arrow"]
67
+
68
+ [[tool.mypy.overrides]]
69
+ module = ["pyarrow", "pyarrow.*"]
70
+ ignore_missing_imports = true
71
+
64
72
  [tool.ruff]
65
73
  # Target the minimum Python version supported by your project
66
74
  target-version = "py310"
@@ -1,8 +1,9 @@
1
1
  from os import PathLike
2
- from typing import IO, Any
2
+ from typing import IO, Any, final
3
3
 
4
4
  from pyarrow import RecordBatch
5
5
 
6
+ @final
6
7
  class XmlToArrowParser:
7
8
  """A parser for converting XML files to Arrow tables based on a configuration.
8
9
 
@@ -13,24 +14,34 @@ class XmlToArrowParser:
13
14
  base exception.
14
15
  """
15
16
 
16
- def __init__(self, config_path: str | PathLike) -> None:
17
+ def __init__(self, config_path: str | PathLike[str]) -> None:
17
18
  """Initializes the parser with a configuration file path.
18
19
 
20
+ The configuration is loaded, validated, and compiled here, once. Reuse a
21
+ single parser instance across many files to amortize this setup cost.
22
+
19
23
  Args:
20
24
  config_path: The path to the YAML configuration file.
21
25
 
22
26
  Raises:
23
- Xml2ArrowError: If the configuration file cannot be loaded or parsed.
27
+ OSError: If the configuration file cannot be opened (e.g.
28
+ FileNotFoundError); the message includes the path.
29
+ Xml2ArrowError: If the configuration file cannot be loaded, parsed, or
30
+ validated. Because validation happens at construction time, an
31
+ invalid config (e.g. InvalidConfigError) is raised here rather
32
+ than on the first ``parse()`` call.
24
33
  """
25
34
 
26
35
  def parse(
27
36
  self,
28
- source: str | PathLike | bytes | bytearray | IO[Any],
37
+ source: str | PathLike[str] | bytes | bytearray | IO[Any],
29
38
  ) -> dict[str, RecordBatch]:
30
39
  """Parses an XML source and returns a dictionary of Arrow RecordBatches.
31
40
 
32
41
  In-memory inputs (``bytes`` and ``bytearray``) take a zero-copy fast
33
42
  path. Paths and file-like objects stream through a buffered reader.
43
+ The GIL is released while parsing, so threads sharing one parser
44
+ instance can parse different sources in parallel.
34
45
 
35
46
  Args:
36
47
  source: The XML to parse. Accepts a path (``str`` or ``os.PathLike``),
@@ -42,12 +53,26 @@ class XmlToArrowParser:
42
53
  PyArrow RecordBatch objects.
43
54
 
44
55
  Raises:
56
+ OSError: If ``source`` is a path that cannot be opened (e.g.
57
+ FileNotFoundError); the message includes the path.
58
+ ValueError: If ``source`` is a ``str`` holding XML content rather
59
+ than a file path.
60
+ TypeError: If ``source`` is not a supported input type.
45
61
  Xml2ArrowError: If an error occurs during XML parsing or Arrow
46
62
  table creation. This can include errors such as invalid XML,
47
- incorrect configuration, or unsupported data types.
63
+ incorrect configuration, or unsupported data types. Exceptions
64
+ raised by a file-like object's ``read()`` method propagate
65
+ unchanged.
48
66
  """
49
67
 
50
68
  def __repr__(self) -> str: ...
51
69
 
70
+ class Xml2ArrowError(Exception): ...
71
+ class XmlParsingError(Xml2ArrowError): ...
72
+ class YamlParsingError(Xml2ArrowError): ...
73
+ class ParseError(Xml2ArrowError): ...
74
+ class UnsupportedConversionError(Xml2ArrowError): ...
75
+ class InvalidConfigError(Xml2ArrowError): ...
76
+
52
77
  def _get_version() -> str:
53
78
  """Returns the version of the xml2arrow package."""
@@ -20,6 +20,11 @@ use pyo3::prelude::*;
20
20
  use pyo3::sync::PyOnceLock;
21
21
 
22
22
  /// A read-only adapter over a Python file-like object.
23
+ ///
24
+ /// Exceptions raised inside `read()` are flattened into `io::Error` here (the
25
+ /// `Read` interface demands it), but pyo3 stores the original `PyErr` as the
26
+ /// inner error and upstream's `From<Error> for PyErr` recovers it, so the
27
+ /// caller still sees the real exception rather than a stringified wrapper.
23
28
  pub struct PyBinaryFile {
24
29
  inner: Py<PyAny>,
25
30
  is_text: bool,
@@ -1,17 +1,18 @@
1
1
  use arrow::pyarrow::ToPyArrow;
2
2
  use pyo3::{
3
+ exceptions::{PyOSError, PyValueError},
3
4
  prelude::*,
4
5
  types::{PyByteArray, PyBytes, PyDict},
5
6
  };
6
7
  use std::fs::File;
7
8
  use std::io::{BufReader, Read};
8
- use std::path::PathBuf;
9
+ use std::path::{Path, PathBuf};
10
+ use xml2arrow::Parser;
9
11
  use xml2arrow::config::Config;
10
12
  use xml2arrow::errors::{
11
13
  InvalidConfigError, ParseError, UnsupportedConversionError, Xml2ArrowError, XmlParsingError,
12
14
  YamlParsingError,
13
15
  };
14
- use xml2arrow::{parse_xml, parse_xml_slice};
15
16
 
16
17
  mod file_like;
17
18
  use file_like::PyBinaryFile;
@@ -23,6 +24,41 @@ fn _get_version() -> &'static str {
23
24
  VERSION
24
25
  }
25
26
 
27
+ /// Rebuilds a filesystem error as a Python `OSError` that carries the path;
28
+ /// the raw `io::Error` drops it, which made "No such file or directory
29
+ /// (os error 2)" unactionable. Also catches the common mistake of passing
30
+ /// XML *content* as a `str`, which would otherwise surface as a baffling
31
+ /// `FileNotFoundError` (or `ENAMETOOLONG` for larger documents).
32
+ fn open_error(err: std::io::Error, path: &Path) -> PyErr {
33
+ let text = path.to_string_lossy();
34
+ if text
35
+ .trim_start_matches('\u{feff}')
36
+ .trim_start()
37
+ .starts_with('<')
38
+ {
39
+ return PyValueError::new_err(
40
+ "source looks like XML content, not a file path; pass XML content as bytes \
41
+ (e.g. source.encode()) or wrap it in io.StringIO",
42
+ );
43
+ }
44
+ let Some(code) = err.raw_os_error() else {
45
+ return err.into();
46
+ };
47
+ // Strip io::Error's "(os error N)" suffix; OSError re-renders the code.
48
+ let msg = err.to_string();
49
+ let msg = msg.split(" (os error ").next().unwrap_or(&msg).to_string();
50
+ let filename = text.into_owned();
51
+ // OSError's multi-arg constructor picks the right subclass
52
+ // (FileNotFoundError, PermissionError, ...) from the error code and
53
+ // includes the filename in the message. On Windows `raw_os_error` is a
54
+ // winerror, which OSError translates only via its fourth argument.
55
+ #[cfg(windows)]
56
+ let args = (code, msg, filename, code);
57
+ #[cfg(not(windows))]
58
+ let args = (code, msg, filename);
59
+ PyOSError::new_err(args)
60
+ }
61
+
26
62
  /// Represents an XML input source.
27
63
  ///
28
64
  /// `Bytes` (zero-copy) and `OwnedBytes` (a safe copy of a mutable
@@ -46,11 +82,12 @@ impl<'a, 'py> FromPyObject<'a, 'py> for XmlInput<'py> {
46
82
  if let Ok(ba) = ob.cast::<PyByteArray>() {
47
83
  return Ok(Self::OwnedBytes(ba.to_vec()));
48
84
  }
85
+ // `PathBuf` extraction accepts both `str` and `os.PathLike`.
49
86
  if let Ok(path) = ob.extract::<PathBuf>() {
50
- return Ok(Self::File(File::open(path)?));
51
- }
52
- if let Ok(path) = ob.extract::<String>() {
53
- return Ok(Self::File(File::open(path)?));
87
+ return match File::open(&path) {
88
+ Ok(f) => Ok(Self::File(f)),
89
+ Err(e) => Err(open_error(e, &path)),
90
+ };
54
91
  }
55
92
  Ok(Self::FileLike(PyBinaryFile::from_bound(ob)?))
56
93
  }
@@ -72,10 +109,16 @@ impl Read for XmlReader {
72
109
  }
73
110
 
74
111
  /// A parser for converting XML files to Arrow tables based on a configuration.
112
+ ///
113
+ /// The configuration's path trie is compiled once, here, and reused for every
114
+ /// `parse()` call via [`xml2arrow::Parser`]. This matters most when one parser
115
+ /// instance processes many files: the fixed per-document setup cost (config
116
+ /// validation + path-trie construction) is paid a single time rather than on
117
+ /// every parse.
75
118
  #[pyclass(name = "XmlToArrowParser")]
76
119
  pub struct XmlToArrowParser {
77
120
  config_path: PathBuf,
78
- config: Config,
121
+ parser: Parser,
79
122
  }
80
123
 
81
124
  #[pymethods]
@@ -89,9 +132,16 @@ impl XmlToArrowParser {
89
132
  /// XmlToArrowParser: A new parser instance.
90
133
  #[new]
91
134
  pub fn new(config_path: PathBuf) -> PyResult<Self> {
135
+ // A missing/unreadable config should name the offending path; the
136
+ // io::Error that bubbles out of `from_yaml_file` drops it.
137
+ std::fs::metadata(&config_path).map_err(|e| open_error(e, &config_path))?;
138
+ // Compile the config once here. `Parser::new` also runs config
139
+ // validation, so an invalid config now surfaces at construction time
140
+ // rather than on the first `parse()` call.
141
+ let config = Config::from_yaml_file(config_path.clone())?;
92
142
  Ok(XmlToArrowParser {
93
- config_path: config_path.clone(),
94
- config: Config::from_yaml_file(config_path)?,
143
+ config_path,
144
+ parser: Parser::new(&config)?,
95
145
  })
96
146
  }
97
147
 
@@ -99,6 +149,8 @@ impl XmlToArrowParser {
99
149
  ///
100
150
  /// In-memory inputs (``bytes`` and ``bytearray``) take a zero-copy fast
101
151
  /// path. Paths and file-like objects stream through a buffered reader.
152
+ /// The GIL is released while parsing, so threads sharing one parser
153
+ /// instance can parse different sources in parallel.
102
154
  ///
103
155
  /// Args:
104
156
  /// source: The XML to parse. Accepts ``str``, ``os.PathLike``,
@@ -108,12 +160,20 @@ impl XmlToArrowParser {
108
160
  /// dict: A dictionary where keys are table names (strings) and values are PyArrow RecordBatch objects.
109
161
  #[pyo3(signature = (source))]
110
162
  pub fn parse(&self, py: Python<'_>, source: XmlInput<'_>) -> PyResult<Py<PyAny>> {
163
+ // Detaching from the interpreter is sound for every variant: `Bytes`
164
+ // is immutable, `OwnedBytes` was copied at extraction, `File` is a
165
+ // plain OS handle, and `FileLike` re-attaches for each read() call.
111
166
  let batches = match source {
112
- XmlInput::Bytes(b) => parse_xml_slice(b.as_bytes(), &self.config)?,
113
- XmlInput::OwnedBytes(v) => parse_xml_slice(&v, &self.config)?,
114
- XmlInput::File(f) => parse_xml(BufReader::new(XmlReader::File(f)), &self.config)?,
167
+ XmlInput::Bytes(b) => {
168
+ let bytes = b.as_bytes();
169
+ py.detach(|| self.parser.parse_slice(bytes))?
170
+ }
171
+ XmlInput::OwnedBytes(v) => py.detach(|| self.parser.parse_slice(&v))?,
172
+ XmlInput::File(f) => {
173
+ py.detach(|| self.parser.parse(BufReader::new(XmlReader::File(f))))?
174
+ }
115
175
  XmlInput::FileLike(f) => {
116
- parse_xml(BufReader::new(XmlReader::FileLike(f)), &self.config)?
176
+ py.detach(|| self.parser.parse(BufReader::new(XmlReader::FileLike(f))))?
117
177
  }
118
178
  };
119
179
  let tables = PyDict::new(py);
@@ -1115,6 +1115,162 @@ tables:
1115
1115
  assert batch.to_pydict()["value"] == ["Ünïcödé 中文 🌍"]
1116
1116
 
1117
1117
 
1118
+ def test_parse_str_path(stations_parser: XmlToArrowParser, test_data_dir: Path) -> None:
1119
+ """Test that parse() accepts a plain str path, not just os.PathLike."""
1120
+ record_batches = stations_parser.parse(str(test_data_dir / "stations.xml"))
1121
+ assert "stations" in record_batches
1122
+
1123
+
1124
+ def test_parse_missing_file_error_includes_path(
1125
+ stations_parser: XmlToArrowParser, tmp_path: Path
1126
+ ) -> None:
1127
+ """Test that a nonexistent XML path raises FileNotFoundError naming the file."""
1128
+ missing = tmp_path / "nope.xml"
1129
+ with pytest.raises(FileNotFoundError, match="nope.xml"):
1130
+ stations_parser.parse(missing)
1131
+ with pytest.raises(FileNotFoundError, match="nope.xml"):
1132
+ stations_parser.parse(str(missing))
1133
+
1134
+
1135
+ def test_missing_config_file_error_includes_path(tmp_path: Path) -> None:
1136
+ """Test that a nonexistent config path raises FileNotFoundError naming the file."""
1137
+ with pytest.raises(FileNotFoundError, match="no_config.yaml"):
1138
+ XmlToArrowParser(tmp_path / "no_config.yaml")
1139
+
1140
+
1141
+ def test_parse_xml_content_as_str_raises_helpful_error(
1142
+ stations_parser: XmlToArrowParser,
1143
+ ) -> None:
1144
+ """Test that passing XML content as a str gets a hint instead of FileNotFoundError."""
1145
+ with pytest.raises(ValueError, match=r"looks like XML content"):
1146
+ stations_parser.parse("<report></report>")
1147
+ # Leading whitespace and an XML declaration should still trigger the hint
1148
+ with pytest.raises(ValueError, match=r"looks like XML content"):
1149
+ stations_parser.parse('\n <?xml version="1.0"?><report/>')
1150
+
1151
+
1152
+ def test_parse_rejects_non_source_types(stations_parser: XmlToArrowParser) -> None:
1153
+ """Test that unsupported source types raise TypeError with the documented message."""
1154
+ with pytest.raises(TypeError, match=r"path, bytes-like, or file-like"):
1155
+ stations_parser.parse(12345) # type: ignore[arg-type]
1156
+
1157
+
1158
+ def test_file_like_read_exception_propagates(stations_parser: XmlToArrowParser) -> None:
1159
+ """Test that an exception raised inside a file-like's read() is re-raised as-is.
1160
+
1161
+ Before the read-error slot in PyBinaryFile, the original exception was
1162
+ flattened into an XmlParsingError message string.
1163
+ """
1164
+
1165
+ class ExplodingReader:
1166
+ def read(self, _size: int) -> bytes:
1167
+ raise ConnectionResetError("connection lost mid-stream")
1168
+
1169
+ with pytest.raises(ConnectionResetError, match="connection lost mid-stream"):
1170
+ stations_parser.parse(ExplodingReader())
1171
+
1172
+
1173
+ def test_file_like_read_wrong_type_raises_type_error(
1174
+ stations_parser: XmlToArrowParser,
1175
+ ) -> None:
1176
+ """Test that a read() returning a non-buffer type surfaces as TypeError."""
1177
+
1178
+ class BadReader:
1179
+ def read(self, _size: int) -> int:
1180
+ return 42
1181
+
1182
+ with pytest.raises(TypeError):
1183
+ stations_parser.parse(BadReader())
1184
+
1185
+
1186
+ def test_parse_closed_file_raises_value_error(
1187
+ stations_parser: XmlToArrowParser, test_data_dir: Path
1188
+ ) -> None:
1189
+ """Test that parsing an already-closed file raises the original ValueError."""
1190
+ f = open(test_data_dir / "stations.xml", "rb")
1191
+ f.close()
1192
+ with pytest.raises(ValueError, match="closed file"):
1193
+ stations_parser.parse(f)
1194
+
1195
+
1196
+ def test_parse_releases_the_gil(tmp_path: Path) -> None:
1197
+ """Test that parse() releases the GIL so other Python threads keep running.
1198
+
1199
+ A worker thread parses a document sized so that one parse takes ~0.3s on
1200
+ this machine; the main thread must be able to execute Python (several
1201
+ loop iterations) before the worker finishes. If parse() held the GIL
1202
+ throughout, the main thread would freeze until the parse completed,
1203
+ accumulating at most the 1-2 iterations it can squeeze in before the
1204
+ worker enters the extension call.
1205
+
1206
+ The document size is calibrated rather than hardcoded: a release wheel
1207
+ parses roughly an order of magnitude faster than a debug build, and CI
1208
+ runners add sleep-granularity noise, so a fixed size either starves the
1209
+ counter on fast machines or wastes time on slow ones.
1210
+ """
1211
+ import threading
1212
+ import time
1213
+
1214
+ config_path = tmp_path / "config.yaml"
1215
+ config_path.write_text(
1216
+ """
1217
+ tables:
1218
+ - name: items
1219
+ xml_path: /root
1220
+ levels: [item]
1221
+ fields:
1222
+ - name: value
1223
+ xml_path: /root/item/value
1224
+ data_type: Int64
1225
+ nullable: false
1226
+ """
1227
+ )
1228
+ parser = XmlToArrowParser(config_path)
1229
+
1230
+ def build_doc(items: int) -> bytes:
1231
+ return b"<root>" + b"<item><value>12345</value></item>" * items + b"</root>"
1232
+
1233
+ # Calibrate: measure a 200k-item parse, then scale the document so one
1234
+ # parse takes ~0.3s (capped at 2M items / ~70 MB to bound memory and
1235
+ # runtime on very slow machines).
1236
+ target_seconds = 0.3
1237
+ items = 200_000
1238
+ start = time.perf_counter()
1239
+ parser.parse(build_doc(items))
1240
+ duration = time.perf_counter() - start
1241
+ if duration < target_seconds:
1242
+ items = min(int(items * target_seconds / max(duration, 1e-6)), 2_000_000)
1243
+ big = build_doc(items)
1244
+
1245
+ started = threading.Event()
1246
+ done = threading.Event()
1247
+ worker_error: list[BaseException] = []
1248
+
1249
+ def work() -> None:
1250
+ started.set()
1251
+ try:
1252
+ parser.parse(big)
1253
+ except BaseException as exc: # pragma: no cover - only on regression
1254
+ worker_error.append(exc)
1255
+ finally:
1256
+ # Always set, even on failure: the main loop below must not spin
1257
+ # forever if the parse raises.
1258
+ done.set()
1259
+
1260
+ worker = threading.Thread(target=work)
1261
+ worker.start()
1262
+ assert started.wait(timeout=10)
1263
+ iterations = 0
1264
+ deadline = time.monotonic() + 60
1265
+ while not done.is_set() and time.monotonic() < deadline:
1266
+ iterations += 1
1267
+ time.sleep(0.001)
1268
+ worker.join(timeout=10)
1269
+ assert done.is_set(), "worker never finished parsing"
1270
+ assert not worker_error, f"parse() raised in the worker: {worker_error[0]!r}"
1271
+ assert iterations >= 3, "main thread was starved: parse() appears to hold the GIL"
1272
+
1273
+
1118
1274
  def test_version_returns_string() -> None:
1119
1275
  """Test that the package version is a non-empty string.
1120
1276
 
@@ -1,18 +0,0 @@
1
- [package]
2
- name = "_xml2arrow"
3
- version = "0.16.0"
4
- edition = "2024"
5
- readme = "README.md"
6
-
7
- # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
8
- [lib]
9
- name = "_xml2arrow"
10
- crate-type = ["cdylib"]
11
-
12
- [dependencies]
13
- xml2arrow = { version = "0.16.0", features = ["python"] }
14
- arrow = { version = "58.1.0", features = ["pyarrow"] }
15
-
16
- [dependencies.pyo3]
17
- version = "0.28"
18
- features = ["extension-module", "abi3-py310"]
File without changes
File without changes
File without changes