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