soaptest 0.1.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.
@@ -0,0 +1,23 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ *.egg
8
+
9
+ # Tools
10
+ .pytest_cache/
11
+ .mypy_cache/
12
+ .ruff_cache/
13
+
14
+ # Virtualenvs
15
+ .venv/
16
+ venv/
17
+ env/
18
+
19
+ # Environment
20
+ .env
21
+
22
+ # Claude
23
+ .claude
soaptest-0.1.0/LICENSE ADDED
@@ -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.
@@ -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,117 @@
1
+ # soaptest
2
+
3
+ Make SOAP services testable from pytest. Point it at a WSDL, get callable Python.
4
+
5
+ ## 30-second example
6
+
7
+ ```bash
8
+ pip install soaptest
9
+ ```
10
+
11
+ ```python
12
+ from soaptest import Client
13
+
14
+ client = Client.from_wsdl(
15
+ "http://www.dataaccess.com/webservicesserver/NumberConversion.wso?WSDL"
16
+ )
17
+
18
+ # Discover what's available
19
+ print(client.describe())
20
+ # NumberConversionSoap @ http://www.dataaccess.com/webservicesserver/NumberConversion.wso
21
+ # NumberToWords(ubiNum: nonNegativeInteger) -> string
22
+ # NumberToDollars(dNum: decimal) -> string
23
+
24
+ # Call any operation by name
25
+ result = client.NumberToWords(ubiNum=42)
26
+ print(result) # "forty two "
27
+ ```
28
+
29
+ ## Why
30
+
31
+ SOAP testing today means SoapUI (a 200 MB GUI) or hand-rolling `requests` with raw XML.
32
+ Neither fits naturally into a pytest suite.
33
+
34
+ `soaptest` is a thin Pythonic layer on top of [zeep](https://docs.python-zeep.org/).
35
+ It gives you WSDL discovery (`describe()`) and operation dispatch (`client.OpName(**kwargs)`)
36
+ with zero boilerplate. You write plain pytest test functions; soaptest handles the SOAP.
37
+
38
+ What it does not do yet: record/replay of responses, fault translation into typed Python
39
+ exceptions, type stubs for operations, or a pytest fixture plugin. Those are on the
40
+ [Roadmap](#roadmap). v0.1 is intentionally minimal — get the basics right first.
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ pip install soaptest
46
+ ```
47
+
48
+ Python 3.9+ is required. `zeep`, `lxml`, and `httpx` are installed automatically.
49
+
50
+ ## Usage
51
+
52
+ ### Discover operations
53
+
54
+ ```python
55
+ from soaptest import Client
56
+
57
+ client = Client.from_wsdl("https://yourservice.example.com/service?WSDL")
58
+ print(client.describe()) # pretty-printed operation list
59
+ ops = client.operations # list of Operation named-tuples
60
+ ```
61
+
62
+ Each `Operation` has: `name`, `input_signature`, `output_signature`, `service`, `port`, `port_url`.
63
+
64
+ ### Call an operation
65
+
66
+ ```python
67
+ result = client.SomeOperation(param1="value", param2=123)
68
+ ```
69
+
70
+ `__getattr__` dispatches to the underlying zeep service. The return value is whatever
71
+ zeep returns (usually a `zeep.objects.*` object or a plain Python scalar). No conversion
72
+ in v0.1 — that's a future feature.
73
+
74
+ Unknown operation names raise `AttributeError`, so `hasattr(client, "Op")` works.
75
+
76
+ ### CLI
77
+
78
+ ```bash
79
+ soaptest http://www.dataaccess.com/webservicesserver/NumberConversion.wso?WSDL
80
+ ```
81
+
82
+ Prints the operation catalog to stdout and exits.
83
+
84
+ ### In pytest
85
+
86
+ ```python
87
+ import pytest
88
+ from soaptest import Client
89
+
90
+ WSDL = "https://yourservice.example.com/service?WSDL"
91
+
92
+ @pytest.fixture(scope="session")
93
+ def soap_client():
94
+ return Client.from_wsdl(WSDL)
95
+
96
+ def test_something(soap_client):
97
+ result = soap_client.SomeOperation(param="value")
98
+ assert result.status == "OK"
99
+ ```
100
+
101
+ ## Roadmap
102
+
103
+ v0.1 ships the discovery layer only. Planned for later releases:
104
+
105
+ - **Fault translation** — map SOAP faults to a typed `SoapFault` Python exception
106
+ - **pytest fixture plugin** — `@pytest.fixture` helpers and a `--wsdl` CLI option
107
+ - **Type-stub generation** — emit `.pyi` stubs so IDEs can autocomplete operations
108
+ - **Record/replay** — record live responses to YAML cassettes; replay offline
109
+
110
+ ## Contributing
111
+
112
+ File a bug or feature request in the issue tracker. Pull requests are welcome once
113
+ an issue is open. For major changes, open an issue first to discuss scope.
114
+
115
+ ## License
116
+
117
+ MIT. See LICENSE.
@@ -0,0 +1,70 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "soaptest"
7
+ version = "0.1.0"
8
+ description = "Make SOAP services testable from pytest — point at a WSDL, get callable Python."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "jaey8den", email = "89349331+jaey8den@users.noreply.github.com" }]
13
+ keywords = ["soap", "wsdl", "testing", "pytest", "zeep"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Testing",
25
+ "Topic :: Internet :: WWW/HTTP",
26
+ ]
27
+ dependencies = [
28
+ "zeep>=4.2",
29
+ "lxml>=5.0",
30
+ "httpx>=0.27",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ dev = [
35
+ "pytest>=8.0",
36
+ "ruff>=0.6",
37
+ "mypy>=1.10",
38
+ ]
39
+
40
+ [project.scripts]
41
+ soaptest = "soaptest.__main__:main"
42
+
43
+ [project.urls]
44
+ Homepage = "https://github.com/jaey8den/soap-tester"
45
+ Repository = "https://github.com/jaey8den/soap-tester"
46
+ Issues = "https://github.com/jaey8den/soap-tester/issues"
47
+
48
+ [tool.hatch.build.targets.wheel]
49
+ packages = ["src/soaptest"]
50
+
51
+ [tool.ruff]
52
+ line-length = 100
53
+ target-version = "py311"
54
+
55
+ [tool.ruff.lint]
56
+ select = ["E", "F", "I", "UP", "B", "SIM"]
57
+
58
+ [tool.mypy]
59
+ strict = true
60
+ python_version = "3.11"
61
+
62
+ [[tool.mypy.overrides]]
63
+ module = "zeep.*"
64
+ ignore_missing_imports = true
65
+
66
+ [tool.pytest.ini_options]
67
+ testpaths = ["tests"]
68
+ markers = [
69
+ "integration: marks tests that require network access to a live WSDL endpoint",
70
+ ]
File without changes
@@ -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"
@@ -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)
File without changes
@@ -0,0 +1,10 @@
1
+ """pytest configuration: register custom markers."""
2
+
3
+ import pytest
4
+
5
+
6
+ def pytest_configure(config: pytest.Config) -> None:
7
+ config.addinivalue_line(
8
+ "markers",
9
+ "integration: marks tests that require network access to a live WSDL endpoint",
10
+ )
@@ -0,0 +1,92 @@
1
+ """Integration tests against the public NumberConversion WSDL.
2
+
3
+ Run all tests: pytest
4
+ Skip network tests: pytest -m "not integration"
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import pytest
10
+
11
+ from soaptest import Client
12
+
13
+ WSDL = "http://www.dataaccess.com/webservicesserver/NumberConversion.wso?WSDL"
14
+
15
+
16
+ @pytest.mark.integration
17
+ def test_describe_lists_operations() -> None:
18
+ """describe() output must mention both known operations."""
19
+ client = Client.from_wsdl(WSDL)
20
+ output = client.describe()
21
+ assert "NumberToWords" in output
22
+ assert "NumberToDollars" in output
23
+
24
+
25
+ @pytest.mark.integration
26
+ def test_call_number_to_words() -> None:
27
+ """Calling NumberToWords(1234) should return a string containing 'thousand'."""
28
+ client = Client.from_wsdl(WSDL)
29
+ result = client.NumberToWords(ubiNum=1234)
30
+ assert "thousand" in str(result).lower()
31
+
32
+
33
+ @pytest.mark.integration
34
+ def test_operations_property_is_non_empty() -> None:
35
+ """client.operations must return at least the two known operations."""
36
+ client = Client.from_wsdl(WSDL)
37
+ names = [op.name for op in client.operations]
38
+ assert "NumberToWords" in names
39
+ assert "NumberToDollars" in names
40
+
41
+
42
+ def test_getattr_raises_for_unknown_operation() -> None:
43
+ """Accessing a non-existent operation must raise AttributeError (not any other error)."""
44
+
45
+ class _FakeOp:
46
+ name = "FakeOp"
47
+ input_signature = ""
48
+ output_signature = "str"
49
+ service = "FakeSvc"
50
+ port = "FakePort"
51
+ port_url = ""
52
+
53
+ from soaptest.client.introspect import Operation
54
+
55
+ fake_op = Operation(
56
+ name="FakeOp",
57
+ input_signature="",
58
+ output_signature="str",
59
+ service="FakeSvc",
60
+ port="FakePort",
61
+ port_url="",
62
+ )
63
+
64
+ # Build a Client without touching the network
65
+ import unittest.mock as mock
66
+
67
+ fake_zeep = mock.MagicMock()
68
+ client = Client(fake_zeep, [fake_op])
69
+
70
+ with pytest.raises(AttributeError):
71
+ _ = client.NonExistentOperation
72
+
73
+
74
+ def test_hasattr_returns_false_for_unknown_operation() -> None:
75
+ """hasattr() must return False (not raise) for unknown operations."""
76
+ import unittest.mock as mock
77
+
78
+ from soaptest.client.introspect import Operation
79
+
80
+ fake_op = Operation(
81
+ name="FakeOp",
82
+ input_signature="",
83
+ output_signature="str",
84
+ service="FakeSvc",
85
+ port="FakePort",
86
+ port_url="",
87
+ )
88
+ fake_zeep = mock.MagicMock()
89
+ client = Client(fake_zeep, [fake_op])
90
+
91
+ assert hasattr(client, "FakeOp") is True
92
+ assert hasattr(client, "DoesNotExist") is False