leptris 1.2.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.
leptris-1.2.0/PKG-INFO ADDED
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: leptris
3
+ Version: 1.2.0
4
+ Summary: Python bindings for libleptris — fast XML 1.0 parsing and XPath 1.0
5
+ Author: Ribose
6
+ License: MIT
7
+ Requires-Python: >=3.8
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: cffi
10
+ Provides-Extra: test
11
+ Requires-Dist: pytest; extra == "test"
12
+
13
+ # leptris (Python) — bindings for libleptris
14
+
15
+ `leptris` wraps the [libleptris](https://github.com/leptris/leptris)
16
+ C API (XML 1.0 parsing, XPath 1.0) using `cffi` in ABI mode — the
17
+ `cdef` in `leptris/_ffi.py` mirrors libleptris's public headers.
18
+
19
+ The pinned libleptris version lives in `libleptris-version.txt`
20
+ (lockstep releases); CI builds it from the release tarball. The
21
+ binding loads the shared library from `LEPTRIS_LIB_PATH` or the
22
+ loader path.
23
+
24
+ ## Requirements
25
+
26
+ - Python 3.8+
27
+ - `cffi` (`pip install cffi`)
28
+ - libleptris as a shared library (`libleptris.dylib` / `libleptris.so`)
29
+ on the loader path, or pointed to by `LEPTRIS_LIB_PATH`. For a
30
+ development checkout:
31
+
32
+ ```bash
33
+ cmake -B build -S . -DLEPTRIS_BUILD_SHARED=ON
34
+ cmake --build build --target leptris_shared
35
+ export LEPTRIS_LIB_PATH=$PWD/build/src/libleptris.dylib
36
+ ```
37
+
38
+ ## Quick start
39
+
40
+ ```python
41
+ from leptris import Document
42
+
43
+ doc = Document.parse("<library><book id='1'>Ulysses</book></library>")
44
+
45
+ doc.root.name # "library"
46
+ book = doc.root.first_child_element
47
+ book.name # "book"
48
+ book.attribute("id") # "1"
49
+ book.text # "Ulysses"
50
+
51
+ doc.xpath("count(//book)") # 1.0
52
+ [e.text for e in doc.xpath("//book")] # ["Ulysses"]
53
+
54
+ doc.close() # or: with Document.parse(xml) as doc: ...
55
+ ```
56
+
57
+ ## Layout
58
+
59
+ - `leptris/_ffi.py` — cdef + shared-library loading (single source
60
+ of the C surface, mirroring the Ruby binding's `lib/leptris.rb`)
61
+ - `leptris/document.py`, `element.py`, `node.py`, `xpath.py`,
62
+ `error.py` — typed wrappers
63
+ - `tests/` — pytest suite (run: `pytest` with `LEPTRIS_LIB_PATH` set)
64
+
65
+ ## Memory model
66
+
67
+ The `Document` owns the whole tree and its pool. Accessor strings
68
+ are copied into Python `str` at the boundary, so nothing depends on
69
+ document lifetime after a call returns. Elements keep a reference to
70
+ their `Document`, so the pool cannot be freed while any wrapper is
71
+ alive. Prefer explicit `close()` / the context manager; `__del__` is
72
+ a refcounting safety net, not a contract.
73
+
74
+ ## Versioning
75
+
76
+ The package version tracks libleptris (lockstep): library 1.1.0 ↔
77
+ leptris 1.1.0.
78
+
79
+ ## Publishing
80
+
81
+ Releases publish to PyPI via `.github/workflows/release.yml`,
82
+ using PyPI **trusted publishing** (no stored credentials). The
83
+ workflow runs on manual dispatch (ships the version in
84
+ `pyproject.toml`) and is called by the libleptris release flow
85
+ (`publish: true`), so every libleptris release ships the wheel.
86
+
87
+ ## Local development
88
+
89
+ ```bash
90
+ python3 -m venv .venv
91
+ ./.venv/bin/pip install --upgrade build setuptools wheel pytest cffi
92
+ ./.venv/bin/python -m build
93
+ LEPTRIS_LIB_PATH=../../build-shared/src/libleptris.dylib \
94
+ ./.venv/bin/python -m pytest tests/ -q
95
+ ```
@@ -0,0 +1,83 @@
1
+ # leptris (Python) — bindings for libleptris
2
+
3
+ `leptris` wraps the [libleptris](https://github.com/leptris/leptris)
4
+ C API (XML 1.0 parsing, XPath 1.0) using `cffi` in ABI mode — the
5
+ `cdef` in `leptris/_ffi.py` mirrors libleptris's public headers.
6
+
7
+ The pinned libleptris version lives in `libleptris-version.txt`
8
+ (lockstep releases); CI builds it from the release tarball. The
9
+ binding loads the shared library from `LEPTRIS_LIB_PATH` or the
10
+ loader path.
11
+
12
+ ## Requirements
13
+
14
+ - Python 3.8+
15
+ - `cffi` (`pip install cffi`)
16
+ - libleptris as a shared library (`libleptris.dylib` / `libleptris.so`)
17
+ on the loader path, or pointed to by `LEPTRIS_LIB_PATH`. For a
18
+ development checkout:
19
+
20
+ ```bash
21
+ cmake -B build -S . -DLEPTRIS_BUILD_SHARED=ON
22
+ cmake --build build --target leptris_shared
23
+ export LEPTRIS_LIB_PATH=$PWD/build/src/libleptris.dylib
24
+ ```
25
+
26
+ ## Quick start
27
+
28
+ ```python
29
+ from leptris import Document
30
+
31
+ doc = Document.parse("<library><book id='1'>Ulysses</book></library>")
32
+
33
+ doc.root.name # "library"
34
+ book = doc.root.first_child_element
35
+ book.name # "book"
36
+ book.attribute("id") # "1"
37
+ book.text # "Ulysses"
38
+
39
+ doc.xpath("count(//book)") # 1.0
40
+ [e.text for e in doc.xpath("//book")] # ["Ulysses"]
41
+
42
+ doc.close() # or: with Document.parse(xml) as doc: ...
43
+ ```
44
+
45
+ ## Layout
46
+
47
+ - `leptris/_ffi.py` — cdef + shared-library loading (single source
48
+ of the C surface, mirroring the Ruby binding's `lib/leptris.rb`)
49
+ - `leptris/document.py`, `element.py`, `node.py`, `xpath.py`,
50
+ `error.py` — typed wrappers
51
+ - `tests/` — pytest suite (run: `pytest` with `LEPTRIS_LIB_PATH` set)
52
+
53
+ ## Memory model
54
+
55
+ The `Document` owns the whole tree and its pool. Accessor strings
56
+ are copied into Python `str` at the boundary, so nothing depends on
57
+ document lifetime after a call returns. Elements keep a reference to
58
+ their `Document`, so the pool cannot be freed while any wrapper is
59
+ alive. Prefer explicit `close()` / the context manager; `__del__` is
60
+ a refcounting safety net, not a contract.
61
+
62
+ ## Versioning
63
+
64
+ The package version tracks libleptris (lockstep): library 1.1.0 ↔
65
+ leptris 1.1.0.
66
+
67
+ ## Publishing
68
+
69
+ Releases publish to PyPI via `.github/workflows/release.yml`,
70
+ using PyPI **trusted publishing** (no stored credentials). The
71
+ workflow runs on manual dispatch (ships the version in
72
+ `pyproject.toml`) and is called by the libleptris release flow
73
+ (`publish: true`), so every libleptris release ships the wheel.
74
+
75
+ ## Local development
76
+
77
+ ```bash
78
+ python3 -m venv .venv
79
+ ./.venv/bin/pip install --upgrade build setuptools wheel pytest cffi
80
+ ./.venv/bin/python -m build
81
+ LEPTRIS_LIB_PATH=../../build-shared/src/libleptris.dylib \
82
+ ./.venv/bin/python -m pytest tests/ -q
83
+ ```
@@ -0,0 +1,21 @@
1
+ """leptris — Python bindings for libleptris.
2
+
3
+ Usage:
4
+
5
+ from leptris import Document
6
+
7
+ doc = Document.parse("<root><item>hi</item></root>")
8
+ print(doc.root.name)
9
+
10
+ Requires libleptris on the library search path (or LEPTRIS_LIB_PATH).
11
+ """
12
+
13
+ __version__ = "1.2.0"
14
+
15
+ from .document import Document
16
+ from .element import Element
17
+ from .error import LeptrisError
18
+ from .node import Node
19
+ from .xpath import XPath
20
+
21
+ __all__ = ["Document", "Element", "Node", "XPath", "LeptrisError"]
@@ -0,0 +1,109 @@
1
+ """C bridge for leptris.
2
+
3
+ cffi ABI mode: the cdef below mirrors the public headers
4
+ (src/include/leptris/). All handles are opaque pointers; strings
5
+ returned by accessors are document-owned and only valid until
6
+ leptris_document_free — copy into Python str at the boundary.
7
+
8
+ The library is resolved from LEPTRIS_LIB_PATH, then the usual
9
+ install names, then the local build directory.
10
+ """
11
+
12
+ import os
13
+
14
+ from cffi import FFI
15
+
16
+ ffi = FFI()
17
+
18
+ ffi.cdef(
19
+ """
20
+ typedef struct leptris_document* LeptrisDocument;
21
+ typedef struct leptris_element* LeptrisElement;
22
+ typedef struct leptris_node* LeptrisNodeRef;
23
+ typedef struct leptris_attribute* LeptrisAttribute;
24
+ typedef struct leptris_xpath_result* LeptrisXPathResult;
25
+
26
+ LeptrisDocument leptris_parse_string(const char* xml, size_t len, int* status);
27
+ void leptris_document_free(LeptrisDocument doc);
28
+ LeptrisElement leptris_document_root(LeptrisDocument doc);
29
+ char* leptris_document_serialize(LeptrisDocument doc, void* options);
30
+ int leptris_xinclude_process(LeptrisDocument doc, const char* base_path);
31
+
32
+ int leptris_node_get_type(LeptrisNodeRef node);
33
+ LeptrisNodeRef leptris_node_first_child(LeptrisNodeRef node);
34
+ LeptrisNodeRef leptris_node_next_sibling(LeptrisNodeRef node);
35
+ LeptrisNodeRef leptris_node_previous_sibling(LeptrisNodeRef node);
36
+ size_t leptris_node_child_count(LeptrisNodeRef node);
37
+ LeptrisElement leptris_node_as_element(LeptrisNodeRef node);
38
+ LeptrisNodeRef leptris_element_as_node(LeptrisElement elem);
39
+
40
+ const char* leptris_element_name(LeptrisElement elem);
41
+ const char* leptris_element_text(LeptrisElement elem);
42
+ LeptrisElement leptris_element_first_child_any(LeptrisElement elem);
43
+ LeptrisElement leptris_element_parent(LeptrisElement elem);
44
+ const char* leptris_element_attribute(LeptrisElement elem,
45
+ const char* name);
46
+ LeptrisElement leptris_element_next_sibling_any(LeptrisElement elem);
47
+ LeptrisAttribute leptris_element_first_attribute(LeptrisElement elem);
48
+ LeptrisAttribute leptris_attribute_next(LeptrisAttribute attr);
49
+ const char* leptris_attribute_get_name(LeptrisAttribute attr);
50
+ const char* leptris_attribute_get_value(LeptrisElement elem,
51
+ LeptrisAttribute attr);
52
+ size_t leptris_element_attribute_count(LeptrisElement elem);
53
+ size_t leptris_element_child_count(LeptrisElement elem);
54
+
55
+ const char* leptris_text_node_get_content(LeptrisNodeRef node);
56
+ const char* leptris_comment_node_get_content(LeptrisNodeRef node);
57
+ const char* leptris_cdata_node_get_content(LeptrisNodeRef node);
58
+ const char* leptris_pi_node_get_target(LeptrisNodeRef node);
59
+ const char* leptris_pi_node_get_data(LeptrisNodeRef node);
60
+
61
+ LeptrisXPathResult leptris_xpath_eval(LeptrisDocument doc,
62
+ LeptrisElement context,
63
+ const char* expression);
64
+ void leptris_xpath_result_free(LeptrisXPathResult result);
65
+ int leptris_xpath_result_type(LeptrisXPathResult result);
66
+ double leptris_xpath_result_number(LeptrisXPathResult result);
67
+ int leptris_xpath_result_boolean(LeptrisXPathResult result);
68
+ char* leptris_xpath_result_string(LeptrisXPathResult result);
69
+ size_t leptris_xpath_result_count(LeptrisXPathResult result);
70
+ LeptrisElement leptris_xpath_result_get(LeptrisXPathResult result, size_t index);
71
+
72
+ void leptris_free_string(char* str);
73
+ """
74
+ )
75
+
76
+
77
+ def _load():
78
+ candidates = []
79
+ if os.environ.get("LEPTRIS_LIB_PATH"):
80
+ candidates.append(os.environ["LEPTRIS_LIB_PATH"])
81
+ candidates += ["libleptris.dylib", "libleptris.so", "leptris.dll"]
82
+ here = os.path.dirname(__file__)
83
+ candidates += [
84
+ os.path.join(here, "..", "..", "..", "build", "src", "libleptris.dylib"),
85
+ os.path.join(here, "..", "..", "..", "build", "src", "libleptris.so"),
86
+ ]
87
+ for name in candidates:
88
+ try:
89
+ return ffi.dlopen(name)
90
+ except OSError:
91
+ continue
92
+ raise ImportError(
93
+ "libleptris not found; build it or set LEPTRIS_LIB_PATH"
94
+ )
95
+
96
+
97
+ lib = _load()
98
+
99
+ NODE_ELEMENT = 0
100
+ NODE_TEXT = 1
101
+ NODE_COMMENT = 2
102
+ NODE_CDATA = 3
103
+ NODE_PI = 4
104
+ NODE_DOCTYPE = 5
105
+
106
+ XPATH_NODESET = 0
107
+ XPATH_BOOLEAN = 1
108
+ XPATH_NUMBER = 2
109
+ XPATH_STRING = 3
@@ -0,0 +1,79 @@
1
+ """Document — wraps LeptrisDocument.
2
+
3
+ The document owns the entire DOM tree and memory pool. Release it
4
+ with close() (or the context manager); __del__ is a last-resort
5
+ safety net for CPython refcounting, not a contract.
6
+ """
7
+
8
+ from . import _ffi
9
+ from .error import LeptrisError
10
+
11
+
12
+ class Document:
13
+ def __init__(self, _ptr):
14
+ self._ptr = _ptr
15
+ self._freed = False
16
+
17
+ @classmethod
18
+ def parse(cls, xml):
19
+ if isinstance(xml, str):
20
+ xml = xml.encode("utf-8")
21
+ if not isinstance(xml, (bytes, bytearray, memoryview)):
22
+ raise TypeError("xml must be str or bytes")
23
+ xml = bytes(xml)
24
+ status = _ffi.ffi.new("int*")
25
+ ptr = _ffi.lib.leptris_parse_string(xml, len(xml), status)
26
+ if ptr == _ffi.ffi.NULL:
27
+ raise LeptrisError(f"parse failed (status={status[0]})")
28
+ return cls(ptr)
29
+
30
+ @property
31
+ def root(self):
32
+ ptr = _ffi.lib.leptris_document_root(self._ptr)
33
+ if ptr == _ffi.ffi.NULL:
34
+ return None
35
+ from .element import Element
36
+
37
+ return Element(ptr, self)
38
+
39
+ def serialize(self) -> str:
40
+ result = _ffi.lib.leptris_document_serialize(self._ptr, _ffi.ffi.NULL)
41
+ if result == _ffi.ffi.NULL:
42
+ return ""
43
+ value = _ffi.ffi.string(result).decode("utf-8")
44
+ _ffi.lib.leptris_free_string(result)
45
+ return value
46
+
47
+ def process_xinclude(self, base_url=None):
48
+ base = base_url.encode("utf-8") if base_url is not None else _ffi.ffi.NULL
49
+ rc = _ffi.lib.leptris_xinclude_process(self._ptr, base)
50
+ if rc != 0:
51
+ raise LeptrisError("XInclude processing failed")
52
+ return self
53
+
54
+ def xpath(self, expression, context=None):
55
+ from .xpath import XPath
56
+
57
+ return XPath.evaluate(self, context, expression)
58
+
59
+ def close(self):
60
+ if not self._freed:
61
+ _ffi.lib.leptris_document_free(self._ptr)
62
+ self._freed = True
63
+ self._ptr = _ffi.ffi.NULL
64
+
65
+ @property
66
+ def closed(self) -> bool:
67
+ return self._freed
68
+
69
+ def __enter__(self):
70
+ return self
71
+
72
+ def __exit__(self, exc_type, exc_val, exc_tb):
73
+ self.close()
74
+
75
+ def __del__(self):
76
+ try:
77
+ self.close()
78
+ except Exception:
79
+ pass
@@ -0,0 +1,109 @@
1
+ """Element — wraps LeptrisElement.
2
+
3
+ Elements are owned by their parent Document; they are never freed
4
+ directly. Element objects keep a reference to the Document so the
5
+ tree cannot outlive its pool.
6
+ """
7
+
8
+ from . import _ffi
9
+
10
+
11
+ class Element:
12
+ def __init__(self, _ptr, document):
13
+ self._ptr = _ptr
14
+ self._document = document
15
+
16
+ @property
17
+ def document(self):
18
+ return self._document
19
+
20
+ @property
21
+ def name(self) -> str:
22
+ value = _ffi.lib.leptris_element_name(self._ptr)
23
+ return _ffi.ffi.string(value).decode("utf-8") if value != _ffi.ffi.NULL else ""
24
+
25
+ @property
26
+ def text(self) -> str:
27
+ value = _ffi.lib.leptris_element_text(self._ptr)
28
+ return _ffi.ffi.string(value).decode("utf-8") if value != _ffi.ffi.NULL else ""
29
+
30
+ def attribute(self, name: str, default=None):
31
+ value = _ffi.lib.leptris_element_attribute(
32
+ self._ptr, name.encode("utf-8")
33
+ )
34
+ if value == _ffi.ffi.NULL:
35
+ return default
36
+ return _ffi.ffi.string(value).decode("utf-8")
37
+
38
+ __getitem__ = attribute
39
+
40
+ def attributes(self):
41
+ """Yield (name, value) for every attribute in document order.
42
+
43
+ Handle-based iteration — O(n) total where index-based access
44
+ re-walks the list per call.
45
+ """
46
+ attr = _ffi.lib.leptris_element_first_attribute(self._ptr)
47
+ while attr != _ffi.ffi.NULL:
48
+ name = _ffi.ffi.string(
49
+ _ffi.lib.leptris_attribute_get_name(attr)
50
+ ).decode("utf-8")
51
+ value = _ffi.ffi.string(
52
+ _ffi.lib.leptris_attribute_get_value(self._ptr, attr)
53
+ ).decode("utf-8")
54
+ yield (name, value)
55
+ attr = _ffi.lib.leptris_attribute_next(attr)
56
+
57
+ @property
58
+ def attribute_count(self) -> int:
59
+ return _ffi.lib.leptris_element_attribute_count(self._ptr)
60
+
61
+ @property
62
+ def child_count(self) -> int:
63
+ return _ffi.lib.leptris_element_child_count(self._ptr)
64
+
65
+ @property
66
+ def parent(self):
67
+ ptr = _ffi.lib.leptris_element_parent(self._ptr)
68
+ if ptr == _ffi.ffi.NULL:
69
+ return None
70
+ return Element(ptr, self._document)
71
+
72
+ @property
73
+ def first_child_element(self):
74
+ ptr = _ffi.lib.leptris_element_first_child_any(self._ptr)
75
+ if ptr == _ffi.ffi.NULL:
76
+ return None
77
+ return Element(ptr, self._document)
78
+
79
+ @property
80
+ def next_sibling_element(self):
81
+ # The node-level sibling chain interleaves text nodes, so
82
+ # walk until the next element (or the end of the chain).
83
+ node = _ffi.lib.leptris_node_next_sibling(_ffi.lib.leptris_element_as_node(self._ptr))
84
+ while node != _ffi.ffi.NULL:
85
+ elem = _ffi.lib.leptris_node_as_element(node)
86
+ if elem != _ffi.ffi.NULL:
87
+ return Element(elem, self._document)
88
+ node = _ffi.lib.leptris_node_next_sibling(node)
89
+ return None
90
+
91
+ def child_elements(self):
92
+ child = self.first_child_element
93
+ while child is not None:
94
+ yield child
95
+ child = child.next_sibling_element
96
+
97
+ def to_node(self):
98
+ from .node import Node
99
+
100
+ return Node(_ffi.lib.leptris_element_as_node(self._ptr), self._document)
101
+
102
+ def xpath(self, expression):
103
+ return self._document.xpath(expression, context=self)
104
+
105
+ def __iter__(self):
106
+ return self.child_elements()
107
+
108
+ def __repr__(self):
109
+ return f"<leptris.Element {self.name!r}>"
@@ -0,0 +1,5 @@
1
+ """Error type for leptris."""
2
+
3
+
4
+ class LeptrisError(Exception):
5
+ """Raised when a libleptris call fails (parse, XPath, XInclude)."""
@@ -0,0 +1,84 @@
1
+ """Node — wraps LeptrisNodeRef for generic tree traversal.
2
+
3
+ Covers every node type (element, text, comment, CDATA, PI, doctype);
4
+ Element is the typed view for element nodes.
5
+ """
6
+
7
+ from . import _ffi
8
+
9
+
10
+ class Node:
11
+ def __init__(self, _ptr, document):
12
+ self._ptr = _ptr
13
+ self._document = document
14
+
15
+ @property
16
+ def type(self) -> int:
17
+ return _ffi.lib.leptris_node_get_type(self._ptr)
18
+
19
+ def is_element(self) -> bool:
20
+ return self.type == _ffi.NODE_ELEMENT
21
+
22
+ def is_text(self) -> bool:
23
+ return self.type == _ffi.NODE_TEXT
24
+
25
+ def is_comment(self) -> bool:
26
+ return self.type == _ffi.NODE_COMMENT
27
+
28
+ def is_cdata(self) -> bool:
29
+ return self.type == _ffi.NODE_CDATA
30
+
31
+ def is_pi(self) -> bool:
32
+ return self.type == _ffi.NODE_PI
33
+
34
+ @property
35
+ def content(self):
36
+ t = self.type
37
+ if t == _ffi.NODE_TEXT:
38
+ getter = _ffi.lib.leptris_text_node_get_content
39
+ elif t == _ffi.NODE_COMMENT:
40
+ getter = _ffi.lib.leptris_comment_node_get_content
41
+ elif t == _ffi.NODE_CDATA:
42
+ getter = _ffi.lib.leptris_cdata_node_get_content
43
+ else:
44
+ return None
45
+ value = getter(self._ptr)
46
+ return _ffi.ffi.string(value).decode("utf-8") if value != _ffi.ffi.NULL else ""
47
+
48
+ @property
49
+ def first_child(self):
50
+ ptr = _ffi.lib.leptris_node_first_child(self._ptr)
51
+ if ptr == _ffi.ffi.NULL:
52
+ return None
53
+ return Node(ptr, self._document)
54
+
55
+ @property
56
+ def next_sibling(self):
57
+ ptr = _ffi.lib.leptris_node_next_sibling(self._ptr)
58
+ if ptr == _ffi.ffi.NULL:
59
+ return None
60
+ return Node(ptr, self._document)
61
+
62
+ @property
63
+ def previous_sibling(self):
64
+ ptr = _ffi.lib.leptris_node_previous_sibling(self._ptr)
65
+ if ptr == _ffi.ffi.NULL:
66
+ return None
67
+ return Node(ptr, self._document)
68
+
69
+ @property
70
+ def child_count(self) -> int:
71
+ return _ffi.lib.leptris_node_child_count(self._ptr)
72
+
73
+ def as_element(self):
74
+ if not self.is_element():
75
+ return None
76
+ ptr = _ffi.lib.leptris_node_as_element(self._ptr)
77
+ if ptr == _ffi.ffi.NULL:
78
+ return None
79
+ from .element import Element
80
+
81
+ return Element(ptr, self._document)
82
+
83
+ def __repr__(self):
84
+ return f"<leptris.Node type={self.type}>"
@@ -0,0 +1,42 @@
1
+ """XPath — evaluates XPath 1.0 expressions.
2
+
3
+ Results are typed: nodeset results yield Element lists; scalar
4
+ results convert to native Python types.
5
+ """
6
+
7
+ from . import _ffi
8
+ from .error import LeptrisError
9
+
10
+
11
+ class XPath:
12
+ @staticmethod
13
+ def evaluate(document, context_element, expression):
14
+ ctx = context_element._ptr if context_element is not None else _ffi.ffi.NULL
15
+ result = _ffi.lib.leptris_xpath_eval(document._ptr, ctx, expression.encode("utf-8"))
16
+ if result == _ffi.ffi.NULL:
17
+ raise LeptrisError(f"XPath evaluation failed: {expression!r}")
18
+
19
+ try:
20
+ result_type = _ffi.lib.leptris_xpath_result_type(result)
21
+ if result_type == _ffi.XPATH_NODESET:
22
+ count = _ffi.lib.leptris_xpath_result_count(result)
23
+ from .element import Element
24
+
25
+ return [
26
+ Element(_ffi.lib.leptris_xpath_result_get(result, i), document)
27
+ for i in range(count)
28
+ ]
29
+ if result_type == _ffi.XPATH_NUMBER:
30
+ return _ffi.lib.leptris_xpath_result_number(result)
31
+ if result_type == _ffi.XPATH_STRING:
32
+ ptr = _ffi.lib.leptris_xpath_result_string(result)
33
+ if ptr == _ffi.ffi.NULL:
34
+ return ""
35
+ value = _ffi.ffi.string(ptr).decode("utf-8")
36
+ _ffi.lib.leptris_free_string(ptr)
37
+ return value
38
+ if result_type == _ffi.XPATH_BOOLEAN:
39
+ return bool(_ffi.lib.leptris_xpath_result_boolean(result))
40
+ return None
41
+ finally:
42
+ _ffi.lib.leptris_xpath_result_free(result)
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: leptris
3
+ Version: 1.2.0
4
+ Summary: Python bindings for libleptris — fast XML 1.0 parsing and XPath 1.0
5
+ Author: Ribose
6
+ License: MIT
7
+ Requires-Python: >=3.8
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: cffi
10
+ Provides-Extra: test
11
+ Requires-Dist: pytest; extra == "test"
12
+
13
+ # leptris (Python) — bindings for libleptris
14
+
15
+ `leptris` wraps the [libleptris](https://github.com/leptris/leptris)
16
+ C API (XML 1.0 parsing, XPath 1.0) using `cffi` in ABI mode — the
17
+ `cdef` in `leptris/_ffi.py` mirrors libleptris's public headers.
18
+
19
+ The pinned libleptris version lives in `libleptris-version.txt`
20
+ (lockstep releases); CI builds it from the release tarball. The
21
+ binding loads the shared library from `LEPTRIS_LIB_PATH` or the
22
+ loader path.
23
+
24
+ ## Requirements
25
+
26
+ - Python 3.8+
27
+ - `cffi` (`pip install cffi`)
28
+ - libleptris as a shared library (`libleptris.dylib` / `libleptris.so`)
29
+ on the loader path, or pointed to by `LEPTRIS_LIB_PATH`. For a
30
+ development checkout:
31
+
32
+ ```bash
33
+ cmake -B build -S . -DLEPTRIS_BUILD_SHARED=ON
34
+ cmake --build build --target leptris_shared
35
+ export LEPTRIS_LIB_PATH=$PWD/build/src/libleptris.dylib
36
+ ```
37
+
38
+ ## Quick start
39
+
40
+ ```python
41
+ from leptris import Document
42
+
43
+ doc = Document.parse("<library><book id='1'>Ulysses</book></library>")
44
+
45
+ doc.root.name # "library"
46
+ book = doc.root.first_child_element
47
+ book.name # "book"
48
+ book.attribute("id") # "1"
49
+ book.text # "Ulysses"
50
+
51
+ doc.xpath("count(//book)") # 1.0
52
+ [e.text for e in doc.xpath("//book")] # ["Ulysses"]
53
+
54
+ doc.close() # or: with Document.parse(xml) as doc: ...
55
+ ```
56
+
57
+ ## Layout
58
+
59
+ - `leptris/_ffi.py` — cdef + shared-library loading (single source
60
+ of the C surface, mirroring the Ruby binding's `lib/leptris.rb`)
61
+ - `leptris/document.py`, `element.py`, `node.py`, `xpath.py`,
62
+ `error.py` — typed wrappers
63
+ - `tests/` — pytest suite (run: `pytest` with `LEPTRIS_LIB_PATH` set)
64
+
65
+ ## Memory model
66
+
67
+ The `Document` owns the whole tree and its pool. Accessor strings
68
+ are copied into Python `str` at the boundary, so nothing depends on
69
+ document lifetime after a call returns. Elements keep a reference to
70
+ their `Document`, so the pool cannot be freed while any wrapper is
71
+ alive. Prefer explicit `close()` / the context manager; `__del__` is
72
+ a refcounting safety net, not a contract.
73
+
74
+ ## Versioning
75
+
76
+ The package version tracks libleptris (lockstep): library 1.1.0 ↔
77
+ leptris 1.1.0.
78
+
79
+ ## Publishing
80
+
81
+ Releases publish to PyPI via `.github/workflows/release.yml`,
82
+ using PyPI **trusted publishing** (no stored credentials). The
83
+ workflow runs on manual dispatch (ships the version in
84
+ `pyproject.toml`) and is called by the libleptris release flow
85
+ (`publish: true`), so every libleptris release ships the wheel.
86
+
87
+ ## Local development
88
+
89
+ ```bash
90
+ python3 -m venv .venv
91
+ ./.venv/bin/pip install --upgrade build setuptools wheel pytest cffi
92
+ ./.venv/bin/python -m build
93
+ LEPTRIS_LIB_PATH=../../build-shared/src/libleptris.dylib \
94
+ ./.venv/bin/python -m pytest tests/ -q
95
+ ```
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ leptris/__init__.py
4
+ leptris/_ffi.py
5
+ leptris/document.py
6
+ leptris/element.py
7
+ leptris/error.py
8
+ leptris/node.py
9
+ leptris/xpath.py
10
+ leptris.egg-info/PKG-INFO
11
+ leptris.egg-info/SOURCES.txt
12
+ leptris.egg-info/dependency_links.txt
13
+ leptris.egg-info/requires.txt
14
+ leptris.egg-info/top_level.txt
15
+ tests/test_binding.py
@@ -0,0 +1,4 @@
1
+ cffi
2
+
3
+ [test]
4
+ pytest
@@ -0,0 +1 @@
1
+ leptris
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools", "cffi"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "leptris"
7
+ version = "1.2.0"
8
+ description = "Python bindings for libleptris — fast XML 1.0 parsing and XPath 1.0"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Ribose" }]
13
+ dependencies = ["cffi"]
14
+
15
+ [project.optional-dependencies]
16
+ test = ["pytest"]
17
+
18
+ [tool.setuptools]
19
+ packages = ["leptris"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,147 @@
1
+ import pytest
2
+
3
+ from leptris import Document, LeptrisError
4
+
5
+ XML = """<?xml version="1.0"?>
6
+ <library>
7
+ <book id="1" lang="en">Ulysses</book>
8
+ <book id="2" lang="fr">L'Etranger</book>
9
+ <!-- a comment -->
10
+ </library>"""
11
+
12
+
13
+ @pytest.fixture()
14
+ def doc():
15
+ with Document.parse(XML) as doc:
16
+ yield doc
17
+
18
+
19
+ class TestParse:
20
+ def test_root_name(self, doc):
21
+ assert doc.root.name == "library"
22
+
23
+ def test_root_is_element(self, doc):
24
+ assert doc.root.to_node().is_element()
25
+
26
+ def test_parse_error_raises(self):
27
+ with pytest.raises(LeptrisError):
28
+ Document.parse("<unclosed>")
29
+
30
+ def test_bytes_input(self):
31
+ with Document.parse(b"<r/>") as doc:
32
+ assert doc.root.name == "r"
33
+
34
+ def test_type_error_on_non_string(self):
35
+ with pytest.raises(TypeError):
36
+ Document.parse(123)
37
+
38
+ def test_close_is_idempotent(self):
39
+ doc = Document.parse("<r/>")
40
+ doc.close()
41
+ doc.close()
42
+ assert doc.closed
43
+
44
+
45
+ class TestElement:
46
+ def test_child_iteration(self, doc):
47
+ books = list(doc.root)
48
+ assert [b.name for b in books] == ["book", "book"]
49
+
50
+ def test_attribute(self, doc):
51
+ book = doc.root.first_child_element
52
+ assert book.attribute("id") == "1"
53
+ assert book.attribute("lang") == "en"
54
+
55
+ def test_attribute_default(self, doc):
56
+ book = doc.root.first_child_element
57
+ assert book.attribute("nope") is None
58
+ assert book.attribute("nope", "x") == "x"
59
+
60
+ def test_attributes_iteration(self, doc):
61
+ book = doc.root.first_child_element
62
+ pairs = list(book.attributes())
63
+ assert pairs == [("id", "1"), ("lang", "en")]
64
+ assert book.attribute_count == 2
65
+
66
+ def test_attributes_entity_expansion_and_empty(self):
67
+ with Document.parse('<e t="a &amp; b"/>') as doc:
68
+ assert list(doc.root.attributes()) == [("t", "a & b")]
69
+ with Document.parse("<e/>") as doc:
70
+ assert list(doc.root.attributes()) == []
71
+ assert doc.root.attribute_count == 0
72
+
73
+ def test_text(self, doc):
74
+ book = doc.root.first_child_element
75
+ assert book.text == "Ulysses"
76
+
77
+ def test_parent(self, doc):
78
+ book = doc.root.first_child_element
79
+ assert book.parent is not None
80
+ assert book.parent.name == "library"
81
+
82
+ def test_next_sibling(self, doc):
83
+ first = doc.root.first_child_element
84
+ second = first.next_sibling_element
85
+ assert second is not None
86
+ assert second.attribute("id") == "2"
87
+
88
+
89
+ class TestNode:
90
+ def test_node_types(self, doc):
91
+ node = doc.root.to_node()
92
+ types = set()
93
+ child = node.first_child
94
+ while child is not None:
95
+ types.add(child.type)
96
+ child = child.next_sibling
97
+ assert 1 in types # text
98
+ assert 2 in types # comment
99
+
100
+ def test_comment_content(self, doc):
101
+ node = doc.root.to_node().first_child
102
+ comments = []
103
+ while node is not None:
104
+ if node.is_comment():
105
+ comments.append(node.content)
106
+ node = node.next_sibling
107
+ # Comment content is the exact inner text, whitespace included.
108
+ assert comments == [" a comment "]
109
+
110
+ def test_child_count_counts_elements(self, doc):
111
+ # child_count is elements-only: root has 2 books plus
112
+ # interleaved text nodes that are not counted.
113
+ assert doc.root.to_node().child_count == 2
114
+
115
+
116
+ class TestXPath:
117
+ def test_count(self, doc):
118
+ assert doc.xpath("count(//book)") == 2.0
119
+
120
+ def test_nodeset(self, doc):
121
+ books = doc.xpath("//book")
122
+ assert len(books) == 2
123
+ assert books[0].text == "Ulysses"
124
+
125
+ def test_string(self, doc):
126
+ assert doc.xpath("string(//book[@id='2'])") == "L'Etranger"
127
+
128
+ def test_boolean(self, doc):
129
+ assert doc.xpath("count(//book) = 2") is True
130
+ assert doc.xpath("count(//book) = 5") is False
131
+
132
+ def test_element_context(self, doc):
133
+ book = doc.root.first_child_element
134
+ assert book.xpath("string(@id)") == "1"
135
+
136
+ def test_error_raises(self, doc):
137
+ with pytest.raises(LeptrisError):
138
+ doc.xpath("///[")
139
+
140
+
141
+ class TestSerialize:
142
+ def test_round_trip(self, doc):
143
+ out = doc.serialize()
144
+ assert "<library>" in out
145
+ reparsed = Document.parse(out)
146
+ assert reparsed.xpath("count(//book)") == 2.0
147
+ reparsed.close()