1"""Base support for parser scenario testing.
2"""
3
4from os import path
5import ConfigParser, os, shelve, shutil, sys, tarfile, time
6import difflib, itertools
7import common
8from autotest_lib.client.common_lib import utils, autotemp
9from autotest_lib.tko import parser_lib
10from autotest_lib.tko.parsers.test import templates
11from autotest_lib.tko.parsers.test import unittest_hotfix
12
13TEMPLATES_DIRPATH = templates.__path__[0]
14# Set TZ used to UTC
15os.environ['TZ'] = 'UTC'
16time.tzset()
17
18KEYVAL = 'keyval'
19STATUS_VERSION = 'status_version'
20PARSER_RESULT_STORE = 'parser_result.store'
21RESULTS_DIR_TARBALL = 'results_dir.tgz'
22CONFIG_FILENAME = 'scenario.cfg'
23TEST = 'test'
24PARSER_RESULT_TAG = 'parser_result_tag'
25
26
27class Error(Exception):
28    pass
29
30
31class BadResultsDirectoryError(Error):
32    pass
33
34
35class UnsupportedParserResultError(Error):
36    pass
37
38
39class UnsupportedTemplateTypeError(Error):
40    pass
41
42
43
44class ParserException(object):
45    """Abstract representation of exception raised from parser execution.
46
47    We will want to persist exceptions raised from the parser but also change
48    the objects that make them up during refactor. For this reason
49    we can't merely pickle the original.
50    """
51
52    def __init__(self, orig):
53        """
54        Args:
55          orig: Exception; To copy
56        """
57        self.classname = orig.__class__.__name__
58        print "Copying exception:", self.classname
59        for key, val in orig.__dict__.iteritems():
60            setattr(self, key, val)
61
62
63    def __eq__(self, other):
64        """Test if equal to another ParserException."""
65        return self.__dict__ == other.__dict__
66
67
68    def __ne__(self, other):
69        """Test if not equal to another ParserException."""
70        return self.__dict__ != other.__dict__
71
72
73    def __str__(self):
74        sd = self.__dict__
75        pairs = ['%s="%s"' % (k, sd[k]) for k in sorted(sd.keys())]
76        return "<%s: %s>" % (self.classname, ', '.join(pairs))
77
78
79class ParserTestResult(object):
80    """Abstract representation of test result parser state.
81
82    We will want to persist test results but also change the
83    objects that make them up during refactor. For this reason
84    we can't merely pickle the originals.
85    """
86
87    def __init__(self, orig):
88        """
89        Tracking all the attributes as they change over time is
90        not desirable. Instead we populate the instance's __dict__
91        by introspecting orig.
92
93        Args:
94            orig: testobj; Framework test result instance to copy.
95        """
96        for key, val in orig.__dict__.iteritems():
97            if key == 'kernel':
98                setattr(self, key, dict(val.__dict__))
99            elif key == 'iterations':
100                setattr(self, key, [dict(it.__dict__) for it in val])
101            else:
102                setattr(self, key, val)
103
104
105    def __eq__(self, other):
106        """Test if equal to another ParserTestResult."""
107        return self.__dict__ == other.__dict__
108
109
110    def __ne__(self, other):
111        """Test if not equal to another ParserTestResult."""
112        return self.__dict__ != other.__dict__
113
114
115    def __str__(self):
116        sd = self.__dict__
117        pairs = ['%s="%s"' % (k, sd[k]) for k in sorted(sd.keys())]
118        return "<%s: %s>" % (self.__class__.__name__, ', '.join(pairs))
119
120
121def copy_parser_result(parser_result):
122    """Copy parser_result into ParserTestResult instances.
123
124    Args:
125      parser_result:
126          list; [testobj, ...]
127          - Or -
128          Exception
129
130    Returns:
131      list; [ParserTestResult, ...]
132      - Or -
133      ParserException
134
135    Raises:
136        UnsupportedParserResultError; If parser_result type is not supported
137    """
138    if type(parser_result) is list:
139        return [ParserTestResult(test) for test in parser_result]
140    elif isinstance(parser_result, Exception):
141        return ParserException(parser_result)
142    else:
143        raise UnsupportedParserResultError
144
145
146def compare_parser_results(left, right):
147    """Generates a textual report (for now) on the differences between.
148
149    Args:
150      left: list of ParserTestResults or a single ParserException
151      right: list of ParserTestResults or a single ParserException
152
153    Returns: Generator returned from difflib.Differ().compare()
154    """
155    def to_los(obj):
156        """Generate a list of strings representation of object."""
157        if type(obj) is list:
158            return [
159                '%d) %s' % pair
160                for pair in itertools.izip(itertools.count(), obj)]
161        else:
162            return ['i) %s' % obj]
163
164    return difflib.Differ().compare(to_los(left), to_los(right))
165
166
167class ParserHarness(object):
168    """Harness for objects related to the parser.
169
170    This can exercise a parser on specific result data in various ways.
171    """
172
173    def __init__(
174        self, parser, job, job_keyval, status_version, status_log_filepath):
175        """
176        Args:
177          parser: tko.parsers.base.parser; Subclass instance of base parser.
178          job: job implementation; Returned from parser.make_job()
179          job_keyval: dict; Result of parsing job keyval file.
180          status_version: str; Status log format version
181          status_log_filepath: str; Path to result data status.log file
182        """
183        self.parser = parser
184        self.job = job
185        self.job_keyval = job_keyval
186        self.status_version = status_version
187        self.status_log_filepath = status_log_filepath
188
189
190    def execute(self):
191        """Basic exercise, pass entire log data into .end()
192
193        Returns: list; [testobj, ...]
194        """
195        status_lines = open(self.status_log_filepath).readlines()
196        self.parser.start(self.job)
197        return self.parser.end(status_lines)
198
199
200class BaseScenarioTestCase(unittest_hotfix.TestCase):
201    """Base class for all Scenario TestCase implementations.
202
203    This will load up all resources from scenario package directory upon
204    instantiation, and initialize a new ParserHarness before each test
205    method execution.
206    """
207    def __init__(self, methodName='runTest'):
208        unittest_hotfix.TestCase.__init__(self, methodName)
209        self.package_dirpath = path.dirname(
210            sys.modules[self.__module__].__file__)
211        self.tmp_dirpath, self.results_dirpath = load_results_dir(
212            self.package_dirpath)
213        self.parser_result_store = load_parser_result_store(
214            self.package_dirpath)
215        self.config = load_config(self.package_dirpath)
216        self.parser_result_tag = self.config.get(
217            TEST, PARSER_RESULT_TAG)
218        self.expected_status_version = self.config.getint(
219            TEST, STATUS_VERSION)
220        self.harness = None
221
222
223    def setUp(self):
224        if self.results_dirpath:
225            self.harness = new_parser_harness(self.results_dirpath)
226
227
228    def tearDown(self):
229        if self.tmp_dirpath:
230            self.tmp_dirpath.clean()
231
232
233    def test_status_version(self):
234        """Ensure basic sanity."""
235        self.skipIf(not self.harness)
236        self.assertEquals(
237            self.harness.status_version, self.expected_status_version)
238
239
240def shelve_open(filename, flag='c', protocol=None, writeback=False):
241    """A more system-portable wrapper around shelve.open, with the exact
242    same arguments and interpretation."""
243    import dumbdbm
244    return shelve.Shelf(dumbdbm.open(filename, flag), protocol, writeback)
245
246
247def new_parser_harness(results_dirpath):
248    """Ensure sane environment and create new parser with wrapper.
249
250    Args:
251      results_dirpath: str; Path to job results directory
252
253    Returns:
254      ParserHarness;
255
256    Raises:
257      BadResultsDirectoryError; If results dir does not exist or is malformed.
258    """
259    if not path.exists(results_dirpath):
260        raise BadResultsDirectoryError
261
262    keyval_path = path.join(results_dirpath, KEYVAL)
263    job_keyval = utils.read_keyval(keyval_path)
264    status_version = job_keyval[STATUS_VERSION]
265    parser = parser_lib.parser(status_version)
266    job = parser.make_job(results_dirpath)
267    status_log_filepath = path.join(results_dirpath, 'status.log')
268    if not path.exists(status_log_filepath):
269        raise BadResultsDirectoryError
270
271    return ParserHarness(
272        parser, job, job_keyval, status_version, status_log_filepath)
273
274
275def store_parser_result(package_dirpath, parser_result, tag):
276    """Persist parser result to specified scenario package, keyed by tag.
277
278    Args:
279      package_dirpath: str; Path to scenario package directory.
280      parser_result: list or Exception; Result from ParserHarness.execute
281      tag: str; Tag to use as shelve key for persisted parser_result
282    """
283    copy = copy_parser_result(parser_result)
284    sto_filepath = path.join(package_dirpath, PARSER_RESULT_STORE)
285    sto = shelve_open(sto_filepath)
286    sto[tag] = copy
287    sto.close()
288
289
290def load_parser_result_store(package_dirpath, open_for_write=False):
291    """Load parser result store from specified scenario package.
292
293    Args:
294      package_dirpath: str; Path to scenario package directory.
295      open_for_write: bool; Open store for writing.
296
297    Returns:
298      shelve.DbfilenameShelf; Looks and acts like a dict
299    """
300    open_flag = open_for_write and 'c' or 'r'
301    sto_filepath = path.join(package_dirpath, PARSER_RESULT_STORE)
302    return shelve_open(sto_filepath, flag=open_flag)
303
304
305def store_results_dir(package_dirpath, results_dirpath):
306    """Make tarball of results_dirpath in package_dirpath.
307
308    Args:
309      package_dirpath: str; Path to scenario package directory.
310      results_dirpath: str; Path to job results directory
311    """
312    tgz_filepath = path.join(package_dirpath, RESULTS_DIR_TARBALL)
313    tgz = tarfile.open(tgz_filepath, 'w:gz')
314    results_dirname = path.basename(results_dirpath)
315    tgz.add(results_dirpath, results_dirname)
316    tgz.close()
317
318
319def load_results_dir(package_dirpath):
320    """Unpack results tarball in package_dirpath to temp dir.
321
322    Args:
323      package_dirpath: str; Path to scenario package directory.
324
325    Returns:
326      str; New temp path for extracted results directory.
327      - Or -
328      None; If tarball does not exist
329    """
330    tgz_filepath = path.join(package_dirpath, RESULTS_DIR_TARBALL)
331    if not path.exists(tgz_filepath):
332        return None, None
333
334    tgz = tarfile.open(tgz_filepath, 'r:gz')
335    tmp_dirpath = autotemp.tempdir(unique_id='scenario_base')
336    results_dirname = tgz.next().name
337    tgz.extract(results_dirname, tmp_dirpath.name)
338    for info in tgz:
339        tgz.extract(info.name, tmp_dirpath.name)
340    return tmp_dirpath, path.join(tmp_dirpath.name, results_dirname)
341
342
343def write_config(package_dirpath, **properties):
344    """Write test configuration file to package_dirpath.
345
346    Args:
347      package_dirpath: str; Path to scenario package directory.
348      properties: dict; Key value entries to write to to config file.
349    """
350    config = ConfigParser.RawConfigParser()
351    config.add_section(TEST)
352    for key, val in properties.iteritems():
353        config.set(TEST, key, val)
354
355    config_filepath = path.join(package_dirpath, CONFIG_FILENAME)
356    fi = open(config_filepath, 'w')
357    config.write(fi)
358    fi.close()
359
360
361def load_config(package_dirpath):
362    """Load config from package_dirpath.
363
364    Args:
365      package_dirpath: str; Path to scenario package directory.
366
367    Returns:
368      ConfigParser.RawConfigParser;
369    """
370    config = ConfigParser.RawConfigParser()
371    config_filepath = path.join(package_dirpath, CONFIG_FILENAME)
372    config.read(config_filepath)
373    return config
374
375
376def install_unittest_module(package_dirpath, template_type):
377    """Install specified unittest template module to package_dirpath.
378
379    Template modules are stored in tko/parsers/test/templates.
380    Installation includes:
381      Copying to package_dirpath/template_type_unittest.py
382      Copying scenario package common.py to package_dirpath
383      Touching package_dirpath/__init__.py
384
385    Args:
386      package_dirpath: str; Path to scenario package directory.
387      template_type: str; Name of template module to install.
388
389    Raises:
390      UnsupportedTemplateTypeError; If there is no module in
391          templates package called template_type.
392    """
393    from_filepath = path.join(
394        TEMPLATES_DIRPATH, '%s.py' % template_type)
395    if not path.exists(from_filepath):
396        raise UnsupportedTemplateTypeError
397
398    to_filepath = path.join(
399        package_dirpath, '%s_unittest.py' % template_type)
400    shutil.copy(from_filepath, to_filepath)
401
402    # For convenience we must copy the common.py hack file too :-(
403    from_common_filepath = path.join(
404        TEMPLATES_DIRPATH, 'scenario_package_common.py')
405    to_common_filepath = path.join(package_dirpath, 'common.py')
406    shutil.copy(from_common_filepath, to_common_filepath)
407
408    # And last but not least, touch an __init__ file
409    os.mknod(path.join(package_dirpath, '__init__.py'))
410
411
412def fix_package_dirname(package_dirname):
413    """Convert package_dirname to a valid package name string, if necessary.
414
415    Args:
416      package_dirname: str; Name of scenario package directory.
417
418    Returns:
419      str; Possibly fixed package_dirname
420    """
421    # Really stupid atm, just enough to handle results dirnames
422    package_dirname = package_dirname.replace('-', '_')
423    pre = ''
424    if package_dirname[0].isdigit():
425        pre = 'p'
426    return pre + package_dirname
427
428
429def sanitize_results_data(results_dirpath):
430    """Replace or remove any data that would possibly contain IP
431
432    Args:
433      results_dirpath: str; Path to job results directory
434    """
435    raise NotImplementedError
436