From dc59f00b95cc209f78420ed7b563875f116f54c0 Mon Sep 17 00:00:00 2001 From: Matthieu Huin Date: Thu, 29 Apr 2021 23:49:46 +0200 Subject: [PATCH] Add output formatters when relevant Add the --format option. 'JSON' and 'text' (ie pretty table output) are supported. Change-Id: Iac49b254fb7a01c7d3c68f84ead1fe3d59127c2e --- requirements.txt | 1 + tests/unit/test_cmd.py | 6 +- zuulclient/cmd/__init__.py | 81 +++-------- zuulclient/utils/formatters.py | 258 +++++++++++++++++++++++++++++++++ 4 files changed, 288 insertions(+), 58 deletions(-) create mode 100644 zuulclient/utils/formatters.py diff --git a/requirements.txt b/requirements.txt index 69b95ea..3b3718d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ pbr>=1.1.0 +python-dateutil>=2.7.0 requests urllib3!=1.25.4,!=1.25.5 # https://github.com/urllib3/urllib3/pull/1684 PrettyTable diff --git a/tests/unit/test_cmd.py b/tests/unit/test_cmd.py index 70ecd85..331a03e 100644 --- a/tests/unit/test_cmd.py +++ b/tests/unit/test_cmd.py @@ -301,7 +301,11 @@ verify_ssl=True""" 'node_expiration': 0, 'expired': 0, 'reason': 'some_reason', - 'nodes': ['node1', 'node2']}) + 'nodes': [{'build': 'alalala', + 'nodes': ['node1', + 'node2']} + ], + }) session.get = MagicMock( side_effect=mock_get(rv) diff --git a/zuulclient/cmd/__init__.py b/zuulclient/cmd/__init__.py index 60664c5..3714cf1 100644 --- a/zuulclient/cmd/__init__.py +++ b/zuulclient/cmd/__init__.py @@ -16,16 +16,15 @@ import argparse import configparser import logging import os -import prettytable import shutil import sys import tempfile import textwrap -import time from zuulclient.api import ZuulRESTClient from zuulclient.utils import get_default from zuulclient.utils import encrypt_with_openssl +from zuulclient.utils import formatters class ArgumentException(Exception): @@ -76,6 +75,9 @@ class ZuulClient(): action='store_false', help='Do not verify SSL connection to Zuul ' '(Defaults to False)') + parser.add_argument('--format', choices=['JSON', 'text'], + default='text', required=False, + help='The output format, when applicable') self.createCommandParsers(parser) return parser @@ -121,6 +123,15 @@ class ZuulClient(): raise ArgumentException( "The 'change' and 'ref' arguments are mutually exclusive.") + @property + def formatter(self): + if self.args.format == 'JSON': + return formatters.JSONFormatter + elif self.args.format == 'text': + return formatters.PrettyTableFormatter + else: + raise Exception('Unsupported formatter: %s' % self.args.format) + def readConfig(self): safe_env = { k: v for k, v in os.environ.items() @@ -270,17 +281,8 @@ class ZuulClient(): print("Autohold request not found") return False - print("ID: %s" % request['id']) - print("Tenant: %s" % request['tenant']) - print("Project: %s" % request['project']) - print("Job: %s" % request['job']) - print("Ref Filter: %s" % request['ref_filter']) - print("Max Count: %s" % request['max_count']) - print("Current Count: %s" % request['current_count']) - print("Node Expiration: %s" % request['node_expiration']) - print("Request Expiration: %s" % time.ctime(request['expired'])) - print("Reason: %s" % request['reason']) - print("Held Nodes: %s" % request['nodes']) + formatted_result = self.formatter('AutoholdQuery')(request) + print(formatted_result) return True @@ -295,30 +297,15 @@ class ZuulClient(): def autohold_list(self): client = self.get_client() self._check_tenant_scope(client) - autohold_requests = client.autohold_list(tenant=self.tenant()) + requests = client.autohold_list(tenant=self.tenant()) - if not autohold_requests: + if not requests: print("No autohold requests found") return True - table = prettytable.PrettyTable( - field_names=[ - 'ID', 'Tenant', 'Project', 'Job', 'Ref Filter', - 'Max Count', 'Reason' - ]) + formatted_result = self.formatter('AutoholdQueries')(requests) + print(formatted_result) - for request in autohold_requests: - table.add_row([ - request['id'], - request['tenant'], - request['project'], - request['job'], - request['ref_filter'], - request['max_count'], - request['reason'], - ]) - - print(table) return True def add_enqueue_subparser(self, subparsers): @@ -676,31 +663,11 @@ class ZuulClient(): if self.args.held: filters['held'] = True client = self.get_client() - builds = client.builds(tenant=self.tenant(), **filters) - table = prettytable.PrettyTable( - field_names=[ - 'ID', 'Job', 'Project', 'Branch', 'Pipeline', 'Change or Ref', - 'Duration (s)', 'Start time', 'Result', 'Event ID' - ] - ) - for build in builds: - if build['change'] and build['patchset']: - change = str(build['change']) + ',' + str(build['patchset']) - else: - change = build['ref'] - table.add_row([ - build.get('uuid') or 'N/A', - build['job_name'], - build['project'], - build['branch'], - build['pipeline'], - change, - build['duration'], - build['start_time'], - build['result'], - build.get('event_id') or 'N/A' - ]) - print(table) + request = client.builds(tenant=self.tenant(), **filters) + + formatted_result = self.formatter('Builds')(request) + print(formatted_result) + return True diff --git a/zuulclient/utils/formatters.py b/zuulclient/utils/formatters.py new file mode 100644 index 0000000..7d5ac9f --- /dev/null +++ b/zuulclient/utils/formatters.py @@ -0,0 +1,258 @@ +# Copyright 2021 Red Hat, inc +# +# Licensed 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 time +from dateutil.parser import isoparse + +import prettytable +import json + + +class BaseFormatter: + + def __init__(self, data_type): + self.data_type = data_type + + def __call__(self, data): + """Format data according to the type of data being displayed.""" + try: + return getattr(self, 'format' + self.data_type)(data) + except Exception: + raise Exception('Unsupported data type "%s"' % self.data_type) + + def formatBuildNodes(self, data): + raise NotImplementedError + + def formatAutoholdQueries(self, data): + raise NotImplementedError + + def formatAutoholdQuery(self, data): + raise NotImplementedError + + def formatJobResource(self, data): + raise NotImplementedError + + def formatArtifacts(self, data): + raise NotImplementedError + + def formatBuild(self, data): + raise NotImplementedError + + def formatBuildSet(self, data): + raise NotImplementedError + + def formatBuilds(self, data): + raise NotImplementedError + + def formatBuildSets(self, data): + raise NotImplementedError + + +class JSONFormatter(BaseFormatter): + def __call__(self, data) -> str: + # Simply format the raw dictionary returned by the API + return json.dumps(data, sort_keys=True, indent=2) + + +class PrettyTableFormatter(BaseFormatter): + """Format Zuul data in a nice human-readable way for the CLI.""" + + def formatAutoholdQuery(self, data) -> str: + text = "" + text += "ID: %s\n" % data.get('id', 'N/A') + text += "Tenant: %s\n" % data.get('tenant', 'N/A') + text += "Project: %s\n" % data.get('project', 'N/A') + text += "Job: %s\n" % data.get('job', 'N/A') + text += "Ref Filter: %s\n" % data.get('ref_filter', 'N/A') + text += "Max Count: %s\n" % (data.get('max_count', None) or + data.get('count', 'N/A')) + text += "Current Count: %s\n" % data.get('current_count', 'N/A') + text += "Node Expiration: %s\n" % ( + data.get('node_expiration', None) or + data.get('node_hold_expiration', 'N/A') + ) + text += "Request Expiration: %s\n" % ( + data.get('expired', None) and time.ctime(data['expired']) or + 'N/A' + ) + text += "Reason: %s\n" % data.get('reason', 'N/A') + text += "Held Nodes:\n" + for buildnodes in data.get('nodes', []): + text += self.formatBuildNodes(buildnodes) + return text + + def formatBuildNodes(self, data) -> str: + table = prettytable.PrettyTable(field_names=['Build ID', 'Node ID']) + for node in data.get('nodes', []): + table.add_row([data.get('build', 'N/A'), node]) + return str(table) + + def formatAutoholdQueries(self, data) -> str: + table = prettytable.PrettyTable( + field_names=[ + 'ID', 'Tenant', 'Project', 'Job', 'Ref Filter', + 'Max Count', 'Reason' + ]) + + for request in data: + table.add_row([ + request.get('id', 'N/A'), + request.get('tenant', 'N/A'), + request.get('project', 'N/A'), + request.get('job', 'N/A'), + request.get('ref_filter', 'N/A'), + request.get('max_count', None) or request.get('count', 'N/A'), + request.get('reason', 'N/A'), + ]) + return str(table) + + def formatBuild(self, data) -> str: + output = '' + # This is based on the web UI + output += 'UUID: %s\n' % data.get('uuid', 'N/A') + output += '=' * len('UUID: %s' % data.get('uuid', 'N/A')) + '\n' + output += 'Result: %s\n' % data.get('result', 'N/A') + output += 'Pipeline: %s\n' % data.get('pipeline', 'N/A') + output += 'Project: %s\n' % data.get('project', 'N/A') + output += 'Job: %s\n' % data.get('job_name', 'N/A') + if data.get('newrev'): + output += 'Ref: %s\n' % data.get('ref', 'N/A') + output += 'New Rev: %s\n' % data['newrev'] + if data.get('change') and data.get('patchset'): + output += 'Change: %s\n' % (str(data['change']) + ',' + + str(data['patchset'])) + output += 'Branch: %s\n' % data.get('branch', 'N/A') + output += 'Ref URL: %s\n' % data.get('ref_url', 'N/A') + output += 'Event ID: %s\n' % data.get('event_id', 'N/A') + output += 'Buildset ID: %s\n' % data.get('buildset', + {}).get('uuid', 'N/A') + output += 'Start time: %s\n' % ( + data.get('start_time') and + isoparse(data['start_time']) or + 'N/A' + ) + output += 'End time: %s\n' % ( + data.get('end_time') and + isoparse(data['end_time']) or + 'N/A' + ) + output += 'Duration: %s\n' % data.get('duration', 'N/A') + output += 'Voting: %s\n' % (data.get('voting') and 'Yes' or 'No') + output += 'Log URL: %s\n' % data.get('log_url', 'N/A') + output += 'Node: %s\n' % data.get('node_name', 'N/A') + + provides = data.get('provides', []) + if provides: + output += 'Provides:\n' + for resource in provides: + output += '- %s\n' % self.formatJobResource(resource) + if data.get('final', None) is not None: + output += 'Final: %s\n' % (data['final'] and 'Yes' or 'No') + else: + output += 'Final: N/A\n' + if data.get('held', None) is not None: + output += 'Held: %s' % (data['held'] and 'Yes' or 'No') + else: + output += 'Held: N/A' + + return output + + def formatArtifacts(self, data) -> str: + table = prettytable.PrettyTable( + field_names=['name', 'url'] + ) + for artifact in data: + table.add_row([artifact.get('name', 'N/A'), + artifact.get('url', 'N/A')]) + return str(table) + + def formatBuildSet(self, data) -> str: + # This is based on the web UI + output = '' + output += 'UUID: %s\n' % data.get('uuid', 'N/A') + output += '=' * len('UUID: %s' % data.get('uuid', 'N/A')) + '\n' + output += 'Result: %s\n' % data.get('result', 'N/A') + if data.get('newrev'): + output += 'Ref: %s\n' % data.get('ref', 'N/A') + output += 'New Rev: %s\n' % data['newrev'] + if data.get('change') and data.get('patchset'): + output += 'Change: %s\n' % (str(data['change']) + ',' + + str(data['patchset'])) + output += 'Project: %s\n' % data.get('project', 'N/A') + output += 'Branch: %s\n' % data.get('branch', 'N/A') + output += 'Pipeline: %s\n' % data.get('pipeline', 'N/A') + output += 'Event ID: %s\n' % data.get('event_id', 'N/A') + output += 'Message: %s' % data.get('message', 'N/A') + return output + + def formatBuildSets(self, data) -> str: + table = prettytable.PrettyTable( + field_names=[ + 'ID', 'Project', 'Branch', 'Pipeline', 'Change or Ref', + 'Result', 'Event ID' + ] + ) + for buildset in data: + if buildset.get('change') and buildset.get('patchset'): + change = ( + str(buildset['change']) + ',' + + str(buildset['patchset']) + ) + else: + change = buildset.get('ref', 'N/A') + table.add_row([ + buildset.get('uuid', 'N/A'), + buildset.get('project', 'N/A'), + buildset.get('branch', 'N/A'), + buildset.get('pipeline', 'N/A'), + change, + buildset.get('result', 'N/A'), + buildset.get('event_id', 'N/A') + ]) + return str(table) + + def formatBuilds(self, data) -> str: + table = prettytable.PrettyTable( + field_names=[ + 'ID', 'Job', 'Project', 'Branch', 'Pipeline', 'Change or Ref', + 'Duration (s)', 'Start time', 'Result', 'Event ID' + ] + ) + for build in data: + if build.get('change') and build.get('patchset'): + change = str(build['change']) + ',' + str(build['patchset']) + else: + change = build.get('ref', 'N/A') + start_time = ( + build.get('start_time') and + isoparse(build['start_time']) or + 'N/A' + ) + table.add_row([ + build.get('uuid', 'N/A'), + build.get('job_name', 'N/A'), + build.get('project', 'N/A'), + build.get('branch', 'N/A'), + build.get('pipeline', 'N/A'), + change, + build.get('duration', 'N/A'), + start_time, + build.get('result', 'N/A'), + build.get('event_id', 'N/A') + ]) + return str(table) + + def formatJobResource(self, data) -> str: + return data.get('name', 'N/A')