mirror of
https://github.com/XRPLF/rippled.git
synced 2025-12-05 08:48:03 +00:00
Move Python code to its own directory.
This commit is contained in:
187
bin/python/ripple/ledger/Args.py
Normal file
187
bin/python/ripple/ledger/Args.py
Normal file
@@ -0,0 +1,187 @@
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import os
|
||||
|
||||
from ripple.ledger import LedgerNumber
|
||||
from ripple.util import File
|
||||
from ripple.util import Log
|
||||
from ripple.util import PrettyPrint
|
||||
from ripple.util import Range
|
||||
from ripple.util.Function import Function
|
||||
|
||||
NAME = 'LedgerTool'
|
||||
VERSION = '0.1'
|
||||
NONE = '(none)'
|
||||
|
||||
_parser = argparse.ArgumentParser(
|
||||
prog=NAME,
|
||||
description='Retrieve and process Ripple ledgers.',
|
||||
epilog=LedgerNumber.HELP,
|
||||
)
|
||||
|
||||
# Positional arguments.
|
||||
_parser.add_argument(
|
||||
'command',
|
||||
nargs='*',
|
||||
help='Command to execute.'
|
||||
)
|
||||
|
||||
# Flag arguments.
|
||||
_parser.add_argument(
|
||||
'--binary',
|
||||
action='store_true',
|
||||
help='If true, searches are binary - by default linear search is used.',
|
||||
)
|
||||
|
||||
_parser.add_argument(
|
||||
'--cache',
|
||||
default='~/.local/share/ripple/ledger',
|
||||
help='The cache directory.',
|
||||
)
|
||||
|
||||
_parser.add_argument(
|
||||
'--complete',
|
||||
action='store_true',
|
||||
help='If set, only match complete ledgers.',
|
||||
)
|
||||
|
||||
_parser.add_argument(
|
||||
'--condition', '-c',
|
||||
help='The name of a condition function used to match ledgers.',
|
||||
)
|
||||
|
||||
_parser.add_argument(
|
||||
'--config',
|
||||
help='The rippled configuration file name.',
|
||||
)
|
||||
|
||||
_parser.add_argument(
|
||||
'--database', '-d',
|
||||
nargs='*',
|
||||
default=NONE,
|
||||
help='Specify a database.',
|
||||
)
|
||||
|
||||
_parser.add_argument(
|
||||
'--display',
|
||||
help='Specify a function to display ledgers.',
|
||||
)
|
||||
|
||||
_parser.add_argument(
|
||||
'--full', '-f',
|
||||
action='store_true',
|
||||
help='If true, request full ledgers.',
|
||||
)
|
||||
|
||||
_parser.add_argument(
|
||||
'--indent', '-i',
|
||||
type=int,
|
||||
default=2,
|
||||
help='How many spaces to indent when display in JSON.',
|
||||
)
|
||||
|
||||
_parser.add_argument(
|
||||
'--offline', '-o',
|
||||
action='store_true',
|
||||
help='If true, work entirely from cache, do not try to contact the server.',
|
||||
)
|
||||
|
||||
_parser.add_argument(
|
||||
'--position', '-p',
|
||||
choices=['all', 'first', 'last'],
|
||||
default='last',
|
||||
help='Select which ledgers to display.',
|
||||
)
|
||||
|
||||
_parser.add_argument(
|
||||
'--rippled', '-r',
|
||||
help='The filename of a rippled binary for retrieving ledgers.',
|
||||
)
|
||||
|
||||
_parser.add_argument(
|
||||
'--server', '-s',
|
||||
help='IP address of a rippled JSON server.',
|
||||
)
|
||||
|
||||
_parser.add_argument(
|
||||
'--utc', '-u',
|
||||
action='store_true',
|
||||
help='If true, display times in UTC rather than local time.',
|
||||
)
|
||||
|
||||
_parser.add_argument(
|
||||
'--validations',
|
||||
default=3,
|
||||
help='The number of validations needed before considering a ledger valid.',
|
||||
)
|
||||
|
||||
_parser.add_argument(
|
||||
'--version',
|
||||
action='version',
|
||||
version='%(prog)s ' + VERSION,
|
||||
help='Print the current version of %(prog)s',
|
||||
)
|
||||
|
||||
_parser.add_argument(
|
||||
'--verbose', '-v',
|
||||
action='store_true',
|
||||
help='If true, give status messages on stderr.',
|
||||
)
|
||||
|
||||
_parser.add_argument(
|
||||
'--window', '-w',
|
||||
type=int,
|
||||
default=0,
|
||||
help='How many ledgers to display around the matching ledger.',
|
||||
)
|
||||
|
||||
_parser.add_argument(
|
||||
'--yes', '-y',
|
||||
action='store_true',
|
||||
help='If true, don\'t ask for confirmation on large commands.',
|
||||
)
|
||||
|
||||
# Read the arguments from the command line.
|
||||
ARGS = _parser.parse_args()
|
||||
ARGS.NONE = NONE
|
||||
|
||||
Log.VERBOSE = ARGS.verbose
|
||||
|
||||
# Now remove any items that look like ledger numbers from the command line.
|
||||
_command = ARGS.command
|
||||
_parts = (ARGS.command, ARGS.ledgers) = ([], [])
|
||||
|
||||
for c in _command:
|
||||
_parts[Range.is_range(c, *LedgerNumber.LEDGERS)].append(c)
|
||||
|
||||
ARGS.command = ARGS.command or ['print' if ARGS.ledgers else 'info']
|
||||
|
||||
ARGS.cache = File.normalize(ARGS.cache)
|
||||
|
||||
if not ARGS.ledgers:
|
||||
if ARGS.condition:
|
||||
Log.warn('--condition needs a range of ledgers')
|
||||
if ARGS.display:
|
||||
Log.warn('--display needs a range of ledgers')
|
||||
|
||||
ARGS.condition = Function(
|
||||
ARGS.condition or 'all_ledgers', 'ripple.ledger.conditions')
|
||||
ARGS.display = Function(
|
||||
ARGS.display or 'ledger_number', 'ripple.ledger.displays')
|
||||
|
||||
if ARGS.window < 0:
|
||||
raise ValueError('Window cannot be negative: --window=%d' %
|
||||
ARGS.window)
|
||||
|
||||
PrettyPrint.INDENT = (ARGS.indent * ' ')
|
||||
|
||||
_loaders = (ARGS.database != NONE) + bool(ARGS.rippled) + bool(ARGS.server)
|
||||
|
||||
if not _loaders:
|
||||
ARGS.rippled = 'rippled'
|
||||
|
||||
elif _loaders > 1:
|
||||
raise ValueError('At most one of --database, --rippled and --server '
|
||||
'may be specified')
|
||||
78
bin/python/ripple/ledger/DatabaseReader.py
Normal file
78
bin/python/ripple/ledger/DatabaseReader.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from ripple.ledger.Args import ARGS
|
||||
from ripple.util import ConfigFile
|
||||
from ripple.util import Database
|
||||
from ripple.util import File
|
||||
from ripple.util import Log
|
||||
from ripple.util import Range
|
||||
|
||||
LEDGER_QUERY = """
|
||||
SELECT
|
||||
L.*, count(1) validations
|
||||
FROM
|
||||
(select LedgerHash, LedgerSeq from Ledgers ORDER BY LedgerSeq DESC) L
|
||||
JOIN Validations V
|
||||
ON (V.LedgerHash = L.LedgerHash)
|
||||
GROUP BY L.LedgerHash
|
||||
HAVING validations >= {validation_quorum}
|
||||
ORDER BY 2;
|
||||
"""
|
||||
|
||||
COMPLETE_QUERY = """
|
||||
SELECT
|
||||
L.LedgerSeq, count(*) validations
|
||||
FROM
|
||||
(select LedgerHash, LedgerSeq from Ledgers ORDER BY LedgerSeq) L
|
||||
JOIN Validations V
|
||||
ON (V.LedgerHash = L.LedgerHash)
|
||||
GROUP BY L.LedgerHash
|
||||
HAVING validations >= :validation_quorum
|
||||
ORDER BY 2;
|
||||
"""
|
||||
|
||||
_DATABASE_NAME = 'ledger.db'
|
||||
|
||||
USE_PLACEHOLDERS = False
|
||||
|
||||
class DatabaseReader(object):
|
||||
def __init__(self, config):
|
||||
assert ARGS.database != ARGS.NONE
|
||||
database = ARGS.database or config['database_path']
|
||||
if not database.endswith(_DATABASE_NAME):
|
||||
database = os.path.join(database, _DATABASE_NAME)
|
||||
if USE_PLACEHOLDERS:
|
||||
cursor = Database.fetchall(
|
||||
database, COMPLETE_QUERY, config)
|
||||
else:
|
||||
cursor = Database.fetchall(
|
||||
database, LEDGER_QUERY.format(**config), {})
|
||||
self.complete = [c[1] for c in cursor]
|
||||
|
||||
def name_to_ledger_index(self, ledger_name, is_full=False):
|
||||
if not self.complete:
|
||||
return None
|
||||
if ledger_name == 'closed':
|
||||
return self.complete[-1]
|
||||
if ledger_name == 'current':
|
||||
return None
|
||||
if ledger_name == 'validated':
|
||||
return self.complete[-1]
|
||||
|
||||
def get_ledger(self, name, is_full=False):
|
||||
cmd = ['ledger', str(name)]
|
||||
if is_full:
|
||||
cmd.append('full')
|
||||
response = self._command(*cmd)
|
||||
result = response.get('ledger')
|
||||
if result:
|
||||
return result
|
||||
error = response['error']
|
||||
etext = _ERROR_TEXT.get(error)
|
||||
if etext:
|
||||
error = '%s (%s)' % (etext, error)
|
||||
Log.fatal(_ERROR_TEXT.get(error, error))
|
||||
18
bin/python/ripple/ledger/LedgerNumber.py
Normal file
18
bin/python/ripple/ledger/LedgerNumber.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
from ripple.util import Range
|
||||
|
||||
FIRST_EVER = 32570
|
||||
|
||||
LEDGERS = {
|
||||
'closed': 'the most recently closed ledger',
|
||||
'current': 'the current ledger',
|
||||
'first': 'the first complete ledger on this server',
|
||||
'last': 'the last complete ledger on this server',
|
||||
'validated': 'the most recently validated ledger',
|
||||
}
|
||||
|
||||
HELP = """
|
||||
Ledgers are either represented by a number, or one of the special ledgers;
|
||||
""" + ',\n'.join('%s, %s' % (k, v) for k, v in sorted(LEDGERS.items())
|
||||
)
|
||||
68
bin/python/ripple/ledger/RippledReader.py
Normal file
68
bin/python/ripple/ledger/RippledReader.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from ripple.ledger.Args import ARGS
|
||||
from ripple.util import File
|
||||
from ripple.util import Log
|
||||
from ripple.util import Range
|
||||
|
||||
_ERROR_CODE_REASON = {
|
||||
62: 'No rippled server is running.',
|
||||
}
|
||||
|
||||
_ERROR_TEXT = {
|
||||
'lgrNotFound': 'The ledger you requested was not found.',
|
||||
'noCurrent': 'The server has no current ledger.',
|
||||
'noNetwork': 'The server did not respond to your request.',
|
||||
}
|
||||
|
||||
_DEFAULT_ERROR_ = "Couldn't connect to server."
|
||||
|
||||
class RippledReader(object):
|
||||
def __init__(self, config):
|
||||
fname = File.normalize(ARGS.rippled)
|
||||
if not os.path.exists(fname):
|
||||
raise Exception('No rippled found at %s.' % fname)
|
||||
self.cmd = [fname]
|
||||
if ARGS.config:
|
||||
self.cmd.extend(['--conf', File.normalize(ARGS.config)])
|
||||
self.info = self._command('server_info')['info']
|
||||
c = self.info.get('complete_ledgers')
|
||||
if c == 'empty':
|
||||
self.complete = []
|
||||
else:
|
||||
self.complete = sorted(Range.from_string(c))
|
||||
|
||||
def name_to_ledger_index(self, ledger_name, is_full=False):
|
||||
return self.get_ledger(ledger_name, is_full)['ledger_index']
|
||||
|
||||
def get_ledger(self, name, is_full=False):
|
||||
cmd = ['ledger', str(name)]
|
||||
if is_full:
|
||||
cmd.append('full')
|
||||
response = self._command(*cmd)
|
||||
result = response.get('ledger')
|
||||
if result:
|
||||
return result
|
||||
error = response['error']
|
||||
etext = _ERROR_TEXT.get(error)
|
||||
if etext:
|
||||
error = '%s (%s)' % (etext, error)
|
||||
Log.fatal(_ERROR_TEXT.get(error, error))
|
||||
|
||||
def _command(self, *cmds):
|
||||
cmd = self.cmd + list(cmds)
|
||||
try:
|
||||
data = subprocess.check_output(cmd, stderr=subprocess.PIPE)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise Exception(_ERROR_CODE_REASON.get(
|
||||
e.returncode, _DEFAULT_ERROR_))
|
||||
|
||||
part = json.loads(data)
|
||||
try:
|
||||
return part['result']
|
||||
except:
|
||||
raise ValueError(part.get('error', 'unknown error'))
|
||||
24
bin/python/ripple/ledger/SearchLedgers.py
Normal file
24
bin/python/ripple/ledger/SearchLedgers.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
import sys
|
||||
|
||||
from ripple.ledger.Args import ARGS
|
||||
from ripple.util import Log
|
||||
from ripple.util import Range
|
||||
from ripple.util import Search
|
||||
|
||||
def search(server):
|
||||
"""Yields a stream of ledger numbers that match the given condition."""
|
||||
condition = lambda number: ARGS.condition(server, number)
|
||||
ledgers = server.ledgers
|
||||
if ARGS.binary:
|
||||
try:
|
||||
position = Search.FIRST if ARGS.position == 'first' else Search.LAST
|
||||
yield Search.binary_search(
|
||||
ledgers[0], ledgers[-1], condition, position)
|
||||
except:
|
||||
Log.fatal('No ledgers matching condition "%s".' % condition,
|
||||
file=sys.stderr)
|
||||
else:
|
||||
for x in Search.linear_search(ledgers, condition):
|
||||
yield x
|
||||
55
bin/python/ripple/ledger/Server.py
Normal file
55
bin/python/ripple/ledger/Server.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from ripple.ledger import DatabaseReader, RippledReader
|
||||
from ripple.ledger.Args import ARGS
|
||||
from ripple.util.FileCache import FileCache
|
||||
from ripple.util import ConfigFile
|
||||
from ripple.util import File
|
||||
from ripple.util import Range
|
||||
|
||||
class Server(object):
|
||||
def __init__(self):
|
||||
cfg_file = File.normalize(ARGS.config or 'rippled.cfg')
|
||||
self.config = ConfigFile.read(open(cfg_file))
|
||||
if ARGS.database != ARGS.NONE:
|
||||
reader = DatabaseReader.DatabaseReader(self.config)
|
||||
else:
|
||||
reader = RippledReader.RippledReader(self.config)
|
||||
|
||||
self.reader = reader
|
||||
self.complete = reader.complete
|
||||
|
||||
names = {
|
||||
'closed': reader.name_to_ledger_index('closed'),
|
||||
'current': reader.name_to_ledger_index('current'),
|
||||
'validated': reader.name_to_ledger_index('validated'),
|
||||
'first': self.complete[0] if self.complete else None,
|
||||
'last': self.complete[-1] if self.complete else None,
|
||||
}
|
||||
self.__dict__.update(names)
|
||||
self.ledgers = sorted(Range.join_ranges(*ARGS.ledgers, **names))
|
||||
|
||||
def make_cache(is_full):
|
||||
name = 'full' if is_full else 'summary'
|
||||
filepath = os.path.join(ARGS.cache, name)
|
||||
creator = lambda n: reader.get_ledger(n, is_full)
|
||||
return FileCache(filepath, creator)
|
||||
self._caches = [make_cache(False), make_cache(True)]
|
||||
|
||||
def info(self):
|
||||
return self.reader.info
|
||||
|
||||
def cache(self, is_full):
|
||||
return self._caches[is_full]
|
||||
|
||||
def get_ledger(self, number, is_full=False):
|
||||
num = int(number)
|
||||
save_in_cache = num in self.complete
|
||||
can_create = (not ARGS.offline and
|
||||
self.complete and
|
||||
self.complete[0] <= num - 1)
|
||||
cache = self.cache(is_full)
|
||||
return cache.get_data(number, save_in_cache, can_create)
|
||||
5
bin/python/ripple/ledger/ServerReader.py
Normal file
5
bin/python/ripple/ledger/ServerReader.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
class ServerReader(object):
|
||||
def __init__(self, config):
|
||||
raise ValueError('Direct server connections are not yet implemented.')
|
||||
0
bin/python/ripple/ledger/__init__.py
Normal file
0
bin/python/ripple/ledger/__init__.py
Normal file
34
bin/python/ripple/ledger/commands/Cache.py
Normal file
34
bin/python/ripple/ledger/commands/Cache.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
from ripple.ledger.Args import ARGS
|
||||
from ripple.util import Log
|
||||
from ripple.util import Range
|
||||
from ripple.util.PrettyPrint import pretty_print
|
||||
|
||||
SAFE = True
|
||||
|
||||
HELP = """cache
|
||||
return server_info"""
|
||||
|
||||
def cache(server, clear=False):
|
||||
cache = server.cache(ARGS.full)
|
||||
name = ['summary', 'full'][ARGS.full]
|
||||
files = cache.file_count()
|
||||
if not files:
|
||||
Log.error('No files in %s cache.' % name)
|
||||
|
||||
elif clear:
|
||||
if not clear.strip() == 'clear':
|
||||
raise Exception("Don't understand 'clear %s'." % clear)
|
||||
if not ARGS.yes:
|
||||
yes = raw_input('OK to clear %s cache? (y/N) ' % name)
|
||||
if not yes.lower().startswith('y'):
|
||||
Log.out('Cancelled.')
|
||||
return
|
||||
cache.clear(ARGS.full)
|
||||
Log.out('%s cache cleared - %d file%s deleted.' %
|
||||
(name.capitalize(), files, '' if files == 1 else 's'))
|
||||
|
||||
else:
|
||||
caches = (int(c) for c in cache.cache_list())
|
||||
Log.out(Range.to_string(caches))
|
||||
21
bin/python/ripple/ledger/commands/Info.py
Normal file
21
bin/python/ripple/ledger/commands/Info.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
from ripple.ledger.Args import ARGS
|
||||
from ripple.util import Log
|
||||
from ripple.util import Range
|
||||
from ripple.util.PrettyPrint import pretty_print
|
||||
|
||||
SAFE = True
|
||||
|
||||
HELP = 'info - return server_info'
|
||||
|
||||
def info(server):
|
||||
Log.out('first =', server.first)
|
||||
Log.out('last =', server.last)
|
||||
Log.out('closed =', server.closed)
|
||||
Log.out('current =', server.current)
|
||||
Log.out('validated =', server.validated)
|
||||
Log.out('complete =', Range.to_string(server.complete))
|
||||
|
||||
if ARGS.full:
|
||||
Log.out(pretty_print(server.info()))
|
||||
15
bin/python/ripple/ledger/commands/Print.py
Normal file
15
bin/python/ripple/ledger/commands/Print.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
from ripple.ledger.Args import ARGS
|
||||
from ripple.ledger import SearchLedgers
|
||||
|
||||
import json
|
||||
|
||||
SAFE = True
|
||||
|
||||
HELP = """print
|
||||
|
||||
Print the ledgers to stdout. The default command."""
|
||||
|
||||
def run_print(server):
|
||||
ARGS.display(print, server, SearchLedgers.search(server))
|
||||
0
bin/python/ripple/ledger/commands/__init__.py
Normal file
0
bin/python/ripple/ledger/commands/__init__.py
Normal file
4
bin/python/ripple/ledger/conditions/__init__.py
Normal file
4
bin/python/ripple/ledger/conditions/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
def all_ledgers(server, ledger_number):
|
||||
return True
|
||||
89
bin/python/ripple/ledger/displays/__init__.py
Normal file
89
bin/python/ripple/ledger/displays/__init__.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
from functools import wraps
|
||||
|
||||
import jsonpath_rw
|
||||
|
||||
from ripple.ledger.Args import ARGS
|
||||
from ripple.util import Dict
|
||||
from ripple.util import Log
|
||||
from ripple.util import Range
|
||||
from ripple.util.Decimal import Decimal
|
||||
from ripple.util.PrettyPrint import pretty_print, Streamer
|
||||
|
||||
TRANSACT_FIELDS = (
|
||||
'accepted',
|
||||
'close_time_human',
|
||||
'closed',
|
||||
'ledger_index',
|
||||
'total_coins',
|
||||
'transactions',
|
||||
)
|
||||
|
||||
LEDGER_FIELDS = (
|
||||
'accepted',
|
||||
'accountState',
|
||||
'close_time_human',
|
||||
'closed',
|
||||
'ledger_index',
|
||||
'total_coins',
|
||||
'transactions',
|
||||
)
|
||||
|
||||
def _dict_filter(d, keys):
|
||||
return dict((k, v) for (k, v) in d.items() if k in keys)
|
||||
|
||||
def ledger_number(print, server, numbers):
|
||||
print(Range.to_string(numbers))
|
||||
|
||||
def display(f):
|
||||
@wraps(f)
|
||||
def wrapper(printer, server, numbers, *args):
|
||||
streamer = Streamer(printer=printer)
|
||||
for number in numbers:
|
||||
ledger = server.get_ledger(number, ARGS.full)
|
||||
if ledger:
|
||||
streamer.add(number, f(ledger, *args))
|
||||
streamer.finish()
|
||||
return wrapper
|
||||
|
||||
def extractor(f):
|
||||
@wraps(f)
|
||||
def wrapper(printer, server, numbers, *paths):
|
||||
try:
|
||||
find = jsonpath_rw.parse('|'.join(paths)).find
|
||||
except:
|
||||
raise ValueError("Can't understand jsonpath '%s'." % path)
|
||||
def fn(ledger, *args):
|
||||
return f(find(ledger), *args)
|
||||
display(fn)(printer, server, numbers)
|
||||
return wrapper
|
||||
|
||||
@display
|
||||
def ledger(ledger, full=False):
|
||||
if ARGS.full:
|
||||
if full:
|
||||
return ledger
|
||||
|
||||
ledger = Dict.prune(ledger, 1, False)
|
||||
|
||||
return _dict_filter(ledger, LEDGER_FIELDS)
|
||||
|
||||
@display
|
||||
def prune(ledger, level=1):
|
||||
return Dict.prune(ledger, level, False)
|
||||
|
||||
@display
|
||||
def transact(ledger):
|
||||
return _dict_filter(ledger, TRANSACT_FIELDS)
|
||||
|
||||
@extractor
|
||||
def extract(finds):
|
||||
return dict((str(f.full_path), str(f.value)) for f in finds)
|
||||
|
||||
@extractor
|
||||
def sum(finds):
|
||||
d = Decimal()
|
||||
for f in finds:
|
||||
d.accumulate(f.value)
|
||||
return [str(d), len(finds)]
|
||||
Reference in New Issue
Block a user