diff --git a/python/README.md b/python/README.md index 805642ec..e465d152 100644 --- a/python/README.md +++ b/python/README.md @@ -42,6 +42,25 @@ Another example: getting all the services in a cluster: hue1 >>> +Shell +----- +After installing the `cm_api` Python package, you can use the API shell `cmps` +(CM Python Shell): + + $ cmps -H --user admin --password admin + Welcome to the Cloudera Manager Console + Select a cluster using 'show clusters' and 'use' + cloudera> show clusters + +------------------+ + | CLUSTER NAME | + +------------------+ + | Cluster 1 - CDH4 | + | Cluster 2 - CDH3 | + +------------------+ + cloudera> + +Please see the `SHELL_README.md` file for more. + Example Scripts --------------- You can find example scripts in the `python/examples` directory. diff --git a/python/SHELL_README.md b/python/SHELL_README.md new file mode 100644 index 00000000..c50482ce --- /dev/null +++ b/python/SHELL_README.md @@ -0,0 +1,143 @@ +Cloudera Manager Python Shell +============================ + + +Getting Started +--------------- + +### Installation ### + +> Run as a privileged user, or in a virtualenv + + $ python setup.py install + +### Usage ### + + $ cmps + usage: cmps [-h] -H HOSTNAME [-p PORT] [-u USERNAME] [-c CLUSTER] + [--password PASSWORD] [-e EXECUTE] [-s SEPERATOR] + cmps: error: argument -H/--host/--hostname is required + +### Login ### + + $ cmps -H + Enter Username: admin + Enter Password: + Welcome to the Cloudera Manager Console + Select a cluster using 'show clusters' and 'use' + cloudera> + +### Using Help ### + + cloudera> help + Cloudera Manager Commands + ========================= + log show status stop_role + restart_role start_cluster stderr stop_service + restart_service start_role stdout use + roles start_service stop_cluster version + + Other Commands + ============== + help + + cloudera> help stop_cluster + Completely stop the cluster + Usage: + > stop_cluster + +### Connecting to a Cluster ### + +> Autocomplete works + + cloudera> use cdh4 + Connected to cdh4 + cdh4> status + +------------+-----------+---------+--------+------------+ + | NAME | SERVICE | STATUS | HEALTH | CONFIG | + +------------+-----------+---------+--------+------------+ + | hbase1 | HBASE | STARTED | GOOD | UP TO DATE | + | hdfs1 | HDFS | STARTED | GOOD | UP TO DATE | + | mapreduce1 | MAPREDUCE | STARTED | GOOD | UP TO DATE | + | zookeeper1 | ZOOKEEPER | STARTED | GOOD | UP TO DATE | + +------------+-----------+---------+--------+------------+ + +### View Roles ### + + cdh4> roles hbase1 + +--------------+---------------------+-----------------------+---------+--------+------------+ + | ROLE TYPE | HOST | ROLE NAME | STATE | HEALTH | CONFIG | + +--------------+---------------------+-----------------------+---------+--------+------------+ + | MASTER | hbase.localdomain | hbase1-MASTER-1 | STARTED | GOOD | UP TO DATE | + | REGIONSERVER | hbase-2.localdomain | hbase1-REGIONSERVER-2 | STARTED | GOOD | UP TO DATE | + | REGIONSERVER | hbase.localdomain | hbase1-REGIONSERVER-1 | STARTED | GOOD | UP TO DATE | + +--------------+---------------------+-----------------------+---------+--------+------------+ + +### Stopping / Starting Services and Roles ### + + cdh4> restart_service hbase1 + hbase1 is being restarted + cdh4> status hbase1 + status hbase1 + +--------+---------+----------+--------+------------+ + | NAME | SERVICE | STATUS | HEALTH | CONFIG | + +--------+---------+----------+--------+------------+ + | hbase1 | HBASE | STARTING | GOOD | UP TO DATE | + +--------+---------+----------+--------+------------+ + + cdh4> stop_role hbase1-REGIONSERVER-2 + Stopping Role + cdh4> roles hbase1 + roles hbase1 + +--------------+---------------------+-----------------------+---------+--------+------------+ + | ROLE TYPE | HOST | ROLE NAME | STATE | HEALTH | CONFIG | + +--------------+---------------------+-----------------------+---------+--------+------------+ + | MASTER | hbase.localdomain | hbase1-MASTER-1 | STARTED | GOOD | UP TO DATE | + | REGIONSERVER | hbase-2.localdomain | hbase1-REGIONSERVER-2 | STOPPED | GOOD | UP TO DATE | + | REGIONSERVER | hbase.localdomain | hbase1-REGIONSERVER-1 | STARTED | GOOD | UP TO DATE | + +--------------+---------------------+-----------------------+---------+--------+------------+ + +### Viewing Logs ### + +> Interactive shells will use less + +> Non-interactive shells will send to stdout + + cdh4> log hbase1-REGIONSERVER-2 + cdh4> stdout hbase1-REGIONSERVER-2 + cdh4> stderr hbase1-REGIONSERVER-2 + +### Non-Interactive ### + + $ cmps -H 192.168.2.105 -u admin --password admin -e "show hosts; show clusters" + +---------------------+---------------+----------+ + | HOSTNAME | IP ADDRESS | RACK | + +---------------------+---------------+----------+ + | hbase.localdomain | 192.168.2.105 | /default | + | hbase-2.localdomain | 192.168.2.110 | /default | + +---------------------+---------------+----------+ + +--------------+ + | CLUSTER NAME | + +--------------+ + | cdh4 | + +--------------+ + +### Custom Output Delimiter ### + + $ cmps -H 192.168.2.105 -u admin --password admin -e "roles hbase1" -c cdh4 -s , + ROLE TYPE,HOST,ROLE NAME,STATE,HEALTH,CONFIG + MASTER,hbase.localdomain,hbase1-MASTER-1,STARTED,GOOD,UP TO DATE + REGIONSERVER,hbase-2.localdomain,hbase1-REGIONSERVER-2,STARTED,GOOD,UP TO DATE + REGIONSERVER,hbase.localdomain,hbase1-REGIONSERVER-1,STARTED,GOOD,UP TO DATE + +### Scripting Example ### + +> Obtain log files for all the region servers + + $ for i in $(cmps -H 192.168.2.105 -u admin --password admin -e "roles hbase1" -c cdh4 -s , | grep REGIONSERVER | awk -F, '{print $3}'); + do + cmps -H 192.168.2.105 -u admin --password admin -c cdh4 -e "log $i" > $i.out; + done + $ du -h *.out + 2.4M hbase1-REGIONSERVER-1.out + 1.9M hbase1-REGIONSERVER-2.out diff --git a/python/setup.py b/python/setup.py index 1cacac43..3abf6414 100644 --- a/python/setup.py +++ b/python/setup.py @@ -17,17 +17,28 @@ from setuptools import setup, find_packages -from sys import version_info +from sys import version_info, platform + if version_info[:2] > (2, 5): install_requires = [] else: install_requires = ['simplejson >= 2.0.0'] +# Python 2.6 and below requires argparse +if version_info[:2] < (2, 7): + install_requires += ['argparse'] + +# Mac does not come default with readline, this is needed for autocomplete +# in the cmps shell +if platform == 'darwin': + install_requires += ['readline'] + setup( name = 'cm_api', - version = '1.0.0', # Compatible with API v1 + version = '5.0.0', # Compatible with API v5 packages = find_packages('src', exclude=['cm_api_tests']), - package_dir = {'cm_api': 'src/cm_api'}, + package_dir = {'cm_api': 'src/cm_api', + 'cm_shell': 'src/cm_shell'}, # Project uses simplejson, so ensure that it gets installed or upgraded # on the target machine @@ -37,4 +48,5 @@ description = 'Cloudera Manager API client', license = 'Apache License 2.0', url = 'https://github.com/cloudera/cm_api', + entry_points = { 'console_scripts': [ 'cmps = cm_shell.cmps:main', ]} ) diff --git a/python/src/cm_shell/__init__.py b/python/src/cm_shell/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python/src/cm_shell/cmps.py b/python/src/cm_shell/cmps.py new file mode 100755 index 00000000..f728c129 --- /dev/null +++ b/python/src/cm_shell/cmps.py @@ -0,0 +1,644 @@ +#!/usr/bin/env python +# Licensed to Cloudera, Inc. under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. Cloudera, Inc. 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 sys +import getpass +import argparse +import readline +import os +import cmd +from prettytable import PrettyTable +from cm_api.api_client import ApiResource, ApiException +from urllib2 import URLError + +# Config +CONFIG = {'cluster': None, 'output_type': 'table', 'seperator': None} + +# Initial Prompt +INIT_PROMPT = "cloudera> " + +# Banner shown at interactive shell login +BANNER = "Welcome to the Cloudera Manager Console\nSelect a cluster using 'show clusters' and 'use'" + +# If true, than the user is running a non-interactive shell (ie: scripting) +EXECUTE = False + +# Readline fix for hyphens +readline.set_completer_delims(readline.get_completer_delims().replace('-', '')) + +# Global API object +api = None + + +class ClouderaShell(cmd.Cmd): + """ + Interactive shell for communicating with your + Cloudera Cluster making use of the cm_api + """ + + # Set initial cloudera prompt + prompt = INIT_PROMPT + + # Set login banner + intro = BANNER + + # Help headers + doc_header = "Cloudera Manager Commands" + undoc_header = "Other Commands" + + # Initial cache is blank + # when autocomplete for one of these components + # is triggered, it will automatically cache them + CACHED_ROLES = {} + CACHED_SERVICES = None + CACHED_CLUSTERS = None + + def preloop(self): + "Checks if the cluster was pre-defined" + if CONFIG['cluster']: + self.set_cluster(CONFIG['cluster']) + else: + self.cluster_object = None + + def generate_output(self, headers, rows, align=None): + if CONFIG['output_type'] == "table": + table = PrettyTable(headers) + if align: + for h in align: + table.align[h] = 'l' + + for r in rows: + table.add_row(r) + print(table) + + if CONFIG['output_type'] == "csv": + print(','.join(headers)) + for r in rows: + print(','.join(r)) + + if CONFIG['output_type'] == "custom": + SEP = CONFIG['seperator'] + print(SEP.join(headers)) + for r in rows: + print(SEP.join(r)) + + def emptyline(self): + """Called each time a user hits enter, by + default it will redo the last command, this + is an extension so it does nothing.""" + pass + + def set_cluster(self, cluster): + try: + cluster = api.get_cluster(cluster) + except ApiException: + print("Cluster Not Found!") + return None + + self.cluster_object = cluster + if not EXECUTE: + print("Connected to %s" % (cluster.name)) + self.prompt = cluster.name + "> " + return True + + @property + def cluster(self): + if EXECUTE: + if not self.set_cluster(CONFIG['cluster']): + sys.exit(1) + return self.cluster_object.name + + if self.cluster_object: + return self.cluster_object.name + else: + return None + + def has_cluster(self): + if not self.cluster: + print("Error: No cluster currently selected") + return None + else: + return True + + def get_log(self, role, log_type=None): + if not role: + return None + + if not self.has_cluster(): + return None + + if '-' not in role: + print("Please enter a valid role name") + return None + + try: + service = api.get_cluster(self.cluster).get_service(role.split('-')[0]) + role = service.get_role(role) + try: + if EXECUTE: + output = sys.stdout + else: + output = os.popen("less", "w") + if log_type == "full": + output.write(role.get_full_log()) + if log_type == "stdout": + output.write(role.get_stdout()) + if log_type == "stderr": + output.write(role.get_stderr()) + + if not EXECUTE: + output.close() + except IOError: + pass + except ApiException: + print("Error: Role or Service Not Found") + + def do_status(self, service): + """ + List all services on the cluster + Usage: + > status + """ + if service: + self.do_show("services", single=service) + else: + self.do_show("services") + + def do_log(self, role): + """ + Download log file for role + Usage: + > log Download log + """ + self.get_log(role, log_type="full") + + def do_stdout(self, role): + """ + Download stdout file for role + Usage: + > stdout Download stdout + """ + self.get_log(role, log_type="stdout") + + def do_stderr(self, role): + """ + Download stderr file for role + Usage: + > stderr Download stderr + """ + self.get_log(role, log_type="stderr") + + def do_show(self, option, single=None): + """ + General System Information + Usage: + > show clusters list of clusters this CM manages + > show hosts list of all hosts CM manages + > show services list of all services on this cluster + including their health. + """ + headers = [] + rows = [] + align = None + # show clusters + if option == "clusters": + "Display list of clusters on system" + headers = ["CLUSTER NAME"] + clusters = api.get_all_clusters() + for cluster in clusters: + rows.append([cluster.name]) + + # show hosts + if option == "hosts": + "Display a list of hosts avaiable on the system" + headers = ["HOSTNAME", "IP ADDRESS", "RACK"] + align = ["HOSTNAME", "IP ADDRESS", "RACK"] + for host in api.get_all_hosts(): + rows.append([host.hostname, host.ipAddress, host.rackId]) + + # show services + if option == "services": + "Show list of services on the cluster" + headers = ["NAME", "SERVICE", "STATUS", "HEALTH", "CONFIG"] + align = ["NAME", "SERVICE"] + + # Check if the user has selected a cluster + if not self.has_cluster(): + print("Error: Please select a cluster first") + return None + + if not single: + for s in api.get_cluster(self.cluster).get_all_services(): + if s.configStale: + config = "STALE" + else: + config = "UP TO DATE" + rows.append([s.name, s.type, s.serviceState, s.healthSummary, config]) + else: + s = api.get_cluster(self.cluster).get_service(single) + if s.configStale: + config = "STALE" + else: + config = "UP TO DATE" + rows.append([s.name, s.type, s.serviceState, s.healthSummary, config]) + + self.generate_output(headers, rows, align=align) + + def complete_log(self, text, line, start_index, end_index): + return self.roles_autocomplete(text, line, start_index, end_index) + + def complete_stdout(self, text, line, start_index, end_index): + return self.roles_autocomplete(text, line, start_index, end_index) + + def complete_stderr(self, text, line, start_index, end_index): + return self.roles_autocomplete(text, line, start_index, end_index) + + def complete_show(self, text, line, start_index, end_index): + show_commands = ["clusters", "hosts", "services"] + if text: + return [c for c in show_commands if c.startswith(text)] + else: + return show_commands + + def service_action(self, service, action): + "Perform given action on service for the selected cluster" + try: + service = api.get_cluster(self.cluster).get_service(service) + except ApiException: + print("Service not found") + return None + + if action == "start": + service.start() + if action == "restart": + service.restart() + if action == "stop": + service.stop() + + return True + + def services_autocomplete(self, text, line, start_index, end_index, append=[]): + if not self.cluster: + return None + else: + if not self.CACHED_SERVICES: + services = [s.name for s in api.get_cluster(self.cluster).get_all_services()] + self.CACHED_SERVICES = services + + if text: + return [s for s in self.CACHED_SERVICES + append if s.startswith(text)] + else: + return self.CACHED_SERVICES + append + + def do_start_service(self, service): + """ + Start a service + Usage: + > start_service + """ + if not self.has_cluster(): + return None + + if self.service_action(service=service, action="start"): + print("%s is being started" % (service)) + else: + print("Error starting service") + return None + + def complete_start_service(self, text, line, start_index, end_index): + return self.services_autocomplete(text, line, start_index, end_index) + + def do_restart_service(self, service): + """ + Restart a service + Usage: + > restart_service + """ + if not self.has_cluster(): + return None + + if self.service_action(service=service, action="restart"): + print("%s is being restarted" % (service)) + else: + print("Error restarting service") + return None + + def complete_restart_service(self, text, line, start_index, end_index): + return self.services_autocomplete(text, line, start_index, end_index) + + def do_stop_service(self, service): + """ + Stop a service + Usage: + > stop_service + """ + if not self.has_cluster(): + return None + + if self.service_action(service=service, action="stop"): + print("%s is being stopped" % (service)) + else: + print("Error stopping service") + return None + + def complete_stop_service(self, text, line, start_index, end_index): + return self.services_autocomplete(text, line, start_index, end_index) + + def do_use(self, cluster): + """ + Connect to Cluster + Usage: + > use + """ + if not self.set_cluster(cluster): + print("Error setting cluster") + + def cluster_autocomplete(self, text, line, start_index, end_index): + "autocomplete for the use command, obtain list of clusters first" + if not self.CACHED_CLUSTERS: + clusters = [cluster.name for cluster in api.get_all_clusters()] + self.CACHED_CLUSTERS = clusters + + if text: + return [cluster for cluster in self.CACHED_CLUSTERS if cluster.startswith(text)] + else: + return self.CACHED_CLUSTERS + + def complete_use(self, text, line, start_index, end_index): + return self.cluster_autocomplete(text, line, start_index, end_index) + + def do_roles(self, service): + """ + Role information + Usage: + > roles Display role information for service + > roles all Display all role information for cluster + """ + if not self.has_cluster(): + return None + + if not service: + return None + + if service == "all": + if not self.CACHED_SERVICES: + self.services_autocomplete('', service, 0, 0) + + for s in self.CACHED_SERVICES: + print("= " + s.upper() + " =") + self.do_roles(s) + return None + try: + service = api.get_cluster(self.cluster).get_service(service) + headers = ["ROLE TYPE", "HOST", "ROLE NAME", "STATE", "HEALTH", "CONFIG"] + align = ["ROLE TYPE", "ROLE NAME", "HOST"] + rows = [] + for roletype in service.get_role_types(): + for role in service.get_roles_by_type(roletype): + if role.configStale: + config = "STALE" + else: + config = "UP TO DATE" + rows.append([role.type, role.hostRef.hostId, role.name, role.roleState, role.healthSummary, config]) + self.generate_output(headers, rows, align=align) + except ApiException: + print("Service not found") + + def complete_roles(self, text, line, start_index, end_index): + return self.services_autocomplete(text, line, start_index, end_index, append=["all"]) + + def roles_autocomplete(self, text, line, start_index, end_index): + "Return full list of roles" + if '-' not in line: + # Append a dash to each service, makes for faster autocompletion of + # roles + return [s + '-' for s in self.services_autocomplete(text, line, start_index, end_index)] + else: + key, role = line.split()[1].split('-', 1) + if key not in self.CACHED_ROLES: + service = api.get_cluster(self.cluster).get_service(key) + roles = [] + for t in service.get_role_types(): + for r in service.get_roles_by_type(t): + roles.append(r.name) + + self.CACHED_ROLES[key] = roles + + if not role: + return self.CACHED_ROLES[key] + else: + return [r for r in self.CACHED_ROLES[key] if r.startswith(line.split()[1])] + + def do_start_role(self, role): + """ + Start a role + Usage: + > start_role Restarts this role + """ + if not role: + return None + + if not self.has_cluster(): + return None + + if '-' not in role: + print("Please enter a valid role name") + return None + + try: + service = api.get_cluster(self.cluster).get_service(role.split('-')[0]) + service.start_roles(role) + print("Starting Role") + except ApiException: + print("Error: Role or Service Not Found") + + def complete_start_role(self, text, line, start_index, end_index): + return self.roles_autocomplete(text, line, start_index, end_index) + + def do_restart_role(self, role): + """ + Restart a role + Usage: + > restart_role Restarts this role + """ + if not role: + return None + + if not self.has_cluster(): + return None + + if '-' not in role: + print("Please enter a valid role name") + return None + + try: + service = api.get_cluster(self.cluster).get_service(role.split('-')[0]) + service.restart_roles(role) + print("Restarting Role") + except ApiException: + print("Error: Role or Service Not Found") + + def complete_restart_role(self, text, line, start_index, end_index): + return self.roles_autocomplete(text, line, start_index, end_index) + + def do_stop_role(self, role): + """ + Stop a role + Usage: + > stop_role Stops this role + """ + if not role: + return None + + if not self.has_cluster(): + return None + + if '-' not in role: + print("Please enter a valid role name") + return None + + try: + service = api.get_cluster(self.cluster).get_service(role.split('-')[0]) + service.stop_roles(role) + print("Stopping Role") + except ApiException: + print("Error: Role or Service Not Found") + + def complete_stop_role(self, text, line, start_index, end_index): + return self.roles_autocomplete(text, line, start_index, end_index) + + def do_stop_cluster(self, cluster): + """ + Completely stop the cluster + Usage: + > stop_cluster + """ + try: + cluster = api.get_cluster(cluster) + cluster.stop() + print("Stopping Cluster") + except ApiException: + print("Cluster not found") + return None + + def complete_stop_cluster(self, text, line, start_index, end_index): + return self.cluster_autocomplete(text, line, start_index, end_index) + + def do_start_cluster(self, cluster): + """ + Start the cluster + Usage: + > start_cluster + """ + try: + cluster = api.get_cluster(cluster) + cluster.start() + print("Starting Cluster") + except ApiException: + print("Cluster not found") + return None + + def complete_start_cluster(self, text, line, start_index, end_index): + return self.cluster_autocomplete(text, line, start_index, end_index) + + def do_version(self, cluster=None): + """ + Obtain cluster CDH version + Usage: + > version + or + > version + """ + if not cluster: + if not self.has_cluster(): + return None + else: + cluster = api.get_cluster(self.cluster) + else: + try: + cluster = api.get_cluster(cluster) + except ApiException: + print("Error: Cluster not found") + return None + + print("Version: %s" % (cluster.version)) + + def complete_version(self, text, line, start_index, end_index): + return self.cluster_autocomplete(text, line, start_index, end_index) + + def complete_status(self, text, line, start_index, end_index): + return self.services_autocomplete(text, line, start_index, end_index) + + +def main(): + parser = argparse.ArgumentParser(description='Cloudera Manager Shell') + parser.add_argument('-H', '--host', '--hostname', action='store', dest='hostname', required=True) + parser.add_argument('-p', '--port', action='store', dest='port', type=int, default=7180) + parser.add_argument('-u', '--user', '--username', action='store', dest='username') + parser.add_argument('-c', '--cluster', action='store', dest='cluster') + parser.add_argument('--password', action='store', dest='password') + parser.add_argument('-e', '--execute', action='store', dest='execute') + parser.add_argument('-s', '--seperator', action='store', dest='seperator') + args = parser.parse_args() + + # Check if a username was suplied, if not, prompt the user + if not args.username: + args.username = raw_input("Enter Username: ") + + # Check if the password was supplied, if not, prompt the user + if not args.password: + args.password = getpass.getpass("Enter Password: ") + + # Attempt to authenticate using the API + global api + api = ApiResource(args.hostname, args.port, args.username, args.password) + try: + api.echo("ping") + except ApiException: + try: + api = ApiResource(args.hostname, args.port, args.username, args.password, version=1) + api.echo("ping") + except ApiException: + print("Unable to Authenticate") + sys.exit(1) + except URLError: + print("Error: Could not connect to %s" % (args.hostname)) + sys.exit(1) + + CONFIG['cluster'] = args.cluster + + # Check if a custom seperator was supplied for the output + if args.seperator: + CONFIG['output_type'] = 'custom' + CONFIG['seperator'] = args.seperator + + # Check if user is attempting non-interactive shell + if args.execute: + EXECUTE = True + shell = ClouderaShell() + for command in args.execute.split(';'): + shell.onecmd(command) + sys.exit(0) + + try: + ClouderaShell().cmdloop() + except KeyboardInterrupt: + sys.stdout.write("\n") + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/python/src/cm_shell/prettytable.py b/python/src/cm_shell/prettytable.py new file mode 100644 index 00000000..30d72b5c --- /dev/null +++ b/python/src/cm_shell/prettytable.py @@ -0,0 +1,1068 @@ +#!/usr/bin/env python +# +# Copyright (c) 2009, Luke Maurits +# All rights reserved. +# With contributions from: +# * Chris Clark +# * Klein Stephane +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * The name of the author may not be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +__version__ = "0.6" + +import sys +import copy +import random +import textwrap + +py3k = sys.version_info[0] >= 3 +if py3k: + unicode = str + basestring = str + from html import escape +else: + from cgi import escape + +# hrule styles +FRAME = 0 +ALL = 1 +NONE = 2 + +# Table styles +DEFAULT = 10 +MSWORD_FRIENDLY = 11 +PLAIN_COLUMNS = 12 +RANDOM = 20 + +def _get_size(text): + max_width = 0 + max_height = 0 + text = _unicode(text) + for line in text.split("\n"): + max_height += 1 + if len(line) > max_width: + max_width = len(line) + + return (max_width, max_height) + +def _unicode(value, encoding="UTF-8"): + if not isinstance(value, basestring): + value = str(value) + if not isinstance(value, unicode): + value = unicode(value, encoding, "replace") + return value + +class PrettyTable(object): + + def __init__(self, field_names=None, **kwargs): + + """Return a new PrettyTable instance + + Arguments: + + field_names - list or tuple of field names + fields - list or tuple of field names to include in displays + start - index of first data row to include in output + end - index of last data row to include in output PLUS ONE (list slice style) + fields - names of fields (columns) to include + header - print a header showing field names (True or False) + border - print a border around the table (True or False) + hrules - controls printing of horizontal rules after rows. Allowed values: FRAME, ALL, NONE + int_format - controls formatting of integer data + float_format - controls formatting of floating point data + padding_width - number of spaces on either side of column data (only used if left and right paddings are None) + left_padding_width - number of spaces on left hand side of column data + right_padding_width - number of spaces on right hand side of column data + vertical_char - single character string used to draw vertical lines + horizontal_char - single character string used to draw horizontal lines + junction_char - single character string used to draw line junctions + sortby - name of field to sort rows by + sort_key - sorting key function, applied to data points before sorting + reversesort - True or False to sort in descending or ascending order""" + + # Data + self._field_names = [] + self._align = {} + self._max_width = {} + self._rows = [] + if field_names: + self.field_names = field_names + else: + self._widths = [] + self._rows = [] + + # Options + self._options = "start end fields header border sortby reversesort sort_key attributes format hrules".split() + self._options.extend("int_format float_format padding_width left_padding_width right_padding_width".split()) + self._options.extend("vertical_char horizontal_char junction_char".split()) + for option in self._options: + if option in kwargs: + self._validate_option(option, kwargs[option]) + else: + kwargs[option] = None + + + self._start = kwargs["start"] or 0 + self._end = kwargs["end"] or None + self._fields = kwargs["fields"] or None + + self._header = kwargs["header"] or True + self._border = kwargs["border"] or True + self._hrules = kwargs["hrules"] or FRAME + + self._sortby = kwargs["sortby"] or None + self._reversesort = kwargs["reversesort"] or False + self._sort_key = kwargs["sort_key"] or (lambda x: x) + + self._int_format = kwargs["float_format"] or {} + self._float_format = kwargs["float_format"] or {} + self._padding_width = kwargs["padding_width"] or 1 + self._left_padding_width = kwargs["left_padding_width"] or None + self._right_padding_width = kwargs["right_padding_width"] or None + + self._vertical_char = kwargs["vertical_char"] or "|" + self._horizontal_char = kwargs["horizontal_char"] or "-" + self._junction_char = kwargs["junction_char"] or "+" + + self._format = kwargs["format"] or False + self._attributes = kwargs["attributes"] or {} + + def __getattr__(self, name): + + if name == "rowcount": + return len(self._rows) + elif name == "colcount": + if self._field_names: + return len(self._field_names) + elif self._rows: + return len(self._rows[0]) + else: + return 0 + else: + raise AttributeError(name) + + def __getitem__(self, index): + + newtable = copy.deepcopy(self) + if isinstance(index, slice): + newtable._rows = self._rows[index] + elif isinstance(index, int): + newtable._rows = [self._rows[index],] + else: + raise Exception("Index %s is invalid, must be an integer or slice" % str(index)) + return newtable + + def __str__(self): + if py3k: + return self.get_string() + else: + return self.get_string().encode("ascii","replace") + + def __unicode__(self): + return self.get_string() + + ############################## + # ATTRIBUTE VALIDATORS # + ############################## + + # The method _validate_option is all that should be used elsewhere in the code base to validate options. + # It will call the appropriate validation method for that option. The individual validation methods should + # never need to be called directly (although nothing bad will happen if they *are*). + # Validation happens in TWO places. + # Firstly, in the property setters defined in the ATTRIBUTE MANAGMENT section. + # Secondly, in the _get_options method, where keyword arguments are mixed with persistent settings + + def _validate_option(self, option, val): + if option in ("start", "end", "padding_width", "left_padding_width", "right_padding_width", "format"): + self._validate_nonnegative_int(option, val) + elif option in ("sortby"): + self._validate_field_name(option, val) + elif option in ("sort_key"): + self._validate_function(option, val) + elif option in ("hrules"): + self._validate_hrules(option, val) + elif option in ("fields"): + self._validate_all_field_names(option, val) + elif option in ("header", "border", "reversesort"): + self._validate_true_or_false(option, val) + elif option in ("int_format"): + self._validate_int_format(option, val) + elif option in ("float_format"): + self._validate_float_format(option, val) + elif option in ("vertical_char", "horizontal_char", "junction_char"): + self._validate_single_char(option, val) + elif option in ("attributes"): + self._validate_attributes(option, val) + else: + raise Exception("Unrecognised option: %s!" % option) + + def _validate_align(self, val): + try: + assert val in ["l","c","r"] + except AssertionError: + raise Exception("Alignment %s is invalid, use l, c or r!" % val) + + def _validate_nonnegative_int(self, name, val): + try: + assert int(val) >= 0 + except AssertionError: + raise Exception("Invalid value for %s: %s!" % (name, _unicode(val))) + + def _validate_true_or_false(self, name, val): + try: + assert val in (True, False) + except AssertionError: + raise Exception("Invalid value for %s! Must be True or False." % name) + + def _validate_int_format(self, name, val): + if val == "": + return + try: + assert type(val) in (str, unicode) + assert val.isdigit() + except AssertionError: + raise Exception("Invalid value for %s! Must be an integer format string." % name) + + def _validate_float_format(self, name, val): + if val == "": + return + try: + assert type(val) in (str, unicode) + assert "." in val + bits = val.split(".") + assert len(bits) <= 2 + assert bits[0] == "" or bits[0].isdigit() + assert bits[1] == "" or bits[1].isdigit() + except AssertionError: + raise Exception("Invalid value for %s! Must be a float format string." % name) + + def _validate_function(self, name, val): + try: + assert hasattr(val, "__call__") + except AssertionError: + raise Exception("Invalid value for %s! Must be a function." % name) + + def _validate_hrules(self, name, val): + try: + assert val in (ALL, FRAME, NONE) + except AssertionError: + raise Exception("Invalid value for %s! Must be ALL, FRAME or NONE." % name) + + def _validate_field_name(self, name, val): + try: + assert val in self._field_names + except AssertionError: + raise Exception("Invalid field name: %s!" % val) + + def _validate_all_field_names(self, name, val): + try: + for x in val: + self._validate_field_name(name, x) + except AssertionError: + raise Exception("fields must be a sequence of field names!") + + def _validate_single_char(self, name, val): + try: + assert len(_unicode(val)) == 1 + except AssertionError: + raise Exception("Invalid value for %s! Must be a string of length 1." % name) + + def _validate_attributes(self, name, val): + try: + assert isinstance(val, dict) + except AssertionError: + raise Exception("attributes must be a dictionary of name/value pairs!") + + ############################## + # ATTRIBUTE MANAGEMENT # + ############################## + + def _get_field_names(self): + return self._field_names + """The names of the fields + + Arguments: + + fields - list or tuple of field names""" + def _set_field_names(self, val): + if self._field_names: + old_names = self._field_names[:] + self._field_names = val + if self._align and old_names: + for old_name, new_name in zip(old_names, val): + self._align[new_name] = self._align[old_name] + for old_name in old_names: + self._align.pop(old_name) + else: + for field in self._field_names: + self._align[field] = "c" + field_names = property(_get_field_names, _set_field_names) + + def _get_align(self): + return self._align + def _set_align(self, val): + self._validate_align(val) + for field in self._field_names: + self._align[field] = val + align = property(_get_align, _set_align) + + def _get_max_width(self): + return self._max_width + def _set_max_width(self, val): + self._validate_nonnegativeint(val) + for field in self._field_names: + self._max_width[field] = val + max_width = property(_get_max_width, _set_max_width) + + def _get_start(self): + """Start index of the range of rows to print + + Arguments: + + start - index of first data row to include in output""" + return self._start + + def _set_start(self, val): + self._validate_option("start", val) + self._start = val + start = property(_get_start, _set_start) + + def _get_end(self): + """End index of the range of rows to print + + Arguments: + + end - index of last data row to include in output PLUS ONE (list slice style)""" + return self._end + def _set_end(self, val): + self._validate_option("end", val) + self._end = val + end = property(_get_end, _set_end) + + def _get_sortby(self): + """Name of field by which to sort rows + + Arguments: + + sortby - field name to sort by""" + return self._sortby + def _set_sortby(self, val): + self._validate_option("sortby", val) + self._sortby = val + sortby = property(_get_sortby, _set_sortby) + + def _get_reversesort(self): + """Controls direction of sorting (ascending vs descending) + + Arguments: + + reveresort - set to True to sort by descending order, or False to sort by ascending order""" + return self._reversesort + def _set_reversesort(self, val): + self._validate_option("reversesort", val) + self._reversesort = val + reversesort = property(_get_reversesort, _set_reversesort) + + def _get_sort_key(self): + """Sorting key function, applied to data points before sorting + + Arguments: + + sort_key - a function which takes one argument and returns something to be sorted""" + return self._sort_key + def _set_sort_key(self, val): + self._validate_option("sort_key", val) + self._sort_key = val + sort_key = property(_get_sort_key, _set_sort_key) + + def _get_header(self): + """Controls printing of table header with field names + + Arguments: + + header - print a header showing field names (True or False)""" + return self._header + def _set_header(self, val): + self._validate_option("header", val) + self._header = val + header = property(_get_header, _set_header) + + def _get_border(self): + """Controls printing of border around table + + Arguments: + + border - print a border around the table (True or False)""" + return self._border + def _set_border(self, val): + self._validate_option("border", val) + self._border = val + border = property(_get_border, _set_border) + + def _get_hrules(self): + """Controls printing of horizontal rules after rows + + Arguments: + + hrules - horizontal rules style. Allowed values: FRAME, ALL, NONE""" + return self._hrules + def _set_hrules(self, val): + self._validate_option("hrules", val) + self._hrules = val + hrules = property(_get_hrules, _set_hrules) + + def _get_int_format(self): + """Controls formatting of integer data + Arguments: + + int_format - integer format string""" + return self._int_format + def _set_int_format(self, val): + self._validate_option("int_format", val) + for field in self._field_names: + self._int_format[field] = val + int_format = property(_get_int_format, _set_int_format) + + def _get_float_format(self): + """Controls formatting of floating point data + Arguments: + + float_format - floating point format string""" + return self._float_format + def _set_float_format(self, val): + self._validate_option("float_format", val) + for field in self._field_names: + self._float_format[field] = val + float_format = property(_get_float_format, _set_float_format) + + def _get_padding_width(self): + """The number of empty spaces between a column's edge and its content + + Arguments: + + padding_width - number of spaces, must be a positive integer""" + return self._padding_width + def _set_padding_width(self, val): + self._validate_option("padding_width", val) + self._padding_width = val + padding_width = property(_get_padding_width, _set_padding_width) + + def _get_left_padding_width(self): + """The number of empty spaces between a column's left edge and its content + + Arguments: + + left_padding - number of spaces, must be a positive integer""" + return self._left_padding_width + def _set_left_padding_width(self, val): + self._validate_option("left_padding_width", val) + self._left_padding_width = val + left_padding_width = property(_get_left_padding_width, _set_left_padding_width) + + def _get_right_padding_width(self): + """The number of empty spaces between a column's right edge and its content + + Arguments: + + right_padding - number of spaces, must be a positive integer""" + return self._right_padding_width + def _set_right_padding_width(self, val): + self._validate_option("right_padding_width", val) + self._right_padding_width = val + right_padding_width = property(_get_right_padding_width, _set_right_padding_width) + + def _get_vertical_char(self): + """The charcter used when printing table borders to draw vertical lines + + Arguments: + + vertical_char - single character string used to draw vertical lines""" + return self._vertical_char + def _set_vertical_char(self, val): + self._validate_option("vertical_char", val) + self._vertical_char = val + vertical_char = property(_get_vertical_char, _set_vertical_char) + + def _get_horizontal_char(self): + """The charcter used when printing table borders to draw horizontal lines + + Arguments: + + horizontal_char - single character string used to draw horizontal lines""" + return self._horizontal_char + def _set_horizontal_char(self, val): + self._validate_option("horizontal_char", val) + self._horizontal_char = val + horizontal_char = property(_get_horizontal_char, _set_horizontal_char) + + def _get_junction_char(self): + """The charcter used when printing table borders to draw line junctions + + Arguments: + + junction_char - single character string used to draw line junctions""" + return self._junction_char + def _set_junction_char(self, val): + self._validate_option("vertical_char", val) + self._junction_char = val + junction_char = property(_get_junction_char, _set_junction_char) + + def _get_format(self): + """Controls whether or not HTML tables are formatted to match styling options + + Arguments: + + format - True or False""" + return self._format + def _set_format(self, val): + self._validate_option("format", val) + self._format = val + format = property(_get_format, _set_format) + + def _get_attributes(self): + """A dictionary of HTML attribute name/value pairs to be included in the tag when printing HTML + + Arguments: + + attributes - dictionary of attributes""" + return self._attributes + def _set_attributes(self, val): + self.validate_option("attributes", val) + self._attributes = val + attributes = property(_get_attributes, _set_attributes) + + ############################## + # OPTION MIXER # + ############################## + + def _get_options(self, kwargs): + + options = {} + for option in self._options: + if option in kwargs: + self._validate_option(option, kwargs[option]) + options[option] = kwargs[option] + else: + options[option] = getattr(self, "_"+option) + return options + + ############################## + # PRESET STYLE LOGIC # + ############################## + + def set_style(self, style): + + if style == DEFAULT: + self._set_default_style() + elif style == MSWORD_FRIENDLY: + self._set_msword_style() + elif style == PLAIN_COLUMNS: + self._set_columns_style() + elif style == RANDOM: + self._set_random_style() + else: + raise Exception("Invalid pre-set style!") + + def _set_default_style(self): + + self.header = True + self.border = True + self._hrules = FRAME + self.padding_width = 1 + self.left_padding_width = 1 + self.right_padding_width = 1 + self.vertical_char = "|" + self.horizontal_char = "-" + self.junction_char = "+" + + def _set_msword_style(self): + + self.header = True + self.border = True + self._hrules = NONE + self.padding_width = 1 + self.left_padding_width = 1 + self.right_padding_width = 1 + self.vertical_char = "|" + + def _set_columns_style(self): + + self.header = True + self.border = False + self.padding_width = 1 + self.left_padding_width = 0 + self.right_padding_width = 8 + + def _set_random_style(self): + + # Just for fun! + self.header = random.choice((True, False)) + self.border = random.choice((True, False)) + self._hrules = random.choice((ALL, FRAME, NONE)) + self.left_padding_width = random.randint(0,5) + self.right_padding_width = random.randint(0,5) + self.vertical_char = random.choice("~!@#$%^&*()_+|-=\{}[];':\",./;<>?") + self.horizontal_char = random.choice("~!@#$%^&*()_+|-=\{}[];':\",./;<>?") + self.junction_char = random.choice("~!@#$%^&*()_+|-=\{}[];':\",./;<>?") + + ############################## + # DATA INPUT METHODS # + ############################## + + def add_row(self, row): + + """Add a row to the table + + Arguments: + + row - row of data, should be a list with as many elements as the table + has fields""" + + if self._field_names and len(row) != len(self._field_names): + raise Exception("Row has incorrect number of values, (actual) %d!=%d (expected)" %(len(row),len(self._field_names))) + self._rows.append(list(row)) + + def del_row(self, row_index): + + """Delete a row to the table + + Arguments: + + row_index - The index of the row you want to delete. Indexing starts at 0.""" + + if row_index > len(self._rows)-1: + raise Exception("Cant delete row at index %d, table only has %d rows!" % (row_index, len(self._rows))) + del self._rows[row_index] + + def add_column(self, fieldname, column, align="c"): + + """Add a column to the table. + + Arguments: + + fieldname - name of the field to contain the new column of data + column - column of data, should be a list with as many elements as the + table has rows + align - desired alignment for this column - "l" for left, "c" for centre and "r" for right""" + + if len(self._rows) in (0, len(column)): + self._validate_align(align) + self._field_names.append(fieldname) + self._align[fieldname] = align + for i in range(0, len(column)): + if len(self._rows) < i+1: + self._rows.append([]) + self._rows[i].append(column[i]) + else: + raise Exception("Column length %d does not match number of rows %d!" % (len(column), len(self._rows))) + + def clear_rows(self): + + """Delete all rows from the table but keep the current field names""" + + self._rows = [] + + def clear(self): + + """Delete all rows and field names from the table, maintaining nothing but styling options""" + + self._rows = [] + self._field_names = [] + self._widths = [] + + ############################## + # MISC PUBLIC METHODS # + ############################## + + def copy(self): + return copy.deepcopy(self) + + ############################## + # MISC PRIVATE METHODS # + ############################## + + def _format_value(self, field, value): + if isinstance(value, int) and field in self._int_format: + value = ("%%%sd" % self._int_format[field]) % value + elif isinstance(value, float) and field in self._float_format: + value = ("%%%sf" % self._float_format[field]) % value + return value + + def _compute_widths(self, rows, options): + if options["header"]: + widths = [_get_size(field)[0] for field in self._field_names] + else: + widths = len(self.field_names) * [0] + for row in rows: + for index, value in enumerate(row): + value = self._format_value(self.field_names[index], value) + widths[index] = max(widths[index], _get_size(_unicode(value))[0]) + self._widths = widths + + def _get_padding_widths(self, options): + + if options["left_padding_width"] is not None: + lpad = options["left_padding_width"] + else: + lpad = options["padding_width"] + if options["right_padding_width"] is not None: + rpad = options["right_padding_width"] + else: + rpad = options["padding_width"] + return lpad, rpad + + def _get_rows(self, options): + """Return only those data rows that should be printed, based on slicing and sorting. + + Arguments: + + options - dictionary of option settings.""" + + # Make a copy of only those rows in the slice range + rows = copy.deepcopy(self._rows[options["start"]:options["end"]]) + # Sort if necessary + if options["sortby"]: + sortindex = self._field_names.index(options["sortby"]) + # Decorate + rows = [[row[sortindex]]+row for row in rows] + # Sort + rows.sort(reverse=options["reversesort"], key=options["sort_key"]) + # Undecorate + rows = [row[1:] for row in rows] + return rows + + ############################## + # PLAIN TEXT STRING METHODS # + ############################## + + def get_string(self, **kwargs): + + """Return string representation of table in current state. + + Arguments: + + start - index of first data row to include in output + end - index of last data row to include in output PLUS ONE (list slice style) + fields - names of fields (columns) to include + header - print a header showing field names (True or False) + border - print a border around the table (True or False) + hrules - controls printing of horizontal rules after rows. Allowed values: FRAME, ALL, NONE + int_format - controls formatting of integer data + float_format - controls formatting of floating point data + padding_width - number of spaces on either side of column data (only used if left and right paddings are None) + left_padding_width - number of spaces on left hand side of column data + right_padding_width - number of spaces on right hand side of column data + vertical_char - single character string used to draw vertical lines + horizontal_char - single character string used to draw horizontal lines + junction_char - single character string used to draw line junctions + sortby - name of field to sort rows by + sort_key - sorting key function, applied to data points before sorting + reversesort - True or False to sort in descending or ascending order""" + + options = self._get_options(kwargs) + + bits = [] + + # Don't think too hard about an empty table + if self.rowcount == 0: + return "" + + rows = self._get_rows(options) + self._compute_widths(rows, options) + + # Build rows + # (for now, this is done before building headers etc. because rowbits.append + # contains width-adjusting voodoo which has to be done first. This is ugly + # and Wrong and will change soon) + rowbits = [] + for row in rows: + rowbits.append(self._stringify_row(row, options)) + + + # Add header or top of border + if options["header"]: + bits.append(self._stringify_header(options)) + elif options["border"] and options["hrules"] != NONE: + bits.append(self._hrule) + + # Add rows + bits.extend(rowbits) + + # Add bottom of border + if options["border"] and not options["hrules"]: + bits.append(self._hrule) + + string = "\n".join(bits) + self._nonunicode = string + return _unicode(string) + + def _stringify_hrule(self, options): + + if not options["border"]: + return "" + lpad, rpad = self._get_padding_widths(options) + bits = [options["junction_char"]] + for field, width in zip(self._field_names, self._widths): + if options["fields"] and field not in options["fields"]: + continue + bits.append((width+lpad+rpad)*options["horizontal_char"]) + bits.append(options["junction_char"]) + return "".join(bits) + + def _stringify_header(self, options): + + bits = [] + lpad, rpad = self._get_padding_widths(options) + if options["border"]: + if options["hrules"] != NONE: + bits.append(self._hrule) + bits.append("\n") + bits.append(options["vertical_char"]) + for field, width, in zip(self._field_names, self._widths): + if options["fields"] and field not in options["fields"]: + continue + if self._align[field] == "l": + bits.append(" " * lpad + _unicode(field).ljust(width) + " " * rpad) + elif self._align[field] == "r": + bits.append(" " * lpad + _unicode(field).rjust(width) + " " * rpad) + else: + bits.append(" " * lpad + _unicode(field).center(width) + " " * rpad) + if options["border"]: + bits.append(options["vertical_char"]) + if options["border"] and options["hrules"] != NONE: + bits.append("\n") + bits.append(self._hrule) + return "".join(bits) + + def _stringify_row(self, row, options): + + for index, value in enumerate(row): + row[index] = self._format_value(self.field_names[index], value) + + for index, field, value, width, in zip(range(0,len(row)), self._field_names, row, self._widths): + # Enforce max widths + max_width = self._max_width.get(field, 0) + lines = _unicode(value).split("\n") + new_lines = [] + for line in lines: + if max_width and len(line) > max_width: + line = textwrap.fill(line, max_width) + new_lines.append(line) + lines = new_lines + value = "\n".join(lines) + row[index] = value + + #old_widths = self._widths[:] + + for index, field in enumerate(self._field_names): + namewidth = len(field) + datawidth = min(self._widths[index], self._max_width.get(field, self._widths[index])) + if options["header"]: + self._widths[index] = max(namewidth, datawidth) + else: + self._widths[index] = datawidth + + row_height = 0 + for c in row: + h = _get_size(c)[1] + if h > row_height: + row_height = h + + bits = [] + lpad, rpad = self._get_padding_widths(options) + for y in range(0, row_height): + bits.append([]) + if options["border"]: + bits[y].append(self.vertical_char) + + for field, value, width, in zip(self._field_names, row, self._widths): + + lines = _unicode(value).split("\n") + if len(lines) < row_height: + lines = lines + ([""] * (row_height-len(lines))) + + y = 0 + for l in lines: + if options["fields"] and field not in options["fields"]: + continue + + if self._align[field] == "l": + bits[y].append(" " * lpad + _unicode(l).ljust(width) + " " * rpad) + elif self._align[field] == "r": + bits[y].append(" " * lpad + _unicode(l).rjust(width) + " " * rpad) + else: + bits[y].append(" " * lpad + _unicode(l).center(width) + " " * rpad) + if options["border"]: + bits[y].append(self.vertical_char) + + y += 1 + + self._hrule = self._stringify_hrule(options) + + if options["border"] and options["hrules"]== ALL: + bits[row_height-1].append("\n") + bits[row_height-1].append(self._hrule) + + for y in range(0, row_height): + bits[y] = "".join(bits[y]) + + #self._widths = old_widths + + return "\n".join(bits) + + ############################## + # HTML STRING METHODS # + ############################## + + def get_html_string(self, **kwargs): + + """Return string representation of HTML formatted version of table in current state. + + Arguments: + + start - index of first data row to include in output + end - index of last data row to include in output PLUS ONE (list slice style) + fields - names of fields (columns) to include + header - print a header showing field names (True or False) + border - print a border around the table (True or False) + hrules - controls printing of horizontal rules after rows. Allowed values: FRAME, ALL, NONE + int_format - controls formatting of integer data + float_format - controls formatting of floating point data + padding_width - number of spaces on either side of column data (only used if left and right paddings are None) + left_padding_width - number of spaces on left hand side of column data + right_padding_width - number of spaces on right hand side of column data + sortby - name of field to sort rows by + sort_key - sorting key function, applied to data points before sorting + attributes - dictionary of name/value pairs to include as HTML attributes in the
tag""" + + options = self._get_options(kwargs) + + if options["format"]: + string = self._get_formatted_html_string(options) + else: + string = self._get_simple_html_string(options) + + self._nonunicode = string + return _unicode(string) + + def _get_simple_html_string(self, options): + + bits = [] + # Slow but works + table_tag = '") + for field in self._field_names: + if options["fields"] and field not in options["fields"]: + continue + bits.append(" " % escape(_unicode(field)).replace("\n", "
")) + bits.append(" ") + + # Data + rows = self._get_rows(options) + for row in rows: + bits.append(" ") + for field, datum in zip(self._field_names, row): + if options["fields"] and field not in options["fields"]: + continue + bits.append(" " % escape(_unicode(datum)).replace("\n", "
")) + bits.append(" ") + + bits.append("
%s
%s
") + string = "\n".join(bits) + + self._nonunicode = string + return _unicode(string) + + def _get_formatted_html_string(self, options): + + bits = [] + lpad, rpad = self._get_padding_widths(options) + # Slow but works + table_tag = '") + for field in self._field_names: + if options["fields"] and field not in options["fields"]: + continue + bits.append(" %s" % (lpad, rpad, escape(_unicode(field)).replace("\n", "
"))) + bits.append(" ") + # Data + rows = self._get_rows(options) + for row in self._rows: + bits.append(" ") + for field, datum in zip(self._field_names, row): + if options["fields"] and field not in options["fields"]: + continue + if self._align[field] == "l": + bits.append(" %s" % (lpad, rpad, escape(_unicode(datum)).replace("\n", "
"))) + elif self._align[field] == "r": + bits.append(" %s" % (lpad, rpad, escape(_unicode(datum)).replace("\n", "
"))) + else: + bits.append(" %s" % (lpad, rpad, escape(_unicode(datum)).replace("\n", "
"))) + bits.append(" ") + bits.append("") + string = "\n".join(bits) + + self._nonunicode = string + return _unicode(string) + +def main(): + + x = PrettyTable(["City name", "Area", "Population", "Annual Rainfall"]) + x.sortby = "Population" + x.reversesort = True + x.int_format["Area"] = "04" + x.float_format = "6.1" + x.align["City name"] = "l" # Left align city names + x.add_row(["Adelaide", 1295, 1158259, 600.5]) + x.add_row(["Brisbane", 5905, 1857594, 1146.4]) + x.add_row(["Darwin", 112, 120900, 1714.7]) + x.add_row(["Hobart", 1357, 205556, 619.5]) + x.add_row(["Sydney", 2058, 4336374, 1214.8]) + x.add_row(["Melbourne", 1566, 3806092, 646.9]) + x.add_row(["Perth", 5386, 1554769, 869.4]) + print(x) + +if __name__ == "__main__": + main()