-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclient.py
More file actions
297 lines (247 loc) · 11.4 KB
/
Copy pathclient.py
File metadata and controls
297 lines (247 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
import os
import sys
import xmlrpc.client
import ssl
import typer
from loguru import logger
from utils import Data, convert_value_from_xmlrpc
# Ensure logs directory exists
logs_dir = "logs"
os.makedirs(logs_dir, exist_ok=True)
# Configure loguru logger. Loguru installs a DEBUG-level stderr sink by
# default, which prints ~17 internal tracing lines per call; replace it so
# both sinks honour LOG_LEVEL (INFO unless overridden).
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
logger.remove()
logger.add(sys.stderr, level=LOG_LEVEL)
logger.add(os.path.join(logs_dir, "client.log"), rotation="1 MB", level=LOG_LEVEL)
app = typer.Typer(help="XML-RPC Client - Call math functions on XML-RPC servers")
class RPCError(Exception):
"""Raised when the server reports an error response."""
def __init__(self, message, response_code=None):
super().__init__(message)
self.message = message
self.response_code = response_code
def parse_number(raw):
"""Parse a CLI numeric argument, keeping integers exact.
Annotating the CLI arguments as float would coerce 9223372036854775807 to
9.223372036854776e+18, defeating the arbitrary-precision integer support
that the wire protocol exists to provide.
"""
text = str(raw).strip()
try:
return int(text)
except ValueError:
pass
try:
return float(text)
except ValueError:
raise typer.BadParameter(f"{raw!r} is not a valid number")
class DataServerProxy(xmlrpc.client.ServerProxy):
"""ServerProxy that automatically wraps arguments in Data for non-system methods."""
class _Method:
def __init__(self, send, name):
self.__send = send
self.__name = name
def __call__(self, *args, **kwargs):
logger.debug("DataServerProxy._Method.__call__: method={}, args={}, kwargs={}", self.__name, args, kwargs)
# Don't wrap system/introspection calls
if self.__name.startswith("system."):
logger.debug("System method detected: {}, passing args as-is", self.__name)
return self.__send(self.__name, args)
# If the first argument is already a Data object, pass as-is
if args and isinstance(args[0], Data):
logger.debug("Data object detected as first argument, passing as-is: {}", args[0])
return self.__send(self.__name, args)
# Otherwise, wrap all args/kwargs in a Data object
data_obj = Data(*args, **kwargs)
logger.debug("Created Data object: {}", data_obj)
logger.debug("Sending to server: method={}, data={}", self.__name, data_obj)
result = self.__send(self.__name, (data_obj,))
logger.debug("Received result from server: {}", result)
return result
def __getattr__(self, name):
full_name = f"{self.__name}.{name}"
logger.debug("DataServerProxy._Method.__getattr__: creating method {}", full_name)
return DataServerProxy._Method(self.__send, full_name)
def __getattr__(self, name):
# Prevent infinite recursion by checking for internal attributes first.
# The server rejects underscore-prefixed methods anyway.
if name.startswith('_'):
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
logger.debug("DataServerProxy.__getattr__: creating method {}", name)
return DataServerProxy._Method(self._ServerProxy__request, name)
def __init__(self, uri, **kwargs):
logger.debug("Initializing DataServerProxy with uri={}, kwargs={}", uri, kwargs)
super().__init__(uri, **kwargs)
logger.info("DataServerProxy initialized for {}", uri)
class XMLRPCClient:
"""XML-RPC client supporting both HTTP and HTTPS with kwargs support"""
def __init__(self, server_url, verify_ssl=False):
"""
Initialize client
Args:
server_url: URL of the XML-RPC server
verify_ssl: Whether to verify SSL certificates (False for self-signed)
"""
logger.debug("Initializing XMLRPCClient with server_url={}, verify_ssl={}", server_url, verify_ssl)
self.server_url = server_url
if server_url.startswith('https://') and not verify_ssl:
# Create SSL context that doesn't verify certificates (for self-signed).
# This accepts any certificate, so it offers no protection against an
# active attacker - it is for local development only.
logger.warning("TLS certificate verification is disabled for {}", server_url)
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
self.proxy = DataServerProxy(server_url, context=context, allow_none=True, use_builtin_types=True)
else:
logger.debug("Creating standard proxy for HTTP")
self.proxy = DataServerProxy(server_url, allow_none=True, use_builtin_types=True)
logger.info("XMLRPCClient initialized for {}", server_url)
def test_connection(self, verbose: bool = True):
"""Test connection to server"""
logger.debug("Testing connection to {}", self.server_url)
try:
# Use introspection to test connection since it's enabled
methods = self.proxy.system.listMethods()
if verbose:
print(f"✓ Connected to {self.server_url}")
print(f"Available methods: {', '.join(methods)}")
logger.success("Connection test successful for {}", self.server_url)
return True
except Exception as e:
if verbose:
print(f"✗ Failed to connect to {self.server_url}: {e}")
logger.error("Connection test failed for {}: {}", self.server_url, e)
return False
def _return_result(self, result):
"""Unwrap a server response, decoding wire markers back to real types.
Raises:
RPCError: if the server reported an error response.
"""
logger.debug("_return_result called with result={}", result)
if isinstance(result, dict) and result.get('_is_data_object', False):
response_code = result.get('response_code', 'unknown')
error = result.get('error')
if error:
logger.error("Server returned error: {} (Code: {})", error, response_code)
raise RPCError(error, response_code)
# Convert string representations back to int/float for final result
actual_result = result.get('result')
converted_result = convert_value_from_xmlrpc(actual_result)
logger.debug("Converted result from {} to {}", actual_result, converted_result)
return converted_result
else:
# Convert direct results as well
converted_result = convert_value_from_xmlrpc(result)
logger.debug("Converted direct result from {} to {}", result, converted_result)
return converted_result
def call_math_function(self, operation: str, x, y):
"""Call a math function on the server and print the decoded result.
Raises:
RPCError: if the server reported an error response.
"""
logger.debug("call_math_function: operation={}, x={}, y={}", operation, x, y)
# Arguments are automatically wrapped in Data by DataServerProxy
response = getattr(self.proxy, operation)(x=x, y=y)
value = self._return_result(response)
print(f"{operation}({x}, {y}) = {value}")
logger.success("Math function {}({}, {}) completed successfully: {}", operation, x, y, value)
return value
def get_client(url: str) -> XMLRPCClient:
"""Get a configured client for the given URL.
No connection probe is made here: the previous implementation called
system.listMethods() before every command, doubling the round trips per
invocation. Connection errors surface on the real call instead.
"""
return XMLRPCClient(url, verify_ssl=False)
def _run_math(operation: str, x: str, y: str, url: str):
"""Shared CLI body: parse arguments, call the server, map failures to exit codes."""
x_val = parse_number(x)
y_val = parse_number(y)
client = get_client(url)
try:
client.call_math_function(operation, x_val, y_val)
except RPCError as e:
print(f"Error calling {operation}({x_val}, {y_val}): {e.message} (Code: {e.response_code})")
raise typer.Exit(1)
except Exception as e:
print(f"Error calling {operation}({x_val}, {y_val}): {e}")
logger.error("Error calling {}({}, {}): {}", operation, x_val, y_val, e)
raise typer.Exit(1)
NUMBER_HELP = "Number to operate on (integer or decimal; integers keep full precision)"
@app.command()
def add(
x: str = typer.Argument(..., metavar="X", help=NUMBER_HELP),
y: str = typer.Argument(..., metavar="Y", help=NUMBER_HELP),
url: str = typer.Option("http://localhost:8000", help="Server URL"),
):
"""Add two numbers"""
_run_math("add", x, y, url)
@app.command()
def subtract(
x: str = typer.Argument(..., metavar="X", help=NUMBER_HELP),
y: str = typer.Argument(..., metavar="Y", help=NUMBER_HELP),
url: str = typer.Option("http://localhost:8000", help="Server URL"),
):
"""Subtract y from x"""
_run_math("subtract", x, y, url)
@app.command()
def multiply(
x: str = typer.Argument(..., metavar="X", help=NUMBER_HELP),
y: str = typer.Argument(..., metavar="Y", help=NUMBER_HELP),
url: str = typer.Option("http://localhost:8000", help="Server URL"),
):
"""Multiply two numbers"""
_run_math("multiply", x, y, url)
@app.command()
def divide(
x: str = typer.Argument(..., metavar="X", help=NUMBER_HELP),
y: str = typer.Argument(..., metavar="Y", help=NUMBER_HELP),
url: str = typer.Option("http://localhost:8000", help="Server URL"),
):
"""Divide x by y"""
_run_math("divide", x, y, url)
@app.command()
def list_methods(
url: str = typer.Option("http://localhost:8000", help="Server URL")
):
"""List available methods on the server"""
client = get_client(url)
try:
methods = client.proxy.system.listMethods()
except Exception as e:
print(f"Error listing methods: {e}")
logger.error("Error listing methods on {}: {}", url, e)
raise typer.Exit(1)
print("Available methods:")
for method in methods:
print(f" - {method}")
@app.command()
def test(
url: str = typer.Option("http://localhost:8000", help="Server URL")
):
"""Test connection to server"""
client = XMLRPCClient(url, verify_ssl=False)
if client.test_connection():
print("Connection test successful!")
else:
raise typer.Exit(1)
@app.callback(invoke_without_command=True)
def main(ctx: typer.Context):
"""XML-RPC Client - Call math functions on XML-RPC servers
Examples:
python client.py add 10 5
python client.py multiply 7 6 --url https://localhost:8443
python client.py list-methods --url https://localhost:8443
"""
if ctx.invoked_subcommand is None:
print("Use --help to see available commands")
print("\nQuick examples:")
print(" python client.py add 10 5")
print(" python client.py multiply 7 6 --url https://localhost:8443")
print(" python client.py list-methods")
if __name__ == "__main__":
with logger.catch():
app()