# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
#
# This is a Python API for the Nitrate test case management system.
# Copyright (c) 2012 Red Hat, Inc. All rights reserved.
# Author: Petr Splichal <psplicha@redhat.com>
#
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
""" High-level API for the Nitrate test case management system. """
import os
import re
import sys
import types
import unittest
import xmlrpclib
import unicodedata
import ConfigParser
import logging as log
from pprint import pformat as pretty
from xmlrpc import NitrateError, NitrateKerbXmlrpc
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Logging
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def setLogLevel(level=None):
"""
Set the default log level.
If the level is not specified environment variable DEBUG is used
with the following meaning:
DEBUG=0 ... Nitrate.log.WARN (default)
DEBUG=1 ... Nitrate.log.INFO
DEBUG=2 ... Nitrate.log.DEBUG
"""
try:
if level is None:
level = {1: log.INFO, 2: log.DEBUG}[int(os.environ["DEBUG"])]
except StandardError:
level = log.WARN
log.basicConfig(format="[%(levelname)s] %(message)s")
log.getLogger().setLevel(level)
setLogLevel()
def info(message):
""" Log provided info message to the standard error output """
sys.stderr.write(message + "\n")
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Caching
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
CACHE_NONE = 0
CACHE_CHANGES = 1
CACHE_OBJECTS = 2
CACHE_ALL = 3
def setCacheLevel(level=None):
"""
Set the caching level.
If the level parameter is not specified environment variable CACHE
is inspected instead. There are three levels available:
CACHE_NONE ...... Write object changes immediately to the server
CACHE_CHANGES ... Changes pushed only by update() or upon destruction
CACHE_OBJECTS ... Any loaded object is saved for possible future use
CACHE_ALL ....... Where possible, pre-fetch all available objects
By default CACHE_OBJECTS is used. That means any changes to objects
are pushed to the server only upon destruction or when explicitly
requested with the update() method. Also, any object already loaded
from the server is kept in local cache so that future references to
that object are faster.
"""
global _cache
if level is None:
try:
_cache = int(os.environ["CACHE"])
except StandardError:
_cache = CACHE_OBJECTS
elif level >= 0 and level <= 3:
_cache = level
else:
raise NitrateError("Invalid cache level '{0}'".format(level))
log.debug("Caching on level {0}".format(_cache))
setCacheLevel()
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Coloring
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
COLOR_ON = 1
COLOR_OFF = 0
COLOR_AUTO = 2
def setColorMode(mode=None):
"""
Set the coloring mode.
If enabled, some objects (like case run Status) are printed in color
to easily spot failures, errors and so on. By default the feature is
enabled when script is attached to a terminal. Possible values are:
COLOR_ON ..... coloring enabled
COLOR_OFF .... coloring disabled
COLOR_AUTO ... enabled if terminal detected (default)
Environment variable COLOR can be used to set up the coloring to the
desired mode without modifying code.
"""
global _color
if mode is None:
try:
mode = int(os.environ["COLOR"])
except StandardError:
mode = COLOR_AUTO
elif mode < 0 or mode > 2:
raise NitrateError("Invalid color mode '{0}'".format(mode))
if mode == COLOR_AUTO:
_color = sys.stdout.isatty()
else:
_color = mode == 1
log.debug("Coloring {0}".format(_color and "enabled" or "disabled"))
def color(text, color=None, background=None, light=False):
""" Return text in desired color if coloring enabled. """
colors = {"black": 30, "red": 31, "green": 32, "yellow": 33,
"blue": 34, "magenta": 35, "cyan": 36, "white": 37}
# Prepare colors (strip 'light' if present in color)
if color and color.startswith("light"):
light = True
color = color[5:]
color = color and ";{0}".format(colors[color]) or ""
background = background and ";{0}".format(colors[background] + 10) or ""
light = light and 1 or 0
# Starting and finishing sequence
start = "\033[{0}{1}{2}m".format(light , color, background)
finish = "\033[1;m"
if _color:
return "".join([start, text, finish])
else:
return text
setColorMode()
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Default Getter & Setter
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def _getter(field):
"""
Simple getter factory function.
For given field generate getter function which calls self._get(), to
initialize instance data if necessary, and returns self._field.
"""
def getter(self):
# Initialize the attribute unless already done
if getattr(self, "_" + field) is NitrateNone:
self._get()
# Return self._field
return getattr(self, "_" + field)
return getter
def _setter(field):
"""
Simple setter factory function.
For given field return setter function which calls self._get(), to
initialize instance data if necessary, updates the self._field and
remembers modifed state if the value is changed.
"""
def setter(self, value):
# Initialize the attribute unless already done
if getattr(self, "_" + field) is NitrateNone:
self._get()
# Update only if changed
if getattr(self, "_" + field) != value:
setattr(self, "_" + field, value)
log.info("Updating {0}'s {1} to '{2}'".format(
self.identifier, field, value))
# Remember modified state if caching
if _cache:
self._modified = True
# Save the changes immediately otherwise
else:
self._update()
return setter
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Various Utilities
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def listed(items, quote=""):
""" Convert provided iterable into a nice, human readable list. """
items = ["{0}{1}{0}".format(quote, item) for item in items]
if len(items) < 2:
return "".join(items)
else:
return ", ".join(items[0:-2] + [" and ".join(items[-2:])])
def ascii(text):
""" Transliterate special unicode characters into pure ascii. """
if not isinstance(text, unicode): text = unicode(text)
return unicodedata.normalize('NFKD', text).encode('ascii','ignore')
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Nitrate None Class
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
class NitrateNone(object):
""" Used for distinguish uninitialized values from regular 'None'. """
pass
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Config Class
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
class Config(object):
""" User configuration. """
# Config path
path = os.path.expanduser("~/.nitrate")
# Minimal config example
example = ("Please, provide at least a minimal config file {0}:\n"
"[nitrate]\n"
"url = http://nitrate.server/xmlrpc/".format(path))
def __init__(self):
""" Initialize the configuration """
# Trivial class for sections
class Section(object): pass
# Parse the config
try:
parser = ConfigParser.SafeConfigParser()
parser.read([self.path])
for section in parser.sections():
# Create a new section object for each section
setattr(self, section, Section())
# Set its attributes to section contents (adjust types)
for name, value in parser.items(section):
try: value = int(value)
except: pass
if value == "True": value = True
if value == "False": value = False
setattr(getattr(self, section), name, value)
except ConfigParser.Error:
log.error(self.example)
raise NitrateError(
"Cannot read the config file")
# Make sure the server URL is set
try:
self.nitrate.url is not None
except AttributeError:
log.error(self.example)
raise NitrateError("No url found in the config file")
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Nitrate Class
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
class Nitrate(object):
"""
General Nitrate Object.
Takes care of initiating the connection to the Nitrate server and
parses user configuration.
"""
_connection = None
_settings = None
_requests = 0
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Nitrate Properties
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
id = property(_getter("id"), doc="Object identifier.")
@property
def identifier(self):
""" Consistent identifier string. """
return "{0}#{1}".format(self._prefix, self._id)
@property
def _config(self):
""" User configuration (expected in ~/.nitrate). """
# Read the config file (unless already done)
if Nitrate._settings is None:
Nitrate._settings = Config()
# Return the settings
return Nitrate._settings
@property
def _server(self):
""" Connection to the server. """
# Connect to the server unless already connected
if Nitrate._connection is None:
log.info("Contacting server {0}".format(self._config.nitrate.url))
Nitrate._connection = NitrateKerbXmlrpc(
self._config.nitrate.url).server
# Return existing connection
Nitrate._requests += 1
return Nitrate._connection
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Nitrate Special
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def __init__(self, id=None, prefix="ID"):
""" Initialize the object id, prefix and internal attributes. """
# Set up prefix and internal attributes
self._prefix = prefix
try:
for attr in self._attributes:
setattr(self, "_" + attr, NitrateNone)
except AttributeError:
self._attributes = []
# Check and set the object id
if id is None:
self._id = NitrateNone
elif isinstance(id, int):
self._id = id
else:
try:
self._id = int(id)
except ValueError:
raise NitrateError("Invalid {0} id: '{1}'".format(
self.__class__.__name__, id))
def __str__(self):
""" Provide ascii string representation. """
return ascii(self.__unicode__())
def __unicode__(self):
""" Short summary about the connection. """
return u"Nitrate server: {0}\nTotal requests handled: {1}".format(
self._config.nitrate.url, self._requests)
def __eq__(self, other):
""" Handle object equality based on its id. """
if not isinstance(other, Nitrate): return False
return self.id == other.id
def __ne__(self, other):
""" Handle object inequality based on its id. """
if not isinstance(other, Nitrate): return True
return self.id != other.id
def __hash__(self):
""" Use object id as the default hash. """
return self.id
def __repr__(self):
return "{0}({1})".format(self.__class__.__name__, self.id)
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Nitrate Methods
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def _get(self):
""" Fetch object data from the server. """
raise NitrateError("To be implemented by respective class")
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Build Class
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
class Build(Nitrate):
""" Product build. """
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Build Properties
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Read-only properties
id = property(_getter("id"), doc="Build id.")
|