Skip to content

Commit 74581ba

Browse files
committed
feat: support functools.partial handlers
Handlers can now be wrapped by `functools.partial`, and take arbitrary arguments.
1 parent 2e9d265 commit 74581ba

File tree

8 files changed

+176
-9
lines changed

8 files changed

+176
-9
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project
66
adheres to [Semantic Versioning](https://semver.org/).
77

8+
## [Unreleased]
9+
10+
- [unreleased]: https://github.com/rogdham/bigxml/compare/v0.10.0...HEAD
11+
12+
### :rocket: Added
13+
14+
- Support `functools.partial` handlers
15+
816
## [0.10.0] - 2023-04-22
917

1018
[0.10.0]: https://github.com/rogdham/bigxml/compare/v0.9.0...v0.10.0

docs/src/handlers.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ The methods `iter_from` and `return_from` take _handlers_ as arguments.
66

77
A handler can be a generator function taking a _node_ as an argument.
88

9+
!!! Tip
10+
11+
A handler can take more than a node argument, by using `functools.partial`.
12+
See [this recipe](recipes.md#arbitrary-handler-args) for examples.
13+
914
Such functions are usually decorated with `xml_handle_element` or `xml_handle_text`, to
1015
restrict the type of nodes they are called with.
1116

@@ -97,6 +102,11 @@ argument is supplied with the encountered node:
97102
<__main__.Cart object...> for user Alice
98103
<__main__.Cart object...> for user Bob
99104

105+
!!! Tip
106+
107+
`__init__` can take more than a node argument, by using `functools.partial`.
108+
See [this recipe](recipes.md#arbitrary-handler-args) for examples.
109+
100110
### Class methods as sub-handlers
101111

102112
The methods decorated with `xml_handle_element` or `xml_handle_text` are used as

docs/src/recipes.md

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,7 @@ the `__post_init__` method:
125125
!!! Warning
126126

127127
The `node` attribute is an `InitVar`, so that it is passed to `__post_init__` but
128-
not stored in class attributes. It must be the only mandatory field, since the class
129-
is automatically instantiated with only one argument (the node). For more details,
130-
see [class handlers](handlers.md#classes).
128+
not stored in class attributes.
131129

132130
## Yielding data in a class `__init__` {: #yield-in-init }
133131

@@ -178,6 +176,43 @@ Instead, you can define a custom `xml_handler` method:
178176
product: 9780099580485
179177
END cart parsing for user Bob
180178

179+
## Passing arbitrary arguments to handlers {: #arbitrary-handler-args}
180+
181+
You may want to pass arbitrary arguments to your handlers. This is achievable by
182+
using `functools.partial`.
183+
184+
For example, let's say you only want to yield fast vehicles from this vehicle file:
185+
186+
:::xml filename=vehicles.xml
187+
<vehicles>
188+
<vehicle speed="300">Train</vehicle>
189+
<vehicle speed="30">Boat</vehicle>
190+
<vehicle speed="80">Car</vehicle>
191+
<vehicle speed="900">Plane</vehicle>
192+
</vehicles>
193+
194+
You can pass the desired speed threshold directly to the handler
195+
by using `functools.partial`:
196+
197+
:::python
198+
>>> from functools import partial
199+
>>> @xml_handle_element("vehicles", "vehicle")
200+
... def handler(speed_threshold, node):
201+
... if int(node.attributes['speed']) > speed_threshold:
202+
... yield node.text
203+
204+
>>> with open("vehicles.xml", "rb") as stream:
205+
... for vehicle in Parser(stream).iter_from(partial(handler, 150)):
206+
... print(vehicle)
207+
Train
208+
Plane
209+
210+
!!! Warning
211+
212+
If the parameter is positional-only, it must come before the `node` argument.
213+
214+
This behavior also work for class (and dataclasses) handlers.
215+
181216
## Streams without root {: #no-root }
182217

183218
In some cases, you may be parsing a stream of XML elements that follow each other

src/bigxml/handler_creator.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from dataclasses import is_dataclass
2+
from functools import partial
23
from inspect import getmembers, isclass
34
from typing import (
45
TYPE_CHECKING,
@@ -55,7 +56,12 @@ class _HandlerTree:
5556
def __init__(self, path: Tuple[str, ...] = ()) -> None:
5657
self.path: Tuple[str, ...] = path
5758
self.children: Dict[str, _HandlerTree] = {}
58-
self.handler: Optional[Callable[..., Iterable[object]]] = None
59+
self.handler: Optional[
60+
Union[
61+
Callable[..., Iterable[object]],
62+
partial[Callable[..., Iterable[object]]],
63+
]
64+
] = None
5965

6066
def add_handler(
6167
self,
@@ -126,9 +132,14 @@ def handle(
126132
self, node: Union["XMLElement", "XMLText"]
127133
) -> Optional[Iterable[object]]:
128134
if self.handler:
129-
if isclass(self.handler):
130-
return self._handle_from_class(self.handler, node)
131-
return self.handler(node)
135+
if isclass(
136+
self.handler.func if isinstance(self.handler, partial) else self.handler
137+
):
138+
return self._handle_from_class(cast(Type[Any], self.handler), node)
139+
return cast(
140+
Callable[[Union["XMLElement", "XMLText"]], Iterable[object]],
141+
self.handler,
142+
)(node)
132143

133144
child: Optional[_HandlerTree] = None
134145
namespace = getattr(node, "namespace", None)

src/bigxml/marks.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
1+
from functools import partial
12
from typing import Tuple
23

34
__ATTR_MARK_NAME = "_xml_handlers_on"
45

56

7+
def _unwrap_partials(obj: object) -> object:
8+
if isinstance(obj, partial):
9+
return obj.func
10+
return obj
11+
12+
613
def has_marks(obj: object) -> bool:
7-
return hasattr(obj, __ATTR_MARK_NAME)
14+
return hasattr(_unwrap_partials(obj), __ATTR_MARK_NAME)
815

916

1017
def get_marks(obj: object) -> Tuple[Tuple[str, ...], ...]:
11-
return getattr(obj, __ATTR_MARK_NAME, ())
18+
return getattr(_unwrap_partials(obj), __ATTR_MARK_NAME, ())
1219

1320

1421
def add_mark(obj: object, mark: Tuple[str, ...]) -> None:
22+
obj = _unwrap_partials(obj)
1523
marks = get_marks(obj)
1624
marks += (mark,)
1725
setattr(obj, __ATTR_MARK_NAME, marks)

tests/unit/test_handler_creator.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,24 @@ def catchall(
149149
test_create_handler(catchall)
150150

151151

152+
@cases(
153+
(("a",), "foo: catchall", "a"),
154+
(("{foo}a",), "foo: catchall", "{foo}a"),
155+
(("d0", "d1"), "foo: catchall", "d0"),
156+
(("d0", "d1", "d2"), "foo: catchall", "d0"),
157+
((":text:",), "foo: catchall", ":text:"),
158+
)
159+
def test_one_partial_catchall(test_create_handler: TEST_CREATE_HANDLER_TYPE) -> None:
160+
def catchall(
161+
ctx: str, node: Union[XMLElement, XMLText]
162+
) -> Iterator[Tuple[str, Union[XMLElement, XMLText]]]:
163+
yield (f"{ctx}: catchall", node)
164+
165+
partial_handler = partial(catchall, "foo")
166+
167+
test_create_handler(partial_handler)
168+
169+
152170
@cases(
153171
(("a",), "0", "a"),
154172
(("{foo}a",), "1", "{foo}a"),
@@ -448,6 +466,20 @@ class Handler:
448466
assert isinstance(items[0], Handler)
449467

450468

469+
def test_partial_class_without_subhandler() -> None:
470+
@xml_handle_element("x")
471+
class Handler:
472+
def __init__(self, ctx: str) -> None:
473+
self.ctx = ctx
474+
475+
partial_handler = partial(Handler, "foo")
476+
nodes = create_nodes("x", "y")
477+
handler = create_handler(partial_handler)
478+
items = list(handler(nodes[0]))
479+
assert len(items) == 1
480+
assert isinstance(items[0], Handler)
481+
482+
451483
@pytest.mark.parametrize("init_mandatory", [False, True])
452484
@pytest.mark.parametrize("init_optional", [False, True])
453485
def test_class_init(init_mandatory: bool, init_optional: bool) -> None:
@@ -581,6 +613,21 @@ def __init__(self, node: XMLElement, answer: int) -> None:
581613
assert "Add a default value for dataclass fields" not in str(excinfo.value)
582614

583615

616+
def test_partial_class_multiple_mandatory_parameters() -> None:
617+
@xml_handle_element("x")
618+
class Handler:
619+
def __init__(self, before: str, node: XMLElement, after: str) -> None:
620+
pass
621+
622+
partial_handler = partial(Handler, "before", after="after")
623+
nodes = create_nodes("x", "y")
624+
handler = create_handler(partial_handler)
625+
items = list(handler(nodes[0]))
626+
627+
assert len(items) == 1
628+
assert isinstance(items[0], Handler)
629+
630+
584631
def test_dataclass_init_two_mandatory_parameters() -> None:
585632
@xml_handle_element("x")
586633
@dataclass
@@ -598,6 +645,23 @@ class Handler:
598645
assert "Add a default value for dataclass fields" in str(excinfo.value)
599646

600647

648+
def test_partial_dataclass_two_mandatory_parameters() -> None:
649+
@xml_handle_element("x")
650+
@dataclass
651+
class Handler:
652+
before: str
653+
node: XMLElement
654+
after: str
655+
656+
partial_handler = partial(Handler, "before", after="after")
657+
nodes = create_nodes("x", "y")
658+
handler = create_handler(partial_handler)
659+
items = list(handler(nodes[0]))
660+
661+
assert len(items) == 1
662+
assert isinstance(items[0], Handler)
663+
664+
601665
def test_class_init_crash() -> None:
602666
@xml_handle_element("x")
603667
class Handler:

tests/unit/test_handler_marker.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from functools import partial
12
from typing import Iterator, Union
23

34
import pytest
@@ -15,6 +16,16 @@ def fct(node: XMLElement) -> Iterator[str]:
1516
assert get_marks(fct) == (("abc", "def"),)
1617

1718

19+
def test_one_marker_element_on_partial_func() -> None:
20+
@xml_handle_element("abc", "def")
21+
def fct(ctx: str, node: XMLElement) -> Iterator[str]:
22+
yield f"{ctx}: <{node.text}>"
23+
24+
partial_fct = partial(fct, "foo")
25+
26+
assert get_marks(partial_fct) == (("abc", "def"),)
27+
28+
1829
def test_one_maker_element_on_method() -> None:
1930
class Klass:
2031
def __init__(self, multiplier: int) -> None:

tests/unit/test_marks.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from functools import partial
2+
13
import pytest
24

35
from bigxml.marks import add_mark, get_marks, has_marks
@@ -20,3 +22,21 @@ class Markable:
2022
add_mark(obj, ("def", "ghi", "jkl"))
2123
assert has_marks(obj)
2224
assert get_marks(obj) == (("abc",), ("def", "ghi", "jkl"))
25+
26+
27+
def test_marks_on_partial() -> None:
28+
class Markable:
29+
pass
30+
31+
obj = partial(Markable, "foo")
32+
33+
assert not has_marks(obj)
34+
assert not get_marks(obj)
35+
36+
add_mark(obj, ("abc",))
37+
assert has_marks(obj)
38+
assert get_marks(obj) == (("abc",),)
39+
40+
add_mark(obj, ("def", "ghi", "jkl"))
41+
assert has_marks(obj)
42+
assert get_marks(obj) == (("abc",), ("def", "ghi", "jkl"))

0 commit comments

Comments
 (0)