Skip to content

Commit d44a378

Browse files
committed
Make xsdata an optional dependency with requests fallback
1 parent f546f24 commit d44a378

File tree

4 files changed

+180
-18
lines changed

4 files changed

+180
-18
lines changed

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ overrides its SOAP
1919

2020
`pip install brazil-fiscal-client`
2121

22+
If you want xsdata databinding/parsing support (recommended for nfelib/Odoo and advanced SOAP use cases), install the optional extra:
23+
24+
`pip install brazil-fiscal-client[xsdata]`
25+
2226
## Usage
2327

2428
For instance, with an appropriate pkcs12 certificate, you can query the NFe server
@@ -77,3 +81,30 @@ downloaded wsdl file and using the
7781
[WSDL xsdata generator](https://xsdata.readthedocs.io/en/latest/codegen/wsdl_modeling/).
7882
All this is usually done in the specialized clients that override this base
7983
`brazil-fiscal-client`SOAP client.
84+
85+
86+
## Usage without xsdata (requests-only mode)
87+
88+
If `xsdata` is not installed, `FiscalClient` still works for basic SOAP calls using plain XML payloads (string/bytes). In this mode, `send()` returns the raw SOAP XML response string.
89+
90+
```python
91+
from brazil_fiscal_client.fiscal_client import FiscalClient
92+
93+
client = FiscalClient(
94+
ambiente="2",
95+
versao="4.00",
96+
pkcs12_data=pkcs12_data,
97+
pkcs12_password=certificate_password,
98+
fake_certificate=True,
99+
)
100+
101+
raw_response_xml = client.send(
102+
action_class=None, # not required in requests-only mode
103+
location="https://example.com/service",
104+
wrapped_obj="""
105+
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
106+
<soapenv:Body>...</soapenv:Body>
107+
</soapenv:Envelope>
108+
""",
109+
)
110+
```

brazil_fiscal_client/fiscal_client.py

Lines changed: 97 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,37 @@
88
from dataclasses import dataclass
99
from datetime import datetime, timedelta, timezone
1010
from enum import Enum
11+
from importlib import import_module
12+
from importlib.util import find_spec
1113
from typing import Any
1214

15+
import requests
1316
from requests.adapters import HTTPAdapter, Retry
1417
from requests.exceptions import RequestException
1518
from requests_pkcs12 import Pkcs12Adapter
16-
from xsdata.exceptions import ParserError
17-
from xsdata.formats.dataclass.client import Client, ClientValueError, Config
18-
from xsdata.formats.dataclass.parsers import DictDecoder
19+
20+
XSDATA_AVAILABLE = find_spec("xsdata") is not None
21+
22+
if XSDATA_AVAILABLE:
23+
ParserError = import_module("xsdata.exceptions").ParserError
24+
_client_mod = import_module("xsdata.formats.dataclass.client")
25+
_parser_mod = import_module("xsdata.formats.dataclass.parsers")
26+
Client = _client_mod.Client
27+
ClientValueError = _client_mod.ClientValueError
28+
Config = _client_mod.Config
29+
DictDecoder = _parser_mod.DictDecoder
30+
else:
31+
32+
class ParserError(Exception):
33+
"""Raised when parsing a SOAP response fails."""
34+
35+
class ClientValueError(ValueError):
36+
"""Raised when a payload does not match client expectations."""
37+
38+
class _ClientBase:
39+
"""Fallback base class used when xsdata isn't installed."""
40+
41+
Client = _ClientBase
1942

2043
_logger = logging.Logger(__name__)
2144

@@ -136,14 +159,22 @@ def __init__(
136159
elif uf:
137160
self.uf = uf.value
138161

139-
super().__init__(config=kwargs.get("config", {}), **kwargs)
162+
self._xsdata_available = XSDATA_AVAILABLE
163+
if self._xsdata_available:
164+
super().__init__(config=kwargs.get("config", {}), **kwargs)
165+
else:
166+
self.session = requests.Session()
140167
self.versao = versao
141168
self.pkcs12_data = pkcs12_data
142169
self.pkcs12_password = pkcs12_password
143170
self.verify_ssl = verify_ssl
144171
self.service = service
145-
self.transport.timeout = timeout
146-
self.transport.session.verify = self.verify_ssl
172+
if self._xsdata_available:
173+
self.transport.timeout = timeout
174+
self.transport.session.verify = self.verify_ssl
175+
else:
176+
self.timeout = timeout
177+
self.session.verify = self.verify_ssl
147178
self.fake_certificate = fake_certificate
148179
self.soap12_envelope = soap12_envelope
149180
self.wrap_response = wrap_response
@@ -190,16 +221,17 @@ def send(
190221
The response model instance.
191222
"""
192223
server = "https://" + location.split("/")[2]
193-
self.config = Config.from_service(action_class, location=location)
224+
if self._xsdata_available:
225+
self.config = Config.from_service(action_class, location=location)
194226

195227
retries = Retry( # retry in case of errors
196228
total=RETRIES,
197229
backoff_factor=BACKOFF_FACTOR,
198230
status_forcelist=RETRY_ERRORS,
199231
)
200-
self.transport.session.mount(server, HTTPAdapter(max_retries=retries))
232+
self._session.mount(server, HTTPAdapter(max_retries=retries))
201233
if not self.fake_certificate:
202-
self.transport.session.mount(
234+
self._session.mount(
203235
server,
204236
Pkcs12Adapter(
205237
pkcs12_data=self.pkcs12_data,
@@ -215,9 +247,7 @@ def send(
215247
try:
216248
_logger.debug(f"Sending SOAP request to {location} with headers: {headers}")
217249
_logger.debug(f"SOAP request payload: {data}")
218-
original_response = self.transport.post(
219-
location, data=data, headers=headers
220-
)
250+
original_response = self._post(location, data=data, headers=headers)
221251
response = original_response.decode()
222252
_logger.debug(f"SOAP response: {response}")
223253

@@ -244,12 +274,15 @@ def send(
244274
)
245275
response = response.replace(SOAP12_ENV_NS, SOAP11_ENV_NS)
246276

247-
res = self.parser.from_string(response, action_class.output)
277+
if self._xsdata_available:
278+
res = self.parser.from_string(response, action_class.output)
279+
else:
280+
res = response
248281
if not self.wrap_response:
249282
return res
250283

251284
return WrappedResponse(
252-
webservice=action_class.soapAction.split("/")[-1],
285+
webservice=self._webservice_name(action_class),
253286
envio_raiz=placeholder_content,
254287
envio_xml=data.encode(),
255288
resposta=res,
@@ -259,13 +292,51 @@ def send(
259292
_logger.error(f"Failed to send SOAP request to {location}: {e}")
260293
raise
261294
except ParserError as e:
295+
if not self._xsdata_available:
296+
raise
262297
_logger.error(
263298
f"Failed to parse SOAP response as {action_class.output}\n"
264299
f"SOAP response:\n{response}"
265300
)
266301
_logger.error(f"Error: {e}")
267302
raise
268303

304+
def prepare_headers(self, headers: dict) -> dict:
305+
"""Prepare request headers.
306+
307+
Keep xsdata behavior when available and default to plain HTTP headers
308+
when running in lightweight mode without xsdata.
309+
"""
310+
if self._xsdata_available:
311+
return super().prepare_headers(headers)
312+
313+
return {
314+
"Content-Type": "text/xml; charset=utf-8",
315+
**headers,
316+
}
317+
318+
@property
319+
def _session(self):
320+
return self.transport.session if self._xsdata_available else self.session
321+
322+
def _post(self, location: str, data: str, headers: dict) -> bytes:
323+
if self._xsdata_available:
324+
return self.transport.post(location, data=data, headers=headers)
325+
326+
response = self.session.post(
327+
location,
328+
data=data,
329+
headers=headers,
330+
timeout=self.timeout,
331+
)
332+
response.raise_for_status()
333+
return response.content
334+
335+
@staticmethod
336+
def _webservice_name(action_class: Any) -> str:
337+
soap_action = getattr(action_class, "soapAction", "")
338+
return soap_action.split("/")[-1] if soap_action else "unknown"
339+
269340
def prepare_payload(
270341
self,
271342
obj: Any,
@@ -291,6 +362,18 @@ def prepare_payload(
291362
Raises:
292363
ClientValueError: If the config input type doesn't match the given object.
293364
"""
365+
if not self._xsdata_available:
366+
if isinstance(obj, bytes):
367+
return obj.decode()
368+
if isinstance(obj, str):
369+
return obj
370+
if isinstance(obj, dict) and "raw_xml" in obj:
371+
return str(obj["raw_xml"])
372+
raise ClientValueError(
373+
"xsdata is not installed: pass the SOAP payload as `str`, `bytes` "
374+
"or a dict containing a `raw_xml` key."
375+
)
376+
294377
if isinstance(obj, dict):
295378
decoder = DictDecoder(context=self.serializer.context)
296379
obj = decoder.decode(obj, self.config.input)

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ classifiers = [
2929
keywords = ["soap", "wsdl", "nfe", "fazenda", "binding", "nfelib", "odoo"]
3030
requires-python = ">=3.8"
3131
dependencies = [
32-
"xsdata",
3332
"requests",
3433
"requests-pkcs12"
3534
]
@@ -45,6 +44,9 @@ Source = "https://github.com/akretion/brazil-fiscal-client"
4544
Documentation = "https://github.com/akretion/brazil-fiscal-client"
4645

4746
[project.optional-dependencies]
47+
xsdata = [
48+
"xsdata",
49+
]
4850
test = [
4951
"pre-commit",
5052
"pytest",
@@ -53,8 +55,6 @@ test = [
5355
"lxml",
5456
]
5557

56-
[project.scripts]
57-
xsdata = "xsdata.__main__:main"
5858

5959
[tool.setuptools]
6060
include-package-data = true

tests/test_fiscal_client.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010
from xsdata.exceptions import ParserError
1111
from xsdata.formats.dataclass.transports import DefaultTransport
1212

13-
from brazil_fiscal_client.fiscal_client import FiscalClient, Tamb, TcodUfIbge
13+
from brazil_fiscal_client.fiscal_client import (
14+
ClientValueError,
15+
FiscalClient,
16+
Tamb,
17+
TcodUfIbge,
18+
)
1419
from tests.fixtures.nfestatusservico4 import NfeStatusServico4SoapNfeStatusServicoNf
1520

1621
response = """<?xml version="1.0" encoding="utf-8"?>
@@ -341,3 +346,46 @@ def test_send_raise_on_soap_mismatch(self, mock_post):
341346
self.assertIn(
342347
SOAP12_ENV_NS, str(cm.exception)
343348
) # Check if error mentions the unexpected NS
349+
350+
@mock.patch("brazil_fiscal_client.fiscal_client.XSDATA_AVAILABLE", False)
351+
@mock.patch("requests.Session.post")
352+
def test_send_without_xsdata_returns_raw_xml(self, mock_post):
353+
mock_post.return_value = mock.Mock(
354+
content=response.encode(),
355+
raise_for_status=mock.Mock(),
356+
)
357+
358+
client = FiscalClient(
359+
ambiente=Tamb.DEV,
360+
uf=TcodUfIbge.SC,
361+
versao="4.00",
362+
pkcs12_data=b"fake_cert",
363+
pkcs12_password="123456",
364+
fake_certificate=True,
365+
service="nfe",
366+
)
367+
368+
raw_payload = "<soapenv:Envelope xmlns:soapenv='http://schemas.xmlsoap.org/soap/envelope/'></soapenv:Envelope>"
369+
result = client.send(
370+
action_class=None,
371+
location="https://nfe-homologacao.svrs.rs.gov.br/ws/NfeStatusServico/NfeStatusServico4.asmx",
372+
wrapped_obj=raw_payload,
373+
)
374+
375+
self.assertIn("<soap:Envelope", result)
376+
self.assertTrue(mock_post.called)
377+
378+
@mock.patch("brazil_fiscal_client.fiscal_client.XSDATA_AVAILABLE", False)
379+
def test_prepare_payload_without_xsdata_requires_raw_xml(self):
380+
client = FiscalClient(
381+
ambiente=Tamb.DEV,
382+
uf=TcodUfIbge.SC,
383+
versao="4.00",
384+
pkcs12_data=b"fake_cert",
385+
pkcs12_password="123456",
386+
fake_certificate=True,
387+
service="nfe",
388+
)
389+
390+
with self.assertRaisesRegex(ClientValueError, "xsdata is not installed"):
391+
client.prepare_payload({"Body": {}})

0 commit comments

Comments
 (0)