Gaudi Framework, version v20r4

Generated: 8 Jan 2009

GaudiTest.py

Go to the documentation of this file.
00001 ########################################################################
00002 # File:   CoolTestExtensions.py
00003 # Author: Marco Clemencic CERN/PH-LBC
00004 # Date:   16/10/2007  
00005 #
00006 # Contents:
00007 #   TemporaryEnvironment: class to change the environment and revert to
00008 #                         the original one
00009 #
00010 #   DBPreparer: handle the details for temporary test databases
00011 #
00012 #   StandardTest: extension to ShellCommandTest that finds Python
00013 #                 scripts in the PATH ad run them through the
00014 #                 interpreter
00015 #
00016 #   DatabaseTest: run a test that needs a database
00017 #
00018 #   SourceTargetTest: run a test that needs 2 databases
00019 #                     (source and target)
00020 #
00021 # Copyright (c) 2003 by CodeSourcery, LLC.  All rights reserved.
00022 #
00023 ########################################################################
00024 __author__  = 'Marco Clemencic CERN/PH-LBC' 
00025 __version__ = "$Revision: 1.52 $" 
00026 __tag__     = "$Name:  $" 
00027 ########################################################################
00028 # Imports
00029 ########################################################################
00030 import os
00031 import sys
00032 import re
00033 import tempfile
00034 import shutil
00035 import string
00036 import difflib
00037 from subprocess import Popen, PIPE, STDOUT
00038 
00039 import qm
00040 from qm.test.classes.command import ExecTestBase
00041 
00042 ########################################################################
00043 # Utility Classes
00044 ########################################################################
00045 class TemporaryEnvironment:
00046     """
00047     Class to changes the environment temporarily.
00048     """
00049     def __init__(self, orig = os.environ, keep_same = False):
00050         """
00051         Create a temporary environment on top of the one specified
00052         (it can be another TemporaryEnvironment instance).
00053         """
00054         #print "New environment"
00055         self.old_values = {}
00056         self.env = orig
00057         self._keep_same = keep_same
00058         
00059     def __setitem__(self,key,value):
00060         """
00061         Set an environment variable recording the previous value.
00062         """
00063         if key not in self.old_values :
00064             if key in self.env :
00065                 if not self._keep_same or self.env[key] != value:
00066                     self.old_values[key] = self.env[key]
00067             else:
00068                 self.old_values[key] = None
00069         self.env[key] = value
00070         
00071     def __getitem__(self,key):
00072         """
00073         Get an environment variable.
00074         Needed to provide the same interface as os.environ.
00075         """
00076         return self.env[key]
00077     
00078     def __delitem__(self,key):
00079         """
00080         Unset an environment variable.
00081         Needed to provide the same interface as os.environ.
00082         """
00083         if key not in self.env :
00084             raise KeyError(key)
00085         self.old_values[key] = self.env[key]
00086         del self.env[key]
00087 
00088     def keys(self):
00089         """
00090         Return the list of defined environment variables.
00091         Needed to provide the same interface as os.environ.
00092         """
00093         return self.env.keys()
00094         
00095     def items(self):
00096         """
00097         Return the list of (name,value) pairs for the defined environment variables.
00098         Needed to provide the same interface as os.environ.
00099         """
00100         return self.env.items()
00101     
00102     def __contains__(self,key):
00103         """
00104         Operator 'in'.
00105         Needed to provide the same interface as os.environ.
00106         """
00107         return key in self.env
00108     
00109     def restore(self):
00110         """
00111         Revert all the changes done to the orignal environment.
00112         """
00113         for key,value in self.old_values.items():
00114             if value is None:
00115                 del self.env[key]
00116             else:
00117                 self.env[key] = value
00118         self.old_values = {}
00119         
00120     def __del__(self):
00121         """
00122         Revert the changes on destruction.
00123         """
00124         #print "Restoring the environment"
00125         self.restore()
00126 
00127     def gen_script(self,shell_type):
00128         """
00129         Generate a shell script to reproduce the changes in the environment.
00130         """
00131         shells = [ 'csh', 'sh', 'bat' ]
00132         if shell_type not in shells:
00133             raise RuntimeError("Shell type '%s' unknown. Available: %s"%(shell_type,shells))
00134         out = ""
00135         for key,value in self.old_values.items():
00136             if key not in self.env:
00137                 # unset variable
00138                 if shell_type == 'csh':
00139                     out += 'unsetenv %s\n'%key
00140                 elif shell_type == 'sh':
00141                     out += 'unset %s\n'%key
00142                 elif shell_type == 'bat':
00143                     out += 'set %s=\n'%key
00144             else:
00145                 # set variable
00146                 if shell_type == 'csh':
00147                     out += 'setenv %s "%s"\n'%(key,self.env[key])
00148                 elif shell_type == 'sh':
00149                     out += 'export %s="%s"\n'%(key,self.env[key])
00150                 elif shell_type == 'bat':
00151                     out += 'set %s=%s\n'%(key,self.env[key])
00152         return out
00153 
00154 class TempDir:
00155     """Small class for temporary directories.
00156     When instantiated, it creates a temporary directory and the instance
00157     behaves as the string containing the directory name.
00158     When the instance goes out of scope, it removes all the content of
00159     the temporary directory (automatic clean-up).
00160     """
00161     def __init__(self, keep = False, chdir = False):
00162         self.name = tempfile.mkdtemp()
00163         self._keep = keep
00164         self._origdir = None
00165         if chdir:
00166             self._origdir = os.getcwd()
00167             os.chdir(self.name)
00168         
00169     def __str__(self):
00170         return self.name
00171     
00172     def __del__(self):
00173         if self._origdir:
00174             os.chdir(self._origdir)
00175         if self.name and not self._keep:
00176             shutil.rmtree(self.name)
00177     
00178     def __getattr__(self,attr):
00179         return getattr(self.name,attr)
00180 
00181 class TempFile:
00182     """Small class for temporary files.
00183     When instantiated, it creates a temporary directory and the instance
00184     behaves as the string containing the directory name.
00185     When the instance goes out of scope, it removes all the content of
00186     the temporary directory (automatic clean-up).
00187     """
00188     def __init__(self, suffix='', prefix='tmp', dir=None, text=False, keep = False):
00189         self.file = None
00190         self.name = None
00191         self._keep = keep
00192         
00193         self._fd, self.name = tempfile.mkstemp(suffix,prefix,dir,text)
00194         self.file = os.fdopen(self._fd,"r+")
00195         
00196     def __str__(self):
00197         return self.name
00198     
00199     def __del__(self):
00200         if self.file:
00201             self.file.close()
00202         if self.name and not self._keep:
00203             os.remove(self.name)
00204     
00205     def __getattr__(self,attr):
00206         return getattr(self.file,attr)
00207 
00208 class CMT:
00209     """Small wrapper to call CMT.
00210     """
00211     def __init__(self,path=None):
00212         if path is None:
00213             path = os.getcwd()
00214         self.path = path
00215     
00216     def _run_cmt(self,command,args):
00217         # prepare command line
00218         if type(args) is str:
00219             args = [args]
00220         cmd = "cmt %s"%command
00221         for arg in args:
00222             cmd += ' "%s"'%arg
00223         
00224         # go to the execution directory
00225         olddir = os.getcwd()
00226         os.chdir(self.path)
00227         # run cmt
00228         result = os.popen4(cmd)[1].read()
00229         # return to the old directory
00230         os.chdir(olddir)
00231         return result
00232     
00233     def __getattr__(self,attr):
00234         return lambda args=[]: self._run_cmt(attr, args)
00235     
00236     def runtime_env(self,env = None):
00237         """Returns a dictionary containing the runtime environment produced by CMT.
00238         If a dictionary is passed a modified instance of it is returned.
00239         """
00240         if env is None:
00241             env = {}
00242         for l in self.setup("-csh").splitlines():
00243             l = l.strip()
00244             if l.startswith("setenv"):
00245                 dummy,name,value = l.split(None,3)
00246                 env[name] = value.strip('"')
00247             elif l.startswith("unsetenv"):
00248                 dummy,name = l.split(None,2)
00249                 if name in env:
00250                     del env[name]
00251         return env
00252     def show_macro(self,k):
00253         r = self.show(["macro",k])
00254         if r.find("CMT> Error: symbol not found") >= 0:
00255             return None
00256         else:
00257             return self.show(["macro_value",k]).strip()
00258 
00259 ########################################################################
00260 # Output Validation Classes
00261 ########################################################################
00262 class BasicOutputValidator:
00263     """Basic implementation of an option validator for Gaudi tests.
00264     This implementation is based on the standard (LCG) validation functions
00265     used in QMTest.
00266     """
00267     def __init__(self,ref,cause,result_key):
00268         self.reference = ref
00269         self.cause = cause
00270         self.result_key = result_key
00271     
00272     def __call__(self, out, result):
00273         """Validate the output of the program.
00274 
00275         'stdout' -- A string containing the data written to the standard output
00276         stream.
00277 
00278         'stderr' -- A string containing the data written to the standard error
00279         stream.
00280 
00281         'result' -- A 'Result' object. It may be used to annotate
00282         the outcome according to the content of stderr.
00283 
00284         returns -- A list of strings giving causes of failure."""
00285 
00286         causes = []
00287         # Check to see if theoutput matches.
00288         if not self.__CompareText(out, self.reference):
00289             causes.append(self.cause) 
00290             result[self.result_key] = result.Quote(self.reference)
00291 
00292         return causes
00293     
00294     def __CompareText(self, s1, s2):
00295         """Compare 's1' and 's2', ignoring line endings.
00296 
00297         's1' -- A string.
00298 
00299         's2' -- A string.
00300 
00301         returns -- True if 's1' and 's2' are the same, ignoring
00302         differences in line endings."""
00303 
00304         # The "splitlines" method works independently of the line ending
00305         # convention in use.
00306         return s1.splitlines() == s2.splitlines()
00307 
00308 class FilePreprocessor:
00309     """ Base class for a callable that takes a file and returns a modified
00310     version of it."""
00311     def __processLine__(self, line):
00312         return line
00313     def __call__(self, input):
00314         if hasattr(input,"__iter__"):
00315             lines = input
00316             mergeback = False
00317         else:
00318             lines = input.splitlines()
00319             mergeback = True
00320         output = []
00321         for l in lines:
00322             l = self.__processLine__(l)
00323             if l: output.append(l)
00324         if mergeback: output = '\n'.join(output)
00325         return output
00326     def __add__(self, rhs):
00327         return FilePreprocessorSequence([self,rhs])
00328 
00329 class FilePreprocessorSequence(FilePreprocessor):
00330     def __init__(self, members = []):
00331         self.members = members
00332     def __add__(self, rhs):
00333         return FilePreprocessorSequence(self.members + [rhs])
00334     def __call__(self, input):
00335         output = input
00336         for pp in self.members:
00337             output = pp(output)
00338         return output
00339 
00340 class LineSkipper(FilePreprocessor):
00341     def __init__(self, strings = [], regexps = []):
00342         import re
00343         self.strings = strings
00344         self.regexps = map(re.compile,regexps)
00345         
00346     def __processLine__(self, line):
00347         for s in self.strings:
00348             if line.find(s) >= 0: return None
00349         for r in self.regexps:
00350             if r.search(line): return None
00351         return line
00352 
00353 class RegexpReplacer(FilePreprocessor):
00354     def __init__(self, orig, repl = "", when = None):
00355         if when:
00356             when = re.compile(when)
00357         self._operations = [ (when, re.compile(orig), repl) ]
00358     def __add__(self,rhs):
00359         if isinstance(rhs, RegexpReplacer):
00360             res = RegexpReplacer("","",None)
00361             res._operations = self._operations + rhs._operations
00362         else:
00363             res = FilePreprocessor.__add__(self, rhs)
00364         return res
00365     def __processLine__(self, line):
00366         for w,o,r in self._operations:
00367             if w is None or w.search(line):
00368                 line = o.sub(r, line)
00369         return line
00370 
00371 # Common preprocessors        
00372 maskPointers  = RegexpReplacer("0x[0-9a-fA-F]{4,16}","0x########")
00373 normalizeDate = RegexpReplacer("[0-2]?[0-9]:[0-5][0-9]:[0-5][0-9] [0-9]{4}[-/][01][0-9][-/][0-3][0-9] *(CES?T)?",
00374                                "00:00:00 1970-01-01")
00375 normalizeEOL = FilePreprocessor()
00376 normalizeEOL.__processLine__ = lambda line: str(line).rstrip() + '\n'
00377 
00378 skipEmptyLines = FilePreprocessor()
00379 # FIXME: that's ugly
00380 skipEmptyLines.__processLine__ = lambda line: (line.strip() and line) or None
00381 
00382 # Preprocessors for GaudiExamples
00383 normalizeExamples = maskPointers + normalizeDate 
00384 for w,o,r in [
00385               #("TIMER.TIMER",r"[0-9]", "0"), # Normalize time output
00386               ("TIMER.TIMER",r"\s+[+-]?[0-9]+[0-9.]*", " 0"), # Normalize time output
00387               ("release all pending",r"^.*/([^/]*:.*)",r"\1"),
00388               ("0x########",r"\[.*/([^/]*.*)\]",r"[\1]"),
00389               ("^#.*file",r"file '.*[/\\]([^/\\]*)$",r"file '\1"),
00390               ("^JobOptionsSvc.*options successfully read in from",r"read in from .*[/\\]([^/\\]*)$",r"file \1"), # normalize path to options
00391               # Normalize UUID, except those ending with all 0s (i.e. the class IDs)
00392               (None,r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}(?!-0{12})-[0-9A-Fa-f]{12}","00000000-0000-0000-0000-000000000000")
00393               ]: #[ ("TIMER.TIMER","[0-9]+[0-9.]*", "") ]
00394     normalizeExamples += RegexpReplacer(o,r,w)
00395 normalizeExamples = LineSkipper(["//GP:",
00396                                  "Time User",
00397                                  "Welcome to",
00398                                  "This machine has a speed",
00399                                  "TIME:",
00400                                  "running on",
00401                                  "ToolSvc.Sequenc...   INFO",
00402                                  "DataListenerSvc      INFO XML written to file:",
00403                                  "[INFO]","[WARNING]",
00404                                  "DEBUG No writable file catalog found which contains FID:",
00405                                  "0 local", # hack for ErrorLogExample
00406                                  # This comes from ROOT, when using GaudiPython 
00407                                  'Note: (file "(tmpfile)", line 2) File "set" already loaded',
00408                                  ],regexps = [
00409                                  r"^#", # Ignore python comments
00410                                  r"(Always|SUCCESS)\s*(Root f|[^ ]* F)ile version:", # skip the message reporting the version of the root file
00411                                  r"0x[0-9a-fA-F#]+ *Algorithm::sysInitialize\(\) *\[", # hack for ErrorLogExample
00412                                  r"0x[0-9a-fA-F#]* *__gxx_personality_v0 *\[", # hack for ErrorLogExample
00413                                  r"File '.*.xml' does not exist",
00414                                  r"INFO Refer to dataset .* by its file ID:",
00415                                  r"INFO Referring to dataset .* by its file ID:",
00416                                  r"INFO Disconnect from dataset",
00417                                  r"INFO Disconnected from dataset",
00418                                  r"INFO Disconnected data IO:",
00419                                  r"IncidentSvc\s*(DEBUG (Adding|Removing)|VERBOSE Calling)",
00420                                  # I want to ignore the header of the unchecked StatusCode report
00421                                  r"^StatusCodeSvc.*listing all unchecked return codes:",
00422                                  r"^StatusCodeSvc\s*INFO\s*$",
00423                                  r"Num\s*|\s*Function\s*|\s*Source Library",
00424                                  r"^[-+]*\s*$",
00425                                  # Hide the fake error message coming from POOL/ROOT (ROOT 5.21)
00426                                  r"ERROR Failed to modify file: .* Errno=2 No such file or directory",
00427                                  # Hide unckeched StatusCodes  from dictionaries
00428                                  r"^ +[0-9]+ \|.*ROOT", 
00429                                  r"^ +[0-9]+ \|.*\|.*Dict", 
00430                                  # Remove ROOT TTree summary table, which changes from one version to the other
00431                                  r"^\*.*\*$",
00432                                  # Remove Histos Summaries
00433                                  r"SUCCESS\s*Booked \d+ Histogram\(s\)",
00434                                  r"^ \|",
00435                                  r"^ ID=",
00436                                  ] ) + normalizeExamples + skipEmptyLines + \
00437                                   normalizeEOL
00438 
00439 class ReferenceFileValidator:
00440     def __init__(self, reffile, cause, result_key, preproc = normalizeExamples):
00441         self.reffile = os.path.expandvars(reffile)
00442         self.cause = cause
00443         self.result_key = result_key
00444         self.preproc = preproc
00445     def __call__(self, stdout, result):
00446         causes = []
00447         if os.path.isfile(self.reffile):
00448             orig = open(self.reffile).xreadlines()
00449             if self.preproc:
00450                 orig = self.preproc(orig)
00451         else:
00452             orig = []
00453         
00454         new = stdout.splitlines()
00455         if self.preproc:
00456             new = self.preproc(new)
00457         #open(self.reffile + ".test","w").writelines(new)
00458         diffs = difflib.ndiff(orig,new,charjunk=difflib.IS_CHARACTER_JUNK)
00459         filterdiffs = map(lambda x: x.strip(),filter(lambda x: x[0] != " ",diffs))
00460         #filterdiffs = [x.strip() for x in diffs]
00461         if filterdiffs:
00462             result[self.result_key] = result.Quote("\n".join(filterdiffs))
00463             result[self.result_key] += result.Quote("""
00464 Legend:
00465         -) reference file
00466         +) standard output of the test""")
00467             causes.append(self.cause)
00468         
00469         return causes
00470 
00471 ########################################################################
00472 # Useful validation functions
00473 ########################################################################
00474 def findReferenceBlock(reference, stdout, result, causes, signature_offset=0, signature=None,
00475                        id = None):
00476     """
00477     Given a block of text, tries to find it in the output.
00478     The block had to be identified by a signature line. By default, the first
00479     line is used as signature, or the line pointed to by signature_offset. If
00480     signature_offset points outside the block, a signature line can be passed as
00481     signature argument. Note: if 'signature' is None (the default), a negative
00482     signature_offset is interpreted as index in a list (e.g. -1 means the last
00483     line), otherwise the it is interpreted as the number of lines before the
00484     first one of the block the signature must appear.
00485     The parameter 'id' allow to distinguish between different calls to this
00486     function in the same validation code.
00487     """
00488     # split reference file, sanitize EOLs and remove empty lines
00489     reflines = filter(None,map(lambda s: s.rstrip(), reference.splitlines()))
00490     if not reflines:
00491         raise RuntimeError("Empty (or null) reference")
00492     # the same on standard output
00493     outlines = filter(None,map(lambda s: s.rstrip(), stdout.splitlines()))
00494     
00495     res_field = "GaudiTest.RefBlock"
00496     if id:
00497         res_field += "_%s" % id 
00498     
00499     if signature is None:
00500         if signature_offset < 0:
00501             signature_offset = len(reference)+signature_offset
00502         signature = reflines[signature_offset]
00503     # find the reference block in the output file
00504     try:
00505         pos = outlines.index(signature)
00506         outlines = outlines[pos-signature_offset:pos+len(reflines)-signature_offset]
00507         if reflines != outlines:
00508             msg = "standard output"
00509             # I do not want 2 messages in causes if teh function is called twice
00510             if not msg in causes: 
00511                 causes.append(msg)
00512             result[res_field + ".observed"] = result.Quote("\n".join(outlines))
00513     except ValueError:
00514         causes.append("missing signature")
00515     result[res_field + ".signature"] = result.Quote(signature)
00516     if len(reflines) > 1 or signature != reflines[0]:
00517         result[res_field + ".expected"] = result.Quote("\n".join(reflines))
00518     
00519     return causes
00520 
00521 def countErrorLines(expected = {'ERROR':0, 'FATAL':0}, **kwargs):
00522     """
00523     Count the number of messages with required severity (by default ERROR and FATAL)
00524     and check if their numbers match the expected ones (0 by default).
00525     The dictionary "expected" can be used to tune the number of errors and fatals
00526     allowed, or to limit the number of expected warnings etc.
00527     """
00528     stdout = kwargs["stdout"]
00529     result = kwargs["result"]
00530     causes = kwargs["causes"]
00531     
00532     # prepare the dictionary to record the extracted lines
00533     errors = {}
00534     for sev in expected:
00535         errors[sev] = []
00536     
00537     outlines = stdout.splitlines()
00538     from math import log10
00539     fmt = "%%%dd - %%s" % (int(log10(len(outlines))+1))
00540     
00541     linecount = 0
00542     for l in outlines:
00543         linecount += 1
00544         words = l.split()
00545         if len(words) >= 2 and words[1] in errors:
00546             errors[words[1]].append(fmt%(linecount,l.rstrip()))
00547     
00548     for e in errors:
00549         if len(errors[e]) != expected[e]:
00550             causes.append('%s(%d)'%(e,len(errors[e])))
00551             result["GaudiTest.lines.%s"%e] = result.Quote('\n'.join(errors[e]))
00552             result["GaudiTest.lines.%s.expected#"%e] = result.Quote(str(expected[e]))
00553     
00554     return causes
00555 
00556 
00557 def _parseTTreeSummary(lines, pos):
00558     """
00559     Parse the TTree summary table in lines, starting from pos.
00560     Returns a tuple with the dictionary with the digested informations and the
00561     position of the first line after the summary.
00562     """
00563     result = {}
00564     i = pos + 1 # first line is a sequence of '*'
00565     count = len(lines)
00566     
00567     splitcols = lambda l: [ f.strip() for f in l.strip("*\n").split(':',2) ]
00568     def parseblock(ll):
00569         r = {}
00570         cols = splitcols(ll[0])
00571         r["Name"], r["Title"] = cols[1:]
00572         
00573         cols = splitcols(ll[1])
00574         r["Entries"] = int(cols[1])
00575         
00576         sizes = cols[2].split()
00577         r["Total size"] = int(sizes[2])
00578         if sizes[-1] == "memory":
00579             r["File size"] = 0
00580         else:
00581             r["File size"] = int(sizes[-1])
00582         
00583         cols = splitcols(ll[2])
00584         sizes = cols[2].split()
00585         if cols[0] == "Baskets":
00586             r["Baskets"] = int(cols[1])
00587             r["Basket size"] = int(sizes[2])
00588         r["Compression"] = float(sizes[-1])
00589         return r    
00590         
00591     if i < (count - 3) and lines[i].startswith("*Tree"):
00592         result = parseblock(lines[i:i+3])
00593         result["Branches"] = {}
00594         i += 4
00595         while i < (count - 3) and lines[i].startswith("*Br"):
00596             branch = parseblock(lines[i:i+3])
00597             result["Branches"][branch["Name"]] = branch
00598             i += 4
00599         
00600     return (result, i)
00601 
00602 def findTTreeSummaries(stdout):
00603     """
00604     Scan stdout to find ROOT TTree summaries and digest them.
00605     """
00606     stars = re.compile(r"^\*+$")
00607     outlines = stdout.splitlines()
00608     nlines = len(outlines)
00609     trees = {}
00610     
00611     i = 0
00612     while i < nlines: #loop over the output
00613         # look for
00614         while i < nlines and not stars.match(outlines[i]):
00615             i += 1
00616         if i < nlines:
00617             tree, i = _parseTTreeSummary(outlines, i)
00618             if tree:
00619                 trees[tree["Name"]] = tree
00620     
00621     return trees
00622 
00623 def cmpTreesDicts(reference, to_check, ignore = None):
00624     """
00625     Check that all the keys in reference are in to_check too, with the same value.
00626     If the value is a dict, the function is called recursively. to_check can
00627     contain more keys than reference, that will not be tested.
00628     The function returns at the first difference found. 
00629     """
00630     fail_keys = []
00631     # filter the keys in the reference dictionary
00632     if ignore:
00633         ignore_re = re.compile(ignore)
00634         keys = [ key for key in reference if not ignore_re.match(key) ]
00635     else:
00636         keys = reference.keys()
00637     # loop over the keys (not ignored) in the reference dictionary
00638     for k in keys:
00639         if k in to_check: # the key must be in the dictionary to_check
00640             if (type(reference[k]) is dict) and (type(to_check[k]) is dict):
00641                 # if both reference and to_check values are dictionaries, recurse
00642                 failed = fail_keys = cmpTreesDicts(reference[k], to_check[k], ignore)
00643             else:
00644                 # compare the two values
00645                 failed = to_check[k] != reference[k]
00646         else: # handle missing keys in the dictionary to check (i.e. failure)
00647             to_check[k] = None
00648             failed = True
00649         if failed:
00650             fail_keys.insert(0, k)
00651             break # exit from the loop at the first failure
00652     return fail_keys # return the list of keys bringing to the different values
00653 
00654 def getCmpFailingValues(reference, to_check, fail_path):
00655     c = to_check
00656     r = reference
00657     for k in fail_path:
00658         c = c.get(k,None)
00659         r = r.get(k,None)
00660         if c is None or r is None:
00661             break # one of the dictionaries is not deep enough
00662     return (fail_path, r, c)
00663 
00664 # signature of the print-out of the histograms
00665 h_count_re = re.compile(r"SUCCESS\s+Booked (\d+) Histogram\(s\) :\s+(.*)")
00666 
00667 def parseHistosSummary(lines, pos):
00668     """
00669     Extract the histograms infos from the lines starting at pos.
00670     Returns the position of the first line after the summary block.
00671     """
00672     global h_count_re
00673     h_table_head = re.compile(r'SUCCESS\s+List of booked (1D|2D|3D|1D profile|2D profile) histograms in directory\s+"(\w*)"')
00674     h_short_summ = re.compile(r"ID=([^\"]+)\s+\"([^\"]+)\"\s+(.*)")
00675     
00676     nlines = len(lines)
00677     
00678     # decode header
00679     m = h_count_re.search(lines[pos])
00680     total = int(m.group(1))
00681     partials = {}
00682     for k, v in [ x.split("=") for x in  m.group(2).split() ]:
00683         partials[k] = int(v)
00684     pos += 1
00685     
00686     summ = {}
00687     while pos < nlines:
00688         m = h_table_head.search(lines[pos])
00689         if m:
00690             t, d = m.groups(1) # type and directory
00691             t = t.replace(" profile", "Prof")
00692             pos += 1
00693             if pos < nlines:
00694                 l = lines[pos]
00695             else:
00696                 l = ""
00697             cont = {}
00698             if l.startswith(" | ID"):
00699                 # table format
00700                 titles = [ x.strip() for x in l.split("|")][1:]
00701                 pos += 1
00702                 while pos < nlines and lines[pos].startswith(" |"):
00703                     l = lines[pos]
00704                     values = [ x.strip() for x in l.split("|")][1:]
00705                     hcont = {}
00706                     for i in range(len(titles)):
00707                         hcont[titles[i]] = values[i]
00708                     cont[hcont["ID"]] = hcont
00709                     pos += 1
00710             elif l.startswith(" ID="):
00711                 while pos < nlines and lines[pos].startswith(" ID="):
00712                     values = [ x.strip() for x in  h_short_summ.search(lines[pos]).groups() ]
00713                     cont[values[0]] = values
00714                     pos += 1
00715             else: # not interpreted
00716                 raise RuntimeError("Cannot understand line %d: '%s'" % (pos, l))
00717             if not d in summ:
00718                 summ[d] = {}
00719             summ[d][t] = cont
00720             summ[d]["header"] = partials
00721             summ[d]["header"]["Total"] = total
00722         else:
00723             break
00724     return summ, pos
00725 
00726 def findHistosSummaries(stdout):
00727     """
00728     Scan stdout to find ROOT TTree summaries and digest them.
00729     """
00730     outlines = stdout.splitlines()
00731     nlines = len(outlines) - 1
00732     summaries = {}
00733     global h_count_re
00734     
00735     pos = 0
00736     while pos < nlines:
00737         summ = {}
00738         # find first line of block:
00739         match = h_count_re.search(outlines[pos])
00740         while pos < nlines and not match:
00741             pos += 1
00742             match = h_count_re.search(outlines[pos])
00743         if match:
00744             summ, pos = parseHistosSummary(outlines, pos)
00745         summaries.update(summ)
00746     return summaries
00747     
00748 ########################################################################
00749 # Test Classes
00750 ########################################################################
00751 class GaudiExeTest(ExecTestBase):
00752     """Standard Gaudi test.
00753     """
00754     arguments = [
00755         qm.fields.TextField(
00756             name="program",
00757             title="Program",
00758             not_empty_text=1,
00759             description="""The path to the program.
00760 
00761             This field indicates the path to the program.  If it is not
00762             an absolute path, the value of the 'PATH' environment
00763             variable will be used to search for the program.
00764             If not specified, $GAUDIEXE or Gaudi.exe are used.
00765             """
00766             ),
00767         qm.fields.SetField(qm.fields.TextField(
00768             name="args",
00769             title="Argument List",
00770             description="""The command-line arguments.
00771 
00772             If this field is left blank, the program is run without any
00773             arguments.
00774 
00775             Use this field to specify the option files.
00776 
00777             An implicit 0th argument (the path to the program) is added
00778             automatically."""
00779             )),
00780         qm.fields.TextField(
00781             name="options",
00782             title="Options",
00783             description="""Options to be passed to the application.
00784             
00785             This field allows to pass a list of options to the main program
00786             without the need of a separate option file.
00787             
00788             The content of the field is written to a temporary file which name
00789             is passed the the application as last argument (appended to the
00790             field "Argument List".
00791             """,
00792             verbatim="true",
00793             multiline="true",
00794             default_value=""
00795             ),
00796         qm.fields.TextField(
00797             name="workdir",
00798             title="Working Directory",
00799             description="""Path to the working directory.
00800 
00801             If this field is left blank, the program will be run from the qmtest
00802             directory, otherwise from the directory specified.""",
00803             default_value=""
00804             ),
00805         qm.fields.TextField(
00806             name="reference",
00807             title="Reference Output",
00808             description="""Path to the file containing the reference output.
00809 
00810             If this field is left blank, any standard output will be considered
00811             valid.
00812             
00813             If the reference file is specified, any output on standard error is
00814             ignored."""
00815             ),
00816         qm.fields.TextField(
00817             name="error_reference",
00818             title="Reference for standard error",
00819             description="""Path to the file containing the reference for the standard error.
00820 
00821             If this field is left blank, any standard output will be considered
00822             valid.
00823             
00824             If the reference file is specified, any output on standard error is
00825             ignored."""
00826             ),
00827         qm.fields.SetField(qm.fields.TextField(
00828             name = "unsupported_platforms",
00829             title = "Unsupported Platforms",
00830             description = """Platform on which the test must not be run.
00831             
00832             List of regular expressions identifying the platforms on which the
00833             test is not run and the result is set to UNTESTED."""
00834             )),
00835                 
00836         qm.fields.TextField(
00837             name = "validator",
00838             title = "Validator",
00839             description = """Function to validate the output of the test.
00840             
00841             If defined, the function is used to validate the products of the
00842             test.
00843             The function is called passing as arguments:
00844               self:   the test class instance
00845               stdout: the standard output of the executed test
00846               stderr: the standard error of the executed test
00847               result: the Result objects to fill with messages
00848             The function must return a list of causes for the failure.
00849             If specified, overrides standard output, standard error and
00850             reference files.
00851             """,
00852             verbatim="true",
00853             multiline="true",
00854             default_value=""
00855             ),
00856             
00857         qm.fields.BooleanField(
00858             name = "use_temp_dir",
00859             title = "Use temporary directory",
00860             description = """Use temporary directory.
00861             
00862             If set to true, use a temporary directory as working directory.
00863             """,
00864             default_value="false"
00865             ),
00866         ]
00867     
00868     def PlatformIsNotSupported(self, context, result):
00869         platform = self.GetPlatform()
00870         unsupported = [ re.compile(x)
00871                         for x in [ str(y).strip()
00872                                    for y in self.unsupported_platforms ]
00873                         if x
00874                        ]
00875         for p_re in unsupported:
00876             if p_re.search(platform):
00877                 result.SetOutcome(result.UNTESTED)
00878                 result[result.CAUSE] = 'Platform not supported.'
00879                 return True
00880         return False
00881     
00882     def GetPlatform(self):
00883         """
00884         Return the platform Id defined in CMTCONFIG or SCRAM_ARCH.
00885         """
00886         arch = "None"
00887         # check architecture name
00888         if "CMTCONFIG" in os.environ:
00889             arch = os.environ["CMTCONFIG"]
00890         elif "SCRAM_ARCH" in os.environ:
00891             arch = os.environ["SCRAM_ARCH"]
00892         return arch
00893     
00894     def _expandReferenceFileName(self, reffile):
00895         # if no file is passed, do nothing 
00896         if not reffile:
00897             return ""
00898         
00899         reference = os.path.normpath(os.path.expandvars(reffile))
00900         # old-style platform-specific reference name
00901         spec_ref = reference[:-3] + self.GetPlatform()[0:3] + reference[-3:]
00902         if os.path.isfile(spec_ref):
00903             reference = spec_ref
00904         else: # look for new-style platform specific reference files:
00905             # get all the files whose name start with the reference filename
00906             dirname, basename = os.path.split(reference)
00907             if not dirname: dirname = '.'
00908             head = basename + "."
00909             head_len = len(head)
00910             platform = self.GetPlatform()
00911             candidates = []
00912             for f in os.listdir(dirname):
00913                 if f.startswith(head) and platform.startswith(f[head_len:]):
00914                     candidates.append( (len(f) - head_len, f) )
00915             if candidates: # take the one with highest matching
00916                 candidates.sort()
00917                 reference = os.path.join(dirname, candidates[-1][1])
00918         return reference
00919         
00920     def CheckTTreesSummaries(self, stdout, result, causes,
00921                              trees_dict = None,
00922                              ignore = r"Basket|.*size|Compression"):
00923         """
00924         Compare the TTree summaries in stdout with the ones in trees_dict or in
00925         the reference file. By default ignore the size, compression and basket
00926         fields.
00927         The presence of TTree summaries when none is expected is not a failure.
00928         """
00929         if trees_dict is None:
00930             reference = self._expandReferenceFileName(self.reference)
00931             # call the validator if the file exists
00932             if reference and os.path.isfile(reference):
00933                 trees_dict = findTTreeSummaries(open(reference).read())
00934             else:
00935                 trees_dict = {}
00936         
00937         from pprint import PrettyPrinter
00938         pp = PrettyPrinter()
00939         if trees_dict:
00940             result["GaudiTest.TTrees.expected"] = result.Quote(pp.pformat(trees_dict))        
00941             if ignore:
00942                 result["GaudiTest.TTrees.ignore"] = result.Quote(ignore)
00943         
00944         trees = findTTreeSummaries(stdout)
00945         failed = cmpTreesDicts(trees_dict, trees, ignore)
00946         if failed:
00947             causes.append("trees summaries")
00948             msg = "%s: %s != %s" % getCmpFailingValues(trees_dict, trees, failed)
00949             result["GaudiTest.TTrees.failure_on"] = result.Quote(msg)
00950             result["GaudiTest.TTrees.found"] = result.Quote(pp.pformat(trees))
00951         
00952         return causes
00953 
00954     def CheckHistosSummaries(self, stdout, result, causes,
00955                              dict = None,
00956                              ignore = None):
00957         """
00958         Compare the TTree summaries in stdout with the ones in trees_dict or in
00959         the reference file. By default ignore the size, compression and basket
00960         fields.
00961         The presence of TTree summaries when none is expected is not a failure.
00962         """
00963         if dict is None:
00964             reference = self._expandReferenceFileName(self.reference)
00965             # call the validator if the file exists
00966             if reference and os.path.isfile(reference):
00967                 dict = findHistosSummaries(open(reference).read())
00968             else:
00969                 dict = {}
00970         
00971         from pprint import PrettyPrinter
00972         pp = PrettyPrinter()
00973         if dict:
00974             result["GaudiTest.Histos.expected"] = result.Quote(pp.pformat(dict))        
00975             if ignore:
00976                 result["GaudiTest.Histos.ignore"] = result.Quote(ignore)
00977         
00978         histos = findHistosSummaries(stdout)
00979         failed = cmpTreesDicts(dict, histos, ignore)
00980         if failed:
00981             causes.append("histos summaries")
00982             msg = "%s: %s != %s" % getCmpFailingValues(dict, histos, failed)
00983             result["GaudiTest.Histos.failure_on"] = result.Quote(msg)
00984             result["GaudiTest.Histos.found"] = result.Quote(pp.pformat(histos))
00985         
00986         return causes
00987 
00988     def ValidateWithReference(self, stdout, stderr, result, causes, preproc = None):
00989         """
00990         Default validation action: compare standard output and error to the
00991         reference files.
00992         """
00993         # set the default output preprocessor
00994         if preproc is None:
00995             preproc = normalizeExamples
00996         # check standard output
00997         reference = self._expandReferenceFileName(self.reference)
00998         # call the validator if the file exists
00999         if reference and os.path.isfile(reference):
01000             result["GaudiTest.output_reference"] = reference
01001             causes += ReferenceFileValidator(reference,
01002                                              "standard output",
01003                                              "GaudiTest.output_diff",
01004                                              preproc = preproc)(stdout, result)
01005         
01006         # Compare TTree summaries
01007         causes = self.CheckTTreesSummaries(stdout, result, causes)
01008         causes = self.CheckHistosSummaries(stdout, result, causes)
01009         
01010         if causes: # Write a new reference file for stdout
01011             newref = open(reference + ".new","w")
01012             # sanitize newlines
01013             for l in stdout.splitlines():
01014                 newref.write(l.rstrip() + '\n')
01015             del newref # flush and close
01016         
01017         
01018         # check standard error
01019         reference = self._expandReferenceFileName(self.error_reference)
01020         # call the validator if we have a file to use
01021         if reference and os.path.isfile(reference):
01022             result["GaudiTest.error_reference"] = reference
01023             newcauses = ReferenceFileValidator(reference,
01024                                                "standard error",
01025                                                "GaudiTest.error_diff",
01026                                                preproc = preproc)(stderr, result)
01027             causes += newcauses
01028             if newcauses: # Write a new reference file for stdedd
01029                 newref = open(reference + ".new","w")
01030                 # sanitize newlines
01031                 for l in stderr.splitlines():
01032                     newref.write(l.rstrip() + '\n')
01033                 del newref # flush and close    
01034         else:
01035             causes += BasicOutputValidator(self.stderr,
01036                                            "standard error",
01037                                            "ExecTest.expected_stderr")(stderr, result)
01038         
01039         return causes
01040         
01041     def ValidateOutput(self, stdout, stderr, result):
01042         causes = []
01043         # if the test definition contains a custom validator, use it
01044         if self.validator.strip() != "":
01045             class CallWrapper(object):
01046                 """
01047                 Small wrapper class to dynamically bind some default arguments
01048                 to a callable.
01049                 """
01050                 def __init__(self, callable, extra_args = {}):
01051                     self.callable = callable
01052                     self.extra_args = extra_args
01053                     # get the list of names of positional arguments
01054                     from inspect import getargspec
01055                     self.args_order = getargspec(callable)[0]
01056                     # Remove "self" from the list of positional arguments
01057                     # since it is added automatically 
01058                     if self.args_order[0] == "self":
01059                         del self.args_order[0]
01060                 def __call__(self, *args, **kwargs):
01061                     # Check which positional arguments are used
01062                     positional = self.args_order[:len(args)]
01063                     
01064                     kwargs = dict(kwargs) # copy the arguments dictionary
01065                     for a in self.extra_args:
01066                         # use "extra_args" for the arguments not specified as
01067                         # positional or keyword
01068                         if a not in positional and a not in kwargs:
01069                             kwargs[a] = self.extra_args[a]
01070                     return apply(self.callable, args, kwargs)
01071             # local names to be exposed in the script 
01072             exported_symbols = {"self":self,
01073                                 "stdout":stdout,
01074                                 "stderr":stderr,
01075                                 "result":result,
01076                                 "causes":causes,
01077                                 "findReferenceBlock":
01078                                     CallWrapper(findReferenceBlock, {"stdout":stdout,
01079                                                                      "result":result,
01080                                                                      "causes":causes}),
01081                                 "validateWithReference":
01082                                     CallWrapper(self.ValidateWithReference, {"stdout":stdout,
01083                                                                              "stderr":stderr,
01084                                                                              "result":result,
01085                                                                              "causes":causes}),
01086                                 "countErrorLines":
01087                                     CallWrapper(countErrorLines, {"stdout":stdout,
01088                                                                   "result":result,
01089                                                                   "causes":causes}),
01090                                 "checkTTreesSummaries":
01091                                     CallWrapper(self.CheckTTreesSummaries, {"stdout":stdout,
01092                                                                             "result":result,
01093                                                                             "causes":causes}),
01094                                 "checkHistosSummaries":
01095                                     CallWrapper(self.CheckHistosSummaries, {"stdout":stdout,
01096                                                                             "result":result,
01097                                                                             "causes":causes}),
01098                                 
01099                                 }
01100             exec self.validator in globals(), exported_symbols
01101         else:
01102             self.ValidateWithReference(stdout, stderr, result, causes)
01103         
01104         return causes
01105     
01106     def DumpEnvironment(self, result):
01107         """
01108         Add the content of the environment to the result object.
01109         
01110         Copied from the QMTest class of COOL.
01111         """
01112         vars = os.environ.keys()
01113         vars.sort()
01114         result['GaudiTest.environment'] = \
01115             result.Quote('\n'.join(["%s=%s"%(v,os.environ[v]) for v in vars]))
01116 
01117     def _find_program(self,prog):
01118         # check if it is an absolute path or the file can be found
01119         # from the local directory, otherwise search for it in PATH
01120         if not os.path.isabs(prog) and not os.path.isfile(prog):
01121             for d in os.environ["PATH"].split(os.pathsep):
01122                 p = os.path.join(d,prog)
01123                 if os.path.isfile(p):
01124                     return p
01125         return prog
01126         
01127     def Run(self, context, result):
01128         """Run the test.
01129 
01130         'context' -- A 'Context' giving run-time parameters to the
01131         test.
01132 
01133         'result' -- A 'Result' object.  The outcome will be
01134         'Result.PASS' when this method is called.  The 'result' may be
01135         modified by this method to indicate outcomes other than
01136         'Result.PASS' or to add annotations."""
01137         
01138         # Check if the platform is supported
01139         if self.PlatformIsNotSupported(context, result):
01140             return
01141         
01142         def rationalizepath(p):
01143             p = os.path.normpath(os.path.expandvars(p))
01144             if os.path.exists(p):
01145                 p = os.path.realpath(p)
01146             return p
01147         
01148         # Prepare program name and arguments (expanding variables, and converting to absolute) 
01149         if self.program:
01150             prog = rationalizepath(self.program)
01151         elif "GAUDIEXE" in os.environ:
01152             prog = os.environ["GAUDIEXE"]
01153         else:
01154             prog = "Gaudi.exe"
01155         self.program = prog
01156             
01157         dummy, prog_ext = os.path.splitext(prog)
01158         if prog_ext not in [ ".exe", ".py", ".bat" ] and self.GetPlatform()[0:3] == "win":
01159             prog += ".exe"
01160             prog_ext = ".exe"
01161             
01162         prog = self._find_program(prog)
01163         
01164         # Convert paths to absolute paths in arguments and reference files
01165         args = map(rationalizepath, self.args)
01166         self.reference = rationalizepath(self.reference)
01167         self.error_reference = rationalizepath(self.error_reference)
01168         
01169         
01170         # check if the user provided inline options
01171         tmpfile = None
01172         if self.options.strip():
01173             ext = ".opts"
01174             if re.search(r"from\s*Gaudi.Configuration\s*import\s*\*", self.options):
01175                 ext = ".py"
01176             tmpfile = TempFile(ext)
01177             tmpfile.writelines("\n".join(self.options.splitlines()))
01178             tmpfile.flush()
01179             args.append(tmpfile.name)
01180             result["GaudiTest.options"] = result.Quote(self.options)
01181         
01182         # if the program is a python file, execute it through python
01183         if prog_ext == ".py":
01184             args.insert(0,prog)
01185             if self.GetPlatform()[0:3] == "win":
01186                 prog = self._find_program("python.exe")
01187             else:
01188                 prog = self._find_program("python")
01189         
01190         # Change to the working directory if specified or to the default temporary
01191         origdir = os.getcwd()
01192         if self.workdir:
01193             os.chdir(str(os.path.normpath(os.path.expandvars(self.workdir))))
01194         elif "qmtest.tmpdir" in context and self.use_temp_dir == "true":
01195             os.chdir(context["qmtest.tmpdir"])
01196         
01197         if "QMTEST_IGNORE_TIMEOUT" not in os.environ:
01198             self.timeout = max(self.timeout,600)
01199         else:
01200             self.timeout = -1
01201         
01202         try:
01203             # Run the test
01204             self.RunProgram(prog, 
01205                             [ prog ] + args,
01206                             context, result)
01207             # Record the content of the enfironment for failing tests 
01208             if result.GetOutcome() not in [ result.PASS ]:
01209                 self.DumpEnvironment(result)
01210         finally:
01211             # revert to the original directory
01212             os.chdir(origdir)
01213         
01214     def RunProgram(self, program, arguments, context, result):
01215         """Run the 'program'.
01216 
01217         'program' -- The path to the program to run.
01218 
01219         'arguments' -- A list of the arguments to the program.  This
01220         list must contain a first argument corresponding to 'argv[0]'.
01221 
01222         'context' -- A 'Context' giving run-time parameters to the
01223         test.
01224 
01225         'result' -- A 'Result' object.  The outcome will be
01226         'Result.PASS' when this method is called.  The 'result' may be
01227         modified by this method to indicate outcomes other than
01228         'Result.PASS' or to add annotations.
01229         
01230         @attention: This method has been copied from command.ExecTestBase
01231                     (QMTest 2.3.0) and modified to keep stdout and stderr
01232                     for tests that have been terminated by a signal.
01233                     (Fundamental for debugging in the Application Area)
01234         """
01235 
01236         # Construct the environment.
01237         environment = self.MakeEnvironment(context)
01238         # Create the executable.
01239         if self.timeout >= 0:
01240             timeout = self.timeout
01241         else:
01242             # If no timeout was specified, we sill run this process in a
01243             # separate process group and kill the entire process group
01244             # when the child is done executing.  That means that
01245             # orphaned child processes created by the test will be
01246             # cleaned up.
01247             timeout = -2
01248         e = qm.executable.Filter(self.stdin, timeout)
01249         # Run it.
01250         exit_status = e.Run(arguments, environment, path = program)
01251 
01252         # If the process terminated normally, check the outputs.
01253         if sys.platform == "win32" or os.WIFEXITED(exit_status):
01254             # There are no causes of failure yet.
01255             causes = []
01256             # The target program terminated normally.  Extract the
01257             # exit code, if this test checks it.
01258             if self.exit_code is None:
01259                 exit_code = None
01260             elif sys.platform == "win32":
01261                 exit_code = exit_status
01262             else:
01263                 exit_code = os.WEXITSTATUS(exit_status)
01264             # Get the output generated by the program.
01265             stdout = e.stdout
01266             stderr = e.stderr
01267             # Record the results.
01268             result["ExecTest.exit_code"] = str(exit_code)
01269             result["ExecTest.stdout"] = result.Quote(stdout)
01270             result["ExecTest.stderr"] = result.Quote(stderr)
01271             # Check to see if the exit code matches.
01272             if exit_code != self.exit_code:
01273                 causes.append("exit_code")
01274                 result["ExecTest.expected_exit_code"] \
01275                     = str(self.exit_code)
01276             # Validate the output.
01277             causes += self.ValidateOutput(stdout, stderr, result)
01278             # If anything went wrong, the test failed.
01279             if causes:
01280                 result.Fail("Unexpected %s." % string.join(causes, ", ")) 
01281         elif os.WIFSIGNALED(exit_status):
01282             # The target program terminated with a signal.  Construe
01283             # that as a test failure.
01284             signal_number = str(os.WTERMSIG(exit_status))
01285             result.Fail("Program terminated by signal.")
01286             result["ExecTest.signal_number"] = signal_number
01287             result["ExecTest.stdout"] = result.Quote(e.stdout)
01288             result["ExecTest.stderr"] = result.Quote(e.stderr)
01289         elif os.WIFSTOPPED(exit_status):
01290             # The target program was stopped.  Construe that as a
01291             # test failure.
01292             signal_number = str(os.WSTOPSIG(exit_status))
01293             result.Fail("Program stopped by signal.")
01294             result["ExecTest.signal_number"] = signal_number
01295             result["ExecTest.stdout"] = result.Quote(e.stdout)
01296             result["ExecTest.stderr"] = result.Quote(e.stderr)
01297         else:
01298             # The target program terminated abnormally in some other
01299             # manner.  (This shouldn't normally happen...)
01300             result.Fail("Program did not terminate normally.")
01301         
01302         # Marco Cl.: This is a special trick to fix a "problem" with the output
01303         # of gaudi jobs when they use colors
01304         esc = '\x1b'
01305         repr_esc = '\\x1b'
01306         result["ExecTest.stdout"] = result["ExecTest.stdout"].replace(esc,repr_esc)
01307         # TODO: (MCl) improve the hack for colors in standard output
01308         #             may be converting them to HTML tags

Generated at Thu Jan 8 17:44:22 2009 for Gaudi Framework, version v20r4 by Doxygen version 1.5.6 written by Dimitri van Heesch, © 1997-2004