#!/usr/bin/env python
# PySys System Test Framework, Copyright (C) 2006-2022 M.B. Grieve
# 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.
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""
The `Project <pysys.config.project.Project>` class holds the ``pysysproject.xml`` project configuration, including all
user-defined project properties.
"""
__all__ = ['Project'] # Project is the only member we expose/document from this module
import os.path, logging, xml.dom.minidom, collections, codecs, time
import platform
import locale
import getpass
import pysys
import pysys.utils.misc
from pysys.constants import *
from pysys import __version__
from importlib import import_module
from pysys.utils.logutils import ColorLogFormatter, BaseLogFormatter
from pysys.utils.fileutils import mkdir, loadProperties, toLongPathSafe
from pysys.utils.pycompat import openfile, makeReadOnlyDict
from pysys.exceptions import UserError
log = logging.getLogger('pysys.config.project')
class _XMLProjectParser(object):
"""
:meta private: Not public API.
"""
def __init__(self, dirname, file, outdir):
self.dirname = toLongPathSafe(dirname, onlyIfNeeded=True) # main reason for this is to capitalize (normalize) drive letter on windows to match other toLongPathSafe invocations
self.xmlfile = os.path.join(dirname, file)
log.debug('Loading project file: %s', self.xmlfile)
self.environment = 'env'
# project load time is a reasonable proxy for test start time,
# and we might want to substitute the date/time into property values
self.startTimestamp = time.time()
try:
username = os.getenv('PYSYS_USERNAME') or getpass.getuser().lower() # getpass throws if no env var is set to help with this
except Exception as ex:
username = 'UNKNOWN_USER'
self.properties = {
'testRootDir':self.dirname,
'outDirName':os.path.basename(outdir),
'startDate':time.strftime('%Y-%m-%d', time.localtime(self.startTimestamp)),
'startTime':time.strftime('%H.%M.%S', time.localtime(self.startTimestamp)),
'startTimeSecs':'%0.3f'%self.startTimestamp,
'hostname':HOSTNAME.lower().split('.')[0],
'os':platform.system().lower(), # e.g. 'windows', 'linux', 'darwin'; a more modern alternative to OSFAMILY
'osfamily':OSFAMILY, # windows or unix
'pysysTemplatesDir': os.path.dirname(__file__)+os.sep+'templates',
'username': username,
'/': os.sep, # so people can write strings like foo${/}bar and get a forward or back-slash depending on platform
# old names
'root':self.dirname, # old name for testRootDir
}
if not os.path.exists(self.xmlfile):
raise Exception("Unable to find supplied project file \"%s\"" % self.xmlfile)
try:
self.doc = xml.dom.minidom.parse(self.xmlfile)
except Exception:
raise Exception(sys.exc_info()[1])
else:
if self.doc.getElementsByTagName('pysysproject') == []:
raise Exception("No <pysysproject> element supplied in project file")
else:
self.root = self.doc.getElementsByTagName('pysysproject')[0]
extraProjectXMLs = os.getenv('PYSYS_PROJECT_APPEND', '')
if extraProjectXMLs:
for extraXMLFile in extraProjectXMLs.split(','):
extraXMLFile = extraXMLFile.strip()
log.info('Loading additional project file: %s', extraXMLFile)
extra = xml.dom.minidom.parse(extraXMLFile).getElementsByTagName('pysysproject')
assert extra, 'Cannot find <pysysproject> in %s'%extraXMLFile
for extraNode in list(extra[0].childNodes):
self.root.appendChild(extraNode)
self.__pathProperties = set() # set set of properties which are known to contain a path
def checkVersions(self):
requirespython = self.root.getElementsByTagName('requires-python')
if requirespython and requirespython[0].firstChild:
requirespython = requirespython[0].firstChild.nodeValue
if requirespython:
if list(sys.version_info) < list(map(int, requirespython.split('.'))):
raise UserError('This test project requires Python version %s or greater, but this is version %s (from %s)'%(requirespython, '.'.join([str(x) for x in sys.version_info[:3]]), sys.executable))
requirespysys = self.root.getElementsByTagName('requires-pysys')
if requirespysys and requirespysys[0].firstChild:
requirespysys = requirespysys[0].firstChild.nodeValue
if requirespysys:
thisversion = __version__
if pysys.utils.misc.compareVersions(requirespysys, thisversion) > 0:
raise UserError('This test project requires PySys version %s or greater, but this is version %s'%(requirespysys, thisversion))
def unlink(self):
if self.doc: self.doc.unlink()
def getProperties(self):
propertyNodeList = [element for element in self.root.getElementsByTagName('property') if element.parentNode == self.root]
for propertyNode in propertyNodeList:
permittedAttributes = None
# use of these options for customizing the property names of env/root/osfamily is no longer encouraged; just kept for compat
if propertyNode.hasAttribute("environment"):
self.environment = propertyNode.getAttribute("environment")
elif propertyNode.hasAttribute("root"):
propname = propertyNode.getAttribute("root")
self.properties[propname] = self.dirname
log.debug('Setting project property %s="%s"', propname, self.dirname)
elif propertyNode.hasAttribute("osfamily"): # just for older configs, better to use ${os} now
propname = propertyNode.getAttribute("osfamily")
self.properties[propname] = OSFAMILY
log.debug('Setting project property %s="%s"', propname, OSFAMILY)
elif propertyNode.hasAttribute("file"):
file = self.expandProperties(propertyNode.getAttribute("file"), default=propertyNode, name='properties file reading')
self.getPropertiesFromFile(os.path.normpath(os.path.join(self.dirname, file)) if file else '',
pathMustExist=(propertyNode.getAttribute("pathMustExist") or '').lower()=='true',
includes=propertyNode.getAttribute("includes"),
excludes=propertyNode.getAttribute("excludes"),
prefix=propertyNode.getAttribute("prefix") or '',
)
permittedAttributes = {'name', 'file', 'default', 'pathMustExist', 'includes', 'excludes', 'prefix'}
elif propertyNode.hasAttribute("name"):
name = propertyNode.getAttribute("name")
value = self.expandProperties(
propertyNode.getAttribute("value") or propertyNode.getAttribute("path")
or '\n'.join(n.data for n in propertyNode.childNodes
if (n.nodeType in {n.TEXT_NODE,n.CDATA_SECTION_NODE}) and n.data),
default=propertyNode, name=name)
if name in self.properties:
raise UserError('Cannot set project property "%s" as it is already set'%name)
ispath = False
if (propertyNode.getAttribute("pathMustExist") or '').lower()=='true':
if not (value and os.path.exists(os.path.join(self.dirname, value))):
raise UserError('Cannot find path referenced in project property "%s": "%s"'%(
name, '' if not value else os.path.normpath(os.path.join(self.dirname, value))))
ispath = True
elif propertyNode.getAttribute("path"): ispath = True
if ispath: # since we know it's a path, make it a nice one
value = os.path.normpath(value)
self.__pathProperties.add(name)
self.properties[name] = value
log.debug('Setting project property %s="%s"', name, value)
permittedAttributes = {'name', 'value', 'path', 'default', 'pathMustExist'}
else:
raise UserError('Found <property> with no name= or file=')
if permittedAttributes is not None:
for att in range(propertyNode.attributes.length):
attName = propertyNode.attributes.item(att).name
if attName not in permittedAttributes:
# not an error, to allow for adding new ones in future pysys versions, but worth warning about
log.warning('Unknown <property> attribute "%s" in project configuration'%attName)
# default is to support IDEs like VSCode
self.properties.setdefault('pysysLogAbsolutePaths', str( os.getenv('TERM_PROGRAM','')=='vscode' ) )
# always allow local env var override regardless of project property
if os.getenv('PYSYS_LOG_ABSOLUTE_PATHS'): self.properties['pysysLogAbsolutePaths'] = os.getenv('PYSYS_LOG_ABSOLUTE_PATHS','').lower()=='true'
return self.properties
def getPropertiesFromFile(self, file, pathMustExist=False, includes=None, excludes=None, prefix=''):
if not os.path.isfile(file):
if pathMustExist:
raise UserError('Cannot find properties file referenced in %s: "%s"'%(
self.xmlfile, file))
log.debug('Skipping project properties file which not exist: "%s"', file)
return
try:
rawProps = loadProperties(file) # since PySys 1.6.0 this is UTF-8 by default
except UnicodeDecodeError:
# fall back to ISO8859-1 if not valid UTF-8 (matching Java 9+ behaviour)
rawProps = loadProperties(file, encoding='iso8859-1')
props = collections.OrderedDict()
for name, value in rawProps.items():
if includes and not re.match(includes, name): continue
if excludes and re.match(excludes, name): continue
props[prefix+name] = value
for name, value in props.items():
# when loading properties files it's not so helpful to give errors (and there's nowhere else to put an empty value) so default to empty string
value = self.expandProperties(value, default='', name=name)
if name in self.__pathProperties:
value = os.path.normpath(value)
if name in self.properties and value != self.properties[name]:
# Whereas we want a hard error for duplicate <property name=".../> entries, for properties files
# there's a good case to allow overwriting of properties, but log it at INFO
if name in self.__pathProperties and os.path.normcase(value)==os.path.normcase(self.properties[name]):
pass
else:
log.info('Overwriting previous value of project property "%s" with new value "%s" from "%s"'%(name, value, os.path.basename(file)))
self.properties[name] = value
log.debug('Setting project property %s="%s" (from %s)', name, self.properties[name], file)
def expandProperties(self, value, default, name=None):
"""
Expand any ${...} project properties or env vars, with ${$} for escaping.
The "default" is expanded and used if value contains some undefined variables.
If default=None then an error is raised instead. If default is a node, its "default" attribute is used
The "name" is used to generate more informative error messages
"""
envprefix = self.environment+'.'
errorprefix = ('Error setting project property "%s": '%name) if name else ''
if hasattr(default, 'getAttribute'):
default = default.getAttribute("default") if default.hasAttribute("default") else None
def expandProperty(m):
m = m.group(1)
if m == '$': return '$'
try:
if m.startswith(envprefix):
return os.environ[m[len(envprefix):]]
if m.startswith('env:'): # for consistency with eval: also support this syntax
return os.environ[m[4:]]
except KeyError as ex:
raise KeyError(errorprefix+'cannot find environment variable "%s"'%m[len(envprefix):])
if m.startswith('eval:'):
props = dict(self.properties)
props.pop('os', None) # remove this to avoid hiding the os.path module
props['properties'] = self.properties
try:
v = pysys.utils.safeeval.safeEval(m[5:], extraNamespace=props,
errorMessage='Failed to evaluate Python eval() string "{expr}" during property expansion due to {error}')
return str(v)
except Exception as ex:
raise UserError(str(ex))
if m in self.properties:
return self.properties[m]
else:
raise KeyError(errorprefix+'PySys project property ${%s} is not defined, please check your pysysproject.xml file'%m)
try:
return re.sub(r'[$][{]([^}]+)[}]', expandProperty, value)
except KeyError as ex:
if default is None: raise UserError('%s; if this is intended to be an optional property please add a default="..." value'%ex)
log.debug('Failed to resolve value "%s" of property "%s", so falling back to default value', value, name or '<unknown>')
return re.sub(r'[$][{]([^}]+)[}]', expandProperty, default)
def getRunnerDetails(self):
nodes = self.root.getElementsByTagName('runner')
if not nodes: return DEFAULT_RUNNER
classname, propertiesdict = self._parseClassAndConfigDict(nodes[0], None, returnClassAsName=True)
assert not propertiesdict, 'Properties are not supported under <runner>'
return classname
def getCollectTestOutputDetails(self):
r = []
for n in self.root.getElementsByTagName('collect-test-output'):
x = {
'pattern':n.getAttribute('pattern'),
'outputDir':self.expandProperties(n.getAttribute('outputDir'), default=None, name='collect-test-output outputDir'),
'outputPattern':n.getAttribute('outputPattern'),
}
assert 'pattern' in x, x
assert 'outputDir' in x, x
assert 'outputPattern' in x, x
assert '@UNIQUE@' in x['outputPattern'], 'collect-test-output outputPattern must include @UNIQUE@'
r.append(x)
return r
def getPerformanceReporterDetails(self):
nodeList = self.root.getElementsByTagName('performance-reporter')
results = []
for n in nodeList:
cls, optionsDict = self._parseClassAndConfigDict(n, 'pysys.perf.reporters.CSVPerformanceReporter')
optionsDict['summaryfile'] = self.expandProperties(optionsDict.get('summaryfile', ''), default=None, name='performance-reporter summaryfile')
results.append( (cls, optionsDict) )
if not results: # add the defaults
results.append( self._parseClassAndConfigDict(None, 'pysys.perf.reporters.CSVPerformanceReporter') )
results.append( self._parseClassAndConfigDict(None, 'pysys.perf.reporters.PrintSummaryPerformanceReporter') )
return results
def getProjectHelp(self):
help = ''
for e in self.root.getElementsByTagName('project-help'):
for n in e.childNodes:
if (n.nodeType in {e.TEXT_NODE,e.CDATA_SECTION_NODE}) and n.data:
help += n.data
return help
def getDescriptorLoaderClass(self):
nodeList = self.root.getElementsByTagName('descriptor-loader')
cls, optionsDict = self._parseClassAndConfigDict(nodeList[0] if nodeList else None, 'pysys.config.descriptor.DescriptorLoader')
if optionsDict: raise UserError('Unexpected descriptor-loader attribute(s): '+', '.join(list(optionsDict.keys())))
return cls
def getTestPlugins(self):
plugins = []
for node in self.root.getElementsByTagName('test-plugin'):
cls, optionsDict = self._parseClassAndConfigDict(node, None)
alias = optionsDict.pop('alias', None)
plugins.append( (cls, alias, optionsDict) )
return plugins
def getRunnerPlugins(self):
plugins = []
for node in self.root.getElementsByTagName('runner-plugin'):
cls, optionsDict = self._parseClassAndConfigDict(node, None)
alias = optionsDict.pop('alias', None)
plugins.append( (cls, alias, optionsDict) )
return plugins
def getDescriptorLoaderPlugins(self):
for node in self.root.getElementsByTagName('descriptor-loader-plugin'):
if node.parentNode == self.root:
# Eventually will be an exception but for now we just log a warning to allow the same project to work with both new and old versions simultaneously for easy migration
# raise UserError(...)
log.warning('From PySys version 2.2, <descriptor-loader-plugin> is no longer supported at the project level - please move it to <pysysdirconfig> instead')
def getMakerDetails(self):
nodes = self.root.getElementsByTagName('maker')
if not nodes: return DEFAULT_MAKER
classname, propertiesdict = self._parseClassAndConfigDict(nodes[0], None, returnClassAsName=True)
assert not propertiesdict, 'Properties are not supported under <maker>'
return classname
def createFormatters(self):
stdout = runlog = None
formattersNodeList = self.root.getElementsByTagName('formatters')
if formattersNodeList:
formattersNodeList = formattersNodeList[0].getElementsByTagName('formatter')
if formattersNodeList:
for formatterNode in formattersNodeList:
fname = formatterNode.getAttribute('name')
if fname not in ['stdout', 'runlog']:
raise UserError('Formatter "%s" is invalid - must be stdout or runlog'%fname)
if fname == 'stdout':
cls, options = self._parseClassAndConfigDict(formatterNode, 'pysys.utils.logutils.ColorLogFormatter')
options['__formatterName'] = 'stdout'
stdout = cls(options)
else:
cls, options = self._parseClassAndConfigDict(formatterNode, 'pysys.utils.logutils.BaseLogFormatter')
options['__formatterName'] = 'runlog'
runlog = cls(options)
return stdout, runlog
def getDefaultFileEncodings(self):
result = []
for n in self.root.getElementsByTagName('default-file-encoding'):
pattern = (n.getAttribute('pattern') or '').strip().replace('\\','/')
encoding = (n.getAttribute('encoding') or '').strip()
if not pattern: raise UserError('<default-file-encoding> element must include both a pattern= attribute')
if encoding:
codecs.lookup(encoding) # give an exception if an invalid encoding is specified
else:
encoding=None
result.append({'pattern':pattern, 'encoding':encoding})
return result
def getExecutionOrderHints(self):
result = []
secondaryModesHintDelta = None
def makeregex(s):
if not s: return None
if s.startswith('!'): raise UserError('Exclusions such as !xxx are not permitted in execution-order configuration')
# make a regex that will match either the entire expression as a literal
# or the entire expression as a regex
s = s.rstrip('$')
try:
#return re.compile('(%s|%s)$'%(re.escape(s), s))
return re.compile('%s$'%(s))
except Exception as ex:
raise UserError('Invalid regular expression in execution-order "%s": %s'%(s, ex))
for parent in self.root.getElementsByTagName('execution-order'):
if parent.getAttribute('secondaryModesHintDelta'):
secondaryModesHintDelta = float(parent.getAttribute('secondaryModesHintDelta'))
for n in parent.getElementsByTagName('execution-order'):
moderegex = makeregex(n.getAttribute('forMode'))
groupregex = makeregex(n.getAttribute('forGroup'))
if not (moderegex or groupregex): raise UserError('Must specify either forMode, forGroup or both')
hintmatcher = lambda groups, mode, moderegex=moderegex, groupregex=groupregex: (
(moderegex is None or moderegex.match(mode or '')) and
(groupregex is None or any(groupregex.match(group) for group in groups))
)
result.append(
(float(n.getAttribute('hint')), hintmatcher )
)
if secondaryModesHintDelta is None:
secondaryModesHintDelta = +100.0 # default value
return result, secondaryModesHintDelta
def getWriterDetails(self):
# writers can optionally be under a 'writers' parent node but this is now optional, to facilitate
# a third party plugin vendor providing a snippet of a few consecutive lines to paste into the project config
# to enable new functionality
writers = []
writerNodeList = self.root.getElementsByTagName('writer')
if not writerNodeList: return []
for writerNode in writerNodeList:
pythonclassconstructor, propertiesdict = self._parseClassAndConfigDict(writerNode, None)
writers.append( (pythonclassconstructor, propertiesdict) )
return writers
def addToPath(self):
for elementname in ['path', 'pythonpath']:
pathNodeList = self.root.getElementsByTagName(elementname)
for pathNode in pathNodeList:
value = self.expandProperties(pathNode.getAttribute("value"), default=None, name='pythonpath')
if not value:
raise UserError('Cannot add directory to the pythonpath: "%s"'%value)
# we ignore the "relative" option and always make it relative to the testrootdir if not already absolute
value = os.path.join(self.dirname, value)
value = os.path.normpath(value)
if not os.path.isdir(value):
raise UserError('Cannot add non-existent directory to the python <path>: "%s"'%value)
else:
log.debug('Adding value to path: %s', value)
sys.path.append(value)
def writeXml(self):
f = open(self.xmlfile, 'w')
f.write(self.doc.toxml())
f.close()
def _parseClassAndConfigDict(self, node, defaultClass, **kwargs):
""" See `_parseClassAndConfigDictImpl` """
return self._parseClassAndConfigDictImpl(self.expandProperties, node, defaultClass, **kwargs)
@staticmethod
def _parseClassAndConfigDictImpl(expandPropertiesImpl, node, defaultClass, returnClassAsName=False):
"""Parses a dictionary of arbitrary options and a python class out of the specified XML node.
This is a static method as it is used internally from DescriptorLoader.
The node may optionally contain classname and module (if not specified as a separate attribute,
module will be extracted from the first part of classname); any other attributes will be returned in
the optionsDict, as will <property name=""></property> child elements.
:param node: The node, may be None
:param defaultClass: a string specifying the name of the default fully-qualified class, if any
:return: a tuple of (pythonclassconstructor, propertiesdict), or if returnClassAsName (classname, propertiesDict)
"""
optionsDict = {}
if node:
for att in range(node.attributes.length):
name = node.attributes.item(att).name.strip()
if name in optionsDict: raise UserError('Duplicate property "%s" in <%s> configuration'%(name, node.tagName))
optionsDict[name] = expandPropertiesImpl(node.attributes.item(att).value, default=None, name=name)
for tag in node.getElementsByTagName('property'):
name = tag.getAttribute('name')
assert name
if name in optionsDict: raise UserError('Duplicate property "%s" in <%s> configuration'%(name, node.tagName))
optionsDict[name] = expandPropertiesImpl(
tag.getAttribute("value") or '\n'.join(n.data for n in tag.childNodes
if (n.nodeType in {n.TEXT_NODE,n.CDATA_SECTION_NODE}) and n.data),
default=tag, name=name)
classname = optionsDict.pop('classname', defaultClass)
if not classname: raise UserError('Missing require attribute "classname=" for <%s>'%node.tagName)
mod = optionsDict.pop('module', '.'.join(classname.split('.')[:-1]))
classname = classname.split('.')[-1]
if returnClassAsName:
return (mod+'.'+classname).strip('.'), optionsDict
# defer importing the module until we actually need to instantiate the
# class, to avoid introducing tricky module import order problems, given
# that the project itself needs loading very early
def classConstructor(*args, **kwargs):
module = import_module(mod)
cls = getattr(module, classname)
try:
return cls(*args, **kwargs) # invoke the constructor for this class
except Exception as ex:
# The classname is not available in the higher-level exception handler, so useful to log it here in case it's not obvious
log.error('Failed to instantiate "%s" class due to %s: %s', cls.__name__, type(ex).__name__, ex)
raise
return classConstructor, optionsDict
def getProjectConfigTemplates():
"""Get a list of available templates that can be used for creating a new project configuration.
:return: A dict, where each value is an absolute path to an XML template file
and each key is the display name for that template.
"""
templatedir = os.path.dirname(__file__)+'/templates/project'
templates = { t.replace('.xml',''): templatedir+'/'+t
for t in os.listdir(templatedir) if t.endswith('.xml')}
assert templates, 'No project templates found in %s'%templatedir
return templates
def createProjectConfig(targetdir, templatepath=None):
"""Create a new project configuration file in the specified targetdir.
"""
if not templatepath: templatepath = getProjectConfigTemplates()['default']
mkdir(targetdir)
# using ascii ensures we don't unintentionally add weird characters to the default (utf-8) file
with openfile(templatepath, encoding='ascii') as src:
with openfile(os.path.abspath(targetdir+'/'+DEFAULT_PROJECTFILE[0]), 'w', encoding='ascii') as target:
for l in src:
l = l.replace('@PYTHON_VERSION@', '%s.%s.%s'%sys.version_info[0:3])
l = l.replace('@PYSYS_VERSION@', '.'.join(__version__.split('.')[0:2]))
target.write(l)
[docs]class Project(object):
"""Contains settings for the entire test project, as defined by the
``pysysproject.xml`` project configuration file; see :doc:`/pysys/ProjectConfiguration`.
To get a reference to the current `Project` instance, use the
`pysys.basetest.BaseTest.project`
(or `pysys.process.user.ProcessUser.project`) field.
All project properties are strings. If you need to get a project property value that's a a bool/int/float/list it is
recommended to use `getProperty()` which will automatically perform the conversion. For string properties
you can just use ``project.propName`` or ``project.properties['propName']``.
:ivar dict(str,str) ~.properties: The resolved values of all project properties defined in the configuration file.
In addition, each of these is set as an attribute onto the `Project` instance itself.
:ivar str ~.root: Full path to the project root directory, as specified by the first PySys project
file encountered when walking up the directory tree from the start directory.
If no project file was found, this is just the start directory PySys was run from.
:ivar str ~.projectFile: Full path to the project file.
"""
__INSTANCE = None
__frozen = False
def __init__(self, root, projectFile, outdir=None):
assert projectFile
self.root = root
if not outdir: outdir = DEFAULT_OUTDIR
if not os.path.exists(os.path.join(root, projectFile)):
raise UserError("Project file not found: %s" % os.path.normpath(os.path.join(root, projectFile)))
try:
parser = _XMLProjectParser(root, projectFile, outdir=outdir)
except UserError:
raise
except Exception as e:
raise Exception("Error parsing project file \"%s\": %s" % (os.path.join(root, projectFile),sys.exc_info()[1]))
parser.checkVersions()
self.projectFile = os.path.join(root, projectFile)
self.startTimestamp = parser.startTimestamp
# get the properties
properties = parser.getProperties()
keys = list(properties.keys())
keys.sort()
for key in keys:
if not hasattr(self, key): # don't overwrite existing props; people will have to use .getProperty() to access them
setattr(self, key, properties[key])
self.properties = dict(properties)
# add to the python path
parser.addToPath()
# get the runner if specified
self.runnerClassname = parser.getRunnerDetails()
# get the maker if specified
self.makerClassname = parser.getMakerDetails()
self.writers = parser.getWriterDetails()
self.testPlugins = parser.getTestPlugins()
self.runnerPlugins = parser.getRunnerPlugins()
parser.getDescriptorLoaderPlugins() # just to check there are none
self.perfReporterConfig = parser.getPerformanceReporterDetails()
self.descriptorLoaderClass = parser.getDescriptorLoaderClass()
# get the stdout and runlog formatters
stdoutformatter, runlogformatter = parser.createFormatters()
self.defaultFileEncodings = parser.getDefaultFileEncodings()
self.executionOrderHints, self.executionOrderSecondaryModesHintDelta = parser.getExecutionOrderHints()
self.collectTestOutput = parser.getCollectTestOutputDetails()
self.projectHelp = parser.getProjectHelp()
self.projectHelp = parser.expandProperties(self.projectHelp, default=None, name='project-help')
self._defaultDirConfig = None # this field is not public API
e = parser.root.getElementsByTagName('pysysdirconfig')
assert len(e) <= 1, 'Cannot have more than one pysysdirconfig element in pysysproject.xml'
if e:
self._defaultDirConfig = pysys.config.descriptor._XMLDescriptorParser.parse(self.projectFile, istest=False,
project=self, xmlRootElement=e[0])
# set the data attributes
parser.unlink()
if not stdoutformatter: stdoutformatter = ColorLogFormatter({'__formatterName':'stdout'})
if not runlogformatter: runlogformatter = BaseLogFormatter({'__formatterName':'runlog'})
PySysFormatters = collections.namedtuple('PySysFormatters', ['stdout', 'runlog'])
self.formatters = PySysFormatters(stdoutformatter, runlogformatter)
# for safety (test independence, and thread-safety), make it hard for people to accidentally edit project properties later
self.properties = makeReadOnlyDict(self.properties)
self.__frozen = True
def __setattr__(self, name, value):
if self.__frozen: raise Exception('Project cannot be modified after it has been loaded (use the runner to store global state if needed)')
object.__setattr__(self, name, value)
[docs] def expandProperties(self, value):
"""
Expand any ${...} project properties in the specified string.
An exception is thrown if any property is missing. This method is only for expanding project properties
and ``${eval:xxx})`` strings, so ``${env.*}`` syntax is not permitted (if you need to use an environment
variable, put it into a project property first).
.. versionadded:: 1.6.0
:param str value: The string in which any properties will be expanded. ${$} can be used for escaping a literal $ if needed.
:return str: The value with properties expanded, or None if value=None.
"""
if (not value) or ('${' not in value): return value
def expandProperty(m):
m = m.group(1)
if m == '$': return '$'
if m.startswith('eval:'):
props = dict(self.properties)
props.pop('os', None) # remove this to avoid hiding the os.path module
props['properties'] = self.properties
try:
v = pysys.utils.safeeval.safeEval(m[5:], extraNamespace=props, errorMessage='{error}')
return str(v)
except Exception as ex:
raise Exception('Error resolving ${%s} eval() string: %s'%(m, ex))
return self.properties[m]
try:
return re.sub(r'[$][{]([^}]+)[}]', expandProperty, value)
except KeyError as ex:
# A more informative error, but not a UserError since we don't have the context of where it was called from
raise Exception('Cannot resolve project property %s in: %s'%(ex, value))
[docs] def getProperty(self, key, default):
"""
Get the specified project property value, or a default if it is not defined, with type conversion from string
to int/float/bool/list[str] (matching the default's type; for list[str], comma-separated input is assumed).
.. versionadded:: 1.6.0
:param str key: The name of the property.
:param bool/int/float/str/list[str] default: The default value to return if the property is not set.
The type of the default parameter will be used to convert the property value from a string if it is
provided. An exception will be raised if the value is non-empty but cannot be converted to the indicated type.
"""
return pysys.utils.misc.getTypedValueOrDefault(key, self.properties.get(key, None), default)
[docs] @staticmethod
def getInstance():
"""
Provides access to the singleton instance of Project.
Raises an exception if the project has not yet been loaded.
Use ``self.project`` to get access to the project instance where possible,
for example from a `pysys.basetest.BaseTest` or `pysys.baserunner.BaseRunner` class. This attribute is for
use in internal functions and classes that do not have a ``self.project``.
"""
if Project.__INSTANCE: return Project.__INSTANCE
if 'doctest' in sys.argv[0]: return None # special-case for doctesting
raise Exception('Cannot call Project.getInstance() as the project has not been loaded yet')
[docs] @staticmethod
def findAndLoadProject(startdir=None, outdir=None):
"""Find and load a project file, starting from the specified directory.
If this fails an error is logged and the process is terminated.
The method walks up the directory tree from the supplied path until the
PySys project file is found. The location of the project file defines
the project root location. The contents of the project file determine
project specific constants as specified by property elements in the
xml project file.
To ensure that all loaded modules have a pre-initialised projects
instance, any launching application should first import the loadproject
file, and then make a call to it prior to importing all names within the
constants module.
:param st rstartdir: The initial path to start from when trying to locate the project file
:param str outdir: The output directory specified on the command line. Some project properties may depend on
this.
"""
projectFile = os.getenv('PYSYS_PROJECTFILE', None)
search = startdir or os.getcwd()
if not projectFile:
projectFileSet = set(DEFAULT_PROJECTFILE)
drive, path = os.path.splitdrive(search)
while (not search == drive):
intersection = projectFileSet & set(os.listdir(search))
if intersection :
projectFile = intersection.pop()
break
else:
search, drop = os.path.split(search)
if not drop: search = drive
if not projectFile or not os.path.exists(os.path.join(search, projectFile)): # pragma: no cover
if os.getenv('PYSYS_PERMIT_NO_PROJECTFILE','').lower()=='true':
sys.stderr.write('FATAL ERROR: The PYSYS_PERMIT_NO_PROJECTFILE environment variable is no longer supported - you must create a pysysproject.xml file for your project')
sys.exit(1)
else:
sys.stderr.write('\n'.join([
# |
"WARNING: No PySys test project file exists in this directory (or its parents):",
" - If you wish to start a new project, begin by running 'pysys makeproject'.",
" - If you are trying to use an existing project, change directory to a ",
" location under the directory that contains your project file.",
""
]))
sys.exit(1)
try:
project = Project(search, projectFile, outdir=outdir)
stdoutHandler.setFormatter(project.formatters.stdout)
import pysys.constants
pysys.constants.PROJECT = project # for compatibility for old tests
Project.__INSTANCE = project # set singleton
return project
except UserError as e:
sys.stderr.write("ERROR: Failed to load project - %s"%e)
sys.exit(1)
except Exception as e:
sys.stderr.write("ERROR: Failed to load project due to %s - %s\n"%(e.__class__.__name__, e))
traceback.print_exc()
sys.exit(1)
pysys.constants.Project = Project # for compatibility's sake' need to do this early
import pysys.utils.safeeval # down here to break circular dependency
import pysys.config.descriptor