mirror of https://github.com/apache/cassandra
Merge branch 'cassandra-2.2' into cassandra-3.0
This commit is contained in:
commit
74070ee4ab
|
|
@ -10,6 +10,7 @@ Merged from 2.2:
|
|||
* (Hadoop) fix splits calculation (CASSANDRA-10640)
|
||||
* (Hadoop) ensure that Cluster instances are always closed (CASSANDRA-10058)
|
||||
Merged from 2.1:
|
||||
* (cqlsh) Improve COPY TO performance and error handling (CASSANDRA-9304)
|
||||
* Create compression chunk for sending file only (CASSANDRA-10680)
|
||||
* Forbid compact clustering column type changes in ALTER TABLE (CASSANDRA-8879)
|
||||
* Reject incremental repair with subrange repair (CASSANDRA-10422)
|
||||
|
|
|
|||
153
bin/cqlsh.py
153
bin/cqlsh.py
|
|
@ -37,7 +37,7 @@ import ConfigParser
|
|||
import csv
|
||||
import getpass
|
||||
import locale
|
||||
import multiprocessing
|
||||
import multiprocessing as mp
|
||||
import optparse
|
||||
import os
|
||||
import platform
|
||||
|
|
@ -125,7 +125,7 @@ cqlshlibdir = os.path.join(CASSANDRA_PATH, 'pylib')
|
|||
if os.path.isdir(cqlshlibdir):
|
||||
sys.path.insert(0, cqlshlibdir)
|
||||
|
||||
from cqlshlib import cql3handling, cqlhandling, pylexotron, sslhandling
|
||||
from cqlshlib import cql3handling, cqlhandling, pylexotron, sslhandling, copy
|
||||
from cqlshlib.displaying import (ANSI_RESET, BLUE, COLUMN_NAME_COLORS, CYAN,
|
||||
RED, FormattedValue, colorme)
|
||||
from cqlshlib.formatting import (DEFAULT_DATE_FORMAT, DEFAULT_NANOTIME_FORMAT,
|
||||
|
|
@ -426,7 +426,8 @@ def complete_copy_column_names(ctxt, cqlsh):
|
|||
return set(colnames[1:]) - set(existcols)
|
||||
|
||||
|
||||
COPY_OPTIONS = ('DELIMITER', 'QUOTE', 'ESCAPE', 'HEADER', 'ENCODING', 'TIMEFORMAT', 'NULL')
|
||||
COPY_OPTIONS = ['DELIMITER', 'QUOTE', 'ESCAPE', 'HEADER', 'NULL', 'ENCODING',
|
||||
'TIMEFORMAT', 'JOBS', 'PAGESIZE', 'PAGETIMEOUT', 'MAXATTEMPTS']
|
||||
|
||||
|
||||
@cqlsh_syntax_completer('copyOption', 'optnames')
|
||||
|
|
@ -435,8 +436,7 @@ def complete_copy_options(ctxt, cqlsh):
|
|||
direction = ctxt.get_binding('dir').upper()
|
||||
opts = set(COPY_OPTIONS) - set(optnames)
|
||||
if direction == 'FROM':
|
||||
opts -= ('ENCODING',)
|
||||
opts -= ('TIMEFORMAT',)
|
||||
opts -= set(['ENCODING', 'TIMEFORMAT', 'JOBS', 'PAGESIZE', 'PAGETIMEOUT', 'MAXATTEMPTS'])
|
||||
return opts
|
||||
|
||||
|
||||
|
|
@ -563,6 +563,19 @@ def describe_interval(seconds):
|
|||
return words
|
||||
|
||||
|
||||
def insert_driver_hooks():
|
||||
extend_cql_deserialization()
|
||||
auto_format_udts()
|
||||
|
||||
|
||||
def extend_cql_deserialization():
|
||||
"""
|
||||
The python driver returns BLOBs as string, but we expect them as bytearrays
|
||||
"""
|
||||
cassandra.cqltypes.BytesType.deserialize = staticmethod(lambda byts, protocol_version: bytearray(byts))
|
||||
cassandra.cqltypes.CassandraType.support_empty_values = True
|
||||
|
||||
|
||||
def auto_format_udts():
|
||||
# when we see a new user defined type, set up the shell formatting for it
|
||||
udt_apply_params = cassandra.cqltypes.UserType.apply_parameters
|
||||
|
|
@ -714,11 +727,6 @@ class Shell(cmd.Cmd):
|
|||
self.query_out = sys.stdout
|
||||
self.consistency_level = cassandra.ConsistencyLevel.ONE
|
||||
self.serial_consistency_level = cassandra.ConsistencyLevel.SERIAL
|
||||
# the python driver returns BLOBs as string, but we expect them as bytearrays
|
||||
cassandra.cqltypes.BytesType.deserialize = staticmethod(lambda byts, protocol_version: bytearray(byts))
|
||||
cassandra.cqltypes.CassandraType.support_empty_values = True
|
||||
|
||||
auto_format_udts()
|
||||
|
||||
self.empty_lines = 0
|
||||
self.statement_error = False
|
||||
|
|
@ -868,11 +876,9 @@ class Shell(cmd.Cmd):
|
|||
def get_keyspaces(self):
|
||||
return self.conn.metadata.keyspaces.values()
|
||||
|
||||
def get_ring(self):
|
||||
if self.current_keyspace is None or self.current_keyspace == 'system':
|
||||
raise NoKeyspaceError("Ring view requires a current non-system keyspace")
|
||||
self.conn.metadata.token_map.rebuild_keyspace(self.current_keyspace, build_if_absent=True)
|
||||
return self.conn.metadata.token_map.tokens_to_hosts_by_ks[self.current_keyspace]
|
||||
def get_ring(self, ks):
|
||||
self.conn.metadata.token_map.rebuild_keyspace(ks, build_if_absent=True)
|
||||
return self.conn.metadata.token_map.tokens_to_hosts_by_ks[ks]
|
||||
|
||||
def get_table_meta(self, ksname, tablename):
|
||||
if ksname is None:
|
||||
|
|
@ -1571,7 +1577,7 @@ class Shell(cmd.Cmd):
|
|||
# print 'Snitch: %s\n' % snitch
|
||||
if self.current_keyspace is not None and self.current_keyspace != 'system':
|
||||
print "Range ownership:"
|
||||
ring = self.get_ring()
|
||||
ring = self.get_ring(self.current_keyspace)
|
||||
for entry in ring.items():
|
||||
print ' %39s [%s]' % (str(entry[0].value), ', '.join([host.address for host in entry[1]]))
|
||||
print
|
||||
|
|
@ -1790,22 +1796,12 @@ class Shell(cmd.Cmd):
|
|||
print "\n%d rows %s in %s." % (rows, verb, describe_interval(timeend - timestart))
|
||||
|
||||
def perform_csv_import(self, ks, cf, columns, fname, opts):
|
||||
dialect_options = self.csv_dialect_defaults.copy()
|
||||
if 'quote' in opts:
|
||||
dialect_options['quotechar'] = opts.pop('quote')
|
||||
if 'escape' in opts:
|
||||
dialect_options['escapechar'] = opts.pop('escape')
|
||||
if 'delimiter' in opts:
|
||||
dialect_options['delimiter'] = opts.pop('delimiter')
|
||||
nullval = opts.pop('null', '')
|
||||
header = bool(opts.pop('header', '').lower() == 'true')
|
||||
if dialect_options['quotechar'] == dialect_options['escapechar']:
|
||||
dialect_options['doublequote'] = True
|
||||
del dialect_options['escapechar']
|
||||
if opts:
|
||||
csv_options, dialect_options, unrecognized_options = copy.parse_options(self, opts)
|
||||
if unrecognized_options:
|
||||
self.printerr('Unrecognized COPY FROM options: %s'
|
||||
% ', '.join(opts.keys()))
|
||||
% ', '.join(unrecognized_options.keys()))
|
||||
return 0
|
||||
nullval, header = csv_options['nullval'], csv_options['header']
|
||||
|
||||
if fname is None:
|
||||
do_close = False
|
||||
|
|
@ -1820,34 +1816,23 @@ class Shell(cmd.Cmd):
|
|||
return 0
|
||||
|
||||
current_record = None
|
||||
|
||||
processes, pipes = [], [],
|
||||
try:
|
||||
if header:
|
||||
linesource.next()
|
||||
reader = csv.reader(linesource, **dialect_options)
|
||||
|
||||
from multiprocessing import Pipe, cpu_count
|
||||
num_processes = copy.get_num_processes(cap=4)
|
||||
|
||||
# Pick a resonable number of child processes. We need to leave at
|
||||
# least one core for the parent process. This doesn't necessarily
|
||||
# need to be capped at 4, but it's currently enough to keep
|
||||
# a single local Cassandra node busy, and I see lower throughput
|
||||
# with more processes.
|
||||
try:
|
||||
num_processes = max(1, min(4, cpu_count() - 1))
|
||||
except NotImplementedError:
|
||||
num_processes = 1
|
||||
|
||||
processes, pipes = [], [],
|
||||
for i in range(num_processes):
|
||||
parent_conn, child_conn = Pipe()
|
||||
parent_conn, child_conn = mp.Pipe()
|
||||
pipes.append(parent_conn)
|
||||
processes.append(ImportProcess(self, child_conn, ks, cf, columns, nullval))
|
||||
|
||||
for process in processes:
|
||||
process.start()
|
||||
|
||||
meter = RateMeter(10000)
|
||||
meter = copy.RateMeter(10000)
|
||||
for current_record, row in enumerate(reader, start=1):
|
||||
# write to the child process
|
||||
pipes[current_record % num_processes].send((current_record, row))
|
||||
|
|
@ -1857,7 +1842,7 @@ class Shell(cmd.Cmd):
|
|||
|
||||
# check for any errors reported by the children
|
||||
if (current_record % 100) == 0:
|
||||
if self._check_child_pipes(current_record, pipes):
|
||||
if self._check_import_processes(current_record, pipes):
|
||||
# no errors seen, continue with outer loop
|
||||
continue
|
||||
else:
|
||||
|
|
@ -1886,7 +1871,7 @@ class Shell(cmd.Cmd):
|
|||
for process in processes:
|
||||
process.join()
|
||||
|
||||
self._check_child_pipes(current_record, pipes)
|
||||
self._check_import_processes(current_record, pipes)
|
||||
|
||||
for pipe in pipes:
|
||||
pipe.close()
|
||||
|
|
@ -1898,8 +1883,7 @@ class Shell(cmd.Cmd):
|
|||
|
||||
return current_record
|
||||
|
||||
def _check_child_pipes(self, current_record, pipes):
|
||||
# check the pipes for errors from child processes
|
||||
def _check_import_processes(self, current_record, pipes):
|
||||
for pipe in pipes:
|
||||
if pipe.poll():
|
||||
try:
|
||||
|
|
@ -1922,63 +1906,13 @@ class Shell(cmd.Cmd):
|
|||
return True
|
||||
|
||||
def perform_csv_export(self, ks, cf, columns, fname, opts):
|
||||
dialect_options = self.csv_dialect_defaults.copy()
|
||||
|
||||
if 'quote' in opts:
|
||||
dialect_options['quotechar'] = opts.pop('quote')
|
||||
if 'escape' in opts:
|
||||
dialect_options['escapechar'] = opts.pop('escape')
|
||||
if 'delimiter' in opts:
|
||||
dialect_options['delimiter'] = opts.pop('delimiter')
|
||||
encoding = opts.pop('encoding', 'utf8')
|
||||
nullval = opts.pop('null', '')
|
||||
header = bool(opts.pop('header', '').lower() == 'true')
|
||||
timestamp_format = opts.pop('timeformat', self.display_timestamp_format)
|
||||
if dialect_options['quotechar'] == dialect_options['escapechar']:
|
||||
dialect_options['doublequote'] = True
|
||||
del dialect_options['escapechar']
|
||||
|
||||
if opts:
|
||||
self.printerr('Unrecognized COPY TO options: %s'
|
||||
% ', '.join(opts.keys()))
|
||||
csv_options, dialect_options, unrecognized_options = copy.parse_options(self, opts)
|
||||
if unrecognized_options:
|
||||
self.printerr('Unrecognized COPY TO options: %s' % ', '.join(unrecognized_options.keys()))
|
||||
return 0
|
||||
|
||||
if fname is None:
|
||||
do_close = False
|
||||
csvdest = sys.stdout
|
||||
else:
|
||||
do_close = True
|
||||
try:
|
||||
csvdest = open(fname, 'wb')
|
||||
except IOError, e:
|
||||
self.printerr("Can't open %r for writing: %s" % (fname, e))
|
||||
return 0
|
||||
|
||||
meter = RateMeter(10000)
|
||||
try:
|
||||
dtformats = DateTimeFormat(timestamp_format, self.display_date_format, self.display_nanotime_format)
|
||||
dump = self.prep_export_dump(ks, cf, columns)
|
||||
writer = csv.writer(csvdest, **dialect_options)
|
||||
if header:
|
||||
writer.writerow(columns)
|
||||
for row in dump:
|
||||
fmt = lambda v: \
|
||||
format_value(v, output_encoding=encoding, nullval=nullval,
|
||||
date_time_format=dtformats,
|
||||
float_precision=self.display_float_precision).strval
|
||||
writer.writerow(map(fmt, row.values()))
|
||||
meter.increment()
|
||||
finally:
|
||||
if do_close:
|
||||
csvdest.close()
|
||||
return meter.current_record
|
||||
|
||||
def prep_export_dump(self, ks, cf, columns):
|
||||
if columns is None:
|
||||
columns = self.get_column_names(ks, cf)
|
||||
columnlist = ', '.join(protect_names(columns))
|
||||
query = 'SELECT %s FROM %s.%s' % (columnlist, protect_name(ks), protect_name(cf))
|
||||
return self.session.execute(query)
|
||||
return copy.ExportTask(self, ks, cf, columns, fname, csv_options, dialect_options,
|
||||
DEFAULT_PROTOCOL_VERSION, CONFIG_FILE).run()
|
||||
|
||||
def do_show(self, parsed):
|
||||
"""
|
||||
|
|
@ -2355,10 +2289,10 @@ class Shell(cmd.Cmd):
|
|||
self.writeresult(text, color, newline=newline, out=sys.stderr)
|
||||
|
||||
|
||||
class ImportProcess(multiprocessing.Process):
|
||||
class ImportProcess(mp.Process):
|
||||
|
||||
def __init__(self, parent, pipe, ks, cf, columns, nullval):
|
||||
multiprocessing.Process.__init__(self)
|
||||
mp.Process.__init__(self)
|
||||
self.pipe = pipe
|
||||
self.nullval = nullval
|
||||
self.ks = ks
|
||||
|
|
@ -2397,8 +2331,8 @@ class ImportProcess(multiprocessing.Process):
|
|||
cqltypes = [table_meta.columns[name].cql_type for name in self.columns]
|
||||
pk_indexes = [self.columns.index(col.name) for col in table_meta.primary_key]
|
||||
query = 'INSERT INTO %s.%s (%s) VALUES (%%s)' % (
|
||||
protect_name(table_meta.keyspace_name),
|
||||
protect_name(table_meta.name),
|
||||
protect_name(self.ks),
|
||||
protect_name(self.cf),
|
||||
', '.join(protect_names(self.columns)))
|
||||
|
||||
# we need to handle some types specially
|
||||
|
|
@ -2799,6 +2733,9 @@ def main(options, hostname, port):
|
|||
if batch_mode and shell.statement_error:
|
||||
sys.exit(2)
|
||||
|
||||
# always call this regardless of module name: when a sub-process is spawned
|
||||
# on Windows then the module name is not __main__, see CASSANDRA-9304
|
||||
insert_driver_hooks()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main(*read_options(sys.argv[1:], os.environ))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,647 @@
|
|||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import csv
|
||||
import json
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import Queue
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from StringIO import StringIO
|
||||
from random import randrange
|
||||
from threading import Lock
|
||||
|
||||
from cassandra.cluster import Cluster
|
||||
from cassandra.metadata import protect_name, protect_names
|
||||
from cassandra.policies import RetryPolicy, WhiteListRoundRobinPolicy, TokenAwarePolicy
|
||||
from cassandra.query import tuple_factory
|
||||
|
||||
|
||||
import sslhandling
|
||||
from displaying import NO_COLOR_MAP
|
||||
from formatting import format_value_default, DateTimeFormat, EMPTY, get_formatter
|
||||
|
||||
|
||||
def parse_options(shell, opts):
|
||||
"""
|
||||
Parse options for import (COPY FROM) and export (COPY TO) operations.
|
||||
Extract from opts csv and dialect options.
|
||||
|
||||
:return: 3 dictionaries: the csv options, the dialect options, any unrecognized options.
|
||||
"""
|
||||
dialect_options = shell.csv_dialect_defaults.copy()
|
||||
if 'quote' in opts:
|
||||
dialect_options['quotechar'] = opts.pop('quote')
|
||||
if 'escape' in opts:
|
||||
dialect_options['escapechar'] = opts.pop('escape')
|
||||
if 'delimiter' in opts:
|
||||
dialect_options['delimiter'] = opts.pop('delimiter')
|
||||
if dialect_options['quotechar'] == dialect_options['escapechar']:
|
||||
dialect_options['doublequote'] = True
|
||||
del dialect_options['escapechar']
|
||||
|
||||
csv_options = dict()
|
||||
csv_options['nullval'] = opts.pop('null', '')
|
||||
csv_options['header'] = bool(opts.pop('header', '').lower() == 'true')
|
||||
csv_options['encoding'] = opts.pop('encoding', 'utf8')
|
||||
csv_options['jobs'] = int(opts.pop('jobs', 12))
|
||||
csv_options['pagesize'] = int(opts.pop('pagesize', 1000))
|
||||
# by default the page timeout is 10 seconds per 1000 entries in the page size or 10 seconds if pagesize is smaller
|
||||
csv_options['pagetimeout'] = int(opts.pop('pagetimeout', max(10, 10 * (csv_options['pagesize'] / 1000))))
|
||||
csv_options['maxattempts'] = int(opts.pop('maxattempts', 5))
|
||||
csv_options['float_precision'] = shell.display_float_precision
|
||||
csv_options['dtformats'] = DateTimeFormat(opts.pop('timeformat', shell.display_timestamp_format),
|
||||
shell.display_date_format,
|
||||
shell.display_nanotime_format)
|
||||
|
||||
return csv_options, dialect_options, opts
|
||||
|
||||
|
||||
def get_num_processes(cap):
|
||||
"""
|
||||
Pick a reasonable number of child processes. We need to leave at
|
||||
least one core for the parent process. This doesn't necessarily
|
||||
need to be capped, but 4 is currently enough to keep
|
||||
a single local Cassandra node busy so we use this for import, whilst
|
||||
for export we use 16 since we can connect to multiple Cassandra nodes.
|
||||
Eventually this parameter will become an option.
|
||||
"""
|
||||
try:
|
||||
return max(1, min(cap, mp.cpu_count() - 1))
|
||||
except NotImplementedError:
|
||||
return 1
|
||||
|
||||
|
||||
class ExportTask(object):
|
||||
"""
|
||||
A class that exports data to .csv by instantiating one or more processes that work in parallel (ExportProcess).
|
||||
"""
|
||||
def __init__(self, shell, ks, cf, columns, fname, csv_options, dialect_options, protocol_version, config_file):
|
||||
self.shell = shell
|
||||
self.csv_options = csv_options
|
||||
self.dialect_options = dialect_options
|
||||
self.ks = ks
|
||||
self.cf = cf
|
||||
self.columns = shell.get_column_names(ks, cf) if columns is None else columns
|
||||
self.fname = fname
|
||||
self.protocol_version = protocol_version
|
||||
self.config_file = config_file
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Initiates the export by creating the processes.
|
||||
"""
|
||||
shell = self.shell
|
||||
fname = self.fname
|
||||
|
||||
if fname is None:
|
||||
do_close = False
|
||||
csvdest = sys.stdout
|
||||
else:
|
||||
do_close = True
|
||||
try:
|
||||
csvdest = open(fname, 'wb')
|
||||
except IOError, e:
|
||||
shell.printerr("Can't open %r for writing: %s" % (fname, e))
|
||||
return 0
|
||||
|
||||
if self.csv_options['header']:
|
||||
writer = csv.writer(csvdest, **self.dialect_options)
|
||||
writer.writerow(self.columns)
|
||||
|
||||
ranges = self.get_ranges()
|
||||
num_processes = get_num_processes(cap=min(16, len(ranges)))
|
||||
|
||||
inmsg = mp.Queue()
|
||||
outmsg = mp.Queue()
|
||||
processes = []
|
||||
for i in xrange(num_processes):
|
||||
process = ExportProcess(outmsg, inmsg, self.ks, self.cf, self.columns, self.dialect_options,
|
||||
self.csv_options, shell.debug, shell.port, shell.conn.cql_version,
|
||||
shell.auth_provider, shell.ssl, self.protocol_version, self.config_file)
|
||||
process.start()
|
||||
processes.append(process)
|
||||
|
||||
try:
|
||||
return self.check_processes(csvdest, ranges, inmsg, outmsg, processes)
|
||||
finally:
|
||||
for process in processes:
|
||||
process.terminate()
|
||||
|
||||
inmsg.close()
|
||||
outmsg.close()
|
||||
if do_close:
|
||||
csvdest.close()
|
||||
|
||||
def get_ranges(self):
|
||||
"""
|
||||
return a queue of tuples, where the first tuple entry is a token range (from, to]
|
||||
and the second entry is a list of hosts that own that range. Each host is responsible
|
||||
for all the tokens in the rage (from, to].
|
||||
|
||||
The ring information comes from the driver metadata token map, which is built by
|
||||
querying System.PEERS.
|
||||
|
||||
We only consider replicas that are in the local datacenter. If there are no local replicas
|
||||
we use the cqlsh session host.
|
||||
"""
|
||||
shell = self.shell
|
||||
hostname = shell.hostname
|
||||
ranges = dict()
|
||||
|
||||
def make_range(hosts):
|
||||
return {'hosts': tuple(hosts), 'attempts': 0, 'rows': 0}
|
||||
|
||||
min_token = self.get_min_token()
|
||||
if shell.conn.metadata.token_map is None or min_token is None:
|
||||
ranges[(None, None)] = make_range([hostname])
|
||||
return ranges
|
||||
|
||||
local_dc = shell.conn.metadata.get_host(hostname).datacenter
|
||||
ring = shell.get_ring(self.ks).items()
|
||||
ring.sort()
|
||||
|
||||
previous_previous = None
|
||||
previous = None
|
||||
for token, replicas in ring:
|
||||
if previous is None and token.value == min_token:
|
||||
continue # avoids looping entire ring
|
||||
|
||||
hosts = []
|
||||
for host in replicas:
|
||||
if host.datacenter == local_dc:
|
||||
hosts.append(host.address)
|
||||
if len(hosts) == 0:
|
||||
hosts.append(hostname) # fallback to default host if no replicas in current dc
|
||||
ranges[(previous, token.value)] = make_range(hosts)
|
||||
previous_previous = previous
|
||||
previous = token.value
|
||||
|
||||
# If the ring is empty we get the entire ring from the
|
||||
# host we are currently connected to, otherwise for the last ring interval
|
||||
# we query the same replicas that hold the last token in the ring
|
||||
if len(ranges) == 0:
|
||||
ranges[(None, None)] = make_range([hostname])
|
||||
else:
|
||||
ranges[(previous, None)] = ranges[(previous_previous, previous)].copy()
|
||||
|
||||
return ranges
|
||||
|
||||
def get_min_token(self):
|
||||
"""
|
||||
:return the minimum token, which depends on the partitioner.
|
||||
For partitioners that do not support tokens we return None, in
|
||||
this cases we will not work in parallel, we'll just send all requests
|
||||
to the cqlsh session host.
|
||||
"""
|
||||
partitioner = self.shell.conn.metadata.partitioner
|
||||
|
||||
if partitioner.endswith('RandomPartitioner'):
|
||||
return -1
|
||||
elif partitioner.endswith('Murmur3Partitioner'):
|
||||
return -(2 ** 63) # Long.MIN_VALUE in Java
|
||||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def send_work(ranges, tokens_to_send, queue):
|
||||
for token_range in tokens_to_send:
|
||||
queue.put((token_range, ranges[token_range]))
|
||||
ranges[token_range]['attempts'] += 1
|
||||
|
||||
def check_processes(self, csvdest, ranges, inmsg, outmsg, processes):
|
||||
"""
|
||||
Here we monitor all child processes by collecting their results
|
||||
or any errors. We terminate when we have processed all the ranges or when there
|
||||
are no more processes.
|
||||
"""
|
||||
shell = self.shell
|
||||
meter = RateMeter(10000)
|
||||
total_jobs = len(ranges)
|
||||
max_attempts = self.csv_options['maxattempts']
|
||||
|
||||
self.send_work(ranges, ranges.keys(), outmsg)
|
||||
|
||||
num_processes = len(processes)
|
||||
succeeded = 0
|
||||
failed = 0
|
||||
while (failed + succeeded) < total_jobs and self.num_live_processes(processes) == num_processes:
|
||||
try:
|
||||
token_range, result = inmsg.get(timeout=1.0)
|
||||
if token_range is None and result is None: # a job has finished
|
||||
succeeded += 1
|
||||
elif isinstance(result, Exception): # an error occurred
|
||||
if token_range is None: # the entire process failed
|
||||
shell.printerr('Error from worker process: %s' % (result))
|
||||
else: # only this token_range failed, retry up to max_attempts if no rows received yet,
|
||||
# if rows are receive we risk duplicating data, there is a back-off policy in place
|
||||
# in the worker process as well, see ExpBackoffRetryPolicy
|
||||
if ranges[token_range]['attempts'] < max_attempts and ranges[token_range]['rows'] == 0:
|
||||
shell.printerr('Error for %s: %s (will try again later attempt %d of %d)'
|
||||
% (token_range, result, ranges[token_range]['attempts'], max_attempts))
|
||||
self.send_work(ranges, [token_range], outmsg)
|
||||
else:
|
||||
shell.printerr('Error for %s: %s (permanently given up after %d rows and %d attempts)'
|
||||
% (token_range, result, ranges[token_range]['rows'],
|
||||
ranges[token_range]['attempts']))
|
||||
failed += 1
|
||||
else: # partial result received
|
||||
data, num = result
|
||||
csvdest.write(data)
|
||||
meter.increment(n=num)
|
||||
ranges[token_range]['rows'] += num
|
||||
except Queue.Empty:
|
||||
pass
|
||||
|
||||
if self.num_live_processes(processes) < len(processes):
|
||||
for process in processes:
|
||||
if not process.is_alive():
|
||||
shell.printerr('Child process %d died with exit code %d' % (process.pid, process.exitcode))
|
||||
|
||||
if succeeded < total_jobs:
|
||||
shell.printerr('Exported %d ranges out of %d total ranges, some records might be missing'
|
||||
% (succeeded, total_jobs))
|
||||
|
||||
return meter.get_total_records()
|
||||
|
||||
@staticmethod
|
||||
def num_live_processes(processes):
|
||||
return sum(1 for p in processes if p.is_alive())
|
||||
|
||||
|
||||
class ExpBackoffRetryPolicy(RetryPolicy):
|
||||
"""
|
||||
A retry policy with exponential back-off for read timeouts,
|
||||
see ExportProcess.
|
||||
"""
|
||||
def __init__(self, export_process):
|
||||
RetryPolicy.__init__(self)
|
||||
self.max_attempts = export_process.csv_options['maxattempts']
|
||||
self.printmsg = lambda txt: export_process.printmsg(txt)
|
||||
|
||||
def on_read_timeout(self, query, consistency, required_responses,
|
||||
received_responses, data_retrieved, retry_num):
|
||||
delay = self.backoff(retry_num)
|
||||
if delay > 0:
|
||||
self.printmsg("Timeout received, retrying after %d seconds" % (delay))
|
||||
time.sleep(delay)
|
||||
return self.RETRY, consistency
|
||||
elif delay == 0:
|
||||
self.printmsg("Timeout received, retrying immediately")
|
||||
return self.RETRY, consistency
|
||||
else:
|
||||
self.printmsg("Timeout received, giving up after %d attempts" % (retry_num + 1))
|
||||
return self.RETHROW, None
|
||||
|
||||
def backoff(self, retry_num):
|
||||
"""
|
||||
Perform exponential back-off up to a maximum number of times, where
|
||||
this maximum is per query.
|
||||
To back-off we should wait a random number of seconds
|
||||
between 0 and 2^c - 1, where c is the number of total failures.
|
||||
randrange() excludes the last value, so we drop the -1.
|
||||
|
||||
:return : the number of seconds to wait for, -1 if we should not retry
|
||||
"""
|
||||
if retry_num >= self.max_attempts:
|
||||
return -1
|
||||
|
||||
delay = randrange(0, pow(2, retry_num + 1))
|
||||
return delay
|
||||
|
||||
|
||||
class ExportSession(object):
|
||||
"""
|
||||
A class for connecting to a cluster and storing the number
|
||||
of jobs that this connection is processing. It wraps the methods
|
||||
for executing a query asynchronously and for shutting down the
|
||||
connection to the cluster.
|
||||
"""
|
||||
def __init__(self, cluster, export_process):
|
||||
session = cluster.connect(export_process.ks)
|
||||
session.row_factory = tuple_factory
|
||||
session.default_fetch_size = export_process.csv_options['pagesize']
|
||||
session.default_timeout = export_process.csv_options['pagetimeout']
|
||||
|
||||
export_process.printmsg("Created connection to %s with page size %d and timeout %d seconds per page"
|
||||
% (session.hosts, session.default_fetch_size, session.default_timeout))
|
||||
|
||||
self.cluster = cluster
|
||||
self.session = session
|
||||
self.jobs = 1
|
||||
self.lock = Lock()
|
||||
|
||||
def add_job(self):
|
||||
with self.lock:
|
||||
self.jobs += 1
|
||||
|
||||
def complete_job(self):
|
||||
with self.lock:
|
||||
self.jobs -= 1
|
||||
|
||||
def num_jobs(self):
|
||||
with self.lock:
|
||||
return self.jobs
|
||||
|
||||
def execute_async(self, query):
|
||||
return self.session.execute_async(query)
|
||||
|
||||
def shutdown(self):
|
||||
self.cluster.shutdown()
|
||||
|
||||
|
||||
class ExportProcess(mp.Process):
|
||||
"""
|
||||
An child worker process for the export task, ExportTask.
|
||||
"""
|
||||
|
||||
def __init__(self, inmsg, outmsg, ks, cf, columns, dialect_options, csv_options,
|
||||
debug, port, cql_version, auth_provider, ssl, protocol_version, config_file):
|
||||
mp.Process.__init__(self, target=self.run)
|
||||
self.inmsg = inmsg
|
||||
self.outmsg = outmsg
|
||||
self.ks = ks
|
||||
self.cf = cf
|
||||
self.columns = columns
|
||||
self.dialect_options = dialect_options
|
||||
self.hosts_to_sessions = dict()
|
||||
|
||||
self.debug = debug
|
||||
self.port = port
|
||||
self.cql_version = cql_version
|
||||
self.auth_provider = auth_provider
|
||||
self.ssl = ssl
|
||||
self.protocol_version = protocol_version
|
||||
self.config_file = config_file
|
||||
|
||||
self.encoding = csv_options['encoding']
|
||||
self.date_time_format = csv_options['dtformats']
|
||||
self.float_precision = csv_options['float_precision']
|
||||
self.nullval = csv_options['nullval']
|
||||
self.maxjobs = csv_options['jobs']
|
||||
self.csv_options = csv_options
|
||||
self.formatters = dict()
|
||||
|
||||
# Here we inject some failures for testing purposes, only if this environment variable is set
|
||||
if os.environ.get('CQLSH_COPY_TEST_FAILURES', ''):
|
||||
self.test_failures = json.loads(os.environ.get('CQLSH_COPY_TEST_FAILURES', ''))
|
||||
else:
|
||||
self.test_failures = None
|
||||
|
||||
def printmsg(self, text):
|
||||
if self.debug:
|
||||
sys.stderr.write(text + os.linesep)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self.inner_run()
|
||||
finally:
|
||||
self.close()
|
||||
|
||||
def inner_run(self):
|
||||
"""
|
||||
The parent sends us (range, info) on the inbound queue (inmsg)
|
||||
in order to request us to process a range, for which we can
|
||||
select any of the hosts in info, which also contains other information for this
|
||||
range such as the number of attempts already performed. We can signal errors
|
||||
on the outbound queue (outmsg) by sending (range, error) or
|
||||
we can signal a global error by sending (None, error).
|
||||
We terminate when the inbound queue is closed.
|
||||
"""
|
||||
while True:
|
||||
if self.num_jobs() > self.maxjobs:
|
||||
time.sleep(0.001) # 1 millisecond
|
||||
continue
|
||||
|
||||
token_range, info = self.inmsg.get()
|
||||
self.start_job(token_range, info)
|
||||
|
||||
def report_error(self, err, token_range=None):
|
||||
if isinstance(err, str):
|
||||
msg = err
|
||||
elif isinstance(err, BaseException):
|
||||
msg = "%s - %s" % (err.__class__.__name__, err)
|
||||
if self.debug:
|
||||
traceback.print_exc(err)
|
||||
else:
|
||||
msg = str(err)
|
||||
|
||||
self.printmsg(msg)
|
||||
self.outmsg.put((token_range, Exception(msg)))
|
||||
|
||||
def start_job(self, token_range, info):
|
||||
"""
|
||||
Begin querying a range by executing an async query that
|
||||
will later on invoke the callbacks attached in attach_callbacks.
|
||||
"""
|
||||
session = self.get_session(info['hosts'])
|
||||
metadata = session.cluster.metadata.keyspaces[self.ks].tables[self.cf]
|
||||
query = self.prepare_query(metadata.partition_key, token_range, info['attempts'])
|
||||
future = session.execute_async(query)
|
||||
self.attach_callbacks(token_range, future, session)
|
||||
|
||||
def num_jobs(self):
|
||||
return sum(session.num_jobs() for session in self.hosts_to_sessions.values())
|
||||
|
||||
def get_session(self, hosts):
|
||||
"""
|
||||
We select a host to connect to. If we have no connections to one of the hosts
|
||||
yet then we select this host, else we pick the one with the smallest number
|
||||
of jobs.
|
||||
|
||||
:return: An ExportSession connected to the chosen host.
|
||||
"""
|
||||
new_hosts = [h for h in hosts if h not in self.hosts_to_sessions]
|
||||
if new_hosts:
|
||||
host = new_hosts[0]
|
||||
new_cluster = Cluster(
|
||||
contact_points=(host,),
|
||||
port=self.port,
|
||||
cql_version=self.cql_version,
|
||||
protocol_version=self.protocol_version,
|
||||
auth_provider=self.auth_provider,
|
||||
ssl_options=sslhandling.ssl_settings(host, self.config_file) if self.ssl else None,
|
||||
load_balancing_policy=TokenAwarePolicy(WhiteListRoundRobinPolicy(hosts)),
|
||||
default_retry_policy=ExpBackoffRetryPolicy(self),
|
||||
compression=None,
|
||||
executor_threads=max(2, self.csv_options['jobs'] / 2))
|
||||
|
||||
session = ExportSession(new_cluster, self)
|
||||
self.hosts_to_sessions[host] = session
|
||||
return session
|
||||
else:
|
||||
host = min(hosts, key=lambda h: self.hosts_to_sessions[h].jobs)
|
||||
session = self.hosts_to_sessions[host]
|
||||
session.add_job()
|
||||
return session
|
||||
|
||||
def attach_callbacks(self, token_range, future, session):
|
||||
def result_callback(rows):
|
||||
if future.has_more_pages:
|
||||
future.start_fetching_next_page()
|
||||
self.write_rows_to_csv(token_range, rows)
|
||||
else:
|
||||
self.write_rows_to_csv(token_range, rows)
|
||||
self.outmsg.put((None, None))
|
||||
session.complete_job()
|
||||
|
||||
def err_callback(err):
|
||||
self.report_error(err, token_range)
|
||||
session.complete_job()
|
||||
|
||||
future.add_callbacks(callback=result_callback, errback=err_callback)
|
||||
|
||||
def write_rows_to_csv(self, token_range, rows):
|
||||
if len(rows) == 0:
|
||||
return # no rows in this range
|
||||
|
||||
try:
|
||||
output = StringIO()
|
||||
writer = csv.writer(output, **self.dialect_options)
|
||||
|
||||
for row in rows:
|
||||
writer.writerow(map(self.format_value, row))
|
||||
|
||||
data = (output.getvalue(), len(rows))
|
||||
self.outmsg.put((token_range, data))
|
||||
output.close()
|
||||
|
||||
except Exception, e:
|
||||
self.report_error(e, token_range)
|
||||
|
||||
def format_value(self, val):
|
||||
if val is None or val == EMPTY:
|
||||
return format_value_default(self.nullval, colormap=NO_COLOR_MAP)
|
||||
|
||||
ctype = type(val)
|
||||
formatter = self.formatters.get(ctype, None)
|
||||
if not formatter:
|
||||
formatter = get_formatter(ctype)
|
||||
self.formatters[ctype] = formatter
|
||||
|
||||
return formatter(val, encoding=self.encoding, colormap=NO_COLOR_MAP, date_time_format=self.date_time_format,
|
||||
float_precision=self.float_precision, nullval=self.nullval, quote=False)
|
||||
|
||||
def close(self):
|
||||
self.printmsg("Export process terminating...")
|
||||
self.inmsg.close()
|
||||
self.outmsg.close()
|
||||
for session in self.hosts_to_sessions.values():
|
||||
session.shutdown()
|
||||
self.printmsg("Export process terminated")
|
||||
|
||||
def prepare_query(self, partition_key, token_range, attempts):
|
||||
"""
|
||||
Return the export query or a fake query with some failure injected.
|
||||
"""
|
||||
if self.test_failures:
|
||||
return self.maybe_inject_failures(partition_key, token_range, attempts)
|
||||
else:
|
||||
return self.prepare_export_query(partition_key, token_range)
|
||||
|
||||
def maybe_inject_failures(self, partition_key, token_range, attempts):
|
||||
"""
|
||||
Examine self.test_failures and see if token_range is either a token range
|
||||
supposed to cause a failure (failing_range) or to terminate the worker process
|
||||
(exit_range). If not then call prepare_export_query(), which implements the
|
||||
normal behavior.
|
||||
"""
|
||||
start_token, end_token = token_range
|
||||
|
||||
if not start_token or not end_token:
|
||||
# exclude first and last ranges to make things simpler
|
||||
return self.prepare_export_query(partition_key, token_range)
|
||||
|
||||
if 'failing_range' in self.test_failures:
|
||||
failing_range = self.test_failures['failing_range']
|
||||
if start_token >= failing_range['start'] and end_token <= failing_range['end']:
|
||||
if attempts < failing_range['num_failures']:
|
||||
return 'SELECT * from bad_table'
|
||||
|
||||
if 'exit_range' in self.test_failures:
|
||||
exit_range = self.test_failures['exit_range']
|
||||
if start_token >= exit_range['start'] and end_token <= exit_range['end']:
|
||||
sys.exit(1)
|
||||
|
||||
return self.prepare_export_query(partition_key, token_range)
|
||||
|
||||
def prepare_export_query(self, partition_key, token_range):
|
||||
"""
|
||||
Return a query where we select all the data for this token range
|
||||
"""
|
||||
pk_cols = ", ".join(protect_names(col.name for col in partition_key))
|
||||
columnlist = ', '.join(protect_names(self.columns))
|
||||
start_token, end_token = token_range
|
||||
query = 'SELECT %s FROM %s.%s' % (columnlist, protect_name(self.ks), protect_name(self.cf))
|
||||
if start_token is not None or end_token is not None:
|
||||
query += ' WHERE'
|
||||
if start_token is not None:
|
||||
query += ' token(%s) > %s' % (pk_cols, start_token)
|
||||
if start_token is not None and end_token is not None:
|
||||
query += ' AND'
|
||||
if end_token is not None:
|
||||
query += ' token(%s) <= %s' % (pk_cols, end_token)
|
||||
return query
|
||||
|
||||
|
||||
class RateMeter(object):
|
||||
|
||||
def __init__(self, log_threshold):
|
||||
self.log_threshold = log_threshold # number of records after which we log
|
||||
self.last_checkpoint_time = time.time() # last time we logged
|
||||
self.current_rate = 0.0 # rows per second
|
||||
self.current_record = 0 # number of records since we last logged
|
||||
self.total_records = 0 # total number of records
|
||||
|
||||
def increment(self, n=1):
|
||||
self.current_record += n
|
||||
|
||||
if self.current_record >= self.log_threshold:
|
||||
self.update()
|
||||
self.log()
|
||||
|
||||
def update(self):
|
||||
new_checkpoint_time = time.time()
|
||||
time_difference = new_checkpoint_time - self.last_checkpoint_time
|
||||
if time_difference != 0.0:
|
||||
self.current_rate = self.get_new_rate(self.current_record / time_difference)
|
||||
|
||||
self.last_checkpoint_time = new_checkpoint_time
|
||||
self.total_records += self.current_record
|
||||
self.current_record = 0
|
||||
|
||||
def get_new_rate(self, new_rate):
|
||||
"""
|
||||
return the previous rate averaged with the new rate to smooth a bit
|
||||
"""
|
||||
if self.current_rate == 0.0:
|
||||
return new_rate
|
||||
else:
|
||||
return (self.current_rate + new_rate) / 2.0
|
||||
|
||||
def log(self):
|
||||
output = 'Processed %d rows; Written: %f rows/s\r' % (self.total_records, self.current_rate,)
|
||||
sys.stdout.write(output)
|
||||
sys.stdout.flush()
|
||||
|
||||
def get_total_records(self):
|
||||
self.update()
|
||||
self.log()
|
||||
return self.total_records
|
||||
|
|
@ -28,11 +28,19 @@ ANSI_RESET = '\033[0m'
|
|||
|
||||
|
||||
def colorme(bval, colormap, colorkey):
|
||||
if colormap is NO_COLOR_MAP:
|
||||
return bval
|
||||
if colormap is None:
|
||||
colormap = DEFAULT_VALUE_COLORS
|
||||
return FormattedValue(bval, colormap[colorkey] + bval + colormap['reset'])
|
||||
|
||||
|
||||
def get_str(val):
|
||||
if isinstance(val, FormattedValue):
|
||||
return val.strval
|
||||
return val
|
||||
|
||||
|
||||
class FormattedValue:
|
||||
|
||||
def __init__(self, strval, coloredval=None, displaywidth=None):
|
||||
|
|
@ -114,3 +122,5 @@ COLUMN_NAME_COLORS = defaultdict(lambda: MAGENTA,
|
|||
blob=DARK_MAGENTA,
|
||||
reset=ANSI_RESET,
|
||||
)
|
||||
|
||||
NO_COLOR_MAP = dict()
|
||||
|
|
|
|||
|
|
@ -14,16 +14,16 @@
|
|||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import binascii
|
||||
import calendar
|
||||
import math
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
import platform
|
||||
from collections import defaultdict
|
||||
|
||||
from . import wcwidth
|
||||
from .displaying import colorme, FormattedValue, DEFAULT_VALUE_COLORS
|
||||
from .displaying import colorme, get_str, FormattedValue, DEFAULT_VALUE_COLORS, NO_COLOR_MAP
|
||||
from cassandra.cqltypes import EMPTY
|
||||
from cassandra.util import datetime_from_timestamp
|
||||
from util import UTC
|
||||
|
|
@ -87,7 +87,6 @@ def color_text(bval, colormap, displaywidth=None):
|
|||
# adding the smarts to handle that in to FormattedValue, we just
|
||||
# make an explicit check to see if a null colormap is being used or
|
||||
# not.
|
||||
|
||||
if displaywidth is None:
|
||||
displaywidth = len(bval)
|
||||
tbr = _make_turn_bits_red_f(colormap['blob'], colormap['text'])
|
||||
|
|
@ -116,7 +115,7 @@ def format_value_default(val, colormap, **_):
|
|||
val = str(val)
|
||||
escapedval = val.replace('\\', '\\\\')
|
||||
bval = controlchars_re.sub(_show_control_chars, escapedval)
|
||||
return color_text(bval, colormap)
|
||||
return bval if colormap is NO_COLOR_MAP else color_text(bval, colormap)
|
||||
|
||||
# Mapping cql type base names ("int", "map", etc) to formatter functions,
|
||||
# making format_value a generic function
|
||||
|
|
@ -130,6 +129,10 @@ def format_value(type, val, **kwargs):
|
|||
return formatter(val, **kwargs)
|
||||
|
||||
|
||||
def get_formatter(type):
|
||||
return _formatters.get(type.__name__, format_value_default)
|
||||
|
||||
|
||||
def formatter_for(typname):
|
||||
def registrator(f):
|
||||
_formatters[typname] = f
|
||||
|
|
@ -139,7 +142,7 @@ def formatter_for(typname):
|
|||
|
||||
@formatter_for('bytearray')
|
||||
def format_value_blob(val, colormap, **_):
|
||||
bval = '0x' + ''.join('%02x' % c for c in val)
|
||||
bval = '0x' + binascii.hexlify(val)
|
||||
return colorme(bval, colormap, 'blob')
|
||||
formatter_for('buffer')(format_value_blob)
|
||||
|
||||
|
|
@ -231,8 +234,8 @@ def format_value_text(val, encoding, colormap, quote=False, **_):
|
|||
bval = escapedval.encode(encoding, 'backslashreplace')
|
||||
if quote:
|
||||
bval = "'%s'" % bval
|
||||
displaywidth = wcwidth.wcswidth(bval.decode(encoding))
|
||||
return color_text(bval, colormap, displaywidth)
|
||||
|
||||
return bval if colormap is NO_COLOR_MAP else color_text(bval, colormap, wcwidth.wcswidth(bval.decode(encoding)))
|
||||
|
||||
# name alias
|
||||
formatter_for('unicode')(format_value_text)
|
||||
|
|
@ -244,7 +247,10 @@ def format_simple_collection(val, lbracket, rbracket, encoding,
|
|||
date_time_format=date_time_format, float_precision=float_precision,
|
||||
nullval=nullval, quote=True)
|
||||
for sval in val]
|
||||
bval = lbracket + ', '.join(sval.strval for sval in subs) + rbracket
|
||||
bval = lbracket + ', '.join(get_str(sval) for sval in subs) + rbracket
|
||||
if colormap is NO_COLOR_MAP:
|
||||
return bval
|
||||
|
||||
lb, sep, rb = [colormap['collection'] + s + colormap['reset']
|
||||
for s in (lbracket, ', ', rbracket)]
|
||||
coloredval = lb + sep.join(sval.coloredval for sval in subs) + rb
|
||||
|
|
@ -281,7 +287,10 @@ def format_value_map(val, encoding, colormap, date_time_format, float_precision,
|
|||
nullval=nullval, quote=True)
|
||||
|
||||
subs = [(subformat(k), subformat(v)) for (k, v) in sorted(val.items())]
|
||||
bval = '{' + ', '.join(k.strval + ': ' + v.strval for (k, v) in subs) + '}'
|
||||
bval = '{' + ', '.join(get_str(k) + ': ' + get_str(v) for (k, v) in subs) + '}'
|
||||
if colormap is NO_COLOR_MAP:
|
||||
return bval
|
||||
|
||||
lb, comma, colon, rb = [colormap['collection'] + s + colormap['reset']
|
||||
for s in ('{', ', ', ': ', '}')]
|
||||
coloredval = lb \
|
||||
|
|
@ -306,7 +315,10 @@ def format_value_utype(val, encoding, colormap, date_time_format, float_precisio
|
|||
return format_value_text(name, encoding=encoding, colormap=colormap, quote=False)
|
||||
|
||||
subs = [(format_field_name(k), format_field_value(v)) for (k, v) in val._asdict().items()]
|
||||
bval = '{' + ', '.join(k.strval + ': ' + v.strval for (k, v) in subs) + '}'
|
||||
bval = '{' + ', '.join(get_str(k) + ': ' + get_str(v) for (k, v) in subs) + '}'
|
||||
if colormap is NO_COLOR_MAP:
|
||||
return bval
|
||||
|
||||
lb, comma, colon, rb = [colormap['collection'] + s + colormap['reset']
|
||||
for s in ('{', ', ', ': ', '}')]
|
||||
coloredval = lb \
|
||||
|
|
|
|||
Loading…
Reference in New Issue