1# -*- coding: UTF-8 -*-
2"""
3Provides a formatter that provides an overview of available step definitions
4(step implementations).
5"""
6
7from __future__ import absolute_import
8from operator import attrgetter
9import inspect
10from six.moves import zip
11from behave.formatter.base import Formatter
12from behave.step_registry import StepRegistry, registry
13from behave.textutil import \
14    compute_words_maxsize, indent, make_indentation, text as _text
15from behave import i18n
16
17
18# -----------------------------------------------------------------------------
19# CLASS: AbstractStepsFormatter
20# -----------------------------------------------------------------------------
21class AbstractStepsFormatter(Formatter):
22    """
23    Provides a formatter base class that provides the common functionality
24    for formatter classes that operate on step definitions (implementations).
25
26    .. note::
27        Supports behave dry-run mode.
28    """
29    step_types = ("given", "when", "then", "step")
30
31    def __init__(self, stream_opener, config):
32        super(AbstractStepsFormatter, self).__init__(stream_opener, config)
33        self.step_registry = None
34        self.current_feature = None
35        self.shows_location = config.show_source
36
37    def reset(self):
38        self.step_registry = None
39        self.current_feature = None
40
41    def discover_step_definitions(self):
42        if self.step_registry is None:
43            self.step_registry = StepRegistry()
44
45        for step_type in registry.steps:
46            step_definitions = tuple(registry.steps[step_type])
47            for step_definition in step_definitions:
48                step_definition.step_type = step_type
49            self.step_registry.steps[step_type] = step_definitions
50
51    # -- FORMATTER API:
52    def feature(self, feature):
53        self.current_feature = feature
54        if not self.step_registry:
55            # -- ONLY-ONCE:
56            self.discover_step_definitions()
57
58    def eof(self):
59        """Called at end of a feature."""
60        self.current_feature = None
61
62    def close(self):
63        """Called at end of test run."""
64        if not self.step_registry:
65            self.discover_step_definitions()
66
67        if self.step_registry:
68            # -- ENSURE: Output stream is open.
69            self.stream = self.open()
70            self.report()
71
72        # -- FINALLY:
73        self.close_stream()
74
75    # -- REPORT SPECIFIC-API:
76    def report(self):
77        raise NotImplementedError()
78
79    # pylint: disable=no-self-use
80    def describe_step_definition(self, step_definition, step_type=None):
81        if not step_type:
82            step_type = step_definition.step_type
83        assert step_type
84        return u"@%s('%s')" % (step_type, step_definition.pattern)
85
86
87# -----------------------------------------------------------------------------
88# CLASS: StepsFormatter
89# -----------------------------------------------------------------------------
90class StepsFormatter(AbstractStepsFormatter):
91    """
92    Provides formatter class that provides an overview
93    which step definitions are available.
94
95    EXAMPLE:
96        $ behave --dry-run -f steps features/
97        GIVEN STEP DEFINITIONS[21]:
98          Given a new working directory
99          Given I use the current directory as working directory
100          Given a file named "{filename}" with
101          ...
102          Given a step passes
103          Given a step fails
104
105        WHEN STEP DEFINITIONS[14]:
106          When I run "{command}"
107          ...
108          When a step passes
109          When a step fails
110
111        THEN STEP DEFINITIONS[45]:
112          Then the command should fail with returncode="{result:int}"
113          Then it should pass with
114          Then it should fail with
115          Then the command output should contain "{text}"
116          ...
117          Then a step passes
118          Then a step fails
119
120        GENERIC STEP DEFINITIONS[13]:
121          * I remove the directory "{directory}"
122          * a file named "{filename}" exists
123          * a file named "{filename}" does not exist
124          ...
125          * a step passes
126          * a step fails
127
128    .. note::
129        Supports behave dry-run mode.
130    """
131    name = "steps"
132    description = "Shows step definitions (step implementations)."
133    shows_location = True
134    min_location_column = 40
135
136    # -- REPORT SPECIFIC-API:
137    def report(self):
138        self.report_steps_by_type()
139
140    def report_steps_by_type(self):
141        """Show an overview of the existing step implementations per step type.
142        """
143        # pylint: disable=too-many-branches
144        assert set(self.step_types) == set(self.step_registry.steps.keys())
145        language = self.config.lang or "en"
146        language_keywords = i18n.languages[language]
147
148        for step_type in self.step_types:
149            steps = list(self.step_registry.steps[step_type])
150            if step_type != "step":
151                steps.extend(self.step_registry.steps["step"])
152            if not steps:
153                continue
154
155            # -- PREPARE REPORT: For a step-type.
156            step_type_name = step_type.upper()
157            if step_type == "step":
158                step_keyword = "*"
159                step_type_name = "GENERIC"
160            else:
161                # step_keyword = step_type.capitalize()
162                keywords = language_keywords[step_type]
163                if keywords[0] == u"*":
164                    assert len(keywords) > 1
165                    step_keyword = keywords[1]
166                else:
167                    step_keyword = keywords[0]
168
169            steps_text = [u"%s %s" % (step_keyword, step.pattern)
170                          for step in steps]
171            if self.shows_location:
172                max_size = compute_words_maxsize(steps_text)
173                if max_size < self.min_location_column:
174                    max_size = self.min_location_column
175                schema = u"  %-" + _text(max_size) + "s  # %s\n"
176            else:
177                schema = u"  %s\n"
178
179            # -- REPORT:
180            message = "%s STEP DEFINITIONS[%s]:\n"
181            self.stream.write(message % (step_type_name, len(steps)))
182            for step, step_text in zip(steps, steps_text):
183                if self.shows_location:
184                    self.stream.write(schema % (step_text, step.location))
185                else:
186                    self.stream.write(schema % step_text)
187            self.stream.write("\n")
188
189
190# -----------------------------------------------------------------------------
191# CLASS: StepsDocFormatter
192# -----------------------------------------------------------------------------
193class StepsDocFormatter(AbstractStepsFormatter):
194    """
195    Provides formatter class that shows the documentation of all registered
196    step definitions. The primary purpose is to provide help for a test writer.
197
198    EXAMPLE:
199        $ behave --dry-run -f steps.doc features/
200        @given('a file named "{filename}" with')
201          Function: step_a_file_named_filename_with()
202          Location: behave4cmd0/command_steps.py:50
203            Creates a textual file with the content provided as docstring.
204
205        @when('I run "{command}"')
206          Function: step_i_run_command()
207          Location: behave4cmd0/command_steps.py:80
208            Run a command as subprocess, collect its output and returncode.
209
210        @step('a file named "{filename}" exists')
211          Function: step_file_named_filename_exists()
212          Location: behave4cmd0/command_steps.py:305
213            Verifies that a file with this filename exists.
214
215            .. code-block:: gherkin
216
217                Given a file named "abc.txt" exists
218                 When a file named "abc.txt" exists
219        ...
220
221    .. note::
222        Supports behave dry-run mode.
223    """
224    name = "steps.doc"
225    description = "Shows documentation for step definitions."
226    shows_location = True
227    shows_function_name = True
228    ordered_by_location = True
229    doc_prefix = make_indentation(4)
230
231    # -- REPORT SPECIFIC-API:
232    def report(self):
233        self.report_step_definition_docs()
234        self.stream.write("\n")
235
236    def report_step_definition_docs(self):
237        step_definitions = []
238        for step_type in self.step_types:
239            for step_definition in self.step_registry.steps[step_type]:
240                # step_definition.step_type = step_type
241                assert step_definition.step_type is not None
242                step_definitions.append(step_definition)
243
244        if self.ordered_by_location:
245            step_definitions = sorted(step_definitions,
246                                      key=attrgetter("location"))
247
248        for step_definition in step_definitions:
249            self.write_step_definition(step_definition)
250
251    def write_step_definition(self, step_definition):
252        step_definition_text = self.describe_step_definition(step_definition)
253        self.stream.write(u"%s\n" % step_definition_text)
254        doc = inspect.getdoc(step_definition.func)
255        func_name = step_definition.func.__name__
256        if self.shows_function_name and func_name not in ("step", "impl"):
257            self.stream.write(u"  Function: %s()\n" % func_name)
258        if self.shows_location:
259            self.stream.write(u"  Location: %s\n" % step_definition.location)
260        if doc:
261            doc = doc.strip()
262            self.stream.write(indent(doc, self.doc_prefix))
263            self.stream.write("\n")
264        self.stream.write("\n")
265
266
267# -----------------------------------------------------------------------------
268# CLASS: StepsCatalogFormatter
269# -----------------------------------------------------------------------------
270class StepsCatalogFormatter(StepsDocFormatter):
271    """
272    Provides formatter class that shows the documentation of all registered
273    step definitions. The primary purpose is to provide help for a test writer.
274
275    In order to ease work for non-programmer testers, the technical details of
276    the steps (i.e. function name, source location) are ommited and the
277    steps are shown as they would apprear in a feature file (no noisy '@',
278    or '(', etc.).
279
280    Also, the output is sorted by step type (Given, When, Then)
281
282    Generic step definitions are listed with all three step types.
283
284    EXAMPLE:
285        $ behave --dry-run -f steps.catalog features/
286        Given a file named "{filename}" with
287            Creates a textual file with the content provided as docstring.
288
289        When I run "{command}"
290            Run a command as subprocess, collect its output and returncode.
291
292        Given a file named "{filename}" exists
293         When a file named "{filename}" exists
294         Then a file named "{filename}" exists
295            Verifies that a file with this filename exists.
296
297            .. code-block:: gherkin
298
299                Given a file named "abc.txt" exists
300                 When a file named "abc.txt" exists
301        ...
302
303    .. note::
304        Supports behave dry-run mode.
305    """
306    name = "steps.catalog"
307    description = "Shows non-technical documentation for step definitions."
308    shows_location = False
309    shows_function_name = False
310    ordered_by_location = False
311    doc_prefix = make_indentation(4)
312
313
314    def describe_step_definition(self, step_definition, step_type=None):
315        if not step_type:
316            step_type = step_definition.step_type
317        assert step_type
318        desc = []
319        if step_type == "step":
320            for step_type1 in self.step_types[:-1]:
321                text = u"%5s %s" % (step_type1.title(), step_definition.pattern)
322                desc.append(text)
323        else:
324            desc.append(u"%s %s" % (step_type.title(), step_definition.pattern))
325
326        return '\n'.join(desc)
327
328
329# -----------------------------------------------------------------------------
330# CLASS: StepsUsageFormatter
331# -----------------------------------------------------------------------------
332class StepsUsageFormatter(AbstractStepsFormatter):
333    """
334    Provides formatter class that shows how step definitions are used by steps.
335
336    EXAMPLE:
337        $ behave --dry-run -f steps.usage features/
338        ...
339
340    .. note::
341        Supports behave dry-run mode.
342    """
343    name = "steps.usage"
344    description = "Shows how step definitions are used by steps."
345    doc_prefix = make_indentation(4)
346    min_location_column = 40
347
348    def __init__(self, stream_opener, config):
349        super(StepsUsageFormatter, self).__init__(stream_opener, config)
350        self.step_usage_database = {}
351        self.undefined_steps = []
352
353    def reset(self):
354        super(StepsUsageFormatter, self).reset()
355        self.step_usage_database = {}
356        self.undefined_steps = []
357
358    # pylint: disable=invalid-name
359    def get_step_type_for_step_definition(self, step_definition):
360        step_type = step_definition.step_type
361        if not step_type:
362            # -- DETERMINE STEP-TYPE FROM STEP-REGISTRY:
363            assert self.step_registry
364            for step_type, values in self.step_registry.steps.items():
365                if step_definition in values:
366                    return step_type
367            # -- OTHERWISE:
368            step_type = "step"
369        return step_type
370    # pylint: enable=invalid-name
371
372    def select_unused_step_definitions(self):
373        step_definitions = set()
374        for step_type, values in self.step_registry.steps.items():
375            step_definitions.update(values)
376        used_step_definitions = set(self.step_usage_database.keys())
377        unused_step_definitions = step_definitions - used_step_definitions
378        return unused_step_definitions
379
380    def update_usage_database(self, step_definition, step):
381        matching_steps = self.step_usage_database.get(step_definition, None)
382        if matching_steps is None:
383            assert step_definition.step_type is not None
384            matching_steps = self.step_usage_database[step_definition] = []
385        # -- AVOID DUPLICATES: From Scenario Outlines
386        if not steps_contain(matching_steps, step):
387            matching_steps.append(step)
388
389    def update_usage_database_for_step(self, step):
390        step_definition = self.step_registry.find_step_definition(step)
391        if step_definition:
392            self.update_usage_database(step_definition, step)
393        # elif step not in self.undefined_steps:
394        elif not steps_contain(self.undefined_steps, step):
395            # -- AVOID DUPLICATES: From Scenario Outlines
396            self.undefined_steps.append(step)
397
398    # pylint: disable=invalid-name
399    def update_usage_database_for_feature(self, feature):
400        # -- PROCESS BACKGROUND (if exists): Use Background steps only once.
401        if feature.background:
402            for step in feature.background.steps:
403                self.update_usage_database_for_step(step)
404
405        # -- PROCESS SCENARIOS: Without background steps.
406        for scenario in feature.walk_scenarios():
407            for step in scenario.steps:
408                self.update_usage_database_for_step(step)
409    # pylint: enable=invalid-name
410
411    # -- FORMATTER API:
412    def feature(self, feature):
413        super(StepsUsageFormatter, self).feature(feature)
414        self.update_usage_database_for_feature(feature)
415
416    # -- REPORT API:
417    def report(self):
418        self.report_used_step_definitions()
419        self.report_unused_step_definitions()
420        self.report_undefined_steps()
421        self.stream.write("\n")
422
423    # -- REPORT SPECIFIC-API:
424    def report_used_step_definitions(self):
425        # -- STEP: Used step definitions.
426        # ORDERING: Sort step definitions by file location.
427        get_location = lambda x: x[0].location
428        step_definition_items = self.step_usage_database.items()
429        step_definition_items = sorted(step_definition_items, key=get_location)
430
431        for step_definition, steps in step_definition_items:
432            stepdef_text = self.describe_step_definition(step_definition)
433            steps_text = [u"  %s %s" % (step.keyword, step.name)
434                          for step in steps]
435            steps_text.append(stepdef_text)
436            max_size = compute_words_maxsize(steps_text)
437            if max_size < self.min_location_column:
438                max_size = self.min_location_column
439
440            schema = u"%-" + _text(max_size) + "s  # %s\n"
441            self.stream.write(schema % (stepdef_text, step_definition.location))
442            schema = u"%-" + _text(max_size) + "s  # %s\n"
443            for step, step_text in zip(steps, steps_text):
444                self.stream.write(schema % (step_text, step.location))
445            self.stream.write("\n")
446
447    def report_unused_step_definitions(self):
448        unused_step_definitions = self.select_unused_step_definitions()
449        if not unused_step_definitions:
450            return
451
452        # -- STEP: Prepare report for unused step definitions.
453        # ORDERING: Sort step definitions by file location.
454        get_location = lambda x: x.location
455        step_definitions = sorted(unused_step_definitions, key=get_location)
456        step_texts = [self.describe_step_definition(step_definition)
457                      for step_definition in step_definitions]
458
459        max_size = compute_words_maxsize(step_texts)
460        if max_size < self.min_location_column-2:
461            max_size = self.min_location_column-2
462
463        # -- STEP: Write report.
464        schema = u"  %-" + _text(max_size) + "s  # %s\n"
465        self.stream.write("UNUSED STEP DEFINITIONS[%d]:\n" % len(step_texts))
466        for step_definition, step_text in zip(step_definitions, step_texts):
467            self.stream.write(schema % (step_text, step_definition.location))
468
469    def report_undefined_steps(self):
470        if not self.undefined_steps:
471            return
472
473        # -- STEP: Undefined steps.
474        undefined_steps = sorted(self.undefined_steps,
475                                 key=attrgetter("location"))
476
477        steps_text = [u"  %s %s" % (step.keyword, step.name)
478                      for step in undefined_steps]
479        max_size = compute_words_maxsize(steps_text)
480        if max_size < self.min_location_column:
481            max_size = self.min_location_column
482
483        self.stream.write("\nUNDEFINED STEPS[%d]:\n" % len(steps_text))
484        schema = u"%-" + _text(max_size) + "s  # %s\n"
485        for step, step_text in zip(undefined_steps, steps_text):
486            self.stream.write(schema % (step_text, step.location))
487
488# -----------------------------------------------------------------------------
489# UTILITY FUNCTIONS:
490# -----------------------------------------------------------------------------
491def steps_contain(steps, step):
492    for other_step in steps:
493        if step == other_step and step.location == other_step.location:
494            # -- NOTE: Step comparison does not take location into account.
495            return True
496    # -- OTHERWISE: Not contained yet (or step in other location).
497    return False
498