2017-06-02 20:30:36 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
# Copyright (c) 2017 The Bitcoin Core developers
|
|
|
|
# Distributed under the MIT software license, see the accompanying
|
|
|
|
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
|
|
|
"""Class for bitcoind node under test"""
|
|
|
|
|
2017-07-11 19:01:44 +02:00
|
|
|
import decimal
|
2017-06-02 20:30:36 +02:00
|
|
|
import errno
|
2018-03-28 15:37:09 +02:00
|
|
|
from enum import Enum
|
2017-06-02 20:30:36 +02:00
|
|
|
import http.client
|
2017-07-11 19:01:44 +02:00
|
|
|
import json
|
2017-06-02 20:30:36 +02:00
|
|
|
import logging
|
|
|
|
import os
|
2017-12-21 00:38:40 +01:00
|
|
|
import re
|
2017-06-02 20:30:36 +02:00
|
|
|
import subprocess
|
2018-02-07 15:36:13 +01:00
|
|
|
import tempfile
|
2017-06-02 20:30:36 +02:00
|
|
|
import time
|
|
|
|
|
2017-03-27 15:42:17 +02:00
|
|
|
from .authproxy import JSONRPCException
|
2017-06-02 20:30:36 +02:00
|
|
|
from .util import (
|
2018-03-06 22:48:15 +01:00
|
|
|
append_config,
|
2018-04-09 20:07:47 +02:00
|
|
|
delete_cookie_file,
|
2017-06-02 20:30:36 +02:00
|
|
|
get_rpc_proxy,
|
|
|
|
rpc_url,
|
2017-08-16 17:52:24 +02:00
|
|
|
wait_until,
|
2017-03-27 15:42:17 +02:00
|
|
|
p2p_port,
|
2017-06-02 20:30:36 +02:00
|
|
|
)
|
|
|
|
|
2017-12-21 00:38:40 +01:00
|
|
|
# For Python 3.4 compatibility
|
|
|
|
JSONDecodeError = getattr(json, "JSONDecodeError", ValueError)
|
|
|
|
|
2017-08-16 17:52:24 +02:00
|
|
|
BITCOIND_PROC_WAIT_TIMEOUT = 60
|
|
|
|
|
2018-03-28 08:44:30 +02:00
|
|
|
|
|
|
|
class FailedToStartError(Exception):
|
|
|
|
"""Raised when a node fails to start correctly."""
|
|
|
|
|
|
|
|
|
2018-03-28 15:37:09 +02:00
|
|
|
class ErrorMatch(Enum):
|
|
|
|
FULL_TEXT = 1
|
|
|
|
FULL_REGEX = 2
|
|
|
|
PARTIAL_REGEX = 3
|
|
|
|
|
|
|
|
|
2017-06-02 20:30:36 +02:00
|
|
|
class TestNode():
|
|
|
|
"""A class for representing a bitcoind node under test.
|
|
|
|
|
|
|
|
This class contains:
|
|
|
|
|
|
|
|
- state about the node (whether it's running, etc)
|
|
|
|
- a Python subprocess.Popen object representing the running process
|
|
|
|
- an RPC connection to the node
|
2017-03-27 15:42:17 +02:00
|
|
|
- one or more P2P connections to the node
|
|
|
|
|
2017-06-02 20:30:36 +02:00
|
|
|
|
2017-03-27 15:42:17 +02:00
|
|
|
To make things easier for the test writer, any unrecognised messages will
|
|
|
|
be dispatched to the RPC connection."""
|
2017-06-02 20:30:36 +02:00
|
|
|
|
2018-01-02 14:57:27 +01:00
|
|
|
def __init__(self, i, datadir, rpchost, timewait, binary, stderr, mocktime, coverage_dir, extra_conf=None, extra_args=None, use_cli=False):
|
2017-06-02 20:30:36 +02:00
|
|
|
self.index = i
|
2018-01-02 14:57:27 +01:00
|
|
|
self.datadir = datadir
|
2017-06-02 20:30:36 +02:00
|
|
|
self.rpchost = rpchost
|
2017-08-16 21:46:48 +02:00
|
|
|
if timewait:
|
|
|
|
self.rpc_timeout = timewait
|
|
|
|
else:
|
|
|
|
# Wait for up to 60 seconds for the RPC server to respond
|
|
|
|
self.rpc_timeout = 60
|
2017-06-02 20:30:36 +02:00
|
|
|
if binary is None:
|
|
|
|
self.binary = os.getenv("BITCOIND", "bitcoind")
|
|
|
|
else:
|
|
|
|
self.binary = binary
|
|
|
|
self.stderr = stderr
|
|
|
|
self.coverage_dir = coverage_dir
|
2018-03-06 22:48:15 +01:00
|
|
|
if extra_conf != None:
|
2018-01-02 14:57:27 +01:00
|
|
|
append_config(datadir, extra_conf)
|
2018-02-15 20:01:43 +01:00
|
|
|
# Most callers will just need to add extra args to the standard list below.
|
2018-03-18 15:26:45 +01:00
|
|
|
# For those callers that need more flexibility, they can just set the args property directly.
|
2018-02-15 20:01:43 +01:00
|
|
|
# Note that common args are set in the config file (see initialize_datadir)
|
2017-06-02 20:30:36 +02:00
|
|
|
self.extra_args = extra_args
|
2018-03-27 06:35:35 +02:00
|
|
|
self.args = [
|
|
|
|
self.binary,
|
|
|
|
"-datadir=" + self.datadir,
|
|
|
|
"-logtimemicros",
|
|
|
|
"-debug",
|
|
|
|
"-debugexclude=libevent",
|
|
|
|
"-debugexclude=leveldb",
|
|
|
|
"-mocktime=" + str(mocktime),
|
|
|
|
"-uacomment=testnode%d" % i,
|
|
|
|
"-noprinttoconsole"
|
|
|
|
]
|
2017-06-02 20:30:36 +02:00
|
|
|
|
2017-07-11 19:01:44 +02:00
|
|
|
self.cli = TestNodeCLI(os.getenv("BITCOINCLI", "bitcoin-cli"), self.datadir)
|
2017-07-11 19:14:18 +02:00
|
|
|
self.use_cli = use_cli
|
2017-07-11 19:01:44 +02:00
|
|
|
|
2017-06-02 20:30:36 +02:00
|
|
|
self.running = False
|
|
|
|
self.process = None
|
|
|
|
self.rpc_connected = False
|
|
|
|
self.rpc = None
|
|
|
|
self.url = None
|
|
|
|
self.log = logging.getLogger('TestFramework.node%d' % i)
|
2018-04-06 16:53:35 +02:00
|
|
|
self.cleanup_on_exit = True # Whether to kill the node when this object goes away
|
2017-06-02 20:30:36 +02:00
|
|
|
|
2017-03-27 15:42:17 +02:00
|
|
|
self.p2ps = []
|
|
|
|
|
2018-04-18 21:17:22 +02:00
|
|
|
def _node_msg(self, msg: str) -> str:
|
|
|
|
"""Return a modified msg that identifies this node by its index as a debugging aid."""
|
|
|
|
return "[node %d] %s" % (self.index, msg)
|
|
|
|
|
|
|
|
def _raise_assertion_error(self, msg: str):
|
|
|
|
"""Raise an AssertionError with msg modified to identify this node."""
|
|
|
|
raise AssertionError(self._node_msg(msg))
|
|
|
|
|
2018-04-06 16:53:35 +02:00
|
|
|
def __del__(self):
|
|
|
|
# Ensure that we don't leave any bitcoind processes lying around after
|
|
|
|
# the test ends
|
|
|
|
if self.process and self.cleanup_on_exit:
|
|
|
|
# Should only happen on test failure
|
|
|
|
# Avoid using logger, as that may have already been shutdown when
|
|
|
|
# this destructor is called.
|
2018-04-18 21:17:22 +02:00
|
|
|
print(self._node_msg("Cleaning up leftover process"))
|
2018-04-06 16:53:35 +02:00
|
|
|
self.process.kill()
|
|
|
|
|
2017-10-20 15:27:55 +02:00
|
|
|
def __getattr__(self, name):
|
2017-07-11 19:14:18 +02:00
|
|
|
"""Dispatches any unrecognised messages to the RPC connection or a CLI instance."""
|
|
|
|
if self.use_cli:
|
|
|
|
return getattr(self.cli, name)
|
|
|
|
else:
|
2018-04-18 21:17:22 +02:00
|
|
|
assert self.rpc_connected and self.rpc is not None, self._node_msg("Error: no RPC connection")
|
2017-07-11 19:14:18 +02:00
|
|
|
return getattr(self.rpc, name)
|
2017-06-02 20:30:36 +02:00
|
|
|
|
2018-01-18 19:15:00 +01:00
|
|
|
def start(self, extra_args=None, stderr=None, *args, **kwargs):
|
2017-06-02 20:30:36 +02:00
|
|
|
"""Start the node."""
|
2017-06-09 22:35:17 +02:00
|
|
|
if extra_args is None:
|
|
|
|
extra_args = self.extra_args
|
|
|
|
if stderr is None:
|
|
|
|
stderr = self.stderr
|
2018-04-09 20:07:47 +02:00
|
|
|
# Delete any existing cookie file -- if such a file exists (eg due to
|
|
|
|
# unclean shutdown), it will get overwritten anyway by bitcoind, and
|
|
|
|
# potentially interfere with our attempt to authenticate
|
|
|
|
delete_cookie_file(self.datadir)
|
2018-01-18 19:15:00 +01:00
|
|
|
self.process = subprocess.Popen(self.args + extra_args, stderr=stderr, *args, **kwargs)
|
2017-06-02 20:30:36 +02:00
|
|
|
self.running = True
|
|
|
|
self.log.debug("bitcoind started, waiting for RPC to come up")
|
|
|
|
|
|
|
|
def wait_for_rpc_connection(self):
|
|
|
|
"""Sets up an RPC connection to the bitcoind process. Returns False if unable to connect."""
|
2017-08-16 21:46:48 +02:00
|
|
|
# Poll at a rate of four times per second
|
|
|
|
poll_per_s = 4
|
|
|
|
for _ in range(poll_per_s * self.rpc_timeout):
|
2018-03-28 08:44:30 +02:00
|
|
|
if self.process.poll() is not None:
|
2018-04-18 21:17:22 +02:00
|
|
|
raise FailedToStartError(self._node_msg(
|
|
|
|
'bitcoind exited with status {} during initialization'.format(self.process.returncode)))
|
2017-06-02 20:30:36 +02:00
|
|
|
try:
|
2017-08-23 21:49:01 +02:00
|
|
|
self.rpc = get_rpc_proxy(rpc_url(self.datadir, self.index, self.rpchost), self.index, timeout=self.rpc_timeout, coveragedir=self.coverage_dir)
|
2017-06-02 20:30:36 +02:00
|
|
|
self.rpc.getblockcount()
|
|
|
|
# If the call to getblockcount() succeeds then the RPC connection is up
|
|
|
|
self.rpc_connected = True
|
|
|
|
self.url = self.rpc.url
|
|
|
|
self.log.debug("RPC successfully started")
|
|
|
|
return
|
|
|
|
except IOError as e:
|
|
|
|
if e.errno != errno.ECONNREFUSED: # Port not yet open?
|
|
|
|
raise # unknown IO error
|
|
|
|
except JSONRPCException as e: # Initialization phase
|
|
|
|
if e.error['code'] != -28: # RPC in warmup?
|
|
|
|
raise # unknown JSON RPC exception
|
|
|
|
except ValueError as e: # cookie file not found and no rpcuser or rpcassword. bitcoind still starting
|
|
|
|
if "No RPC credentials" not in str(e):
|
|
|
|
raise
|
2017-08-18 22:09:58 +02:00
|
|
|
time.sleep(1.0 / poll_per_s)
|
2018-04-18 21:17:22 +02:00
|
|
|
self._raise_assertion_error("Unable to connect to bitcoind")
|
2017-06-02 20:30:36 +02:00
|
|
|
|
|
|
|
def get_wallet_rpc(self, wallet_name):
|
2017-07-11 19:14:18 +02:00
|
|
|
if self.use_cli:
|
|
|
|
return self.cli("-rpcwallet={}".format(wallet_name))
|
|
|
|
else:
|
2018-04-18 21:17:22 +02:00
|
|
|
assert self.rpc_connected and self.rpc, self._node_msg("RPC not connected")
|
2017-07-11 19:14:18 +02:00
|
|
|
wallet_path = "wallet/%s" % wallet_name
|
|
|
|
return self.rpc / wallet_path
|
2017-06-02 20:30:36 +02:00
|
|
|
|
|
|
|
def stop_node(self):
|
|
|
|
"""Stop the node."""
|
|
|
|
if not self.running:
|
|
|
|
return
|
|
|
|
self.log.debug("Stopping node")
|
|
|
|
try:
|
|
|
|
self.stop()
|
|
|
|
except http.client.CannotSendRequest:
|
|
|
|
self.log.exception("Unable to stop node.")
|
2017-03-27 15:42:17 +02:00
|
|
|
del self.p2ps[:]
|
2017-06-02 20:30:36 +02:00
|
|
|
|
|
|
|
def is_node_stopped(self):
|
|
|
|
"""Checks whether the node has stopped.
|
|
|
|
|
|
|
|
Returns True if the node has stopped. False otherwise.
|
|
|
|
This method is responsible for freeing resources (self.process)."""
|
|
|
|
if not self.running:
|
|
|
|
return True
|
|
|
|
return_code = self.process.poll()
|
2017-08-16 17:52:24 +02:00
|
|
|
if return_code is None:
|
|
|
|
return False
|
|
|
|
|
|
|
|
# process has stopped. Assert that it didn't return an error code.
|
2018-04-18 21:17:22 +02:00
|
|
|
assert return_code == 0, self._node_msg(
|
|
|
|
"Node returned non-zero exit code (%d) when stopping" % return_code)
|
2017-08-16 17:52:24 +02:00
|
|
|
self.running = False
|
|
|
|
self.process = None
|
|
|
|
self.rpc_connected = False
|
|
|
|
self.rpc = None
|
|
|
|
self.log.debug("Node stopped")
|
|
|
|
return True
|
|
|
|
|
|
|
|
def wait_until_stopped(self, timeout=BITCOIND_PROC_WAIT_TIMEOUT):
|
|
|
|
wait_until(self.is_node_stopped, timeout=timeout)
|
2017-06-02 20:30:36 +02:00
|
|
|
|
2018-03-28 15:37:09 +02:00
|
|
|
def assert_start_raises_init_error(self, extra_args=None, expected_msg=None, match=ErrorMatch.FULL_TEXT, *args, **kwargs):
|
2018-02-07 15:36:13 +01:00
|
|
|
"""Attempt to start the node and expect it to raise an error.
|
|
|
|
|
2018-02-07 16:38:25 +01:00
|
|
|
extra_args: extra arguments to pass through to bitcoind
|
|
|
|
expected_msg: regex that stderr should match when bitcoind fails
|
|
|
|
|
2018-02-07 15:36:13 +01:00
|
|
|
Will throw if bitcoind starts without an error.
|
2018-02-07 16:38:25 +01:00
|
|
|
Will throw if an expected_msg is provided and it does not match bitcoind's stdout."""
|
2018-02-07 15:36:13 +01:00
|
|
|
with tempfile.SpooledTemporaryFile(max_size=2**16) as log_stderr:
|
|
|
|
try:
|
|
|
|
self.start(extra_args, stderr=log_stderr, *args, **kwargs)
|
|
|
|
self.wait_for_rpc_connection()
|
|
|
|
self.stop_node()
|
2018-03-28 08:44:30 +02:00
|
|
|
self.wait_until_stopped()
|
|
|
|
except FailedToStartError as e:
|
|
|
|
self.log.debug('bitcoind failed to start: %s', e)
|
2018-02-07 15:36:13 +01:00
|
|
|
self.running = False
|
|
|
|
self.process = None
|
2018-02-07 16:38:25 +01:00
|
|
|
# Check stderr for expected message
|
2018-02-07 15:36:13 +01:00
|
|
|
if expected_msg is not None:
|
|
|
|
log_stderr.seek(0)
|
2018-03-19 20:35:04 +01:00
|
|
|
stderr = log_stderr.read().decode('utf-8').strip()
|
2018-03-28 15:37:09 +02:00
|
|
|
if match == ErrorMatch.PARTIAL_REGEX:
|
2018-03-19 20:35:04 +01:00
|
|
|
if re.search(expected_msg, stderr, flags=re.MULTILINE) is None:
|
2018-04-18 21:17:22 +02:00
|
|
|
self._raise_assertion_error(
|
|
|
|
'Expected message "{}" does not partially match stderr:\n"{}"'.format(expected_msg, stderr))
|
2018-03-28 15:37:09 +02:00
|
|
|
elif match == ErrorMatch.FULL_REGEX:
|
2018-03-19 20:35:04 +01:00
|
|
|
if re.fullmatch(expected_msg, stderr) is None:
|
2018-04-18 21:17:22 +02:00
|
|
|
self._raise_assertion_error(
|
|
|
|
'Expected message "{}" does not fully match stderr:\n"{}"'.format(expected_msg, stderr))
|
2018-03-28 15:37:09 +02:00
|
|
|
elif match == ErrorMatch.FULL_TEXT:
|
|
|
|
if expected_msg != stderr:
|
2018-04-18 21:17:22 +02:00
|
|
|
self._raise_assertion_error(
|
|
|
|
'Expected message "{}" does not fully match stderr:\n"{}"'.format(expected_msg, stderr))
|
2018-02-07 15:36:13 +01:00
|
|
|
else:
|
|
|
|
if expected_msg is None:
|
|
|
|
assert_msg = "bitcoind should have exited with an error"
|
|
|
|
else:
|
|
|
|
assert_msg = "bitcoind should have exited with expected error " + expected_msg
|
2018-04-18 21:17:22 +02:00
|
|
|
self._raise_assertion_error(assert_msg)
|
2018-02-07 15:36:13 +01:00
|
|
|
|
2017-06-02 20:30:36 +02:00
|
|
|
def node_encrypt_wallet(self, passphrase):
|
|
|
|
""""Encrypts the wallet.
|
|
|
|
|
|
|
|
This causes bitcoind to shutdown, so this method takes
|
|
|
|
care of cleaning up resources."""
|
|
|
|
self.encryptwallet(passphrase)
|
2017-08-16 17:52:24 +02:00
|
|
|
self.wait_until_stopped()
|
2017-07-11 19:01:44 +02:00
|
|
|
|
2017-11-17 21:01:24 +01:00
|
|
|
def add_p2p_connection(self, p2p_conn, *args, **kwargs):
|
2017-03-27 15:42:17 +02:00
|
|
|
"""Add a p2p connection to the node.
|
|
|
|
|
|
|
|
This method adds the p2p connection to the self.p2ps list and also
|
|
|
|
returns the connection to the caller."""
|
|
|
|
if 'dstport' not in kwargs:
|
|
|
|
kwargs['dstport'] = p2p_port(self.index)
|
|
|
|
if 'dstaddr' not in kwargs:
|
|
|
|
kwargs['dstaddr'] = '127.0.0.1'
|
2017-11-17 21:01:24 +01:00
|
|
|
|
|
|
|
p2p_conn.peer_connect(*args, **kwargs)
|
2017-03-27 15:42:17 +02:00
|
|
|
self.p2ps.append(p2p_conn)
|
|
|
|
|
|
|
|
return p2p_conn
|
|
|
|
|
|
|
|
@property
|
|
|
|
def p2p(self):
|
|
|
|
"""Return the first p2p connection
|
|
|
|
|
|
|
|
Convenience property - most tests only use a single p2p connection to each
|
|
|
|
node, so this saves having to write node.p2ps[0] many times."""
|
2018-04-18 21:17:22 +02:00
|
|
|
assert self.p2ps, self._node_msg("No p2p connection")
|
2017-03-27 15:42:17 +02:00
|
|
|
return self.p2ps[0]
|
|
|
|
|
2017-11-08 22:28:17 +01:00
|
|
|
def disconnect_p2ps(self):
|
|
|
|
"""Close all p2p connections to the node."""
|
|
|
|
for p in self.p2ps:
|
2017-11-17 21:01:24 +01:00
|
|
|
p.peer_disconnect()
|
|
|
|
del self.p2ps[:]
|
2017-11-08 22:28:17 +01:00
|
|
|
|
2017-12-21 10:54:43 +01:00
|
|
|
class TestNodeCLIAttr:
|
|
|
|
def __init__(self, cli, command):
|
|
|
|
self.cli = cli
|
|
|
|
self.command = command
|
|
|
|
|
|
|
|
def __call__(self, *args, **kwargs):
|
|
|
|
return self.cli.send_cli(self.command, *args, **kwargs)
|
|
|
|
|
|
|
|
def get_request(self, *args, **kwargs):
|
|
|
|
return lambda: self(*args, **kwargs)
|
2017-03-27 15:42:17 +02:00
|
|
|
|
2017-07-11 19:01:44 +02:00
|
|
|
class TestNodeCLI():
|
|
|
|
"""Interface to bitcoin-cli for an individual node"""
|
|
|
|
|
|
|
|
def __init__(self, binary, datadir):
|
2018-01-23 19:58:53 +01:00
|
|
|
self.options = []
|
2017-07-11 19:01:44 +02:00
|
|
|
self.binary = binary
|
|
|
|
self.datadir = datadir
|
2017-09-06 18:07:21 +02:00
|
|
|
self.input = None
|
2017-07-11 19:14:18 +02:00
|
|
|
self.log = logging.getLogger('TestFramework.bitcoincli')
|
2017-09-06 18:07:21 +02:00
|
|
|
|
2018-01-23 19:58:53 +01:00
|
|
|
def __call__(self, *options, input=None):
|
|
|
|
# TestNodeCLI is callable with bitcoin-cli command-line options
|
2017-12-21 00:41:12 +01:00
|
|
|
cli = TestNodeCLI(self.binary, self.datadir)
|
2018-01-23 19:58:53 +01:00
|
|
|
cli.options = [str(o) for o in options]
|
2017-12-21 00:41:12 +01:00
|
|
|
cli.input = input
|
|
|
|
return cli
|
2017-07-11 19:01:44 +02:00
|
|
|
|
|
|
|
def __getattr__(self, command):
|
2017-12-21 10:54:43 +01:00
|
|
|
return TestNodeCLIAttr(self, command)
|
|
|
|
|
|
|
|
def batch(self, requests):
|
|
|
|
results = []
|
|
|
|
for request in requests:
|
|
|
|
try:
|
|
|
|
results.append(dict(result=request()))
|
|
|
|
except JSONRPCException as e:
|
|
|
|
results.append(dict(error=e))
|
|
|
|
return results
|
2017-07-11 19:01:44 +02:00
|
|
|
|
2018-01-23 20:00:34 +01:00
|
|
|
def send_cli(self, command=None, *args, **kwargs):
|
2017-07-11 19:01:44 +02:00
|
|
|
"""Run bitcoin-cli command. Deserializes returned string as python object."""
|
|
|
|
|
|
|
|
pos_args = [str(arg) for arg in args]
|
|
|
|
named_args = [str(key) + "=" + str(value) for (key, value) in kwargs.items()]
|
|
|
|
assert not (pos_args and named_args), "Cannot use positional arguments and named arguments in the same bitcoin-cli call"
|
2018-01-23 19:58:53 +01:00
|
|
|
p_args = [self.binary, "-datadir=" + self.datadir] + self.options
|
2017-07-11 19:01:44 +02:00
|
|
|
if named_args:
|
|
|
|
p_args += ["-named"]
|
2018-01-23 20:00:34 +01:00
|
|
|
if command is not None:
|
|
|
|
p_args += [command]
|
|
|
|
p_args += pos_args + named_args
|
2017-07-11 19:14:18 +02:00
|
|
|
self.log.debug("Running bitcoin-cli command: %s" % command)
|
2017-09-06 17:36:13 +02:00
|
|
|
process = subprocess.Popen(p_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
|
|
|
|
cli_stdout, cli_stderr = process.communicate(input=self.input)
|
|
|
|
returncode = process.poll()
|
|
|
|
if returncode:
|
2017-12-21 00:38:40 +01:00
|
|
|
match = re.match(r'error code: ([-0-9]+)\nerror message:\n(.*)', cli_stderr)
|
|
|
|
if match:
|
|
|
|
code, message = match.groups()
|
|
|
|
raise JSONRPCException(dict(code=int(code), message=message))
|
2017-09-06 17:36:13 +02:00
|
|
|
# Ignore cli_stdout, raise with cli_stderr
|
|
|
|
raise subprocess.CalledProcessError(returncode, self.binary, output=cli_stderr)
|
2017-12-21 00:38:40 +01:00
|
|
|
try:
|
|
|
|
return json.loads(cli_stdout, parse_float=decimal.Decimal)
|
|
|
|
except JSONDecodeError:
|
|
|
|
return cli_stdout.rstrip("\n")
|