soaptest 0.1.0__py3-none-any.whl

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.
soaptest/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """soaptest — make SOAP services testable from pytest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from soaptest.client import Client
6
+
7
+ __all__ = ["Client"]
8
+ __version__ = "0.1.0"
soaptest/__main__.py ADDED
@@ -0,0 +1,26 @@
1
+ """CLI entry-point: ``soaptest <wsdl-url>`` prints the operation catalog."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+
8
+ def main() -> None:
9
+ """Print the operation catalog for a given WSDL URL."""
10
+ if len(sys.argv) < 2: # noqa: PLR2004
11
+ print("Usage: soaptest <wsdl-url-or-path>", file=sys.stderr)
12
+ sys.exit(1)
13
+
14
+ from soaptest import Client # local import keeps startup fast when --help is enough
15
+
16
+ url = sys.argv[1]
17
+ try:
18
+ client = Client.from_wsdl(url)
19
+ client.describe()
20
+ except Exception as exc: # noqa: BLE001
21
+ print(f"Error: {exc}", file=sys.stderr)
22
+ sys.exit(1)
23
+
24
+
25
+ if __name__ == "__main__":
26
+ main()
@@ -0,0 +1,7 @@
1
+ """soaptest.client — re-exports the public Client class."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from soaptest.client.client import Client
6
+
7
+ __all__ = ["Client"]
@@ -0,0 +1,107 @@
1
+ """Client: thin Pythonic wrapper around zeep for SOAP service discovery and calling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from soaptest.client.introspect import Operation, build_catalog, format_catalog
8
+
9
+
10
+ class Client:
11
+ """A thin wrapper around a zeep client for a single WSDL.
12
+
13
+ Typical usage::
14
+
15
+ from soaptest import Client
16
+
17
+ client = Client.from_wsdl("http://example.com/service?WSDL")
18
+ print(client.describe())
19
+ result = client.SomeOperation(param="value")
20
+ """
21
+
22
+ def __init__(self, zeep_client: Any, catalog: list[Operation]) -> None:
23
+ # Store under mangled names so __getattr__ never intercepts them
24
+ self._zeep_client = zeep_client
25
+ self._catalog = catalog
26
+ self._catalog_index: dict[str, Operation] = {op.name: op for op in catalog}
27
+
28
+ # ------------------------------------------------------------------
29
+ # Construction
30
+ # ------------------------------------------------------------------
31
+
32
+ @classmethod
33
+ def from_wsdl(cls, url: str) -> Client:
34
+ """Create a Client by loading a WSDL from *url* (http/https or file path).
35
+
36
+ Args:
37
+ url: URL or local file path to the WSDL document.
38
+
39
+ Returns:
40
+ A ready-to-use Client instance.
41
+ """
42
+ import zeep # local import so zeep errors surface at call time, not module import
43
+
44
+ zeep_client: Any = zeep.Client(wsdl=url) # type: ignore[no-untyped-call]
45
+ catalog = build_catalog(zeep_client)
46
+ return cls(zeep_client, catalog)
47
+
48
+ # ------------------------------------------------------------------
49
+ # Discovery
50
+ # ------------------------------------------------------------------
51
+
52
+ @property
53
+ def operations(self) -> list[Operation]:
54
+ """Read-only list of Operation named-tuples discovered from the WSDL."""
55
+ return list(self._catalog)
56
+
57
+ def describe(self) -> str:
58
+ """Return (and print) a formatted listing of all available operations.
59
+
60
+ The output groups operations by service/port::
61
+
62
+ NumberConversionSoap @ https://...
63
+ NumberToWords(ubiNum: nonNegativeInteger) -> string
64
+ NumberToDollars(dNum: decimal) -> string
65
+ """
66
+ text = format_catalog(self._catalog)
67
+ print(text)
68
+ return text
69
+
70
+ # ------------------------------------------------------------------
71
+ # Dynamic dispatch
72
+ # ------------------------------------------------------------------
73
+
74
+ def __getattr__(self, name: str) -> Any:
75
+ """Enable ``client.OperationName(**kwargs)`` for any catalogued operation.
76
+
77
+ Raises:
78
+ AttributeError: if *name* is not a known WSDL operation, so that
79
+ ``hasattr(client, name)`` works correctly.
80
+ """
81
+ # _catalog_index is set in __init__ via object.__setattr__-free assignment,
82
+ # so it IS in __dict__. But during pickling/copy Python may call __getattr__
83
+ # before __dict__ is fully restored — guard against that.
84
+ catalog_index = self.__dict__.get("_catalog_index", {})
85
+ if name not in catalog_index:
86
+ raise AttributeError(
87
+ f"{type(self).__name__!r} object has no attribute {name!r}. "
88
+ f"Available operations: {list(catalog_index)}"
89
+ )
90
+
91
+ service = self._zeep_client.service
92
+
93
+ def _call(**kwargs: Any) -> Any:
94
+ method = getattr(service, name)
95
+ return method(**kwargs)
96
+
97
+ _call.__name__ = name
98
+ _call.__qualname__ = f"Client.{name}"
99
+ return _call
100
+
101
+ # ------------------------------------------------------------------
102
+ # Representation
103
+ # ------------------------------------------------------------------
104
+
105
+ def __repr__(self) -> str:
106
+ n = len(self._catalog)
107
+ return f"<soaptest.Client operations={n}>"
@@ -0,0 +1,149 @@
1
+ """Operation catalog: walk zeep's WSDL bindings and build a structured list of operations.
2
+
3
+ Note: `binding._operations` is technically a private zeep API. It is the only
4
+ programmatic way to enumerate operations without parsing raw WSDL XML ourselves.
5
+ TODO: revisit if zeep exposes a stable public introspection API in a future release.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ from collections import defaultdict
12
+ from typing import NamedTuple
13
+
14
+
15
+ class Operation(NamedTuple):
16
+ """A single WSDL operation with its signatures and provenance."""
17
+
18
+ name: str
19
+ input_signature: str
20
+ output_signature: str
21
+ service: str
22
+ port: str
23
+ port_url: str
24
+
25
+
26
+ def _format_element(element: object) -> str:
27
+ """Return a human-readable type string for a zeep WSDL element."""
28
+ try:
29
+ # zeep elements have a .type attribute whose name we can read
30
+ type_obj = getattr(element, "type", None)
31
+ if type_obj is not None:
32
+ qname = getattr(type_obj, "name", None)
33
+ if qname:
34
+ return str(qname)
35
+ # Fall back to the element's own name
36
+ name = getattr(element, "name", None)
37
+ if name:
38
+ return str(name)
39
+ except Exception: # noqa: BLE001
40
+ pass
41
+ return "any"
42
+
43
+
44
+ def _input_signature(op: object) -> str:
45
+ """Produce 'param: type, ...' from a zeep operation's input message."""
46
+ try:
47
+ op_input = getattr(op, "input", None)
48
+ body = getattr(op_input, "body", None)
49
+ if body is None:
50
+ return ""
51
+ elements = getattr(body, "type", body)
52
+ # zeep exposes elements as an iterable of (name, element) pairs
53
+ parts = getattr(elements, "elements", None)
54
+ if parts:
55
+ return ", ".join(f"{n}: {_format_element(e)}" for n, e in parts)
56
+ # Some operations have a flat type with no sub-elements (scalar input)
57
+ name = getattr(body, "name", None)
58
+ if name:
59
+ return f"{name}: {_format_element(body)}"
60
+ except (AttributeError, TypeError):
61
+ pass
62
+ return ""
63
+
64
+
65
+ def _output_signature(op: object) -> str:
66
+ """Produce a type string from a zeep operation's output message."""
67
+ try:
68
+ op_output = getattr(op, "output", None)
69
+ body = getattr(op_output, "body", None)
70
+ if body is None:
71
+ return "any"
72
+ elements = getattr(body, "type", body)
73
+ parts = getattr(elements, "elements", None)
74
+ if parts:
75
+ pairs = list(parts)
76
+ if len(pairs) == 1:
77
+ return _format_element(pairs[0][1])
78
+ return ", ".join(f"{n}: {_format_element(e)}" for n, e in pairs)
79
+ return _format_element(body)
80
+ except (AttributeError, TypeError):
81
+ pass
82
+ return "any"
83
+
84
+
85
+ def build_catalog(zeep_client: object) -> list[Operation]:
86
+ """Walk zeep_client.wsdl.services to build a list of Operation records."""
87
+ operations: list[Operation] = []
88
+
89
+ wsdl = getattr(zeep_client, "wsdl", None)
90
+ if wsdl is None:
91
+ return operations
92
+
93
+ services = getattr(wsdl, "services", {})
94
+ for service_name, service in services.items():
95
+ ports = getattr(service, "ports", {})
96
+ for port_name, port in ports.items():
97
+ binding = getattr(port, "binding", None)
98
+ if binding is None:
99
+ continue
100
+ # _operations: dict[str, zeep.wsdl.definitions.Operation]
101
+ # Private API — see module docstring.
102
+ raw_ops = getattr(binding, "_operations", {})
103
+ port_url: str = ""
104
+ with contextlib.suppress(AttributeError, TypeError):
105
+ port_url = str(port.binding_options.get("address", ""))
106
+ for op_name, op in raw_ops.items():
107
+ operations.append(
108
+ Operation(
109
+ name=str(op_name),
110
+ input_signature=_input_signature(op),
111
+ output_signature=_output_signature(op),
112
+ service=str(service_name),
113
+ port=str(port_name),
114
+ port_url=port_url,
115
+ )
116
+ )
117
+
118
+ return operations
119
+
120
+
121
+ def format_catalog(operations: list[Operation]) -> str:
122
+ """Render the operation list as a human-readable multi-line string.
123
+
124
+ Output format::
125
+
126
+ ServiceName @ https://endpoint.example.com
127
+ OperationA(param: type) -> returnType
128
+ OperationB() -> returnType
129
+ """
130
+ if not operations:
131
+ return "(no operations found)"
132
+
133
+ # Group by (service, port) preserving insertion order
134
+ groups: dict[tuple[str, str, str], list[Operation]] = defaultdict(list)
135
+ for op in operations:
136
+ groups[(op.service, op.port, op.port_url)].append(op)
137
+
138
+ lines: list[str] = []
139
+ for (_service, port, url), ops in groups.items():
140
+ header = f"{port}"
141
+ if url:
142
+ header += f" @ {url}"
143
+ lines.append(header)
144
+ for op in ops:
145
+ sig = op.input_signature or ""
146
+ ret = op.output_signature or "any"
147
+ lines.append(f" {op.name}({sig}) -> {ret}")
148
+
149
+ return "\n".join(lines)
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.4
2
+ Name: soaptest
3
+ Version: 0.1.0
4
+ Summary: Make SOAP services testable from pytest — point at a WSDL, get callable Python.
5
+ Project-URL: Homepage, https://github.com/jaey8den/soap-tester
6
+ Project-URL: Repository, https://github.com/jaey8den/soap-tester
7
+ Project-URL: Issues, https://github.com/jaey8den/soap-tester/issues
8
+ Author-email: jaey8den <89349331+jaey8den@users.noreply.github.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: pytest,soap,testing,wsdl,zeep
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Internet :: WWW/HTTP
22
+ Classifier: Topic :: Software Development :: Testing
23
+ Requires-Python: >=3.9
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: lxml>=5.0
26
+ Requires-Dist: zeep>=4.2
27
+ Provides-Extra: dev
28
+ Requires-Dist: mypy>=1.10; extra == 'dev'
29
+ Requires-Dist: pytest>=8.0; extra == 'dev'
30
+ Requires-Dist: ruff>=0.6; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # soaptest
34
+
35
+ Make SOAP services testable from pytest. Point it at a WSDL, get callable Python.
36
+
37
+ ## 30-second example
38
+
39
+ ```bash
40
+ pip install soaptest
41
+ ```
42
+
43
+ ```python
44
+ from soaptest import Client
45
+
46
+ client = Client.from_wsdl(
47
+ "http://www.dataaccess.com/webservicesserver/NumberConversion.wso?WSDL"
48
+ )
49
+
50
+ # Discover what's available
51
+ print(client.describe())
52
+ # NumberConversionSoap @ http://www.dataaccess.com/webservicesserver/NumberConversion.wso
53
+ # NumberToWords(ubiNum: nonNegativeInteger) -> string
54
+ # NumberToDollars(dNum: decimal) -> string
55
+
56
+ # Call any operation by name
57
+ result = client.NumberToWords(ubiNum=42)
58
+ print(result) # "forty two "
59
+ ```
60
+
61
+ ## Why
62
+
63
+ SOAP testing today means SoapUI (a 200 MB GUI) or hand-rolling `requests` with raw XML.
64
+ Neither fits naturally into a pytest suite.
65
+
66
+ `soaptest` is a thin Pythonic layer on top of [zeep](https://docs.python-zeep.org/).
67
+ It gives you WSDL discovery (`describe()`) and operation dispatch (`client.OpName(**kwargs)`)
68
+ with zero boilerplate. You write plain pytest test functions; soaptest handles the SOAP.
69
+
70
+ What it does not do yet: record/replay of responses, fault translation into typed Python
71
+ exceptions, type stubs for operations, or a pytest fixture plugin. Those are on the
72
+ [Roadmap](#roadmap). v0.1 is intentionally minimal — get the basics right first.
73
+
74
+ ## Installation
75
+
76
+ ```bash
77
+ pip install soaptest
78
+ ```
79
+
80
+ Python 3.9+ is required. `zeep`, `lxml`, and `httpx` are installed automatically.
81
+
82
+ ## Usage
83
+
84
+ ### Discover operations
85
+
86
+ ```python
87
+ from soaptest import Client
88
+
89
+ client = Client.from_wsdl("https://yourservice.example.com/service?WSDL")
90
+ print(client.describe()) # pretty-printed operation list
91
+ ops = client.operations # list of Operation named-tuples
92
+ ```
93
+
94
+ Each `Operation` has: `name`, `input_signature`, `output_signature`, `service`, `port`, `port_url`.
95
+
96
+ ### Call an operation
97
+
98
+ ```python
99
+ result = client.SomeOperation(param1="value", param2=123)
100
+ ```
101
+
102
+ `__getattr__` dispatches to the underlying zeep service. The return value is whatever
103
+ zeep returns (usually a `zeep.objects.*` object or a plain Python scalar). No conversion
104
+ in v0.1 — that's a future feature.
105
+
106
+ Unknown operation names raise `AttributeError`, so `hasattr(client, "Op")` works.
107
+
108
+ ### CLI
109
+
110
+ ```bash
111
+ soaptest http://www.dataaccess.com/webservicesserver/NumberConversion.wso?WSDL
112
+ ```
113
+
114
+ Prints the operation catalog to stdout and exits.
115
+
116
+ ### In pytest
117
+
118
+ ```python
119
+ import pytest
120
+ from soaptest import Client
121
+
122
+ WSDL = "https://yourservice.example.com/service?WSDL"
123
+
124
+ @pytest.fixture(scope="session")
125
+ def soap_client():
126
+ return Client.from_wsdl(WSDL)
127
+
128
+ def test_something(soap_client):
129
+ result = soap_client.SomeOperation(param="value")
130
+ assert result.status == "OK"
131
+ ```
132
+
133
+ ## Roadmap
134
+
135
+ v0.1 ships the discovery layer only. Planned for later releases:
136
+
137
+ - **Fault translation** — map SOAP faults to a typed `SoapFault` Python exception
138
+ - **pytest fixture plugin** — `@pytest.fixture` helpers and a `--wsdl` CLI option
139
+ - **Type-stub generation** — emit `.pyi` stubs so IDEs can autocomplete operations
140
+ - **Record/replay** — record live responses to YAML cassettes; replay offline
141
+
142
+ ## Contributing
143
+
144
+ File a bug or feature request in the issue tracker. Pull requests are welcome once
145
+ an issue is open. For major changes, open an issue first to discuss scope.
146
+
147
+ ## License
148
+
149
+ MIT. See LICENSE.
@@ -0,0 +1,10 @@
1
+ soaptest/__init__.py,sha256=25kzJGt9lM96IshbsK_aZN0seibLCjXl1CSR85ITyD0,176
2
+ soaptest/__main__.py,sha256=MCdo5NlmT--jXnbq3utDtgUI5yWSwWzAoq3Sq4_tgts,679
3
+ soaptest/client/__init__.py,sha256=6Afaf3rbcpPiWXYPj59X9eklAiF2vOI_XSc0ES4qXsE,163
4
+ soaptest/client/client.py,sha256=anv4IAjOxaWboXkP0ZzT5Xj8rR8ypIfVjcLiUG_dClI,3868
5
+ soaptest/client/introspect.py,sha256=PmOOHWVJX9g0umuQS3B2xzqyTIojPwuha8jtsOb28B4,5123
6
+ soaptest-0.1.0.dist-info/METADATA,sha256=0sDUuakIi4srZEYt0koHayGj3Yb20SIcSQnTmbaiy7o,4628
7
+ soaptest-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ soaptest-0.1.0.dist-info/entry_points.txt,sha256=nJZtYO_xEZbENhJMsdjA3_bRqvXh333u--rH8_nv4_M,52
9
+ soaptest-0.1.0.dist-info/licenses/LICENSE,sha256=TILopF8tBKvJWkTqy_7G0943ZQwINo2dE9lI5araqUc,1065
10
+ soaptest-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ soaptest = soaptest.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jaey8den
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.