import copy import datetime import posixpath import textwrap from collections import OrderedDict import prettytable from dcos import auth, marathon, mesos, util EMPTY_ENTRY = '---' DEPLOYMENT_DISPLAY = {'ResolveArtifacts': 'artifacts', 'ScaleApplication': 'scale', 'StartApplication': 'start', 'StopApplication': 'stop', 'RestartApplication': 'restart', 'ScalePod': 'scale', 'StartPod': 'start', 'StopPod': 'stop', 'RestartPod': 'restart', 'KillAllOldTasksOf': 'kill-tasks'} logger = util.get_logger(__name__) def task_table(tasks): """Returns a PrettyTable representation of the provided mesos tasks. :param tasks: tasks to render :type tasks: [Task] :rtype: PrettyTable """ fields = OrderedDict([ ("NAME", lambda t: t["name"]), ("HOST", lambda t: t.slave()["hostname"]), ("USER", lambda t: t.user()), ("STATE", lambda t: t["state"].split("_")[-1][0]), ("ID", lambda t: t["id"]), ("MESOS ID", lambda t: t["slave_id"]), ]) tb = table(fields, tasks, sortby="NAME") tb.align["NAME"] = "l" tb.align["HOST"] = "l" tb.align["ID"] = "l" tb.align["MESOS ID"] = "l" return tb def app_table(apps, deployments): """Returns a PrettyTable representation of the provided apps. :param apps: apps to render :type apps: [dict] :param deployments: deployments to enhance information :type deployments: [dict] :rtype: PrettyTable """ deployment_map = {} for deployment in deployments: deployment_map[deployment['id']] = deployment def get_cmd(app): if app["cmd"] is not None: return app["cmd"] else: return app["args"] def get_container(app): if app["container"] is not None: return app["container"]["type"] else: return "mesos" def get_health(app): if app["healthChecks"]: return "{}/{}".format(app["tasksHealthy"], app["tasksRunning"]) else: return EMPTY_ENTRY def get_deployment(app): deployment_ids = {deployment['id'] for deployment in app['deployments']} actions = [] for deployment_id in deployment_ids: deployment = deployment_map.get(deployment_id) if deployment: for action in deployment['currentActions']: if action['app'] == app['id']: actions.append(DEPLOYMENT_DISPLAY[action['action']]) if len(actions) == 0: return EMPTY_ENTRY elif len(actions) == 1: return actions[0] else: return "({})".format(", ".join(actions)) fields = OrderedDict([ ("ID", lambda a: a["id"]), ("MEM", lambda a: a["mem"]), ("CPUS", lambda a: a["cpus"]), ("TASKS", lambda a: "{}/{}".format(a["tasksRunning"], a["instances"])), ("HEALTH", get_health), ("DEPLOYMENT", get_deployment), ("WAITING", lambda app: app.get('overdue', False)), ("CONTAINER", get_container), ("CMD", get_cmd) ]) limits = { "CMD": 35 } tb = truncate_table(fields, apps, limits, sortby="ID") tb.align["CMD"] = "l" tb.align["ID"] = "l" tb.align["WAITING"] = "l" return tb def app_task_table(tasks): """Returns a PrettyTable representation of the provided marathon tasks. :param tasks: tasks to render :type tasks: [dict] :rtype: PrettyTable """ fields = OrderedDict([ ("APP", lambda t: t["appId"]), ("HEALTHY", lambda t: all(check['alive'] for check in t.get('healthCheckResults', []))), ("STARTED", lambda t: t.get("startedAt", "N/A")), ("HOST", lambda t: t["host"]), ("ID", lambda t: t["id"]) ]) tb = table(fields, tasks, sortby="APP") tb.align["APP"] = "l" tb.align["ID"] = "l" return tb def deployment_table(deployments): """Returns a PrettyTable representation of the provided marathon deployments. :param deployments: deployments to render :type deployments: [dict] :rtype: PrettyTable """ def join_path_ids(deployment, affected_resources_key): """Create table cell for "affectedApps"/"affectedPods" in deployment. :param deployment: the deployment JSON to read :type deployment: {} :param affected_resources_key: either "affectedApps" or "affectedPods" :type affected_resources_key: str :returns: newline-separated path IDs if they exist, otherwise an empty cell indicator :rtype: str """ path_ids = deployment.get(affected_resources_key) return '\n'.join(path_ids) if path_ids else '-' def resource_path_id(action): """Get the path ID of the app or pod represented by the given action. :param action: the Marathon deployment action JSON object to read :type action: {} :returns: the value of the "app" or "pod" field if it exists, else None :rtype: str """ path_id = action.get('app') or action.get('pod') if path_id is None: template = 'Expected "app" or "pod" field in action: %s' logger.exception(template, action) return path_id def get_action(deployment): multiple_resources = len({resource_path_id(action) for action in deployment['currentActions']}) > 1 ret = [] for action in deployment['currentActions']: try: action_display = DEPLOYMENT_DISPLAY[action['action']] except KeyError: logger.exception('Missing action entry') raise ValueError( 'Unknown Marathon action: {}'.format(action['action'])) if resource_path_id(action) is None: ret.append('N/A') elif multiple_resources: path_id = resource_path_id(action) ret.append('{0} {1}'.format(action_display, path_id)) else: ret.append(action_display) return '\n'.join(ret) fields = OrderedDict([ ('APP', lambda d: join_path_ids(d, 'affectedApps')), ('POD', lambda d: join_path_ids(d, 'affectedPods')), ('ACTION', get_action), ('PROGRESS', lambda d: '{0}/{1}'.format(d['currentStep']-1, d['totalSteps'])), ('ID', lambda d: d['id']) ]) tb = table(fields, deployments, sortby="APP") tb.align['APP'] = 'l' tb.align['POD'] = 'l' tb.align['ACTION'] = 'l' tb.align['ID'] = 'l' return tb def service_table(services): """Returns a PrettyTable representation of the provided DC/OS services. :param services: services to render :type services: [Framework] :rtype: PrettyTable """ fields = OrderedDict([ ("NAME", lambda s: s['name']), ("HOST", lambda s: s['hostname']), ("ACTIVE", lambda s: s['active']), ("TASKS", lambda s: len(s['tasks'])), ("CPU", lambda s: s['resources']['cpus']), ("MEM", lambda s: s['resources']['mem']), ("DISK", lambda s: s['resources']['disk']), ("ID", lambda s: s['id']), ]) tb = table(fields, services, sortby="NAME") tb.align["ID"] = 'l' tb.align["NAME"] = 'l' return tb def job_table(job_list): """Returns a PrettyTable representation of the job list from Metronome. :param job_list: jobs to render :type job_list: [job] :rtype: PrettyTable """ fields = OrderedDict([ ('id', lambda s: s['id']), ('Description', lambda s: s['description'] if 'description' in s else ''), ('Status', lambda s: _job_status(s)), ('Last Succesful Run', lambda s: s['history']['lastSuccessAt'] if 'history' in s else 'N/A'), ]) limits = { "Description": 35 } tb = truncate_table(fields, job_list, limits, sortby="ID") tb.align['ID'] = 'l' tb.align["DESCRIPTION"] = 'l' tb.align["STATUS"] = 'l' return tb def job_history_table(schedule_list): """Returns a PrettyTable representation of the job history from Metronome. :param schedule_list: job schedule list to render :type schedule_list: [history] :rtype: PrettyTable """ fields = OrderedDict([ ('id', lambda s: s['id']), ('started', lambda s: s['createdAt']), ('finished', lambda s: s['finishedAt']), ]) tb = table(fields, schedule_list, sortby="STARTED") tb.align['ID'] = 'l' return tb def schedule_table(schedule_list): """Returns a PrettyTable representation of the schedule list of a job from Metronome. :param schedule_list: schedules to render :type schedule_list: [schedule] :rtype: PrettyTable """ fields = OrderedDict([ ('id', lambda s: s['id']), ('cron', lambda s: s['cron']), ('enabled', lambda s: s['enabled']), ('next run', lambda s: s['nextRunAt']), ('concurrency policy', lambda s: s['concurrencyPolicy']), ]) tb = table(fields, schedule_list) tb.align['ID'] = 'l' tb.align['CRON'] = 'l' return tb def job_runs_table(runs_list): """Returns a PrettyTable representation of the runs list of a job from Metronome. :param runs_list: current runs of a job to render :type runs_list: [runs] :rtype: PrettyTable """ fields = OrderedDict([ ('job id', lambda s: s['jobId']), ('id', lambda s: s['id']), ('started at', lambda s: s['createdAt']), ]) tb = table(fields, runs_list) tb.align['ID'] = 'l' tb.align['JOB ID'] = 'l' return tb def _job_status(job): """Utility function that returns the status of a job :param job: job json :type job: json :rtype: str """ if 'activeRuns' in job: return "Running" # short circuit will prevent failure elif 'schedules' not in job or not job['schedules']: return "Unscheduled" else: return "Scheduled" def _count_apps(group, group_dict): """Counts how many apps are registered for each group. Recursively populates the profided `group_dict`, which maps group_id -> (group, count). :param group: nested group dictionary :type group: dict :param group_dict: group map that maps group_id -> (group, count) :type group_dict: dict :rtype: dict """ for child_group in group['groups']: _count_apps(child_group, group_dict) count = (len(group['apps']) + sum(group_dict[child_group['id']][1] for child_group in group['groups'])) group_dict[group['id']] = (group, count) def group_table(groups): """Returns a PrettyTable representation of the provided marathon groups :param groups: groups to render :type groups: [dict] :rtype: PrettyTable """ group_dict = {} for group in groups: _count_apps(group, group_dict) fields = OrderedDict([ ('ID', lambda g: g[0]['id']), ('APPS', lambda g: g[1]), ]) tb = table(fields, group_dict.values(), sortby="ID") tb.align['ID'] = 'l' return tb def pod_table(pods): """Returns a PrettyTable representation of the provided Marathon pods. :param pods: pods to render :type pods: [dict] :rtype: PrettyTable """ def id_and_containers(pod): """Extract the pod ID and container names from the given pod JSON. :param pod: the pod JSON to read :type pod: {} :returns: the entry for the ID+CONTAINER column of the pod table :rtype: str """ pod_id = pod['id'] container_names = sorted(container['name'] for container in pod['spec']['containers']) container_lines = ('\n |-{}'.format(name) for name in container_names) return pod_id + ''.join(container_lines) key_column = 'ID+TASKS' fields = OrderedDict([ (key_column, id_and_containers), ('INSTANCES', lambda pod: len(pod.get('instances', []))), ('VERSION', lambda pod: pod['spec'].get('version', '-')), ('STATUS', lambda pod: pod['status']), ('STATUS SINCE', lambda pod: pod['statusSince']), ('WAITING', lambda pod: pod.get('overdue', False)) ]) tb = table(fields, pods, sortby=key_column) tb.align[key_column] = 'l' tb.align['VERSION'] = 'l' tb.align['STATUS'] = 'l' tb.align['STATUS SINCE'] = 'l' tb.align['WAITING'] = 'l' return tb def queued_apps_table(queued_apps): """Returns a PrettyTable representation of the Marathon launch queue content. :param queued_apps: apps to render :type queued_apps: [dict] :rtype: PrettyTable """ def extract_value_from_entry(entry, value): """Extracts the value parameter from given row entry. If value is not present, EMPTY_ENTRY will be returned :param entry: row entry :type entry: [dict] :param value: value which should be extracted :type value: string :rtype: str """ return entry.get('processedOffersSummary', {}).get(value, EMPTY_ENTRY) key_column = 'ID' fields = OrderedDict([ (key_column, lambda entry: marathon.get_app_or_pod_id(entry)), ('SINCE', lambda entry: entry.get('since', EMPTY_ENTRY) ), ('INSTANCES TO LAUNCH', lambda entry: entry.get('count', EMPTY_ENTRY) ), ('WAITING', lambda entry: entry.get('delay', {}).get('overdue', EMPTY_ENTRY) ), ('PROCESSED OFFERS', lambda entry: extract_value_from_entry(entry, 'processedOffersCount') ), ('UNUSED OFFERS', lambda entry: extract_value_from_entry(entry, 'unusedOffersCount') ), ('LAST UNUSED OFFER', lambda entry: extract_value_from_entry(entry, 'lastUnusedOfferAt') ), ('LAST USED OFFER', lambda entry: extract_value_from_entry(entry, 'lastUsedOfferAt') ), ]) tb = table(fields, queued_apps, sortby=key_column) tb.align[key_column] = 'l' tb.align['SINCE'] = 'l' tb.align['INSTANCES TO LAUNCH'] = 'l' tb.align['WAITING'] = 'l' tb.align['PROCESSED OFFERS'] = 'l' tb.align['UNUSED OFFERS'] = 'l' tb.align['LAST UNUSED OFFER'] = 'l' tb.align['LAST USED OFFER'] = 'l' return tb def queued_app_table(queued_app): """Returns a PrettyTable representation of the Marathon launch queue content. :param queued_app: app to render :type queued_app: dict :rtype: PrettyTable """ def calc_division(dividend, divisor): """Calcs divident / divisor, displays 0 if divisor equals 0. :param dividend: divident :type dividend: int :param divisor: divisor :type divisor: int :rtype: str """ if divisor == 0: return 0 else: return 100 * dividend / divisor def add_reason_entry(calculations, key, requested, reason_entry): """Pretty prints the division of reason_entry.get('declined') / reason_entry.get('processed') :param calculations: object where result should be added :type calculations: dict :param key: key for which the result should be added :type key: string :param requested: the value initially was requested for this entry :type requested: string :param reason_entry: entry for a declined offer reason :type reason_entry: [dict] :rtype: str """ dividend = reason_entry.get('processed', 0) - \ reason_entry.get('declined', 0) divisor = reason_entry.get('processed', 0) calculations[key]['REQUESTED'] = requested calculations[key]['MATCHED'] = '{0} / {1}'\ .format(dividend, divisor) if divisor > 0: calculations[key]['PERCENTAGE'] = '{0:0.2f}%' \ .format(calc_division(dividend, divisor)) else: calculations[key]['PERCENTAGE'] = EMPTY_ENTRY def extract_reason_from_list(list, reason_string): """Extracts the reason for the given reason_string from the given list :param list: list of reason entries :type list: [dict] :param reason_string: reasong as string :type reason_string: str :rtype: reason entry """ filtered = [x for x in list if x['reason'] == reason_string] if len(filtered) == 1: return filtered[0] else: return {'reason': reason_string, 'declined': 0, 'processed': 0} fields = OrderedDict([ ('RESOURCE', lambda entry: calculations.get(entry, {}).get('RESOURCE', EMPTY_ENTRY) ), ('REQUESTED', lambda entry: calculations.get(entry, {}).get('REQUESTED', EMPTY_ENTRY) ), ('MATCHED', lambda entry: calculations.get(entry, {}).get('MATCHED', EMPTY_ENTRY) ), ('PERCENTAGE', lambda entry: calculations.get(entry, {}).get('PERCENTAGE', EMPTY_ENTRY) ), ]) summary = queued_app.get('processedOffersSummary', {}) reasons = summary.get('rejectSummaryLastOffers', {}) declined_by_role = extract_reason_from_list( reasons, 'UnfulfilledRole') declined_by_constraints = extract_reason_from_list( reasons, 'UnfulfilledConstraint') declined_by_cpus = extract_reason_from_list( reasons, 'InsufficientCpus') declined_by_mem = extract_reason_from_list( reasons, 'InsufficientMemory') declined_by_disk = extract_reason_from_list( reasons, 'InsufficientDisk') """declined_by_gpus = extract_reason_from_list( reasons, 'InsufficientGpus')""" declined_by_ports = extract_reason_from_list( reasons, 'InsufficientPorts') app = queued_app.get('app') if app: roles = app.get('acceptedResourceRoles', []) if len(roles) == 0: spec_roles = '[*]' else: spec_roles = roles spec_constraints = app.get('constraints', EMPTY_ENTRY) spec_cpus = app.get('cpus', EMPTY_ENTRY) spec_mem = app.get('mem', EMPTY_ENTRY) spec_disk = app.get('disk', EMPTY_ENTRY) """spec_gpus = app.get('gpus', EMPTY_ENTRY)""" spec_ports = app.get('ports', EMPTY_ENTRY) else: def sum_resources(value): def container_value(container): return container.get('resources', {}).get(value, 0) """While running pods, marathon will add resources for the executor to the requested resources. Therefore this requirements should be reflected in the summary.""" def executor_value(): return pod.get('executorResources', {}).get(value, 0) resources = sum(map(container_value, pod.get('containers', []))) return resources + executor_value() pod = queued_app.get('pod') roles = pod.\ get('scheduling', {}).get('placement', {}).\ get('acceptedResourceRoles', []) if len(roles) == 0: spec_roles = '[*]' else: spec_roles = roles spec_constraints = pod.\ get('scheduling', {}).get('placement', {}).\ get('constraints', EMPTY_ENTRY) spec_cpus = sum_resources('cpus') spec_mem = sum_resources('mem') spec_disk = sum_resources('disk') """spec_gpus = sum_resources('gpus')""" spec_ports = [] for container in pod.get('containers', []): for endpoint in container.get('endpoints', []): spec_ports.append(endpoint.get('hostPort')) """'GPUS'""" rows = ['ROLE', 'CONSTRAINTS', 'CPUS', 'MEM', 'DISK', 'PORTS'] calculations = {} for reason in rows: calculations[reason] = {} calculations[reason]['RESOURCE'] = reason add_reason_entry(calculations, 'ROLE', spec_roles, declined_by_role) add_reason_entry( calculations, 'CONSTRAINTS', spec_constraints, declined_by_constraints) add_reason_entry(calculations, 'CPUS', spec_cpus, declined_by_cpus) add_reason_entry(calculations, 'MEM', spec_mem, declined_by_mem) add_reason_entry(calculations, 'DISK', spec_disk, declined_by_disk) """ add_reason_entry(calculations, 'GPUS', spec_gpus, declined_by_gpus) """ add_reason_entry(calculations, 'PORTS', spec_ports, declined_by_ports) tb = table(fields, rows) tb.align['RESOURCE'] = 'l' tb.align['REQUESTED'] = 'l' tb.align['MATCHED'] = 'l' tb.align['PERCENTAGE'] = 'l' return tb def queued_app_details_table(queued_app): """Returns a PrettyTable representation of the Marathon launch queue detailed content. :param queued_app: app to render :type queued_app: dict :rtype: PrettyTable """ def value_declined(entry, value): """Returns `yes` if the value was inside entry.get('reason'), returns `no` otherwise. :param entry: row entry :type entry: [dict] :param value: value which should be checked :type value: string :rtype: PrettyTable """ if value not in entry.get('reason', []): return 'ok' else: return '-' reasons = queued_app.get('lastUnusedOffers') fields = OrderedDict([ ('HOSTNAME', lambda entry: entry.get('offer', {}).get('hostname', EMPTY_ENTRY) ), ('ROLE', lambda entry: value_declined(entry, 'UnfulfilledRole')), ('CONSTRAINTS', lambda entry: value_declined(entry, 'UnfulfilledConstraint') ), ('CPUS', lambda entry: value_declined(entry, 'InsufficientCpus')), ('MEM', lambda entry: value_declined(entry, 'InsufficientMemory')), ('DISK', lambda entry: value_declined(entry, 'InsufficientDisk')), ('PORTS', lambda entry: value_declined(entry, 'UnfulfilledRole')), ('RECEIVED', lambda entry: entry.get('timestamp', EMPTY_ENTRY) ), ]) """('GPUS', lambda entry: value_declined(entry, 'InsufficientGpus')),""" tb = table(fields, reasons, sortby='HOSTNAME') tb.align['HOSTNAME'] = 'l' tb.align['REASON'] = 'l' tb.align['RECEIVED'] = 'l' return tb def package_table(packages): """Returns a PrettyTable representation of the provided DC/OS packages :param packages: packages to render :type packages: [dict] :rtype: PrettyTable """ fields = OrderedDict([ ('NAME', lambda p: p['name']), ('VERSION', lambda p: p['version']), ('APP', lambda p: '\n'.join(p['apps']) if p.get('apps') else EMPTY_ENTRY), ('COMMAND', lambda p: p['command']['name'] if 'command' in p else EMPTY_ENTRY), ('DESCRIPTION', lambda p: p['description']) ]) tb = table(fields, packages, sortby="NAME") tb.align['NAME'] = 'l' tb.align['VERSION'] = 'l' tb.align['APP'] = 'l' tb.align['COMMAND'] = 'l' tb.align['DESCRIPTION'] = 'l' return tb def package_search_table(search_results): """Returns a PrettyTable representation of the provided DC/OS package search results :param search_results: search_results, in the format of dcos.package.IndexEntries::as_dict() :type search_results: [dict] :rtype: PrettyTable """ fields = OrderedDict([ ('NAME', lambda p: p['name']), ('VERSION', lambda p: p['currentVersion']), ('SELECTED', lambda p: p.get("selected", False)), ('FRAMEWORK', lambda p: p['framework']), ('DESCRIPTION', lambda p: p['description'] if len(p['description']) < 77 else p['description'][0:77] + "...") ]) packages = [] for package in search_results['packages']: package_ = copy.deepcopy(package) packages.append(package_) tb = table(fields, packages) tb.align['NAME'] = 'l' tb.align['VERSION'] = 'l' tb.align['SELECTED'] = 'l' tb.align['FRAMEWORK'] = 'l' tb.align['DESCRIPTION'] = 'l' return tb def auth_provider_table(providers): """Returns a PrettyTable representation of the auth providers for cluster :param providers: auth providers available :type providers: dict :rtype: PrettyTable """ fields = OrderedDict([ ('PROVIDER ID', lambda p: p), ('AUTHENTICATION TYPE', lambda p: auth.auth_type_description( providers[p])), ]) tb = table(fields, providers, sortby="PROVIDER ID") tb.align['PROVIDER ID'] = 'l' tb.align['AUTHENTICATION TYPE'] = 'l' return tb def slave_table(slaves): """Returns a PrettyTable representation of the provided DC/OS slaves :param slaves: slaves to render. dicts from /mesos/state-summary :type slaves: [dict] :rtype: PrettyTable """ fields = OrderedDict([ ('HOSTNAME', lambda s: s['hostname']), ('IP', lambda s: mesos.parse_pid(s['pid'])[1]), ('ID', lambda s: s['id']) ]) tb = table(fields, slaves, sortby="HOSTNAME") return tb def _format_unix_timestamp(ts): """ Formats a unix timestamp in a `dcos task ls --long` format. :param ts: unix timestamp :type ts: int :rtype: str """ return datetime.datetime.fromtimestamp(ts).strftime('%b %d %H:%M') def ls_long_table(files): """Returns a PrettyTable representation of `files` :param files: Files to render. Of the form returned from the mesos /files/browse.json endpoint. :param files: [dict] :rtype: PrettyTable """ fields = OrderedDict([ ('MODE', lambda f: f['mode']), ('NLINK', lambda f: f['nlink']), ('UID', lambda f: f['uid']), ('GID', lambda f: f['gid']), ('SIZE', lambda f: f['size']), ('DATE', lambda f: _format_unix_timestamp(int(f['mtime']))), ('PATH', lambda f: posixpath.basename(f['path']))]) tb = table(fields, files, sortby="PATH", header=False) tb.align = 'r' return tb def truncate_table(fields, objs, limits, **kwargs): """Returns a PrettyTable. `fields` represents the header schema of the table. `objs` represents the objects to be rendered into rows. :param fields: An OrderedDict, where each element represents a column. The key is the column header, and the value is the function that transforms an element of `objs` into a value for that column. :type fields: OrderdDict(str, function) :param objs: objects to render into rows :type objs: [object] :param limits: limits for truncating for each row :type limits: [object] :param **kwargs: kwargs to pass to `prettytable.PrettyTable` :type **kwargs: dict :rtype: PrettyTable """ tb = prettytable.PrettyTable( [k.upper() for k in fields.keys()], border=False, hrules=prettytable.NONE, vrules=prettytable.NONE, left_padding_width=0, right_padding_width=1, **kwargs ) # Set these explicitly due to a bug in prettytable where # '0' values are not honored. tb._left_padding_width = 0 tb._right_padding_width = 2 def format_table(obj, key, function): """Formats the given object for the given function :param object: object to format :type object: object :param key: value which should be checked :type key: string :param function: function to format the cell :type function: function :rtype: PrettyTable """ result = str(function(obj)) if (limits is not None and limits.get(key) is not None): result = textwrap.\ shorten(result, width=limits.get(key), placeholder='...') return result for obj in objs: row = [format_table(obj, key, fields.get(key)) for key in fields.keys()] tb.add_row(row) return tb def table(fields, objs, **kwargs): """Returns a PrettyTable. `fields` represents the header schema of the table. `objs` represents the objects to be rendered into rows. :param fields: An OrderedDict, where each element represents a column. The key is the column header, and the value is the function that transforms an element of `objs` into a value for that column. :type fields: OrderdDict(str, function) :param objs: objects to render into rows :type objs: [object] :param **kwargs: kwargs to pass to `prettytable.PrettyTable` :type **kwargs: dict :rtype: PrettyTable """ return truncate_table(fields, objs, None, **kwargs)