1# -*- coding: UTF-8 -*-
2"""
3This module provides the abstract base classes and core concepts
4for the model elements in behave.
5"""
6
7import os.path
8import sys
9import six
10from behave.capture import Captured
11from behave.textutil import text as _text
12from enum import Enum
13
14
15PLATFORM_WIN = sys.platform.startswith("win")
16def posixpath_normalize(path):
17    return path.replace("\\", "/")
18
19
20# -----------------------------------------------------------------------------
21# GENERIC MODEL CLASSES:
22# -----------------------------------------------------------------------------
23class Status(Enum):
24    """Provides the (test-run) status of a model element.
25    Features and Scenarios use: untested, skipped, passed, failed.
26    Steps may use all enum-values.
27
28    Enum values:
29    * untested (initial state):
30
31        Defines the initial state before a test-run.
32        Sometimes used to indicate that the model element was not executed
33        during a test run.
34
35    * skipped:
36
37        A model element is skipped because it should not run.
38        This is caused by filtering mechanisms, like tags, active-tags,
39        file-location arg, select-by-name, etc.
40
41    * passed: A model element was executed and passed (without failures).
42    * failed: Failures occurred while executing it.
43    * undefined: Used for undefined-steps (no step implementation was found).
44    * executing: Marks the steps during execution (used in a formatter)
45
46    .. versionadded:: 1.2.6
47        Superceeds string-based status values.
48    """
49    untested = 0
50    skipped = 1
51    passed = 2
52    failed = 3
53    undefined = 4
54    executing = 5
55
56    def __eq__(self, other):
57        """Comparison operator equals-to other value.
58        Supports other enum-values and string (for backward compatibility).
59
60        EXAMPLES::
61
62            status = Status.passed
63            assert status == Status.passed
64            assert status == "passed"
65            assert status != "failed"
66
67        :param other:   Other value to compare (enum-value, string).
68        :return: True, if both values are equal. False, otherwise.
69        """
70        if isinstance(other, six.string_types):
71            # -- CONVENIENCE: Compare with string-name (backward-compatible)
72            return self.name == other
73        return super(Status, self).__eq__(other)
74
75    @classmethod
76    def from_name(cls, name):
77        """Select enumeration value by using its name.
78
79        :param name:    Name as key to the enum value (as string).
80        :return: Enum value (instance)
81        :raises: LookupError, if status name is unknown.
82        """
83        # pylint: disable=no-member
84        enum_value = cls.__members__.get(name, None)
85        if enum_value is None:
86            known_names = ", ".join(cls.__members__.keys())
87            raise LookupError("%s (expected: %s)" % (name, known_names))
88        return enum_value
89
90
91class Argument(object):
92    """An argument found in a *feature file* step name and extracted using
93    step decorator `parameters`_.
94
95    The attributes are:
96
97    .. attribute:: original
98
99       The actual text matched in the step name.
100
101    .. attribute:: value
102
103       The potentially type-converted value of the argument.
104
105    .. attribute:: name
106
107       The name of the argument. This will be None if the parameter is
108       anonymous.
109
110    .. attribute:: start
111
112       The start index in the step name of the argument. Used for display.
113
114    .. attribute:: end
115
116       The end index in the step name of the argument. Used for display.
117    """
118    def __init__(self, start, end, original, value, name=None):
119        self.start = start
120        self.end = end
121        self.original = original
122        self.value = value
123        self.name = name
124
125
126# @total_ordering
127# class FileLocation(unicode):
128class FileLocation(object):
129    """
130    Provides a value object for file location objects.
131    A file location consists of:
132
133      * filename
134      * line (number), optional
135
136    LOCATION SCHEMA:
137      * "{filename}:{line}" or
138      * "{filename}" (if line number is not present)
139    """
140    __pychecker__ = "missingattrs=line"     # -- Ignore warnings for 'line'.
141
142    def __init__(self, filename, line=None):
143        if PLATFORM_WIN:
144            filename = posixpath_normalize(filename)
145        self.filename = filename
146        self.line = line
147
148    def get(self):
149        return self.filename
150
151    def abspath(self):
152        return os.path.abspath(self.filename)
153
154    def basename(self):
155        return os.path.basename(self.filename)
156
157    def dirname(self):
158        return os.path.dirname(self.filename)
159
160    def relpath(self, start=os.curdir):
161        """Compute relative path for start to filename.
162
163        :param start: Base path or start directory (default=current dir).
164        :return: Relative path from start to filename
165        """
166        return os.path.relpath(self.filename, start)
167
168    def exists(self):
169        return os.path.exists(self.filename)
170
171    def _line_lessthan(self, other_line):
172        if self.line is None:
173            # return not (other_line is None)
174            return other_line is not None
175        elif other_line is None:
176            return False
177        else:
178            return self.line < other_line
179
180    def __eq__(self, other):
181        if isinstance(other, FileLocation):
182            return self.filename == other.filename and self.line == other.line
183        elif isinstance(other, six.string_types):
184            return self.filename == other
185        else:
186            raise TypeError("Cannot compare FileLocation with %s:%s" % \
187                            (type(other), other))
188
189    def __ne__(self, other):
190        # return not self == other    # pylint: disable=unneeded-not
191        return not self.__eq__(other)
192
193    def __lt__(self, other):
194        if isinstance(other, FileLocation):
195            if self.filename < other.filename:
196                return True
197            elif self.filename > other.filename:
198                return False
199            else:
200                assert self.filename == other.filename
201                return self._line_lessthan(other.line)
202
203        elif isinstance(other, six.string_types):
204            return self.filename < other
205        else:
206            raise TypeError("Cannot compare FileLocation with %s:%s" % \
207                            (type(other), other))
208
209    def __le__(self, other):
210        # -- SEE ALSO: python2.7, functools.total_ordering
211        # return not other < self     # pylint unneeded-not
212        return other >= self
213
214    def __gt__(self, other):
215        # -- SEE ALSO: python2.7, functools.total_ordering
216        if isinstance(other, FileLocation):
217            return other < self
218        else:
219            return self.filename > other
220
221    def __ge__(self, other):
222        # -- SEE ALSO: python2.7, functools.total_ordering
223        # return not self < other
224        return not self.__lt__(other)
225
226    def __repr__(self):
227        return u'<FileLocation: filename="%s", line=%s>' % \
228               (self.filename, self.line)
229
230    def __str__(self):
231        filename = self.filename
232        if isinstance(filename, six.binary_type):
233            filename = _text(filename, "utf-8")
234        if self.line is None:
235            return filename
236        return u"%s:%d" % (filename, self.line)
237
238    if six.PY2:
239        __unicode__ = __str__
240        __str__ = lambda self: self.__unicode__().encode("utf-8")
241
242    @classmethod
243    def for_function(cls, func, curdir=None):
244        """Extracts the location information from the function and builds
245        the location string (schema: "{source_filename}:{line_number}").
246
247        :param func: Function whose location should be determined.
248        :return: FileLocation object
249        """
250        func = unwrap_function(func)
251        function_code = six.get_function_code(func)
252        filename = function_code.co_filename
253        line_number = function_code.co_firstlineno
254
255        curdir = curdir or os.getcwd()
256        try:
257            filename = os.path.relpath(filename, curdir)
258        except ValueError:
259            # WINDOWS-SPECIFIC (#599):
260            # If a step-function comes from a different disk drive,
261            # a relative path will fail: Keep the absolute path.
262            pass
263        return cls(filename, line_number)
264
265
266# -----------------------------------------------------------------------------
267# ABSTRACT MODEL CLASSES (and concepts):
268# -----------------------------------------------------------------------------
269class BasicStatement(object):
270    def __init__(self, filename, line, keyword, name):
271        filename = filename or '<string>'
272        filename = os.path.relpath(filename, os.getcwd())   # -- NEEDS: abspath?
273        self.location = FileLocation(filename, line)
274        assert isinstance(keyword, six.text_type)
275        assert isinstance(name, six.text_type)
276        self.keyword = keyword
277        self.name = name
278        # -- SINCE: 1.2.6
279        self.captured = Captured()
280        # -- ERROR CONTEXT INFO:
281        self.exception = None
282        self.exc_traceback = None
283        self.error_message = None
284
285    @property
286    def filename(self):
287        # return os.path.abspath(self.location.filename)
288        return self.location.filename
289
290    @property
291    def line(self):
292        return self.location.line
293
294    def reset(self):
295        # -- RESET: Captured output data
296        self.captured.reset()
297        # -- RESET: ERROR CONTEXT INFO
298        self.exception = None
299        self.exc_traceback = None
300        self.error_message = None
301
302    def store_exception_context(self, exception):
303        self.exception = exception
304        self.exc_traceback = sys.exc_info()[2]
305
306    def __hash__(self):
307        # -- NEEDED-FOR: PYTHON3
308        # return id((self.keyword, self.name))
309        return id(self)
310
311    def __eq__(self, other):
312        # -- PYTHON3 SUPPORT, ORDERABLE:
313        # NOTE: Ignore potential FileLocation differences.
314        return (self.keyword, self.name) == (other.keyword, other.name)
315
316    def __lt__(self, other):
317        # -- PYTHON3 SUPPORT, ORDERABLE:
318        # NOTE: Ignore potential FileLocation differences.
319        return (self.keyword, self.name) < (other.keyword, other.name)
320
321    def __ne__(self, other):
322        return not self.__eq__(other)
323
324    def __le__(self, other):
325        # -- SEE ALSO: python2.7, functools.total_ordering
326        # return not other < self
327        return other >= self
328
329    def __gt__(self, other):
330        # -- SEE ALSO: python2.7, functools.total_ordering
331        assert isinstance(other, BasicStatement)
332        return other < self
333
334    def __ge__(self, other):
335        # -- SEE ALSO: python2.7, functools.total_ordering
336        # OR: return self >= other
337        return not self < other     # pylint: disable=unneeded-not
338
339    # def __cmp__(self, other):
340    #     # -- NOTE: Ignore potential FileLocation differences.
341    #     return cmp((self.keyword, self.name), (other.keyword, other.name))
342
343
344class TagStatement(BasicStatement):
345
346    def __init__(self, filename, line, keyword, name, tags):
347        if tags is None:
348            tags = []
349        super(TagStatement, self).__init__(filename, line, keyword, name)
350        self.tags = tags
351
352    def should_run_with_tags(self, tag_expression):
353        """Determines if statement should run when the tag expression is used.
354
355        :param tag_expression:  Runner/config environment tags to use.
356        :return: True, if examples should run. False, otherwise (skip it).
357        """
358        return tag_expression.check(self.tags)
359
360
361class TagAndStatusStatement(BasicStatement):
362    # final_status = ('passed', 'failed', 'skipped')
363    final_status = (Status.passed, Status.failed, Status.skipped)
364
365    def __init__(self, filename, line, keyword, name, tags):
366        super(TagAndStatusStatement, self).__init__(filename, line, keyword, name)
367        self.tags = tags
368        self.should_skip = False
369        self.skip_reason = None
370        self._cached_status = Status.untested
371
372    def should_run_with_tags(self, tag_expression):
373        """Determines if statement should run when the tag expression is used.
374
375        :param tag_expression:  Runner/config environment tags to use.
376        :return: True, if examples should run. False, otherwise (skip it).
377        """
378        return tag_expression.check(self.tags)
379
380    @property
381    def status(self):
382        if self._cached_status not in self.final_status:
383            # -- RECOMPUTE: As long as final status is not reached.
384            self._cached_status = self.compute_status()
385        return self._cached_status
386
387    def set_status(self, value):
388        if isinstance(value, six.string_types):
389            value = Status.from_name(value)
390        self._cached_status = value
391
392    def clear_status(self):
393        self._cached_status = Status.untested
394
395    def reset(self):
396        self.should_skip = False
397        self.skip_reason = None
398        self.clear_status()
399
400    def compute_status(self):
401        raise NotImplementedError
402
403
404class Replayable(object):
405    type = None
406
407    def replay(self, formatter):
408        getattr(formatter, self.type)(self)
409
410
411# -----------------------------------------------------------------------------
412# UTILITY FUNCTIONS:
413# -----------------------------------------------------------------------------
414def unwrap_function(func, max_depth=10):
415    """Unwraps a function that is wrapped with :func:`functools.partial()`"""
416    iteration = 0
417    wrapped = getattr(func, "__wrapped__", None)
418    while wrapped and iteration < max_depth:
419        func = wrapped
420        wrapped = getattr(func, "__wrapped__", None)
421        iteration += 1
422    return func
423