From 994dbfe30e2a372182ea613333e06f069ab97d4b Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 12 Jan 2021 13:51:02 +0100 Subject: [PATCH 001/240] FIX CI-TRAVIS: For python 2.7 builds (mock >= 4.0 only for python.version >= 3.6) --- py.requirements/ci.travis.txt | 3 ++- py.requirements/testing.txt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/py.requirements/ci.travis.txt b/py.requirements/ci.travis.txt index cbc60c095..1d6e05037 100644 --- a/py.requirements/ci.travis.txt +++ b/py.requirements/ci.travis.txt @@ -6,7 +6,8 @@ pytest < 5.0; python_version < '3.0' pytest >= 5.0; python_version >= '3.0' pytest-html >= 1.19.0,<2.0 -mock >= 2.0 +mock < 4.0; python_version < '3.6' +mock >= 4.0; python_version >= '3.6' PyHamcrest >= 2.0.2; python_version >= '3.0' PyHamcrest < 2.0; python_version < '3.0' diff --git a/py.requirements/testing.txt b/py.requirements/testing.txt index fc8fd8246..9230c1f4f 100644 --- a/py.requirements/testing.txt +++ b/py.requirements/testing.txt @@ -8,7 +8,8 @@ pytest < 5.0; python_version < '3.0' # pytest >= 4.2 pytest >= 5.0; python_version >= '3.0' pytest-html >= 1.19.0,<2.0 -mock >= 2.0 +mock < 4.0; python_version < '3.6' +mock >= 4.0; python_version >= '3.6' PyHamcrest >= 2.0.2; python_version >= '3.0' PyHamcrest < 2.0; python_version < '3.0' From 49f4ca404194f4da70d4466652b468dff93a08e7 Mon Sep 17 00:00:00 2001 From: kingbuzzman Date: Fri, 19 Jun 2020 09:18:51 -0400 Subject: [PATCH 002/240] Adds the ability to use a custom runner in the behave command --- behave/__main__.py | 2 +- behave/configuration.py | 16 ++++++++++++++++ docs/behave.rst | 5 +++++ tests/unit/test_configuration.py | 19 +++++++++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/behave/__main__.py b/behave/__main__.py index 3cae36d3b..edb99c498 100644 --- a/behave/__main__.py +++ b/behave/__main__.py @@ -215,7 +215,7 @@ def main(args=None): :return: 0, if successful. Non-zero, in case of errors/failures. """ config = Configuration(args) - return run_behave(config) + return run_behave(config, runner_class=config.runner_class) if __name__ == "__main__": diff --git a/behave/configuration.py b/behave/configuration.py index 65e2e3e96..04c014aa5 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -8,6 +8,7 @@ import sys import shlex import six +from importlib import import_module from six.moves import configparser from behave.model import ScenarioOutline @@ -65,6 +66,16 @@ def to_string(level): # ----------------------------------------------------------------------------- # CONFIGURATION SCHEMA: # ----------------------------------------------------------------------------- + +def valid_python_module(path): + try: + module_path, class_name = path.rsplit('.', 1) + module = import_module(module_path) + return getattr(module, class_name) + except (ValueError, AttributeError, ImportError): + raise argparse.ArgumentTypeError("No module named '%s' was found." % path) + + options = [ (("-c", "--no-color"), dict(action="store_false", dest="color", @@ -111,6 +122,11 @@ def to_string(level): dict(metavar="PATH", dest="junit_directory", default="reports", help="""Directory in which to store JUnit reports.""")), + + (("--runner-class",), + dict(action="store", + default="behave.runner.Runner", type=valid_python_module, + help="Tells Behave to use a specific runner. (default: %(default)s)")), ((), # -- CONFIGFILE only dict(dest="default_format", diff --git a/docs/behave.rst b/docs/behave.rst index 25ce52327..8c1c12591 100644 --- a/docs/behave.rst +++ b/docs/behave.rst @@ -55,6 +55,11 @@ You may see the same information presented below at any time using ``behave Directory in which to store JUnit reports. +.. option:: --runner-class + + This allows you to use your own custom runner. The default is + ``behave.runner.Runner``. + .. option:: -f, --format Specify a formatter. If none is specified the default formatter is diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index c96cf63a5..025a6d06f 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -5,6 +5,7 @@ import pytest from behave import configuration from behave.configuration import Configuration, UserData +from behave.runner import Runner as BaseRunner from unittest import TestCase @@ -37,6 +38,10 @@ ROOTDIR_PREFIX = os.environ.get("BEHAVE_ROOTDIR_PREFIX", ROOTDIR_PREFIX_DEFAULT) +class CustomTestRunner(BaseRunner): + """Custom, dummy runner""" + + class TestConfiguration(object): def test_read_file(self): @@ -92,6 +97,20 @@ def test_settings_with_stage_from_envvar(self): assert "STAGE2_environment.py" == config.environment_file del os.environ["BEHAVE_STAGE"] + def test_settings_runner_class(self, capsys): + config = Configuration("") + assert BaseRunner == config.runner_class + + def test_settings_runner_class_custom(self, capsys): + config = Configuration(["--runner-class=tests.unit.test_configuration.CustomTestRunner"]) + assert CustomTestRunner == config.runner_class + + def test_settings_runner_class_invalid(self, capsys): + with pytest.raises(SystemExit): + Configuration(["--runner-class=does.not.exist.Runner"]) + captured = capsys.readouterr() + assert "No module named 'does.not.exist.Runner' was found." in captured.err + class TestConfigurationUserData(TestCase): """Test userdata aspects in behave.configuration.Configuration class.""" From 7461dcf065a95aba28e679108d7aa5a424939b67 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Thu, 3 Aug 2017 07:29:38 -0700 Subject: [PATCH 003/240] Allow forcing color with --color=always even if stdout is not a tty (e.g.: Jenkins) --- behave/configuration.py | 7 ++++--- behave/formatter/pretty.py | 12 +++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/behave/configuration.py b/behave/configuration.py index 04c014aa5..1b0bc2b11 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -82,9 +82,10 @@ def valid_python_module(path): help="Disable the use of ANSI color escapes.")), (("--color",), - dict(action="store_true", dest="color", - help="""Use ANSI color escapes. This is the default - behaviour. This switch is used to override a + dict(dest="color", choices=["never", "always", "auto"], + default="auto", const="auto", nargs="?", + help="""Use ANSI color escapes. Defaults to %(const)r. + This switch is used to override a configuration file setting.""")), (("-d", "--dry-run"), diff --git a/behave/formatter/pretty.py b/behave/formatter/pretty.py index 794e1d793..b97438aae 100644 --- a/behave/formatter/pretty.py +++ b/behave/formatter/pretty.py @@ -66,9 +66,7 @@ def __init__(self, stream_opener, config): super(PrettyFormatter, self).__init__(stream_opener, config) # -- ENSURE: Output stream is open. self.stream = self.open() - isatty = getattr(self.stream, "isatty", lambda: True) - stream_supports_colors = isatty() - self.monochrome = not config.color or not stream_supports_colors + self.monochrome = self._get_monochrome(config) self.show_source = config.show_source self.show_timings = config.show_timings self.show_multiline = config.show_multiline @@ -83,6 +81,14 @@ def __init__(self, stream_opener, config): self.indentations = [] self.step_lines = 0 + def _get_monochrome(self, config): + isatty = getattr(self.stream, "isatty", lambda: True) + if config.color == 'always': + return False + elif config.color == 'never': + return True + else: + return not isatty() def reset(self): # -- UNUSED: self.tag_statement = None From a93a079e1079b37bca28e23af5459b89d9b90d71 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Thu, 3 Aug 2017 09:11:22 -0700 Subject: [PATCH 004/240] Allow --color with no value followed by posarg Allow commands like `--color features/whizbang.feature` to work Without this, argparse will treat the positional arg as the value to --color and we'd get: argument --color: invalid choice: 'features/whizbang.feature' --- behave/configuration.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/behave/configuration.py b/behave/configuration.py index 1b0bc2b11..0fdfd5eb6 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -83,7 +83,7 @@ def valid_python_module(path): (("--color",), dict(dest="color", choices=["never", "always", "auto"], - default="auto", const="auto", nargs="?", + default=None, const="auto", nargs="?", help="""Use ANSI color escapes. Defaults to %(const)r. This switch is used to override a configuration file setting.""")), @@ -562,6 +562,16 @@ def __init__(self, command_args=None, load_config=True, verbose=None, # -- AUTO-DISCOVER: Verbose mode from command-line args. verbose = ("-v" in command_args) or ("--verbose" in command_args) + # Allow commands like `--color features/whizbang.feature` to work + # Without this, argparse will treat the positional arg as the value to + # --color and we'd get: + # argument --color: invalid choice: 'features/whizbang.feature' + # (choose from 'never', 'always', 'auto') + if '--color' in command_args: + color_arg_pos = command_args.index('--color') + if os.path.exists(command_args[color_arg_pos + 1]): + command_args.insert(color_arg_pos + 1, '--') + self.version = None self.tags_help = None self.lang_list = None From bfda54448906d919ffd3fa4424b6d3c155a5388b Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Thu, 3 Aug 2017 13:29:55 -0700 Subject: [PATCH 005/240] Add BEHAVE_COLOR env var --- behave/configuration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/behave/configuration.py b/behave/configuration.py index 0fdfd5eb6..e7d385d6c 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -83,7 +83,7 @@ def valid_python_module(path): (("--color",), dict(dest="color", choices=["never", "always", "auto"], - default=None, const="auto", nargs="?", + default=os.getenv('BEHAVE_COLOR'), const="auto", nargs="?", help="""Use ANSI color escapes. Defaults to %(const)r. This switch is used to override a configuration file setting.""")), @@ -507,7 +507,7 @@ class Configuration(object): """Configuration object for behave and behave runners.""" # pylint: disable=too-many-instance-attributes defaults = dict( - color=sys.platform != "win32", + color='never' if sys.platform == "win32" else os.getenv('BEHAVE_COLOR', 'auto'), show_snippets=True, show_skipped=True, dry_run=False, From 6b1e5c4137d9f9e37072f46bc9dc76c6b5984c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Dom=C3=ADnguez?= Date: Thu, 9 Sep 2021 12:19:21 +0200 Subject: [PATCH 006/240] fix: malformed table rows warning --- behave/parser.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/behave/parser.py b/behave/parser.py index 58c68be5a..b71adfe0f 100644 --- a/behave/parser.py +++ b/behave/parser.py @@ -41,6 +41,7 @@ # pylint: enable=line-too-long from __future__ import absolute_import, with_statement +import logging import re import sys import six @@ -644,6 +645,10 @@ def action_table(self, line): self.state = "steps" return self.action_steps(line) + if not re.match(r"^(|.+)\|$", line): + logger = logging.getLogger("behave") + logger.warning(u"Malformed table row at %s: line %i", self.feature.filename, self.line) + # -- SUPPORT: Escaped-pipe(s) in Gherkin cell values. # Search for pipe(s) that are not preceeded with an escape char. cells = [cell.replace("\\|", "|").strip() From 34ec5097f33c9b1620714d8dd95e75901559668b Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 12 Sep 2021 16:07:32 +0200 Subject: [PATCH 007/240] FIX #955: setup: Remove attribute 'use_2to3' REASON: * This attribute is deprecated since setuptools >= v58.0.2 (2021-09-06). * 2to3 conversion should not be needed anymore. Currently, code should run on python2 and python3 (by using six, etc.). --- setup.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index fd89bdad5..ba407fd9d 100644 --- a/setup.py +++ b/setup.py @@ -118,8 +118,8 @@ def find_packages_by_root_package(where): "pylint", ], }, - # MAYBE-DISABLE: use_2to3 - use_2to3= bool(python_version >= 3.0), + # DISABLED: use_2to3= bool(python_version >= 3.0), + # DEPRECATED SINCE: setuptools v58.0.2 (2021-09-06) license="BSD", classifiers=[ "Development Status :: 4 - Beta", @@ -129,12 +129,11 @@ def find_packages_by_root_package(where): "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.3", - "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: Jython", "Programming Language :: Python :: Implementation :: PyPy", From 9520119376046aeff73804b5f1ea05d87a63f370 Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 20 Sep 2021 16:13:59 +0200 Subject: [PATCH 008/240] Add info for fixed issue #955 --- CHANGES.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.rst b/CHANGES.rst index ff821328d..880fd91f4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -42,6 +42,7 @@ FIXED: * FIXED: Some tests related to python3.9 * FIXED: active-tag logic if multiple tags with same category exists. +* issue #955: setup: Remove attribute 'use_2to3' (submitted by: krisgesling) * issue #772: ScenarioOutline.Examples without table (submitted by: The-QA-Geek) * issue #755: Failures with Python 3.8 (submitted by: hroncok) * issue #725: Scenario Outline description lines seem to be ignored (submitted by: nizwiz) From 0ba9e4f521f42ef71327485730e5a3f8a3d2f273 Mon Sep 17 00:00:00 2001 From: Drew Ayling Date: Thu, 14 Oct 2021 11:39:45 -0600 Subject: [PATCH 009/240] Update __init__.py in behave import to fix pylint #641 complains about having to disable the no-name-in-module import error everywhere behave is imported OR you have to disable wildcard-import either way - users using strict configurations of pylint are burdened with this to simply get around it, if the steps are directly imported in `behave/__init__.py` this is no longer a burden on the user - if for some reason a new step type is added to the registry, this line would need to be updated (but so would the `__all__`), so I dont think this is too much of a burden on improvements to behave --- behave/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/behave/__init__.py b/behave/__init__.py index 53a533724..c913f986e 100644 --- a/behave/__init__.py +++ b/behave/__init__.py @@ -17,7 +17,7 @@ """ from __future__ import absolute_import -from behave.step_registry import * # pylint: disable=wildcard-import +from behave.step_registry import given, when, then, step, Given, When, Then, Step # pylint: disable=no-name-in-module from behave.matchers import use_step_matcher, step_matcher, register_type from behave.fixture import fixture, use_fixture from behave.version import VERSION as __version__ From c1710837ff0f14b0bd9497cb9372905116ef7f64 Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Sun, 9 Jan 2022 02:19:22 +0100 Subject: [PATCH 010/240] Allow installing additional formatters as an option Closes behave-contrib/behave-html-formatter#5 --- docs/install.rst | 18 ++++++++++++++++++ setup.py | 5 +++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/install.rst b/docs/install.rst index 36cb3d06a..6e78ce703 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -55,3 +55,21 @@ where is the placeholder for an `existing tag`_. .. _`Github repository`: https://github.com/behave/behave .. _`existing tag`: https://github.com/behave/behave/tags + + +Optional Dependencies +--------------------- + +If needed, additional dependencies can be installed using ``pip install`` +with one of the following installation targets. + +==================== =================================================================== +Installation Target Description +==================== =================================================================== +behave[docs] Include packages needed for building Behave's documentation. +behave[develop] Optional packages helpful for local development. +behave[formatters] Install formatters from `behave-contrib`_ to extend the list of + :ref:`id.appendix.formatters` provided by default. +==================== =================================================================== + +.. _`behave-contrib`: https://github.com/behave-contrib diff --git a/setup.py b/setup.py index ba407fd9d..163cdd26e 100644 --- a/setup.py +++ b/setup.py @@ -117,6 +117,9 @@ def find_packages_by_root_package(where): "modernize >= 0.5", "pylint", ], + 'formatters': [ + "behave-html-formatter", + ], }, # DISABLED: use_2to3= bool(python_version >= 3.0), # DEPRECATED SINCE: setuptools v58.0.2 (2021-09-06) @@ -142,5 +145,3 @@ def find_packages_by_root_package(where): ], zip_safe = True, ) - - From 647b62e264f06eaf37ddc5600df31b40507ed9fd Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Sun, 9 Jan 2022 02:59:28 +0100 Subject: [PATCH 011/240] Add link to tutorial by Nicole Harris and talk by Nick Coghlan As suggested by @ncoghlan via #848. --- docs/more_info.rst | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/more_info.rst b/docs/more_info.rst index 0d87a9c46..65b26b179 100644 --- a/docs/more_info.rst +++ b/docs/more_info.rst @@ -16,13 +16,15 @@ and `behave`_ (after reading the behave documentation): The following small tutorials provide an introduction how you use `behave`_ in a specific testing domain: -* Phillip Johnson, `Getting Started with Behavior Testing in Python with Behave`_ -* `Bdd with Python, Behave and WebDriver`_ +* Phillip Johnson, `Getting Started with Behavior Testing in Python with Behave`_, 2015-10-15. +* Nicole Harris, `Beginning BDD with Django`_ (part 1 and 2), 2015-03-16. +* TestingBot, `Bdd with Python, Behave and WebDriver`_ * Wayne Witzel III, `Using Behave with Pyramid`_, 2014-01-10. .. _`Getting Started with Behavior Testing in Python with Behave`: https://semaphoreci.com/community/tutorials/getting-started-with-behavior-testing-in-python-with-behave +.. _`Beginning BDD with Django`: https://whoisnicoleharris.com/2015/03/16/bdd-part-one.html .. _`Bdd with Python, Behave and WebDriver`: https://testingbot.com/support/getting-started/behave.html -.. _`Using Behave with Pyramid`: https://www.safaribooksonline.com/blog/2014/01/10/using-behave-with-pyramid/ +.. _`Using Behave with Pyramid`: https://active6.blogspot.com/2014/01/using-behave-with-pyramid.html .. warning:: @@ -73,6 +75,8 @@ Presentation Videos * `Selenium Python Webdriver Tutorial - Behave (BDD)`_ (14min), 2016-01-21 +* `Front-end integration testing with splinter`_ (30min), 2017-08-05 + .. hidden: @@ -91,6 +95,8 @@ Presentation Videos * `Selenium Python Webdriver Tutorial - Behave (BDD)`_ (14min), 2016-01-21 + * `Front-end integration testing with splinter`_ (30min), 2017-08-05 + .. hint:: @@ -144,11 +150,22 @@ Presentation Videos :width: 600 :height: 400 + Nick Coghlan: `Front-end integration testing with splinter`_ + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + :Date: 2017-08-05 + :Duration: 30min + + .. youtube:: HY0_RtTUfUg + :width: 600 + :height: 400 + .. _`Making Your Application Behave`: https://www.youtube.com/watch?v=u8BOKuNkmhg .. _`First behave python tutorial with selenium`: https://www.youtube.com/watch?v=D24_QrGUCFk .. _`Automation with Python and Behave`: https://www.youtube.com/watch?v=e78c7h6DRDQ .. _`Selenium Python Webdriver Tutorial - Behave (BDD)`: https://www.youtube.com/watch?v=mextSo0UExc +.. _`Front-end integration testing with splinter`: https://pyvideo.org/pycon-au-2017/front-end-integration-testing-with-splinter.html .. _sphinxcontrib-youtube: https://bitbucket.org/birkenfeld/sphinx-contrib From 1ac8f282e492ae328e025ed9fd27aa97414ce913 Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 12 Jan 2022 22:13:37 +0100 Subject: [PATCH 012/240] Create Security Policy Add "SECURITY.md" file with details. --- .github/SECURITY.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/SECURITY.md diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 000000000..008bcd97f --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,25 @@ +# Security Policy + +## Supported Versions + +The following versions of `behave` are currently being supported with security updates. + +| Version | Supported | +| ---------- | ------------------- | +| `HEAD` | :white_check_mark: | +| `1.2.6` | :white_check_mark: | + +HINT: Older versions are not supported. + + +## Reporting a Vulnerability + +SHORT VERSION: Please report security issues by emailing to [behave-security@noreply.github.com](mailto:jenisys@users.noreply.github.com) . + +If you believe you’ve found something in Django which has security implications, +please send a description of the issue via email to the email address mentioned above (see: SHORT VERSION). +Mail sent to that address reaches the security team. + +Once you’ve submitted an issue via email, you should receive an acknowledgment from a member of the security team within 48 hours, +and depending on the action to be taken, you may receive further followup emails. + From 4ff26ec55df77412e8ee4737f0cfb482442fe5a4 Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 12 Jan 2022 22:16:56 +0100 Subject: [PATCH 013/240] Create codeql-analysis.yml --- .github/workflows/codeql-analysis.yml | 70 +++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/workflows/codeql-analysis.yml diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 000000000..de682a8f7 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,70 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master ] + schedule: + - cron: '16 19 * * 6' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Learn more about CodeQL language support at https://git.io/codeql-language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 From 2e547b0fd2da2a6987f27c1b56b0f4b321f02617 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 16 Jan 2022 11:10:00 +0100 Subject: [PATCH 014/240] UPDATE: Acknowledge #989 and #848 --- CHANGES.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.rst b/CHANGES.rst index 880fd91f4..8f9340268 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -63,6 +63,7 @@ MINOR: DOCUMENTATION: +* pull #989: Add more tutorial links: Nicole Harris, Nick Coghlan (provided by: ncoghlan, bittner; related: #848) * pull #877: docs: API reference - Capitalizing Step Keywords in example (provided by: Ibrian93) * pull #731: Update links to Django docs (provided by: bittner) * pull #722: DOC remove remaining pythonhosted links (provided by: leszekhanusz) From 703b92a0a6e7069051903e678c73e6c5719596d2 Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Sun, 9 Jan 2022 02:59:28 +0100 Subject: [PATCH 015/240] Add link to tutorial by Nicole Harris and talk by Nick Coghlan As suggested by @ncoghlan via #848. --- docs/more_info.rst | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/more_info.rst b/docs/more_info.rst index 0d87a9c46..65b26b179 100644 --- a/docs/more_info.rst +++ b/docs/more_info.rst @@ -16,13 +16,15 @@ and `behave`_ (after reading the behave documentation): The following small tutorials provide an introduction how you use `behave`_ in a specific testing domain: -* Phillip Johnson, `Getting Started with Behavior Testing in Python with Behave`_ -* `Bdd with Python, Behave and WebDriver`_ +* Phillip Johnson, `Getting Started with Behavior Testing in Python with Behave`_, 2015-10-15. +* Nicole Harris, `Beginning BDD with Django`_ (part 1 and 2), 2015-03-16. +* TestingBot, `Bdd with Python, Behave and WebDriver`_ * Wayne Witzel III, `Using Behave with Pyramid`_, 2014-01-10. .. _`Getting Started with Behavior Testing in Python with Behave`: https://semaphoreci.com/community/tutorials/getting-started-with-behavior-testing-in-python-with-behave +.. _`Beginning BDD with Django`: https://whoisnicoleharris.com/2015/03/16/bdd-part-one.html .. _`Bdd with Python, Behave and WebDriver`: https://testingbot.com/support/getting-started/behave.html -.. _`Using Behave with Pyramid`: https://www.safaribooksonline.com/blog/2014/01/10/using-behave-with-pyramid/ +.. _`Using Behave with Pyramid`: https://active6.blogspot.com/2014/01/using-behave-with-pyramid.html .. warning:: @@ -73,6 +75,8 @@ Presentation Videos * `Selenium Python Webdriver Tutorial - Behave (BDD)`_ (14min), 2016-01-21 +* `Front-end integration testing with splinter`_ (30min), 2017-08-05 + .. hidden: @@ -91,6 +95,8 @@ Presentation Videos * `Selenium Python Webdriver Tutorial - Behave (BDD)`_ (14min), 2016-01-21 + * `Front-end integration testing with splinter`_ (30min), 2017-08-05 + .. hint:: @@ -144,11 +150,22 @@ Presentation Videos :width: 600 :height: 400 + Nick Coghlan: `Front-end integration testing with splinter`_ + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + :Date: 2017-08-05 + :Duration: 30min + + .. youtube:: HY0_RtTUfUg + :width: 600 + :height: 400 + .. _`Making Your Application Behave`: https://www.youtube.com/watch?v=u8BOKuNkmhg .. _`First behave python tutorial with selenium`: https://www.youtube.com/watch?v=D24_QrGUCFk .. _`Automation with Python and Behave`: https://www.youtube.com/watch?v=e78c7h6DRDQ .. _`Selenium Python Webdriver Tutorial - Behave (BDD)`: https://www.youtube.com/watch?v=mextSo0UExc +.. _`Front-end integration testing with splinter`: https://pyvideo.org/pycon-au-2017/front-end-integration-testing-with-splinter.html .. _sphinxcontrib-youtube: https://bitbucket.org/birkenfeld/sphinx-contrib From e749cc9298940b484ad1de4ec765b5a185b2cb82 Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 12 Jan 2022 22:13:37 +0100 Subject: [PATCH 016/240] Create Security Policy Add "SECURITY.md" file with details. --- .github/SECURITY.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/SECURITY.md diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 000000000..008bcd97f --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,25 @@ +# Security Policy + +## Supported Versions + +The following versions of `behave` are currently being supported with security updates. + +| Version | Supported | +| ---------- | ------------------- | +| `HEAD` | :white_check_mark: | +| `1.2.6` | :white_check_mark: | + +HINT: Older versions are not supported. + + +## Reporting a Vulnerability + +SHORT VERSION: Please report security issues by emailing to [behave-security@noreply.github.com](mailto:jenisys@users.noreply.github.com) . + +If you believe you’ve found something in Django which has security implications, +please send a description of the issue via email to the email address mentioned above (see: SHORT VERSION). +Mail sent to that address reaches the security team. + +Once you’ve submitted an issue via email, you should receive an acknowledgment from a member of the security team within 48 hours, +and depending on the action to be taken, you may receive further followup emails. + From b384f4d4756ca46c72abfa0127a8adbe26fa5c58 Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 12 Jan 2022 22:16:56 +0100 Subject: [PATCH 017/240] Create codeql-analysis.yml --- .github/workflows/codeql-analysis.yml | 70 +++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/workflows/codeql-analysis.yml diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 000000000..de682a8f7 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,70 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master ] + schedule: + - cron: '16 19 * * 6' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Learn more about CodeQL language support at https://git.io/codeql-language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 From 6f93d7a3ae958bcfc01d7d30d84ed2aae9247d06 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 16 Jan 2022 11:10:00 +0100 Subject: [PATCH 018/240] UPDATE: Acknowledge #989 and #848 --- CHANGES.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.rst b/CHANGES.rst index 880fd91f4..8f9340268 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -63,6 +63,7 @@ MINOR: DOCUMENTATION: +* pull #989: Add more tutorial links: Nicole Harris, Nick Coghlan (provided by: ncoghlan, bittner; related: #848) * pull #877: docs: API reference - Capitalizing Step Keywords in example (provided by: Ibrian93) * pull #731: Update links to Django docs (provided by: bittner) * pull #722: DOC remove remaining pythonhosted links (provided by: leszekhanusz) From 67b5d1829f79523f05f26247bdd4c451d8d357ed Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 30 Jan 2022 22:21:25 +0100 Subject: [PATCH 019/240] UPDATE: pull #967 (fixes: #641) --- CHANGES.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 8f9340268..a8c5b7a41 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -42,12 +42,14 @@ FIXED: * FIXED: Some tests related to python3.9 * FIXED: active-tag logic if multiple tags with same category exists. +* pull #967: Update __init__.py in behave import to fix pylint (provided by: dsayling) * issue #955: setup: Remove attribute 'use_2to3' (submitted by: krisgesling) * issue #772: ScenarioOutline.Examples without table (submitted by: The-QA-Geek) * issue #755: Failures with Python 3.8 (submitted by: hroncok) * issue #725: Scenario Outline description lines seem to be ignored (submitted by: nizwiz) * issue #713: Background section doesn't support description (provided by: dgou) * pull #657: Allow async steps with timeouts to fail when they raise exceptions (provided by: ALSchwalm) +* issue #641: Pylint errors when importing given - when - then from behave (solved by: #967) * issue #631: ScenarioOutline variables not possible in table headings (provided by: mschnelle, pull #642) * issue #619: Context __getattr__ should raise AttributeError instead of KeyError (submitted by: anxodio) * pull #588: Steps-catalog argument should not break configured rerun settings (provided by: Lego3) From 9c413aee1e2553f9197458ebf8c71d274a2cafac Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 5 Feb 2022 21:24:51 +0100 Subject: [PATCH 020/240] ADD .envrc: Simplify setup of environmenty variables (via: direnv; or: source) --- .envrc | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .envrc diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..6c5ecfd5c --- /dev/null +++ b/.envrc @@ -0,0 +1,20 @@ +# =========================================================================== +# PROJECT ENVIRONMENT SETUP: HERE/.envrc +# =========================================================================== +# SHELL: bash (or similiar) +# SEE ALSO: https://direnv.net/ +# USAGE: +# source .envrc +# +# # -- BETTER: Use direnv (requires: Setup in bash -- $HOME/.bashrc) +# # eval "$(direnv hook bash)" +# direnv allow . +# +# SIMPLISTIC ALTERNATIVE (with cleanup when directory scope is left again): +# source .envrc +# =========================================================================== + +export HERE="$PWD" +export PYTHONPATH=".:${HERE}:${PYTHONPATH}" +export PATH="${HERE}/bin:${PATH}" +unset HERE From 39bbf525aa7fe1e1f3731536c3b2854d500e7fe4 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 5 Feb 2022 21:36:46 +0100 Subject: [PATCH 021/240] setup.py: Readd final empty line --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 163cdd26e..1c8d83c3a 100644 --- a/setup.py +++ b/setup.py @@ -145,3 +145,4 @@ def find_packages_by_root_package(where): ], zip_safe = True, ) + From f6c0712fcb2ac298cdb2db52dae40a8072a16e07 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 5 Feb 2022 21:42:51 +0100 Subject: [PATCH 022/240] CHANGES: Add pull #988 description --- CHANGES.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.rst b/CHANGES.rst index a8c5b7a41..7af92190e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -28,6 +28,7 @@ ENHANCEMENTS: * Use cucumber "gherkin-languages.json" now (simplify: Gherkin v6 aliases, language usage) * Support emojis in ``*.feature`` files and steps * Select-by-location: Add support for "Scenario container" (Feature, Rule, ScenarioOutline) (related to: #391) +* pull #988: setup.py: Add category to install additional formatters (html) (provided-by: bittner) * pull #895: UPDATE: i18n/gherkin-languages.json from cucumber repository #895 (related to: #827) * pull #827: Fixed keyword translation in Estonian #827 (provided by: ookull) * issue #740: Enhancement: possibility to add cleanup to be called upon leaving outer context stack frames (submitted by: nizwiz, dcvmoole) From 6456f34326727558c838381ababf345d7f48127d Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 14 Feb 2022 23:44:12 +0100 Subject: [PATCH 023/240] FIX for Python 3.10: * DEPRECATED: asyncio.get_event_loop() REMOVED IN: Python 3.12 USE: asyncio.get_running_loop() instead (since: Python 3.7) --- behave/api/async_step.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/behave/api/async_step.py b/behave/api/async_step.py index 85e2b2a92..939452735 100644 --- a/behave/api/async_step.py +++ b/behave/api/async_step.py @@ -54,6 +54,7 @@ def step_async_step_waits_seconds2(context, duration): except ImportError: has_asyncio = False + # ----------------------------------------------------------------------------- # ASYNC STEP DECORATORS: # ----------------------------------------------------------------------------- @@ -156,14 +157,17 @@ def wrapped_decorator2(context, *args, **kwargs): # -- CASE: @decorator ... or astep = decorator(astep) # MAYBE: return functools.partial(step_decorator, astep_func=astep_func) assert callable(astep_func) + @functools.wraps(astep_func) def wrapped_decorator(context, *args, **kwargs): return step_decorator(astep_func, context, *args, **kwargs) return wrapped_decorator + # -- ALIAS: run_until_complete = async_run_until_complete + # ----------------------------------------------------------------------------- # ASYNC STEP UTILITY CLASSES: # ----------------------------------------------------------------------------- @@ -224,7 +228,8 @@ async def my_async_func(param): default_name = "async_context" def __init__(self, loop=None, name=None, should_close=False, tasks=None): - self.loop = loop or asyncio.get_event_loop() or asyncio.new_event_loop() + # DISABLED: loop = asyncio.get_event_loop() or asyncio.new_event_loop() + self.loop = use_or_create_event_loop(loop) self.tasks = tasks or [] self.name = name or self.default_name self.should_close = should_close @@ -242,6 +247,22 @@ def close(self): # ----------------------------------------------------------------------------- # ASYNC STEP UTILITY FUNCTIONS: # ----------------------------------------------------------------------------- +def use_or_create_event_loop(loop=None): + if loop: + # -- USE: Supplied event loop. + return loop + + # -- NORMAL CASE: Try to use the current event loop or create a new one. + try: + # -- SINCE: Python 3.7 + return asyncio.get_running_loop() + except RuntimeError: + return asyncio.new_event_loop() + except AttributeError: + # -- BACKWARD-COMPATIBLE: For Python < 3.7 + return asyncio.get_event_loop() + + def use_or_create_async_context(context, name=None, loop=None, **kwargs): """Utility function to be used in step implementations to ensure that an :class:`AsyncContext` object is stored in the :param:`context` object. From ab369f5879b45f4fbebacee6f9c58539bdb4a81c Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 14 Feb 2022 23:46:37 +0100 Subject: [PATCH 024/240] FIX: behave.userdata.parse_bool() in Python 3.10 * DEPRECATED: distutils.util.strtobool REMOVED IN: Python 3.12 USE: Own implementation instead (based on: strtobool()) --- behave/userdata.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/behave/userdata.py b/behave/userdata.py index 84a79eb12..b2e052f1a 100644 --- a/behave/userdata.py +++ b/behave/userdata.py @@ -19,8 +19,14 @@ def parse_bool(text): :raises: ValueError, if text is invalid """ - from distutils.util import strtobool - return bool(strtobool(text)) + # -- BASED ON: distutils.util.strtobool (deprecated; removed in Python 3.12) + text = text.lower().strip() + if text in ("yes", "true", "on", "1"): + return True + elif text in ("no", "false", "off", "0"): + return False + else: + raise ValueError("invalid truth value: %r" % (text,)) def parse_user_define(text): From 0716b2c3ed576baff73145d084cac9e13ef1f514 Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 15 Feb 2022 07:18:32 +0100 Subject: [PATCH 025/240] ADD ATTRIBUTE: captured to Context, ModelRunner, CaptureController REMOVED: Context.stdout_capture, .stderr_capture, .log_capture --- behave/capture.py | 19 ++------------- behave/configuration.py | 2 +- behave/runner.py | 52 ++++++++++++++++++++++++----------------- 3 files changed, 33 insertions(+), 40 deletions(-) diff --git a/behave/capture.py b/behave/capture.py index 35cbae21c..d0a3b7bf4 100644 --- a/behave/capture.py +++ b/behave/capture.py @@ -108,6 +108,7 @@ def __iadd__(self, other): class CaptureController(object): """Simplifies the lifecycle to capture output from various sources.""" + def __init__(self, config): self.config = config self.stdout_capture = None @@ -185,23 +186,7 @@ def teardown_capture(self): def make_capture_report(self): """Combine collected output and return as string.""" return self.captured.make_report() - # report = u"" - # if self.config.stdout_capture and self.stdout_capture: - # output = self.stdout_capture.getvalue() - # if output: - # output = _text(output) - # report += u"\nCaptured stdout:\n" + output - # if self.config.stderr_capture and self.stderr_capture: - # output = self.stderr_capture.getvalue() - # if output: - # output = _text(output) - # report += u"\nCaptured stderr:\n" + output - # if self.config.log_capture and self.log_capture: - # output = self.log_capture.getvalue() - # if output: - # output = _text(output) - # report += u"\nCaptured logging:\n" + output - # return report + # ----------------------------------------------------------------------------- # UTILITY FUNCTIONS: diff --git a/behave/configuration.py b/behave/configuration.py index e7d385d6c..3f4daeead 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -123,7 +123,7 @@ def valid_python_module(path): dict(metavar="PATH", dest="junit_directory", default="reports", help="""Directory in which to store JUnit reports.""")), - + (("--runner-class",), dict(action="store", default="behave.runner.Runner", type=valid_python_module, diff --git a/behave/runner.py b/behave/runner.py index c583cafc1..1f32ec1bb 100644 --- a/behave/runner.py +++ b/behave/runner.py @@ -122,23 +122,13 @@ class Context(object): :class:`~behave.model.Row` that is active for the current scenario. It is present mostly for debugging, but may be useful otherwise. - .. attribute:: log_capture + .. attribute:: captured - If logging capture is enabled then this attribute contains the captured - logging as an instance of :class:`~behave.log_capture.LoggingCapture`. - It is not present if logging is not being captured. + If any output capture is enabled, provides access to a + :class:`~behave.capture.Captured` object that contains a snapshot + of all captured data (stdout/stderr/log). - .. attribute:: stdout_capture - - If stdout capture is enabled then this attribute contains the captured - output as a StringIO instance. It is not present if stdout is not being - captured. - - .. attribute:: stderr_capture - - If stderr capture is enabled then this attribute contains the captured - output as a StringIO instance. It is not present if stderr is not being - captured. + .. versionadded:: 1.3.0 A :class:`behave.runner.ContextMaskWarning` warning will be raised if user code attempts to overwrite one of these variables, or if *behave* itself @@ -179,16 +169,16 @@ def __init__(self, runner): self._mode = ContextMode.BEHAVE # -- MODEL ENTITY REFERENCES/SUPPORT: - self.feature = None # DISABLED: self.rule = None # DISABLED: self.scenario = None + self.feature = None self.text = None self.table = None # -- RUNTIME SUPPORT: - self.stdout_capture = None - self.stderr_capture = None - self.log_capture = None + # DISABLED: self.stdout_capture = None + # DISABLED: self.stderr_capture = None + # DISABLED: self.log_capture = None self.fail_on_cleanup_errors = self.FAIL_ON_CLEANUP_ERRORS @staticmethod @@ -475,6 +465,10 @@ def internal_cleanup_func(): # -- AVOID DUPLICATES: current_frame["@cleanups"].append(internal_cleanup_func) + @property + def captured(self): + return self._runner.captured + def attach(self, mime_type, data): """Embeds data (e.g. a screenshot) in reports for all formatters that support it, such as the JSON formatter. @@ -562,6 +556,14 @@ class ModelRunner(object): This is set to true when the user aborts a test run (:exc:`KeyboardInterrupt` exception). Initially: False. Stored as derived attribute in :attr:`Context.aborted`. + + .. attribute:: captured + + If any output capture is enabled, provides access to a + :class:`~behave.capture.Captured` object that contains a snapshot + of all captured data (stdout/stderr/log). + + .. versionadded:: 1.3.0 """ # pylint: disable=too-many-instance-attributes @@ -654,6 +656,13 @@ def stop_capture(self): def teardown_capture(self): self.capture_controller.teardown_capture() + @property + def captured(self): + """Return the current state of the captured output/logging + (as captured object). + """ + return self.capture_controller.captured + def run_model(self, features=None): # pylint: disable=too-many-branches if not self.context: @@ -726,20 +735,19 @@ def run(self): class Runner(ModelRunner): - """ - Standard test runner for behave: + """Standard test runner for behave: * setup paths * loads environment hooks * loads step definitions * select feature files, parses them and creates model (elements) """ + def __init__(self, config): super(Runner, self).__init__(config) self.path_manager = PathManager() self.base_dir = None - def setup_paths(self): # pylint: disable=too-many-branches, too-many-statements if self.config.paths: From d67213c0c2b13cbc5fccf3ca202cb39ed8014184 Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 15 Feb 2022 07:20:57 +0100 Subject: [PATCH 026/240] tox: Add Python 3.10 (as primary python) --- tox.ini | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index c145b5147..22b195a3a 100644 --- a/tox.ini +++ b/tox.ini @@ -23,12 +23,13 @@ # SEE ALSO: # * http://tox.testrun.org/latest/config.html # ============================================================================ +# envlist = py310, py39, py38, py27, py37, py36, py35, pypy3, pypy, docs # -- ONLINE USAGE: # PIP_INDEX_URL = http://pypi.org/simple [tox] minversion = 2.3 -envlist = py39, py38, py27, py37, py36, py35, pypy3, pypy, docs +envlist = py310, py39, py27, py38, py37, py36, py35, pypy3, pypy, docs skip_missing_interpreters = True sitepackages = False indexserver = From c67afdafa8bad3561802e3b279e4491a3941f893 Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 15 Feb 2022 07:21:41 +0100 Subject: [PATCH 027/240] docs: Document Context.stdout_capture, ... removal. --- CHANGES.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 7af92190e..aad321e1c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -13,6 +13,9 @@ BACKWARD-INCOMPATIBLE: - DEPRECATING: tag-expressions v1 (old-style) - BUT: Currently, tag-expression version is automatically detected (and used). +* CLEANUP: Remove ``stdout_capture``, ``stderr_capture``, ``log_capture`` + attributes from ``behave.runner.Context`` class + (use: ``captured`` attribute instead). GOALS: From 61781ede2e701aec6dc0314478ab52a58b2e09b0 Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 15 Feb 2022 16:28:30 +0100 Subject: [PATCH 028/240] UPDATE: i18m / gherkin-lanaguages --- behave/i18n.py | 43 +++++++++++------ etc/gherkin/gherkin-languages.json | 75 ++++++++++++++++++++++++------ features/cmdline.lang_list.feature | 1 + 3 files changed, 91 insertions(+), 28 deletions(-) diff --git a/behave/i18n.py b/behave/i18n.py index a572626ae..708e71593 100644 --- a/behave/i18n.py +++ b/behave/i18n.py @@ -23,7 +23,7 @@ 'given': ['* ', 'Gegewe '], 'name': 'Afrikaans', 'native': 'Afrikaans', - 'rule': ['Rule'], + 'rule': ['Regel'], 'scenario': ['Voorbeeld', 'Situasie'], 'scenario_outline': ['Situasie Uiteensetting'], 'then': ['* ', 'Dan '], @@ -101,7 +101,7 @@ 'given': ['* ', 'Дадено '], 'name': 'Bulgarian', 'native': 'български', - 'rule': ['Rule'], + 'rule': ['Правило'], 'scenario': ['Пример', 'Сценарий'], 'scenario_outline': ['Рамка на сценарий'], 'then': ['* ', 'То '], @@ -156,7 +156,7 @@ 'given': ['* ', 'Pokud ', 'Za předpokladu '], 'name': 'Czech', 'native': 'Česky', - 'rule': ['Rule'], + 'rule': ['Pravidlo'], 'scenario': ['Příklad', 'Scénář'], 'scenario_outline': ['Náčrt Scénáře', 'Osnova scénáře'], 'then': ['* ', 'Pak '], @@ -313,6 +313,21 @@ 'scenario_outline': ['Shiver me timbers'], 'then': ['* ', 'Let go and haul '], 'when': ['* ', 'Blimey! ']}, + 'en-tx': {'and': ['Come hell or high water '], + 'background': ["Lemme tell y'all a story"], + 'but': ["Well now hold on, I'll you what "], + 'examples': ["Now that's a story longer than a cattle drive in " + 'July'], + 'feature': ['This ain’t my first rodeo', 'All gussied up'], + 'given': ["Fixin' to ", 'All git out '], + 'name': 'Texas', + 'native': 'Texas', + 'rule': ['Rule '], + 'scenario': ['All hat and no cattle'], + 'scenario_outline': ['Serious as a snake bite', + 'Busy as a hound in flea season'], + 'then': ['There’s no tree but bears some fruit '], + 'when': ['Quick out of the chute ']}, 'eo': {'and': ['* ', 'Kaj '], 'background': ['Fono'], 'but': ['* ', 'Sed '], @@ -330,11 +345,11 @@ 'background': ['Antecedentes'], 'but': ['* ', 'Pero '], 'examples': ['Ejemplos'], - 'feature': ['Característica'], + 'feature': ['Característica', 'Necesidad del negocio', 'Requisito'], 'given': ['* ', 'Dado ', 'Dada ', 'Dados ', 'Dadas '], 'name': 'Spanish', 'native': 'español', - 'rule': ['Regla'], + 'rule': ['Regla', 'Regla de negocio'], 'scenario': ['Ejemplo', 'Escenario'], 'scenario_outline': ['Esquema del escenario'], 'then': ['* ', 'Entonces '], @@ -471,7 +486,7 @@ 'given': ['* ', 'अगर ', 'यदि ', 'चूंकि '], 'name': 'Hindi', 'native': 'हिंदी', - 'rule': ['Rule'], + 'rule': ['नियम'], 'scenario': ['परिदृश्य'], 'scenario_outline': ['परिदृश्य रूपरेखा'], 'then': ['* ', 'तब ', 'तदा '], @@ -515,7 +530,7 @@ 'given': ['* ', 'Amennyiben ', 'Adott '], 'name': 'Hungarian', 'native': 'magyar', - 'rule': ['Rule'], + 'rule': ['Szabály'], 'scenario': ['Példa', 'Forgatókönyv'], 'scenario_outline': ['Forgatókönyv vázlat'], 'then': ['* ', 'Akkor '], @@ -555,11 +570,11 @@ 'background': ['Contesto'], 'but': ['* ', 'Ma '], 'examples': ['Esempi'], - 'feature': ['Funzionalità'], + 'feature': ['Funzionalità', 'Esigenza di Business', 'Abilità'], 'given': ['* ', 'Dato ', 'Data ', 'Dati ', 'Date '], 'name': 'Italian', 'native': 'italiano', - 'rule': ['Rule'], + 'rule': ['Regola'], 'scenario': ['Esempio', 'Scenario'], 'scenario_outline': ['Schema dello scenario'], 'then': ['* ', 'Allora '], @@ -780,7 +795,7 @@ 'given': ['* ', 'Zakładając ', 'Mając ', 'Zakładając, że '], 'name': 'Polish', 'native': 'polski', - 'rule': ['Rule'], + 'rule': ['Zasada', 'Reguła'], 'scenario': ['Przykład', 'Scenariusz'], 'scenario_outline': ['Szablon scenariusza'], 'then': ['* ', 'Wtedy '], @@ -833,7 +848,7 @@ 'native': 'русский', 'rule': ['Правило'], 'scenario': ['Пример', 'Сценарий'], - 'scenario_outline': ['Структура сценария'], + 'scenario_outline': ['Структура сценария', 'Шаблон сценария'], 'then': ['* ', 'То ', 'Затем ', 'Тогда '], 'when': ['* ', 'Когда ', 'Если ']}, 'sk': {'and': ['* ', 'A ', 'A tiež ', 'A taktiež ', 'A zároveň '], @@ -881,7 +896,7 @@ 'given': ['* ', 'За дато ', 'За дате ', 'За дати '], 'name': 'Serbian', 'native': 'Српски', - 'rule': ['Rule'], + 'rule': ['Правило'], 'scenario': ['Пример', 'Сценарио', 'Пример'], 'scenario_outline': ['Структура сценарија', 'Скица', 'Концепт'], 'then': ['* ', 'Онда '], @@ -894,7 +909,7 @@ 'given': ['* ', 'Za dato ', 'Za date ', 'Za dati '], 'name': 'Serbian (Latin)', 'native': 'Srpski (Latinica)', - 'rule': ['Rule'], + 'rule': ['Pravilo'], 'scenario': ['Scenario', 'Primer'], 'scenario_outline': ['Struktura scenarija', 'Skica', 'Koncept'], 'then': ['* ', 'Onda '], @@ -972,7 +987,7 @@ 'given': ['* ', 'Diyelim ki '], 'name': 'Turkish', 'native': 'Türkçe', - 'rule': ['Rule'], + 'rule': ['Kural'], 'scenario': ['Örnek', 'Senaryo'], 'scenario_outline': ['Senaryo taslağı'], 'then': ['* ', 'O zaman '], diff --git a/etc/gherkin/gherkin-languages.json b/etc/gherkin/gherkin-languages.json index 6069664e2..83f0559d9 100644 --- a/etc/gherkin/gherkin-languages.json +++ b/etc/gherkin/gherkin-languages.json @@ -26,7 +26,7 @@ "name": "Afrikaans", "native": "Afrikaans", "rule": [ - "Rule" + "Regel" ], "scenario": [ "Voorbeeld", @@ -303,7 +303,7 @@ "name": "Bulgarian", "native": "български", "rule": [ - "Rule" + "Правило" ], "scenario": [ "Пример", @@ -494,7 +494,7 @@ "name": "Czech", "native": "Česky", "rule": [ - "Rule" + "Pravidlo" ], "scenario": [ "Příklad", @@ -1013,6 +1013,46 @@ "* ", "Blimey! " ] + }, + "en-tx": { + "and": [ + "Come hell or high water " + ], + "background": [ + "Lemme tell y'all a story" + ], + "but": [ + "Well now hold on, I'll you what " + ], + "examples": [ + "Now that's a story longer than a cattle drive in July" + ], + "feature": [ + "This ain’t my first rodeo", + "All gussied up" + ], + "given": [ + "Fixin' to ", + "All git out " + ], + "name": "Texas", + "native": "Texas", + "rule": [ + "Rule " + ], + "scenario": [ + "All hat and no cattle" + ], + "scenarioOutline": [ + "Serious as a snake bite", + "Busy as a hound in flea season" + ], + "then": [ + "There’s no tree but bears some fruit " + ], + "when": [ + "Quick out of the chute " + ] }, "eo": { "and": [ @@ -1078,7 +1118,9 @@ "Ejemplos" ], "feature": [ - "Característica" + "Característica", + "Necesidad del negocio", + "Requisito" ], "given": [ "* ", @@ -1090,7 +1132,8 @@ "name": "Spanish", "native": "español", "rule": [ - "Regla" + "Regla", + "Regla de negocio" ], "scenario": [ "Ejemplo", @@ -1520,7 +1563,7 @@ "name": "Hindi", "native": "हिंदी", "rule": [ - "Rule" + "नियम" ], "scenario": [ "परिदृश्य" @@ -1672,7 +1715,7 @@ "name": "Hungarian", "native": "magyar", "rule": [ - "Rule" + "Szabály" ], "scenario": [ "Példa", @@ -1804,7 +1847,9 @@ "Esempi" ], "feature": [ - "Funzionalità" + "Funzionalità", + "Esigenza di Business", + "Abilità" ], "given": [ "* ", @@ -1816,7 +1861,7 @@ "name": "Italian", "native": "italiano", "rule": [ - "Rule" + "Regola" ], "scenario": [ "Esempio", @@ -2561,7 +2606,8 @@ "name": "Polish", "native": "polski", "rule": [ - "Rule" + "Zasada", + "Reguła" ], "scenario": [ "Przykład", @@ -2736,7 +2782,8 @@ "Сценарий" ], "scenarioOutline": [ - "Структура сценария" + "Структура сценария", + "Шаблон сценария" ], "then": [ "* ", @@ -2896,7 +2943,7 @@ "name": "Serbian", "native": "Српски", "rule": [ - "Rule" + "Правило" ], "scenario": [ "Пример", @@ -2951,7 +2998,7 @@ "name": "Serbian (Latin)", "native": "Srpski (Latinica)", "rule": [ - "Rule" + "Pravilo" ], "scenario": [ "Scenario", @@ -3229,7 +3276,7 @@ "name": "Turkish", "native": "Türkçe", "rule": [ - "Rule" + "Kural" ], "scenario": [ "Örnek", diff --git a/features/cmdline.lang_list.feature b/features/cmdline.lang_list.feature index 20822ec10..138adfc1e 100644 --- a/features/cmdline.lang_list.feature +++ b/features/cmdline.lang_list.feature @@ -33,6 +33,7 @@ Feature: Command-line options: Use behave --lang-list en-lol: LOLCAT / LOLCAT en-old: Englisc / Old English en-pirate: Pirate / Pirate + en-tx: Texas / Texas eo: Esperanto / Esperanto es: español / Spanish et: eesti keel / Estonian From a1392dcd40e61daed991c045466ec2493808a9be Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 6 Mar 2022 21:22:20 +0100 Subject: [PATCH 029/240] Renamed default branch of Git repository to "main" (was: "master") --- CHANGES.rst | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index aad321e1c..1703a5b3f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,23 +6,28 @@ Version: 1.2.7 (unreleased) BACKWARD-INCOMPATIBLE: -* Replace old-style tag-expressions with `cucumber-tag-expressions`_ +* Replace old-style tag-expressions with `cucumber-tag-expressions`_ as ``tag-expressions v2``. HINTS: - - DEPRECATING: tag-expressions v1 (old-style) + - DEPRECATING: ``tag-expressions v1`` (old-style) - BUT: Currently, tag-expression version is automatically detected (and used). -* CLEANUP: Remove ``stdout_capture``, ``stderr_capture``, ``log_capture`` - attributes from ``behave.runner.Context`` class - (use: ``captured`` attribute instead). - GOALS: - Improve support for Windows (continued) - FIX: Unicode problems on Windows (in behave-1.2.6) - FIX: Regression test problems on Windows (in behave-1.2.6) +DEVELOPMENT: + +* Renamed default branch of Git repository to "main" (was: "master"). + +CLEANUPS: + +* Remove ``stdout_capture``, ``stderr_capture``, ``log_capture`` + attributes from ``behave.runner.Context`` class + (use: ``captured`` attribute instead). ENHANCEMENTS: From a4dd3fe99face2e0d6bfa5747ffde3a65f506d6a Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 7 Mar 2022 00:05:09 +0100 Subject: [PATCH 030/240] Use github-actions as CI/CD pipeline (and remove Travis as CI). --- .github/renovate.json | 2 +- .github/workflows/tests.yml | 56 ++++++++++++++++++++++++ .travis.yml | 61 --------------------------- CHANGES.rst | 1 + README.rst | 9 ++-- issue.features/environment.py | 8 ++-- py.requirements/ci.github.testing.txt | 2 + py.requirements/ci.travis.txt | 28 ------------ py.requirements/testing.txt | 8 ++-- 9 files changed, 74 insertions(+), 101 deletions(-) create mode 100644 .github/workflows/tests.yml delete mode 100644 .travis.yml create mode 100644 py.requirements/ci.github.testing.txt delete mode 100644 py.requirements/ci.travis.txt diff --git a/.github/renovate.json b/.github/renovate.json index 9b72f76b5..091c773a7 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -10,7 +10,7 @@ "py.requirements/develop.txt", "py.requirements/testing.txt", "py.requirements/ci.tox.txt", - "py.requirements/ci.travis.txt", + "py.requirements/ci.github.testing.txt", "tasks/py.requirements.txt", "issue.features/py.requirements.txt" ] diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..3c7c0cf29 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,56 @@ +# -- SOURCE: https://github.com/marketplace/actions/setup-python +# SEE: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: tests +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + # -- EXAMPLE: runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # PREPARED: os: [ubuntu-latest, macos-latest, windows-latest] + # PREPARED: python-version: ['3.9', '2.7', '3.10', '3.8', 'pypy-2.7', 'pypy-3.8'] + # PREPARED: os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest] + python-version: ['3.10', '3.9', '2.7'] + exclude: + - os: windows-latest + python-version: "2.7" + steps: + - uses: actions/checkout@v2 + - name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: 'py.requirements/*.txt' + - name: Show Python version + run: python --version + - name: Install Python package dependencies + run: | + python -m pip install -U pip setuptools wheel + pip install --upgrade -r py.requirements/ci.github.testing.txt + pip install -e . + - name: Run tests + run: pytest + - name: Run behave tests + run: | + behave --format=progress features + behave --format=progress tools/test-features + behave --format=progress issue.features + - name: Upload test reports + uses: actions/upload-artifact@v3 + with: + name: test reports + path: | + build/testing/report.xml + build/testing/report.html + if: ${{ job.status == 'failure' }} + # MAYBE: if: ${{ always() }} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2b78d97d1..000000000 --- a/.travis.yml +++ /dev/null @@ -1,61 +0,0 @@ -arch: - - amd64 - - ppc64le - -language: python -sudo: false -dist: xenial # required for Python >= 3.7 -python: - - "3.8-dev" - - "3.7" - - "2.7" - - -# -- DISABLE-TEMPORARILY: Ensure faster builds -# - "3.6" -# - "3.5" -# - "pypy" -# - "pypy3" - -# -- DISABLED: -# - "nightly" -# -# NOW SUPPORTED: "3.5" => python 3.5.2 (>= 3.5.1) -# NOTE: nightly = 3.7-dev - -# -- TEST-BALLON: Check if Python 3.6 is actually Python 3.5.1 or newer -matrix: - allow_failures: - - python: "3.8-dev" - - python: "nightly" - -cache: - directories: - - $HOME/.cache/pip - -install: - - travis_retry pip install -q -r py.requirements/ci.travis.txt - - pip show setuptools - - python setup.py -q install - -script: - - python --version - - pytest tests - - behave -f progress --junit features/ - - behave -f progress --junit tools/test-features/ - - behave -f progress --junit issue.features/ - -after_failure: - - echo "FAILURE DETAILS (from XML reports):" - - bin/behave.junit_filter.py --status=failed reports - -# -- ALTERNATIVE: -# egrep -L 'errors="0"|failures="0"' reports/*.xml | xargs -t cat - -# -- USE: New container-based infrastructure for faster startup. -# http://docs.travis-ci.com/user/workers/container-based-infrastructure/ -# -# SEE ALSO: -# http://lint.travis-ci.org -# http://docs.travis-ci.com/user/caching/ -# http://docs.travis-ci.com/user/multi-os/ (Linux, MACOSX) diff --git a/CHANGES.rst b/CHANGES.rst index 1703a5b3f..63f150b6e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -22,6 +22,7 @@ GOALS: DEVELOPMENT: * Renamed default branch of Git repository to "main" (was: "master"). +* Use github-actions as CI/CD pipeline (and remove Travis as CI). CLEANUPS: diff --git a/README.rst b/README.rst index 22b035236..93bd8168e 100644 --- a/README.rst +++ b/README.rst @@ -1,10 +1,11 @@ ====== -Behave +behave ====== -.. image:: https://img.shields.io/travis/behave/behave/master.svg - :target: https://travis-ci.org/behave/behave - :alt: Travis CI Build Status + +.. image:: https://github.com/behave/behave/actions/workflows/tests.yml/badge.svg + :target: https://github.com/behave/behave/actions/workflows/tests.yml + :alt: CI Build Status .. image:: https://readthedocs.org/projects/behave/badge/?version=latest :target: http://behave.readthedocs.io/en/latest/?badge=latest diff --git a/issue.features/environment.py b/issue.features/environment.py index ab85e6c9f..ecb65153b 100644 --- a/issue.features/environment.py +++ b/issue.features/environment.py @@ -60,13 +60,13 @@ def discover_ci_server(): # pylint: disable=invalid-name ci_server = "none" CI = os.environ.get("CI", "false").lower() == "true" + GITHUB_ACTIONS = os.environ.get("GITHUB_ACTIONS", "false").lower() == "true" APPVEYOR = os.environ.get("APPVEYOR", "false").lower() == "true" - TRAVIS = os.environ.get("TRAVIS", "false").lower() == "true" if CI: - if APPVEYOR: + if GITHUB_ACTIONS: + ci_server = "github-actions" + elif APPVEYOR: ci_server = "appveyor" - elif TRAVIS: - ci_server = "travis" else: ci_server = "unknown" return ci_server diff --git a/py.requirements/ci.github.testing.txt b/py.requirements/ci.github.testing.txt new file mode 100644 index 000000000..71bf2e9ce --- /dev/null +++ b/py.requirements/ci.github.testing.txt @@ -0,0 +1,2 @@ +-r basic.txt +-r testing.txt diff --git a/py.requirements/ci.travis.txt b/py.requirements/ci.travis.txt deleted file mode 100644 index 1d6e05037..000000000 --- a/py.requirements/ci.travis.txt +++ /dev/null @@ -1,28 +0,0 @@ -# ============================================================================ -# PYTHON PACKAGE REQUIREMENTS FOR: behave -- ci.travis.txt -# ============================================================================ - -pytest < 5.0; python_version < '3.0' -pytest >= 5.0; python_version >= '3.0' - -pytest-html >= 1.19.0,<2.0 -mock < 4.0; python_version < '3.6' -mock >= 4.0; python_version >= '3.6' -PyHamcrest >= 2.0.2; python_version >= '3.0' -PyHamcrest < 2.0; python_version < '3.0' - -# -- NEEDED: By some tests (as proof of concept) -# NOTE: path.py-10.1 is required for python2.6 -# HINT: path.py => path (python-install-package was renamed for python3) -path.py >= 11.5.0; python_version < '3.5' -path >= 13.1.0; python_version >= '3.5' - -jsonschema - -# -- NOTE: Travis.CI tweak related w/ invalid linecache2 tests. -# This problem does not exist if you use pip. -linecache2 >= 1.0; python_version < '3.0' - -# FIX: setuptoools problem w/ Python3.7-dev -setuptools >= 38.5.1; python_version > '3.6' -setuptools >= 36.2.1; python_version <= '3.6' diff --git a/py.requirements/testing.txt b/py.requirements/testing.txt index 9230c1f4f..5d1fd64c8 100644 --- a/py.requirements/testing.txt +++ b/py.requirements/testing.txt @@ -7,7 +7,9 @@ pytest < 5.0; python_version < '3.0' # pytest >= 4.2 pytest >= 5.0; python_version >= '3.0' -pytest-html >= 1.19.0,<2.0 +pytest-html >= 1.19.0,<2.0; python_version < '3.0' +pytest-html >= 2.0; python_version >= '3.0' + mock < 4.0; python_version < '3.6' mock >= 4.0; python_version >= '3.6' PyHamcrest >= 2.0.2; python_version >= '3.0' @@ -16,7 +18,7 @@ PyHamcrest < 2.0; python_version < '3.0' # -- NEEDED: By some tests (as proof of concept) # NOTE: path.py-10.1 is required for python2.6 # HINT: path.py => path (python-install-package was renamed for python3) -path.py >= 11.5.0; python_version < '3.5' -path >= 13.1.0; python_version >= '3.5' +path.py >= 11.5.0,<13.0; python_version < '3.5' +path >= 13.1.0; python_version >= '3.5' -r ../issue.features/py.requirements.txt From 8858098b5c97ca1e0d3cb2c9c20916726f020dfc Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 8 Mar 2022 23:12:51 +0100 Subject: [PATCH 031/240] FIX: Windows problem if filename is on another drive than CWD. --- .github/workflows/tests-windows.yml | 49 +++++++++++++++++++++++++++++ behave/runner_util.py | 7 ++++- 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/tests-windows.yml diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml new file mode 100644 index 000000000..2d69d563f --- /dev/null +++ b/.github/workflows/tests-windows.yml @@ -0,0 +1,49 @@ +# -- FAST-PROTOTYPING: Tests on Windows -- Until tests are OK. +# BASED ON: tests.yml + +name: tests-windows +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + # PREPARED: python-version: ['3.10', '3.9'] + python-version: ['3.9'] + steps: + - uses: actions/checkout@v2 + - name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: 'py.requirements/*.txt' + - name: Show Python version + run: python --version + - name: Install Python package dependencies + run: | + python -m pip install -U pip setuptools wheel + pip install --upgrade -r py.requirements/ci.github.testing.txt + pip install -e . + - name: Run tests + run: pytest + - name: Run behave tests + run: | + behave --format=progress features + behave --format=progress tools/test-features + behave --format=progress issue.features + - name: Upload test reports + uses: actions/upload-artifact@v3 + with: + name: test reports + path: | + build/testing/report.xml + build/testing/report.html + if: ${{ job.status == 'failure' }} + # MAYBE: if: ${{ always() }} diff --git a/behave/runner_util.py b/behave/runner_util.py index 80b99a0f4..bb51418e5 100644 --- a/behave/runner_util.py +++ b/behave/runner_util.py @@ -550,7 +550,12 @@ def exec_file(filename, globals_=None, locals_=None): locals_["__file__"] = filename with open(filename, "rb") as f: # pylint: disable=exec-used - filename2 = os.path.relpath(filename, os.getcwd()) + try: + filename2 = os.path.relpath(filename, os.getcwd()) + except ValueError: + # -- CASE Windows: CWD and filename on different drives. + filename2 = filename + code = compile(f.read(), filename2, "exec", dont_inherit=True) exec(code, globals_, locals_) From 61abeb376058a9d506d1119a7f89c04e70da4b44 Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 9 Mar 2022 23:37:30 +0100 Subject: [PATCH 032/240] FIX: Windows regression w/ sleep() waiting delta. --- .github/workflows/tests-windows.yml | 3 ++- tests/api/_test_async_step34.py | 6 ++++-- tests/api/_test_async_step35.py | 16 ++++++++++++---- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index 2d69d563f..7f54e8f35 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -10,11 +10,12 @@ on: jobs: test: - runs-on: windows-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: # PREPARED: python-version: ['3.10', '3.9'] + os: [windows-latest] python-version: ['3.9'] steps: - uses: actions/checkout@v2 diff --git a/tests/api/_test_async_step34.py b/tests/api/_test_async_step34.py index 1c0c31fc9..a8673c3a8 100644 --- a/tests/api/_test_async_step34.py +++ b/tests/api/_test_async_step34.py @@ -38,8 +38,10 @@ # TEST MARKERS: # ----------------------------------------------------------------------------- # DEPRECATED: @asyncio.coroutine decorator (since: Python >= 3.8) -_python_version = float("%s.%s" % sys.version_info[:2]) -requires_py34_to_py37 = pytest.mark.skipif(not (3.4 <= _python_version < 3.8), +PYTHON_3_5 = (3, 5) +PYTHON_3_8 = (3, 8) +python_version = sys.version_info[:2] +requires_py34_to_py37 = pytest.mark.skipif(not (PYTHON_3_5 <= python_version < PYTHON_3_8), reason="Supported only for python.versions: 3.4 .. 3.7 (inclusive)") diff --git a/tests/api/_test_async_step35.py b/tests/api/_test_async_step35.py index f4068db22..a75c76552 100644 --- a/tests/api/_test_async_step35.py +++ b/tests/api/_test_async_step35.py @@ -6,6 +6,7 @@ # -- IMPORTS: from __future__ import absolute_import, print_function import sys +from hamcrest import assert_that, close_to from behave._stepimport import use_step_import_modules from behave.runner import Context, Runner import pytest @@ -40,9 +41,15 @@ # ----------------------------------------------------------------------------- # TEST MARKERS: # ----------------------------------------------------------------------------- -# xfail = pytest.mark.xfail -_python_version = float("%s.%s" % sys.version_info[:2]) -py35_or_newer = pytest.mark.skipif(_python_version < 3.5, reason="Needs Python >= 3.5") +PYTHON_3_5 = (3, 5) +python_version = sys.version_info[:2] +py35_or_newer = pytest.mark.skipif(python_version < PYTHON_3_5, reason="Needs Python >= 3.5") + +SLEEP_DELTA = 0.050 +if sys.platform.startswith("win"): + # MAYBE: SLEEP_DELTA = 0.100 + SLEEP_DELTA = 0.050 + # ----------------------------------------------------------------------------- # TESTSUITE: @@ -72,7 +79,8 @@ async def step_async_step_waits_seconds(context, duration): context = Context(runner=Runner(config={})) with StopWatch() as stop_watch: step_async_step_waits_seconds(context, 0.2) - assert abs(stop_watch.duration - 0.2) <= 0.05 + # DISABLED: assert abs(stop_watch.duration - 0.2) <= 0.05 + assert_that(stop_watch.duration, close_to(0.2, delta=SLEEP_DELTA)) @py35_or_newer From 30a77eb277f2673565d61012b7b58098ce55158d Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 9 Mar 2022 23:47:12 +0100 Subject: [PATCH 033/240] FIX: Windows regression w/ case-insensitive matching => Use case-sensitive matching. --- behave/tag_expression/model_ext.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/behave/tag_expression/model_ext.py b/behave/tag_expression/model_ext.py index 22d4e4090..ea18c1f67 100644 --- a/behave/tag_expression/model_ext.py +++ b/behave/tag_expression/model_ext.py @@ -33,7 +33,7 @@ """ from __future__ import absolute_import -from fnmatch import fnmatch +from fnmatch import fnmatchcase import glob from .model import Expression @@ -74,7 +74,8 @@ def name(self): def evaluate(self, values): for value in values: - if fnmatch(value, self.pattern): + # -- REQUIRE: case-sensitive matching + if fnmatchcase(value, self.pattern): return True # -- OTHERWISE: no-match return False From 5651bf0d2f85f84befa40c96472a746072d759de Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 10 Mar 2022 00:24:44 +0100 Subject: [PATCH 034/240] config-file: Add alias for behave-html-formatter --- behave.ini | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/behave.ini b/behave.ini index 952240d6e..9c9644ebc 100644 --- a/behave.ini +++ b/behave.ini @@ -22,9 +22,8 @@ logging_level = INFO # logging_format = LOG.%(levelname)-8s %(name)-10s: %(message)s # logging_format = LOG.%(levelname)-8s %(asctime)s %(name)-10s: %(message)s -# -- ALLURE-FORMATTER REQUIRES: +# -- ALLURE-FORMATTER REQUIRES: pip install allure-behave # brew install allure -# pip install allure-behave # ALLURE_REPORTS_DIR=allure.reports # behave -f allure -o $ALLURE_REPORTS_DIR ... # allure serve $ALLURE_REPORTS_DIR @@ -32,8 +31,12 @@ logging_level = INFO # SEE ALSO: # * https://github.com/allure-framework/allure2 # * https://github.com/allure-framework/allure-python +# +# -- HTML-FORMATTER REQUIRES: pip install behave-html-formatter +# SEE ALSO: https://github.com/behave-contrib/behave-html-formatter [behave.formatters] allure = allure_behave.formatter:AllureFormatter +html = behave_html_formatter:HTMLFormatter # PREPARED: # [behave] From ea55920c9079fc6bf495db299d7c1ba47e2a6c04 Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 10 Mar 2022 00:25:46 +0100 Subject: [PATCH 035/240] PREPARE FIX: Windows regression, enable more diagnostics. --- .github/workflows/tests-windows.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index 7f54e8f35..f8a711be9 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -36,9 +36,9 @@ jobs: run: pytest - name: Run behave tests run: | - behave --format=progress features - behave --format=progress tools/test-features - behave --format=progress issue.features + behave --format=progress3 features + behave --format=progress3 tools/test-features + behave --format=progress3 issue.features - name: Upload test reports uses: actions/upload-artifact@v3 with: @@ -48,3 +48,8 @@ jobs: build/testing/report.html if: ${{ job.status == 'failure' }} # MAYBE: if: ${{ always() }} + - name: Upload behave test reports + uses: actions/upload-artifact@v3 + with: + name: behave.reports + path: build/behave.reports/ From 74e7d37a935f9408fe5438d9ad6074628dd9b38a Mon Sep 17 00:00:00 2001 From: jenisys Date: Fri, 11 Mar 2022 23:36:51 +0100 Subject: [PATCH 036/240] CI: Use newer github-actions for checkout, setup-python --- .github/workflows/tests-windows.yml | 12 ++++++------ .github/workflows/tests.yml | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index f8a711be9..e02163d7e 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -4,7 +4,6 @@ name: tests-windows on: push: - branches: [ main ] pull_request: branches: [ main ] @@ -18,15 +17,16 @@ jobs: os: [windows-latest] python-version: ['3.9'] steps: - - uses: actions/checkout@v2 - - name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} + - uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} cache: 'pip' cache-dependency-path: 'py.requirements/*.txt' - - name: Show Python version - run: python --version + # DISABLED: + # - name: Show Python version + # run: python --version - name: Install Python package dependencies run: | python -m pip install -U pip setuptools wheel diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3c7c0cf29..88673f56b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,7 +4,6 @@ name: tests on: push: - branches: [ main ] pull_request: branches: [ main ] @@ -24,15 +23,16 @@ jobs: - os: windows-latest python-version: "2.7" steps: - - uses: actions/checkout@v2 - - name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} + - uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} cache: 'pip' cache-dependency-path: 'py.requirements/*.txt' - - name: Show Python version - run: python --version + # DISABLED: + # - name: Show Python version + # run: python --version - name: Install Python package dependencies run: | python -m pip install -U pip setuptools wheel From d1704be2a7837509c4c22f9337c04ca2b268984b Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 20 Mar 2022 19:15:23 +0100 Subject: [PATCH 037/240] DEVELOPMENT: Improve "direnv" configuration ADD OPTIONAL PARTS (disabled per default): * Use virtualenv via "venv": On entering this directory: Create and activate virtual-environment On leaving this directory: Deactivate this virtual-environment * Support PEP-0582 "__pypackages__/${PYTHON_VERSION}/" directories TO ENABLE A PART: * Rename ".envrc.${PART}.disabled" to ".envrc.${PART}" --- .envrc | 29 ++++++++++++++++++----------- .envrc.use_pep0582.disabled | 29 +++++++++++++++++++++++++++++ .envrc.use_venv.disabled | 21 +++++++++++++++++++++ .gitignore | 7 +++++-- invoke.yaml | 5 +++++ 5 files changed, 78 insertions(+), 13 deletions(-) create mode 100644 .envrc.use_pep0582.disabled create mode 100644 .envrc.use_venv.disabled diff --git a/.envrc b/.envrc index 6c5ecfd5c..8cca8e7aa 100644 --- a/.envrc +++ b/.envrc @@ -1,20 +1,27 @@ # =========================================================================== -# PROJECT ENVIRONMENT SETUP: HERE/.envrc +# PROJECT ENVIRONMENT SETUP: .envrc # =========================================================================== # SHELL: bash (or similiar) -# SEE ALSO: https://direnv.net/ +# REQUIRES: direnv >= 2.26.0 -- NEEDED FOR: dotenv_if_exists # USAGE: -# source .envrc -# # # -- BETTER: Use direnv (requires: Setup in bash -- $HOME/.bashrc) -# # eval "$(direnv hook bash)" +# # BASH PROFILE NEEDS: eval "$(direnv hook bash)" # direnv allow . # -# SIMPLISTIC ALTERNATIVE (with cleanup when directory scope is left again): -# source .envrc +# SEE ALSO: +# * https://direnv.net/ +# * https://github.com/direnv/direnv +# * https://peps.python.org/pep-0582/ Python local packages directory # =========================================================================== +# MAYBE: HERE="${PWD}" + +# -- USE OPTIONAL PARTS (if exist/enabled): +dotenv_if_exists .env +source_env_if_exists .envrc.use_venv +source_env_if_exists .envrc.use_pep0582 + +# -- SETUP-PYTHON: Prepend ${HERE} to PYTHONPATH (as PRIMARY search path) +# SIMILAR TO: export PYTHONPATH="${HERE}:${PYTHONPATH}" +path_add PYTHONPATH . -export HERE="$PWD" -export PYTHONPATH=".:${HERE}:${PYTHONPATH}" -export PATH="${HERE}/bin:${PATH}" -unset HERE +source_env_if_exists .envrc.override diff --git a/.envrc.use_pep0582.disabled b/.envrc.use_pep0582.disabled new file mode 100644 index 000000000..a2e5a03a5 --- /dev/null +++ b/.envrc.use_pep0582.disabled @@ -0,0 +1,29 @@ +# =========================================================================== +# PROJECT ENVIRONMENT SETUP: .envrc.use_pep0582 +# =========================================================================== +# DESCRIPTION: +# Setup Python search path to use the PEP-0582 sub-directory tree. +# +# ENABLE/DISABLE THIS OPTIONAL PART: +# * TO ENABLE: Rename ".envrc.use_pep0582.disabled" to ".envrc.use_pep0582" +# * TO DISABLE: Rename ".envrc.use_pep0582" to ".envrc.use_pep0582.disabled" +# +# SEE ALSO: +# * https://direnv.net/ +# * https://peps.python.org/pep-0582/ Python local packages directory +# =========================================================================== + +if [ -z "${PYTHON_VERSION}" ]; then + # -- AUTO-DETECT: Default Python3 version + # EXAMPLE: export PYTHON_VERSION="3.9" + export PYTHON_VERSION=$(python3 -c "import sys; print('.'.join([str(x) for x in sys.version_info[:2]]))") +fi +echo "USE: PYTHON_VERSION=${PYTHON_VERSION}" + +# -- HINT: Support PEP-0582 Python local packages directory (supported by: pdm) +path_add PATH __pypackages__/${PYTHON_VERSION}/bin +path_add PYTHONPATH __pypackages__/${PYTHON_VERSION}/lib + +# -- SIMILAR-TO: +# export PATH="${HERE}/__pypackages__/${PYTHON_VERSION}/bin:${PATH}" +# export PYTHONPATH="${HERE}:${HERE}/__pypackages__/${PYTHON_VERSION}/lib:${PYTHONPATH}" diff --git a/.envrc.use_venv.disabled b/.envrc.use_venv.disabled new file mode 100644 index 000000000..e9834503d --- /dev/null +++ b/.envrc.use_venv.disabled @@ -0,0 +1,21 @@ +# =========================================================================== +# PROJECT ENVIRONMENT SETUP: .envrc.use_venv +# =========================================================================== +# DESCRIPTION: +# Setup and use a Python virtual environment (venv). +# On entering the directory: Creates and activates a venv for a python version. +# On leaving the directory: Deactivates the venv (virtual environment). +# +# ENABLE/DISABLE THIS OPTIONAL PART: +# * TO ENABLE: Rename ".envrc.use_venv.disabled" to ".envrc.use_venv" +# * TO DISABLE: Rename ".envrc.use_venv" to ".envrc.use_venv.disabled" +# +# SEE ALSO: +# * https://direnv.net/ +# * https://github.com/direnv/direnv/wiki/Python +# * https://direnv.net/man/direnv-stdlib.1.html#codelayout-python-ltpythonexegtcode +# =========================================================================== + +# -- VIRTUAL ENVIRONMENT SUPPORT: layout python python3 +# VENV LOCATION: .direnv/python-$(PYTHON_VERSION) +layout python python3 diff --git a/.gitignore b/.gitignore index 8d7c28e2c..a575154e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.egg-info +*.lock *.log *.pyc *.pyo @@ -6,6 +7,7 @@ build/ dist/ __pycache__/ +__pypackages__/ __WORKDIR__/ __*/ __*.txt @@ -15,14 +17,15 @@ _WORKSPACE/ reports/ tools/virtualenvs .cache/ +.direnv/ .idea/ .pytest_cache/ .tox/ .venv*/ .vscode/ +.envrc.use_venv +.envrc.use_pep0582 .DS_Store .coverage -.ropeproject -nosetests.xml rerun.txt testrun*.json diff --git a/invoke.yaml b/invoke.yaml index a32345e5c..647d0fe7f 100644 --- a/invoke.yaml +++ b/invoke.yaml @@ -39,9 +39,14 @@ cleanup_all: extra_directories: - .hypothesis - .pytest_cache + - .direnv extra_files: - "**/testrun*.json" + - "*.lock" + - "*.log" + - .coverage + - rerun.txt behave_test: scopes: From 2f5dfe40834abfddef13409159dda1f8db8189e0 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 20 Mar 2022 21:09:07 +0100 Subject: [PATCH 038/240] DEVELOP EXPERIMENT: Add support for just/justfile * Simple multi-platform build system (implemented in: Rust) * Potential alternative to "invoke in the future EXCEPT FOR: invoke cleanup/cleanup.all --- .gitignore | 1 + .justfile | 102 +++++++++++++++++++++++++++++++++++++ invoke.yaml | 1 + py.requirements/invoke.txt | 6 +++ 4 files changed, 110 insertions(+) create mode 100644 .justfile create mode 100644 py.requirements/invoke.txt diff --git a/.gitignore b/.gitignore index a575154e4..7b08d10ca 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ tools/virtualenvs .tox/ .venv*/ .vscode/ +.done.* .envrc.use_venv .envrc.use_pep0582 .DS_Store diff --git a/.justfile b/.justfile new file mode 100644 index 000000000..3c4cffd7d --- /dev/null +++ b/.justfile @@ -0,0 +1,102 @@ +# ============================================================================= +# justfile: A makefile like build script +# ============================================================================= +# REQUIRES: cargo install just +# PLATFORMS: Windows, Linux, macOS, ... +# USAGE: +# just --list +# just +# just +# +# SEE ALSO: +# * https://github.com/casey/just +# ============================================================================= + +# -- OPTION: Load environment-variables from "$HERE/.env" file (if exists) +set dotenv-load +set export := true + +# ----------------------------------------------------------------------------- +# CONFIG: +# ----------------------------------------------------------------------------- +# NOTES: +# - PYTHON: Newer Linux may have no "python" executable, only "python3". + +HERE := justfile_directory() +PYTHON := env_var_or_default("PYTHON", "python3") +PIP_INSTALL_OPTIONS := env_var_or_default("PIP_INSTALL_OPTIONS", "--quiet") + +BEHAVE_FORMATTER := env_var_or_default("BEHAVE_FORMATTER", "progress") +PYTEST_OPTIONS := env_var_or_default("PYTEST_OPTIONS", "") + +# ----------------------------------------------------------------------------- +# BUILD RECIPES / TARGETS: +# ----------------------------------------------------------------------------- + +# DEFAULT-TARGET: Ensure that packages are installed and runs tests. +default: (_ensure-install-packages "basic") (_ensure-install-packages "testing") test + +# PART=all, testing, ... +install-packages PART="all": + @echo "INSTALL-PACKAGES: {{PART}} ..." + {{PYTHON}} -m pip install {{PIP_INSTALL_OPTIONS}} -r py.requirements/{{PART}}.txt + @touch "{{HERE}}/.done.install-packages.{{PART}}" + +# ENSURE: Python packages are installed. +_ensure-install-packages PART="all": + #!/usr/bin/env python3 + from subprocess import run + from os import path + if not path.exists("{{HERE}}/.done.install-packages.{{PART}}"): + run("just install-packages {{PART}}", shell=True) + +# -- SIMILAR: This solution requires a Bourne-like shell (may not work on: Windows). +# _ensure-install-packages PART="testing": +# @test -e "{{HERE}}/.done.install-packages.{{PART}}" || just install-packages {{PART}} + +# Run tests. +test *TESTS: + {{PYTHON}} -m pytest {{PYTEST_OPTIONS}} {{TESTS}} + +# Run behave with feature file(s) or directory(s). +behave +FEATURE_FILES="features": + {{PYTHON}} bin/behave --format={{BEHAVE_FORMATTER}} {{FEATURE_FILES}} + +# Run all behave tests. +behave-all: + {{PYTHON}} bin/behave --format={{BEHAVE_FORMATTER}} features + {{PYTHON}} bin/behave --format={{BEHAVE_FORMATTER}} issue.features + {{PYTHON}} bin/behave --format={{BEHAVE_FORMATTER}} tools/test-features + +# Run behave with code coverage collection(s) enabled. +coverage-behave: + export COVERAGE_PROCESS_START="{{HERE}}/.coveragerc" + {{PYTHON}} bin/behave --format={{BEHAVE_FORMATTER}} features + {{PYTHON}} bin/behave --format={{BEHAVE_FORMATTER}} issue.features + {{PYTHON}} bin/behave --format={{BEHAVE_FORMATTER}} tools/test-features + COVERAGE_PROCESS_START= + +# Run all behave tests. +test-all: test behave-all + +# Determine test coverage by running the tests. +coverage: + coverage run -m pytest + export COVERAGE_PROCESS_START="{{HERE}}/.coveragerc" + just coverage-behave + COVERAGE_PROCESS_START= + coverage combine + coverage report + coverage html + +# coverage run -m behave --format={{BEHAVE_FORMATTER}} features +# coverage run -m behave --format={{BEHAVE_FORMATTER}} issue.features +# coverage run -m behave --format={{BEHAVE_FORMATTER}} tools/test-features + +# Cleanup most parts (but leave PRECIOUS parts). +cleanup: (_ensure-install-packages "invoke") + invoke cleanup + +# Cleanup everything. +cleanup-all: (_ensure-install-packages "invoke") + invoke cleanup.all diff --git a/invoke.yaml b/invoke.yaml index 647d0fe7f..890619b2f 100644 --- a/invoke.yaml +++ b/invoke.yaml @@ -43,6 +43,7 @@ cleanup_all: extra_files: - "**/testrun*.json" + - ".done.*" - "*.lock" - "*.log" - .coverage diff --git a/py.requirements/invoke.txt b/py.requirements/invoke.txt new file mode 100644 index 000000000..e7e65031e --- /dev/null +++ b/py.requirements/invoke.txt @@ -0,0 +1,6 @@ +# ============================================================================ +# PYTHON PACKAGE REQUIREMENTS FOR: behave -- invoke build-system +# ============================================================================ + +# -- REUSE: invoke tasks requirements +-r ../tasks/py.requirements.txt From b9d16ff4929a4142b23c47aca9b8fbdca86d3d88 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 20 Mar 2022 23:26:29 +0100 Subject: [PATCH 039/240] UPDATE DEPENDENCIES: setup.py, py.requirements/*.txt * Update dependencies to newer versions * tox: Cleanup config-file * tox: Fix problem w/ python2.7 - Need to use "pip install --user ..." for now - Scripts seem no longer to be installed HINT: Seems to be related to virtualenv and macOS 12 Monterey SEE: https://github.com/pypa/virtualenv/issues/2284 --- py.requirements/basic.txt | 11 +++--- py.requirements/develop.txt | 17 ++++---- py.requirements/testing.txt | 4 +- setup.py | 48 +++++++++++++++-------- tox.ini | 77 ++++++++++++++----------------------- 5 files changed, 75 insertions(+), 82 deletions(-) diff --git a/py.requirements/basic.txt b/py.requirements/basic.txt index 6c644e04c..84b134fd9 100644 --- a/py.requirements/basic.txt +++ b/py.requirements/basic.txt @@ -8,14 +8,15 @@ # * http://www.pip-installer.org/ # ============================================================================ -cucumber-tag-expressions >= 1.1.2 +cucumber-tag-expressions >= 4.1.0 +enum34; python_version < '3.4' parse >= 1.18.0 -parse_type >= 0.4.2 -six == 1.15.0 +parse_type >= 0.6.0 +six >= 1.15.0 traceback2; python_version < '3.0' -contextlib2 # MAYBE: python_version < '3.5' -win_unicode_console >= 0.5; python_version >= '2.7' +contextlib2; python_version < '3.5' +win_unicode_console >= 0.5; python_version < '3.6' colorama >= 0.3.7 # -- DISABLED PYTHON 2.6 SUPPORT: diff --git a/py.requirements/develop.txt b/py.requirements/develop.txt index e7dc41800..d79f1f526 100644 --- a/py.requirements/develop.txt +++ b/py.requirements/develop.txt @@ -2,10 +2,8 @@ # PYTHON PACKAGE REQUIREMENTS FOR: behave -- For development only # ============================================================================ -# -- BUILD-TOOL: -invoke == 1.4.1 -pathlib; python_version <= '3.4' -pycmd +# -- BUILD-SYSTEM: invoke +-r invoke.txt # -- HINT: path.py => path (python-install-package was renamed for python3) path.py >= 11.5.0; python_version < '3.5' @@ -19,9 +17,6 @@ bump2version >= 0.5.6 twine >= 1.13.0 # -- DEVELOPMENT SUPPORT: -tox >= 1.8.1 -coverage >= 4.2 -pytest-cov # -- PYTHON2/3 COMPATIBILITY: pypa/modernize # python-futurize @@ -30,7 +25,11 @@ modernize >= 0.5 # -- STATIC CODE ANALYSIS: pylint -# -- REQUIRES: testing, docs, invoke-task requirements +# -- REQUIRES: testing -r testing.txt +coverage >= 4.2 +pytest-cov +tox >= 1.8.1 + +# -- REQUIRED FOR: docs -r docs.txt --r ../tasks/py.requirements.txt diff --git a/py.requirements/testing.txt b/py.requirements/testing.txt index 5d1fd64c8..94bffdee1 100644 --- a/py.requirements/testing.txt +++ b/py.requirements/testing.txt @@ -18,7 +18,7 @@ PyHamcrest < 2.0; python_version < '3.0' # -- NEEDED: By some tests (as proof of concept) # NOTE: path.py-10.1 is required for python2.6 # HINT: path.py => path (python-install-package was renamed for python3) -path.py >= 11.5.0,<13.0; python_version < '3.5' -path >= 13.1.0; python_version >= '3.5' +path.py >=11.5.0,<13.0; python_version < '3.5' +path >= 13.1.0; python_version >= '3.5' -r ../issue.features/py.requirements.txt diff --git a/setup.py b/setup.py index 1c8d83c3a..67c89fb23 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -* +# -*- coding: UTF-8 -* """ Setup script for behave. @@ -76,25 +76,32 @@ def find_packages_by_root_package(where): # SUPPORT: python2.7, python3.3 (or higher) python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*", install_requires=[ - "cucumber-tag-expressions >= 1.1.2", + "cucumber-tag-expressions >= 4.1.0", + "enum34; python_version < '3.4'", "parse >= 1.18.0", - "parse_type >= 0.4.2", - "six >= 1.12.0", + "parse_type >= 0.6.0", + "six >= 1.15.0", "traceback2; python_version < '3.0'", - "enum34; python_version < '3.4'", + # -- PREPARED: "win_unicode_console; python_version < '3.6'", - "colorama", + "contextlib2; python_version < '3.5'", + # DISABLED: "contextlib2 >= 21.6.0; python_version < '3.5'", + "colorama >= 0.3.7", ], tests_require=[ "pytest < 5.0; python_version < '3.0'", # >= 4.2 "pytest >= 5.0; python_version >= '3.0'", - "pytest-html >= 1.19.0", - "mock >= 1.1", - "PyHamcrest >= 1.9", + "pytest-html >= 1.19.0,<2.0; python_version < '3.0'", + "pytest-html >= 2.0; python_version >= '3.0'", + "mock < 4.0; python_version < '3.6'", + "mock >= 4.0; python_version >= '3.6'", + "PyHamcrest >= 2.0.2; python_version >= '3.0'", + "PyHamcrest < 2.0; python_version < '3.0'", + # -- HINT: path.py => path (python-install-package was renamed for python3) - "path.py >= 11.5.0; python_version < '3.5'", - "path >= 13.1.0; python_version >= '3.5'", + "path >= 13.1.0; python_version >= '3.5'", + "path.py >=11.5.0,<13.0; python_version < '3.5'", ], cmdclass = { "behave_test": behave_test, @@ -106,12 +113,20 @@ def find_packages_by_root_package(where): ], "develop": [ "coverage", - "pytest >= 4.2", - "pytest-html >= 1.19.0", + "pytest >=4.2,<5.0; python_version < '3.0' # pytest >= 4.2", + "pytest >= 5.0; python_version >= '3.0'", + "pytest-html >= 1.19.0,<2.0; python_version < '3.0'", + "pytest-html >= 2.0; python_version >= '3.0'", + "mock < 4.0; python_version < '3.6'", + "mock >= 4.0; python_version >= '3.6'", + "PyHamcrest >= 2.0.2; python_version >= '3.0'", + "PyHamcrest < 2.0; python_version < '3.0'", "pytest-cov", "tox", - "invoke >= 1.2.0", - "path.py >= 11.5.0", + "invoke >= 1.4.0", + # -- HINT: path.py => path (python-install-package was renamed for python3) + "path >= 13.1.0; python_version >= '3.5'", + "path.py >= 11.5.0; python_version < '3.5'", "pycmd", "pathlib; python_version <= '3.4'", "modernize >= 0.5", @@ -121,8 +136,6 @@ def find_packages_by_root_package(where): "behave-html-formatter", ], }, - # DISABLED: use_2to3= bool(python_version >= 3.0), - # DEPRECATED SINCE: setuptools v58.0.2 (2021-09-06) license="BSD", classifiers=[ "Development Status :: 4 - Beta", @@ -137,6 +150,7 @@ def find_packages_by_root_package(where): "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: Jython", "Programming Language :: Python :: Implementation :: PyPy", diff --git a/tox.ini b/tox.ini index 22b195a3a..a57cc2f55 100644 --- a/tox.ini +++ b/tox.ini @@ -1,65 +1,24 @@ # ============================================================================ # TOX CONFIGURATION: behave # ============================================================================ +# REQUIRES: pip install tox # DESCRIPTION: -# # Use tox to run tasks (tests, ...) in a clean virtual environment. -# Afterwards you can run tox in offline mode, like: -# -# tox -e py38 -# -# Tox can be configured for offline usage. -# Initialize local workspace once (download packages, create PyPI index): # -# tox -e init1 -# tox -e init2 (alternative) -# -# NOTE: -# You can either use "local1" or "local2" as local "tox.indexserver.default": -# -# * $HOME/.pip/downloads/ (local1, default) -# * downloads/ (local2, alternative) +# USAGE: +# tox -e py39 #< Run tests with python3.9 +# tox -e py27 #< Run tests with python2.7 # # SEE ALSO: -# * http://tox.testrun.org/latest/config.html +# * https://tox.wiki/en/latest/config.html # ============================================================================ -# envlist = py310, py39, py38, py27, py37, py36, py35, pypy3, pypy, docs -# -- ONLINE USAGE: # PIP_INDEX_URL = http://pypi.org/simple [tox] minversion = 2.3 -envlist = py310, py39, py27, py38, py37, py36, py35, pypy3, pypy, docs -skip_missing_interpreters = True -sitepackages = False -indexserver = - default = https://pypi.org/simple - default2 = file://{homedir}/.pip/downloads/simple - local1 = file://{toxinidir}/downloads/simple - local2 = file://{homedir}/.pip/downloads/simple - pypi = https://pypi.org/simple - -# ----------------------------------------------------------------------------- -# TOX PREPARE/BOOTSTRAP: Initialize local workspace for tox off-line usage -# ----------------------------------------------------------------------------- -[testenv:init1] -changedir = {toxinidir} -skipsdist = True -commands= - {toxinidir}/bin/toxcmd.py mkdir {toxinidir}/downloads - pip download --dest={toxinidir}/downloads -r py.requirements/all.txt - {toxinidir}/bin/make_localpi.py {toxinidir}/downloads -deps= - +envlist = py39, py27, py310, py38, pypy3, pypy, docs +skip_missing_interpreters = true -[testenv:init2] -changedir = {toxinidir} -skipsdist = True -commands= - {toxinidir}/bin/toxcmd.py mkdir {homedir}/.pip/downloads - pip download --dest={homedir}/.pip/downloads -r py.requirements/all.txt - {toxinidir}/bin/make_localpi.py {homedir}/.pip/downloads -deps= # ----------------------------------------------------------------------------- # TEST ENVIRONMENTS: @@ -77,9 +36,28 @@ setenv = PYTHONPATH = {toxinidir} +# -- HINT: Script(s) seems to be no longer installed on Python 2.7. +# WEIRD: pip-install seems to need "--user" option. +# RELATED: https://github.com/pypa/virtualenv/issues/2284 -- macOS 12 Monterey related +[testenv:py27] +# MAYBE: platform = darwin +install_command = pip install --user -U {opts} {packages} +changedir = {toxinidir} +commands= + python -m pytest {posargs:tests} + python -m behave --format=progress {posargs:features} + python -m behave --format=progress {posargs:tools/test-features} + python -m behave --format=progress {posargs:issue.features} +deps= + {[testenv]deps} +setenv = + PYTHONPATH = {toxinidir} + + [testenv:docs] changedir = docs -commands = sphinx-build -W -b html -D language=en -d {toxinidir}/build/docs/doctrees . {toxinidir}/build/docs/html/en +commands = + sphinx-build -W -b html -D language=en -d {toxinidir}/build/docs/doctrees . {toxinidir}/build/docs/html/en deps = -r{toxinidir}/py.requirements/docs.txt @@ -128,6 +106,7 @@ deps= setenv = PYTHONPATH = .:{envdir} + # --------------------------------------------------------------------------- # SELDOM-USED: OPTIONAL TEST ENVIRONMENTS: # --------------------------------------------------------------------------- From 9a3e35991df301360e8807145b872bbf4bd41d9c Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 22 Mar 2022 08:13:03 +0100 Subject: [PATCH 040/240] tasks: Add support to run "docs" task(s) in other directory. HINTS: * Can now run invoke in "docs/" directory. * Only PROJECT_DIR was support in the past. --- tasks/docs.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tasks/docs.py b/tasks/docs.py index 77c1d8338..e3d4c4e63 100644 --- a/tasks/docs.py +++ b/tasks/docs.py @@ -18,6 +18,8 @@ # ----------------------------------------------------------------------------- # CONSTANTS: # ----------------------------------------------------------------------------- +HERE = Path(__file__).dirname().abspath() +PROJECT_DIR = Path(HERE/"..").abspath() SPHINX_LANGUAGE_DEFAULT = os.environ.get("SPHINX_LANGUAGE", "en") @@ -58,8 +60,8 @@ def clean(ctx, dry_run=False): def build(ctx, builder="html", language=None, options=""): """Build docs with sphinx-build""" language = _sphinxdoc_get_language(ctx, language) - sourcedir = ctx.config.sphinx.sourcedir - destdir = _sphinxdoc_get_destdir(ctx, builder, language=language) + sourcedir = PROJECT_DIR/ctx.config.sphinx.sourcedir + destdir = PROJECT_DIR/_sphinxdoc_get_destdir(ctx, builder, language=language) destdir = destdir.abspath() with cd(sourcedir): destdir_relative = Path(".").relpathto(destdir) @@ -118,7 +120,9 @@ def linkcheck(ctx): def browse(ctx, language=None): """Open documentation in web browser.""" output_dir = _sphinxdoc_get_destdir(ctx, "html", language=language) - page_html = Path(output_dir)/"index.html" + project_dir = Path(".").relpathto(PROJECT_DIR) + page_html = project_dir/output_dir/"index.html" + # OR: page_html = Path(PROJECT_DIR)/output_dir/"index.html" if not page_html.exists(): build(ctx, builder="html") assert page_html.exists() From ddcaca4505aa767adc6266cd1500690beecacfd7 Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 22 Mar 2022 08:14:49 +0100 Subject: [PATCH 041/240] docs: Fix some hyperlink problems related to Sphinx v4.4.0 * FIX a broken hyperlink in "install" page to "formatters" * formatters page: Rewrite screenshot example OTHERWISE: * AVOID: Using Sphinx v4.4.0 and newer for now. REASON: New hyperlink suggestion warnings cause problems. --- docs/_common_extlinks.rst | 1 + docs/behave.rst | 20 ++++++++---- docs/conf.py | 12 ++++--- docs/fixtures.rst | 3 +- docs/formatters.rst | 69 ++++++++++++++++++++++++++++++++++----- docs/install.rst | 16 ++++----- py.requirements/docs.txt | 3 +- 7 files changed, 93 insertions(+), 31 deletions(-) diff --git a/docs/_common_extlinks.rst b/docs/_common_extlinks.rst index 5c1dea9ad..26bb5bcb7 100644 --- a/docs/_common_extlinks.rst +++ b/docs/_common_extlinks.rst @@ -1,6 +1,7 @@ .. _behave: https://github.com/behave/behave .. _behave4cmd: https://github.com/behave/behave4cmd +.. _`behave.example`: https://github.com/behave/behave.example .. _`pytest.fixture`: https://docs.pytest.org/en/latest/fixture.html .. _`@pytest.fixture`: https://docs.pytest.org/en/latest/fixture.html diff --git a/docs/behave.rst b/docs/behave.rst index 8c1c12591..7cf5e9bac 100644 --- a/docs/behave.rst +++ b/docs/behave.rst @@ -21,8 +21,8 @@ You may see the same information presented below at any time using ``behave .. option:: --color - Use ANSI color escapes. This is the default behaviour. This switch is - used to override a configuration file setting. + Use ANSI color escapes. Defaults to %(const)r. This switch is used to + override a configuration file setting. .. option:: -d, --dry-run @@ -57,8 +57,7 @@ You may see the same information presented below at any time using ``behave .. option:: --runner-class - This allows you to use your own custom runner. The default is - ``behave.runner.Runner``. + Tells Behave to use a specific runner. (default: %(default)s) .. option:: -f, --format @@ -343,10 +342,10 @@ Configuration Parameters .. index:: single: configuration param; color -.. describe:: color : bool +.. describe:: color : text - Use ANSI color escapes. This is the default behaviour. This switch is - used to override a configuration file setting. + Use ANSI color escapes. Defaults to %(const)r. This switch is used to + override a configuration file setting. .. index:: single: configuration param; dry_run @@ -393,6 +392,13 @@ Configuration Parameters Directory in which to store JUnit reports. +.. index:: + single: configuration param; runner_class + +.. describe:: runner_class : text + + Tells Behave to use a specific runner. (default: %(default)s) + .. index:: single: configuration param; default_format diff --git a/docs/conf.py b/docs/conf.py index cece86a4e..cd89a0d03 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -52,13 +52,17 @@ extlinks = { - "pypi": ("https://pypi.org/project/%s", ""), - "github": ("https://github.com/%s", "github:/"), + "behave": ("https://github.com/behave/behave", None), + "behave.example": ("https://github.com/behave/behave.example", None), "issue": ("https://github.com/behave/behave/issues/%s", "issue #"), + "pull": ("https://github.com/behave/behave/issues/%s", "PR #"), + "github": ("https://github.com/%s", "github:/"), + "pypi": ("https://pypi.org/project/%s", ""), "youtube": ("https://www.youtube.com/watch?v=%s", "youtube:video="), "behave": ("https://github.com/behave/behave", None), - "cucumber": ("https://github.com/cucumber/cucumber/", None), - "cucumber.issue": ("https://github.com/cucumber/cucumber/issues/%s", "issue #"), + + "cucumber": ("https://github.com/cucumber/common/", None), + "cucumber.issue": ("https://github.com/cucumber/common/issues/%s", "cucumber issue #"), } intersphinx_mapping = { diff --git a/docs/fixtures.rst b/docs/fixtures.rst index 59411b1c9..54c917c70 100644 --- a/docs/fixtures.rst +++ b/docs/fixtures.rst @@ -11,6 +11,7 @@ A common task during test execution is to: **Fixtures** are provided as concept to simplify this setup/cleanup task in `behave`_. +.. include:: _common_extlinks.rst Providing a Fixture ------------------- @@ -53,8 +54,6 @@ Providing a Fixture * a `pytest.fixture`_ * the `scope guard`_ idiom -.. include:: _common_extlinks.rst - Using a Fixture --------------- diff --git a/docs/formatters.rst b/docs/formatters.rst index 6080fc491..af3e755a6 100644 --- a/docs/formatters.rst +++ b/docs/formatters.rst @@ -6,8 +6,8 @@ Formatters and Reporters :pypi:`behave` provides 2 different concepts for reporting results of a test run: - * formatters - * reporters +* formatters +* reporters A slightly different interface is provided for each "formatter" concept. The ``Formatter`` is informed about each step that is taken. @@ -109,29 +109,80 @@ teamcity :pypi:`behave-teamcity`, a formatter for JetBrains TeamCity CI te with behave. ============== ========================================================================= +The usage of a custom formatter can be simplified if a formatter alias is defined for. + +EXAMPLE: + .. code-block:: ini # -- FILE: behave.ini - # FORMATTER ALIASES: behave -f allure ... + # FORMATTER ALIASES: "behave -f allure" and others... [behave.formatters] allure = allure_behave.formatter:AllureFormatter html = behave_html_formatter:HTMLFormatter teamcity = behave_teamcity:TeamcityFormatter -Embedding data (e.g. screenshots) in reports +Embedding Screenshots / Data in Reports ------------------------------------------------------------------------------ +:Hint 1: Only supported by JSON formatter +:Hint 2: Binary attachments may require base64 encoding. + You can embed data in reports with the :class:`~behave.runner.Context` method -:func:`~behave.runner.Context.attach`, if you have configured a formatter that +:func:`~behave.runner.Context.attach()`, if you have configured a formatter that supports it. Currently only the JSON formatter supports embedding data. For example: .. code-block:: python + # -- FILE: features/steps/screenshot_example_steps.py + from behave import fiven, when + from behave4example.web_browser.util import take_screenshot_and_attach_to_scenario + + @given(u'I open the Google webpage') @when(u'I open the Google webpage') - def step_impl(context): - context.browser.get('http://www.google.com') - img = context.browser.get_full_page_screenshot_as_png() - context.attach("image/png", img) + def step_open_google_webpage(ctx): + ctx.browser.get("https://www.google.com") + take_screenshot_and_attach_to_scenario(ctx) + +.. code-block:: python + + # -- FILE: behave4example/web_browser/util.py + # HINTS: + # * EXAMPLE CODE ONLY + # * BROWSER-SPECIFIC: Implementation may depend on browser driver. + def take_screenshot_and_attach_to_scenario(ctx): + # -- HINT: SELENIUM WITH CHROME: ctx.browser.get_screenshot_as_base64() + screenshot_image = ctx.browser.get_full_page_screenshot_as_png() + ctx.attach("image/png", screenshot_image) + +.. code-block:: python + + # -- FILE: features/environment.py + # EXAMPLE REQUIRES: This browser driver setup code (or something similar). + from selenium import webdriver + + def before_all(ctx): + ctx.browser = webdriver.Firefox() + +.. seealso:: + + * Selenium Python SDK: https://www.selenium.dev/selenium/docs/api/py/ + * Playwright Python SDK: https://playwright.dev/python/docs/intro + + + **RELATED:** Selenium webdriver details: + + * Selenium webdriver (for Firefox): `selenium.webdriver.firefox.webdriver.WebDriver.get_full_page_screenshot_as_png`_ + * Selenium webdriver (for Chrome): `selenium.webdriver.remote.webdriver.WebDriver.get_screenshot_as_base64`_ + + + **RELATED:** Playwright details: + + * https://playwright.dev/python/docs/api/class-locator#locator-screenshot + * https://playwright.dev/python/docs/api/class-page#page-screenshot + +.. _`selenium.webdriver.firefox.webdriver.WebDriver.get_full_page_screenshot_as_png`: https://www.selenium.dev/selenium/docs/api/py/webdriver_firefox/selenium.webdriver.firefox.webdriver.html?highlight=screenshot#selenium.webdriver.firefox.webdriver.WebDriver.get_full_page_screenshot_as_png +.. _`selenium.webdriver.remote.webdriver.WebDriver.get_screenshot_as_base64`: https://www.selenium.dev/selenium/docs/api/py/webdriver_remote/selenium.webdriver.remote.webdriver.html?highlight=get_screenshot_as_base64#selenium.webdriver.remote.webdriver.WebDriver.get_screenshot_as_base64 diff --git a/docs/install.rst b/docs/install.rst index 6e78ce703..a3cd8be96 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -63,13 +63,13 @@ Optional Dependencies If needed, additional dependencies can be installed using ``pip install`` with one of the following installation targets. -==================== =================================================================== -Installation Target Description -==================== =================================================================== -behave[docs] Include packages needed for building Behave's documentation. -behave[develop] Optional packages helpful for local development. -behave[formatters] Install formatters from `behave-contrib`_ to extend the list of - :ref:`id.appendix.formatters` provided by default. -==================== =================================================================== +======================= =================================================================== +Installation Target Description +======================= =================================================================== +``behave[docs]`` Include packages needed for building Behave's documentation. +``behave[develop]`` Optional packages helpful for local development. +``behave[formatters]`` Install formatters from `behave-contrib`_ to extend the list of + :ref:`formatters ` provided by default. +======================= =================================================================== .. _`behave-contrib`: https://github.com/behave-contrib diff --git a/py.requirements/docs.txt b/py.requirements/docs.txt index 1384e00a4..e50190db0 100644 --- a/py.requirements/docs.txt +++ b/py.requirements/docs.txt @@ -2,8 +2,9 @@ # BEHAVE: PYTHON PACKAGE REQUIREMENTS: For documentation generation # ============================================================================ # REQUIRES: pip >= 8.0 +# AVOID: shponx v4.4.0 and newer -- Problems w/ new link check suggestion warnings -sphinx >= 1.6 +sphinx >=1.6,<4.4 sphinx-autobuild sphinx_bootstrap_theme >= 0.6.0 From 7ae19c1c10985fb5b3d9758e01fab2ac65465c25 Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 31 Mar 2022 23:14:28 +0200 Subject: [PATCH 042/240] FIX: CommandShell.run("behave ...") * ENSURE: Use same python executable to run "behave" commands * This fixes a corner case where python3 was running behave tests but CommandShell.run("behave") was using python (python2). --- behave4cmd0/command_shell.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/behave4cmd0/command_shell.py b/behave4cmd0/command_shell.py index c55bdfba7..c823bfad4 100755 --- a/behave4cmd0/command_shell.py +++ b/behave4cmd0/command_shell.py @@ -74,7 +74,11 @@ class Command(object): """ DEBUG = False COMMAND_MAP = { - "behave": os.path.normpath("{0}/bin/behave".format(TOP)) + # OLD: "behave": os.path.normpath("{0}/bin/behave".format(TOP)), + "behave": "{python} {behave_cmd}".format( + python=sys.executable, + behave_cmd=os.path.normpath("{0}/bin/behave".format(TOP)) + ), } PREPROCESSOR_MAP = {} POSTPROCESSOR_MAP = {} From 44cac5edf15eda31e7ba6e66ea901b55c379c4eb Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 31 Mar 2022 23:18:41 +0200 Subject: [PATCH 043/240] justfile: Better multi-platform/OS support * Better support differences on Windows / Linux / macOS * RENAMED TO: "justfile" again to better support IDE plugins and syntax coloring (hint: ".justfile" is not supported by IDE plugin(s)). --- .justfile => justfile | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) rename .justfile => justfile (81%) diff --git a/.justfile b/justfile similarity index 81% rename from .justfile rename to justfile index 3c4cffd7d..8928ba95b 100644 --- a/.justfile +++ b/justfile @@ -1,16 +1,23 @@ # ============================================================================= -# justfile: A makefile like build script +# justfile: A makefile like build script -- Command Runner # ============================================================================= # REQUIRES: cargo install just -# PLATFORMS: Windows, Linux, macOS, ... +# PLATFORMS: macOS, Linux, Windows, ... # USAGE: # just --list # just -# just +# just ... # # SEE ALSO: # * https://github.com/casey/just # ============================================================================= +# WORKS BEST FOR: macOS, Linux +# PLATFORM HINTS: +# * Windows: Python 3.x has only "python.exe", but no "python3.exe" +# HINT: Requires "bash.exe", provided by WSL or git-bash. +# * Linux: Python 3.x has only "python3", but no "python" (for newer versions) +# HINT: "python" seems to be used for "python2". +# ============================================================================= # -- OPTION: Load environment-variables from "$HERE/.env" file (if exists) set dotenv-load @@ -19,11 +26,9 @@ set export := true # ----------------------------------------------------------------------------- # CONFIG: # ----------------------------------------------------------------------------- -# NOTES: -# - PYTHON: Newer Linux may have no "python" executable, only "python3". - HERE := justfile_directory() -PYTHON := env_var_or_default("PYTHON", "python3") +PYTHON_DEFAULT := if os() == "windows" { "python" } else { "python3" } +PYTHON := env_var_or_default("PYTHON", PYTHON_DEFAULT) PIP_INSTALL_OPTIONS := env_var_or_default("PIP_INSTALL_OPTIONS", "--quiet") BEHAVE_FORMATTER := env_var_or_default("BEHAVE_FORMATTER", "progress") @@ -34,7 +39,7 @@ PYTEST_OPTIONS := env_var_or_default("PYTEST_OPTIONS", "") # ----------------------------------------------------------------------------- # DEFAULT-TARGET: Ensure that packages are installed and runs tests. -default: (_ensure-install-packages "basic") (_ensure-install-packages "testing") test +default: (_ensure-install-packages "basic") (_ensure-install-packages "testing") test-all # PART=all, testing, ... install-packages PART="all": @@ -60,7 +65,7 @@ test *TESTS: # Run behave with feature file(s) or directory(s). behave +FEATURE_FILES="features": - {{PYTHON}} bin/behave --format={{BEHAVE_FORMATTER}} {{FEATURE_FILES}} + {{PYTHON}} {{HERE}}/bin/behave --format={{BEHAVE_FORMATTER}} {{FEATURE_FILES}} # Run all behave tests. behave-all: From a159d079eef76ea87438a865bb3d67a7229c7c15 Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 31 Mar 2022 23:34:01 +0200 Subject: [PATCH 044/240] TEST-BALLON: Use win_unicode_console more on Windows. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 67c89fb23..74b24fba3 100644 --- a/setup.py +++ b/setup.py @@ -84,7 +84,7 @@ def find_packages_by_root_package(where): "traceback2; python_version < '3.0'", # -- PREPARED: - "win_unicode_console; python_version < '3.6'", + "win_unicode_console; python_version <= '3.9'", "contextlib2; python_version < '3.5'", # DISABLED: "contextlib2 >= 21.6.0; python_version < '3.5'", "colorama >= 0.3.7", From 51014658b178c6ecdb63a722d8adfc80d8cba9a3 Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 2 May 2022 21:11:33 +0200 Subject: [PATCH 045/240] Issue #1020: Provide example that works. --- issue.features/issue1020.feature | 46 ++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 issue.features/issue1020.feature diff --git a/issue.features/issue1020.feature b/issue.features/issue1020.feature new file mode 100644 index 000000000..abb357962 --- /dev/null +++ b/issue.features/issue1020.feature @@ -0,0 +1,46 @@ +@issue +@mistaken +Feature: Issue #1020 -- Switch Step-Matcher in Step Definition File + + Ensure that you can redefine the Step-Matcher in a step definition file. + SEE: https://github.com/behave/behave/issues/1020 + + Scenario: Use steps with regex-matcher + Given a new working directory + And a file named "features/example_1020.feature" with: + """ + Feature: + Scenario: Alice + When I meet with "Alice" + Then I have a lot of fun with "Alice" + + Scenario: Bob + When I meet with "Bob" + Then I have a lot of fun with "Bob" + """ + And a file named "features/steps/steps.py" with: + """ + # -- FILE: features/steps/step.py + from behave import given, when, use_step_matcher + from hamcrest import assert_that, equal_to + + use_step_matcher("re") + + @when(u'I meet with "(?PAlice|Bob)"') + def step_when_I_meet(context, person): + context.person = person + + use_step_matcher("parse") + + @then(u'I have a lot of fun with "{person}"') + def step_then_I_have_fun_with(context, person): + assert_that(person, equal_to(context.person)) + """ + + When I run "behave -f plain features/example_1020.feature" + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 0 skipped + 4 steps passed, 0 failed, 0 skipped + """ + And the command output should contain "0 undefined" From b12a6ebcc52e1db4fa5d09620d5be27e0cec509e Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Mon, 16 May 2022 11:12:23 +0200 Subject: [PATCH 046/240] Fix comment typo --- features/environment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/environment.py b/features/environment.py index 72ebaa077..2ac096005 100644 --- a/features/environment.py +++ b/features/environment.py @@ -1,5 +1,5 @@ # -*- coding: UTF-8 -*- -# FILE: features/environemnt.py +# FILE: features/environment.py from __future__ import absolute_import, print_function from behave.tag_matcher import \ From d0f1a3d8d098b22f95485012dfafaea77de56bf6 Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 9 Aug 2022 19:16:26 +0200 Subject: [PATCH 047/240] FIX #1047: Inherit step type for generic step if possible Generic steps, starting with an asterisk ('*') normally have step type 'given'. If generic steps are intermixed with Given/When/Then steps, the step type of the last step is now inherited during parsing the steps. --- CHANGES.rst | 1 + behave/parser.py | 17 +++++++++++------ tests/issues/test_issue1047.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 tests/issues/test_issue1047.py diff --git a/CHANGES.rst b/CHANGES.rst index 63f150b6e..2bed57e91 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -66,6 +66,7 @@ FIXED: MINOR: +* issue #1047: Step type is inherited for generic step if possible (submitted by: zettseb) * issue #800: Cleanups related to Gherkin parser/ParseError question (submitted by: otstanteplz) * pull #767: FIX: use_fixture_by_tag didn't return the actual fixture in all cases (provided by: jgentil) * pull #751: gherkin: Adding Rule keyword translation in portuguese and spanish to gherkin-languages.json (provided by: dunossauro) diff --git a/behave/parser.py b/behave/parser.py index b71adfe0f..751107e74 100644 --- a/behave/parser.py +++ b/behave/parser.py @@ -178,7 +178,7 @@ def __init__(self, language=None, variant=None): self.variant = variant self.state = "init" self.line = 0 - self.last_step = None + self.last_step_type = None self.multiline_start = None self.multiline_leading = None self.multiline_terminator = None @@ -207,7 +207,7 @@ def reset(self): self.state = "init" self.line = 0 - self.last_step = None + self.last_step_type = None self.multiline_start = None self.multiline_leading = None self.multiline_terminator = None @@ -546,6 +546,7 @@ def action_scenario(self, line): * first step of Scenario/ScenarioOutline * next Scenario/ScenarioOutline """ + self.last_step_type = None line = line.strip() step = self.parse_step(line) if step: @@ -736,13 +737,17 @@ def parse_step(self, line): # BUT: Keywords in some languages (like Chinese, Japanese, ...) # do not need a whitespace as word separator. step_text_after_keyword = line[len(kw):].strip() - if step_type in ("and", "but"): - if not self.last_step: + if kw.startswith("*") and self.last_step_type: + # -- CASE: Generic steps and Given/When/Then steps are mixed. + # HINT: Inherit step type from last step. + step_type = self.last_step_type + elif step_type in ("and", "but"): + if not self.last_step_type: raise ParserError(u"No previous step", self.line, self.filename) - step_type = self.last_step + step_type = self.last_step_type else: - self.last_step = step_type + self.last_step_type = step_type keyword = kw.rstrip() # HINT: Strip optional trailing SPACE. step = model.Step(self.filename, self.line, diff --git a/tests/issues/test_issue1047.py b/tests/issues/test_issue1047.py new file mode 100644 index 000000000..fe6014828 --- /dev/null +++ b/tests/issues/test_issue1047.py @@ -0,0 +1,28 @@ +""" +https://github.com/behave/behave/issues/1047 +""" + +from __future__ import absolute_import, print_function +from behave.parser import parse_steps + + +def test_issue_1047_step_type_for_generic_steps_is_inherited(): + """Verifies that issue #1047 is fixed.""" + + text = u"""\ +When my step +And my second step +* my third step +""" + steps = parse_steps(text) + assert steps[-1].step_type == "when" + + +def test_issue_1047_step_type_if_only_generic_steps_are_used(): + text = u"""\ +* my step +* my another step +""" + steps = parse_steps(text) + assert steps[0].step_type == "given" + assert steps[1].step_type == "given" From acf367f28603a34d462e93aea4b9aae7feb0ec7e Mon Sep 17 00:00:00 2001 From: jenisys Date: Fri, 14 Oct 2022 00:07:17 +0200 Subject: [PATCH 048/240] FIX ISSUE #1061: Scenario should inherit its rule.tags * Move core functionality to "behave.model_core" * Use "self.parent" attribute prepared for Rule(s) * should_run_with_tags(): Unify (and cleanup) implementations --- CHANGES.rst | 1 + behave/model.py | 76 ++-- behave/model_core.py | 26 +- issue.features/issue1061.feature | 42 ++ tests/functional/test_tag_inheritance.py | 501 +++++++++++++++++++++++ tests/issues/test_issue1061.py | 70 ++++ 6 files changed, 678 insertions(+), 38 deletions(-) create mode 100644 issue.features/issue1061.feature create mode 100644 tests/functional/test_tag_inheritance.py create mode 100644 tests/issues/test_issue1061.py diff --git a/CHANGES.rst b/CHANGES.rst index 2bed57e91..f3b15a77a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -52,6 +52,7 @@ FIXED: * FIXED: Some tests related to python3.9 * FIXED: active-tag logic if multiple tags with same category exists. +* issue #1061: Scenario should inherit Rule tags (submitted by: testgitdl) * pull #967: Update __init__.py in behave import to fix pylint (provided by: dsayling) * issue #955: setup: Remove attribute 'use_2to3' (submitted by: krisgesling) * issue #772: ScenarioOutline.Examples without table (submitted by: The-QA-Geek) diff --git a/behave/model.py b/behave/model.py index f1ec7256c..a2fcce8d0 100644 --- a/behave/model.py +++ b/behave/model.py @@ -300,21 +300,24 @@ def should_run(self, config=None): return answer def should_run_with_tags(self, tag_expression): - """Determines if this feature should run when the tag expression is used. - A feature should run if: - * it should run according to its tags - * any of its scenarios should run according to its tags + """Determines if this feature or rule should run when the tag expression is used. + + A feature (or rule) should run if: + + * it should run according to its tags + * any of its scenarios should run according to its tags :param tag_expression: Runner/config environment tags to use. :return: True, if feature should run. False, otherwise (skip it). """ - run_feature = tag_expression.check(self.tags) - if not run_feature: - for scenario in self: - if scenario.should_run_with_tags(tag_expression): - run_feature = True - break - return run_feature + if tag_expression.check(self.effective_tags): + return True + + for run_item in self.run_items: + if run_item.should_run_with_tags(tag_expression): + return True + # -- OTHERWISE: Should NOT run + return False def mark_skipped(self): """Marks this feature (and all its scenarios and steps) as skipped. @@ -1040,21 +1043,8 @@ def duration(self): scenario_duration += step.duration return scenario_duration - @property - def effective_tags(self): - """ - Effective tags for this scenario: - * own tags - * tags inherited from its feature - """ - tags = self.tags - if self.feature: - tags = self.feature.tags + self.tags - return tags - def should_run(self, config=None): - """ - Determines if this Scenario (or ScenarioOutline) should run. + """Determines if this Scenario (or ScenarioOutline) should run. Implements the run decision logic for a scenario. The decision depends on: @@ -1071,14 +1061,6 @@ def should_run(self, config=None): self.should_run_with_name_select(config)) return answer - def should_run_with_tags(self, tag_expression): - """Checks if scenario should run when the tag expression is used. - - :param tag_expression: Runner/config environment tags to use. - :return: True, if scenario should run. False, otherwise (skip it). - """ - return tag_expression.check(self.effective_tags) - def should_run_with_name_select(self, config): """Checks if scenario should run when it is selected by name. @@ -1308,6 +1290,10 @@ def __init__(self, name, index): return self.annotation_schema.format(name=scenario_name, examples=example_data, row=row_data) + @staticmethod + def is_parametrized_tag(tag): + return "<" in tag and ">" in tag + @classmethod def make_row_tags(cls, outline_tags, row, params=None): if not outline_tags: @@ -1315,9 +1301,9 @@ def make_row_tags(cls, outline_tags, row, params=None): tags = [] for tag in outline_tags: - if "<" in tag and ">" in tag: + if cls.is_parametrized_tag(tag): tag = cls.render_template(tag, row, params) - if "<" in tag or ">" in tag: + if cls.is_parametrized_tag(tag): # -- OOPS: Unknown placeholder, drop tag. continue new_tag = Tag.make_name(tag, unescape=True) @@ -1494,6 +1480,26 @@ def scenarios(self): self._scenarios = builder.build_scenarios(self) return self._scenarios + @property + def effective_tags(self): + """Compute effective tags of this ScenarioOutline/ScenarioTemplate. + This is includes the own tags and the inherited tags from the parents. + Note that parametrized tags are filter out. + + :return: Set of effective tags + + .. note:: Overrides generic implementation in base class. + """ + # -- SPECIAL CASE: ScenarioOutline/ScenarioTemplate + # Filter out "abstract tags" (parametrized tags) used in this template. + tags = set([tag for tag in self.tags + if not ScenarioOutlineBuilder.is_parametrized_tag(tag)]) + if self.parent: + # -- INHERIT TAGS: From parent(s), recursively + inherited_tags = self.parent.effective_tags + tags.update(inherited_tags) + return tags + def __repr__(self): return '' % self.name diff --git a/behave/model_core.py b/behave/model_core.py index 515ab6bac..d4a9d4f71 100644 --- a/behave/model_core.py +++ b/behave/model_core.py @@ -358,7 +358,11 @@ def should_run_with_tags(self, tag_expression): class TagAndStatusStatement(BasicStatement): - # final_status = ('passed', 'failed', 'skipped') + """Base class for statements with: + + * tags (as: taggable statement) + * status (has a result after a test run) + """ final_status = (Status.passed, Status.failed, Status.skipped) def __init__(self, filename, line, keyword, name, tags, parent=None): @@ -369,13 +373,29 @@ def __init__(self, filename, line, keyword, name, tags, parent=None): self.skip_reason = None self._cached_status = Status.untested + @property + def effective_tags(self): + """Compute effective tags of this entity. + This is includes the own tags and the inherited tags from the parents. + + :return: Set of effective tags + + .. versionadded:: 1.2.7 + """ + tags = set(self.tags) + if self.parent: + # -- INHERIT TAGS: From parent(s), recursively + inherited_tags = self.parent.effective_tags + tags.update(inherited_tags) + return tags + def should_run_with_tags(self, tag_expression): """Determines if statement should run when the tag expression is used. :param tag_expression: Runner/config environment tags to use. - :return: True, if examples should run. False, otherwise (skip it). + :return: True, if this statement should run. False, otherwise (skip it). """ - return tag_expression.check(self.tags) + return tag_expression.check(self.effective_tags) @property def status(self): diff --git a/issue.features/issue1061.feature b/issue.features/issue1061.feature new file mode 100644 index 000000000..e909242e5 --- /dev/null +++ b/issue.features/issue1061.feature @@ -0,0 +1,42 @@ +@issue +Feature: Issue #1061 -- Syndrome: Scenario does not inherit Rule Tags + + Background: Setup + Given a new working directory + And a file named "features/syndrome_1061.feature" with: + """ + Feature: F1 + + @rule_tag + Rule: R1 + + Scenario: S1 + Given a step passes + When another step passes + """ + And a file named "features/steps/use_step_library.py" with: + """ + # -- REUSE STEPS: + import behave4cmd0.passing_steps + """ + And a file named "behave.ini" with: + """ + [behave] + show_timings = false + """ + + Scenario: Verify syndrome is fixed + When I run "behave -f plain --tags=rule_tag features/syndrome_1061.feature" + Then it should pass with: + """ + Scenario: S1 + Given a step passes ... passed + When another step passes ... passed + """ + And the command output should contain: + """ + 1 rule passed, 0 failed, 0 skipped + 1 scenario passed, 0 failed, 0 skipped + 2 steps passed, 0 failed, 0 skipped + """ + And note that "the rule scenario is NOT SKIPPED" diff --git a/tests/functional/test_tag_inheritance.py b/tests/functional/test_tag_inheritance.py new file mode 100644 index 000000000..c6078b205 --- /dev/null +++ b/tests/functional/test_tag_inheritance.py @@ -0,0 +1,501 @@ +""" +Test the tag inheritance mechanism between model entities: + +* Feature(s) +* Rule(s) +* ScenarioOutline(s) +* Scenario(s) + +Tag inheritance mechanism: + +* Inner model element inherits tags from its outer/parent elements +* Parametrized tags from a ScenarioOutline/ScenarioTemplate are filtered out + +EXAMPLES: + +* Scenario inherits the tags of its Feature +* Scenario inherits the tags of its Rule +* Scenario derives its tags of its ScenarioOutline (and Examples table) + +* Rule inherits tags of its Feature +* ScenarioOutline/ScenarioTemplate inherits tags from its Feature +* ScenarioOutline/ScenarioTemplate inherits tags from its Rule +""" + +from __future__ import absolute_import, print_function +from behave.parser import parse_feature +import pytest + + +# ----------------------------------------------------------------------------- +# TEST SUPPORT +# ----------------------------------------------------------------------------- +def get_inherited_tags(model_element): + inherited_tags = set(model_element.effective_tags).difference(model_element.tags) + return sorted(inherited_tags) # -- ENSURE: Deterministic ordering + + +def assert_tags_same_as_effective_tags(model_element): + assert set(model_element.tags) == set(model_element.effective_tags) + + +def assert_inherited_tags_equal_to(model_element, expected_tags): + inherited_tags = get_inherited_tags(model_element) + assert inherited_tags == expected_tags + + +def assert_no_tags_are_inherited(model_element): + assert_inherited_tags_equal_to(model_element, []) + + +# ----------------------------------------------------------------------------- +# TEST SUITE +# ----------------------------------------------------------------------------- +class TestTagInheritance4Feature(object): + """A Feature is the outermost model element. + Therefore, it cannot inherit any features. + """ + + @pytest.mark.parametrize("tags, case", [ + ([], "without tags"), + (["feature_tag1", "feature_tag2"], "with tags"), + ]) + def test_no_inherited_tags(self, tags, case): + tag_line = " ".join("@%s" % tag for tag in tags) + text = u""" + {tag_line} + Feature: F1 + """.format(tag_line=tag_line) + this_feature = parse_feature(text) + assert this_feature.tags == tags + assert this_feature.effective_tags == set(tags) + assert_no_tags_are_inherited(this_feature) + + +class TestTagInheritance4Rule(object): + def test_no_inherited_tags__without_feature_tags(self): + text = u""" + Feature: F1 + @rule_tag1 + Rule: R1 + """ + this_feature = parse_feature(text) + this_rule = this_feature.rules[0] + assert this_feature.tags == [] + assert this_rule.tags == ["rule_tag1"] + assert_tags_same_as_effective_tags(this_rule) + assert_no_tags_are_inherited(this_rule) + + def test_inherited_tags__with_feature_tags(self): + text = u""" + @feature_tag1 @feature_tag2 + Feature: F2 + @rule_tag1 + Rule: R2 + """ + this_feature = parse_feature(text) + this_rule = this_feature.rules[0] + expected_feature_tags = ["feature_tag1", "feature_tag2"] + assert this_feature.tags == expected_feature_tags + assert this_rule.tags == ["rule_tag1"] + assert_inherited_tags_equal_to(this_rule, expected_feature_tags) + + def test_duplicated_tags_are_removed_from_inherited_tags(self): + text = u""" + @feature_tag1 @duplicated_tag + Feature: F2 + @rule_tag1 @duplicated_tag + Rule: R2 + """ + this_feature = parse_feature(text) + this_rule = this_feature.rules[0] + assert this_feature.tags == ["feature_tag1", "duplicated_tag"] + assert this_rule.tags == ["rule_tag1", "duplicated_tag"] + assert_inherited_tags_equal_to(this_rule, ["feature_tag1"]) + + +class TestTagInheritance4ScenarioOutline(object): + def test_no_inherited_tags__without_feature_tags(self): + text = u""" + Feature: F3 + @outline_tag1 + Scenario Outline: T1 + """ + this_feature = parse_feature(text) + this_scenario_outline = this_feature.run_items[0] + assert this_feature.tags == [] + assert this_scenario_outline.tags == ["outline_tag1"] + assert_no_tags_are_inherited(this_scenario_outline) + + def test_no_inherited_tags__without_feature_and_rule_tags(self): + text = u""" + Feature: F3 + Rule: R3 + @outline_tag1 + Scenario Outline: T1 + """ + this_feature = parse_feature(text) + this_rule = this_feature.rules[0] + this_scenario_outline = this_rule.run_items[0] + assert this_feature.tags == [] + assert this_rule.tags == [] + assert this_scenario_outline.tags == ["outline_tag1"] + assert_no_tags_are_inherited(this_scenario_outline) + + def test_inherited_tags__with_feature_tags(self): + text = u""" + @feature_tag1 @feature_tag2 + Feature: F3 + @outline_tag1 + Scenario Outline: T3 + """ + this_feature = parse_feature(text) + this_scenario_outline = this_feature.run_items[0] + expected_feature_tags = ["feature_tag1", "feature_tag2"] + assert this_feature.tags == expected_feature_tags + assert this_scenario_outline.tags == ["outline_tag1"] + assert_inherited_tags_equal_to(this_scenario_outline, expected_feature_tags) + + def test_inherited_tags__with_rule_tags(self): + text = u""" + Feature: F3 + @rule_tag1 @rule_tag2 + Rule: R3 + @outline_tag1 + Scenario Outline: T3 + """ + this_feature = parse_feature(text) + this_rule = this_feature.rules[0] + this_scenario_outline = this_rule.run_items[0] + expected_rule_tags = ["rule_tag1", "rule_tag2"] + assert this_feature.tags == [] + assert this_rule.tags == expected_rule_tags + assert this_scenario_outline.tags == ["outline_tag1"] + assert_inherited_tags_equal_to(this_scenario_outline, expected_rule_tags) + + def test_inherited_tags__with_feature_and_rule_tags(self): + text = u""" + @feature_tag1 + Feature: F3 + @rule_tag1 @rule_tag2 + Rule: R3 + @outline_tag1 + Scenario Outline: T3 + """ + this_feature = parse_feature(text) + this_rule = this_feature.rules[0] + this_scenario_outline = this_rule.run_items[0] + expected_feature_tags = ["feature_tag1"] + expected_rule_tags = ["rule_tag1", "rule_tag2"] + expected_inherited_tags = ["feature_tag1", "rule_tag1", "rule_tag2"] + assert this_feature.tags == expected_feature_tags + assert this_rule.tags == expected_rule_tags + assert this_scenario_outline.tags == ["outline_tag1"] + assert_inherited_tags_equal_to(this_scenario_outline, expected_inherited_tags) + + def test_duplicated_tags_are_removed_from_inherited_tags(self): + text = u""" + @feature_tag1 @duplicated_tag + Feature: F3 + @rule_tag1 @duplicated_tag + Rule: R3 + @outline_tag1 @duplicated_tag + Scenario Outline: T3 + """ + this_feature = parse_feature(text) + this_rule = this_feature.rules[0] + this_scenario_outline = this_rule.run_items[0] + assert this_feature.tags == ["feature_tag1", "duplicated_tag"] + assert this_rule.tags == ["rule_tag1", "duplicated_tag"] + assert this_scenario_outline.tags == ["outline_tag1", "duplicated_tag"] + assert_inherited_tags_equal_to(this_scenario_outline, ["feature_tag1", "rule_tag1"]) + + +class TestTagInheritance4Scenario(object): + def test_no_inherited_tags__without_feature_tags(self): + text = u""" + Feature: F4 + @scenario_tag1 + Scenario: S4 + """ + this_feature = parse_feature(text) + this_scenario = this_feature.scenarios[0] + assert this_feature.tags == [] + assert this_scenario.tags == ["scenario_tag1"] + assert_no_tags_are_inherited(this_scenario) + + def test_no_inherited_tags__without_feature_and_rule_tags(self): + text = u""" + Feature: F4 + Rule: R4 + @scenario_tag1 + Scenario: S4 + """ + this_feature = parse_feature(text) + this_rule = this_feature.rules[0] + this_scenario = this_rule.scenarios[0] + assert this_feature.tags == [] + assert this_rule.tags == [] + assert this_scenario.tags == ["scenario_tag1"] + assert_no_tags_are_inherited(this_scenario) + + def test_inherited_tags__with_feature_tags(self): + text = u""" + @feature_tag1 @feature_tag2 + Feature: F4 + @scenario_tag1 + Scenario: S4 + """ + this_feature = parse_feature(text) + this_scenario = this_feature.scenarios[0] + expected_feature_tags = ["feature_tag1", "feature_tag2"] + assert this_feature.tags == expected_feature_tags + assert this_scenario.tags == ["scenario_tag1"] + assert_inherited_tags_equal_to(this_scenario, expected_feature_tags) + + def test_inherited_tags__with_rule_tags(self): + text = u""" + Feature: F3 + @rule_tag1 @rule_tag2 + Rule: R3 + @scenario_tag1 + Scenario: S4 + """ + this_feature = parse_feature(text) + this_rule = this_feature.rules[0] + this_scenario = this_rule.scenarios[0] + expected_rule_tags = ["rule_tag1", "rule_tag2"] + assert this_feature.tags == [] + assert this_rule.tags == expected_rule_tags + assert this_scenario.tags == ["scenario_tag1"] + assert_inherited_tags_equal_to(this_scenario, expected_rule_tags) + + def test_inherited_tags__with_feature_and_rule_tags(self): + text = u""" + @feature_tag1 + Feature: F4 + @rule_tag1 @rule_tag2 + Rule: R4 + @scenario_tag1 + Scenario: S4 + """ + this_feature = parse_feature(text) + this_rule = this_feature.rules[0] + this_scenario = this_rule.scenarios[0] + expected_feature_tags = ["feature_tag1"] + expected_rule_tags = ["rule_tag1", "rule_tag2"] + expected_inherited_tags = ["feature_tag1", "rule_tag1", "rule_tag2"] + assert this_feature.tags == expected_feature_tags + assert this_rule.tags == expected_rule_tags + assert this_scenario.tags == ["scenario_tag1"] + assert_inherited_tags_equal_to(this_scenario, expected_inherited_tags) + + def test_duplicated_tags_are_removed_from_inherited_tags(self): + text = u""" + @feature_tag1 @duplicated_tag + Feature: F4 + @rule_tag1 @duplicated_tag + Rule: R4 + @scenario_tag1 @duplicated_tag + Scenario: S4 + """ + this_feature = parse_feature(text) + this_rule = this_feature.rules[0] + this_scenario = this_rule.scenarios[0] + assert this_feature.tags == ["feature_tag1", "duplicated_tag"] + assert this_rule.tags == ["rule_tag1", "duplicated_tag"] + assert this_scenario.tags == ["scenario_tag1", "duplicated_tag"] + assert_inherited_tags_equal_to(this_scenario, ["feature_tag1", "rule_tag1"]) + + +class TestTagInheritance4ScenarioFromTemplate(object): + """Test tag inheritance for scenarios from a ScenarioOutline or + ScenarioTemplate (as alias for ScenarioOutline). + + SCENARIO TEMPLATE MECHANISM:: + + scenario_template := scenario_outline + scenario.tags := scenario_template.tags + scenario_template.examples[i].tags + """ + + def test_no_inherited_tags__without_feature_tags(self): + text = u""" + Feature: F5 + @template_tag1 + Scenario Outline: T5 + Given I meet "" + + @examples_tag1 + Examples: + | name | + | Alice | + """ + this_feature = parse_feature(text) + this_scenario_outline = this_feature.run_items[0] + this_scenario = this_scenario_outline.scenarios[0] + assert this_feature.tags == [] + assert this_scenario_outline.tags == ["template_tag1"] + assert this_scenario.tags == ["template_tag1", "examples_tag1"] + assert_no_tags_are_inherited(this_scenario) + + def test_no_inherited_tags__without_feature_and_rule_tags(self): + text = u""" + Feature: F5 + Rule: R5 + @template_tag1 + Scenario Outline: T5 + Given I meet "" + + @examples_tag1 + Examples: + | name | + | Alice | + """ + this_feature = parse_feature(text) + this_rule = this_feature.rules[0] + this_scenario_outline = this_rule.run_items[0] + this_scenario = this_scenario_outline.scenarios[0] + assert this_feature.tags == [] + assert this_rule.tags == [] + assert this_scenario_outline.tags == ["template_tag1"] + assert this_scenario.tags == ["template_tag1", "examples_tag1"] + assert_no_tags_are_inherited(this_scenario) + + def test_inherited_tags__with_feature_tags(self): + text = u""" + @feature_tag1 @feature_tag2 + Feature: F5 + @template_tag1 + Scenario Outline: T5 + Given I meet "" + + Examples: + | name | + | Alice | + """ + this_feature = parse_feature(text) + this_scenario_outline = this_feature.run_items[0] + this_scenario = this_scenario_outline.scenarios[0] + expected_feature_tags = ["feature_tag1", "feature_tag2"] + assert this_feature.tags == expected_feature_tags + assert this_scenario.tags == ["template_tag1"] + assert_inherited_tags_equal_to(this_scenario, expected_feature_tags) + + def test_inherited_tags__with_rule_tags(self): + text = u""" + Feature: F5 + @rule_tag1 @rule_tag2 + Rule: R5 + + @template_tag1 + Scenario Outline: T5 + Given I meet "" + + Examples: + | name | + | Alice | + """ + this_feature = parse_feature(text) + this_rule = this_feature.rules[0] + this_scenario_outline = this_feature.run_items[0] + this_scenario = this_scenario_outline.scenarios[0] + expected_rule_tags = ["rule_tag1", "rule_tag2"] + assert this_feature.tags == [] + assert this_rule.tags == expected_rule_tags + assert this_scenario.tags == ["template_tag1"] + assert_inherited_tags_equal_to(this_scenario, expected_rule_tags) + + def test_inherited_tags__with_feature_and_rule_tags(self): + text = u""" + @feature_tag1 + Feature: F4 + @rule_tag1 @rule_tag2 + Rule: R4 + + @template_tag1 + Scenario Outline: T5 + Given I meet "" + + Examples: + | name | + | Alice | + """ + this_feature = parse_feature(text) + this_rule = this_feature.rules[0] + this_scenario_outline = this_rule.run_items[0] + this_scenario = this_scenario_outline.scenarios[0] + + expected_feature_tags = ["feature_tag1"] + expected_rule_tags = ["rule_tag1", "rule_tag2"] + expected_inherited_tags = expected_feature_tags + expected_rule_tags + assert this_feature.tags == expected_feature_tags + assert this_rule.tags == expected_rule_tags + assert this_scenario.tags == ["template_tag1"] + assert_inherited_tags_equal_to(this_scenario, expected_inherited_tags) + + def test_tags_are_derived_from_template(self): + text = u""" + Feature: F5 + + @template_tag1 @param_name_ + Scenario Outline: T5 + Given I meet "" + + Examples: + | name | + | Alice | + """ + this_feature = parse_feature(text) + this_scenario_template = this_feature.run_items[0] + this_scenario = this_scenario_template.scenarios[0] + + assert this_feature.tags == [] + assert this_scenario_template.tags == ["template_tag1", "param_name_"] + assert this_scenario.tags == ["template_tag1", "param_name_Alice"] + assert_no_tags_are_inherited(this_scenario) + + def test_tags_are_derived_from_template_examples_for_table_row(self): + text = u""" + Feature: F5 + Rule: R5 + Scenario Outline: T5 + Given I meet "" + + @examples_tag1 + Examples: + | name | + | Alice | + """ + this_feature = parse_feature(text) + this_rule = this_feature.rules[0] + this_scenario_outline = this_rule.run_items[0] + this_scenario = this_scenario_outline.scenarios[0] + + assert this_feature.tags == [] + assert this_scenario.tags == ["examples_tag1"] + assert_no_tags_are_inherited(this_scenario) + + def test_duplicated_tags_are_removed_from_inherited_tags(self): + text = u""" + @feature_tag1 @duplicated_tag + Feature: F4 + @rule_tag1 @duplicated_tag + Rule: R4 + + @template_tag1 @duplicated_tag + Scenario Outline: T5 + Given I meet "" + + @examples_tag1 + Examples: + | name | + | Alice | + """ + this_feature = parse_feature(text) + this_rule = this_feature.rules[0] + this_scenario_template = this_rule.scenarios[0] + this_scenario = this_scenario_template.scenarios[0] + assert this_feature.tags == ["feature_tag1", "duplicated_tag"] + assert this_rule.tags == ["rule_tag1", "duplicated_tag"] + assert this_scenario.tags == ["template_tag1", "duplicated_tag", "examples_tag1"] + assert_inherited_tags_equal_to(this_scenario, ["feature_tag1", "rule_tag1"]) diff --git a/tests/issues/test_issue1061.py b/tests/issues/test_issue1061.py new file mode 100644 index 000000000..25223206b --- /dev/null +++ b/tests/issues/test_issue1061.py @@ -0,0 +1,70 @@ +""" +https://github.com/behave/behave/issues/1061 +""" + +from __future__ import absolute_import, print_function +from behave.parser import parse_feature +from tests.functional.test_tag_inheritance import \ + get_inherited_tags, assert_inherited_tags_equal_to + + +# ----------------------------------------------------------------------------- +# TEST SUITE +# ----------------------------------------------------------------------------- +class TestIssue(object): + """Verifies that issue is fixed. + Verifies basics that tag-inheritance mechanism works. + + .. seealso:: tests/functional/test_tag_inheritance.py + """ + + def test_scenario_inherits_tags_with_feature(self): + """Verifies that issue #1047 is fixed.""" + text = u""" + @feature_tag1 + Feature: F1 + + @scenario_tag1 + Scenario: S1 + """ + this_feature = parse_feature(text) + this_scenario = this_feature.scenarios[0] + expected_tags = set(["scenario_tag1", "feature_tag1"]) + assert this_scenario.effective_tags == expected_tags + assert_inherited_tags_equal_to(this_scenario, ["feature_tag1"]) + + def test_scenario_inherits_tags_with_rule(self): + text = u""" + @feature_tag1 + Feature: F1 + @rule_tag1 @rule_tag2 + Rule: R1 + @scenario_tag1 + Scenario: S1 + """ + this_feature = parse_feature(text) + this_scenario = this_feature.rules[0].scenarios[0] + inherited_tags = ["feature_tag1", "rule_tag1", "rule_tag2"] + expected_tags = set(["scenario_tag1"]).union(inherited_tags) + assert this_scenario.effective_tags == expected_tags + assert_inherited_tags_equal_to(this_scenario, inherited_tags) + + def test_issue_scenario_inherits_tags_with_scenario_outline_and_rule(self): + text = u""" + @feature_tag1 + Feature: F1 + @rule_tag1 @rule_tag2 + Rule: R1 + @scenario_tag1 + Scenario Outline: S1 + Examples: + | name | + | Alice | + """ + this_feature = parse_feature(text) + this_scenario_outline = this_feature.rules[0].scenarios[0] + this_scenario = this_scenario_outline.scenarios[0] + inherited_tags = ["feature_tag1", "rule_tag1", "rule_tag2"] + expected_tags = set(["scenario_tag1"]).union(inherited_tags) + assert this_scenario.effective_tags == expected_tags + assert_inherited_tags_equal_to(this_scenario, inherited_tags) From 9d932bc58b943a6c9985cfd9ae22da7fe970f30e Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 16 Oct 2022 17:06:10 +0200 Subject: [PATCH 049/240] FIX: Windows regression problems in test suite. reporter.junit: * Strip trailing whitespace in message attributes behave4cmd0: * Normalize line-endings * Strip some text(s)/command output(s) CI workflow on Windows: * Use PYTHONUTF8=1 environment variable --- .github/workflows/tests-windows.yml | 4 ++ behave/model.py | 10 +++- behave/reporter/junit.py | 7 ++- behave4cmd0/command_steps.py | 89 ++++++++++++++++++++--------- behave4cmd0/textutil.py | 29 +++++++++- features/i18n_emoji.feature | 4 ++ 6 files changed, 110 insertions(+), 33 deletions(-) diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index e02163d7e..e7cbe896a 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -7,6 +7,10 @@ on: pull_request: branches: [ main ] +# -- TEST BALLOON: Fix encoding="cp1252" problems by using "UTF-8" +env: + PYTHONUTF8: 1 + jobs: test: runs-on: ${{ matrix.os }} diff --git a/behave/model.py b/behave/model.py index a2fcce8d0..73f1a61e5 100644 --- a/behave/model.py +++ b/behave/model.py @@ -2213,13 +2213,19 @@ def assert_equals(self, expected): """ if self == expected: return True + + # -- DETAILED HINTS: Why comparison failed. diff = [] for line in difflib.unified_diff(self.splitlines(), expected.splitlines()): diff.append(line) - # strip unnecessary diff prefix + if not diff: + # -- MAYBE: Only differences in line-endings => GRACEFULLY ACCEPT as OK. + return True + + # -- HINT: Strip unnecessary diff prefix diff = ["Text does not match:"] + diff[3:] - raise AssertionError("\n".join(diff)) + raise AssertionError("\n".join(diff) +";") # ----------------------------------------------------------------------------- diff --git a/behave/reporter/junit.py b/behave/reporter/junit.py index 901839909..da54bcaa6 100644 --- a/behave/reporter/junit.py +++ b/behave/reporter/junit.py @@ -397,7 +397,7 @@ def _process_scenario(self, scenario, report): step_text = self.describe_step(step).rstrip() text = u"\nFailing step: %s\nLocation: %s\n" % \ (step_text, step.location) - message = _text(step.exception) + message = _text(step.exception).strip() failure.set(u'type', step.exception.__class__.__name__) failure.set(u'message', message) text += _text(step.error_message) @@ -407,7 +407,7 @@ def _process_scenario(self, scenario, report): if scenario.exception: failure_type = scenario.exception.__class__.__name__ failure.set(u'type', failure_type) - failure.set(u'message', scenario.error_message or "") + failure.set(u'message', scenario.error_message.strip() or "") traceback_lines = traceback.format_tb(scenario.exc_traceback) traceback_lines.insert(0, u"Traceback:\n") text = _text(u"".join(traceback_lines)) @@ -420,9 +420,10 @@ def _process_scenario(self, scenario, report): if step: # -- UNDEFINED-STEP: report.counts_failed += 1 + message = u"Undefined Step: %s" % step.name.strip() failure = ElementTree.Element(u"failure") failure.set(u"type", u"undefined") - failure.set(u"message", (u"Undefined Step: %s" % step.name)) + failure.set(u"message", message) case.append(failure) else: skip = ElementTree.Element(u'skipped') diff --git a/behave4cmd0/command_steps.py b/behave4cmd0/command_steps.py index 19c3e807d..f9bc980c2 100644 --- a/behave4cmd0/command_steps.py +++ b/behave4cmd0/command_steps.py @@ -11,6 +11,8 @@ """ from __future__ import absolute_import, print_function + +import codecs import contextlib import difflib import os @@ -37,6 +39,17 @@ # ----------------------------------------------------------------------------- # UTILITIES: # ----------------------------------------------------------------------------- +def print_differences(actual, expected): + # diff = difflib.unified_diff(expected.splitlines(), actual.splitlines(), + # "expected", "actual") + diff = difflib.ndiff(expected.splitlines(), actual.splitlines()) + diff_text = u"\n".join(diff) + print(u"DIFF (+ ACTUAL, - EXPECTED):\n{0}\n".format(diff_text)) + if DEBUG: + print(u"expected:\n{0}\n".format(expected)) + print(u"actual:\n{0}\n".format(actual)) + + @contextlib.contextmanager def on_assert_failed_print_details(actual, expected): """ @@ -50,14 +63,7 @@ def on_assert_failed_print_details(actual, expected): try: yield except AssertionError: - # diff = difflib.unified_diff(expected.splitlines(), actual.splitlines(), - # "expected", "actual") - diff = difflib.ndiff(expected.splitlines(), actual.splitlines()) - diff_text = u"\n".join(diff) - print(u"DIFF (+ ACTUAL, - EXPECTED):\n{0}\n".format(diff_text)) - if DEBUG: - print(u"expected:\n{0}\n".format(expected)) - print(u"actual:\n{0}\n".format(actual)) + print_differences(actual, expected) raise @contextlib.contextmanager @@ -73,14 +79,17 @@ def on_error_print_details(actual, expected): try: yield except Exception: - diff = difflib.ndiff(expected.splitlines(), actual.splitlines()) - diff_text = u"\n".join(diff) - print(u"DIFF (+ ACTUAL, - EXPECTED):\n{0}\n".format(diff_text)) - if DEBUG: - print(u"expected:\n{0}\n".format(expected)) - print(u"actual:\n{0}".format(actual)) + print_differences(actual, expected) raise + +def is_encoding_valid(encoding): + try: + return bool(codecs.lookup(encoding)) + except LookupError: + return False + + # ----------------------------------------------------------------------------- # STEPS: WORKING DIR # ----------------------------------------------------------------------------- @@ -94,14 +103,14 @@ def step_a_new_working_directory(context): shutil.rmtree(context.workdir, ignore_errors=True) command_util.ensure_workdir_exists(context) + @given(u'I use the current directory as working directory') def step_use_curdir_as_working_directory(context): - """ - Uses the current directory as working directory - """ + """Uses the current directory as working directory""" context.workdir = os.path.abspath(".") command_util.ensure_workdir_exists(context) + @step(u'I use the directory "{directory}" as working directory') def step_use_directory_as_working_directory(context, directory): """Uses the directory as new working directory""" @@ -125,16 +134,16 @@ def step_use_directory_as_working_directory(context, directory): context.workdir = workdir command_util.ensure_workdir_exists(context) + # ----------------------------------------------------------------------------- # STEPS: Create files with contents # ----------------------------------------------------------------------------- @given(u'a file named "{filename}" and encoding="{encoding}" with') def step_a_file_named_filename_and_encoding_with(context, filename, encoding): """Creates a textual file with the content provided as docstring.""" - __encoding_is_valid = True assert context.text is not None, "ENSURE: multiline text is provided." assert not os.path.isabs(filename) - assert __encoding_is_valid + assert is_encoding_valid(encoding), "INVALID: encoding=%s;" % encoding command_util.ensure_workdir_exists(context) filename2 = os.path.join(context.workdir, filename) pathutil.create_textfile_with_contents(filename2, context.text, encoding) @@ -178,40 +187,48 @@ def step_i_run_command(context, command): print(u"run_command: {0}".format(command)) print(u"run_command.output {0}".format(context.command_result.output)) + @when(u'I successfully run "{command}"') @when(u'I successfully run `{command}`') def step_i_successfully_run_command(context, command): step_i_run_command(context, command) step_it_should_pass(context) + @then(u'it should fail with result "{result:int}"') def step_it_should_fail_with_result(context, result): assert_that(context.command_result.returncode, equal_to(result)) assert_that(result, is_not(equal_to(0))) + @then(u'the command should fail with returncode="{result:int}"') def step_it_should_fail_with_returncode(context, result): assert_that(context.command_result.returncode, equal_to(result)) assert_that(result, is_not(equal_to(0))) + @then(u'the command returncode is "{result:int}"') def step_the_command_returncode_is(context, result): assert_that(context.command_result.returncode, equal_to(result)) + @then(u'the command returncode is non-zero') def step_the_command_returncode_is_nonzero(context): assert_that(context.command_result.returncode, is_not(equal_to(0))) + @then(u'it should pass') def step_it_should_pass(context): assert_that(context.command_result.returncode, equal_to(0), context.command_result.output) + @then(u'it should fail') def step_it_should_fail(context): assert_that(context.command_result.returncode, is_not(equal_to(0)), context.command_result.output) + @then(u'it should pass with') def step_it_should_pass_with(context): ''' @@ -299,11 +316,13 @@ def step_command_output_should_contain_text_multiple_times(context, text, count) __CWD__ = posixpath_normpath(os.getcwd()) ) actual_output = context.command_result.output - with on_assert_failed_print_details(actual_output, expected_text): + expected_text_part = expected_text + with on_assert_failed_print_details(actual_output, expected_text_part): textutil.assert_normtext_should_contain_multiple_times(actual_output, - expected_text, + expected_text_part, count) + @then(u'the command output should contain exactly "{text}"') def step_command_output_should_contain_exactly_text(context, text): """ @@ -366,7 +385,9 @@ def step_command_output_should_not_contain(context): """ ''' assert context.text is not None, "REQUIRE: multi-line text" - step_command_output_should_not_contain_text(context, context.text.strip()) + text = context.text.rstrip() + step_command_output_should_not_contain_text(context, text) + @then(u'the command output should contain {count:d} times') def step_command_output_should_contain_multiple_times(context, count): @@ -381,19 +402,22 @@ def step_command_output_should_contain_multiple_times(context, count): """ ''' assert context.text is not None, "REQUIRE: multi-line text" - step_command_output_should_contain_text_multiple_times(context, - context.text, count) + text = context.text.rstrip() + step_command_output_should_contain_text_multiple_times(context, text, count) + @then(u'the command output should contain exactly') def step_command_output_should_contain_exactly_with_multiline_text(context): assert context.text is not None, "REQUIRE: multi-line text" - step_command_output_should_contain_exactly_text(context, context.text) + text = context.text.rstrip() + step_command_output_should_contain_exactly_text(context, text) @then(u'the command output should not contain exactly') def step_command_output_should_contain_not_exactly_with_multiline_text(context): assert context.text is not None, "REQUIRE: multi-line text" - step_command_output_should_not_contain_exactly_text(context, context.text) + text = context.text.rstrip() + step_command_output_should_not_contain_exactly_text(context, text) # ----------------------------------------------------------------------------- @@ -408,6 +432,7 @@ def step_remove_directory(context, directory): shutil.rmtree(path_, ignore_errors=True) assert_that(not os.path.isdir(path_)) + @given(u'I ensure that the directory "{directory}" exists') def step_given_ensure_that_the_directory_exists(context, directory): path_ = directory @@ -417,10 +442,12 @@ def step_given_ensure_that_the_directory_exists(context, directory): os.makedirs(path_) assert_that(os.path.isdir(path_)) + @given(u'I ensure that the directory "{directory}" does not exist') def step_given_the_directory_should_not_exist(context, directory): step_remove_directory(context, directory) + @given(u'a directory named "{path}"') def step_directory_named_dirname(context, path): assert context.workdir, "REQUIRE: context.workdir" @@ -429,6 +456,7 @@ def step_directory_named_dirname(context, path): os.makedirs(path_) assert os.path.isdir(path_) + @then(u'the directory "{directory}" should exist') def step_the_directory_should_exist(context, directory): path_ = directory @@ -436,6 +464,7 @@ def step_the_directory_should_exist(context, directory): path_ = os.path.join(context.workdir, os.path.normpath(directory)) assert_that(os.path.isdir(path_)) + @then(u'the directory "{directory}" should not exist') def step_the_directory_should_not_exist(context, directory): path_ = directory @@ -443,6 +472,7 @@ def step_the_directory_should_not_exist(context, directory): path_ = os.path.join(context.workdir, os.path.normpath(directory)) assert_that(not os.path.isdir(path_)) + @step(u'the directory "{directory}" exists') def step_directory_exists(context, directory): """ @@ -455,6 +485,7 @@ def step_directory_exists(context, directory): """ step_the_directory_should_exist(context, directory) + @step(u'the directory "{directory}" does not exist') def step_directory_named_does_not_exist(context, directory): """ @@ -467,6 +498,7 @@ def step_directory_named_does_not_exist(context, directory): """ step_the_directory_should_not_exist(context, directory) + # ----------------------------------------------------------------------------- # FILE STEPS: # ----------------------------------------------------------------------------- @@ -482,6 +514,7 @@ def step_file_named_filename_exists(context, filename): """ step_file_named_filename_should_exist(context, filename) + @step(u'a file named "{filename}" does not exist') @step(u'the file named "{filename}" does not exist') def step_file_named_filename_does_not_exist(context, filename): @@ -495,18 +528,21 @@ def step_file_named_filename_does_not_exist(context, filename): """ step_file_named_filename_should_not_exist(context, filename) + @then(u'a file named "{filename}" should exist') def step_file_named_filename_should_exist(context, filename): command_util.ensure_workdir_exists(context) filename_ = pathutil.realpath_with_context(filename, context) assert_that(os.path.exists(filename_) and os.path.isfile(filename_)) + @then(u'a file named "{filename}" should not exist') def step_file_named_filename_should_not_exist(context, filename): command_util.ensure_workdir_exists(context) filename_ = pathutil.realpath_with_context(filename, context) assert_that(not os.path.exists(filename_)) + @step(u'I remove the file "{filename}"') def step_remove_file(context, filename): path_ = filename @@ -567,6 +603,7 @@ def step_I_set_the_environment_variable_to(context, env_name, env_value): context.environ[env_name] = env_value os.environ[env_name] = env_value + @step(u'I remove the environment variable "{env_name}"') def step_I_remove_the_environment_variable(context, env_name): if not hasattr(context, "environ"): diff --git a/behave4cmd0/textutil.py b/behave4cmd0/textutil.py index 7f04dc27c..c62845881 100644 --- a/behave4cmd0/textutil.py +++ b/behave4cmd0/textutil.py @@ -171,6 +171,7 @@ def text_remove_empty_lines(text): lines = [ line.rstrip() for line in text.splitlines() if line.strip() ] return "\n".join(lines) + def text_normalize(text): """ Whitespace normalization: @@ -187,6 +188,11 @@ def text_normalize(text): lines = [ line.strip() for line in text.splitlines() if line.strip() ] return "\n".join(lines) + +def text_normalize_line_endings(text): + return text.replace("\r\n", "\n") + + # ----------------------------------------------------------------------------- # ASSERTIONS: # ----------------------------------------------------------------------------- @@ -230,34 +236,52 @@ def contains_substring_multiple_times(substring, expected_count): def assert_text_should_equal(actual_text, expected_text): assert_that(actual_text, equal_to(expected_text)) + def assert_text_should_not_equal(actual_text, expected_text): assert_that(actual_text, is_not(equal_to(expected_text))) def assert_text_should_contain_exactly(text, expected_part): + text = text_normalize_line_endings(text) + expected_part = text_normalize_line_endings(expected_part) assert_that(text, contains_string(expected_part)) + def assert_text_should_not_contain_exactly(text, expected_part): + text = text_normalize_line_endings(text) + expected_part = text_normalize_line_endings(expected_part) assert_that(text, is_not(contains_string(expected_part))) + def assert_text_should_contain(text, expected_part): + text = text_normalize_line_endings(text) + expected_part = text_normalize_line_endings(expected_part) assert_that(text, contains_string(expected_part)) -def assert_normtext_should_contain_multiple_times(text, expected_text, count): - assert_that(text, contains_substring_multiple_times(expected_text, count)) + +def assert_normtext_should_contain_multiple_times(text, expected_text_part, count): + text = text_normalize(text.strip()) + expected_text_part = text_normalize(expected_text_part.strip()) + assert_that(text, contains_substring_multiple_times(expected_text_part, count)) + def assert_text_should_not_contain(text, unexpected_part): + text = text_normalize_line_endings(text) + unexpected_part = text_normalize_line_endings(unexpected_part) assert_that(text, is_not(contains_string(unexpected_part))) + def assert_normtext_should_equal(actual_text, expected_text): expected_text2 = text_normalize(expected_text.strip()) actual_text2 = text_normalize(actual_text.strip()) assert_that(actual_text2, equal_to(expected_text2)) + def assert_normtext_should_not_equal(actual_text, expected_text): expected_text2 = text_normalize(expected_text.strip()) actual_text2 = text_normalize(actual_text.strip()) assert_that(actual_text2, is_not(equal_to(expected_text2))) + def assert_normtext_should_contain(text, expected_part): expected_part2 = text_normalize(expected_part) actual_text = text_normalize(text.strip()) @@ -266,6 +290,7 @@ def assert_normtext_should_contain(text, expected_part): print("actual:\n{0}".format(actual_text)) assert_text_should_contain(actual_text, expected_part2) + def assert_normtext_should_not_contain(text, unexpected_part): unexpected_part2 = text_normalize(unexpected_part) actual_text = text_normalize(text.strip()) diff --git a/features/i18n_emoji.feature b/features/i18n_emoji.feature index db23ac2cd..43ca99f44 100644 --- a/features/i18n_emoji.feature +++ b/features/i18n_emoji.feature @@ -1,6 +1,10 @@ # language: em # SOURCE: https://github.com/cucumber/cucumber/blob/master/gherkin/testdata/good/i18n_emoji.feature +# HINT: +# Temporarily disabled on os=win32 (Windows) until unicode encoding issues are fixed. +# Try with environment variable: PYTHONUTF8=1 +@not.with_os=win32 📚: 🙈🙉🙊 📕: 💃 From bc7cec48d9bee1b056dc77f8da9e20ed9576b9b8 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 22 Oct 2022 20:01:16 +0200 Subject: [PATCH 050/240] UPDATE: i18n * Pull/update current gherkin-languages.json from Cucumber repository * Update "behave.i18n" module from JSON file --- behave/i18n.py | 14 ++++++++++---- etc/gherkin/gherkin-languages.json | 16 ++++++++++------ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/behave/i18n.py b/behave/i18n.py index 708e71593..3aa8a2671 100644 --- a/behave/i18n.py +++ b/behave/i18n.py @@ -299,7 +299,13 @@ 'Tha the ', 'Þa þe ', 'Ða ðe '], - 'when': ['* ', 'Tha ', 'Þa ', 'Ða ']}, + 'when': ['* ', + 'Bæþsealf ', + 'Bæþsealfa ', + 'Bæþsealfe ', + 'Ciricæw ', + 'Ciricæwe ', + 'Ciricæwa ']}, 'en-pirate': {'and': ['* ', 'Aye '], 'background': ['Yo-ho-ho'], 'but': ['* ', 'Avast! '], @@ -735,7 +741,7 @@ 'scenario_outline': ['परिदृश्य रूपरेखा'], 'then': ['* ', 'मग ', 'तेव्हा '], 'when': ['* ', 'जेव्हा ']}, - 'ne': {'and': ['* ', 'र ', 'अनी '], + 'ne': {'and': ['* ', 'र ', 'अनि '], 'background': ['पृष्ठभूमी'], 'but': ['* ', 'तर '], 'examples': ['उदाहरण', 'उदाहरणहरु'], @@ -1036,7 +1042,7 @@ 'but': ['* ', 'Лекин ', 'Бирок ', 'Аммо '], 'examples': ['Мисоллар'], 'feature': ['Функционал'], - 'given': ['* ', 'Агар '], + 'given': ['* ', 'Belgilangan '], 'name': 'Uzbek', 'native': 'Узбекча', 'rule': ['Rule'], @@ -1065,7 +1071,7 @@ 'given': ['* ', '假如', '假设', '假定'], 'name': 'Chinese simplified', 'native': '简体中文', - 'rule': ['Rule'], + 'rule': ['Rule', '规则'], 'scenario': ['场景', '剧本'], 'scenario_outline': ['场景大纲', '剧本大纲'], 'then': ['* ', '那么'], diff --git a/etc/gherkin/gherkin-languages.json b/etc/gherkin/gherkin-languages.json index 83f0559d9..14b52117d 100644 --- a/etc/gherkin/gherkin-languages.json +++ b/etc/gherkin/gherkin-languages.json @@ -967,9 +967,12 @@ ], "when": [ "* ", - "Tha ", - "Þa ", - "Ða " + "Bæþsealf ", + "Bæþsealfa ", + "Bæþsealfe ", + "Ciricæw ", + "Ciricæwe ", + "Ciricæwa " ] }, "en-pirate": { @@ -2395,7 +2398,7 @@ "and": [ "* ", "र ", - "अनी " + "अनि " ], "background": [ "पृष्ठभूमी" @@ -3459,7 +3462,7 @@ ], "given": [ "* ", - "Агар " + "Belgilangan " ], "name": "Uzbek", "native": "Узбекча", @@ -3555,7 +3558,8 @@ "name": "Chinese simplified", "native": "简体中文", "rule": [ - "Rule" + "Rule", + "规则" ], "scenario": [ "场景", From 3c8a067e58c58ca51f33a7434811e13284c43652 Mon Sep 17 00:00:00 2001 From: Alexander Klyuev Date: Tue, 22 Oct 2019 13:14:22 +0300 Subject: [PATCH 051/240] Fix junit JUnit XML outpu. Fixes #510 1. Escape 'invalid-xml-characters' with their U+0000 form 2. Escape ']]>' with ']]>' --- behave/reporter/junit.py | 43 ++++++++++++++++++++++-- issue.features/issue0510.feature | 56 ++++++++++++++++++++++++++++++-- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/behave/reporter/junit.py b/behave/reporter/junit.py index da54bcaa6..ab4740d5e 100644 --- a/behave/reporter/junit.py +++ b/behave/reporter/junit.py @@ -72,6 +72,8 @@ from __future__ import absolute_import import os.path import codecs +import re +import sys from xml.etree import ElementTree from datetime import datetime from behave.reporter.base import Reporter @@ -87,6 +89,7 @@ import traceback2 as traceback else: import traceback + unichr = chr def CDATA(text=None): # pylint: disable=invalid-name @@ -95,6 +98,42 @@ def CDATA(text=None): # pylint: disable=invalid-name element.text = ansi_escapes.strip_escapes(text) return element +def _compile_invalid_re(): + # http://stackoverflow.com/questions/1707890/fast-way-to-filter-illegal-xml-unicode-chars-in-python + illegal_unichrs = [ + (0x00, 0x08), (0x0B, 0x1F), (0x7F, 0x84), (0x86, 0x9F), + (0xD800, 0xDFFF), (0xFDD0, 0xFDDF), (0xFFFE, 0xFFFF), + (0x1FFFE, 0x1FFFF), (0x2FFFE, 0x2FFFF), (0x3FFFE, 0x3FFFF), + (0x4FFFE, 0x4FFFF), (0x5FFFE, 0x5FFFF), (0x6FFFE, 0x6FFFF), + (0x7FFFE, 0x7FFFF), (0x8FFFE, 0x8FFFF), (0x9FFFE, 0x9FFFF), + (0xAFFFE, 0xAFFFF), (0xBFFFE, 0xBFFFF), (0xCFFFE, 0xCFFFF), + (0xDFFFE, 0xDFFFF), (0xEFFFE, 0xEFFFF), (0xFFFFE, 0xFFFFF), + (0x10FFFE, 0x10FFFF), + ] + + illegal_ranges = [ + "%s-%s" % (unichr(low), unichr(high)) + for (low, high) in illegal_unichrs + if low < sys.maxunicode] + + return re.compile(u'[%s]' % u''.join(illegal_ranges)) + + +_invalid_re = _compile_invalid_re() + +def _escape_invalid_xml_chars(text): + # replace invalid chars with Unicode hex + return _invalid_re.subn(lambda c: u'U+{0:0=4}'.format(ord(c.group())), text)[0] + + +def escape_CDATA(text): # pylint: disable=invalid-name + # -- issue #510 escape text in CDATA + # CDATA cannot contain the string "]]>" anywhere in the XML document. + if not text: + return text + text = text.replace(u']]>', u']]>') + return _escape_invalid_xml_chars(text) + class ElementTreeWithCDATA(ElementTree.ElementTree): # pylint: disable=redefined-builtin, no-member @@ -114,7 +153,7 @@ def _serialize_xml2(write, elem, encoding, qnames, namespaces, orig=ElementTree._serialize_xml): if elem.tag == '![CDATA[': write("\n<%s%s]]>\n" % \ - (elem.tag, elem.text.encode(encoding, "xmlcharrefreplace"))) + (elem.tag, escape_CDATA(elem.text).encode(encoding, "xmlcharrefreplace"))) return return orig(write, elem, encoding, qnames, namespaces) @@ -123,7 +162,7 @@ def _serialize_xml3(write, elem, qnames, namespaces, orig=ElementTree._serialize_xml): if elem.tag == '![CDATA[': write("\n<{tag}{text}]]>\n".format( - tag=elem.tag, text=elem.text)) + tag=elem.tag, text=escape_CDATA(elem.text))) return if short_empty_elements: # python >=3.3 diff --git a/issue.features/issue0510.feature b/issue.features/issue0510.feature index f9e93e1c2..f174545b3 100644 --- a/issue.features/issue0510.feature +++ b/issue.features/issue0510.feature @@ -1,6 +1,5 @@ @issue @junit -@wip Feature: Issue #510 -- JUnit XML output is not well-formed (in some cases) . Special control characters in JUnit stdout/stderr sections @@ -12,12 +11,24 @@ Feature: Issue #510 -- JUnit XML output is not well-formed (in some cases) . Char ::= #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF] . /* any Unicode character, excluding the surrogate blocks, FFFE, and FFFF. */ . - . [XML-charsets] "The normative reference is XML 1.0 (Fifth Edition), + . [XML-charsets] The normative reference is XML 1.0 (Fifth Edition), . section 2.2, https://www.w3.org/TR/REC-xml/#charsets + . + . Within a CDATA section, only the CDEnd string is recognized as markup, + . so that left angle brackets and ampersands may occur in their literal form; + . they need not (and cannot) be escaped using " < " and " & ". + . CDATA sections cannot nest. + . + . CDSect ::= CDStart CData CDEnd + . CDStart ::= '' Char*)) + . CDEnd ::= ']]>' + . + . [CDATA Sections] https://www.w3.org/TR/REC-xml/#sec-cdata-sect + . @use.with_xmllint=yes - @xfail Scenario: Given a new working directory And a file named "features/steps/special_char_steps.py" with: @@ -49,3 +60,42 @@ Feature: Issue #510 -- JUnit XML output is not well-formed (in some cases) reports/TESTS-special_char.xml:12: parser error : PCDATA invalid Char value 4 """ And note that "xmllint reports additional correlated errors" + + @use.with_xmllint=yes + Scenario: + Given a new working directory + And a file named "features/steps/cdata_end.py" with: + """ + # -*- coding: UTF-8 -*- + from __future__ import print_function + from behave import step + import logging + + @step(u'we print ]]>') + def step_cdata_end(context): + print(u"]]>") + + @step(u'we log ]]>') + def step_log_cdata_end(context): + logging.warning(u"]]>") + """ + And a file named "features/cdata_end.feature" with: + """ + Feature: A CDATA end + Scenario: Print and log CDATA end + When we print ]]> + And we log ]]> + """ + When I run "behave --junit features/cdata_end.feature" + Then it should pass with: + """ + 1 scenario passed, 0 failed, 0 skipped + """ + When I run "xmllint reports/TESTS-cdata_end.xml" + Then it should pass + And the command output should not contain "parser error" + And the command output should not contain: + """ + reports/TESTS-cdata_end.xml:6: parser error : Sequence ']]>' not allowed in content + """ + And note that "xmllint reports additional correlated errors" From f50433822a1c0d7255083b03db927a78fe423f3c Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 24 Oct 2022 23:33:44 +0200 Subject: [PATCH 052/240] FIX: asyncio.get_event_loop() is DEPRECATED warnings * Function is deprecated since: Python >= 3.10 * USED BY: async steps --- behave/api/async_step.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/behave/api/async_step.py b/behave/api/async_step.py index 939452735..4893b7782 100644 --- a/behave/api/async_step.py +++ b/behave/api/async_step.py @@ -47,6 +47,7 @@ def step_async_step_waits_seconds2(context, duration): # -- REQUIRES: Python >= 3.4 # MAYBE BACKPORT: trollius import functools +import sys from six import string_types try: import asyncio @@ -54,6 +55,8 @@ def step_async_step_waits_seconds2(context, duration): except ImportError: has_asyncio = False +_PYTHON_VERSION = sys.version_info[:2] + # ----------------------------------------------------------------------------- # ASYNC STEP DECORATORS: @@ -117,7 +120,12 @@ def step_decorator(astep_func, context, *args, **kwargs): assert isinstance(async_context, AsyncContext) loop = async_context.loop if loop is None: - loop = asyncio.get_event_loop() or asyncio.new_event_loop() + if _PYTHON_VERSION < (3, 10): + # -- DEPRECATED SINCE: Python 3.10 + loop = asyncio.get_event_loop() + if loop is None: + loop = asyncio.new_event_loop() + should_close = True # -- WORKHORSE: try: From 0dbc3071092b48a6f32e4af3ed5a484a02dfbd3e Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 24 Oct 2022 23:42:06 +0200 Subject: [PATCH 053/240] FIX: issue.features/ with Python 3.11 * Add missing activate-tags for Python 3.11 --- issue.features/issue0330.feature | 6 ++++++ issue.features/issue0446.feature | 4 ++++ issue.features/issue0457.feature | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/issue.features/issue0330.feature b/issue.features/issue0330.feature index 56ac23838..692171664 100644 --- a/issue.features/issue0330.feature +++ b/issue.features/issue0330.feature @@ -73,6 +73,7 @@ Feature: Issue #330: Skipped scenarios are included in junit reports when --no-s @not.with_python.version=3.8 @not.with_python.version=3.9 @not.with_python.version=3.10 + @not.with_python.version=3.11 Scenario: Junit report for skipped feature is created with --show-skipped (py.version < 3.8) When I run "behave --junit -t @tag1 --show-skipped @alice_and_bob.featureset" Then it should pass with: @@ -89,6 +90,7 @@ Feature: Issue #330: Skipped scenarios are included in junit reports when --no-s @use.with_python.version=3.8 @use.with_python.version=3.9 @use.with_python.version=3.10 + @use.with_python.version=3.11 Scenario: Junit report for skipped feature is created with --show-skipped (py.version >= 3.8) When I run "behave --junit -t @tag1 --show-skipped @alice_and_bob.featureset" Then it should pass with: @@ -107,6 +109,7 @@ Feature: Issue #330: Skipped scenarios are included in junit reports when --no-s @not.with_python.version=3.8 @not.with_python.version=3.9 @not.with_python.version=3.10 + @not.with_python.version=3.11 Scenario: Junit report for skipped scenario is neither shown nor counted with --no-skipped (py.version < 3.8) When I run "behave --junit -t @tag1 --no-skipped" Then it should pass with: @@ -129,6 +132,7 @@ Feature: Issue #330: Skipped scenarios are included in junit reports when --no-s @use.with_python.version=3.8 @use.with_python.version=3.9 @use.with_python.version=3.10 + @use.with_python.version=3.11 Scenario: Junit report for skipped scenario is neither shown nor counted with --no-skipped (py.version >= 3.8) When I run "behave --junit -t @tag1 --no-skipped" Then it should pass with: @@ -154,6 +158,7 @@ Feature: Issue #330: Skipped scenarios are included in junit reports when --no-s @not.with_python.version=3.8 @not.with_python.version=3.9 @not.with_python.version=3.10 + @not.with_python.version=3.11 Scenario: Junit report for skipped scenario is shown and counted with --show-skipped (py.version < 3.8) When I run "behave --junit -t @tag1 --show-skipped" Then it should pass with: @@ -177,6 +182,7 @@ Feature: Issue #330: Skipped scenarios are included in junit reports when --no-s @use.with_python.version=3.8 @use.with_python.version=3.9 @use.with_python.version=3.10 + @use.with_python.version=3.11 Scenario: Junit report for skipped scenario is shown and counted with --show-skipped (py.version >= 3.8) When I run "behave --junit -t @tag1 --show-skipped" Then it should pass with: diff --git a/issue.features/issue0446.feature b/issue.features/issue0446.feature index d7db76480..6309dade5 100644 --- a/issue.features/issue0446.feature +++ b/issue.features/issue0446.feature @@ -61,6 +61,7 @@ Feature: Issue #446 -- Support scenario hook-errors with JUnitReporter @not.with_python.version=3.8 @not.with_python.version=3.9 @not.with_python.version=3.10 + @not.with_python.version=3.11 Scenario: Hook error in before_scenario() (py.version < 3.8) When I run "behave -f plain --junit features/before_scenario_failure.feature" Then it should fail with: @@ -92,6 +93,7 @@ Feature: Issue #446 -- Support scenario hook-errors with JUnitReporter @use.with_python.version=3.8 @use.with_python.version=3.9 @use.with_python.version=3.10 + @use.with_python.version=3.11 Scenario: Hook error in before_scenario() (py.version >= 3.8) When I run "behave -f plain --junit features/before_scenario_failure.feature" Then it should fail with: @@ -127,6 +129,7 @@ Feature: Issue #446 -- Support scenario hook-errors with JUnitReporter @not.with_python.version=3.8 @not.with_python.version=3.9 @not.with_python.version=3.10 + @not.with_python.version=3.11 Scenario: Hook error in after_scenario() (py.version < 3.8) When I run "behave -f plain --junit features/after_scenario_failure.feature" Then it should fail with: @@ -160,6 +163,7 @@ Feature: Issue #446 -- Support scenario hook-errors with JUnitReporter @use.with_python.version=3.8 @use.with_python.version=3.9 @use.with_python.version=3.10 + @use.with_python.version=3.11 Scenario: Hook error in after_scenario() (py.version >= 3.8) When I run "behave -f plain --junit features/after_scenario_failure.feature" Then it should fail with: diff --git a/issue.features/issue0457.feature b/issue.features/issue0457.feature index c14c7a40c..41997f580 100644 --- a/issue.features/issue0457.feature +++ b/issue.features/issue0457.feature @@ -27,6 +27,7 @@ Feature: Issue #457 -- Double-quotes in error messages of JUnit XML reports @not.with_python.version=3.8 @not.with_python.version=3.9 @not.with_python.version=3.10 + @not.with_python.version=3.11 Scenario: Use failing assertation in a JUnit XML report (py.version < 3.8) Given a file named "features/fails1.feature" with: """ @@ -50,6 +51,7 @@ Feature: Issue #457 -- Double-quotes in error messages of JUnit XML reports @use.with_python.version=3.8 @use.with_python.version=3.9 @use.with_python.version=3.10 + @use.with_python.version=3.11 Scenario: Use failing assertation in a JUnit XML report (py.version >= 3.8) Given a file named "features/fails1.feature" with: """ @@ -76,6 +78,7 @@ Feature: Issue #457 -- Double-quotes in error messages of JUnit XML reports @not.with_python.version=3.8 @not.with_python.version=3.9 @not.with_python.version=3.10 + @not.with_python.version=3.11 Scenario: Use exception in a JUnit XML report (py.version < 3.8) Given a file named "features/fails2.feature" with: """ @@ -100,6 +103,7 @@ Feature: Issue #457 -- Double-quotes in error messages of JUnit XML reports @use.with_python.version=3.8 @use.with_python.version=3.9 @use.with_python.version=3.10 + @use.with_python.version=3.11 Scenario: Use exception in a JUnit XML report (py.version >= 3.8) Given a file named "features/fails2.feature" with: """ From a7fc535d26a0f40647ee6364f5cc61979a31c911 Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 24 Oct 2022 23:51:06 +0200 Subject: [PATCH 054/240] UPDATE: Github CI actions versions * tests: setup-python@v4 (was: v3) * CodeQL: init/autobuild/analyze@v2 (was: v1) --- .github/workflows/codeql-analysis.yml | 6 +++--- .github/workflows/tests-windows.yml | 5 +++-- .github/workflows/tests.yml | 5 +++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index de682a8f7..6fb4e077c 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -53,7 +53,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v1 + uses: github/codeql-action/autobuild@v2 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -67,4 +67,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index e7cbe896a..c45776392 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -1,5 +1,6 @@ # -- FAST-PROTOTYPING: Tests on Windows -- Until tests are OK. # BASED ON: tests.yml +# SUPPORTED PYTHON VERSIONS: https://github.com/actions/python-versions name: tests-windows on: @@ -19,11 +20,11 @@ jobs: matrix: # PREPARED: python-version: ['3.10', '3.9'] os: [windows-latest] - python-version: ['3.9'] + python-version: ["3.10", "3.9"] steps: - uses: actions/checkout@v3 # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} - - uses: actions/setup-python@v3 + - uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} cache: 'pip' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 88673f56b..0098acaec 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,5 +1,6 @@ # -- SOURCE: https://github.com/marketplace/actions/setup-python # SEE: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python +# SUPPORTED PYTHON VERSIONS: https://github.com/actions/python-versions name: tests on: @@ -18,14 +19,14 @@ jobs: # PREPARED: python-version: ['3.9', '2.7', '3.10', '3.8', 'pypy-2.7', 'pypy-3.8'] # PREPARED: os: [ubuntu-latest, windows-latest] os: [ubuntu-latest] - python-version: ['3.10', '3.9', '2.7'] + python-version: ["3.10", "3.9", "2.7"] exclude: - os: windows-latest python-version: "2.7" steps: - uses: actions/checkout@v3 # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} - - uses: actions/setup-python@v3 + - uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} cache: 'pip' From 8f4419afda515b924a5bfe89372fb3c4df0b42ff Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 30 Oct 2022 18:35:41 +0100 Subject: [PATCH 055/240] ENHANCEMENT: Active-Tag ValueObject NEW CLASS: behave.tag_matcher.ValueObject * Allows the user to specific an optional comparison function, like: equals (default), greater_or_equal, less_or_equal, contains, ... * Supports callable getter-function instead of current.value * Supports type-conversion from "tag.value" as string DERIVED CLASSES: * NumberValueObject, BoolValueObject (in module: behave.tag_matcher) * VersionValueObject (in module: behave.active_tag.python) ACTIVE-TAG MATCHER PROTOCOL (internally used only): * ActiveTagMatcher class uses ValueObject class internally to support that "current_value.matches(tag_value)" can be called independent of the selected comparison function (even if no ValueObject is provided). ACTIVE TAG CHANGES: * ADDED: python.min_version, python.max_version * RENAMED: python.feature.xxx (from: python_has_xxx) OTHERWISE: * Created "behave.active_tag" package for internal active-tag values * Moved "behave.python" to "behave.active_tag.python" * Moved "behave.python_feature" to "behave.active_tag.python_feature" (mostly) --- CHANGES.rst | 3 + behave/active_tag/__init__.py | 0 behave/active_tag/python.py | 70 ++++++++++ behave/active_tag/python_feature.py | 28 ++++ behave/python_feature.py | 27 +--- behave/tag_matcher.py | 130 +++++++++++++++++- docs/new_and_noteworthy_v1.2.7.rst | 111 +++++++++++++++ .../features/async_dispatch.feature | 3 +- .../async_step/features/async_run.feature | 3 +- examples/async_step/features/environment.py | 3 +- features/environment.py | 14 +- features/step.async_steps.feature | 18 +-- issue.features/environment.py | 11 +- issue.features/issue0330.feature | 33 ++--- issue.features/issue0446.feature | 22 +-- issue.features/issue0457.feature | 22 +-- issue.features/issue0657.feature | 2 +- more.features/run_examples.feature | 2 +- tests/unit/test_tag_matcher.py | 109 +++++++++++++++ 19 files changed, 493 insertions(+), 118 deletions(-) create mode 100644 behave/active_tag/__init__.py create mode 100644 behave/active_tag/python.py create mode 100644 behave/active_tag/python_feature.py diff --git a/CHANGES.rst b/CHANGES.rst index f3b15a77a..fa01a42c4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -32,6 +32,8 @@ CLEANUPS: ENHANCEMENTS: +* active-tags: Added ``ValueObject`` class for enhanced control of comparison mechanism + (supports: equals, less-than, less-or-equal, greater-than, greater-or-equal, contains, ...) * Add support for Gherkin v6 grammar and syntax in ``*.feature`` files * Use `cucumber-tag-expressions`_ with tag-matching extension (superceeds: old-style tag-expressions) * Use cucumber "gherkin-languages.json" now (simplify: Gherkin v6 aliases, language usage) @@ -50,6 +52,7 @@ CLARIFICATION: FIXED: +* FIXED: Some tests related to python3.11 * FIXED: Some tests related to python3.9 * FIXED: active-tag logic if multiple tags with same category exists. * issue #1061: Scenario should inherit Rule tags (submitted by: testgitdl) diff --git a/behave/active_tag/__init__.py b/behave/active_tag/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/behave/active_tag/python.py b/behave/active_tag/python.py new file mode 100644 index 000000000..335f32ae8 --- /dev/null +++ b/behave/active_tag/python.py @@ -0,0 +1,70 @@ +# -*- coding: UTF-8 -*- +""" +Supports some active-tags for Python/Python version related functionality. +""" + +from __future__ import absolute_import, print_function +import operator +from platform import python_implementation +import sys +import six +from behave.tag_matcher import ValueObject, BoolValueObject + + +# ----------------------------------------------------------------------------- +# CONSTANTS +# ----------------------------------------------------------------------------- +PYTHON_VERSION = sys.version_info[:2] +PYTHON_VERSION3 = sys.version_info[:3] + + +# ----------------------------------------------------------------------------- +# HELPERS: ValueObjects +# ----------------------------------------------------------------------------- +class VersionValueObject(ValueObject): + """Provides a ValueObject for version comparisons with version-tuples + (as: tuple of numbers). + """ + + def __int__(self, value, compare_func=None): + if isinstance(value, six.string_types): + value = self.to_version_tuple(value) + super(VersionValueObject, self).__init__(value, compare_func) + + def matches(self, tag_value): + try: + tag_version = self.to_version_tuple(tag_value) + return super(VersionValueObject, self).matches(tag_version) + except (TypeError, ValueError) as e: + self.on_type_conversion_error(tag_value, e) + + @staticmethod + def to_version_tuple(version): + if isinstance(version, tuple): + # -- ASSUME: tuple of numbers + return version + elif isinstance(version, six.string_types): + # -- CONVERT: string-to-tuple of numbers + return tuple([int(x) for x in version.split(".")]) + + # -- OTHERWISE: + raise TypeError("Expect: string or tuple") + + +# ----------------------------------------------------------------------------- +# ACTIVE-TAGS +# ----------------------------------------------------------------------------- +ACTIVE_TAG_VALUE_PROVIDER = { + "python2": BoolValueObject(six.PY2), + "python3": BoolValueObject(six.PY3), + "python.version": "%s.%s" % PYTHON_VERSION, + "python.min_version": VersionValueObject(PYTHON_VERSION, operator.ge), + "python.max_version": VersionValueObject(PYTHON_VERSION, operator.le), + + "os": sys.platform.lower(), + "platform": sys.platform, + + # -- python.implementation: cpython, pypy, jython, ironpython + "python.implementation": python_implementation().lower(), + "pypy": BoolValueObject("__pypy__" in sys.modules), +} diff --git a/behave/active_tag/python_feature.py b/behave/active_tag/python_feature.py new file mode 100644 index 000000000..7ef2b4908 --- /dev/null +++ b/behave/active_tag/python_feature.py @@ -0,0 +1,28 @@ +# -*- coding: UTF-8 -*- +""" +Supports some active-tags related Python features (similar to: feature flags). +""" + +from __future__ import absolute_import +from behave.tag_matcher import BoolValueObject +from behave.python_feature import PythonFeature + + +# ----------------------------------------------------------------------------- +# SUPPORTED: ACTIVE-TAGS +# ----------------------------------------------------------------------------- +# -- PYTHON FEATURE, like: @use.with_python.feature.coroutine=yes +ACTIVE_TAG_VALUE_PROVIDER = { + "python.feature.coroutine": BoolValueObject(PythonFeature.has_coroutine()), + "python.feature.asyncio.coroutine_decorator": + BoolValueObject(PythonFeature.has_asyncio_coroutine_decorator()), + "python.feature.async_function": BoolValueObject(PythonFeature.has_async_function()), + "python.feature.async_keyword": BoolValueObject(PythonFeature.has_async_keyword()), + + # -- DEPRECATING (older active-tag names): + "python_has_coroutine": BoolValueObject(PythonFeature.has_coroutine()), + "python_has_asyncio.coroutine_decorator": + BoolValueObject(PythonFeature.has_asyncio_coroutine_decorator()), + "python_has_async_function": BoolValueObject(PythonFeature.has_async_function()), + "python_has_async_keyword": BoolValueObject(PythonFeature.has_async_keyword()), +} diff --git a/behave/python_feature.py b/behave/python_feature.py index 4e134defd..a44153b8d 100644 --- a/behave/python_feature.py +++ b/behave/python_feature.py @@ -6,8 +6,6 @@ from __future__ import absolute_import import sys -import six -from behave.tag_matcher import bool_to_string # ----------------------------------------------------------------------------- @@ -55,27 +53,10 @@ async def async_waits_seconds(duration): """ return (3, 5) <= PYTHON_VERSION + @classmethod + def has_async_keyword(cls): + return cls.has_async_function() + @classmethod def has_coroutine(cls): return cls.has_async_function() or cls.has_asyncio_coroutine_decorator() - - -# ----------------------------------------------------------------------------- -# SUPPORTED: ACTIVE-TAGS -# ----------------------------------------------------------------------------- -PYTHON_HAS_ASYNCIO_COROUTINE_DECORATOR = PythonFeature.has_asyncio_coroutine_decorator() -PYTHON_HAS_ASYNC_FUNCTION = PythonFeature.has_async_function() -PYTHON_HAS_COROUTINE = PythonFeature.has_coroutine() -ACTIVE_TAG_VALUE_PROVIDER = { - "python2": bool_to_string(six.PY2), - "python3": bool_to_string(six.PY3), - "python.version": "%s.%s" % PYTHON_VERSION, - "os": sys.platform.lower(), - - # -- PYTHON FEATURE, like: @use.with_py.feature_asyncio.coroutine - "python_has_coroutine": bool_to_string(PYTHON_HAS_COROUTINE), - "python_has_asyncio.coroutine_decorator": - bool_to_string(PYTHON_HAS_ASYNCIO_COROUTINE_DECORATOR), - "python_has_async_function": bool_to_string(PYTHON_HAS_ASYNC_FUNCTION), - "python_has_async_keyword": bool_to_string(PYTHON_HAS_ASYNC_FUNCTION), -} diff --git a/behave/tag_matcher.py b/behave/tag_matcher.py index 78d706176..dd3359373 100644 --- a/behave/tag_matcher.py +++ b/behave/tag_matcher.py @@ -5,12 +5,129 @@ """ from __future__ import absolute_import, print_function +import logging +import operator import re import six from ._types import Unknown from .compat.collections import UserDict +# ----------------------------------------------------------------------------- +# VALUE OBJECT CLASSES FOR: Active-Tag Value Providers +# ----------------------------------------------------------------------------- +class ValueObject(object): + """Value object for active-tags that holds the current value for + one activate-tag category and its comparison function. + + The :param:`compare_func(current_value, tag_value)` is a predicate function + with two arguments that performs the comparison between the + "current_value" and the "tag_value". + + EXAMPLE:: + + # -- SIMPLIFIED EXAMPLE: + from behave.tag_matcher import ValueObject + import operator # Module contains comparison functions. + class NumberObject(ValueObject): ... # Details left out here. + + xxx_current_value = 42 + active_tag_value_provider = { + "xxx.value": ValueObject(xxx_current_value) # USES: operator.eq (equals) + "xxx.min_value": NumberValueObject(xxx_current_value, operator.ge), + "xxx.max_value": NumberValueObject(xxx_current_value, operator.le), + } + + # -- LATER WITHIN: ActivTag Logic + # EXAMPLE TAG: @use.with_xxx.min_value=10 (schema: "@use.with_{category}={value}") + tag_category = "xxx.min_value" + current_value = active_tag_value_provider.get(tag_category) + if not isinstance(current_value, ValueObject): + current_value = ValueObject(current_value) + ... + tag_matches = current_value.matches(tag_value) + """ + def __init__(self, value, compare=operator.eq): + assert callable(compare) + self._value = value + self.compare = compare + + @property + def value(self): + if callable(self._value): + # -- SUPPORT: Lazy computation of current-value. + return self._value() + # -- OTHERWISE: + return self._value + + def matches(self, tag_value): + """Comparison between current value and :param:`tag_value`. + + :param tag_value: Tag value from active tag (as string). + :return: True, if comparison matches. False, otherwise. + """ + return bool(self.compare(self.value, tag_value)) + + @staticmethod + def on_type_conversion_error(tag_value, e): + logger = logging.getLogger("behave.active_tags") + logger.error("TYPE CONVERSION ERROR: active_tag.value='%s' (error: %s)" % \ + (tag_value, str(e))) + # MAYBE: logger.exception(e) + return False # HINT: mis-matched + + def __str__(self): + """Conversion to string.""" + return str(self.value) + + def __repr__(self): + return "<%s: value=%s, compare=%s>" % \ + (self.__class__.__name__, self.value, self.compare) + + +class NumberValueObject(ValueObject): + def matches(self, tag_value): + try: + tag_number = int(tag_value) + return super(NumberValueObject, self).matches(tag_number) + except ValueError as e: + # -- INTEGER TYPE-CONVERSION ERROR: + return self.on_type_conversion_error(tag_value, e) + + def __int__(self): + """Convert into integer-number value.""" + return int(self.value) + + +class BoolValueObject(ValueObject): + TRUE_STRINGS = set(["true", "yes", "on"]) + FALSE_STRINGS = set(["false", "no", "off"]) + + def matches(self, tag_value): + try: + boolean_tag_value = self.to_bool(tag_value) + return super(BoolValueObject, self).matches(boolean_tag_value) + except ValueError as e: + return self.on_type_conversion_error(tag_value, e) + + def __bool__(self): + """Conversion to boolean value.""" + return bool(self.value) + + @classmethod + def to_bool(cls, value): + if isinstance(value, six.string_types): + text = value.lower() + if text in cls.TRUE_STRINGS: + return True + elif text in cls.FALSE_STRINGS: + return False + else: + raise ValueError("NON-BOOL: %s" % value) + # -- OTHERWISE: + return bool(value) + + # ----------------------------------------------------------------------------- # CLASSES FOR: Active-Tags and ActiveTagMatchers # ----------------------------------------------------------------------------- @@ -223,6 +340,8 @@ def is_tag_group_enabled(self, group_category, group_tag_pairs): if current_value is Unknown and self.ignore_unknown_categories: # -- CASE: Unknown category, ignore it. return True + elif not isinstance(current_value, ValueObject): + current_value = ValueObject(current_value) positive_tags_matched = [] negative_tags_matched = [] @@ -234,11 +353,13 @@ def is_tag_group_enabled(self, group_category, group_tag_pairs): if self.is_tag_negated(tag_prefix): # -- CASE: @not.with_CATEGORY=VALUE - tag_matched = (tag_value == current_value) + # NORMALLY: tag_matched = (current_value == tag_value) + tag_matched = current_value.matches(tag_value) negative_tags_matched.append(tag_matched) else: # -- CASE: @use.with_CATEGORY=VALUE - tag_matched = (tag_value == current_value) + # NORMALLY: tag_matched = (current_value == tag_value) + tag_matched = current_value.matches(tag_value) positive_tags_matched.append(tag_matched) tag_expression1 = any(positive_tags_matched) #< LOGICAL-OR expression tag_expression2 = any(negative_tags_matched) #< LOGICAL-OR expression @@ -411,8 +532,7 @@ def values(self): def items(self): for category in self.keys(): value = self.get(category) - yield (category, value) - + yield category, value # ----------------------------------------------------------------------------- @@ -444,7 +564,7 @@ def print_active_tags(active_tag_value_provider, categories=None): """Print a summary of the current active-tag values.""" if categories is None: try: - categories = list(active_tag_value_provider) + categories = list(active_tag_value_provider.keys()) except TypeError: # TypeError: object is not iterable categories = [] diff --git a/docs/new_and_noteworthy_v1.2.7.rst b/docs/new_and_noteworthy_v1.2.7.rst index 2eb94ade3..7af2c8c66 100644 --- a/docs/new_and_noteworthy_v1.2.7.rst +++ b/docs/new_and_noteworthy_v1.2.7.rst @@ -10,6 +10,7 @@ Summary: * `Select-by-location for Scenario Containers`_ (Feature, Rule, ScenarioOutline) * `Support for emojis in feature files and steps`_ * `Improve Active-Tags Logic`_ +* `Active-Tags: Use ValueObject for better Comparisons`_ .. _`Example Mapping`: https://cucumber.io/blog/example-mapping-introduction/ .. _`Example Mapping Webinar`: https://cucumber.io/blog/example-mapping-webinar/ @@ -197,3 +198,113 @@ EXAMPLE: HINT 1: Only executed with browser: Firefox HINT 2: Only executed on OS: Linux and Darwin (macOS) ... + + +Active-Tags: Use ValueObject for better Comparisons +------------------------------------------------------------------------------- + +The current mechanism of active-tags only supports the ``equals / equal-to`` comparison +mechanism to determine if the ``tag.value`` matches the ``current.value``, like:: + + # -- SCHEMA: "@use.with_{category}={value}" or "@not.with_{category}={value}" + @use.with_browser=Safari # HINT: tag.value = "Safari" + + ACTIVE TAG MATCHES, if: current.value == tag.value (for strings) + +The ``equals`` comparison method is sufficient for many situations. +But in some situations, you want to use other comparison methods. +The ``behave.tag_matcher.ValueObject`` class was added to allow +the user to provide an own comparison method (and type conversion support). + +**EXAMPLE 1:** + +.. code:: gherkin + + Feature: Active-Tag Example 1 with ValueObject + + @use.with_temperatur.min_value=15 + Scenario: Only run if temperature >= 15 degrees Celcius + ... + +.. code:: python + + # -- FILE: features/environment.py + import operator + from behave.tag_matcher import ActiveTagMatcher, ValueObject + from my_system.sensors import Sensors + + # -- SIMPLIFIED: Better use behave.tag_matcher.NumberValueObject + # CONSTRUCTOR: ValueObject(value, compare=operator.eq) + # HINT: Parameter "value" can be a getter-function (w/o args). + class NumberValueObject(ValueObject): + def matches(self, tag_value): + tag_number = int(tag_value) + return self.compare(self.value, tag_number) + + current_temperature = Sensors().get_temperature() + active_tag_value_provider = { + # -- COMPARISON: + # temperature.value: current.value == tag.value -- DEFAULT: equals (eq) + # temperature.min_value: current.value >= tag.value -- greater_or_equal (ge) + "temperature.value": NumberValueObject(current_temperature), + "temperature.min_value": NumberValueObject(current_temperature, operator.ge), + } + active_tag_matcher = ActiveTagMatcher(active_tag_value_provider) + + # -- HOOKS SETUP FOR ACTIVE-TAGS: ... (omitted here) + + +**EXAMPLE 2:** + +A slightly more complex situation arises, if you need to constrain the +execution of an scenario to a temperature range, like: + +.. code:: gherkin + + Feature: Active-Tag Example 2 with Value Range + + @use.with_temperature.min_value=10 + @use.with_temperature.max_value=70 + Scenario: Only run if temperature is between 10 and 70 degrees Celcius + ... + +.. code:: python + + # -- FILE: features/environment.py + ... + current_temperature = get_temperature() # RETURNS: integer-number in Celcius. + active_tag_value_provider = { + # -- COMPARISON: + # temperature.min_value: current.value >= tag.value + # temperature.max_value: current.value <= tag.value + "temperature.min_value": NumberValueObject(current_temperature, operator.ge), + "temperature.max_value": NumberValueObject(current_temperature, operator.le), + } + ... + +**EXAMPLE 3:** + +.. code:: gherkin + + Feature: Active-Tag Example 3 with Contains/Contained-in Comparison + + @use.with_supported_payment_method=VISA + Scenario: Only run if VISA is one of the supported payment methods + ... + + # OR: @use.with_supported_payment_methods.contains_value=VISA + +.. code:: python + + # -- FILE: features/environment.py + # NORMALLY: + # from my_system.payment import get_supported_payment_methods + # payment_methods = get_supported_payment_methods() + ... + payment_methods = ["VISA", "MasterCard", "paycheck"] + active_tag_value_provider = { + # -- COMPARISON: + # supported_payment_method: current.value contains tag.value + "supported_payment_method": ValueObject(payment_methods, operator.contains), + } + ... diff --git a/examples/async_step/features/async_dispatch.feature b/examples/async_step/features/async_dispatch.feature index e0eba1e3a..5a403e082 100644 --- a/examples/async_step/features/async_dispatch.feature +++ b/examples/async_step/features/async_dispatch.feature @@ -1,5 +1,4 @@ -@use.with_python_has_async_function=true -@use.with_python_has_asyncio.coroutine_decorator=true +@use.with_python.feature.coroutine=true Feature: Scenario: Given I dispatch an async-call with param "Alice" diff --git a/examples/async_step/features/async_run.feature b/examples/async_step/features/async_run.feature index 8b6e55538..14a23f1ea 100644 --- a/examples/async_step/features/async_run.feature +++ b/examples/async_step/features/async_run.feature @@ -1,5 +1,4 @@ -@use.with_python_has_async_function=true -@use.with_python_has_asyncio.coroutine_decorator=true +@use.with_python.feature.coroutine=true Feature: Scenario: Given an async-step waits 0.3 seconds diff --git a/examples/async_step/features/environment.py b/examples/async_step/features/environment.py index 3fa960453..c285f4a2b 100644 --- a/examples/async_step/features/environment.py +++ b/examples/async_step/features/environment.py @@ -2,8 +2,7 @@ from behave.tag_matcher import ActiveTagMatcher, setup_active_tag_values from behave.api.runtime_constraint import require_min_python_version -from behave import python_feature - +from behave.active_tag import python_feature # ----------------------------------------------------------------------------- # REQUIRE: python >= 3.4 diff --git a/features/environment.py b/features/environment.py index 2ac096005..cdc165a57 100644 --- a/features/environment.py +++ b/features/environment.py @@ -5,19 +5,15 @@ from behave.tag_matcher import \ ActiveTagMatcher, setup_active_tag_values, print_active_tags from behave4cmd0.setup_command_shell import setup_command_shell_processors4behave -from behave import python_feature -import platform -import sys +import behave.active_tag.python +import behave.active_tag.python_feature # -- MATCHES ANY TAGS: @use.with_{category}={value} # NOTE: active_tag_value_provider provides category values for active tags. -active_tag_value_provider = { - # -- python.implementation: cpython, pypy, jython, ironpython - "python.implementation": platform.python_implementation().lower(), - "pypy": str("__pypy__" in sys.modules).lower(), -} -active_tag_value_provider.update(python_feature.ACTIVE_TAG_VALUE_PROVIDER) +active_tag_value_provider = {} +active_tag_value_provider.update(behave.active_tag.python.ACTIVE_TAG_VALUE_PROVIDER) +active_tag_value_provider.update(behave.active_tag.python_feature.ACTIVE_TAG_VALUE_PROVIDER) active_tag_matcher = ActiveTagMatcher(active_tag_value_provider) diff --git a/features/step.async_steps.feature b/features/step.async_steps.feature index 5919397f4..ea5be6b1b 100644 --- a/features/step.async_steps.feature +++ b/features/step.async_steps.feature @@ -1,5 +1,5 @@ @not.with_python2=true -@use.with_python_has_coroutine=true +@use.with_python.feature.coroutine=true Feature: Async-Test Support (async-step, ...) As a test writer and step provider @@ -14,7 +14,7 @@ Feature: Async-Test Support (async-step, ...) . . TERMINOLOGY: async-step . An async-step is either - . * an async-function as coroutine using async/await keywords (Python 3.5) + . * an async-function as coroutine using async/await keywords (Python >= 3.5) . * an async-function tagged with @asyncio.coroutine and using "yield from" . . # -- EXAMPLE CASE 1 (since Python 3.5; preferred): @@ -31,7 +31,7 @@ Feature: Async-Test Support (async-step, ...) . The async-step can directly interact with other async-functions. - @use.with_python_has_async_function=true + @use.with_python.feature.async_keyword=true Scenario: Use async-step with @async_run_until_complete (async; requires: py.version >= 3.5) Given a new working directory And a file named "features/steps/async_steps35.py" with: @@ -60,7 +60,7 @@ Feature: Async-Test Support (async-step, ...) """ - @use.with_python_has_asyncio.coroutine_decorator=true + @use.with_python.feature.asyncio.coroutine_decorator=true Scenario: Use async-step with @async_run_until_complete (@asyncio.coroutine) Given a new working directory And a file named "features/steps/async_steps34.py" with: @@ -89,7 +89,7 @@ Feature: Async-Test Support (async-step, ...) Given an async-step waits 0.3 seconds ... passed in 0.3 """ - @use.with_python_has_async_function=true + @use.with_python.feature.async_keyword=true Scenario: Use @async_run_until_complete(timeout=...) and TIMEOUT occurs (async-function) Given a new working directory And a file named "features/steps/async_steps_timeout35.py" with: @@ -123,7 +123,7 @@ Feature: Async-Test Support (async-step, ...) Assertion Failed: TIMEOUT-OCCURED: timeout=0.1 """ - @use.with_python_has_async_function=true + @use.with_python.feature.async_keyword=true @async_step_fails Scenario: Use @async_run_until_complete and async-step fails (async-function) Given a new working directory @@ -164,7 +164,7 @@ Feature: Async-Test Support (async-step, ...) Assertion Failed: XFAIL in async-step """ - @use.with_python_has_async_function=true + @use.with_python.feature.async_keyword=true @async_step_fails Scenario: Use @async_run_until_complete and async-step raises error (async-function) Given a new working directory @@ -205,7 +205,7 @@ Feature: Async-Test Support (async-step, ...) raise RuntimeError("XFAIL in async-step") """ - @use.with_python_has_asyncio.coroutine_decorator=true + @use.with_python.feature.asyncio.coroutine_decorator=true Scenario: Use @async_run_until_complete(timeout=...) and TIMEOUT occurs (@asyncio.coroutine) Given a new working directory And a file named "features/steps/async_steps_timeout34.py" with: @@ -240,7 +240,7 @@ Feature: Async-Test Support (async-step, ...) Assertion Failed: TIMEOUT-OCCURED: timeout=0.2 """ - @use.with_python_has_asyncio.coroutine_decorator=true + @use.with_python.feature.asyncio.coroutine_decorator=true Scenario: Use async-dispatch and async-collect concepts (@asyncio.coroutine) Given a new working directory And a file named "features/steps/async_dispatch_steps.py" with: diff --git a/issue.features/environment.py b/issue.features/environment.py index ecb65153b..69542fdc1 100644 --- a/issue.features/environment.py +++ b/issue.features/environment.py @@ -13,6 +13,8 @@ import platform import os.path import six +from behave.active_tag.python import \ + ACTIVE_TAG_VALUE_PROVIDER as ACTIVE_TAG_VALUE_PROVIDER4PYTHON from behave.tag_matcher import ActiveTagMatcher, print_active_tags from behave4cmd0.setup_command_shell import setup_command_shell_processors4behave # PREPARED: from behave.tag_matcher import setup_active_tag_values @@ -79,17 +81,10 @@ def discover_ci_server(): # NOTE: active_tag_value_provider provides category values for active tags. python_version = "%s.%s" % sys.version_info[:2] active_tag_value_provider = { - "platform": sys.platform, - "python2": str(six.PY2).lower(), - "python3": str(six.PY3).lower(), - "python.version": python_version, - # -- python.implementation: cpython, pypy, jython, ironpython - "python.implementation": platform.python_implementation().lower(), - "pypy": str("__pypy__" in sys.modules).lower(), - "os": sys.platform, "xmllint": as_bool_string(require_tool("xmllint")), "ci": discover_ci_server() } +active_tag_value_provider.update(ACTIVE_TAG_VALUE_PROVIDER4PYTHON) active_tag_matcher = ActiveTagMatcher(active_tag_value_provider) diff --git a/issue.features/issue0330.feature b/issue.features/issue0330.feature index 692171664..2bb507857 100644 --- a/issue.features/issue0330.feature +++ b/issue.features/issue0330.feature @@ -70,10 +70,8 @@ Feature: Issue #330: Skipped scenarios are included in junit reports when --no-s And note that "bob.feature is skipped" - @not.with_python.version=3.8 - @not.with_python.version=3.9 - @not.with_python.version=3.10 - @not.with_python.version=3.11 + # -- SIMILAR TO: @use.with_python.max_version=3.7 + @not.with_python.min_version=3.8 Scenario: Junit report for skipped feature is created with --show-skipped (py.version < 3.8) When I run "behave --junit -t @tag1 --show-skipped @alice_and_bob.featureset" Then it should pass with: @@ -87,10 +85,7 @@ Feature: Issue #330: Skipped scenarios are included in junit reports when --no-s """ - @use.with_python.version=3.8 - @use.with_python.version=3.9 - @use.with_python.version=3.10 - @use.with_python.version=3.11 + @use.with_python.min_version=3.8 Scenario: Junit report for skipped feature is created with --show-skipped (py.version >= 3.8) When I run "behave --junit -t @tag1 --show-skipped @alice_and_bob.featureset" Then it should pass with: @@ -106,10 +101,8 @@ Feature: Issue #330: Skipped scenarios are included in junit reports when --no-s # -- HINT FOR: Python < 3.8 # - @not.with_python.version=3.8 - @not.with_python.version=3.9 - @not.with_python.version=3.10 - @not.with_python.version=3.11 + # -- SIMILAR TO: @use.with_python.max_version=3.7 + @not.with_python.min_version=3.8 Scenario: Junit report for skipped scenario is neither shown nor counted with --no-skipped (py.version < 3.8) When I run "behave --junit -t @tag1 --no-skipped" Then it should pass with: @@ -129,10 +122,7 @@ Feature: Issue #330: Skipped scenarios are included in junit reports when --no-s """ And note that "Charly2 is the skipped scenarion in charly.feature" - @use.with_python.version=3.8 - @use.with_python.version=3.9 - @use.with_python.version=3.10 - @use.with_python.version=3.11 + @use.with_python.min_version=3.8 Scenario: Junit report for skipped scenario is neither shown nor counted with --no-skipped (py.version >= 3.8) When I run "behave --junit -t @tag1 --no-skipped" Then it should pass with: @@ -155,10 +145,8 @@ Feature: Issue #330: Skipped scenarios are included in junit reports when --no-s And note that "Charly2 is the skipped scenarion in charly.feature" - @not.with_python.version=3.8 - @not.with_python.version=3.9 - @not.with_python.version=3.10 - @not.with_python.version=3.11 + # -- SIMILAR TO: @use.with_python.max_version=3.7 + @not.with_python.min_version=3.8 Scenario: Junit report for skipped scenario is shown and counted with --show-skipped (py.version < 3.8) When I run "behave --junit -t @tag1 --show-skipped" Then it should pass with: @@ -179,10 +167,7 @@ Feature: Issue #330: Skipped scenarios are included in junit reports when --no-s And note that "Charly2 is the skipped scenarion in charly.feature" - @use.with_python.version=3.8 - @use.with_python.version=3.9 - @use.with_python.version=3.10 - @use.with_python.version=3.11 + @use.with_python.min_version=3.8 Scenario: Junit report for skipped scenario is shown and counted with --show-skipped (py.version >= 3.8) When I run "behave --junit -t @tag1 --show-skipped" Then it should pass with: diff --git a/issue.features/issue0446.feature b/issue.features/issue0446.feature index 6309dade5..c62dc4613 100644 --- a/issue.features/issue0446.feature +++ b/issue.features/issue0446.feature @@ -58,10 +58,8 @@ Feature: Issue #446 -- Support scenario hook-errors with JUnitReporter behave.reporter.junit.show_hostname = False """ - @not.with_python.version=3.8 - @not.with_python.version=3.9 - @not.with_python.version=3.10 - @not.with_python.version=3.11 + # -- SIMILAR TO; @use.with_python.max_version=3.7 + @not.with_python.min_version=3.8 Scenario: Hook error in before_scenario() (py.version < 3.8) When I run "behave -f plain --junit features/before_scenario_failure.feature" Then it should fail with: @@ -90,10 +88,7 @@ Feature: Issue #446 -- Support scenario hook-errors with JUnitReporter And note that "the traceback is contained in the XML element " - @use.with_python.version=3.8 - @use.with_python.version=3.9 - @use.with_python.version=3.10 - @use.with_python.version=3.11 + @use.with_python.min_version=3.8 Scenario: Hook error in before_scenario() (py.version >= 3.8) When I run "behave -f plain --junit features/before_scenario_failure.feature" Then it should fail with: @@ -126,10 +121,8 @@ Feature: Issue #446 -- Support scenario hook-errors with JUnitReporter And note that "the traceback is contained in the XML element " - @not.with_python.version=3.8 - @not.with_python.version=3.9 - @not.with_python.version=3.10 - @not.with_python.version=3.11 + # -- SIMILAR TO: @use.with_python.max_version=3.7 + @not.with_python.min_version=3.8 Scenario: Hook error in after_scenario() (py.version < 3.8) When I run "behave -f plain --junit features/after_scenario_failure.feature" Then it should fail with: @@ -160,10 +153,7 @@ Feature: Issue #446 -- Support scenario hook-errors with JUnitReporter And note that "the traceback is contained in the XML element " - @use.with_python.version=3.8 - @use.with_python.version=3.9 - @use.with_python.version=3.10 - @use.with_python.version=3.11 + @use.with_python.min_version=3.8 Scenario: Hook error in after_scenario() (py.version >= 3.8) When I run "behave -f plain --junit features/after_scenario_failure.feature" Then it should fail with: diff --git a/issue.features/issue0457.feature b/issue.features/issue0457.feature index 41997f580..c706e7453 100644 --- a/issue.features/issue0457.feature +++ b/issue.features/issue0457.feature @@ -24,10 +24,8 @@ Feature: Issue #457 -- Double-quotes in error messages of JUnit XML reports """ - @not.with_python.version=3.8 - @not.with_python.version=3.9 - @not.with_python.version=3.10 - @not.with_python.version=3.11 + # -- SIMILAR TO: @use.with_python.max_version=3.7 + @not.with_python.min_version=3.8 Scenario: Use failing assertation in a JUnit XML report (py.version < 3.8) Given a file named "features/fails1.feature" with: """ @@ -48,10 +46,7 @@ Feature: Issue #457 -- Double-quotes in error messages of JUnit XML reports = 3.8) Given a file named "features/fails1.feature" with: """ @@ -75,10 +70,8 @@ Feature: Issue #457 -- Double-quotes in error messages of JUnit XML reports # = 3.8) Given a file named "features/fails2.feature" with: """ diff --git a/issue.features/issue0657.feature b/issue.features/issue0657.feature index a674a2657..28f00ad62 100644 --- a/issue.features/issue0657.feature +++ b/issue.features/issue0657.feature @@ -2,7 +2,7 @@ @not.with_python2=true Feature: Issue #657 -- Allow async steps with timeouts to fail when they raise exceptions - @use.with_python_has_async_function=true + @use.with_python.feature.async_keyword=true @async_step_fails Scenario: Use @async_run_until_complete and async-step fails (py.version >= 3.8) Given a new working directory diff --git a/more.features/run_examples.feature b/more.features/run_examples.feature index 14acf981f..ee3c2fa92 100644 --- a/more.features/run_examples.feature +++ b/more.features/run_examples.feature @@ -29,7 +29,7 @@ Feature: Ensure that all examples are usable features/rule_fails.feature:16 F2 -- Fails """ - @use.with_python_has_coroutine=true + @use.with_python.feature.coroutine=true Scenario: examples/async_step (requires: python.version >= 3.4) Given I use the directory "examples/async_step" as working directory When I run "behave features/" diff --git a/tests/unit/test_tag_matcher.py b/tests/unit/test_tag_matcher.py index 43f5af066..b7c1457f7 100644 --- a/tests/unit/test_tag_matcher.py +++ b/tests/unit/test_tag_matcher.py @@ -428,3 +428,112 @@ def test_should_exclude_with__returns_false_when_no_tag_matcher_return_true(self actual_true_count = self.count_tag_matcher_with_result( self.ctag_matcher.tag_matchers, tags, True) self.assertEqual(0, actual_true_count) + +# ----------------------------------------------------------------------------- +# TEST SUPPORT FOR: ActiveTag ValueObject(s) +# ----------------------------------------------------------------------------- +# XXX from behave.python_feature import VersionObject +from behave.tag_matcher import ValueObject +import operator + + +class NumberValueObject(ValueObject): + def matches(self, tag_value): + tag_number = int(tag_value) # HINT: Conversion from string-to-int + return self.compare(self.value, tag_number) + + +# ----------------------------------------------------------------------------- +# TEST SUITE WITH: ActiveTag ValueObject(s) +# ----------------------------------------------------------------------------- +class TestActiveTagMatcherWithValueObject(object): + """Tests :class:`behave.tag_matcher.ValueObject` functionality. + + ValueObject(s) support additional comparison functions that matches + the "tag_value" of the active-tag with the "current_value". + """ + + # -- ASSERTION HELPERS: + @staticmethod + def assert_active_tags_should_run(tags, value_provider, expected_verdict): + active_tag_matcher = ActiveTagMatcher(value_provider) + actual_verdict = active_tag_matcher.should_run_with(tags) + assert actual_verdict == expected_verdict + + @classmethod + def assert_active_tag_should_run(cls, tag, value_provider, expected_verdict): + cls.assert_active_tags_should_run([tag], value_provider, expected_verdict) + + # -- USE TAG: @use.with_xxx.min_value=10 + @pytest.mark.parametrize("current_value, expected_verdict", [ + (0, False), + (1, False), + (9, False), + (10, True), # -- THRESHOLD BY: active_tag.value + (11, True), + (100, True), + ]) + def test_active_tag_with_min_value_10_should_run(self, current_value, expected_verdict): + # -- USE: min_value.compare: current_value >= tag_number -- greater_or_equal + tag = "use.with_xxx.min_value=10" + value_provider = { + "xxx.min_value": NumberValueObject(current_value, operator.ge) + } + self.assert_active_tag_should_run(tag, value_provider, expected_verdict) + + # -- USE TAG: @use.with_xxx.max_value=10 + @pytest.mark.parametrize("current_value, expected_verdict", [ + (0, True), + (1, True), + (9, True), + (10, True), # -- THRESHOLD BY: active_tag.value + (11, False), + (100, False), + ]) + def test_active_tag_with_max_value_10_should_run(self, current_value, expected_verdict): + # -- USE: max_value.compare: current_value <= tag_value -- less_or_equal + tag = "use.with_xxx.max_value=10" + value_provider = { + "xxx.max_value": NumberValueObject(current_value, operator.le) + } + self.assert_active_tag_should_run(tag, value_provider, expected_verdict) + + # -- TAGS: @use.with_xxx.min_value=3 @use.with_xxx.max_value=10 + # HINT: Tests active-tag compositions logic: active-tag1 and active-tag2 and ... + @pytest.mark.parametrize("current_value, expected_verdict", [ + (0, False), + (2, False), + (3, True), # -- THRESHOLD 1: active_tag.min_value + (4, True), + (9, True), + (10, True), # -- THRESHOLD 2: active_tag.max_value + (11, False), + (100, False), + ]) + def test_active_tag_with_min_value_3_and_max_value_10_should_run(self, current_value, expected_verdict): + # -- USE: min_value.compare: current_value >= tag_number + # -- USE: max_value.compare: current_value <= tag_number + tags = ["use.with_xxx.min_value=3", "use.with_xxx.max_value=10"] + value_provider = { + "xxx.min_value": NumberValueObject(current_value, operator.ge), + "xxx.max_value": NumberValueObject(current_value, operator.le), + } + self.assert_active_tags_should_run(tags, value_provider, expected_verdict) + + # -- TAG: @use.with_xxx.contains_value=10 + @pytest.mark.parametrize("current_value, expected_verdict", [ + # -- CASE: IS_CONTAINED + ([10], True), + ([2, 10, 14, 10], True), + # -- CASE: NOT_CONTAINED + ([], False), + ([2, 8, 9], False), + ([11, 12, 100], False), + ]) + def test_active_tag_with_contains_value_10_should_run(self, current_value, expected_verdict): + # -- USE: contains_value.compare: tag_number contained-in current_value + tag = "use.with_xxx.contains_value=10" + value_provider = { + "xxx.contains_value": NumberValueObject(current_value, operator.contains), + } + self.assert_active_tag_should_run(tag, value_provider, expected_verdict) From c355078c9af0876525bf060fe925ce6b777afc59 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 30 Oct 2022 19:13:31 +0100 Subject: [PATCH 056/240] FIX: Windows test regression with time.sleep() * Sleep duration seems to jitter more than on UNIX (Linux, macOS) * Increase SLEEP_DELTA for Windows to: 0.1 (was: 0.05) --- tests/api/_test_async_step34.py | 12 ++++++++++-- tests/api/_test_async_step35.py | 8 +------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/api/_test_async_step34.py b/tests/api/_test_async_step34.py index a8673c3a8..30db3f535 100644 --- a/tests/api/_test_async_step34.py +++ b/tests/api/_test_async_step34.py @@ -5,10 +5,11 @@ # -- IMPORTS: from __future__ import absolute_import, print_function +import sys from behave.api.async_step import AsyncContext, use_or_create_async_context from behave._stepimport import use_step_import_modules from behave.runner import Context, Runner -import sys +from hamcrest import assert_that, close_to from mock import Mock import pytest @@ -45,6 +46,11 @@ reason="Supported only for python.versions: 3.4 .. 3.7 (inclusive)") +SLEEP_DELTA = 0.050 +if sys.platform.startswith("win"): + SLEEP_DELTA = 0.100 + + # ----------------------------------------------------------------------------- # TESTSUITE: # ----------------------------------------------------------------------------- @@ -74,7 +80,9 @@ def step_async_step_waits_seconds2(context, duration): context = Context(runner=Runner(config={})) with StopWatch() as stop_watch: step_async_step_waits_seconds2(context, duration=0.2) - assert abs(stop_watch.duration - 0.2) <= 0.05 + + # DISABLED: assert abs(stop_watch.duration - 0.2) <= 0.05 + assert_that(stop_watch.duration, close_to(0.2, delta=SLEEP_DELTA)) class TestAsyncContext(object): diff --git a/tests/api/_test_async_step35.py b/tests/api/_test_async_step35.py index a75c76552..7f9219e4e 100644 --- a/tests/api/_test_async_step35.py +++ b/tests/api/_test_async_step35.py @@ -15,11 +15,6 @@ from .testing_support_async import AsyncStepTheory -# ----------------------------------------------------------------------------- -# SUPPORT: -# ----------------------------------------------------------------------------- - - # ----------------------------------------------------------------------------- # ASYNC STEP EXAMPLES: # ----------------------------------------------------------------------------- @@ -47,8 +42,7 @@ SLEEP_DELTA = 0.050 if sys.platform.startswith("win"): - # MAYBE: SLEEP_DELTA = 0.100 - SLEEP_DELTA = 0.050 + SLEEP_DELTA = 0.100 # ----------------------------------------------------------------------------- From 5dff50b1ff60cfa7502cf3b2cc5beecb3021c9d0 Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 1 Nov 2022 15:05:14 +0100 Subject: [PATCH 057/240] Issue #1068: * Verify observed behavior * Provide solution for desired functionality (and verify it) --- issue.features/issue1068.feature | 114 +++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 issue.features/issue1068.feature diff --git a/issue.features/issue1068.feature b/issue.features/issue1068.feature new file mode 100644 index 000000000..275c60828 --- /dev/null +++ b/issue.features/issue1068.feature @@ -0,0 +1,114 @@ +@issue +Feature: Issue #1068 -- Feature.status is Status.failed in before_scenario() Hook + + . DESCRIPTION OF OBSERVED BEHAVIOR: + . Current feature status computation makes only sense after all scenarios are executed. + . Each scenario.status is initially in "Status.untested" before the test run. + . If a hook implementation decides to call "context.abort()" during the test run, + . several scenarios of a feature may still be untested. + . + . Therefore, the feature status computation currently counts + . an untested scenario as failed if one or more scenarios have passed or failed. + + Background: Setup + Given a new working directory + And a file named "features/steps/use_step_library.py" with: + """ + from behave import then + + @then(u'{num1:d} is greater than {num2:d}') + def step_impl(context, num1, num2): + assert num1 > num2, "FAILED: num1=%s, num2=%s" % (num1, num2) + """ + And a file named "behave.ini" with: + """ + [behave] + show_timings = false + """ + + Scenario: Verify observed behaviour + Given a file named "features/syndrome_1068_1.feature" with: + """ + Feature: F1 + Scenario: Test case 1.1 + Then 5 is greater than 4 + + Scenario: Test case 1.2 + Then 2 is greater than 1 + + Scenario: Test case 1.3 + Then 3 is greater than 2 + """ + And a file named "features/environment.py" with: + """ + from __future__ import print_function + + def before_scenario(context, scenario): + print("BEFORE_SCENARIO: Feature status is: {0} (scenario: {1})".format( + context.feature.status, scenario.name)) + """ + When I run "behave -f plain features/syndrome_1068_1.feature" + Then it should pass with: + """ + 1 feature passed, 0 failed, 0 skipped + 3 scenarios passed, 0 failed, 0 skipped + """ + And the command output should contain: + """ + BEFORE_SCENARIO: Feature status is: Status.untested (scenario: Test case 1.1) + """ + And the command output should contain: + """ + BEFORE_SCENARIO: Feature status is: Status.failed (scenario: Test case 1.2) + """ + And the command output should contain: + """ + BEFORE_SCENARIO: Feature status is: Status.failed (scenario: Test case 1.3) + """ + But note that "the feature.status is failed iff some scenarios are passed and others untested" + + + Scenario: Proof-of-Concept for Desired Functionality + Given a file named "features/syndrome_1068_2.feature" with: + """ + Feature: F2 + Scenario: Test case 2.1 + Then 5 is greater than 4 + + Scenario: Test case 2.2 (expected to fail) + Then 1 is greater than 3 + + Scenario: Test case 2.3 + Then 3 is greater than 2 + + Scenario: Test case 2.4 + Then 3 is greater than 1 + """ + And a file named "features/environment.py" with: + """ + from __future__ import print_function + from behave.model_core import Status + + def after_scenario(context, scenario): + if scenario.status == Status.failed: + print("AFTER_FAILED_SCENARIO: %s" % scenario.name) + skip_remaining_feature_scenarios(context.feature) + + def skip_remaining_feature_scenarios(feature): + for scenario in feature.iter_scenarios(): + if scenario.status == Status.untested: + print("SKIP-SCENARIO: %s" % scenario.name) + scenario.skip() + """ + When I run "behave -f plain features/syndrome_1068_2.feature" + Then it should fail with: + """ + 0 features passed, 1 failed, 0 skipped + 1 scenario passed, 1 failed, 2 skipped + """ + And the command output should contain: + """ + AFTER_FAILED_SCENARIO: Test case 2.2 (expected to fail) + SKIP-SCENARIO: Test case 2.3 + SKIP-SCENARIO: Test case 2.4 + """ From cf5bfdcf71dba3e7422677c3aa067af7fad4b313 Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 1 Nov 2022 16:04:09 +0100 Subject: [PATCH 058/240] Add abort() method to TestRunner and Context class. --- behave/model.py | 2 +- behave/runner.py | 38 ++++++++++++++++++++++++++++++-------- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/behave/model.py b/behave/model.py index 73f1a61e5..6940a76d0 100644 --- a/behave/model.py +++ b/behave/model.py @@ -1814,7 +1814,7 @@ def run(self, runner, quiet=False, capture=True): # -- NOTE: Executed step may have skipped scenario and itself. self.status = Status.passed except KeyboardInterrupt as e: - runner.aborted = True + runner.abort(reason="KeyboardInterrupt") error = u"ABORTED: By user (KeyboardInterrupt)." self.status = Status.failed self.store_exception_context(e) diff --git a/behave/runner.py b/behave/runner.py index 1f32ec1bb..02d6439b4 100644 --- a/behave/runner.py +++ b/behave/runner.py @@ -154,7 +154,7 @@ class Context(object): def __init__(self, runner): self._runner = weakref.proxy(runner) self._config = runner.config - d = self._root = { + root_data = self._root = { "aborted": False, "failed": False, "config": self._config, @@ -163,7 +163,7 @@ def __init__(self, runner): "@cleanups": [], # -- REQUIRED-BY: before_all() hook "@layer": "testrun", } - self._stack = [d] + self._stack = [root_data] self._record = {} self._origin = {} self._mode = ContextMode.BEHAVE @@ -181,6 +181,16 @@ def __init__(self, runner): # DISABLED: self.log_capture = None self.fail_on_cleanup_errors = self.FAIL_ON_CLEANUP_ERRORS + def abort(self, reason=None): + """Abort the test run. + + This sets the :attr:`aborted` attribute to true. + Any test runner evaluates this attribute to abort a test run. + + .. versionadded:: 1.2.7 + """ + self._set_root_attribute("aborted", True) + @staticmethod def ignore_cleanup_error(context, cleanup_func, exception): pass @@ -451,7 +461,7 @@ def add_cleanup(self, cleanup_func, *args, **kwargs): # MAYBE: assert callable(cleanup_func), "REQUIRES: callable(cleanup_func)" assert self._stack - layer = kwargs.pop("layer", None) + layer_name = kwargs.pop("layer", None) if args or kwargs: def internal_cleanup_func(): cleanup_func(*args, **kwargs) @@ -459,8 +469,8 @@ def internal_cleanup_func(): internal_cleanup_func = cleanup_func current_frame = self._stack[0] - if layer: - current_frame = self._select_stack_frame_by_layer(layer) + if layer_name: + current_frame = self._select_stack_frame_by_layer(layer_name) if cleanup_func not in current_frame["@cleanups"]: # -- AVOID DUPLICATES: current_frame["@cleanups"].append(internal_cleanup_func) @@ -596,13 +606,25 @@ def _set_aborted(self, value): aborted = property(_get_aborted, _set_aborted, doc="Indicates that test run is aborted by the user.") + def abort(self, reason=None): + """Abort the test run. + + .. versionadded:: 1.2.7 + """ + if self.context is None: + return # -- GRACEFULLY IGNORED. + + # -- NORMAL CASE: + # SIMILAR TO: self.aborted = True + self.context.abort(reason=reason) + def run_hook(self, name, context, *args): if not self.config.dry_run and (name in self.hooks): try: with context.use_with_user_mode(): self.hooks[name](context, *args) # except KeyboardInterrupt: - # self.aborted = True + # self.abort(reason="KeyboardInterrupt") # if name not in ("before_all", "after_all"): # raise except Exception as e: # pylint: disable=broad-except @@ -624,7 +646,7 @@ def run_hook(self, name, context, *args): statement = getattr(context, "scenario", context.feature) elif "all" in name: # -- ABORT EXECUTION: For before_all/after_all - self.aborted = True + self.abort(reason="HOOK-ERROR in hook=%s" % name) statement = None else: # -- CASE: feature, scenario, step @@ -695,7 +717,7 @@ def run_model(self, features=None): # -- FAIL-EARLY: After first failure. run_feature = False except KeyboardInterrupt: - self.aborted = True + self.abort(reason="KeyboardInterrupt") failed_count += 1 run_feature = False From eba47138895538929b94f7803bb9fffc3abebb75 Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 3 Nov 2022 15:59:44 +0100 Subject: [PATCH 059/240] UPDATE: i18n from cucumber repository: gherkin_languages.json * i18n: added amharic language translation (#2111) * i18n: Georgian (ka) localization fixes (#2041) * i18n: Update Japanese (ja) translations (#2100) --- behave/i18n.py | 42 ++++++++++----- etc/gherkin/gherkin-languages.json | 86 ++++++++++++++++++++++++++---- features/cmdline.lang_list.feature | 3 +- issue.features/issue0309.feature | 1 + tasks/develop.py | 4 +- 5 files changed, 110 insertions(+), 26 deletions(-) diff --git a/behave/i18n.py b/behave/i18n.py index 3aa8a2671..524cbc17c 100644 --- a/behave/i18n.py +++ b/behave/i18n.py @@ -41,6 +41,19 @@ 'scenario_outline': ['Սցենարի կառուցվացքը'], 'then': ['* ', 'Ապա '], 'when': ['* ', 'Եթե ', 'Երբ ']}, + 'amh': {'and': ['* ', 'እና '], + 'background': ['ቅድመ ሁኔታ', 'መነሻ', 'መነሻ ሀሳብ'], + 'but': ['* ', 'ግን '], + 'examples': ['ምሳሌዎች', 'ሁናቴዎች'], + 'feature': ['ስራ', 'የተፈለገው ስራ', 'የሚፈለገው ድርጊት'], + 'given': ['* ', 'የተሰጠ '], + 'name': 'Amharic', + 'native': 'አማርኛ', + 'rule': ['ህግ'], + 'scenario': ['ምሳሌ', 'ሁናቴ'], + 'scenario_outline': ['ሁናቴ ዝርዝር', 'ሁናቴ አብነት'], + 'then': ['* ', 'ከዚያ '], + 'when': ['* ', 'መቼ ']}, 'an': {'and': ['* ', 'Y ', 'E '], 'background': ['Antecedents'], 'but': ['* ', 'Pero '], @@ -585,15 +598,15 @@ 'scenario_outline': ['Schema dello scenario'], 'then': ['* ', 'Allora '], 'when': ['* ', 'Quando ']}, - 'ja': {'and': ['* ', 'かつ'], + 'ja': {'and': ['* ', '且つ', 'かつ'], 'background': ['背景'], - 'but': ['* ', 'しかし', '但し', 'ただし'], + 'but': ['* ', '然し', 'しかし', '但し', 'ただし'], 'examples': ['例', 'サンプル'], 'feature': ['フィーチャ', '機能'], 'given': ['* ', '前提'], 'name': 'Japanese', 'native': '日本語', - 'rule': ['Rule'], + 'rule': ['ルール'], 'scenario': ['シナリオ'], 'scenario_outline': ['シナリオアウトライン', 'シナリオテンプレート', 'テンプレ', 'シナリオテンプレ'], 'then': ['* ', 'ならば'], @@ -611,19 +624,22 @@ 'scenario_outline': ['Konsep skenario'], 'then': ['* ', 'Njuk ', 'Banjur '], 'when': ['* ', 'Manawa ', 'Menawa ']}, - 'ka': {'and': ['* ', 'და'], + 'ka': {'and': ['* ', 'და ', 'ასევე '], 'background': ['კონტექსტი'], - 'but': ['* ', 'მაგ\xadრამ'], + 'but': ['* ', 'მაგრამ ', 'თუმცა '], 'examples': ['მაგალითები'], - 'feature': ['თვისება'], - 'given': ['* ', 'მოცემული'], + 'feature': ['თვისება', 'მოთხოვნა'], + 'given': ['* ', 'მოცემული ', 'Მოცემულია ', 'ვთქვათ '], 'name': 'Georgian', - 'native': 'ქართველი', - 'rule': ['Rule'], - 'scenario': ['მაგალითად', 'სცენარის'], - 'scenario_outline': ['სცენარის ნიმუში'], - 'then': ['* ', 'მაშინ'], - 'when': ['* ', 'როდესაც']}, + 'native': 'ქართული', + 'rule': ['წესი'], + 'scenario': ['მაგალითად', 'მაგალითი', 'მაგ', 'სცენარი'], + 'scenario_outline': ['სცენარის ნიმუში', + 'სცენარის შაბლონი', + 'ნიმუში', + 'შაბლონი'], + 'then': ['* ', 'მაშინ '], + 'when': ['* ', 'როდესაც ', 'როცა ', 'როგორც კი ', 'თუ ']}, 'kn': {'and': ['* ', 'ಮತ್ತು '], 'background': ['ಹಿನ್ನೆಲೆ'], 'but': ['* ', 'ಆದರೆ '], diff --git a/etc/gherkin/gherkin-languages.json b/etc/gherkin/gherkin-languages.json index 14b52117d..a8541cd2d 100644 --- a/etc/gherkin/gherkin-languages.json +++ b/etc/gherkin/gherkin-languages.json @@ -1885,6 +1885,7 @@ "ja": { "and": [ "* ", + "且つ", "かつ" ], "background": [ @@ -1892,6 +1893,7 @@ ], "but": [ "* ", + "然し", "しかし", "但し", "ただし" @@ -1911,7 +1913,7 @@ "name": "Japanese", "native": "日本語", "rule": [ - "Rule" + "ルール" ], "scenario": [ "シナリオ" @@ -1982,44 +1984,57 @@ "ka": { "and": [ "* ", - "და" + "და ", + "ასევე " ], "background": [ "კონტექსტი" ], "but": [ "* ", - "მაგ­რამ" + "მაგრამ ", + "თუმცა " ], "examples": [ "მაგალითები" ], "feature": [ - "თვისება" + "თვისება", + "მოთხოვნა" ], "given": [ "* ", - "მოცემული" + "მოცემული ", + "Მოცემულია ", + "ვთქვათ " ], "name": "Georgian", - "native": "ქართველი", + "native": "ქართული", "rule": [ - "Rule" + "წესი" ], "scenario": [ "მაგალითად", - "სცენარის" + "მაგალითი", + "მაგ", + "სცენარი" ], "scenarioOutline": [ - "სცენარის ნიმუში" + "სცენარის ნიმუში", + "სცენარის შაბლონი", + "ნიმუში", + "შაბლონი" ], "then": [ "* ", - "მაშინ" + "მაშინ " ], "when": [ "* ", - "როდესაც" + "როდესაც ", + "როცა ", + "როგორც კი ", + "თუ " ] }, "kn": { @@ -3672,5 +3687,54 @@ "* ", "जेव्हा " ] + }, + "amh": { + "and": [ + "* ", + "እና " + ], + "background": [ + "ቅድመ ሁኔታ", + "መነሻ", + "መነሻ ሀሳብ" + ], + "but": [ + "* ", + "ግን " + ], + "examples": [ + "ምሳሌዎች", + "ሁናቴዎች" + ], + "feature": [ + "ስራ", + "የተፈለገው ስራ", + "የሚፈለገው ድርጊት" + ], + "given": [ + "* ", + "የተሰጠ " + ], + "name": "Amharic", + "native": "አማርኛ", + "rule": [ + "ህግ" + ], + "scenario": [ + "ምሳሌ", + "ሁናቴ" + ], + "scenarioOutline": [ + "ሁናቴ ዝርዝር", + "ሁናቴ አብነት" + ], + "then": [ + "* ", + "ከዚያ " + ], + "when": [ + "* ", + "መቼ " + ] } } diff --git a/features/cmdline.lang_list.feature b/features/cmdline.lang_list.feature index 138adfc1e..38b03aa57 100644 --- a/features/cmdline.lang_list.feature +++ b/features/cmdline.lang_list.feature @@ -13,6 +13,7 @@ Feature: Command-line options: Use behave --lang-list Languages available: af: Afrikaans / Afrikaans am: հայերեն / Armenian + amh: አማርኛ / Amharic an: Aragonés / Aragonese ar: العربية / Arabic ast: asturianu / Asturian @@ -53,7 +54,7 @@ Feature: Command-line options: Use behave --lang-list it: italiano / Italian ja: 日本語 / Japanese jv: Basa Jawa / Javanese - ka: ქართველი / Georgian + ka: ქართული / Georgian kn: ಕನ್ನಡ / Kannada ko: 한국어 / Korean lt: lietuvių kalba / Lithuanian diff --git a/issue.features/issue0309.feature b/issue.features/issue0309.feature index b50e32d3b..daa13c6fd 100644 --- a/issue.features/issue0309.feature +++ b/issue.features/issue0309.feature @@ -32,6 +32,7 @@ Feature: Issue #309 -- behave --lang-list fails on Python3 Languages available: af: Afrikaans / Afrikaans am: հայերեն / Armenian + amh: አማርኛ / Amharic an: Aragonés / Aragonese ar: العربية / Arabic ast: asturianu / Asturian diff --git a/tasks/develop.py b/tasks/develop.py index eb5fedd6a..1f5519df3 100644 --- a/tasks/develop.py +++ b/tasks/develop.py @@ -13,7 +13,9 @@ # ----------------------------------------------------------------------------- # CONSTANTS: # ----------------------------------------------------------------------------- -GHERKIN_LANGUAGES_URL = "https://raw.githubusercontent.com/cucumber/cucumber/master/gherkin/gherkin-languages.json" +# DISABLED: OLD LOCATION: +# GHERKIN_LANGUAGES_URL = "https://raw.githubusercontent.com/cucumber/cucumber/master/gherkin/gherkin-languages.json" +GHERKIN_LANGUAGES_URL = "https://raw.githubusercontent.com/cucumber/common/main/gherkin/gherkin-languages.json" # ----------------------------------------------------------------------------- From 46ad983ffa7fadf0aa04227ed61123bd7e00472b Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 5 Nov 2022 18:09:14 +0100 Subject: [PATCH 060/240] docs: Fix typo in "New and Noteworthy". --- docs/new_and_noteworthy_v1.2.7.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/new_and_noteworthy_v1.2.7.rst b/docs/new_and_noteworthy_v1.2.7.rst index 7af2c8c66..08d3411d5 100644 --- a/docs/new_and_noteworthy_v1.2.7.rst +++ b/docs/new_and_noteworthy_v1.2.7.rst @@ -209,7 +209,7 @@ mechanism to determine if the ``tag.value`` matches the ``current.value``, like: # -- SCHEMA: "@use.with_{category}={value}" or "@not.with_{category}={value}" @use.with_browser=Safari # HINT: tag.value = "Safari" - ACTIVE TAG MATCHES, if: current.value == tag.value (for strings) + ACTIVE TAG MATCHES, if: current.value == tag.value (for string values) The ``equals`` comparison method is sufficient for many situations. But in some situations, you want to use other comparison methods. @@ -222,7 +222,7 @@ the user to provide an own comparison method (and type conversion support). Feature: Active-Tag Example 1 with ValueObject - @use.with_temperatur.min_value=15 + @use.with_temperature.min_value=15 Scenario: Only run if temperature >= 15 degrees Celcius ... @@ -234,7 +234,7 @@ the user to provide an own comparison method (and type conversion support). from my_system.sensors import Sensors # -- SIMPLIFIED: Better use behave.tag_matcher.NumberValueObject - # CONSTRUCTOR: ValueObject(value, compare=operator.eq) + # CTOR: ValueObject(value, compare=operator.eq) # HINT: Parameter "value" can be a getter-function (w/o args). class NumberValueObject(ValueObject): def matches(self, tag_value): @@ -261,7 +261,7 @@ execution of an scenario to a temperature range, like: .. code:: gherkin - Feature: Active-Tag Example 2 with Value Range + Feature: Active-Tag Example 2 with Min/Max Value Range @use.with_temperature.min_value=10 @use.with_temperature.max_value=70 @@ -272,7 +272,7 @@ execution of an scenario to a temperature range, like: # -- FILE: features/environment.py ... - current_temperature = get_temperature() # RETURNS: integer-number in Celcius. + current_temperature = Sensors().get_temperature() active_tag_value_provider = { # -- COMPARISON: # temperature.min_value: current.value >= tag.value From 1e40cb9d72e6aa55f9860cd5dc7072465f0faab3 Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 8 Nov 2022 21:21:10 +0100 Subject: [PATCH 061/240] FIX: py.requirements/ci.tox.txt * Sync from "testing.txt" (that already contains the fix) * Fix needed for newer Python3 versions (pytest, pytest-html, ...) --- py.requirements/ci.tox.txt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/py.requirements/ci.tox.txt b/py.requirements/ci.tox.txt index 387e90525..4eabfc3f4 100644 --- a/py.requirements/ci.tox.txt +++ b/py.requirements/ci.tox.txt @@ -1,17 +1,22 @@ # ============================================================================ # BEHAVE: PYTHON PACKAGE REQUIREMENTS: ci.tox.txt # ============================================================================ +# BASED ON: testing.txt pytest < 5.0; python_version < '3.0' # pytest >= 4.2 pytest >= 5.0; python_version >= '3.0' -pytest-html >= 1.19.0,<2.0 -mock >= 2.0 +pytest-html >= 1.19.0,<2.0; python_version < '3.0' +pytest-html >= 2.0; python_version >= '3.0' + +mock < 4.0; python_version < '3.6' +mock >= 4.0; python_version >= '3.6' PyHamcrest >= 2.0.2; python_version >= '3.0' PyHamcrest < 2.0; python_version < '3.0' # -- HINT: path.py => path (python-install-package was renamed for python3) -path.py >= 11.5.0; python_version < '3.5' -path >= 13.1.0; python_version >= '3.5' +path.py >=11.5.0,<13.0; python_version < '3.5' +path >= 13.1.0; python_version >= '3.5' jsonschema + From c3cfd40fd5683f1997274b56708ea3fd67de24f5 Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 14 Nov 2022 17:51:58 +0100 Subject: [PATCH 062/240] CI workflow/tests: Add Python 3.11 --- .github/workflows/tests-windows.yml | 5 ++--- .github/workflows/tests.yml | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index c45776392..c309470ab 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -18,9 +18,8 @@ jobs: strategy: fail-fast: false matrix: - # PREPARED: python-version: ['3.10', '3.9'] os: [windows-latest] - python-version: ["3.10", "3.9"] + python-version: ["3.11", "3.10", "3.9"] steps: - uses: actions/checkout@v3 # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} @@ -29,7 +28,7 @@ jobs: python-version: ${{ matrix.python-version }} cache: 'pip' cache-dependency-path: 'py.requirements/*.txt' - # DISABLED: + # -- DISABLED: # - name: Show Python version # run: python --version - name: Install Python package dependencies diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0098acaec..9454228a9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,7 @@ jobs: # PREPARED: python-version: ['3.9', '2.7', '3.10', '3.8', 'pypy-2.7', 'pypy-3.8'] # PREPARED: os: [ubuntu-latest, windows-latest] os: [ubuntu-latest] - python-version: ["3.10", "3.9", "2.7"] + python-version: ["3.11", "3.10", "3.9", "2.7"] exclude: - os: windows-latest python-version: "2.7" @@ -31,7 +31,7 @@ jobs: python-version: ${{ matrix.python-version }} cache: 'pip' cache-dependency-path: 'py.requirements/*.txt' - # DISABLED: + # -- DISABLED: # - name: Show Python version # run: python --version - name: Install Python package dependencies From 97ddb2b859cb5c7ef75078186ce72c5b9d2bae31 Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 14 Nov 2022 18:11:20 +0100 Subject: [PATCH 063/240] Github security reporting: Use new Github vulnerability mechanism * New Github vulnerability reporting mechanism was enabled for this repo. * Update description to use this mechanism (instead of using emails). This simplifies how to keep track of the state of this security issue. --- .github/SECURITY.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 008bcd97f..b2108eb7c 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -14,12 +14,11 @@ HINT: Older versions are not supported. ## Reporting a Vulnerability -SHORT VERSION: Please report security issues by emailing to [behave-security@noreply.github.com](mailto:jenisys@users.noreply.github.com) . +Please report security issues by using the new +[Github vulnerability reporting mechanism](https://github.com/behave/behave/security/advisories) +that is enabled for this repository. -If you believe you’ve found something in Django which has security implications, -please send a description of the issue via email to the email address mentioned above (see: SHORT VERSION). -Mail sent to that address reaches the security team. - -Once you’ve submitted an issue via email, you should receive an acknowledgment from a member of the security team within 48 hours, -and depending on the action to be taken, you may receive further followup emails. +SEE ALSO: +* https://github.com/behave/behave/security/advisories +* https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability From 94592ef3a4e18e32e6549724a778d8a8de7af5c3 Mon Sep 17 00:00:00 2001 From: Javier Buzzi Date: Thu, 21 Nov 2019 09:58:45 +0100 Subject: [PATCH 064/240] Update __main__.py --- behave/__main__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/behave/__main__.py b/behave/__main__.py index edb99c498..27f0b29ec 100644 --- a/behave/__main__.py +++ b/behave/__main__.py @@ -60,6 +60,7 @@ def run_behave(config, runner_class=None): """ # pylint: disable=too-many-branches, too-many-statements, too-many-return-statements if runner_class is None: + warn.warning('Depricated, no default value will be provided') runner_class = Runner if config.version: From 917672f4d765c1ae81ee8a1e3756a74acbea1c22 Mon Sep 17 00:00:00 2001 From: Javier Buzzi Date: Thu, 21 Nov 2019 11:01:53 +0100 Subject: [PATCH 065/240] Update __main__.py --- behave/__main__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/behave/__main__.py b/behave/__main__.py index 27f0b29ec..edb99c498 100644 --- a/behave/__main__.py +++ b/behave/__main__.py @@ -60,7 +60,6 @@ def run_behave(config, runner_class=None): """ # pylint: disable=too-many-branches, too-many-statements, too-many-return-statements if runner_class is None: - warn.warning('Depricated, no default value will be provided') runner_class = Runner if config.version: From 596f339ab9ee8ea9608b08dc826560b9474acc28 Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 13 Jan 2021 23:30:00 +0100 Subject: [PATCH 066/240] REFACTOR: Use RunnerPlugin * Use RunnerPlugin to provide extension in one place and own module * Move parts from behave.configuration to behave.runner_plugin * Add support for runner-aliases via config-file (section: behave.runners) * Cleanup behave.configuration * Add behave.api.runner module to provide Runner interface (IRunner) as abstract base class (ABC). * Add some exceptions, like ClassNotFoundError, to improve diagnostics --- behave.ini | 4 + behave/__main__.py | 26 ++- behave/api/runner.py | 35 +++ behave/compat/exceptions.py | 25 +++ behave/configuration.py | 43 ++-- behave/exception.py | 26 ++- behave/formatter/_registry.py | 9 +- behave/importer.py | 19 ++ behave/runner.py | 8 + behave/runner_plugin.py | 140 ++++++++++++ docs/behave.rst | 11 + features/runner.use_runner_class.feature | 264 +++++++++++++++++++++++ py.requirements/testing.txt | 2 + tests/unit/test_configuration.py | 122 +++++++++-- 14 files changed, 689 insertions(+), 45 deletions(-) create mode 100644 behave/api/runner.py create mode 100644 behave/compat/exceptions.py create mode 100644 behave/runner_plugin.py create mode 100644 features/runner.use_runner_class.feature diff --git a/behave.ini b/behave.ini index 9c9644ebc..2c30ef342 100644 --- a/behave.ini +++ b/behave.ini @@ -38,6 +38,10 @@ logging_level = INFO allure = allure_behave.formatter:AllureFormatter html = behave_html_formatter:HTMLFormatter +[behave.runners] +default = behave.runner:Runner + + # PREPARED: # [behave] # format = ... missing_steps ... diff --git a/behave/__main__.py b/behave/__main__.py index edb99c498..c6520e6da 100644 --- a/behave/__main__.py +++ b/behave/__main__.py @@ -7,16 +7,20 @@ from behave.version import VERSION as BEHAVE_VERSION from behave.configuration import Configuration from behave.exception import ConstraintError, ConfigError, \ - FileNotFoundError, InvalidFileLocationError, InvalidFilenameError + FileNotFoundError, InvalidFileLocationError, InvalidFilenameError, \ + ModuleNotFoundError, ClassNotFoundError, InvalidClassError from behave.parser import ParserError from behave.runner import Runner from behave.runner_util import print_undefined_step_snippets, reset_runtime from behave.textutil import compute_words_maxsize, text as _text +from behave.runner_plugin import RunnerPlugin +# PREPARED: from behave.importer import make_scoped_class_name # --------------------------------------------------------------------------- # CONSTANTS: # --------------------------------------------------------------------------- +DEBUG = __debug__ TAG_HELP = """ Scenarios inherit tags that are declared on the Feature level. The simplest TAG_EXPRESSION is simply a tag:: @@ -59,8 +63,6 @@ def run_behave(config, runner_class=None): .. note:: BEST EFFORT, not intended for multi-threaded usage. """ # pylint: disable=too-many-branches, too-many-statements, too-many-return-statements - if runner_class is None: - runner_class = Runner if config.version: print("behave " + BEHAVE_VERSION) @@ -96,10 +98,12 @@ def run_behave(config, runner_class=None): return 1 # -- MAIN PART: + runner = None failed = True try: reset_runtime() - runner = runner_class(config) + runner = RunnerPlugin(runner_class=runner_class).make_runner(config) + # print("USING RUNNER: {0}".format(make_scoped_class_name(runner))) failed = runner.run() except ParserError as e: print(u"ParserError: %s" % e) @@ -111,6 +115,16 @@ def run_behave(config, runner_class=None): print(u"InvalidFileLocationError: %s" % e) except InvalidFilenameError as e: print(u"InvalidFilenameError: %s" % e) + except ModuleNotFoundError as e: + print(u"ModuleNotFoundError: %s" % e) + except ClassNotFoundError as e: + print(u"ClassNotFoundError: %s" % e) + except InvalidClassError as e: + print(u"InvalidClassError: %s" % e) + except ImportError as e: + print(u"%s: %s" % (e.__class__.__name__, e)) + if DEBUG: + raise except ConstraintError as e: print(u"ConstraintError: %s" % e) except Exception as e: @@ -119,7 +133,7 @@ def run_behave(config, runner_class=None): print(u"Exception %s: %s" % (e.__class__.__name__, text)) raise - if config.show_snippets and runner.undefined_steps: + if config.show_snippets and runner and runner.undefined_steps: print_undefined_step_snippets(runner.undefined_steps, colored=config.color) @@ -215,7 +229,7 @@ def main(args=None): :return: 0, if successful. Non-zero, in case of errors/failures. """ config = Configuration(args) - return run_behave(config, runner_class=config.runner_class) + return run_behave(config) if __name__ == "__main__": diff --git a/behave/api/runner.py b/behave/api/runner.py new file mode 100644 index 000000000..2a1e518a6 --- /dev/null +++ b/behave/api/runner.py @@ -0,0 +1,35 @@ +# -*- coding: UTF-8 -*- +""" +Specifies the common interface for runner(s)/runner-plugin(s). + +.. seealso:: + + * https://pymotw.com/3/abc/index.html + +""" + +from __future__ import absolute_import +from abc import ABCMeta, abstractmethod +from six import add_metaclass + + +@add_metaclass(ABCMeta) +class IRunner(object): + """Interface that a test runner-class should provide: + + * Constructor: with config parameter object (at least) and some kw-args. + * run() method without any args. + """ + + @abstractmethod + def __init__(self, config, **kwargs): + self.config = config + + @abstractmethod + def run(self): + """Run the selected features. + + :return: True, if test-run failed. False, on success. + :rtype: bool + """ + return False diff --git a/behave/compat/exceptions.py b/behave/compat/exceptions.py new file mode 100644 index 000000000..e49498a31 --- /dev/null +++ b/behave/compat/exceptions.py @@ -0,0 +1,25 @@ +# -*- coding: UTF-8 -*- +# pylint: disable=redefined-builtin,unused-import +""" +Provides some Python3 exception classes for Python2 and early Python3 versions. +""" + +from __future__ import absolute_import +import errno as _errno +from six.moves import builtins as _builtins + + +# ----------------------------------------------------------------------------- +# EXCEPTION CLASSES: +# ----------------------------------------------------------------------------- +FileNotFoundError = getattr(_builtins, "FileNotFoundError", None) +if not FileNotFoundError: + class FileNotFoundError(OSError): + """Provided since Python >= 3.3""" + errno = _errno.ENOENT + + +ModuleNotFoundError = getattr(_builtins, "ModuleNotFoundError", None) +if not ModuleNotFoundError: + class ModuleNotFoundError(ImportError): + """Provided since Python >= 3.6""" diff --git a/behave/configuration.py b/behave/configuration.py index 3f4daeead..4ede4d8d2 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- -from __future__ import print_function +from __future__ import absolute_import, print_function import argparse +import inspect import logging import os import re import sys import shlex import six -from importlib import import_module from six.moves import configparser from behave.model import ScenarioOutline @@ -30,7 +30,13 @@ # ----------------------------------------------------------------------------- -# CONFIGURATION DATA TYPES: +# CONSTANTS: +# ----------------------------------------------------------------------------- +DEFAULT_RUNNER_CLASS_NAME = "behave.runner:Runner" + + +# ----------------------------------------------------------------------------- +# CONFIGURATION DATA TYPES and TYPE CONVERTERS: # ----------------------------------------------------------------------------- class LogLevel(object): names = [ @@ -66,16 +72,6 @@ def to_string(level): # ----------------------------------------------------------------------------- # CONFIGURATION SCHEMA: # ----------------------------------------------------------------------------- - -def valid_python_module(path): - try: - module_path, class_name = path.rsplit('.', 1) - module = import_module(module_path) - return getattr(module, class_name) - except (ValueError, AttributeError, ImportError): - raise argparse.ArgumentTypeError("No module named '%s' was found." % path) - - options = [ (("-c", "--no-color"), dict(action="store_false", dest="color", @@ -124,11 +120,6 @@ def valid_python_module(path): default="reports", help="""Directory in which to store JUnit reports.""")), - (("--runner-class",), - dict(action="store", - default="behave.runner.Runner", type=valid_python_module, - help="Tells Behave to use a specific runner. (default: %(default)s)")), - ((), # -- CONFIGFILE only dict(dest="default_format", help="Specify default formatter (default: pretty).")), @@ -284,6 +275,11 @@ def valid_python_module(path): dict(action="store_true", help="Alias for --no-snippets --no-source.")), + (("-r", "--runner"), + dict(dest="runner", action="store", metavar="RUNNER_CLASS", + default=DEFAULT_RUNNER_CLASS_NAME, + help="Use own runner class, like: behave.runner:Runner")), + (("-s", "--no-source"), dict(action="store_false", dest="show_source", help="""Don't print the file and line of the step definition with the @@ -442,6 +438,7 @@ def read_configuration(path): # SCHEMA: config_section: data_name special_config_section_map = { "behave.formatters": "more_formatters", + "behave.runners": "more_runners", "behave.userdata": "userdata", } for section_name, data_name in special_config_section_map.items(): @@ -518,6 +515,7 @@ class Configuration(object): log_capture=True, logging_format="%(levelname)s:%(name)s:%(message)s", logging_level=logging.INFO, + runner=DEFAULT_RUNNER_CLASS_NAME, steps_catalog=False, summary=True, junit=False, @@ -601,6 +599,8 @@ def __init__(self, command_args=None, load_config=True, verbose=None, self.environment_file = "environment.py" self.userdata_defines = None self.more_formatters = None + self.more_runners = None + self.runner_aliases = dict(default=DEFAULT_RUNNER_CLASS_NAME) if load_config: load_configuration(self.defaults, verbose=verbose) parser = setup_parser() @@ -669,6 +669,7 @@ def __init__(self, command_args=None, load_config=True, verbose=None, self.setup_stage(self.stage) self.setup_model() self.setup_userdata() + self.setup_runners() # -- FINALLY: Setup Reporters and Formatters # NOTE: Reporters and Formatters can now use userdata information. @@ -686,7 +687,6 @@ def __init__(self, command_args=None, load_config=True, verbose=None, if unknown_formats: parser.error("format=%s is unknown" % ", ".join(unknown_formats)) - def setup_outputs(self, args_outfiles=None): if self.outputs: assert not args_outfiles, "ONLY-ONCE" @@ -708,6 +708,11 @@ def setup_formats(self): for name, scoped_class_name in self.more_formatters.items(): _format_registry.register_as(name, scoped_class_name) + def setup_runners(self): + if self.more_runners: + for name, scoped_class_name in self.more_runners.items(): + self.runner_aliases[name] = scoped_class_name + def collect_unknown_formats(self): unknown_formats = [] if self.format: diff --git a/behave/exception.py b/behave/exception.py index ba2120646..de03262dc 100644 --- a/behave/exception.py +++ b/behave/exception.py @@ -1,10 +1,14 @@ # -*- coding: UTF-8 -*- +# pylint: disable=redefined-builtin,unused-import """ Behave exception classes. .. versionadded:: 1.2.7 """ +from __future__ import absolute_import +from behave.compat.exceptions import FileNotFoundError, ModuleNotFoundError + # --------------------------------------------------------------------------- # EXCEPTION/ERROR CLASSES: @@ -23,10 +27,10 @@ class ConfigError(Exception): # --------------------------------------------------------------------------- # EXCEPTION/ERROR CLASSES: Related to File Handling # --------------------------------------------------------------------------- -class FileNotFoundError(LookupError): - """Used if a specified file was not found.""" - - +# -- SINCE: Python 3.3 -- FileNotFoundError is built-in exception +# class FileNotFoundError(LookupError): +# """Used if a specified file was not found.""" +# class InvalidFileLocationError(LookupError): """Used if a :class:`behave.model_core.FileLocation` is invalid. This occurs if the file location is no exactly correct and @@ -38,3 +42,17 @@ class InvalidFilenameError(ValueError): """Used if a filename does not have the expected file extension, etc.""" +# --------------------------------------------------------------------------- +# EXCEPTION/ERROR CLASSES: Related to Imported Plugins +# --------------------------------------------------------------------------- +# RELATED: class ModuleNotFoundError(ImportError): -- Since Python 3.6 +class ClassNotFoundError(ImportError): + """Used if module to import exists, but class with this name does not exist.""" + + +class InvalidClassError(TypeError): + """Used if the specified class has the wrong type: + + * not a class + * not subclass of a required class + """ diff --git a/behave/formatter/_registry.py b/behave/formatter/_registry.py index 0f7ad942b..c4f2a2c9d 100644 --- a/behave/formatter/_registry.py +++ b/behave/formatter/_registry.py @@ -4,6 +4,7 @@ import warnings from behave.formatter.base import Formatter, StreamOpener from behave.importer import LazyDict, LazyObject, parse_scoped_name, load_module +from behave.exception import ClassNotFoundError import six @@ -12,15 +13,18 @@ # ----------------------------------------------------------------------------- _formatter_registry = LazyDict() + def format_iter(): return iter(_formatter_registry.keys()) + def format_items(resolved=False): if resolved: # -- ENSURE: All formatter classes are loaded (and resolved). _formatter_registry.load_all(strict=False) return iter(_formatter_registry.items()) + def register_as(name, formatter_class): """ Register formatter class with given name. @@ -47,9 +51,11 @@ def register_as(name, formatter_class): issubclass(formatter_class, Formatter)) _formatter_registry[name] = formatter_class + def register(formatter_class): register_as(formatter_class.name, formatter_class) + def register_formats(formats): """Register many format items into the registry. @@ -58,6 +64,7 @@ def register_formats(formats): for formatter_name, formatter_class_name in formats: register_as(formatter_name, formatter_class_name) + def load_formatter_class(scoped_class_name): """Load a formatter class by using its scoped class name. @@ -73,7 +80,7 @@ def load_formatter_class(scoped_class_name): formatter_module = load_module(module_name) formatter_class = getattr(formatter_module, class_name, None) if formatter_class is None: - raise ImportError("CLASS NOT FOUND: %s" % scoped_class_name) + raise ClassNotFoundError("CLASS NOT FOUND: %s" % scoped_class_name) return formatter_class diff --git a/behave/importer.py b/behave/importer.py index 6611ada05..2c217f9fa 100644 --- a/behave/importer.py +++ b/behave/importer.py @@ -7,8 +7,10 @@ from __future__ import absolute_import import importlib +import inspect from behave._types import Unknown + def parse_scoped_name(scoped_name): """ SCHEMA: my.module_name:MyClassName @@ -19,9 +21,26 @@ def parse_scoped_name(scoped_name): if "::" in scoped_name: # -- ALTERNATIVE: my.module_name::MyClassName scoped_name = scoped_name.replace("::", ":") + if ":" not in scoped_name: + schema = "%s: Missing ':' (colon) as module-to-name seperator'" + raise ValueError(schema % scoped_name) module_name, object_name = scoped_name.rsplit(":", 1) return module_name, object_name + +def make_scoped_class_name(obj): + """Build scoped-class-name from an object/class. + + :param obj: Object or class. + :return Scoped-class-name (as string). + """ + if inspect.isclass(obj): + class_name = obj.__name__ + else: + class_name = obj.__class__.__name__ + return "{0}:{1}".format(obj.__module__, class_name) + + def load_module(module_name): return importlib.import_module(module_name) diff --git a/behave/runner.py b/behave/runner.py index 02d6439b4..d27c529e5 100644 --- a/behave/runner.py +++ b/behave/runner.py @@ -13,6 +13,7 @@ import six +from behave.api.runner import IRunner from behave._types import ExceptionUtil from behave.capture import CaptureController from behave.exception import ConfigError @@ -908,3 +909,10 @@ def run_with_paths(self): stream_openers = self.config.outputs self.formatters = make_formatters(self.config, stream_openers) return self.run_model() + + +# ----------------------------------------------------------------------------- +# REGISTER RUNNER-CLASSES: +# ----------------------------------------------------------------------------- +IRunner.register(ModelRunner) +IRunner.register(Runner) diff --git a/behave/runner_plugin.py b/behave/runner_plugin.py new file mode 100644 index 000000000..5663855ba --- /dev/null +++ b/behave/runner_plugin.py @@ -0,0 +1,140 @@ +# -*- coding: UTF-8 -*- +""" +Create a runner as behave plugin by using its name: + +* scoped-class-name, like: "behave.runner:Runner" (dotted.module:ClassName) +* runner-alias (alias mapping provided in config-file "behave.runners" section) + +.. code-block:: + + # -- FILE: behave.ini + # RUNNER-ALIAS EXAMPLE: + # USE: behave --runner=default features/ + [behave.runners] + default = behave.runner:Runner +""" + +from __future__ import absolute_import, print_function +import inspect +from behave.api.runner import IRunner +from behave.exception import ConfigError, ClassNotFoundError, InvalidClassError +from behave.importer import parse_scoped_name +from behave._types import Unknown +from importlib import import_module + + +class RunnerPlugin(object): + """Extension point to load an runner_class and create its runner: + + * create a runner by using its scoped-class-name + * create a runner by using its runner-alias (provided in config-file) + * create a runner by using a runner-class + + .. code-block:: py + + # -- EXAMPLE: Provide own test runner-class + from behave.api.runner import IRunner + class MyRunner(IRunner): + def __init__(self, config, **kwargs): + self.config = config + + def run(self): + ... # IMPLEMENTATION DETAILS: Left out here. + + .. code-block:: py + + # -- CASE 1A: Make a runner by using its scoped-class-name + plugin = RunnerPlugin("behave.runner:Runner") + runner = plugin.make_runner(config) + + # -- CASE 1B: Make a runner by using its runner-alias + # CONFIG-FILE SECTION: "behave.ini" + # [behave.runners] + # one = behave.runner:Runner + plugin = RunnerPlugin("one") + runner = plugin.make_runner(config) + + # -- CASE 2: Make a runner by using a runner-class + from behave.runner import Runner as DefaultRunner + plugin = RunnerPlugin(runner_class=DefaultRunner) + runner = plugin.make_runner(config) + """ + def __init__(self, runner_name=None, runner_class=None, runner_aliases=None): + self.runner_name = runner_name + self.runner_class = runner_class + self.runner_aliases = runner_aliases + + @staticmethod + def is_runner_class_valid(runner_class): + run_method = getattr(runner_class, "run", None) + return (inspect.isclass(runner_class) and + issubclass(runner_class, IRunner) and + callable(run_method)) + + @staticmethod + def load_runner_class(runner_class_name): + """Loads a runner class by using its scoped-class-name, like: + `my.module:Class1`. + + :param runner_class_name: Scoped class-name (as string). + :return: Loaded runner-class (on success). + :raises ClassNotFoundError: If module exist, but class was not found. + :raises InvalidClassError: If class is invalid (wrong subclass or not a class). + :raises ImportError: If module was not found (or other Import-Errors above). + """ + module_name, class_name = parse_scoped_name(runner_class_name) + try: + module = import_module(module_name) + runner_class = getattr(module, class_name, Unknown) + if runner_class is Unknown: + raise ClassNotFoundError(runner_class_name) + elif not inspect.isclass(runner_class): + schema = "{0}: not a class" + raise InvalidClassError(schema.format(runner_class_name)) + elif not issubclass(runner_class, IRunner): + schema = "{0}: not subclass-of behave.api.runner.IRunner" + raise InvalidClassError(schema.format(runner_class_name)) + run_method = getattr(runner_class, "run", None) + if not callable(run_method): + schema = "{0}: run() is not callable" + raise InvalidClassError(schema.format(runner_class_name)) + return runner_class + except ImportError as e: + print("FAILED to load runner-class: %s: %s" % (e.__class__.__name__, e)) + raise + except TypeError as e: + print("FAILED to load runner-class: %s: %s" % (e.__class__.__name__, e)) + raise + + def make_runner(self, config, **runner_kwargs): + """Build a runner either by: + + * providing a runner-class + * using its name (alias-name or scoped-class-name). + + :param config: Configuration object to use for runner. + :param runner_kwargs: Keyword args for runner creation. + :return: Runner object to use. + :raises ClassNotFoundError: If module exist, but class was not found. + :raises InvalidClassError: If class is invalid (wrong subclass or not a class). + :raises ImportError: If module was not found (or other Import-Errors above). + :raises ConfigError: If runner-alias is not in config-file (section: behave.runners). + """ + runner_class = self.runner_class + if not runner_class: + # -- CASE: Using runner-name (alias) or scoped_class_name. + runner_name = self.runner_name + runner_aliases = self.runner_aliases + if runner_aliases is None: + runner_aliases = config.runner_aliases + if not runner_name: + runner_name = config.runner + scoped_class_name = runner_aliases.get(runner_name, runner_name) + if scoped_class_name == runner_name and ":" not in scoped_class_name: + # -- CASE: runner-alias is not in config-file section="behave.runner". + raise ConfigError("runner=%s (RUNNER-ALIAS NOT FOUND)" % scoped_class_name) + runner_class = self.load_runner_class(scoped_class_name) + + assert self.is_runner_class_valid(runner_class) + runner = runner_class(config, **runner_kwargs) + return runner diff --git a/docs/behave.rst b/docs/behave.rst index 7cf5e9bac..0fafc4d32 100644 --- a/docs/behave.rst +++ b/docs/behave.rst @@ -180,6 +180,10 @@ You may see the same information presented below at any time using ``behave Alias for --no-snippets --no-source. +.. option:: -r, --runner + + Use own runner class, like: behave.runner:Runner + .. option:: -s, --no-source Don't print the file and line of the step definition with the steps. @@ -566,6 +570,13 @@ Configuration Parameters Alias for --no-snippets --no-source. +.. index:: + single: configuration param; runner + +.. describe:: runner : text + + Use own runner class, like: behave.runner:Runner + .. index:: single: configuration param; show_source diff --git a/features/runner.use_runner_class.feature b/features/runner.use_runner_class.feature new file mode 100644 index 000000000..69eb4d472 --- /dev/null +++ b/features/runner.use_runner_class.feature @@ -0,0 +1,264 @@ +Feature: User-provided runner class (extension-point) + + As a user/developer + I want sometimes replace behave's default runner with an own runner class + So that I easily support special use cases where a different test runner is needed. + + . NOTES: + . * This extension-point was already available internally + . * Now you can specify the runner_class in the config-file + . or as a command-line option. + . + . XXX_TODO: runner-alias(es) + + + Background: + Given a new working directory + And a file named "features/steps/use_steplib_behave4cmd.py" with: + """ + import behave4cmd0.passing_steps + import behave4cmd0.failing_steps + import behave4cmd0.note_steps + """ + And a file named "features/environment.py" with: + """ + from __future__ import print_function + import os + from fnmatch import fnmatch + + def print_environment(pattern=None): + names = ["PYTHONPATH", "PATH"] + for name in names: + value = os.environ.get(name, None) + print("DIAG: env: %s = %r" % (name, value)) + + def before_all(ctx): + print_environment() + """ + And a file named "features/passing.feature" with: + """ + @pass + Feature: Alice + Scenario: A1 + Given a step passes + When another step passes + + Scenario: A2 + When some step passes + """ + + + Rule: Use the default runner if no runner is specified + + @cmdline + @default_runner + @default_runner. + Scenario Outline: Use default runner option () + Given a file named "behave.ini" does not exist + When I run "behave -f plain features" + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 0 skipped + 3 steps passed, 0 failed, 0 skipped, 0 undefined + """ + And note that "" + + Examples: + | short_id | runner_options | case | + | NO_RUNNER | | no runner options are used on cmdline and config-fule. | + | DEFAULT_RUNNER | --runner=behave.runner:Runner | runner option with default runner class is used on cmdline. | + + + @own_runner + Rule: Use own runner class + + Background: Provide own Runner Classes + Given a file named "my/good_example.py" with: + """ + from __future__ import print_function + from behave.runner import Runner + + class MyRunner1(Runner): + def run(self): + print("RUNNER=MyRunner1") + return super(MyRunner1, self).run() + + class MyRunner2(Runner): + def run(self): + print("RUNNER=MyRunner2") + return super(MyRunner2, self).run() + """ + And an empty file named "my/__init__.py" + + @own_runner + @cmdline + Scenario: Use own runner on cmdline + Given a file named "behave.ini" does not exist + When I run "behave -f plain --runner=my.good_example:MyRunner1" + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 0 skipped + 3 steps passed, 0 failed, 0 skipped, 0 undefined + """ + And the command output should contain: + """ + RUNNER=MyRunner1 + """ + + @own_runner + @config_file + Scenario: Use runner in config-file + Given a file named "behave.ini" with: + """ + [behave] + runner = my.good_example:MyRunner2 + """ + When I run "behave -f plain features" + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 0 skipped + 3 steps passed, 0 failed, 0 skipped, 0 undefined + """ + And the command output should contain: + """ + RUNNER=MyRunner2 + """ + + @own_runner + @cmdline + @config_file + Scenario: Use runner on command-line overrides runner in config-file + Given a file named "behave.ini" with: + """ + [behave] + runner = my.good_example:MyRunner2 + """ + When I run "behave -f plain --runner=my.good_example:MyRunner1" + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 0 skipped + 3 steps passed, 0 failed, 0 skipped, 0 undefined + """ + And the command output should contain: + """ + RUNNER=MyRunner1 + """ + + + Rule: Bad cases on command-line + Background: Bad runner classes + Given a file named "my/bad_example.py" with: + """ + from behave.api.runner import IRunner + class NotRunner1(object): pass + class NotRunner2(object): + run = True + + class ImcompleteRunner1(IRunner): # NO-CTOR + def run(self): pass + + class ImcompleteRunner2(IRunner): # NO-RUN-METHOD + def __init__(self, config): + self.config = config + + class ImcompleteRunner3(IRunner): # BAD-RUN-METHOD + def __init__(self, config): + self.config = config + run = True + + CONSTANT_1 = 42 + + def return_none(*args, **kwargs): + return None + """ + And an empty file named "my/__init__.py" + + Scenario Outline: Bad cmdline with --runner= () + When I run "behave -f plain --runner=" + Then it should fail with: + """ + + """ + But note that "problem: " + + Examples: + | syndrome | runner_class | failure_message | case | + | UNKNOWN_MODULE | unknown:Runner1 | ModuleNotFoundError: No module named 'unknown' | Python module does not exist (or was not found) | + | UNKNOWN_CLASS | my:UnknownClass | ClassNotFoundError: my:UnknownClass | Runner class does not exist in module. | + | UNKNOWN_CLASS | my.bad_example:42 | ClassNotFoundError: my.bad_example:42 | runner_class=number | + | BAD_CLASS | my.bad_example:NotRunner1 | InvalidClassError: my.bad_example:NotRunner1: not subclass-of behave.api.runner.IRunner | Specified runner_class is not a runner. | + | BAD_CLASS | my.bad_example:NotRunner2 | InvalidClassError: my.bad_example:NotRunner2: not subclass-of behave.api.runner.IRunner | Runner class does not behave properly. | + | BAD_FUNCTION | my.bad_example:return_none | InvalidClassError: my.bad_example:return_none: not a class | runner_class is a function. | + | BAD_VALUE | my.bad_example:CONSTANT_1 | InvalidClassError: my.bad_example:CONSTANT_1: not a class | runner_class is a constant number. | + | INCOMPLETE_CLASS | my.bad_example:ImcompleteRunner1 | TypeError: Can't instantiate abstract class ImcompleteRunner1 with abstract methods __init__ | Constructor is missing | + | INCOMPLETE_CLASS | my.bad_example:ImcompleteRunner2 | TypeError: Can't instantiate abstract class ImcompleteRunner2 with abstract methods run | run() method is missing | + + + Scenario Outline: Weird cmdline with --runner= () + When I run "behave -f plain --runner=" + Then it should fail with: + """ + + """ + But note that "problem: " + + Examples: + | syndrome | runner_class | failure_message | case | + | NO_CLASS | 42 | ConfigError: runner=42 (RUNNER-ALIAS NOT FOUND) | runner_class.module=number | + | NO_CLASS | 4.23 | ConfigError: runner=4.23 (RUNNER-ALIAS NOT FOUND) | runner_class.module=floating-point-number | + | NO_CLASS | True | ConfigError: runner=True (RUNNER-ALIAS NOT FOUND) | runner_class.module=bool | + | INVALID_CLASS | my.bad_example:ImcompleteRunner3 | InvalidClassError: my.bad_example:ImcompleteRunner3: run() is not callable | run is a bool-value (no method) | + + + Rule: Bad cases with config-file + + Background: + Given a file named "my/bad_example.py" with: + """ + from behave.api.runner import IRunner + class NotRunner1(object): pass + class NotRunner2(object): + run = True + + class ImcompleteRunner1(IRunner): # NO-CTOR + def run(self): pass + + class ImcompleteRunner2(IRunner): # NO-RUN-METHOD + def __init__(self, config): + self.config = config + + class ImcompleteRunner3(IRunner): # BAD-RUN-METHOD + def __init__(self, config): + self.config = config + run = True + + CONSTANT_1 = 42 + + def return_none(*args, **kwargs): + return None + """ + And an empty file named "my/__init__.py" + + Scenario Outline: Bad config-file.runner= () + Given a file named "behave.ini" with: + """ + [behave] + runner = + """ + When I run "behave -f plain" + Then it should fail with: + """ + + """ + But note that "problem: " + + Examples: + | syndrome | runner_class | failure_message | case | + | UNKNOWN_MODULE | unknown:Runner1 | ModuleNotFoundError: No module named 'unknown' | Python module does not exist (or was not found) | + | UNKNOWN_CLASS | my:UnknownClass | ClassNotFoundError: my:UnknownClass | Runner class does not exist in module. | + | BAD_CLASS | my.bad_example:NotRunner1 | InvalidClassError: my.bad_example:NotRunner1: not subclass-of behave.api.runner.IRunner | Specified runner_class is not a runner. | + | BAD_CLASS | my.bad_example:NotRunner2 | InvalidClassError: my.bad_example:NotRunner2: not subclass-of behave.api.runner.IRunner | Runner class does not behave properly. | + | BAD_FUNCTION | my.bad_example:return_none | InvalidClassError: my.bad_example:return_none: not a class | runner_class=function | + | BAD_VALUE | my.bad_example:CONSTANT_1 | InvalidClassError: my.bad_example:CONSTANT_1: not a class | runner_class=number | + + diff --git a/py.requirements/testing.txt b/py.requirements/testing.txt index 94bffdee1..85f08b93a 100644 --- a/py.requirements/testing.txt +++ b/py.requirements/testing.txt @@ -15,6 +15,8 @@ mock >= 4.0; python_version >= '3.6' PyHamcrest >= 2.0.2; python_version >= '3.0' PyHamcrest < 2.0; python_version < '3.0' +jsonschema + # -- NEEDED: By some tests (as proof of concept) # NOTE: path.py-10.1 is required for python2.6 # HINT: path.py => path (python-install-package was renamed for python3) diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index 025a6d06f..9fa68e877 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -4,8 +4,11 @@ import six import pytest from behave import configuration +from behave.api.runner import IRunner from behave.configuration import Configuration, UserData -from behave.runner import Runner as BaseRunner +from behave.exception import ClassNotFoundError, InvalidClassError +from behave.runner import Runner as DefaultRunnerClass +from behave.runner_plugin import RunnerPlugin from unittest import TestCase @@ -27,7 +30,9 @@ answer = 42 """ - +# ----------------------------------------------------------------------------- +# TEST SUPPORT: +# ----------------------------------------------------------------------------- ROOTDIR_PREFIX = "" if sys.platform.startswith("win"): # -- OR: ROOTDIR_PREFIX = os.path.splitdrive(sys.executable) @@ -38,10 +43,9 @@ ROOTDIR_PREFIX = os.environ.get("BEHAVE_ROOTDIR_PREFIX", ROOTDIR_PREFIX_DEFAULT) -class CustomTestRunner(BaseRunner): - """Custom, dummy runner""" - - +# ----------------------------------------------------------------------------- +# TEST SUITE: +# ----------------------------------------------------------------------------- class TestConfiguration(object): def test_read_file(self): @@ -97,19 +101,107 @@ def test_settings_with_stage_from_envvar(self): assert "STAGE2_environment.py" == config.environment_file del os.environ["BEHAVE_STAGE"] - def test_settings_runner_class(self, capsys): + +# ----------------------------------------------------------------------------- +# TEST SUPPORT: +# ----------------------------------------------------------------------------- +# -- TEST-RUNNER CLASS EXAMPLES: +class CustomTestRunner(IRunner): + """Custom, dummy runner""" + + def __init__(self, config, **kwargs): + self.config = config + + def run(self): + return True # OOPS: Failed. + + +# -- BAD TEST-RUNNER CLASS EXAMPLES: +# PROBLEM: Is not a class +INVALID_TEST_RUNNER_CLASS0 = True + + +class InvalidTestRunner1(object): + """PROBLEM: Missing IRunner.register(InvalidTestRunner).""" + def run(self, features): pass + + +class InvalidTestRunner2(IRunner): + """PROBLEM: run() method signature differs""" + def run(self, features): pass + + +# ----------------------------------------------------------------------------- +# TEST SUITE: +# ----------------------------------------------------------------------------- +class TestConfigurationRunner(object): + """Test the runner-plugin configuration.""" + + def test_runner_default(self, capsys): config = Configuration("") - assert BaseRunner == config.runner_class + runner = RunnerPlugin().make_runner(config) + assert config.runner == configuration.DEFAULT_RUNNER_CLASS_NAME + assert isinstance(runner, DefaultRunnerClass) + + def test_runner_with_normal_runner_class(self, capsys): + config = Configuration(["--runner=behave.runner:Runner"]) + runner = RunnerPlugin().make_runner(config) + assert isinstance(runner, DefaultRunnerClass) + + def test_runner_with_own_runner_class(self): + config = Configuration(["--runner=tests.unit.test_configuration:CustomTestRunner"]) + runner = RunnerPlugin().make_runner(config) + assert isinstance(runner, CustomTestRunner) + + def test_runner_with_unknown_module(self, capsys): + with pytest.raises(ImportError): + config = Configuration(["--runner=unknown_module:Runner"]) + runner = RunnerPlugin().make_runner(config) + captured = capsys.readouterr() + if six.PY2: + assert "No module named unknown_module" in captured.out + else: + assert "No module named 'unknown_module'" in captured.out - def test_settings_runner_class_custom(self, capsys): - config = Configuration(["--runner-class=tests.unit.test_configuration.CustomTestRunner"]) - assert CustomTestRunner == config.runner_class + def test_runner_with_unknown_class(self, capsys): + with pytest.raises(ClassNotFoundError) as exc_info: + config = Configuration(["--runner=behave.runner:UnknownRunner"]) + RunnerPlugin().make_runner(config) - def test_settings_runner_class_invalid(self, capsys): - with pytest.raises(SystemExit): - Configuration(["--runner-class=does.not.exist.Runner"]) captured = capsys.readouterr() - assert "No module named 'does.not.exist.Runner' was found." in captured.err + assert "FAILED to load runner-class" in captured.out + assert "ClassNotFoundError: behave.runner:UnknownRunner" in captured.out + + expected = "behave.runner:UnknownRunner" + assert exc_info.type is ClassNotFoundError + assert exc_info.match(expected) + + def test_runner_with_invalid_runner_class0(self): + with pytest.raises(TypeError) as exc_info: + config = Configuration(["--runner=tests.unit.test_configuration:INVALID_TEST_RUNNER_CLASS0"]) + RunnerPlugin().make_runner(config) + + expected = "tests.unit.test_configuration:INVALID_TEST_RUNNER_CLASS0: not a class" + assert exc_info.type is InvalidClassError + assert exc_info.match(expected) + + def test_runner_with_invalid_runner_class1(self): + with pytest.raises(TypeError) as exc_info: + config = Configuration(["--runner=tests.unit.test_configuration:InvalidTestRunner1"]) + RunnerPlugin().make_runner(config) + + expected = "tests.unit.test_configuration:InvalidTestRunner1: not subclass-of behave.api.runner.IRunner" + assert exc_info.type is InvalidClassError + assert exc_info.match(expected) + + def test_runner_with_invalid_runner_class2(self): + with pytest.raises(TypeError) as exc_info: + config = Configuration(["--runner=tests.unit.test_configuration:InvalidTestRunner2"]) + RunnerPlugin().make_runner(config) + + expected = "Can't instantiate abstract class InvalidTestRunner2 with abstract methods __init__" + assert exc_info.type is TypeError + assert exc_info.match(expected) class TestConfigurationUserData(TestCase): From 77d0f6a508262fc5b4d1bbbb32b9fc2aef77ed4a Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 13 Nov 2022 14:38:59 +0100 Subject: [PATCH 067/240] behave4cmd0: Add steps to match command-output w/ regexp pattern * Then the command output should match "{pattern}" * Then the command output should match -- with step.text="{pattern}" * Then the command output should not match "{pattern}" * Then the command output should not match -- with step.text="{pattern}" --- behave4cmd0/command_steps.py | 41 ++++++++++++++++++++++++++++++++ behave4cmd0/textutil.py | 46 +++++++++++++++++++----------------- 2 files changed, 65 insertions(+), 22 deletions(-) diff --git a/behave4cmd0/command_steps.py b/behave4cmd0/command_steps.py index f9bc980c2..f3a3797bb 100644 --- a/behave4cmd0/command_steps.py +++ b/behave4cmd0/command_steps.py @@ -420,6 +420,47 @@ def step_command_output_should_contain_not_exactly_with_multiline_text(context): step_command_output_should_not_contain_exactly_text(context, text) +# ----------------------------------------------------------------------------- +# STEP DEFINITIONS: command output should/should_not match +# ----------------------------------------------------------------------------- +@then(u'the command output should match /{pattern}/') +@then(u'the command output should match "{pattern}"') +def step_command_output_should_match_pattern(context, pattern): + """Verifies that command output matches the ``pattern``. + + :param pattern: Regular expression pattern to use (as string or compiled). + + .. code-block:: gherkin + + # -- STEP-SCHEMA: Then the command output should match /{pattern}/ + Scenario: + When I run `echo Hello world` + Then the command output should match /Hello \\w+/ + """ + # steputil.assert_attribute_exists(context, "command_result") + text = context.command_result.output.strip() + textutil.assert_text_should_match_pattern(text, pattern) + +@then(u'the command output should not match /{pattern}/') +@then(u'the command output should not match "{pattern}"') +def step_command_output_should_not_match_pattern(context, pattern): + # steputil.assert_attribute_exists(context, "command_result") + text = context.command_result.output + textutil.assert_text_should_not_match_pattern(text, pattern) + +@then(u'the command output should match') +def step_command_output_should_match_with_multiline_text(context): + assert context.text is not None, "ENSURE: multiline text is provided." + pattern = context.text + step_command_output_should_match_pattern(context, pattern) + +@then(u'the command output should not match') +def step_command_output_should_not_match_with_multiline_text(context): + assert context.text is not None, "ENSURE: multiline text is provided." + pattern = context.text + step_command_output_should_not_match_pattern(context, pattern) + + # ----------------------------------------------------------------------------- # STEPS FOR: Directories # ----------------------------------------------------------------------------- diff --git a/behave4cmd0/textutil.py b/behave4cmd0/textutil.py index c62845881..ea63df4a8 100644 --- a/behave4cmd0/textutil.py +++ b/behave4cmd0/textutil.py @@ -8,7 +8,7 @@ from __future__ import absolute_import, print_function from hamcrest import assert_that, is_not, equal_to, contains_string -# DISABLED: from behave4cmd.hamcrest_text import matches_regexp +from hamcrest import matches_regexp import codecs DEBUG = False @@ -300,27 +300,29 @@ def assert_normtext_should_not_contain(text, unexpected_part): assert_text_should_not_contain(actual_text, unexpected_part2) -# def assert_text_should_match_pattern(text, pattern): -# """ -# Assert that the :attr:`text` matches the regular expression :attr:`pattern`. -# -# :param text: Multi-line text (as string). -# :param pattern: Regular expression pattern (as string, compiled regexp). -# :raise: AssertionError, if text matches not the pattern. -# """ -# assert_that(text, matches_regexp(pattern)) -# -# def assert_text_should_not_match_pattern(text, pattern): -# """ -# Assert that the :attr:`text` matches not the regular expression -# :attr:`pattern`. -# -# :param text: Multi-line text (as string). -# :param pattern: Regular expression pattern (as string, compiled regexp). -# :raise: AssertionError, if text matches the pattern. -# """ -# assert_that(text, is_not(matches_regexp(pattern))) -# +def assert_text_should_match_pattern(text, pattern): + """ + Assert that the :attr:`text` matches the regular expression :attr:`pattern`. + + :param text: Multi-line text (as string). + :param pattern: Regular expression pattern (as string, compiled regexp). + :raise: AssertionError, if text matches not the pattern. + """ + assert_that(text, matches_regexp(pattern)) + + +def assert_text_should_not_match_pattern(text, pattern): + """ + Assert that the :attr:`text` matches not the regular expression + :attr:`pattern`. + + :param text: Multi-line text (as string). + :param pattern: Regular expression pattern (as string, compiled regexp). + :raise: AssertionError, if text matches the pattern. + """ + assert_that(text, is_not(matches_regexp(pattern))) + + # ----------------------------------------------------------------------------- # MAIN: # ----------------------------------------------------------------------------- From 61868019c06a559149b45d69d04a21821254daf4 Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 14 Nov 2022 18:54:00 +0100 Subject: [PATCH 068/240] Improve diagnostics if a BAD_FORMAT(TER) is used * Show problem(s) together with BAD FORMAT(TER): ModuleNotFoundError, ClassNotFoundError, InvalidClassError, LookupError --- CHANGES.rst | 1 + behave/configuration.py | 39 ++++++++++++++++------- behave/exception.py | 6 ++++ features/formatter.user_defined.feature | 20 ++++++------ features/runner.unknown_formatter.feature | 31 +++++++++++++++--- 5 files changed, 70 insertions(+), 27 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index fa01a42c4..a1294bfd3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -32,6 +32,7 @@ CLEANUPS: ENHANCEMENTS: +* User-defined formatters: Improve diagnostics if bad formatter is used (ModuleNotFound, ...) * active-tags: Added ``ValueObject`` class for enhanced control of comparison mechanism (supports: equals, less-than, less-or-equal, greater-than, greater-or-equal, contains, ...) * Add support for Gherkin v6 grammar and syntax in ``*.feature`` files diff --git a/behave/configuration.py b/behave/configuration.py index 4ede4d8d2..ea489a4f9 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -278,7 +278,7 @@ def to_string(level): (("-r", "--runner"), dict(dest="runner", action="store", metavar="RUNNER_CLASS", default=DEFAULT_RUNNER_CLASS_NAME, - help="Use own runner class, like: behave.runner:Runner")), + help='Use own runner class, like: "behave.runner:Runner"')), (("-s", "--no-source"), dict(action="store_false", dest="show_source", @@ -583,6 +583,7 @@ def __init__(self, command_args=None, load_config=True, verbose=None, self.steps_catalog = None self.userdata = None self.wip = None + self.verbose = verbose defaults = self.defaults.copy() for name, value in six.iteritems(kwargs): @@ -669,7 +670,7 @@ def __init__(self, command_args=None, load_config=True, verbose=None, self.setup_stage(self.stage) self.setup_model() self.setup_userdata() - self.setup_runners() + self.setup_runner_aliases() # -- FINALLY: Setup Reporters and Formatters # NOTE: Reporters and Formatters can now use userdata information. @@ -683,9 +684,13 @@ def __init__(self, command_args=None, load_config=True, verbose=None, self.reporters.append(SummaryReporter(self)) self.setup_formats() - unknown_formats = self.collect_unknown_formats() - if unknown_formats: - parser.error("format=%s is unknown" % ", ".join(unknown_formats)) + bad_formats_and_errors = self.select_bad_formats_with_errors() + if bad_formats_and_errors: + bad_format_parts = [] + for name, error in bad_formats_and_errors: + message = "%s (problem: %s)" % (name, error) + bad_format_parts.append(message) + parser.error("BAD_FORMAT=%s" % ", ".join(bad_format_parts)) def setup_outputs(self, args_outfiles=None): if self.outputs: @@ -708,20 +713,30 @@ def setup_formats(self): for name, scoped_class_name in self.more_formatters.items(): _format_registry.register_as(name, scoped_class_name) - def setup_runners(self): + def setup_runner_aliases(self): if self.more_runners: for name, scoped_class_name in self.more_runners.items(): self.runner_aliases[name] = scoped_class_name - def collect_unknown_formats(self): - unknown_formats = [] + def select_bad_formats_with_errors(self): + bad_formats = [] if self.format: for format_name in self.format: - if (format_name == "help" or - _format_registry.is_formatter_valid(format_name)): + formatter_valid = _format_registry.is_formatter_valid(format_name) + if format_name == "help" or formatter_valid: continue - unknown_formats.append(format_name) - return unknown_formats + + try: + _ = _format_registry.select_formatter_class(format_name) + bad_formats.append((format_name, "InvalidClassError")) + except Exception as e: + formatter_error = e.__class__.__name__ + if formatter_error == "KeyError": + formatter_error = "LookupError" + if self.verbose: + formatter_error += ": %s" % str(e) + bad_formats.append((format_name, formatter_error)) + return bad_formats @staticmethod def build_name_re(names): diff --git a/behave/exception.py b/behave/exception.py index de03262dc..ce159dad3 100644 --- a/behave/exception.py +++ b/behave/exception.py @@ -7,6 +7,8 @@ """ from __future__ import absolute_import +# -- USE MODERN EXCEPTION CLASSES: +# COMPATIBILITY: Emulated if not supported yet by Python version. from behave.compat.exceptions import FileNotFoundError, ModuleNotFoundError @@ -50,6 +52,10 @@ class ClassNotFoundError(ImportError): """Used if module to import exists, but class with this name does not exist.""" +class ObjectNotFoundError(ImportError): + """Used if module to import exists, but object with this name does not exist.""" + + class InvalidClassError(TypeError): """Used if the specified class has the wrong type: diff --git a/features/formatter.user_defined.feature b/features/formatter.user_defined.feature index 04f0701c0..a1527ec19 100644 --- a/features/formatter.user_defined.feature +++ b/features/formatter.user_defined.feature @@ -114,14 +114,14 @@ Feature: Use a user-defined Formatter When I run "behave -f features/passing.feature" Then it should fail with: """ - error: format= is unknown + error: BAD_FORMAT= (problem: ) """ Examples: - | formatter.class | case | - | my.unknown_module:SomeFormatter | Unknown module | - | behave_ext.formatter_one:UnknownClass | Unknown class | - | behave_ext.formatter_one:NotAFormatter | Invalid Formatter class | + | formatter.class | formatter.error | case | + | my.unknown_module:SomeFormatter | ModuleNotFoundError | Unknown module | + | behave_ext.formatter_one:UnknownClass | ClassNotFoundError | Unknown class | + | behave_ext.formatter_one:NotAFormatter | InvalidClassError | Invalid Formatter class | @formatter.registered_by_name @@ -185,12 +185,12 @@ Feature: Use a user-defined Formatter When I run "behave -f features/passing.feature" Then it should fail with: """ - error: format= is unknown + error: BAD_FORMAT= (problem: ) """ Examples: - | formatter.name | formatter.class | case | - | unknown1 | my.unknown_module:SomeFormatter | Unknown module | - | unknown2 | behave_ext.formatter_one:UnknownClass | Unknown class | - | invalid1 | behave_ext.formatter_one:NotAFormatter | Invalid Formatter class | + | formatter.name | formatter.class | formatter.error | case | + | unknown1 | my.unknown_module:SomeFormatter | ModuleNotFoundError | Unknown module | + | unknown2 | behave_ext.formatter_one:UnknownClass | ClassNotFoundError | Unknown class | + | invalid1 | behave_ext.formatter_one:NotAFormatter | InvalidClassError | Invalid Formatter class | diff --git a/features/runner.unknown_formatter.feature b/features/runner.unknown_formatter.feature index e8cc61dbc..74c9b29ea 100644 --- a/features/runner.unknown_formatter.feature +++ b/features/runner.unknown_formatter.feature @@ -1,23 +1,44 @@ Feature: When an unknown formatter is used - Scenario: Unknown formatter is used + Scenario: Unknown formatter alias is used When I run "behave -f unknown1" Then it should fail with: """ - behave: error: format=unknown1 is unknown + behave: error: BAD_FORMAT=unknown1 (problem: LookupError) + """ + + Scenario: Unknown formatter class is used (case: unknown module) + When I run "behave -f behave.formatter.unknown:UnknownFormatter" + Then it should fail with: + """ + behave: error: BAD_FORMAT=behave.formatter.unknown:UnknownFormatter (problem: ModuleNotFoundError) + """ + + Scenario: Unknown formatter class is used (case: unknown class) + When I run "behave -f behave.formatter.plain:UnknownFormatter" + Then it should fail with: + """ + behave: error: BAD_FORMAT=behave.formatter.plain:UnknownFormatter (problem: ClassNotFoundError) + """ + + Scenario: Invalid formatter class is used + When I run "behave -f behave.formatter.base:StreamOpener" + Then it should fail with: + """ + behave: error: BAD_FORMAT=behave.formatter.base:StreamOpener (problem: InvalidClassError) """ Scenario: Unknown formatter is used together with another formatter When I run "behave -f plain -f unknown1" Then it should fail with: """ - behave: error: format=unknown1 is unknown + behave: error: BAD_FORMAT=unknown1 (problem: LookupError) """ Scenario: Two unknown formatters are used - When I run "behave -f plain -f unknown1 -f tags -f unknown2" + When I run "behave -f plain -f unknown1 -f tags -f behave.formatter.plain:UnknownFormatter" Then it should fail with: """ - behave: error: format=unknown1, unknown2 is unknown + behave: error: BAD_FORMAT=unknown1 (problem: LookupError), behave.formatter.plain:UnknownFormatter (problem: ClassNotFoundError) """ From 5fac450548ad4d173877b428dfb6a60587d35410 Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 14 Nov 2022 19:00:01 +0100 Subject: [PATCH 069/240] Add runner_plugin to simplify to use other runner-classes * Simplify use in main() function * Support runner aliases via behave config-file (simplifies use of user-defined runners) * Better error diagnostics if a "BAD RUNNER" is used (ModuleNotFoundError, ClassNotFoundError, InvalidClassError, ...) * Move error case out-of configuration class. --- behave.ini | 6 +- behave/__main__.py | 58 ++++-- behave/api/runner.py | 2 +- behave/importer.py | 89 ++++++--- behave/runner.py | 9 +- behave/runner_plugin.py | 102 +++++++---- features/runner.use_runner_class.feature | 64 +++---- py.requirements/testing.txt | 5 +- setup.py | 2 + tests/unit/test_configuration.py | 104 ----------- tests/unit/test_runner_plugin.py | 220 +++++++++++++++++++++++ 11 files changed, 448 insertions(+), 213 deletions(-) create mode 100644 tests/unit/test_runner_plugin.py diff --git a/behave.ini b/behave.ini index 2c30ef342..379b90e66 100644 --- a/behave.ini +++ b/behave.ini @@ -38,11 +38,13 @@ logging_level = INFO allure = allure_behave.formatter:AllureFormatter html = behave_html_formatter:HTMLFormatter +# -- RUNNER ALIASES: +# SCHEMA: runner_alias = scoped_runner.module_name:class_name +# EXAMPLE: default = behave.runner:Runner [behave.runners] -default = behave.runner:Runner -# PREPARED: +# -- PREPARED: # [behave] # format = ... missing_steps ... # output = ... features/steps/missing_steps.py ... diff --git a/behave/__main__.py b/behave/__main__.py index c6520e6da..2597466c4 100644 --- a/behave/__main__.py +++ b/behave/__main__.py @@ -97,12 +97,16 @@ def run_behave(config, runner_class=None): (len(config.outputs), len(config.format))) return 1 + if config.runner == "help": + print_runners(config.runner_aliases) + return 0 + # -- MAIN PART: runner = None failed = True try: reset_runtime() - runner = RunnerPlugin(runner_class=runner_class).make_runner(config) + runner = RunnerPlugin(runner_class).make_runner(config) # print("USING RUNNER: {0}".format(make_scoped_class_name(runner))) failed = runner.run() except ParserError as e: @@ -196,7 +200,7 @@ def print_language_help(config, stream=None): return 0 -def print_formatters(title=None, stream=None): +def print_formatters(title=None, file=None): """Prints the list of available formatters and their description. :param title: Optional title (as string). @@ -205,18 +209,48 @@ def print_formatters(title=None, stream=None): from behave.formatter._registry import format_items from operator import itemgetter - if stream is None: - stream = sys.stdout - if title: - stream.write(u"%s\n" % title) + print_ = lambda text: print(text, file=file) + + formatter_items = sorted(format_items(resolved=True), key=itemgetter(0)) + formatter_names = [item[0] for item in formatter_items] + column_size = compute_words_maxsize(formatter_names) + schema = u" %-"+ _text(column_size) +"s %s" - format_items = sorted(format_items(resolved=True), key=itemgetter(0)) - format_names = [item[0] for item in format_items] - column_size = compute_words_maxsize(format_names) - schema = u" %-"+ _text(column_size) +"s %s\n" - for name, formatter_class in format_items: + if title: + print_(u"%s" % title) + for name, formatter_class in formatter_items: formatter_description = getattr(formatter_class, "description", "") - stream.write(schema % (name, formatter_description)) + formatter_error = getattr(formatter_class, "error", None) + if formatter_error: + # -- DIAGNOSTICS: Indicate if formatter definition has a problem. + formatter_description = formatter_error + print_(schema % (name, formatter_description)) + + +def print_runners(runner_aliases, file=None): + """Print a list of known test runner classes that can be used with the + command-line option ``--runner=RUNNER_CLASS``. + + :param runner_aliases: List of known runner aliases (as strings) + :param file: Optional, to redirect print-output to a file. + """ + # MAYBE: file = file or sys.stdout + print_ = lambda text: print(text, file=file) + + title = "AVAILABLE RUNNERS:" + runner_names = sorted(runner_aliases.keys()) + column_size = compute_words_maxsize(runner_names) + schema = u" %-"+ _text(column_size) +"s = %s%s" + + print_(title) + for runner_name in runner_names: + scoped_class_name = runner_aliases[runner_name] + annotation = "" + problem = RunnerPlugin.make_problem_description(scoped_class_name) + if problem: + annotation = " (problem: %s)" % problem + + print_(schema % (runner_name, scoped_class_name, annotation)) # --------------------------------------------------------------------------- diff --git a/behave/api/runner.py b/behave/api/runner.py index 2a1e518a6..b35935aca 100644 --- a/behave/api/runner.py +++ b/behave/api/runner.py @@ -14,7 +14,7 @@ @add_metaclass(ABCMeta) -class IRunner(object): +class ITestRunner(object): """Interface that a test runner-class should provide: * Constructor: with config parameter object (at least) and some kw-args. diff --git a/behave/importer.py b/behave/importer.py index 2c217f9fa..3779fa3f9 100644 --- a/behave/importer.py +++ b/behave/importer.py @@ -9,6 +9,7 @@ import importlib import inspect from behave._types import Unknown +from behave.exception import ClassNotFoundError, ModuleNotFoundError def parse_scoped_name(scoped_name): @@ -25,7 +26,7 @@ def parse_scoped_name(scoped_name): schema = "%s: Missing ':' (colon) as module-to-name seperator'" raise ValueError(schema % scoped_name) module_name, object_name = scoped_name.rsplit(":", 1) - return module_name, object_name + return module_name, object_name or "" def make_scoped_class_name(obj): @@ -38,16 +39,35 @@ def make_scoped_class_name(obj): class_name = obj.__name__ else: class_name = obj.__class__.__name__ - return "{0}:{1}".format(obj.__module__, class_name) + module_name = getattr(obj, "__module__", None) + if module_name: + return "{0}:{1}".format(obj.__module__, class_name) + # -- OTHERWISE: Builtin data type + return class_name def load_module(module_name): - return importlib.import_module(module_name) + try: + return importlib.import_module(module_name) + except ModuleNotFoundError: + # -- SINCE: Python 3.6 (special kind of ImportError) + raise + except ImportError as e: + # -- CASE: Python < 3.6 (Python 2.7, ...) + msg = str(e) + if not msg.endswith("'"): + # -- NOTE: Emulate ModuleNotFoundError message: + # "No module named '{module_name}'" + prefix, module_name = msg.rsplit(" ", 1) + msg = "{0} '{1}'".format(prefix, module_name) + raise ModuleNotFoundError(msg) class LazyObject(object): - """ - Provides a placeholder for an object that should be loaded lazily. + """Provides a placeholder for an class/object that should be loaded lazily. + + It stores the module-name, object-name/class-name and + imports it later (on demand) when this lazy-object is accessed. """ def __init__(self, module_name, object_name=None): @@ -57,24 +77,33 @@ def __init__(self, module_name, object_name=None): self.module_name = module_name self.object_name = object_name self.resolved_object = None + self.error = None + # -- PYTHON DESCRIPTOR PROTOCOL: def __get__(self, obj=None, type=None): # pylint: disable=redefined-builtin - """ - Implement descriptor protocol, + """Implement descriptor protocol, useful if this class is used as attribute. + :return: Real object (lazy-loaded if necessary). - :raise ImportError: If module or object cannot be imported. + :raise ModuleNotFoundError: If module is not found or cannot be imported. + :raise ClassNotFoundError: If class/object is not found in module. """ __pychecker__ = "unusednames=obj,type" resolved_object = None if not self.resolved_object: # -- SETUP-ONCE: Lazy load the real object. - module = load_module(self.module_name) - resolved_object = getattr(module, self.object_name, Unknown) - if resolved_object is Unknown: - msg = "%s: %s is Unknown" % (self.module_name, self.object_name) - raise ImportError(msg) - self.resolved_object = resolved_object + try: + module = load_module(self.module_name) + resolved_object = getattr(module, self.object_name, Unknown) + if resolved_object is Unknown: + # OLD: msg = "%s: %s is Unknown" % (self.module_name, self.object_name) + scoped_name = "%s:%s" % (self.module_name, self.object_name) + raise ClassNotFoundError(scoped_name) + self.resolved_object = resolved_object + except ImportError as e: + self.error = "%s: %s" % (e.__class__.__name__, e) + raise + # OR: resolved_object = self return resolved_object def __set__(self, obj, value): @@ -87,27 +116,45 @@ def get(self): class LazyDict(dict): - """ - Provides a dict that supports lazy loading of objects. + """Provides a dict that supports lazy loading of classes/objects. A LazyObject is provided as placeholder for a value that should be loaded lazily. + + EXAMPLE: + + .. code-block:: python + + from behave.importer import LazyDict + + the_plugin_registry = LazyDict({ + "alice": LazyObject("my_module.alice_plugin:AliceClass"), + "bob": LayzObject("my_module.bob_plugin:BobClass"), + }) + + # -- LATER: Import plugin-class module(s) only if needed. + # INTENTION: Pay only (with runtime costs) for what you use. + config.plugin_name = "alice" + plugin_class = the_plugin_registry[config.plugin_name] + ... """ def __getitem__(self, key): - """ - Provides access to stored dict values. + """Provides access to the stored dict value(s). + Implements lazy loading of item value (if necessary). When lazy object is loaded, its value with the dict is replaced with the real value. :param key: Key to access the value of an item in the dict. :return: value - :raises: KeyError if item is not found - :raises: ImportError for a LazyObject that cannot be imported. + :raises KeyError: if item is not found. + :raises ModuleNotFoundError: for a LazyObject module is not found. + :raises ClassNotFoundError: for a LazyObject class/object is not found in module. """ value = dict.__getitem__(self, key) if isinstance(value, LazyObject): - # -- LAZY-LOADING MECHANISM: Load object and replace with lazy one. + # -- LAZY-LOADING MECHANISM: + # Load class/object once and replace the lazy placeholder. value = value.__get__() self[key] = value return value diff --git a/behave/runner.py b/behave/runner.py index d27c529e5..1b7fb5984 100644 --- a/behave/runner.py +++ b/behave/runner.py @@ -13,7 +13,7 @@ import six -from behave.api.runner import IRunner +from behave.api.runner import ITestRunner from behave._types import ExceptionUtil from behave.capture import CaptureController from behave.exception import ConfigError @@ -557,8 +557,7 @@ def path_getrootdir(path): class ModelRunner(object): - """ - Test runner for a behave model (features). + """Test runner for a behave model (features). Provides the core functionality of a test runner and the functional API needed by model elements. @@ -914,5 +913,5 @@ def run_with_paths(self): # ----------------------------------------------------------------------------- # REGISTER RUNNER-CLASSES: # ----------------------------------------------------------------------------- -IRunner.register(ModelRunner) -IRunner.register(Runner) +ITestRunner.register(ModelRunner) +ITestRunner.register(Runner) diff --git a/behave/runner_plugin.py b/behave/runner_plugin.py index 5663855ba..c4e4c5916 100644 --- a/behave/runner_plugin.py +++ b/behave/runner_plugin.py @@ -5,7 +5,7 @@ * scoped-class-name, like: "behave.runner:Runner" (dotted.module:ClassName) * runner-alias (alias mapping provided in config-file "behave.runners" section) -.. code-block:: +.. code-block:: ini # -- FILE: behave.ini # RUNNER-ALIAS EXAMPLE: @@ -16,32 +16,32 @@ from __future__ import absolute_import, print_function import inspect -from behave.api.runner import IRunner -from behave.exception import ConfigError, ClassNotFoundError, InvalidClassError -from behave.importer import parse_scoped_name +from behave.api.runner import ITestRunner +from behave.exception import ConfigError, ClassNotFoundError, InvalidClassError, ModuleNotFoundError +from behave.importer import load_module, make_scoped_class_name, parse_scoped_name from behave._types import Unknown -from importlib import import_module class RunnerPlugin(object): - """Extension point to load an runner_class and create its runner: + """Extension point to load a runner_class and create its runner: * create a runner by using its scoped-class-name * create a runner by using its runner-alias (provided in config-file) * create a runner by using a runner-class - .. code-block:: py + .. code-block:: python # -- EXAMPLE: Provide own test runner-class - from behave.api.runner import IRunner - class MyRunner(IRunner): + from behave.api.runner import ITestRunner + + class MyRunner(ITestRunner): def __init__(self, config, **kwargs): self.config = config def run(self): ... # IMPLEMENTATION DETAILS: Left out here. - .. code-block:: py + .. code-block:: python # -- CASE 1A: Make a runner by using its scoped-class-name plugin = RunnerPlugin("behave.runner:Runner") @@ -60,52 +60,83 @@ def run(self): runner = plugin.make_runner(config) """ def __init__(self, runner_name=None, runner_class=None, runner_aliases=None): + if not runner_class and runner_name and inspect.isclass(runner_name): + # -- USABILITY: Use runner_name as runner_class + runner_class = runner_name + runner_name = make_scoped_class_name(runner_class) self.runner_name = runner_name self.runner_class = runner_class - self.runner_aliases = runner_aliases + self.runner_aliases = runner_aliases or {} @staticmethod def is_runner_class_valid(runner_class): run_method = getattr(runner_class, "run", None) return (inspect.isclass(runner_class) and - issubclass(runner_class, IRunner) and + issubclass(runner_class, ITestRunner) and callable(run_method)) @staticmethod - def load_runner_class(runner_class_name): + def check_runner_class(runner_class, runner_class_name=None): + """Check if a runner class supports the Runner API constraints.""" + if not runner_class_name: + runner_class_name = make_scoped_class_name(runner_class) + + if not inspect.isclass(runner_class): + schema = "{0}: not a class" + raise InvalidClassError(schema.format(runner_class_name)) + elif not issubclass(runner_class, ITestRunner): + schema = "{0}: not subclass-of behave.api.runner.ITestRunner" + raise InvalidClassError(schema.format(runner_class_name)) + + run_method = getattr(runner_class, "run", None) + if not callable(run_method): + schema = "{0}: run() is not callable" + raise InvalidClassError(schema.format(runner_class_name)) + + + @classmethod + def load_runner_class(cls, runner_class_name, verbose=True): """Loads a runner class by using its scoped-class-name, like: `my.module:Class1`. :param runner_class_name: Scoped class-name (as string). :return: Loaded runner-class (on success). + :raises ModleNotFoundError: If module does not exist or not importable. :raises ClassNotFoundError: If module exist, but class was not found. :raises InvalidClassError: If class is invalid (wrong subclass or not a class). :raises ImportError: If module was not found (or other Import-Errors above). """ module_name, class_name = parse_scoped_name(runner_class_name) try: - module = import_module(module_name) + module = load_module(module_name) runner_class = getattr(module, class_name, Unknown) if runner_class is Unknown: raise ClassNotFoundError(runner_class_name) - elif not inspect.isclass(runner_class): - schema = "{0}: not a class" - raise InvalidClassError(schema.format(runner_class_name)) - elif not issubclass(runner_class, IRunner): - schema = "{0}: not subclass-of behave.api.runner.IRunner" - raise InvalidClassError(schema.format(runner_class_name)) - run_method = getattr(runner_class, "run", None) - if not callable(run_method): - schema = "{0}: run() is not callable" - raise InvalidClassError(schema.format(runner_class_name)) + cls.check_runner_class(runner_class, runner_class_name) return runner_class - except ImportError as e: - print("FAILED to load runner-class: %s: %s" % (e.__class__.__name__, e)) - raise - except TypeError as e: - print("FAILED to load runner-class: %s: %s" % (e.__class__.__name__, e)) + except (ImportError, TypeError) as e: + # -- CASE: ModuleNotFoundError, ClassNotFoundError, InvalidClassError, ... + if verbose: + print("BAD_RUNNER_CLASS: FAILED to load runner.class=%s (%s)" % \ + (runner_class_name, e.__class__.__name__)) raise + @classmethod + def make_problem_description(cls, scoped_class_name): + """Check runner class for problems. + + :param scoped_class_name: Runner class name (as string). + :return: EMPTY_STRING, if no problem exists. + :return: Problem exception class name (as string). + """ + # -- STEP: Check runner for problems + problem_description = "" + try: + RunnerPlugin.load_runner_class(scoped_class_name, verbose=False) + except (ImportError, TypeError) as e: + problem_description = e.__class__.__name__ + return problem_description + def make_runner(self, config, **runner_kwargs): """Build a runner either by: @@ -123,18 +154,17 @@ def make_runner(self, config, **runner_kwargs): runner_class = self.runner_class if not runner_class: # -- CASE: Using runner-name (alias) or scoped_class_name. - runner_name = self.runner_name - runner_aliases = self.runner_aliases - if runner_aliases is None: - runner_aliases = config.runner_aliases - if not runner_name: - runner_name = config.runner + runner_name = self.runner_name or config.runner + runner_aliases = {} + runner_aliases.update(config.runner_aliases) + runner_aliases.update(self.runner_aliases) scoped_class_name = runner_aliases.get(runner_name, runner_name) if scoped_class_name == runner_name and ":" not in scoped_class_name: # -- CASE: runner-alias is not in config-file section="behave.runner". raise ConfigError("runner=%s (RUNNER-ALIAS NOT FOUND)" % scoped_class_name) runner_class = self.load_runner_class(scoped_class_name) + else: + self.check_runner_class(runner_class) - assert self.is_runner_class_valid(runner_class) runner = runner_class(config, **runner_kwargs) return runner diff --git a/features/runner.use_runner_class.feature b/features/runner.use_runner_class.feature index 69eb4d472..8a84f6b8a 100644 --- a/features/runner.use_runner_class.feature +++ b/features/runner.use_runner_class.feature @@ -127,7 +127,7 @@ Feature: User-provided runner class (extension-point) @own_runner @cmdline @config_file - Scenario: Use runner on command-line overrides runner in config-file + Scenario: Runner on command-line overrides runner in config-file Given a file named "behave.ini" with: """ [behave] @@ -149,19 +149,19 @@ Feature: User-provided runner class (extension-point) Background: Bad runner classes Given a file named "my/bad_example.py" with: """ - from behave.api.runner import IRunner + from behave.api.runner import ITestRunner class NotRunner1(object): pass class NotRunner2(object): run = True - class ImcompleteRunner1(IRunner): # NO-CTOR + class IncompleteRunner1(ITestRunner): # NO-CTOR def run(self): pass - class ImcompleteRunner2(IRunner): # NO-RUN-METHOD + class IncompleteRunner2(ITestRunner): # NO-RUN-METHOD def __init__(self, config): self.config = config - class ImcompleteRunner3(IRunner): # BAD-RUN-METHOD + class IncompleteRunner3(ITestRunner): # BAD-RUN-METHOD def __init__(self, config): self.config = config run = True @@ -175,23 +175,29 @@ Feature: User-provided runner class (extension-point) Scenario Outline: Bad cmdline with --runner= () When I run "behave -f plain --runner=" - Then it should fail with: + Then it should fail + And the command output should match: """ """ But note that "problem: " Examples: - | syndrome | runner_class | failure_message | case | - | UNKNOWN_MODULE | unknown:Runner1 | ModuleNotFoundError: No module named 'unknown' | Python module does not exist (or was not found) | - | UNKNOWN_CLASS | my:UnknownClass | ClassNotFoundError: my:UnknownClass | Runner class does not exist in module. | - | UNKNOWN_CLASS | my.bad_example:42 | ClassNotFoundError: my.bad_example:42 | runner_class=number | - | BAD_CLASS | my.bad_example:NotRunner1 | InvalidClassError: my.bad_example:NotRunner1: not subclass-of behave.api.runner.IRunner | Specified runner_class is not a runner. | - | BAD_CLASS | my.bad_example:NotRunner2 | InvalidClassError: my.bad_example:NotRunner2: not subclass-of behave.api.runner.IRunner | Runner class does not behave properly. | - | BAD_FUNCTION | my.bad_example:return_none | InvalidClassError: my.bad_example:return_none: not a class | runner_class is a function. | - | BAD_VALUE | my.bad_example:CONSTANT_1 | InvalidClassError: my.bad_example:CONSTANT_1: not a class | runner_class is a constant number. | - | INCOMPLETE_CLASS | my.bad_example:ImcompleteRunner1 | TypeError: Can't instantiate abstract class ImcompleteRunner1 with abstract methods __init__ | Constructor is missing | - | INCOMPLETE_CLASS | my.bad_example:ImcompleteRunner2 | TypeError: Can't instantiate abstract class ImcompleteRunner2 with abstract methods run | run() method is missing | + | syndrome | runner_class | failure_message | case | + | UNKNOWN_MODULE | unknown:Runner1 | ModuleNotFoundError: No module named 'unknown' | Python module does not exist (or was not found) | + | UNKNOWN_CLASS | my:UnknownClass | ClassNotFoundError: my:UnknownClass | Runner class does not exist in module. | + | UNKNOWN_CLASS | my.bad_example:42 | ClassNotFoundError: my.bad_example:42 | runner_class=number | + | BAD_CLASS | my.bad_example:NotRunner1 | InvalidClassError: my.bad_example:NotRunner1: not subclass-of behave.api.runner.ITestRunner | Specified runner_class is not a runner. | + | BAD_CLASS | my.bad_example:NotRunner2 | InvalidClassError: my.bad_example:NotRunner2: not subclass-of behave.api.runner.ITestRunner | Runner class does not behave properly. | + | BAD_FUNCTION | my.bad_example:return_none | InvalidClassError: my.bad_example:return_none: not a class | runner_class is a function. | + | BAD_VALUE | my.bad_example:CONSTANT_1 | InvalidClassError: my.bad_example:CONSTANT_1: not a class | runner_class is a constant number. | + | INCOMPLETE_CLASS | my.bad_example:IncompleteRunner1 | TypeError: Can't instantiate abstract class IncompleteRunner1 with abstract method(s)? __init__ | Constructor is missing | + | INCOMPLETE_CLASS | my.bad_example:IncompleteRunner2 | TypeError: Can't instantiate abstract class IncompleteRunner2 with abstract method(s)? run | run() method is missing | + + # -- PYTHON VERSION SENSITIVITY on INCOMPLETE_CLASS with API TypeError exception: + # Since Python 3.9: "... methods ..." is only used in plural case (if multiple methods are missing). + # "TypeError: Can't instantiate abstract class with abstract method " ( for Python.version >= 3.9) + # "TypeError: Can't instantiate abstract class with abstract methods " (for Python.version < 3.9) Scenario Outline: Weird cmdline with --runner= () @@ -207,7 +213,7 @@ Feature: User-provided runner class (extension-point) | NO_CLASS | 42 | ConfigError: runner=42 (RUNNER-ALIAS NOT FOUND) | runner_class.module=number | | NO_CLASS | 4.23 | ConfigError: runner=4.23 (RUNNER-ALIAS NOT FOUND) | runner_class.module=floating-point-number | | NO_CLASS | True | ConfigError: runner=True (RUNNER-ALIAS NOT FOUND) | runner_class.module=bool | - | INVALID_CLASS | my.bad_example:ImcompleteRunner3 | InvalidClassError: my.bad_example:ImcompleteRunner3: run() is not callable | run is a bool-value (no method) | + | INVALID_CLASS | my.bad_example:IncompleteRunner3 | InvalidClassError: my.bad_example:IncompleteRunner3: run() is not callable | run is a bool-value (no method) | Rule: Bad cases with config-file @@ -215,19 +221,19 @@ Feature: User-provided runner class (extension-point) Background: Given a file named "my/bad_example.py" with: """ - from behave.api.runner import IRunner + from behave.api.runner import ITestRunner class NotRunner1(object): pass class NotRunner2(object): run = True - class ImcompleteRunner1(IRunner): # NO-CTOR + class IncompleteRunner1(ITestRunner): # NO-CTOR def run(self): pass - class ImcompleteRunner2(IRunner): # NO-RUN-METHOD + class IncompleteRunner2(ITestRunner): # NO-RUN-METHOD def __init__(self, config): self.config = config - class ImcompleteRunner3(IRunner): # BAD-RUN-METHOD + class IncompleteRunner3(ITestRunner): # BAD-RUN-METHOD def __init__(self, config): self.config = config run = True @@ -253,12 +259,10 @@ Feature: User-provided runner class (extension-point) But note that "problem: " Examples: - | syndrome | runner_class | failure_message | case | - | UNKNOWN_MODULE | unknown:Runner1 | ModuleNotFoundError: No module named 'unknown' | Python module does not exist (or was not found) | - | UNKNOWN_CLASS | my:UnknownClass | ClassNotFoundError: my:UnknownClass | Runner class does not exist in module. | - | BAD_CLASS | my.bad_example:NotRunner1 | InvalidClassError: my.bad_example:NotRunner1: not subclass-of behave.api.runner.IRunner | Specified runner_class is not a runner. | - | BAD_CLASS | my.bad_example:NotRunner2 | InvalidClassError: my.bad_example:NotRunner2: not subclass-of behave.api.runner.IRunner | Runner class does not behave properly. | - | BAD_FUNCTION | my.bad_example:return_none | InvalidClassError: my.bad_example:return_none: not a class | runner_class=function | - | BAD_VALUE | my.bad_example:CONSTANT_1 | InvalidClassError: my.bad_example:CONSTANT_1: not a class | runner_class=number | - - + | syndrome | runner_class | failure_message | case | + | UNKNOWN_MODULE | unknown:Runner1 | ModuleNotFoundError: No module named 'unknown' | Python module does not exist (or was not found) | + | UNKNOWN_CLASS | my:UnknownClass | ClassNotFoundError: my:UnknownClass | Runner class does not exist in module. | + | BAD_CLASS | my.bad_example:NotRunner1 | InvalidClassError: my.bad_example:NotRunner1: not subclass-of behave.api.runner.ITestRunner | Specified runner_class is not a runner. | + | BAD_CLASS | my.bad_example:NotRunner2 | InvalidClassError: my.bad_example:NotRunner2: not subclass-of behave.api.runner.ITestRunner | Runner class does not behave properly. | + | BAD_FUNCTION | my.bad_example:return_none | InvalidClassError: my.bad_example:return_none: not a class | runner_class=function | + | BAD_VALUE | my.bad_example:CONSTANT_1 | InvalidClassError: my.bad_example:CONSTANT_1: not a class | runner_class=number | diff --git a/py.requirements/testing.txt b/py.requirements/testing.txt index 85f08b93a..0afd2f03d 100644 --- a/py.requirements/testing.txt +++ b/py.requirements/testing.txt @@ -15,12 +15,13 @@ mock >= 4.0; python_version >= '3.6' PyHamcrest >= 2.0.2; python_version >= '3.0' PyHamcrest < 2.0; python_version < '3.0' -jsonschema - # -- NEEDED: By some tests (as proof of concept) # NOTE: path.py-10.1 is required for python2.6 # HINT: path.py => path (python-install-package was renamed for python3) path.py >=11.5.0,<13.0; python_version < '3.5' path >= 13.1.0; python_version >= '3.5' +# -- PYTHON2 BACKPORTS: +pathlib; python_version <= '3.4' + -r ../issue.features/py.requirements.txt diff --git a/setup.py b/setup.py index 74b24fba3..6041a4970 100644 --- a/setup.py +++ b/setup.py @@ -102,6 +102,8 @@ def find_packages_by_root_package(where): # -- HINT: path.py => path (python-install-package was renamed for python3) "path >= 13.1.0; python_version >= '3.5'", "path.py >=11.5.0,<13.0; python_version < '3.5'", + # -- PYTHON2 BACKPORTS: + "pathlib; python_version <= '3.4'", ], cmdclass = { "behave_test": behave_test, diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index 9fa68e877..2268bcb47 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -2,13 +2,8 @@ import sys import tempfile import six -import pytest from behave import configuration -from behave.api.runner import IRunner from behave.configuration import Configuration, UserData -from behave.exception import ClassNotFoundError, InvalidClassError -from behave.runner import Runner as DefaultRunnerClass -from behave.runner_plugin import RunnerPlugin from unittest import TestCase @@ -102,108 +97,9 @@ def test_settings_with_stage_from_envvar(self): del os.environ["BEHAVE_STAGE"] -# ----------------------------------------------------------------------------- -# TEST SUPPORT: -# ----------------------------------------------------------------------------- -# -- TEST-RUNNER CLASS EXAMPLES: -class CustomTestRunner(IRunner): - """Custom, dummy runner""" - - def __init__(self, config, **kwargs): - self.config = config - - def run(self): - return True # OOPS: Failed. - - -# -- BAD TEST-RUNNER CLASS EXAMPLES: -# PROBLEM: Is not a class -INVALID_TEST_RUNNER_CLASS0 = True - - -class InvalidTestRunner1(object): - """PROBLEM: Missing IRunner.register(InvalidTestRunner).""" - def run(self, features): pass - - -class InvalidTestRunner2(IRunner): - """PROBLEM: run() method signature differs""" - def run(self, features): pass - - # ----------------------------------------------------------------------------- # TEST SUITE: # ----------------------------------------------------------------------------- -class TestConfigurationRunner(object): - """Test the runner-plugin configuration.""" - - def test_runner_default(self, capsys): - config = Configuration("") - runner = RunnerPlugin().make_runner(config) - assert config.runner == configuration.DEFAULT_RUNNER_CLASS_NAME - assert isinstance(runner, DefaultRunnerClass) - - def test_runner_with_normal_runner_class(self, capsys): - config = Configuration(["--runner=behave.runner:Runner"]) - runner = RunnerPlugin().make_runner(config) - assert isinstance(runner, DefaultRunnerClass) - - def test_runner_with_own_runner_class(self): - config = Configuration(["--runner=tests.unit.test_configuration:CustomTestRunner"]) - runner = RunnerPlugin().make_runner(config) - assert isinstance(runner, CustomTestRunner) - - def test_runner_with_unknown_module(self, capsys): - with pytest.raises(ImportError): - config = Configuration(["--runner=unknown_module:Runner"]) - runner = RunnerPlugin().make_runner(config) - captured = capsys.readouterr() - if six.PY2: - assert "No module named unknown_module" in captured.out - else: - assert "No module named 'unknown_module'" in captured.out - - def test_runner_with_unknown_class(self, capsys): - with pytest.raises(ClassNotFoundError) as exc_info: - config = Configuration(["--runner=behave.runner:UnknownRunner"]) - RunnerPlugin().make_runner(config) - - captured = capsys.readouterr() - assert "FAILED to load runner-class" in captured.out - assert "ClassNotFoundError: behave.runner:UnknownRunner" in captured.out - - expected = "behave.runner:UnknownRunner" - assert exc_info.type is ClassNotFoundError - assert exc_info.match(expected) - - def test_runner_with_invalid_runner_class0(self): - with pytest.raises(TypeError) as exc_info: - config = Configuration(["--runner=tests.unit.test_configuration:INVALID_TEST_RUNNER_CLASS0"]) - RunnerPlugin().make_runner(config) - - expected = "tests.unit.test_configuration:INVALID_TEST_RUNNER_CLASS0: not a class" - assert exc_info.type is InvalidClassError - assert exc_info.match(expected) - - def test_runner_with_invalid_runner_class1(self): - with pytest.raises(TypeError) as exc_info: - config = Configuration(["--runner=tests.unit.test_configuration:InvalidTestRunner1"]) - RunnerPlugin().make_runner(config) - - expected = "tests.unit.test_configuration:InvalidTestRunner1: not subclass-of behave.api.runner.IRunner" - assert exc_info.type is InvalidClassError - assert exc_info.match(expected) - - def test_runner_with_invalid_runner_class2(self): - with pytest.raises(TypeError) as exc_info: - config = Configuration(["--runner=tests.unit.test_configuration:InvalidTestRunner2"]) - RunnerPlugin().make_runner(config) - - expected = "Can't instantiate abstract class InvalidTestRunner2 with abstract methods __init__" - assert exc_info.type is TypeError - assert exc_info.match(expected) - - class TestConfigurationUserData(TestCase): """Test userdata aspects in behave.configuration.Configuration class.""" diff --git a/tests/unit/test_runner_plugin.py b/tests/unit/test_runner_plugin.py new file mode 100644 index 000000000..6fb3f833f --- /dev/null +++ b/tests/unit/test_runner_plugin.py @@ -0,0 +1,220 @@ +# -*- coding: UTF-8 -*- +""" +Unit tests for :mod:`behave.runner_plugin`. +""" + +from __future__ import absolute_import, print_function +from contextlib import contextmanager +import os +from pathlib import Path + +from behave import configuration +from behave.api.runner import ITestRunner +from behave.configuration import Configuration +from behave.exception import ClassNotFoundError, InvalidClassError, ModuleNotFoundError +from behave.runner import Runner as DefaultRunnerClass +from behave.runner_plugin import RunnerPlugin + +import pytest + + +# ----------------------------------------------------------------------------- +# TEST SUPPORT: +# ----------------------------------------------------------------------------- + + +@contextmanager +def use_directory(directory_path): + # -- COMPATIBILITY: Use directory-string instead of Path + initial_directory = str(Path.cwd()) + try: + os.chdir(str(directory_path)) + yield directory_path + finally: + os.chdir(initial_directory) + + +# ----------------------------------------------------------------------------- +# TEST SUPPORT: TEST RUNNER CLASS CANDIDATES -- GOOD EXAMPLES +# ----------------------------------------------------------------------------- +class CustomTestRunner(ITestRunner): + """Custom, dummy runner""" + + def __init__(self, config, **kwargs): + self.config = config + + def run(self): + return True # OOPS: Failed. + + +class PhoenixTestRunner(ITestRunner): + def __init__(self, config, **kwargs): + self.config = config + self.the_runner = DefaultRunnerClass(config) + + def run(self, features=None): + return self.the_runner.run(features=features) + + +class RegisteredTestRunner(object): + """Not derived from :class:`behave.api.runner:ITestrunner`. + In this case, you need to register this class to the interface class. + """ + + def __init__(self, config, **kwargs): + self.config = config + + def run(self): + return True # OOPS: Failed. + + +# -- REQUIRES REGISTRATION WITH INTERFACE: +# Register as subclass of ITestRunner interface-class. +ITestRunner.register(RegisteredTestRunner) + + +# ----------------------------------------------------------------------------- +# TEST SUPPORT: TEST RUNNERS CANDIDATES -- BAD EXAMPLES +# ----------------------------------------------------------------------------- +# SYNDEOME: Is not a class, but a boolean value. +INVALID_TEST_RUNNER_CLASS0 = True + + +class InvalidTestRunnerNotSubclass(object): + """SYNDROME: Missing ITestRunner.register(InvalidTestRunnerNotSubclass).""" + def __int__(self, config): + pass + + def run(self, features=None): + return True + + +class InvalidTestRunnerWithoutCtor(ITestRunner): + """SYNDROME: ctor() method is missing""" + def run(self, features=None): + pass + + +class InvalidTestRunnerWithoutRun(ITestRunner): + """SYNDROME: run() method is missing""" + def __init__(self, config, **kwargs): + self.config = config + + +# ----------------------------------------------------------------------------- +# TEST SUITE: +# ----------------------------------------------------------------------------- +class TestRunnerPlugin(object): + """Test the runner-plugin configuration.""" + THIS_MODULE_NAME = CustomTestRunner.__module__ + + def test_make_runner_with_default(self, capsys): + config = Configuration("") + runner = RunnerPlugin().make_runner(config) + assert config.runner == configuration.DEFAULT_RUNNER_CLASS_NAME + assert isinstance(runner, DefaultRunnerClass) + + def test_make_runner_with_normal_runner_class(self): + config = Configuration(["--runner=behave.runner:Runner"]) + runner = RunnerPlugin().make_runner(config) + assert isinstance(runner, DefaultRunnerClass) + + def test_make_runner_with_own_runner_class(self): + config = Configuration(["--runner=%s:CustomTestRunner" % self.THIS_MODULE_NAME]) + runner = RunnerPlugin().make_runner(config) + assert isinstance(runner, CustomTestRunner) + + def test_make_runner_with_registered_runner_class(self): + config = Configuration(["--runner=%s:RegisteredTestRunner" % self.THIS_MODULE_NAME]) + runner = RunnerPlugin().make_runner(config) + assert isinstance(runner, RegisteredTestRunner) + assert isinstance(runner, ITestRunner) + assert issubclass(RegisteredTestRunner, ITestRunner) + + def test_make_runner_with_runner_alias(self): + config = Configuration(["--runner=custom"]) + config.runner_aliases["custom"] = "%s:CustomTestRunner" % self.THIS_MODULE_NAME + runner = RunnerPlugin().make_runner(config) + assert isinstance(runner, CustomTestRunner) + + def test_make_runner_with_runner_alias_from_configfile(self, tmp_path): + config_file = tmp_path/"behave.ini" + config_file.write_text(u""" +[behave.runners] +custom = {this_module}:CustomTestRunner +""".format(this_module=self.THIS_MODULE_NAME)) + + with use_directory(tmp_path): + config = Configuration(["--runner=custom"]) + runner = RunnerPlugin().make_runner(config) + assert isinstance(runner, CustomTestRunner) + + def test_make_runner_fails_with_unknown_module(self, capsys): + with pytest.raises(ModuleNotFoundError) as exc_info: + config = Configuration(["--runner=unknown_module:Runner"]) + runner = RunnerPlugin().make_runner(config) + captured = capsys.readouterr() + + expected = "unknown_module" + assert exc_info.type is ModuleNotFoundError + assert exc_info.match(expected) + + # -- OOPS: No output + print("CAPTURED-OUTPUT: %s;" % captured.out) + print("CAPTURED-ERROR: %s;" % captured.err) + # if six.PY2: + # assert "No module named unknown_module" in captured.err + # else: + # assert "No module named 'unknown_module'" in captured.out + + def test_make_runner_fails_with_unknown_class(self, capsys): + with pytest.raises(ClassNotFoundError) as exc_info: + config = Configuration(["--runner=behave.runner:UnknownRunner"]) + RunnerPlugin().make_runner(config) + + captured = capsys.readouterr() + assert "FAILED to load runner.class" in captured.out + assert "behave.runner:UnknownRunner (ClassNotFoundError)" in captured.out + + expected = "behave.runner:UnknownRunner" + assert exc_info.type is ClassNotFoundError + assert exc_info.match(expected) + + def test_make_runner_fails_if_runner_class_is_not_a_class(self): + with pytest.raises(InvalidClassError) as exc_info: + config = Configuration(["--runner=%s:INVALID_TEST_RUNNER_CLASS0" % self.THIS_MODULE_NAME]) + RunnerPlugin().make_runner(config) + + expected = "%s:INVALID_TEST_RUNNER_CLASS0: not a class" % self.THIS_MODULE_NAME + assert exc_info.type is InvalidClassError + assert exc_info.match(expected) + + def test_make_runner_fails_if_runner_class_is_not_subclass_of_runner_interface(self): + with pytest.raises(InvalidClassError) as exc_info: + config = Configuration(["--runner=%s:InvalidTestRunnerNotSubclass" % self.THIS_MODULE_NAME]) + RunnerPlugin().make_runner(config) + + expected = "%s:InvalidTestRunnerNotSubclass: not subclass-of behave.api.runner.ITestRunner" % self.THIS_MODULE_NAME + assert exc_info.type is InvalidClassError + assert exc_info.match(expected) + + def test_make_runner_fails_if_runner_class_has_no_ctor(self): + with pytest.raises(TypeError) as exc_info: + config = Configuration(["--runner=%s:InvalidTestRunnerWithoutCtor" % self.THIS_MODULE_NAME]) + RunnerPlugin().make_runner(config) + + expected = "Can't instantiate abstract class InvalidTestRunnerWithoutCtor with abstract method(s)? __init__" + assert exc_info.type is TypeError + assert exc_info.match(expected) + + + def test_make_runner_fails_if_runner_class_has_no_run_method(self): + with pytest.raises(TypeError) as exc_info: + config = Configuration(["--runner=%s:InvalidTestRunnerWithoutRun" % self.THIS_MODULE_NAME]) + RunnerPlugin().make_runner(config) + + expected = "Can't instantiate abstract class InvalidTestRunnerWithoutRun with abstract method(s)? run" + assert exc_info.type is TypeError + assert exc_info.match(expected) + + From 050fc2a896e18fce567f722f370c52ff4d8adcf5 Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 21 Nov 2022 12:07:21 +0100 Subject: [PATCH 070/240] FIXES #899: Bad formatters * Show bad formatters in "UNAVAILABLE FORMATTERS" section * Show bad runners in "UNAVAILABLE RUNNERS" section OTHERWISE: * Changes textual output slighty to use UPPER_CASE for section titles. --- CHANGES.rst | 1 + behave/__main__.py | 95 ++++++++------- behave/formatter/_registry.py | 56 ++++++++- features/cmdline.lang_list.feature | 2 +- features/formatter.help.feature | 151 ++++++++++++++++++++---- features/formatter.user_defined.feature | 2 +- issue.features/issue0031.feature | 2 +- issue.features/issue0309.feature | 2 +- 8 files changed, 239 insertions(+), 72 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index a1294bfd3..eb3905012 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -42,6 +42,7 @@ ENHANCEMENTS: * Select-by-location: Add support for "Scenario container" (Feature, Rule, ScenarioOutline) (related to: #391) * pull #988: setup.py: Add category to install additional formatters (html) (provided-by: bittner) * pull #895: UPDATE: i18n/gherkin-languages.json from cucumber repository #895 (related to: #827) +* issue #889: Warn or error about incorrectly configured formatter aliases (provided by: jenisys, submitted by: bittner) * pull #827: Fixed keyword translation in Estonian #827 (provided by: ookull) * issue #740: Enhancement: possibility to add cleanup to be called upon leaving outer context stack frames (submitted by: nizwiz, dcvmoole) * issue #678: Scenario Outline: Support tags with commas and semicolons (provided by: lawnmowerlatte, pull #679) diff --git a/behave/__main__.py b/behave/__main__.py index 2597466c4..52f4f73d3 100644 --- a/behave/__main__.py +++ b/behave/__main__.py @@ -72,12 +72,14 @@ def run_behave(config, runner_class=None): print(TAG_HELP) return 0 - if config.lang_list: + if config.lang == "help" or config.lang_list: print_language_list() return 0 if config.lang_help: - print_language_help(config) + # -- PROVIDE HELP: For one, specific language + language = config.lang_help + print_language_help(language) return 0 if not config.format: @@ -89,7 +91,7 @@ def run_behave(config, runner_class=None): # -- NO FORMATTER on command-line: Add default formatter. config.format.append(config.default_format) if "help" in config.format: - print_formatters("Available formatters:") + print_formatters() return 0 if len(config.outputs) > len(config.format): @@ -150,7 +152,7 @@ def run_behave(config, runner_class=None): # --------------------------------------------------------------------------- # MAIN SUPPORT FOR: run_behave() # --------------------------------------------------------------------------- -def print_language_list(stream=None): +def print_language_list(file=None): """Print list of supported languages, like: * English @@ -160,51 +162,49 @@ def print_language_list(stream=None): """ from behave.i18n import languages - if stream is None: - stream = sys.stdout - if six.PY2: - # -- PYTHON2: Overcome implicit encode problems (encoding=ASCII). - stream = codecs.getwriter("UTF-8")(sys.stdout) + print_ = lambda text: print(text, file=file) + if six.PY2: + # -- PYTHON2: Overcome implicit encode problems (encoding=ASCII). + file = codecs.getwriter("UTF-8")(file or sys.stdout) iso_codes = languages.keys() - print("Languages available:") + print("AVAILABLE LANGUAGES:") for iso_code in sorted(iso_codes): native = languages[iso_code]["native"] name = languages[iso_code]["name"] - print(u" %s: %s / %s" % (iso_code, native, name), file=stream) - return 0 + print_(u" %s: %s / %s" % (iso_code, native, name)) -def print_language_help(config, stream=None): +def print_language_help(language, file=None): from behave.i18n import languages + # if stream is None: + # stream = sys.stdout + # if six.PY2: + # # -- PYTHON2: Overcome implicit encode problems (encoding=ASCII). + # stream = codecs.getwriter("UTF-8")(sys.stdout) - if stream is None: - stream = sys.stdout - if six.PY2: - # -- PYTHON2: Overcome implicit encode problems (encoding=ASCII). - stream = codecs.getwriter("UTF-8")(sys.stdout) + print_ = lambda text: print(text, file=file) + if six.PY2: + # -- PYTHON2: Overcome implicit encode problems (encoding=ASCII). + file = codecs.getwriter("UTF-8")(file or sys.stdout) - if config.lang_help not in languages: - print("%s is not a recognised language: try --lang-list" % \ - config.lang_help, file=stream) + if language not in languages: + print_("%s is not a recognised language: try --lang-list" % language) return 1 - trans = languages[config.lang_help] - print(u"Translations for %s / %s" % (trans["name"], - trans["native"]), file=stream) + trans = languages[language] + print_(u"Translations for %s / %s" % (trans["name"], trans["native"])) for kw in trans: if kw in "name native".split(): continue - print(u"%16s: %s" % (kw.title().replace("_", " "), + print_(u"%16s: %s" % (kw.title().replace("_", " "), u", ".join(w for w in trans[kw] if w != "*"))) - return 0 -def print_formatters(title=None, file=None): +def print_formatters(file=None): """Prints the list of available formatters and their description. - :param title: Optional title (as string). - :param stream: Optional, output stream to use (default: sys.stdout). + :param file: Optional, output file to use (default: sys.stdout). """ from behave.formatter._registry import format_items from operator import itemgetter @@ -215,16 +215,23 @@ def print_formatters(title=None, file=None): formatter_names = [item[0] for item in formatter_items] column_size = compute_words_maxsize(formatter_names) schema = u" %-"+ _text(column_size) +"s %s" + problematic_formatters = [] - if title: - print_(u"%s" % title) + print_("AVAILABLE FORMATTERS:") for name, formatter_class in formatter_items: formatter_description = getattr(formatter_class, "description", "") formatter_error = getattr(formatter_class, "error", None) if formatter_error: # -- DIAGNOSTICS: Indicate if formatter definition has a problem. - formatter_description = formatter_error - print_(schema % (name, formatter_description)) + problematic_formatters.append((name, formatter_error)) + else: + # -- NORMAL CASE: + print_(schema % (name, formatter_description)) + + if problematic_formatters: + print_("\nUNAVAILABLE FORMATTERS:") + for name, formatter_error in problematic_formatters: + print_(schema % (name, formatter_error)) def print_runners(runner_aliases, file=None): @@ -237,20 +244,26 @@ def print_runners(runner_aliases, file=None): # MAYBE: file = file or sys.stdout print_ = lambda text: print(text, file=file) - title = "AVAILABLE RUNNERS:" runner_names = sorted(runner_aliases.keys()) column_size = compute_words_maxsize(runner_names) - schema = u" %-"+ _text(column_size) +"s = %s%s" + schema1 = u" %-"+ _text(column_size) +"s = %s%s" + schema2 = u" %-"+ _text(column_size) +"s %s" + problematic_runners = [] - print_(title) + print_("AVAILABLE RUNNERS:") for runner_name in runner_names: scoped_class_name = runner_aliases[runner_name] - annotation = "" - problem = RunnerPlugin.make_problem_description(scoped_class_name) + problem = RunnerPlugin.make_problem_description(scoped_class_name, use_details=True) if problem: - annotation = " (problem: %s)" % problem - - print_(schema % (runner_name, scoped_class_name, annotation)) + problematic_runners.append((runner_name, problem)) + else: + # -- NORMAL CASE: + print_(schema1 % (runner_name, scoped_class_name, "")) + + if problematic_runners: + print_("\nUNAVAILABLE RUNNERS:") + for runner_name, problem_description in problematic_runners: + print_(schema2 % (runner_name, problem_description)) # --------------------------------------------------------------------------- diff --git a/behave/formatter/_registry.py b/behave/formatter/_registry.py index c4f2a2c9d..ba5e08a2b 100644 --- a/behave/formatter/_registry.py +++ b/behave/formatter/_registry.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- - +import inspect import sys import warnings from behave.formatter.base import Formatter, StreamOpener @@ -8,6 +8,29 @@ import six +# ----------------------------------------------------------------------------- +# FORMATTER BAD CASES: +# ----------------------------------------------------------------------------- +class BadFormatterClass(object): + """Placeholder class if a formatter class is invalid.""" + def __init__(self, name, formatter_class): + self.name = name + self.formatter_class = formatter_class + self._error_text = None + + @property + def error(self): + from behave.importer import make_scoped_class_name + if self._error_text is None: + error_text = "" + if not inspect.isclass(self.formatter_class): + error_text = "InvalidClassError: is not a class" + elif not is_formatter_class_valid(self.formatter_class): + error_text = "InvalidClassError: is not a subclass-of Formatter" + self._error_text = error_text + return self._error_text + + # ----------------------------------------------------------------------------- # FORMATTER REGISTRY: # ----------------------------------------------------------------------------- @@ -22,6 +45,16 @@ def format_items(resolved=False): if resolved: # -- ENSURE: All formatter classes are loaded (and resolved). _formatter_registry.load_all(strict=False) + + # -- BETTER DIAGNOSTICS: Ensure problematic cases are covered. + for name, formatter_class in _formatter_registry.items(): + if isinstance(formatter_class, BadFormatterClass): + continue + elif not is_formatter_class_valid(formatter_class): + if not hasattr(formatter_class, "error"): + bad_formatter_class = BadFormatterClass(name, formatter_class) + _formatter_registry[name] = bad_formatter_class + return iter(_formatter_registry.items()) @@ -80,7 +113,10 @@ def load_formatter_class(scoped_class_name): formatter_module = load_module(module_name) formatter_class = getattr(formatter_module, class_name, None) if formatter_class is None: - raise ClassNotFoundError("CLASS NOT FOUND: %s" % scoped_class_name) + raise ClassNotFoundError(scoped_class_name) + elif not is_formatter_class_valid(formatter_class): + # -- BETTER DIAGNOSTICS: + formatter_class = BadFormatterClass(scoped_class_name, formatter_class) return formatter_class @@ -97,14 +133,24 @@ def select_formatter_class(formatter_name): :raises: ValueError, if formatter name is invalid. """ try: - return _formatter_registry[formatter_name] + formatter_class = _formatter_registry[formatter_name] + return formatter_class + # if not is_formatter_class_valid(formatter_class): + # formatter_class = BadFormatterClass(formatter_name, formatter_class) + # _formatter_registry[formatter_name] = formatter_class + # return formatter_class except KeyError: # -- NOT-FOUND: if ":" not in formatter_name: raise # -- OTHERWISE: SCOPED-NAME, try to load a user-specific formatter. # MAY RAISE: ImportError - return load_formatter_class(formatter_name) + formatter_class = load_formatter_class(formatter_name) + return formatter_class + + +def is_formatter_class_valid(formatter_class): + return inspect.isclass(formatter_class) and issubclass(formatter_class, Formatter) def is_formatter_valid(formatter_name): @@ -115,7 +161,7 @@ def is_formatter_valid(formatter_name): """ try: formatter_class = select_formatter_class(formatter_name) - return issubclass(formatter_class, Formatter) + return is_formatter_class_valid(formatter_class) except (LookupError, ImportError, ValueError): return False diff --git a/features/cmdline.lang_list.feature b/features/cmdline.lang_list.feature index 38b03aa57..3fda63f21 100644 --- a/features/cmdline.lang_list.feature +++ b/features/cmdline.lang_list.feature @@ -10,7 +10,7 @@ Feature: Command-line options: Use behave --lang-list When I run "behave --lang-list" Then it should pass with: """ - Languages available: + AVAILABLE LANGUAGES: af: Afrikaans / Afrikaans am: հայերեն / Armenian amh: አማርኛ / Amharic diff --git a/features/formatter.help.feature b/features/formatter.help.feature index 48f1a02b4..a5ca43591 100644 --- a/features/formatter.help.feature +++ b/features/formatter.help.feature @@ -1,30 +1,137 @@ Feature: Help Formatter As a tester - I want to know which formatter are supported + I want to know which formatters are supported To be able to select one. - Scenario: + . SPECIFICATION: Using "behave --format=help" on command line + . * Shows list of available formatters with their name and description + . * Good formatters / formatter-aliases are shown in "AVAILABLE FORMATTERS" section + . * Bad formatter-aliases are shown in "UNAVAILABLE FORMATTERS" section + . * Bad formatter syndromes are: ModuleNotFoundError, ClassNotFoundError, InvalidClassError + . + . FORMATTER ALIASES: + . * You can specify formatter-aliases for user-defined formatter classes + . under the section "[behave.formatters]" in the config-file. + + Background: Given a new working directory - When I run "behave --format=help" - Then it should pass - And the command output should contain: - """ - Available formatters: - json JSON dump of test run - json.pretty JSON dump of test run (human readable) - null Provides formatter that does not output anything. - plain Very basic formatter with maximum compatibility - pretty Standard colourised pretty formatter - progress Shows dotted progress for each executed scenario. - progress2 Shows dotted progress for each executed step. - progress3 Shows detailed progress for each step of a scenario. + + Rule: Good Formatters are shown in "AVAILABLE FORMATTERS" Section + Scenario: Good case (with builtin formatters) + Given an empty file named "behave.ini" + When I run "behave --format=help" + Then it should pass + And the command output should contain: + """ + AVAILABLE FORMATTERS: + json JSON dump of test run + json.pretty JSON dump of test run (human readable) + null Provides formatter that does not output anything. + plain Very basic formatter with maximum compatibility + pretty Standard colourised pretty formatter + progress Shows dotted progress for each executed scenario. + progress2 Shows dotted progress for each executed step. + progress3 Shows detailed progress for each step of a scenario. + rerun Emits scenario file locations of failing scenarios + sphinx.steps Generate sphinx-based documentation for step definitions. + steps Shows step definitions (step implementations). + steps.catalog Shows non-technical documentation for step definitions. + steps.doc Shows documentation for step definitions. + steps.usage Shows how step definitions are used by steps. + tags Shows tags (and how often they are used). + tags.location Shows tags and the location where they are used. + """ + + Scenario: Good Formatter by using a Formatter-Alias + Given an empty file named "behave4me/__init__.py" + And a file named "behave4me/good_formatter.py" with: + """ + from behave.formatter.base import Formatter + + class SomeFormatter(Formatter): + name = "some" + description = "Very basic formatter for Some format." + + def __init__(self, stream_opener, config): + super(SomeFormatter, self).__init__(stream_opener, config) + """ + And a file named "behave.ini" with: + """ + [behave.formatters] + some = behave4me.good_formatter:SomeFormatter + """ + When I run "behave --format=help" + Then it should pass + And the command output should contain: + """ rerun Emits scenario file locations of failing scenarios + some Very basic formatter for Some format. sphinx.steps Generate sphinx-based documentation for step definitions. - steps Shows step definitions (step implementations). - steps.catalog Shows non-technical documentation for step definitions. - steps.doc Shows documentation for step definitions. - steps.usage Shows how step definitions are used by steps. - tags Shows tags (and how often they are used). - tags.location Shows tags and the location where they are used. - """ + """ + And note that "the new formatter appears in the sorted list of formatters" + But the command output should not contain "UNAVAILABLE FORMATTERS" + + + Rule: Bad Formatters are shown in "UNAVAILABLE FORMATTERS" Section + + HINT ON SYNDROME: ModuleNotFoundError + The config-file "behave.ini" may contain formatter-aliases + that refer to missing/not-installed Python packages. + + Background: + Given an empty file named "behave4me/__init__.py" + And a file named "behave4me/bad_formatter.py" with: + """ + class InvalidFormatter1(object): pass # CASE 1: Not a subclass-of Formatter + InvalidFormatter2 = True # CASE 2: Not a class + """ + + @ @formatter.syndrome. + Scenario Template: Bad Formatter with + Given a file named "behave.ini" with: + """ + [behave.formatters] + = + """ + When I run "behave --format=help" + Then it should pass + And the command output should contain: + """ + UNAVAILABLE FORMATTERS: + : + """ + + @use.with_python.min_version=3.6 + Examples: For Python >= 3.6 + | formatter_name | formatter_class | formatter_syndrome | problem_description | + | bad_formatter1 | behave4me.unknown:Formatter | ModuleNotFoundError | No module named 'behave4me.unknown' | + + @not.with_python.min_version=3.6 + Examples: For Python < 3.6 + | formatter_name | formatter_class | formatter_syndrome | problem_description | + | bad_formatter1 | behave4me.unknown:Formatter | ModuleNotFoundError | No module named 'unknown' | + + Examples: + | formatter_name | formatter_class | formatter_syndrome | problem_description | + | bad_formatter2 | behave4me.bad_formatter:UnknownFormatter | ClassNotFoundError | behave4me.bad_formatter:UnknownFormatter | + | bad_formatter3 | behave4me.bad_formatter:InvalidFormatter1 | InvalidClassError | is not a subclass-of Formatter | + | bad_formatter4 | behave4me.bad_formatter:InvalidFormatter2 | InvalidClassError | is not a class | + + + Scenario: Multiple Bad Formatters + Given a file named "behave.ini" with: + """ + [behave.formatters] + bad_formatter2 = behave4me.bad_formatter:UnknownFormatter + bad_formatter3 = behave4me.bad_formatter:InvalidFormatter1 + """ + When I run "behave --format=help" + Then it should pass + And the command output should contain: + """ + UNAVAILABLE FORMATTERS: + bad_formatter2 ClassNotFoundError: behave4me.bad_formatter:UnknownFormatter + bad_formatter3 InvalidClassError: is not a subclass-of Formatter + """ + And note that "the list of UNAVAILABLE FORMATTERS is sorted-by-name" diff --git a/features/formatter.user_defined.feature b/features/formatter.user_defined.feature index a1527ec19..4b94d47c5 100644 --- a/features/formatter.user_defined.feature +++ b/features/formatter.user_defined.feature @@ -162,7 +162,7 @@ Feature: Use a user-defined Formatter When I run "behave -f help" Then it should pass with: """ - Available formatters: + AVAILABLE FORMATTERS: """ And the command output should contain: """ diff --git a/issue.features/issue0031.feature b/issue.features/issue0031.feature index 8f1b493e8..62aabb843 100644 --- a/issue.features/issue0031.feature +++ b/issue.features/issue0031.feature @@ -7,7 +7,7 @@ Feature: Issue #31 "behave --format help" raises an error Then it should pass And the command output should contain: """ - Available formatters: + AVAILABLE FORMATTERS: json JSON dump of test run json.pretty JSON dump of test run (human readable) null Provides formatter that does not output anything. diff --git a/issue.features/issue0309.feature b/issue.features/issue0309.feature index daa13c6fd..2bfc548b4 100644 --- a/issue.features/issue0309.feature +++ b/issue.features/issue0309.feature @@ -29,7 +29,7 @@ Feature: Issue #309 -- behave --lang-list fails on Python3 When I run "behave --lang-list" Then it should pass with: """ - Languages available: + AVAILABLE LANGUAGES: af: Afrikaans / Afrikaans am: հայերեն / Armenian amh: አማርኛ / Amharic From 3aa14fa9df2004f2a999b6c12ec714c1e8fc6326 Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 21 Nov 2022 12:17:35 +0100 Subject: [PATCH 071/240] PART: Runner classes via runner_plugin * Add missing "undefined_steps" property run ITestRunner API * Cleanup and rework feature tests * Add feature test "runner.help" (related to: #899) --- behave/api/runner.py | 22 ++ behave/runner.py | 28 +- behave/runner_plugin.py | 36 +- features/runner.help.feature | 126 +++++++ features/runner.use_runner_class.feature | 452 ++++++++++++++++------- tests/unit/test_runner_plugin.py | 111 +++++- 6 files changed, 600 insertions(+), 175 deletions(-) create mode 100644 features/runner.help.feature diff --git a/behave/api/runner.py b/behave/api/runner.py index b35935aca..1538a3027 100644 --- a/behave/api/runner.py +++ b/behave/api/runner.py @@ -10,9 +10,13 @@ from __future__ import absolute_import from abc import ABCMeta, abstractmethod +from sys import version_info as _version_info from six import add_metaclass +_PYTHON_VERSION = _version_info[:2] + + @add_metaclass(ABCMeta) class ITestRunner(object): """Interface that a test runner-class should provide: @@ -24,6 +28,7 @@ class ITestRunner(object): @abstractmethod def __init__(self, config, **kwargs): self.config = config + # MAYBE: self.undefined_steps = [] @abstractmethod def run(self): @@ -33,3 +38,20 @@ def run(self): :rtype: bool """ return False + + # if _PYTHON_VERSION < (3, 3): + # # -- HINT: @abstractproperty, deprecated since Python 3.3 + # from abc import abstractproperty + # @abstractproperty + # def undefined_steps(self): + # raise NotImplementedError() + # else: + @property + @abstractmethod + def undefined_steps(self): + """Provides list of undefined steps that were found during the test-run. + + :return: List of unmatched steps (as string) in feature-files. + """ + raise NotImplementedError() + # return NotImplemented diff --git a/behave/runner.py b/behave/runner.py index 1b7fb5984..35f2a0814 100644 --- a/behave/runner.py +++ b/behave/runner.py @@ -582,7 +582,7 @@ def __init__(self, config, features=None, step_registry=None): self.features = features or [] self.hooks = {} self.formatters = [] - self.undefined_steps = [] + self._undefined_steps = [] self.step_registry = step_registry self.capture_controller = CaptureController(config) @@ -590,21 +590,27 @@ def __init__(self, config, features=None, step_registry=None): self.feature = None self.hook_failures = 0 - # @property - def _get_aborted(self): - value = False + @property + def undefined_steps(self): + return self._undefined_steps + + @property + def aborted(self): + """Indicates that test run is aborted by the user or system.""" if self.context: - value = self.context.aborted - return value + return self.context.aborted + # -- OTHERWISE + return False - # @aborted.setter - def _set_aborted(self, value): + @aborted.setter + def aborted(self, value): + """Mark the test run as aborted.""" # pylint: disable=protected-access assert self.context, "REQUIRE: context, but context=%r" % self.context - self.context._set_root_attribute("aborted", bool(value)) + if self.context: + self.context._set_root_attribute("aborted", bool(value)) - aborted = property(_get_aborted, _set_aborted, - doc="Indicates that test run is aborted by the user.") + # DISABLED: aborted = property(_get_aborted, _set_aborted, doc="...") def abort(self, reason=None): """Abort the test run. diff --git a/behave/runner_plugin.py b/behave/runner_plugin.py index c4e4c5916..4e9fe5529 100644 --- a/behave/runner_plugin.py +++ b/behave/runner_plugin.py @@ -69,33 +69,37 @@ def __init__(self, runner_name=None, runner_class=None, runner_aliases=None): self.runner_aliases = runner_aliases or {} @staticmethod - def is_runner_class_valid(runner_class): + def is_class_valid(runner_class): run_method = getattr(runner_class, "run", None) return (inspect.isclass(runner_class) and issubclass(runner_class, ITestRunner) and callable(run_method)) @staticmethod - def check_runner_class(runner_class, runner_class_name=None): + def validate_class(runner_class, runner_class_name=None): """Check if a runner class supports the Runner API constraints.""" if not runner_class_name: runner_class_name = make_scoped_class_name(runner_class) if not inspect.isclass(runner_class): - schema = "{0}: not a class" - raise InvalidClassError(schema.format(runner_class_name)) + raise InvalidClassError("is not a class") elif not issubclass(runner_class, ITestRunner): - schema = "{0}: not subclass-of behave.api.runner.ITestRunner" - raise InvalidClassError(schema.format(runner_class_name)) + message = "is not a subclass-of 'behave.api.runner:ITestRunner'" + raise InvalidClassError(message) run_method = getattr(runner_class, "run", None) if not callable(run_method): - schema = "{0}: run() is not callable" - raise InvalidClassError(schema.format(runner_class_name)) + raise InvalidClassError("run() is not callable") + undefined_steps = getattr(runner_class, "undefined_steps", None) + if undefined_steps is None: + raise InvalidClassError("undefined_steps: missing attribute or property") + # MAYBE: + # elif not callable(undefined_steps): + # raise InvalidClassError("undefined_steps is not callable") @classmethod - def load_runner_class(cls, runner_class_name, verbose=True): + def load_class(cls, runner_class_name, verbose=True): """Loads a runner class by using its scoped-class-name, like: `my.module:Class1`. @@ -112,9 +116,9 @@ def load_runner_class(cls, runner_class_name, verbose=True): runner_class = getattr(module, class_name, Unknown) if runner_class is Unknown: raise ClassNotFoundError(runner_class_name) - cls.check_runner_class(runner_class, runner_class_name) + cls.validate_class(runner_class, runner_class_name) return runner_class - except (ImportError, TypeError) as e: + except (ImportError, InvalidClassError, TypeError) as e: # -- CASE: ModuleNotFoundError, ClassNotFoundError, InvalidClassError, ... if verbose: print("BAD_RUNNER_CLASS: FAILED to load runner.class=%s (%s)" % \ @@ -122,7 +126,7 @@ def load_runner_class(cls, runner_class_name, verbose=True): raise @classmethod - def make_problem_description(cls, scoped_class_name): + def make_problem_description(cls, scoped_class_name, use_details=False): """Check runner class for problems. :param scoped_class_name: Runner class name (as string). @@ -132,9 +136,11 @@ def make_problem_description(cls, scoped_class_name): # -- STEP: Check runner for problems problem_description = "" try: - RunnerPlugin.load_runner_class(scoped_class_name, verbose=False) + cls.load_class(scoped_class_name, verbose=False) except (ImportError, TypeError) as e: problem_description = e.__class__.__name__ + if use_details: + problem_description = "%s: %s" % (problem_description, str(e)) return problem_description def make_runner(self, config, **runner_kwargs): @@ -162,9 +168,9 @@ def make_runner(self, config, **runner_kwargs): if scoped_class_name == runner_name and ":" not in scoped_class_name: # -- CASE: runner-alias is not in config-file section="behave.runner". raise ConfigError("runner=%s (RUNNER-ALIAS NOT FOUND)" % scoped_class_name) - runner_class = self.load_runner_class(scoped_class_name) + runner_class = self.load_class(scoped_class_name) else: - self.check_runner_class(runner_class) + self.validate_class(runner_class) runner = runner_class(config, **runner_kwargs) return runner diff --git a/features/runner.help.feature b/features/runner.help.feature new file mode 100644 index 000000000..410a4c485 --- /dev/null +++ b/features/runner.help.feature @@ -0,0 +1,126 @@ +Feature: Runner Help + + As a tester + I want to know which test runner classes are supported + To be able to select one. + + . SPECIFICATION: Using "behave --runner=help" on command line + . * Shows list of available test runner classes + . * Good test runner-aliases are shown in "AVAILABLE RUNNERS" section + . * Bad test runner-aliases are shown in "UNAVAILABLE RUNNERS" section + . * Bad test runner syndromes are: + . ModuleNotFoundError, ClassNotFoundError, InvalidClassError + . + . TEST RUNNER ALIASES: + . * You can specify runner-aliases for user-defined test runner classes + . under the section "[behave.runners]" in the config-file. + + Background: + Given a new working directory + + Rule: Good Runners are shown in "AVAILABLE RUNNERS" Section + + Scenario: Good case (with builtin runners) + Given an empty file named "behave.ini" + When I run "behave --runner=help" + Then it should pass + And the command output should contain: + """ + AVAILABLE RUNNERS: + default = behave.runner:Runner + """ + + Scenario: Good Runner by using a Runner-Alias + Given an empty file named "behave4me/__init__.py" + And a file named "behave4me/good_runner.py" with: + """ + from behave.api.runner import ITestRunner + from behave.runner import Runner as CoreRunner + + class SomeRunner(ITestRunner): + def __init__(self, config, **kwargs): + super(ITestRunner, self).__init__(config) + self.config = config + self._runner = CoreRunner(config) + + def run(self): + return self._runner.run() + """ + And a file named "behave.ini" with: + """ + [behave.runners] + some = behave4me.good_runner:SomeRunner + """ + When I run "behave --runner=help" + Then it should pass + And the command output should contain: + """ + default = behave.runner:Runner + some = behave4me.good_runner:SomeRunner + """ + And note that "the new runner appears in the sorted list of runners" + But the command output should not contain "UNAVAILABLE RUNNERS" + + + Rule: Bad Runners are shown in "UNAVAILABLE RUNNERS" Section + + HINT ON SYNDROME: ModuleNotFoundError + The config-file "behave.ini" may contain runner-aliases + that refer to missing/not-installed Python packages. + + Background: + Given an empty file named "behave4me/__init__.py" + And a file named "behave4me/bad_runner.py" with: + """ + class InvalidRunner1(object): pass # CASE 1: Not a subclass-of ITestRunner + InvalidRunner2 = True # CASE 2: Not a class + """ + + @ @runner.syndrome. + Scenario Template: Bad Runner with + Given a file named "behave.ini" with: + """ + [behave.runners] + = + """ + When I run "behave --runner=help" + Then it should pass + And the command output should contain: + """ + UNAVAILABLE RUNNERS: + : + """ + + @use.with_python.min_version=3.0 + Examples: For Python >= 3.0 + | runner_name | runner_class | runner_syndrome | problem_description | + | bad_runner1 | behave4me.unknown:Runner | ModuleNotFoundError | No module named 'behave4me.unknown' | + + @not.with_python.min_version=3.0 + Examples: For Python < 3.0 + | runner_name | runner_class | runner_syndrome | problem_description | + | bad_runner1 | behave4me.unknown:Runner | ModuleNotFoundError | No module named 'unknown' | + + Examples: + | runner_name | runner_class | runner_syndrome | problem_description | + | bad_runner2 | behave4me.bad_runner:UnknownRunner | ClassNotFoundError | behave4me.bad_runner:UnknownRunner | + | bad_runner3 | behave4me.bad_runner:InvalidRunner1 | InvalidClassError | is not a subclass-of 'behave.api.runner:ITestRunner' | + | bad_runner4 | behave4me.bad_runner:InvalidRunner2 | InvalidClassError | is not a class | + + + Scenario: Multiple Bad Runners + Given a file named "behave.ini" with: + """ + [behave.runners] + bad_runner3 = behave4me.bad_runner:InvalidRunner1 + bad_runner2 = behave4me.bad_runner:UnknownRunner + """ + When I run "behave --runner=help" + Then it should pass + And the command output should contain: + """ + UNAVAILABLE RUNNERS: + bad_runner2 ClassNotFoundError: behave4me.bad_runner:UnknownRunner + bad_runner3 InvalidClassError: is not a subclass-of 'behave.api.runner:ITestRunner' + """ + And note that "the list of UNAVAILABLE RUNNERS is sorted-by-name" diff --git a/features/runner.use_runner_class.feature b/features/runner.use_runner_class.feature index 8a84f6b8a..58d11f5ec 100644 --- a/features/runner.use_runner_class.feature +++ b/features/runner.use_runner_class.feature @@ -1,4 +1,4 @@ -Feature: User-provided runner class (extension-point) +Feature: User-provided Test Runner Class (extension-point) As a user/developer I want sometimes replace behave's default runner with an own runner class @@ -8,8 +8,6 @@ Feature: User-provided runner class (extension-point) . * This extension-point was already available internally . * Now you can specify the runner_class in the config-file . or as a command-line option. - . - . XXX_TODO: runner-alias(es) Background: @@ -18,23 +16,32 @@ Feature: User-provided runner class (extension-point) """ import behave4cmd0.passing_steps import behave4cmd0.failing_steps - import behave4cmd0.note_steps """ - And a file named "features/environment.py" with: + And a file named "features/environment.py" with: """ - from __future__ import print_function - import os - from fnmatch import fnmatch - - def print_environment(pattern=None): - names = ["PYTHONPATH", "PATH"] - for name in names: - value = os.environ.get(name, None) - print("DIAG: env: %s = %r" % (name, value)) + from __future__ import absolute_import, print_function def before_all(ctx): - print_environment() - """ + print_test_runner_class(ctx._runner) + + def print_test_runner_class(runner): + print("TEST_RUNNER_CLASS=%s::%s" % (runner.__class__.__module__, + runner.__class__.__name__)) + """ +# And a file named "features/environment.py" with: +# """ +# from __future__ import print_function +# import os +# +# def print_environment(pattern=None): +# names = ["PYTHONPATH", "PATH"] +# for name in names: +# value = os.environ.get(name, None) +# print("DIAG: env: %s = %r" % (name, value)) +# +# def before_all(ctx): +# print_environment() +# """ And a file named "features/passing.feature" with: """ @pass @@ -48,133 +55,260 @@ Feature: User-provided runner class (extension-point) """ - Rule: Use the default runner if no runner is specified + @default_runner + Rule: Use default runner - @cmdline - @default_runner - @default_runner. - Scenario Outline: Use default runner option () + Scenario: Use default runner Given a file named "behave.ini" does not exist - When I run "behave -f plain features" + When I run "behave features/" Then it should pass with: """ 2 scenarios passed, 0 failed, 0 skipped 3 steps passed, 0 failed, 0 skipped, 0 undefined """ - And note that "" + And the command output should contain: + """ + TEST_RUNNER_CLASS=behave.runner::Runner + """ + And note that "the DEFAULT TEST_RUNNER CLASS is used" - Examples: - | short_id | runner_options | case | - | NO_RUNNER | | no runner options are used on cmdline and config-fule. | - | DEFAULT_RUNNER | --runner=behave.runner:Runner | runner option with default runner class is used on cmdline. | + Scenario: Use default runner from config-file (case: ITestRunner subclass) + Given a file named "behave_example1.py" with: + """ + from __future__ import absolute_import, print_function + from behave.api.runner import ITestRunner + from behave.runner import Runner as CoreRunner + class MyRunner(ITestRunner): + def __init__(self, config, **kwargs): + super(MyRunner, self).__init__(config) + self._runner = CoreRunner(config) - @own_runner - Rule: Use own runner class + def run(self): + print("THIS_RUNNER_CLASS=%s::%s" % (self.__class__.__module__, + self.__class__.__name__)) + return self._runner.run() - Background: Provide own Runner Classes - Given a file named "my/good_example.py" with: + @property + def undefined_steps(self): + return self._runner.undefined_steps """ - from __future__ import print_function - from behave.runner import Runner + And a file named "behave.ini" with + """ + [behave] + runner = behave_example1:MyRunner + """ + When I run "behave features/" + Then it should pass with: + """ + THIS_RUNNER_CLASS=behave_example1::MyRunner + """ + And note that "my own TEST_RUNNER CLASS is used" + + Scenario: Use default runner from config-file (case: ITestRunner.register) + Given a file named "behave_example2.py" with: + """ + from __future__ import absolute_import, print_function + from behave.runner import Runner as CoreRunner + + class MyRunner2(object): + def __init__(self, config, **kwargs): + self.config = config + self._runner = CoreRunner(config) - class MyRunner1(Runner): def run(self): - print("RUNNER=MyRunner1") - return super(MyRunner1, self).run() + print("THIS_RUNNER_CLASS=%s::%s" % (self.__class__.__module__, + self.__class__.__name__)) + return self._runner.run() + + @property + def undefined_steps(self): + return self._runner.undefined_steps + + # -- REGISTER AS TEST-RUNNER: + from behave.api.runner import ITestRunner + ITestRunner.register(MyRunner2) + """ + And a file named "behave.ini" with + """ + [behave] + runner = behave_example2:MyRunner2 + """ + When I run "behave features/" + Then it should pass with: + """ + THIS_RUNNER_CLASS=behave_example2::MyRunner2 + """ + And note that "my own TEST_RUNNER CLASS is used" + + + Scenario: Use default runner from config-file (using: runner-name) + Given a file named "behave_example3.py" with: + """ + from __future__ import absolute_import, print_function + from behave.api.runner import ITestRunner + from behave.runner import Runner as CoreRunner + + class MyRunner(ITestRunner): + def __init__(self, config, **kwargs): + super(MyRunner, self).__init__(config) + self._runner = CoreRunner(config) - class MyRunner2(Runner): def run(self): - print("RUNNER=MyRunner2") - return super(MyRunner2, self).run() + print("THIS_RUNNER_CLASS=%s::%s" % (self.__class__.__module__, + self.__class__.__name__)) + return self._runner.run() + + @property + def undefined_steps(self): + return self._runner.undefined_steps + """ + And a file named "behave.ini" with """ - And an empty file named "my/__init__.py" + [behave] + runner = some_runner - @own_runner - @cmdline - Scenario: Use own runner on cmdline - Given a file named "behave.ini" does not exist - When I run "behave -f plain --runner=my.good_example:MyRunner1" + [behave.runners] + some_runner = behave_example3:MyRunner + """ + When I run "behave features/" Then it should pass with: """ - 2 scenarios passed, 0 failed, 0 skipped - 3 steps passed, 0 failed, 0 skipped, 0 undefined + THIS_RUNNER_CLASS=behave_example3::MyRunner """ - And the command output should contain: + And note that "the runner-name/alias from the config-file was used" + + + @own_runner + Rule: Use own Test Runner (GOOD CASE) + + Scenario Template: Use --runner=NORMAL_ on command-line (without config-file) + Given a file named "behave.ini" does not exist + When I run "behave --runner= features" + Then it should pass with: """ - RUNNER=MyRunner1 + TEST_RUNNER_CLASS=behave.runner::Runner """ + And note that "the NORMAL RUNNER CLASS is used" - @own_runner - @config_file - Scenario: Use runner in config-file - Given a file named "behave.ini" with: + Examples: + | case | runner_value | + | RUNNER_NAME | default | + | RUNNER_CLASS | behave.runner:Runner | + + + Scenario: Use --runner=RUNNER_CLASS on command-line without config-file + Given a file named "behave.ini" does not exist + And a file named "behave_example/good_runner.py" with: """ - [behave] - runner = my.good_example:MyRunner2 + from __future__ import print_function + from behave.runner import Runner + + class MyRunner1(Runner): pass """ - When I run "behave -f plain features" + And an empty file named "behave_example/__init__.py" + When I run "behave --runner=behave_example.good_runner:MyRunner1" Then it should pass with: """ - 2 scenarios passed, 0 failed, 0 skipped - 3 steps passed, 0 failed, 0 skipped, 0 undefined + TEST_RUNNER_CLASS=behave_example.good_runner::MyRunner1 """ - And the command output should contain: + + + Scenario: Use --runner=RUNNER_NAME on command-line with config-file + Given a file named "behave_example/good_runner.py" with: """ - RUNNER=MyRunner2 + from __future__ import print_function + from behave.runner import Runner + + class MyRunner1(Runner): pass + """ + And an empty file named "behave_example/__init__.py" + And a file named "behave.ini" with: + """ + [behave.runners] + runner1 = behave_example.good_runner:MyRunner1 + """ + When I run "behave --runner=runner1 features" + Then it should pass with: + """ + TEST_RUNNER_CLASS=behave_example.good_runner::MyRunner1 """ - @own_runner - @cmdline - @config_file - Scenario: Runner on command-line overrides runner in config-file + Scenario: Runner option on command-line overrides config-file + Given a file named "behave_example/good_runner.py" with: + """ + from __future__ import print_function + from behave.runner import Runner + + class MyRunner1(Runner): pass + class MyRunner2(Runner): pass + """ + And an empty file named "behave_example/__init__.py" Given a file named "behave.ini" with: """ [behave] - runner = my.good_example:MyRunner2 + runner = behave_example.good_runner:MyRunner1 """ - When I run "behave -f plain --runner=my.good_example:MyRunner1" + When I run "behave --runner=behave_example.good_runner:MyRunner2" Then it should pass with: """ - 2 scenarios passed, 0 failed, 0 skipped - 3 steps passed, 0 failed, 0 skipped, 0 undefined - """ - And the command output should contain: - """ - RUNNER=MyRunner1 + TEST_RUNNER_CLASS=behave_example.good_runner::MyRunner2 """ - Rule: Bad cases on command-line + Rule: Use own Test Runner-by-Class (BAD CASES) + Background: Bad runner classes - Given a file named "my/bad_example.py" with: + Given a file named "behave_example/bad_runner.py" with: """ from behave.api.runner import ITestRunner + class NotRunner1(object): pass class NotRunner2(object): run = True + CONSTANT_1 = 42 + + def return_none(*args, **kwargs): + return None + """ + And a file named "behave_example/incomplete.py" with: + """ + from behave.api.runner import ITestRunner + class IncompleteRunner1(ITestRunner): # NO-CTOR def run(self): pass + @property + def undefined_steps(self): + return [] + class IncompleteRunner2(ITestRunner): # NO-RUN-METHOD def __init__(self, config): self.config = config - class IncompleteRunner3(ITestRunner): # BAD-RUN-METHOD + @property + def undefined_steps(self): + return [] + + class IncompleteRunner3(ITestRunner): # MISSING: undefined_steps def __init__(self, config): self.config = config - run = True + def run(self): pass - CONSTANT_1 = 42 + class IncompleteRunner4(ITestRunner): # BAD-RUN-METHOD + def __init__(self, config): + self.config = config + run = True - def return_none(*args, **kwargs): - return None + @property + def undefined_steps(self): + return [] """ - And an empty file named "my/__init__.py" + And an empty file named "behave_example/__init__.py" - Scenario Outline: Bad cmdline with --runner= () - When I run "behave -f plain --runner=" + Scenario Template: Use BAD-RUNNER-CLASS with --runner= () + When I run "behave --runner=" Then it should fail And the command output should match: """ @@ -182,17 +316,23 @@ Feature: User-provided runner class (extension-point) """ But note that "problem: " - Examples: - | syndrome | runner_class | failure_message | case | - | UNKNOWN_MODULE | unknown:Runner1 | ModuleNotFoundError: No module named 'unknown' | Python module does not exist (or was not found) | - | UNKNOWN_CLASS | my:UnknownClass | ClassNotFoundError: my:UnknownClass | Runner class does not exist in module. | - | UNKNOWN_CLASS | my.bad_example:42 | ClassNotFoundError: my.bad_example:42 | runner_class=number | - | BAD_CLASS | my.bad_example:NotRunner1 | InvalidClassError: my.bad_example:NotRunner1: not subclass-of behave.api.runner.ITestRunner | Specified runner_class is not a runner. | - | BAD_CLASS | my.bad_example:NotRunner2 | InvalidClassError: my.bad_example:NotRunner2: not subclass-of behave.api.runner.ITestRunner | Runner class does not behave properly. | - | BAD_FUNCTION | my.bad_example:return_none | InvalidClassError: my.bad_example:return_none: not a class | runner_class is a function. | - | BAD_VALUE | my.bad_example:CONSTANT_1 | InvalidClassError: my.bad_example:CONSTANT_1: not a class | runner_class is a constant number. | - | INCOMPLETE_CLASS | my.bad_example:IncompleteRunner1 | TypeError: Can't instantiate abstract class IncompleteRunner1 with abstract method(s)? __init__ | Constructor is missing | - | INCOMPLETE_CLASS | my.bad_example:IncompleteRunner2 | TypeError: Can't instantiate abstract class IncompleteRunner2 with abstract method(s)? run | run() method is missing | + Examples: BAD_CASE + | syndrome | runner_class | failure_message | case | + | UNKNOWN_MODULE | unknown:Runner1 | ModuleNotFoundError: No module named 'unknown' | Python module does not exist (or was not found) | + | UNKNOWN_CLASS | behave_example:UnknownClass | ClassNotFoundError: behave_example:UnknownClass | Runner class does not exist in module. | + | UNKNOWN_CLASS | behave_example.bad_runner:42 | ClassNotFoundError: behave_example.bad_runner:42 | runner_class=number | + | BAD_CLASS | behave_example.bad_runner:NotRunner1 | InvalidClassError: is not a subclass-of 'behave.api.runner:ITestRunner' | Specified runner_class is not a runner. | + | BAD_CLASS | behave_example.bad_runner:NotRunner2 | InvalidClassError: is not a subclass-of 'behave.api.runner:ITestRunner' | Runner class does not behave properly. | + | BAD_FUNCTION | behave_example.bad_runner:return_none | InvalidClassError: is not a class | runner_class is a function. | + | BAD_VALUE | behave_example.bad_runner:CONSTANT_1 | InvalidClassError: is not a class | runner_class is a constant number. | + | INCOMPLETE_CLASS | behave_example.incomplete:IncompleteRunner1 | TypeError: Can't instantiate abstract class IncompleteRunner1 with abstract method(s)? __init__ | Constructor is missing | + | INCOMPLETE_CLASS | behave_example.incomplete:IncompleteRunner2 | TypeError: Can't instantiate abstract class IncompleteRunner2 with abstract method(s)? run | run() method is missing | + | INVALID_CLASS | behave_example.incomplete:IncompleteRunner4 | InvalidClassError: run\(\) is not callable | run is a bool-value (no method) | + + @use.with_python.min_version=3.3 + Examples: BAD_CASE2 + | syndrome | runner_class | failure_message | case | + | INCOMPLETE_CLASS | behave_example.incomplete:IncompleteRunner3 | TypeError: Can't instantiate abstract class IncompleteRunner3 with abstract method(s)? undefined_steps | undefined_steps property is missing | # -- PYTHON VERSION SENSITIVITY on INCOMPLETE_CLASS with API TypeError exception: # Since Python 3.9: "... methods ..." is only used in plural case (if multiple methods are missing). @@ -200,69 +340,121 @@ Feature: User-provided runner class (extension-point) # "TypeError: Can't instantiate abstract class with abstract methods " (for Python.version < 3.9) - Scenario Outline: Weird cmdline with --runner= () - When I run "behave -f plain --runner=" - Then it should fail with: + Rule: Use own Test Runner-by-Name (BAD CASES) + + Scenario Template: Use UNKNOWN-RUNNER-NAME with --runner= (ConfigError) + Given an empty file named "behave.ini" + When I run "behave --runner=" + Then it should fail + And the command output should contain: """ - + : """ - But note that "problem: " Examples: - | syndrome | runner_class | failure_message | case | - | NO_CLASS | 42 | ConfigError: runner=42 (RUNNER-ALIAS NOT FOUND) | runner_class.module=number | - | NO_CLASS | 4.23 | ConfigError: runner=4.23 (RUNNER-ALIAS NOT FOUND) | runner_class.module=floating-point-number | - | NO_CLASS | True | ConfigError: runner=True (RUNNER-ALIAS NOT FOUND) | runner_class.module=bool | - | INVALID_CLASS | my.bad_example:IncompleteRunner3 | InvalidClassError: my.bad_example:IncompleteRunner3: run() is not callable | run is a bool-value (no method) | + | runner_name | syndrome | failure_message | + | UNKNOWN_NAME | ConfigError | runner=UNKNOWN_NAME (RUNNER-ALIAS NOT FOUND) | + | 42 | ConfigError | runner=42 (RUNNER-ALIAS NOT FOUND) | + | 4.23 | ConfigError | runner=4.23 (RUNNER-ALIAS NOT FOUND) | + | true | ConfigError | runner=true (RUNNER-ALIAS NOT FOUND) | - Rule: Bad cases with config-file - - Background: - Given a file named "my/bad_example.py" with: + Scenario Template: Use BAD-RUNNER-NAME with --runner= () + Given a file named "behave_example/bad_runner.py" with: """ from behave.api.runner import ITestRunner + class NotRunner1(object): pass class NotRunner2(object): run = True + CONSTANT_1 = 42 + + def return_none(*args, **kwargs): + return None + """ + And an empty file named "behave_example/__init__.py" + And a file named "behave.ini" with: + """ + [behave.runners] + = + """ + When I run "behave --runner=" + Then it should fail + And the command output should contain: + """ + BAD_RUNNER_CLASS: FAILED to load runner.class= () + """ + And the command output should match: + """ + : + """ + + Examples: BAD_CASE + | runner_name | runner_class | syndrome | problem_description | case | + | NAME_FOR_UNKNOWN_MODULE | unknown:Runner1 | ModuleNotFoundError | No module named 'unknown' | Python module does not exist (or was not found) | + | NAME_FOR_UNKNOWN_CLASS_1 | behave_example:UnknownClass | ClassNotFoundError | behave_example:UnknownClass | Runner class does not exist in module. | + | NAME_FOR_UNKNOWN_CLASS_2 | behave_example.bad_runner:42 | ClassNotFoundError | behave_example.bad_runner:42 | runner_class=number | + | NAME_FOR_BAD_CLASS_1 | behave_example.bad_runner:NotRunner1 | InvalidClassError | is not a subclass-of 'behave.api.runner:ITestRunner' | Specified runner_class is not a runner. | + | NAME_FOR_BAD_CLASS_2 | behave_example.bad_runner:NotRunner2 | InvalidClassError | is not a subclass-of 'behave.api.runner:ITestRunner' | Runner class does not behave properly. | + | NAME_FOR_BAD_CLASS_3 | behave_example.bad_runner:return_none | InvalidClassError | is not a class | runner_class is a function. | + | NAME_FOR_BAD_CLASS_4 | behave_example.bad_runner:CONSTANT_1 | InvalidClassError | is not a class | runner_class is a constant number. | + + + Scenario Template: Use INCOMPLETE-RUNNER-NAME with --runner= () + Given a file named "behave_example/incomplete.py" with: + """ + from behave.api.runner import ITestRunner + class IncompleteRunner1(ITestRunner): # NO-CTOR def run(self): pass + @property + def undefined_steps(self): + return [] + class IncompleteRunner2(ITestRunner): # NO-RUN-METHOD def __init__(self, config): self.config = config - class IncompleteRunner3(ITestRunner): # BAD-RUN-METHOD + @property + def undefined_steps(self): + return [] + + class IncompleteRunner3(ITestRunner): # MISSING: undefined_steps def __init__(self, config): self.config = config - run = True + def run(self): pass - CONSTANT_1 = 42 + class IncompleteRunner4(ITestRunner): # BAD-RUN-METHOD + def __init__(self, config): + self.config = config + run = True - def return_none(*args, **kwargs): - return None + @property + def undefined_steps(self): + return [] """ - And an empty file named "my/__init__.py" - - Scenario Outline: Bad config-file.runner= () - Given a file named "behave.ini" with: + And an empty file named "behave_example/__init__.py" + And a file named "behave.ini" with: """ - [behave] - runner = + [behave.runners] + = """ - When I run "behave -f plain" - Then it should fail with: + When I run "behave --runner=" + Then it should fail + And the command output should match: """ - + : """ - But note that "problem: " - Examples: - | syndrome | runner_class | failure_message | case | - | UNKNOWN_MODULE | unknown:Runner1 | ModuleNotFoundError: No module named 'unknown' | Python module does not exist (or was not found) | - | UNKNOWN_CLASS | my:UnknownClass | ClassNotFoundError: my:UnknownClass | Runner class does not exist in module. | - | BAD_CLASS | my.bad_example:NotRunner1 | InvalidClassError: my.bad_example:NotRunner1: not subclass-of behave.api.runner.ITestRunner | Specified runner_class is not a runner. | - | BAD_CLASS | my.bad_example:NotRunner2 | InvalidClassError: my.bad_example:NotRunner2: not subclass-of behave.api.runner.ITestRunner | Runner class does not behave properly. | - | BAD_FUNCTION | my.bad_example:return_none | InvalidClassError: my.bad_example:return_none: not a class | runner_class=function | - | BAD_VALUE | my.bad_example:CONSTANT_1 | InvalidClassError: my.bad_example:CONSTANT_1: not a class | runner_class=number | + Examples: BAD_CASE + | runner_name | runner_class | syndrome | problem_description | case | + | NAME_FOR_INCOMPLETE_CLASS_1 | behave_example.incomplete:IncompleteRunner1 | TypeError | Can't instantiate abstract class IncompleteRunner1 with abstract method(s)? __init__ | Constructor is missing | + | NAME_FOR_INCOMPLETE_CLASS_2 | behave_example.incomplete:IncompleteRunner2 | TypeError | Can't instantiate abstract class IncompleteRunner2 with abstract method(s)? run | run() method is missing | + | NAME_FOR_INCOMPLETE_CLASS_4 | behave_example.incomplete:IncompleteRunner4 | InvalidClassError | run\(\) is not callable | run is a bool-value (no method) | + + @use.with_python.min_version=3.3 + Examples: BAD_CASE2 + | runner_name | runner_class | syndrome | problem_description | case | + | NAME_FOR_INCOMPLETE_CLASS_3 | behave_example.incomplete:IncompleteRunner3 | TypeError | Can't instantiate abstract class IncompleteRunner3 with abstract method(s)? undefined_steps | missing-property | diff --git a/tests/unit/test_runner_plugin.py b/tests/unit/test_runner_plugin.py index 6fb3f833f..0c43fe133 100644 --- a/tests/unit/test_runner_plugin.py +++ b/tests/unit/test_runner_plugin.py @@ -4,27 +4,38 @@ """ from __future__ import absolute_import, print_function +import sys from contextlib import contextmanager import os from pathlib import Path - from behave import configuration from behave.api.runner import ITestRunner from behave.configuration import Configuration from behave.exception import ClassNotFoundError, InvalidClassError, ModuleNotFoundError from behave.runner import Runner as DefaultRunnerClass from behave.runner_plugin import RunnerPlugin - import pytest # ----------------------------------------------------------------------------- -# TEST SUPPORT: +# CONSTANTS: # ----------------------------------------------------------------------------- +PYTHON_VERSION = sys.version_info[:2] +# ----------------------------------------------------------------------------- +# TEST SUPPORT: +# ----------------------------------------------------------------------------- @contextmanager -def use_directory(directory_path): +def use_current_directory(directory_path): + """Use directory as current directory. + + :: + + with use_current_directory("/tmp/some_directory"): + pass # DO SOMETHING in current directory. + # -- ON EXIT: Restore old current-directory. + """ # -- COMPATIBILITY: Use directory-string instead of Path initial_directory = str(Path.cwd()) try: @@ -46,6 +57,10 @@ def __init__(self, config, **kwargs): def run(self): return True # OOPS: Failed. + @property + def undefined_steps(self): + return [] + class PhoenixTestRunner(ITestRunner): def __init__(self, config, **kwargs): @@ -55,6 +70,10 @@ def __init__(self, config, **kwargs): def run(self, features=None): return self.the_runner.run(features=features) + @property + def undefined_steps(self): + return self.the_runner.undefined_steps + class RegisteredTestRunner(object): """Not derived from :class:`behave.api.runner:ITestrunner`. @@ -67,6 +86,10 @@ def __init__(self, config, **kwargs): def run(self): return True # OOPS: Failed. + @property + def undefined_steps(self): + return self.the_runner.undefined_steps + # -- REQUIRES REGISTRATION WITH INTERFACE: # Register as subclass of ITestRunner interface-class. @@ -76,30 +99,49 @@ def run(self): # ----------------------------------------------------------------------------- # TEST SUPPORT: TEST RUNNERS CANDIDATES -- BAD EXAMPLES # ----------------------------------------------------------------------------- -# SYNDEOME: Is not a class, but a boolean value. +# SYNDROME: Is not a class, but a boolean value. INVALID_TEST_RUNNER_CLASS0 = True class InvalidTestRunnerNotSubclass(object): """SYNDROME: Missing ITestRunner.register(InvalidTestRunnerNotSubclass).""" def __int__(self, config): - pass + self.undefined_steps = [] def run(self, features=None): return True + class InvalidTestRunnerWithoutCtor(ITestRunner): """SYNDROME: ctor() method is missing""" def run(self, features=None): pass + @property + def undefined_steps(self): + return [] + class InvalidTestRunnerWithoutRun(ITestRunner): """SYNDROME: run() method is missing""" def __init__(self, config, **kwargs): self.config = config + @property + def undefined_steps(self): + return [] + + +class InvalidTestRunnerWithoutUndefinedSteps(ITestRunner): + """SYNDROME: undefined_steps property is missing""" + def __init__(self, config, **kwargs): + self.config = config + # self.undefined_steps = [] + + def run(self, features=None): + pass + # ----------------------------------------------------------------------------- # TEST SUITE: @@ -108,11 +150,28 @@ class TestRunnerPlugin(object): """Test the runner-plugin configuration.""" THIS_MODULE_NAME = CustomTestRunner.__module__ - def test_make_runner_with_default(self, capsys): - config = Configuration("") - runner = RunnerPlugin().make_runner(config) - assert config.runner == configuration.DEFAULT_RUNNER_CLASS_NAME - assert isinstance(runner, DefaultRunnerClass) + def test_make_runner_with_default(self, tmp_path): + with use_current_directory(tmp_path): + config_file = tmp_path/"behave.ini" + config = Configuration("") + runner = RunnerPlugin().make_runner(config) + assert config.runner == configuration.DEFAULT_RUNNER_CLASS_NAME + assert isinstance(runner, DefaultRunnerClass) + assert not config_file.exists() + + def test_make_runner_with_default_from_configfile(self, tmp_path): + config_file = tmp_path/"behave.ini" + config_file.write_text(u""" +[behave] +runner = behave.runner:Runner +""") + + with use_current_directory(tmp_path): + config = Configuration("") + runner = RunnerPlugin().make_runner(config) + assert config.runner == configuration.DEFAULT_RUNNER_CLASS_NAME + assert isinstance(runner, DefaultRunnerClass) + assert config_file.exists() def test_make_runner_with_normal_runner_class(self): config = Configuration(["--runner=behave.runner:Runner"]) @@ -144,10 +203,11 @@ def test_make_runner_with_runner_alias_from_configfile(self, tmp_path): custom = {this_module}:CustomTestRunner """.format(this_module=self.THIS_MODULE_NAME)) - with use_directory(tmp_path): + with use_current_directory(tmp_path): config = Configuration(["--runner=custom"]) runner = RunnerPlugin().make_runner(config) assert isinstance(runner, CustomTestRunner) + assert config_file.exists() def test_make_runner_fails_with_unknown_module(self, capsys): with pytest.raises(ModuleNotFoundError) as exc_info: @@ -185,7 +245,7 @@ def test_make_runner_fails_if_runner_class_is_not_a_class(self): config = Configuration(["--runner=%s:INVALID_TEST_RUNNER_CLASS0" % self.THIS_MODULE_NAME]) RunnerPlugin().make_runner(config) - expected = "%s:INVALID_TEST_RUNNER_CLASS0: not a class" % self.THIS_MODULE_NAME + expected = "is not a class" assert exc_info.type is InvalidClassError assert exc_info.match(expected) @@ -194,27 +254,40 @@ def test_make_runner_fails_if_runner_class_is_not_subclass_of_runner_interface(s config = Configuration(["--runner=%s:InvalidTestRunnerNotSubclass" % self.THIS_MODULE_NAME]) RunnerPlugin().make_runner(config) - expected = "%s:InvalidTestRunnerNotSubclass: not subclass-of behave.api.runner.ITestRunner" % self.THIS_MODULE_NAME + expected = "is not a subclass-of 'behave.api.runner:ITestRunner'" assert exc_info.type is InvalidClassError assert exc_info.match(expected) def test_make_runner_fails_if_runner_class_has_no_ctor(self): + class_name = "InvalidTestRunnerWithoutCtor" with pytest.raises(TypeError) as exc_info: - config = Configuration(["--runner=%s:InvalidTestRunnerWithoutCtor" % self.THIS_MODULE_NAME]) + config = Configuration(["--runner=%s:%s" % (self.THIS_MODULE_NAME, class_name)]) RunnerPlugin().make_runner(config) - expected = "Can't instantiate abstract class InvalidTestRunnerWithoutCtor with abstract method(s)? __init__" + expected = "Can't instantiate abstract class %s with abstract method(s)? __init__" % \ + class_name assert exc_info.type is TypeError assert exc_info.match(expected) - def test_make_runner_fails_if_runner_class_has_no_run_method(self): + class_name = "InvalidTestRunnerWithoutRun" with pytest.raises(TypeError) as exc_info: - config = Configuration(["--runner=%s:InvalidTestRunnerWithoutRun" % self.THIS_MODULE_NAME]) + config = Configuration(["--runner=%s:%s" % (self.THIS_MODULE_NAME, class_name)]) RunnerPlugin().make_runner(config) - expected = "Can't instantiate abstract class InvalidTestRunnerWithoutRun with abstract method(s)? run" + expected = "Can't instantiate abstract class %s with abstract method(s)? run" % \ + class_name assert exc_info.type is TypeError assert exc_info.match(expected) + @pytest.mark.skipif(PYTHON_VERSION < (3, 0), reason="TypeError is not raised.") + def test_make_runner_fails_if_runner_class_has_no_undefined_steps(self): + class_name = "InvalidTestRunnerWithoutUndefinedSteps" + with pytest.raises(TypeError) as exc_info: + config = Configuration(["--runner=%s:%s" % (self.THIS_MODULE_NAME, class_name)]) + RunnerPlugin().make_runner(config) + expected = "Can't instantiate abstract class %s with abstract method(s)? undefined_steps" % \ + class_name + assert exc_info.type is TypeError + assert exc_info.match(expected) From 203faf150052ce6d9d05f5a453d7e9f62daa5064 Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 23 Nov 2022 11:08:37 +0100 Subject: [PATCH 072/240] UPDATE: docs * Update/Correct --runner option description. --- docs/behave.rst | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/docs/behave.rst b/docs/behave.rst index 0fafc4d32..f4a92ef21 100644 --- a/docs/behave.rst +++ b/docs/behave.rst @@ -55,10 +55,6 @@ You may see the same information presented below at any time using ``behave Directory in which to store JUnit reports. -.. option:: --runner-class - - Tells Behave to use a specific runner. (default: %(default)s) - .. option:: -f, --format Specify a formatter. If none is specified the default formatter is @@ -182,7 +178,7 @@ You may see the same information presented below at any time using ``behave .. option:: -r, --runner - Use own runner class, like: behave.runner:Runner + Use own runner class, like: "behave.runner:Runner" .. option:: -s, --no-source @@ -396,13 +392,6 @@ Configuration Parameters Directory in which to store JUnit reports. -.. index:: - single: configuration param; runner_class - -.. describe:: runner_class : text - - Tells Behave to use a specific runner. (default: %(default)s) - .. index:: single: configuration param; default_format @@ -575,7 +564,7 @@ Configuration Parameters .. describe:: runner : text - Use own runner class, like: behave.runner:Runner + Use own runner class, like: "behave.runner:Runner" .. index:: single: configuration param; show_source From c19cce83e24322c695af6e47a8d83a89634cad8f Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 23 Nov 2022 12:03:20 +0100 Subject: [PATCH 073/240] Cleanup CLI short-options Remove seldom unsed shortr-options for: * --no-skipped: -k * --no-multiline: -m * --no-source: -s CLEANUP: * Unused option: --expand --- CHANGES.rst | 4 ++++ behave/configuration.py | 13 ++++--------- docs/behave.rst | 17 +++-------------- 3 files changed, 11 insertions(+), 23 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index eb3905012..bacdebd70 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -13,6 +13,9 @@ BACKWARD-INCOMPATIBLE: - DEPRECATING: ``tag-expressions v1`` (old-style) - BUT: Currently, tag-expression version is automatically detected (and used). +* CLI: Cleanup command-line short-options that are seldom used + (short-options for: --no-skipped (-k), --no-multiline (-m), --no-source (-s)). + GOALS: - Improve support for Windows (continued) @@ -26,6 +29,7 @@ DEVELOPMENT: CLEANUPS: +* CLI: Remove unused option ``--expand`` * Remove ``stdout_capture``, ``stderr_capture``, ``log_capture`` attributes from ``behave.runner.Context`` class (use: ``captured`` attribute instead). diff --git a/behave/configuration.py b/behave/configuration.py index ea489a4f9..f061a7b24 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -141,7 +141,7 @@ def to_string(level): help="""Specify name annotation schema for scenario outline (default="{name} -- @{row.id} {examples.name}").""")), - (("-k", "--no-skipped"), + (("--no-skipped",), dict(action="store_false", dest="show_skipped", help="Don't print skipped steps (due to tags).")), @@ -160,10 +160,9 @@ def to_string(level): This is the default behaviour. This switch is used to override a configuration file setting.""")), - (("-m", "--no-multiline"), + (("--no-multiline",), dict(action="store_false", dest="show_multiline", - help="""Don't print multiline strings and tables under - steps.""")), + help="""Don't print multiline strings and tables under steps.""")), (("--multiline", ), dict(action="store_true", dest="show_multiline", @@ -280,7 +279,7 @@ def to_string(level): default=DEFAULT_RUNNER_CLASS_NAME, help='Use own runner class, like: "behave.runner:Runner"')), - (("-s", "--no-source"), + (("--no-source",), dict(action="store_false", dest="show_source", help="""Don't print the file and line of the step definition with the steps.""")), @@ -342,10 +341,6 @@ def to_string(level): "plain" formatter, do not capture stdout or logging output and stop at the first failure.""")), - (("-x", "--expand"), - dict(action="store_true", - help="Expand scenario outline tables in output.")), - (("--lang",), dict(metavar="LANG", help="Use keywords for a language other than English.")), diff --git a/docs/behave.rst b/docs/behave.rst index f4a92ef21..8cf6deeeb 100644 --- a/docs/behave.rst +++ b/docs/behave.rst @@ -65,7 +65,7 @@ You may see the same information presented below at any time using ``behave Show a catalog of all available step definitions. SAME AS: --format=steps.catalog --dry-run --no-summary -q -.. option:: -k, --no-skipped +.. option:: --no-skipped Don't print skipped steps (due to tags). @@ -83,7 +83,7 @@ You may see the same information presented below at any time using ``behave Print snippets for unimplemented steps. This is the default behaviour. This switch is used to override a configuration file setting. -.. option:: -m, --no-multiline +.. option:: --no-multiline Don't print multiline strings and tables under steps. @@ -180,7 +180,7 @@ You may see the same information presented below at any time using ``behave Use own runner class, like: "behave.runner:Runner" -.. option:: -s, --no-source +.. option:: --no-source Don't print the file and line of the step definition with the steps. @@ -225,10 +225,6 @@ You may see the same information presented below at any time using ``behave formatter, do not capture stdout or logging output and stop at the first failure. -.. option:: -x, --expand - - Expand scenario outline tables in output. - .. option:: --lang Use keywords for a language other than English. @@ -633,13 +629,6 @@ Configuration Parameters formatter, do not capture stdout or logging output and stop at the first failure. -.. index:: - single: configuration param; expand - -.. describe:: expand : bool - - Expand scenario outline tables in output. - .. index:: single: configuration param; lang From 018a9d09ba93e3eaf24b45466be050c734b92427 Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 23 Nov 2022 12:46:52 +0100 Subject: [PATCH 074/240] CLI: Add --jobs/--parallel option * RESERVED FOR: Future use * Only supported by test runners that support parallel execution --- behave/configuration.py | 18 ++++++++++++++++-- docs/behave.rst | 13 +++++++++++++ docs/update_behave_rst.py | 36 ++++++++++++++++++++++++++---------- 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/behave/configuration.py b/behave/configuration.py index f061a7b24..6cfb13e5a 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -69,6 +69,14 @@ def to_string(level): return logging.getLevelName(level) +def positive_number(text): + """Converts a string into a positive integer number.""" + value = int(text) + if value < 0: + raise ValueError("POSITIVE NUMBER, but was: %s" % text) + return value + + # ----------------------------------------------------------------------------- # CONFIGURATION SCHEMA: # ----------------------------------------------------------------------------- @@ -120,9 +128,14 @@ def to_string(level): default="reports", help="""Directory in which to store JUnit reports.""")), + (("-j", "--jobs", "--parallel"), + dict(metavar="NUMBER", dest="jobs", default=1, type=positive_number, + help="""Number of concurrent jobs to use (default: %(default)s). + Only supported by test runners that support parallel execution.""")), + ((), # -- CONFIGFILE only - dict(dest="default_format", - help="Specify default formatter (default: pretty).")), + dict(dest="default_format", default="pretty", + help="Specify default formatter (default: %(default)s).")), (("-f", "--format"), @@ -500,6 +513,7 @@ class Configuration(object): # pylint: disable=too-many-instance-attributes defaults = dict( color='never' if sys.platform == "win32" else os.getenv('BEHAVE_COLOR', 'auto'), + jobs=1, show_snippets=True, show_skipped=True, dry_run=False, diff --git a/docs/behave.rst b/docs/behave.rst index 8cf6deeeb..e27036bbd 100644 --- a/docs/behave.rst +++ b/docs/behave.rst @@ -55,6 +55,11 @@ You may see the same information presented below at any time using ``behave Directory in which to store JUnit reports. +.. option:: -j, --jobs, --parallel + + Number of concurrent jobs to use (default: 1). Only supported by test + runners that support parallel execution. + .. option:: -f, --format Specify a formatter. If none is specified the default formatter is @@ -388,6 +393,14 @@ Configuration Parameters Directory in which to store JUnit reports. +.. index:: + single: configuration param; jobs + +.. describe:: jobs : positive_number + + Number of concurrent jobs to use (default: 1). Only supported by test + runners that support parallel execution. + .. index:: single: configuration param; default_format diff --git a/docs/update_behave_rst.py b/docs/update_behave_rst.py index 9044b87b3..90965f00f 100755 --- a/docs/update_behave_rst.py +++ b/docs/update_behave_rst.py @@ -17,6 +17,8 @@ from behave import configuration from behave.__main__ import TAG_HELP + +positive_number = configuration.positive_number cmdline = [] config = [] indent = " " @@ -51,9 +53,31 @@ assert len(opt) == 2 dest = opt[1:] + # -- COMMON PART: + action = keywords.get("action", "store") + data_type = keywords.get("type", None) + default_value = keywords.get("default", None) + if action == "store": + type = "text" + if data_type is positive_number: + type = "positive_number" + if data_type is int: + type = "number" + elif action in ("store_true","store_false"): + type = "bool" + default_value = False + if action == "store_true": + default_value = True + elif action == "append": + type = "sequence" + else: + raise ValueError("unknown action %s" % action) + # -- CASE: command-line option text = re.sub(r"\s+", " ", keywords["help"]).strip() text = text.replace("%%", "%") + if default_value and "%(default)s" in text: + text = text.replace("%(default)s", str(default_value)) text = textwrap.fill(text, 70, initial_indent="", subsequent_indent=indent) if fixed: # -- COMMAND-LINE OPTIONS (CONFIGFILE only have empty fixed): @@ -66,22 +90,14 @@ continue # -- CASE: configuration-file parameter - action = keywords.get("action", "store") - if action == "store": - type = "text" - elif action in ("store_true","store_false"): - type = "bool" - elif action == "append": - type = "sequence" - else: - raise ValueError("unknown action %s" % action) - if action == "store_false": # -- AVOID: Duplicated descriptions, use only case:true. continue text = re.sub(r"\s+", " ", keywords.get("config_help", keywords["help"])).strip() text = text.replace("%%", "%") + if default_value and "%(default)s" in text: + text = text.replace("%(default)s", str(default_value)) text = textwrap.fill(text, 70, initial_indent="", subsequent_indent=indent) config.append(config_param_schema.format(param=dest, type=type, text=text)) From 234c32eae95d4393b242c6d5789afd9a299b5cc3 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 10 Dec 2022 16:01:38 +0100 Subject: [PATCH 075/240] Tweak verbose output for configuration defaults * Show configuration params in sorted way * Slightly tweak the output format --- behave/configuration.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/behave/configuration.py b/behave/configuration.py index 6cfb13e5a..4a9cda63f 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -477,9 +477,9 @@ def load_configuration(defaults, verbose=False): defaults.update(read_configuration(filename)) if verbose: - print("Using defaults:") - for k, v in six.iteritems(defaults): - print("%15s %s" % (k, v)) + print("Using CONFIGURATION DEFAULTS:") + for k, v in sorted(six.iteritems(defaults)): + print("%18s: %s" % (k, v)) def setup_parser(): From d31700b447e30444ef325a4a3b8ff24ea642cc83 Mon Sep 17 00:00:00 2001 From: Pablo Woolvett Date: Mon, 10 Jan 2022 22:44:22 -0300 Subject: [PATCH 076/240] feat(config): parse pyproject.toml --- behave.ini | 2 +- behave/configuration.py | 162 ++++++++++++++++++++++++++----- docs/behave.rst | 18 +++- docs/behave.rst-template | 18 +++- docs/install.rst | 3 + py.requirements/testing.txt | 3 + setup.py | 5 +- tests/unit/test_configuration.py | 45 ++++++--- 8 files changed, 213 insertions(+), 43 deletions(-) diff --git a/behave.ini b/behave.ini index 379b90e66..30f7e5605 100644 --- a/behave.ini +++ b/behave.ini @@ -1,7 +1,7 @@ # ============================================================================= # BEHAVE CONFIGURATION # ============================================================================= -# FILE: .behaverc, behave.ini, setup.cfg, tox.ini +# FILE: .behaverc, behave.ini, setup.cfg, tox.ini, pyproject.toml # # SEE ALSO: # * http://packages.python.org/behave/behave.html#configuration-files diff --git a/behave/configuration.py b/behave/configuration.py index 4a9cda63f..4bdaf1648 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -3,6 +3,7 @@ from __future__ import absolute_import, print_function import argparse import inspect +import json import logging import os import re @@ -28,6 +29,17 @@ if six.PY2: ConfigParser = configparser.SafeConfigParser +try: + if sys.version_info >= (3, 11): + import tomllib + elif sys.version_info < (3, 0): + import toml as tomllib + else: + import tomli as tomllib + _TOML_AVAILABLE = True +except ImportError: + _TOML_AVAILABLE = False + # ----------------------------------------------------------------------------- # CONSTANTS: @@ -382,41 +394,49 @@ def positive_number(text): ]) -def read_configuration(path): - # pylint: disable=too-many-locals, too-many-branches - config = ConfigParser() - config.optionxform = str # -- SUPPORT: case-sensitive keys - config.read(path) - config_dir = os.path.dirname(path) - result = {} +def values_to_str(d): + return json.loads( + json.dumps(d), + parse_float=str, + parse_int=str, + parse_constant=str + ) + + +def decode_options(config): for fixed, keywords in options: if "dest" in keywords: dest = keywords["dest"] else: + dest = None for opt in fixed: if opt.startswith("--"): dest = opt[2:].replace("-", "_") else: assert len(opt) == 2 dest = opt[1:] - if dest in "tags_help lang_list lang_help version".split(): + if ( + not dest + ) or ( + dest in "tags_help lang_list lang_help version".split() + ): continue - if not config.has_option("behave", dest): + try: + if dest not in config["behave"]: + continue + except AttributeError as exc: + # SafeConfigParser instance has no attribute '__getitem__' (py27) + if "__getitem__" not in str(exc): + raise + if not config.has_option("behave", dest): + continue + except KeyError: continue action = keywords.get("action", "store") - if action == "store": - use_raw_value = dest in raw_value_options - result[dest] = config.get("behave", dest, raw=use_raw_value) - elif action in ("store_true", "store_false"): - result[dest] = config.getboolean("behave", dest) - elif action == "append": - if dest == "userdata_defines": - continue # -- SKIP-CONFIGFILE: Command-line only option. - result[dest] = \ - [s.strip() for s in config.get("behave", dest).splitlines()] - else: - raise ValueError('action "%s" not implemented' % action) + yield dest, action + +def format_outfiles_coupling(result, config_dir): # -- STEP: format/outfiles coupling if "format" in result: # -- OPTIONS: format/outfiles are coupled in configuration file. @@ -442,6 +462,32 @@ def read_configuration(path): result[paths_name] = \ [os.path.normpath(os.path.join(config_dir, p)) for p in paths] + +def read_configparser(path): + # pylint: disable=too-many-locals, too-many-branches + config = ConfigParser() + config.optionxform = str # -- SUPPORT: case-sensitive keys + config.read(path) + config_dir = os.path.dirname(path) + result = {} + + for dest, action in decode_options(config): + if action == "store": + result[dest] = config.get( + "behave", dest, raw=dest in raw_value_options + ) + elif action in ("store_true", "store_false"): + result[dest] = config.getboolean("behave", dest) + elif action == "append": + if dest == "userdata_defines": + continue # -- SKIP-CONFIGFILE: Command-line only option. + result[dest] = \ + [s.strip() for s in config.get("behave", dest).splitlines()] + else: + raise ValueError('action "%s" not implemented' % action) + + format_outfiles_coupling(result, config_dir) + # -- STEP: Special additional configuration sections. # SCHEMA: config_section: data_name special_config_section_map = { @@ -457,14 +503,82 @@ def read_configuration(path): return result +def read_toml(path): + """Read configuration from pyproject.toml file. + + Configuration should be stored inside the 'tool.behave' table. + + See https://www.python.org/dev/peps/pep-0518/#tool-table + """ + # pylint: disable=too-many-locals, too-many-branches + with open(path, "rb") as tomlfile: + config = json.loads(json.dumps(tomllib.load(tomlfile))) # simple dict + + config = config['tool'] + config_dir = os.path.dirname(path) + result = {} + + for dest, action in decode_options(config): + raw = config["behave"][dest] + if action == "store": + result[dest] = str(raw) + elif action in ("store_true", "store_false"): + result[dest] = bool(raw) + elif action == "append": + if dest == "userdata_defines": + continue # -- SKIP-CONFIGFILE: Command-line only option. + # toml has native arrays and quoted strings, so there's no + # need to split by newlines or strip values + result[dest] = raw + else: + raise ValueError('action "%s" not implemented' % action) + format_outfiles_coupling(result, config_dir) + + # -- STEP: Special additional configuration sections. + # SCHEMA: config_section: data_name + special_config_section_map = { + "formatters": "more_formatters", + "userdata": "userdata", + } + for section_name, data_name in special_config_section_map.items(): + result[data_name] = {} + try: + result[data_name] = values_to_str(config["behave"][section_name]) + except KeyError: + result[data_name] = {} + + return result + + +def read_configuration(path, verbose=False): + ext = path.split(".")[-1] + parsers = { + "ini": read_configparser, + "cfg": read_configparser, + "behaverc": read_configparser, + } + + if _TOML_AVAILABLE: + parsers["toml"] = read_toml + parse_func = parsers.get(ext, None) + if not parse_func: + if verbose: + print('Unable to find a parser for "%s"' % path) + return {} + parsed = parse_func(path) + + return parsed + + def config_filenames(): paths = ["./", os.path.expanduser("~")] if sys.platform in ("cygwin", "win32") and "APPDATA" in os.environ: paths.append(os.path.join(os.environ["APPDATA"])) for path in reversed(paths): - for filename in reversed( - ("behave.ini", ".behaverc", "setup.cfg", "tox.ini")): + for filename in reversed(( + "behave.ini", ".behaverc", "setup.cfg", "tox.ini", "pyproject.toml" + )): filename = os.path.join(path, filename) if os.path.isfile(filename): yield filename @@ -474,7 +588,7 @@ def load_configuration(defaults, verbose=False): for filename in config_filenames(): if verbose: print('Loading config defaults from "%s"' % filename) - defaults.update(read_configuration(filename)) + defaults.update(read_configuration(filename, verbose)) if verbose: print("Using CONFIGURATION DEFAULTS:") diff --git a/docs/behave.rst b/docs/behave.rst index e27036bbd..93a25a9c6 100644 --- a/docs/behave.rst +++ b/docs/behave.rst @@ -287,9 +287,9 @@ You can also exclude several tags:: Configuration Files =================== -Configuration files for *behave* are called either ".behaverc", -"behave.ini", "setup.cfg" or "tox.ini" (your preference) and are located in -one of three places: +Configuration files for *behave* are called either ".behaverc", "behave.ini", +"setup.cfg", "tox.ini", or "pyproject.toml" (your preference) and are located +in one of three places: 1. the current working directory (good for per-project settings), 2. your home directory ($HOME), or @@ -308,6 +308,16 @@ formatted in the Windows INI style, for example: logging_clear_handlers=yes logging_filter=-suds +Alternatively, if using "pyproject.toml" instead (note the "tool." prefix): + +.. code-block:: toml + + [tool.behave] + format = "plain" + logging_clear_handlers = true + logging_filter = "-suds" + +NOTE: toml does not support `'%'` interpolations. Configuration Parameter Types ----------------------------- @@ -322,6 +332,7 @@ The following types are supported (and used): The text describes the functionality when the value is true. True values are "1", "yes", "true", and "on". False values are "0", "no", "false", and "off". + TOML: toml only accepts its native `true` **sequence** These fields accept one or more values on new lines, for example a tag @@ -335,6 +346,7 @@ The following types are supported (and used): --tags="(@foo or not @bar) and @zap" + TOML: toml can use arrays natively. Configuration Parameters diff --git a/docs/behave.rst-template b/docs/behave.rst-template index 6487afadc..64f550028 100644 --- a/docs/behave.rst-template +++ b/docs/behave.rst-template @@ -29,9 +29,9 @@ Tag Expression Configuration Files =================== -Configuration files for *behave* are called either ".behaverc", -"behave.ini", "setup.cfg" or "tox.ini" (your preference) and are located in -one of three places: +Configuration files for *behave* are called either ".behaverc", "behave.ini", +"setup.cfg", "tox.ini", or "pyproject.toml" (your preference) and are located +in one of three places: 1. the current working directory (good for per-project settings), 2. your home directory ($HOME), or @@ -50,6 +50,16 @@ formatted in the Windows INI style, for example: logging_clear_handlers=yes logging_filter=-suds +Alternatively, if using "pyproject.toml" instead (note the "tool." prefix): + +.. code-block:: toml + + [tool.behave] + format = "plain" + logging_clear_handlers = true + logging_filter = "-suds" + +NOTE: toml does not support `'%'` interpolations. Configuration Parameter Types ----------------------------- @@ -64,6 +74,7 @@ The following types are supported (and used): The text describes the functionality when the value is true. True values are "1", "yes", "true", and "on". False values are "0", "no", "false", and "off". + TOML: toml only accepts its native `true` **sequence** These fields accept one or more values on new lines, for example a tag @@ -77,6 +88,7 @@ The following types are supported (and used): --tags="(@foo or not @bar) and @zap" + TOML: toml can use arrays natively. Configuration Parameters diff --git a/docs/install.rst b/docs/install.rst index a3cd8be96..1b3fb6e0f 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -70,6 +70,9 @@ Installation Target Description ``behave[develop]`` Optional packages helpful for local development. ``behave[formatters]`` Install formatters from `behave-contrib`_ to extend the list of :ref:`formatters ` provided by default. +``behave[toml]`` Optional toml package to configure behave from 'toml' files, + like 'pyproject.toml' from `pep-518`_. ======================= =================================================================== .. _`behave-contrib`: https://github.com/behave-contrib +.. _`pep-518`: https://peps.python.org/pep-0518/#tool-table diff --git a/py.requirements/testing.txt b/py.requirements/testing.txt index 0afd2f03d..b5a209c9c 100644 --- a/py.requirements/testing.txt +++ b/py.requirements/testing.txt @@ -20,6 +20,9 @@ PyHamcrest < 2.0; python_version < '3.0' # HINT: path.py => path (python-install-package was renamed for python3) path.py >=11.5.0,<13.0; python_version < '3.5' path >= 13.1.0; python_version >= '3.5' +# NOTE: toml extra for pyproject.toml-based config +.[toml] + # -- PYTHON2 BACKPORTS: pathlib; python_version <= '3.4' diff --git a/setup.py b/setup.py index 6041a4970..bd739deb6 100644 --- a/setup.py +++ b/setup.py @@ -137,6 +137,10 @@ def find_packages_by_root_package(where): 'formatters': [ "behave-html-formatter", ], + 'toml': [ # Enable pyproject.toml support. + "tomli>=1.1.0; python_version >= '3.0' and python_version < '3.11'", + "toml>=0.10.2; python_version < '3.0'", # py27 support + ], }, license="BSD", classifiers=[ @@ -161,4 +165,3 @@ def find_packages_by_root_package(where): ], zip_safe = True, ) - diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index 2268bcb47..c01ffa41e 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -2,13 +2,18 @@ import sys import tempfile import six +import pytest from behave import configuration from behave.configuration import Configuration, UserData from unittest import TestCase # one entry of each kind handled -TEST_CONFIG="""[behave] +# configparser and toml +TEST_CONFIGS = [ + ( + ".behaverc", + """[behave] outfiles= /absolute/path1 relative/path2 paths = /absolute/path3 @@ -23,7 +28,22 @@ [behave.userdata] foo = bar answer = 42 -""" +"""), + ( + "pyproject.toml", + """[tool.behave] +outfiles = ["/absolute/path1", "relative/path2"] +paths = ["/absolute/path3", "relative/path4"] +default_tags = ["@foo,~@bar", "@zap"] +format = ["pretty", "tag-counter"] +stdout_capture = false +bogus = "spam" + +[tool.behave.userdata] +foo = "bar" +answer = 42 +""") +] # ----------------------------------------------------------------------------- # TEST SUPPORT: @@ -43,15 +63,18 @@ # ----------------------------------------------------------------------------- class TestConfiguration(object): - def test_read_file(self): - tn = tempfile.mktemp() - tndir = os.path.dirname(tn) - with open(tn, "w") as f: - f.write(TEST_CONFIG) - + @pytest.mark.parametrize( + ("filename", "contents"), + list(TEST_CONFIGS) + ) + def test_read_file(self, filename, contents): + tndir = tempfile.mkdtemp() + file_path = os.path.normpath(os.path.join(tndir, filename)) + with open(file_path, "w") as fp: + fp.write(contents) # -- WINDOWS-REQUIRES: normpath - d = configuration.read_configuration(tn) - assert d["outfiles"] ==[ + d = configuration.read_configuration(file_path) + assert d["outfiles"] == [ os.path.normpath(ROOTDIR_PREFIX + "/absolute/path1"), os.path.normpath(os.path.join(tndir, "relative/path2")), ] @@ -61,7 +84,7 @@ def test_read_file(self): ] assert d["format"] == ["pretty", "tag-counter"] assert d["default_tags"] == ["@foo,~@bar", "@zap"] - assert d["stdout_capture"] == False + assert d["stdout_capture"] is False assert "bogus" not in d assert d["userdata"] == {"foo": "bar", "answer": "42"} From 9ffcd8f3d83888553e65d6affce33b9bfda4e89d Mon Sep 17 00:00:00 2001 From: fliiiix Date: Sun, 26 Feb 2023 19:55:38 +0100 Subject: [PATCH 077/240] docs: fix typos in tag expressions v2 --- docs/_content.tag_expressions_v2.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/_content.tag_expressions_v2.rst b/docs/_content.tag_expressions_v2.rst index 8b7d91018..a6f6f10f6 100644 --- a/docs/_content.tag_expressions_v2.rst +++ b/docs/_content.tag_expressions_v2.rst @@ -1,9 +1,9 @@ Tag-Expressions v2 ------------------------------------------------------------------------------- -:pypi:`cucumber-tag-expressions` are now supported and superceed the old-style +:pypi:`cucumber-tag-expressions` are now supported and supersedes the old-style tag-expressions (which are deprecating). :pypi:`cucumber-tag-expressions` are much -more readible and flexible to select tags on command-line. +more readable and flexible to select tags on command-line. .. code-block:: sh @@ -22,7 +22,7 @@ Example: .. code-block:: sh # -- SELECT-BY-TAG-EXPRESSION (with tag-expressions v2): - # Sellect all features / scenarios with both "@foo" and "@bar" tags. + # Select all features / scenarios with both "@foo" and "@bar" tags. $ behave --tags="@foo and @bar" features/ From 7c47b308eac538589666b5382abb6ef9b376d916 Mon Sep 17 00:00:00 2001 From: aneeshdurg Date: Wed, 29 Mar 2023 15:11:08 -0500 Subject: [PATCH 078/240] Support 'And' step with no prior step if there's a 'Background' --- behave/parser.py | 8 ++++++-- tests/unit/test_parser.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/behave/parser.py b/behave/parser.py index 751107e74..c17999064 100644 --- a/behave/parser.py +++ b/behave/parser.py @@ -743,8 +743,12 @@ def parse_step(self, line): step_type = self.last_step_type elif step_type in ("and", "but"): if not self.last_step_type: - raise ParserError(u"No previous step", - self.line, self.filename) + if self.scenario_container and self.scenario_container.background: + last_background_step = self.scenario_container.background.steps[-1] + self.last_step_type = last_background_step.step_type + else: + raise ParserError(u"No previous step", + self.line, self.filename) step_type = self.last_step_type else: self.last_step_type = step_type diff --git a/tests/unit/test_parser.py b/tests/unit/test_parser.py index 01006f9bc..3b18988c7 100644 --- a/tests/unit/test_parser.py +++ b/tests/unit/test_parser.py @@ -408,6 +408,35 @@ def test_parses_feature_with_a_scenario_with_and_and_but(self): ('then', 'But', 'not the bad stuff', None, None), ]) + def test_parses_feature_with_a_scenario_with_background_and_and(self): + doc = u""" +Feature: Stuff + Background: + Given some background + And more background + + Scenario: Doing stuff + And with the background + When I do stuff + Then stuff happens +""".lstrip() + feature = parser.parse_feature(doc) + assert feature + assert feature.name == "Stuff" + assert len(feature.scenarios) == 1 + assert feature.background + assert_compare_steps(feature.background.steps, [ + ('given', 'Given', 'some background', None, None), + ('given', 'And', 'more background', None, None) + ]) + + assert feature.scenarios[0].name == "Doing stuff" + assert_compare_steps(feature.scenarios[0].steps, [ + ('given', 'And', 'with the background', None, None), + ('when', 'When', 'I do stuff', None, None), + ('then', 'Then', 'stuff happens', None, None), + ]) + def test_parses_feature_with_a_step_with_a_string_argument(self): doc = u''' Feature: Stuff From 96c22d93cf7dc9f33c55d8e7cea60257b864a6ff Mon Sep 17 00:00:00 2001 From: aneeshdurg Date: Wed, 29 Mar 2023 15:18:15 -0500 Subject: [PATCH 079/240] Support 'Rule's with no steps in 'Background' --- behave/parser.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/behave/parser.py b/behave/parser.py index c17999064..7f197c006 100644 --- a/behave/parser.py +++ b/behave/parser.py @@ -743,10 +743,15 @@ def parse_step(self, line): step_type = self.last_step_type elif step_type in ("and", "but"): if not self.last_step_type: + found_step_type = False if self.scenario_container and self.scenario_container.background: - last_background_step = self.scenario_container.background.steps[-1] - self.last_step_type = last_background_step.step_type - else: + if self.scenario_container.background.steps: + # -- HINT: Rule may have default background w/o steps. + last_background_step = self.scenario_container.background.steps[-1] + self.last_step_type = last_background_step.step_type + found_step_type = True + + if not found_step_type: raise ParserError(u"No previous step", self.line, self.filename) step_type = self.last_step_type From 2f451578a4bd02ae63752a5cd0e692400b9b4703 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 2 Apr 2023 18:32:42 +0200 Subject: [PATCH 080/240] BUMP-VERSION: 1.2.7.dev3 (was: 1.2.7.dev2) --- .bumpversion.cfg | 3 +-- VERSION.txt | 2 +- behave/version.py | 2 +- pytest.ini | 2 +- setup.py | 2 +- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 4f2bb76df..8225c378b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,9 +1,8 @@ [bumpversion] -current_version = 1.2.7.dev2 +current_version = 1.2.7.dev3 files = behave/version.py setup.py VERSION.txt pytest.ini .bumpversion.cfg parse = (?P\d+)\.(?P\d+)\.(?P\d+)(?P\w*) serialize = {major}.{minor}.{patch}{drop} commit = False tag = False allow_dirty = True - diff --git a/VERSION.txt b/VERSION.txt index c4e75f6de..1c6178fbf 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.2.7.dev2 +1.2.7.dev3 diff --git a/behave/version.py b/behave/version.py index 67f4a418f..4e19db26a 100644 --- a/behave/version.py +++ b/behave/version.py @@ -1,2 +1,2 @@ # -- BEHAVE-VERSION: -VERSION = "1.2.7.dev2" +VERSION = "1.2.7.dev3" diff --git a/pytest.ini b/pytest.ini index df2a81fb9..712acba91 100644 --- a/pytest.ini +++ b/pytest.ini @@ -21,7 +21,7 @@ testpaths = tests python_files = test_*.py junit_family = xunit2 addopts = --metadata PACKAGE_UNDER_TEST behave - --metadata PACKAGE_VERSION 1.2.7.dev2 + --metadata PACKAGE_VERSION 1.2.7.dev3 --html=build/testing/report.html --self-contained-html --junit-xml=build/testing/report.xml markers = diff --git a/setup.py b/setup.py index bd739deb6..635b861fe 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ def find_packages_by_root_package(where): # ----------------------------------------------------------------------------- setup( name="behave", - version="1.2.7.dev2", + version="1.2.7.dev3", description="behave is behaviour-driven development, Python style", long_description=description, author="Jens Engel, Benno Rice and Richard Jones", From 0085ff8a784903280ef1db7ba2440a7db02a1358 Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 4 Apr 2023 00:17:28 +0200 Subject: [PATCH 081/240] FIX: Test regression on Windows Gherkin parser: * Strip trailing whitespace in multi-line text REASON: Whitespace normalization (may contain carriage-return on Windows) --- behave/parser.py | 83 +++++++++++++++++++++++++++++++-------- tests/unit/test_parser.py | 26 +++++++++++- 2 files changed, 91 insertions(+), 18 deletions(-) diff --git a/behave/parser.py b/behave/parser.py index 751107e74..2e535eb2a 100644 --- a/behave/parser.py +++ b/behave/parser.py @@ -229,7 +229,7 @@ def parse(self, text, filename=None): for line in text.split("\n"): self.line += 1 - if not line.strip() and self.state != "multiline": + if not line.strip() and self.state != "multiline_text": # -- SKIP EMPTY LINES, except in multiline string args. continue self.action(line) @@ -381,7 +381,7 @@ def ask_parse_failure_oracle(self, line): return None def action(self, line): - if line.strip().startswith("#") and self.state != "multiline": + if line.strip().startswith("#") and self.state != "multiline_text": if self.state != "init" or self.tags or self.variant != "feature": return @@ -584,8 +584,24 @@ def action_steps(self, line): # pylint: disable=R0911 # R0911 Too many return statements (8/6) stripped = line.lstrip() + # if self.statement.steps: + # # -- ENSURE: Multi-line text follows a step. + # if stripped.startswith('"""') or stripped.startswith("'''"): + # # -- CASE: Multi-line text (docstring) after a step detected. + # self.state = "multiline_text" + # self.multiline_start = self.line + # self.multiline_terminator = stripped[:3] + # self.multiline_leading = line.index(stripped[0]) + # return True + if stripped.startswith('"""') or stripped.startswith("'''"): - self.state = "multiline" + # -- CASE: Multi-line text (docstring) after a step detected. + # REQUIRE: Multi-line text follows a step. + if not self.statement.steps: + raise ParserError("Multi-line text before any step", + self.line, self.filename) + + self.state = "multiline_text" self.multiline_start = self.line self.multiline_terminator = stripped[:3] self.multiline_leading = line.index(stripped[0]) @@ -602,25 +618,47 @@ def action_steps(self, line): return True if line.startswith("|"): - assert self.statement.steps, "TABLE-START without step detected." + # -- CASE: TABLE-START detected for data-table of a step + # OLD: assert self.statement.steps, "TABLE-START without step detected" + if not self.statement.steps: + raise ParserError("TABLE-START without step detected", + self.line, self.filename) self.state = "table" return self.action_table(line) return False - def action_multiline(self, line): + def action_multiline_text(self, line): + """Parse remaining multi-line/docstring text below a step + after the triple-quotes were detected: + + * triple-double-quotes or + * triple-single-quotes + + Leading and trailing triple-quotes must be the same. + + :param line: Parsed line, as part of a multi-line text (as string). + """ if line.strip().startswith(self.multiline_terminator): - step = self.statement.steps[-1] - step.text = model.Text(u"\n".join(self.lines), u"text/plain", - self.multiline_start) - if step.name.endswith(":"): - step.name = step.name[:-1] + # -- CASE: Handle the end of a multi-line text part. + # Store the multi-line text in the step object (and continue). + this_step = self.statement.steps[-1] + text = u"\n".join(self.lines) + this_step.text = model.Text(text, u"text/plain", self.multiline_start) + if this_step.name.endswith(":"): + this_step.name = this_step.name[:-1] + + # -- RESET INTERNALS: For next step self.lines = [] self.multiline_terminator = None - self.state = "steps" + self.state = "steps" # NEXT-STATE: Accept additional step(s). return True - self.lines.append(line[self.multiline_leading:]) + # -- SPECIAL CASE: Strip trailing whitespace (whitespace normalization). + # HINT: Required for Windows line-endings, like "\r\n", etc. + text_line = line[self.multiline_leading:].rstrip() + self.lines.append(text_line) + # -- BETTER DIAGNOSTICS: May remove non-whitespace in execute_steps() removed_line_prefix = line[:self.multiline_leading] if removed_line_prefix.strip(): @@ -631,35 +669,46 @@ def action_multiline(self, line): return True def action_table(self, line): - line = line.strip() + """Parse a table, with pipe-separated columns: + * Data table of a step (after the step line) + * Examples table of a ScenarioOutline + """ + line = line.strip() if not line.startswith("|"): + # -- CASE: End-of-table detected if self.examples: + # -- CASE: Examples table of a ScenarioOutline self.examples.table = self.table self.examples = None else: + # -- CASE: Data table of a step step = self.statement.steps[-1] step.table = self.table if step.name.endswith(":"): step.name = step.name[:-1] + + # -- RESET: Parameters for parsing the next step(s). self.table = None self.state = "steps" return self.action_steps(line) if not re.match(r"^(|.+)\|$", line): logger = logging.getLogger("behave") - logger.warning(u"Malformed table row at %s: line %i", self.feature.filename, self.line) + logger.warning(u"Malformed table row at %s: line %i", + self.feature.filename, self.line) # -- SUPPORT: Escaped-pipe(s) in Gherkin cell values. # Search for pipe(s) that are not preceeded with an escape char. cells = [cell.replace("\\|", "|").strip() for cell in re.split(r"(? Date: Tue, 18 Apr 2023 21:04:26 +0200 Subject: [PATCH 082/240] EXAMPLE: Using assertpy.soft_assertions in behave RELATED TO: * Discussion in #1094 --- examples/soft_asserts/README.rst | 97 +++++++++++++++++++ examples/soft_asserts/behave.ini | 15 +++ .../behave_run.output_example.txt | 51 ++++++++++ .../behave_run.output_example2.txt | 44 +++++++++ examples/soft_asserts/features/environment.py | 30 ++++++ .../features/soft_asserts.feature | 39 ++++++++ .../features/steps/number_steps.py | 38 ++++++++ .../features/steps/use_steplib_behave4cmd.py | 12 +++ examples/soft_asserts/py.requirements.txt | 4 + py.requirements/testing.txt | 5 +- 10 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 examples/soft_asserts/README.rst create mode 100644 examples/soft_asserts/behave.ini create mode 100644 examples/soft_asserts/behave_run.output_example.txt create mode 100644 examples/soft_asserts/behave_run.output_example2.txt create mode 100644 examples/soft_asserts/features/environment.py create mode 100644 examples/soft_asserts/features/soft_asserts.feature create mode 100644 examples/soft_asserts/features/steps/number_steps.py create mode 100644 examples/soft_asserts/features/steps/use_steplib_behave4cmd.py create mode 100644 examples/soft_asserts/py.requirements.txt diff --git a/examples/soft_asserts/README.rst b/examples/soft_asserts/README.rst new file mode 100644 index 000000000..e49a8b65a --- /dev/null +++ b/examples/soft_asserts/README.rst @@ -0,0 +1,97 @@ +EXAMPLE: Use Soft Assertions in behave +============================================================================= + +:RELATED TO: `discussion #1094`_ + +This directory provides a simple example how soft-assertions can be used +in ``behave`` by using the ``assertpy`` package. + + +HINT: + +* Python2.7: "@soft_assertions()" decorator does not seem to work. + Use ContextManager solution instead, like: ``with soft_assertions(): ...`` + + +Bootstrap +----------------------------------------------------------------------------- + +ASSUMPTIONS: + +* Python3 is installed (or: Python2.7) +* virtualenv is installed (otherwise use: pip install virtualenv) + +Create a virtual-environment with "virtualenv" and activate it:: + + + $ python3 -mvirtualenv .venv + + # -- STEP 2: Activate the virtualenv + # CASE 1: BASH-LIKE SHELL (on UNIX-like platform: Linux, macOS, WSL, ...) + $ source .venv/bin/activate + + # CASE 2: CMD SHELL (on Windows) + cmd> .venv/Scripts/activate + +Install the required Python packages in the virtualenv:: + + $ pip install -r py.requirements.txt + + +Run the Example +----------------------------------------------------------------------------- + +:: + + # -- USE: -f plain --no-capture (via "behave.ini" defaults) + $ ../../bin/behave -f pretty features + Feature: Use Soft Assertions in behave # features/soft_asserts.feature:1 + RELATED TO: https://github.com/behave/behave/discussions/1094 + Scenario: Failing with Soft Assertions -- CASE 1 # features/soft_asserts.feature:5 + Given a minimum number value of "5" # features/steps/number_steps.py:16 + Then the numbers "2" and "12" are in the valid range # features/steps/number_steps.py:27 + Assertion Failed: soft assertion failures: + 1. Expected <2> to be greater than or equal to <5>, but was not. + + But note that "the step-2 (then step) is expected to fail" # None + + @behave.continue_after_failed_step + Scenario: Failing with Soft Assertions -- CASE 2 # features/soft_asserts.feature:17 + Given a minimum number value of "5" # features/steps/number_steps.py:16 + Then the number "4" is in the valid range # features/steps/number_steps.py:21 + Assertion Failed: Expected <4> to be greater than or equal to <5>, but was not. + + And the number "8" is in the valid range # features/steps/number_steps.py:21 + But note that "the step-2 and step-3 are expected to fail" # ../../behave4cmd0/note_steps.py:15 + But note that "the step-4 should pass" # ../../behave4cmd0/note_steps.py:15 + + @behave.continue_after_failed_step + Scenario: Failing with Soft Assertions -- CASE 1 and CASE 2 # features/soft_asserts.feature:28 + Given a minimum number value of "5" # features/steps/number_steps.py:16 + Then the number "2" is in the valid range # features/steps/number_steps.py:21 + Assertion Failed: Expected <2> to be greater than or equal to <5>, but was not. + + And the numbers "3" and "4" are in the valid range # features/steps/number_steps.py:27 + Assertion Failed: soft assertion failures: + 1. Expected <3> to be greater than or equal to <5>, but was not. + 2. Expected <4> to be greater than or equal to <5>, but was not. + + And the number "8" is in the valid range # features/steps/number_steps.py:21 + But note that "the step-2 and step-3 are expected to fail" # ../../behave4cmd0/note_steps.py:15 + But note that "the step-4 should pass" # ../../behave4cmd0/note_steps.py:15 + + Scenario: Passing # features/soft_asserts.feature:37 + Given a step passes # ../../behave4cmd0/passing_steps.py:23 + And note that "this scenario should be executed and should pass" # ../../behave4cmd0/note_steps.py:15 + + + Failing scenarios: + features/soft_asserts.feature:5 Failing with Soft Assertions -- CASE 1 + features/soft_asserts.feature:17 Failing with Soft Assertions -- CASE 2 + features/soft_asserts.feature:28 Failing with Soft Assertions -- CASE 1 and CASE 2 + + 0 features passed, 1 failed, 0 skipped + 1 scenario passed, 3 failed, 0 skipped + 11 steps passed, 4 failed, 1 skipped, 0 undefined + +.. _`discussion #1094`: https://github.com/behave/behave/discussions/1094 diff --git a/examples/soft_asserts/behave.ini b/examples/soft_asserts/behave.ini new file mode 100644 index 000000000..7c160ef10 --- /dev/null +++ b/examples/soft_asserts/behave.ini @@ -0,0 +1,15 @@ +# ============================================================================= +# BEHAVE CONFIGURATION +# ============================================================================= +# FILE: .behaverc, behave.ini +# +# SEE ALSO: +# * http://packages.python.org/behave/behave.html#configuration-files +# * https://github.com/behave/behave +# * http://pypi.python.org/pypi/behave/ +# ============================================================================= + +[behave] +default_format = pretty +stdout_capture = false +show_source = true diff --git a/examples/soft_asserts/behave_run.output_example.txt b/examples/soft_asserts/behave_run.output_example.txt new file mode 100644 index 000000000..727e1e1cb --- /dev/null +++ b/examples/soft_asserts/behave_run.output_example.txt @@ -0,0 +1,51 @@ +# -- HINT: EXECUTE: ../../bin/behave -f pretty + +Feature: Use Soft Assertions in behave # features/soft_asserts.feature:1 + RELATED TO: https://github.com/behave/behave/discussions/1094 + Scenario: Failing with Soft Assertions -- CASE 1 # features/soft_asserts.feature:5 + Given a minimum number value of "5" # features/steps/number_steps.py:16 + Then the numbers "2" and "12" are in the valid range # features/steps/number_steps.py:25 + Assertion Failed: soft assertion failures: + 1. Expected <2> to be greater than or equal to <5>, but was not. + + But note that "the step-2 (then step) is expected to fail" # None + + @behave.continue_after_failed_step + Scenario: Failing with Soft Assertions -- CASE 2 # features/soft_asserts.feature:17 + Given a minimum number value of "5" # features/steps/number_steps.py:16 + Then the number "4" is in the valid range # features/steps/number_steps.py:21 + Assertion Failed: Expected <4> to be greater than or equal to <5>, but was not. + + And the number "8" is in the valid range # features/steps/number_steps.py:21 + But note that "the step-2 is expected to fail" # ../../behave4cmd0/note_steps.py:15 + But note that "the step-3 should be executed and should pass" # ../../behave4cmd0/note_steps.py:15 + + @behave.continue_after_failed_step + Scenario: Failing with Soft Assertions -- CASE 1 and CASE 2 # features/soft_asserts.feature:28 + Given a minimum number value of "5" # features/steps/number_steps.py:16 + Then the number "2" is in the valid range # features/steps/number_steps.py:21 + Assertion Failed: Expected <2> to be greater than or equal to <5>, but was not. + + And the numbers "3" and "4" are in the valid range # features/steps/number_steps.py:25 + Assertion Failed: soft assertion failures: + 1. Expected <3> to be greater than or equal to <5>, but was not. + 2. Expected <4> to be greater than or equal to <5>, but was not. + + And the number "8" is in the valid range # features/steps/number_steps.py:21 + But note that "the step-2 and step-3 are expected to fail" # ../../behave4cmd0/note_steps.py:15 + But note that "the step-4 should be executed and should pass" # ../../behave4cmd0/note_steps.py:15 + + Scenario: Passing # features/soft_asserts.feature:37 + Given a step passes # ../../behave4cmd0/passing_steps.py:23 + And note that "this scenario should be executed and should pass" # ../../behave4cmd0/note_steps.py:15 + + +Failing scenarios: + features/soft_asserts.feature:5 Failing with Soft Assertions -- CASE 1 + features/soft_asserts.feature:17 Failing with Soft Assertions -- CASE 2 + features/soft_asserts.feature:28 Failing with Soft Assertions -- CASE 1 and CASE 2 + +0 features passed, 1 failed, 0 skipped +1 scenario passed, 3 failed, 0 skipped +11 steps passed, 4 failed, 1 skipped, 0 undefined +Took 0m0.001s diff --git a/examples/soft_asserts/behave_run.output_example2.txt b/examples/soft_asserts/behave_run.output_example2.txt new file mode 100644 index 000000000..27ad0b551 --- /dev/null +++ b/examples/soft_asserts/behave_run.output_example2.txt @@ -0,0 +1,44 @@ +# -- HINT: EXECUTE: ../../bin/behave -f plain + +Feature: Use Soft Assertions in behave + + Scenario: Failing with Soft Assertions -- CASE 1 + Given a minimum number value of "5" ... passed + Then the numbers "2" and "12" are in the valid range ... failed +Assertion Failed: soft assertion failures: +1. Expected <2> to be greater than or equal to <5>, but was not. + + Scenario: Failing with Soft Assertions -- CASE 2 + Given a minimum number value of "5" ... passed + Then the number "4" is in the valid range ... failed +Assertion Failed: Expected <4> to be greater than or equal to <5>, but was not. + And the number "8" is in the valid range ... passed + But note that "the step-2 is expected to fail" ... passed + But note that "the step-3 should be executed and should pass" ... passed + + Scenario: Failing with Soft Assertions -- CASE 1 and CASE 2 + Given a minimum number value of "5" ... passed + Then the number "2" is in the valid range ... failed +Assertion Failed: Expected <2> to be greater than or equal to <5>, but was not. + And the numbers "3" and "4" are in the valid range ... failed +Assertion Failed: soft assertion failures: +1. Expected <3> to be greater than or equal to <5>, but was not. +2. Expected <4> to be greater than or equal to <5>, but was not. + And the number "8" is in the valid range ... passed + But note that "the step-2 and step-3 are expected to fail" ... passed + But note that "the step-4 should be executed and should pass" ... passed + + Scenario: Passing + Given a step passes ... passed + And note that "this scenario should be executed and should pass" ... passed + + +Failing scenarios: + features/soft_asserts.feature:5 Failing with Soft Assertions -- CASE 1 + features/soft_asserts.feature:17 Failing with Soft Assertions -- CASE 2 + features/soft_asserts.feature:28 Failing with Soft Assertions -- CASE 1 and CASE 2 + +0 features passed, 1 failed, 0 skipped +1 scenario passed, 3 failed, 0 skipped +11 steps passed, 4 failed, 1 skipped, 0 undefined +Took 0m0.001s diff --git a/examples/soft_asserts/features/environment.py b/examples/soft_asserts/features/environment.py new file mode 100644 index 000000000..e24c00eda --- /dev/null +++ b/examples/soft_asserts/features/environment.py @@ -0,0 +1,30 @@ +# -*- coding: UTF-8 -*- +# FILE: features/environment.py + +from __future__ import absolute_import, print_function +import os.path +import sys + + +HERE = os.path.abspath(os.path.dirname(__file__)) +TOP_DIR = os.path.abspath(os.path.join(HERE, "../..")) + + +# ----------------------------------------------------------------------------- +# HOOKS: +# ----------------------------------------------------------------------------- +def before_all(context): + setup_python_path() + + +def before_scenario(context, scenario): + if "behave.continue_after_failed_step" in scenario.effective_tags: + scenario.continue_after_failed_step = True + + +# ----------------------------------------------------------------------------- +# SPECIFIC FUNCTIONALITY: +# ----------------------------------------------------------------------------- +def setup_python_path(): + # -- ENSURE: behave4cmd0 can be imported in steps-directory. + sys.path.insert(0, TOP_DIR) diff --git a/examples/soft_asserts/features/soft_asserts.feature b/examples/soft_asserts/features/soft_asserts.feature new file mode 100644 index 000000000..527dea04c --- /dev/null +++ b/examples/soft_asserts/features/soft_asserts.feature @@ -0,0 +1,39 @@ +Feature: Use Soft Assertions in behave + + RELATED TO: https://github.com/behave/behave/discussions/1094 + + Scenario: Failing with Soft Assertions -- CASE 1 + + HINT: + Multiple assert statements in a step are executed even if a assert fails. + After a failed step in the Scenario, + the remaining steps are skipped and the next Scenario is executed. + + Given a minimum number value of "5" + Then the numbers "2" and "12" are in the valid range + But note that "the step-2 (then step) is expected to fail" + + @behave.continue_after_failed_step + Scenario: Failing with Soft Assertions -- CASE 2 + + HINT: If a step in the Scenario fails, execution is continued. + + Given a minimum number value of "5" + Then the number "4" is in the valid range + And the number "8" is in the valid range + But note that "the step-2 is expected to fail" + But note that "the step-3 should be executed and should pass" + + @behave.continue_after_failed_step + Scenario: Failing with Soft Assertions -- CASE 1 and CASE 2 + + Given a minimum number value of "5" + Then the number "2" is in the valid range + And the numbers "3" and "4" are in the valid range + And the number "8" is in the valid range + But note that "the step-2 and step-3 are expected to fail" + But note that "the step-4 should be executed and should pass" + + Scenario: Passing + Given a step passes + And note that "this scenario should be executed and should pass" diff --git a/examples/soft_asserts/features/steps/number_steps.py b/examples/soft_asserts/features/steps/number_steps.py new file mode 100644 index 000000000..84f6be580 --- /dev/null +++ b/examples/soft_asserts/features/steps/number_steps.py @@ -0,0 +1,38 @@ +# -*- coding: UTF-8 -*- +# -- FILE: features/steps/number_steps.py +""" +Step-functions for soft-assertion example. + +STEPS: + Given a minimum number value of "5" + Then the numbers "2" and "12" are in the valid range + And the number "4" is in the valid range +""" +from __future__ import print_function +from behave import given, when, then, step +from assertpy import assert_that, soft_assertions + + +@given(u'a minimum number value of "{min_value:d}"') +def step_given_min_number_value(ctx, min_value): + ctx.min_number_value = min_value + + +@then(u'the number "{number:d}" is in the valid range') +def step_then_number_is_valid(ctx, number): + assert_that(number).is_greater_than_or_equal_to(ctx.min_number_value) + +@then(u'the numbers "{number1:d}" and "{number2:d}" are in the valid range') +@soft_assertions() +def step_then_numbers_are_valid(ctx, number1, number2): + assert_that(number1).is_greater_than_or_equal_to(ctx.min_number_value) + assert_that(number2).is_greater_than_or_equal_to(ctx.min_number_value) + + +@then(u'the positive number "{number:d}" is in the valid range') +# DISABLED: @soft_assertions() +def step_then_positive_number_is_valid(ctx, number): + # -- ALTERNATIVE: Use ContextManager instead of disabled decorator above. + with soft_assertions(): + assert_that(number).is_greater_than_or_equal_to(0) + assert_that(number).is_greater_than_or_equal_to(ctx.min_number_value) diff --git a/examples/soft_asserts/features/steps/use_steplib_behave4cmd.py b/examples/soft_asserts/features/steps/use_steplib_behave4cmd.py new file mode 100644 index 000000000..a56e2fd75 --- /dev/null +++ b/examples/soft_asserts/features/steps/use_steplib_behave4cmd.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +""" +Use behave4cmd0 step library (predecessor of behave4cmd). +""" + +from __future__ import absolute_import + +# -- REGISTER-STEPS FROM STEP-LIBRARY: +# DISABLED: import behave4cmd0.__all_steps__ +import behave4cmd0.passing_steps +import behave4cmd0.failing_steps +import behave4cmd0.note_steps diff --git a/examples/soft_asserts/py.requirements.txt b/examples/soft_asserts/py.requirements.txt new file mode 100644 index 000000000..51025cfda --- /dev/null +++ b/examples/soft_asserts/py.requirements.txt @@ -0,0 +1,4 @@ +assertpy >= 1.1 + +-r ../../py.requirements/basic.txt +-r ../../py.requirements/testing.txt diff --git a/py.requirements/testing.txt b/py.requirements/testing.txt index b5a209c9c..80802a1a8 100644 --- a/py.requirements/testing.txt +++ b/py.requirements/testing.txt @@ -20,8 +20,11 @@ PyHamcrest < 2.0; python_version < '3.0' # HINT: path.py => path (python-install-package was renamed for python3) path.py >=11.5.0,<13.0; python_version < '3.5' path >= 13.1.0; python_version >= '3.5' + # NOTE: toml extra for pyproject.toml-based config -.[toml] +# DISABLED: .[toml] +tomli >= 1.1.0; python_version >= '3.0' and python_version < '3.11' +toml >= 0.10.2; python_version < '3.0' # -- PYTHON2 BACKPORTS: From fcfe5af74aa1affb23b34cdeedf6799b56b2a509 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 22 Apr 2023 18:58:51 +0200 Subject: [PATCH 083/240] CLEANUP: Remove tempfile module usage * Use pytest tmp_path fixture instead --- tests/unit/test_configuration.py | 5 ++--- tests/unit/test_runner.py | 19 +++++++++---------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index c01ffa41e..e66b3f80f 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -1,6 +1,5 @@ import os.path import sys -import tempfile import six import pytest from behave import configuration @@ -67,8 +66,8 @@ class TestConfiguration(object): ("filename", "contents"), list(TEST_CONFIGS) ) - def test_read_file(self, filename, contents): - tndir = tempfile.mkdtemp() + def test_read_file(self, filename, contents, tmp_path): + tndir = str(tmp_path) file_path = os.path.normpath(os.path.join(tndir, filename)) with open(file_path, "w") as fp: fp.write(contents) diff --git a/tests/unit/test_runner.py b/tests/unit/test_runner.py index beaff8fc9..98eba082d 100644 --- a/tests/unit/test_runner.py +++ b/tests/unit/test_runner.py @@ -7,7 +7,6 @@ import os.path import sys import warnings -import tempfile import unittest import six from six import StringIO @@ -623,18 +622,18 @@ def test_teardown_capture_removes_log_tap(self): r.capture_controller.log_capture.abandon.assert_called_with() - def test_exec_file(self): - fn = tempfile.mktemp() - with open(fn, "w") as f: + def test_exec_file(self, tmp_path): + filename = str(tmp_path/"example.py") + with open(filename, "w") as f: f.write("spam = __file__\n") - g = {} - l = {} - runner_util.exec_file(fn, g, l) - assert "__file__" in l + my_globals = {} + my_locals = {} + runner_util.exec_file(filename, my_globals, my_locals) + assert "__file__" in my_locals # pylint: disable=too-many-format-args - assert "spam" in l, '"spam" variable not set in locals (%r)' % (g, l) + assert "spam" in my_locals, '"spam" variable not set in locals (%r)' % (my_globals, my_locals) # pylint: enable=too-many-format-args - assert l["spam"] == fn + assert my_locals["spam"] == filename def test_run_returns_true_if_everything_passed(self): r = runner.Runner(Mock()) From f5c65296c7da0dbd5974672d41058e08fceb521f Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Thu, 4 May 2023 17:46:08 +0200 Subject: [PATCH 084/240] Add instructions for extras when installing from GitHub --- docs/install.rst | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/install.rst b/docs/install.rst index 1b3fb6e0f..cb111446c 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -35,33 +35,36 @@ enter the newly created directory "behave-" and run:: pip install . -Using the Github Repository +Using the GitHub Repository --------------------------- :Category: Bleeding edge :Precondition: :pypi:`pip` is installed Run the following command -to install the newest version from the `Github repository`_:: - +to install the newest version from the `GitHub repository`_:: pip install git+https://github.com/behave/behave -To install a tagged version from the `Github repository`_, use:: +To install a tagged version from the `GitHub repository`_, use:: pip install git+https://github.com/behave/behave@ where is the placeholder for an `existing tag`_. -.. _`Github repository`: https://github.com/behave/behave +When installing extras, use ``#egg=behave[...]``, e.g.:: + + pip install git+https://github.com/behave/behave@v1.2.7.dev3#egg=behave[toml] + +.. _`GitHub repository`: https://github.com/behave/behave .. _`existing tag`: https://github.com/behave/behave/tags Optional Dependencies --------------------- -If needed, additional dependencies can be installed using ``pip install`` -with one of the following installation targets. +If needed, additional dependencies ("extras") can be installed using +``pip install`` with one of the following installation targets. ======================= =================================================================== Installation Target Description From 6ffb031df5381e0462d2d3972018c20a785dd6d0 Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Thu, 4 May 2023 21:19:10 +0200 Subject: [PATCH 085/240] Use Gitter badge link without tracking query string --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 93bd8168e..d1e182f70 100644 --- a/README.rst +++ b/README.rst @@ -19,9 +19,9 @@ behave :target: https://pypi.python.org/pypi/behave/ :alt: License -.. image:: https://badges.gitter.im/Join%20Chat.svg +.. image:: https://badges.gitter.im/join_chat.svg :alt: Join the chat at https://gitter.im/behave/behave - :target: https://gitter.im/behave/behave?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge + :target: https://app.gitter.im/#/room/#behave_behave:gitter.im .. |logo| image:: https://raw.github.com/behave/behave/master/docs/_static/behave_logo1.png From 5326818501c35b36bef9b1ee4f7fddb70256a97b Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Thu, 4 May 2023 22:35:22 +0200 Subject: [PATCH 086/240] Fix failing build of docs (pin urllib3, see #1106) Could not import extension sphinx.builders.linkcheck (exception: urllib3 v2.0 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with OpenSSL 1.0.2n 7 Dec 2017. --- py.requirements/docs.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/py.requirements/docs.txt b/py.requirements/docs.txt index e50190db0..276186044 100644 --- a/py.requirements/docs.txt +++ b/py.requirements/docs.txt @@ -2,11 +2,14 @@ # BEHAVE: PYTHON PACKAGE REQUIREMENTS: For documentation generation # ============================================================================ # REQUIRES: pip >= 8.0 -# AVOID: shponx v4.4.0 and newer -- Problems w/ new link check suggestion warnings +# AVOID: sphinx v4.4.0 and newer -- Problems w/ new link check suggestion warnings +# urllib3 v2.0+ only supports OpenSSL 1.1.1+, 'ssl' module is compiled with +# v1.0.2, see: https://github.com/urllib3/urllib3/issues/2168 sphinx >=1.6,<4.4 sphinx-autobuild sphinx_bootstrap_theme >= 0.6.0 +urllib3 < 2.0.0 # -- SUPPORT: sphinx-doc translations (prepared) sphinx-intl >= 0.9.11 From bea66b069d5fb7f717f6f8bf0d547c401d7b1bc8 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 6 May 2023 15:43:42 +0200 Subject: [PATCH 087/240] FIX: invoke==1.4.1 (pinned) pip requirement * Causes problems for Python >= 3.8 due to bundled YAML RELATED TO: collections.Hashable (DEPRECATED) * Use invoke >= 1.7.0 (that fixes the problem) SEE: * https://www.pyinvoke.org/changelog.html * https://github.com/yaml/pyyaml/issues/202 --- setup.py | 7 ++++--- tasks/py.requirements.txt | 9 +++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/setup.py b/setup.py index 635b861fe..cb0138c2c 100644 --- a/setup.py +++ b/setup.py @@ -125,9 +125,10 @@ def find_packages_by_root_package(where): "PyHamcrest < 2.0; python_version < '3.0'", "pytest-cov", "tox", - "invoke >= 1.4.0", - # -- HINT: path.py => path (python-install-package was renamed for python3) - "path >= 13.1.0; python_version >= '3.5'", + "invoke >=1.7.0,<2.0; python_version < '3.6'", + "invoke >=1.7.0; python_version >= '3.6'", + # -- HINT, was RENAMED: path.py => path (for python3) + "path >= 13.1.0; python_version >= '3.5'", "path.py >= 11.5.0; python_version < '3.5'", "pycmd", "pathlib; python_version <= '3.4'", diff --git a/tasks/py.requirements.txt b/tasks/py.requirements.txt index ac19e9469..a02e6e0e2 100644 --- a/tasks/py.requirements.txt +++ b/tasks/py.requirements.txt @@ -8,12 +8,13 @@ # * http://www.pip-installer.org/ # ============================================================================ -invoke==1.4.1 +invoke >=1.7.0,<2.0; python_version < '3.6' +invoke >=1.7.0; python_version >= '3.6' pycmd -six==1.15.0 +six >= 1.15.0 -# -- HINT: path.py => path (python-install-package was renamed for python3) -path >= 13.1.0; python_version >= '3.5' +# -- HINT, was RENAMED: path.py => path (for python3) +path >= 13.1.0; python_version >= '3.5' path.py >= 11.5.0; python_version < '3.5' # -- PYTHON2 BACKPORTS: From 3f264e2252a3a9833f0a2ebcc44a0ce53efa7df2 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 6 May 2023 20:44:47 +0200 Subject: [PATCH 088/240] CLEANUP: tox, virtualenv related * Use tox < 4.0 for now (hint: tox 4.x does not support python2) * Use virtualenv < 20.22.0 to retain support for Python 2.7, Python <= 3.6 * tox.ini: Remove py27 section, virtualenv >= 20.14.1 fixes #2284 issue SEE: https://github.com/pypa/virtualenv/issues/2284 * py.requirements/ci.tox.txt: Simplify and use "testing.txt" requirements --- py.requirements/ci.tox.txt | 18 +----------------- py.requirements/develop.txt | 3 ++- py.requirements/docs.txt | 5 ++++- py.requirements/testing.txt | 1 - setup.py | 3 ++- tox.ini | 20 +------------------- 6 files changed, 10 insertions(+), 40 deletions(-) diff --git a/py.requirements/ci.tox.txt b/py.requirements/ci.tox.txt index 4eabfc3f4..87c7d5f01 100644 --- a/py.requirements/ci.tox.txt +++ b/py.requirements/ci.tox.txt @@ -1,22 +1,6 @@ # ============================================================================ # BEHAVE: PYTHON PACKAGE REQUIREMENTS: ci.tox.txt # ============================================================================ -# BASED ON: testing.txt - -pytest < 5.0; python_version < '3.0' # pytest >= 4.2 -pytest >= 5.0; python_version >= '3.0' - -pytest-html >= 1.19.0,<2.0; python_version < '3.0' -pytest-html >= 2.0; python_version >= '3.0' - -mock < 4.0; python_version < '3.6' -mock >= 4.0; python_version >= '3.6' -PyHamcrest >= 2.0.2; python_version >= '3.0' -PyHamcrest < 2.0; python_version < '3.0' - -# -- HINT: path.py => path (python-install-package was renamed for python3) -path.py >=11.5.0,<13.0; python_version < '3.5' -path >= 13.1.0; python_version >= '3.5' +-r testing.txt jsonschema - diff --git a/py.requirements/develop.txt b/py.requirements/develop.txt index d79f1f526..f2df9c199 100644 --- a/py.requirements/develop.txt +++ b/py.requirements/develop.txt @@ -29,7 +29,8 @@ pylint -r testing.txt coverage >= 4.2 pytest-cov -tox >= 1.8.1 +tox >= 1.8.1,<4.0 # -- HINT: tox >= 4.0 has breaking changes. +virtualenv < 20.22.0 # -- SUPPORT FOR: Python 2.7, Python <= 3.6 # -- REQUIRED FOR: docs -r docs.txt diff --git a/py.requirements/docs.txt b/py.requirements/docs.txt index 276186044..48404d856 100644 --- a/py.requirements/docs.txt +++ b/py.requirements/docs.txt @@ -9,7 +9,10 @@ sphinx >=1.6,<4.4 sphinx-autobuild sphinx_bootstrap_theme >= 0.6.0 -urllib3 < 2.0.0 + +# -- NEEDED FOR: RTD (as temporary fix) +urllib3 < 2.0.0; python_version < '3.10' +urllib3 >= 2.0.0; python_version >= '3.10' # -- SUPPORT: sphinx-doc translations (prepared) sphinx-intl >= 0.9.11 diff --git a/py.requirements/testing.txt b/py.requirements/testing.txt index 80802a1a8..1cea6736c 100644 --- a/py.requirements/testing.txt +++ b/py.requirements/testing.txt @@ -26,7 +26,6 @@ path >= 13.1.0; python_version >= '3.5' tomli >= 1.1.0; python_version >= '3.0' and python_version < '3.11' toml >= 0.10.2; python_version < '3.0' - # -- PYTHON2 BACKPORTS: pathlib; python_version <= '3.4' diff --git a/setup.py b/setup.py index cb0138c2c..7bdf2f6ca 100644 --- a/setup.py +++ b/setup.py @@ -124,7 +124,8 @@ def find_packages_by_root_package(where): "PyHamcrest >= 2.0.2; python_version >= '3.0'", "PyHamcrest < 2.0; python_version < '3.0'", "pytest-cov", - "tox", + "tox >= 1.8.1,<4.0", # -- HINT: tox >= 4.0 has breaking changes. + "virtualenv < 20.22.0", # -- SUPPORT FOR: Python 2.7, Python <= 3.6 "invoke >=1.7.0,<2.0; python_version < '3.6'", "invoke >=1.7.0; python_version >= '3.6'", # -- HINT, was RENAMED: path.py => path (for python3) diff --git a/tox.ini b/tox.ini index a57cc2f55..c4cdd0ff1 100644 --- a/tox.ini +++ b/tox.ini @@ -16,7 +16,7 @@ [tox] minversion = 2.3 -envlist = py39, py27, py310, py38, pypy3, pypy, docs +envlist = py311, py27, py310, py39, py38, pypy3, pypy, docs skip_missing_interpreters = true @@ -36,24 +36,6 @@ setenv = PYTHONPATH = {toxinidir} -# -- HINT: Script(s) seems to be no longer installed on Python 2.7. -# WEIRD: pip-install seems to need "--user" option. -# RELATED: https://github.com/pypa/virtualenv/issues/2284 -- macOS 12 Monterey related -[testenv:py27] -# MAYBE: platform = darwin -install_command = pip install --user -U {opts} {packages} -changedir = {toxinidir} -commands= - python -m pytest {posargs:tests} - python -m behave --format=progress {posargs:features} - python -m behave --format=progress {posargs:tools/test-features} - python -m behave --format=progress {posargs:issue.features} -deps= - {[testenv]deps} -setenv = - PYTHONPATH = {toxinidir} - - [testenv:docs] changedir = docs commands = From 4576db0d6acfc6ccef679298c5affe25580d85d1 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 6 May 2023 21:10:16 +0200 Subject: [PATCH 089/240] CONSTRAIN: urllib3 fix for RTD docs building * Constrain requirement to python_version <= '3.8' * RTD currently used python3.7 (with the urllib3 problem) --- py.requirements/docs.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/py.requirements/docs.txt b/py.requirements/docs.txt index 48404d856..75bc980c7 100644 --- a/py.requirements/docs.txt +++ b/py.requirements/docs.txt @@ -11,8 +11,7 @@ sphinx-autobuild sphinx_bootstrap_theme >= 0.6.0 # -- NEEDED FOR: RTD (as temporary fix) -urllib3 < 2.0.0; python_version < '3.10' -urllib3 >= 2.0.0; python_version >= '3.10' +urllib3 < 2.0.0; python_version < '3.8' # -- SUPPORT: sphinx-doc translations (prepared) sphinx-intl >= 0.9.11 From ffd677601d1a997d94cb80e5308ef0cc065a1fe3 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 6 May 2023 22:36:11 +0200 Subject: [PATCH 090/240] EXAMPLE FOR: #1002, #1045 (duplicate) Provide an example how an EMPTY string can be matched. HINT: parse module no longer supports EMPTY strings. --- issue.features/issue1002.feature | 186 +++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 issue.features/issue1002.feature diff --git a/issue.features/issue1002.feature b/issue.features/issue1002.feature new file mode 100644 index 000000000..f6739223b --- /dev/null +++ b/issue.features/issue1002.feature @@ -0,0 +1,186 @@ +@issue +Feature: Issue #1002 -- ScenarioOutline with Empty Placeholder Values in Examples Table + + SEE: https://github.com/behave/behave/issues/1002 + SEE: https://github.com/behave/behave/issues/1045 (duplicated) + + . COMMENTS: + . * Named placeholders in the "parse" module do not match EMPTY-STRING (anymore) + . + . SOLUTIONS: + . * Use "Cardinality field parser (cfparse) with optional word, like: "{param:Word?}" + . * Use a second step alias that matches empty string, like: + . + . @step(u'I meet with "{name}"') + . @step(u'I meet with ""') + . def step_meet_person_with_name(ctx, name=""): + . if not name: + . name = "NOBODY" + . + . * Use explicit type converters instead of MATCH-ANYTHING (non-empty), like: + . + . @parse.with_pattern(r".*") + . def parse_any_text(text): + . return text + . + . @parse.with_pattern(r'[^"]*') + . def parse_unquoted_or_empty_text(text): + . return text + . + . register_type(AnyText=parse_any_text) + . register_type(Unquoted=parse_unquoted_or_empty_text) + . + . # -- VARIANT 1: + . @step('Passing parameter "{param:AnyText}"') + . def step_use_parameter_v1(context, param): + . print(param) + . + . # -- VARIANT 2 (ALTERNATIVE: either/or): + . @step('Passing parameter "{param:Unquoted}"') + . def step_use_parameter_v2(context, param): + . print(param) + + Background: Test Setup + Given a new working directory + And a file named "features/example_1002.feature" with: + """ + Feature: + Scenario Outline: Meet with + When I meet with "" + + Examples: + | name | case | + | Alice | Non-empty value | + | | Empty string (SYNDROME) | + """ + + Scenario: SOLUTION 1: Use another step binding for empty-string + Given a file named "features/steps/steps.py" with: + """ + # -- FILE: features/steps/steps.py + from behave import step + + @step(u'I meet with "{name}"') + @step(u'I meet with ""') # -- SPECIAL CASE: Match EMPTY-STRING + def step_meet_with_person(ctx, name=""): + ctx.other_person = name + """ + When I run "behave -f plain features/example_1002.feature" + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 0 skipped + 2 steps passed, 0 failed, 0 skipped + """ + And the command output should not contain "NotImplementedError" + + + Scenario: SOLUTION 2: Use a placeholder type -- AnyText + Given a file named "features/steps/steps.py" with: + """ + # -- FILE: features/steps/steps.py + from behave import step, register_type + import parse + + @parse.with_pattern(r".*") + def parse_any_text(text): + # -- SUPPORTS: AnyText including EMPTY string. + return text + + register_type(AnyText=parse_any_text) + + @step(u'I meet with "{name:AnyText}"') + def step_meet_with_person(ctx, name): + ctx.other_person = name + """ + When I run "behave -f plain features/example_1002.feature" + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 0 skipped + 2 steps passed, 0 failed, 0 skipped + """ + And the command output should not contain "NotImplementedError" + + + Scenario: SOLUTION 3: Use a placeholder type -- Unquoted_or_Empty + Given a file named "features/steps/steps.py" with: + """ + # -- FILE: features/steps/steps.py + from behave import step, register_type + import parse + + @parse.with_pattern(r'[^"]*') + def parse_unquoted_or_empty_text(text): + return text + + register_type(Unquoted_or_Empty=parse_unquoted_or_empty_text) + + @step(u'I meet with "{name:Unquoted_or_Empty}"') + def step_meet_with_person(ctx, name): + # -- SUPPORTS: Unquoted text including EMPTY string + ctx.other_person = name + """ + When I run "behave -f plain features/example_1002.feature" + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 0 skipped + 2 steps passed, 0 failed, 0 skipped + """ + And the command output should not contain "NotImplementedError" + + + Scenario: SOLUTION 4: Use a placeholder type -- OptionalUnquoted + Given a file named "features/steps/steps.py" with: + """ + # -- FILE: features/steps/steps.py + # USE: cfparse with cardinality-field support for: Optional + from behave import step, register_type, use_step_matcher + import parse + + @parse.with_pattern(r'[^"]+') + def parse_unquoted(text): + # -- SUPPORTS: Non-empty unquoted-text + return text + + register_type(Unquoted=parse_unquoted) + use_step_matcher("cfparse") # -- SUPPORT FOR: OptionalUnquoted + + @step(u'I meet with "{name:Unquoted?}"') + def step_meet_with_person(ctx, name): + ctx.other_person = name + """ + When I run "behave -f plain features/example_1002.feature" + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 0 skipped + 2 steps passed, 0 failed, 0 skipped + """ + And the command output should not contain "NotImplementedError" + + + Scenario: SOLUTION 5: Use a placeholder type -- OptionalWord + Given a file named "features/steps/steps.py" with: + """ + # -- FILE: features/steps/steps.py + # USE: cfparse (with cardinality-field support for: Optional) + from behave import step, register_type, use_step_matcher + import parse + + @parse.with_pattern(r'[A-Za-z0-9_\-\.]+') + def parse_word(text): + # -- SUPPORTS: Word but not an EMPTY string + return text + + register_type(Word=parse_word) + use_step_matcher("cfparse") # -- NEEDED FOR: Optional + + @step(u'I meet with "{name:Word?}"') + def step_meet_with_person(ctx, name): + ctx.other_person = name + """ + When I run "behave -f plain features/example_1002.feature" + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 0 skipped + 2 steps passed, 0 failed, 0 skipped + """ + And the command output should not contain "NotImplementedError" From 3a90183be51b0eb5b7cfeafd4dd357bec142182d Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 18 May 2023 23:11:14 +0200 Subject: [PATCH 091/240] FIX: setuptools warning with dash-separated params * SCOPE: setup,cfg parameters (like: update-dir instead of: upload_dir) --- setup.cfg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.cfg b/setup.cfg index d8fc33dc2..b61e71fcc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,7 +11,7 @@ formats = gztar universal = true [upload_docs] -upload-dir = build/docs/html +upload_dir = build/docs/html [behave_test] format = progress @@ -19,8 +19,8 @@ tags = -@xfail args = features tools/test-features issue.features [build_sphinx] -source-dir = docs/ -build-dir = build/docs +source_dir = docs/ +build_dir = build/docs builder = html all_files = true From 0371c22fbdee02264c6121ce3bac59ab1759366b Mon Sep 17 00:00:00 2001 From: jenisys Date: Fri, 19 May 2023 09:35:19 +0200 Subject: [PATCH 092/240] CI CodeQL: Update actions/checkout to v3 (wa: v2) --- .github/workflows/codeql-analysis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 6fb4e077c..7cc64f10d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL From 3c659691b434dff46ff3a462b0147548aa6266e1 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 21 May 2023 12:43:12 +0200 Subject: [PATCH 093/240] behave4cmd0: Modularize steps into multiple modules REFACTOR: command_steps into * command_steps (keep: command related steps only) * environment_steps * filesystem_steps * workdir_steps --- behave4cmd0/__all_steps__.py | 3 + behave4cmd0/command_shell.py | 0 behave4cmd0/command_shell_proc.py | 0 behave4cmd0/command_steps.py | 386 +----------------- behave4cmd0/environment_steps.py | 44 ++ behave4cmd0/filesystem_steps.py | 234 +++++++++++ behave4cmd0/log/steps.py | 11 +- behave4cmd0/setup_command_shell.py | 0 behave4cmd0/step_util.py | 71 ++++ behave4cmd0/workdir_steps.py | 59 +++ features/steps/use_steplib_behave4cmd.py | 10 +- .../steps/use_steplib_behave4cmd.py | 5 + 12 files changed, 443 insertions(+), 380 deletions(-) mode change 100755 => 100644 behave4cmd0/command_shell.py mode change 100755 => 100644 behave4cmd0/command_shell_proc.py create mode 100644 behave4cmd0/environment_steps.py create mode 100644 behave4cmd0/filesystem_steps.py mode change 100755 => 100644 behave4cmd0/setup_command_shell.py create mode 100644 behave4cmd0/step_util.py create mode 100644 behave4cmd0/workdir_steps.py diff --git a/behave4cmd0/__all_steps__.py b/behave4cmd0/__all_steps__.py index 270d04168..7931aa096 100644 --- a/behave4cmd0/__all_steps__.py +++ b/behave4cmd0/__all_steps__.py @@ -10,3 +10,6 @@ import behave4cmd0.command_steps import behave4cmd0.note_steps import behave4cmd0.log.steps +import behave4cmd0.environment_steps +import behave4cmd0.filesystem_steps +import behave4cmd0.workdir_steps diff --git a/behave4cmd0/command_shell.py b/behave4cmd0/command_shell.py old mode 100755 new mode 100644 diff --git a/behave4cmd0/command_shell_proc.py b/behave4cmd0/command_shell_proc.py old mode 100755 new mode 100644 diff --git a/behave4cmd0/command_steps.py b/behave4cmd0/command_steps.py index f3a3797bb..7a01e577a 100644 --- a/behave4cmd0/command_steps.py +++ b/behave4cmd0/command_steps.py @@ -12,17 +12,13 @@ from __future__ import absolute_import, print_function -import codecs -import contextlib -import difflib -import os -import shutil -from behave import given, when, then, step, matchers # pylint: disable=no-name-in-module +from behave import when, then, matchers # pylint: disable=no-name-in-module +from behave4cmd0 import command_shell, command_util, textutil +from behave4cmd0.step_util import (DEBUG, + on_assert_failed_print_details, normalize_text_with_placeholders) from hamcrest import assert_that, equal_to, is_not -from behave4cmd0 import command_shell, command_util, pathutil, textutil -from behave4cmd0.pathutil import posixpath_normpath -from behave4cmd0.command_shell_proc import \ - TextProcessor, BehaveWinCommandOutputProcessor + + # NOT-USED: from hamcrest import contains_string @@ -30,145 +26,6 @@ # INIT: # ----------------------------------------------------------------------------- matchers.register_type(int=int) -DEBUG = False -file_contents_normalizer = None -if BehaveWinCommandOutputProcessor.enabled: - file_contents_normalizer = TextProcessor(BehaveWinCommandOutputProcessor()) - - -# ----------------------------------------------------------------------------- -# UTILITIES: -# ----------------------------------------------------------------------------- -def print_differences(actual, expected): - # diff = difflib.unified_diff(expected.splitlines(), actual.splitlines(), - # "expected", "actual") - diff = difflib.ndiff(expected.splitlines(), actual.splitlines()) - diff_text = u"\n".join(diff) - print(u"DIFF (+ ACTUAL, - EXPECTED):\n{0}\n".format(diff_text)) - if DEBUG: - print(u"expected:\n{0}\n".format(expected)) - print(u"actual:\n{0}\n".format(actual)) - - -@contextlib.contextmanager -def on_assert_failed_print_details(actual, expected): - """ - Print text details in case of assertation failed errors. - - .. sourcecode:: python - - with on_assert_failed_print_details(actual_text, expected_text): - assert actual == expected - """ - try: - yield - except AssertionError: - print_differences(actual, expected) - raise - -@contextlib.contextmanager -def on_error_print_details(actual, expected): - """ - Print text details in case of assertation failed errors. - - .. sourcecode:: python - - with on_error_print_details(actual_text, expected_text): - ... # Do something - """ - try: - yield - except Exception: - print_differences(actual, expected) - raise - - -def is_encoding_valid(encoding): - try: - return bool(codecs.lookup(encoding)) - except LookupError: - return False - - -# ----------------------------------------------------------------------------- -# STEPS: WORKING DIR -# ----------------------------------------------------------------------------- -@given(u'a new working directory') -def step_a_new_working_directory(context): - """Creates a new, empty working directory.""" - command_util.ensure_context_attribute_exists(context, "workdir", None) - # MAYBE: command_util.ensure_workdir_not_exists(context) - command_util.ensure_workdir_exists(context) - # OOPS: - shutil.rmtree(context.workdir, ignore_errors=True) - command_util.ensure_workdir_exists(context) - - -@given(u'I use the current directory as working directory') -def step_use_curdir_as_working_directory(context): - """Uses the current directory as working directory""" - context.workdir = os.path.abspath(".") - command_util.ensure_workdir_exists(context) - - -@step(u'I use the directory "{directory}" as working directory') -def step_use_directory_as_working_directory(context, directory): - """Uses the directory as new working directory""" - command_util.ensure_context_attribute_exists(context, "workdir", None) - current_workdir = context.workdir - if not current_workdir: - current_workdir = os.getcwd() - - if not os.path.isabs(directory): - new_workdir = os.path.join(current_workdir, directory) - exists_relto_current_dir = os.path.isdir(directory) - exists_relto_current_workdir = os.path.isdir(new_workdir) - if exists_relto_current_workdir or not exists_relto_current_dir: - # -- PREFER: Relative to current workdir - workdir = new_workdir - else: - assert exists_relto_current_workdir - workdir = directory - workdir = os.path.abspath(workdir) - - context.workdir = workdir - command_util.ensure_workdir_exists(context) - - -# ----------------------------------------------------------------------------- -# STEPS: Create files with contents -# ----------------------------------------------------------------------------- -@given(u'a file named "{filename}" and encoding="{encoding}" with') -def step_a_file_named_filename_and_encoding_with(context, filename, encoding): - """Creates a textual file with the content provided as docstring.""" - assert context.text is not None, "ENSURE: multiline text is provided." - assert not os.path.isabs(filename) - assert is_encoding_valid(encoding), "INVALID: encoding=%s;" % encoding - command_util.ensure_workdir_exists(context) - filename2 = os.path.join(context.workdir, filename) - pathutil.create_textfile_with_contents(filename2, context.text, encoding) - - -@given(u'a file named "{filename}" with') -def step_a_file_named_filename_with(context, filename): - """Creates a textual file with the content provided as docstring.""" - step_a_file_named_filename_and_encoding_with(context, filename, "UTF-8") - - # -- SPECIAL CASE: For usage with behave steps. - if filename.endswith(".feature"): - command_util.ensure_context_attribute_exists(context, "features", []) - context.features.append(filename) - - -@given(u'an empty file named "{filename}"') -def step_an_empty_file_named_filename(context, filename): - """ - Creates an empty file. - """ - assert not os.path.isabs(filename) - command_util.ensure_workdir_exists(context) - filename2 = os.path.join(context.workdir, filename) - pathutil.create_textfile_with_contents(filename2, "") # ----------------------------------------------------------------------------- @@ -272,17 +129,14 @@ def step_command_output_should_contain_text(context, text): ... Then the command output should contain "TEXT" ''' - expected_text = text - if "{__WORKDIR__}" in expected_text or "{__CWD__}" in expected_text: - expected_text = textutil.template_substitute(text, - __WORKDIR__ = posixpath_normpath(context.workdir), - __CWD__ = posixpath_normpath(os.getcwd()) - ) + expected_text = normalize_text_with_placeholders(context, text) actual_output = context.command_result.output with on_assert_failed_print_details(actual_output, expected_text): textutil.assert_normtext_should_contain(actual_output, expected_text) + + @then(u'the command output should not contain "{text}"') def step_command_output_should_not_contain_text(context, text): ''' @@ -290,12 +144,7 @@ def step_command_output_should_not_contain_text(context, text): ... then the command output should not contain "TEXT" ''' - expected_text = text - if "{__WORKDIR__}" in text or "{__CWD__}" in text: - expected_text = textutil.template_substitute(text, - __WORKDIR__ = posixpath_normpath(context.workdir), - __CWD__ = posixpath_normpath(os.getcwd()) - ) + expected_text = normalize_text_with_placeholders(context, text) actual_output = context.command_result.output with on_assert_failed_print_details(actual_output, expected_text): textutil.assert_normtext_should_not_contain(actual_output, expected_text) @@ -309,12 +158,7 @@ def step_command_output_should_contain_text_multiple_times(context, text, count) Then the command output should contain "TEXT" 3 times ''' assert count >= 0 - expected_text = text - if "{__WORKDIR__}" in expected_text or "{__CWD__}" in expected_text: - expected_text = textutil.template_substitute(text, - __WORKDIR__ = posixpath_normpath(context.workdir), - __CWD__ = posixpath_normpath(os.getcwd()) - ) + expected_text = normalize_text_with_placeholders(context, text) actual_output = context.command_result.output expected_text_part = expected_text with on_assert_failed_print_details(actual_output, expected_text_part): @@ -334,24 +178,14 @@ def step_command_output_should_contain_exactly_text(context, text): When I run "echo Hello" Then the command output should contain "Hello" """ - expected_text = text - if "{__WORKDIR__}" in text or "{__CWD__}" in text: - expected_text = textutil.template_substitute(text, - __WORKDIR__ = posixpath_normpath(context.workdir), - __CWD__ = posixpath_normpath(os.getcwd()) - ) + expected_text = normalize_text_with_placeholders(context, text) actual_output = context.command_result.output textutil.assert_text_should_contain_exactly(actual_output, expected_text) @then(u'the command output should not contain exactly "{text}"') def step_command_output_should_not_contain_exactly_text(context, text): - expected_text = text - if "{__WORKDIR__}" in text or "{__CWD__}" in text: - expected_text = textutil.template_substitute(text, - __WORKDIR__ = posixpath_normpath(context.workdir), - __CWD__ = posixpath_normpath(os.getcwd()) - ) + expected_text = normalize_text_with_placeholders(context, text) actual_output = context.command_result.output textutil.assert_text_should_not_contain_exactly(actual_output, expected_text) @@ -459,197 +293,3 @@ def step_command_output_should_not_match_with_multiline_text(context): assert context.text is not None, "ENSURE: multiline text is provided." pattern = context.text step_command_output_should_not_match_pattern(context, pattern) - - -# ----------------------------------------------------------------------------- -# STEPS FOR: Directories -# ----------------------------------------------------------------------------- -@step(u'I remove the directory "{directory}"') -def step_remove_directory(context, directory): - path_ = directory - if not os.path.isabs(directory): - path_ = os.path.join(context.workdir, os.path.normpath(directory)) - if os.path.isdir(path_): - shutil.rmtree(path_, ignore_errors=True) - assert_that(not os.path.isdir(path_)) - - -@given(u'I ensure that the directory "{directory}" exists') -def step_given_ensure_that_the_directory_exists(context, directory): - path_ = directory - if not os.path.isabs(directory): - path_ = os.path.join(context.workdir, os.path.normpath(directory)) - if not os.path.isdir(path_): - os.makedirs(path_) - assert_that(os.path.isdir(path_)) - - -@given(u'I ensure that the directory "{directory}" does not exist') -def step_given_the_directory_should_not_exist(context, directory): - step_remove_directory(context, directory) - - -@given(u'a directory named "{path}"') -def step_directory_named_dirname(context, path): - assert context.workdir, "REQUIRE: context.workdir" - path_ = os.path.join(context.workdir, os.path.normpath(path)) - if not os.path.exists(path_): - os.makedirs(path_) - assert os.path.isdir(path_) - - -@then(u'the directory "{directory}" should exist') -def step_the_directory_should_exist(context, directory): - path_ = directory - if not os.path.isabs(directory): - path_ = os.path.join(context.workdir, os.path.normpath(directory)) - assert_that(os.path.isdir(path_)) - - -@then(u'the directory "{directory}" should not exist') -def step_the_directory_should_not_exist(context, directory): - path_ = directory - if not os.path.isabs(directory): - path_ = os.path.join(context.workdir, os.path.normpath(directory)) - assert_that(not os.path.isdir(path_)) - - -@step(u'the directory "{directory}" exists') -def step_directory_exists(context, directory): - """ - Verifies that a directory exists. - - .. code-block:: gherkin - - Given the directory "abc.txt" exists - When the directory "abc.txt" exists - """ - step_the_directory_should_exist(context, directory) - - -@step(u'the directory "{directory}" does not exist') -def step_directory_named_does_not_exist(context, directory): - """ - Verifies that a directory does not exist. - - .. code-block:: gherkin - - Given the directory "abc/" does not exist - When the directory "abc/" does not exist - """ - step_the_directory_should_not_exist(context, directory) - - -# ----------------------------------------------------------------------------- -# FILE STEPS: -# ----------------------------------------------------------------------------- -@step(u'a file named "{filename}" exists') -def step_file_named_filename_exists(context, filename): - """ - Verifies that a file with this filename exists. - - .. code-block:: gherkin - - Given a file named "abc.txt" exists - When a file named "abc.txt" exists - """ - step_file_named_filename_should_exist(context, filename) - - -@step(u'a file named "{filename}" does not exist') -@step(u'the file named "{filename}" does not exist') -def step_file_named_filename_does_not_exist(context, filename): - """ - Verifies that a file with this filename does not exist. - - .. code-block:: gherkin - - Given a file named "abc.txt" does not exist - When a file named "abc.txt" does not exist - """ - step_file_named_filename_should_not_exist(context, filename) - - -@then(u'a file named "{filename}" should exist') -def step_file_named_filename_should_exist(context, filename): - command_util.ensure_workdir_exists(context) - filename_ = pathutil.realpath_with_context(filename, context) - assert_that(os.path.exists(filename_) and os.path.isfile(filename_)) - - -@then(u'a file named "{filename}" should not exist') -def step_file_named_filename_should_not_exist(context, filename): - command_util.ensure_workdir_exists(context) - filename_ = pathutil.realpath_with_context(filename, context) - assert_that(not os.path.exists(filename_)) - - -@step(u'I remove the file "{filename}"') -def step_remove_file(context, filename): - path_ = filename - if not os.path.isabs(filename): - path_ = os.path.join(context.workdir, os.path.normpath(filename)) - if os.path.exists(path_) and os.path.isfile(path_): - os.remove(path_) - assert_that(not os.path.isfile(path_)) - - -# ----------------------------------------------------------------------------- -# STEPS FOR FILE CONTENTS: -# ----------------------------------------------------------------------------- -@then(u'the file "{filename}" should contain "{text}"') -def step_file_should_contain_text(context, filename, text): - expected_text = text - if "{__WORKDIR__}" in text or "{__CWD__}" in text: - expected_text = textutil.template_substitute(text, - __WORKDIR__ = posixpath_normpath(context.workdir), - __CWD__ = posixpath_normpath(os.getcwd()) - ) - file_contents = pathutil.read_file_contents(filename, context=context) - file_contents = file_contents.rstrip() - if file_contents_normalizer: - # -- HACK: Inject TextProcessor as text normalizer - file_contents = file_contents_normalizer(file_contents) - with on_assert_failed_print_details(file_contents, expected_text): - textutil.assert_normtext_should_contain(file_contents, expected_text) - - -@then(u'the file "{filename}" should not contain "{text}"') -def step_file_should_not_contain_text(context, filename, text): - file_contents = pathutil.read_file_contents(filename, context=context) - file_contents = file_contents.rstrip() - textutil.assert_normtext_should_not_contain(file_contents, text) - # DISABLED: assert_that(file_contents, is_not(contains_string(text))) - - -@then(u'the file "{filename}" should contain') -def step_file_should_contain_multiline_text(context, filename): - assert context.text is not None, "REQUIRE: multiline text" - step_file_should_contain_text(context, filename, context.text) - - -@then(u'the file "{filename}" should not contain') -def step_file_should_not_contain_multiline_text(context, filename): - assert context.text is not None, "REQUIRE: multiline text" - step_file_should_not_contain_text(context, filename, context.text) - - -# ----------------------------------------------------------------------------- -# ENVIRONMENT VARIABLES -# ----------------------------------------------------------------------------- -@step(u'I set the environment variable "{env_name}" to "{env_value}"') -def step_I_set_the_environment_variable_to(context, env_name, env_value): - if not hasattr(context, "environ"): - context.environ = {} - context.environ[env_name] = env_value - os.environ[env_name] = env_value - - -@step(u'I remove the environment variable "{env_name}"') -def step_I_remove_the_environment_variable(context, env_name): - if not hasattr(context, "environ"): - context.environ = {} - context.environ[env_name] = "" - os.environ[env_name] = "" - del context.environ[env_name] - del os.environ[env_name] diff --git a/behave4cmd0/environment_steps.py b/behave4cmd0/environment_steps.py new file mode 100644 index 000000000..23900d1ec --- /dev/null +++ b/behave4cmd0/environment_steps.py @@ -0,0 +1,44 @@ +# -*- coding: UTF-8 +""" +Behave steps for environment variables (process environment). +""" + +from __future__ import absolute_import, print_function +import os +from behave import given, when, then, step +from hamcrest import assert_that, is_, is_not + + +# ----------------------------------------------------------------------------- +# ENVIRONMENT VARIABLES +# ----------------------------------------------------------------------------- +@step(u'I set the environment variable "{env_name}" to "{env_value}"') +def step_I_set_the_environment_variable_to(context, env_name, env_value): + if not hasattr(context, "environ"): + context.environ = {} + context.environ[env_name] = env_value + os.environ[env_name] = env_value + + +@step(u'I remove the environment variable "{env_name}"') +def step_I_remove_the_environment_variable(context, env_name): + if not hasattr(context, "environ"): + context.environ = {} + context.environ[env_name] = "" + os.environ[env_name] = "" + del context.environ[env_name] + del os.environ[env_name] + + +@given(u'the environment variable "{env_name}" exists') +@then(u'the environment variable "{env_name}" exists') +def step_the_environment_variable_exists(context, env_name): + env_variable_value = os.environ.get(env_name) + assert_that(env_variable_value, is_not(None)) + + +@given(u'the environment variable "{env_name}" does not exist') +@then(u'the environment variable "{env_name}" does not exist') +def step_I_set_the_environment_variable_to(context, env_name): + env_variable_value = os.environ.get(env_name) + assert_that(env_variable_value, is_(None)) diff --git a/behave4cmd0/filesystem_steps.py b/behave4cmd0/filesystem_steps.py new file mode 100644 index 000000000..f0afceb53 --- /dev/null +++ b/behave4cmd0/filesystem_steps.py @@ -0,0 +1,234 @@ + +from __future__ import absolute_import, print_function +import codecs +import os +import os.path +import shutil +from behave import given, when, then, step +from behave4cmd0 import command_util, pathutil, textutil +from behave4cmd0.step_util import ( + on_assert_failed_print_details, normalize_text_with_placeholders) +from behave4cmd0.command_shell_proc import \ + TextProcessor, BehaveWinCommandOutputProcessor +from behave4cmd0.pathutil import posixpath_normpath +from hamcrest import assert_that + + +file_contents_normalizer = None +if BehaveWinCommandOutputProcessor.enabled: + file_contents_normalizer = TextProcessor(BehaveWinCommandOutputProcessor()) + + +def is_encoding_valid(encoding): + try: + return bool(codecs.lookup(encoding)) + except LookupError: + return False + + +# ----------------------------------------------------------------------------- +# STEPS FOR: Directories +# ----------------------------------------------------------------------------- +@step(u'I remove the directory "{directory}"') +def step_remove_directory(context, directory): + path_ = directory + if not os.path.isabs(directory): + path_ = os.path.join(context.workdir, os.path.normpath(directory)) + if os.path.isdir(path_): + shutil.rmtree(path_, ignore_errors=True) + assert_that(not os.path.isdir(path_)) + + +@given(u'I ensure that the directory "{directory}" exists') +def step_given_ensure_that_the_directory_exists(context, directory): + path_ = directory + if not os.path.isabs(directory): + path_ = os.path.join(context.workdir, os.path.normpath(directory)) + if not os.path.isdir(path_): + os.makedirs(path_) + assert_that(os.path.isdir(path_)) + + +@given(u'I ensure that the directory "{directory}" does not exist') +def step_given_the_directory_should_not_exist(context, directory): + step_remove_directory(context, directory) + + +@given(u'a directory named "{path}"') +def step_directory_named_dirname(context, path): + assert context.workdir, "REQUIRE: context.workdir" + path_ = os.path.join(context.workdir, os.path.normpath(path)) + if not os.path.exists(path_): + os.makedirs(path_) + assert os.path.isdir(path_) + + +@then(u'the directory "{directory}" should exist') +def step_the_directory_should_exist(context, directory): + path_ = directory + if not os.path.isabs(directory): + path_ = os.path.join(context.workdir, os.path.normpath(directory)) + assert_that(os.path.isdir(path_)) + + +@then(u'the directory "{directory}" should not exist') +def step_the_directory_should_not_exist(context, directory): + path_ = directory + if not os.path.isabs(directory): + path_ = os.path.join(context.workdir, os.path.normpath(directory)) + assert_that(not os.path.isdir(path_)) + + +@step(u'the directory "{directory}" exists') +def step_directory_exists(context, directory): + """ + Verifies that a directory exists. + + .. code-block:: gherkin + + Given the directory "abc.txt" exists + When the directory "abc.txt" exists + """ + step_the_directory_should_exist(context, directory) + + +@step(u'the directory "{directory}" does not exist') +def step_directory_named_does_not_exist(context, directory): + """ + Verifies that a directory does not exist. + + .. code-block:: gherkin + + Given the directory "abc/" does not exist + When the directory "abc/" does not exist + """ + step_the_directory_should_not_exist(context, directory) + + +# ----------------------------------------------------------------------------- +# FILE STEPS: +# ----------------------------------------------------------------------------- +@step(u'a file named "{filename}" exists') +def step_file_named_filename_exists(context, filename): + """ + Verifies that a file with this filename exists. + + .. code-block:: gherkin + + Given a file named "abc.txt" exists + When a file named "abc.txt" exists + """ + step_file_named_filename_should_exist(context, filename) + + +@step(u'a file named "{filename}" does not exist') +@step(u'the file named "{filename}" does not exist') +def step_file_named_filename_does_not_exist(context, filename): + """ + Verifies that a file with this filename does not exist. + + .. code-block:: gherkin + + Given a file named "abc.txt" does not exist + When a file named "abc.txt" does not exist + """ + step_file_named_filename_should_not_exist(context, filename) + + +@then(u'a file named "{filename}" should exist') +def step_file_named_filename_should_exist(context, filename): + command_util.ensure_workdir_exists(context) + filename_ = pathutil.realpath_with_context(filename, context) + assert_that(os.path.exists(filename_) and os.path.isfile(filename_)) + + +@then(u'a file named "{filename}" should not exist') +def step_file_named_filename_should_not_exist(context, filename): + command_util.ensure_workdir_exists(context) + filename_ = pathutil.realpath_with_context(filename, context) + assert_that(not os.path.exists(filename_)) + + +# ----------------------------------------------------------------------------- +# STEPS FOR EXISTING FILES WITH FILE CONTENTS: +# ----------------------------------------------------------------------------- +@then(u'the file "{filename}" should contain "{text}"') +def step_file_should_contain_text(context, filename, text): + expected_text = normalize_text_with_placeholders(context, text) + file_contents = pathutil.read_file_contents(filename, context=context) + file_contents = file_contents.rstrip() + if file_contents_normalizer: + # -- HACK: Inject TextProcessor as text normalizer + file_contents = file_contents_normalizer(file_contents) + with on_assert_failed_print_details(file_contents, expected_text): + textutil.assert_normtext_should_contain(file_contents, expected_text) + + +@then(u'the file "{filename}" should not contain "{text}"') +def step_file_should_not_contain_text(context, filename, text): + expected_text = normalize_text_with_placeholders(context, text) + file_contents = pathutil.read_file_contents(filename, context=context) + file_contents = file_contents.rstrip() + + with on_assert_failed_print_details(file_contents, expected_text): + textutil.assert_normtext_should_not_contain(file_contents, expected_text) + # DISABLED: assert_that(file_contents, is_not(contains_string(text))) + + +@then(u'the file "{filename}" should contain') +def step_file_should_contain_multiline_text(context, filename): + assert context.text is not None, "REQUIRE: multiline text" + step_file_should_contain_text(context, filename, context.text) + + +@then(u'the file "{filename}" should not contain') +def step_file_should_not_contain_multiline_text(context, filename): + assert context.text is not None, "REQUIRE: multiline text" + step_file_should_not_contain_text(context, filename, context.text) + + +# ----------------------------------------------------------------------------- +# STEPS FOR CREATING FILES WITH FILE CONTENTS: +# ----------------------------------------------------------------------------- +@given(u'a file named "{filename}" and encoding="{encoding}" with') +def step_a_file_named_filename_and_encoding_with(context, filename, encoding): + """Creates a textual file with the content provided as docstring.""" + assert context.text is not None, "ENSURE: multiline text is provided." + assert not os.path.isabs(filename) + assert is_encoding_valid(encoding), "INVALID: encoding=%s;" % encoding + command_util.ensure_workdir_exists(context) + filename2 = os.path.join(context.workdir, filename) + pathutil.create_textfile_with_contents(filename2, context.text, encoding) + + +@given(u'a file named "{filename}" with') +def step_a_file_named_filename_with(context, filename): + """Creates a textual file with the content provided as docstring.""" + step_a_file_named_filename_and_encoding_with(context, filename, "UTF-8") + + # -- SPECIAL CASE: For usage with behave steps. + if filename.endswith(".feature"): + command_util.ensure_context_attribute_exists(context, "features", []) + context.features.append(filename) + + +@given(u'an empty file named "{filename}"') +def step_an_empty_file_named_filename(context, filename): + """ + Creates an empty file. + """ + assert not os.path.isabs(filename) + command_util.ensure_workdir_exists(context) + filename2 = os.path.join(context.workdir, filename) + pathutil.create_textfile_with_contents(filename2, "") + + +@step(u'I remove the file "{filename}"') +@step(u'I remove the file named "{filename}"') +def step_remove_file(context, filename): + path_ = filename + if not os.path.isabs(filename): + path_ = os.path.join(context.workdir, os.path.normpath(filename)) + if os.path.exists(path_) and os.path.isfile(path_): + os.remove(path_) + assert_that(not os.path.isfile(path_)) diff --git a/behave4cmd0/log/steps.py b/behave4cmd0/log/steps.py index cec2cab31..2ec94fa1c 100644 --- a/behave4cmd0/log/steps.py +++ b/behave4cmd0/log/steps.py @@ -57,14 +57,15 @@ | bar | CURRENT | xxx | """ -from __future__ import absolute_import +from __future__ import absolute_import, print_function +import logging from behave import given, when, then, step -from behave4cmd0.command_steps import \ - step_file_should_contain_multiline_text, \ - step_file_should_not_contain_multiline_text from behave.configuration import LogLevel from behave.log_capture import LoggingCapture -import logging +from behave4cmd0.filesystem_steps import ( + step_file_should_contain_multiline_text, + step_file_should_not_contain_multiline_text) + # ----------------------------------------------------------------------------- # STEP UTILS: diff --git a/behave4cmd0/setup_command_shell.py b/behave4cmd0/setup_command_shell.py old mode 100755 new mode 100644 diff --git a/behave4cmd0/step_util.py b/behave4cmd0/step_util.py new file mode 100644 index 000000000..75e06e7aa --- /dev/null +++ b/behave4cmd0/step_util.py @@ -0,0 +1,71 @@ +from __future__ import absolute_import, print_function +import contextlib +import difflib +import os + +from behave4cmd0 import textutil +from behave4cmd0.pathutil import posixpath_normpath + + +# ----------------------------------------------------------------------------- +# CONSTANTS: +# ----------------------------------------------------------------------------- +DEBUG = False + + +# ----------------------------------------------------------------------------- +# UTILITY FUNCTIONS: +# ----------------------------------------------------------------------------- +def print_differences(actual, expected): + # diff = difflib.unified_diff(expected.splitlines(), actual.splitlines(), + # "expected", "actual") + diff = difflib.ndiff(expected.splitlines(), actual.splitlines()) + diff_text = u"\n".join(diff) + print(u"DIFF (+ ACTUAL, - EXPECTED):\n{0}\n".format(diff_text)) + if DEBUG: + print(u"expected:\n{0}\n".format(expected)) + print(u"actual:\n{0}\n".format(actual)) + + +@contextlib.contextmanager +def on_assert_failed_print_details(actual, expected): + """ + Print text details in case of assertation failed errors. + + .. sourcecode:: python + + with on_assert_failed_print_details(actual_text, expected_text): + assert actual == expected + """ + try: + yield + except AssertionError: + print_differences(actual, expected) + raise + + +@contextlib.contextmanager +def on_error_print_details(actual, expected): + """ + Print text details in case of assertation failed errors. + + .. sourcecode:: python + + with on_error_print_details(actual_text, expected_text): + ... # Do something + """ + try: + yield + except Exception: + print_differences(actual, expected) + raise + + +def normalize_text_with_placeholders(ctx, text): + expected_text = text + if "{__WORKDIR__}" in expected_text or "{__CWD__}" in expected_text: + expected_text = textutil.template_substitute(text, + __WORKDIR__=posixpath_normpath(ctx.workdir), + __CWD__=posixpath_normpath(os.getcwd()) + ) + return expected_text diff --git a/behave4cmd0/workdir_steps.py b/behave4cmd0/workdir_steps.py new file mode 100644 index 000000000..7358d5364 --- /dev/null +++ b/behave4cmd0/workdir_steps.py @@ -0,0 +1,59 @@ +""" +Provides :mod:`behave` steps to provide and use "working directory" +as base directory to: + +* Create files +* Create directories +""" + +from __future__ import absolute_import, print_function +import os +import shutil + +from behave import given, step +from behave4cmd0 import command_util + + +# ----------------------------------------------------------------------------- +# STEPS: WORKING DIR +# ----------------------------------------------------------------------------- +@given(u'a new working directory') +def step_a_new_working_directory(context): + """Creates a new, empty working directory.""" + command_util.ensure_context_attribute_exists(context, "workdir", None) + # MAYBE: command_util.ensure_workdir_not_exists(context) + command_util.ensure_workdir_exists(context) + # OOPS: + shutil.rmtree(context.workdir, ignore_errors=True) + command_util.ensure_workdir_exists(context) + + +@given(u'I use the current directory as working directory') +def step_use_curdir_as_working_directory(context): + """Uses the current directory as working directory""" + context.workdir = os.path.abspath(".") + command_util.ensure_workdir_exists(context) + + +@step(u'I use the directory "{directory}" as working directory') +def step_use_directory_as_working_directory(context, directory): + """Uses the directory as new working directory""" + command_util.ensure_context_attribute_exists(context, "workdir", None) + current_workdir = context.workdir + if not current_workdir: + current_workdir = os.getcwd() + + if not os.path.isabs(directory): + new_workdir = os.path.join(current_workdir, directory) + exists_relto_current_dir = os.path.isdir(directory) + exists_relto_current_workdir = os.path.isdir(new_workdir) + if exists_relto_current_workdir or not exists_relto_current_dir: + # -- PREFER: Relative to current workdir + workdir = new_workdir + else: + assert exists_relto_current_workdir + workdir = directory + workdir = os.path.abspath(workdir) + + context.workdir = workdir + command_util.ensure_workdir_exists(context) diff --git a/features/steps/use_steplib_behave4cmd.py b/features/steps/use_steplib_behave4cmd.py index 94d8766af..94aaab362 100644 --- a/features/steps/use_steplib_behave4cmd.py +++ b/features/steps/use_steplib_behave4cmd.py @@ -6,7 +6,13 @@ from __future__ import absolute_import # -- REGISTER-STEPS FROM STEP-LIBRARY: -import behave4cmd0.__all_steps__ -import behave4cmd0.passing_steps +# import behave4cmd0.__all_steps__ +import behave4cmd0.command_steps +import behave4cmd0.environment_steps +import behave4cmd0.filesystem_steps +import behave4cmd0.workdir_steps +import behave4cmd0.log.steps + import behave4cmd0.failing_steps +import behave4cmd0.passing_steps import behave4cmd0.note_steps diff --git a/issue.features/steps/use_steplib_behave4cmd.py b/issue.features/steps/use_steplib_behave4cmd.py index 98174b6ec..8f50a2954 100644 --- a/issue.features/steps/use_steplib_behave4cmd.py +++ b/issue.features/steps/use_steplib_behave4cmd.py @@ -8,6 +8,11 @@ # -- REGISTER-STEPS FROM STEP-LIBRARY: # import behave4cmd0.__all_steps__ import behave4cmd0.command_steps +import behave4cmd0.environment_steps +import behave4cmd0.filesystem_steps +import behave4cmd0.workdir_steps +import behave4cmd0.log.steps + import behave4cmd0.passing_steps import behave4cmd0.failing_steps import behave4cmd0.note_steps From 0e5c80fa2ad63e58017ed2d687d130633428171f Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 21 May 2023 12:43:12 +0200 Subject: [PATCH 094/240] behave4cmd0: Modularize steps into multiple modules REFACTOR: command_steps into * command_steps (keep: command related steps only) * environment_steps * filesystem_steps * workdir_steps --- behave4cmd0/__all_steps__.py | 3 + behave4cmd0/command_shell.py | 0 behave4cmd0/command_shell_proc.py | 0 behave4cmd0/command_steps.py | 386 +----------------- behave4cmd0/environment_steps.py | 44 ++ behave4cmd0/filesystem_steps.py | 236 +++++++++++ behave4cmd0/log/steps.py | 11 +- behave4cmd0/setup_command_shell.py | 0 behave4cmd0/step_util.py | 71 ++++ behave4cmd0/workdir_steps.py | 59 +++ features/steps/use_steplib_behave4cmd.py | 10 +- .../steps/use_steplib_behave4cmd.py | 5 + 12 files changed, 445 insertions(+), 380 deletions(-) mode change 100755 => 100644 behave4cmd0/command_shell.py mode change 100755 => 100644 behave4cmd0/command_shell_proc.py create mode 100644 behave4cmd0/environment_steps.py create mode 100644 behave4cmd0/filesystem_steps.py mode change 100755 => 100644 behave4cmd0/setup_command_shell.py create mode 100644 behave4cmd0/step_util.py create mode 100644 behave4cmd0/workdir_steps.py diff --git a/behave4cmd0/__all_steps__.py b/behave4cmd0/__all_steps__.py index 270d04168..7931aa096 100644 --- a/behave4cmd0/__all_steps__.py +++ b/behave4cmd0/__all_steps__.py @@ -10,3 +10,6 @@ import behave4cmd0.command_steps import behave4cmd0.note_steps import behave4cmd0.log.steps +import behave4cmd0.environment_steps +import behave4cmd0.filesystem_steps +import behave4cmd0.workdir_steps diff --git a/behave4cmd0/command_shell.py b/behave4cmd0/command_shell.py old mode 100755 new mode 100644 diff --git a/behave4cmd0/command_shell_proc.py b/behave4cmd0/command_shell_proc.py old mode 100755 new mode 100644 diff --git a/behave4cmd0/command_steps.py b/behave4cmd0/command_steps.py index f3a3797bb..7a01e577a 100644 --- a/behave4cmd0/command_steps.py +++ b/behave4cmd0/command_steps.py @@ -12,17 +12,13 @@ from __future__ import absolute_import, print_function -import codecs -import contextlib -import difflib -import os -import shutil -from behave import given, when, then, step, matchers # pylint: disable=no-name-in-module +from behave import when, then, matchers # pylint: disable=no-name-in-module +from behave4cmd0 import command_shell, command_util, textutil +from behave4cmd0.step_util import (DEBUG, + on_assert_failed_print_details, normalize_text_with_placeholders) from hamcrest import assert_that, equal_to, is_not -from behave4cmd0 import command_shell, command_util, pathutil, textutil -from behave4cmd0.pathutil import posixpath_normpath -from behave4cmd0.command_shell_proc import \ - TextProcessor, BehaveWinCommandOutputProcessor + + # NOT-USED: from hamcrest import contains_string @@ -30,145 +26,6 @@ # INIT: # ----------------------------------------------------------------------------- matchers.register_type(int=int) -DEBUG = False -file_contents_normalizer = None -if BehaveWinCommandOutputProcessor.enabled: - file_contents_normalizer = TextProcessor(BehaveWinCommandOutputProcessor()) - - -# ----------------------------------------------------------------------------- -# UTILITIES: -# ----------------------------------------------------------------------------- -def print_differences(actual, expected): - # diff = difflib.unified_diff(expected.splitlines(), actual.splitlines(), - # "expected", "actual") - diff = difflib.ndiff(expected.splitlines(), actual.splitlines()) - diff_text = u"\n".join(diff) - print(u"DIFF (+ ACTUAL, - EXPECTED):\n{0}\n".format(diff_text)) - if DEBUG: - print(u"expected:\n{0}\n".format(expected)) - print(u"actual:\n{0}\n".format(actual)) - - -@contextlib.contextmanager -def on_assert_failed_print_details(actual, expected): - """ - Print text details in case of assertation failed errors. - - .. sourcecode:: python - - with on_assert_failed_print_details(actual_text, expected_text): - assert actual == expected - """ - try: - yield - except AssertionError: - print_differences(actual, expected) - raise - -@contextlib.contextmanager -def on_error_print_details(actual, expected): - """ - Print text details in case of assertation failed errors. - - .. sourcecode:: python - - with on_error_print_details(actual_text, expected_text): - ... # Do something - """ - try: - yield - except Exception: - print_differences(actual, expected) - raise - - -def is_encoding_valid(encoding): - try: - return bool(codecs.lookup(encoding)) - except LookupError: - return False - - -# ----------------------------------------------------------------------------- -# STEPS: WORKING DIR -# ----------------------------------------------------------------------------- -@given(u'a new working directory') -def step_a_new_working_directory(context): - """Creates a new, empty working directory.""" - command_util.ensure_context_attribute_exists(context, "workdir", None) - # MAYBE: command_util.ensure_workdir_not_exists(context) - command_util.ensure_workdir_exists(context) - # OOPS: - shutil.rmtree(context.workdir, ignore_errors=True) - command_util.ensure_workdir_exists(context) - - -@given(u'I use the current directory as working directory') -def step_use_curdir_as_working_directory(context): - """Uses the current directory as working directory""" - context.workdir = os.path.abspath(".") - command_util.ensure_workdir_exists(context) - - -@step(u'I use the directory "{directory}" as working directory') -def step_use_directory_as_working_directory(context, directory): - """Uses the directory as new working directory""" - command_util.ensure_context_attribute_exists(context, "workdir", None) - current_workdir = context.workdir - if not current_workdir: - current_workdir = os.getcwd() - - if not os.path.isabs(directory): - new_workdir = os.path.join(current_workdir, directory) - exists_relto_current_dir = os.path.isdir(directory) - exists_relto_current_workdir = os.path.isdir(new_workdir) - if exists_relto_current_workdir or not exists_relto_current_dir: - # -- PREFER: Relative to current workdir - workdir = new_workdir - else: - assert exists_relto_current_workdir - workdir = directory - workdir = os.path.abspath(workdir) - - context.workdir = workdir - command_util.ensure_workdir_exists(context) - - -# ----------------------------------------------------------------------------- -# STEPS: Create files with contents -# ----------------------------------------------------------------------------- -@given(u'a file named "{filename}" and encoding="{encoding}" with') -def step_a_file_named_filename_and_encoding_with(context, filename, encoding): - """Creates a textual file with the content provided as docstring.""" - assert context.text is not None, "ENSURE: multiline text is provided." - assert not os.path.isabs(filename) - assert is_encoding_valid(encoding), "INVALID: encoding=%s;" % encoding - command_util.ensure_workdir_exists(context) - filename2 = os.path.join(context.workdir, filename) - pathutil.create_textfile_with_contents(filename2, context.text, encoding) - - -@given(u'a file named "{filename}" with') -def step_a_file_named_filename_with(context, filename): - """Creates a textual file with the content provided as docstring.""" - step_a_file_named_filename_and_encoding_with(context, filename, "UTF-8") - - # -- SPECIAL CASE: For usage with behave steps. - if filename.endswith(".feature"): - command_util.ensure_context_attribute_exists(context, "features", []) - context.features.append(filename) - - -@given(u'an empty file named "{filename}"') -def step_an_empty_file_named_filename(context, filename): - """ - Creates an empty file. - """ - assert not os.path.isabs(filename) - command_util.ensure_workdir_exists(context) - filename2 = os.path.join(context.workdir, filename) - pathutil.create_textfile_with_contents(filename2, "") # ----------------------------------------------------------------------------- @@ -272,17 +129,14 @@ def step_command_output_should_contain_text(context, text): ... Then the command output should contain "TEXT" ''' - expected_text = text - if "{__WORKDIR__}" in expected_text or "{__CWD__}" in expected_text: - expected_text = textutil.template_substitute(text, - __WORKDIR__ = posixpath_normpath(context.workdir), - __CWD__ = posixpath_normpath(os.getcwd()) - ) + expected_text = normalize_text_with_placeholders(context, text) actual_output = context.command_result.output with on_assert_failed_print_details(actual_output, expected_text): textutil.assert_normtext_should_contain(actual_output, expected_text) + + @then(u'the command output should not contain "{text}"') def step_command_output_should_not_contain_text(context, text): ''' @@ -290,12 +144,7 @@ def step_command_output_should_not_contain_text(context, text): ... then the command output should not contain "TEXT" ''' - expected_text = text - if "{__WORKDIR__}" in text or "{__CWD__}" in text: - expected_text = textutil.template_substitute(text, - __WORKDIR__ = posixpath_normpath(context.workdir), - __CWD__ = posixpath_normpath(os.getcwd()) - ) + expected_text = normalize_text_with_placeholders(context, text) actual_output = context.command_result.output with on_assert_failed_print_details(actual_output, expected_text): textutil.assert_normtext_should_not_contain(actual_output, expected_text) @@ -309,12 +158,7 @@ def step_command_output_should_contain_text_multiple_times(context, text, count) Then the command output should contain "TEXT" 3 times ''' assert count >= 0 - expected_text = text - if "{__WORKDIR__}" in expected_text or "{__CWD__}" in expected_text: - expected_text = textutil.template_substitute(text, - __WORKDIR__ = posixpath_normpath(context.workdir), - __CWD__ = posixpath_normpath(os.getcwd()) - ) + expected_text = normalize_text_with_placeholders(context, text) actual_output = context.command_result.output expected_text_part = expected_text with on_assert_failed_print_details(actual_output, expected_text_part): @@ -334,24 +178,14 @@ def step_command_output_should_contain_exactly_text(context, text): When I run "echo Hello" Then the command output should contain "Hello" """ - expected_text = text - if "{__WORKDIR__}" in text or "{__CWD__}" in text: - expected_text = textutil.template_substitute(text, - __WORKDIR__ = posixpath_normpath(context.workdir), - __CWD__ = posixpath_normpath(os.getcwd()) - ) + expected_text = normalize_text_with_placeholders(context, text) actual_output = context.command_result.output textutil.assert_text_should_contain_exactly(actual_output, expected_text) @then(u'the command output should not contain exactly "{text}"') def step_command_output_should_not_contain_exactly_text(context, text): - expected_text = text - if "{__WORKDIR__}" in text or "{__CWD__}" in text: - expected_text = textutil.template_substitute(text, - __WORKDIR__ = posixpath_normpath(context.workdir), - __CWD__ = posixpath_normpath(os.getcwd()) - ) + expected_text = normalize_text_with_placeholders(context, text) actual_output = context.command_result.output textutil.assert_text_should_not_contain_exactly(actual_output, expected_text) @@ -459,197 +293,3 @@ def step_command_output_should_not_match_with_multiline_text(context): assert context.text is not None, "ENSURE: multiline text is provided." pattern = context.text step_command_output_should_not_match_pattern(context, pattern) - - -# ----------------------------------------------------------------------------- -# STEPS FOR: Directories -# ----------------------------------------------------------------------------- -@step(u'I remove the directory "{directory}"') -def step_remove_directory(context, directory): - path_ = directory - if not os.path.isabs(directory): - path_ = os.path.join(context.workdir, os.path.normpath(directory)) - if os.path.isdir(path_): - shutil.rmtree(path_, ignore_errors=True) - assert_that(not os.path.isdir(path_)) - - -@given(u'I ensure that the directory "{directory}" exists') -def step_given_ensure_that_the_directory_exists(context, directory): - path_ = directory - if not os.path.isabs(directory): - path_ = os.path.join(context.workdir, os.path.normpath(directory)) - if not os.path.isdir(path_): - os.makedirs(path_) - assert_that(os.path.isdir(path_)) - - -@given(u'I ensure that the directory "{directory}" does not exist') -def step_given_the_directory_should_not_exist(context, directory): - step_remove_directory(context, directory) - - -@given(u'a directory named "{path}"') -def step_directory_named_dirname(context, path): - assert context.workdir, "REQUIRE: context.workdir" - path_ = os.path.join(context.workdir, os.path.normpath(path)) - if not os.path.exists(path_): - os.makedirs(path_) - assert os.path.isdir(path_) - - -@then(u'the directory "{directory}" should exist') -def step_the_directory_should_exist(context, directory): - path_ = directory - if not os.path.isabs(directory): - path_ = os.path.join(context.workdir, os.path.normpath(directory)) - assert_that(os.path.isdir(path_)) - - -@then(u'the directory "{directory}" should not exist') -def step_the_directory_should_not_exist(context, directory): - path_ = directory - if not os.path.isabs(directory): - path_ = os.path.join(context.workdir, os.path.normpath(directory)) - assert_that(not os.path.isdir(path_)) - - -@step(u'the directory "{directory}" exists') -def step_directory_exists(context, directory): - """ - Verifies that a directory exists. - - .. code-block:: gherkin - - Given the directory "abc.txt" exists - When the directory "abc.txt" exists - """ - step_the_directory_should_exist(context, directory) - - -@step(u'the directory "{directory}" does not exist') -def step_directory_named_does_not_exist(context, directory): - """ - Verifies that a directory does not exist. - - .. code-block:: gherkin - - Given the directory "abc/" does not exist - When the directory "abc/" does not exist - """ - step_the_directory_should_not_exist(context, directory) - - -# ----------------------------------------------------------------------------- -# FILE STEPS: -# ----------------------------------------------------------------------------- -@step(u'a file named "{filename}" exists') -def step_file_named_filename_exists(context, filename): - """ - Verifies that a file with this filename exists. - - .. code-block:: gherkin - - Given a file named "abc.txt" exists - When a file named "abc.txt" exists - """ - step_file_named_filename_should_exist(context, filename) - - -@step(u'a file named "{filename}" does not exist') -@step(u'the file named "{filename}" does not exist') -def step_file_named_filename_does_not_exist(context, filename): - """ - Verifies that a file with this filename does not exist. - - .. code-block:: gherkin - - Given a file named "abc.txt" does not exist - When a file named "abc.txt" does not exist - """ - step_file_named_filename_should_not_exist(context, filename) - - -@then(u'a file named "{filename}" should exist') -def step_file_named_filename_should_exist(context, filename): - command_util.ensure_workdir_exists(context) - filename_ = pathutil.realpath_with_context(filename, context) - assert_that(os.path.exists(filename_) and os.path.isfile(filename_)) - - -@then(u'a file named "{filename}" should not exist') -def step_file_named_filename_should_not_exist(context, filename): - command_util.ensure_workdir_exists(context) - filename_ = pathutil.realpath_with_context(filename, context) - assert_that(not os.path.exists(filename_)) - - -@step(u'I remove the file "{filename}"') -def step_remove_file(context, filename): - path_ = filename - if not os.path.isabs(filename): - path_ = os.path.join(context.workdir, os.path.normpath(filename)) - if os.path.exists(path_) and os.path.isfile(path_): - os.remove(path_) - assert_that(not os.path.isfile(path_)) - - -# ----------------------------------------------------------------------------- -# STEPS FOR FILE CONTENTS: -# ----------------------------------------------------------------------------- -@then(u'the file "{filename}" should contain "{text}"') -def step_file_should_contain_text(context, filename, text): - expected_text = text - if "{__WORKDIR__}" in text or "{__CWD__}" in text: - expected_text = textutil.template_substitute(text, - __WORKDIR__ = posixpath_normpath(context.workdir), - __CWD__ = posixpath_normpath(os.getcwd()) - ) - file_contents = pathutil.read_file_contents(filename, context=context) - file_contents = file_contents.rstrip() - if file_contents_normalizer: - # -- HACK: Inject TextProcessor as text normalizer - file_contents = file_contents_normalizer(file_contents) - with on_assert_failed_print_details(file_contents, expected_text): - textutil.assert_normtext_should_contain(file_contents, expected_text) - - -@then(u'the file "{filename}" should not contain "{text}"') -def step_file_should_not_contain_text(context, filename, text): - file_contents = pathutil.read_file_contents(filename, context=context) - file_contents = file_contents.rstrip() - textutil.assert_normtext_should_not_contain(file_contents, text) - # DISABLED: assert_that(file_contents, is_not(contains_string(text))) - - -@then(u'the file "{filename}" should contain') -def step_file_should_contain_multiline_text(context, filename): - assert context.text is not None, "REQUIRE: multiline text" - step_file_should_contain_text(context, filename, context.text) - - -@then(u'the file "{filename}" should not contain') -def step_file_should_not_contain_multiline_text(context, filename): - assert context.text is not None, "REQUIRE: multiline text" - step_file_should_not_contain_text(context, filename, context.text) - - -# ----------------------------------------------------------------------------- -# ENVIRONMENT VARIABLES -# ----------------------------------------------------------------------------- -@step(u'I set the environment variable "{env_name}" to "{env_value}"') -def step_I_set_the_environment_variable_to(context, env_name, env_value): - if not hasattr(context, "environ"): - context.environ = {} - context.environ[env_name] = env_value - os.environ[env_name] = env_value - - -@step(u'I remove the environment variable "{env_name}"') -def step_I_remove_the_environment_variable(context, env_name): - if not hasattr(context, "environ"): - context.environ = {} - context.environ[env_name] = "" - os.environ[env_name] = "" - del context.environ[env_name] - del os.environ[env_name] diff --git a/behave4cmd0/environment_steps.py b/behave4cmd0/environment_steps.py new file mode 100644 index 000000000..23900d1ec --- /dev/null +++ b/behave4cmd0/environment_steps.py @@ -0,0 +1,44 @@ +# -*- coding: UTF-8 +""" +Behave steps for environment variables (process environment). +""" + +from __future__ import absolute_import, print_function +import os +from behave import given, when, then, step +from hamcrest import assert_that, is_, is_not + + +# ----------------------------------------------------------------------------- +# ENVIRONMENT VARIABLES +# ----------------------------------------------------------------------------- +@step(u'I set the environment variable "{env_name}" to "{env_value}"') +def step_I_set_the_environment_variable_to(context, env_name, env_value): + if not hasattr(context, "environ"): + context.environ = {} + context.environ[env_name] = env_value + os.environ[env_name] = env_value + + +@step(u'I remove the environment variable "{env_name}"') +def step_I_remove_the_environment_variable(context, env_name): + if not hasattr(context, "environ"): + context.environ = {} + context.environ[env_name] = "" + os.environ[env_name] = "" + del context.environ[env_name] + del os.environ[env_name] + + +@given(u'the environment variable "{env_name}" exists') +@then(u'the environment variable "{env_name}" exists') +def step_the_environment_variable_exists(context, env_name): + env_variable_value = os.environ.get(env_name) + assert_that(env_variable_value, is_not(None)) + + +@given(u'the environment variable "{env_name}" does not exist') +@then(u'the environment variable "{env_name}" does not exist') +def step_I_set_the_environment_variable_to(context, env_name): + env_variable_value = os.environ.get(env_name) + assert_that(env_variable_value, is_(None)) diff --git a/behave4cmd0/filesystem_steps.py b/behave4cmd0/filesystem_steps.py new file mode 100644 index 000000000..311104160 --- /dev/null +++ b/behave4cmd0/filesystem_steps.py @@ -0,0 +1,236 @@ + +from __future__ import absolute_import, print_function +import codecs +import os +import os.path +import shutil +from behave import given, when, then, step +from behave4cmd0 import command_util, pathutil, textutil +from behave4cmd0.step_util import ( + on_assert_failed_print_details, normalize_text_with_placeholders) +from behave4cmd0.command_shell_proc import \ + TextProcessor, BehaveWinCommandOutputProcessor +from behave4cmd0.pathutil import posixpath_normpath +from hamcrest import assert_that + + +file_contents_normalizer = None +if BehaveWinCommandOutputProcessor.enabled: + file_contents_normalizer = TextProcessor(BehaveWinCommandOutputProcessor()) + + +def is_encoding_valid(encoding): + try: + return bool(codecs.lookup(encoding)) + except LookupError: + return False + + +# ----------------------------------------------------------------------------- +# STEPS FOR: Directories +# ----------------------------------------------------------------------------- +@step(u'I remove the directory "{directory}"') +def step_remove_directory(context, directory): + path_ = directory + if not os.path.isabs(directory): + path_ = os.path.join(context.workdir, os.path.normpath(directory)) + if os.path.isdir(path_): + shutil.rmtree(path_, ignore_errors=True) + assert_that(not os.path.isdir(path_)) + + +@given(u'I ensure that the directory "{directory}" exists') +def step_given_ensure_that_the_directory_exists(context, directory): + path_ = directory + if not os.path.isabs(directory): + path_ = os.path.join(context.workdir, os.path.normpath(directory)) + if not os.path.isdir(path_): + os.makedirs(path_) + assert_that(os.path.isdir(path_)) + + +@given(u'I ensure that the directory "{directory}" does not exist') +def step_given_the_directory_should_not_exist(context, directory): + step_remove_directory(context, directory) + + +@given(u'a directory named "{path}"') +def step_directory_named_dirname(context, path): + assert context.workdir, "REQUIRE: context.workdir" + path_ = os.path.join(context.workdir, os.path.normpath(path)) + if not os.path.exists(path_): + os.makedirs(path_) + assert os.path.isdir(path_) + + +@given(u'the directory "{directory}" should exist') +@then(u'the directory "{directory}" should exist') +def step_the_directory_should_exist(context, directory): + path_ = directory + if not os.path.isabs(directory): + path_ = os.path.join(context.workdir, os.path.normpath(directory)) + assert_that(os.path.isdir(path_)) + + +@given(u'the directory "{directory}" should not exist') +@then(u'the directory "{directory}" should not exist') +def step_the_directory_should_not_exist(context, directory): + path_ = directory + if not os.path.isabs(directory): + path_ = os.path.join(context.workdir, os.path.normpath(directory)) + assert_that(not os.path.isdir(path_)) + + +@step(u'the directory "{directory}" exists') +def step_directory_exists(context, directory): + """ + Verifies that a directory exists. + + .. code-block:: gherkin + + Given the directory "abc.txt" exists + When the directory "abc.txt" exists + """ + step_the_directory_should_exist(context, directory) + + +@step(u'the directory "{directory}" does not exist') +def step_directory_named_does_not_exist(context, directory): + """ + Verifies that a directory does not exist. + + .. code-block:: gherkin + + Given the directory "abc/" does not exist + When the directory "abc/" does not exist + """ + step_the_directory_should_not_exist(context, directory) + + +# ----------------------------------------------------------------------------- +# FILE STEPS: +# ----------------------------------------------------------------------------- +@step(u'a file named "{filename}" exists') +def step_file_named_filename_exists(context, filename): + """ + Verifies that a file with this filename exists. + + .. code-block:: gherkin + + Given a file named "abc.txt" exists + When a file named "abc.txt" exists + """ + step_file_named_filename_should_exist(context, filename) + + +@step(u'a file named "{filename}" does not exist') +@step(u'the file named "{filename}" does not exist') +def step_file_named_filename_does_not_exist(context, filename): + """ + Verifies that a file with this filename does not exist. + + .. code-block:: gherkin + + Given a file named "abc.txt" does not exist + When a file named "abc.txt" does not exist + """ + step_file_named_filename_should_not_exist(context, filename) + + +@then(u'a file named "{filename}" should exist') +def step_file_named_filename_should_exist(context, filename): + command_util.ensure_workdir_exists(context) + filename_ = pathutil.realpath_with_context(filename, context) + assert_that(os.path.exists(filename_) and os.path.isfile(filename_)) + + +@then(u'a file named "{filename}" should not exist') +def step_file_named_filename_should_not_exist(context, filename): + command_util.ensure_workdir_exists(context) + filename_ = pathutil.realpath_with_context(filename, context) + assert_that(not os.path.exists(filename_)) + + +# ----------------------------------------------------------------------------- +# STEPS FOR EXISTING FILES WITH FILE CONTENTS: +# ----------------------------------------------------------------------------- +@then(u'the file "{filename}" should contain "{text}"') +def step_file_should_contain_text(context, filename, text): + expected_text = normalize_text_with_placeholders(context, text) + file_contents = pathutil.read_file_contents(filename, context=context) + file_contents = file_contents.rstrip() + if file_contents_normalizer: + # -- HACK: Inject TextProcessor as text normalizer + file_contents = file_contents_normalizer(file_contents) + with on_assert_failed_print_details(file_contents, expected_text): + textutil.assert_normtext_should_contain(file_contents, expected_text) + + +@then(u'the file "{filename}" should not contain "{text}"') +def step_file_should_not_contain_text(context, filename, text): + expected_text = normalize_text_with_placeholders(context, text) + file_contents = pathutil.read_file_contents(filename, context=context) + file_contents = file_contents.rstrip() + + with on_assert_failed_print_details(file_contents, expected_text): + textutil.assert_normtext_should_not_contain(file_contents, expected_text) + # DISABLED: assert_that(file_contents, is_not(contains_string(text))) + + +@then(u'the file "{filename}" should contain') +def step_file_should_contain_multiline_text(context, filename): + assert context.text is not None, "REQUIRE: multiline text" + step_file_should_contain_text(context, filename, context.text) + + +@then(u'the file "{filename}" should not contain') +def step_file_should_not_contain_multiline_text(context, filename): + assert context.text is not None, "REQUIRE: multiline text" + step_file_should_not_contain_text(context, filename, context.text) + + +# ----------------------------------------------------------------------------- +# STEPS FOR CREATING FILES WITH FILE CONTENTS: +# ----------------------------------------------------------------------------- +@given(u'a file named "{filename}" and encoding="{encoding}" with') +def step_a_file_named_filename_and_encoding_with(context, filename, encoding): + """Creates a textual file with the content provided as docstring.""" + assert context.text is not None, "ENSURE: multiline text is provided." + assert not os.path.isabs(filename) + assert is_encoding_valid(encoding), "INVALID: encoding=%s;" % encoding + command_util.ensure_workdir_exists(context) + filename2 = os.path.join(context.workdir, filename) + pathutil.create_textfile_with_contents(filename2, context.text, encoding) + + +@given(u'a file named "{filename}" with') +def step_a_file_named_filename_with(context, filename): + """Creates a textual file with the content provided as docstring.""" + step_a_file_named_filename_and_encoding_with(context, filename, "UTF-8") + + # -- SPECIAL CASE: For usage with behave steps. + if filename.endswith(".feature"): + command_util.ensure_context_attribute_exists(context, "features", []) + context.features.append(filename) + + +@given(u'an empty file named "{filename}"') +def step_an_empty_file_named_filename(context, filename): + """ + Creates an empty file. + """ + assert not os.path.isabs(filename) + command_util.ensure_workdir_exists(context) + filename2 = os.path.join(context.workdir, filename) + pathutil.create_textfile_with_contents(filename2, "") + + +@step(u'I remove the file "{filename}"') +@step(u'I remove the file named "{filename}"') +def step_remove_file(context, filename): + path_ = filename + if not os.path.isabs(filename): + path_ = os.path.join(context.workdir, os.path.normpath(filename)) + if os.path.exists(path_) and os.path.isfile(path_): + os.remove(path_) + assert_that(not os.path.isfile(path_)) diff --git a/behave4cmd0/log/steps.py b/behave4cmd0/log/steps.py index cec2cab31..2ec94fa1c 100644 --- a/behave4cmd0/log/steps.py +++ b/behave4cmd0/log/steps.py @@ -57,14 +57,15 @@ | bar | CURRENT | xxx | """ -from __future__ import absolute_import +from __future__ import absolute_import, print_function +import logging from behave import given, when, then, step -from behave4cmd0.command_steps import \ - step_file_should_contain_multiline_text, \ - step_file_should_not_contain_multiline_text from behave.configuration import LogLevel from behave.log_capture import LoggingCapture -import logging +from behave4cmd0.filesystem_steps import ( + step_file_should_contain_multiline_text, + step_file_should_not_contain_multiline_text) + # ----------------------------------------------------------------------------- # STEP UTILS: diff --git a/behave4cmd0/setup_command_shell.py b/behave4cmd0/setup_command_shell.py old mode 100755 new mode 100644 diff --git a/behave4cmd0/step_util.py b/behave4cmd0/step_util.py new file mode 100644 index 000000000..75e06e7aa --- /dev/null +++ b/behave4cmd0/step_util.py @@ -0,0 +1,71 @@ +from __future__ import absolute_import, print_function +import contextlib +import difflib +import os + +from behave4cmd0 import textutil +from behave4cmd0.pathutil import posixpath_normpath + + +# ----------------------------------------------------------------------------- +# CONSTANTS: +# ----------------------------------------------------------------------------- +DEBUG = False + + +# ----------------------------------------------------------------------------- +# UTILITY FUNCTIONS: +# ----------------------------------------------------------------------------- +def print_differences(actual, expected): + # diff = difflib.unified_diff(expected.splitlines(), actual.splitlines(), + # "expected", "actual") + diff = difflib.ndiff(expected.splitlines(), actual.splitlines()) + diff_text = u"\n".join(diff) + print(u"DIFF (+ ACTUAL, - EXPECTED):\n{0}\n".format(diff_text)) + if DEBUG: + print(u"expected:\n{0}\n".format(expected)) + print(u"actual:\n{0}\n".format(actual)) + + +@contextlib.contextmanager +def on_assert_failed_print_details(actual, expected): + """ + Print text details in case of assertation failed errors. + + .. sourcecode:: python + + with on_assert_failed_print_details(actual_text, expected_text): + assert actual == expected + """ + try: + yield + except AssertionError: + print_differences(actual, expected) + raise + + +@contextlib.contextmanager +def on_error_print_details(actual, expected): + """ + Print text details in case of assertation failed errors. + + .. sourcecode:: python + + with on_error_print_details(actual_text, expected_text): + ... # Do something + """ + try: + yield + except Exception: + print_differences(actual, expected) + raise + + +def normalize_text_with_placeholders(ctx, text): + expected_text = text + if "{__WORKDIR__}" in expected_text or "{__CWD__}" in expected_text: + expected_text = textutil.template_substitute(text, + __WORKDIR__=posixpath_normpath(ctx.workdir), + __CWD__=posixpath_normpath(os.getcwd()) + ) + return expected_text diff --git a/behave4cmd0/workdir_steps.py b/behave4cmd0/workdir_steps.py new file mode 100644 index 000000000..7358d5364 --- /dev/null +++ b/behave4cmd0/workdir_steps.py @@ -0,0 +1,59 @@ +""" +Provides :mod:`behave` steps to provide and use "working directory" +as base directory to: + +* Create files +* Create directories +""" + +from __future__ import absolute_import, print_function +import os +import shutil + +from behave import given, step +from behave4cmd0 import command_util + + +# ----------------------------------------------------------------------------- +# STEPS: WORKING DIR +# ----------------------------------------------------------------------------- +@given(u'a new working directory') +def step_a_new_working_directory(context): + """Creates a new, empty working directory.""" + command_util.ensure_context_attribute_exists(context, "workdir", None) + # MAYBE: command_util.ensure_workdir_not_exists(context) + command_util.ensure_workdir_exists(context) + # OOPS: + shutil.rmtree(context.workdir, ignore_errors=True) + command_util.ensure_workdir_exists(context) + + +@given(u'I use the current directory as working directory') +def step_use_curdir_as_working_directory(context): + """Uses the current directory as working directory""" + context.workdir = os.path.abspath(".") + command_util.ensure_workdir_exists(context) + + +@step(u'I use the directory "{directory}" as working directory') +def step_use_directory_as_working_directory(context, directory): + """Uses the directory as new working directory""" + command_util.ensure_context_attribute_exists(context, "workdir", None) + current_workdir = context.workdir + if not current_workdir: + current_workdir = os.getcwd() + + if not os.path.isabs(directory): + new_workdir = os.path.join(current_workdir, directory) + exists_relto_current_dir = os.path.isdir(directory) + exists_relto_current_workdir = os.path.isdir(new_workdir) + if exists_relto_current_workdir or not exists_relto_current_dir: + # -- PREFER: Relative to current workdir + workdir = new_workdir + else: + assert exists_relto_current_workdir + workdir = directory + workdir = os.path.abspath(workdir) + + context.workdir = workdir + command_util.ensure_workdir_exists(context) diff --git a/features/steps/use_steplib_behave4cmd.py b/features/steps/use_steplib_behave4cmd.py index 94d8766af..94aaab362 100644 --- a/features/steps/use_steplib_behave4cmd.py +++ b/features/steps/use_steplib_behave4cmd.py @@ -6,7 +6,13 @@ from __future__ import absolute_import # -- REGISTER-STEPS FROM STEP-LIBRARY: -import behave4cmd0.__all_steps__ -import behave4cmd0.passing_steps +# import behave4cmd0.__all_steps__ +import behave4cmd0.command_steps +import behave4cmd0.environment_steps +import behave4cmd0.filesystem_steps +import behave4cmd0.workdir_steps +import behave4cmd0.log.steps + import behave4cmd0.failing_steps +import behave4cmd0.passing_steps import behave4cmd0.note_steps diff --git a/issue.features/steps/use_steplib_behave4cmd.py b/issue.features/steps/use_steplib_behave4cmd.py index 98174b6ec..8f50a2954 100644 --- a/issue.features/steps/use_steplib_behave4cmd.py +++ b/issue.features/steps/use_steplib_behave4cmd.py @@ -8,6 +8,11 @@ # -- REGISTER-STEPS FROM STEP-LIBRARY: # import behave4cmd0.__all_steps__ import behave4cmd0.command_steps +import behave4cmd0.environment_steps +import behave4cmd0.filesystem_steps +import behave4cmd0.workdir_steps +import behave4cmd0.log.steps + import behave4cmd0.passing_steps import behave4cmd0.failing_steps import behave4cmd0.note_steps From 1a72d3db5ce3cd8e570f3d2616c0de2f15d1494d Mon Sep 17 00:00:00 2001 From: jenisys Date: Fri, 26 May 2023 14:10:24 +0200 Subject: [PATCH 095/240] ADD STEP-ALIASES TO: behave4cmd0.filesystem_steps Extend the existing @then steps by using step-aliases: * @given('a file named "{filename}" should exist') * @given('a file named "{filename}" should not exist') CLEANUP: * PEP-0582 was rejected. --- .envrc.use_pep0582.disabled | 29 ----------------------------- behave.ini | 1 + behave4cmd0/filesystem_steps.py | 2 ++ 3 files changed, 3 insertions(+), 29 deletions(-) delete mode 100644 .envrc.use_pep0582.disabled diff --git a/.envrc.use_pep0582.disabled b/.envrc.use_pep0582.disabled deleted file mode 100644 index a2e5a03a5..000000000 --- a/.envrc.use_pep0582.disabled +++ /dev/null @@ -1,29 +0,0 @@ -# =========================================================================== -# PROJECT ENVIRONMENT SETUP: .envrc.use_pep0582 -# =========================================================================== -# DESCRIPTION: -# Setup Python search path to use the PEP-0582 sub-directory tree. -# -# ENABLE/DISABLE THIS OPTIONAL PART: -# * TO ENABLE: Rename ".envrc.use_pep0582.disabled" to ".envrc.use_pep0582" -# * TO DISABLE: Rename ".envrc.use_pep0582" to ".envrc.use_pep0582.disabled" -# -# SEE ALSO: -# * https://direnv.net/ -# * https://peps.python.org/pep-0582/ Python local packages directory -# =========================================================================== - -if [ -z "${PYTHON_VERSION}" ]; then - # -- AUTO-DETECT: Default Python3 version - # EXAMPLE: export PYTHON_VERSION="3.9" - export PYTHON_VERSION=$(python3 -c "import sys; print('.'.join([str(x) for x in sys.version_info[:2]]))") -fi -echo "USE: PYTHON_VERSION=${PYTHON_VERSION}" - -# -- HINT: Support PEP-0582 Python local packages directory (supported by: pdm) -path_add PATH __pypackages__/${PYTHON_VERSION}/bin -path_add PYTHONPATH __pypackages__/${PYTHON_VERSION}/lib - -# -- SIMILAR-TO: -# export PATH="${HERE}/__pypackages__/${PYTHON_VERSION}/bin:${PATH}" -# export PYTHONPATH="${HERE}:${HERE}/__pypackages__/${PYTHON_VERSION}/lib:${PYTHONPATH}" diff --git a/behave.ini b/behave.ini index 30f7e5605..b6c6467e6 100644 --- a/behave.ini +++ b/behave.ini @@ -24,6 +24,7 @@ logging_level = INFO # -- ALLURE-FORMATTER REQUIRES: pip install allure-behave # brew install allure +# pip install allure-behave # ALLURE_REPORTS_DIR=allure.reports # behave -f allure -o $ALLURE_REPORTS_DIR ... # allure serve $ALLURE_REPORTS_DIR diff --git a/behave4cmd0/filesystem_steps.py b/behave4cmd0/filesystem_steps.py index 311104160..34e48939d 100644 --- a/behave4cmd0/filesystem_steps.py +++ b/behave4cmd0/filesystem_steps.py @@ -137,6 +137,7 @@ def step_file_named_filename_does_not_exist(context, filename): step_file_named_filename_should_not_exist(context, filename) +@given(u'a file named "{filename}" should exist') @then(u'a file named "{filename}" should exist') def step_file_named_filename_should_exist(context, filename): command_util.ensure_workdir_exists(context) @@ -144,6 +145,7 @@ def step_file_named_filename_should_exist(context, filename): assert_that(os.path.exists(filename_) and os.path.isfile(filename_)) +@given(u'a file named "{filename}" should not exist') @then(u'a file named "{filename}" should not exist') def step_file_named_filename_should_not_exist(context, filename): command_util.ensure_workdir_exists(context) From 4e984c789f124bc1e40d5b7aecb307b4d2f98441 Mon Sep 17 00:00:00 2001 From: jenisys Date: Fri, 26 May 2023 14:32:25 +0200 Subject: [PATCH 096/240] CI WORKFLOW: tests * Tweak the push/pull_request events slightly by using paths * Add trigger:workflow_dispatch --- .github/workflows/tests.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9454228a9..26ea1817b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,9 +4,26 @@ name: tests on: + workflow_dispatch: push: + branches: [ "main", "release/**" ] + paths: + - "**/*.py" + - "**/*.feature" + - "py.requirements/**" + - "*.cfg" + - "*.ini" + - "*.toml" pull_request: - branches: [ main ] + types: [opened, reopened, review_requested] + branches: [ "main" ] + paths: + - "**/*.py" + - "**/*.feature" + - "py.requirements/**" + - "*.cfg" + - "*.ini" + - "*.toml" jobs: test: From 5a397ba28a8f974b9385b363e2e016cf34d447a0 Mon Sep 17 00:00:00 2001 From: jenisys Date: Fri, 26 May 2023 14:47:38 +0200 Subject: [PATCH 097/240] CI WORKFLOW: tests-windows * Add path filter to push/pull_request events * Add workflow_dispatch_event --- .github/workflows/tests-windows.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index c309470ab..3e3acad65 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -4,9 +4,27 @@ name: tests-windows on: + workflow_dispatch: push: + branches: [ "main", "release/**" ] + paths: + - "**/*.py" + - "**/*.feature" + - "py.requirements/**" + - "*.cfg" + - "*.ini" + - "*.toml" pull_request: - branches: [ main ] + types: [opened, reopened, review_requested] + branches: [ "main" ] + paths: + - "**/*.py" + - "**/*.feature" + - "py.requirements/**" + - "*.cfg" + - "*.ini" + - "*.toml" + # -- TEST BALLOON: Fix encoding="cp1252" problems by using "UTF-8" env: From 6210f732486cfa3da81fb4e718a5e594af54de15 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 27 May 2023 18:01:21 +0200 Subject: [PATCH 098/240] CLEANUP: In feature files * Use "behave --no-color" instead of "behave -c" (short-option) --- features/exploratory_testing.with_table.feature | 2 +- features/runner.default_format.feature | 2 +- features/runner.multiple_formatters.feature | 2 +- features/runner.use_stage_implementations.feature | 8 ++++---- features/scenario_outline.improved.feature | 2 +- features/scenario_outline.parametrized.feature | 6 +++--- issue.features/issue0040.feature | 12 ++++++------ issue.features/issue0041.feature | 6 +++--- issue.features/issue0044.feature | 2 +- issue.features/issue0046.feature | 6 +++--- issue.features/issue0052.feature | 4 ++-- issue.features/issue0063.feature | 4 ++-- issue.features/issue0069.feature | 2 +- issue.features/issue0073.feature | 2 +- issue.features/issue0080.feature | 2 +- issue.features/issue0081.feature | 6 +++--- issue.features/issue0083.feature | 6 +++--- issue.features/issue0085.feature | 2 +- issue.features/issue0096.feature | 10 +++++----- issue.features/issue0112.feature | 4 ++-- issue.features/issue0231.feature | 4 ++-- 21 files changed, 47 insertions(+), 47 deletions(-) diff --git a/features/exploratory_testing.with_table.feature b/features/exploratory_testing.with_table.feature index 35852bc73..a957ddad3 100644 --- a/features/exploratory_testing.with_table.feature +++ b/features/exploratory_testing.with_table.feature @@ -7,7 +7,7 @@ Feature: Exploratory Testing with Tables and Table Annotations . HINT: Does not work with monochrome format in pretty formatter: . behave -f pretty --no-color ... - . behave -c ... + . behave --no-color ... @setup diff --git a/features/runner.default_format.feature b/features/runner.default_format.feature index 225d2b0e3..9e6414530 100644 --- a/features/runner.default_format.feature +++ b/features/runner.default_format.feature @@ -38,7 +38,7 @@ Feature: Default Formatter @no_configfile Scenario: Pretty formatter is used as default formatter if no other is defined Given a file named "behave.ini" does not exist - When I run "behave -c features/" + When I run "behave --no-color features/" Then it should pass with: """ 2 features passed, 0 failed, 0 skipped diff --git a/features/runner.multiple_formatters.feature b/features/runner.multiple_formatters.feature index 55cc5716e..b38c497f2 100644 --- a/features/runner.multiple_formatters.feature +++ b/features/runner.multiple_formatters.feature @@ -214,7 +214,7 @@ Feature: Multiple Formatter with different outputs outfiles = output/plain.out """ And I remove the directory "output" - When I run "behave -c -f pretty -o output/pretty.out -f progress -o output/progress.out features/" + When I run "behave --no-color -f pretty -o output/pretty.out -f progress -o output/progress.out features/" Then it should pass And the file "output/progress.out" should contain: """ diff --git a/features/runner.use_stage_implementations.feature b/features/runner.use_stage_implementations.feature index 5c290ce31..f3b0dd793 100644 --- a/features/runner.use_stage_implementations.feature +++ b/features/runner.use_stage_implementations.feature @@ -72,7 +72,7 @@ Feature: Use Alternate Step Implementations for Each Test Stage assert context.config.stage == "develop" assert context.use_develop_environment """ - When I run "behave -c --stage=develop features/example1.feature" + When I run "behave --no-color --stage=develop features/example1.feature" Then it should pass with: """ 1 feature passed, 0 failed, 0 skipped @@ -87,7 +87,7 @@ Feature: Use Alternate Step Implementations for Each Test Stage Scenario: Use default stage Given I remove the environment variable "BEHAVE_STAGE" - When I run "behave -c features/example1.feature" + When I run "behave --no-color features/example1.feature" Then it should pass with: """ 1 feature passed, 0 failed, 0 skipped @@ -102,7 +102,7 @@ Feature: Use Alternate Step Implementations for Each Test Stage Scenario: Use the BEHAVE_STAGE environment variable to define the test stage Given I set the environment variable "BEHAVE_STAGE" to "develop" - When I run "behave -c features/example1.feature" + When I run "behave --no-color features/example1.feature" Then it should pass with: """ 1 feature passed, 0 failed, 0 skipped @@ -119,7 +119,7 @@ Feature: Use Alternate Step Implementations for Each Test Stage Scenario: Using an unknown stage - When I run "behave -c --stage=unknown features/example1.feature" + When I run "behave --no-color --stage=unknown features/example1.feature" Then it should fail with: """ ConfigError: No unknown_steps directory diff --git a/features/scenario_outline.improved.feature b/features/scenario_outline.improved.feature index c837cc1fe..da1f6dd18 100644 --- a/features/scenario_outline.improved.feature +++ b/features/scenario_outline.improved.feature @@ -81,7 +81,7 @@ Feature: Scenario Outline -- Improvements """ Scenario: Unique File Locations in generated scenarios - When I run "behave -f pretty -c features/named_examples.feature" + When I run "behave -f pretty --no-color features/named_examples.feature" Then it should pass with: """ Scenario Outline: Named Examples -- @1.1 Alice # features/named_examples.feature:7 diff --git a/features/scenario_outline.parametrized.feature b/features/scenario_outline.parametrized.feature index 807769807..e10b5a8d7 100644 --- a/features/scenario_outline.parametrized.feature +++ b/features/scenario_outline.parametrized.feature @@ -3,7 +3,7 @@ Feature: Scenario Outline -- Parametrized Scenarios As a test writer I want to use the DRY principle when writing scenarios So that I am more productive and my work is less error-prone. - + . COMMENT: . A Scenario Outline is basically a parametrized Scenario template. . It is instantiated for each examples row with the corresponding data. @@ -348,7 +348,7 @@ Feature: Scenario Outline -- Parametrized Scenarios | 001 | Alice | | 002 | Bob | """ - When I run "behave -f pretty -c --no-timings features/parametrized_tags.feature" + When I run "behave -f pretty --no-color --no-timings features/parametrized_tags.feature" Then it should pass with: """ @foo @outline.e1 @outline.row.1.1 @outline.ID.001 @@ -382,7 +382,7 @@ Feature: Scenario Outline -- Parametrized Scenarios | 002 | Bob\tMarley | Placeholder value w/ tab | | 003 | Joe\nCocker | Placeholder value w/ newline | """ - When I run "behave -f pretty -c --no-source features/parametrized_tags2.feature" + When I run "behave -f pretty --no-color --no-source features/parametrized_tags2.feature" Then it should pass with: """ @outline.name.Alice_Cooper diff --git a/issue.features/issue0040.feature b/issue.features/issue0040.feature index a2102bebb..372dbb5fa 100644 --- a/issue.features/issue0040.feature +++ b/issue.features/issue0040.feature @@ -41,7 +41,7 @@ Feature: Issue #40 Test Summary Scenario/Step Counts are incorrect for Scenario |Alice| |Bob | """ - When I run "behave -c -f plain features/issue40_1.feature" + When I run "behave --no-color -f plain features/issue40_1.feature" Then it should pass with: """ 2 scenarios passed, 0 failed, 0 skipped @@ -62,7 +62,7 @@ Feature: Issue #40 Test Summary Scenario/Step Counts are incorrect for Scenario |Alice| |Bob | """ - When I run "behave -c -f plain features/issue40_2G.feature" + When I run "behave --no-color -f plain features/issue40_2G.feature" Then it should fail with: """ 0 scenarios passed, 2 failed, 0 skipped @@ -83,7 +83,7 @@ Feature: Issue #40 Test Summary Scenario/Step Counts are incorrect for Scenario |Alice| |Bob | """ - When I run "behave -c -f plain features/issue40_2W.feature" + When I run "behave --no-color -f plain features/issue40_2W.feature" Then it should fail with: """ 0 scenarios passed, 2 failed, 0 skipped @@ -104,7 +104,7 @@ Feature: Issue #40 Test Summary Scenario/Step Counts are incorrect for Scenario |Alice| |Bob | """ - When I run "behave -c -f plain features/issue40_2T.feature" + When I run "behave --no-color -f plain features/issue40_2T.feature" Then it should fail with: """ 0 scenarios passed, 2 failed, 0 skipped @@ -125,7 +125,7 @@ Feature: Issue #40 Test Summary Scenario/Step Counts are incorrect for Scenario |Alice| |Bob | """ - When I run "behave -c -f plain features/issue40_3W.feature" + When I run "behave --no-color -f plain features/issue40_3W.feature" Then it should fail with: """ 1 scenario passed, 1 failed, 0 skipped @@ -146,7 +146,7 @@ Feature: Issue #40 Test Summary Scenario/Step Counts are incorrect for Scenario |Alice| |Bob | """ - When I run "behave -c -f plain features/issue40_3W.feature" + When I run "behave --no-color -f plain features/issue40_3W.feature" Then it should fail with: """ 1 scenario passed, 1 failed, 0 skipped diff --git a/issue.features/issue0041.feature b/issue.features/issue0041.feature index a01288c8c..81d424a34 100644 --- a/issue.features/issue0041.feature +++ b/issue.features/issue0041.feature @@ -37,7 +37,7 @@ Feature: Issue #41 Missing Steps are duplicated in a Scenario Outline |Alice| |Bob | """ - When I run "behave -c -f plain features/issue41_missing1.feature" + When I run "behave --no-color -f plain features/issue41_missing1.feature" Then it should fail with: """ 0 steps passed, 0 failed, 4 skipped, 2 undefined @@ -74,7 +74,7 @@ Feature: Issue #41 Missing Steps are duplicated in a Scenario Outline |Alice| |Bob | """ - When I run "behave -c -f plain features/issue41_missing2.feature" + When I run "behave --no-color -f plain features/issue41_missing2.feature" Then it should fail with: """ 2 steps passed, 0 failed, 2 skipped, 2 undefined @@ -111,7 +111,7 @@ Feature: Issue #41 Missing Steps are duplicated in a Scenario Outline |Alice| |Bob | """ - When I run "behave -c -f plain features/issue41_missing3.feature" + When I run "behave --no-color -f plain features/issue41_missing3.feature" Then it should fail with: """ 4 steps passed, 0 failed, 0 skipped, 2 undefined diff --git a/issue.features/issue0044.feature b/issue.features/issue0044.feature index 81011f70d..3c919bf8e 100644 --- a/issue.features/issue0044.feature +++ b/issue.features/issue0044.feature @@ -38,7 +38,7 @@ Feature: Issue #44 Shell-like comments are removed in Multiline Args Ipsum lorem. """ ''' - When I run "behave -c -f pretty features/issue44_test.feature" + When I run "behave --no-color -f pretty features/issue44_test.feature" Then it should pass And the command output should contain: """ diff --git a/issue.features/issue0046.feature b/issue.features/issue0046.feature index 9553ffdc2..5b0259c6f 100644 --- a/issue.features/issue0046.feature +++ b/issue.features/issue0046.feature @@ -27,7 +27,7 @@ Feature: Issue #46 Behave returns 0 (SUCCESS) even in case of test failures Scenario: Passing Scenario Example Given passing """ - When I run "behave -c -q features/passing.feature" + When I run "behave --no-color -q features/passing.feature" Then it should pass with: """ 1 feature passed, 0 failed, 0 skipped @@ -42,7 +42,7 @@ Feature: Issue #46 Behave returns 0 (SUCCESS) even in case of test failures Scenario: Failing Scenario Example Given failing """ - When I run "behave -c -q features/failing.feature" + When I run "behave --no-color -q features/failing.feature" Then it should fail with: """ 0 features passed, 1 failed, 0 skipped @@ -59,7 +59,7 @@ Feature: Issue #46 Behave returns 0 (SUCCESS) even in case of test failures Scenario: Failing Scenario Example Given failing """ - When I run "behave -c -q features/passing_and_failing.feature" + When I run "behave --no-color -q features/passing_and_failing.feature" Then it should fail with: """ 0 features passed, 1 failed, 0 skipped diff --git a/issue.features/issue0052.feature b/issue.features/issue0052.feature index efe055292..653e93ab7 100644 --- a/issue.features/issue0052.feature +++ b/issue.features/issue0052.feature @@ -35,7 +35,7 @@ Feature: Issue #52 Summary counts are wrong with option --tags Scenario: N2 Given passing """ - When I run "behave --junit -c --tags @done features/tagged_scenario1.feature" + When I run "behave --junit --no-color --tags @done features/tagged_scenario1.feature" Then it should pass with: """ 1 feature passed, 0 failed, 0 skipped @@ -57,7 +57,7 @@ Feature: Issue #52 Summary counts are wrong with option --tags Scenario: N2 Given passing """ - When I run "behave --junit -c --tags @done features/tagged_scenario2.feature" + When I run "behave --junit --no-color --tags @done features/tagged_scenario2.feature" Then it should fail And the command output should contain: """ diff --git a/issue.features/issue0063.feature b/issue.features/issue0063.feature index 1959ae85d..a9d4e8e19 100644 --- a/issue.features/issue0063.feature +++ b/issue.features/issue0063.feature @@ -49,7 +49,7 @@ Feature: Issue #63: 'ScenarioOutline' object has no attribute 'stdout' |Alice| |Bob | """ - When I run "behave -c --junit features/issue63_case1.feature" + When I run "behave --no-color --junit features/issue63_case1.feature" Then it should pass with: """ 2 scenarios passed, 0 failed, 0 skipped @@ -74,7 +74,7 @@ Feature: Issue #63: 'ScenarioOutline' object has no attribute 'stdout' |Alice| |Bob | """ - When I run "behave -c --junit features/issue63_case2.feature" + When I run "behave --no-color --junit features/issue63_case2.feature" Then it should fail with: """ 0 scenarios passed, 2 failed, 0 skipped diff --git a/issue.features/issue0069.feature b/issue.features/issue0069.feature index 7bfb84b2f..ac590c459 100644 --- a/issue.features/issue0069.feature +++ b/issue.features/issue0069.feature @@ -47,7 +47,7 @@ Feature: Issue #69: JUnitReporter: Fault when processing ScenarioOutlines with f |Alice| |Bob | """ - When I run "behave -c --junit features/issue63_case2.feature" + When I run "behave --no-color --junit features/issue63_case2.feature" Then it should fail with: """ 0 scenarios passed, 2 failed, 0 skipped diff --git a/issue.features/issue0073.feature b/issue.features/issue0073.feature index d61939c27..fec932bd4 100644 --- a/issue.features/issue0073.feature +++ b/issue.features/issue0073.feature @@ -202,7 +202,7 @@ Feature: Issue #73: the current_matcher is not predictable When a step passes Then another step passes """ - When I run "behave -c -f pretty --no-timings features/passing3.feature" + When I run "behave --no-color -f pretty --no-timings features/passing3.feature" Then it should pass with: """ 3 scenarios passed, 0 failed, 0 skipped diff --git a/issue.features/issue0080.feature b/issue.features/issue0080.feature index 224718287..99ca3ecc6 100644 --- a/issue.features/issue0080.feature +++ b/issue.features/issue0080.feature @@ -36,7 +36,7 @@ Feature: Issue #80: source file names not properly printed with python3 """ Scenario: Show step locations - When I run "behave -c -f pretty --no-timings features/basic.feature" + When I run "behave --no-color -f pretty --no-timings features/basic.feature" Then it should pass And the command output should contain: """ diff --git a/issue.features/issue0081.feature b/issue.features/issue0081.feature index fa7a2a8f0..1417ccd6e 100644 --- a/issue.features/issue0081.feature +++ b/issue.features/issue0081.feature @@ -62,7 +62,7 @@ Feature: Issue #81: Allow defining steps in a separate library """ from step_library42.alice_steps import * """ - When I run "behave -c -f pretty features/use_step_library.feature" + When I run "behave --no-color -f pretty features/use_step_library.feature" Then it should pass with: """ 1 scenario passed, 0 failed, 0 skipped @@ -101,7 +101,7 @@ Feature: Issue #81: Allow defining steps in a separate library from step_library42.bob_steps import when_I_use_steps_from_this_step_library from step_library42.bob_steps import then_these_steps_are_executed """ - When I run "behave -c -f pretty features/use_step_library.feature" + When I run "behave --no-color -f pretty features/use_step_library.feature" Then it should pass with: """ 1 scenario passed, 0 failed, 0 skipped @@ -122,7 +122,7 @@ Feature: Issue #81: Allow defining steps in a separate library from step_library42.alice_steps import * """ And an empty file named "features/steps/__init__.py" - When I run "behave -c -f pretty features/use_step_library.feature" + When I run "behave --no-color -f pretty features/use_step_library.feature" Then it should pass with: """ 1 scenario passed, 0 failed, 0 skipped diff --git a/issue.features/issue0083.feature b/issue.features/issue0083.feature index 34f2cd676..fdbf6c392 100644 --- a/issue.features/issue0083.feature +++ b/issue.features/issue0083.feature @@ -32,7 +32,7 @@ Feature: Issue #83: behave.__main__:main() Various sys.exit issues When a step passes Then a step passes """ - When I run "behave -c features/passing.feature" + When I run "behave --no-color features/passing.feature" Then it should pass And the command returncode is "0" @@ -44,7 +44,7 @@ Feature: Issue #83: behave.__main__:main() Various sys.exit issues Given a step passes When2 a step passes """ - When I run "behave -c features/invalid_with_ParseError.feature" + When I run "behave --no-color features/invalid_with_ParseError.feature" Then it should fail And the command returncode is non-zero And the command output should contain: @@ -60,7 +60,7 @@ Feature: Issue #83: behave.__main__:main() Various sys.exit issues Scenario: Given a step passes """ - When I run "behave -c features/passing2.feature" + When I run "behave --no-color features/passing2.feature" Then it should fail And the command returncode is non-zero And the command output should contain: diff --git a/issue.features/issue0085.feature b/issue.features/issue0085.feature index f76ced7c8..a35305c67 100644 --- a/issue.features/issue0085.feature +++ b/issue.features/issue0085.feature @@ -102,7 +102,7 @@ Feature: Issue #85: AssertionError with nested regex and pretty formatter """ Scenario: Run regexp steps with --format=pretty - When I run "behave -c --format=pretty features/matching.feature" + When I run "behave --no-color --format=pretty features/matching.feature" Then it should pass with: """ 1 feature passed, 0 failed, 0 skipped diff --git a/issue.features/issue0096.feature b/issue.features/issue0096.feature index 7b10111ac..e73da8192 100644 --- a/issue.features/issue0096.feature +++ b/issue.features/issue0096.feature @@ -67,7 +67,7 @@ Feature: Issue #96: Sub-steps failed without any error info to help debug issue Then a step passes """ ''' - When I run "behave -c features/issue96_case1.feature" + When I run "behave --no-color features/issue96_case1.feature" Then it should fail with: """ Assertion Failed: FAILED SUB-STEP: When a step fails @@ -86,7 +86,7 @@ Feature: Issue #96: Sub-steps failed without any error info to help debug issue Then a step passes """ ''' - When I run "behave -c features/issue96_case2.feature" + When I run "behave --no-color features/issue96_case2.feature" Then it should fail with: """ RuntimeError: Alice is alive @@ -109,7 +109,7 @@ Feature: Issue #96: Sub-steps failed without any error info to help debug issue Then a step passes """ ''' - When I run "behave -c features/issue96_case3.feature" + When I run "behave --no-color features/issue96_case3.feature" Then it should fail with: """ Assertion Failed: FAILED SUB-STEP: When a step fails with stdout "STDOUT: Alice is alive" @@ -134,7 +134,7 @@ Feature: Issue #96: Sub-steps failed without any error info to help debug issue Then a step passes """ ''' - When I run "behave -c features/issue96_case4.feature" + When I run "behave --no-color features/issue96_case4.feature" Then it should fail with: """ Assertion Failed: FAILED SUB-STEP: When a step fails with stderr "STDERR: Alice is alive" @@ -164,7 +164,7 @@ Feature: Issue #96: Sub-steps failed without any error info to help debug issue Then a step fails ''') """ - When I run "behave -c features/issue96_case5.feature" + When I run "behave --no-color features/issue96_case5.feature" Then it should fail with: """ HOOK-ERROR in before_scenario: AssertionError: FAILED SUB-STEP: Then a step fails diff --git a/issue.features/issue0112.feature b/issue.features/issue0112.feature index 339f082db..17cac6e98 100644 --- a/issue.features/issue0112.feature +++ b/issue.features/issue0112.feature @@ -31,7 +31,7 @@ Feature: Issue #112: Improvement to AmbiguousStep error def step_given_I_buy(context, amount, product): pass """ - When I run "behave -c features/syndrome112.feature" + When I run "behave --no-color features/syndrome112.feature" Then it should pass with: """ 1 feature passed, 0 failed, 0 skipped @@ -55,7 +55,7 @@ Feature: Issue #112: Improvement to AmbiguousStep error def step_given_I_buy2(context, number, items): pass """ - When I run "behave -c features/syndrome112.feature" + When I run "behave --no-color features/syndrome112.feature" Then it should fail And the command output should contain: """ diff --git a/issue.features/issue0231.feature b/issue.features/issue0231.feature index c9bd62295..659250603 100644 --- a/issue.features/issue0231.feature +++ b/issue.features/issue0231.feature @@ -51,7 +51,7 @@ Feature: Issue #231: Display the output of the last print command Scenario: Write to stdout without newline - When I run "behave -f pretty -c -T features/syndrome1.feature" + When I run "behave -f pretty --no-color -T features/syndrome1.feature" Then it should fail with: """ 0 scenarios passed, 1 failed, 0 skipped @@ -64,7 +64,7 @@ Feature: Issue #231: Display the output of the last print command """ Scenario: Use print function without newline - When I run "behave -f pretty -c -T features/syndrome2.feature" + When I run "behave -f pretty --no-color -T features/syndrome2.feature" Then it should fail with: """ 0 scenarios passed, 1 failed, 0 skipped From 1e3e66aa4c14f4458d756c0723a7ac54e519b4c5 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 27 May 2023 22:26:42 +0200 Subject: [PATCH 099/240] UPDATE: gherkin_languages.json -- behave/i18n.py * i18n: language=be was added * FIX: DOWNLOAD_URL, repo structure has changed (branch=main, ...) --- behave/i18n.py | 26 ++++++++++-- etc/gherkin/convert_gherkin-languages.py | 20 +++++---- etc/gherkin/gherkin-languages.json | 54 +++++++++++++++++++++++- features/cmdline.lang_list.feature | 1 + tasks/develop.py | 6 +-- 5 files changed, 89 insertions(+), 18 deletions(-) diff --git a/behave/i18n.py b/behave/i18n.py index 524cbc17c..24442a8a6 100644 --- a/behave/i18n.py +++ b/behave/i18n.py @@ -1,11 +1,12 @@ # -*- coding: UTF-8 -*- # -- GENERATED BY: convert_gherkin-languages.py # FROM: "gherkin-languages.json" -# SOURCE: https://raw.githubusercontent.com/cucumber/cucumber/master/gherkin/gherkin-languages.json +# SOURCE: https://raw.githubusercontent.com/cucumber/gherkin/main/gherkin-languages.json # pylint: disable=line-too-long, too-many-lines, missing-docstring, invalid-name +# ruff: noqa: E501 """ Gherkin keywords in the different I18N languages, like: - + * English * French * German @@ -106,6 +107,19 @@ 'scenario_outline': ['Ssenarinin strukturu'], 'then': ['* ', 'O halda '], 'when': ['* ', 'Əgər ', 'Nə vaxt ki ']}, + 'be': {'and': ['* ', 'I ', 'Ды ', 'Таксама '], + 'background': ['Кантэкст'], + 'but': ['* ', 'Але ', 'Інакш '], + 'examples': ['Прыклады'], + 'feature': ['Функцыянальнасць', 'Фіча'], + 'given': ['* ', 'Няхай ', 'Дадзена '], + 'name': 'Belarusian', + 'native': 'Беларуская', + 'rule': ['Правілы'], + 'scenario': ['Сцэнарый', 'Cцэнар'], + 'scenario_outline': ['Шаблон сцэнарыя', 'Узор сцэнара'], + 'then': ['* ', 'Тады '], + 'when': ['* ', 'Калі ']}, 'bg': {'and': ['* ', 'И '], 'background': ['Предистория'], 'but': ['* ', 'Но '], @@ -629,7 +643,7 @@ 'but': ['* ', 'მაგრამ ', 'თუმცა '], 'examples': ['მაგალითები'], 'feature': ['თვისება', 'მოთხოვნა'], - 'given': ['* ', 'მოცემული ', 'Მოცემულია ', 'ვთქვათ '], + 'given': ['* ', 'მოცემული ', 'მოცემულია ', 'ვთქვათ '], 'name': 'Georgian', 'native': 'ქართული', 'rule': ['წესი'], @@ -864,7 +878,11 @@ 'background': ['Предыстория', 'Контекст'], 'but': ['* ', 'Но ', 'А ', 'Иначе '], 'examples': ['Примеры'], - 'feature': ['Функция', 'Функциональность', 'Функционал', 'Свойство'], + 'feature': ['Функция', + 'Функциональность', + 'Функционал', + 'Свойство', + 'Фича'], 'given': ['* ', 'Допустим ', 'Дано ', 'Пусть '], 'name': 'Russian', 'native': 'русский', diff --git a/etc/gherkin/convert_gherkin-languages.py b/etc/gherkin/convert_gherkin-languages.py index 9ef9b0c18..7d674904a 100755 --- a/etc/gherkin/convert_gherkin-languages.py +++ b/etc/gherkin/convert_gherkin-languages.py @@ -11,13 +11,15 @@ * six * PyYAML -.. _cucumber: https://github.com/cucumber/cucumber/ -.. _`gherkin-languages.json`: https://raw.githubusercontent.com/cucumber/cucumber/master/gherkin/gherkin-languages.json +.. _cucumber: https://github.com/cucumber/common +.. _gherkin: https://github.com/cucumber/gherkin +.. _`gherkin-languages.json`: https://raw.githubusercontent.com/cucumber/gherkin/main/gherkin-languages.json .. seealso:: - * https://github.com/cucumber/cucumber/blob/master/gherkin/gherkin-languages.json - * https://raw.githubusercontent.com/cucumber/cucumber/master/gherkin/gherkin-languages.json + * https://github.com/cucumber/gherkin/blob/main/gherkin-languages.json + * https://raw.githubusercontent.com/cucumber/gherkin/main/gherkin-languages.json + * https://github.com/cucumber/common .. note:: @@ -42,7 +44,8 @@ STEP_KEYWORDS = (u"and", u"but", u"given", u"when", u"then") GHERKIN_LANGUAGES_JSON_URL = \ - "https://raw.githubusercontent.com/cucumber/cucumber/master/gherkin/gherkin-languages.json" + "https://raw.githubusercontent.com/cucumber/gherkin/main/gherkin-languages.json" + def download_file(source_url, filename=None): @@ -133,9 +136,10 @@ def gherkin_languages_to_python_module(gherkin_languages_path, output_file=None, # FROM: "gherkin-languages.json" # SOURCE: {gherkin_languages_json_url} # pylint: disable=line-too-long, too-many-lines, missing-docstring, invalid-name +# ruff: noqa: E501 """ Gherkin keywords in the different I18N languages, like: - + * English * French * German @@ -164,8 +168,8 @@ def gherkin_languages_to_python_module(gherkin_languages_path, output_file=None, def main(args=None): - """Main function to generate the "behave/i18n.py" module from the - the "gherkin-languages.json" file. + """Main function to generate the "behave/i18n.py" module + from the "gherkin-languages.json" file. :param args: List of command-line args (if None: Use ``sys.argv``) :return: 0, on success (or sys.exit(NON_ZERO_NUMBER) on failure). diff --git a/etc/gherkin/gherkin-languages.json b/etc/gherkin/gherkin-languages.json index a8541cd2d..209042dd0 100644 --- a/etc/gherkin/gherkin-languages.json +++ b/etc/gherkin/gherkin-languages.json @@ -277,6 +277,55 @@ "Əgər ", "Nə vaxt ki " ] + }, + "be": { + "and": [ + "* ", + "I ", + "Ды ", + "Таксама " + ], + "background": [ + "Кантэкст" + ], + "but": [ + "* ", + "Але ", + "Інакш " + ], + "examples": [ + "Прыклады" + ], + "feature": [ + "Функцыянальнасць", + "Фіча" + ], + "given": [ + "* ", + "Няхай ", + "Дадзена " + ], + "name": "Belarusian", + "native": "Беларуская", + "rule": [ + "Правілы" + ], + "scenario": [ + "Сцэнарый", + "Cцэнар" + ], + "scenarioOutline": [ + "Шаблон сцэнарыя", + "Узор сцэнара" + ], + "then": [ + "* ", + "Тады " + ], + "when": [ + "* ", + "Калі " + ] }, "bg": { "and": [ @@ -2005,7 +2054,7 @@ "given": [ "* ", "მოცემული ", - "Მოცემულია ", + "მოცემულია ", "ვთქვათ " ], "name": "Georgian", @@ -2782,7 +2831,8 @@ "Функция", "Функциональность", "Функционал", - "Свойство" + "Свойство", + "Фича" ], "given": [ "* ", diff --git a/features/cmdline.lang_list.feature b/features/cmdline.lang_list.feature index 3fda63f21..91439c555 100644 --- a/features/cmdline.lang_list.feature +++ b/features/cmdline.lang_list.feature @@ -18,6 +18,7 @@ Feature: Command-line options: Use behave --lang-list ar: العربية / Arabic ast: asturianu / Asturian az: Azərbaycanca / Azerbaijani + be: Беларуская / Belarusian bg: български / Bulgarian bm: Bahasa Melayu / Malay bs: Bosanski / Bosnian diff --git a/tasks/develop.py b/tasks/develop.py index 1f5519df3..6208b1a4c 100644 --- a/tasks/develop.py +++ b/tasks/develop.py @@ -13,9 +13,7 @@ # ----------------------------------------------------------------------------- # CONSTANTS: # ----------------------------------------------------------------------------- -# DISABLED: OLD LOCATION: -# GHERKIN_LANGUAGES_URL = "https://raw.githubusercontent.com/cucumber/cucumber/master/gherkin/gherkin-languages.json" -GHERKIN_LANGUAGES_URL = "https://raw.githubusercontent.com/cucumber/common/main/gherkin/gherkin-languages.json" +GHERKIN_LANGUAGES_URL = "https://raw.githubusercontent.com/cucumber/gherkin/main/gherkin-languages.json" # ----------------------------------------------------------------------------- @@ -38,7 +36,7 @@ def update_gherkin(ctx, dry_run=False, verbose=False): print('Downloading "gherkin-languages.json" from github:cucumber ...') download_request = requests.get(GHERKIN_LANGUAGES_URL) assert download_request.ok - print('Download finished: OK (size={0})'.format(len(download_request.content))) + print("Download finished: OK (size={0})".format(len(download_request.content))) with open(gherkin_languages_file, "wb") as f: f.write(download_request.content) From 20054f0426a1d96b1d5b75f75497deb01043c98f Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 27 May 2023 22:42:36 +0200 Subject: [PATCH 100/240] invoke tasks: Use invoke_cleanup from git-repo RELATED TO: py.requirements * ADDED: assertpy to setup.py, py.requirements/testing.txt * ADDED: check-jsonschema to py.requirements/jsonschema.txt (was: json.txt) * ADDED: ruff to py.requirements/pylinters.txt * UPDATE: .pylintrc to newer config-schema TESTS: Related to tag-expressions (and diagnostics) * ADDED: features/tags.help.feature * ADDED: tests/issues/test_issue1051.py to verify #1054 issue. PREPARED: behave.config.Configuration.has_colored_mode() * PrettyFormatter * main: run_behave() --- .pylintrc | 757 +++++++++++++------ behave/__main__.py | 2 +- behave/formatter/pretty.py | 28 +- features/tags.help.feature | 74 ++ py.requirements/all.txt | 2 +- py.requirements/ci.tox.txt | 2 +- py.requirements/develop.txt | 2 +- py.requirements/{json.txt => jsonschema.txt} | 1 + py.requirements/pylinters.txt | 8 + py.requirements/testing.txt | 1 + setup.py | 1 + tasks/__init__.py | 4 +- tasks/docs.py | 2 +- tasks/invoke_cleanup.py | 447 ----------- tasks/py.requirements.txt | 2 + tasks/release.py | 2 +- tasks/test.py | 2 +- tests/issues/test_issue1054.py | 41 + 18 files changed, 668 insertions(+), 710 deletions(-) create mode 100644 features/tags.help.feature rename py.requirements/{json.txt => jsonschema.txt} (90%) create mode 100644 py.requirements/pylinters.txt delete mode 100644 tasks/invoke_cleanup.py create mode 100644 tests/issues/test_issue1054.py diff --git a/.pylintrc b/.pylintrc index 27334322f..49d5195a2 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,386 +1,667 @@ # ============================================================================= # PYLINT CONFIGURATION # ============================================================================= -# PYLINT-VERSION: 1.5.x +# PYLINT-VERSION: XXX_UPDATE: 1.5.x # SEE ALSO: http://www.pylint.org/ # ============================================================================= -[MASTER] +[MAIN] -# Specify a configuration file. -#rcfile=.pylintrc +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Clear in-memory caches upon conclusion of linting. Useful if running pylint +# in a server-like mode. +clear-cache-post-run=no + +# Load and enable all available extensions. Use --list-extensions to see a list +# all available extensions. +#enable-all-extensions= + +# In error mode, messages with a category besides ERROR or FATAL are +# suppressed, and no reports are done by default. Error mode is compatible with +# disabling specific errors. +#errors-only= + +# Always return a 0 (non-error) status code, even if lint errors are found. +# This is primarily useful in continuous integration scripts. +#exit-zero= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-allow-list= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. (This is an alternative name to extension-pkg-allow-list +# for backward compatibility.) +extension-pkg-whitelist= + +# Return non-zero exit code if any of these messages/categories are detected, +# even if score is above --fail-under value. Syntax same as enable. Messages +# specified are enabled, while categories only check already-enabled messages. +fail-on= + +# Specify a score threshold under which the program will exit with error. +fail-under=10 + +# Interpret the stdin as a python script, whose filename needs to be passed as +# the module_or_package argument. +#from-stdin= + +# Files or directories to be skipped. They should be base names, not paths. +ignore=.git + +# Add files or directories matching the regular expressions patterns to the +# ignore-list. The regex matches against paths and can be in Posix or Windows +# format. Because '\\' represents the directory delimiter on Windows systems, +# it can't be used as an escape character. +ignore-paths= + +# Files or directories matching the regular expression patterns are skipped. +# The regex matches against base names, not paths. The default value ignores +# Emacs file locks +ignore-patterns=^\.# + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis). It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= # Python code to execute, usually for sys.path manipulation such as # pygtk.require(). #init-hook= -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore=.git +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use, and will cap the count on Windows to +# avoid hangs. +jobs=1 -# Pickle collected data for later comparisons. -persistent=yes +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 -# List of plugins (as comma separated values of python modules names) to load, +# List of plugins (as comma separated values of python module names) to load, # usually to register additional checkers. load-plugins= -# Use multiple processes to speed up Pylint. -jobs=1 +# Pickle collected data for later comparisons. +persistent=yes + +# Minimum Python version to use for version dependent checks. Will default to +# the version used to run pylint. +py-version=3.10 + +# Discover python modules and packages in the file system subtree. +recursive=no + +# Add paths to the list of the source roots. Supports globbing patterns. The +# source root is an absolute path or a path relative to the current working +# directory used to determine a package namespace for modules located under the +# source root. +source-roots= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. unsafe-load-any-extension=no -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code -extension-pkg-whitelist= +# In verbose mode, extra non-checker-related info will be displayed. +#verbose= -# Allow optimization of some AST trees. This will activate a peephole AST -# optimizer, which will apply various small optimizations. For instance, it can -# be used to obtain the result of joining multiple strings with the addition -# operator. Joining a lot of strings can lead to a maximum recursion error in -# Pylint and this flag can prevent that. It has one side effect, the resulting -# AST will be different than the one from reality. -optimize-ast=no +[BASIC] -[MESSAGES CONTROL] +# Naming style matching correct argument names. +argument-naming-style=snake_case -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED -confidence= +# Regular expression matching correct argument names. Overrides argument- +# naming-style. If left empty, argument names will be checked with the set +# naming style. +argument-rgx=[a-z_][a-z0-9_]{2,30}$ -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time. See also the "--disable" option for examples. -#enable= +# Naming style matching correct attribute names. +attr-naming-style=snake_case -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once).You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use"--disable=all --enable=classes -# --disable=W" -disable=import-star-module-level,old-octal-literal,oct-method,print-statement,unpacking-in-except,parameter-unpacking,backtick,old-raise-syntax,old-ne-operator,long-suffix,dict-view-method,dict-iter-method,metaclass-assignment,next-method-called,raising-string,indexing-exception,raw_input-builtin,long-builtin,file-builtin,execfile-builtin,coerce-builtin,cmp-builtin,buffer-builtin,basestring-builtin,apply-builtin,filter-builtin-not-iterating,unused-variable,using-cmp-argument,useless-suppression,range-builtin-not-iterating,suppressed-message,missing-docstring,no-absolute-import,old-division,cmp-method,reload-builtin,zip-builtin-not-iterating,intern-builtin,unichr-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,input-builtin,too-few-public-methods,round-builtin,locally-disabled,hex-method,nonzero-method,map-builtin-not-iterating +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. If left empty, attribute names will be checked with the set naming +# style. +attr-rgx=[a-z_][a-z0-9_]{2,30}$ +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata -[REPORTS] +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= -# Set the output format. Available formats are text, parseable, colorized, msvs -# (visual studio) and html. You can also give a reporter class, eg -# mypackage.mymodule.MyReporterClass. -# output-format=text -output-format=colorized +# Naming style matching correct class attribute names. +class-attribute-naming-style=any -# Put messages in a separate file for each module / package specified on the -# command line instead of printing them on stdout. Reports (if any) will be -# written in a file name "pylint_global.[txt|html]". -files-output=no +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. If left empty, class attribute names will be checked +# with the set naming style. +class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,40}|(__.*__))$ -# Tells whether to display a full report or only the messages -reports=yes +# Naming style matching correct class constant names. +class-const-naming-style=UPPER_CASE -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) +# Regular expression matching correct class constant names. Overrides class- +# const-naming-style. If left empty, class constant names will be checked with +# the set naming style. +#class-const-rgx= -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details -#msg-template= +# Naming style matching correct class names. +class-naming-style=PascalCase +# Regular expression matching correct class names. Overrides class-naming- +# style. If left empty, class names will be checked with the set naming style. +class-rgx=[A-Z_][a-zA-Z0-9]+$ -[BASIC] +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. If left empty, constant names will be checked with the set naming +# style. +const-rgx=(([a-zA-Z_][a-zA-Z0-9_]*)|(__.*__))$ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. If left empty, function names will be checked with the set +# naming style. +function-rgx=[a-z_][a-z0-9_]{2,40}$ + +# Good variable names which should always be accepted, separated by a comma. +good-names=c, + d, + f, + h, + i, + j, + k, + m, + n, + o, + p, + r, + s, + v, + w, + x, + y, + e, + ex, + kw, + up, + _ + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=yes + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any -# List of builtins function names that should not be used, separated by a comma -bad-functions=map,filter,apply,input +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. If left empty, inline iteration names will be checked +# with the set naming style. +inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ -# Good variable names which should always be accepted, separated by a comma -good-names=c,d,f,h,i,j,k,m,n,o,p,r,s,v,w,x,y,e,ex,kw,up,_ +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. If left empty, method names will be checked with the set naming style. +method-rgx=[a-z_][a-z0-9_]{2,30}$ -# Bad variable names which should always be refused, separated by a comma -bad-names=foo,bar,baz,toto,tutu,tata +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. If left empty, module names will be checked with the set naming style. +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ # Colon-delimited sets of names that determine each other's naming style when # the name regexes allow several styles. name-group= -# Include a hint for the correct naming format with invalid-name -include-naming-hint=yes +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=__.*__ -# Regular expression matching correct function names -function-rgx=[a-z_][a-z0-9_]{2,40}$ +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Regular expression matching correct type alias names. If left empty, type +# alias names will be checked with the set naming style. +#typealias-rgx= -# Naming hint for function names -function-name-hint=[a-z_][a-z0-9_]{2,40}$ +# Regular expression matching correct type variable names. If left empty, type +# variable names will be checked with the set naming style. +#typevar-rgx= -# Regular expression matching correct variable names +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. If left empty, variable names will be checked with the set +# naming style. variable-rgx=[a-z_][a-z0-9_]{2,40}$ -# Naming hint for variable names -variable-name-hint=[a-z_][a-z0-9_]{2,40}$ -# Regular expression matching correct constant names -const-rgx=(([a-zA-Z_][a-zA-Z0-9_]*)|(__.*__))$ +[CLASSES] -# Naming hint for constant names -const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ +# Warn about protected attribute access inside special methods +check-protected-access-in-special-methods=no -# Regular expression matching correct attribute names -attr-rgx=[a-z_][a-z0-9_]{2,30}$ +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp -# Naming hint for attribute names -attr-name-hint=[a-z_][a-z0-9_]{2,30}$ +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make -# Regular expression matching correct argument names -argument-rgx=[a-z_][a-z0-9_]{2,30}$ +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls -# Naming hint for argument names -argument-name-hint=[a-z_][a-z0-9_]{2,30}$ +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs -# Regular expression matching correct class attribute names -class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,40}|(__.*__))$ -# Naming hint for class attribute names -class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,40}|(__.*__))$ +[DESIGN] -# Regular expression matching correct inline iteration names -inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ +# List of regular expressions of class ancestor names to ignore when counting +# public methods (see R0903) +exclude-too-few-public-methods= -# Naming hint for inline iteration names -inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ +# List of qualified class names to ignore when counting class parents (see +# R0901) +ignored-parents= -# Regular expression matching correct class names -class-rgx=[A-Z_][a-zA-Z0-9]+$ +# Maximum number of arguments for function / method. +max-args=10 -# Naming hint for class names -class-name-hint=[A-Z_][a-zA-Z0-9]+$ +# Maximum number of attributes for a class (see R0902). +max-attributes=10 -# Regular expression matching correct module names -module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 -# Naming hint for module names -module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ +# Maximum number of branch for function / method body. +max-branches=12 -# Regular expression matching correct method names -method-rgx=[a-z_][a-z0-9_]{2,30}$ +# Maximum number of locals for function / method body. +max-locals=15 -# Naming hint for method names -method-name-hint=[a-z_][a-z0-9_]{2,30}$ +# Maximum number of parents for a class (see R0901). +max-parents=7 -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=__.*__ +# Maximum number of public methods for a class (see R0904). +max-public-methods=30 -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 +# Maximum number of return / yield for function / method body. +max-returns=6 +# Maximum number of statements in function / method body. +max-statements=50 -[ELIF] +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 + +[EXCEPTIONS] + +# Exceptions that will emit a warning when caught. +overgeneral-exceptions=builtins.Exception [FORMAT] -# Maximum number of characters on a single line. -max-line-length=85 +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= # Regexp for a line that is allowed to be longer than the limit. ignore-long-lines=^\s*(# )??$ +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=85 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + # Allow the body of an if to be on the same line as the test if there is no # else. single-line-if-stmt=no -# List of optional constructs for which whitespace checking is disabled. `dict- -# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. -# `trailing-comma` allows a space between comma and closing bracket: (a, ). -# `empty-line` allows space-only lines. -no-space-check=trailing-comma,dict-separator -# Maximum number of lines in a module -max-module-lines=1000 +[IMPORTS] -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 +# Allow explicit reexports by alias from a package __init__. +allow-reexport-from-package=no -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules=regsub, + string, + TERMIOS, + Bastion, + rexec + +# Output a graph (.gv or any supported image format) of external dependencies +# to the given file (report RP0402 must not be disabled). +ext-import-graph= + +# Output a graph (.gv or any supported image format) of all (i.e. internal and +# external) dependencies to the given file (report RP0402 must not be +# disabled). +import-graph= + +# Output a graph (.gv or any supported image format) of internal dependencies +# to the given file (report RP0402 must not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= [LOGGING] +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + # Logging modules to check that the string format arguments are in logging -# function parameter format +# function parameter format. logging-modules=logging -[MISCELLANEOUS] +[MESSAGES CONTROL] -# List of note tags to take in consideration, separated by a comma. -notes=FIXME,XXX,TODO +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, +# UNDEFINED. +confidence=HIGH, + CONTROL_FLOW, + INFERENCE, + INFERENCE_FAILURE, + UNDEFINED +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then re-enable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + unused-variable, + missing-module-docstring, + missing-class-docstring, + missing-function-docstring, + too-few-public-methods -[SIMILARITIES] +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member -# Minimum lines number of a similarity. -min-similarity-lines=4 -# Ignore comments when computing similarities. -ignore-comments=yes +[METHOD_ARGS] -# Ignore docstrings when computing similarities. -ignore-docstrings=yes +# List of qualified names (i.e., library.method) which require a timeout +# parameter e.g. 'requests.api.get,requests.api.post' +timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request -# Ignore imports when computing similarities. -ignore-imports=no +[MISCELLANEOUS] -[SPELLING] +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO -# Spelling dictionary name. Available dictionaries: none. To make it working -# install python-enchant package. -spelling-dict= +# Regular expression of note tags to take in consideration. +notes-rgx= -# List of comma separated words that should not be checked. -spelling-ignore-words= -# A path to a file that contains private dictionary; one word per line. -spelling-private-dict-file= +[REFACTORING] -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. -spelling-store-unknown-words=no +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit,argparse.parse_error -[TYPECHECK] -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes +[REPORTS] -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis. It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules= +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'fatal', 'error', 'warning', 'refactor', +# 'convention', and 'info' which contain the number of messages in each +# category, as well as 'statement' which is the total number of statements +# analyzed. This score is used by the global evaluation report (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) -# List of classes names for which member attributes should not be checked -# (useful for classes with attributes dynamically set). This supports can work -# with qualified names. -ignored-classes=SQLObject +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +msg-template= -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members=REQUEST,acl_users,aq_parent +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +#output-format= +# Tells whether to display a full report or only the messages. +reports=yes -[VARIABLES] +# Activate the evaluation score. +score=yes -# Tells whether we should check for unused import in __init__ files. -init-import=no -# A regular expression matching the name of dummy variables (i.e. expectedly -# not used). -dummy-variables-rgx=_|dummy|kwargs +[SIMILARITIES] -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid to define new builtins when possible. -additional-builtins= +# Comments are removed from the similarity computation +ignore-comments=yes -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_,_cb +# Docstrings are removed from the similarity computation +ignore-docstrings=yes +# Imports are removed from the similarity computation +ignore-imports=no -[CLASSES] +# Signatures are removed from the similarity computation +ignore-signatures=yes -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__,__new__,setUp +# Minimum lines number of a similarity. +min-similarity-lines=4 -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=mcs +[SPELLING] -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict,_fields,_replace,_source,_make +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 +# Spelling dictionary name. No available dictionaries : You need to install +# both the python package and the system dependency for enchant to work.. +spelling-dict= -[DESIGN] +# List of comma separated words that should be considered directives if they +# appear at the beginning of a comment and should not be checked. +spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: -# Maximum number of arguments for function / method -max-args=10 +# List of comma separated words that should not be checked. +spelling-ignore-words= -# Argument names that match this expression will be ignored. Default to name -# with leading underscore -ignored-argument-names=_.* +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= -# Maximum number of locals for function / method body -max-locals=15 +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no -# Maximum number of return / yield for function / method body -max-returns=6 -# Maximum number of branch for function / method body -max-branches=12 +[STRING] -# Maximum number of statements in function / method body -max-statements=50 +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no -# Maximum number of parents for a class (see R0901). -max-parents=7 +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no -# Maximum number of attributes for a class (see R0902). -max-attributes=10 -# Minimum number of public methods for a class (see R0903). -min-public-methods=2 +[TYPECHECK] -# Maximum number of public methods for a class (see R0904). -max-public-methods=30 +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager -# Maximum number of boolean expressions in a if statement -max-bool-expr=5 +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members=REQUEST, + acl_users, + aq_parent + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of symbolic message names to ignore for Mixin members. +ignored-checks-for-mixins=no-member, + not-async-context-manager, + not-context-manager, + attribute-defined-outside-init + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=SQLObject +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes -[IMPORTS] +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 -# Deprecated modules which should not be used, separated by a comma -deprecated-modules=regsub,string,TERMIOS,Bastion,rexec +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled) -import-graph= +# Regex pattern to define which classes are considered mixins. +mixin-class-rgx=.*[Mm]ixin -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled) -ext-import-graph= +# List of decorators that change the signature of a decorated function. +signature-mutators= -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled) -int-import-graph= +[VARIABLES] -[EXCEPTIONS] +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of names allowed to shadow builtins +allowed-redefined-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_|dummy|kwargs + +# Argument names that match this expression will be ignored. +ignored-argument-names=_.* + +# Tells whether we should check for unused import in __init__ files. +init-import=no -# Exceptions that will emit a warning when being caught. Defaults to -# "Exception" -overgeneral-exceptions=Exception +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io diff --git a/behave/__main__.py b/behave/__main__.py index 52f4f73d3..bb9367db7 100644 --- a/behave/__main__.py +++ b/behave/__main__.py @@ -141,7 +141,7 @@ def run_behave(config, runner_class=None): if config.show_snippets and runner and runner.undefined_steps: print_undefined_step_snippets(runner.undefined_steps, - colored=config.color) + colored=config.has_colored_mode()) return_code = 0 if failed: diff --git a/behave/formatter/pretty.py b/behave/formatter/pretty.py index b97438aae..9c1c103c5 100644 --- a/behave/formatter/pretty.py +++ b/behave/formatter/pretty.py @@ -2,13 +2,13 @@ from __future__ import absolute_import, division import sys +import six +from six.moves import range, zip from behave.formatter.ansi_escapes import escapes, up from behave.formatter.base import Formatter from behave.model_core import Status from behave.model_describe import escape_cell, escape_triple_quotes -from behave.textutil import indent, make_indentation, text as _text -import six -from six.moves import range, zip +from behave.textutil import indent, text as _text # ----------------------------------------------------------------------------- @@ -66,7 +66,7 @@ def __init__(self, stream_opener, config): super(PrettyFormatter, self).__init__(stream_opener, config) # -- ENSURE: Output stream is open. self.stream = self.open() - self.monochrome = self._get_monochrome(config) + self.colored = config.has_colored_mode(self.stream) self.show_source = config.show_source self.show_timings = config.show_timings self.show_multiline = config.show_multiline @@ -81,14 +81,9 @@ def __init__(self, stream_opener, config): self.indentations = [] self.step_lines = 0 - def _get_monochrome(self, config): - isatty = getattr(self.stream, "isatty", lambda: True) - if config.color == 'always': - return False - elif config.color == 'never': - return True - else: - return not isatty() + @property + def monochrome(self): + return not self.colored def reset(self): # -- UNUSED: self.tag_statement = None @@ -143,11 +138,11 @@ def match(self, match): self._match = match self.print_statement() self.print_step(Status.executing, self._match.arguments, - self._match.location, self.monochrome) + self._match.location, proceed=self.monochrome) self.stream.flush() def result(self, step): - if not self.monochrome: + if self.colored: lines = self.step_lines + 1 if self.show_multiline: if step.table: @@ -160,7 +155,7 @@ def result(self, step): if self._match: arguments = self._match.arguments location = self._match.location - self.print_step(step.status, arguments, location, True) + self.print_step(step.status, arguments, location, proceed=True) if step.error_message: self.stream.write(indent(step.error_message.strip(), u" ")) self.stream.write("\n\n") @@ -170,10 +165,11 @@ def arg_format(self, key): return self.format(key + "_arg") def format(self, key): - if self.monochrome: + if not self.colored: if self.formats is None: self.formats = MonochromeFormat() return self.formats + # -- OTHERWISE: if self.formats is None: self.formats = {} diff --git a/features/tags.help.feature b/features/tags.help.feature new file mode 100644 index 000000000..8b0b98c1e --- /dev/null +++ b/features/tags.help.feature @@ -0,0 +1,74 @@ +Feature: behave --tags-help option + + As a user + I want to understand how to specify tag-expressions on command-line + So that I can select some features, rules or scenarios, etc. + + . IN ADDITION: + . The --tags-help option helps to diagnose tag-expression v2 problems. + + Rule: Use --tags-help option to see tag-expression syntax and examples + Scenario: Shows tag-expression description + When I run "behave --tags-help" + Then it should pass with: + """ + TAG-EXPRESSIONS selects Features/Rules/Scenarios by using their tags. + A TAG-EXPRESSION is a boolean expression that references some tags. + + EXAMPLES: + + --tags=@smoke + --tags="not @xfail" + --tags="@smoke or @wip" + --tags="@smoke and @wip" + --tags="(@slow and not @fixme) or @smoke" + --tags="not (@fixme or @xfail)" + + NOTES: + * The tag-prefix "@" is optional. + * An empty tag-expression is "true" (select-anything). + """ + + Rule: Use --tags-help option to inspect current tag-expression + Scenario: Shows current tag-expression without any tags + When I run "behave --tags-help" + Then it should pass with: + """ + CURRENT TAG_EXPRESSION: true + """ + And note that "an EMPTY tag-expression is always TRUE" + + Scenario: Shows current tag-expression with tags + When I run "behave --tags-help --tags='@one and @two'" + Then it should pass with: + """ + CURRENT TAG_EXPRESSION: (one and two) + """ + + Scenario Outline: Shows more details of current tag-expression in verbose mode + When I run "behave --tags-help --tags='' --verbose" + Then it should pass with: + """ + CURRENT TAG_EXPRESSION: + means: + """ + But note that "the low-level tag-expression details are shown in verbose mode" + + Examples: + | tags | tag_expression | tag_expression.logic | + | @one or @two and @three | (one or (two and three)) | Or(Tag('one'), And(Tag('two'), Tag('three'))) | + | @one and @two or @three | ((one and two) or three) | Or(And(Tag('one'), Tag('two')), Tag('three')) | + + Rule: Use --tags-help option with BAD TAG-EXPRESSION + Scenario: Shows Tag-Expression Error for BAD TAG-EXPRESSION + When I run "behave --tags-help --tags='not @one @two'" + Then it should fail with: + """ + TagExpressionError: Syntax error. Expected operator after one + Expression: ( not one two ) + ______________________^ (HERE) + """ + And note that "the error description indicates where the problem is" + And note that "the correct tag-expression may be: not @one and @two" + But the command output should not contain "Traceback" + diff --git a/py.requirements/all.txt b/py.requirements/all.txt index 4298e3328..de0cec006 100644 --- a/py.requirements/all.txt +++ b/py.requirements/all.txt @@ -12,4 +12,4 @@ -r basic.txt -r develop.txt --r json.txt +-r jsonschema.txt diff --git a/py.requirements/ci.tox.txt b/py.requirements/ci.tox.txt index 87c7d5f01..942588e9f 100644 --- a/py.requirements/ci.tox.txt +++ b/py.requirements/ci.tox.txt @@ -3,4 +3,4 @@ # ============================================================================ -r testing.txt -jsonschema +-r jsonschema.txt diff --git a/py.requirements/develop.txt b/py.requirements/develop.txt index f2df9c199..4048b47a4 100644 --- a/py.requirements/develop.txt +++ b/py.requirements/develop.txt @@ -23,7 +23,7 @@ twine >= 1.13.0 modernize >= 0.5 # -- STATIC CODE ANALYSIS: -pylint +-r pylinters.txt # -- REQUIRES: testing -r testing.txt diff --git a/py.requirements/json.txt b/py.requirements/jsonschema.txt similarity index 90% rename from py.requirements/json.txt rename to py.requirements/jsonschema.txt index e765b951b..6487590f0 100644 --- a/py.requirements/json.txt +++ b/py.requirements/jsonschema.txt @@ -4,3 +4,4 @@ # -- OPTIONAL: For JSON validation jsonschema >= 1.3.0 +# MAYBE NOW: check-jsonschema diff --git a/py.requirements/pylinters.txt b/py.requirements/pylinters.txt new file mode 100644 index 000000000..0c836d79d --- /dev/null +++ b/py.requirements/pylinters.txt @@ -0,0 +1,8 @@ +# ============================================================================ +# PYTHON PACKAGE REQUIREMENTS FOR: behave -- Static Code Analysis Tools +# ============================================================================ +# SEE: https://github.com/charliermarsh/ruff + +# -- STATIC CODE ANALYSIS: +pylint +ruff >= 0.0.270 diff --git a/py.requirements/testing.txt b/py.requirements/testing.txt index 1cea6736c..6dfc32c7f 100644 --- a/py.requirements/testing.txt +++ b/py.requirements/testing.txt @@ -14,6 +14,7 @@ mock < 4.0; python_version < '3.6' mock >= 4.0; python_version >= '3.6' PyHamcrest >= 2.0.2; python_version >= '3.0' PyHamcrest < 2.0; python_version < '3.0' +assertpy >= 1.1 # -- NEEDED: By some tests (as proof of concept) # NOTE: path.py-10.1 is required for python2.6 diff --git a/setup.py b/setup.py index 7bdf2f6ca..1668b9ac4 100644 --- a/setup.py +++ b/setup.py @@ -98,6 +98,7 @@ def find_packages_by_root_package(where): "mock >= 4.0; python_version >= '3.6'", "PyHamcrest >= 2.0.2; python_version >= '3.0'", "PyHamcrest < 2.0; python_version < '3.0'", + "assertpy >= 1.1", # -- HINT: path.py => path (python-install-package was renamed for python3) "path >= 13.1.0; python_version >= '3.5'", diff --git a/tasks/__init__.py b/tasks/__init__.py index 9ae899b9a..e96925fe0 100644 --- a/tasks/__init__.py +++ b/tasks/__init__.py @@ -35,8 +35,8 @@ from invoke import Collection # -- TASK-LIBRARY: -# PREPARED: import invoke_cleanup as cleanup -from . import invoke_cleanup as cleanup +# DISABLED: from . import invoke_cleanup as cleanup +import invoke_cleanup as cleanup from . import docs from . import test from . import release diff --git a/tasks/docs.py b/tasks/docs.py index e3d4c4e63..2c9835b1e 100644 --- a/tasks/docs.py +++ b/tasks/docs.py @@ -12,7 +12,7 @@ # -- TASK-LIBRARY: # PREPARED: from invoke_cleanup import cleanup_tasks, cleanup_dirs -from .invoke_cleanup import cleanup_tasks, cleanup_dirs +from invoke_cleanup import cleanup_tasks, cleanup_dirs # ----------------------------------------------------------------------------- diff --git a/tasks/invoke_cleanup.py b/tasks/invoke_cleanup.py deleted file mode 100644 index 4e631c432..000000000 --- a/tasks/invoke_cleanup.py +++ /dev/null @@ -1,447 +0,0 @@ -# -*- coding: UTF-8 -*- -""" -Provides cleanup tasks for invoke build scripts (as generic invoke tasklet). -Simplifies writing common, composable and extendable cleanup tasks. - -PYTHON PACKAGE DEPENDENCIES: - -* path (python >= 3.5) or path.py >= 11.5.0 (as path-object abstraction) -* pathlib (for ant-like wildcard patterns; since: python > 3.5) -* pycmd (required-by: clean_python()) - - -cleanup task: Add Additional Directories and Files to be removed -------------------------------------------------------------------------------- - -Create an invoke configuration file (YAML of JSON) with the additional -configuration data: - -.. code-block:: yaml - - # -- FILE: invoke.yaml - # USE: cleanup.directories, cleanup.files to override current configuration. - cleanup: - # directories: Default directory patterns (can be overwritten). - # files: Default file patterns (can be ovewritten). - extra_directories: - - **/tmp/ - extra_files: - - **/*.log - - **/*.bak - - -Registration of Cleanup Tasks ------------------------------- - -Other task modules often have an own cleanup task to recover the clean state. -The :meth:`cleanup` task, that is provided here, supports the registration -of additional cleanup tasks. Therefore, when the :meth:`cleanup` task is executed, -all registered cleanup tasks will be executed. - -EXAMPLE:: - - # -- FILE: tasks/docs.py - from __future__ import absolute_import - from invoke import task, Collection - from invoke_cleanup import cleanup_tasks, cleanup_dirs - - @task - def clean(ctx): - "Cleanup generated documentation artifacts." - dry_run = ctx.config.run.dry - cleanup_dirs(["build/docs"], dry_run=dry_run) - - namespace = Collection(clean) - ... - - # -- REGISTER CLEANUP TASK: - cleanup_tasks.add_task(clean, "clean_docs") - cleanup_tasks.configure(namespace.configuration()) -""" - -from __future__ import absolute_import, print_function -import os -import sys -from invoke import task, Collection -from invoke.executor import Executor -from invoke.exceptions import Exit, Failure, UnexpectedExit -from invoke.util import cd -from path import Path - -# -- PYTHON BACKWARD COMPATIBILITY: -python_version = sys.version_info[:2] -python35 = (3, 5) # HINT: python3.8 does not raise OSErrors. -if python_version < python35: # noqa - import pathlib2 as pathlib -else: - import pathlib # noqa - - -# ----------------------------------------------------------------------------- -# CONSTANTS: -# ----------------------------------------------------------------------------- -VERSION = "0.3.6" - - -# ----------------------------------------------------------------------------- -# CLEANUP UTILITIES: -# ----------------------------------------------------------------------------- -def execute_cleanup_tasks(ctx, cleanup_tasks, workdir=".", verbose=False): - """Execute several cleanup tasks as part of the cleanup. - - :param ctx: Context object for the tasks. - :param cleanup_tasks: Collection of cleanup tasks (as Collection). - """ - # pylint: disable=redefined-outer-name - executor = Executor(cleanup_tasks, ctx.config) - failure_count = 0 - with cd(workdir) as cwd: - for cleanup_task in cleanup_tasks.tasks: - try: - print("CLEANUP TASK: %s" % cleanup_task) - executor.execute(cleanup_task) - except (Exit, Failure, UnexpectedExit) as e: - print(e) - print("FAILURE in CLEANUP TASK: %s (GRACEFULLY-IGNORED)" % cleanup_task) - failure_count += 1 - - if failure_count: - print("CLEANUP TASKS: %d failure(s) occured" % failure_count) - - -def make_excluded(excluded, config_dir=None, workdir=None): - workdir = workdir or Path.getcwd() - config_dir = config_dir or workdir - workdir = Path(workdir) - config_dir = Path(config_dir) - - excluded2 = [] - for p in excluded: - assert p, "REQUIRE: non-empty" - p = Path(p) - if p.isabs(): - excluded2.append(p.normpath()) - else: - # -- RELATIVE PATH: - # Described relative to config_dir. - # Recompute it relative to current workdir. - p = Path(config_dir)/p - p = workdir.relpathto(p) - excluded2.append(p.normpath()) - excluded2.append(p.abspath()) - return set(excluded2) - - -def is_directory_excluded(directory, excluded): - directory = Path(directory).normpath() - directory2 = directory.abspath() - if (directory in excluded) or (directory2 in excluded): - return True - # -- OTHERWISE: - return False - - -def cleanup_dirs(patterns, workdir=".", excluded=None, - dry_run=False, verbose=False, show_skipped=False): - """Remove directories (and their contents) recursively. - Skips removal if directories does not exist. - - :param patterns: Directory name patterns, like "**/tmp*" (as list). - :param workdir: Current work directory (default=".") - :param dry_run: Dry-run mode indicator (as bool). - """ - excluded = excluded or [] - excluded = set([Path(p) for p in excluded]) - show_skipped = show_skipped or verbose - current_dir = Path(workdir) - python_basedir = Path(Path(sys.executable).dirname()).joinpath("..").abspath() - warn2_counter = 0 - for dir_pattern in patterns: - for directory in path_glob(dir_pattern, current_dir): - if is_directory_excluded(directory, excluded): - print("SKIP-DIR: %s (excluded)" % directory) - continue - directory2 = directory.abspath() - if sys.executable.startswith(directory2): - # -- PROTECT VIRTUAL ENVIRONMENT (currently in use): - # pylint: disable=line-too-long - print("SKIP-SUICIDE: '%s' contains current python executable" % directory) - continue - elif directory2.startswith(python_basedir): - # -- PROTECT VIRTUAL ENVIRONMENT (currently in use): - # HINT: Limit noise in DIAGNOSTIC OUTPUT to X messages. - if warn2_counter <= 4: # noqa - print("SKIP-SUICIDE: '%s'" % directory) - warn2_counter += 1 - continue - - if not directory.isdir(): - if show_skipped: - print("RMTREE: %s (SKIPPED: Not a directory)" % directory) - continue - - if dry_run: - print("RMTREE: %s (dry-run)" % directory) - else: - try: - # -- MAYBE: directory.rmtree(ignore_errors=True) - print("RMTREE: %s" % directory) - directory.rmtree_p() - except OSError as e: - print("RMTREE-FAILED: %s (for: %s)" % (e, directory)) - - -def cleanup_files(patterns, workdir=".", dry_run=False, verbose=False, show_skipped=False): - """Remove files or files selected by file patterns. - Skips removal if file does not exist. - - :param patterns: File patterns, like "**/*.pyc" (as list). - :param workdir: Current work directory (default=".") - :param dry_run: Dry-run mode indicator (as bool). - """ - show_skipped = show_skipped or verbose - current_dir = Path(workdir) - python_basedir = Path(Path(sys.executable).dirname()).joinpath("..").abspath() - error_message = None - error_count = 0 - for file_pattern in patterns: - for file_ in path_glob(file_pattern, current_dir): - if file_.abspath().startswith(python_basedir): - # -- PROTECT VIRTUAL ENVIRONMENT (currently in use): - continue - if not file_.isfile(): - if show_skipped: - print("REMOVE: %s (SKIPPED: Not a file)" % file_) - continue - - if dry_run: - print("REMOVE: %s (dry-run)" % file_) - else: - print("REMOVE: %s" % file_) - try: - file_.remove_p() - except os.error as e: - message = "%s: %s" % (e.__class__.__name__, e) - print(message + " basedir: "+ python_basedir) - error_count += 1 - if not error_message: - error_message = message - if False and error_message: # noqa - class CleanupError(RuntimeError): - pass - raise CleanupError(error_message) - - -def path_glob(pattern, current_dir=None): - """Use pathlib for ant-like patterns, like: "**/*.py" - - :param pattern: File/directory pattern to use (as string). - :param current_dir: Current working directory (as Path, pathlib.Path, str) - :return Resolved Path (as path.Path). - """ - if not current_dir: # noqa - current_dir = pathlib.Path.cwd() - elif not isinstance(current_dir, pathlib.Path): - # -- CASE: string, path.Path (string-like) - current_dir = pathlib.Path(str(current_dir)) - - pattern_path = Path(pattern) - if pattern_path.isabs(): - # -- SPECIAL CASE: Path.glob() only supports relative-path(s) / pattern(s). - if pattern_path.isdir(): - yield pattern_path - return - - # -- HINT: OSError is no longer raised in pathlib2 or python35.pathlib - # try: - for p in current_dir.glob(pattern): - yield Path(str(p)) - # except OSError as e: - # # -- CORNER-CASE 1: x.glob(pattern) may fail with: - # # OSError: [Errno 13] Permission denied: - # # HINT: Directory lacks excutable permissions for traversal. - # # -- CORNER-CASE 2: symlinked endless loop - # # OSError: [Errno 62] Too many levels of symbolic links: - # print("{0}: {1}".format(e.__class__.__name__, e)) - - -# ----------------------------------------------------------------------------- -# GENERIC CLEANUP TASKS: -# ----------------------------------------------------------------------------- -@task(help={ - "workdir": "Directory to clean(up) (default: $CWD).", - "verbose": "Enable verbose mode (default: OFF).", -}) -def clean(ctx, workdir=".", verbose=False): - """Cleanup temporary dirs/files to regain a clean state.""" - dry_run = ctx.config.run.dry - config_dir = getattr(ctx.config, "config_dir", workdir) - directories = list(ctx.config.cleanup.directories or []) - directories.extend(ctx.config.cleanup.extra_directories or []) - files = list(ctx.config.cleanup.files or []) - files.extend(ctx.config.cleanup.extra_files or []) - excluded_directories = list(ctx.config.cleanup.excluded_directories or []) - excluded_directories = make_excluded(excluded_directories, - config_dir=config_dir, workdir=".") - - # -- PERFORM CLEANUP: - execute_cleanup_tasks(ctx, cleanup_tasks) - cleanup_dirs(directories, workdir=workdir, excluded=excluded_directories, - dry_run=dry_run, verbose=verbose) - cleanup_files(files, workdir=workdir, dry_run=dry_run, verbose=verbose) - - # -- CONFIGURABLE EXTENSION-POINT: - # use_cleanup_python = ctx.config.cleanup.use_cleanup_python or False - # if use_cleanup_python: - # clean_python(ctx) - - -@task(name="all", aliases=("distclean",), - help={ - "workdir": "Directory to clean(up) (default: $CWD).", - "verbose": "Enable verbose mode (default: OFF).", -}) -def clean_all(ctx, workdir=".", verbose=False): - """Clean up everything, even the precious stuff. - NOTE: clean task is executed last. - """ - dry_run = ctx.config.run.dry - config_dir = getattr(ctx.config, "config_dir", workdir) - directories = list(ctx.config.cleanup_all.directories or []) - directories.extend(ctx.config.cleanup_all.extra_directories or []) - files = list(ctx.config.cleanup_all.files or []) - files.extend(ctx.config.cleanup_all.extra_files or []) - excluded_directories = list(ctx.config.cleanup_all.excluded_directories or []) - excluded_directories.extend(ctx.config.cleanup.excluded_directories or []) - excluded_directories = make_excluded(excluded_directories, - config_dir=config_dir, workdir=".") - - # -- PERFORM CLEANUP: - # HINT: Remove now directories, files first before cleanup-tasks. - cleanup_dirs(directories, workdir=workdir, excluded=excluded_directories, - dry_run=dry_run, verbose=verbose) - cleanup_files(files, workdir=workdir, dry_run=dry_run, verbose=verbose) - execute_cleanup_tasks(ctx, cleanup_all_tasks) - clean(ctx, workdir=workdir, verbose=verbose) - - # -- CONFIGURABLE EXTENSION-POINT: - # use_cleanup_python1 = ctx.config.cleanup.use_cleanup_python or False - # use_cleanup_python2 = ctx.config.cleanup_all.use_cleanup_python or False - # if use_cleanup_python2 and not use_cleanup_python1: - # clean_python(ctx) - - -@task(aliases=["python"]) -def clean_python(ctx, workdir=".", verbose=False): - """Cleanup python related files/dirs: *.pyc, *.pyo, ...""" - dry_run = ctx.config.run.dry or False - # MAYBE NOT: "**/__pycache__" - cleanup_dirs(["build", "dist", "*.egg-info", "**/__pycache__"], - workdir=workdir, dry_run=dry_run, verbose=verbose) - if not dry_run: - ctx.run("py.cleanup") - cleanup_files(["**/*.pyc", "**/*.pyo", "**/*$py.class"], - workdir=workdir, dry_run=dry_run, verbose=verbose) - - -@task(help={ - "path": "Path to cleanup.", - "interactive": "Enable interactive mode.", - "force": "Enable force mode.", - "options": "Additional git-clean options", -}) -def git_clean(ctx, path=None, interactive=False, force=False, - dry_run=False, options=None): - """Perform git-clean command to cleanup the worktree of a git repository. - - BEWARE: This may remove any precious files that are not checked in. - WARNING: DANGEROUS COMMAND. - """ - args = [] - force = force or ctx.config.git_clean.force - path = path or ctx.config.git_clean.path or "." - interactive = interactive or ctx.config.git_clean.interactive - dry_run = dry_run or ctx.config.run.dry or ctx.config.git_clean.dry_run - - if interactive: - args.append("--interactive") - if force: - args.append("--force") - if dry_run: - args.append("--dry-run") - args.append(options or "") - args = " ".join(args).strip() - - ctx.run("git clean {options} {path}".format(options=args, path=path)) - - -# ----------------------------------------------------------------------------- -# TASK CONFIGURATION: -# ----------------------------------------------------------------------------- -CLEANUP_EMPTY_CONFIG = { - "directories": [], - "files": [], - "extra_directories": [], - "extra_files": [], - "excluded_directories": [], - "excluded_files": [], - "use_cleanup_python": False, -} -def make_cleanup_config(**kwargs): - config_data = CLEANUP_EMPTY_CONFIG.copy() - config_data.update(kwargs) - return config_data - - -namespace = Collection(clean_all, clean_python) -namespace.add_task(clean, default=True) -namespace.add_task(git_clean) -namespace.configure({ - "cleanup": make_cleanup_config( - files=["**/*.bak", "**/*.log", "**/*.tmp", "**/.DS_Store"], - excluded_directories=[".git", ".hg", ".bzr", ".svn"], - ), - "cleanup_all": make_cleanup_config( - directories=[".venv*", ".tox", "downloads", "tmp"], - ), - "git_clean": { - "interactive": True, - "force": False, - "path": ".", - "dry_run": False, - }, -}) - - -# -- EXTENSION-POINT: CLEANUP TASKS (called by: clean, clean_all task) -# NOTE: Can be used by other tasklets to register cleanup tasks. -cleanup_tasks = Collection("cleanup_tasks") -cleanup_all_tasks = Collection("cleanup_all_tasks") - -# -- EXTEND NORMAL CLEANUP-TASKS: -# DISABLED: cleanup_tasks.add_task(clean_python) - -# ----------------------------------------------------------------------------- -# EXTENSION-POINT: CONFIGURATION HELPERS: Can be used from other task modules -# ----------------------------------------------------------------------------- -def config_add_cleanup_dirs(directories): - # pylint: disable=protected-access - the_cleanup_directories = namespace._configuration["cleanup"]["directories"] - the_cleanup_directories.extend(directories) - -def config_add_cleanup_files(files): - # pylint: disable=protected-access - the_cleanup_files = namespace._configuration["cleanup"]["files"] - the_cleanup_files.extend(files) - # namespace.configure({"cleanup": {"files": files}}) - # print("DIAG cleanup.config.cleanup: %r" % namespace.configuration()) - -def config_add_cleanup_all_dirs(directories): - # pylint: disable=protected-access - the_cleanup_directories = namespace._configuration["cleanup_all"]["directories"] - the_cleanup_directories.extend(directories) - -def config_add_cleanup_all_files(files): - # pylint: disable=protected-access - the_cleanup_files = namespace._configuration["cleanup_all"]["files"] - the_cleanup_files.extend(files) diff --git a/tasks/py.requirements.txt b/tasks/py.requirements.txt index a02e6e0e2..3990858b1 100644 --- a/tasks/py.requirements.txt +++ b/tasks/py.requirements.txt @@ -21,5 +21,7 @@ path.py >= 11.5.0; python_version < '3.5' pathlib; python_version <= '3.4' backports.shutil_which; python_version <= '3.3' +git+https://github.com/jenisys/invoke-cleanup@v0.3.7 + # -- SECTION: develop requests diff --git a/tasks/release.py b/tasks/release.py index e17a46fc1..f8626f347 100644 --- a/tasks/release.py +++ b/tasks/release.py @@ -51,7 +51,7 @@ from __future__ import absolute_import, print_function from invoke import Collection, task -from .invoke_cleanup import path_glob +from invoke_cleanup import path_glob from ._dry_run import DryRunContext diff --git a/tasks/test.py b/tasks/test.py index d6b4189ee..685e8e6eb 100644 --- a/tasks/test.py +++ b/tasks/test.py @@ -10,7 +10,7 @@ # -- TASK-LIBRARY: # PREPARED: from invoke_cleanup import cleanup_tasks, cleanup_dirs, cleanup_files -from .invoke_cleanup import cleanup_tasks, cleanup_dirs, cleanup_files +from invoke_cleanup import cleanup_tasks, cleanup_dirs, cleanup_files # --------------------------------------------------------------------------- diff --git a/tests/issues/test_issue1054.py b/tests/issues/test_issue1054.py new file mode 100644 index 000000000..adf572706 --- /dev/null +++ b/tests/issues/test_issue1054.py @@ -0,0 +1,41 @@ +""" +SEE: https://github.com/behave/behave/issues/1054 +""" + +from __future__ import absolute_import, print_function +from behave.__main__ import run_behave +from behave.configuration import Configuration +from behave.tag_expression import make_tag_expression +import pytest +from assertpy import assert_that + + +def test_syndrome_with_core(capsys): + """Verifies the problem with the core functionality.""" + cmdline_tags = ["fish or fries", "beer and water"] + tag_expression = make_tag_expression(cmdline_tags) + + tag_expression_text1 = tag_expression.to_string() + tag_expression_text2 = repr(tag_expression) + expected1 = "((fish or fries) and (beer and water))" + expected2 = "And(Or(Literal('fish'), Literal('fries')), And(Literal('beer'), Literal('water')))" + assert tag_expression_text1 == expected1 + assert tag_expression_text2 == expected2 + + +@pytest.mark.parametrize("tags_options", [ + ["--tags", "fish or fries", "--tags", "beer and water"], + # ['--tags="fish or fries"', '--tags="beer and water"'], + # ["--tags='fish or fries'", "--tags='beer and water'"], +]) +def test_syndrome_functional(tags_options, capsys): + """Verifies that the issue is fixed.""" + command_args = tags_options + ["--tags-help", "--verbose"] + config = Configuration(command_args, load_config=False) + run_behave(config) + + captured = capsys.readouterr() + expected_part1 = "CURRENT TAG_EXPRESSION: ((fish or fries) and (beer and water))" + expected_part2 = "means: And(Or(Tag('fish'), Tag('fries')), And(Tag('beer'), Tag('water')))" + assert_that(captured.out).contains(expected_part1) + assert_that(captured.out).contains(expected_part2) From 31d6ffd99c9676db6e3c22b7a2b617bb19aaebc1 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 28 May 2023 17:40:15 +0200 Subject: [PATCH 101/240] FIX: Captured output from "behave --lang-list" * CAUSED BY: gherkin-languages.json / i14n.py update. --- issue.features/issue0309.feature | 1 + 1 file changed, 1 insertion(+) diff --git a/issue.features/issue0309.feature b/issue.features/issue0309.feature index 2bfc548b4..f64848685 100644 --- a/issue.features/issue0309.feature +++ b/issue.features/issue0309.feature @@ -37,6 +37,7 @@ Feature: Issue #309 -- behave --lang-list fails on Python3 ar: العربية / Arabic ast: asturianu / Asturian az: Azərbaycanca / Azerbaijani + be: Беларуская / Belarusian bg: български / Bulgarian bm: Bahasa Melayu / Malay bs: Bosanski / Bosnian From ca371cd97ca0ff94bd1ef2d18269c8b913edc3f5 Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 8 Jun 2023 14:27:48 +0200 Subject: [PATCH 102/240] CLEANUP: behave.matchers * Provide StepMatcherFactory to better keep track of things * Matcher classes: Provide register_type(), ... to better keep track of Matcher specific type-converters. OTHERWISE: * ADDED: behave.api.step_matchers -- Provides public API for step writers * behave._stepimport: Added SimpleStepContainer to simplify reuse HINT: Moved here from tests.api.testing_support module. --- .gitignore | 1 + .pylintrc | 757 +++++++++++++++++++++---------- behave/__init__.py | 15 +- behave/_stepimport.py | 96 ++-- behave/api/step_matchers.py | 32 ++ behave/exception.py | 24 +- behave/matchers.py | 383 ++++++++++++---- behave/runner_util.py | 66 +-- behave/step_registry.py | 4 +- docs/tutorial.rst | 169 +++++-- issue.features/issue0073.feature | 12 +- issue.features/issue0547.feature | 4 +- tests/api/_test_async_step34.py | 34 +- tests/api/_test_async_step35.py | 24 +- tests/api/test_async_step.py | 8 +- tests/api/testing_support.py | 53 --- tests/unit/test_matchers.py | 200 +++++++- tests/unit/test_step_registry.py | 8 +- 18 files changed, 1325 insertions(+), 565 deletions(-) create mode 100644 behave/api/step_matchers.py diff --git a/.gitignore b/.gitignore index 7b08d10ca..d52e7fdb5 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ reports/ tools/virtualenvs .cache/ .direnv/ +.fleet/ .idea/ .pytest_cache/ .tox/ diff --git a/.pylintrc b/.pylintrc index 27334322f..bc571b11a 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,386 +1,667 @@ # ============================================================================= # PYLINT CONFIGURATION # ============================================================================= -# PYLINT-VERSION: 1.5.x +# PYLINT-VERSION: XXX_UPDATE: 1.5.x # SEE ALSO: http://www.pylint.org/ # ============================================================================= -[MASTER] +[MAIN] -# Specify a configuration file. -#rcfile=.pylintrc +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Clear in-memory caches upon conclusion of linting. Useful if running pylint +# in a server-like mode. +clear-cache-post-run=no + +# Load and enable all available extensions. Use --list-extensions to see a list +# all available extensions. +#enable-all-extensions= + +# In error mode, messages with a category besides ERROR or FATAL are +# suppressed, and no reports are done by default. Error mode is compatible with +# disabling specific errors. +#errors-only= + +# Always return a 0 (non-error) status code, even if lint errors are found. +# This is primarily useful in continuous integration scripts. +#exit-zero= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-allow-list= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. (This is an alternative name to extension-pkg-allow-list +# for backward compatibility.) +extension-pkg-whitelist= + +# Return non-zero exit code if any of these messages/categories are detected, +# even if score is above --fail-under value. Syntax same as enable. Messages +# specified are enabled, while categories only check already-enabled messages. +fail-on= + +# Specify a score threshold under which the program will exit with error. +fail-under=10 + +# Interpret the stdin as a python script, whose filename needs to be passed as +# the module_or_package argument. +#from-stdin= + +# Files or directories to be skipped. They should be base names, not paths. +ignore=.git + +# Add files or directories matching the regular expressions patterns to the +# ignore-list. The regex matches against paths and can be in Posix or Windows +# format. Because '\\' represents the directory delimiter on Windows systems, +# it can't be used as an escape character. +ignore-paths= + +# Files or directories matching the regular expression patterns are skipped. +# The regex matches against base names, not paths. The default value ignores +# Emacs file locks +ignore-patterns=^\.# + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis). It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= # Python code to execute, usually for sys.path manipulation such as # pygtk.require(). #init-hook= -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore=.git +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use, and will cap the count on Windows to +# avoid hangs. +jobs=1 -# Pickle collected data for later comparisons. -persistent=yes +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 -# List of plugins (as comma separated values of python modules names) to load, +# List of plugins (as comma separated values of python module names) to load, # usually to register additional checkers. load-plugins= -# Use multiple processes to speed up Pylint. -jobs=1 +# Pickle collected data for later comparisons. +persistent=yes + +# Minimum Python version to use for version dependent checks. Will default to +# the version used to run pylint. +py-version=3.10 + +# Discover python modules and packages in the file system subtree. +recursive=no + +# Add paths to the list of the source roots. Supports globbing patterns. The +# source root is an absolute path or a path relative to the current working +# directory used to determine a package namespace for modules located under the +# source root. +source-roots= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. unsafe-load-any-extension=no -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code -extension-pkg-whitelist= +# In verbose mode, extra non-checker-related info will be displayed. +#verbose= -# Allow optimization of some AST trees. This will activate a peephole AST -# optimizer, which will apply various small optimizations. For instance, it can -# be used to obtain the result of joining multiple strings with the addition -# operator. Joining a lot of strings can lead to a maximum recursion error in -# Pylint and this flag can prevent that. It has one side effect, the resulting -# AST will be different than the one from reality. -optimize-ast=no +[BASIC] -[MESSAGES CONTROL] +# Naming style matching correct argument names. +argument-naming-style=snake_case -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED -confidence= +# Regular expression matching correct argument names. Overrides argument- +# naming-style. If left empty, argument names will be checked with the set +# naming style. +argument-rgx=[a-z_][a-z0-9_]{2,30}$ -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time. See also the "--disable" option for examples. -#enable= +# Naming style matching correct attribute names. +attr-naming-style=snake_case -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once).You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use"--disable=all --enable=classes -# --disable=W" -disable=import-star-module-level,old-octal-literal,oct-method,print-statement,unpacking-in-except,parameter-unpacking,backtick,old-raise-syntax,old-ne-operator,long-suffix,dict-view-method,dict-iter-method,metaclass-assignment,next-method-called,raising-string,indexing-exception,raw_input-builtin,long-builtin,file-builtin,execfile-builtin,coerce-builtin,cmp-builtin,buffer-builtin,basestring-builtin,apply-builtin,filter-builtin-not-iterating,unused-variable,using-cmp-argument,useless-suppression,range-builtin-not-iterating,suppressed-message,missing-docstring,no-absolute-import,old-division,cmp-method,reload-builtin,zip-builtin-not-iterating,intern-builtin,unichr-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,input-builtin,too-few-public-methods,round-builtin,locally-disabled,hex-method,nonzero-method,map-builtin-not-iterating +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. If left empty, attribute names will be checked with the set naming +# style. +attr-rgx=[a-z_][a-z0-9_]{2,30}$ +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata -[REPORTS] +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= -# Set the output format. Available formats are text, parseable, colorized, msvs -# (visual studio) and html. You can also give a reporter class, eg -# mypackage.mymodule.MyReporterClass. -# output-format=text -output-format=colorized +# Naming style matching correct class attribute names. +class-attribute-naming-style=any -# Put messages in a separate file for each module / package specified on the -# command line instead of printing them on stdout. Reports (if any) will be -# written in a file name "pylint_global.[txt|html]". -files-output=no +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. If left empty, class attribute names will be checked +# with the set naming style. +class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,40}|(__.*__))$ -# Tells whether to display a full report or only the messages -reports=yes +# Naming style matching correct class constant names. +class-const-naming-style=UPPER_CASE -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) +# Regular expression matching correct class constant names. Overrides class- +# const-naming-style. If left empty, class constant names will be checked with +# the set naming style. +#class-const-rgx= -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details -#msg-template= +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. If left empty, class names will be checked with the set naming style. +class-rgx=[A-Z_][a-zA-Z0-9]+$ +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE -[BASIC] +# Regular expression matching correct constant names. Overrides const-naming- +# style. If left empty, constant names will be checked with the set naming +# style. +const-rgx=(([a-zA-Z_][a-zA-Z0-9_]*)|(__.*__))$ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. If left empty, function names will be checked with the set +# naming style. +function-rgx=[a-z_][a-z0-9_]{2,40}$ -# List of builtins function names that should not be used, separated by a comma -bad-functions=map,filter,apply,input +# Good variable names which should always be accepted, separated by a comma. +good-names=c, + d, + f, + h, + i, + j, + k, + m, + n, + o, + p, + r, + s, + v, + w, + x, + y, + e, + ex, + kw, + up, + _ + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=yes + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. If left empty, inline iteration names will be checked +# with the set naming style. +inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ + +# Naming style matching correct method names. +method-naming-style=snake_case -# Good variable names which should always be accepted, separated by a comma -good-names=c,d,f,h,i,j,k,m,n,o,p,r,s,v,w,x,y,e,ex,kw,up,_ +# Regular expression matching correct method names. Overrides method-naming- +# style. If left empty, method names will be checked with the set naming style. +method-rgx=[a-z_][a-z0-9_]{2,40}$ -# Bad variable names which should always be refused, separated by a comma -bad-names=foo,bar,baz,toto,tutu,tata +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. If left empty, module names will be checked with the set naming style. +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ # Colon-delimited sets of names that determine each other's naming style when # the name regexes allow several styles. name-group= -# Include a hint for the correct naming format with invalid-name -include-naming-hint=yes +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=__.*__ -# Regular expression matching correct function names -function-rgx=[a-z_][a-z0-9_]{2,40}$ +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Regular expression matching correct type alias names. If left empty, type +# alias names will be checked with the set naming style. +#typealias-rgx= -# Naming hint for function names -function-name-hint=[a-z_][a-z0-9_]{2,40}$ +# Regular expression matching correct type variable names. If left empty, type +# variable names will be checked with the set naming style. +#typevar-rgx= -# Regular expression matching correct variable names +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. If left empty, variable names will be checked with the set +# naming style. variable-rgx=[a-z_][a-z0-9_]{2,40}$ -# Naming hint for variable names -variable-name-hint=[a-z_][a-z0-9_]{2,40}$ -# Regular expression matching correct constant names -const-rgx=(([a-zA-Z_][a-zA-Z0-9_]*)|(__.*__))$ +[CLASSES] -# Naming hint for constant names -const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ +# Warn about protected attribute access inside special methods +check-protected-access-in-special-methods=no -# Regular expression matching correct attribute names -attr-rgx=[a-z_][a-z0-9_]{2,30}$ +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp -# Naming hint for attribute names -attr-name-hint=[a-z_][a-z0-9_]{2,30}$ +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make -# Regular expression matching correct argument names -argument-rgx=[a-z_][a-z0-9_]{2,30}$ +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls -# Naming hint for argument names -argument-name-hint=[a-z_][a-z0-9_]{2,30}$ +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs -# Regular expression matching correct class attribute names -class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,40}|(__.*__))$ -# Naming hint for class attribute names -class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,40}|(__.*__))$ +[DESIGN] -# Regular expression matching correct inline iteration names -inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ +# List of regular expressions of class ancestor names to ignore when counting +# public methods (see R0903) +exclude-too-few-public-methods= -# Naming hint for inline iteration names -inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ +# List of qualified class names to ignore when counting class parents (see +# R0901) +ignored-parents= -# Regular expression matching correct class names -class-rgx=[A-Z_][a-zA-Z0-9]+$ +# Maximum number of arguments for function / method. +max-args=10 -# Naming hint for class names -class-name-hint=[A-Z_][a-zA-Z0-9]+$ +# Maximum number of attributes for a class (see R0902). +max-attributes=10 -# Regular expression matching correct module names -module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 -# Naming hint for module names -module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ +# Maximum number of branch for function / method body. +max-branches=12 -# Regular expression matching correct method names -method-rgx=[a-z_][a-z0-9_]{2,30}$ +# Maximum number of locals for function / method body. +max-locals=15 -# Naming hint for method names -method-name-hint=[a-z_][a-z0-9_]{2,30}$ +# Maximum number of parents for a class (see R0901). +max-parents=7 -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=__.*__ +# Maximum number of public methods for a class (see R0904). +max-public-methods=30 -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 +# Maximum number of return / yield for function / method body. +max-returns=6 +# Maximum number of statements in function / method body. +max-statements=50 -[ELIF] +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 + +[EXCEPTIONS] + +# Exceptions that will emit a warning when caught. +overgeneral-exceptions=builtins.Exception [FORMAT] -# Maximum number of characters on a single line. -max-line-length=85 +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= # Regexp for a line that is allowed to be longer than the limit. ignore-long-lines=^\s*(# )??$ +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + # Allow the body of an if to be on the same line as the test if there is no # else. single-line-if-stmt=no -# List of optional constructs for which whitespace checking is disabled. `dict- -# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. -# `trailing-comma` allows a space between comma and closing bracket: (a, ). -# `empty-line` allows space-only lines. -no-space-check=trailing-comma,dict-separator -# Maximum number of lines in a module -max-module-lines=1000 +[IMPORTS] -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 +# Allow explicit reexports by alias from a package __init__. +allow-reexport-from-package=no -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules=regsub, + string, + TERMIOS, + Bastion, + rexec + +# Output a graph (.gv or any supported image format) of external dependencies +# to the given file (report RP0402 must not be disabled). +ext-import-graph= + +# Output a graph (.gv or any supported image format) of all (i.e. internal and +# external) dependencies to the given file (report RP0402 must not be +# disabled). +import-graph= + +# Output a graph (.gv or any supported image format) of internal dependencies +# to the given file (report RP0402 must not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= [LOGGING] +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + # Logging modules to check that the string format arguments are in logging -# function parameter format +# function parameter format. logging-modules=logging -[MISCELLANEOUS] +[MESSAGES CONTROL] -# List of note tags to take in consideration, separated by a comma. -notes=FIXME,XXX,TODO +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, +# UNDEFINED. +confidence=HIGH, + CONTROL_FLOW, + INFERENCE, + INFERENCE_FAILURE, + UNDEFINED +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then re-enable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + unused-variable, + missing-module-docstring, + missing-class-docstring, + missing-function-docstring, + too-few-public-methods -[SIMILARITIES] +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member -# Minimum lines number of a similarity. -min-similarity-lines=4 -# Ignore comments when computing similarities. -ignore-comments=yes +[METHOD_ARGS] -# Ignore docstrings when computing similarities. -ignore-docstrings=yes +# List of qualified names (i.e., library.method) which require a timeout +# parameter e.g. 'requests.api.get,requests.api.post' +timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request -# Ignore imports when computing similarities. -ignore-imports=no +[MISCELLANEOUS] -[SPELLING] +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO -# Spelling dictionary name. Available dictionaries: none. To make it working -# install python-enchant package. -spelling-dict= +# Regular expression of note tags to take in consideration. +notes-rgx= -# List of comma separated words that should not be checked. -spelling-ignore-words= -# A path to a file that contains private dictionary; one word per line. -spelling-private-dict-file= +[REFACTORING] -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. -spelling-store-unknown-words=no +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit,argparse.parse_error -[TYPECHECK] -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes +[REPORTS] -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis. It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules= +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'fatal', 'error', 'warning', 'refactor', +# 'convention', and 'info' which contain the number of messages in each +# category, as well as 'statement' which is the total number of statements +# analyzed. This score is used by the global evaluation report (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) -# List of classes names for which member attributes should not be checked -# (useful for classes with attributes dynamically set). This supports can work -# with qualified names. -ignored-classes=SQLObject +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +msg-template= -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members=REQUEST,acl_users,aq_parent +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +#output-format= +# Tells whether to display a full report or only the messages. +reports=yes -[VARIABLES] +# Activate the evaluation score. +score=yes -# Tells whether we should check for unused import in __init__ files. -init-import=no -# A regular expression matching the name of dummy variables (i.e. expectedly -# not used). -dummy-variables-rgx=_|dummy|kwargs +[SIMILARITIES] -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid to define new builtins when possible. -additional-builtins= +# Comments are removed from the similarity computation +ignore-comments=yes -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_,_cb +# Docstrings are removed from the similarity computation +ignore-docstrings=yes +# Imports are removed from the similarity computation +ignore-imports=no -[CLASSES] +# Signatures are removed from the similarity computation +ignore-signatures=yes -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__,__new__,setUp +# Minimum lines number of a similarity. +min-similarity-lines=4 -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=mcs +[SPELLING] -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict,_fields,_replace,_source,_make +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 +# Spelling dictionary name. No available dictionaries : You need to install +# both the python package and the system dependency for enchant to work.. +spelling-dict= -[DESIGN] +# List of comma separated words that should be considered directives if they +# appear at the beginning of a comment and should not be checked. +spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: -# Maximum number of arguments for function / method -max-args=10 +# List of comma separated words that should not be checked. +spelling-ignore-words= -# Argument names that match this expression will be ignored. Default to name -# with leading underscore -ignored-argument-names=_.* +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= -# Maximum number of locals for function / method body -max-locals=15 +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no -# Maximum number of return / yield for function / method body -max-returns=6 -# Maximum number of branch for function / method body -max-branches=12 +[STRING] -# Maximum number of statements in function / method body -max-statements=50 +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no -# Maximum number of parents for a class (see R0901). -max-parents=7 +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no -# Maximum number of attributes for a class (see R0902). -max-attributes=10 -# Minimum number of public methods for a class (see R0903). -min-public-methods=2 +[TYPECHECK] -# Maximum number of public methods for a class (see R0904). -max-public-methods=30 +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager -# Maximum number of boolean expressions in a if statement -max-bool-expr=5 +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members=REQUEST, + acl_users, + aq_parent + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of symbolic message names to ignore for Mixin members. +ignored-checks-for-mixins=no-member, + not-async-context-manager, + not-context-manager, + attribute-defined-outside-init + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=SQLObject +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes -[IMPORTS] +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 -# Deprecated modules which should not be used, separated by a comma -deprecated-modules=regsub,string,TERMIOS,Bastion,rexec +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled) -import-graph= +# Regex pattern to define which classes are considered mixins. +mixin-class-rgx=.*[Mm]ixin -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled) -ext-import-graph= +# List of decorators that change the signature of a decorated function. +signature-mutators= -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled) -int-import-graph= +[VARIABLES] -[EXCEPTIONS] +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of names allowed to shadow builtins +allowed-redefined-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_|dummy|kwargs + +# Argument names that match this expression will be ignored. +ignored-argument-names=_.* + +# Tells whether we should check for unused import in __init__ files. +init-import=no -# Exceptions that will emit a warning when being caught. Defaults to -# "Exception" -overgeneral-exceptions=Exception +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io diff --git a/behave/__init__.py b/behave/__init__.py index c913f986e..f00d6350a 100644 --- a/behave/__init__.py +++ b/behave/__init__.py @@ -17,15 +17,22 @@ """ from __future__ import absolute_import -from behave.step_registry import given, when, then, step, Given, When, Then, Step # pylint: disable=no-name-in-module -from behave.matchers import use_step_matcher, step_matcher, register_type +# pylint: disable=no-name-in-module +from behave.step_registry import given, when, then, step, Given, When, Then, Step +# pylint: enable=no-name-in-module +from behave.api.step_matchers import ( + register_type, + use_default_step_matcher, use_step_matcher, + step_matcher +) from behave.fixture import fixture, use_fixture -from behave.version import VERSION as __version__ +from behave.version import VERSION as __version__ # noqa: F401 # pylint: disable=undefined-all-variable __all__ = [ - "given", "when", "then", "step", "use_step_matcher", "register_type", + "given", "when", "then", "step", "Given", "When", "Then", "Step", + "use_default_step_matcher", "use_step_matcher", "register_type", "fixture", "use_fixture", # -- DEPRECATING: "step_matcher" diff --git a/behave/_stepimport.py b/behave/_stepimport.py index 3015639f6..e4372f63c 100644 --- a/behave/_stepimport.py +++ b/behave/_stepimport.py @@ -1,4 +1,6 @@ # -*- coding: UTF-8 -*- +# pylint: disable=useless-object-inheritance +# pylint: disable=super-with-arguments """ This module provides low-level helper functionality during step imports. @@ -15,10 +17,12 @@ from types import ModuleType import os.path import sys -from behave import step_registry as _step_registry -# from behave import matchers as _matchers import six +from behave import step_registry as _step_registry +from behave.matchers import StepMatcherFactory +from behave.step_registry import StepRegistry + # ----------------------------------------------------------------------------- # UTILITY FUNCTIONS: @@ -26,10 +30,22 @@ def setup_api_with_step_decorators(module, step_registry): _step_registry.setup_step_decorators(module, step_registry) -def setup_api_with_matcher_functions(module, matcher_factory): - module.use_step_matcher = matcher_factory.use_step_matcher - module.step_matcher = matcher_factory.use_step_matcher - module.register_type = matcher_factory.register_type +def setup_api_with_matcher_functions(module, step_matcher_factory): + # -- PUBLIC API: Same as behave.api.step_matchers + module.use_default_step_matcher = step_matcher_factory.use_default_step_matcher + module.use_step_matcher = step_matcher_factory.use_step_matcher + module.step_matcher = step_matcher_factory.use_step_matcher + module.register_type = step_matcher_factory.register_type + + +class SimpleStepContainer(object): + def __init__(self, step_registry=None): + if step_registry is None: + step_registry = StepRegistry() + self.step_matcher_factory = StepMatcherFactory() + self.step_registry = step_registry + self.step_registry.step_matcher_factory = self.step_matcher_factory + # ----------------------------------------------------------------------------- # FAKE MODULE CLASSES: For step imports @@ -60,14 +76,20 @@ def __init__(self, step_registry): class StepMatchersModule(FakeModule): - __all__ = ["use_step_matcher", "register_type", "step_matcher"] + __all__ = [ + "use_default_step_matcher", + "use_step_matcher", + "step_matcher", # -- DEPRECATING + "register_type" + ] - def __init__(self, matcher_factory): + def __init__(self, step_matcher_factory): super(StepMatchersModule, self).__init__("behave.matchers") - self.matcher_factory = matcher_factory - setup_api_with_matcher_functions(self, matcher_factory) - self.use_default_step_matcher = matcher_factory.use_default_step_matcher - self.get_matcher = matcher_factory.make_matcher + self.step_matcher_factory = step_matcher_factory + setup_api_with_matcher_functions(self, step_matcher_factory) + self.make_matcher = step_matcher_factory.make_matcher + # -- DEPRECATED-FUNCTION-COMPATIBILITY + # self.get_matcher = self.make_matcher # self.matcher_mapping = matcher_mapping or _matchers.matcher_mapping.copy() # self.current_matcher = current_matcher or _matchers.current_matcher @@ -78,36 +100,19 @@ def __init__(self, matcher_factory): self.__name__ = "behave.matchers" # self.__path__ = [os.path.abspath(here)] - # def use_step_matcher(self, name): - # self.matcher_factory.use_step_matcher(name) - # # self.current_matcher = self.matcher_mapping[name] - # - # def use_default_step_matcher(self, name=None): - # self.matcher_factory.use_default_step_matcher(name=None) - # - # def get_matcher(self, func, pattern): - # # return self.current_matcher - # return self.matcher_factory.make_matcher(func, pattern) - # - # def register_type(self, **kwargs): - # # _matchers.register_type(**kwargs) - # self.matcher_factory.register_type(**kwargs) - # - # step_matcher = use_step_matcher - class BehaveModule(FakeModule): __all__ = StepRegistryModule.__all__ + StepMatchersModule.__all__ - def __init__(self, step_registry, matcher_factory=None): - if matcher_factory is None: - matcher_factory = step_registry.step_matcher_factory - assert matcher_factory is not None + def __init__(self, step_registry, step_matcher_factory=None): + if step_matcher_factory is None: + step_matcher_factory = step_registry.step_step_matcher_factory + assert step_matcher_factory is not None super(BehaveModule, self).__init__("behave") setup_api_with_step_decorators(self, step_registry) - setup_api_with_matcher_functions(self, matcher_factory) - self.use_default_step_matcher = matcher_factory.use_default_step_matcher - assert step_registry.matcher_factory == matcher_factory + setup_api_with_matcher_functions(self, step_matcher_factory) + self.use_default_step_matcher = step_matcher_factory.use_default_step_matcher + assert step_registry.step_matcher_factory == step_matcher_factory # -- INJECT PYTHON PACKAGE META-DATA: # REQUIRED-FOR: Non-fake submodule imports (__path__). @@ -122,13 +127,13 @@ class StepImportModuleContext(object): def __init__(self, step_container): self.step_registry = step_container.step_registry - self.matcher_factory = step_container.matcher_factory - assert self.step_registry.matcher_factory == self.matcher_factory - self.step_registry.matcher_factory = self.matcher_factory + self.step_matcher_factory = step_container.step_matcher_factory + assert self.step_registry.step_matcher_factory == self.step_matcher_factory + self.step_registry.step_matcher_factory = self.step_matcher_factory step_registry_module = StepRegistryModule(self.step_registry) - step_matchers_module = StepMatchersModule(self.matcher_factory) - behave_module = BehaveModule(self.step_registry, self.matcher_factory) + step_matchers_module = StepMatchersModule(self.step_matcher_factory) + behave_module = BehaveModule(self.step_registry, self.step_matcher_factory) self.modules = { "behave": behave_module, "behave.matchers": step_matchers_module, @@ -137,14 +142,16 @@ def __init__(self, step_container): # self.default_matcher = self.step_matchers_module.current_matcher def reset_current_matcher(self): - self.matcher_factory.use_default_step_matcher() + self.step_matcher_factory.use_default_step_matcher() + _step_import_lock = Lock() unknown = object() @contextmanager def use_step_import_modules(step_container): - """Redirect any step/type registration to the runner's step-context object + """ + Redirect any step/type registration to the runner's step-context object during step imports by using fake modules (instead of using module-globals). This allows that multiple runners can be used without polluting the @@ -161,7 +168,8 @@ def load_step_definitions(self, ...): ... import_context.reset_current_matcher() - :param step_container: Step context object with step_registry, matcher_factory. + :param step_container: + Step context object with step_registry, step_matcher_factory. """ orig_modules = {} import_context = StepImportModuleContext(step_container) diff --git a/behave/api/step_matchers.py b/behave/api/step_matchers.py new file mode 100644 index 000000000..4e898bacd --- /dev/null +++ b/behave/api/step_matchers.py @@ -0,0 +1,32 @@ +# -*- coding: UTF-8 -*- +""" +Official API for step writers that want to use step-matchers. +""" + +from __future__ import absolute_import, print_function +import warnings +from behave import matchers as _step_matchers + + +def register_type(**kwargs): + _step_matchers.register_type(**kwargs) + + +def use_default_step_matcher(name=None): + return _step_matchers.use_default_step_matcher(name=name) + +def use_step_matcher(name): + return _step_matchers.use_step_matcher(name) + +def step_matcher(name): + """DEPRECATED, use :func:`use_step_matcher()` instead.""" + # -- BACKWARD-COMPATIBLE NAME: Mark as deprecated. + warnings.warn("deprecated: Use 'use_step_matcher()' instead", + DeprecationWarning, stacklevel=2) + return use_step_matcher(name) + + +# -- REUSE: API function descriptions (aka: docstrings). +register_type.__doc__ = _step_matchers.register_type.__doc__ +use_step_matcher.__doc__ = _step_matchers.use_step_matcher.__doc__ +use_default_step_matcher.__doc__ = _step_matchers.use_default_step_matcher.__doc__ diff --git a/behave/exception.py b/behave/exception.py index ce159dad3..e00201b83 100644 --- a/behave/exception.py +++ b/behave/exception.py @@ -6,21 +6,30 @@ .. versionadded:: 1.2.7 """ -from __future__ import absolute_import +from __future__ import absolute_import, print_function # -- USE MODERN EXCEPTION CLASSES: # COMPATIBILITY: Emulated if not supported yet by Python version. -from behave.compat.exceptions import FileNotFoundError, ModuleNotFoundError - +from behave.compat.exceptions import ( + FileNotFoundError, ModuleNotFoundError # noqa: F401 +) # --------------------------------------------------------------------------- # EXCEPTION/ERROR CLASSES: # --------------------------------------------------------------------------- class ConstraintError(RuntimeError): - """Used if a constraint/precondition is not fulfilled at runtime. + """ + Used if a constraint/precondition is not fulfilled at runtime. .. versionadded:: 1.2.7 """ +class ResourceExistsError(ConstraintError): + """ + Used if you try to register a resource and another exists already + with the same name. + + .. versionadded:: 1.2.7 + """ class ConfigError(Exception): """Used if the configuration is (partially) invalid.""" @@ -62,3 +71,10 @@ class InvalidClassError(TypeError): * not a class * not subclass of a required class """ + +class NotSupportedWarning(Warning): + """ + Used if a certain functionality is not supported. + + .. versionadded:: 1.2.7 + """ diff --git a/behave/matchers.py b/behave/matchers.py index 0fee0c79c..a136d918e 100644 --- a/behave/matchers.py +++ b/behave/matchers.py @@ -1,4 +1,8 @@ -# -*- coding: utf-8 -*- +# -*- coding: UTF-8 -*- +# pylint: disable=redundant-u-string-prefix +# pylint: disable=super-with-arguments +# pylint: disable=consider-using-f-string +# pylint: disable=useless-object-inheritance """ This module provides the step matchers functionality that matches a step definition (as text) with step-functions that implement this step. @@ -6,12 +10,14 @@ from __future__ import absolute_import, print_function, with_statement import copy +import inspect import re import warnings -import parse import six +import parse from parse_type import cfparse from behave._types import ChainedExceptionUtil, ExceptionUtil +from behave.exception import NotSupportedWarning, ResourceExistsError from behave.model_core import Argument, FileLocation, Replayable @@ -155,6 +161,17 @@ class Matcher(object): """ schema = u"@%s('%s')" # Schema used to describe step definition (matcher) + @classmethod + def register_type(cls, **kwargs): + """Register one (or more) user-defined types used for matching types + in step patterns of this matcher. + """ + raise NotImplementedError() + + @classmethod + def clear_registered_types(cls): + raise NotImplementedError() + def __init__(self, func, pattern, step_type=None): self.func = func self.pattern = pattern @@ -225,6 +242,47 @@ class ParseMatcher(Matcher): custom_types = {} parser_class = parse.Parser + @classmethod + def register_type(cls, **kwargs): + r""" + Register one (or more) user-defined types used for matching types + in step patterns of this matcher. + + A type converter should follow :pypi:`parse` module rules. + In general, a type converter is a function that converts text (as string) + into a value-type (type converted value). + + EXAMPLE: + + .. code-block:: python + + from behave import register_type, given + import parse + + + # -- TYPE CONVERTER: For a simple, positive integer number. + @parse.with_pattern(r"\d+") + def parse_number(text): + return int(text) + + # -- REGISTER TYPE-CONVERTER: With behave + register_type(Number=parse_number) + # ALTERNATIVE: + current_step_matcher = use_step_matcher("parse") + current_step_matcher.register_type(Number=parse_number) + + # -- STEP DEFINITIONS: Use type converter. + @given('{amount:Number} vehicles') + def step_impl(context, amount): + assert isinstance(amount, int) + """ + cls.custom_types.update(**kwargs) + + @classmethod + def clear_registered_types(cls): + cls.custom_types.clear() + + def __init__(self, func, pattern, step_type=None): super(ParseMatcher, self).__init__(func, pattern, step_type) self.parser = self.parser_class(pattern, self.custom_types) @@ -260,44 +318,27 @@ class CFParseMatcher(ParseMatcher): parser_class = cfparse.Parser -def register_type(**kw): - r"""Registers a custom type that will be available to "parse" - for type conversion during step matching. - - Converters should be supplied as ``name=callable`` arguments (or as dict). - - A type converter should follow :pypi:`parse` module rules. - In general, a type converter is a function that converts text (as string) - into a value-type (type converted value). - - EXAMPLE: - - .. code-block:: python - - from behave import register_type, given - import parse - - # -- TYPE CONVERTER: For a simple, positive integer number. - @parse.with_pattern(r"\d+") - def parse_number(text): - return int(text) - - # -- REGISTER TYPE-CONVERTER: With behave - register_type(Number=parse_number) +class RegexMatcher(Matcher): + @classmethod + def register_type(cls, **kwargs): + """ + Register one (or more) user-defined types used for matching types + in step patterns of this matcher. - # -- STEP DEFINITIONS: Use type converter. - @given('{amount:Number} vehicles') - def step_impl(context, amount): - assert isinstance(amount, int) - """ - ParseMatcher.custom_types.update(kw) + NOTE: + This functionality is not supported for :class:`RegexMatcher` classes. + """ + raise NotSupportedWarning("%s.register_type" % cls.__name__) + @classmethod + def clear_registered_types(cls): + pass # -- HINT: GRACEFULLY ignored. -class RegexMatcher(Matcher): def __init__(self, func, pattern, step_type=None): super(RegexMatcher, self).__init__(func, pattern, step_type) self.regex = re.compile(self.pattern) + def check_match(self, step): m = self.regex.match(step) if not m: @@ -314,7 +355,8 @@ def check_match(self, step): return args class SimplifiedRegexMatcher(RegexMatcher): - """Simplified regular expression step-matcher that automatically adds + """ + Simplified regular expression step-matcher that automatically adds start-of-line/end-of-line matcher symbols to string: .. code-block:: python @@ -332,7 +374,8 @@ def __init__(self, func, pattern, step_type=None): class CucumberRegexMatcher(RegexMatcher): - """Compatible to (old) Cucumber style regular expressions. + """ + Compatible to (old) Cucumber style regular expressions. Text must contain start-of-line/end-of-line matcher symbols to string: .. code-block:: python @@ -341,79 +384,231 @@ class CucumberRegexMatcher(RegexMatcher): def step_impl(context): pass """ -matcher_mapping = { - "parse": ParseMatcher, - "cfparse": CFParseMatcher, - "re": SimplifiedRegexMatcher, - # -- BACKWARD-COMPATIBLE REGEX MATCHER: Old Cucumber compatible style. - # To make it the default step-matcher use the following snippet: - # # -- FILE: features/environment.py - # from behave import use_step_matcher - # def before_all(context): - # use_step_matcher("re0") - "re0": CucumberRegexMatcher, -} -current_matcher = ParseMatcher # pylint: disable=invalid-name +# ----------------------------------------------------------------------------- +# STEP MATCHER FACTORY (for public API) +# ----------------------------------------------------------------------------- +class StepMatcherFactory(object): + """ + This class provides functionality for the public API of step-matchers. + + It allows to change the step-matcher class in use + while parsing step definitions. + This allows to use multiple step-matcher classes: + + * in the same steps module + * in different step modules + There are several step-matcher classes available in **behave**: -def use_step_matcher(name): - """Change the parameter matcher used in parsing step text. + * **parse** (the default, based on: :pypi:`parse`): + * **cfparse** (extends: :pypi:`parse`, requires: :pypi:`parse_type`) + * **re** (using regular expressions) - The change is immediate and may be performed between step definitions in - your step implementation modules - allowing adjacent steps to use different - matchers if necessary. + You may `define your own step-matcher class`_. - There are several parsers available in *behave* (by default): + .. _`define your own step-matcher class`: api.html#step-parameters - **parse** (the default, based on: :pypi:`parse`) - Provides a simple parser that replaces regular expressions for - step parameters with a readable syntax like ``{param:Type}``. - The syntax is inspired by the Python builtin ``string.format()`` - function. - Step parameters must use the named fields syntax of :pypi:`parse` - in step definitions. The named fields are extracted, - optionally type converted and then used as step function arguments. + parse + ------ - Supports type conversions by using type converters - (see :func:`~behave.register_type()`). + Provides a simple parser that replaces regular expressions for + step parameters with a readable syntax like ``{param:Type}``. + The syntax is inspired by the Python builtin ``string.format()`` function. + Step parameters must use the named fields syntax of :pypi:`parse` + in step definitions. The named fields are extracted, + optionally type converted and then used as step function arguments. - **cfparse** (extends: :pypi:`parse`, requires: :pypi:`parse_type`) - Provides an extended parser with "Cardinality Field" (CF) support. - Automatically creates missing type converters for related cardinality - as long as a type converter for cardinality=1 is provided. - Supports parse expressions like: + Supports type conversions by using type converters + (see :func:`~behave.register_type()`). - * ``{values:Type+}`` (cardinality=1..N, many) - * ``{values:Type*}`` (cardinality=0..N, many0) - * ``{value:Type?}`` (cardinality=0..1, optional) + cfparse + ------- - Supports type conversions (as above). + Provides an extended parser with "Cardinality Field" (CF) support. + Automatically creates missing type converters for related cardinality + as long as a type converter for cardinality=1 is provided. + Supports parse expressions like: - **re** - This uses full regular expressions to parse the clause text. You will - need to use named groups "(?P...)" to define the variables pulled - from the text and passed to your ``step()`` function. + * ``{values:Type+}`` (cardinality=1..N, many) + * ``{values:Type*}`` (cardinality=0..N, many0) + * ``{value:Type?}`` (cardinality=0..1, optional) - Type conversion is **not supported**. - A step function writer may implement type conversion - inside the step function (implementation). + Supports type conversions (as above). - You may `define your own matcher`_. + re (regex based parser) + ----------------------- - .. _`define your own matcher`: api.html#step-parameters - """ - global current_matcher # pylint: disable=global-statement - current_matcher = matcher_mapping[name] + This uses full regular expressions to parse the clause text. You will + need to use named groups "(?P...)" to define the variables pulled + from the text and passed to your ``step()`` function. -def step_matcher(name): + Type conversion is **not supported**. + A step function writer may implement type conversion + inside the step function (implementation). """ - DEPRECATED, use :func:`use_step_matcher()` instead. - """ - # -- BACKWARD-COMPATIBLE NAME: Mark as deprecated. - warnings.warn("deprecated: Use 'use_step_matcher()' instead", - DeprecationWarning, stacklevel=2) - use_step_matcher(name) + MATCHER_MAPPING = { + "parse": ParseMatcher, + "cfparse": CFParseMatcher, + "re": SimplifiedRegexMatcher, + + # -- BACKWARD-COMPATIBLE REGEX MATCHER: Old Cucumber compatible style. + # To make it the default step-matcher use the following snippet: + # # -- FILE: features/environment.py + # from behave import use_step_matcher + # def before_all(context): + # use_step_matcher("re0") + "re0": CucumberRegexMatcher, + } + DEFAULT_MATCHER_NAME = "parse" + + def __init__(self, matcher_mapping=None, default_matcher_name=None): + if matcher_mapping is None: + matcher_mapping = self.MATCHER_MAPPING.copy() + if default_matcher_name is None: + default_matcher_name = self.DEFAULT_MATCHER_NAME + + self.matcher_mapping = matcher_mapping + self.initial_matcher_name = default_matcher_name + self.default_matcher_name = default_matcher_name + self.default_matcher = matcher_mapping[default_matcher_name] + self._current_matcher = self.default_matcher + assert self.default_matcher in self.matcher_mapping.values() + + def reset(self): + self.use_default_step_matcher(self.initial_matcher_name) + self.clear_registered_types() + + @property + def current_matcher(self): + # -- ENSURE: READ-ONLY access + return self._current_matcher + + def register_type(self, **kwargs): + """ + Registers one (or more) custom type that will be available + by some matcher classes, like the :class:`ParseMatcher` and its + derived classes, for type conversion during step matching. + + Converters should be supplied as ``name=callable`` arguments (or as dict). + A type converter should follow the rules of its :class:`Matcher` class. + """ + self.current_matcher.register_type(**kwargs) + + def clear_registered_types(self): + for step_matcher_class in self.matcher_mapping.values(): + step_matcher_class.clear_registered_types() + + def register_step_matcher_class(self, name, step_matcher_class, + override=False): + """Register a new step-matcher class to use. + + :param name: Name of the step-matcher to use. + :param step_matcher_class: Step-matcher class. + :param override: Use ``True`` to override any existing step-matcher class. + """ + assert inspect.isclass(step_matcher_class) + assert issubclass(step_matcher_class, Matcher), "OOPS: %r" % step_matcher_class + known_class = self.matcher_mapping.get(name, None) + if (not override and + known_class is not None and known_class is not step_matcher_class): + message = "ALREADY REGISTERED: {name}={class_name}".format( + name=name, class_name=known_class.__name__) + raise ResourceExistsError(message) + + self.matcher_mapping[name] = step_matcher_class + + def use_step_matcher(self, name): + """ + Changes the step-matcher class to use while parsing step definitions. + This allows to use multiple step-matcher classes: + + * in the same steps module + * in different step modules + + There are several step-matcher classes available in **behave**: + + * **parse** (the default, based on: :pypi:`parse`): + * **cfparse** (extends: :pypi:`parse`, requires: :pypi:`parse_type`) + * **re** (using regular expressions) + + :param name: Name of the step-matcher class. + :return: Current step-matcher class that is now in use. + """ + self._current_matcher = self.matcher_mapping[name] + return self._current_matcher + + def use_default_step_matcher(self, name=None): + """Use the default step-matcher. + If a :param:`name` is provided, the default step-matcher is defined. + + :param name: Optional, use it to specify the default step-matcher. + :return: Current step-matcher class (or object). + """ + if name: + self.default_matcher = self.matcher_mapping[name] + self.default_matcher_name = name + self._current_matcher = self.default_matcher + return self._current_matcher + + def use_current_step_matcher_as_default(self): + self.default_matcher = self._current_matcher + + def make_matcher(self, func, step_text, step_type=None): + return self.current_matcher(func, step_text, step_type=step_type) + + +# -- MODULE INSTANCE: +_the_matcher_factory = StepMatcherFactory() + + +# ----------------------------------------------------------------------------- +# INTERNAL API FUNCTIONS: +# ----------------------------------------------------------------------------- +def get_matcher_factory(): + return _the_matcher_factory + + +def make_matcher(func, step_text, step_type=None): + return _the_matcher_factory.make_matcher(func, step_text, + step_type=step_type) + + +def use_current_step_matcher_as_default(): + return _the_matcher_factory.use_current_step_matcher_as_default() + + + +# ----------------------------------------------------------------------------- +# PUBLIC API FOR: step-writers +# ----------------------------------------------------------------------------- +def use_step_matcher(name): + return _the_matcher_factory.use_step_matcher(name) + + +def use_default_step_matcher(name=None): + return _the_matcher_factory.use_default_step_matcher(name=name) + + +def register_type(**kwargs): + _the_matcher_factory.register_type(**kwargs) + + +# -- REUSE DOCSTRINGS: +register_type.__doc__ = StepMatcherFactory.register_type.__doc__ +use_step_matcher.__doc__ = StepMatcherFactory.use_step_matcher.__doc__ +use_default_step_matcher.__doc__ = ( + StepMatcherFactory.use_default_step_matcher.__doc__) + + +# ----------------------------------------------------------------------------- +# BEHAVE EXTENSION-POINT: Add your own step-matcher class(es) +# ----------------------------------------------------------------------------- +def register_step_matcher_class(name, step_matcher_class, override=False): + _the_matcher_factory.register_step_matcher_class(name, step_matcher_class, + override=override) + -def get_matcher(func, pattern): - return current_matcher(func, pattern) +# -- REUSE DOCSTRINGS: +register_step_matcher_class.__doc__ = ( + StepMatcherFactory.register_step_matcher_class.__doc__) diff --git a/behave/runner_util.py b/behave/runner_util.py index bb51418e5..9b33d86fe 100644 --- a/behave/runner_util.py +++ b/behave/runner_util.py @@ -1,19 +1,29 @@ -# -*- coding: utf-8 -*- +# -*- coding: UTF-8 -*- +# pylint: disable=redundant-u-string-prefix +# pylint: disable=consider-using-f-string +# pylint: disable=useless-object-inheritance """ Contains utility functions and classes for Runners. """ -from __future__ import absolute_import +from __future__ import absolute_import, print_function from bisect import bisect +from collections import OrderedDict import glob import os.path import re import sys from six import string_types + from behave import parser -from behave.exception import \ - FileNotFoundError, InvalidFileLocationError, InvalidFilenameError +# pylint: disable=redefined-builtin +from behave.exception import ( + FileNotFoundError, + InvalidFileLocationError, InvalidFilenameError +) +# pylint: enable=redefined-builtin from behave.model_core import FileLocation +from behave.model import Feature, Rule, ScenarioOutline, Scenario from behave.textutil import ensure_stream_with_encoder # LAZY: from behave.step_registry import setup_step_decorators @@ -45,10 +55,6 @@ def parse(cls, text): # ----------------------------------------------------------------------------- # CLASSES: # ----------------------------------------------------------------------------- -from collections import OrderedDict -from .model import Feature, Rule, ScenarioOutline, Scenario - - class FeatureLineDatabase(object): """Helper class that supports select-by-location mechanism (FileLocation) within a feature file by storing the feature line numbers for each entity. @@ -70,7 +76,8 @@ def __init__(self, entity=None, line_data=None): def select_run_item_by_line(self, line): """Select one run-items by using the line number. - * Exact match returns run-time entity (Feature, Rule, ScenarioOutline, Scenario) + * Exact match returns run-time entity: + Feature, Rule, ScenarioOutline, Scenario * Any other line in between uses the predecessor entity :param line: Line number in Feature file (as int) @@ -84,8 +91,7 @@ def select_run_item_by_line(self, line): self._line_entities = list(self.data.values()) pos = bisect(self._line_numbers, line) - 1 - if pos < 0: - pos = 0 + pos = max(pos, 0) run_item = self._line_entities[pos] return run_item @@ -207,8 +213,7 @@ def select_scenario_line_for(line, scenario_lines): if not scenario_lines: return 0 # -- Select all scenarios. pos = bisect(scenario_lines, line) - 1 - if pos < 0: - pos = 0 + pos = max(pos, 0) return scenario_lines[pos] def discover_selected_scenarios(self, strict=False): @@ -297,8 +302,7 @@ def select_scenario_line_for(line, scenario_lines): if not scenario_lines: return 0 # -- Select all scenarios. pos = bisect(scenario_lines, line) - 1 - if pos < 0: - pos = 0 + pos = max(pos, 0) return scenario_lines[pos] def discover_selected_scenarios(self, strict=False): @@ -396,7 +400,7 @@ def parse(text, here=None): filename = line.strip() if not filename: continue # SKIP: Over empty line(s). - elif filename.startswith('#'): + if filename.startswith('#'): continue # SKIP: Over comment line(s). if here and not os.path.isabs(filename): @@ -425,10 +429,10 @@ def parse_file(cls, filename): if not os.path.isfile(filename): raise FileNotFoundError(filename) here = os.path.dirname(filename) or "." - # -- MAYBE BETTER: - # contents = codecs.open(filename, "utf-8").read() - contents = open(filename).read() - return cls.parse(contents, here) + # MAYBE: with codecs.open(filename, encoding="UTF-8") as f: + with open(filename) as f: + contents = f.read() + return cls.parse(contents, here) class PathManager(object): @@ -483,7 +487,7 @@ def parse_features(feature_files, language=None): if location.filename == scenario_collector.filename: scenario_collector.add_location(location) continue - elif scenario_collector.feature: + if scenario_collector.feature: # -- NEW FEATURE DETECTED: Add current feature. current_feature = scenario_collector.build_feature() features.append(current_feature) @@ -535,7 +539,7 @@ def collect_feature_locations(paths, strict=True): location = FileLocationParser.parse(path) if not location.filename.endswith(".feature"): raise InvalidFilenameError(location.filename) - elif location.exists(): + if location.exists(): locations.append(location) elif strict: raise FileNotFoundError(path) @@ -562,18 +566,21 @@ def exec_file(filename, globals_=None, locals_=None): def load_step_modules(step_paths): """Load step modules with step definitions from step_paths directories.""" - from behave import matchers + # pylint: disable=import-outside-toplevel + from behave.api.step_matchers import use_step_matcher, use_default_step_matcher + from behave.api.step_matchers import step_matcher + from behave.matchers import use_current_step_matcher_as_default from behave.step_registry import setup_step_decorators step_globals = { - "use_step_matcher": matchers.use_step_matcher, - "step_matcher": matchers.step_matcher, # -- DEPRECATING + "use_step_matcher": use_step_matcher, + "step_matcher": step_matcher, # -- DEPRECATING } setup_step_decorators(step_globals) # -- Allow steps to import other stuff from the steps dir # NOTE: Default matcher can be overridden in "environment.py" hook. with PathManager(step_paths): - default_matcher = matchers.current_matcher + use_current_step_matcher_as_default() for path in step_paths: for name in sorted(os.listdir(path)): if name.endswith(".py"): @@ -584,7 +591,7 @@ def load_step_modules(step_paths): # try: step_module_globals = step_globals.copy() exec_file(os.path.join(path, name), step_module_globals) - matchers.current_matcher = default_matcher + use_default_step_matcher() def make_undefined_step_snippet(step, language=None): @@ -654,6 +661,7 @@ def print_undefined_step_snippets(undefined_steps, stream=None, colored=True): if colored: # -- OOPS: Unclear if stream supports ANSI coloring. + # pylint: disable=import-outside-toplevel from behave.formatter.ansi_escapes import escapes msg = escapes['undefined'] + msg + escapes['reset'] @@ -665,11 +673,11 @@ def reset_runtime(): """Reset runtime environment. Best effort to reset module data to initial state. """ + # pylint: disable=import-outside-toplevel from behave import step_registry from behave import matchers # -- RESET 1: behave.step_registry step_registry.registry = step_registry.StepRegistry() step_registry.setup_step_decorators(None, step_registry.registry) # -- RESET 2: behave.matchers - matchers.ParseMatcher.custom_types = {} - matchers.current_matcher = matchers.ParseMatcher + matchers.get_matcher_factory().reset() diff --git a/behave/step_registry.py b/behave/step_registry.py index b235711b9..41bfd672e 100644 --- a/behave/step_registry.py +++ b/behave/step_registry.py @@ -6,7 +6,7 @@ """ from __future__ import absolute_import -from behave.matchers import Match, get_matcher +from behave.matchers import Match, make_matcher from behave.textutil import text as _text # limit import * to just the decorators @@ -56,7 +56,7 @@ def add_step_definition(self, keyword, step_text, func): existing_step = existing.describe() existing_step += u" at %s" % existing.location raise AmbiguousStep(message % (new_step, existing_step)) - step_definitions.append(get_matcher(func, step_text)) + step_definitions.append(make_matcher(func, step_text)) def find_step_definition(self, step): candidates = self.steps[step.step_type] diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 27698a4b8..355b6f6db 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -281,11 +281,11 @@ the preceding step's keyword (so an "and" following a "given" will become a .. note:: - Step function names do not need to have a unique symbol name, because the - text matching selects the step function from the step registry before it is - called as anonymous function. Hence, when *behave* prints out the missing - step implementations in a test run, it uses "step_impl" for all functions - by default. + Step function names do not need to have a unique symbol name, because the + text matching selects the step function from the step registry before it is + called as anonymous function. Hence, when *behave* prints out the missing + step implementations in a test run, it uses "step_impl" for all functions + by default. If you find you'd like your step implementation to invoke another step you may do so with the :class:`~behave.runner.Context` method @@ -307,77 +307,152 @@ the other two steps as though they had also appeared in the scenario file. .. _docid.tutorial.step-parameters: +.. _`step parameters`: Step Parameters --------------- -You may find that your feature steps sometimes include very common phrases -with only some variation. For example: +Steps sometimes include very common phrases with only one variation +(one word is different or some words are different). +For example: .. code-block:: gherkin - Scenario: look up a book - Given I search for a valid book - Then the result page will include "success" + # -- FILE: features/example_step_parameters.feature + Scenario: look up a book + Given I search for a valid book + Then the result page will include "success" - Scenario: look up an invalid book - Given I search for a invalid book - Then the result page will include "failure" + Scenario: look up an invalid book + Given I search for a invalid book + Then the result page will include "failure" -You may define a single Python step that handles both of those Then -clauses (with a Given step that puts some text into -``context.response``): +You can define one Python step-definition that handles both cases by using `step parameters`_ . +In this case, the *Then* step verifies the ``context.response`` parameter +that was stored in the ``context`` by the *Given* step: .. code-block:: python + # -- FILE: features/steps/example_steps_with_step_parameters.py + # HINT: Step-matcher "parse" is the DEFAULT step-matcher class. + from behave import then + @then('the result page will include "{text}"') def step_impl(context, text): if text not in context.response: fail('%r not in %r' % (text, context.response)) -There are several parsers available in *behave* (by default): +There are several step-matcher classes available in **behave** +that can be used for `step parameters`_. +You can select another step-matcher class by using +the :func:`behave.use_step_matcher()` function: + +.. code-block:: python + + # -- FILE: features/steps/example_use_step_matcher_in_steps.py + # HINTS: + # * "parse" in the DEFAULT step-matcher + # * Use "use_step_matcher(...)" in "features/environment.py" file + # to define your own own default step-matcher. + from behave import given, when, use_step_matcher + + use_step_matcher("cfparse") + + @given('some event named "{event_name}" happens') + def step_given_some_event_named_happens(context, event_name): + pass # ... DETAILS LEFT OUT HERE. + + use_step_matcher("re") + + @when('a person named "(?P...)" enters the room') + def step_when_person_enters_room(context, name): + pass # ... DETAILS LEFT OUT HERE. + + +Step-matchers +-------------- + +There are several step-matcher classes available in **behave** +that can be used for parsing `step parameters`_: + +* **parse** (default step-matcher class, based on: :pypi:`parse`): +* **cfparse** (extends: :pypi:`parse`, requires: :pypi:`parse_type`): +* **re** (step-matcher class is based on regular expressions): + + +Step-matcher: parse +~~~~~~~~~~~~~~~~~~~ + +This step-matcher class provides a parser based on: :pypi:`parse` module. + +It provides a simple parser that replaces regular expressions +for step parameters with a readable syntax like ``{param:Type}``. -**parse** (the default, based on: :pypi:`parse`) - Provides a simple parser that replaces regular expressions for step parameters - with a readable syntax like ``{param:Type}``. - The syntax is inspired by the Python builtin ``string.format()`` function. - Step parameters must use the named fields syntax of :pypi:`parse` - in step definitions. The named fields are extracted, - optionally type converted and then used as step function arguments. +The syntax is inspired by the Python builtin ``string.format()`` function. +Step parameters must use the named fields syntax of :pypi:`parse` +in step definitions. The named fields are extracted, +optionally type converted and then used as step function arguments. - Supports type conversions by using type converters - (see :func:`~behave.register_type()`). +FEATURES: -**cfparse** (extends: :pypi:`parse`, requires: :pypi:`parse_type`) - Provides an extended parser with "Cardinality Field" (CF) support. - Automatically creates missing type converters for related cardinality - as long as a type converter for cardinality=1 is provided. - Supports parse expressions like: +* Supports named step parameters (and unnamed step parameters) +* Supports **type conversions** by using type converters + (see :func:`~behave.register_type()`). - * ``{values:Type+}`` (cardinality=1..N, many) - * ``{values:Type*}`` (cardinality=0..N, many0) - * ``{value:Type?}`` (cardinality=0..1, optional). - Supports type conversions (as above). +Step-matcher: cfparse +~~~~~~~~~~~~~~~~~~~~~ -**re** - This uses full regular expressions to parse the clause text. You will - need to use named groups "(?P...)" to define the variables pulled - from the text and passed to your ``step()`` function. +This step-matcher class extends the ``parse`` step-matcher +and provides an extended parser with "Cardinality Field" (CF) support. + +It automatically creates missing type converters for other cardinalities +as long as a type converter for cardinality=1 is provided. + +It supports parse expressions like: + +* ``{values:Type+}`` (cardinality=1..N, many) +* ``{values:Type*}`` (cardinality=0..N, many0) +* ``{value:Type?}`` (cardinality=0..1, optional). + +FEATURES: + +* Supports named step parameters (and unnamed step parameters) +* Supports **type conversions** by using type converters + (see :func:`~behave.register_type()`). + + + +Step-matcher: re +~~~~~~~~~~~~~~~~~~~~~ + +This step-matcher provides step-matcher class is based on regular expressions. +It uses full regular expressions to parse the clause text. +You will need to use named groups "(?P...)" to define the variables pulled +from the text and passed to your ``step()`` function. + +.. hint:: Type conversion is **not supported**. - Type conversion is **not supported**. A step function writer may implement type conversion inside the step function (implementation). -To specify which parser to use invoke :func:`~behave.use_step_matcher` -with the name of the matcher to use. You may change matcher to suit -specific step functions - the last call to ``use_step_matcher`` before a step -function declaration will be the one it uses. -.. note:: +To specify which parser to use, +call the :func:`~behave.use_step_matcher()` function with the name +of the step-matcher class to use. + +You can change the step-matcher class at any time to suit your needs. +The following step-definitions use the current step-matcher class. + +FEATURES: + +* Supports named step parameters (and unnamed step parameters) +* Supports no type conversions + +VARIANTS: - The function :func:`~behave.matchers.step_matcher()` is becoming deprecated. - Use :func:`~behave.use_step_matcher()` instead. +* ``"re0"``: Provides a regex matcher that is compatible with ``cucumber`` + (regex based step-matcher). Context diff --git a/issue.features/issue0073.feature b/issue.features/issue0073.feature index d61939c27..86d70cccd 100644 --- a/issue.features/issue0073.feature +++ b/issue.features/issue0073.feature @@ -45,8 +45,8 @@ Feature: Issue #73: the current_matcher is not predictable Given a new working directory And a file named "features/environment.py" with: """ - from behave import use_step_matcher - use_step_matcher("re") + from behave import use_default_step_matcher + use_default_step_matcher("re") """ And a file named "features/steps/regexp_steps.py" with: """ @@ -76,8 +76,8 @@ Feature: Issue #73: the current_matcher is not predictable Given a new working directory And a file named "features/environment.py" with: """ - from behave import use_step_matcher - use_step_matcher("re") + from behave import use_default_step_matcher + use_default_step_matcher("re") """ And a file named "features/steps/eparse_steps.py" with: """ @@ -125,8 +125,8 @@ Feature: Issue #73: the current_matcher is not predictable Given a new working directory And a file named "features/environment.py" with: """ - from behave import use_step_matcher - use_step_matcher("re") + from behave import use_default_step_matcher + use_default_step_matcher("re") """ And a file named "features/steps/given_steps.py" with: """ diff --git a/issue.features/issue0547.feature b/issue.features/issue0547.feature index 69f79c3b2..e02d4e0d6 100644 --- a/issue.features/issue0547.feature +++ b/issue.features/issue0547.feature @@ -7,14 +7,14 @@ Feature: Issue 547 -- behave crashes when adding a step definition with optional Given a new working directory And a file named "features/environment.py" with: """ - from behave import register_type, use_step_matcher + from behave import register_type, use_default_step_matcher import parse @parse.with_pattern(r"optional\s+") def parse_optional_word(text): return text.strip() - use_step_matcher("cfparse") + use_default_step_matcher("cfparse") register_type(opt_=parse_optional_word) """ And a file named "features/steps/steps.py" with: diff --git a/tests/api/_test_async_step34.py b/tests/api/_test_async_step34.py index 30db3f535..abf124fa8 100644 --- a/tests/api/_test_async_step34.py +++ b/tests/api/_test_async_step34.py @@ -1,4 +1,5 @@ # -*- coding: UTF-8 -*- +# pylint: disable=invalid-name """ Unit tests for :mod:`behave.api.async_test`. """ @@ -6,14 +7,15 @@ # -- IMPORTS: from __future__ import absolute_import, print_function import sys -from behave.api.async_step import AsyncContext, use_or_create_async_context -from behave._stepimport import use_step_import_modules -from behave.runner import Context, Runner +from unittest.mock import Mock from hamcrest import assert_that, close_to -from mock import Mock import pytest -from .testing_support import StopWatch, SimpleStepContainer +from behave.api.async_step import AsyncContext, use_or_create_async_context +from behave._stepimport import use_step_import_modules, SimpleStepContainer +from behave.runner import Context, Runner + +from .testing_support import StopWatch from .testing_support_async import AsyncStepTheory @@ -42,7 +44,8 @@ PYTHON_3_5 = (3, 5) PYTHON_3_8 = (3, 8) python_version = sys.version_info[:2] -requires_py34_to_py37 = pytest.mark.skipif(not (PYTHON_3_5 <= python_version < PYTHON_3_8), +requires_py34_to_py37 = pytest.mark.skipif( + not (PYTHON_3_5 <= python_version < PYTHON_3_8), reason="Supported only for python.versions: 3.4 .. 3.7 (inclusive)") @@ -55,13 +58,14 @@ # TESTSUITE: # ----------------------------------------------------------------------------- @requires_py34_to_py37 -class TestAsyncStepDecoratorPy34(object): +class TestAsyncStepDecoratorPy34: def test_step_decorator_async_run_until_complete2(self): step_container = SimpleStepContainer() with use_step_import_modules(step_container): # -- STEP-DEFINITIONS EXAMPLE (as MODULE SNIPPET): # VARIANT 2: Use @asyncio.coroutine def step_impl() + # pylint: disable=import-outside-toplevel, unused-argument from behave import step from behave.api.async_step import async_run_until_complete import asyncio @@ -72,6 +76,7 @@ def test_step_decorator_async_run_until_complete2(self): def step_async_step_waits_seconds2(context, duration): yield from asyncio.sleep(duration) + # pylint: enable=import-outside-toplevel, unused-argument # -- USES: async def step_impl(...) as async-step (coroutine) AsyncStepTheory.validate(step_async_step_waits_seconds2) @@ -85,13 +90,14 @@ def step_async_step_waits_seconds2(context, duration): assert_that(stop_watch.duration, close_to(0.2, delta=SLEEP_DELTA)) -class TestAsyncContext(object): +class TestAsyncContext: @staticmethod def make_context(): return Context(runner=Runner(config={})) def test_use_or_create_async_context__when_missing(self): # -- CASE: AsynContext attribute is created with default_name + # pylint: disable=protected-access context = self.make_context() context._push() @@ -142,7 +148,7 @@ def test_use_or_create_async_context__when_exists_with_name(self): @requires_py34_to_py37 -class TestAsyncStepRunPy34(object): +class TestAsyncStepRunPy34: """Ensure that execution of async-steps works as expected.""" def test_async_step_passes(self): @@ -151,6 +157,7 @@ def test_async_step_passes(self): with use_step_import_modules(step_container): # -- STEP-DEFINITIONS EXAMPLE (as MODULE SNIPPET): # VARIANT 1: Use async def step_impl() + # pylint: disable=import-outside-toplevel, unused-argument from behave import given, when from behave.api.async_step import async_run_until_complete import asyncio @@ -167,7 +174,9 @@ def given_async_step_passes(context): def when_async_step_passes(context): context.traced_steps.append("async-step2") - # -- RUN ASYNC-STEP: Verify that async-steps can be execution without problems. + # pylint: enable=import-outside-toplevel, unused-argument + # -- RUN ASYNC-STEP: + # Verify that async-steps can be execution without problems. context = Context(runner=Runner(config={})) context.traced_steps = [] given_async_step_passes(context) @@ -181,6 +190,7 @@ def test_async_step_fails(self): with use_step_import_modules(step_container): # -- STEP-DEFINITIONS EXAMPLE (as MODULE SNIPPET): # VARIANT 1: Use async def step_impl() + # pylint: disable=import-outside-toplevel, unused-argument from behave import when from behave.api.async_step import async_run_until_complete import asyncio @@ -191,6 +201,7 @@ def test_async_step_fails(self): def when_async_step_fails(context): assert False, "XFAIL in async-step" + # pylint: enable=import-outside-toplevel, unused-argument # -- RUN ASYNC-STEP: Verify that AssertionError is detected. context = Context(runner=Runner(config={})) with pytest.raises(AssertionError): @@ -202,6 +213,7 @@ def test_async_step_raises_exception(self): with use_step_import_modules(step_container): # -- STEP-DEFINITIONS EXAMPLE (as MODULE SNIPPET): # VARIANT 1: Use async def step_impl() + # pylint: disable=import-outside-toplevel, unused-argument from behave import when from behave.api.async_step import async_run_until_complete import asyncio @@ -210,8 +222,10 @@ def test_async_step_raises_exception(self): @async_run_until_complete @asyncio.coroutine def when_async_step_raises_exception(context): + # pylint: disable=pointless-statement 1 / 0 # XFAIL-HERE: Raises ZeroDivisionError + # pylint: enable=import-outside-toplevel, unused-argument # -- RUN ASYNC-STEP: Verify that raised exeception is detected. context = Context(runner=Runner(config={})) with pytest.raises(ZeroDivisionError): diff --git a/tests/api/_test_async_step35.py b/tests/api/_test_async_step35.py index 7f9219e4e..dc07dc41d 100644 --- a/tests/api/_test_async_step35.py +++ b/tests/api/_test_async_step35.py @@ -1,4 +1,5 @@ # -*- coding: UTF-8 -*- +# pylint: disable=invalid-name """ Unit tests for :mod:`behave.api.async_test` for Python 3.5 (or newer). """ @@ -7,11 +8,11 @@ from __future__ import absolute_import, print_function import sys from hamcrest import assert_that, close_to -from behave._stepimport import use_step_import_modules -from behave.runner import Context, Runner import pytest -from .testing_support import StopWatch, SimpleStepContainer +from behave._stepimport import use_step_import_modules, SimpleStepContainer +from behave.runner import Context, Runner +from .testing_support import StopWatch from .testing_support_async import AsyncStepTheory @@ -38,7 +39,8 @@ # ----------------------------------------------------------------------------- PYTHON_3_5 = (3, 5) python_version = sys.version_info[:2] -py35_or_newer = pytest.mark.skipif(python_version < PYTHON_3_5, reason="Needs Python >= 3.5") +py35_or_newer = pytest.mark.skipif(python_version < PYTHON_3_5, + reason="Needs Python >= 3.5") SLEEP_DELTA = 0.050 if sys.platform.startswith("win"): @@ -49,13 +51,14 @@ # TESTSUITE: # ----------------------------------------------------------------------------- @py35_or_newer -class TestAsyncStepDecoratorPy35(object): +class TestAsyncStepDecoratorPy35: def test_step_decorator_async_run_until_complete1(self): step_container = SimpleStepContainer() with use_step_import_modules(step_container): # -- STEP-DEFINITIONS EXAMPLE (as MODULE SNIPPET): # VARIANT 1: Use async def step_impl() + # pylint: disable=import-outside-toplevel, unused-argument from behave import step from behave.api.async_step import async_run_until_complete import asyncio @@ -65,6 +68,7 @@ def test_step_decorator_async_run_until_complete1(self): async def step_async_step_waits_seconds(context, duration): await asyncio.sleep(duration) + # pylint: enable=import-outside-toplevel, unused-argument # -- USES: async def step_impl(...) as async-step (coroutine) AsyncStepTheory.validate(step_async_step_waits_seconds) @@ -78,7 +82,7 @@ async def step_async_step_waits_seconds(context, duration): @py35_or_newer -class TestAsyncStepRunPy35(object): +class TestAsyncStepRunPy35: """Ensure that execution of async-steps works as expected.""" def test_async_step_passes(self): @@ -87,6 +91,7 @@ def test_async_step_passes(self): with use_step_import_modules(step_container): # -- STEP-DEFINITIONS EXAMPLE (as MODULE SNIPPET): # VARIANT 1: Use async def step_impl() + # pylint: disable=import-outside-toplevel, unused-argument from behave import given, when from behave.api.async_step import async_run_until_complete @@ -100,7 +105,7 @@ async def given_async_step_passes(context): async def when_async_step_passes(context): context.traced_steps.append("async-step2") - + # pylint: enable=import-outside-toplevel, unused-argument # -- RUN ASYNC-STEP: Verify that async-steps can be executed. context = Context(runner=Runner(config={})) context.traced_steps = [] @@ -115,6 +120,7 @@ def test_async_step_fails(self): with use_step_import_modules(step_container): # -- STEP-DEFINITIONS EXAMPLE (as MODULE SNIPPET): # VARIANT 1: Use async def step_impl() + # pylint: disable=import-outside-toplevel, unused-argument from behave import when from behave.api.async_step import async_run_until_complete @@ -123,6 +129,7 @@ def test_async_step_fails(self): async def when_async_step_fails(context): assert False, "XFAIL in async-step" + # pylint: enable=import-outside-toplevel, unused-argument # -- RUN ASYNC-STEP: Verify that AssertionError is detected. context = Context(runner=Runner(config={})) with pytest.raises(AssertionError): @@ -135,14 +142,17 @@ def test_async_step_raises_exception(self): with use_step_import_modules(step_container): # -- STEP-DEFINITIONS EXAMPLE (as MODULE SNIPPET): # VARIANT 1: Use async def step_impl() + # pylint: disable=import-outside-toplevel, unused-argument from behave import when from behave.api.async_step import async_run_until_complete @when('an async-step raises exception') @async_run_until_complete async def when_async_step_raises_exception(context): + # pylint: disable=pointless-statement 1 / 0 # XFAIL-HERE: Raises ZeroDivisionError + # pylint: enable=import-outside-toplevel, unused-argument # -- RUN ASYNC-STEP: Verify that raised exception is detected. context = Context(runner=Runner(config={})) with pytest.raises(ZeroDivisionError): diff --git a/tests/api/test_async_step.py b/tests/api/test_async_step.py index 1b82d4a4d..a45698936 100644 --- a/tests/api/test_async_step.py +++ b/tests/api/test_async_step.py @@ -1,4 +1,5 @@ # -*- coding: UTF-8 -*- +# pylint: disable=wildcard-import,unused-wildcard-import """ Unit test facade to protect pytest runner from Python 3.4/3.5 grammar changes. @@ -15,10 +16,11 @@ if _python_version >= (3, 4): # -- PROTECTED-IMPORT: # Older Python version have problems with grammer extensions (yield-from). - # from ._test_async_step34 import TestAsyncStepDecorator34, TestAsyncContext, TestAsyncStepRun34 - from ._test_async_step34 import * + # from ._test_async_step34 import TestAsyncStepDecorator34 + # from ._test_async_step34 import TestAsyncContext, TestAsyncStepRun34 + from ._test_async_step34 import * # noqa: F403 if _python_version >= (3, 5): # -- PROTECTED-IMPORT: # Older Python version have problems with grammer extensions (async/await). # from ._test_async_step35 import TestAsyncStepDecorator35, TestAsyncStepRun35 - from ._test_async_step35 import * + from ._test_async_step35 import * # noqa: F403 diff --git a/tests/api/testing_support.py b/tests/api/testing_support.py index 585c53879..e220b0a03 100644 --- a/tests/api/testing_support.py +++ b/tests/api/testing_support.py @@ -5,8 +5,6 @@ # -- IMPORTS: from __future__ import absolute_import -from behave.step_registry import StepRegistry -from behave.matchers import ParseMatcher, CFParseMatcher, RegexMatcher import time @@ -41,54 +39,3 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, exc_tb): self.stop() -# -- NEEDED-UNTIL: stepimport functionality is completly provided. -class MatcherFactory(object): - matcher_mapping = { - "parse": ParseMatcher, - "cfparse": CFParseMatcher, - "re": RegexMatcher, - } - default_matcher = ParseMatcher - - def __init__(self, matcher_mapping=None, default_matcher=None): - self.matcher_mapping = matcher_mapping or self.matcher_mapping - self.default_matcher = default_matcher or self.default_matcher - self.current_matcher = self.default_matcher - self.type_registry = {} - # self.type_registry = ParseMatcher.custom_types.copy() - - def register_type(self, **kwargs): - self.type_registry.update(**kwargs) - - def use_step_matcher(self, name): - self.current_matcher = self.matcher_mapping[name] - - def use_default_step_matcher(self, name=None): - if name: - self.default_matcher = self.matcher_mapping[name] - self.current_matcher = self.default_matcher - - def make_matcher(self, func, step_text, step_type=None): - return self.current_matcher(func, step_text, step_type=step_type, - custom_types=self.type_registry) - - def step_matcher(self, name): - """ - DEPRECATED, use :method:`~MatcherFactory.use_step_matcher()` instead. - """ - # -- BACKWARD-COMPATIBLE NAME: Mark as deprecated. - import warnings - warnings.warn("Use 'use_step_matcher()' instead", - PendingDeprecationWarning, stacklevel=2) - self.use_step_matcher(name) - - -class SimpleStepContainer(object): - def __init__(self, step_registry=None): - if step_registry is None: - step_registry = StepRegistry() - matcher_factory = MatcherFactory() - - self.step_registry = step_registry - self.step_registry.matcher_factory = matcher_factory - self.matcher_factory = matcher_factory diff --git a/tests/unit/test_matchers.py b/tests/unit/test_matchers.py index 815581caa..97ba49eb1 100644 --- a/tests/unit/test_matchers.py +++ b/tests/unit/test_matchers.py @@ -3,8 +3,11 @@ import pytest from mock import Mock, patch import parse -from behave.matchers import Match, Matcher, ParseMatcher, RegexMatcher, \ - SimplifiedRegexMatcher, CucumberRegexMatcher +from behave.exception import NotSupportedWarning +from behave.matchers import ( + Match, Matcher, + ParseMatcher, CFParseMatcher, + RegexMatcher, SimplifiedRegexMatcher, CucumberRegexMatcher) from behave import matchers, runner @@ -38,6 +41,7 @@ def test_returns_match_object_if_check_match_returns_arguments(self): class TestParseMatcher(object): # pylint: disable=invalid-name, no-self-use + STEP_MATCHER_CLASS = ParseMatcher def setUp(self): self.recorded_args = None @@ -45,17 +49,29 @@ def setUp(self): def record_args(self, *args, **kwargs): self.recorded_args = (args, kwargs) + def test_register_type__can_register_own_type_converters(self): + def parse_number(text): + return int(text) + + # -- EXPECT: + this_matcher_class = self.STEP_MATCHER_CLASS + this_matcher_class.custom_types.clear() + this_matcher_class.register_type(Number=parse_number) + assert "Number" in this_matcher_class.custom_types + def test_returns_none_if_parser_does_not_match(self): # pylint: disable=redefined-outer-name # REASON: parse - matcher = ParseMatcher(None, 'a string') + this_matcher_class = self.STEP_MATCHER_CLASS + matcher = this_matcher_class(None, 'a string') with patch.object(matcher.parser, 'parse') as parse: parse.return_value = None assert matcher.match('just a random step') is None def test_returns_arguments_based_on_matches(self): + this_matcher_class = self.STEP_MATCHER_CLASS func = lambda x: -x - matcher = ParseMatcher(func, 'foo') + matcher = this_matcher_class(func, 'foo') results = parse.Result([1, 2, 3], {'foo': 'bar', 'baz': -45.3}, { @@ -83,8 +99,9 @@ def test_returns_arguments_based_on_matches(self): assert have == expected def test_named_arguments(self): + this_matcher_class = self.STEP_MATCHER_CLASS text = "has a {string}, an {integer:d} and a {decimal:f}" - matcher = ParseMatcher(self.record_args, text) + matcher = this_matcher_class(self.record_args, text) context = runner.Context(Mock()) m = matcher.match("has a foo, an 11 and a 3.14159") @@ -95,31 +112,174 @@ def test_named_arguments(self): 'decimal': 3.14159 }) + def test_named_arguments_with_own_types(self): + @parse.with_pattern(r"[A-Za-z][A-Za-z0-9_\-]*") + def parse_word(text): + return text.strip() + + @parse.with_pattern(r"\d+") + def parse_number(text): + return int(text) + + this_matcher_class = self.STEP_MATCHER_CLASS + this_matcher_class.register_type(Number=parse_number, + Word=parse_word) + + pattern = "has a {word:Word}, a {number:Number}" + matcher = this_matcher_class(self.record_args, pattern) + context = runner.Context(Mock()) + + m = matcher.match("has a foo, a 42") + m.run(context) + expected = { + "word": "foo", + "number": 42, + } + assert self.recorded_args, ((context,) == expected) + + def test_positional_arguments(self): + this_matcher_class = self.STEP_MATCHER_CLASS text = "has a {}, an {:d} and a {:f}" - matcher = ParseMatcher(self.record_args, text) + matcher = this_matcher_class(self.record_args, text) context = runner.Context(Mock()) m = matcher.match("has a foo, an 11 and a 3.14159") m.run(context) assert self.recorded_args == ((context, 'foo', 11, 3.14159), {}) + +class TestCFParseMatcher(TestParseMatcher): + STEP_MATCHER_CLASS = CFParseMatcher + + # def test_ + def test_named_optional__without_value(self): + @parse.with_pattern(r"\d+") + def parse_number(text): + return int(text) + + this_matcher_class = self.STEP_MATCHER_CLASS + this_matcher_class.register_type(Number=parse_number) + + pattern = "has an optional number={number:Number?}." + matcher = this_matcher_class(self.record_args, pattern) + context = runner.Context(Mock()) + + m = matcher.match("has an optional number=.") + m.run(context) + expected = { + "number": None, + } + assert self.recorded_args, ((context,) == expected) + + + def test_named_optional__with_value(self): + @parse.with_pattern(r"\d+") + def parse_number(text): + return int(text) + + this_matcher_class = self.STEP_MATCHER_CLASS + this_matcher_class.register_type(Number=parse_number) + + pattern = "has an optional number={number:Number?}." + matcher = this_matcher_class(self.record_args, pattern) + context = runner.Context(Mock()) + + m = matcher.match("has an optional number=42.") + m.run(context) + expected = { + "number": 42, + } + assert self.recorded_args, ((context,) == expected) + + def test_named_many__with_values(self): + @parse.with_pattern(r"\d+") + def parse_number(text): + return int(text) + + this_matcher_class = self.STEP_MATCHER_CLASS + this_matcher_class.register_type(Number=parse_number) + + pattern = "has numbers={number:Number+};" + matcher = this_matcher_class(self.record_args, pattern) + context = runner.Context(Mock()) + + m = matcher.match("has numbers=1, 2, 3;") + m.run(context) + expected = { + "numbers": [1, 2, 3], + } + assert self.recorded_args, ((context,) == expected) + + def test_named_many0__with_empty_list(self): + @parse.with_pattern(r"\d+") + def parse_number(text): + return int(text) + + this_matcher_class = self.STEP_MATCHER_CLASS + this_matcher_class.register_type(Number=parse_number) + + pattern = "has numbers={number:Number*};" + matcher = this_matcher_class(self.record_args, pattern) + context = runner.Context(Mock()) + + m = matcher.match("has numbers=;") + m.run(context) + expected = { + "numbers": [], + } + assert self.recorded_args, ((context,) == expected) + + + def test_named_many0__with_values(self): + @parse.with_pattern(r"\d+") + def parse_number(text): + return int(text) + + this_matcher_class = self.STEP_MATCHER_CLASS + this_matcher_class.register_type(Number=parse_number) + + pattern = "has numbers={number:Number+};" + matcher = this_matcher_class(self.record_args, pattern) + context = runner.Context(Mock()) + + m = matcher.match("has numbers=1, 2, 3;") + m.run(context) + expected = { + "numbers": [1, 2, 3], + } + assert self.recorded_args, ((context,) == expected) + + class TestRegexMatcher(object): # pylint: disable=invalid-name, no-self-use - MATCHER_CLASS = RegexMatcher + STEP_MATCHER_CLASS = RegexMatcher + + def test_register_type__is_not_supported(self): + def parse_number(text): + return int(text) + + this_matcher_class = self.STEP_MATCHER_CLASS + with pytest.raises(NotSupportedWarning) as exc_info: + this_matcher_class.register_type(Number=parse_number) + + excecption_text = exc_info.exconly() + class_name = this_matcher_class.__name__ + expected = "NotSupportedWarning: {0}.register_type".format(class_name) + assert expected in excecption_text def test_returns_none_if_regex_does_not_match(self): - RegexMatcher = self.MATCHER_CLASS - matcher = RegexMatcher(None, 'a string') + this_matcher_class = self.STEP_MATCHER_CLASS + matcher = this_matcher_class(None, 'a string') regex = Mock() regex.match.return_value = None matcher.regex = regex assert matcher.match('just a random step') is None def test_returns_arguments_based_on_groups(self): - RegexMatcher = self.MATCHER_CLASS + this_matcher_class = self.STEP_MATCHER_CLASS func = lambda x: -x - matcher = RegexMatcher(func, 'foo') + matcher = this_matcher_class(func, 'foo') regex = Mock() regex.groupindex = {'foo': 4, 'baz': 5} @@ -156,7 +316,7 @@ def test_returns_arguments_based_on_groups(self): class TestSimplifiedRegexMatcher(TestRegexMatcher): - MATCHER_CLASS = SimplifiedRegexMatcher + STEP_MATCHER_CLASS = SimplifiedRegexMatcher def test_steps_with_same_prefix_are_not_ordering_sensitive(self): # -- RELATED-TO: issue #280 @@ -193,7 +353,7 @@ def test_step_should_not_use_regex_begin_and_end_marker(self): class TestCucumberRegexMatcher(TestRegexMatcher): - MATCHER_CLASS = CucumberRegexMatcher + STEP_MATCHER_CLASS = CucumberRegexMatcher def test_steps_with_same_prefix_are_not_ordering_sensitive(self): # -- RELATED-TO: issue #280 @@ -227,10 +387,14 @@ def test_step_should_use_regex_begin_and_end_marker(self): def test_step_matcher_current_matcher(): - current_matcher = matchers.current_matcher - for name, klass in list(matchers.matcher_mapping.items()): - matchers.use_step_matcher(name) - matcher = matchers.get_matcher(lambda x: -x, 'foo') + step_matcher_factory = matchers.get_matcher_factory() + for name, klass in list(step_matcher_factory.matcher_mapping.items()): + current_matcher1 = matchers.use_step_matcher(name) + current_matcher2 = step_matcher_factory.current_matcher + matcher = matchers.make_matcher(lambda x: -x, "foo") assert isinstance(matcher, klass) + assert current_matcher1 is klass + assert current_matcher2 is klass - matchers.current_matcher = current_matcher + # -- CLEANUP: Revert to default matcher + step_matcher_factory.use_default_step_matcher() diff --git a/tests/unit/test_step_registry.py b/tests/unit/test_step_registry.py index 6f85729e6..59d09e157 100644 --- a/tests/unit/test_step_registry.py +++ b/tests/unit/test_step_registry.py @@ -12,19 +12,19 @@ class TestStepRegistry(object): def test_add_step_definition_adds_to_lowercased_keyword(self): registry = step_registry.StepRegistry() # -- MONKEYPATCH-PROBLEM: - # with patch('behave.matchers.get_matcher') as get_matcher: - with patch('behave.step_registry.get_matcher') as get_matcher: + # with patch('behave.matchers.make_matcher') as make_matcher: + with patch('behave.step_registry.make_matcher') as make_matcher: func = lambda x: -x pattern = 'just a test string' magic_object = object() - get_matcher.return_value = magic_object + make_matcher.return_value = magic_object for step_type in list(registry.steps.keys()): l = [] registry.steps[step_type] = l registry.add_step_definition(step_type.upper(), pattern, func) - get_matcher.assert_called_with(func, pattern) + make_matcher.assert_called_with(func, pattern) assert l == [magic_object] def test_find_match_with_specific_step_type_also_searches_generic(self): From 6abdb43cf8a75f68555df5e67e1f000de4b26bb0 Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 8 Jun 2023 18:55:30 +0200 Subject: [PATCH 103/240] RELATED TO: tag-expressions ADDED: TagExpressionProtocol * Supports "any" (v1 and v2) and "strict" (only v2) mode * Helps to better control which tag-expression versions are usable HINTS: - Tag-expressions v2 provide much better parser diagnostics - Some parse errors are unnoticable if "any" mode is active. Some parser errors are mapped to "tag-expression v1". IMPROVED: --tags-help command-line option * tags-help: Improve textual description and remove some old parts * tags-help: Helps now to diagnose tag-expression related problems FIXED: * issue #1054: TagExpressions v2: AND concatenation is faulty * FIX main: Avoid stacktrace when a BAD TAG-EXPRESSION is used. * Not.to_string conversion: Double-parenthesis problem Double-parenthensis are used if Not contains a binary operator (And, Or). REASON: Binary operators already put parenthensis aroung their terms. --- CHANGES.rst | 2 + behave/__main__.py | 87 +++++++++-------- behave/exception.py | 28 +++++- behave/tag_expression/__init__.py | 115 ++++++++++++++++++----- behave/tag_expression/model.py | 32 ++++++- behave/tag_expression/parser.py | 2 +- behave/tag_expression/v1.py | 34 ++++++- features/tags.help.feature | 3 + tests/unit/tag_expression/test_basics.py | 12 +-- tests/unit/tag_expression/test_parser.py | 3 +- 10 files changed, 240 insertions(+), 78 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index bacdebd70..c3a7928bd 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -62,6 +62,7 @@ FIXED: * FIXED: Some tests related to python3.9 * FIXED: active-tag logic if multiple tags with same category exists. * issue #1061: Scenario should inherit Rule tags (submitted by: testgitdl) +* issue #1054: TagExpressions v2: AND concatenation is faulty (submitted by: janoskut) * pull #967: Update __init__.py in behave import to fix pylint (provided by: dsayling) * issue #955: setup: Remove attribute 'use_2to3' (submitted by: krisgesling) * issue #772: ScenarioOutline.Examples without table (submitted by: The-QA-Geek) @@ -77,6 +78,7 @@ FIXED: MINOR: * issue #1047: Step type is inherited for generic step if possible (submitted by: zettseb) +* issue #958: Replace dashes with underscores to comply with setuptools v54.1.0 #958 (submitted by: arrooney) * issue #800: Cleanups related to Gherkin parser/ParseError question (submitted by: otstanteplz) * pull #767: FIX: use_fixture_by_tag didn't return the actual fixture in all cases (provided by: jgentil) * pull #751: gherkin: Adding Rule keyword translation in portuguese and spanish to gherkin-languages.json (provided by: dunossauro) diff --git a/behave/__main__.py b/behave/__main__.py index bb9367db7..723ea855c 100644 --- a/behave/__main__.py +++ b/behave/__main__.py @@ -6,47 +6,44 @@ import six from behave.version import VERSION as BEHAVE_VERSION from behave.configuration import Configuration -from behave.exception import ConstraintError, ConfigError, \ - FileNotFoundError, InvalidFileLocationError, InvalidFilenameError, \ - ModuleNotFoundError, ClassNotFoundError, InvalidClassError +from behave.exception import (ConstraintError, ConfigError, + FileNotFoundError, InvalidFileLocationError, InvalidFilenameError, + ModuleNotFoundError, ClassNotFoundError, InvalidClassError, + TagExpressionError) +from behave.importer import make_scoped_class_name from behave.parser import ParserError -from behave.runner import Runner +from behave.runner import Runner # noqa: F401 from behave.runner_util import print_undefined_step_snippets, reset_runtime -from behave.textutil import compute_words_maxsize, text as _text from behave.runner_plugin import RunnerPlugin -# PREPARED: from behave.importer import make_scoped_class_name +from behave.textutil import compute_words_maxsize, text as _text # --------------------------------------------------------------------------- # CONSTANTS: # --------------------------------------------------------------------------- DEBUG = __debug__ -TAG_HELP = """ -Scenarios inherit tags that are declared on the Feature level. -The simplest TAG_EXPRESSION is simply a tag:: - - --tags=@dev - -You may even leave off the "@" - behave doesn't mind. - -You can also exclude all features / scenarios that have a tag, -by using boolean NOT:: - - --tags="not @dev" - -A tag expression can also use a logical OR:: - - --tags="@dev or @wip" - -The --tags option can be specified several times, -and this represents logical AND, -for instance this represents the boolean expression:: - - --tags="(@foo or not @bar) and @zap" - -You can also exclude several tags:: - - --tags="not (@fixme or @buggy)" +TAG_EXPRESSIONS_HELP = """ +TAG-EXPRESSIONS selects Features/Rules/Scenarios by using their tags. +A TAG-EXPRESSION is a boolean expression that references some tags. + +EXAMPLES: + + --tags=@smoke + --tags="not @xfail" + --tags="@smoke or @wip" + --tags="@smoke and @wip" + --tags="(@slow and not @fixme) or @smoke" + --tags="not (@fixme or @xfail)" + +NOTES: +* The tag-prefix "@" is optional. +* An empty tag-expression is "true" (select-anything). + +TAG-INHERITANCE: +* A Rule inherits the tags of its Feature +* A Scenario inherits the tags of its Feature or Rule. +* A Scenario of a ScenarioOutline/ScenarioTemplate inherit tags + from this ScenarioOutline/ScenarioTemplate and its Example table. """.strip() @@ -69,7 +66,7 @@ def run_behave(config, runner_class=None): return 0 if config.tags_help: - print(TAG_HELP) + print_tags_help(config) return 0 if config.lang == "help" or config.lang_list: @@ -109,7 +106,7 @@ def run_behave(config, runner_class=None): try: reset_runtime() runner = RunnerPlugin(runner_class).make_runner(config) - # print("USING RUNNER: {0}".format(make_scoped_class_name(runner))) + print("USING RUNNER: {0}".format(make_scoped_class_name(runner))) failed = runner.run() except ParserError as e: print(u"ParserError: %s" % e) @@ -152,6 +149,17 @@ def run_behave(config, runner_class=None): # --------------------------------------------------------------------------- # MAIN SUPPORT FOR: run_behave() # --------------------------------------------------------------------------- +def print_tags_help(config): + print(TAG_EXPRESSIONS_HELP) + + current_tag_expression = config.tag_expression.to_string() + print("\nCURRENT TAG_EXPRESSION: {0}".format(current_tag_expression)) + if config.verbose: + # -- SHOW LOW-LEVEL DETAILS: + text = repr(config.tag_expression).replace("Literal(", "Tag(") + print(" means: {0}".format(text)) + + def print_language_list(file=None): """Print list of supported languages, like: @@ -275,9 +283,14 @@ def main(args=None): :param args: Command-line args (or string) to use. :return: 0, if successful. Non-zero, in case of errors/failures. """ - config = Configuration(args) - return run_behave(config) - + try: + config = Configuration(args) + return run_behave(config) + except ConfigError as e: + print("ConfigError: %s" % e) + except TagExpressionError as e: + print("TagExpressionError: %s" % e) + return 1 # FAILED: if __name__ == "__main__": # -- EXAMPLE: main("--version") diff --git a/behave/exception.py b/behave/exception.py index e00201b83..b2375469d 100644 --- a/behave/exception.py +++ b/behave/exception.py @@ -1,4 +1,5 @@ # -*- coding: UTF-8 -*- +# ruff: noqa: F401 # pylint: disable=redefined-builtin,unused-import """ Behave exception classes. @@ -7,11 +8,28 @@ """ from __future__ import absolute_import, print_function -# -- USE MODERN EXCEPTION CLASSES: -# COMPATIBILITY: Emulated if not supported yet by Python version. -from behave.compat.exceptions import ( - FileNotFoundError, ModuleNotFoundError # noqa: F401 -) +# -- RE-EXPORT: Exception class(es) here (provided in other places). +# USE MODERN EXCEPTION CLASSES: FileNotFoundError, ModuleNotFoundError +# COMPATIBILITY: Emulated if not supported yet by Python version. +from behave.compat.exceptions import (FileNotFoundError, ModuleNotFoundError) # noqa: F401 +from behave.tag_expression.parser import TagExpressionError + + +__all__ = [ + "ClassNotFoundError", + "ConfigError", + "ConstraintError", + "FileNotFoundError", + "InvalidClassError", + "InvalidFileLocationError", + "InvalidFilenameError", + "ModuleNotFoundError", + "NotSupportedWarning", + "ObjectNotFoundError", + "ResourceExistsError", + "TagExpressionError", +] + # --------------------------------------------------------------------------- # EXCEPTION/ERROR CLASSES: diff --git a/behave/tag_expression/__init__.py b/behave/tag_expression/__init__.py index c68521ecb..c2d7e5f16 100644 --- a/behave/tag_expression/__init__.py +++ b/behave/tag_expression/__init__.py @@ -1,4 +1,5 @@ # -*- coding: UTF-8 -*- +# pylint: disable=C0209 """ Common module for tag-expressions: @@ -12,25 +13,83 @@ """ from __future__ import absolute_import +from enum import Enum import six # -- NEW CUCUMBER TAG-EXPRESSIONS (v2): from .parser import TagExpressionParser -# -- OLD-STYLE TAG-EXPRESSIONS (v1): -# HINT: BACKWARD-COMPATIBLE (deprecating) +from .model import Expression # noqa: F401 +# -- DEPRECATING: OLD-STYLE TAG-EXPRESSIONS (v1): +# BACKWARD-COMPATIBLE SUPPORT from .v1 import TagExpression +# ----------------------------------------------------------------------------- +# CLASS: TagExpressionProtocol +# ----------------------------------------------------------------------------- +class TagExpressionProtocol(Enum): + """Used to specify which tag-expression versions to support: + + * ANY: Supports tag-expressions v2 and v1 (as compatibility mode) + * STRICT: Supports only tag-expressions v2 (better diagnostics) + + NOTE: + * Some errors are not caught in ANY mode. + """ + ANY = 1 + STRICT = 2 + DEFAULT = ANY + + @classmethod + def choices(cls): + return [member.name.lower() for member in cls] + + @classmethod + def parse(cls, name): + name2 = name.upper() + for member in cls: + if name2 == member.name: + return member + # -- OTHERWISE: + message = "{0} (expected: {1})".format(name, ", ".join(cls.choices())) + raise ValueError(message) + + def select_parser(self, tag_expression_text_or_seq): + if self is self.STRICT: + return parse_tag_expression_v2 + # -- CASE: TagExpressionProtocol.ANY + return select_tag_expression_parser4any(tag_expression_text_or_seq) + + + # -- SINGLETON FUNCTIONALITY: + @classmethod + def current(cls): + """Return currently selected protocol instance.""" + return getattr(cls, "_current", cls.DEFAULT) + + @classmethod + def use(cls, member): + """Specify which TagExpression protocol to use.""" + if isinstance(member, six.string_types): + name = member + member = cls.parse(name) + assert isinstance(member, TagExpressionProtocol), "%s:%s" % (type(member), member) + setattr(cls, "_current", member) + + + # ----------------------------------------------------------------------------- # FUNCTIONS: # ----------------------------------------------------------------------------- -def make_tag_expression(tag_expression_text): +def make_tag_expression(text_or_seq): """Build a TagExpression object by parsing the tag-expression (as text). - :param tag_expression_text: Tag expression text to parse (as string). + :param text_or_seq: + Tag expression text(s) to parse (as string, sequence). + :param protocol: Tag-expression protocol to use. :return: TagExpression object to use. """ - parse_tag_expression = select_tag_expression_parser(tag_expression_text) - return parse_tag_expression(tag_expression_text) + parse_tag_expression = TagExpressionProtocol.current().select_parser(text_or_seq) + return parse_tag_expression(text_or_seq) def parse_tag_expression_v1(tag_expression_parts): @@ -38,27 +97,33 @@ def parse_tag_expression_v1(tag_expression_parts): # -- HINT: DEPRECATING if isinstance(tag_expression_parts, six.string_types): tag_expression_parts = tag_expression_parts.split() + elif not isinstance(tag_expression_parts, (list, tuple)): + raise TypeError("EXPECTED: string, sequence", tag_expression_parts) + # print("parse_tag_expression_v1: %s" % " ".join(tag_expression_parts)) return TagExpression(tag_expression_parts) -def parse_tag_expression_v2(tag_expression_text): +def parse_tag_expression_v2(text_or_seq): """Parse cucumber-tag-expressions and build a TagExpression object.""" - text = tag_expression_text - if not isinstance(text, six.string_types): + text = text_or_seq + if isinstance(text, (list, tuple)): # -- ASSUME: List of strings - assert isinstance(text, (list, tuple)) - text = " and ".join(text) + sequence = text_or_seq + terms = ["({0})".format(term) for term in sequence] + text = " and ".join(terms) + elif not isinstance(text, six.string_types): + raise TypeError("EXPECTED: string, sequence", text) if "@" in text: # -- NORMALIZE: tag-expression text => Remove '@' tag decorators. text = text.replace("@", "") text = text.replace(" ", " ") - # print("parse_tag_expression_v2: %s" % text) + # DIAG: print("parse_tag_expression_v2: %s" % text) return TagExpressionParser.parse(text) -def check_for_complete_keywords(words, keywords): +def is_any_equal_to_keyword(words, keywords): for keyword in keywords: for word in words: if keyword == word: @@ -66,10 +131,11 @@ def check_for_complete_keywords(words, keywords): return False -def select_tag_expression_parser(tag_expression_text): +# -- CASE: TagExpressionProtocol.ANY +def select_tag_expression_parser4any(text_or_seq): """Select/Auto-detect which version of tag-expressions is used. - :param tag_expression_text: Tag expression text (as string) + :param text_or_seq: Tag expression text (as string, sequence) :return: TagExpression parser to use (as function). """ TAG_EXPRESSION_V1_KEYWORDS = [ @@ -79,19 +145,18 @@ def select_tag_expression_parser(tag_expression_text): "and", "or", "not", "(", ")" ] - text = tag_expression_text - if not isinstance(text, six.string_types): - # -- ASSUME: List of strings - assert isinstance(text, (list, tuple)) - text = " ".join(text) + text = text_or_seq + if isinstance(text, (list, tuple)): + # -- CASE: sequence -- Sequence of tag_expression parts + parts = text_or_seq + text = " ".join(parts) + elif not isinstance(text, six.string_types): + raise TypeError("EXPECTED: string, sequence", text) text = text.replace("(", " ( ").replace(")", " ) ") words = text.split() - contains_v1_keywords = any([(k in text) for k in TAG_EXPRESSION_V1_KEYWORDS]) - contains_v2_keywords = check_for_complete_keywords(words, TAG_EXPRESSION_V2_KEYWORDS) - # contains_v2_keywords = any([(k in text) for k in TAG_EXPRESSION_V2_KEYWORDS]) - # DIAG: print("XXX select_tag_expression_parser: v1=%r, v2=%r, words.size=%d (tags: %r)" % \ - # DIAG: (contains_v1_keywords, contains_v2_keywords, len(words), text)) + contains_v1_keywords = any((k in text) for k in TAG_EXPRESSION_V1_KEYWORDS) + contains_v2_keywords = is_any_equal_to_keyword(words, TAG_EXPRESSION_V2_KEYWORDS) if contains_v2_keywords: # -- USE: Use cucumber-tag-expressions return parse_tag_expression_v2 diff --git a/behave/tag_expression/model.py b/behave/tag_expression/model.py index 3b22f9e72..56477d87f 100644 --- a/behave/tag_expression/model.py +++ b/behave/tag_expression/model.py @@ -1,10 +1,11 @@ # -*- coding: UTF-8 -*- +# ruff: noqa: F401 # HINT: Import adapter only -from cucumber_tag_expressions.model import Expression, Literal, And, Or, Not +from cucumber_tag_expressions.model import Expression, Literal, And, Or, Not, True_ # ----------------------------------------------------------------------------- -# PATCH TAG-EXPRESSION BASE-CLASS: +# PATCH TAG-EXPRESSION BASE-CLASS: Expression # ----------------------------------------------------------------------------- def _Expression_check(self, tags): """Checks if tags match this tag-expression. @@ -16,5 +17,32 @@ def _Expression_check(self, tags): """ return self.evaluate(tags) +def _Expression_to_string(self, pretty=True): + """Provide nicer string conversion(s).""" + text = str(self) + if pretty: + # -- REMOVE WHITESPACE: Around parenthensis + text = text.replace("( ", "(").replace(" )", ")") + return text + +# -- MONKEY-PATCH: Expression.check = _Expression_check +Expression.to_string = _Expression_to_string + + +# ----------------------------------------------------------------------------- +# PATCH TAG-EXPRESSION CLASS: Not +# ----------------------------------------------------------------------------- +def _Not_to_string(self): + """Provide nicer/more compact output if Literal(s) are involved.""" + # MAYBE: Literal/True_ need no parenthesis + schema = "not ( {0} )" + if isinstance(self.term, (And, Or)): + # -- REASON: And/Or term have parenthesis already. + schema = "not {0}" + return schema.format(self.term) + + +# -- MONKEY-PATCH: +Not.__str__ = _Not_to_string diff --git a/behave/tag_expression/parser.py b/behave/tag_expression/parser.py index 690b0881f..6854b8b63 100644 --- a/behave/tag_expression/parser.py +++ b/behave/tag_expression/parser.py @@ -17,7 +17,7 @@ from cucumber_tag_expressions.parser import ( TagExpressionParser as _TagExpressionParser, # PROVIDE: Similar interface like: cucumber_tag_expressions.parser - TagExpressionError + TagExpressionError # noqa: F401 ) from cucumber_tag_expressions.model import Literal from .model_ext import Matcher diff --git a/behave/tag_expression/v1.py b/behave/tag_expression/v1.py index 5ffe3fa38..489d00ef0 100644 --- a/behave/tag_expression/v1.py +++ b/behave/tag_expression/v1.py @@ -105,6 +105,38 @@ def __str__(self): and_parts.append(u",".join(or_terms)) return u" ".join(and_parts) + def __repr__(self): + class_name = self.__class__.__name__ +"_v1" + and_parts = [] + # TODO + # for or_terms in self.ands: + # or_parts = [] + # for or_term in or_terms.split(): + # + # or_expression = u"Or(%s)" % u",".join(or_terms) + # and_parts.append(or_expression) + if len(self.ands) == 0: + expression = u"True()" + elif len(self.ands) >= 1: + and_parts = [] + for or_terms in self.ands: + or_parts = [] + for or_term in or_terms: + or_parts.extend(or_term.split()) + and_parts.append(u"Or(%s)" % ", ".join(or_parts)) + expression = u"And(%s)" % u",".join([and_part for and_part in and_parts]) + if len(self.ands) == 1: + expression = and_parts[0] + + # expression = u"And(%s)" % u",".join([or_term.split() + # for or_terms in self.ands + # for or_term in or_terms]) + return "<%s: expression=%s>" % (class_name, expression) + if six.PY2: __unicode__ = __str__ - __str__ = lambda self: self.__unicode__().encode("utf-8") + __str__ = lambda self: self.__unicode__().encode("utf-8") # noqa: E731 + + # -- API COMPATIBILITY TO: TagExpressions v2 + def to_string(self, pretty=True): + return str(self) diff --git a/features/tags.help.feature b/features/tags.help.feature index 8b0b98c1e..2b9c44062 100644 --- a/features/tags.help.feature +++ b/features/tags.help.feature @@ -7,6 +7,9 @@ Feature: behave --tags-help option . IN ADDITION: . The --tags-help option helps to diagnose tag-expression v2 problems. + Background: + Given a new working directory + Rule: Use --tags-help option to see tag-expression syntax and examples Scenario: Shows tag-expression description When I run "behave --tags-help" diff --git a/tests/unit/tag_expression/test_basics.py b/tests/unit/tag_expression/test_basics.py index 69b3256d7..6ebc4a896 100644 --- a/tests/unit/tag_expression/test_basics.py +++ b/tests/unit/tag_expression/test_basics.py @@ -1,7 +1,7 @@ # -*- coding: UTF-8 -*- from behave.tag_expression import ( - make_tag_expression, select_tag_expression_parser, + make_tag_expression, select_tag_expression_parser4any, parse_tag_expression_v1, parse_tag_expression_v2 ) from behave.tag_expression.v1 import TagExpression as TagExpressionV1 @@ -19,7 +19,7 @@ def test_make_tag_expression__with_v2(): # ----------------------------------------------------------------------------- -# TEST SUITE FOR: select_tag_expression_parser() +# TEST SUITE FOR: select_tag_expression_parser4any() # ----------------------------------------------------------------------------- @pytest.mark.parametrize("text", [ "@foo @bar", @@ -31,8 +31,8 @@ def test_make_tag_expression__with_v2(): "@foo,@bar", "-@xfail -@not_implemented", ]) -def test_select_tag_expression_parser__with_v1(text): - parser = select_tag_expression_parser(text) +def test_select_tag_expression_parser4any__with_v1(text): + parser = select_tag_expression_parser4any(text) assert parser is parse_tag_expression_v1, "tag_expression: %s" % text @@ -45,6 +45,6 @@ def test_select_tag_expression_parser__with_v1(text): "(@foo and @bar) or @baz", "not @xfail or not @not_implemented" ]) -def test_select_tag_expression_parser__with_v2(text): - parser = select_tag_expression_parser(text) +def test_select_tag_expression_parser4any__with_v2(text): + parser = select_tag_expression_parser4any(text) assert parser is parse_tag_expression_v2, "tag_expression: %s" % text diff --git a/tests/unit/tag_expression/test_parser.py b/tests/unit/tag_expression/test_parser.py index 49e3fe7a0..dfe14f4d2 100644 --- a/tests/unit/tag_expression/test_parser.py +++ b/tests/unit/tag_expression/test_parser.py @@ -164,7 +164,8 @@ def test_parse__empty_is_always_true(self, text): ("a or not b", "( a or not ( b ) )"), ("not a and b", "( not ( a ) and b )"), ("not a or b", "( not ( a ) or b )"), - ("not (a and b) or c", "( not ( ( a and b ) ) or c )"), + ("not (a and b) or c", "( not ( a and b ) or c )"), + # OLD: ("not (a and b) or c", "( not ( ( a and b ) ) or c )"), ]) def test_parse__ensure_precedence(self, text, expected): """Ensures that the operation precedence is parsed correctly.""" From 0b98c4d80cff5bac364c6a57f9cb762719de5efb Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 8 Jun 2023 19:05:44 +0200 Subject: [PATCH 104/240] CLEANUP: behave.configuration * Constructor: Simplify, move parts to own "setup_()" methods * Config-file processing: Simplify and fix some bugs HINT: Negated-option descriptions were processed, ... * Color-mode: Simplify detection and use for others. --- behave/__main__.py | 2 + behave/configuration.py | 703 +++++++++++++++++++----------- behave/tag_expression/__init__.py | 7 +- docs/behave.rst | 58 +-- docs/update_behave_rst.py | 43 +- tests/unit/test_configuration.py | 162 ++++++- 6 files changed, 669 insertions(+), 306 deletions(-) diff --git a/behave/__main__.py b/behave/__main__.py index 723ea855c..346533d7f 100644 --- a/behave/__main__.py +++ b/behave/__main__.py @@ -36,10 +36,12 @@ --tags="not (@fixme or @xfail)" NOTES: + * The tag-prefix "@" is optional. * An empty tag-expression is "true" (select-anything). TAG-INHERITANCE: + * A Rule inherits the tags of its Feature * A Scenario inherits the tags of its Feature or Rule. * A Scenario of a ScenarioOutline/ScenarioTemplate inherit tags diff --git a/behave/configuration.py b/behave/configuration.py index 4bdaf1648..3b28409e4 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -1,10 +1,22 @@ -# -*- coding: utf-8 -*- +# -*- coding: UTF-8 -*- +# pylint: disable=redundant-u-string-prefix +# pylint: disable=consider-using-f-string +# pylint: disable=too-many-lines +# pylint: disable=useless-object-inheritance +# pylint: disable=use-dict-literal +""" +This module provides the configuration for :mod:`behave`: + +* Configuration object(s) +* config-file loading and storing params in Configuration object(s) +* command-line parsing and storing params in Configuration object(s) +""" from __future__ import absolute_import, print_function import argparse -import inspect import json import logging +from logging.config import fileConfig as logging_config_fileConfig import os import re import sys @@ -14,11 +26,11 @@ from behave.model import ScenarioOutline from behave.model_core import FileLocation -from behave.reporter.junit import JUnitReporter -from behave.reporter.summary import SummaryReporter -from behave.tag_expression import make_tag_expression from behave.formatter.base import StreamOpener from behave.formatter import _registry as _format_registry +from behave.reporter.junit import JUnitReporter +from behave.reporter.summary import SummaryReporter +from behave.tag_expression import make_tag_expression, TagExpressionProtocol from behave.userdata import UserData, parse_user_define from behave._types import Unknown from behave.textutil import select_best_encoding, to_texts @@ -26,19 +38,21 @@ # -- PYTHON 2/3 COMPATIBILITY: # SINCE Python 3.2: ConfigParser = SafeConfigParser ConfigParser = configparser.ConfigParser -if six.PY2: +if six.PY2: # pragma: no cover ConfigParser = configparser.SafeConfigParser -try: - if sys.version_info >= (3, 11): - import tomllib - elif sys.version_info < (3, 0): - import toml as tomllib - else: - import tomli as tomllib - _TOML_AVAILABLE = True -except ImportError: - _TOML_AVAILABLE = False +# -- OPTIONAL TOML SUPPORT: Using "pyproject.toml" as config-file +_TOML_AVAILABLE = True +if _TOML_AVAILABLE: # pragma: no cover + try: + if sys.version_info >= (3, 11): + import tomllib + elif sys.version_info < (3, 0): + import toml as tomllib + else: + import tomli as tomllib + except ImportError: + _TOML_AVAILABLE = False # ----------------------------------------------------------------------------- @@ -92,17 +106,22 @@ def positive_number(text): # ----------------------------------------------------------------------------- # CONFIGURATION SCHEMA: # ----------------------------------------------------------------------------- -options = [ - (("-c", "--no-color"), - dict(action="store_false", dest="color", - help="Disable the use of ANSI color escapes.")), +COLOR_CHOICES = ["auto", "on", "off", "always", "never"] +COLOR_DEFAULT = os.getenv("BEHAVE_COLOR", "auto") +COLOR_DEFAULT_OFF = "off" +COLOR_ON_VALUES = ("on", "always") +COLOR_OFF_VALUES = ("off", "never") + + +OPTIONS = [ + (("-C", "--no-color"), + dict(dest="color", action="store_const", const=COLOR_DEFAULT_OFF, + help="Disable colored mode.")), (("--color",), - dict(dest="color", choices=["never", "always", "auto"], - default=os.getenv('BEHAVE_COLOR'), const="auto", nargs="?", - help="""Use ANSI color escapes. Defaults to %(const)r. - This switch is used to override a - configuration file setting.""")), + dict(dest="color", choices=COLOR_CHOICES, + default=COLOR_DEFAULT, const=COLOR_DEFAULT, nargs="?", + help="""Use colored mode or not (default: %(default)s).""")), (("-d", "--dry-run"), dict(action="store_true", @@ -143,7 +162,8 @@ def positive_number(text): (("-j", "--jobs", "--parallel"), dict(metavar="NUMBER", dest="jobs", default=1, type=positive_number, help="""Number of concurrent jobs to use (default: %(default)s). - Only supported by test runners that support parallel execution.""")), + Only supported by test runners that support parallel execution. + """)), ((), # -- CONFIGFILE only dict(dest="default_format", default="pretty", @@ -151,13 +171,13 @@ def positive_number(text): (("-f", "--format"), - dict(action="append", + dict(dest="format", action="append", help="""Specify a formatter. If none is specified the default formatter is used. Pass "--format help" to get a list of available formatters.""")), (("--steps-catalog",), - dict(action="store_true", dest="steps_catalog", + dict(dest="steps_catalog", action="store_true", help="""Show a catalog of all available step definitions. SAME AS: --format=steps.catalog --dry-run --no-summary -q""")), @@ -167,7 +187,7 @@ def positive_number(text): (default="{name} -- @{row.id} {examples.name}").""")), (("--no-skipped",), - dict(action="store_false", dest="show_skipped", + dict(dest="show_skipped", action="store_false", help="Don't print skipped steps (due to tags).")), (("--show-skipped",), @@ -177,69 +197,69 @@ def positive_number(text): override a configuration file setting.""")), (("--no-snippets",), - dict(action="store_false", dest="show_snippets", + dict(dest="show_snippets", action="store_false", help="Don't print snippets for unimplemented steps.")), (("--snippets",), - dict(action="store_true", dest="show_snippets", + dict(dest="show_snippets", action="store_true", help="""Print snippets for unimplemented steps. This is the default behaviour. This switch is used to override a configuration file setting.""")), (("--no-multiline",), - dict(action="store_false", dest="show_multiline", + dict(dest="show_multiline", action="store_false", help="""Don't print multiline strings and tables under steps.""")), (("--multiline", ), - dict(action="store_true", dest="show_multiline", + dict(dest="show_multiline", action="store_true", help="""Print multiline strings and tables under steps. This is the default behaviour. This switch is used to override a configuration file setting.""")), (("-n", "--name"), - dict(action="append", metavar="NAME_PATTERN", + dict(dest="name", action="append", metavar="NAME_PATTERN", help="""Select feature elements (scenarios, ...) to run which match part of the given name (regex pattern). If this option is given more than once, it will match against all the given names.""")), (("--no-capture",), - dict(action="store_false", dest="stdout_capture", + dict(dest="stdout_capture", action="store_false", help="""Don't capture stdout (any stdout output will be printed immediately.)""")), (("--capture",), - dict(action="store_true", dest="stdout_capture", + dict(dest="stdout_capture", action="store_true", help="""Capture stdout (any stdout output will be printed if there is a failure.) This is the default behaviour. This switch is used to override a configuration file setting.""")), (("--no-capture-stderr",), - dict(action="store_false", dest="stderr_capture", + dict(dest="stderr_capture", action="store_false", help="""Don't capture stderr (any stderr output will be printed immediately.)""")), (("--capture-stderr",), - dict(action="store_true", dest="stderr_capture", + dict(dest="stderr_capture", action="store_true", help="""Capture stderr (any stderr output will be printed if there is a failure.) This is the default behaviour. This switch is used to override a configuration file setting.""")), (("--no-logcapture",), - dict(action="store_false", dest="log_capture", + dict(dest="log_capture", action="store_false", help="""Don't capture logging. Logging configuration will be left intact.""")), (("--logcapture",), - dict(action="store_true", dest="log_capture", + dict(dest="log_capture", action="store_true", help="""Capture logging. All logging during a step will be captured and displayed in the event of a failure. This is the default behaviour. This switch is used to override a configuration file setting.""")), (("--logging-level",), - dict(type=LogLevel.parse_type, + dict(type=LogLevel.parse_type, default=logging.INFO, help="""Specify a level to capture logging at. The default is INFO - capturing everything.""")), @@ -288,12 +308,21 @@ def positive_number(text): help="""Display the summary at the end of the run.""")), (("-o", "--outfile"), - dict(action="append", dest="outfiles", metavar="FILE", + dict(dest="outfiles", action="append", metavar="FILE", help="Write to specified file instead of stdout.")), ((), # -- CONFIGFILE only - dict(action="append", dest="paths", + dict(dest="paths", action="append", help="Specify default feature paths, used when none are provided.")), + ((), # -- CONFIGFILE only + dict(dest="tag_expression_protocol", type=TagExpressionProtocol.parse, + choices=TagExpressionProtocol.choices(), + default=TagExpressionProtocol.default().name.lower(), + help="""\ +Specify the tag-expression protocol to use (default: %(default)s). +With "any", tag-expressions v2 and v2 are supported (in auto-detect mode). +With "strict", only tag-expressions v2 is supported (better error diagnostics). +""")), (("-q", "--quiet"), dict(action="store_true", @@ -305,12 +334,12 @@ def positive_number(text): help='Use own runner class, like: "behave.runner:Runner"')), (("--no-source",), - dict(action="store_false", dest="show_source", + dict( dest="show_source", action="store_false", help="""Don't print the file and line of the step definition with the steps.""")), (("--show-source",), - dict(action="store_true", dest="show_source", + dict(dest="show_source", action="store_true", help="""Print the file and line of the step definition with the steps. This is the default behaviour. This switch is used to override a @@ -346,11 +375,11 @@ def positive_number(text): tag expressions in configuration files.""")), (("-T", "--no-timings"), - dict(action="store_false", dest="show_timings", + dict( dest="show_timings", action="store_false", help="""Don't print the time taken for each step.""")), (("--show-timings",), - dict(action="store_true", dest="show_timings", + dict(dest="show_timings", action="store_true", help="""Print the time taken, in seconds, of each step after the step has completed. This is the default behaviour. This switch is used to override a configuration file @@ -386,81 +415,109 @@ def positive_number(text): dict(action="store_true", help="Show version.")), ] + +# -- CONFIG-FILE SKIPS: +# * Skip SOME_HELP options, like: --tags-help, --lang-list, ... +# * Skip --no- options (action: "store_false", "store_const") +CONFIGFILE_EXCLUDED_OPTIONS = set([ + "tags_help", "lang_list", "lang_help", + "version", + "userdata_defines", +]) +CONFIGFILE_EXCLUDED_ACTIONS = set(["store_false", "store_const"]) + # -- OPTIONS: With raw value access semantics in configuration file. -raw_value_options = frozenset([ +RAW_VALUE_OPTIONS = frozenset([ "logging_format", "logging_datefmt", # -- MAYBE: "scenario_outline_annotation_schema", ]) -def values_to_str(d): - return json.loads( - json.dumps(d), +def _values_to_str(data): + return json.loads(json.dumps(data), parse_float=str, parse_int=str, parse_constant=str ) -def decode_options(config): - for fixed, keywords in options: - if "dest" in keywords: +def has_negated_option(option_words): + return any([word.startswith("--no-") for word in option_words]) + + +def derive_dest_from_long_option(fixed_options): + for option_name in fixed_options: + if option_name.startswith("--"): + return option_name[2:].replace("-", "_") + return None + +# -- TEST-BALLOON: +from collections import namedtuple +ConfigFileOption = namedtuple("ConfigFileOption", ("dest", "action", "type")) + + +def configfile_options_iter(config): + skip_missing = bool(config) + def config_has_param(config, param_name): + try: + return param_name in config["behave"] + except AttributeError as exc: # pragma: no cover + # H-- INT: PY27: SafeConfigParser instance has no attribute "__getitem__" + return config.has_option("behave", param_name) + except KeyError: + return False + + for fixed, keywords in OPTIONS: + action = keywords.get("action", "store") + if has_negated_option(fixed) or action == "store_false": + # -- SKIP NEGATED OPTIONS, like: --no-color + continue + elif "dest" in keywords: dest = keywords["dest"] else: - dest = None - for opt in fixed: - if opt.startswith("--"): - dest = opt[2:].replace("-", "_") - else: - assert len(opt) == 2 - dest = opt[1:] - if ( - not dest - ) or ( - dest in "tags_help lang_list lang_help version".split() - ): + # -- CASE: dest=... keyword is missing + # DERIVE IT FROM: fixed-option words. + dest = derive_dest_from_long_option(fixed) + if not dest or (dest in CONFIGFILE_EXCLUDED_OPTIONS): continue - try: - if dest not in config["behave"]: - continue - except AttributeError as exc: - # SafeConfigParser instance has no attribute '__getitem__' (py27) - if "__getitem__" not in str(exc): - raise - if not config.has_option("behave", dest): - continue - except KeyError: + elif skip_missing and not config_has_param(config, dest): continue + + # -- FINALLY: action = keywords.get("action", "store") - yield dest, action + value_type = keywords.get("type", None) + # OLD: yield dest, action, value_type + yield ConfigFileOption(dest, action, value_type) -def format_outfiles_coupling(result, config_dir): +def format_outfiles_coupling(config_data, config_dir): # -- STEP: format/outfiles coupling - if "format" in result: + if "format" in config_data: # -- OPTIONS: format/outfiles are coupled in configuration file. - formatters = result["format"] + formatters = config_data["format"] formatter_size = len(formatters) - outfiles = result.get("outfiles", []) + outfiles = config_data.get("outfiles", []) outfiles_size = len(outfiles) if outfiles_size < formatter_size: for formatter_name in formatters[outfiles_size:]: outfile = "%s.output" % formatter_name outfiles.append(outfile) - result["outfiles"] = outfiles + config_data["outfiles"] = outfiles elif len(outfiles) > formatter_size: print("CONFIG-ERROR: Too many outfiles (%d) provided." % outfiles_size) - result["outfiles"] = outfiles[:formatter_size] + config_data["outfiles"] = outfiles[:formatter_size] for paths_name in ("paths", "outfiles"): - if paths_name in result: + if paths_name in config_data: # -- Evaluate relative paths relative to location. # NOTE: Absolute paths are preserved by os.path.join(). - paths = result[paths_name] - result[paths_name] = \ - [os.path.normpath(os.path.join(config_dir, p)) for p in paths] + paths = config_data[paths_name] + config_data[paths_name] = [ + os.path.normpath(os.path.join(config_dir, p)) + for p in paths + ] def read_configparser(path): @@ -468,25 +525,32 @@ def read_configparser(path): config = ConfigParser() config.optionxform = str # -- SUPPORT: case-sensitive keys config.read(path) - config_dir = os.path.dirname(path) - result = {} + this_config = {} + + for dest, action, value_type in configfile_options_iter(config): + param_name = dest + if dest == "tags": + # -- SPECIAL CASE: Distinguish config-file tags from command-line. + param_name = "config_tags" - for dest, action in decode_options(config): if action == "store": - result[dest] = config.get( - "behave", dest, raw=dest in raw_value_options - ) - elif action in ("store_true", "store_false"): - result[dest] = config.getboolean("behave", dest) + raw_mode = dest in RAW_VALUE_OPTIONS + value = config.get("behave", dest, raw=raw_mode) + if value_type: + value = value_type(value) # May raise ParseError/ValueError, etc. + this_config[param_name] = value + elif action == "store_true": + # -- HINT: Only non-negative options are used in config-file. + # SKIPS: --no-color, --no-snippets, ... + this_config[param_name] = config.getboolean("behave", dest) elif action == "append": - if dest == "userdata_defines": - continue # -- SKIP-CONFIGFILE: Command-line only option. - result[dest] = \ - [s.strip() for s in config.get("behave", dest).splitlines()] - else: + value_parts = config.get("behave", dest).splitlines() + this_config[param_name] = [part.strip() for part in value_parts] + elif action not in CONFIGFILE_EXCLUDED_ACTIONS: # pragma: no cover raise ValueError('action "%s" not implemented' % action) - format_outfiles_coupling(result, config_dir) + config_dir = os.path.dirname(path) + format_outfiles_coupling(this_config, config_dir) # -- STEP: Special additional configuration sections. # SCHEMA: config_section: data_name @@ -496,77 +560,95 @@ def read_configparser(path): "behave.userdata": "userdata", } for section_name, data_name in special_config_section_map.items(): - result[data_name] = {} + this_config[data_name] = {} if config.has_section(section_name): - result[data_name].update(config.items(section_name)) + this_config[data_name].update(config.items(section_name)) - return result + return this_config -def read_toml(path): - """Read configuration from pyproject.toml file. +def read_toml_config(path): + """ + Read configuration from "pyproject.toml" file. + The "behave" configuration should be stored in TOML table(s): - Configuration should be stored inside the 'tool.behave' table. + * "tool.behave" + * "tool.behave.*" - See https://www.python.org/dev/peps/pep-0518/#tool-table + SEE: https://www.python.org/dev/peps/pep-0518/#tool-table """ # pylint: disable=too-many-locals, too-many-branches - with open(path, "rb") as tomlfile: - config = json.loads(json.dumps(tomllib.load(tomlfile))) # simple dict + with open(path, "rb") as toml_file: + # -- HINT: Use simple dictionary for "config". + config = json.loads(json.dumps(tomllib.load(toml_file))) - config = config['tool'] - config_dir = os.path.dirname(path) - result = {} + config_tool = config["tool"] + this_config = {} - for dest, action in decode_options(config): - raw = config["behave"][dest] + for dest, action, value_type in configfile_options_iter(config_tool): + param_name = dest + if dest == "tags": + # -- SPECIAL CASE: Distinguish config-file tags from command-line. + param_name = "config_tags" + + raw_value = config_tool["behave"][dest] if action == "store": - result[dest] = str(raw) + this_config[param_name] = str(raw_value) elif action in ("store_true", "store_false"): - result[dest] = bool(raw) + this_config[param_name] = bool(raw_value) elif action == "append": - if dest == "userdata_defines": - continue # -- SKIP-CONFIGFILE: Command-line only option. - # toml has native arrays and quoted strings, so there's no - # need to split by newlines or strip values - result[dest] = raw - else: + # -- TOML SPECIFIC: + # TOML has native arrays and quoted strings. + # There is no need to split by newlines or strip values. + this_config[param_name] = raw_value + elif action not in CONFIGFILE_EXCLUDED_ACTIONS: raise ValueError('action "%s" not implemented' % action) - format_outfiles_coupling(result, config_dir) + + config_dir = os.path.dirname(path) + format_outfiles_coupling(this_config, config_dir) # -- STEP: Special additional configuration sections. # SCHEMA: config_section: data_name special_config_section_map = { "formatters": "more_formatters", + "runners": "more_runners", "userdata": "userdata", } for section_name, data_name in special_config_section_map.items(): - result[data_name] = {} + this_config[data_name] = {} try: - result[data_name] = values_to_str(config["behave"][section_name]) + section_data = config_tool["behave"][section_name] + this_config[data_name] = _values_to_str(section_data) except KeyError: - result[data_name] = {} + this_config[data_name] = {} + + return this_config - return result + +CONFIG_FILE_PARSERS = { + "ini": read_configparser, + "cfg": read_configparser, + "behaverc": read_configparser, +} +if _TOML_AVAILABLE: + CONFIG_FILE_PARSERS["toml"] = read_toml_config def read_configuration(path, verbose=False): - ext = path.split(".")[-1] - parsers = { - "ini": read_configparser, - "cfg": read_configparser, - "behaverc": read_configparser, - } + """ + Read the "behave" config from a config-file. - if _TOML_AVAILABLE: - parsers["toml"] = read_toml - parse_func = parsers.get(ext, None) + :param path: Path to the config-file + """ + file_extension = path.split(".")[-1] + parse_func = CONFIG_FILE_PARSERS.get(file_extension, None) if not parse_func: if verbose: - print('Unable to find a parser for "%s"' % path) + print("MISSING CONFIG-FILE PARSER FOR: %s" % path) return {} - parsed = parse_func(path) + # -- NORMAL CASE: + parsed = parse_func(path) return parsed @@ -598,35 +680,55 @@ def load_configuration(defaults, verbose=False): def setup_parser(): # construct the parser - # usage = "%(prog)s [options] [ [FILE|DIR|URL][:LINE[:LINE]*] ]+" - usage = "%(prog)s [options] [ [DIR|FILE|FILE:LINE] ]+" - description = """\ - Run a number of feature tests with behave.""" - more = """ - EXAMPLES: - behave features/ - behave features/one.feature features/two.feature - behave features/one.feature:10 - behave @features.txt - """ - parser = argparse.ArgumentParser(usage=usage, description=description) - for fixed, keywords in options: + # usage = "%(prog)s [options] [FILE|DIR|FILE:LINE|AT_FILE]+" + usage = "%(prog)s [options] [DIRECTORY|FILE|FILE:LINE|AT_FILE]*" + description = """Run a number of feature tests with behave. + +EXAMPLES: + behave features/ + behave features/one.feature features/two.feature + behave features/one.feature:10 + behave @features.txt +""" + formatter_class = argparse.RawDescriptionHelpFormatter + parser = argparse.ArgumentParser(usage=usage, + description=description, + formatter_class=formatter_class) + for fixed, keywords in OPTIONS: if not fixed: - continue # -- CONFIGFILE only. + # -- SKIP: CONFIG-FILE ONLY OPTION. + continue + if "config_help" in keywords: keywords = dict(keywords) del keywords["config_help"] parser.add_argument(*fixed, **keywords) parser.add_argument("paths", nargs="*", - help="Feature directory, file or file location (FILE:LINE).") + help="Feature directory, file or file-location (FILE:LINE).") return parser +def setup_config_file_parser(): + # -- TEST-BALLOON: Auto-documentation of config-file schema. + # COVERS: config-file.section="behave" + description = "config-file schema" + formatter_class = argparse.RawDescriptionHelpFormatter + parser = argparse.ArgumentParser(description=description, + formatter_class=formatter_class) + for fixed, keywords in configfile_options_iter(None): + if "config_help" in keywords: + keywords = dict(keywords) + config_help = keywords["config_help"] + keywords["help"] = config_help + del keywords["config_help"] + parser.add_argument(*fixed, **keywords) + return parser + class Configuration(object): """Configuration object for behave and behave runners.""" # pylint: disable=too-many-instance-attributes defaults = dict( - color='never' if sys.platform == "win32" else os.getenv('BEHAVE_COLOR', 'auto'), + color=os.getenv("BEHAVE_COLOR", COLOR_DEFAULT), jobs=1, show_snippets=True, show_skipped=True, @@ -641,12 +743,14 @@ class Configuration(object): runner=DEFAULT_RUNNER_CLASS_NAME, steps_catalog=False, summary=True, + tag_expression_protocol=TagExpressionProtocol.default(), junit=False, stage=None, userdata={}, # -- SPECIAL: default_format="pretty", # -- Used when no formatters are configured. default_tags="", # -- Used when no tags are defined. + config_tags=None, scenario_outline_annotation_schema=u"{name} -- @{row.id} {examples.name}" ) cmdline_only_options = set("userdata_defines") @@ -666,33 +770,49 @@ def __init__(self, command_args=None, load_config=True, verbose=None, :param verbose: Indicate if diagnostic output is enabled :param kwargs: Used to hand-over/overwrite default values. """ - # pylint: disable=too-many-branches, too-many-statements - if command_args is None: - command_args = sys.argv[1:] - elif isinstance(command_args, six.string_types): - encoding = select_best_encoding() or "utf-8" - if six.PY2 and isinstance(command_args, six.text_type): - command_args = command_args.encode(encoding) - elif six.PY3 and isinstance(command_args, six.binary_type): - command_args = command_args.decode(encoding) - command_args = shlex.split(command_args) - elif isinstance(command_args, (list, tuple)): - command_args = to_texts(command_args) + self.init(verbose=verbose, **kwargs) - if verbose is None: - # -- AUTO-DISCOVER: Verbose mode from command-line args. - verbose = ("-v" in command_args) or ("--verbose" in command_args) + # -- STEP: Load config-file(s) and parse command-line + command_args = self.make_command_args(command_args, verbose=verbose) + if load_config: + load_configuration(self.defaults, verbose=verbose) + parser = setup_parser() + parser.set_defaults(**self.defaults) + args = parser.parse_args(command_args) + for key, value in six.iteritems(args.__dict__): + if key.startswith("_") and key not in self.cmdline_only_options: + continue + setattr(self, key, value) - # Allow commands like `--color features/whizbang.feature` to work - # Without this, argparse will treat the positional arg as the value to - # --color and we'd get: - # argument --color: invalid choice: 'features/whizbang.feature' - # (choose from 'never', 'always', 'auto') - if '--color' in command_args: - color_arg_pos = command_args.index('--color') - if os.path.exists(command_args[color_arg_pos + 1]): - command_args.insert(color_arg_pos + 1, '--') + self.paths = [os.path.normpath(path) for path in self.paths] + self.setup_outputs(args.outfiles) + if self.steps_catalog: + self.setup_steps_catalog_mode() + if self.wip: + self.setup_wip_mode() + if self.quiet: + self.show_source = False + self.show_snippets = False + + self.setup_tag_expression() + self.setup_select_by_filters() + self.setup_stage(self.stage) + self.setup_model() + self.setup_userdata() + self.setup_runner_aliases() + + # -- FINALLY: Setup Reporters and Formatters + # NOTE: Reporters and Formatters can now use userdata information. + self.setup_reporters() + self.setup_formats() + self.show_bad_formats_and_fail(parser) + + def init(self, verbose=None, **kwargs): + """ + (Re-)Init this configuration object. + """ + self.defaults = self.make_defaults(**kwargs) self.version = None self.tags_help = None self.lang_list = None @@ -701,17 +821,18 @@ def __init__(self, command_args=None, load_config=True, verbose=None, self.junit = None self.logging_format = None self.logging_datefmt = None + self.logging_level = None self.name = None - self.scope = None + self.stage = None self.steps_catalog = None + self.tag_expression_protocol = None + self.tag_expression = None + self.tags = None + self.config_tags = None + self.default_tags = None self.userdata = None self.wip = None - self.verbose = verbose - - defaults = self.defaults.copy() - for name, value in six.iteritems(kwargs): - defaults[name] = value - self.defaults = defaults + self.verbose = verbose or False self.formatters = [] self.reporters = [] self.name_re = None @@ -724,79 +845,103 @@ def __init__(self, command_args=None, load_config=True, verbose=None, self.userdata_defines = None self.more_formatters = None self.more_runners = None - self.runner_aliases = dict(default=DEFAULT_RUNNER_CLASS_NAME) - if load_config: - load_configuration(self.defaults, verbose=verbose) - parser = setup_parser() - parser.set_defaults(**self.defaults) - args = parser.parse_args(command_args) - for key, value in six.iteritems(args.__dict__): - if key.startswith("_") and key not in self.cmdline_only_options: - continue - setattr(self, key, value) + self.runner_aliases = { + "default": DEFAULT_RUNNER_CLASS_NAME + } - # -- ATTRIBUTE-NAME-CLEANUP: - self.tag_expression = None - self._tags = self.tags - self.tags = None - if isinstance(self.default_tags, six.string_types): - self.default_tags = self.default_tags.split() + @classmethod + def make_defaults(cls, **kwargs): + data = cls.defaults.copy() + for name, value in six.iteritems(kwargs): + data[name] = value + return data - self.paths = [os.path.normpath(path) for path in self.paths] - self.setup_outputs(args.outfiles) + def has_colored_mode(self, file=None): + if self.color in COLOR_ON_VALUES: + return True + elif self.color in COLOR_OFF_VALUES: + return False + else: + # -- AUTO-DETECT: color="auto" + output_file = file or sys.stdout + isatty = getattr(output_file, "isatty", lambda: True) + colored = isatty() + return colored - if self.steps_catalog: - # -- SHOW STEP-CATALOG: As step summary. - self.default_format = "steps.catalog" - if self.format: - self.format.append("steps.catalog") - else: - self.format = ["steps.catalog"] - self.dry_run = True - self.summary = False - self.show_skipped = False - self.quiet = True + def make_command_args(self, command_args=None, verbose=None): + # pylint: disable=too-many-branches, too-many-statements + if command_args is None: + command_args = sys.argv[1:] + elif isinstance(command_args, six.string_types): + encoding = select_best_encoding() or "utf-8" + if six.PY2 and isinstance(command_args, six.text_type): + command_args = command_args.encode(encoding) + elif six.PY3 and isinstance(command_args, six.binary_type): + command_args = command_args.decode(encoding) + command_args = shlex.split(command_args) + elif isinstance(command_args, (list, tuple)): + command_args = to_texts(command_args) - if self.wip: - # Only run scenarios tagged with "wip". - # Additionally: - # * use the "plain" formatter (per default) - # * do not capture stdout or logging output and - # * stop at the first failure. - self.default_format = "plain" - self._tags = ["wip"] + self.default_tags - self.color = False - self.stop = True - self.log_capture = False - self.stdout_capture = False - - self.tag_expression = make_tag_expression(self._tags or self.default_tags) - # -- BACKWARD-COMPATIBLE (BAD-NAMING STYLE; deprecating): - self.tags = self.tag_expression + # -- SUPPORT OPTION: --color=VALUE and --color (without VALUE) + # HACK: Should be handled in command-line parser specification. + # OPTION: --color=value, --color (hint: with optional value) + # SUPPORTS: + # behave --color features/some.feature # PROBLEM-POINT + # behave --color=auto features/some.feature # NO_PROBLEM + # behave --color auto features/some.feature # NO_PROBLEM + if "--color" in command_args: + color_arg_pos = command_args.index("--color") + next_arg = command_args[color_arg_pos + 1] + if os.path.exists(next_arg): + command_args.insert(color_arg_pos + 1, "--") - if self.quiet: - self.show_source = False - self.show_snippets = False + if verbose is None: + # -- AUTO-DISCOVER: Verbose mode from command-line args. + verbose = ("-v" in command_args) or ("--verbose" in command_args) + self.verbose = verbose + return command_args + + def setup_wip_mode(self): + # Only run scenarios tagged with "wip". + # Additionally: + # * use the "plain" formatter (per default) + # * do not capture stdout or logging output and + # * stop at the first failure. + self.default_format = "plain" + self.color = "off" + self.stop = True + self.log_capture = False + self.stdout_capture = False + + # -- EXTEND TAG-EXPRESSION: Add @wip tag + self.tags = self.tags or [] + if self.tags and isinstance(self.tags, six.string_types): + self.tags = [self.tags] + self.tags.append("@wip") + + def setup_steps_catalog_mode(self): + # -- SHOW STEP-CATALOG: As step summary. + self.default_format = "steps.catalog" + self.format = self.format or [] + if self.format: + self.format.append("steps.catalog") + else: + self.format = ["steps.catalog"] + self.dry_run = True + self.summary = False + self.show_skipped = False + self.quiet = True + def setup_select_by_filters(self): if self.exclude_re: self.exclude_re = re.compile(self.exclude_re) - if self.include_re: self.include_re = re.compile(self.include_re) if self.name: # -- SELECT: Scenario-by-name, build regular expression. self.name_re = self.build_name_re(self.name) - if self.stage is None: # pylint: disable=access-member-before-definition - # -- USE ENVIRONMENT-VARIABLE, if stage is undefined. - self.stage = os.environ.get("BEHAVE_STAGE", None) - self.setup_stage(self.stage) - self.setup_model() - self.setup_userdata() - self.setup_runner_aliases() - - # -- FINALLY: Setup Reporters and Formatters - # NOTE: Reporters and Formatters can now use userdata information. + def setup_reporters(self): if self.junit: # Buffer the output (it will be put into Junit report) self.stdout_capture = True @@ -806,7 +951,10 @@ def __init__(self, command_args=None, load_config=True, verbose=None, if self.summary: self.reporters.append(SummaryReporter(self)) - self.setup_formats() + def show_bad_formats_and_fail(self, parser): + """ + Show any BAD-FORMATTER(s) and fail with ``ParseError``if any exists. + """ bad_formats_and_errors = self.select_bad_formats_with_errors() if bad_formats_and_errors: bad_format_parts = [] @@ -815,6 +963,54 @@ def __init__(self, command_args=None, load_config=True, verbose=None, bad_format_parts.append(message) parser.error("BAD_FORMAT=%s" % ", ".join(bad_format_parts)) + def setup_tag_expression(self, tags=None): + """ + Build the tag_expression object from: + + * command-line tags (as tag-expression text) + * config-file tags (as tag-expression text) + """ + config_tags = self.config_tags or self.default_tags or "" + tags = tags or self.tags or config_tags + # DISABLED: tags = self._normalize_tags(tags) + + # -- STEP: Support that tags on command-line can use config-file.tags + TagExpressionProtocol.use(self.tag_expression_protocol) + config_tag_expression = make_tag_expression(config_tags) + placeholder = "{config.tags}" + placeholder_value = "{0}".format(config_tag_expression) + if isinstance(tags, six.string_types) and placeholder in tags: + tags = tags.replace(placeholder, placeholder_value) + elif isinstance(tags, (list, tuple)): + for index, item in enumerate(tags): + if placeholder in item: + new_item = item.replace(placeholder, placeholder_value) + tags[index] = new_item + + # -- STEP: Make tag-expression + self.tag_expression = make_tag_expression(tags) + self.tags = tags + + # def _normalize_tags(self, tags): + # if isinstance(tags, six.string_types): + # if tags.startswith('"') and tags.endswith('"'): + # return tags[1:-1] + # elif tags.startswith("'") and tags.endswith("'"): + # return tags[1:-1] + # return tags + # elif not isinstance(tags, (list, tuple)): + # raise TypeError("EXPECTED: string, sequence", tags) + # + # # -- CASE: sequence + # unquote_needed = (any('"' in part for part in tags) or + # any("'" in part for part in tags)) + # if unquote_needed: + # parts = [] + # for part in tags: + # parts.append(self._normalize_tags(part)) + # tags = parts + # return tags + def setup_outputs(self, args_outfiles=None): if self.outputs: assert not args_outfiles, "ONLY-ONCE" @@ -852,7 +1048,7 @@ def select_bad_formats_with_errors(self): try: _ = _format_registry.select_formatter_class(format_name) bad_formats.append((format_name, "InvalidClassError")) - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught formatter_error = e.__class__.__name__ if formatter_error == "KeyError": formatter_error = "LookupError" @@ -911,8 +1107,7 @@ def before_all(context): level = self.logging_level # pylint: disable=no-member if configfile: - from logging.config import fileConfig - fileConfig(configfile) + logging_config_fileConfig(configfile) else: # pylint: disable=no-member format_ = kwargs.pop("format", self.logging_format) @@ -928,7 +1123,8 @@ def setup_model(self): ScenarioOutline.annotation_schema = name_schema.strip() def setup_stage(self, stage=None): - """Setup the test stage that selects a different set of + """ + Set up the test stage that selects a different set of steps and environment implementations. :param stage: Name of current test stage (as string or None). @@ -946,6 +1142,10 @@ def setup_stage(self, stage=None): assert config.steps_dir == "product_steps" assert config.environment_file == "product_environment.py" """ + if stage is None: + # -- USE ENVIRONMENT-VARIABLE, if stage is undefined. + stage = os.environ.get("BEHAVE_STAGE", None) + steps_dir = "steps" environment_file = "environment.py" if stage: @@ -953,6 +1153,9 @@ def setup_stage(self, stage=None): prefix = stage + "_" steps_dir = prefix + steps_dir environment_file = prefix + environment_file + + # -- STORE STAGE-CONFIGURATION: + self.stage = stage self.steps_dir = steps_dir self.environment_file = environment_file diff --git a/behave/tag_expression/__init__.py b/behave/tag_expression/__init__.py index c2d7e5f16..bf6d7d704 100644 --- a/behave/tag_expression/__init__.py +++ b/behave/tag_expression/__init__.py @@ -37,7 +37,10 @@ class TagExpressionProtocol(Enum): """ ANY = 1 STRICT = 2 - DEFAULT = ANY + + @classmethod + def default(cls): + return cls.ANY @classmethod def choices(cls): @@ -64,7 +67,7 @@ def select_parser(self, tag_expression_text_or_seq): @classmethod def current(cls): """Return currently selected protocol instance.""" - return getattr(cls, "_current", cls.DEFAULT) + return getattr(cls, "_current", cls.default()) @classmethod def use(cls, member): diff --git a/docs/behave.rst b/docs/behave.rst index 93a25a9c6..cc80eafc2 100644 --- a/docs/behave.rst +++ b/docs/behave.rst @@ -15,14 +15,13 @@ Command-Line Arguments You may see the same information presented below at any time using ``behave -h``. -.. option:: -c, --no-color +.. option:: -C, --no-color - Disable the use of ANSI color escapes. + Disable colored mode. .. option:: --color - Use ANSI color escapes. Defaults to %(const)r. This switch is used to - override a configuration file setting. + Use colored mode or not (default: auto). .. option:: -d, --dry-run @@ -255,31 +254,29 @@ You may see the same information presented below at any time using ``behave Tag Expression -------------- -Scenarios inherit tags that are declared on the Feature level. -The simplest TAG_EXPRESSION is simply a tag:: - - --tags=@dev - -You may even leave off the "@" - behave doesn't mind. - -You can also exclude all features / scenarios that have a tag, -by using boolean NOT:: - - --tags="not @dev" +TAG-EXPRESSIONS selects Features/Rules/Scenarios by using their tags. +A TAG-EXPRESSION is a boolean expression that references some tags. -A tag expression can also use a logical OR:: +EXAMPLES: - --tags="@dev or @wip" + --tags=@smoke + --tags="not @xfail" + --tags="@smoke or @wip" + --tags="@smoke and @wip" + --tags="(@slow and not @fixme) or @smoke" + --tags="not (@fixme or @xfail)" -The --tags option can be specified several times, -and this represents logical AND, -for instance this represents the boolean expression:: +NOTES: - --tags="(@foo or not @bar) and @zap" +* The tag-prefix "@" is optional. +* An empty tag-expression is "true" (select-anything). -You can also exclude several tags:: +TAG-INHERITANCE: - --tags="not (@fixme or @buggy)" +* A Rule inherits the tags of its Feature +* A Scenario inherits the tags of its Feature or Rule. +* A Scenario of a ScenarioOutline/ScenarioTemplate inherit tags + from this ScenarioOutline/ScenarioTemplate and its Example table. .. _docid.behave.configuration-files: @@ -355,10 +352,9 @@ Configuration Parameters .. index:: single: configuration param; color -.. describe:: color : text +.. describe:: color : Colored (Enum) - Use ANSI color escapes. Defaults to %(const)r. This switch is used to - override a configuration file setting. + Use colored mode or not (default: auto). .. index:: single: configuration param; dry_run @@ -573,6 +569,16 @@ Configuration Parameters Specify default feature paths, used when none are provided. +.. index:: + single: configuration param; tag_expression_protocol + +.. describe:: tag_expression_protocol : TagExpressionProtocol (Enum) + + Specify the tag-expression protocol to use (default: any). With "any", + tag-expressions v2 and v2 are supported (in auto-detect mode). + With "strict", only tag-expressions v2 is supported (better error + diagnostics). + .. index:: single: configuration param; quiet diff --git a/docs/update_behave_rst.py b/docs/update_behave_rst.py index 90965f00f..d0e97cf3e 100755 --- a/docs/update_behave_rst.py +++ b/docs/update_behave_rst.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +# TODO: # -*- coding: UTF-8 -*- """ Generates documentation of behave's @@ -15,7 +16,7 @@ import conf import textwrap from behave import configuration -from behave.__main__ import TAG_HELP +from behave.__main__ import TAG_EXPRESSIONS_HELP positive_number = configuration.positive_number @@ -36,15 +37,23 @@ {text} """ +def is_no_option(fixed_options): + return any([opt.startswith("--no") for opt in fixed_options]) + + # -- STEP: Collect information and preprocess it. -for fixed, keywords in configuration.options: +for fixed, keywords in configuration.OPTIONS: skip = False + config_file_param = True + if is_no_option(fixed): + # -- EXCLUDE: --no-xxx option + config_file_param = False + if "dest" in keywords: dest = keywords["dest"] else: for opt in fixed: if opt.startswith("--no"): - option_case = False skip = True if opt.startswith("--"): dest = opt[2:].replace("-", "_") @@ -54,22 +63,30 @@ dest = opt[1:] # -- COMMON PART: + type_name_default = "text" + type_name_map = { + "color": "Colored (Enum)", + "tag_expression_protocol": "TagExpressionProtocol (Enum)", + } + type_name = "string" action = keywords.get("action", "store") data_type = keywords.get("type", None) default_value = keywords.get("default", None) - if action == "store": - type = "text" + if action in ("store", "store_const"): + type_name = "text" if data_type is positive_number: - type = "positive_number" - if data_type is int: - type = "number" + type_name = "positive_number" + elif data_type is int: + type_name = "number" + else: + type_name = type_name_map.get(dest, type_name_default) elif action in ("store_true","store_false"): - type = "bool" + type_name = "bool" default_value = False if action == "store_true": default_value = True elif action == "append": - type = "sequence" + type_name = "sequence" else: raise ValueError("unknown action %s" % action) @@ -90,7 +107,7 @@ continue # -- CASE: configuration-file parameter - if action == "store_false": + if not config_file_param or action == "store_false": # -- AVOID: Duplicated descriptions, use only case:true. continue @@ -99,7 +116,7 @@ if default_value and "%(default)s" in text: text = text.replace("%(default)s", str(default_value)) text = textwrap.fill(text, 70, initial_indent="", subsequent_indent=indent) - config.append(config_param_schema.format(param=dest, type=type, text=text)) + config.append(config_param_schema.format(param=dest, type=type_name, text=text)) # -- STEP: Generate documentation. @@ -109,7 +126,7 @@ values = dict( cmdline="\n".join(cmdline), - tag_expression=TAG_HELP, + tag_expression=TAG_EXPRESSIONS_HELP, config="\n".join(config), ) with open("behave.rst", "w") as f: diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index e66b3f80f..50bce2f58 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -1,24 +1,30 @@ +from __future__ import absolute_import, print_function +from contextlib import contextmanager import os.path +from pathlib import Path import sys import six import pytest from behave import configuration -from behave.configuration import Configuration, UserData +from behave.configuration import ( + Configuration, + ConfigFileOption, + UserData, + configfile_options_iter +) +from behave.tag_expression import TagExpressionProtocol from unittest import TestCase # one entry of each kind handled # configparser and toml TEST_CONFIGS = [ - ( - ".behaverc", - """[behave] + (".behaverc", """[behave] outfiles= /absolute/path1 relative/path2 paths = /absolute/path3 relative/path4 -default_tags = @foo,~@bar - @zap +default_tags = (@foo and not @bar) or @zap format=pretty tag-counter stdout_capture=no @@ -28,12 +34,12 @@ foo = bar answer = 42 """), - ( - "pyproject.toml", - """[tool.behave] + + # -- TOML CONFIG-FILE: + ("pyproject.toml", """[tool.behave] outfiles = ["/absolute/path1", "relative/path2"] paths = ["/absolute/path3", "relative/path4"] -default_tags = ["@foo,~@bar", "@zap"] +default_tags = ["(@foo and not @bar) or @zap"] format = ["pretty", "tag-counter"] stdout_capture = false bogus = "spam" @@ -57,22 +63,46 @@ ROOTDIR_PREFIX = os.environ.get("BEHAVE_ROOTDIR_PREFIX", ROOTDIR_PREFIX_DEFAULT) +@contextmanager +def use_current_directory(directory_path): + """Use directory as current directory. + + :: + + with use_current_directory("/tmp/some_directory"): + pass # DO SOMETHING in current directory. + # -- ON EXIT: Restore old current-directory. + """ + # -- COMPATIBILITY: Use directory-string instead of Path + initial_directory = str(Path.cwd()) + try: + os.chdir(str(directory_path)) + yield directory_path + finally: + os.chdir(initial_directory) + + # ----------------------------------------------------------------------------- # TEST SUITE: # ----------------------------------------------------------------------------- class TestConfiguration(object): - @pytest.mark.parametrize( - ("filename", "contents"), - list(TEST_CONFIGS) - ) + @pytest.mark.parametrize(("filename", "contents"), list(TEST_CONFIGS)) def test_read_file(self, filename, contents, tmp_path): tndir = str(tmp_path) file_path = os.path.normpath(os.path.join(tndir, filename)) with open(file_path, "w") as fp: fp.write(contents) + # -- WINDOWS-REQUIRES: normpath + # DISABLED: pprint(d, sort_dicts=True) + from pprint import pprint + extra_kwargs = {} + if six.PY3: + extra_kwargs = {"sort_dicts": True} + d = configuration.read_configuration(file_path) + pprint(d, **extra_kwargs) assert d["outfiles"] == [ os.path.normpath(ROOTDIR_PREFIX + "/absolute/path1"), os.path.normpath(os.path.join(tndir, "relative/path2")), @@ -82,7 +112,7 @@ def test_read_file(self, filename, contents, tmp_path): os.path.normpath(os.path.join(tndir, "relative/path4")), ] assert d["format"] == ["pretty", "tag-counter"] - assert d["default_tags"] == ["@foo,~@bar", "@zap"] + assert d["default_tags"] == ["(@foo and not @bar) or @zap"] assert d["stdout_capture"] is False assert "bogus" not in d assert d["userdata"] == {"foo": "bar", "answer": "42"} @@ -204,3 +234,105 @@ def test_update_userdata__without_cmdline_defines(self): expected_data = dict(person1="Alice", person2="Bob", person3="Charly") assert config.userdata == expected_data assert config.userdata_defines is None + + +class TestConfigFileParser(object): + + def test_configfile_iter__verify_option_names(self): + config_options = configfile_options_iter(None) + config_options_names = [opt[0] for opt in config_options] + expected_names = [ + "color", + "default_format", + "default_tags", + "dry_run", + "exclude_re", + "format", + "include_re", + "jobs", + "junit", + "junit_directory", + "lang", + "log_capture", + "logging_clear_handlers", + "logging_datefmt", + "logging_filter", + "logging_format", + "logging_level", + "name", + "outfiles", + "paths", + "quiet", + "runner", + "scenario_outline_annotation_schema", + "show_multiline", + "show_skipped", + "show_snippets", + "show_source", + "show_timings", + "stage", + "stderr_capture", + "stdout_capture", + "steps_catalog", + "stop", + "summary", + "tag_expression_protocol", + "tags", + "verbose", + "wip", + ] + assert sorted(config_options_names) == expected_names + + +class TestConfigFile(object): + + @staticmethod + def make_config_file_with_tag_expression_protocol(value, tmp_path): + config_file = tmp_path / "behave.ini" + config_file.write_text(u""" +[behave] +tag_expression_protocol = {value} +""".format(value=value)) + assert config_file.exists() + + @classmethod + def check_tag_expression_protocol_with_valid_value(cls, value, tmp_path): + TagExpressionProtocol.use(TagExpressionProtocol.default()) + cls.make_config_file_with_tag_expression_protocol(value, tmp_path) + with use_current_directory(tmp_path): + config = Configuration() + print("USE: tag_expression_protocol.value={0}".format(value)) + print("USE: config.tag_expression_protocol={0}".format( + config.tag_expression_protocol)) + + assert config.tag_expression_protocol in TagExpressionProtocol + assert TagExpressionProtocol.current() is config.tag_expression_protocol + + @pytest.mark.parametrize("value", TagExpressionProtocol.choices()) + def test_tag_expression_protocol(self, value, tmp_path): + self.check_tag_expression_protocol_with_valid_value(value, tmp_path) + + @pytest.mark.parametrize("value", ["Any", "ANY", "Strict", "STRICT"]) + def test_tag_expression_protocol__is_not_case_sensitive(self, value, tmp_path): + self.check_tag_expression_protocol_with_valid_value(value, tmp_path) + + @pytest.mark.parametrize("value", [ + "__UNKNOWN__", "v1", "v2", + # -- SIMILAR: to valid values + ".any", "any.", "_strict", "strict_" + ]) + def test_tag_expression_protocol__with_invalid_value_raises_error(self, value, tmp_path): + default_value = TagExpressionProtocol.default() + TagExpressionProtocol.use(default_value) + self.make_config_file_with_tag_expression_protocol(value, tmp_path) + with use_current_directory(tmp_path): + with pytest.raises(ValueError) as exc_info: + config = Configuration() + print("USE: tag_expression_protocol.value={0}".format(value)) + print("USE: config.tag_expression_protocol={0}".format( + config.tag_expression_protocol)) + + assert TagExpressionProtocol.current() is default_value + expected = "{value} (expected: any, strict)".format(value=value) + assert exc_info.type is ValueError + assert expected in str(exc_info.value) From 9510817eb156c3d12e9216561c9acc5284f0f3fa Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 8 Jun 2023 22:43:19 +0200 Subject: [PATCH 105/240] FIXED #1116: behave erroring in pretty format in pyproject.toml * Provide feature-file for problem * Improve diagnostics if wrong type is used for config-param --- CHANGES.rst | 1 + behave/__main__.py | 3 +- behave/configuration.py | 15 ++++-- behave/exception.py | 4 ++ issue.features/issue1116.feature | 93 ++++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 issue.features/issue1116.feature diff --git a/CHANGES.rst b/CHANGES.rst index c3a7928bd..7bf80c0da 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -61,6 +61,7 @@ FIXED: * FIXED: Some tests related to python3.11 * FIXED: Some tests related to python3.9 * FIXED: active-tag logic if multiple tags with same category exists. +* issue #1116: behave erroring in pretty format in pyproject.toml (submitted by: morning-sunn) * issue #1061: Scenario should inherit Rule tags (submitted by: testgitdl) * issue #1054: TagExpressions v2: AND concatenation is faulty (submitted by: janoskut) * pull #967: Update __init__.py in behave import to fix pylint (provided by: dsayling) diff --git a/behave/__main__.py b/behave/__main__.py index 346533d7f..dfc59ccc2 100644 --- a/behave/__main__.py +++ b/behave/__main__.py @@ -289,7 +289,8 @@ def main(args=None): config = Configuration(args) return run_behave(config) except ConfigError as e: - print("ConfigError: %s" % e) + exception_class_name = e.__class__.__name__ + print("%s: %s" % (exception_class_name, e)) except TagExpressionError as e: print("TagExpressionError: %s" % e) return 1 # FAILED: diff --git a/behave/configuration.py b/behave/configuration.py index 3b28409e4..69ddcb551 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -24,6 +24,8 @@ import six from six.moves import configparser +from behave._types import Unknown +from behave.exception import ConfigParamTypeError from behave.model import ScenarioOutline from behave.model_core import FileLocation from behave.formatter.base import StreamOpener @@ -31,9 +33,8 @@ from behave.reporter.junit import JUnitReporter from behave.reporter.summary import SummaryReporter from behave.tag_expression import make_tag_expression, TagExpressionProtocol -from behave.userdata import UserData, parse_user_define -from behave._types import Unknown from behave.textutil import select_best_encoding, to_texts +from behave.userdata import UserData, parse_user_define # -- PYTHON 2/3 COMPATIBILITY: # SINCE Python 3.2: ConfigParser = SafeConfigParser @@ -487,7 +488,6 @@ def config_has_param(config, param_name): # -- FINALLY: action = keywords.get("action", "store") value_type = keywords.get("type", None) - # OLD: yield dest, action, value_type yield ConfigFileOption(dest, action, value_type) @@ -545,7 +545,8 @@ def read_configparser(path): this_config[param_name] = config.getboolean("behave", dest) elif action == "append": value_parts = config.get("behave", dest).splitlines() - this_config[param_name] = [part.strip() for part in value_parts] + value_type = value_type or six.text_type + this_config[param_name] = [value_type(part.strip()) for part in value_parts] elif action not in CONFIGFILE_EXCLUDED_ACTIONS: # pragma: no cover raise ValueError('action "%s" not implemented' % action) @@ -600,6 +601,12 @@ def read_toml_config(path): # -- TOML SPECIFIC: # TOML has native arrays and quoted strings. # There is no need to split by newlines or strip values. + value_type = value_type or six.text_type + if not isinstance(raw_value, list): + message = "%s = %r (expected: list<%s>, was: %s)" % \ + (param_name, raw_value, value_type.__name__, + type(raw_value).__name__) + raise ConfigParamTypeError(message) this_config[param_name] = raw_value elif action not in CONFIGFILE_EXCLUDED_ACTIONS: raise ValueError('action "%s" not implemented' % action) diff --git a/behave/exception.py b/behave/exception.py index b2375469d..1a2d17c72 100644 --- a/behave/exception.py +++ b/behave/exception.py @@ -18,6 +18,7 @@ __all__ = [ "ClassNotFoundError", "ConfigError", + "ConfigTypeError", "ConstraintError", "FileNotFoundError", "InvalidClassError", @@ -52,6 +53,9 @@ class ResourceExistsError(ConstraintError): class ConfigError(Exception): """Used if the configuration is (partially) invalid.""" +class ConfigParamTypeError(ConfigError): + """Used if a config-param has the wrong type.""" + # --------------------------------------------------------------------------- # EXCEPTION/ERROR CLASSES: Related to File Handling diff --git a/issue.features/issue1116.feature b/issue.features/issue1116.feature new file mode 100644 index 000000000..d83f8662a --- /dev/null +++ b/issue.features/issue1116.feature @@ -0,0 +1,93 @@ +@issue +@user.failure +Feature: Issue #1116 -- behave erroring in pretty format in pyproject.toml + + . DESCRIPTION OF OBSERVED BEHAVIOR: + . * I am using a "pyproject.toml" with behave-configuration + . * I am using 'format = "pretty"' in the TOML config + . * When I run it with "behave", I get the following error message: + . + . behave: error: BAD_FORMAT=p (problem: LookupError), r (problem: LookupError), ... + . + . PROBLEM ANALYSIS: + . * Config-param: format : sequence = ${default_format} + . * Wrong type "string" was used for "format" config-param. + . + . PROBLEM RESOLUTION: + . * Works fine if the correct type is used. + . * BUT: Improve diagnostics if wrong type is used. + + Background: Setup + Given a new working directory + And a file named "features/steps/use_step_library.py" with: + """ + import behave4cmd0.passing_steps + """ + And a file named "features/simple.feature" with: + """ + Feature: F1 + Scenario: S1 + Given a step passes + When another step passes + """ + + # @use.with_python.min_version="3.0" + @use.with_python3=true + Scenario: Use Problematic Config-File (case: Python 3.x) + Given a file named "pyproject.toml" with: + """ + [tool.behave] + format = "pretty" + """ + When I run "behave features/simple.feature" + Then it should fail with: + """ + ConfigParamTypeError: format = 'pretty' (expected: list, was: str) + """ + And the command output should not contain: + """ + behave: error: BAD_FORMAT=p (problem: LookupError), r (problem: LookupError), + """ + But note that "format config-param uses a string type (expected: list)" + + + # @not.with_python.min_version="3.0" + @use.with_python2=true + Scenario: Use Problematic Config-File (case: Python 2.7) + Given a file named "pyproject.toml" with: + """ + [tool.behave] + format = "pretty" + """ + When I run "behave features/simple.feature" + Then it should fail with: + """ + ConfigParamTypeError: format = u'pretty' (expected: list, was: unicode) + """ + And the command output should not contain: + """ + behave: error: BAD_FORMAT=p (problem: LookupError), r (problem: LookupError), + """ + But note that "format config-param uses a string type (expected: list)" + + + Scenario: Use Good Config-File + Given a file named "pyproject.toml" with: + """ + [tool.behave] + format = ["pretty"] + """ + When I run "behave features/simple.feature" + Then it should pass with: + """ + 1 scenario passed, 0 failed, 0 skipped + """ + And the command output should contain: + """ + Feature: F1 # features/simple.feature:1 + + Scenario: S1 # features/simple.feature:2 + Given a step passes # ../behave4cmd0/passing_steps.py:23 + When another step passes # ../behave4cmd0/passing_steps.py:23 + """ + But note that "the correct format config-param type was used now" From 32acf24084eddccbdfd57d97b451fe08fe768e49 Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 8 Jun 2023 22:45:24 +0200 Subject: [PATCH 106/240] ADDED: pyproject.toml * Used only for ruff linter configuration (for now) --- pyproject.toml | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..23abedbcd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +# ----------------------------------------------------------------------------- +# SECTION: ruff -- Python linter +# ----------------------------------------------------------------------------- +# SEE: https://github.com/charliermarsh/ruff +# Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. +[tool.ruff] +select = ["E", "F"] +ignore = [] + +# Allow autofix for all enabled rules (when `--fix`) is provided. +fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", + "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", + "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", + "TCH", "TID", "TRY", "UP", "YTT" +] +unfixable = [] + +# Exclude a variety of commonly ignored directories. +exclude = [ + ".direnv", + ".eggs", + ".git", + ".ruff_cache", + ".tox", + ".venv*", + "__pypackages__", + "build", + "dist", + "venv", +] +per-file-ignores = {} + +# Same as Black. +# WAS: line-length = 88 +line-length = 100 + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" +target-version = "py310" + +[tool.ruff.mccabe] +max-complexity = 10 From d93183a0dea250562ac9a755ac46f30237d81594 Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 8 Jun 2023 22:55:32 +0200 Subject: [PATCH 107/240] UPDATE: #1070 status --- CHANGES.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.rst b/CHANGES.rst index 7bf80c0da..8b7bba822 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -61,6 +61,7 @@ FIXED: * FIXED: Some tests related to python3.11 * FIXED: Some tests related to python3.9 * FIXED: active-tag logic if multiple tags with same category exists. +* issue #1070: Color support detection: Fails for WindowsTerminal (provided by: jenisys) * issue #1116: behave erroring in pretty format in pyproject.toml (submitted by: morning-sunn) * issue #1061: Scenario should inherit Rule tags (submitted by: testgitdl) * issue #1054: TagExpressions v2: AND concatenation is faulty (submitted by: janoskut) From c12aa0e471af4228a1e850fc958e043a8413a072 Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 8 Jun 2023 23:20:44 +0200 Subject: [PATCH 108/240] UPDATE: CHANGES * Add info on goals for next "behave" release (in the future) --- CHANGES.rst | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 8b7bba822..7069b8891 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,32 @@ Version History =============================================================================== +Version: 1.4.0 (planning) +------------------------------------------------------------------------------- + +GOALS: + +* Drop support for Python 2.7 +* MAYBE: Requires Python >= 3.7 (at least) + +DEPRECATIONS: + +* DEPRECATED: ``tag-expressions v1`` (old-style tag-expressions) + + +Version: 1.3.0 (planning) +------------------------------------------------------------------------------- + +GOALS: + +* Will be released on https://pypi.org +* Inlude all changes from behave v1.2.7 development +* Last version minor version with Python 2.7 support +* ``tag-expressions v2``: Enabled by default ("strict" mode: only v2 supported). +* ``tag-expressions v1``: Disabled by default (in "strict" mode). + BUT: Can be enabled via config-file parameter in "any" mode (supports: v1 and v2). + + Version: 1.2.7 (unreleased) ------------------------------------------------------------------------------- @@ -102,9 +128,10 @@ DOCUMENTATION: BREAKING CHANGES (naming): -* behave.runner.Context._push(layer=None): Was Context._push(layer_name=None) +* behave.configuration.OPTIONS: was ``behave.configuration.options`` +* behave.runner.Context._push(layer=None): was Context._push(layer_name=None) * behave.runner.scoped_context_layer(context, layer=None): - Was scoped_context_layer(context.layer_name=None) + was scoped_context_layer(context.layer_name=None) .. _`cucumber-tag-expressions`: https://pypi.org/project/cucumber-tag-expressions/ From b7ebfdca1530e6716afde6e4b802b85a18971e31 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 10 Jun 2023 08:53:19 +0200 Subject: [PATCH 109/240] CLEANUP: configuration * Tweak the CLI help output for "tag_expression_protocol" * show_bad_formats_and_fail(): Check expected-type first * Move import statement to TOP of file. FIXED: * Linter warnings --- behave/configuration.py | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/behave/configuration.py b/behave/configuration.py index 69ddcb551..c62c23ecc 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -14,6 +14,7 @@ from __future__ import absolute_import, print_function import argparse +from collections import namedtuple import json import logging from logging.config import fileConfig as logging_config_fileConfig @@ -321,8 +322,8 @@ def positive_number(text): default=TagExpressionProtocol.default().name.lower(), help="""\ Specify the tag-expression protocol to use (default: %(default)s). -With "any", tag-expressions v2 and v2 are supported (in auto-detect mode). -With "strict", only tag-expressions v2 is supported (better error diagnostics). +With "any", tag-expressions v1 and v2 are supported (in auto-detect mode). +With "strict", only tag-expressions v2 are supported (better error diagnostics). """)), (("-q", "--quiet"), @@ -444,7 +445,7 @@ def _values_to_str(data): def has_negated_option(option_words): - return any([word.startswith("--no-") for word in option_words]) + return any(word.startswith("--no-") for word in option_words) def derive_dest_from_long_option(fixed_options): @@ -453,8 +454,7 @@ def derive_dest_from_long_option(fixed_options): return option_name[2:].replace("-", "_") return None -# -- TEST-BALLOON: -from collections import namedtuple + ConfigFileOption = namedtuple("ConfigFileOption", ("dest", "action", "type")) @@ -463,7 +463,7 @@ def configfile_options_iter(config): def config_has_param(config, param_name): try: return param_name in config["behave"] - except AttributeError as exc: # pragma: no cover + except AttributeError: # pragma: no cover # H-- INT: PY27: SafeConfigParser instance has no attribute "__getitem__" return config.has_option("behave", param_name) except KeyError: @@ -474,7 +474,7 @@ def config_has_param(config, param_name): if has_negated_option(fixed) or action == "store_false": # -- SKIP NEGATED OPTIONS, like: --no-color continue - elif "dest" in keywords: + if "dest" in keywords: dest = keywords["dest"] else: # -- CASE: dest=... keyword is missing @@ -482,7 +482,7 @@ def config_has_param(config, param_name): dest = derive_dest_from_long_option(fixed) if not dest or (dest in CONFIGFILE_EXCLUDED_OPTIONS): continue - elif skip_missing and not config_has_param(config, dest): + if skip_missing and not config_has_param(config, dest): continue # -- FINALLY: @@ -866,14 +866,14 @@ def make_defaults(cls, **kwargs): def has_colored_mode(self, file=None): if self.color in COLOR_ON_VALUES: return True - elif self.color in COLOR_OFF_VALUES: + if self.color in COLOR_OFF_VALUES: return False - else: - # -- AUTO-DETECT: color="auto" - output_file = file or sys.stdout - isatty = getattr(output_file, "isatty", lambda: True) - colored = isatty() - return colored + + # -- OTHERWISE in AUTO-DETECT mode: color="auto" + output_file = file or sys.stdout + isatty = getattr(output_file, "isatty", lambda: True) + colored = isatty() + return colored def make_command_args(self, command_args=None, verbose=None): # pylint: disable=too-many-branches, too-many-statements @@ -962,6 +962,11 @@ def show_bad_formats_and_fail(self, parser): """ Show any BAD-FORMATTER(s) and fail with ``ParseError``if any exists. """ + # -- SANITY-CHECK FIRST: Is correct type used for "config.format" + if self.format is not None and not isinstance(self.format, list): + parser.error("CONFIG-PARAM-TYPE-ERROR: format = %r (expected: list<%s>, was: %s)" % + (self.format, six.text_type, type(self.format).__name__)) + bad_formats_and_errors = self.select_bad_formats_with_errors() if bad_formats_and_errors: bad_format_parts = [] From 907b4932540d87ba41fc7324b2f7ed9c9cccce6b Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 12 Jun 2023 13:12:23 +0200 Subject: [PATCH 110/240] UPDATE: README.rst * Describe procedure in more detail * Correct old hyperlinks with current usable ones. --- etc/gherkin/README.rst | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/etc/gherkin/README.rst b/etc/gherkin/README.rst index 7ec21081b..3d098049e 100644 --- a/etc/gherkin/README.rst +++ b/etc/gherkin/README.rst @@ -1,4 +1,23 @@ -SOURCE: +behave i18n (gherkin-languages.json) +===================================================================================== -* https://github.com/cucumber/cucumber/blob/master/gherkin/gherkin-languages.json -* https://raw.githubusercontent.com/cucumber/cucumber/master/gherkin/gherkin-languages.json +`behave`_ uses the official `cucumber`_ `gherkin-languages.json`_ file +to keep track of step keywords for any I18n spoken language. + +Use the following procedure if any language keywords are missing/should-be-corrected, etc. + +**PROCEDURE:** + +* Make pull-request on: https://github.com/cucumber/gherkin repository +* After it is merged, I pull the new version of `gherkin-languages.json` and generate `behave/i18n.py` from it +* OPTIONAL: Give an info that it is merged (if I am missing this state-change) + +SEE ALSO: + +* https://github.com/cucumber/gherkin +* https://github.com/cucumber/gherkin/blob/main/gherkin-languages.json +* https://raw.githubusercontent.com/cucumber/gherkin/main/gherkin-languages.json + +.. _behave: https://github.com/behave/behave +.. _cucumber: https://github.com/cucumber/common +.. _gherkin-languages.json: https://github.com/cucumber/gherkin/blob/main/gherkin-languages.json From c0c678b7908900278da54b240985f178df2d81d9 Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 21 Jun 2023 18:52:33 +0200 Subject: [PATCH 111/240] ADDED: Config-file for read-the-docs * SEE: https://blog.readthedocs.com/migrate-configuration-v2/ --- .readthedocs.yaml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..570518df8 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,28 @@ +# ============================================================================= +# READTHEDOCS CONFIG-FILE: .readthedocs.yaml +# ============================================================================= +# SEE ALSO: +# * https://docs.readthedocs.io/en/stable/config-file/v2.html +# * https://blog.readthedocs.com/migrate-configuration-v2/ +# ============================================================================= + +version: 2 +build: + os: ubuntu-20.04 + tools: + python: "3.11" + +python: + install: + - requirements: py.requirements/docs.txt + - method: pip + path: . + +sphinx: + configuration: docs/conf.py + builder: dirhtml + fail_on_warning: true + +# -- PREPARED: Additional formats to generate +# formats: +# - pdf From a6cd096e299183acc6246cdda9731c37fbd437fb Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 9 Jul 2023 12:25:54 +0200 Subject: [PATCH 112/240] CI: Remove python.version=2.7 from test pipeline * REASON: No longer supported by Github Actions (date: 2023-07) OTHERWISE: * .envrc.use_venv: Now used by default RENAMED FROM: .envrc.use_venv.disabled --- .envrc.use_venv.disabled => .envrc.use_venv | 0 .github/workflows/tests.yml | 8 ++------ .gitignore | 2 -- CHANGES.rst | 2 ++ 4 files changed, 4 insertions(+), 8 deletions(-) rename .envrc.use_venv.disabled => .envrc.use_venv (100%) diff --git a/.envrc.use_venv.disabled b/.envrc.use_venv similarity index 100% rename from .envrc.use_venv.disabled rename to .envrc.use_venv diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 26ea1817b..f0cee853c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,13 +33,9 @@ jobs: fail-fast: false matrix: # PREPARED: os: [ubuntu-latest, macos-latest, windows-latest] - # PREPARED: python-version: ['3.9', '2.7', '3.10', '3.8', 'pypy-2.7', 'pypy-3.8'] - # PREPARED: os: [ubuntu-latest, windows-latest] + # PREPARED: python-version: ["3.11", "3.10", "3.9", "3.8", "pypy-3.10"] os: [ubuntu-latest] - python-version: ["3.11", "3.10", "3.9", "2.7"] - exclude: - - os: windows-latest - python-version: "2.7" + python-version: ["3.11", "3.10", "3.9"] steps: - uses: actions/checkout@v3 # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} diff --git a/.gitignore b/.gitignore index d52e7fdb5..4e6e89900 100644 --- a/.gitignore +++ b/.gitignore @@ -25,8 +25,6 @@ tools/virtualenvs .venv*/ .vscode/ .done.* -.envrc.use_venv -.envrc.use_pep0582 .DS_Store .coverage rerun.txt diff --git a/CHANGES.rst b/CHANGES.rst index 7069b8891..434ce2277 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -52,6 +52,8 @@ DEVELOPMENT: * Renamed default branch of Git repository to "main" (was: "master"). * Use github-actions as CI/CD pipeline (and remove Travis as CI). +* CI: Remove python.version=2.7 for CI pipeline + (reason: No longer supported by Github Actions, date: 2023-07). CLEANUPS: From 3f193a1971d68f027cd1f3a15f661de5ef4b597f Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 9 Jul 2023 12:42:39 +0200 Subject: [PATCH 113/240] CI: Add python.version="pypy-3.10" to test pipeline * PREPARED, but DISABLED: python.version="pypy-2.7" REASON: Some behave.tests are failing. --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f0cee853c..7db600491 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,9 +33,9 @@ jobs: fail-fast: false matrix: # PREPARED: os: [ubuntu-latest, macos-latest, windows-latest] - # PREPARED: python-version: ["3.11", "3.10", "3.9", "3.8", "pypy-3.10"] + # PREPARED: python-version: ["3.11", "3.10", "3.9", "pypy-3.10", "pypy-2.7"] os: [ubuntu-latest] - python-version: ["3.11", "3.10", "3.9"] + python-version: ["3.11", "3.10", "3.9", "pypy-3.10"] steps: - uses: actions/checkout@v3 # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} From 9cdd059328e22d0ce7d2728b6663f91d27569c4d Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 10 Jul 2023 06:49:07 +0200 Subject: [PATCH 114/240] CI: Enable python.version="pypy-2.7" for workflow "tests" * FIX: 2 minor issues where pypy-2.7 exception output differs from normal cpython-2.7. --- .github/workflows/tests.yml | 3 +-- features/formatter.help.feature | 7 +++++++ features/runner.help.feature | 15 +++++++++++---- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7db600491..1a683d52f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,9 +33,8 @@ jobs: fail-fast: false matrix: # PREPARED: os: [ubuntu-latest, macos-latest, windows-latest] - # PREPARED: python-version: ["3.11", "3.10", "3.9", "pypy-3.10", "pypy-2.7"] os: [ubuntu-latest] - python-version: ["3.11", "3.10", "3.9", "pypy-3.10"] + python-version: ["3.11", "3.10", "3.9", "pypy-3.10", "pypy-2.7"] steps: - uses: actions/checkout@v3 # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} diff --git a/features/formatter.help.feature b/features/formatter.help.feature index a5ca43591..dd1730752 100644 --- a/features/formatter.help.feature +++ b/features/formatter.help.feature @@ -108,6 +108,13 @@ Feature: Help Formatter | bad_formatter1 | behave4me.unknown:Formatter | ModuleNotFoundError | No module named 'behave4me.unknown' | @not.with_python.min_version=3.6 + @use.with_pypy=true + Examples: For Python < 3.6 + | formatter_name | formatter_class | formatter_syndrome | problem_description | + | bad_formatter1 | behave4me.unknown:Formatter | ModuleNotFoundError | No module named 'behave4me.unknown' | + + @not.with_python.min_version=3.6 + @not.with_pypy=true Examples: For Python < 3.6 | formatter_name | formatter_class | formatter_syndrome | problem_description | | bad_formatter1 | behave4me.unknown:Formatter | ModuleNotFoundError | No module named 'unknown' | diff --git a/features/runner.help.feature b/features/runner.help.feature index 410a4c485..5d368ab8c 100644 --- a/features/runner.help.feature +++ b/features/runner.help.feature @@ -93,13 +93,20 @@ Feature: Runner Help @use.with_python.min_version=3.0 Examples: For Python >= 3.0 - | runner_name | runner_class | runner_syndrome | problem_description | - | bad_runner1 | behave4me.unknown:Runner | ModuleNotFoundError | No module named 'behave4me.unknown' | + | runner_name | runner_class | runner_syndrome | problem_description | + | bad_runner1 | behave4me.unknown:Runner | ModuleNotFoundError | No module named 'behave4me.unknown' | @not.with_python.min_version=3.0 + @use.with_pypy=true Examples: For Python < 3.0 - | runner_name | runner_class | runner_syndrome | problem_description | - | bad_runner1 | behave4me.unknown:Runner | ModuleNotFoundError | No module named 'unknown' | + | runner_name | runner_class | runner_syndrome | problem_description | + | bad_runner1 | behave4me.unknown:Runner | ModuleNotFoundError | No module named 'behave4me.unknown' | + + @not.with_python.min_version=3.0 + @not.with_pypy=true + Examples: For Python < 3.0 + | runner_name | runner_class | runner_syndrome | problem_description | + | bad_runner1 | behave4me.unknown:Runner | ModuleNotFoundError | No module named 'unknown' | Examples: | runner_name | runner_class | runner_syndrome | problem_description | From c30d61c527867d8986ffe9cfd779eba05fe9eec9 Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 12 Jul 2023 22:54:10 +0200 Subject: [PATCH 115/240] FIX #1120: Logging ignoring level set in setup_logging Configuration.setup_logging(level, ...): * Need to reassign new level to self.logging_level REASON: config.logging_level is used in "behave.log_capture.LoggingCapture" and "behave.log_capture.capture" --- CHANGES.rst | 1 + behave/configuration.py | 7 +++ behave/log_capture.py | 17 ++++--- issue.features/issue1120.feature | 87 ++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 issue.features/issue1120.feature diff --git a/CHANGES.rst b/CHANGES.rst index 434ce2277..355911679 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -89,6 +89,7 @@ FIXED: * FIXED: Some tests related to python3.11 * FIXED: Some tests related to python3.9 * FIXED: active-tag logic if multiple tags with same category exists. +* issue #1120: Logging ignoring level set in setup_logging (submitted by: j7an) * issue #1070: Color support detection: Fails for WindowsTerminal (provided by: jenisys) * issue #1116: behave erroring in pretty format in pyproject.toml (submitted by: morning-sunn) * issue #1061: Scenario should inherit Rule tags (submitted by: testgitdl) diff --git a/behave/configuration.py b/behave/configuration.py index c62c23ecc..63319ff0c 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -18,6 +18,7 @@ import json import logging from logging.config import fileConfig as logging_config_fileConfig +from logging import _checkLevel as logging_check_level import os import re import sys @@ -1117,6 +1118,9 @@ def before_all(context): """ if level is None: level = self.logging_level # pylint: disable=no-member + else: + # pylint: disable=import-outside-toplevel + level = logging_check_level(level) if configfile: logging_config_fileConfig(configfile) @@ -1127,7 +1131,10 @@ def before_all(context): logging.basicConfig(format=format_, datefmt=datefmt, **kwargs) # -- ENSURE: Default log level is set # (even if logging subsystem is already configured). + # -- HINT: Ressign to self.logging_level + # NEEDED FOR: behave.log_capture.LoggingCapture, capture logging.getLogger().setLevel(level) + self.logging_level = level # pylint: disable=W0201 def setup_model(self): if self.scenario_outline_annotation_schema: diff --git a/behave/log_capture.py b/behave/log_capture.py index 0a751f77a..7f02129bf 100644 --- a/behave/log_capture.py +++ b/behave/log_capture.py @@ -180,9 +180,10 @@ def abandon(self): def capture(*args, **kw): """Decorator to wrap an *environment file function* in log file capture. - It configures the logging capture using the *behave* context - the first - argument to the function being decorated (so don't use this to decorate - something that doesn't have *context* as the first argument.) + It configures the logging capture using the *behave* context, + the first argument to the function being decorated + (so don't use this to decorate something that + doesn't have *context* as the first argument). The basic usage is: @@ -192,9 +193,9 @@ def capture(*args, **kw): def after_scenario(context, scenario): ... - The function prints any captured logging (at the level determined by the - ``log_level`` configuration setting) directly to stdout, regardless of - error conditions. + The function prints any captured logging + (at the level determined by the ``log_level`` configuration setting) + directly to stdout, regardless of error conditions. It is mostly useful for debugging in situations where you are seeing a message like:: @@ -210,8 +211,8 @@ def after_scenario(context, scenario): def after_scenario(context, scenario): ... - This would limit the logging captured to just ERROR and above, and thus - only display logged events if they are interesting. + This would limit the logging captured to just ERROR and above, + and thus only display logged events if they are interesting. """ def create_decorator(func, level=None): def f(context, *args): diff --git a/issue.features/issue1120.feature b/issue.features/issue1120.feature new file mode 100644 index 000000000..d1b4abf2a --- /dev/null +++ b/issue.features/issue1120.feature @@ -0,0 +1,87 @@ +@issue +Feature: Issue #1120 -- Logging ignoring level set in setup_logging + + . DESCRIPTION OF SYNDROME (OBSERVED BEHAVIOR): + . * I setup logging-level in "before_all()" hook w/ context.config.setup_logging() + . * I use logging in "after_scenario()" hook + . * Even levels below "logging.WARNING" are shown + + Background: Setup + Given a new working directory + And a file named "features/steps/use_step_library.py" with: + """ + import behave4cmd0.passing_steps + import behave4cmd0.failing_steps + """ + And a file named "features/simple.feature" with: + """ + Feature: F1 + Scenario: S1 + Given a step passes + When another step passes + """ + + Scenario: Check Syndrome + Given a file named "features/environment.py" with: + """ + from __future__ import absolute_import, print_function + import logging + from behave.log_capture import capture + + def before_all(context): + context.config.setup_logging(logging.WARNING) + + @capture + def after_scenario(context, scenario): + logging.debug("THIS_LOG_MESSAGE::debug") + logging.info("THIS_LOG_MESSAGE::info") + logging.warning("THIS_LOG_MESSAGE::warning") + logging.error("THIS_LOG_MESSAGE::error") + logging.critical("THIS_LOG_MESSAGE::critical") + """ + When I run "behave features/simple.feature" + Then it should pass with: + """ + 1 feature passed, 0 failed, 0 skipped + """ + And the command output should contain "THIS_LOG_MESSAGE::critical" + And the command output should contain "THIS_LOG_MESSAGE::error" + And the command output should contain "THIS_LOG_MESSAGE::warning" + But the command output should not contain "THIS_LOG_MESSAGE::debug" + And the command output should not contain "THIS_LOG_MESSAGE::info" + + + Scenario: Workaround for Syndrome (works without fix) + Given a file named "features/environment.py" with: + """ + from __future__ import absolute_import, print_function + import logging + from behave.log_capture import capture + + def before_all(context): + # -- HINT: Use behave.config.logging_level from config-file + context.config.setup_logging() + + @capture + def after_scenario(context, scenario): + logging.debug("THIS_LOG_MESSAGE::debug") + logging.info("THIS_LOG_MESSAGE::info") + logging.warning("THIS_LOG_MESSAGE::warning") + logging.error("THIS_LOG_MESSAGE::error") + logging.critical("THIS_LOG_MESSAGE::critical") + """ + And a file named "behave.ini" with: + """ + [behave] + logging_level = WARNING + """ + When I run "behave features/simple.feature" + Then it should pass with: + """ + 1 feature passed, 0 failed, 0 skipped + """ + And the command output should contain "THIS_LOG_MESSAGE::critical" + And the command output should contain "THIS_LOG_MESSAGE::error" + And the command output should contain "THIS_LOG_MESSAGE::warning" + But the command output should not contain "THIS_LOG_MESSAGE::debug" + And the command output should not contain "THIS_LOG_MESSAGE::info" From 7b7d1733aedcc2a93b46cb44bf0df8342412aaf2 Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 12 Jul 2023 23:07:28 +0200 Subject: [PATCH 116/240] BUMP-VERSION: 1.2.7.dev4 (was: 1.2.7.dev3) --- .bumpversion.cfg | 4 ++-- VERSION.txt | 2 +- behave/version.py | 2 +- pytest.ini | 1 - setup.py | 2 +- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 8225c378b..e9675ff66 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] -current_version = 1.2.7.dev3 -files = behave/version.py setup.py VERSION.txt pytest.ini .bumpversion.cfg +current_version = 1.2.7.dev4 +files = behave/version.py setup.py VERSION.txt .bumpversion.cfg parse = (?P\d+)\.(?P\d+)\.(?P\d+)(?P\w*) serialize = {major}.{minor}.{patch}{drop} commit = False diff --git a/VERSION.txt b/VERSION.txt index 1c6178fbf..c4a872d5b 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.2.7.dev3 +1.2.7.dev4 diff --git a/behave/version.py b/behave/version.py index 4e19db26a..91cb2ed68 100644 --- a/behave/version.py +++ b/behave/version.py @@ -1,2 +1,2 @@ # -- BEHAVE-VERSION: -VERSION = "1.2.7.dev3" +VERSION = "1.2.7.dev4" diff --git a/pytest.ini b/pytest.ini index 712acba91..641915b6a 100644 --- a/pytest.ini +++ b/pytest.ini @@ -21,7 +21,6 @@ testpaths = tests python_files = test_*.py junit_family = xunit2 addopts = --metadata PACKAGE_UNDER_TEST behave - --metadata PACKAGE_VERSION 1.2.7.dev3 --html=build/testing/report.html --self-contained-html --junit-xml=build/testing/report.xml markers = diff --git a/setup.py b/setup.py index 1668b9ac4..024cde4e9 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ def find_packages_by_root_package(where): # ----------------------------------------------------------------------------- setup( name="behave", - version="1.2.7.dev3", + version="1.2.7.dev4", description="behave is behaviour-driven development, Python style", long_description=description, author="Jens Engel, Benno Rice and Richard Jones", From 7b0191ccc670c6c40452b449dc18d2b5ef5bd503 Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 31 Jul 2023 21:48:07 +0200 Subject: [PATCH 117/240] ADDED: pyproject.toml * Duplicates data from "setup.py" (for now; until: Python 2.7 support is dropped) * NEEDED FOR: Newer pip versions * HINT: "setup.py" will become DEPRECATED soon (approx. 2023-09). OTHERWISE: * Add SPDX-License-Identifier to "behave/__init__.py" * UPDATE/TWEAK: py.requirements/*.txt --- .ruff.toml | 43 +++++ LICENSE | 3 +- MANIFEST.in | 2 + behave/__init__.py | 4 +- docs/install.rst | 39 ++++- py.requirements/all.txt | 3 +- py.requirements/basic.txt | 2 +- py.requirements/behave_extensions.txt | 15 ++ py.requirements/ci.tox.txt | 2 +- py.requirements/develop.txt | 4 +- py.requirements/docs.txt | 2 +- py.requirements/jsonschema.txt | 4 +- pyproject.toml | 243 +++++++++++++++++++++----- setup.py | 15 +- 14 files changed, 322 insertions(+), 59 deletions(-) create mode 100644 .ruff.toml create mode 100644 py.requirements/behave_extensions.txt diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 000000000..2cde028ed --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,43 @@ +# ----------------------------------------------------------------------------- +# SECTION: ruff -- Python linter +# ----------------------------------------------------------------------------- +# SEE: https://github.com/charliermarsh/ruff +# SEE: https://beta.ruff.rs/docs/configuration/#using-rufftoml +# Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. + +select = ["E", "F"] +ignore = [] + +# Allow autofix for all enabled rules (when `--fix`) is provided. +fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", + "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", + "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", + "TCH", "TID", "TRY", "UP", "YTT" +] +unfixable = [] + +# Exclude a variety of commonly ignored directories. +exclude = [ + ".direnv", + ".eggs", + ".git", + ".ruff_cache", + ".tox", + ".venv*", + "__pypackages__", + "build", + "dist", + "venv", +] +per-file-ignores = {} + +# Same as Black. +# WAS: line-length = 88 +line-length = 100 + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" +target-version = "py310" + +[mccabe] +max-complexity = 10 diff --git a/LICENSE b/LICENSE index 0870e5377..387e41d67 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,5 @@ -Copyright (c) 2012-2014 Benno Rice, Richard Jones, Jens Engel and others, except where noted. +Copyright (c) 2012-2014 Benno Rice, Richard Jones and others, except where noted. +Copyright (c) 2014-2023 Jens Engel and others, except where noted. All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/MANIFEST.in b/MANIFEST.in index 84d20f49b..fee66c76f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -13,6 +13,7 @@ include *.rst include *.txt include *.yml include *.yaml +exclude __*.txt include bin/behave* include bin/invoke* recursive-include .ci *.yml @@ -33,3 +34,4 @@ recursive-include py.requirements *.txt *.rst prune .tox prune .venv* +prune __* diff --git a/behave/__init__.py b/behave/__init__.py index f00d6350a..2e9b56d5a 100644 --- a/behave/__init__.py +++ b/behave/__init__.py @@ -1,5 +1,7 @@ # -*- coding: UTF-8 -*- -"""behave is behaviour-driven development, Python style +# SPDX-License-Identifier: BSD-2-Clause +""" +behave is behaviour-driven development, Python style Behavior-driven development (or BDD) is an agile software development technique that encourages collaboration between developers, QA and diff --git a/docs/install.rst b/docs/install.rst index cb111446c..495f0c42b 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -48,13 +48,13 @@ to install the newest version from the `GitHub repository`_:: To install a tagged version from the `GitHub repository`_, use:: - pip install git+https://github.com/behave/behave@ + pip install git+https://github.com/behave/behave@ -where is the placeholder for an `existing tag`_. +where is the placeholder for an `existing tag`_. -When installing extras, use ``#egg=behave[...]``, e.g.:: +When installing extras, use ``#egg=behave[...]``, e.g.:: - pip install git+https://github.com/behave/behave@v1.2.7.dev3#egg=behave[toml] + pip install git+https://github.com/behave/behave@v1.2.7.dev4#egg=behave[toml] .. _`GitHub repository`: https://github.com/behave/behave .. _`existing tag`: https://github.com/behave/behave/tags @@ -79,3 +79,34 @@ Installation Target Description .. _`behave-contrib`: https://github.com/behave-contrib .. _`pep-518`: https://peps.python.org/pep-0518/#tool-table + + +Specify Dependency to "behave" +------------------------------ + +Use the following recipe in the ``"pyproject.toml"`` config-file if: + +* your project depends on `behave`_ and +* you use a ``version`` from the git-repository (or a ``git branch``) + +EXAMPLE: + +.. code-block:: toml + + # -- FILE: my-project/pyproject.toml + # SCHEMA: Use "behave" from git-repository (instead of: https://pypi.org/ ) + # "behave @ git+https://github.com/behave/behave.git@" + # "behave @ git+https://github.com/behave/behave.git@" + # "behave[VARIANT] @ git+https://github.com/behave/behave.git@" # with VARIANT=develop, docs, ... + # SEE: https://peps.python.org/pep-0508/ + + [project] + name = "my-project" + ... + dependencies = [ + "behave @ git+https://github.com/behave/behave.git@v1.2.7.dev4", + # OR: "behave[develop] @ git+https://github.com/behave/behave.git@main", + ... + ] + +.. _behave: https://github.com/behave/behave diff --git a/py.requirements/all.txt b/py.requirements/all.txt index de0cec006..a09fa5d82 100644 --- a/py.requirements/all.txt +++ b/py.requirements/all.txt @@ -5,11 +5,12 @@ # pip install -r # # SEE ALSO: -# * http://www.pip-installer.org/ +# * https://pip.pypa.io/en/stable/user_guide/ # ============================================================================ # ALREADY: -r testing.txt # ALREADY: -r docs.txt -r basic.txt +-r behave_extensions.txt -r develop.txt -r jsonschema.txt diff --git a/py.requirements/basic.txt b/py.requirements/basic.txt index 84b134fd9..86bf15383 100644 --- a/py.requirements/basic.txt +++ b/py.requirements/basic.txt @@ -5,7 +5,7 @@ # pip install -r # # SEE ALSO: -# * http://www.pip-installer.org/ +# * https://pip.pypa.io/en/stable/user_guide/ # ============================================================================ cucumber-tag-expressions >= 4.1.0 diff --git a/py.requirements/behave_extensions.txt b/py.requirements/behave_extensions.txt new file mode 100644 index 000000000..f80938598 --- /dev/null +++ b/py.requirements/behave_extensions.txt @@ -0,0 +1,15 @@ + +# ============================================================================ +# PYTHON PACKAGE REQUIREMENTS: behave extensions +# ============================================================================ +# DESCRIPTION: +# pip install -r +# +# SEE ALSO: +# * https://pip.pypa.io/en/stable/user_guide/ +# ============================================================================ + +# -- FORMATTERS: +# DISABLED: allure-behave +behave-html-formatter >= 0.9.10; python_version >= '3.6' +behave-html-pretty-formatter >= 1.9.1; python_version >= '3.6' diff --git a/py.requirements/ci.tox.txt b/py.requirements/ci.tox.txt index 942588e9f..77226b71b 100644 --- a/py.requirements/ci.tox.txt +++ b/py.requirements/ci.tox.txt @@ -1,5 +1,5 @@ # ============================================================================ -# BEHAVE: PYTHON PACKAGE REQUIREMENTS: ci.tox.txt +# PYTHON PACKAGE REQUIREMENTS: behave -- ci.tox.txt # ============================================================================ -r testing.txt diff --git a/py.requirements/develop.txt b/py.requirements/develop.txt index 4048b47a4..22d49386d 100644 --- a/py.requirements/develop.txt +++ b/py.requirements/develop.txt @@ -15,9 +15,9 @@ bump2version >= 0.5.6 # -- RELEASE MANAGEMENT: Push package to pypi. twine >= 1.13.0 +build >= 0.5.1 # -- DEVELOPMENT SUPPORT: - # -- PYTHON2/3 COMPATIBILITY: pypa/modernize # python-futurize modernize >= 0.5 @@ -27,7 +27,7 @@ modernize >= 0.5 # -- REQUIRES: testing -r testing.txt -coverage >= 4.2 +coverage >= 5.0 pytest-cov tox >= 1.8.1,<4.0 # -- HINT: tox >= 4.0 has breaking changes. virtualenv < 20.22.0 # -- SUPPORT FOR: Python 2.7, Python <= 3.6 diff --git a/py.requirements/docs.txt b/py.requirements/docs.txt index 75bc980c7..f325f7746 100644 --- a/py.requirements/docs.txt +++ b/py.requirements/docs.txt @@ -1,5 +1,5 @@ # ============================================================================ -# BEHAVE: PYTHON PACKAGE REQUIREMENTS: For documentation generation +# PYTHON PACKAGE REQUIREMENTS: behave -- For documentation generation # ============================================================================ # REQUIRES: pip >= 8.0 # AVOID: sphinx v4.4.0 and newer -- Problems w/ new link check suggestion warnings diff --git a/py.requirements/jsonschema.txt b/py.requirements/jsonschema.txt index 6487590f0..db45dafcc 100644 --- a/py.requirements/jsonschema.txt +++ b/py.requirements/jsonschema.txt @@ -3,5 +3,7 @@ # ============================================================================ # -- OPTIONAL: For JSON validation +# DEPRECATING: jsonschema +# USE INSTEAD: check-jsonschema jsonschema >= 1.3.0 -# MAYBE NOW: check-jsonschema +check-jsonschema diff --git a/pyproject.toml b/pyproject.toml index 23abedbcd..f028bfefc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,42 +1,205 @@ +# ============================================================================= +# PACKAGE: behave +# ============================================================================= +# SPDX-License-Identifier: BSD-2-Clause +# DESCRIPTION: +# Provides a "pyproject.toml" for packaging usecases of this package. +# +# REASONS: +# * Python project will need a "pyproject.toml" soon to be installable with "pip". +# * Currently, duplicates information from "setup.py" here. +# * "setup.py" is kept until Python 2.7 support is dropped +# * "setup.py" is sometimes needed in some weird cases (old pip version, ...) +# +# SEE ALSO: +# * https://packaging.python.org/en/latest/tutorials/packaging-projects/ +# * https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html +# * https://pip.pypa.io/en/stable/reference/build-system/pyproject-toml/ +# +# RELATED: Project-Metadata Schema +# * https://packaging.python.org/en/latest/specifications/declaring-project-metadata/ +# * https://packaging.python.org/en/latest/specifications/core-metadata/ +# * https://pypi.org/classifiers/ +# * https://spdx.org/licenses/preview/ +# +# PEPs: https://peps.python.org/pep-XXXX/ +# * PEP 508 – Dependency specification for Python Software Packages +# * PEP 621 – Storing project metadata in pyproject.toml => CURRENT-SPEC: declaring-project-metadata +# * PEP 631 – Dependency specification in pyproject.toml based on PEP 508 +# * PEP 639 – Improving License Clarity with Better Package Metadata +# ============================================================================= +# MAYBE: requires = ["setuptools", "setuptools-scm"] +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + + +[project] +name = "behave" +authors = [ + {name = "Jens Engel", email = "jenisys@noreply.github.com"}, + {name = "Benno Rice"}, + {name = "Richard Jones"}, +] +maintainers = [ + {name = "Jens Engel", email = "jenisys@noreply.github.com"}, + {name = "Peter Bittner", email = "bittner@noreply.github.com"}, +] +description = "behave is behaviour-driven development, Python style" +readme = "README.rst" +requires-python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +keywords = [ + "BDD", "behavior-driven-development", "bdd-framework", + "behave", "gherkin", "cucumber-like" +] +license = {text = "BSD-2-Clause"} +# DISABLED: license-files = ["LICENSE"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: Jython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Software Development :: Testing", + "License :: OSI Approved :: BSD License", +] +dependencies = [ + "cucumber-tag-expressions >= 4.1.0", + "enum34; python_version < '3.4'", + "parse >= 1.18.0", + "parse-type >= 0.6.0", + "six >= 1.15.0", + "traceback2; python_version < '3.0'", + + # -- PREPARED: + "win_unicode_console; python_version <= '3.9'", + "contextlib2; python_version < '3.5'", + "colorama >= 0.3.7", + + # -- SUPPORT: "pyproject.toml" (or: "behave.toml") + "tomli>=1.1.0; python_version >= '3.0' and python_version < '3.11'", + "toml>=0.10.2; python_version < '3.0'", # py27 support +] +dynamic = ["version"] + + +[project.urls] +Homepage = "https://github.com/behave/behave" +Download = "https://pypi.org/project/behave/" +"Source Code" = "https://github.com/behave/behave" +"Issue Tracker" = "https://github.com/behave/behave/issues/" + + +[project.scripts] +behave = "behave.__main__:main" + +[project.entry-points."distutils.commands"] +behave_test = "setuptools_behave:behave_test" + + +[project.optional-dependencies] +develop = [ + "build >= 0.5.1", + "twine >= 1.13.0", + "coverage >= 5.0", + "pytest >=4.2,<5.0; python_version < '3.0'", + "pytest >= 5.0; python_version >= '3.0'", + "pytest-html >= 1.19.0,<2.0; python_version < '3.0'", + "pytest-html >= 2.0; python_version >= '3.0'", + "mock < 4.0; python_version < '3.6'", + "mock >= 4.0; python_version >= '3.6'", + "PyHamcrest >= 2.0.2; python_version >= '3.0'", + "PyHamcrest < 2.0; python_version < '3.0'", + "pytest-cov", + "tox >= 1.8.1,<4.0", # -- HINT: tox >= 4.0 has breaking changes. + "virtualenv < 20.22.0", # -- SUPPORT FOR: Python 2.7, Python <= 3.6 + "invoke >=1.7.0,<2.0; python_version < '3.6'", + "invoke >=1.7.0; python_version >= '3.6'", + # -- HINT, was RENAMED: path.py => path (for python3) + "path >= 13.1.0; python_version >= '3.5'", + "path.py >= 11.5.0; python_version < '3.5'", + "pycmd", + "pathlib; python_version <= '3.4'", + "modernize >= 0.5", + "pylint", + "ruff; python_version >= '3.7'", +] +docs = [ + "Sphinx >=1.6", + "sphinx_bootstrap_theme >= 0.6.0" +] +formatters = [ + "behave-html-formatter >= 0.9.10; python_version >= '3.6'", + "behave-html-pretty-formatter >= 1.9.1; python_version >= '3.6'" +] +testing = [ + "pytest < 5.0; python_version < '3.0'", # >= 4.2 + "pytest >= 5.0; python_version >= '3.0'", + "pytest-html >= 1.19.0,<2.0; python_version < '3.0'", + "pytest-html >= 2.0; python_version >= '3.0'", + "mock < 4.0; python_version < '3.6'", + "mock >= 4.0; python_version >= '3.6'", + "PyHamcrest >= 2.0.2; python_version >= '3.0'", + "PyHamcrest < 2.0; python_version < '3.0'", + "assertpy >= 1.1", + + # -- HINT: path.py => path (python-install-package was renamed for python3) + "path >= 13.1.0; python_version >= '3.5'", + "path.py >=11.5.0,<13.0; python_version < '3.5'", + # -- PYTHON2 BACKPORTS: + "pathlib; python_version <= '3.4'", +] +# -- BACKWORD-COMPATIBLE SECTION: Can be removed in the future +# HINT: Package-requirements are now part of "dependencies" parameter above. +toml = [ + "tomli>=1.1.0; python_version >= '3.0' and python_version < '3.11'", + "toml>=0.10.2; python_version < '3.0'", +] + + +[tool.distutils.bdist_wheel] +universal = true + + # ----------------------------------------------------------------------------- -# SECTION: ruff -- Python linter +# PACAKING TOOL SPECIFIC PARTS: # ----------------------------------------------------------------------------- -# SEE: https://github.com/charliermarsh/ruff -# Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. -[tool.ruff] -select = ["E", "F"] -ignore = [] - -# Allow autofix for all enabled rules (when `--fix`) is provided. -fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", - "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", - "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", - "TCH", "TID", "TRY", "UP", "YTT" -] -unfixable = [] - -# Exclude a variety of commonly ignored directories. -exclude = [ - ".direnv", - ".eggs", - ".git", - ".ruff_cache", - ".tox", - ".venv*", - "__pypackages__", - "build", - "dist", - "venv", -] -per-file-ignores = {} - -# Same as Black. -# WAS: line-length = 88 -line-length = 100 - -# Allow unused variables when underscore-prefixed. -dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" -target-version = "py310" - -[tool.ruff.mccabe] -max-complexity = 10 +[tool.setuptools] +platforms = ["any"] +py-modules = ["setuptools_behave"] +zip-safe = true + +[tool.setuptools.cmdclass] +behave_test = "setuptools_behave.behave_test" + +[tool.setuptools.dynamic] +version = {attr = "behave.version.VERSION"} + +[tool.setuptools.packages.find] +where = ["."] +include = ["behave*"] +exclude = ["behave4cmd0*", "tests*"] +namespaces = false + + + +# ----------------------------------------------------------------------------- +# PYLINT: +# ----------------------------------------------------------------------------- +[tool.pylint.messages_control] +disable = "C0330, C0326" + +[tool.pylint.format] +max-line-length = "100" diff --git a/setup.py b/setup.py index 024cde4e9..3ad5257d2 100644 --- a/setup.py +++ b/setup.py @@ -60,7 +60,7 @@ def find_packages_by_root_package(where): long_description=description, author="Jens Engel, Benno Rice and Richard Jones", author_email="behave-users@googlegroups.com", - url="http://github.com/behave/behave", + url="https://github.com/behave/behave", provides = ["behave", "setuptools_behave"], packages = find_packages_by_root_package(BEHAVE), py_modules = ["setuptools_behave"], @@ -79,7 +79,7 @@ def find_packages_by_root_package(where): "cucumber-tag-expressions >= 4.1.0", "enum34; python_version < '3.4'", "parse >= 1.18.0", - "parse_type >= 0.6.0", + "parse-type >= 0.6.0", "six >= 1.15.0", "traceback2; python_version < '3.0'", @@ -90,7 +90,7 @@ def find_packages_by_root_package(where): "colorama >= 0.3.7", ], tests_require=[ - "pytest < 5.0; python_version < '3.0'", # >= 4.2 + "pytest < 5.0; python_version < '3.0'", # USE: pytest >= 4.2 "pytest >= 5.0; python_version >= '3.0'", "pytest-html >= 1.19.0,<2.0; python_version < '3.0'", "pytest-html >= 2.0; python_version >= '3.0'", @@ -115,8 +115,10 @@ def find_packages_by_root_package(where): "sphinx_bootstrap_theme >= 0.6" ], "develop": [ - "coverage", - "pytest >=4.2,<5.0; python_version < '3.0' # pytest >= 4.2", + "build >= 0.5.1", + "twine >= 1.13.0", + "coverage >= 5.0", + "pytest >=4.2,<5.0; python_version < '3.0'", # pytest >= 4.2 "pytest >= 5.0; python_version >= '3.0'", "pytest-html >= 1.19.0,<2.0; python_version < '3.0'", "pytest-html >= 2.0; python_version >= '3.0'", @@ -147,7 +149,7 @@ def find_packages_by_root_package(where): }, license="BSD", classifiers=[ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "Operating System :: OS Independent", @@ -160,6 +162,7 @@ def find_packages_by_root_package(where): "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: Jython", "Programming Language :: Python :: Implementation :: PyPy", From 1c6197b35c15e07b5bae62b3131a98f9caa88f4e Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 31 Jul 2023 22:06:53 +0200 Subject: [PATCH 118/240] BUMP-VERSION: 1.2.7.dev5 (was: 1.2.7.dev4) * ADDED: pyproject.toml support --- .bumpversion.cfg | 2 +- CHANGES.rst | 1 + VERSION.txt | 2 +- behave/version.py | 2 +- docs/install.rst | 4 ++-- setup.py | 2 +- 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index e9675ff66..6fac22e71 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.2.7.dev4 +current_version = 1.2.7.dev5 files = behave/version.py setup.py VERSION.txt .bumpversion.cfg parse = (?P\d+)\.(?P\d+)\.(?P\d+)(?P\w*) serialize = {major}.{minor}.{patch}{drop} diff --git a/CHANGES.rst b/CHANGES.rst index 355911679..90ed72bf9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -54,6 +54,7 @@ DEVELOPMENT: * Use github-actions as CI/CD pipeline (and remove Travis as CI). * CI: Remove python.version=2.7 for CI pipeline (reason: No longer supported by Github Actions, date: 2023-07). +* ADDED: pyproject.toml support (hint: "setup.py" will become DEPRECATED soon) CLEANUPS: diff --git a/VERSION.txt b/VERSION.txt index c4a872d5b..e353c6873 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.2.7.dev4 +1.2.7.dev5 diff --git a/behave/version.py b/behave/version.py index 91cb2ed68..72c278160 100644 --- a/behave/version.py +++ b/behave/version.py @@ -1,2 +1,2 @@ # -- BEHAVE-VERSION: -VERSION = "1.2.7.dev4" +VERSION = "1.2.7.dev5" diff --git a/docs/install.rst b/docs/install.rst index 495f0c42b..7139fafdf 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -54,7 +54,7 @@ where is the placeholder for an `existing tag`_. When installing extras, use ``#egg=behave[...]``, e.g.:: - pip install git+https://github.com/behave/behave@v1.2.7.dev4#egg=behave[toml] + pip install git+https://github.com/behave/behave@v1.2.7.dev5#egg=behave[toml] .. _`GitHub repository`: https://github.com/behave/behave .. _`existing tag`: https://github.com/behave/behave/tags @@ -104,7 +104,7 @@ EXAMPLE: name = "my-project" ... dependencies = [ - "behave @ git+https://github.com/behave/behave.git@v1.2.7.dev4", + "behave @ git+https://github.com/behave/behave.git@v1.2.7.dev5", # OR: "behave[develop] @ git+https://github.com/behave/behave.git@main", ... ] diff --git a/setup.py b/setup.py index 3ad5257d2..f0c843270 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ def find_packages_by_root_package(where): # ----------------------------------------------------------------------------- setup( name="behave", - version="1.2.7.dev4", + version="1.2.7.dev5", description="behave is behaviour-driven development, Python style", long_description=description, author="Jens Engel, Benno Rice and Richard Jones", From 313e8430d939956143e34c0ad89772a9bd84f865 Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 12 Sep 2023 21:14:31 +0200 Subject: [PATCH 119/240] invoke tasks: * invoke.yaml: Use yamllint to provide correct YAML format * test.behave: grouped_by_prefix(): Support list (and string) for args param --- bin/invoke_cmd.py | 6 ++++ invoke.yaml | 75 +++++++++++++++++++++++++---------------------- tasks/test.py | 11 +++++-- 3 files changed, 55 insertions(+), 37 deletions(-) create mode 100755 bin/invoke_cmd.py diff --git a/bin/invoke_cmd.py b/bin/invoke_cmd.py new file mode 100755 index 000000000..876bf1211 --- /dev/null +++ b/bin/invoke_cmd.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 + +if __name__ == "__main__": + import sys + from invoke.main import program + sys.exit(program.run()) diff --git a/invoke.yaml b/invoke.yaml index 890619b2f..a8571f415 100644 --- a/invoke.yaml +++ b/invoke.yaml @@ -9,50 +9,55 @@ # ===================================================== # MAYBE: tasks: auto_dash_names: false +--- project: - name: behave + name: behave run: - echo: true - # DISABLED: pty: true + echo: true sphinx: - sourcedir: "docs" - destdir: "build/docs" - language: en - languages: - - de - # PREPARED: - zh-CN + sourcedir: "docs" + destdir: "build/docs" + language: en + languages: + - en + - de + # PREPARED: - zh-CN cleanup: - extra_directories: - - "build" - - "dist" - - "__WORKDIR__" - - reports + extra_directories: + - "build" + - "dist" + - "__WORKDIR__" + - reports - extra_files: - - "etc/gherkin/gherkin*.json.SAVED" - - "etc/gherkin/i18n.py" + extra_files: + - "etc/gherkin/gherkin*.json.SAVED" + - "etc/gherkin/i18n.py" cleanup_all: - extra_directories: - - .hypothesis - - .pytest_cache - - .direnv - - extra_files: - - "**/testrun*.json" - - ".done.*" - - "*.lock" - - "*.log" - - .coverage - - rerun.txt + extra_directories: + - .hypothesis + - .pytest_cache + - .direnv + - .tox + - ".venv*" + + extra_files: + - "**/testrun*.json" + - ".done.*" + - "*.lock" + - "*.log" + - .coverage + - rerun.txt behave_test: - scopes: - - features - - tools/test-features - - issue.features - args: features tools/test-features issue.features - + scopes: + - features + - tools/test-features + - issue.features + args: + - features + - tools/test-features + - issue.features diff --git a/tasks/test.py b/tasks/test.py index 685e8e6eb..adc3642b3 100644 --- a/tasks/test.py +++ b/tasks/test.py @@ -6,6 +6,8 @@ from __future__ import print_function import os.path import sys + +import six from invoke import task, Collection # -- TASK-LIBRARY: @@ -137,9 +139,14 @@ def select_by_prefix(args, prefixes): def grouped_by_prefix(args, prefixes): """Group behave args by (directory) scope into multiple test-runs.""" + if isinstance(args, six.string_types): + args = args.strip().split() + if not isinstance(args, list): + raise TypeError("args.type=%s (expected: list, string)" % type(args)) + group_args = [] current_scope = None - for arg in args.strip().split(): + for arg in args: assert not arg.startswith("-"), "REQUIRE: arg, not options" scope = select_prefix_for(arg, prefixes) if scope != current_scope: @@ -180,7 +187,7 @@ def grouped_by_prefix(args, prefixes): # "behave_test": behave.namespace._configuration["behave_test"], "behave_test": { "scopes": ["features", "issue.features"], - "args": "features issue.features", + "args": ["features", "issue.features"], "format": "progress", "options": "", # -- NOTE: Overide in configfile "invoke.yaml" "coverage_options": "", From d93f6a8d20c45d594fa210d6a8ef896896df44a9 Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 12 Sep 2023 21:21:21 +0200 Subject: [PATCH 120/240] invoke: Add yamllint as dependency * NEEDED FOR: Development support --- tasks/py.requirements.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tasks/py.requirements.txt b/tasks/py.requirements.txt index 3990858b1..263331d34 100644 --- a/tasks/py.requirements.txt +++ b/tasks/py.requirements.txt @@ -25,3 +25,6 @@ git+https://github.com/jenisys/invoke-cleanup@v0.3.7 # -- SECTION: develop requests + +# -- DEVELOPMENT SUPPORT: Check "invoke.yaml" config-file(s) +yamllint >= 1.32.0; python_version >= '3.7' From 387d773ce30126873566287643dde75db6e64883 Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 12 Sep 2023 22:54:24 +0200 Subject: [PATCH 121/240] behave.runner.Context: Add use_or_create_param(), use_or_assign_param() * Add helper methods to simplify to add context-params if needed only RELATED TO: * Discussion #1136 --- behave/runner.py | 39 ++- tests/unit/test_runner.py | 470 +------------------------- tests/unit/test_runner_context.py | 545 ++++++++++++++++++++++++++++++ 3 files changed, 584 insertions(+), 470 deletions(-) create mode 100644 tests/unit/test_runner_context.py diff --git a/behave/runner.py b/behave/runner.py index 35f2a0814..3defac330 100644 --- a/behave/runner.py +++ b/behave/runner.py @@ -4,7 +4,6 @@ """ from __future__ import absolute_import, print_function, with_statement - import contextlib import os.path import sys @@ -192,6 +191,44 @@ def abort(self, reason=None): """ self._set_root_attribute("aborted", True) + def use_or_assign_param(self, name, value): + """Use an existing context parameter (aka: attribute) or + assign a value to new context parameter (if it does not exist yet). + + :param name: Context parameter name (as string) + :param value: Parameter value for new parameter. + :return: Existing or newly created parameter. + + .. versionadded:: 1.2.7 + """ + if name not in self: + # -- CASE: New, missing param -- Assign parameter-value. + setattr(self, name, value) + return value + # -- OTHERWISE: Use existing param + return getattr(self, name, None) + + + def use_or_create_param(self, name, factory_func, *args, **kwargs): + """Use an existing context parameter (aka: attribute) or + create a new parameter if it does not exist yet. + + :param name: Context parameter name (as string) + :param factory_func: Factory function, used if parameter is created. + :param args: Positional args for ``factory_func()`` on create. + :param kwargs: Named args for ``factory_func()`` on create. + :return: Existing or newly created parameter. + + .. versionadded:: 1.2.7 + """ + if name not in self: + # -- CASE: New, missing param -- Create it. + param_value = factory_func(*args, **kwargs) + setattr(self, name, param_value) + return param_value + # -- OTHERWISE: Use existing param + return getattr(self, name, None) + @staticmethod def ignore_cleanup_error(context, cleanup_func, exception): pass diff --git a/tests/unit/test_runner.py b/tests/unit/test_runner.py index 98eba082d..03b7aae3f 100644 --- a/tests/unit/test_runner.py +++ b/tests/unit/test_runner.py @@ -3,487 +3,19 @@ from __future__ import absolute_import, print_function, with_statement from collections import defaultdict -from platform import python_implementation import os.path import sys -import warnings import unittest import six from six import StringIO import pytest from mock import Mock, patch from behave import runner_util -from behave.model import Table -from behave.step_registry import StepRegistry -from behave import parser, runner -from behave.runner import ContextMode +from behave import runner from behave.exception import ConfigError from behave.formatter.base import StreamOpener -# -- CONVENIENCE-ALIAS: -_text = six.text_type - - -class TestContext(unittest.TestCase): - # pylint: disable=invalid-name, protected-access, no-self-use - - def setUp(self): - r = Mock() - self.config = r.config = Mock() - r.config.verbose = False - self.context = runner.Context(r) - - def test_user_mode_shall_restore_behave_mode(self): - # -- CASE: No exception is raised. - initial_mode = ContextMode.BEHAVE - assert self.context._mode == initial_mode - with self.context.use_with_user_mode(): - assert self.context._mode == ContextMode.USER - self.context.thing = "stuff" - assert self.context._mode == initial_mode - - def test_user_mode_shall_restore_behave_mode_if_assert_fails(self): - initial_mode = ContextMode.BEHAVE - assert self.context._mode == initial_mode - try: - with self.context.use_with_user_mode(): - assert self.context._mode == ContextMode.USER - assert False, "XFAIL" - except AssertionError: - assert self.context._mode == initial_mode - - def test_user_mode_shall_restore_behave_mode_if_exception_is_raised(self): - initial_mode = ContextMode.BEHAVE - assert self.context._mode == initial_mode - try: - with self.context.use_with_user_mode(): - assert self.context._mode == ContextMode.USER - raise RuntimeError("XFAIL") - except RuntimeError: - assert self.context._mode == initial_mode - - def test_use_with_user_mode__shall_restore_initial_mode(self): - # -- CASE: No exception is raised. - # pylint: disable=protected-access - initial_mode = ContextMode.BEHAVE - self.context._mode = initial_mode - with self.context.use_with_user_mode(): - assert self.context._mode == ContextMode.USER - self.context.thing = "stuff" - assert self.context._mode == initial_mode - - def test_use_with_user_mode__shall_restore_initial_mode_with_error(self): - # -- CASE: Exception is raised. - # pylint: disable=protected-access - initial_mode = ContextMode.BEHAVE - self.context._mode = initial_mode - try: - with self.context.use_with_user_mode(): - assert self.context._mode == ContextMode.USER - raise RuntimeError("XFAIL") - except RuntimeError: - assert self.context._mode == initial_mode - - def test_use_with_behave_mode__shall_restore_initial_mode(self): - # -- CASE: No exception is raised. - # pylint: disable=protected-access - initial_mode = ContextMode.USER - self.context._mode = initial_mode - with self.context._use_with_behave_mode(): - assert self.context._mode == ContextMode.BEHAVE - self.context.thing = "stuff" - assert self.context._mode == initial_mode - - def test_use_with_behave_mode__shall_restore_initial_mode_with_error(self): - # -- CASE: Exception is raised. - # pylint: disable=protected-access - initial_mode = ContextMode.USER - self.context._mode = initial_mode - try: - with self.context._use_with_behave_mode(): - assert self.context._mode == ContextMode.BEHAVE - raise RuntimeError("XFAIL") - except RuntimeError: - assert self.context._mode == initial_mode - - def test_context_contains(self): - assert "thing" not in self.context - self.context.thing = "stuff" - assert "thing" in self.context - self.context._push() - assert "thing" in self.context - - def test_attribute_set_at_upper_level_visible_at_lower_level(self): - self.context.thing = "stuff" - self.context._push() - assert self.context.thing == "stuff" - - def test_attribute_set_at_lower_level_not_visible_at_upper_level(self): - self.context._push() - self.context.thing = "stuff" - self.context._pop() - assert getattr(self.context, "thing", None) is None - - def test_attributes_set_at_upper_level_visible_at_lower_level(self): - self.context.thing = "stuff" - self.context._push() - assert self.context.thing == "stuff" - self.context.other_thing = "more stuff" - self.context._push() - assert self.context.thing == "stuff" - assert self.context.other_thing == "more stuff" - self.context.third_thing = "wombats" - self.context._push() - assert self.context.thing == "stuff" - assert self.context.other_thing == "more stuff" - assert self.context.third_thing == "wombats" - - def test_attributes_set_at_lower_level_not_visible_at_upper_level(self): - self.context.thing = "stuff" - - self.context._push() - self.context.other_thing = "more stuff" - - self.context._push() - self.context.third_thing = "wombats" - assert self.context.thing == "stuff" - assert self.context.other_thing == "more stuff" - assert self.context.third_thing == "wombats" - - self.context._pop() - assert self.context.thing == "stuff" - assert self.context.other_thing == "more stuff" - assert getattr(self.context, "third_thing", None) is None, "%s is not None" % self.context.third_thing - - self.context._pop() - assert self.context.thing == "stuff" - assert getattr(self.context, "other_thing", None) is None, "%s is not None" % self.context.other_thing - assert getattr(self.context, "third_thing", None) is None, "%s is not None" % self.context.third_thing - - def test_masking_existing_user_attribute_when_verbose_causes_warning(self): - warns = [] - - def catch_warning(*args, **kwargs): - warns.append(args[0]) - - old_showwarning = warnings.showwarning - warnings.showwarning = catch_warning - - # pylint: disable=protected-access - self.config.verbose = True - with self.context.use_with_user_mode(): - self.context.thing = "stuff" - self.context._push() - self.context.thing = "other stuff" - - warnings.showwarning = old_showwarning - - print(repr(warns)) - assert warns, "warns is empty!" - warning = warns[0] - assert isinstance(warning, runner.ContextMaskWarning), "warning is not a ContextMaskWarning" - info = warning.args[0] - assert info.startswith("user code"), "%r doesn't start with 'user code'" % info - assert "'thing'" in info, "%r not in %r" % ("'thing'", info) - assert "tutorial" in info, '"tutorial" not in %r' % (info, ) - - def test_masking_existing_user_attribute_when_not_verbose_causes_no_warning(self): - warns = [] - - def catch_warning(*args, **kwargs): - warns.append(args[0]) - - old_showwarning = warnings.showwarning - warnings.showwarning = catch_warning - - # explicit - # pylint: disable=protected-access - self.config.verbose = False - with self.context.use_with_user_mode(): - self.context.thing = "stuff" - self.context._push() - self.context.thing = "other stuff" - - warnings.showwarning = old_showwarning - - assert not warns - - def test_behave_masking_user_attribute_causes_warning(self): - warns = [] - - def catch_warning(*args, **kwargs): - warns.append(args[0]) - - old_showwarning = warnings.showwarning - warnings.showwarning = catch_warning - - with self.context.use_with_user_mode(): - self.context.thing = "stuff" - # pylint: disable=protected-access - self.context._push() - self.context.thing = "other stuff" - - warnings.showwarning = old_showwarning - - print(repr(warns)) - assert warns, "OOPS: warns is empty, but expected non-empty" - warning = warns[0] - assert isinstance(warning, runner.ContextMaskWarning), "warning is not a ContextMaskWarning" - info = warning.args[0] - assert info.startswith("behave runner"), "%r doesn't start with 'behave runner'" % info - assert "'thing'" in info, "%r not in %r" % ("'thing'", info) - filename = __file__.rsplit(".", 1)[0] - if python_implementation() == "Jython": - filename = filename.replace("$py", ".py") - assert filename in info, "%r not in %r" % (filename, info) - - def test_setting_root_attribute_that_masks_existing_causes_warning(self): - # pylint: disable=protected-access - warns = [] - - def catch_warning(*args, **kwargs): - warns.append(args[0]) - - old_showwarning = warnings.showwarning - warnings.showwarning = catch_warning - - with self.context.use_with_user_mode(): - self.context._push() - self.context.thing = "teak" - self.context._set_root_attribute("thing", "oak") - - warnings.showwarning = old_showwarning - - print(repr(warns)) - assert warns - warning = warns[0] - assert isinstance(warning, runner.ContextMaskWarning) - info = warning.args[0] - assert info.startswith("behave runner"), "%r doesn't start with 'behave runner'" % info - assert "'thing'" in info, "%r not in %r" % ("'thing'", info) - filename = __file__.rsplit(".", 1)[0] - if python_implementation() == "Jython": - filename = filename.replace("$py", ".py") - assert filename in info, "%r not in %r" % (filename, info) - - def test_context_deletable(self): - assert "thing" not in self.context - self.context.thing = "stuff" - assert "thing" in self.context - del self.context.thing - assert "thing" not in self.context - - # OLD: @raises(AttributeError) - def test_context_deletable_raises(self): - # pylint: disable=protected-access - assert "thing" not in self.context - self.context.thing = "stuff" - assert "thing" in self.context - self.context._push() - assert "thing" in self.context - with pytest.raises(AttributeError): - del self.context.thing - - -class ExampleSteps(object): - text = None - table = None - - @staticmethod - def step_passes(context): # pylint: disable=unused-argument - pass - - @staticmethod - def step_fails(context): # pylint: disable=unused-argument - assert False, "XFAIL" - - @classmethod - def step_with_text(cls, context): - assert context.text is not None, "REQUIRE: multi-line text" - cls.text = context.text - - @classmethod - def step_with_table(cls, context): - assert context.table, "REQUIRE: table" - cls.table = context.table - - @classmethod - def register_steps_with(cls, step_registry): - # pylint: disable=bad-whitespace - step_definitions = [ - ("step", "a step passes", cls.step_passes), - ("step", "a step fails", cls.step_fails), - ("step", "a step with text", cls.step_with_text), - ("step", "a step with a table", cls.step_with_table), - ] - for keyword, pattern, func in step_definitions: - step_registry.add_step_definition(keyword, pattern, func) - - -class TestContext_ExecuteSteps(unittest.TestCase): - """ - Test the behave.runner.Context.execute_steps() functionality. - """ - # pylint: disable=invalid-name, no-self-use - step_registry = None - - def setUp(self): - if not self.step_registry: - # -- SETUP ONCE: - self.step_registry = StepRegistry() - ExampleSteps.register_steps_with(self.step_registry) - ExampleSteps.text = None - ExampleSteps.table = None - - runner_ = Mock() - self.config = runner_.config = Mock() - runner_.config.verbose = False - runner_.config.stdout_capture = False - runner_.config.stderr_capture = False - runner_.config.log_capture = False - runner_.config.logging_format = None - runner_.config.logging_datefmt = None - runner_.step_registry = self.step_registry - - self.context = runner.Context(runner_) - runner_.context = self.context - self.context.feature = Mock() - self.context.feature.parser = parser.Parser() - self.context.runner = runner_ - # self.context.text = None - # self.context.table = None - - def test_execute_steps_with_simple_steps(self): - doc = u""" -Given a step passes -Then a step passes -""".lstrip() - with patch("behave.step_registry.registry", self.step_registry): - result = self.context.execute_steps(doc) - assert result is True - - def test_execute_steps_with_failing_step(self): - doc = u""" -Given a step passes -When a step fails -Then a step passes -""".lstrip() - with patch("behave.step_registry.registry", self.step_registry): - try: - result = self.context.execute_steps(doc) - except AssertionError as e: - assert "FAILED SUB-STEP: When a step fails" in _text(e) - - def test_execute_steps_with_undefined_step(self): - doc = u""" -Given a step passes -When a step is undefined -Then a step passes -""".lstrip() - with patch("behave.step_registry.registry", self.step_registry): - try: - result = self.context.execute_steps(doc) - except AssertionError as e: - assert "UNDEFINED SUB-STEP: When a step is undefined" in _text(e) - - def test_execute_steps_with_text(self): - doc = u''' -Given a step passes -When a step with text: - """ - Lorem ipsum - Ipsum lorem - """ -Then a step passes -'''.lstrip() - with patch("behave.step_registry.registry", self.step_registry): - result = self.context.execute_steps(doc) - expected_text = "Lorem ipsum\nIpsum lorem" - assert result is True - assert expected_text == ExampleSteps.text - - def test_execute_steps_with_table(self): - doc = u""" -Given a step with a table: - | Name | Age | - | Alice | 12 | - | Bob | 23 | -Then a step passes -""".lstrip() - with patch("behave.step_registry.registry", self.step_registry): - # pylint: disable=bad-whitespace, bad-continuation - result = self.context.execute_steps(doc) - expected_table = Table([u"Name", u"Age"], 0, [ - [u"Alice", u"12"], - [u"Bob", u"23"], - ]) - assert result is True - assert expected_table == ExampleSteps.table - - def test_context_table_is_restored_after_execute_steps_without_table(self): - doc = u""" -Given a step passes -Then a step passes -""".lstrip() - with patch("behave.step_registry.registry", self.step_registry): - original_table = "" - self.context.table = original_table - self.context.execute_steps(doc) - assert self.context.table == original_table - - def test_context_table_is_restored_after_execute_steps_with_table(self): - doc = u""" -Given a step with a table: - | Name | Age | - | Alice | 12 | - | Bob | 23 | -Then a step passes -""".lstrip() - with patch("behave.step_registry.registry", self.step_registry): - original_table = "" - self.context.table = original_table - self.context.execute_steps(doc) - assert self.context.table == original_table - - def test_context_text_is_restored_after_execute_steps_without_text(self): - doc = u""" -Given a step passes -Then a step passes -""".lstrip() - with patch("behave.step_registry.registry", self.step_registry): - original_text = "" - self.context.text = original_text - self.context.execute_steps(doc) - assert self.context.text == original_text - - def test_context_text_is_restored_after_execute_steps_with_text(self): - doc = u''' -Given a step passes -When a step with text: - """ - Lorem ipsum - Ipsum lorem - """ -'''.lstrip() - with patch("behave.step_registry.registry", self.step_registry): - original_text = "" - self.context.text = original_text - self.context.execute_steps(doc) - assert self.context.text == original_text - - - # OLD: @raises(ValueError) - def test_execute_steps_should_fail_when_called_without_feature(self): - doc = u""" -Given a passes -Then a step passes -""".lstrip() - with patch("behave.step_registry.registry", self.step_registry): - self.context.feature = None - with pytest.raises(ValueError): - self.context.execute_steps(doc) - def create_mock_config(): config = Mock() diff --git a/tests/unit/test_runner_context.py b/tests/unit/test_runner_context.py new file mode 100644 index 000000000..b128547a6 --- /dev/null +++ b/tests/unit/test_runner_context.py @@ -0,0 +1,545 @@ +""" +Unit tests for :class:`behave.runner.Context`. +""" + +from __future__ import absolute_import, print_function +import unittest +import warnings +from platform import python_implementation + +from mock import Mock, patch +import pytest +import six + +from behave import runner, parser +from behave.model import Table +from behave.runner import Context, ContextMode, scoped_context_layer +from behave.step_registry import StepRegistry + + +# -- CONVENIENCE-ALIAS: +_text = six.text_type + + +class TestContext(object): + @staticmethod + def make_runner(config=None): + if config is None: + config = Mock() + # MAYBE: the_runner = runner.Runner(config) + the_runner = Mock() + the_runner.config = config + return the_runner + + @classmethod + def make_context(cls, runner=None, **runner_kwargs): + the_runner = runner + if the_runner is None: + the_runner = cls.make_runner(**runner_kwargs) + context = Context(the_runner) + return context + + # -- TESTSUITE FOR: behave.runner.Context (PART 1) + def test_use_or_assign_param__with_existing_param_uses_param(self): + param_name = "some_param" + context = self.make_context() + with context.use_with_user_mode(): + context.some_param = 12 + with scoped_context_layer(context, "scenario"): + assert param_name in context + param = context.use_or_assign_param(param_name, 123) + assert param_name in context + assert param == 12 + + def test_use_or_assign_param__with_nonexisting_param_assigns_param(self): + param_name = "other_param" + context = self.make_context() + with context.use_with_user_mode(): + with scoped_context_layer(context, "scenario"): + assert param_name not in context + param = context.use_or_assign_param(param_name, 123) + assert param_name in context + assert param == 123 + + def test_use_or_create_param__with_existing_param_uses_param(self): + param_name = "some_param" + context = self.make_context() + with context.use_with_user_mode(): + context.some_param = 12 + with scoped_context_layer(context, "scenario"): + assert param_name in context + param = context.use_or_create_param(param_name, int, 123) + assert param_name in context + assert param == 12 + + def test_use_or_create_param__with_nonexisting_param_creates_param(self): + param_name = "other_param" + context = self.make_context() + with context.use_with_user_mode(): + with scoped_context_layer(context, "scenario"): + assert param_name not in context + param = context.use_or_create_param(param_name, int, 123) + assert param_name in context + assert param == 123 + + def test_context_contains(self): + context = self.make_context() + assert "thing" not in context + context.thing = "stuff" + assert "thing" in context + context._push() + assert "thing" in context + + +class TestContext2(unittest.TestCase): + # pylint: disable=invalid-name, protected-access, no-self-use + + def setUp(self): + r = Mock() + self.config = r.config = Mock() + r.config.verbose = False + self.context = runner.Context(r) + + # -- TESTSUITE FOR: behave.runner.Context (PART 2) + def test_user_mode_shall_restore_behave_mode(self): + # -- CASE: No exception is raised. + initial_mode = ContextMode.BEHAVE + assert self.context._mode == initial_mode + with self.context.use_with_user_mode(): + assert self.context._mode == ContextMode.USER + self.context.thing = "stuff" + assert self.context._mode == initial_mode + + def test_user_mode_shall_restore_behave_mode_if_assert_fails(self): + initial_mode = ContextMode.BEHAVE + assert self.context._mode == initial_mode + try: + with self.context.use_with_user_mode(): + assert self.context._mode == ContextMode.USER + assert False, "XFAIL" + except AssertionError: + assert self.context._mode == initial_mode + + def test_user_mode_shall_restore_behave_mode_if_exception_is_raised(self): + initial_mode = ContextMode.BEHAVE + assert self.context._mode == initial_mode + try: + with self.context.use_with_user_mode(): + assert self.context._mode == ContextMode.USER + raise RuntimeError("XFAIL") + except RuntimeError: + assert self.context._mode == initial_mode + + def test_use_with_user_mode__shall_restore_initial_mode(self): + # -- CASE: No exception is raised. + # pylint: disable=protected-access + initial_mode = ContextMode.BEHAVE + self.context._mode = initial_mode + with self.context.use_with_user_mode(): + assert self.context._mode == ContextMode.USER + self.context.thing = "stuff" + assert self.context._mode == initial_mode + + def test_use_with_user_mode__shall_restore_initial_mode_with_error(self): + # -- CASE: Exception is raised. + # pylint: disable=protected-access + initial_mode = ContextMode.BEHAVE + self.context._mode = initial_mode + try: + with self.context.use_with_user_mode(): + assert self.context._mode == ContextMode.USER + raise RuntimeError("XFAIL") + except RuntimeError: + assert self.context._mode == initial_mode + + def test_use_with_behave_mode__shall_restore_initial_mode(self): + # -- CASE: No exception is raised. + # pylint: disable=protected-access + initial_mode = ContextMode.USER + self.context._mode = initial_mode + with self.context._use_with_behave_mode(): + assert self.context._mode == ContextMode.BEHAVE + self.context.thing = "stuff" + assert self.context._mode == initial_mode + + def test_use_with_behave_mode__shall_restore_initial_mode_with_error(self): + # -- CASE: Exception is raised. + # pylint: disable=protected-access + initial_mode = ContextMode.USER + self.context._mode = initial_mode + try: + with self.context._use_with_behave_mode(): + assert self.context._mode == ContextMode.BEHAVE + raise RuntimeError("XFAIL") + except RuntimeError: + assert self.context._mode == initial_mode + + def test_attribute_set_at_upper_level_visible_at_lower_level(self): + self.context.thing = "stuff" + self.context._push() + assert self.context.thing == "stuff" + + def test_attribute_set_at_lower_level_not_visible_at_upper_level(self): + self.context._push() + self.context.thing = "stuff" + self.context._pop() + assert getattr(self.context, "thing", None) is None + + def test_attributes_set_at_upper_level_visible_at_lower_level(self): + self.context.thing = "stuff" + self.context._push() + assert self.context.thing == "stuff" + self.context.other_thing = "more stuff" + self.context._push() + assert self.context.thing == "stuff" + assert self.context.other_thing == "more stuff" + self.context.third_thing = "wombats" + self.context._push() + assert self.context.thing == "stuff" + assert self.context.other_thing == "more stuff" + assert self.context.third_thing == "wombats" + + def test_attributes_set_at_lower_level_not_visible_at_upper_level(self): + self.context.thing = "stuff" + + self.context._push() + self.context.other_thing = "more stuff" + + self.context._push() + self.context.third_thing = "wombats" + assert self.context.thing == "stuff" + assert self.context.other_thing == "more stuff" + assert self.context.third_thing == "wombats" + + self.context._pop() + assert self.context.thing == "stuff" + assert self.context.other_thing == "more stuff" + assert getattr(self.context, "third_thing", None) is None, "%s is not None" % self.context.third_thing + + self.context._pop() + assert self.context.thing == "stuff" + assert getattr(self.context, "other_thing", None) is None, "%s is not None" % self.context.other_thing + assert getattr(self.context, "third_thing", None) is None, "%s is not None" % self.context.third_thing + + def test_masking_existing_user_attribute_when_verbose_causes_warning(self): + warns = [] + + def catch_warning(*args, **kwargs): + warns.append(args[0]) + + old_showwarning = warnings.showwarning + warnings.showwarning = catch_warning + + # pylint: disable=protected-access + self.config.verbose = True + with self.context.use_with_user_mode(): + self.context.thing = "stuff" + self.context._push() + self.context.thing = "other stuff" + + warnings.showwarning = old_showwarning + + print(repr(warns)) + assert warns, "warns is empty!" + warning = warns[0] + assert isinstance(warning, runner.ContextMaskWarning), "warning is not a ContextMaskWarning" + info = warning.args[0] + assert info.startswith("user code"), "%r doesn't start with 'user code'" % info + assert "'thing'" in info, "%r not in %r" % ("'thing'", info) + assert "tutorial" in info, '"tutorial" not in %r' % (info, ) + + def test_masking_existing_user_attribute_when_not_verbose_causes_no_warning(self): + warns = [] + + def catch_warning(*args, **kwargs): + warns.append(args[0]) + + old_showwarning = warnings.showwarning + warnings.showwarning = catch_warning + + # explicit + # pylint: disable=protected-access + self.config.verbose = False + with self.context.use_with_user_mode(): + self.context.thing = "stuff" + self.context._push() + self.context.thing = "other stuff" + + warnings.showwarning = old_showwarning + + assert not warns + + def test_behave_masking_user_attribute_causes_warning(self): + warns = [] + + def catch_warning(*args, **kwargs): + warns.append(args[0]) + + old_showwarning = warnings.showwarning + warnings.showwarning = catch_warning + + with self.context.use_with_user_mode(): + self.context.thing = "stuff" + # pylint: disable=protected-access + self.context._push() + self.context.thing = "other stuff" + + warnings.showwarning = old_showwarning + + print(repr(warns)) + assert warns, "OOPS: warns is empty, but expected non-empty" + warning = warns[0] + assert isinstance(warning, runner.ContextMaskWarning), "warning is not a ContextMaskWarning" + info = warning.args[0] + assert info.startswith("behave runner"), "%r doesn't start with 'behave runner'" % info + assert "'thing'" in info, "%r not in %r" % ("'thing'", info) + filename = __file__.rsplit(".", 1)[0] + if python_implementation() == "Jython": + filename = filename.replace("$py", ".py") + assert filename in info, "%r not in %r" % (filename, info) + + def test_setting_root_attribute_that_masks_existing_causes_warning(self): + # pylint: disable=protected-access + warns = [] + + def catch_warning(*args, **kwargs): + warns.append(args[0]) + + old_showwarning = warnings.showwarning + warnings.showwarning = catch_warning + + with self.context.use_with_user_mode(): + self.context._push() + self.context.thing = "teak" + self.context._set_root_attribute("thing", "oak") + + warnings.showwarning = old_showwarning + + print(repr(warns)) + assert warns + warning = warns[0] + assert isinstance(warning, runner.ContextMaskWarning) + info = warning.args[0] + assert info.startswith("behave runner"), "%r doesn't start with 'behave runner'" % info + assert "'thing'" in info, "%r not in %r" % ("'thing'", info) + filename = __file__.rsplit(".", 1)[0] + if python_implementation() == "Jython": + filename = filename.replace("$py", ".py") + assert filename in info, "%r not in %r" % (filename, info) + + def test_context_deletable(self): + assert "thing" not in self.context + self.context.thing = "stuff" + assert "thing" in self.context + del self.context.thing + assert "thing" not in self.context + + # OLD: @raises(AttributeError) + def test_context_deletable_raises(self): + # pylint: disable=protected-access + assert "thing" not in self.context + self.context.thing = "stuff" + assert "thing" in self.context + self.context._push() + assert "thing" in self.context + with pytest.raises(AttributeError): + del self.context.thing + + +class ExampleSteps(object): + text = None + table = None + + @staticmethod + def step_passes(context): # pylint: disable=unused-argument + pass + + @staticmethod + def step_fails(context): # pylint: disable=unused-argument + assert False, "XFAIL" + + @classmethod + def step_with_text(cls, context): + assert context.text is not None, "REQUIRE: multi-line text" + cls.text = context.text + + @classmethod + def step_with_table(cls, context): + assert context.table, "REQUIRE: table" + cls.table = context.table + + @classmethod + def register_steps_with(cls, step_registry): + # pylint: disable=bad-whitespace + step_definitions = [ + ("step", "a step passes", cls.step_passes), + ("step", "a step fails", cls.step_fails), + ("step", "a step with text", cls.step_with_text), + ("step", "a step with a table", cls.step_with_table), + ] + for keyword, pattern, func in step_definitions: + step_registry.add_step_definition(keyword, pattern, func) + + +class TestContext_ExecuteSteps(unittest.TestCase): + """ + Test the behave.runner.Context.execute_steps() functionality. + """ + # pylint: disable=invalid-name, no-self-use + step_registry = None + + def setUp(self): + if not self.step_registry: + # -- SETUP ONCE: + self.step_registry = StepRegistry() + ExampleSteps.register_steps_with(self.step_registry) + ExampleSteps.text = None + ExampleSteps.table = None + + runner_ = Mock() + self.config = runner_.config = Mock() + runner_.config.verbose = False + runner_.config.stdout_capture = False + runner_.config.stderr_capture = False + runner_.config.log_capture = False + runner_.config.logging_format = None + runner_.config.logging_datefmt = None + runner_.step_registry = self.step_registry + + self.context = runner.Context(runner_) + runner_.context = self.context + self.context.feature = Mock() + self.context.feature.parser = parser.Parser() + self.context.runner = runner_ + # self.context.text = None + # self.context.table = None + + def test_execute_steps_with_simple_steps(self): + doc = u""" +Given a step passes +Then a step passes +""".lstrip() + with patch("behave.step_registry.registry", self.step_registry): + result = self.context.execute_steps(doc) + assert result is True + + def test_execute_steps_with_failing_step(self): + doc = u""" +Given a step passes +When a step fails +Then a step passes +""".lstrip() + with patch("behave.step_registry.registry", self.step_registry): + try: + result = self.context.execute_steps(doc) + except AssertionError as e: + assert "FAILED SUB-STEP: When a step fails" in _text(e) + + def test_execute_steps_with_undefined_step(self): + doc = u""" +Given a step passes +When a step is undefined +Then a step passes +""".lstrip() + with patch("behave.step_registry.registry", self.step_registry): + try: + result = self.context.execute_steps(doc) + except AssertionError as e: + assert "UNDEFINED SUB-STEP: When a step is undefined" in _text(e) + + def test_execute_steps_with_text(self): + doc = u''' +Given a step passes +When a step with text: + """ + Lorem ipsum + Ipsum lorem + """ +Then a step passes +'''.lstrip() + with patch("behave.step_registry.registry", self.step_registry): + result = self.context.execute_steps(doc) + expected_text = "Lorem ipsum\nIpsum lorem" + assert result is True + assert expected_text == ExampleSteps.text + + def test_execute_steps_with_table(self): + doc = u""" +Given a step with a table: + | Name | Age | + | Alice | 12 | + | Bob | 23 | +Then a step passes +""".lstrip() + with patch("behave.step_registry.registry", self.step_registry): + # pylint: disable=bad-whitespace, bad-continuation + result = self.context.execute_steps(doc) + expected_table = Table([u"Name", u"Age"], 0, [ + [u"Alice", u"12"], + [u"Bob", u"23"], + ]) + assert result is True + assert expected_table == ExampleSteps.table + + def test_context_table_is_restored_after_execute_steps_without_table(self): + doc = u""" +Given a step passes +Then a step passes +""".lstrip() + with patch("behave.step_registry.registry", self.step_registry): + original_table = "" + self.context.table = original_table + self.context.execute_steps(doc) + assert self.context.table == original_table + + def test_context_table_is_restored_after_execute_steps_with_table(self): + doc = u""" +Given a step with a table: + | Name | Age | + | Alice | 12 | + | Bob | 23 | +Then a step passes +""".lstrip() + with patch("behave.step_registry.registry", self.step_registry): + original_table = "" + self.context.table = original_table + self.context.execute_steps(doc) + assert self.context.table == original_table + + def test_context_text_is_restored_after_execute_steps_without_text(self): + doc = u""" +Given a step passes +Then a step passes +""".lstrip() + with patch("behave.step_registry.registry", self.step_registry): + original_text = "" + self.context.text = original_text + self.context.execute_steps(doc) + assert self.context.text == original_text + + def test_context_text_is_restored_after_execute_steps_with_text(self): + doc = u''' +Given a step passes +When a step with text: + """ + Lorem ipsum + Ipsum lorem + """ +'''.lstrip() + with patch("behave.step_registry.registry", self.step_registry): + original_text = "" + self.context.text = original_text + self.context.execute_steps(doc) + assert self.context.text == original_text + + + # OLD: @raises(ValueError) + def test_execute_steps_should_fail_when_called_without_feature(self): + doc = u""" +Given a passes +Then a step passes +""".lstrip() + with patch("behave.step_registry.registry", self.step_registry): + self.context.feature = None + with pytest.raises(ValueError): + self.context.execute_steps(doc) From 34efe0beceefe16ce0d4b34a3fce17f1887c3a76 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 7 Oct 2023 17:11:15 +0200 Subject: [PATCH 122/240] FIX TESTS FOR: Python 3.12 * Some exception messages changed (related to: TypeError: abstract method) * configparser.SafeConfigParser was removed --- CHANGES.rst | 5 +++-- features/runner.use_runner_class.feature | 22 +++++++++++++++------- features/userdata.feature | 7 ++----- tests/unit/test_runner_plugin.py | 24 ++++++++++++++++++------ 4 files changed, 38 insertions(+), 20 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 90ed72bf9..5337d12ce 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -87,8 +87,9 @@ CLARIFICATION: FIXED: -* FIXED: Some tests related to python3.11 -* FIXED: Some tests related to python3.9 +* FIXED: Some tests for python-3.12 +* FIXED: Some tests related to python-3.11 +* FIXED: Some tests related to python-3.9 * FIXED: active-tag logic if multiple tags with same category exists. * issue #1120: Logging ignoring level set in setup_logging (submitted by: j7an) * issue #1070: Color support detection: Fails for WindowsTerminal (provided by: jenisys) diff --git a/features/runner.use_runner_class.feature b/features/runner.use_runner_class.feature index 58d11f5ec..04df1e355 100644 --- a/features/runner.use_runner_class.feature +++ b/features/runner.use_runner_class.feature @@ -325,19 +325,27 @@ Feature: User-provided Test Runner Class (extension-point) | BAD_CLASS | behave_example.bad_runner:NotRunner2 | InvalidClassError: is not a subclass-of 'behave.api.runner:ITestRunner' | Runner class does not behave properly. | | BAD_FUNCTION | behave_example.bad_runner:return_none | InvalidClassError: is not a class | runner_class is a function. | | BAD_VALUE | behave_example.bad_runner:CONSTANT_1 | InvalidClassError: is not a class | runner_class is a constant number. | - | INCOMPLETE_CLASS | behave_example.incomplete:IncompleteRunner1 | TypeError: Can't instantiate abstract class IncompleteRunner1 with abstract method(s)? __init__ | Constructor is missing | - | INCOMPLETE_CLASS | behave_example.incomplete:IncompleteRunner2 | TypeError: Can't instantiate abstract class IncompleteRunner2 with abstract method(s)? run | run() method is missing | | INVALID_CLASS | behave_example.incomplete:IncompleteRunner4 | InvalidClassError: run\(\) is not callable | run is a bool-value (no method) | + Examples: BAD_CASE (python <= 3.11) + | syndrome | runner_class | failure_message | case | + | INCOMPLETE_CLASS | behave_example.incomplete:IncompleteRunner1 | TypeError: Can't instantiate abstract class IncompleteRunner1 (with\|without an implementation for) abstract method(s)? (')?__init__(')? | Constructor is missing | + | INCOMPLETE_CLASS | behave_example.incomplete:IncompleteRunner2 | TypeError: Can't instantiate abstract class IncompleteRunner2 (with\|without an implementation for) abstract method(s)? (')?run(')? | run() method is missing | + @use.with_python.min_version=3.3 - Examples: BAD_CASE2 + # DISABLED: @use.with_python.max_version=3.11 + Examples: BAD_CASE4 | syndrome | runner_class | failure_message | case | - | INCOMPLETE_CLASS | behave_example.incomplete:IncompleteRunner3 | TypeError: Can't instantiate abstract class IncompleteRunner3 with abstract method(s)? undefined_steps | undefined_steps property is missing | + | INCOMPLETE_CLASS | behave_example.incomplete:IncompleteRunner3 | TypeError: Can't instantiate abstract class IncompleteRunner3 (with\|without an implementation for) abstract method(s)? (')?undefined_steps(')? | undefined_steps property is missing | # -- PYTHON VERSION SENSITIVITY on INCOMPLETE_CLASS with API TypeError exception: # Since Python 3.9: "... methods ..." is only used in plural case (if multiple methods are missing). # "TypeError: Can't instantiate abstract class with abstract method " ( for Python.version >= 3.9) # "TypeError: Can't instantiate abstract class with abstract methods " (for Python.version < 3.9) + # + # Since Python 3.12: + # NEW: "TypeError: Can't instantiate abstract class without implementation for abstract method ''" + # OLD: "TypeError: Can't instantiate abstract class with abstract methods " (for Python.version < 3.12) Rule: Use own Test Runner-by-Name (BAD CASES) @@ -450,11 +458,11 @@ Feature: User-provided Test Runner Class (extension-point) Examples: BAD_CASE | runner_name | runner_class | syndrome | problem_description | case | - | NAME_FOR_INCOMPLETE_CLASS_1 | behave_example.incomplete:IncompleteRunner1 | TypeError | Can't instantiate abstract class IncompleteRunner1 with abstract method(s)? __init__ | Constructor is missing | - | NAME_FOR_INCOMPLETE_CLASS_2 | behave_example.incomplete:IncompleteRunner2 | TypeError | Can't instantiate abstract class IncompleteRunner2 with abstract method(s)? run | run() method is missing | + | NAME_FOR_INCOMPLETE_CLASS_1 | behave_example.incomplete:IncompleteRunner1 | TypeError | Can't instantiate abstract class IncompleteRunner1 (with\|without an implementation for) abstract method(s)? (')?__init__(')? | Constructor is missing | + | NAME_FOR_INCOMPLETE_CLASS_2 | behave_example.incomplete:IncompleteRunner2 | TypeError | Can't instantiate abstract class IncompleteRunner2 (with\|without an implementation for) abstract method(s)? (')?run(')? | run() method is missing | | NAME_FOR_INCOMPLETE_CLASS_4 | behave_example.incomplete:IncompleteRunner4 | InvalidClassError | run\(\) is not callable | run is a bool-value (no method) | @use.with_python.min_version=3.3 Examples: BAD_CASE2 | runner_name | runner_class | syndrome | problem_description | case | - | NAME_FOR_INCOMPLETE_CLASS_3 | behave_example.incomplete:IncompleteRunner3 | TypeError | Can't instantiate abstract class IncompleteRunner3 with abstract method(s)? undefined_steps | missing-property | + | NAME_FOR_INCOMPLETE_CLASS_3 | behave_example.incomplete:IncompleteRunner3 | TypeError | Can't instantiate abstract class IncompleteRunner3 (with\|without an implementation for) abstract method(s)? (')?undefined_steps(')? | missing-property | diff --git a/features/userdata.feature b/features/userdata.feature index e02295357..400811e2b 100644 --- a/features/userdata.feature +++ b/features/userdata.feature @@ -233,16 +233,13 @@ Feature: User-specific Configuration Data (userdata) """ And a file named "features/environment.py" with: """ - try: - import configparser - except: - import ConfigParser as configparser # -- PY2 + from behave.configuration import ConfigParser def before_all(context): userdata = context.config.userdata configfile = userdata.get("configfile", "userconfig.ini") section = userdata.get("config_section", "behave.userdata") - parser = configparser.SafeConfigParser() + parser = ConfigParser() parser.read(configfile) if parser.has_section(section): userdata.update(parser.items(section)) diff --git a/tests/unit/test_runner_plugin.py b/tests/unit/test_runner_plugin.py index 0c43fe133..892386e10 100644 --- a/tests/unit/test_runner_plugin.py +++ b/tests/unit/test_runner_plugin.py @@ -45,6 +45,21 @@ def use_current_directory(directory_path): os.chdir(initial_directory) +def make_exception_message4abstract_method(class_name, method_name): + """ + Creates a regexp matcher object for the TypeError exception message + that is raised if an abstract method is encountered. + """ + # -- RAISED AS: TypeError + # UNTIL python 3.11: Can't instantiate abstract class with abstract method + # FROM python 3.12: Can't instantiate abstract class without an implementation for abstract method '' + message = """ +Can't instantiate abstract class {class_name} (with|without an implementation for) abstract method(s)? (')?{method_name}(')? +""".format(class_name=class_name, method_name=method_name).strip() + return message + + + # ----------------------------------------------------------------------------- # TEST SUPPORT: TEST RUNNER CLASS CANDIDATES -- GOOD EXAMPLES # ----------------------------------------------------------------------------- @@ -264,8 +279,7 @@ def test_make_runner_fails_if_runner_class_has_no_ctor(self): config = Configuration(["--runner=%s:%s" % (self.THIS_MODULE_NAME, class_name)]) RunnerPlugin().make_runner(config) - expected = "Can't instantiate abstract class %s with abstract method(s)? __init__" % \ - class_name + expected = make_exception_message4abstract_method(class_name, method_name="__init__") assert exc_info.type is TypeError assert exc_info.match(expected) @@ -275,8 +289,7 @@ def test_make_runner_fails_if_runner_class_has_no_run_method(self): config = Configuration(["--runner=%s:%s" % (self.THIS_MODULE_NAME, class_name)]) RunnerPlugin().make_runner(config) - expected = "Can't instantiate abstract class %s with abstract method(s)? run" % \ - class_name + expected = make_exception_message4abstract_method(class_name, method_name="run") assert exc_info.type is TypeError assert exc_info.match(expected) @@ -287,7 +300,6 @@ def test_make_runner_fails_if_runner_class_has_no_undefined_steps(self): config = Configuration(["--runner=%s:%s" % (self.THIS_MODULE_NAME, class_name)]) RunnerPlugin().make_runner(config) - expected = "Can't instantiate abstract class %s with abstract method(s)? undefined_steps" % \ - class_name + expected = make_exception_message4abstract_method(class_name, "undefined_steps") assert exc_info.type is TypeError assert exc_info.match(expected) From 3f099adb0bd722eed2f07c9b212c08dee7d4cc1d Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 7 Oct 2023 17:22:18 +0200 Subject: [PATCH 123/240] CI: Use Python 3.12 * USE: actions/checkout@v4 --- .github/workflows/tests-windows.yml | 4 ++-- .github/workflows/tests.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index 3e3acad65..6e3ee190a 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -37,9 +37,9 @@ jobs: fail-fast: false matrix: os: [windows-latest] - python-version: ["3.11", "3.10", "3.9"] + python-version: ["3.12", "3.11", "3.10", "3.9"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} - uses: actions/setup-python@v4 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1a683d52f..24af4931e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,9 +34,9 @@ jobs: matrix: # PREPARED: os: [ubuntu-latest, macos-latest, windows-latest] os: [ubuntu-latest] - python-version: ["3.11", "3.10", "3.9", "pypy-3.10", "pypy-2.7"] + python-version: ["3.12", "3.11", "3.10", "3.9", "pypy-3.10", "pypy-2.7"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} - uses: actions/setup-python@v4 with: @@ -45,7 +45,7 @@ jobs: cache-dependency-path: 'py.requirements/*.txt' # -- DISABLED: # - name: Show Python version - # run: python --version + # run: python --version - name: Install Python package dependencies run: | python -m pip install -U pip setuptools wheel From 16ef6e434737ffdb6041ef70268826e5c0df0685 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 7 Oct 2023 17:34:46 +0200 Subject: [PATCH 124/240] CI: Tweak conditions to run workflows * ADDED: .github workflow files --- .github/workflows/tests-windows.yml | 2 ++ .github/workflows/tests.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index 6e3ee190a..e3d1f382e 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -8,6 +8,7 @@ on: push: branches: [ "main", "release/**" ] paths: + - ".github/**/*.yml" - "**/*.py" - "**/*.feature" - "py.requirements/**" @@ -18,6 +19,7 @@ on: types: [opened, reopened, review_requested] branches: [ "main" ] paths: + - ".github/**/*.yml" - "**/*.py" - "**/*.feature" - "py.requirements/**" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 24af4931e..2190d852e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,6 +8,7 @@ on: push: branches: [ "main", "release/**" ] paths: + - ".github/**/*.yml" - "**/*.py" - "**/*.feature" - "py.requirements/**" @@ -18,6 +19,7 @@ on: types: [opened, reopened, review_requested] branches: [ "main" ] paths: + - ".github/**/*.yml" - "**/*.py" - "**/*.feature" - "py.requirements/**" From 13893e30eeb8fd5fc4ebeef5763dfe781b6f12a1 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 7 Oct 2023 17:38:12 +0200 Subject: [PATCH 125/240] pyproject.toml: Mark support for Python 3.12 * Same for: setup.py --- pyproject.toml | 1 + setup.py | 1 + 2 files changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index f028bfefc..0eaca19cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: Jython", "Programming Language :: Python :: Implementation :: PyPy", diff --git a/setup.py b/setup.py index f0c843270..2655f0446 100644 --- a/setup.py +++ b/setup.py @@ -163,6 +163,7 @@ def find_packages_by_root_package(where): "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: Jython", "Programming Language :: Python :: Implementation :: PyPy", From afb6b6716cd0f3e028829416475312db804a6aa9 Mon Sep 17 00:00:00 2001 From: Gaomengsuijia Date: Thu, 9 Nov 2023 15:43:56 +0800 Subject: [PATCH 126/240] Update formatters.rst Misspell --- docs/formatters.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/formatters.rst b/docs/formatters.rst index af3e755a6..2614aa229 100644 --- a/docs/formatters.rst +++ b/docs/formatters.rst @@ -138,7 +138,7 @@ For example: .. code-block:: python # -- FILE: features/steps/screenshot_example_steps.py - from behave import fiven, when + from behave import given, when from behave4example.web_browser.util import take_screenshot_and_attach_to_scenario @given(u'I open the Google webpage') From 5a0a53a6c3bf0d54eb8f27c473dd0e8ad2cf4b24 Mon Sep 17 00:00:00 2001 From: jenisys Date: Fri, 19 Jan 2024 09:37:19 +0100 Subject: [PATCH 127/240] FIX #1154: Config-files are not shown in verbose mode --- CHANGES.rst | 1 + behave/configuration.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 5337d12ce..55acbb77d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -91,6 +91,7 @@ FIXED: * FIXED: Some tests related to python-3.11 * FIXED: Some tests related to python-3.9 * FIXED: active-tag logic if multiple tags with same category exists. +* issue #1154: Config-files are not shown in verbose mode (submitted by: soblom) * issue #1120: Logging ignoring level set in setup_logging (submitted by: j7an) * issue #1070: Color support detection: Fails for WindowsTerminal (provided by: jenisys) * issue #1116: behave erroring in pretty format in pyproject.toml (submitted by: morning-sunn) diff --git a/behave/configuration.py b/behave/configuration.py index 63319ff0c..62a91d7d1 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -783,7 +783,7 @@ def __init__(self, command_args=None, load_config=True, verbose=None, # -- STEP: Load config-file(s) and parse command-line command_args = self.make_command_args(command_args, verbose=verbose) if load_config: - load_configuration(self.defaults, verbose=verbose) + load_configuration(self.defaults, verbose=self.verbose) parser = setup_parser() parser.set_defaults(**self.defaults) args = parser.parse_args(command_args) From e6fe05903b4dd626720a254752b437164f71db4a Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 3 Feb 2024 17:50:35 +0100 Subject: [PATCH 128/240] Issue #1158: Provide feature file to check problem (MISTAKEN) --- issue.features/issue1158.feature | 57 ++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 issue.features/issue1158.feature diff --git a/issue.features/issue1158.feature b/issue.features/issue1158.feature new file mode 100644 index 000000000..91d444feb --- /dev/null +++ b/issue.features/issue1158.feature @@ -0,0 +1,57 @@ +@issue @mistaken +Feature: Issue #1158 -- ParseMatcher failing on steps with type annotations + + . DESCRIPTION OF SYNDROME (OBSERVED BEHAVIOR): + . * AmbiguousStep exception occurs when using the ParseMatcher + . * MISTAKEN: No such problem exists + . * PROBABLY: Error on the user side + + Scenario: Check Syndrome + Given a new working directory + And a file named "features/steps/steps.py" with: + """ + from __future__ import absolute_import, print_function + from behave import then, register_type, use_step_matcher + from parse_type import TypeBuilder + from enum import Enum + + class CommunicationState(Enum): + ALIVE = 1 + SUSPICIOUS = 2 + DEAD = 3 + UNKNOWN = 4 + + parse_communication_state = TypeBuilder.make_enum(CommunicationState) + register_type(CommunicationState=parse_communication_state) + use_step_matcher("parse") + + @then(u'the SCADA reports that the supervisory controls communication status is {com_state:CommunicationState}') + def step1_reports_communication_status(ctx, com_state): + print("STEP_1: com_state={com_state}".format(com_state=com_state)) + + @then(u'the SCADA finally reports that the supervisory controls communication status is {com_state:CommunicationState}') + def step2_finally_reports_communication_status(ctx, com_state): + print("STEP_2: com_state={com_state}".format(com_state=com_state)) + """ + And a file named "features/syndrome_1158.feature" with: + """ + Feature: F1 + Scenario Outline: STEP_1 and STEP_2 with com_state= + Then the SCADA reports that the supervisory controls communication status is + And the SCADA finally reports that the supervisory controls communication status is + + Examples: + | communication_state | + | ALIVE | + | SUSPICIOUS | + | DEAD | + | UNKNOWN | + """ + When I run "behave features/syndrome_1158.feature" + Then it should pass with: + """ + 1 feature passed, 0 failed, 0 skipped + 4 scenarios passed, 0 failed, 0 skipped + 8 steps passed, 0 failed, 0 skipped + """ + And the command output should not contain "AmbiguousStep" From 99c5668864bc82483eed48d974f11831107f83a0 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 3 Feb 2024 18:44:09 +0100 Subject: [PATCH 129/240] FIX: sphinxcontrib-applehelp dependency problem * Add explicit constraints until Sphinx >= 4.4 can be used * Apply sphinx-version constraint to "setup.py", "pyproject.toml" (was missed when sphinx version was limited in requirements-file) --- py.requirements/docs.txt | 9 +++++++++ pyproject.toml | 12 ++++++++++-- setup.py | 12 ++++++++++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/py.requirements/docs.txt b/py.requirements/docs.txt index f325f7746..4b53b407a 100644 --- a/py.requirements/docs.txt +++ b/py.requirements/docs.txt @@ -15,3 +15,12 @@ urllib3 < 2.0.0; python_version < '3.8' # -- SUPPORT: sphinx-doc translations (prepared) sphinx-intl >= 0.9.11 + +# -- CONSTRAINTS UNTIL: sphinx > 5.0 can be used +# PROBLEM: sphinxcontrib-applehelp v1.0.8 requires sphinx > 5.0 +# SEE: https://stackoverflow.com/questions/77848565/sphinxcontrib-applehelp-breaking-sphinx-builds-with-sphinx-version-less-than-5-0 +sphinxcontrib-applehelp==1.0.4 +sphinxcontrib-devhelp==1.0.2 +sphinxcontrib-htmlhelp==2.0.1 +sphinxcontrib-qthelp==1.0.3 +sphinxcontrib-serializinghtml==1.1.5 diff --git a/pyproject.toml b/pyproject.toml index 0eaca19cc..e472d14ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,8 +138,16 @@ develop = [ "ruff; python_version >= '3.7'", ] docs = [ - "Sphinx >=1.6", - "sphinx_bootstrap_theme >= 0.6.0" + "Sphinx >=1.6,<4.4", + "sphinx_bootstrap_theme >= 0.6.0", + # -- CONSTRAINTS UNTIL: sphinx > 5.0 is usable -- 2024-01 + # PROBLEM: sphinxcontrib-applehelp v1.0.8 requires sphinx > 5.0 + # SEE: https://stackoverflow.com/questions/77848565/sphinxcontrib-applehelp-breaking-sphinx-builds-with-sphinx-version-less-than-5-0 + "sphinxcontrib-applehelp==1.0.4", + "sphinxcontrib-devhelp==1.0.2", + "sphinxcontrib-htmlhelp==2.0.1", + "sphinxcontrib-qthelp==1.0.3", + "sphinxcontrib-serializinghtml==1.1.5", ] formatters = [ "behave-html-formatter >= 0.9.10; python_version >= '3.6'", diff --git a/setup.py b/setup.py index 2655f0446..ecf412b54 100644 --- a/setup.py +++ b/setup.py @@ -111,8 +111,16 @@ def find_packages_by_root_package(where): }, extras_require={ "docs": [ - "sphinx >= 1.6", - "sphinx_bootstrap_theme >= 0.6" + "sphinx >= 1.6,<4.4", + "sphinx_bootstrap_theme >= 0.6", + # -- CONSTRAINTS UNTIL: sphinx > 5.0 can be used -- 2024-01 + # PROBLEM: sphinxcontrib-applehelp v1.0.8 requires sphinx > 5.0 + # SEE: https://stackoverflow.com/questions/77848565/sphinxcontrib-applehelp-breaking-sphinx-builds-with-sphinx-version-less-than-5-0 + "sphinxcontrib-applehelp==1.0.4", + "sphinxcontrib-devhelp==1.0.2", + "sphinxcontrib-htmlhelp==2.0.1", + "sphinxcontrib-qthelp==1.0.3", + "sphinxcontrib-serializinghtml==1.1.5", ], "develop": [ "build >= 0.5.1", From 78a2af6043d9b8a8d60a5658508088cc598155ee Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 3 Feb 2024 19:07:44 +0100 Subject: [PATCH 130/240] =?UTF-8?q?CI=20github-actions:=20Use=20actions/se?= =?UTF-8?q?tup-python=C2=ABv5=20(was:=20v4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/tests-windows.yml | 2 +- .github/workflows/tests.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index e3d1f382e..d3bc9c03a 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -43,7 +43,7 @@ jobs: steps: - uses: actions/checkout@v4 # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2190d852e..5e4183b0c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -40,7 +40,7 @@ jobs: steps: - uses: actions/checkout@v4 # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' From 79a2e5c546253bc309e4084bbb75f87902bd37a0 Mon Sep 17 00:00:00 2001 From: Karel Hovorka Date: Sat, 18 Nov 2023 13:45:15 +0100 Subject: [PATCH 131/240] Added __contains__ to Row. --- behave/model.py | 3 +++ tests/unit/test_model.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/behave/model.py b/behave/model.py index 6940a76d0..0c5b93240 100644 --- a/behave/model.py +++ b/behave/model.py @@ -2081,6 +2081,9 @@ def __getitem__(self, name): raise KeyError('"%s" is not a row heading' % name) return self.cells[index] + def __contains__(self, item): + return item in self.headings + def __repr__(self): return "" % (self.cells,) diff --git a/tests/unit/test_model.py b/tests/unit/test_model.py index 21d6c2768..3d43e346c 100644 --- a/tests/unit/test_model.py +++ b/tests/unit/test_model.py @@ -762,3 +762,9 @@ def test_as_dict(self): assert data1["name"] == u"Alice" assert data1["sex"] == u"female" assert data1["age"] == u"12" + + def test_contains(self): + assert "name" in self.row + assert "sex" in self.row + assert "age" in self.row + assert "non-existent-header" not in self.row From 0d0203dce45038dcb05accb03cd3a2b49b3ff243 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 28 Jan 2024 00:15:00 +0000 Subject: [PATCH 132/240] Update actions/checkout action to v4 --- .github/workflows/codeql-analysis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 7cc64f10d..ccf2f3ca8 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL From b5274976cb0a792d05d541a749c0adcd9d20062d Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 15 Apr 2024 23:01:09 +0200 Subject: [PATCH 133/240] Issue #1170: Add test to reproduce problem * WORKAROUND: Use "tag_expression_protocol = strict" --- issue.features/issue1170.feature | 61 ++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 issue.features/issue1170.feature diff --git a/issue.features/issue1170.feature b/issue.features/issue1170.feature new file mode 100644 index 000000000..a5c63095a --- /dev/null +++ b/issue.features/issue1170.feature @@ -0,0 +1,61 @@ +@issue +Feature: Issue #1170 -- Tag Expression Auto Detection Problem + + . DESCRIPTION OF SYNDROME (OBSERVED BEHAVIOR): + . TagExpression v2 wildcard matching does not work if one dashed-tag is used. + . + . WORKAROUND: + . * Use TagExpression auto-detection in strict mode + + + Background: Setup + Given a new working directory + And a file named "features/steps/steps.py" with: + """ + from __future__ import absolute_import + import behave4cmd0.passing_steps + """ + And a file named "features/syndrome_1170.feature" with: + """ + Feature: F1 + + @file-test_1 + Scenario: S1 + Given a step passes + + @file-test_2 + Scenario: S2 + When another step passes + + Scenario: S3 -- Untagged + Then some step passes + """ + + @xfailed + Scenario: Use one TagExpression Term with Wildcard -- BROKEN + When I run `behave --tags="file-test*" features/syndrome_1170.feature` + Then it should pass with: + """ + 0 features passed, 0 failed, 1 skipped + 0 scenarios passed, 0 failed, 3 skipped + """ + And note that "TagExpression auto-detection seems to select TagExpressionV1" + And note that "no scenarios is selected/executed" + But note that "first two scenarios should have been executed" + + + Scenario: Use one TagExpression Term with Wildcard -- Strict Mode + Given a file named "behave.ini" with: + """ + # -- ENSURE: Only TagExpression v2 is used (with auto-detection in strict mode) + [behave] + tag_expression_protocol = strict + """ + When I run `behave --tags="file-test*" features/syndrome_1170.feature` + Then it should pass with: + """ + 1 feature passed, 0 failed, 0 skipped + 2 scenarios passed, 0 failed, 1 skipped + """ + And note that "TagExpression auto-detection seems to select TagExpressionV2" + And note that "first two scenarios are selected/executed" From 655f14d92a7c32e8346c319fc4104ab27ea57e48 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 12 May 2024 18:09:36 +0200 Subject: [PATCH 134/240] FIX #1170: Auto-detection of Tag-Expressions * Simplify/cleanup TagExpressionProtocol and make_tag_expression() * Add TagExpressionProtocol values for "v1" and "v2" * Move core functionality to "behave.tag_expression.builder" module * Provide additional tests --- CHANGES.rst | 1 + behave/configuration.py | 13 +- behave/tag_expression/__init__.py | 163 +------------ behave/tag_expression/builder.py | 220 ++++++++++++++++++ behave/tag_expression/model.py | 116 +++++++++ behave/tag_expression/model_ext.py | 93 -------- behave/tag_expression/parser.py | 5 +- docs/behave.rst | 8 +- features/steps/behave_tag_expression_steps.py | 2 + issue.features/issue1170.feature | 57 +++-- tasks/py.requirements.txt | 4 +- tests/issues/test_issue1054.py | 2 +- tests/unit/tag_expression/test_basics.py | 50 ---- tests/unit/tag_expression/test_builder.py | 181 ++++++++++++++ tests/unit/tag_expression/test_model_ext.py | 15 +- tests/unit/tag_expression/test_parser.py | 5 +- .../test_tag_expression_v1_part1.py | 44 ++-- .../test_tag_expression_v1_part2.py | 34 +-- tests/unit/test_configuration.py | 21 +- tox.ini | 3 +- 20 files changed, 654 insertions(+), 383 deletions(-) create mode 100644 behave/tag_expression/builder.py delete mode 100644 behave/tag_expression/model_ext.py delete mode 100644 tests/unit/tag_expression/test_basics.py create mode 100644 tests/unit/tag_expression/test_builder.py diff --git a/CHANGES.rst b/CHANGES.rst index 55acbb77d..a39d18861 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -91,6 +91,7 @@ FIXED: * FIXED: Some tests related to python-3.11 * FIXED: Some tests related to python-3.9 * FIXED: active-tag logic if multiple tags with same category exists. +* issue #1170: TagExpression auto-detection is not working properly (submitted by: Luca-morphy) * issue #1154: Config-files are not shown in verbose mode (submitted by: soblom) * issue #1120: Logging ignoring level set in setup_logging (submitted by: j7an) * issue #1070: Color support detection: Fails for WindowsTerminal (provided by: jenisys) diff --git a/behave/configuration.py b/behave/configuration.py index 62a91d7d1..0762dfd89 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -34,7 +34,7 @@ from behave.formatter import _registry as _format_registry from behave.reporter.junit import JUnitReporter from behave.reporter.summary import SummaryReporter -from behave.tag_expression import make_tag_expression, TagExpressionProtocol +from behave.tag_expression import TagExpressionProtocol, make_tag_expression from behave.textutil import select_best_encoding, to_texts from behave.userdata import UserData, parse_user_define @@ -318,13 +318,14 @@ def positive_number(text): dict(dest="paths", action="append", help="Specify default feature paths, used when none are provided.")), ((), # -- CONFIGFILE only - dict(dest="tag_expression_protocol", type=TagExpressionProtocol.parse, + dict(dest="tag_expression_protocol", type=TagExpressionProtocol.from_name, choices=TagExpressionProtocol.choices(), - default=TagExpressionProtocol.default().name.lower(), + default=TagExpressionProtocol.DEFAULT.name.lower(), help="""\ Specify the tag-expression protocol to use (default: %(default)s). -With "any", tag-expressions v1 and v2 are supported (in auto-detect mode). -With "strict", only tag-expressions v2 are supported (better error diagnostics). +With "v1", only tag-expressions v1 are supported. +With "v2", only tag-expressions v2 are supported. +With "auto_detect", tag-expressions v1 and v2 are auto-detected. """)), (("-q", "--quiet"), @@ -751,7 +752,7 @@ class Configuration(object): runner=DEFAULT_RUNNER_CLASS_NAME, steps_catalog=False, summary=True, - tag_expression_protocol=TagExpressionProtocol.default(), + tag_expression_protocol=TagExpressionProtocol.DEFAULT, junit=False, stage=None, userdata={}, diff --git a/behave/tag_expression/__init__.py b/behave/tag_expression/__init__.py index bf6d7d704..5b10686da 100644 --- a/behave/tag_expression/__init__.py +++ b/behave/tag_expression/__init__.py @@ -4,7 +4,7 @@ Common module for tag-expressions: * v1: old tag expressions (deprecating; superceeded by: cucumber-tag-expressions) -* v2: cucumber-tag-expressions +* v2: cucumber-tag-expressions (with wildcard extension) .. seealso:: @@ -13,161 +13,8 @@ """ from __future__ import absolute_import -from enum import Enum -import six -# -- NEW CUCUMBER TAG-EXPRESSIONS (v2): -from .parser import TagExpressionParser -from .model import Expression # noqa: F401 -# -- DEPRECATING: OLD-STYLE TAG-EXPRESSIONS (v1): -# BACKWARD-COMPATIBLE SUPPORT -from .v1 import TagExpression +from .builder import TagExpressionProtocol, make_tag_expression # noqa: F401 - -# ----------------------------------------------------------------------------- -# CLASS: TagExpressionProtocol -# ----------------------------------------------------------------------------- -class TagExpressionProtocol(Enum): - """Used to specify which tag-expression versions to support: - - * ANY: Supports tag-expressions v2 and v1 (as compatibility mode) - * STRICT: Supports only tag-expressions v2 (better diagnostics) - - NOTE: - * Some errors are not caught in ANY mode. - """ - ANY = 1 - STRICT = 2 - - @classmethod - def default(cls): - return cls.ANY - - @classmethod - def choices(cls): - return [member.name.lower() for member in cls] - - @classmethod - def parse(cls, name): - name2 = name.upper() - for member in cls: - if name2 == member.name: - return member - # -- OTHERWISE: - message = "{0} (expected: {1})".format(name, ", ".join(cls.choices())) - raise ValueError(message) - - def select_parser(self, tag_expression_text_or_seq): - if self is self.STRICT: - return parse_tag_expression_v2 - # -- CASE: TagExpressionProtocol.ANY - return select_tag_expression_parser4any(tag_expression_text_or_seq) - - - # -- SINGLETON FUNCTIONALITY: - @classmethod - def current(cls): - """Return currently selected protocol instance.""" - return getattr(cls, "_current", cls.default()) - - @classmethod - def use(cls, member): - """Specify which TagExpression protocol to use.""" - if isinstance(member, six.string_types): - name = member - member = cls.parse(name) - assert isinstance(member, TagExpressionProtocol), "%s:%s" % (type(member), member) - setattr(cls, "_current", member) - - - -# ----------------------------------------------------------------------------- -# FUNCTIONS: -# ----------------------------------------------------------------------------- -def make_tag_expression(text_or_seq): - """Build a TagExpression object by parsing the tag-expression (as text). - - :param text_or_seq: - Tag expression text(s) to parse (as string, sequence). - :param protocol: Tag-expression protocol to use. - :return: TagExpression object to use. - """ - parse_tag_expression = TagExpressionProtocol.current().select_parser(text_or_seq) - return parse_tag_expression(text_or_seq) - - -def parse_tag_expression_v1(tag_expression_parts): - """Parse old style tag-expressions and build a TagExpression object.""" - # -- HINT: DEPRECATING - if isinstance(tag_expression_parts, six.string_types): - tag_expression_parts = tag_expression_parts.split() - elif not isinstance(tag_expression_parts, (list, tuple)): - raise TypeError("EXPECTED: string, sequence", tag_expression_parts) - - # print("parse_tag_expression_v1: %s" % " ".join(tag_expression_parts)) - return TagExpression(tag_expression_parts) - - -def parse_tag_expression_v2(text_or_seq): - """Parse cucumber-tag-expressions and build a TagExpression object.""" - text = text_or_seq - if isinstance(text, (list, tuple)): - # -- ASSUME: List of strings - sequence = text_or_seq - terms = ["({0})".format(term) for term in sequence] - text = " and ".join(terms) - elif not isinstance(text, six.string_types): - raise TypeError("EXPECTED: string, sequence", text) - - if "@" in text: - # -- NORMALIZE: tag-expression text => Remove '@' tag decorators. - text = text.replace("@", "") - text = text.replace(" ", " ") - # DIAG: print("parse_tag_expression_v2: %s" % text) - return TagExpressionParser.parse(text) - - -def is_any_equal_to_keyword(words, keywords): - for keyword in keywords: - for word in words: - if keyword == word: - return True - return False - - -# -- CASE: TagExpressionProtocol.ANY -def select_tag_expression_parser4any(text_or_seq): - """Select/Auto-detect which version of tag-expressions is used. - - :param text_or_seq: Tag expression text (as string, sequence) - :return: TagExpression parser to use (as function). - """ - TAG_EXPRESSION_V1_KEYWORDS = [ - "~", "-", "," - ] - TAG_EXPRESSION_V2_KEYWORDS = [ - "and", "or", "not", "(", ")" - ] - - text = text_or_seq - if isinstance(text, (list, tuple)): - # -- CASE: sequence -- Sequence of tag_expression parts - parts = text_or_seq - text = " ".join(parts) - elif not isinstance(text, six.string_types): - raise TypeError("EXPECTED: string, sequence", text) - - text = text.replace("(", " ( ").replace(")", " ) ") - words = text.split() - contains_v1_keywords = any((k in text) for k in TAG_EXPRESSION_V1_KEYWORDS) - contains_v2_keywords = is_any_equal_to_keyword(words, TAG_EXPRESSION_V2_KEYWORDS) - if contains_v2_keywords: - # -- USE: Use cucumber-tag-expressions - return parse_tag_expression_v2 - elif contains_v1_keywords or len(words) > 1: - # -- CASE 1: "-@foo", "~@foo" (negated) - # -- CASE 2: "@foo @bar" - return parse_tag_expression_v1 - - # -- OTHERWISSE: Use cucumber-tag-expressions - # CASE: "@foo" (1 tag) - return parse_tag_expression_v2 +# -- BACKWARD-COMPATIBLE SUPPORT: +# DEPRECATING: OLD-STYLE TAG-EXPRESSIONS (v1) +from .v1 import TagExpression # noqa: F401 diff --git a/behave/tag_expression/builder.py b/behave/tag_expression/builder.py new file mode 100644 index 000000000..3bb7d358e --- /dev/null +++ b/behave/tag_expression/builder.py @@ -0,0 +1,220 @@ +from __future__ import absolute_import +from enum import Enum +import six + +# -- NEW TAG-EXPRESSIONSx v2 (cucumber-tag-expressions with extensions): +from .parser import TagExpressionParser, TagExpressionError +from .model import Matcher as _MatcherV2 +# -- BACKWARD-COMPATIBLE SUPPORT: +# DEPRECATING: OLD-STYLE TAG-EXPRESSIONS (v1) +from .v1 import TagExpression as _TagExpressionV1 + + +# ----------------------------------------------------------------------------- +# CLASS: TagExpression Parsers +# ----------------------------------------------------------------------------- +def _parse_tag_expression_v1(tag_expression_parts): + """Parse old style tag-expressions and build a TagExpression object.""" + # -- HINT: DEPRECATING + if isinstance(tag_expression_parts, six.string_types): + tag_expression_parts = tag_expression_parts.split() + elif not isinstance(tag_expression_parts, (list, tuple)): + raise TypeError("EXPECTED: string, sequence", tag_expression_parts) + + # print("_parse_tag_expression_v1: %s" % " ".join(tag_expression_parts)) + return _TagExpressionV1(tag_expression_parts) + + +def _parse_tag_expression_v2(text_or_seq): + """ + Parse TagExpressions v2 (cucumber-tag-expressions) and + build a TagExpression object. + """ + text = text_or_seq + if isinstance(text, (list, tuple)): + # -- BACKWARD-COMPATIBLE: Sequence mode will be removed (DEPRECATING) + # ASSUME: List of strings + sequence = text_or_seq + terms = ["({0})".format(term) for term in sequence] + text = " and ".join(terms) + elif not isinstance(text, six.string_types): + raise TypeError("EXPECTED: string, sequence", text) + + if "@" in text: + # -- NORMALIZE: tag-expression text => Remove '@' tag decorators. + text = text.replace("@", "") + text = text.replace(" ", " ") + # DIAG: print("_parse_tag_expression_v2: %s" % text) + return TagExpressionParser.parse(text) + + +# ----------------------------------------------------------------------------- +# CLASS: TagExpressionProtocol +# ----------------------------------------------------------------------------- +class TagExpressionProtocol(Enum): + """Used to specify which tag-expression versions to support: + + * AUTO_DETECT: Supports tag-expressions v2 and v1 (as compatibility mode) + * STRICT: Supports only tag-expressions v2 (better diagnostics) + + NOTE: + * Some errors are not caught in AUTO_DETECT mode. + """ + __order__ = "V1, V2, AUTO_DETECT" + V1 = (_parse_tag_expression_v1,) + V2 = (_parse_tag_expression_v2,) + AUTO_DETECT = (None,) # -- AUTO-DETECT: V1 or V2 + + # -- ALIASES: For backward compatibility. + STRICT = V2 + DEFAULT = AUTO_DETECT + + def __init__(self, parse_func): + self._parse_func = parse_func + + def parse(self, text_or_seq): + """ + Parse a TagExpression as string (or sequence-of-strings) + and return the TagExpression object. + """ + parse_func = self._parse_func + if self is self.AUTO_DETECT: + parse_func = _select_tag_expression_parser4auto(text_or_seq) + return parse_func(text_or_seq) + + # -- CLASS-SUPPORT: + @classmethod + def choices(cls): + """Returns a list of TagExpressionProtocol enum-value names.""" + return [member.name.lower() for member in cls] + + @classmethod + def from_name(cls, name): + """Parse the Enum-name and return the Enum-Value.""" + name2 = name.upper() + for member in cls: + if name2 == member.name: + return member + + # -- SPECIAL-CASE: ALIASES + if name2 == "STRICT": + return cls.STRICT + + # -- OTHERWISE: + message = "{0} (expected: {1})".format(name, ", ".join(cls.choices())) + raise ValueError(message) + + # -- SINGLETON FUNCTIONALITY: + @classmethod + def current(cls): + """Return the currently selected protocol default value.""" + return getattr(cls, "_current", cls.DEFAULT) + + @classmethod + def use(cls, member): + """Specify which TagExpression protocol to use per default.""" + if isinstance(member, six.string_types): + name = member + member = cls.from_name(name) + assert isinstance(member, TagExpressionProtocol), "%s:%s" % (type(member), member) + setattr(cls, "_current", member) + + +# ----------------------------------------------------------------------------- +# FUNCTIONS: +# ----------------------------------------------------------------------------- +def make_tag_expression(text_or_seq, protocol=None): + """ + Build a TagExpression object by parsing the tag-expression (as text). + The current TagExpressionProtocol is used to parse the tag-expression. + + :param text_or_seq: + Tag expression text(s) to parse (as string, sequence). + :param protocol: TagExpressionProtocol value to use (or None). + If None is used, the the current TagExpressionProtocol is used. + :return: TagExpression object to use. + """ + if protocol is None: + protocol = TagExpressionProtocol.current() + return protocol.parse(text_or_seq) + + +# ----------------------------------------------------------------------------- +# SUPPORT CASE: TagExpressionProtocol.AUTO_DETECT +# ----------------------------------------------------------------------------- +def _any_word_is_keyword(words, keywords): + """Checks if any word is a keyword.""" + for keyword in keywords: + for word in words: + if keyword == word: + return True + return False + + +def _any_word_contains_keyword(words, keywords): + for keyword in keywords: + for word in words: + if keyword in word: + return True + return False + + +def _any_word_contains_wildcards(words): + """ + Checks if any word (as tag) contains wildcard(s) supported by TagExpression v2. + + :param words: List of words/tags. + :return: True, if any word contains wildcard(s). + """ + return any([_MatcherV2.contains_wildcards(word) for word in words]) + + +def _any_word_starts_with(words, prefixes): + for prefix in prefixes: + if any([w.startswith(prefix) for w in words]): + return True + return False + + +def _select_tag_expression_parser4auto(text_or_seq): + """Select/Auto-detect which version of tag-expressions is used. + + :param text_or_seq: Tag expression text (as string, sequence) + :return: TagExpression parser to use (as function). + """ + TAG_EXPRESSION_V1_NOT_PREFIXES = ["~", "-"] + TAG_EXPRESSION_V1_OTHER_KEYWORDS = [","] + TAG_EXPRESSION_V2_KEYWORDS = [ + "and", "or", "not", "(", ")" + ] + + text = text_or_seq + if isinstance(text, (list, tuple)): + # -- CASE: sequence -- Sequence of tag_expression parts + parts = text_or_seq + text = " ".join(parts) + elif not isinstance(text, six.string_types): + raise TypeError("EXPECTED: string, sequence", text) + + text = text.replace("(", " ( ").replace(")", " ) ") + words = text.split() + contains_v1_prefixes = _any_word_starts_with(words, TAG_EXPRESSION_V1_NOT_PREFIXES) + contains_v1_keywords = (_any_word_contains_keyword(words, TAG_EXPRESSION_V1_OTHER_KEYWORDS) or + # any((k in text) for k in TAG_EXPRESSION_V1_OTHER_KEYWORDS) or + contains_v1_prefixes) + contains_v2_keywords = (_any_word_is_keyword(words, TAG_EXPRESSION_V2_KEYWORDS) or + _any_word_contains_wildcards(words)) + + if contains_v1_prefixes and contains_v2_keywords: + raise TagExpressionError("Contains TagExpression v2 and v1 NOT-PREFIX: %s" % text) + + if contains_v2_keywords: + # -- USE: Use cucumber-tag-expressions + return _parse_tag_expression_v2 + elif contains_v1_keywords or len(words) > 1: + # -- CASE 1: "-@foo", "~@foo" (negated) + # -- CASE 2: "@foo @bar" + return _parse_tag_expression_v1 + + # -- OTHERWISE: Use cucumber-tag-expressions -- One tag/term (CASE: "@foo") + return _parse_tag_expression_v2 diff --git a/behave/tag_expression/model.py b/behave/tag_expression/model.py index 56477d87f..115ddef87 100644 --- a/behave/tag_expression/model.py +++ b/behave/tag_expression/model.py @@ -1,9 +1,51 @@ # -*- coding: UTF-8 -*- # ruff: noqa: F401 # HINT: Import adapter only +""" +Provides TagExpression v2 model classes with some extensions. +Extensions: + +* :class:`Matcher` as tag-matcher, like: ``@a.*`` + +.. code-block:: python + + # -- Expression := a and b + expression = And(Literal("a"), Literal("b")) + assert True == expression.evaluate(["a", "b"]) + assert False == expression.evaluate(["a"]) + assert False == expression.evaluate([]) + + # -- Expression := a or b + expression = Or(Literal("a"), Literal("b")) + assert True == expression.evaluate(["a", "b"]) + assert True == expression.evaluate(["a"]) + assert False == expression.evaluate([]) + + # -- Expression := not a + expression = Not(Literal("a")) + assert False == expression.evaluate(["a"]) + assert True == expression.evaluate(["other"]) + assert True == expression.evaluate([]) + + # -- Expression := (a or b) and c + expression = And(Or(Literal("a"), Literal("b")), Literal("c")) + assert True == expression.evaluate(["a", "c"]) + assert False == expression.evaluate(["c", "other"]) + assert False == expression.evaluate([]) + + # -- Expression := (a.* or b) and c + expression = And(Or(Matcher("a.*"), Literal("b")), Literal("c")) + assert True == expression.evaluate(["a.one", "c"]) +""" + +from __future__ import absolute_import, print_function +from fnmatch import fnmatchcase +import glob +# -- INJECT: Cucumber TagExpression model classes from cucumber_tag_expressions.model import Expression, Literal, And, Or, Not, True_ + # ----------------------------------------------------------------------------- # PATCH TAG-EXPRESSION BASE-CLASS: Expression # ----------------------------------------------------------------------------- @@ -17,6 +59,7 @@ def _Expression_check(self, tags): """ return self.evaluate(tags) + def _Expression_to_string(self, pretty=True): """Provide nicer string conversion(s).""" text = str(self) @@ -46,3 +89,76 @@ def _Not_to_string(self): # -- MONKEY-PATCH: Not.__str__ = _Not_to_string + + +# ----------------------------------------------------------------------------- +# TAG-EXPRESSION EXTENSION: +# ----------------------------------------------------------------------------- +class Matcher(Expression): + """Matches one or more similar tags by using wildcards. + Supports simple filename-matching / globbing wildcards only. + + .. code-block:: python + + # -- CASE: Tag starts-with "foo." + matcher1 = Matcher("foo.*") + assert True == matcher1.evaluate(["foo.bar"]) + + # -- CASE: Tag ends-with ".foo" + matcher2 = Matcher("*.foo") + assert True == matcher2.evaluate(["bar.foo"]) + assert True == matcher2.evaluate(["bar.baz_more.foo"]) + + # -- CASE: Tag contains "foo" + matcher3 = Matcher("*.foo.*") + assert True == matcher3.evaluate(["bar.foo.more"]) + assert True == matcher3.evaluate(["bar.foo"]) + + .. see:: :mod:`fnmatch` + """ + # pylint: disable=too-few-public-methods + def __init__(self, pattern): + super(Matcher, self).__init__() + self.pattern = pattern + + @property + def name(self): + return self.pattern + + def evaluate(self, values): + for value in values: + # -- REQUIRE: case-sensitive matching + if fnmatchcase(value, self.pattern): + return True + # -- OTHERWISE: no-match + return False + + def __str__(self): + return self.pattern + + def __repr__(self): + return "Matcher('%s')" % self.pattern + + @staticmethod + def contains_wildcards(text): + """Indicates if text contains supported wildcards.""" + # -- NOTE: :mod:`glob` wildcards are same as :mod:`fnmatch` + return glob.has_magic(text) + + +# ----------------------------------------------------------------------------- +# TAG-EXPRESSION EXTENSION: +# ----------------------------------------------------------------------------- +class Never(Expression): + """ + A TagExpression which always returns False. + """ + + def evaluate(self, _values): + return False + + def __str__(self): + return "never" + + def __repr__(self): + return "Never()" diff --git a/behave/tag_expression/model_ext.py b/behave/tag_expression/model_ext.py deleted file mode 100644 index ea18c1f67..000000000 --- a/behave/tag_expression/model_ext.py +++ /dev/null @@ -1,93 +0,0 @@ -# -*- coding: UTF-8 -*- -# pylint: disable=missing-docstring -""" -Extended tag-expression model that supports tag-matchers. - -Provides model classes to evaluate parsed boolean tag expressions. - -.. code-block:: python - - # -- Expression := a and b - expression = And(Literal("a"), Literal("b")) - assert True == expression.evaluate(["a", "b"]) - assert False == expression.evaluate(["a"]) - assert False == expression.evaluate([]) - - # -- Expression := a or b - expression = Or(Literal("a"), Literal("b")) - assert True == expression.evaluate(["a", "b"]) - assert True == expression.evaluate(["a"]) - assert False == expression.evaluate([]) - - # -- Expression := not a - expression = Not(Literal("a")) - assert False == expression.evaluate(["a"]) - assert True == expression.evaluate(["other"]) - assert True == expression.evaluate([]) - - # -- Expression := (a or b) and c - expression = And(Or(Literal("a"), Literal("b")), Literal("c")) - assert True == expression.evaluate(["a", "c"]) - assert False == expression.evaluate(["c", "other"]) - assert False == expression.evaluate([]) -""" - -from __future__ import absolute_import -from fnmatch import fnmatchcase -import glob -from .model import Expression - - -# ----------------------------------------------------------------------------- -# TAG-EXPRESSION MODEL CLASSES: -# ----------------------------------------------------------------------------- -class Matcher(Expression): - """Matches one or more similar tags by using wildcards. - Supports simple filename-matching / globbing wildcards only. - - .. code-block:: python - - # -- CASE: Tag starts-with "foo." - matcher1 = Matcher("foo.*") - assert True == matcher1.evaluate(["foo.bar"]) - - # -- CASE: Tag ends-with ".foo" - matcher2 = Matcher("*.foo") - assert True == matcher2.evaluate(["bar.foo"]) - assert True == matcher2.evaluate(["bar.baz_more.foo"]) - - # -- CASE: Tag contains "foo" - matcher3 = Matcher("*.foo.*") - assert True == matcher3.evaluate(["bar.foo.more"]) - assert True == matcher3.evaluate(["bar.foo"]) - - .. see:: :mod:`fnmatch` - """ - # pylint: disable=too-few-public-methods - def __init__(self, pattern): - super(Matcher, self).__init__() - self.pattern = pattern - - @property - def name(self): - return self.pattern - - def evaluate(self, values): - for value in values: - # -- REQUIRE: case-sensitive matching - if fnmatchcase(value, self.pattern): - return True - # -- OTHERWISE: no-match - return False - - def __str__(self): - return self.pattern - - def __repr__(self): - return "Matcher('%s')" % self.pattern - - @staticmethod - def contains_wildcards(text): - """Indicates if text contains supported wildcards.""" - # -- NOTE: :mod:`glob` wildcards are same as :mod:`fnmatch` - return glob.has_magic(text) diff --git a/behave/tag_expression/parser.py b/behave/tag_expression/parser.py index 6854b8b63..33776509e 100644 --- a/behave/tag_expression/parser.py +++ b/behave/tag_expression/parser.py @@ -16,11 +16,10 @@ from __future__ import absolute_import from cucumber_tag_expressions.parser import ( TagExpressionParser as _TagExpressionParser, - # PROVIDE: Similar interface like: cucumber_tag_expressions.parser + # -- PROVIDE: Similar interface like: cucumber_tag_expressions.parser TagExpressionError # noqa: F401 ) -from cucumber_tag_expressions.model import Literal -from .model_ext import Matcher +from .model import Literal, Matcher class TagExpressionParser(_TagExpressionParser): diff --git a/docs/behave.rst b/docs/behave.rst index cc80eafc2..30d65cfaf 100644 --- a/docs/behave.rst +++ b/docs/behave.rst @@ -574,10 +574,10 @@ Configuration Parameters .. describe:: tag_expression_protocol : TagExpressionProtocol (Enum) - Specify the tag-expression protocol to use (default: any). With "any", - tag-expressions v2 and v2 are supported (in auto-detect mode). - With "strict", only tag-expressions v2 is supported (better error - diagnostics). + Specify the tag-expression protocol to use (default: auto_detect). + With "v1", only tag-expressions v1 are supported. With "v2", only + tag-expressions v2 are supported. With "auto_detect", tag- + expressions v1 and v2 are auto-detected. .. index:: single: configuration param; quiet diff --git a/features/steps/behave_tag_expression_steps.py b/features/steps/behave_tag_expression_steps.py index e7e5f2a15..833f15fa0 100644 --- a/features/steps/behave_tag_expression_steps.py +++ b/features/steps/behave_tag_expression_steps.py @@ -42,6 +42,8 @@ def __init__(self, name, tags=None): # ----------------------------------------------------------------------------- def convert_tag_expression(text): return make_tag_expression(text.strip()) + + register_type(TagExpression=convert_tag_expression) diff --git a/issue.features/issue1170.feature b/issue.features/issue1170.feature index a5c63095a..bed8030fd 100644 --- a/issue.features/issue1170.feature +++ b/issue.features/issue1170.feature @@ -4,8 +4,8 @@ Feature: Issue #1170 -- Tag Expression Auto Detection Problem . DESCRIPTION OF SYNDROME (OBSERVED BEHAVIOR): . TagExpression v2 wildcard matching does not work if one dashed-tag is used. . - . WORKAROUND: - . * Use TagExpression auto-detection in strict mode + . WORKAROUND-UNTIL-FIXED: + . * Use TagExpression auto-detection in v2 mode (or strict mode) Background: Setup @@ -31,31 +31,62 @@ Feature: Issue #1170 -- Tag Expression Auto Detection Problem Then some step passes """ - @xfailed - Scenario: Use one TagExpression Term with Wildcard -- BROKEN + + Scenario: Use one TagExpression Term with Wildcard in default mode (AUTO-DETECT) When I run `behave --tags="file-test*" features/syndrome_1170.feature` Then it should pass with: """ - 0 features passed, 0 failed, 1 skipped - 0 scenarios passed, 0 failed, 3 skipped + 2 scenarios passed, 0 failed, 1 skipped + """ + And note that "TagExpression auto-detection should to select TagExpressionV2" + And note that "first two scenarios should have been executed" + But note that "last scenario should be skipped" + + + Scenario: Use one TagExpression Term with Wildcard in AUTO Mode (explicit: auto-detect) + Given a file named "behave.ini" with: + """ + # -- ENSURE: Use TagExpression v1 or v2 (with auto-detection) + [behave] + tag_expression_protocol = auto_detect + """ + When I run `behave --tags="file-test*" features/syndrome_1170.feature` + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 1 skipped """ - And note that "TagExpression auto-detection seems to select TagExpressionV1" - And note that "no scenarios is selected/executed" - But note that "first two scenarios should have been executed" + And note that "TagExpression auto-detection should to select TagExpressionV2" + And note that "first two scenarios should have been executed" + But note that "last scenario should be skipped" + + + Scenario: Use one TagExpression Term with Wildcard in V2 Mode + Given a file named "behave.ini" with: + """ + # -- ENSURE: Only TagExpressions v2 is used + [behave] + tag_expression_protocol = v2 + """ + When I run `behave --tags="file-test*" features/syndrome_1170.feature` + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 1 skipped + """ + And note that "TagExpressions v2 are used" + And note that "first two scenarios are selected/executed" - Scenario: Use one TagExpression Term with Wildcard -- Strict Mode + Scenario: Use one TagExpression Term with Wildcard in STRICT Mode Given a file named "behave.ini" with: """ - # -- ENSURE: Only TagExpression v2 is used (with auto-detection in strict mode) + # -- ENSURE: Only TagExpressions v2 is used with strict mode [behave] tag_expression_protocol = strict """ When I run `behave --tags="file-test*" features/syndrome_1170.feature` Then it should pass with: """ - 1 feature passed, 0 failed, 0 skipped 2 scenarios passed, 0 failed, 1 skipped """ - And note that "TagExpression auto-detection seems to select TagExpressionV2" + And note that "TagExpressions v2 are used" And note that "first two scenarios are selected/executed" diff --git a/tasks/py.requirements.txt b/tasks/py.requirements.txt index 263331d34..fdabed241 100644 --- a/tasks/py.requirements.txt +++ b/tasks/py.requirements.txt @@ -14,8 +14,8 @@ pycmd six >= 1.15.0 # -- HINT, was RENAMED: path.py => path (for python3) -path >= 13.1.0; python_version >= '3.5' -path.py >= 11.5.0; python_version < '3.5' +path.py >=11.5.0,<13.0; python_version < '3.5' +path >= 13.1.0; python_version >= '3.5' # -- PYTHON2 BACKPORTS: pathlib; python_version <= '3.4' diff --git a/tests/issues/test_issue1054.py b/tests/issues/test_issue1054.py index adf572706..5dd943969 100644 --- a/tests/issues/test_issue1054.py +++ b/tests/issues/test_issue1054.py @@ -5,7 +5,7 @@ from __future__ import absolute_import, print_function from behave.__main__ import run_behave from behave.configuration import Configuration -from behave.tag_expression import make_tag_expression +from behave.tag_expression.builder import make_tag_expression import pytest from assertpy import assert_that diff --git a/tests/unit/tag_expression/test_basics.py b/tests/unit/tag_expression/test_basics.py deleted file mode 100644 index 6ebc4a896..000000000 --- a/tests/unit/tag_expression/test_basics.py +++ /dev/null @@ -1,50 +0,0 @@ -# -*- coding: UTF-8 -*- - -from behave.tag_expression import ( - make_tag_expression, select_tag_expression_parser4any, - parse_tag_expression_v1, parse_tag_expression_v2 -) -from behave.tag_expression.v1 import TagExpression as TagExpressionV1 -from behave.tag_expression.model_ext import Expression as TagExpressionV2 -import pytest - -# ----------------------------------------------------------------------------- -# TEST SUITE FOR: make_tag_expression() -# ----------------------------------------------------------------------------- -def test_make_tag_expression__with_v1(): - pass - -def test_make_tag_expression__with_v2(): - pass - - -# ----------------------------------------------------------------------------- -# TEST SUITE FOR: select_tag_expression_parser4any() -# ----------------------------------------------------------------------------- -@pytest.mark.parametrize("text", [ - "@foo @bar", - "foo bar", - "-foo", - "~foo", - "-@foo", - "~@foo", - "@foo,@bar", - "-@xfail -@not_implemented", -]) -def test_select_tag_expression_parser4any__with_v1(text): - parser = select_tag_expression_parser4any(text) - assert parser is parse_tag_expression_v1, "tag_expression: %s" % text - - -@pytest.mark.parametrize("text", [ - "@foo", - "foo", - "not foo", - "foo and bar", - "@foo or @bar", - "(@foo and @bar) or @baz", - "not @xfail or not @not_implemented" -]) -def test_select_tag_expression_parser4any__with_v2(text): - parser = select_tag_expression_parser4any(text) - assert parser is parse_tag_expression_v2, "tag_expression: %s" % text diff --git a/tests/unit/tag_expression/test_builder.py b/tests/unit/tag_expression/test_builder.py new file mode 100644 index 000000000..06c39fa04 --- /dev/null +++ b/tests/unit/tag_expression/test_builder.py @@ -0,0 +1,181 @@ +""" +Test if TagExpression protocol/version is detected correctly. +""" + +from __future__ import absolute_import, print_function +import pytest +from behave.tag_expression.builder import TagExpressionProtocol, make_tag_expression +from behave.tag_expression.v1 import TagExpression as TagExpressionV1 +from behave.tag_expression.model import Expression as TagExpressionV2 +from behave.tag_expression.parser import TagExpressionError as TagExpressionError + + +# ----------------------------------------------------------------------------- +# TEST DATA +# ----------------------------------------------------------------------------- +# -- USED FOR: TagExpressionProtocol.AUTO_DETECT +TAG_EXPRESSION_V1_GOOD_EXAMPLES_FOR_AUTO_DETECT = [ + "@a,@b", + "@a @b", + "-@a", + "~@a", +] +TAG_EXPRESSION_V2_GOOD_EXAMPLES_FOR_AUTO_DETECT = [ + "@a", + "@a.*", + "@dashed-tag", + "@a and @b", + "@a or @b", + "@a or (@b and @c)", + "not @a", +] +# -- CHECK-SOME: Mixtures of TagExpression v1 and v2 +TAG_EXPRESSION_V2_BAD_EXAMPLES_FOR_AUTO_DETECT = [ + "-@a and @b", + "@a and -@b", + "~@a or @b", + "@a or ~@b", + "@a and not -@b", +] + +# -- USED FOR: TagExpressionProtocol.V1 +TAG_EXPRESSION_V1_GOOD_EXAMPLES = [ + "@a", + "@one-and-more", +] + TAG_EXPRESSION_V1_GOOD_EXAMPLES_FOR_AUTO_DETECT + +# -- USED FOR: TagExpressionProtocol.V2 +TAG_EXPRESSION_V2_GOOD_EXAMPLES = TAG_EXPRESSION_V2_GOOD_EXAMPLES_FOR_AUTO_DETECT + + +# ----------------------------------------------------------------------------- +# TEST SUPPORT +# ----------------------------------------------------------------------------- +def assert_is_tag_expression_v1(tag_expression): + assert isinstance(tag_expression, TagExpressionV1), "%r" % tag_expression + + +def assert_is_tag_expression_v2(tag_expression): + assert isinstance(tag_expression, TagExpressionV2), "%r" % tag_expression + + +def assert_is_tag_expression_for_protocol(tag_expression, expected_tag_expression_protocol): + # -- STEP 1: Select assert-function + def assert_false(tag_expression): + assert False, "UNEXPECTED: %r (for: %s)" % \ + (expected_tag_expression_protocol, tag_expression) + + assert_func = assert_false + if expected_tag_expression_protocol is TagExpressionProtocol.V1: + assert_func = assert_is_tag_expression_v1 + elif expected_tag_expression_protocol is TagExpressionProtocol.V2: + assert_func = assert_is_tag_expression_v2 + + # -- STEP 2: Apply assert-function + assert_func(tag_expression) + + +# ----------------------------------------------------------------------------- +# TEST SUITE +# ----------------------------------------------------------------------------- +class TestTagExpressionProtocol(object): + """ + Test TagExpressionProtocol class. + """ + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V1_GOOD_EXAMPLES_FOR_AUTO_DETECT) + def test_parse_using_protocol_auto_detect_builds_v1(self, text): + this_protocol = TagExpressionProtocol.AUTO_DETECT + tag_expression = this_protocol.parse(text) + assert_is_tag_expression_v1(tag_expression) + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V2_GOOD_EXAMPLES_FOR_AUTO_DETECT) + def test_parse_using_protocol_auto_detect_builds_v2(self, text): + this_protocol = TagExpressionProtocol.AUTO_DETECT + tag_expression = this_protocol.parse(text) + assert_is_tag_expression_v2(tag_expression) + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V2_BAD_EXAMPLES_FOR_AUTO_DETECT) + def test_parse_using_protocol_auto_detect_raises_error_if_v1_and_v2_are_used(self, text): + this_protocol = TagExpressionProtocol.AUTO_DETECT + with pytest.raises(TagExpressionError) as e: + _tag_expression = this_protocol.parse(text) + + print("CAUGHT-EXCEPTION: %s" % e.value) + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V1_GOOD_EXAMPLES) + def test_parse_using_protocol_v1_builds_v1(self, text): + print("tag_expression: %s" % text) + this_protocol = TagExpressionProtocol.V1 + tag_expression = this_protocol.parse(text) + assert_is_tag_expression_v1(tag_expression) + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V2_GOOD_EXAMPLES) + def test_parse_using_protocol_v2_builds_v2(self, text): + print("tag_expression: %s" % text) + this_protocol = TagExpressionProtocol.V2 + tag_expression = this_protocol.parse(text) + assert_is_tag_expression_v2(tag_expression) + + +# ----------------------------------------------------------------------------- +# TEST SUITE FOR: make_tag_expression() +# ----------------------------------------------------------------------------- +class TestMakeTagExpression(object): + """Test :func:`make_tag_expression()`.""" + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V1_GOOD_EXAMPLES) + def test_with_protocol_v1(self, text): + tag_expression = make_tag_expression(text, TagExpressionProtocol.V1) + assert_is_tag_expression_v1(tag_expression) + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V2_GOOD_EXAMPLES) + def test_with_protocol_v2(self, text): + tag_expression = make_tag_expression(text, TagExpressionProtocol.V2) + assert_is_tag_expression_v2(tag_expression) + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V1_GOOD_EXAMPLES_FOR_AUTO_DETECT) + def test_with_protocol_auto_detect_for_v1(self, text): + tag_expression = make_tag_expression(text, TagExpressionProtocol.AUTO_DETECT) + assert_is_tag_expression_v1(tag_expression) + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V2_GOOD_EXAMPLES_FOR_AUTO_DETECT) + def test_with_protocol_auto_detect_for_v2(self, text): + tag_expression = make_tag_expression(text, TagExpressionProtocol.AUTO_DETECT) + assert_is_tag_expression_v2(tag_expression) + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V1_GOOD_EXAMPLES) + def test_with_default_protocol_v1(self, text): + TagExpressionProtocol.use(TagExpressionProtocol.V1) + tag_expression = make_tag_expression(text) + assert_is_tag_expression_v1(tag_expression) + assert TagExpressionProtocol.current() == TagExpressionProtocol.V1 + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V2_GOOD_EXAMPLES) + def test_with_default_protocol_v2(self, text): + TagExpressionProtocol.use(TagExpressionProtocol.V2) + tag_expression = make_tag_expression(text) + assert_is_tag_expression_v2(tag_expression) + assert TagExpressionProtocol.current() == TagExpressionProtocol.V2 + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V1_GOOD_EXAMPLES_FOR_AUTO_DETECT) + def test_with_default_protocol_auto_and_tag_expression_v1(self, text): + TagExpressionProtocol.use(TagExpressionProtocol.AUTO_DETECT) + tag_expression = make_tag_expression(text) + assert_is_tag_expression_for_protocol(tag_expression, TagExpressionProtocol.V1) + assert TagExpressionProtocol.current() == TagExpressionProtocol.AUTO_DETECT + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V2_GOOD_EXAMPLES_FOR_AUTO_DETECT) + def test_with_default_protocol_auto_and_tag_expression_v2(self, text): + TagExpressionProtocol.use(TagExpressionProtocol.AUTO_DETECT) + tag_expression = make_tag_expression(text) + assert_is_tag_expression_for_protocol(tag_expression, TagExpressionProtocol.V2) + assert TagExpressionProtocol.current() == TagExpressionProtocol.AUTO_DETECT + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V2_BAD_EXAMPLES_FOR_AUTO_DETECT) + def test_with_default_protocol_auto_and_bad_tag_expression_with_v1_and_v2(self, text): + TagExpressionProtocol.use(TagExpressionProtocol.AUTO_DETECT) + with pytest.raises(TagExpressionError) as e: + _tag_expression = make_tag_expression(text) + + print("CAUGHT-EXCEPTION: %s" % e.value) diff --git a/tests/unit/tag_expression/test_model_ext.py b/tests/unit/tag_expression/test_model_ext.py index 71193d483..50ff68bbd 100644 --- a/tests/unit/tag_expression/test_model_ext.py +++ b/tests/unit/tag_expression/test_model_ext.py @@ -2,10 +2,7 @@ # pylint: disable=bad-whitespace from __future__ import absolute_import -from behave.tag_expression.model import Expression, Literal -from behave.tag_expression.model_ext import Matcher -# NOT-NEEDED: from cucumber_tag_expressions.model import Literal, Matcher -# NOT-NEEDED: from cucumber_tag_expressions.model import And, Or, Not, True_ +from behave.tag_expression.model import Literal, Matcher, Never import pytest @@ -54,3 +51,13 @@ def test_evaluate_with_endswith_pattern(self, expected, tag, case): def test_evaluate_with_contains_pattern(self, expected, tag, case): expression = Matcher("*.foo.*") assert expression.evaluate([tag]) == expected + +class TestNever(object): + @pytest.mark.parametrize("tags, case", [ + ([], "no_tags"), + (["foo", "bar"], "some tags"), + (["foo", "other"], "some tags2"), + ]) + def test_evaluate_returns_false(self, tags, case): + expression = Never() + assert expression.evaluate(tags) is False diff --git a/tests/unit/tag_expression/test_parser.py b/tests/unit/tag_expression/test_parser.py index dfe14f4d2..ee626da2d 100644 --- a/tests/unit/tag_expression/test_parser.py +++ b/tests/unit/tag_expression/test_parser.py @@ -1,13 +1,14 @@ # -*- coding: UTF-8 -*- # pylint: disable=bad-whitespace """ -Unit tests for tag-expression parser. +Unit tests for tag-expression parser for TagExpression v2. """ from __future__ import absolute_import, print_function from behave.tag_expression.parser import TagExpressionParser, TagExpressionError -from cucumber_tag_expressions.parser import \ +from cucumber_tag_expressions.parser import ( Token, Associative, TokenType +) import pytest diff --git a/tests/unit/tag_expression/test_tag_expression_v1_part1.py b/tests/unit/tag_expression/test_tag_expression_v1_part1.py index 56fb85d5b..6b36e3674 100644 --- a/tests/unit/tag_expression/test_tag_expression_v1_part1.py +++ b/tests/unit/tag_expression/test_tag_expression_v1_part1.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import -from behave.tag_expression import TagExpression +from behave.tag_expression.v1 import TagExpression as TagExpressionV1 import pytest import unittest @@ -11,7 +11,7 @@ # ---------------------------------------------------------------------------- class TestTagExpressionNoTags(unittest.TestCase): def setUp(self): - self.e = TagExpression([]) + self.e = TagExpressionV1([]) def test_should_match_empty_tags(self): assert self.e.check([]) @@ -22,7 +22,7 @@ def test_should_match_foo(self): class TestTagExpressionFoo(unittest.TestCase): def setUp(self): - self.e = TagExpression(['foo']) + self.e = TagExpressionV1(['foo']) def test_should_not_match_no_tags(self): assert not self.e.check([]) @@ -36,7 +36,7 @@ def test_should_not_match_bar(self): class TestTagExpressionNotFoo(unittest.TestCase): def setUp(self): - self.e = TagExpression(['-foo']) + self.e = TagExpressionV1(['-foo']) def test_should_match_no_tags(self): assert self.e.check([]) @@ -55,7 +55,7 @@ class TestTagExpressionFooAndBar(unittest.TestCase): # -- LOGIC: @foo and @bar def setUp(self): - self.e = TagExpression(['foo', 'bar']) + self.e = TagExpressionV1(['foo', 'bar']) def test_should_not_match_no_tags(self): assert not self.e.check([]) @@ -108,7 +108,7 @@ class TestTagExpressionFooAndNotBar(unittest.TestCase): # -- LOGIC: @foo and not @bar def setUp(self): - self.e = TagExpression(['foo', '-bar']) + self.e = TagExpressionV1(['foo', '-bar']) def test_should_not_match_no_tags(self): assert not self.e.check([]) @@ -162,14 +162,14 @@ class TestTagExpressionNotBarAndFoo(TestTagExpressionFooAndNotBar): # LOGIC: not @bar and @foo == @foo and not @bar def setUp(self): - self.e = TagExpression(['-bar', 'foo']) + self.e = TagExpressionV1(['-bar', 'foo']) class TestTagExpressionNotFooAndNotBar(unittest.TestCase): # -- LOGIC: not @bar and not @foo def setUp(self): - self.e = TagExpression(['-foo', '-bar']) + self.e = TagExpressionV1(['-foo', '-bar']) def test_should_match_no_tags(self): assert self.e.check([]) @@ -223,7 +223,7 @@ class TestTagExpressionNotBarAndNotFoo(TestTagExpressionNotFooAndNotBar): # LOGIC: not @bar and not @foo == not @foo and not @bar def setUp(self): - self.e = TagExpression(['-bar', '-foo']) + self.e = TagExpressionV1(['-bar', '-foo']) # ---------------------------------------------------------------------------- @@ -231,7 +231,7 @@ def setUp(self): # ---------------------------------------------------------------------------- class TestTagExpressionFooOrBar(unittest.TestCase): def setUp(self): - self.e = TagExpression(['foo,bar']) + self.e = TagExpressionV1(['foo,bar']) def test_should_not_match_no_tags(self): assert not self.e.check([]) @@ -284,12 +284,12 @@ class TestTagExpressionBarOrFoo(TestTagExpressionFooOrBar): # -- REUSE: Test suite due to symmetry in reversed expression # LOGIC: @bar or @foo == @foo or @bar def setUp(self): - self.e = TagExpression(['bar,foo']) + self.e = TagExpressionV1(['bar,foo']) class TestTagExpressionFooOrNotBar(unittest.TestCase): def setUp(self): - self.e = TagExpression(['foo,-bar']) + self.e = TagExpressionV1(['foo,-bar']) def test_should_match_no_tags(self): assert self.e.check([]) @@ -342,12 +342,12 @@ class TestTagExpressionNotBarOrFoo(TestTagExpressionFooOrNotBar): # -- REUSE: Test suite due to symmetry in reversed expression # LOGIC: not @bar or @foo == @foo or not @bar def setUp(self): - self.e = TagExpression(['-bar,foo']) + self.e = TagExpressionV1(['-bar,foo']) class TestTagExpressionNotFooOrNotBar(unittest.TestCase): def setUp(self): - self.e = TagExpression(['-foo,-bar']) + self.e = TagExpressionV1(['-foo,-bar']) def test_should_match_no_tags(self): assert self.e.check([]) @@ -400,7 +400,7 @@ class TestTagExpressionNotBarOrNotFoo(TestTagExpressionNotFooOrNotBar): # -- REUSE: Test suite due to symmetry in reversed expression # LOGIC: not @bar or @foo == @foo or not @bar def setUp(self): - self.e = TagExpression(['-bar,-foo']) + self.e = TagExpressionV1(['-bar,-foo']) # ---------------------------------------------------------------------------- @@ -408,7 +408,7 @@ def setUp(self): # ---------------------------------------------------------------------------- class TestTagExpressionFooOrBarAndNotZap(unittest.TestCase): def setUp(self): - self.e = TagExpression(['foo,bar', '-zap']) + self.e = TagExpressionV1(['foo,bar', '-zap']) def test_should_match_foo(self): assert self.e.check(['foo']) @@ -473,7 +473,7 @@ def test_should_not_match_zap_baz_other(self): # ---------------------------------------------------------------------------- class TestTagExpressionFoo3OrNotBar4AndZap5(unittest.TestCase): def setUp(self): - self.e = TagExpression(['foo:3,-bar', 'zap:5']) + self.e = TagExpressionV1(['foo:3,-bar', 'zap:5']) def test_should_count_tags_for_positive_tags(self): assert self.e.limits == {'foo': 3, 'zap': 5} @@ -484,7 +484,7 @@ def test_should_match_foo_zap(self): class TestTagExpressionParsing(unittest.TestCase): def setUp(self): - self.e = TagExpression([' foo:3 , -bar ', ' zap:5 ']) + self.e = TagExpressionV1([' foo:3 , -bar ', ' zap:5 ']) def test_should_have_limits(self): assert self.e.limits == {'zap': 5, 'foo': 3} @@ -492,18 +492,18 @@ def test_should_have_limits(self): class TestTagExpressionTagLimits(unittest.TestCase): def test_should_be_counted_for_negative_tags(self): - e = TagExpression(['-todo:3']) + e = TagExpressionV1(['-todo:3']) assert e.limits == {'todo': 3} def test_should_be_counted_for_positive_tags(self): - e = TagExpression(['todo:3']) + e = TagExpressionV1(['todo:3']) assert e.limits == {'todo': 3} def test_should_raise_an_error_for_inconsistent_limits(self): with pytest.raises(Exception): - _ = TagExpression(['todo:3', '-todo:4']) + _ = TagExpressionV1(['todo:3', '-todo:4']) def test_should_allow_duplicate_consistent_limits(self): - e = TagExpression(['todo:3', '-todo:3']) + e = TagExpressionV1(['todo:3', '-todo:3']) assert e.limits == {'todo': 3} diff --git a/tests/unit/tag_expression/test_tag_expression_v1_part2.py b/tests/unit/tag_expression/test_tag_expression_v1_part2.py index cf619da95..9f58e713b 100644 --- a/tests/unit/tag_expression/test_tag_expression_v1_part2.py +++ b/tests/unit/tag_expression/test_tag_expression_v1_part2.py @@ -9,7 +9,7 @@ import itertools from six.moves import range import pytest -from behave.tag_expression import TagExpression +from behave.tag_expression.v1 import TagExpression as TagExpressionV1 has_combinations = hasattr(itertools, "combinations") @@ -96,7 +96,7 @@ class TestTagExpressionWith1Term(TagExpressionTestCase): tag_combinations = all_combinations(tags) def test_matches__foo(self): - tag_expression = TagExpression(["@foo"]) + tag_expression = TagExpressionV1(["@foo"]) expected = [ # -- WITH 0 tags: None "@foo", @@ -106,7 +106,7 @@ def test_matches__foo(self): self.tag_combinations, expected) def test_matches__not_foo(self): - tag_expression = TagExpression(["-@foo"]) + tag_expression = TagExpressionV1(["-@foo"]) expected = [ NO_TAGS, "@other", @@ -127,7 +127,7 @@ class TestTagExpressionWith2Terms(TagExpressionTestCase): # -- LOGICAL-OR CASES: def test_matches__foo_or_bar(self): - tag_expression = TagExpression(["@foo,@bar"]) + tag_expression = TagExpressionV1(["@foo,@bar"]) expected = [ # -- WITH 0 tags: None "@foo", "@bar", @@ -138,7 +138,7 @@ def test_matches__foo_or_bar(self): self.tag_combinations, expected) def test_matches__foo_or_not_bar(self): - tag_expression = TagExpression(["@foo,-@bar"]) + tag_expression = TagExpressionV1(["@foo,-@bar"]) expected = [ NO_TAGS, "@foo", "@other", @@ -149,7 +149,7 @@ def test_matches__foo_or_not_bar(self): self.tag_combinations, expected) def test_matches__not_foo_or_not_bar(self): - tag_expression = TagExpression(["-@foo,-@bar"]) + tag_expression = TagExpressionV1(["-@foo,-@bar"]) expected = [ NO_TAGS, "@foo", "@bar", "@other", @@ -160,7 +160,7 @@ def test_matches__not_foo_or_not_bar(self): # -- LOGICAL-AND CASES: def test_matches__foo_and_bar(self): - tag_expression = TagExpression(["@foo", "@bar"]) + tag_expression = TagExpressionV1(["@foo", "@bar"]) expected = [ # -- WITH 0 tags: None # -- WITH 1 tag: None @@ -171,7 +171,7 @@ def test_matches__foo_and_bar(self): self.tag_combinations, expected) def test_matches__foo_and_not_bar(self): - tag_expression = TagExpression(["@foo", "-@bar"]) + tag_expression = TagExpressionV1(["@foo", "-@bar"]) expected = [ # -- WITH 0 tags: None # -- WITH 1 tag: None @@ -183,7 +183,7 @@ def test_matches__foo_and_not_bar(self): self.tag_combinations, expected) def test_matches__not_foo_and_not_bar(self): - tag_expression = TagExpression(["-@foo", "-@bar"]) + tag_expression = TagExpressionV1(["-@foo", "-@bar"]) expected = [ NO_TAGS, "@other", @@ -211,7 +211,7 @@ class TestTagExpressionWith3Terms(TagExpressionTestCase): # -- LOGICAL-OR CASES: def test_matches__foo_or_bar_or_zap(self): - tag_expression = TagExpression(["@foo,@bar,@zap"]) + tag_expression = TagExpressionV1(["@foo,@bar,@zap"]) matched = [ # -- WITH 0 tags: None # -- WITH 1 tag: @@ -242,7 +242,7 @@ def test_matches__foo_or_bar_or_zap(self): self.tag_combinations, mismatched) def test_matches__foo_or_not_bar_or_zap(self): - tag_expression = TagExpression(["@foo,-@bar,@zap"]) + tag_expression = TagExpressionV1(["@foo,-@bar,@zap"]) matched = [ # -- WITH 0 tags: NO_TAGS, @@ -275,7 +275,7 @@ def test_matches__foo_or_not_bar_or_zap(self): def test_matches__foo_or_not_bar_or_not_zap(self): - tag_expression = TagExpression(["foo,-@bar,-@zap"]) + tag_expression = TagExpressionV1(["foo,-@bar,-@zap"]) matched = [ # -- WITH 0 tags: NO_TAGS, @@ -306,7 +306,7 @@ def test_matches__foo_or_not_bar_or_not_zap(self): self.tag_combinations, mismatched) def test_matches__not_foo_or_not_bar_or_not_zap(self): - tag_expression = TagExpression(["-@foo,-@bar,-@zap"]) + tag_expression = TagExpressionV1(["-@foo,-@bar,-@zap"]) matched = [ # -- WITH 0 tags: NO_TAGS, @@ -337,7 +337,7 @@ def test_matches__not_foo_or_not_bar_or_not_zap(self): self.tag_combinations, mismatched) def test_matches__foo_and_bar_or_zap(self): - tag_expression = TagExpression(["@foo", "@bar,@zap"]) + tag_expression = TagExpressionV1(["@foo", "@bar,@zap"]) matched = [ # -- WITH 0 tags: # -- WITH 1 tag: @@ -368,7 +368,7 @@ def test_matches__foo_and_bar_or_zap(self): self.tag_combinations, mismatched) def test_matches__foo_and_bar_or_not_zap(self): - tag_expression = TagExpression(["@foo", "@bar,-@zap"]) + tag_expression = TagExpressionV1(["@foo", "@bar,-@zap"]) matched = [ # -- WITH 0 tags: # -- WITH 1 tag: @@ -401,7 +401,7 @@ def test_matches__foo_and_bar_or_not_zap(self): self.tag_combinations, mismatched) def test_matches__foo_and_bar_and_zap(self): - tag_expression = TagExpression(["@foo", "@bar", "@zap"]) + tag_expression = TagExpressionV1(["@foo", "@bar", "@zap"]) matched = [ # -- WITH 0 tags: # -- WITH 1 tag: @@ -432,7 +432,7 @@ def test_matches__foo_and_bar_and_zap(self): self.tag_combinations, mismatched) def test_matches__not_foo_and_not_bar_and_not_zap(self): - tag_expression = TagExpression(["-@foo", "-@bar", "-@zap"]) + tag_expression = TagExpressionV1(["-@foo", "-@bar", "-@zap"]) matched = [ # -- WITH 0 tags: NO_TAGS, diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index 50bce2f58..f55c1ac5b 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -297,7 +297,7 @@ def make_config_file_with_tag_expression_protocol(value, tmp_path): @classmethod def check_tag_expression_protocol_with_valid_value(cls, value, tmp_path): - TagExpressionProtocol.use(TagExpressionProtocol.default()) + TagExpressionProtocol.use(TagExpressionProtocol.DEFAULT) cls.make_config_file_with_tag_expression_protocol(value, tmp_path) with use_current_directory(tmp_path): config = Configuration() @@ -312,17 +312,25 @@ def check_tag_expression_protocol_with_valid_value(cls, value, tmp_path): def test_tag_expression_protocol(self, value, tmp_path): self.check_tag_expression_protocol_with_valid_value(value, tmp_path) - @pytest.mark.parametrize("value", ["Any", "ANY", "Strict", "STRICT"]) + @pytest.mark.parametrize("value", [ + "v1", "V1", + "v2", "V2", + "auto_detect", "AUTO_DETECT", "Auto_detect", + # -- DEPRECATING: + "strict", "STRICT", "Strict", + ]) def test_tag_expression_protocol__is_not_case_sensitive(self, value, tmp_path): self.check_tag_expression_protocol_with_valid_value(value, tmp_path) @pytest.mark.parametrize("value", [ - "__UNKNOWN__", "v1", "v2", + "__UNKNOWN__", # -- SIMILAR: to valid values - ".any", "any.", "_strict", "strict_" + "v1_", "_v2", + ".auto", "auto_detect.", + "_strict", "strict_" ]) def test_tag_expression_protocol__with_invalid_value_raises_error(self, value, tmp_path): - default_value = TagExpressionProtocol.default() + default_value = TagExpressionProtocol.DEFAULT TagExpressionProtocol.use(default_value) self.make_config_file_with_tag_expression_protocol(value, tmp_path) with use_current_directory(tmp_path): @@ -332,7 +340,8 @@ def test_tag_expression_protocol__with_invalid_value_raises_error(self, value, t print("USE: config.tag_expression_protocol={0}".format( config.tag_expression_protocol)) + choices = ", ".join(TagExpressionProtocol.choices()) + expected = "{value} (expected: {choices})".format(value=value, choices=choices) assert TagExpressionProtocol.current() is default_value - expected = "{value} (expected: any, strict)".format(value=value) assert exc_info.type is ValueError assert expected in str(exc_info.value) diff --git a/tox.ini b/tox.ini index c4cdd0ff1..eb838df8d 100644 --- a/tox.ini +++ b/tox.ini @@ -7,7 +7,6 @@ # # USAGE: # tox -e py39 #< Run tests with python3.9 -# tox -e py27 #< Run tests with python2.7 # # SEE ALSO: # * https://tox.wiki/en/latest/config.html @@ -16,7 +15,7 @@ [tox] minversion = 2.3 -envlist = py311, py27, py310, py39, py38, pypy3, pypy, docs +envlist = py312, py311, py27, py310, py39, pypy3, pypy, docs skip_missing_interpreters = true From f76f4d6b67f3b6ed290598d512fe8789480f13db Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 12 May 2024 18:56:59 +0200 Subject: [PATCH 135/240] CLEANUP: py.requirements * Add more python-version constraints NEEDED-FOR: python 2.7 * Cleanup duplicates of path/path.py requirements --- py.requirements/behave_extensions.txt | 1 - py.requirements/develop.txt | 4 ---- py.requirements/docs.txt | 10 +++++----- py.requirements/jsonschema.txt | 2 +- py.requirements/pylinters.txt | 2 +- 5 files changed, 7 insertions(+), 12 deletions(-) diff --git a/py.requirements/behave_extensions.txt b/py.requirements/behave_extensions.txt index f80938598..ab834a630 100644 --- a/py.requirements/behave_extensions.txt +++ b/py.requirements/behave_extensions.txt @@ -1,4 +1,3 @@ - # ============================================================================ # PYTHON PACKAGE REQUIREMENTS: behave extensions # ============================================================================ diff --git a/py.requirements/develop.txt b/py.requirements/develop.txt index 22d49386d..c1ce9533c 100644 --- a/py.requirements/develop.txt +++ b/py.requirements/develop.txt @@ -5,10 +5,6 @@ # -- BUILD-SYSTEM: invoke -r invoke.txt -# -- HINT: path.py => path (python-install-package was renamed for python3) -path.py >= 11.5.0; python_version < '3.5' -path >= 13.1.0; python_version >= '3.5' - # -- CONFIGURATION MANAGEMENT (helpers): # FORMER: bumpversion >= 0.4.0 bump2version >= 0.5.6 diff --git a/py.requirements/docs.txt b/py.requirements/docs.txt index 4b53b407a..31354cc17 100644 --- a/py.requirements/docs.txt +++ b/py.requirements/docs.txt @@ -19,8 +19,8 @@ sphinx-intl >= 0.9.11 # -- CONSTRAINTS UNTIL: sphinx > 5.0 can be used # PROBLEM: sphinxcontrib-applehelp v1.0.8 requires sphinx > 5.0 # SEE: https://stackoverflow.com/questions/77848565/sphinxcontrib-applehelp-breaking-sphinx-builds-with-sphinx-version-less-than-5-0 -sphinxcontrib-applehelp==1.0.4 -sphinxcontrib-devhelp==1.0.2 -sphinxcontrib-htmlhelp==2.0.1 -sphinxcontrib-qthelp==1.0.3 -sphinxcontrib-serializinghtml==1.1.5 +sphinxcontrib-applehelp==1.0.4; python_version >= '3.7' +sphinxcontrib-devhelp==1.0.2; python_version >= '3.7' +sphinxcontrib-htmlhelp==2.0.1; python_version >= '3.7' +sphinxcontrib-qthelp==1.0.3; python_version >= '3.7' +sphinxcontrib-serializinghtml==1.1.5; python_version >= '3.7' diff --git a/py.requirements/jsonschema.txt b/py.requirements/jsonschema.txt index db45dafcc..d9505cd16 100644 --- a/py.requirements/jsonschema.txt +++ b/py.requirements/jsonschema.txt @@ -6,4 +6,4 @@ # DEPRECATING: jsonschema # USE INSTEAD: check-jsonschema jsonschema >= 1.3.0 -check-jsonschema +check-jsonschema; python_version >= '3.7' diff --git a/py.requirements/pylinters.txt b/py.requirements/pylinters.txt index 0c836d79d..fcdebb195 100644 --- a/py.requirements/pylinters.txt +++ b/py.requirements/pylinters.txt @@ -5,4 +5,4 @@ # -- STATIC CODE ANALYSIS: pylint -ruff >= 0.0.270 +ruff >= 0.0.270; python_version >= '3.7' From 951f293f314e162c3b111b13f6aa399d2e2646a4 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 12 May 2024 20:09:44 +0200 Subject: [PATCH 136/240] FIX docs: code-block with TOML warning * Support newer Sphinx versions (>= 7.3.7) * Tweak dependencies to silence Sphinx warnings for v8 deprecation READTHEDOCS: * Use os: ubuntu-lts-latest * Use python 3.12 --- .readthedocs.yaml | 4 ++-- docs/conf.py | 12 ++++++------ docs/install.rst | 3 +-- py.requirements/basic.txt | 8 +++----- py.requirements/docs.txt | 20 ++++++++++++++------ py.requirements/testing.txt | 7 ++----- pyproject.toml | 15 +++++++++------ setup.py | 19 +++++++++++++------ 8 files changed, 50 insertions(+), 38 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 570518df8..4b36928a3 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -8,9 +8,9 @@ version: 2 build: - os: ubuntu-20.04 + os: ubuntu-lts-latest tools: - python: "3.11" + python: "3.12" python: install: diff --git a/docs/conf.py b/docs/conf.py index cd89a0d03..90a58ebf1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -54,15 +54,15 @@ extlinks = { "behave": ("https://github.com/behave/behave", None), "behave.example": ("https://github.com/behave/behave.example", None), - "issue": ("https://github.com/behave/behave/issues/%s", "issue #"), - "pull": ("https://github.com/behave/behave/issues/%s", "PR #"), + "issue": ("https://github.com/behave/behave/issues/%s", "issue #%s"), + "pull": ("https://github.com/behave/behave/issues/%s", "PR #%s"), "github": ("https://github.com/%s", "github:/"), - "pypi": ("https://pypi.org/project/%s", ""), - "youtube": ("https://www.youtube.com/watch?v=%s", "youtube:video="), - "behave": ("https://github.com/behave/behave", None), + "pypi": ("https://pypi.org/project/%s", None), + "youtube": ("https://www.youtube.com/watch?v=%s", "youtube:video=%s"), + # -- CUCUMBER RELATED: "cucumber": ("https://github.com/cucumber/common/", None), - "cucumber.issue": ("https://github.com/cucumber/common/issues/%s", "cucumber issue #"), + "cucumber.issue": ("https://github.com/cucumber/common/issues/%s", "cucumber issue #%s"), } intersphinx_mapping = { diff --git a/docs/install.rst b/docs/install.rst index 7139fafdf..f587c98ef 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -102,11 +102,10 @@ EXAMPLE: [project] name = "my-project" - ... dependencies = [ "behave @ git+https://github.com/behave/behave.git@v1.2.7.dev5", # OR: "behave[develop] @ git+https://github.com/behave/behave.git@main", - ... ] + .. _behave: https://github.com/behave/behave diff --git a/py.requirements/basic.txt b/py.requirements/basic.txt index 86bf15383..08fded02e 100644 --- a/py.requirements/basic.txt +++ b/py.requirements/basic.txt @@ -19,8 +19,6 @@ contextlib2; python_version < '3.5' win_unicode_console >= 0.5; python_version < '3.6' colorama >= 0.3.7 -# -- DISABLED PYTHON 2.6 SUPPORT: -# REQUIRES: pip >= 6.0 -# argparse; python_version <= '2.6' -# ordereddict; python_version <= '2.6' -# importlib; python_version <= '2.6' +# -- SUPPORT: "pyproject.toml" (or: "behave.toml") +tomli>=1.1.0; python_version >= '3.0' and python_version < '3.11' +toml>=0.10.2; python_version < '3.0' # py27 support diff --git a/py.requirements/docs.txt b/py.requirements/docs.txt index 31354cc17..6d7e6a7df 100644 --- a/py.requirements/docs.txt +++ b/py.requirements/docs.txt @@ -6,7 +6,12 @@ # urllib3 v2.0+ only supports OpenSSL 1.1.1+, 'ssl' module is compiled with # v1.0.2, see: https://github.com/urllib3/urllib3/issues/2168 -sphinx >=1.6,<4.4 +# -- NEEDS: +-r basic.txt + +# -- DOCUMENTATION DEPENDENCIES: +sphinx >= 7.3.7; python_version >= '3.7' +sphinx >=1.6,<4.4; python_version < '3.7' sphinx-autobuild sphinx_bootstrap_theme >= 0.6.0 @@ -19,8 +24,11 @@ sphinx-intl >= 0.9.11 # -- CONSTRAINTS UNTIL: sphinx > 5.0 can be used # PROBLEM: sphinxcontrib-applehelp v1.0.8 requires sphinx > 5.0 # SEE: https://stackoverflow.com/questions/77848565/sphinxcontrib-applehelp-breaking-sphinx-builds-with-sphinx-version-less-than-5-0 -sphinxcontrib-applehelp==1.0.4; python_version >= '3.7' -sphinxcontrib-devhelp==1.0.2; python_version >= '3.7' -sphinxcontrib-htmlhelp==2.0.1; python_version >= '3.7' -sphinxcontrib-qthelp==1.0.3; python_version >= '3.7' -sphinxcontrib-serializinghtml==1.1.5; python_version >= '3.7' +# DISABLED: sphinxcontrib-applehelp==1.0.4; python_version >= '3.7' +# DISABLED: sphinxcontrib-devhelp==1.0.2; python_version >= '3.7' +# DISABLED: sphinxcontrib-htmlhelp==2.0.1; python_version >= '3.7' +# DISABLED: sphinxcontrib-qthelp==1.0.3; python_version >= '3.7' +# DISABLED: sphinxcontrib-serializinghtml==1.1.5; python_version >= '3.7' + +sphinxcontrib-applehelp >= 1.0.8; python_version >= '3.7' +sphinxcontrib-htmlhelp >= 2.0.5; python_version >= '3.7' diff --git a/py.requirements/testing.txt b/py.requirements/testing.txt index 6dfc32c7f..f1cc09cdd 100644 --- a/py.requirements/testing.txt +++ b/py.requirements/testing.txt @@ -2,6 +2,8 @@ # PYTHON PACKAGE REQUIREMENTS FOR: behave -- For testing only # ============================================================================ +-r basic.txt + # -- TESTING: Unit tests and behave self-tests. # PREPARED-FUTURE: behave4cmd0, behave4cmd pytest < 5.0; python_version < '3.0' # pytest >= 4.2 @@ -22,11 +24,6 @@ assertpy >= 1.1 path.py >=11.5.0,<13.0; python_version < '3.5' path >= 13.1.0; python_version >= '3.5' -# NOTE: toml extra for pyproject.toml-based config -# DISABLED: .[toml] -tomli >= 1.1.0; python_version >= '3.0' and python_version < '3.11' -toml >= 0.10.2; python_version < '3.0' - # -- PYTHON2 BACKPORTS: pathlib; python_version <= '3.4' diff --git a/pyproject.toml b/pyproject.toml index e472d14ea..bdae32b84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,16 +138,19 @@ develop = [ "ruff; python_version >= '3.7'", ] docs = [ - "Sphinx >=1.6,<4.4", + "sphinx >= 7.3.7; python_version >= '3.7'", + "sphinx >=1.6,<4.4; python_version < '3.7'", "sphinx_bootstrap_theme >= 0.6.0", # -- CONSTRAINTS UNTIL: sphinx > 5.0 is usable -- 2024-01 # PROBLEM: sphinxcontrib-applehelp v1.0.8 requires sphinx > 5.0 # SEE: https://stackoverflow.com/questions/77848565/sphinxcontrib-applehelp-breaking-sphinx-builds-with-sphinx-version-less-than-5-0 - "sphinxcontrib-applehelp==1.0.4", - "sphinxcontrib-devhelp==1.0.2", - "sphinxcontrib-htmlhelp==2.0.1", - "sphinxcontrib-qthelp==1.0.3", - "sphinxcontrib-serializinghtml==1.1.5", + "sphinxcontrib-applehelp >= 1.0.8; python_version >= '3.7'", + "sphinxcontrib-htmlhelp >= 2.0.5; python_version >= '3.7'", + # DISABLED: "sphinxcontrib-applehelp==1.0.4", + # DISABLED: "sphinxcontrib-devhelp==1.0.2", + # DISABLED: "sphinxcontrib-htmlhelp==2.0.1", + # DISABLED: "sphinxcontrib-qthelp==1.0.3", + # DISABLED: "sphinxcontrib-serializinghtml==1.1.5", ] formatters = [ "behave-html-formatter >= 0.9.10; python_version >= '3.6'", diff --git a/setup.py b/setup.py index ecf412b54..cf953cbd9 100644 --- a/setup.py +++ b/setup.py @@ -88,6 +88,10 @@ def find_packages_by_root_package(where): "contextlib2; python_version < '3.5'", # DISABLED: "contextlib2 >= 21.6.0; python_version < '3.5'", "colorama >= 0.3.7", + + # -- SUPPORT: "pyproject.toml" (or: "behave.toml") + "tomli>=1.1.0; python_version >= '3.0' and python_version < '3.11'", + "toml>=0.10.2; python_version < '3.0'", # py27 support ], tests_require=[ "pytest < 5.0; python_version < '3.0'", # USE: pytest >= 4.2 @@ -111,16 +115,19 @@ def find_packages_by_root_package(where): }, extras_require={ "docs": [ - "sphinx >= 1.6,<4.4", + "sphinx >= 7.3.7; python_version >= '3.7'", + "sphinx >=1.6,<4.4; python_version < '3.7'", "sphinx_bootstrap_theme >= 0.6", # -- CONSTRAINTS UNTIL: sphinx > 5.0 can be used -- 2024-01 # PROBLEM: sphinxcontrib-applehelp v1.0.8 requires sphinx > 5.0 # SEE: https://stackoverflow.com/questions/77848565/sphinxcontrib-applehelp-breaking-sphinx-builds-with-sphinx-version-less-than-5-0 - "sphinxcontrib-applehelp==1.0.4", - "sphinxcontrib-devhelp==1.0.2", - "sphinxcontrib-htmlhelp==2.0.1", - "sphinxcontrib-qthelp==1.0.3", - "sphinxcontrib-serializinghtml==1.1.5", + "sphinxcontrib-applehelp >= 1.0.8; python_version >= '3.7'", + "sphinxcontrib-htmlhelp >= 2.0.5; python_version >= '3.7'", + # DISABLED: "sphinxcontrib-applehelp==1.0.4", + # DISABLED: "sphinxcontrib-devhelp==1.0.2", + # DISABLED: "sphinxcontrib-htmlhelp==2.0.1", + # DISABLED: "sphinxcontrib-qthelp==1.0.3", + # DISABLED: "sphinxcontrib-serializinghtml==1.1.5", ], "develop": [ "build >= 0.5.1", From 16386ad6e5e73c27fe5a4196f997d2042b50a71d Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 12 May 2024 20:28:06 +0200 Subject: [PATCH 137/240] CLEANUP: .envrc* files for direnv support --- .envrc | 6 +++--- .envrc.use_venv | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.envrc b/.envrc index 8cca8e7aa..e2a9c9077 100644 --- a/.envrc +++ b/.envrc @@ -2,7 +2,7 @@ # PROJECT ENVIRONMENT SETUP: .envrc # =========================================================================== # SHELL: bash (or similiar) -# REQUIRES: direnv >= 2.26.0 -- NEEDED FOR: dotenv_if_exists +# REQUIRES: direnv >= 2.21.0 -- NEEDED FOR: path_add, venv support # USAGE: # # -- BETTER: Use direnv (requires: Setup in bash -- $HOME/.bashrc) # # BASH PROFILE NEEDS: eval "$(direnv hook bash)" @@ -16,9 +16,9 @@ # MAYBE: HERE="${PWD}" # -- USE OPTIONAL PARTS (if exist/enabled): -dotenv_if_exists .env +# REQUIRES: direnv >= 2.26.0 -- NEEDED FOR: dotenv_if_exists +# DISABLED: dotenv_if_exists .env source_env_if_exists .envrc.use_venv -source_env_if_exists .envrc.use_pep0582 # -- SETUP-PYTHON: Prepend ${HERE} to PYTHONPATH (as PRIMARY search path) # SIMILAR TO: export PYTHONPATH="${HERE}:${PYTHONPATH}" diff --git a/.envrc.use_venv b/.envrc.use_venv index e9834503d..f65b57d33 100644 --- a/.envrc.use_venv +++ b/.envrc.use_venv @@ -1,6 +1,7 @@ # =========================================================================== # PROJECT ENVIRONMENT SETUP: .envrc.use_venv # =========================================================================== +# REQUIRES: direnv >= 2.21.0 -- NEEDED FOR: venv support # DESCRIPTION: # Setup and use a Python virtual environment (venv). # On entering the directory: Creates and activates a venv for a python version. From 7454670f4071fa3f75cbbc5b6263b4d3d6d39e35 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 12 May 2024 22:29:40 +0200 Subject: [PATCH 138/240] TWEAK: Badges on README --- README.rst | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/README.rst b/README.rst index d1e182f70..36a9e38d6 100644 --- a/README.rst +++ b/README.rst @@ -2,30 +2,35 @@ behave ====== +.. |badge.latest_version| image:: https://img.shields.io/pypi/v/behave.svg + :target: https://pypi.python.org/pypi/behave + :alt: Latest Version + +.. |badge.license| image:: https://img.shields.io/pypi/l/behave.svg + :target: https://pypi.python.org/pypi/behave/ + :alt: License -.. image:: https://github.com/behave/behave/actions/workflows/tests.yml/badge.svg +.. |badge.CI_status| image:: https://github.com/behave/behave/actions/workflows/tests.yml/badge.svg :target: https://github.com/behave/behave/actions/workflows/tests.yml :alt: CI Build Status -.. image:: https://readthedocs.org/projects/behave/badge/?version=latest +.. |badge.docs_status| image:: https://readthedocs.org/projects/behave/badge/?version=latest :target: http://behave.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status -.. image:: https://img.shields.io/pypi/v/behave.svg - :target: https://pypi.python.org/pypi/behave - :alt: Latest Version +.. |badge.discussions| image:: https://img.shields.io/badge/chat-github_discussions-darkgreen + :target: https://github.com/behave/behave/discussions + :alt: Discussions at https://github.com/behave/behave/discussions -.. image:: https://img.shields.io/pypi/l/behave.svg - :target: https://pypi.python.org/pypi/behave/ - :alt: License - -.. image:: https://badges.gitter.im/join_chat.svg - :alt: Join the chat at https://gitter.im/behave/behave +.. |badge.gitter| image:: https://badges.gitter.im/join_chat.svg :target: https://app.gitter.im/#/room/#behave_behave:gitter.im + :alt: Chat at https://gitter.im/behave/behave .. |logo| image:: https://raw.github.com/behave/behave/master/docs/_static/behave_logo1.png +|badge.latest_version| |badge.license| |badge.CI_status| |badge.docs_status| |badge.discussions| |badge.gitter| + behave is behavior-driven development, Python style. |logo| From 81fac5ad8d4fbd37ba90e5847a1687c4de940d3a Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Mon, 13 May 2024 11:28:56 +0200 Subject: [PATCH 139/240] README: Upgrade URLs to https, fix broken link to PDF --- README.rst | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.rst b/README.rst index 36a9e38d6..8af80f037 100644 --- a/README.rst +++ b/README.rst @@ -101,10 +101,10 @@ we recommend the `tutorial`_ and then the `feature testing language`_ and `api`_ references. -.. _`Install *behave*.`: http://behave.readthedocs.io/en/stable/install.html -.. _`tutorial`: http://behave.readthedocs.io/en/stable/tutorial.html#features -.. _`feature testing language`: http://behave.readthedocs.io/en/stable/gherkin.html -.. _`api`: http://behave.readthedocs.io/en/stable/api.html +.. _`Install *behave*.`: https://behave.readthedocs.io/en/stable/install.html +.. _`tutorial`: https://behave.readthedocs.io/en/stable/tutorial.html#features +.. _`feature testing language`: https://behave.readthedocs.io/en/stable/gherkin.html +.. _`api`: https://behave.readthedocs.io/en/stable/api.html More Information @@ -115,10 +115,10 @@ More Information * `changelog`_ (latest changes) -.. _behave documentation: http://behave.readthedocs.io/ -.. _changelog: https://github.com/behave/behave/blob/master/CHANGES.rst +.. _behave documentation: https://behave.readthedocs.io/ +.. _changelog: https://github.com/behave/behave/blob/main/CHANGES.rst .. _behave.example: https://github.com/behave/behave.example -.. _`latest edition`: http://behave.readthedocs.io/en/latest/ -.. _`stable edition`: http://behave.readthedocs.io/en/stable/ -.. _PDF: https://media.readthedocs.org/pdf/behave/latest/behave.pdf +.. _`latest edition`: https://behave.readthedocs.io/en/latest/ +.. _`stable edition`: https://behave.readthedocs.io/en/stable/ +.. _PDF: https://behave.readthedocs.io/_/downloads/en/stable/pdf/ From ba46393c0d6bb0fc08d035ec7d7d8f7ec2ec668b Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Tue, 14 May 2024 09:44:55 +0200 Subject: [PATCH 140/240] Avoid being vulnerable by using yaml.load() This code might be old and obsolete. If not fixed we may consider deleting it instead. --- .attic/convert_i18n_yaml.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.attic/convert_i18n_yaml.py b/.attic/convert_i18n_yaml.py index d6a6713e4..f75675dd7 100755 --- a/.attic/convert_i18n_yaml.py +++ b/.attic/convert_i18n_yaml.py @@ -50,7 +50,7 @@ def main(args=None): parser.error("YAML file not found: %s" % options.yaml_file) # -- STEP 1: Load YAML data. - languages = yaml.load(open(options.yaml_file)) + languages = yaml.safe_load(open(options.yaml_file)) languages = yaml_normalize(languages) # -- STEP 2: Generate python module with i18n data. From 0745437c02e9d35fbb81c25c3bc0242a84c22a61 Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 14 May 2024 22:15:43 +0200 Subject: [PATCH 141/240] docs: Fix pypi-extlink * Shows now again the name of the pypi package * WAS: Showing the URL to the pypi package --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 90a58ebf1..f58519da1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -57,7 +57,7 @@ "issue": ("https://github.com/behave/behave/issues/%s", "issue #%s"), "pull": ("https://github.com/behave/behave/issues/%s", "PR #%s"), "github": ("https://github.com/%s", "github:/"), - "pypi": ("https://pypi.org/project/%s", None), + "pypi": ("https://pypi.org/project/%s", "%s"), "youtube": ("https://www.youtube.com/watch?v=%s", "youtube:video=%s"), # -- CUCUMBER RELATED: From 2c11d2e284c8ecdf71c391b5d49218d29458df54 Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 15 May 2024 00:11:44 +0200 Subject: [PATCH 142/240] docs: Improve Tag-Expressions v2 description * Add Tag-Expressions v1 END-OF-LIFE support * Describe "{config.tags}" placeholder for --tags option on command-line * Descripe "tag_expression_protocol" parameter in config-file CLEANUP: * Use "https:" instead of "http:" URL prefix where possible * Gherkin v6 aliases: Use table (was: unnumbered-list) --- docs/_common_extlinks.rst | 10 ++-- docs/_content.tag_expressions_v2.rst | 82 +++++++++++++++++++++++++--- docs/new_and_noteworthy_v1.2.7.rst | 13 +++-- 3 files changed, 88 insertions(+), 17 deletions(-) diff --git a/docs/_common_extlinks.rst b/docs/_common_extlinks.rst index 26bb5bcb7..baea4f59b 100644 --- a/docs/_common_extlinks.rst +++ b/docs/_common_extlinks.rst @@ -9,15 +9,13 @@ .. _`C++ scope guard`: https://en.wikibooks.org/wiki/More_C++_Idioms/Scope_Guard .. _Cucumber: https://cucumber.io/ -.. _Lettuce: http://lettuce.it/ - -.. _Selenium: http://docs.seleniumhq.org/ +.. _Selenium: https://docs.seleniumhq.org/ .. _PyCharm: https://www.jetbrains.com/pycharm/ -.. _Eclipse: http://www.eclipse.org/ +.. _Eclipse: https://www.eclipse.org/ .. _VisualStudio: https://www.visualstudio.com/ .. _`PyCharm BDD`: https://blog.jetbrains.com/pycharm/2014/09/feature-spotlight-behavior-driven-development-in-pycharm/ -.. _`Cucumber-Eclipse`: http://cucumber.github.io/cucumber-eclipse/ +.. _`Cucumber-Eclipse`: https://cucumber.github.io/cucumber-eclipse/ -.. _ctags: http://ctags.sourceforge.net/ +.. _ctags: https://ctags.sourceforge.net/ diff --git a/docs/_content.tag_expressions_v2.rst b/docs/_content.tag_expressions_v2.rst index a6f6f10f6..ffa5e181d 100644 --- a/docs/_content.tag_expressions_v2.rst +++ b/docs/_content.tag_expressions_v2.rst @@ -1,23 +1,37 @@ Tag-Expressions v2 ------------------------------------------------------------------------------- -:pypi:`cucumber-tag-expressions` are now supported and supersedes the old-style -tag-expressions (which are deprecating). :pypi:`cucumber-tag-expressions` are much -more readable and flexible to select tags on command-line. +Tag-Expressions v2 are based on :pypi:`cucumber-tag-expressions` with some extensions: + +* Tag-Expressions v2 provide `boolean logic expression` + (with ``and``, ``or`` and ``not`` operators and parenthesis for grouping expressions) +* Tag-Expressions v2 are far more readable and composable than Tag-Expressions v1 +* Some boolean-logic-expressions where not possible with Tag-Expressions v1 +* Therefore, Tag-Expressions v2 supersedes the old-style tag-expressions. + +EXAMPLES: .. code-block:: sh # -- SIMPLE TAG-EXPRESSION EXAMPLES: + # EXAMPLE 1: Select features/scenarios that have the tags: @a and @b @a and @b - @a or @b + + # EXAMPLE 2: Select features/scenarios that have the tag: @a or @b + @a or @b + + # EXAMPLE 3: Select features/scenarios that do not have the tag: @a not @a # -- MORE TAG-EXPRESSION EXAMPLES: # HINT: Boolean expressions can be grouped with parenthesis. + # EXAMPLE 4: Select features/scenarios that have the tags: @a but not @b @a and not @b + + # EXAMPLE 5: Select features/scenarios that have the tags: (@a or @b) but not @c (@a or @b) and not @c -Example: +COMMAND-LINE EXAMPLE: .. code-block:: sh @@ -25,16 +39,36 @@ Example: # Select all features / scenarios with both "@foo" and "@bar" tags. $ behave --tags="@foo and @bar" features/ + # -- EXAMPLE: Use default_tags from config-file "behave.ini". + # Use placeholder "{config.tags}" to refer to this tag-expression. + # HERE: config.tags = "not (@xfail or @not_implemented)" + $ behave --tags="(@foo or @bar) and {config.tags}" --tags-help + ... + CURRENT TAG_EXPRESSION: ((foo or bar) and not (xfail or not_implemented)) + + # -- EXAMPLE: Uses Tag-Expression diagnostics with --tags-help option + $ behave --tags="(@foo and @bar) or @baz" --tags-help + $ behave --tags="(@foo and @bar) or @baz" --tags-help --verbose .. seealso:: * https://docs.cucumber.io/cucumber/api/#tag-expressions + * :pypi:`cucumber-tag-expressions` (Python package) Tag Matching with Tag-Expressions ------------------------------------------------------------------------------- -The new tag-expressions also support **partial string/tag matching** with wildcards. +Tag-Expressions v2 support **partial string/tag matching** with wildcards. +This supports tag-expressions: + +=================== ======================== +Tag Matching Idiom Tag-Expression Example +=================== ======================== +``tag-starts-with`` ``@foo.*`` or ``foo.*`` +``tag-ends-with`` ``@*.one`` or ``*.one`` +``tag-contains`` ``@*foo*`` or ``*foo*`` +=================== ======================== .. code-block:: gherkin @@ -69,9 +103,43 @@ that start with "@foo.": # -- HINT: Only Alice.1 and Alice.2 are matched (not: Alice.3). -.. hint:: +.. note:: * Filename matching wildcards are supported. See :mod:`fnmatch` (Unix style filename matching). * The tag matching functionality is an extension to :pypi:`cucumber-tag-expressions`. + + +Select the Tag-Expression Version to Use +------------------------------------------------------------------------------- + +The tag-expression version, that should be used by :pypi:`behave`, +can be specified in the :pypi:`behave` config-file. + +This allows a user to select: + +* Tag-Expressions v1 (if needed) +* Tag-Expressions v2 when it is feasible + +EXAMPLE: + +.. code-block:: ini + + # -- FILE: behave.ini + # SPECIFY WHICH TAG-EXPRESSION-PROTOCOL SHOULD BE USED: + # SUPPORTED VALUES: v1, v2, auto_detect + # CURRENT DEFAULT: auto_detect + [behave] + tag_expression_protocol = v1 # -- Use Tag-Expressions v1. + + +Tag-Expressions v1 +------------------------------------------------------------------------------- + +Tag-Expressions v1 are becoming deprecated (but are currently still supported). +Use **Tag-Expressions v2** instead. + +.. note:: + + Tag-Expressions v1 support will be dropped in ``behave v1.4.0``. diff --git a/docs/new_and_noteworthy_v1.2.7.rst b/docs/new_and_noteworthy_v1.2.7.rst index 08d3411d5..fc462bad8 100644 --- a/docs/new_and_noteworthy_v1.2.7.rst +++ b/docs/new_and_noteworthy_v1.2.7.rst @@ -36,11 +36,16 @@ A Rule (or: business rule) allows to group multiple Scenario(s)/Example(s):: Scenario* #< CARDINALITY: 0..N (many) ScenarioOutline* #< CARDINALITY: 0..N (many) -Gherkin v6 keyword aliases:: +Gherkin v6 keyword aliases: + +================== =================== ====================== +Concept Preferred Keyword Alias(es) +================== =================== ====================== +Scenario Example Scenario +Scenario Outline Scenario Outline Scenario Template +Examples Examples Scenarios +================== =================== ====================== - | Concept | Preferred Keyword | Alias(es) | - | Scenario | Example | Scenario | - | Scenario Outline | Scenario Outline | Scenario Template | Example: From 808d33752c6ec1db9205ed4500f37c6d2886cb06 Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 22 May 2024 11:43:44 +0200 Subject: [PATCH 143/240] FIX #1177: MatchWithError is turned into AmbiguousStepError * CAUSED BY: Bad type-converter pattern using named-params HINT: parse module raises NotImplementedError exception. * NotImplementedError is raised for Python >= 3.11 --- CHANGES.rst | 1 + behave/matchers.py | 5 +- behave/step_registry.py | 13 ++++- tests/issues/test_issue1177.py | 103 +++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 tests/issues/test_issue1177.py diff --git a/CHANGES.rst b/CHANGES.rst index a39d18861..324cbd791 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -91,6 +91,7 @@ FIXED: * FIXED: Some tests related to python-3.11 * FIXED: Some tests related to python-3.9 * FIXED: active-tag logic if multiple tags with same category exists. +* issue #1177: Bad type-converter pattern: MatchWithError is turned into AmbiguousStep (submitted by: omrischwarz) * issue #1170: TagExpression auto-detection is not working properly (submitted by: Luca-morphy) * issue #1154: Config-files are not shown in verbose mode (submitted by: soblom) * issue #1120: Logging ignoring level set in setup_logging (submitted by: j7an) diff --git a/behave/matchers.py b/behave/matchers.py index a136d918e..130ade627 100644 --- a/behave/matchers.py +++ b/behave/matchers.py @@ -47,7 +47,6 @@ def __init__(self, text=None, exc_cause=None): ChainedExceptionUtil.set_cause(self, exc_cause) - # ----------------------------------------------------------------------------- # SECTION: Model Elements # ----------------------------------------------------------------------------- @@ -208,7 +207,6 @@ def describe(self, schema=None): schema = self.schema return schema % (step_type, self.pattern) - def check_match(self, step): """Match me against the "step" name supplied. @@ -224,7 +222,8 @@ def match(self, step): # -- PROTECT AGAINST: Type conversion errors (with ParseMatcher). try: result = self.check_match(step) - except Exception as e: # pylint: disable=broad-except + except (StepParseError, ValueError, TypeError) as e: + # -- TYPE-CONVERTER ERROR occurred. return MatchWithError(self.func, e) if result is None: diff --git a/behave/step_registry.py b/behave/step_registry.py index 41bfd672e..2d6fd3982 100644 --- a/behave/step_registry.py +++ b/behave/step_registry.py @@ -6,7 +6,7 @@ """ from __future__ import absolute_import -from behave.matchers import Match, make_matcher +from behave.matchers import Match, MatchWithError, make_matcher from behave.textutil import text as _text # limit import * to just the decorators @@ -49,7 +49,16 @@ def add_step_definition(self, keyword, step_text, func): # -- EXACT-STEP: Same step function is already registered. # This may occur when a step module imports another one. return - elif existing.match(step_text): # -- SIMPLISTIC + + matched = existing.match(step_text) + if matched is None or isinstance(matched, MatchWithError): + # -- CASES: + # - step-mismatch (None) + # - matching the step caused a type-converter function error + # REASON: Bad type-converter function is used. + continue + + if isinstance(matched, Match): message = u"%s has already been defined in\n existing step %s" new_step = u"@%s('%s')" % (step_type, step_text) existing.step_type = step_type diff --git a/tests/issues/test_issue1177.py b/tests/issues/test_issue1177.py new file mode 100644 index 000000000..6866d132f --- /dev/null +++ b/tests/issues/test_issue1177.py @@ -0,0 +1,103 @@ +""" +Test issue #1177. + +.. seealso:: https://github.com/behave/behave/issues/1177 +""" +# -- IMPORTS: +from __future__ import absolute_import, print_function + +import sys + +from behave._stepimport import use_step_import_modules, SimpleStepContainer +import parse +import pytest + + +@parse.with_pattern(r"true|false") +def parse_bool_good(text): + return text == "true" + + +@parse.with_pattern(r"(?P(?i)true|(?i)false)", regex_group_count=1) +def parse_bool_bad(text): + return text == "true" + + +@pytest.mark.parametrize("parse_bool", [parse_bool_good]) # DISABLED:, parse_bool_bad]) +def test_parse_expr(parse_bool): + parser = parse.Parser("Light is on: {answer:Bool}", + extra_types=dict(Bool=parse_bool)) + result = parser.parse("Light is on: true") + assert result["answer"] == True + result = parser.parse("Light is on: false") + assert result["answer"] == False + result = parser.parse("Light is on: __NO_MATCH__") + assert result is None + + +# -- SYNDROME: NotImplementedError is only raised for Python >= 3.11 +@pytest.mark.skipif(sys.version_info < (3, 11), + reason="Python >= 3.11: NotImplementedError is raised") +def test_syndrome(): + """ + Ensure that no AmbiguousStepError is raised + when another step is added after the one with the BAD TYPE-CONVERTER PATTERN. + """ + step_container = SimpleStepContainer() + this_step_registry = step_container.step_registry + with use_step_import_modules(step_container): + from behave import then, register_type + + register_type(Bool=parse_bool_bad) + + @then(u'first step is "{value:Bool}"') + def then_first_step(ctx, value): + assert isinstance(value, bool), "%r" % value + + with pytest.raises(NotImplementedError) as excinfo1: + # -- CASE: Another step is added + # EXPECTED: No AmbiguousStepError is raised. + @then(u'first step and more') + def then_second_step(ctx, value): + assert isinstance(value, bool), "%r" % value + + # -- CASE: Manually add step to step-registry + # EXPECTED: No AmbiguousStepError is raised. + with pytest.raises(NotImplementedError) as excinfo2: + step_text = u'first step and other' + def then_third_step(ctx, value): pass + this_step_registry.add_step_definition("then", step_text, then_third_step) + + assert "Group names (e.g. (?P) can cause failure" in str(excinfo1.value) + assert "Group names (e.g. (?P) can cause failure" in str(excinfo2.value) + + +@pytest.mark.skipif(sys.version_info >= (3, 11), + reason="Python < 3.11 -- NotImpplementedError is not raised") +def test_syndrome_for_py310_and_older(): + """ + Ensure that no AmbiguousStepError is raised + when another step is added after the one with the BAD TYPE-CONVERTER PATTERN. + """ + step_container = SimpleStepContainer() + this_step_registry = step_container.step_registry + with use_step_import_modules(step_container): + from behave import then, register_type + + register_type(Bool=parse_bool_bad) + + @then(u'first step is "{value:Bool}"') + def then_first_step(ctx, value): + assert isinstance(value, bool), "%r" % value + + # -- CASE: Another step is added + # EXPECTED: No AmbiguousStepError is raised. + @then(u'first step and mpre') + def then_second_step(ctx, value): + assert isinstance(value, bool), "%r" % value + + # -- CASE: Manually add step to step-registry + # EXPECTED: No AmbiguousStepError is raised. + step_text = u'first step and other' + def then_third_step(ctx, value): pass + this_step_registry.add_step_definition("then", step_text, then_third_step) From f5523f429830165a198bff9d85a6163b8ca88c1f Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 22 May 2024 12:23:38 +0200 Subject: [PATCH 144/240] CI WORKFLOW UPDATE: actions/upload-artifact@v4 (was: v3) --- .github/workflows/tests-windows.yml | 13 +++++++------ .github/workflows/tests.yml | 3 ++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index d3bc9c03a..411f698b4 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -64,16 +64,17 @@ jobs: behave --format=progress3 tools/test-features behave --format=progress3 issue.features - name: Upload test reports - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: test reports path: | build/testing/report.xml build/testing/report.html + # MAYBE: build/behave.reports/ if: ${{ job.status == 'failure' }} # MAYBE: if: ${{ always() }} - - name: Upload behave test reports - uses: actions/upload-artifact@v3 - with: - name: behave.reports - path: build/behave.reports/ +# - name: Upload behave test reports +# uses: actions/upload-artifact@v4 +# with: +# name: behave.reports +# path: build/behave.reports/ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5e4183b0c..5b53d504d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -61,11 +61,12 @@ jobs: behave --format=progress tools/test-features behave --format=progress issue.features - name: Upload test reports - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: test reports path: | build/testing/report.xml build/testing/report.html + # MAYBE: build/behave.reports/ if: ${{ job.status == 'failure' }} # MAYBE: if: ${{ always() }} From 3b9fc3f018e7d89a08c0da80b7e2e0fef1ceb9aa Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 26 May 2024 15:47:45 +0200 Subject: [PATCH 145/240] ruff: Update config to use "[lint]" sections where needed --- .ruff.toml | 42 +++++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/.ruff.toml b/.ruff.toml index 2cde028ed..97547054a 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -2,21 +2,10 @@ # SECTION: ruff -- Python linter # ----------------------------------------------------------------------------- # SEE: https://github.com/charliermarsh/ruff +# SEE: https://docs.astral.sh/ruff/configuration/ # SEE: https://beta.ruff.rs/docs/configuration/#using-rufftoml # Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. -select = ["E", "F"] -ignore = [] - -# Allow autofix for all enabled rules (when `--fix`) is provided. -fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", - "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", - "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", - "TCH", "TID", "TRY", "UP", "YTT" -] -unfixable = [] - -# Exclude a variety of commonly ignored directories. exclude = [ ".direnv", ".eggs", @@ -29,15 +18,34 @@ exclude = [ "dist", "venv", ] -per-file-ignores = {} - -# Same as Black. # WAS: line-length = 88 line-length = 100 +indent-width = 4 +target-version = "py312" + + +[lint] +select = ["E", "F"] +ignore = [] +# Allow autofix for all enabled rules (when `--fix`) is provided. +fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", + "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", + "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", + "TCH", "TID", "TRY", "UP", "YTT" +] +unfixable = [] + +# Exclude a variety of commonly ignored directories. +per-file-ignores = {} # Allow unused variables when underscore-prefixed. dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" -target-version = "py310" -[mccabe] + +[lint.mccabe] max-complexity = 10 + + +[format] +quote-style = "double" +indent-style = "space" From eb5d3fb9127f0fbaceb7d9ea7905d9936cbd301d Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 26 May 2024 15:50:42 +0200 Subject: [PATCH 146/240] RELATED TO #1177: Improve behaviour on BAD STEP-DEFINITIONS * Newer Python versions (=> 3.11) raise "re.error" exceptions when bad regular-expressions are used. NOTE: This may occur in the regex pattern of a type-converter function. * parse-expressions: On compiling the internal regular-expression, this will fail consistently when `parser.parse("...")` is called. This may cause always problems when any step should be matched. CHANGED BEHAVIOUR: * behave.matchers.Matcher class: Provides `compile()` method to enforce that the regular-expression can be compiled early. Derived classes must implement this method. NOTES: - This was done for `behave` derived classes. - Lazy-compiling of regexp was partly used in the past. * behave.step_registry.StepRegistry: Checks now for BAD STEP-DEFINITION on calling `add_step_definition()`. BAD STEP-DEFINITION(s) are reported and ignored. --- behave/matchers.py | 303 +++++++++++++++++++++------- behave/step_registry.py | 126 +++++++++--- tests/issues/test_issue1177.py | 88 +++++--- tests/unit/test_matchers.py | 24 ++- tests/unit/test_step_registry.py | 13 +- tools/test-features/outline.feature | 10 +- tools/test-features/steps/steps.py | 29 ++- 7 files changed, 439 insertions(+), 154 deletions(-) diff --git a/behave/matchers.py b/behave/matchers.py index 130ade627..5d557c863 100644 --- a/behave/matchers.py +++ b/behave/matchers.py @@ -142,23 +142,41 @@ def run(self, context): raise StepParseError(exc_cause=self.stored_error) - - # ----------------------------------------------------------------------------- -# SECTION: Matchers +# SECTION: Step Matchers # ----------------------------------------------------------------------------- class Matcher(object): - """Pull parameters out of step names. + """ + Provides an abstract base class for step-matcher classes. + + Matches steps from "*.feature" files (Gherkin files) + and extracts step-parameters for these steps. + + RESPONSIBILITIES: + + * Matches steps from "*.feature" files (or not) + * Returns :class:`Match` objects if this step-matcher matches + that is used to run the step-definition function w/ its parameters. + * Compile parse-expression/regular-expression to detect + BAD STEP-DEFINITION(s) early. .. attribute:: pattern - The match pattern attached to the step function. + The match pattern attached to the step function. .. attribute:: func - The step function the pattern is being attached to. + The associated step-definition function to use for this pattern. + + .. attribute:: location + + File location of the step-definition function. """ - schema = u"@%s('%s')" # Schema used to describe step definition (matcher) + # -- DESCRIBE-SCHEMA FOR STEP-DEFINITIONS (step-matchers): + SCHEMA = u"@{this.step_type}('{this.pattern}')" + SCHEMA_AT_LOCATION = SCHEMA + u" at {this.location}" + SCHEMA_WITH_LOCATION = SCHEMA + u" # {this.location}" + SCHEMA_AS_STEP = u"{this.step_type} {this.pattern}" @classmethod def register_type(cls, **kwargs): @@ -174,7 +192,7 @@ def clear_registered_types(cls): def __init__(self, func, pattern, step_type=None): self.func = func self.pattern = pattern - self.step_type = step_type + self.step_type = step_type or "step" self._location = None # -- BACKWARD-COMPATIBILITY: @@ -202,44 +220,115 @@ def describe(self, schema=None): :param schema: Text schema to use. :return: Textual description of this step definition (matcher). """ - step_type = self.step_type or "step" if not schema: - schema = self.schema - return schema % (step_type, self.pattern) + schema = self.SCHEMA + + # -- SUPPORT: schema = "{this.step_type} {this.pattern}" + return schema.format(this=self) + + def compile(self): + """ + Compiles the regular-expression pattern (if necessary). + + NOTES: + - This allows to detect some errors with BAD regular expressions early. + - Must be implemneted by derived classes. + + :return: Self (to support daisy-chaining) + """ + raise NotImplementedError() - def check_match(self, step): - """Match me against the "step" name supplied. + def check_match(self, step_text): + """ + Match me against the supplied "step_text". Return None, if I don't match otherwise return a list of matches as :class:`~behave.model_core.Argument` instances. The return value from this function will be converted into a :class:`~behave.matchers.Match` instance by *behave*. + + :param step_text: Step text that should be matched (as string). + :return: A list of matched-arguments (on match). None, on mismatch. + :raises: ValueError, re.error, ... """ - raise NotImplementedError + raise NotImplementedError() - def match(self, step): + def match(self, step_text): # -- PROTECT AGAINST: Type conversion errors (with ParseMatcher). try: - result = self.check_match(step) - except (StepParseError, ValueError, TypeError) as e: + matched_args = self.check_match(step_text) + # MAYBE: except (StepParseError, ValueError, TypeError) as e: + except NotImplementedError: + # -- CASES: + # - check_match() is not implemented + # - check_match() raises NotImplementedError (on: re.error) + raise + except Exception as e: # -- TYPE-CONVERTER ERROR occurred. return MatchWithError(self.func, e) - if result is None: + if matched_args is None: return None # -- NO-MATCH - return Match(self.func, result) + return Match(self.func, matched_args) + + def matches(self, step_text): + """ + Checks if :param:`step_text` matches this step-definition/step-matcher. + + :param step_text: Step text to check. + :return: True, if step is matched. False, otherwise. + """ + if self.pattern == step_text: + # -- SIMPLISTIC CASE: No step-parameters. + return True + + # -- HINT: Ignore MatchWithError here. + matched = self.match(step_text) + return (matched and isinstance(matched, Match) and + not isinstance(matched, MatchWithError)) def __repr__(self): return u"<%s: %r>" % (self.__class__.__name__, self.pattern) class ParseMatcher(Matcher): - """Uses :class:`~parse.Parser` class to be able to use simpler - parse expressions compared to normal regular expressions. + r""" + Provides a step-matcher that uses parse-expressions. + Parse-expressions provide a simpler syntax compared to regular expressions. + Parse-expressions are :func:`string.format()` expressions but for parsing. + + RESPONSIBILITIES: + + * Provides parse-expressions, like: "a positive number {number:PositiveNumber}" + * Support for custom type-converter functions + + COLLABORATORS: + + * :class:`~parse.Parser` to support parse-expressions. + + EXAMPLE: + + .. code-block:: python + + from behave import register_type, given, use_step_matcher + import parse + + # -- TYPE CONVERTER: For a simple, positive integer number. + @parse.with_pattern(r"\d+") + def parse_number(text): + return int(text) + + register_type(Number=parse_number) + + @given('{amount:Number} vehicles') + def step_given_amount_vehicles(ctx, amount): + assert isinstance(amount, int) + print("{amount} vehicles".format(amount=amount))} """ custom_types = {} parser_class = parse.Parser + case_sensitive = True @classmethod def register_type(cls, **kwargs): @@ -250,30 +339,6 @@ def register_type(cls, **kwargs): A type converter should follow :pypi:`parse` module rules. In general, a type converter is a function that converts text (as string) into a value-type (type converted value). - - EXAMPLE: - - .. code-block:: python - - from behave import register_type, given - import parse - - - # -- TYPE CONVERTER: For a simple, positive integer number. - @parse.with_pattern(r"\d+") - def parse_number(text): - return int(text) - - # -- REGISTER TYPE-CONVERTER: With behave - register_type(Number=parse_number) - # ALTERNATIVE: - current_step_matcher = use_step_matcher("parse") - current_step_matcher.register_type(Number=parse_number) - - # -- STEP DEFINITIONS: Use type converter. - @given('{amount:Number} vehicles') - def step_impl(context, amount): - assert isinstance(amount, int) """ cls.custom_types.update(**kwargs) @@ -281,43 +346,102 @@ def step_impl(context, amount): def clear_registered_types(cls): cls.custom_types.clear() - def __init__(self, func, pattern, step_type=None): super(ParseMatcher, self).__init__(func, pattern, step_type) - self.parser = self.parser_class(pattern, self.custom_types) + self.parser = self.parser_class(pattern, self.custom_types, + case_sensitive=self.case_sensitive) @property def regex_pattern(self): # -- OVERWRITTEN: Pattern as regex text. return self.parser._expression # pylint: disable=protected-access - def check_match(self, step): + def compile(self): + """ + Compiles internal regular-expression. + + Compiles "parser._match_re" which may lead to error (always) + if a BAD regular expression is used (or: BAD TYPE-CONVERTER). + """ + # -- HINT: Triggers implicit compile of "self.parser._match_re" + _ = self.parser.parse("") + return self + + def check_match(self, step_text): + """ + Checks if the :param:`step_text` is matched (or not). + + :param step_text: Step text to check. + :return: step-args if step was matched, None otherwise. + :raises ValueError: If type-converter functions fails. + """ # -- FAILURE-POINT: Type conversion of parameters may fail here. # NOTE: Type converter should raise ValueError in case of PARSE ERRORS. - result = self.parser.parse(step) - if not result: + matched = self.parser.parse(step_text) + if not matched: return None args = [] - for index, value in enumerate(result.fixed): - start, end = result.spans[index] - args.append(Argument(start, end, step[start:end], value)) - for name, value in result.named.items(): - start, end = result.spans[name] - args.append(Argument(start, end, step[start:end], value, name)) + for index, value in enumerate(matched.fixed): + start, end = matched.spans[index] + args.append(Argument(start, end, step_text[start:end], value)) + for name, value in matched.named.items(): + start, end = matched.spans[name] + args.append(Argument(start, end, step_text[start:end], value, name)) args.sort(key=lambda x: x.start) return args class CFParseMatcher(ParseMatcher): - """Uses :class:`~parse_type.cfparse.Parser` instead of "parse.Parser". - Provides support for automatic generation of type variants - for fields with CardinalityField part. + """ + Provides a step-matcher that uses parse-expressions with cardinality-fields. + Parse-expressions use simpler syntax compared to normal regular expressions. + + Cardinality-fields provide a compact syntax for cardinalities: + + * many: "+" (cardinality: ``1..N``) + * many0: "*" (cardinality: ``0..N``) + * optional: "?" (cardinality: ``0..1``) + + Regular expressions and type-converters for cardinality-fields are + generated by the parser if a type-converter for the cardinality=1 is registered. + + COLLABORATORS: + + * :class:`~parse_type.cfparse.Parser` is used to support parse-expressions + with cardinality-field support. + + EXAMPLE: + + .. code-block:: python + + from behave import register_type, given, use_step_matcher + use_step_matcher("cfparse") + # ... -- OMITTED: Provide type-converter function for Number + + @given(u'{amount:Number+} as numbers') # CARDINALITY-FIELD: Many-Numbers + def step_many_numbers(ctx, numbers): + assert isinstance(numbers, list) + assert isinstance(numbers[0], int) + print("numbers = %r" % numbers) + + step_matcher = CFParseMatcher(step_many_numbers, "{amount:Number+} as numbers") + matched = step_matcher.matches("1, 2, 3 as numbers") + assert matched is True + # -- STEP MATCHES: numbers = [1, 2, 3] """ parser_class = cfparse.Parser class RegexMatcher(Matcher): + """ + Provides a step-matcher that uses regular-expressions + + RESPONSIBILITIES: + + * Custom type-converters are NOT SUPPORTED. + """ + @classmethod def register_type(cls, **kwargs): """ @@ -335,50 +459,81 @@ def clear_registered_types(cls): def __init__(self, func, pattern, step_type=None): super(RegexMatcher, self).__init__(func, pattern, step_type) - self.regex = re.compile(self.pattern) + self._regex = None # -- HINT: Defer re.compile(self.pattern) + + @property + def regex(self): + if self._regex is None: + # self._regex = re.compile(self.pattern) + self._regex = re.compile(self.pattern, re.UNICODE) + return self._regex + @regex.setter + def regex(self, value): + self._regex = value - def check_match(self, step): - m = self.regex.match(step) - if not m: + @property + def regex_pattern(self): + """Return the regex pattern that is used for matching steps.""" + return self.regex.pattern + + def compile(self): + # -- HINT: Compiles "parser._match_re" which may lead to error (always). + _ = self.regex # -- HINT: IMPLICIT-COMPILE + return self + + def check_match(self, step_text): + matched = self.regex.match(step_text) + if not matched: return None - groupindex = dict((y, x) for x, y in self.regex.groupindex.items()) + group_index = dict((y, x) for x, y in self.regex.groupindex.items()) args = [] - for index, group in enumerate(m.groups()): + for index, group in enumerate(matched.groups()): index += 1 - name = groupindex.get(index, None) - args.append(Argument(m.start(index), m.end(index), group, - group, name)) + name = group_index.get(index, None) + args.append(Argument(matched.start(index), matched.end(index), + group, group, name)) return args + class SimplifiedRegexMatcher(RegexMatcher): """ Simplified regular expression step-matcher that automatically adds - start-of-line/end-of-line matcher symbols to string: + START_OF_LINE/END_OF_LINE regular-expression markers to the string. + + EXAMPLE: .. code-block:: python - @when(u'a step passes') # re.pattern = "^a step passes$" - def step_impl(context): pass + from behave import when, use_step_matcher + use_step_matcher("re") + + @when(u'a step passes') # re.pattern = "^a step passes$" + def step_impl(context): + pass """ def __init__(self, func, pattern, step_type=None): assert not (pattern.startswith("^") or pattern.endswith("$")), \ "Regular expression should not use begin/end-markers: "+ pattern - expression = "^%s$" % pattern + expression = r"^%s$" % pattern super(SimplifiedRegexMatcher, self).__init__(func, expression, step_type) - self.pattern = pattern class CucumberRegexMatcher(RegexMatcher): """ Compatible to (old) Cucumber style regular expressions. - Text must contain start-of-line/end-of-line matcher symbols to string: + Step-text must contain START_OF_LINE/END_OF_LINE markers. + + EXAMPLE: .. code-block:: python + from behave import when, use_step_matcher + use_step_matcher("re0") + @when(u'^a step passes$') # re.pattern = "^a step passes$" def step_impl(context): pass """ diff --git a/behave/step_registry.py b/behave/step_registry.py index 2d6fd3982..0dffb0a14 100644 --- a/behave/step_registry.py +++ b/behave/step_registry.py @@ -5,8 +5,10 @@ step implementations (step definitions). This is necessary to execute steps. """ -from __future__ import absolute_import -from behave.matchers import Match, MatchWithError, make_matcher +from __future__ import absolute_import, print_function +import sys + +from behave.matchers import make_matcher from behave.textutil import text as _text # limit import * to just the decorators @@ -14,7 +16,7 @@ # names = "given when then step" # names = names + " " + names.title() # __all__ = names.split() -__all__ = [ +__all__ = [ # noqa: F822 "given", "when", "then", "step", # PREFERRED. "Given", "When", "Then", "Step" # Also possible. ] @@ -24,14 +26,62 @@ class AmbiguousStep(ValueError): pass +class BadStepDefinitionErrorHandler(object): + BAD_STEP_DEFINITION_MESSAGE = """\ +BAD-STEP-DEFINITION: {step} + LOCATION: {step_location} +""".strip() + BAD_STEP_DEFINITION_MESSAGE_WITH_ERROR = BAD_STEP_DEFINITION_MESSAGE + """ +RAISED EXCEPTION: {error.__class__.__name__}:{error}""" + + def __init__(self): + self.bad_step_definitions = [] + + def clear(self): + self.bad_step_definitions = [] + + def on_error(self, step_matcher, error): + self.bad_step_definitions.append(step_matcher) + self.print(step_matcher, error) + + def print_all(self): + print("BAD STEP-DEFINITIONS[%d]:" % len(self.bad_step_definitions)) + for index, bad_step_definition in enumerate(self.bad_step_definitions): + print("%d. " % index, end="") + self.print(bad_step_definition, error=None) + + # -- CLASS METHODS: + @classmethod + def print(cls, step_matcher, error=None): + message = cls.BAD_STEP_DEFINITION_MESSAGE_WITH_ERROR + if error is None: + message = cls.BAD_STEP_DEFINITION_MESSAGE + + print(message.format(step=step_matcher.describe(), + step_location=step_matcher.location, + error=error), file=sys.stderr) + + @classmethod + def raise_error(cls, step_matcher, error): + cls.print(step_matcher, error) + raise error + + class StepRegistry(object): + BAD_STEP_DEFINITION_HANDLER_CLASS = BadStepDefinitionErrorHandler + RAISE_ERROR_ON_BAD_STEP_DEFINITION = False + def __init__(self): - self.steps = { - "given": [], - "when": [], - "then": [], - "step": [], - } + self.steps = dict(given=[], when=[], then=[], step=[]) + self.error_handler = self.BAD_STEP_DEFINITION_HANDLER_CLASS() + + def clear(self): + """ + Forget any step-definitions (step-matchers) and + forget any bad step-definitions. + """ + self.steps = dict(given=[], when=[], then=[], step=[]) + self.error_handler.clear() @staticmethod def same_step_definition(step, other_pattern, other_location): @@ -39,33 +89,57 @@ def same_step_definition(step, other_pattern, other_location): step.location == other_location and other_location.filename != "") + def on_bad_step_definition(self, step_matcher, error): + # -- STEP: Select on_error() function + on_error = self.error_handler.on_error + if self.RAISE_ERROR_ON_BAD_STEP_DEFINITION: + on_error = self.error_handler.raise_error + + on_error(step_matcher, error) + + def is_good_step_definition(self, step_matcher): + """ + Check if a :param:`step_matcher` provides a good step definition. + + PROBLEM: + * :func:`Parser.parse()` may always raise an exception + (cases: :exc:`NotImplementedError` caused by :exc:`re.error`, ...). + * regex errors (from :mod:`re`) are more enforced since Python >= 3.11 + + :param step_matcher: Step-matcher (step-definition) to check. + :return: True, if step-matcher is good to use; False, otherwise. + """ + try: + step_matcher.compile() + return True + except Exception as error: + self.on_bad_step_definition(step_matcher, error) + return False + def add_step_definition(self, keyword, step_text, func): - step_location = Match.make_location(func) - step_type = keyword.lower() + new_step_type = keyword.lower() step_text = _text(step_text) - step_definitions = self.steps[step_type] + new_step_matcher = make_matcher(func, step_text, new_step_type) + if not self.is_good_step_definition(new_step_matcher): + # -- CASE: BAD STEP-DEFINITION -- Ignore it. + return + + # -- CURRENT: + step_location = new_step_matcher.location + step_definitions = self.steps[new_step_type] for existing in step_definitions: if self.same_step_definition(existing, step_text, step_location): # -- EXACT-STEP: Same step function is already registered. # This may occur when a step module imports another one. return - matched = existing.match(step_text) - if matched is None or isinstance(matched, MatchWithError): - # -- CASES: - # - step-mismatch (None) - # - matching the step caused a type-converter function error - # REASON: Bad type-converter function is used. - continue - - if isinstance(matched, Match): + if existing.matches(step_text): + # WHY: existing.step_type = new_step_type message = u"%s has already been defined in\n existing step %s" - new_step = u"@%s('%s')" % (step_type, step_text) - existing.step_type = step_type - existing_step = existing.describe() - existing_step += u" at %s" % existing.location + new_step = new_step_matcher.describe() + existing_step = existing.describe(existing.SCHEMA_AT_LOCATION) raise AmbiguousStep(message % (new_step, existing_step)) - step_definitions.append(make_matcher(func, step_text)) + step_definitions.append(new_step_matcher) def find_step_definition(self, step): candidates = self.steps[step.step_type] diff --git a/tests/issues/test_issue1177.py b/tests/issues/test_issue1177.py index 6866d132f..5bb27900d 100644 --- a/tests/issues/test_issue1177.py +++ b/tests/issues/test_issue1177.py @@ -9,6 +9,10 @@ import sys from behave._stepimport import use_step_import_modules, SimpleStepContainer +from behave.configuration import Configuration +from behave.matchers import Match, StepParseError +from behave.parser import parse_step +from behave.runner import Context, ModelRunner import parse import pytest @@ -35,10 +39,24 @@ def test_parse_expr(parse_bool): assert result is None +@pytest.mark.skipif(sys.version_info < (3, 11), reason="REQUIRES: Python >= 3.11") +def test_parse_with_bad_type_converter_pattern_raises_not_implemented_error(): + # -- HINT: re.error is only raised for Python >= 3.11 + # FAILURE-POINT: parse.Parser._match_re property -- compiles _match_re + parser = parse.Parser("Light is on: {answer:Bool}", + extra_types=dict(Bool=parse_bool_bad)) + + # -- PROBLEM POINT: + with pytest.raises(NotImplementedError) as exc_info: + _ = parser.parse("Light is on: true") + + expected = "Group names (e.g. (?P) can cause failure, as they are not escaped properly:" + assert expected in str(exc_info.value) + + # -- SYNDROME: NotImplementedError is only raised for Python >= 3.11 -@pytest.mark.skipif(sys.version_info < (3, 11), - reason="Python >= 3.11: NotImplementedError is raised") -def test_syndrome(): +@pytest.mark.skipif(sys.version_info < (3, 11), reason="REQUIRES: Python >= 3.11") +def test_syndrome(capsys): """ Ensure that no AmbiguousStepError is raised when another step is added after the one with the BAD TYPE-CONVERTER PATTERN. @@ -54,30 +72,26 @@ def test_syndrome(): def then_first_step(ctx, value): assert isinstance(value, bool), "%r" % value - with pytest.raises(NotImplementedError) as excinfo1: - # -- CASE: Another step is added - # EXPECTED: No AmbiguousStepError is raised. - @then(u'first step and more') - def then_second_step(ctx, value): - assert isinstance(value, bool), "%r" % value + # -- ENSURE: No AmbiguousStepError is raised when another step is added. + @then(u'first step and more') + def then_second_step(ctx): + pass - # -- CASE: Manually add step to step-registry - # EXPECTED: No AmbiguousStepError is raised. - with pytest.raises(NotImplementedError) as excinfo2: - step_text = u'first step and other' - def then_third_step(ctx, value): pass - this_step_registry.add_step_definition("then", step_text, then_third_step) + # -- ENSURE: BAD-STEP-DEFINITION is not registered in step_registry + step = parse_step(u'Then this step is "true"') + assert this_step_registry.find_step_definition(step) is None - assert "Group names (e.g. (?P) can cause failure" in str(excinfo1.value) - assert "Group names (e.g. (?P) can cause failure" in str(excinfo2.value) + # -- ENSURE: BAD-STEP-DEFINITION is shown in output. + captured = capsys.readouterr() + expected = """BAD-STEP-DEFINITION: @then('first step is "{value:Bool}"')""" + assert expected in captured.err + assert "RAISED EXCEPTION: NotImplementedError:Group names (e.g. (?P)" in captured.err -@pytest.mark.skipif(sys.version_info >= (3, 11), - reason="Python < 3.11 -- NotImpplementedError is not raised") -def test_syndrome_for_py310_and_older(): +@pytest.mark.skipif(sys.version_info < (3, 11), reason="REQUIRES: Python >= 3.11") +def test_bad_step_is_not_registered_if_regex_compile_fails(capsys): """ - Ensure that no AmbiguousStepError is raised - when another step is added after the one with the BAD TYPE-CONVERTER PATTERN. + Ensure that step-definition is not registered if parse-expression compile fails. """ step_container = SimpleStepContainer() this_step_registry = step_container.step_registry @@ -90,14 +104,26 @@ def test_syndrome_for_py310_and_older(): def then_first_step(ctx, value): assert isinstance(value, bool), "%r" % value - # -- CASE: Another step is added - # EXPECTED: No AmbiguousStepError is raised. - @then(u'first step and mpre') - def then_second_step(ctx, value): + # -- ENSURE: Step-definition is not registered in step-registry. + step = parse_step(u'Then first step is "true"') + step_matcher = this_step_registry.find_step_definition(step) + assert step_matcher is None + + +@pytest.mark.skipif(sys.version_info >= (3, 11), reason="REQUIRES: Python < 3.11") +def test_bad_step_is_registered_if_regex_compile_succeeds(capsys): + step_container = SimpleStepContainer() + this_step_registry = step_container.step_registry + with use_step_import_modules(step_container): + from behave import then, register_type + + register_type(Bool=parse_bool_bad) + + @then(u'first step is "{value:Bool}"') + def then_first_step(ctx, value): assert isinstance(value, bool), "%r" % value - # -- CASE: Manually add step to step-registry - # EXPECTED: No AmbiguousStepError is raised. - step_text = u'first step and other' - def then_third_step(ctx, value): pass - this_step_registry.add_step_definition("then", step_text, then_third_step) + # -- ENSURE: Step-definition is not registered in step-registry. + step = parse_step(u'Then first step is "true"') + step_matcher = this_step_registry.find_step_definition(step) + assert step_matcher is not None diff --git a/tests/unit/test_matchers.py b/tests/unit/test_matchers.py index 97ba49eb1..b737c44ca 100644 --- a/tests/unit/test_matchers.py +++ b/tests/unit/test_matchers.py @@ -324,18 +324,20 @@ def test_steps_with_same_prefix_are_not_ordering_sensitive(self): def step_func1(context): pass # pylint: disable=multiple-statements def step_func2(context): pass # pylint: disable=multiple-statements # pylint: enable=unused-argument - matcher1 = SimplifiedRegexMatcher(step_func1, "I do something") - matcher2 = SimplifiedRegexMatcher(step_func2, "I do something more") + text1 = u"I do something" + text2 = u"I do something more" + matcher1 = SimplifiedRegexMatcher(step_func1, text1) + matcher2 = SimplifiedRegexMatcher(step_func2, text2) # -- CHECK: ORDERING SENSITIVITY - matched1 = matcher1.match(matcher2.pattern) - matched2 = matcher2.match(matcher1.pattern) + matched1 = matcher1.match(text2) + matched2 = matcher2.match(text1) assert matched1 is None assert matched2 is None # -- CHECK: Can match itself (if step text is simple) - matched1 = matcher1.match(matcher1.pattern) - matched2 = matcher2.match(matcher2.pattern) + matched1 = matcher1.match(text1) + matched2 = matcher2.match(text2) assert isinstance(matched1, Match) assert isinstance(matched2, Match) @@ -363,16 +365,18 @@ def step_func2(context): pass # pylint: disable=multiple-statements # pylint: enable=unused-argument matcher1 = CucumberRegexMatcher(step_func1, "^I do something$") matcher2 = CucumberRegexMatcher(step_func2, "^I do something more$") + text1 = matcher1.pattern[1:-1] + text2 = matcher2.pattern[1:-1] # -- CHECK: ORDERING SENSITIVITY - matched1 = matcher1.match(matcher2.pattern[1:-1]) - matched2 = matcher2.match(matcher1.pattern[1:-1]) + matched1 = matcher1.match(text2) + matched2 = matcher2.match(text1) assert matched1 is None assert matched2 is None # -- CHECK: Can match itself (if step text is simple) - matched1 = matcher1.match(matcher1.pattern[1:-1]) - matched2 = matcher2.match(matcher2.pattern[1:-1]) + matched1 = matcher1.match(text1) + matched2 = matcher2.match(text2) assert isinstance(matched1, Match) assert isinstance(matched2, Match) diff --git a/tests/unit/test_step_registry.py b/tests/unit/test_step_registry.py index 59d09e157..106f91c47 100644 --- a/tests/unit/test_step_registry.py +++ b/tests/unit/test_step_registry.py @@ -4,6 +4,7 @@ from mock import Mock, patch from six.moves import range # pylint: disable=redefined-builtin from behave import step_registry +from behave.matchers import ParseMatcher class TestStepRegistry(object): @@ -15,17 +16,17 @@ def test_add_step_definition_adds_to_lowercased_keyword(self): # with patch('behave.matchers.make_matcher') as make_matcher: with patch('behave.step_registry.make_matcher') as make_matcher: func = lambda x: -x - pattern = 'just a test string' - magic_object = object() + pattern = u"just a test string" + magic_object = Mock() make_matcher.return_value = magic_object for step_type in list(registry.steps.keys()): - l = [] - registry.steps[step_type] = l + registered_steps = [] + registry.steps[step_type] = registered_steps registry.add_step_definition(step_type.upper(), pattern, func) - make_matcher.assert_called_with(func, pattern) - assert l == [magic_object] + make_matcher.assert_called_with(func, pattern, step_type) + assert registered_steps == [magic_object] def test_find_match_with_specific_step_type_also_searches_generic(self): registry = step_registry.StepRegistry() diff --git a/tools/test-features/outline.feature b/tools/test-features/outline.feature index 410cb0e73..522895fae 100644 --- a/tools/test-features/outline.feature +++ b/tools/test-features/outline.feature @@ -1,25 +1,25 @@ Feature: support scenario outlines Scenario Outline: run scenarios with one example table - Given Some text + Given some text When we add some text Then we should get the Examples: some simple examples | prefix | suffix | combination | | go | ogle | google | - | onomat | opoeia | onomatopoeia | + | onomat | opoeia | onomatopoeia | | comb | ination | combination | Scenario Outline: run scenarios with examples - Given Some text + Given some text When we add some text Then we should get the Examples: some simple examples | prefix | suffix | combination | | go | ogle | google | - | onomat | opoeia | onomatopoeia | + | onomat | opoeia | onomatopoeia | | comb | ination | combination | Examples: some other examples @@ -29,7 +29,7 @@ Feature: support scenario outlines @xfail Scenario Outline: scenarios that reference invalid subs - Given Some text + Given some text When we add try to use a reference Then it won't work diff --git a/tools/test-features/steps/steps.py b/tools/test-features/steps/steps.py index c382277e6..62bb80308 100644 --- a/tools/test-features/steps/steps.py +++ b/tools/test-features/steps/steps.py @@ -1,71 +1,88 @@ # -*- coding: UTF-8 -*- from __future__ import absolute_import -from behave import given, when, then import logging +from behave import given, when, then, register_type from six.moves import zip + spam_log = logging.getLogger('spam') ham_log = logging.getLogger('ham') + @given("I am testing stuff") def step_impl(context): context.testing_stuff = True + @given("some stuff is set up") def step_impl(context): context.stuff_set_up = True + @given("stuff has been set up") def step_impl(context): assert context.testing_stuff is True assert context.stuff_set_up is True + @when("I exercise it work") def step_impl(context): spam_log.error('logging!') ham_log.error('logging!') + @then("it will work") def step_impl(context): pass + @given("some text {prefix}") def step_impl(context, prefix): context.prefix = prefix + @when('we add some text {suffix}') def step_impl(context, suffix): context.combination = context.prefix + suffix + @then('we should get the {combination}') def step_impl(context, combination): assert context.combination == combination + @given('some body of text') def step_impl(context): assert context.text context.saved_text = context.text + TEXT = ''' Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.''' + + @then('the text is as expected') def step_impl(context): assert context.saved_text, 'context.saved_text is %r!!' % (context.saved_text, ) context.saved_text.assert_equals(TEXT) + @given('some initial data') def step_impl(context): assert context.table context.saved_table = context.table + TABLE_DATA = [ dict(name='Barry', department='Beer Cans'), dict(name='Pudey', department='Silly Walks'), dict(name='Two-Lumps', department='Silly Walks'), ] + + @then('we will have the expected data') def step_impl(context): assert context.saved_table, 'context.saved_table is %r!!' % (context.saved_table, ) @@ -73,6 +90,7 @@ def step_impl(context): assert expected['name'] == got['name'] assert expected['department'] == got['department'] + @then('the text is substituted as expected') def step_impl(context): assert context.saved_text, 'context.saved_text is %r!!' % (context.saved_text, ) @@ -85,6 +103,8 @@ def step_impl(context): dict(name='Pudey', department='Silly Walks'), dict(name='Two-Lumps', department='Silly Walks'), ] + + @then('we will have the substituted data') def step_impl(context): assert context.saved_table, 'context.saved_table is %r!!' % (context.saved_table, ) @@ -93,27 +113,32 @@ def step_impl(context): assert context.saved_table[0]['department'] == expected, '%r != %r' % ( context.saved_table[0]['department'], expected) + @given('the tag "{tag}" is set') def step_impl(context, tag): assert tag in context.tags, '%r NOT present in %r!' % (tag, context.tags) if tag == 'spam': assert context.is_spammy + @given('the tag "{tag}" is not set') def step_impl(context, tag): assert tag not in context.tags, '%r IS present in %r!' % (tag, context.tags) + @given('a string {argument} an argument') def step_impl(context, argument): context.argument = argument -from behave.matchers import register_type + register_type(custom=lambda s: s.upper()) + @given('a string {argument:custom} a custom type') def step_impl(context, argument): context.argument = argument + @then('we get "{argument}" parsed') def step_impl(context, argument): assert context.argument == argument From c6ab01c4ace5b9a2ccb6a9e63d4453cffe5d8a84 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 26 May 2024 16:20:27 +0200 Subject: [PATCH 147/240] CI: Try to use "uv" to speed-up package installations. * Drop using Python 3.9 * Move pypy-27 on ubuntu-latest to own workflow (not supported by: uv) --- .github/workflows/tests-pypy27.yml | 65 +++++++++++++++++++++++++++++ .github/workflows/tests-windows.yml | 21 ++++++---- .github/workflows/tests.yml | 15 +++++-- 3 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/tests-pypy27.yml diff --git a/.github/workflows/tests-pypy27.yml b/.github/workflows/tests-pypy27.yml new file mode 100644 index 000000000..0f9247b00 --- /dev/null +++ b/.github/workflows/tests-pypy27.yml @@ -0,0 +1,65 @@ +# -- TEST-VARIANT: pypy-27 on ubuntu-latest +# BASED ON: tests.yml + +name: tests-pypy27 +on: + workflow_dispatch: + push: + branches: [ "main", "release/**" ] + paths: + - ".github/**/*.yml" + - "**/*.py" + - "**/*.feature" + - "py.requirements/**" + - "*.cfg" + - "*.ini" + - "*.toml" + pull_request: + types: [opened, reopened, review_requested] + branches: [ "main" ] + paths: + - ".github/**/*.yml" + - "**/*.py" + - "**/*.feature" + - "py.requirements/**" + - "*.cfg" + - "*.ini" + - "*.toml" + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["pypy-2.7"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: 'py.requirements/*.txt' + + - name: Install Python package dependencies + run: | + python -m pip install -U pip setuptools wheel + pip install --upgrade -r py.requirements/ci.github.testing.txt + pip install -e . + - name: Run tests + run: pytest + - name: Run behave tests + run: | + behave --format=progress features + behave --format=progress tools/test-features + behave --format=progress issue.features + - name: Upload test reports + uses: actions/upload-artifact@v4 + with: + name: test reports + path: | + build/testing/report.xml + build/testing/report.html + # MAYBE: build/behave.reports/ + if: ${{ job.status == 'failure' }} + # MAYBE: if: ${{ always() }} diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index 411f698b4..cd396ad9a 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -39,7 +39,7 @@ jobs: fail-fast: false matrix: os: [windows-latest] - python-version: ["3.12", "3.11", "3.10", "3.9"] + python-version: ["3.12", "3.11", "3.10"] steps: - uses: actions/checkout@v4 # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} @@ -51,18 +51,25 @@ jobs: # -- DISABLED: # - name: Show Python version # run: python --version + # -- SPEED-UP: Use "uv" to speed up installation of package dependencies. + - name: Install uv + run: python -m pip install -U uv - name: Install Python package dependencies run: | - python -m pip install -U pip setuptools wheel - pip install --upgrade -r py.requirements/ci.github.testing.txt - pip install -e . + python -m uv pip install -U pip setuptools wheel + python -m uv pip install --upgrade -r py.requirements/ci.github.testing.txt + python -m uv pip install -e . + # -- OLD: + # python -m pip install -U pip setuptools wheel + # pip install --upgrade -r py.requirements/ci.github.testing.txt + # pip install -e . - name: Run tests run: pytest - name: Run behave tests run: | - behave --format=progress3 features - behave --format=progress3 tools/test-features - behave --format=progress3 issue.features + behave --format=progress features + behave --format=progress tools/test-features + behave --format=progress issue.features - name: Upload test reports uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5b53d504d..10650ac3c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,7 +36,7 @@ jobs: matrix: # PREPARED: os: [ubuntu-latest, macos-latest, windows-latest] os: [ubuntu-latest] - python-version: ["3.12", "3.11", "3.10", "3.9", "pypy-3.10", "pypy-2.7"] + python-version: ["3.12", "3.11", "3.10", "pypy-3.10"] steps: - uses: actions/checkout@v4 # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} @@ -48,11 +48,18 @@ jobs: # -- DISABLED: # - name: Show Python version # run: python --version + # -- SPEED-UP: Use "uv" to speed up installation of package dependencies. + - name: Install uv + run: python -m pip install -U uv - name: Install Python package dependencies run: | - python -m pip install -U pip setuptools wheel - pip install --upgrade -r py.requirements/ci.github.testing.txt - pip install -e . + python -m uv pip install -U pip setuptools wheel + python -m uv pip install --upgrade -r py.requirements/ci.github.testing.txt + python -m uv pip install -e . + # -- OLD: + # python -m pip install -U pip setuptools wheel + # pip install --upgrade -r py.requirements/ci.github.testing.txt + # pip install -e . - name: Run tests run: pytest - name: Run behave tests From 3b2fa2e9a3ad66eb1cd8c69f9c0b83c6ed4e7fdf Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 26 May 2024 17:13:38 +0200 Subject: [PATCH 148/240] BUMP-VERSION: 1.2.7.dev6 (was: 1.2.7.dev5) --- .bumpversion.cfg | 2 +- VERSION.txt | 2 +- behave/version.py | 2 +- setup.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 6fac22e71..55b264263 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.2.7.dev5 +current_version = 1.2.7.dev6 files = behave/version.py setup.py VERSION.txt .bumpversion.cfg parse = (?P\d+)\.(?P\d+)\.(?P\d+)(?P\w*) serialize = {major}.{minor}.{patch}{drop} diff --git a/VERSION.txt b/VERSION.txt index e353c6873..e7e6efeb1 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.2.7.dev5 +1.2.7.dev6 diff --git a/behave/version.py b/behave/version.py index 72c278160..4bc7659a4 100644 --- a/behave/version.py +++ b/behave/version.py @@ -1,2 +1,2 @@ # -- BEHAVE-VERSION: -VERSION = "1.2.7.dev5" +VERSION = "1.2.7.dev6" diff --git a/setup.py b/setup.py index cf953cbd9..6c121468c 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ def find_packages_by_root_package(where): # ----------------------------------------------------------------------------- setup( name="behave", - version="1.2.7.dev5", + version="1.2.7.dev6", description="behave is behaviour-driven development, Python style", long_description=description, author="Jens Engel, Benno Rice and Richard Jones", From 86a0d007ceb5507e4454745b5d690ea843761ed1 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 26 May 2024 17:23:39 +0200 Subject: [PATCH 149/240] CI: Tweak install-packages w/ "uv" --- .github/workflows/tests-windows.yml | 10 +++------- .github/workflows/tests.yml | 10 +++------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index cd396ad9a..e6560eff9 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -51,18 +51,14 @@ jobs: # -- DISABLED: # - name: Show Python version # run: python --version + # -- SPEED-UP: Use "uv" to speed up installation of package dependencies. - - name: Install uv - run: python -m pip install -U uv - - name: Install Python package dependencies + - name: "Install Python package dependencies (with: uv)" run: | + python -m pip install -U uv python -m uv pip install -U pip setuptools wheel python -m uv pip install --upgrade -r py.requirements/ci.github.testing.txt python -m uv pip install -e . - # -- OLD: - # python -m pip install -U pip setuptools wheel - # pip install --upgrade -r py.requirements/ci.github.testing.txt - # pip install -e . - name: Run tests run: pytest - name: Run behave tests diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 10650ac3c..9a55e3b11 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -48,18 +48,14 @@ jobs: # -- DISABLED: # - name: Show Python version # run: python --version + # -- SPEED-UP: Use "uv" to speed up installation of package dependencies. - - name: Install uv - run: python -m pip install -U uv - - name: Install Python package dependencies + - name: "Install Python package dependencies (with: uv)" run: | + python -m pip install -U uv python -m uv pip install -U pip setuptools wheel python -m uv pip install --upgrade -r py.requirements/ci.github.testing.txt python -m uv pip install -e . - # -- OLD: - # python -m pip install -U pip setuptools wheel - # pip install --upgrade -r py.requirements/ci.github.testing.txt - # pip install -e . - name: Run tests run: pytest - name: Run behave tests From 4152642db7253db562287871dfa6c8984d59af78 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 2 Apr 2023 18:32:42 +0200 Subject: [PATCH 150/240] BUMP-VERSION: 1.2.7.dev3 (was: 1.2.7.dev2) --- .bumpversion.cfg | 3 +-- VERSION.txt | 2 +- behave/version.py | 2 +- pytest.ini | 2 +- setup.py | 2 +- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 4f2bb76df..8225c378b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,9 +1,8 @@ [bumpversion] -current_version = 1.2.7.dev2 +current_version = 1.2.7.dev3 files = behave/version.py setup.py VERSION.txt pytest.ini .bumpversion.cfg parse = (?P\d+)\.(?P\d+)\.(?P\d+)(?P\w*) serialize = {major}.{minor}.{patch}{drop} commit = False tag = False allow_dirty = True - diff --git a/VERSION.txt b/VERSION.txt index c4e75f6de..1c6178fbf 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.2.7.dev2 +1.2.7.dev3 diff --git a/behave/version.py b/behave/version.py index 67f4a418f..4e19db26a 100644 --- a/behave/version.py +++ b/behave/version.py @@ -1,2 +1,2 @@ # -- BEHAVE-VERSION: -VERSION = "1.2.7.dev2" +VERSION = "1.2.7.dev3" diff --git a/pytest.ini b/pytest.ini index df2a81fb9..712acba91 100644 --- a/pytest.ini +++ b/pytest.ini @@ -21,7 +21,7 @@ testpaths = tests python_files = test_*.py junit_family = xunit2 addopts = --metadata PACKAGE_UNDER_TEST behave - --metadata PACKAGE_VERSION 1.2.7.dev2 + --metadata PACKAGE_VERSION 1.2.7.dev3 --html=build/testing/report.html --self-contained-html --junit-xml=build/testing/report.xml markers = diff --git a/setup.py b/setup.py index bd739deb6..635b861fe 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ def find_packages_by_root_package(where): # ----------------------------------------------------------------------------- setup( name="behave", - version="1.2.7.dev2", + version="1.2.7.dev3", description="behave is behaviour-driven development, Python style", long_description=description, author="Jens Engel, Benno Rice and Richard Jones", From 2b6845e54131c8587461c80eaa5b3ac26ca46e7f Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 4 Apr 2023 00:17:28 +0200 Subject: [PATCH 151/240] FIX: Test regression on Windows Gherkin parser: * Strip trailing whitespace in multi-line text REASON: Whitespace normalization (may contain carriage-return on Windows) --- behave/parser.py | 83 +++++++++++++++++++++++++++++++-------- tests/unit/test_parser.py | 26 +++++++++++- 2 files changed, 91 insertions(+), 18 deletions(-) diff --git a/behave/parser.py b/behave/parser.py index 7f197c006..ba0e60b67 100644 --- a/behave/parser.py +++ b/behave/parser.py @@ -229,7 +229,7 @@ def parse(self, text, filename=None): for line in text.split("\n"): self.line += 1 - if not line.strip() and self.state != "multiline": + if not line.strip() and self.state != "multiline_text": # -- SKIP EMPTY LINES, except in multiline string args. continue self.action(line) @@ -381,7 +381,7 @@ def ask_parse_failure_oracle(self, line): return None def action(self, line): - if line.strip().startswith("#") and self.state != "multiline": + if line.strip().startswith("#") and self.state != "multiline_text": if self.state != "init" or self.tags or self.variant != "feature": return @@ -584,8 +584,24 @@ def action_steps(self, line): # pylint: disable=R0911 # R0911 Too many return statements (8/6) stripped = line.lstrip() + # if self.statement.steps: + # # -- ENSURE: Multi-line text follows a step. + # if stripped.startswith('"""') or stripped.startswith("'''"): + # # -- CASE: Multi-line text (docstring) after a step detected. + # self.state = "multiline_text" + # self.multiline_start = self.line + # self.multiline_terminator = stripped[:3] + # self.multiline_leading = line.index(stripped[0]) + # return True + if stripped.startswith('"""') or stripped.startswith("'''"): - self.state = "multiline" + # -- CASE: Multi-line text (docstring) after a step detected. + # REQUIRE: Multi-line text follows a step. + if not self.statement.steps: + raise ParserError("Multi-line text before any step", + self.line, self.filename) + + self.state = "multiline_text" self.multiline_start = self.line self.multiline_terminator = stripped[:3] self.multiline_leading = line.index(stripped[0]) @@ -602,25 +618,47 @@ def action_steps(self, line): return True if line.startswith("|"): - assert self.statement.steps, "TABLE-START without step detected." + # -- CASE: TABLE-START detected for data-table of a step + # OLD: assert self.statement.steps, "TABLE-START without step detected" + if not self.statement.steps: + raise ParserError("TABLE-START without step detected", + self.line, self.filename) self.state = "table" return self.action_table(line) return False - def action_multiline(self, line): + def action_multiline_text(self, line): + """Parse remaining multi-line/docstring text below a step + after the triple-quotes were detected: + + * triple-double-quotes or + * triple-single-quotes + + Leading and trailing triple-quotes must be the same. + + :param line: Parsed line, as part of a multi-line text (as string). + """ if line.strip().startswith(self.multiline_terminator): - step = self.statement.steps[-1] - step.text = model.Text(u"\n".join(self.lines), u"text/plain", - self.multiline_start) - if step.name.endswith(":"): - step.name = step.name[:-1] + # -- CASE: Handle the end of a multi-line text part. + # Store the multi-line text in the step object (and continue). + this_step = self.statement.steps[-1] + text = u"\n".join(self.lines) + this_step.text = model.Text(text, u"text/plain", self.multiline_start) + if this_step.name.endswith(":"): + this_step.name = this_step.name[:-1] + + # -- RESET INTERNALS: For next step self.lines = [] self.multiline_terminator = None - self.state = "steps" + self.state = "steps" # NEXT-STATE: Accept additional step(s). return True - self.lines.append(line[self.multiline_leading:]) + # -- SPECIAL CASE: Strip trailing whitespace (whitespace normalization). + # HINT: Required for Windows line-endings, like "\r\n", etc. + text_line = line[self.multiline_leading:].rstrip() + self.lines.append(text_line) + # -- BETTER DIAGNOSTICS: May remove non-whitespace in execute_steps() removed_line_prefix = line[:self.multiline_leading] if removed_line_prefix.strip(): @@ -631,35 +669,46 @@ def action_multiline(self, line): return True def action_table(self, line): - line = line.strip() + """Parse a table, with pipe-separated columns: + * Data table of a step (after the step line) + * Examples table of a ScenarioOutline + """ + line = line.strip() if not line.startswith("|"): + # -- CASE: End-of-table detected if self.examples: + # -- CASE: Examples table of a ScenarioOutline self.examples.table = self.table self.examples = None else: + # -- CASE: Data table of a step step = self.statement.steps[-1] step.table = self.table if step.name.endswith(":"): step.name = step.name[:-1] + + # -- RESET: Parameters for parsing the next step(s). self.table = None self.state = "steps" return self.action_steps(line) if not re.match(r"^(|.+)\|$", line): logger = logging.getLogger("behave") - logger.warning(u"Malformed table row at %s: line %i", self.feature.filename, self.line) + logger.warning(u"Malformed table row at %s: line %i", + self.feature.filename, self.line) # -- SUPPORT: Escaped-pipe(s) in Gherkin cell values. # Search for pipe(s) that are not preceeded with an escape char. cells = [cell.replace("\\|", "|").strip() for cell in re.split(r"(? Date: Tue, 18 Apr 2023 21:04:26 +0200 Subject: [PATCH 152/240] EXAMPLE: Using assertpy.soft_assertions in behave RELATED TO: * Discussion in #1094 --- examples/soft_asserts/README.rst | 97 +++++++++++++++++++ examples/soft_asserts/behave.ini | 15 +++ .../behave_run.output_example.txt | 51 ++++++++++ .../behave_run.output_example2.txt | 44 +++++++++ examples/soft_asserts/features/environment.py | 30 ++++++ .../features/soft_asserts.feature | 39 ++++++++ .../features/steps/number_steps.py | 38 ++++++++ .../features/steps/use_steplib_behave4cmd.py | 12 +++ examples/soft_asserts/py.requirements.txt | 4 + py.requirements/testing.txt | 5 +- 10 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 examples/soft_asserts/README.rst create mode 100644 examples/soft_asserts/behave.ini create mode 100644 examples/soft_asserts/behave_run.output_example.txt create mode 100644 examples/soft_asserts/behave_run.output_example2.txt create mode 100644 examples/soft_asserts/features/environment.py create mode 100644 examples/soft_asserts/features/soft_asserts.feature create mode 100644 examples/soft_asserts/features/steps/number_steps.py create mode 100644 examples/soft_asserts/features/steps/use_steplib_behave4cmd.py create mode 100644 examples/soft_asserts/py.requirements.txt diff --git a/examples/soft_asserts/README.rst b/examples/soft_asserts/README.rst new file mode 100644 index 000000000..e49a8b65a --- /dev/null +++ b/examples/soft_asserts/README.rst @@ -0,0 +1,97 @@ +EXAMPLE: Use Soft Assertions in behave +============================================================================= + +:RELATED TO: `discussion #1094`_ + +This directory provides a simple example how soft-assertions can be used +in ``behave`` by using the ``assertpy`` package. + + +HINT: + +* Python2.7: "@soft_assertions()" decorator does not seem to work. + Use ContextManager solution instead, like: ``with soft_assertions(): ...`` + + +Bootstrap +----------------------------------------------------------------------------- + +ASSUMPTIONS: + +* Python3 is installed (or: Python2.7) +* virtualenv is installed (otherwise use: pip install virtualenv) + +Create a virtual-environment with "virtualenv" and activate it:: + + + $ python3 -mvirtualenv .venv + + # -- STEP 2: Activate the virtualenv + # CASE 1: BASH-LIKE SHELL (on UNIX-like platform: Linux, macOS, WSL, ...) + $ source .venv/bin/activate + + # CASE 2: CMD SHELL (on Windows) + cmd> .venv/Scripts/activate + +Install the required Python packages in the virtualenv:: + + $ pip install -r py.requirements.txt + + +Run the Example +----------------------------------------------------------------------------- + +:: + + # -- USE: -f plain --no-capture (via "behave.ini" defaults) + $ ../../bin/behave -f pretty features + Feature: Use Soft Assertions in behave # features/soft_asserts.feature:1 + RELATED TO: https://github.com/behave/behave/discussions/1094 + Scenario: Failing with Soft Assertions -- CASE 1 # features/soft_asserts.feature:5 + Given a minimum number value of "5" # features/steps/number_steps.py:16 + Then the numbers "2" and "12" are in the valid range # features/steps/number_steps.py:27 + Assertion Failed: soft assertion failures: + 1. Expected <2> to be greater than or equal to <5>, but was not. + + But note that "the step-2 (then step) is expected to fail" # None + + @behave.continue_after_failed_step + Scenario: Failing with Soft Assertions -- CASE 2 # features/soft_asserts.feature:17 + Given a minimum number value of "5" # features/steps/number_steps.py:16 + Then the number "4" is in the valid range # features/steps/number_steps.py:21 + Assertion Failed: Expected <4> to be greater than or equal to <5>, but was not. + + And the number "8" is in the valid range # features/steps/number_steps.py:21 + But note that "the step-2 and step-3 are expected to fail" # ../../behave4cmd0/note_steps.py:15 + But note that "the step-4 should pass" # ../../behave4cmd0/note_steps.py:15 + + @behave.continue_after_failed_step + Scenario: Failing with Soft Assertions -- CASE 1 and CASE 2 # features/soft_asserts.feature:28 + Given a minimum number value of "5" # features/steps/number_steps.py:16 + Then the number "2" is in the valid range # features/steps/number_steps.py:21 + Assertion Failed: Expected <2> to be greater than or equal to <5>, but was not. + + And the numbers "3" and "4" are in the valid range # features/steps/number_steps.py:27 + Assertion Failed: soft assertion failures: + 1. Expected <3> to be greater than or equal to <5>, but was not. + 2. Expected <4> to be greater than or equal to <5>, but was not. + + And the number "8" is in the valid range # features/steps/number_steps.py:21 + But note that "the step-2 and step-3 are expected to fail" # ../../behave4cmd0/note_steps.py:15 + But note that "the step-4 should pass" # ../../behave4cmd0/note_steps.py:15 + + Scenario: Passing # features/soft_asserts.feature:37 + Given a step passes # ../../behave4cmd0/passing_steps.py:23 + And note that "this scenario should be executed and should pass" # ../../behave4cmd0/note_steps.py:15 + + + Failing scenarios: + features/soft_asserts.feature:5 Failing with Soft Assertions -- CASE 1 + features/soft_asserts.feature:17 Failing with Soft Assertions -- CASE 2 + features/soft_asserts.feature:28 Failing with Soft Assertions -- CASE 1 and CASE 2 + + 0 features passed, 1 failed, 0 skipped + 1 scenario passed, 3 failed, 0 skipped + 11 steps passed, 4 failed, 1 skipped, 0 undefined + +.. _`discussion #1094`: https://github.com/behave/behave/discussions/1094 diff --git a/examples/soft_asserts/behave.ini b/examples/soft_asserts/behave.ini new file mode 100644 index 000000000..7c160ef10 --- /dev/null +++ b/examples/soft_asserts/behave.ini @@ -0,0 +1,15 @@ +# ============================================================================= +# BEHAVE CONFIGURATION +# ============================================================================= +# FILE: .behaverc, behave.ini +# +# SEE ALSO: +# * http://packages.python.org/behave/behave.html#configuration-files +# * https://github.com/behave/behave +# * http://pypi.python.org/pypi/behave/ +# ============================================================================= + +[behave] +default_format = pretty +stdout_capture = false +show_source = true diff --git a/examples/soft_asserts/behave_run.output_example.txt b/examples/soft_asserts/behave_run.output_example.txt new file mode 100644 index 000000000..727e1e1cb --- /dev/null +++ b/examples/soft_asserts/behave_run.output_example.txt @@ -0,0 +1,51 @@ +# -- HINT: EXECUTE: ../../bin/behave -f pretty + +Feature: Use Soft Assertions in behave # features/soft_asserts.feature:1 + RELATED TO: https://github.com/behave/behave/discussions/1094 + Scenario: Failing with Soft Assertions -- CASE 1 # features/soft_asserts.feature:5 + Given a minimum number value of "5" # features/steps/number_steps.py:16 + Then the numbers "2" and "12" are in the valid range # features/steps/number_steps.py:25 + Assertion Failed: soft assertion failures: + 1. Expected <2> to be greater than or equal to <5>, but was not. + + But note that "the step-2 (then step) is expected to fail" # None + + @behave.continue_after_failed_step + Scenario: Failing with Soft Assertions -- CASE 2 # features/soft_asserts.feature:17 + Given a minimum number value of "5" # features/steps/number_steps.py:16 + Then the number "4" is in the valid range # features/steps/number_steps.py:21 + Assertion Failed: Expected <4> to be greater than or equal to <5>, but was not. + + And the number "8" is in the valid range # features/steps/number_steps.py:21 + But note that "the step-2 is expected to fail" # ../../behave4cmd0/note_steps.py:15 + But note that "the step-3 should be executed and should pass" # ../../behave4cmd0/note_steps.py:15 + + @behave.continue_after_failed_step + Scenario: Failing with Soft Assertions -- CASE 1 and CASE 2 # features/soft_asserts.feature:28 + Given a minimum number value of "5" # features/steps/number_steps.py:16 + Then the number "2" is in the valid range # features/steps/number_steps.py:21 + Assertion Failed: Expected <2> to be greater than or equal to <5>, but was not. + + And the numbers "3" and "4" are in the valid range # features/steps/number_steps.py:25 + Assertion Failed: soft assertion failures: + 1. Expected <3> to be greater than or equal to <5>, but was not. + 2. Expected <4> to be greater than or equal to <5>, but was not. + + And the number "8" is in the valid range # features/steps/number_steps.py:21 + But note that "the step-2 and step-3 are expected to fail" # ../../behave4cmd0/note_steps.py:15 + But note that "the step-4 should be executed and should pass" # ../../behave4cmd0/note_steps.py:15 + + Scenario: Passing # features/soft_asserts.feature:37 + Given a step passes # ../../behave4cmd0/passing_steps.py:23 + And note that "this scenario should be executed and should pass" # ../../behave4cmd0/note_steps.py:15 + + +Failing scenarios: + features/soft_asserts.feature:5 Failing with Soft Assertions -- CASE 1 + features/soft_asserts.feature:17 Failing with Soft Assertions -- CASE 2 + features/soft_asserts.feature:28 Failing with Soft Assertions -- CASE 1 and CASE 2 + +0 features passed, 1 failed, 0 skipped +1 scenario passed, 3 failed, 0 skipped +11 steps passed, 4 failed, 1 skipped, 0 undefined +Took 0m0.001s diff --git a/examples/soft_asserts/behave_run.output_example2.txt b/examples/soft_asserts/behave_run.output_example2.txt new file mode 100644 index 000000000..27ad0b551 --- /dev/null +++ b/examples/soft_asserts/behave_run.output_example2.txt @@ -0,0 +1,44 @@ +# -- HINT: EXECUTE: ../../bin/behave -f plain + +Feature: Use Soft Assertions in behave + + Scenario: Failing with Soft Assertions -- CASE 1 + Given a minimum number value of "5" ... passed + Then the numbers "2" and "12" are in the valid range ... failed +Assertion Failed: soft assertion failures: +1. Expected <2> to be greater than or equal to <5>, but was not. + + Scenario: Failing with Soft Assertions -- CASE 2 + Given a minimum number value of "5" ... passed + Then the number "4" is in the valid range ... failed +Assertion Failed: Expected <4> to be greater than or equal to <5>, but was not. + And the number "8" is in the valid range ... passed + But note that "the step-2 is expected to fail" ... passed + But note that "the step-3 should be executed and should pass" ... passed + + Scenario: Failing with Soft Assertions -- CASE 1 and CASE 2 + Given a minimum number value of "5" ... passed + Then the number "2" is in the valid range ... failed +Assertion Failed: Expected <2> to be greater than or equal to <5>, but was not. + And the numbers "3" and "4" are in the valid range ... failed +Assertion Failed: soft assertion failures: +1. Expected <3> to be greater than or equal to <5>, but was not. +2. Expected <4> to be greater than or equal to <5>, but was not. + And the number "8" is in the valid range ... passed + But note that "the step-2 and step-3 are expected to fail" ... passed + But note that "the step-4 should be executed and should pass" ... passed + + Scenario: Passing + Given a step passes ... passed + And note that "this scenario should be executed and should pass" ... passed + + +Failing scenarios: + features/soft_asserts.feature:5 Failing with Soft Assertions -- CASE 1 + features/soft_asserts.feature:17 Failing with Soft Assertions -- CASE 2 + features/soft_asserts.feature:28 Failing with Soft Assertions -- CASE 1 and CASE 2 + +0 features passed, 1 failed, 0 skipped +1 scenario passed, 3 failed, 0 skipped +11 steps passed, 4 failed, 1 skipped, 0 undefined +Took 0m0.001s diff --git a/examples/soft_asserts/features/environment.py b/examples/soft_asserts/features/environment.py new file mode 100644 index 000000000..e24c00eda --- /dev/null +++ b/examples/soft_asserts/features/environment.py @@ -0,0 +1,30 @@ +# -*- coding: UTF-8 -*- +# FILE: features/environment.py + +from __future__ import absolute_import, print_function +import os.path +import sys + + +HERE = os.path.abspath(os.path.dirname(__file__)) +TOP_DIR = os.path.abspath(os.path.join(HERE, "../..")) + + +# ----------------------------------------------------------------------------- +# HOOKS: +# ----------------------------------------------------------------------------- +def before_all(context): + setup_python_path() + + +def before_scenario(context, scenario): + if "behave.continue_after_failed_step" in scenario.effective_tags: + scenario.continue_after_failed_step = True + + +# ----------------------------------------------------------------------------- +# SPECIFIC FUNCTIONALITY: +# ----------------------------------------------------------------------------- +def setup_python_path(): + # -- ENSURE: behave4cmd0 can be imported in steps-directory. + sys.path.insert(0, TOP_DIR) diff --git a/examples/soft_asserts/features/soft_asserts.feature b/examples/soft_asserts/features/soft_asserts.feature new file mode 100644 index 000000000..527dea04c --- /dev/null +++ b/examples/soft_asserts/features/soft_asserts.feature @@ -0,0 +1,39 @@ +Feature: Use Soft Assertions in behave + + RELATED TO: https://github.com/behave/behave/discussions/1094 + + Scenario: Failing with Soft Assertions -- CASE 1 + + HINT: + Multiple assert statements in a step are executed even if a assert fails. + After a failed step in the Scenario, + the remaining steps are skipped and the next Scenario is executed. + + Given a minimum number value of "5" + Then the numbers "2" and "12" are in the valid range + But note that "the step-2 (then step) is expected to fail" + + @behave.continue_after_failed_step + Scenario: Failing with Soft Assertions -- CASE 2 + + HINT: If a step in the Scenario fails, execution is continued. + + Given a minimum number value of "5" + Then the number "4" is in the valid range + And the number "8" is in the valid range + But note that "the step-2 is expected to fail" + But note that "the step-3 should be executed and should pass" + + @behave.continue_after_failed_step + Scenario: Failing with Soft Assertions -- CASE 1 and CASE 2 + + Given a minimum number value of "5" + Then the number "2" is in the valid range + And the numbers "3" and "4" are in the valid range + And the number "8" is in the valid range + But note that "the step-2 and step-3 are expected to fail" + But note that "the step-4 should be executed and should pass" + + Scenario: Passing + Given a step passes + And note that "this scenario should be executed and should pass" diff --git a/examples/soft_asserts/features/steps/number_steps.py b/examples/soft_asserts/features/steps/number_steps.py new file mode 100644 index 000000000..84f6be580 --- /dev/null +++ b/examples/soft_asserts/features/steps/number_steps.py @@ -0,0 +1,38 @@ +# -*- coding: UTF-8 -*- +# -- FILE: features/steps/number_steps.py +""" +Step-functions for soft-assertion example. + +STEPS: + Given a minimum number value of "5" + Then the numbers "2" and "12" are in the valid range + And the number "4" is in the valid range +""" +from __future__ import print_function +from behave import given, when, then, step +from assertpy import assert_that, soft_assertions + + +@given(u'a minimum number value of "{min_value:d}"') +def step_given_min_number_value(ctx, min_value): + ctx.min_number_value = min_value + + +@then(u'the number "{number:d}" is in the valid range') +def step_then_number_is_valid(ctx, number): + assert_that(number).is_greater_than_or_equal_to(ctx.min_number_value) + +@then(u'the numbers "{number1:d}" and "{number2:d}" are in the valid range') +@soft_assertions() +def step_then_numbers_are_valid(ctx, number1, number2): + assert_that(number1).is_greater_than_or_equal_to(ctx.min_number_value) + assert_that(number2).is_greater_than_or_equal_to(ctx.min_number_value) + + +@then(u'the positive number "{number:d}" is in the valid range') +# DISABLED: @soft_assertions() +def step_then_positive_number_is_valid(ctx, number): + # -- ALTERNATIVE: Use ContextManager instead of disabled decorator above. + with soft_assertions(): + assert_that(number).is_greater_than_or_equal_to(0) + assert_that(number).is_greater_than_or_equal_to(ctx.min_number_value) diff --git a/examples/soft_asserts/features/steps/use_steplib_behave4cmd.py b/examples/soft_asserts/features/steps/use_steplib_behave4cmd.py new file mode 100644 index 000000000..a56e2fd75 --- /dev/null +++ b/examples/soft_asserts/features/steps/use_steplib_behave4cmd.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +""" +Use behave4cmd0 step library (predecessor of behave4cmd). +""" + +from __future__ import absolute_import + +# -- REGISTER-STEPS FROM STEP-LIBRARY: +# DISABLED: import behave4cmd0.__all_steps__ +import behave4cmd0.passing_steps +import behave4cmd0.failing_steps +import behave4cmd0.note_steps diff --git a/examples/soft_asserts/py.requirements.txt b/examples/soft_asserts/py.requirements.txt new file mode 100644 index 000000000..51025cfda --- /dev/null +++ b/examples/soft_asserts/py.requirements.txt @@ -0,0 +1,4 @@ +assertpy >= 1.1 + +-r ../../py.requirements/basic.txt +-r ../../py.requirements/testing.txt diff --git a/py.requirements/testing.txt b/py.requirements/testing.txt index b5a209c9c..80802a1a8 100644 --- a/py.requirements/testing.txt +++ b/py.requirements/testing.txt @@ -20,8 +20,11 @@ PyHamcrest < 2.0; python_version < '3.0' # HINT: path.py => path (python-install-package was renamed for python3) path.py >=11.5.0,<13.0; python_version < '3.5' path >= 13.1.0; python_version >= '3.5' + # NOTE: toml extra for pyproject.toml-based config -.[toml] +# DISABLED: .[toml] +tomli >= 1.1.0; python_version >= '3.0' and python_version < '3.11' +toml >= 0.10.2; python_version < '3.0' # -- PYTHON2 BACKPORTS: From 7ab1414d44fc1907564c2d23f8352d9fc4e5989b Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 22 Apr 2023 18:58:51 +0200 Subject: [PATCH 153/240] CLEANUP: Remove tempfile module usage * Use pytest tmp_path fixture instead --- tests/unit/test_configuration.py | 5 ++--- tests/unit/test_runner.py | 19 +++++++++---------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index c01ffa41e..e66b3f80f 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -1,6 +1,5 @@ import os.path import sys -import tempfile import six import pytest from behave import configuration @@ -67,8 +66,8 @@ class TestConfiguration(object): ("filename", "contents"), list(TEST_CONFIGS) ) - def test_read_file(self, filename, contents): - tndir = tempfile.mkdtemp() + def test_read_file(self, filename, contents, tmp_path): + tndir = str(tmp_path) file_path = os.path.normpath(os.path.join(tndir, filename)) with open(file_path, "w") as fp: fp.write(contents) diff --git a/tests/unit/test_runner.py b/tests/unit/test_runner.py index beaff8fc9..98eba082d 100644 --- a/tests/unit/test_runner.py +++ b/tests/unit/test_runner.py @@ -7,7 +7,6 @@ import os.path import sys import warnings -import tempfile import unittest import six from six import StringIO @@ -623,18 +622,18 @@ def test_teardown_capture_removes_log_tap(self): r.capture_controller.log_capture.abandon.assert_called_with() - def test_exec_file(self): - fn = tempfile.mktemp() - with open(fn, "w") as f: + def test_exec_file(self, tmp_path): + filename = str(tmp_path/"example.py") + with open(filename, "w") as f: f.write("spam = __file__\n") - g = {} - l = {} - runner_util.exec_file(fn, g, l) - assert "__file__" in l + my_globals = {} + my_locals = {} + runner_util.exec_file(filename, my_globals, my_locals) + assert "__file__" in my_locals # pylint: disable=too-many-format-args - assert "spam" in l, '"spam" variable not set in locals (%r)' % (g, l) + assert "spam" in my_locals, '"spam" variable not set in locals (%r)' % (my_globals, my_locals) # pylint: enable=too-many-format-args - assert l["spam"] == fn + assert my_locals["spam"] == filename def test_run_returns_true_if_everything_passed(self): r = runner.Runner(Mock()) From 96cc856cedb8ef322e621b10b5caa0d0474c562e Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Thu, 4 May 2023 17:46:08 +0200 Subject: [PATCH 154/240] Add instructions for extras when installing from GitHub --- docs/install.rst | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/install.rst b/docs/install.rst index 1b3fb6e0f..cb111446c 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -35,33 +35,36 @@ enter the newly created directory "behave-" and run:: pip install . -Using the Github Repository +Using the GitHub Repository --------------------------- :Category: Bleeding edge :Precondition: :pypi:`pip` is installed Run the following command -to install the newest version from the `Github repository`_:: - +to install the newest version from the `GitHub repository`_:: pip install git+https://github.com/behave/behave -To install a tagged version from the `Github repository`_, use:: +To install a tagged version from the `GitHub repository`_, use:: pip install git+https://github.com/behave/behave@ where is the placeholder for an `existing tag`_. -.. _`Github repository`: https://github.com/behave/behave +When installing extras, use ``#egg=behave[...]``, e.g.:: + + pip install git+https://github.com/behave/behave@v1.2.7.dev3#egg=behave[toml] + +.. _`GitHub repository`: https://github.com/behave/behave .. _`existing tag`: https://github.com/behave/behave/tags Optional Dependencies --------------------- -If needed, additional dependencies can be installed using ``pip install`` -with one of the following installation targets. +If needed, additional dependencies ("extras") can be installed using +``pip install`` with one of the following installation targets. ======================= =================================================================== Installation Target Description From bea885cf36fb82a300ee6054ce5344a4c0f7e366 Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Thu, 4 May 2023 21:19:10 +0200 Subject: [PATCH 155/240] Use Gitter badge link without tracking query string --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 93bd8168e..d1e182f70 100644 --- a/README.rst +++ b/README.rst @@ -19,9 +19,9 @@ behave :target: https://pypi.python.org/pypi/behave/ :alt: License -.. image:: https://badges.gitter.im/Join%20Chat.svg +.. image:: https://badges.gitter.im/join_chat.svg :alt: Join the chat at https://gitter.im/behave/behave - :target: https://gitter.im/behave/behave?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge + :target: https://app.gitter.im/#/room/#behave_behave:gitter.im .. |logo| image:: https://raw.github.com/behave/behave/master/docs/_static/behave_logo1.png From df2314830b4e7dfc7633a3e73f2ed734ae564466 Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Thu, 4 May 2023 22:35:22 +0200 Subject: [PATCH 156/240] Fix failing build of docs (pin urllib3, see #1106) Could not import extension sphinx.builders.linkcheck (exception: urllib3 v2.0 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with OpenSSL 1.0.2n 7 Dec 2017. --- py.requirements/docs.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/py.requirements/docs.txt b/py.requirements/docs.txt index e50190db0..276186044 100644 --- a/py.requirements/docs.txt +++ b/py.requirements/docs.txt @@ -2,11 +2,14 @@ # BEHAVE: PYTHON PACKAGE REQUIREMENTS: For documentation generation # ============================================================================ # REQUIRES: pip >= 8.0 -# AVOID: shponx v4.4.0 and newer -- Problems w/ new link check suggestion warnings +# AVOID: sphinx v4.4.0 and newer -- Problems w/ new link check suggestion warnings +# urllib3 v2.0+ only supports OpenSSL 1.1.1+, 'ssl' module is compiled with +# v1.0.2, see: https://github.com/urllib3/urllib3/issues/2168 sphinx >=1.6,<4.4 sphinx-autobuild sphinx_bootstrap_theme >= 0.6.0 +urllib3 < 2.0.0 # -- SUPPORT: sphinx-doc translations (prepared) sphinx-intl >= 0.9.11 From 0a835245d9563ea6f126c248f024660b0a0cb757 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 6 May 2023 15:43:42 +0200 Subject: [PATCH 157/240] FIX: invoke==1.4.1 (pinned) pip requirement * Causes problems for Python >= 3.8 due to bundled YAML RELATED TO: collections.Hashable (DEPRECATED) * Use invoke >= 1.7.0 (that fixes the problem) SEE: * https://www.pyinvoke.org/changelog.html * https://github.com/yaml/pyyaml/issues/202 --- setup.py | 7 ++++--- tasks/py.requirements.txt | 9 +++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/setup.py b/setup.py index 635b861fe..cb0138c2c 100644 --- a/setup.py +++ b/setup.py @@ -125,9 +125,10 @@ def find_packages_by_root_package(where): "PyHamcrest < 2.0; python_version < '3.0'", "pytest-cov", "tox", - "invoke >= 1.4.0", - # -- HINT: path.py => path (python-install-package was renamed for python3) - "path >= 13.1.0; python_version >= '3.5'", + "invoke >=1.7.0,<2.0; python_version < '3.6'", + "invoke >=1.7.0; python_version >= '3.6'", + # -- HINT, was RENAMED: path.py => path (for python3) + "path >= 13.1.0; python_version >= '3.5'", "path.py >= 11.5.0; python_version < '3.5'", "pycmd", "pathlib; python_version <= '3.4'", diff --git a/tasks/py.requirements.txt b/tasks/py.requirements.txt index ac19e9469..a02e6e0e2 100644 --- a/tasks/py.requirements.txt +++ b/tasks/py.requirements.txt @@ -8,12 +8,13 @@ # * http://www.pip-installer.org/ # ============================================================================ -invoke==1.4.1 +invoke >=1.7.0,<2.0; python_version < '3.6' +invoke >=1.7.0; python_version >= '3.6' pycmd -six==1.15.0 +six >= 1.15.0 -# -- HINT: path.py => path (python-install-package was renamed for python3) -path >= 13.1.0; python_version >= '3.5' +# -- HINT, was RENAMED: path.py => path (for python3) +path >= 13.1.0; python_version >= '3.5' path.py >= 11.5.0; python_version < '3.5' # -- PYTHON2 BACKPORTS: From 6983da2e69c7203c49d9d3166538a318fe111ef6 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 6 May 2023 20:44:47 +0200 Subject: [PATCH 158/240] CLEANUP: tox, virtualenv related * Use tox < 4.0 for now (hint: tox 4.x does not support python2) * Use virtualenv < 20.22.0 to retain support for Python 2.7, Python <= 3.6 * tox.ini: Remove py27 section, virtualenv >= 20.14.1 fixes #2284 issue SEE: https://github.com/pypa/virtualenv/issues/2284 * py.requirements/ci.tox.txt: Simplify and use "testing.txt" requirements --- py.requirements/ci.tox.txt | 18 +----------------- py.requirements/develop.txt | 3 ++- py.requirements/docs.txt | 5 ++++- py.requirements/testing.txt | 1 - setup.py | 3 ++- tox.ini | 20 +------------------- 6 files changed, 10 insertions(+), 40 deletions(-) diff --git a/py.requirements/ci.tox.txt b/py.requirements/ci.tox.txt index 4eabfc3f4..87c7d5f01 100644 --- a/py.requirements/ci.tox.txt +++ b/py.requirements/ci.tox.txt @@ -1,22 +1,6 @@ # ============================================================================ # BEHAVE: PYTHON PACKAGE REQUIREMENTS: ci.tox.txt # ============================================================================ -# BASED ON: testing.txt - -pytest < 5.0; python_version < '3.0' # pytest >= 4.2 -pytest >= 5.0; python_version >= '3.0' - -pytest-html >= 1.19.0,<2.0; python_version < '3.0' -pytest-html >= 2.0; python_version >= '3.0' - -mock < 4.0; python_version < '3.6' -mock >= 4.0; python_version >= '3.6' -PyHamcrest >= 2.0.2; python_version >= '3.0' -PyHamcrest < 2.0; python_version < '3.0' - -# -- HINT: path.py => path (python-install-package was renamed for python3) -path.py >=11.5.0,<13.0; python_version < '3.5' -path >= 13.1.0; python_version >= '3.5' +-r testing.txt jsonschema - diff --git a/py.requirements/develop.txt b/py.requirements/develop.txt index d79f1f526..f2df9c199 100644 --- a/py.requirements/develop.txt +++ b/py.requirements/develop.txt @@ -29,7 +29,8 @@ pylint -r testing.txt coverage >= 4.2 pytest-cov -tox >= 1.8.1 +tox >= 1.8.1,<4.0 # -- HINT: tox >= 4.0 has breaking changes. +virtualenv < 20.22.0 # -- SUPPORT FOR: Python 2.7, Python <= 3.6 # -- REQUIRED FOR: docs -r docs.txt diff --git a/py.requirements/docs.txt b/py.requirements/docs.txt index 276186044..48404d856 100644 --- a/py.requirements/docs.txt +++ b/py.requirements/docs.txt @@ -9,7 +9,10 @@ sphinx >=1.6,<4.4 sphinx-autobuild sphinx_bootstrap_theme >= 0.6.0 -urllib3 < 2.0.0 + +# -- NEEDED FOR: RTD (as temporary fix) +urllib3 < 2.0.0; python_version < '3.10' +urllib3 >= 2.0.0; python_version >= '3.10' # -- SUPPORT: sphinx-doc translations (prepared) sphinx-intl >= 0.9.11 diff --git a/py.requirements/testing.txt b/py.requirements/testing.txt index 80802a1a8..1cea6736c 100644 --- a/py.requirements/testing.txt +++ b/py.requirements/testing.txt @@ -26,7 +26,6 @@ path >= 13.1.0; python_version >= '3.5' tomli >= 1.1.0; python_version >= '3.0' and python_version < '3.11' toml >= 0.10.2; python_version < '3.0' - # -- PYTHON2 BACKPORTS: pathlib; python_version <= '3.4' diff --git a/setup.py b/setup.py index cb0138c2c..7bdf2f6ca 100644 --- a/setup.py +++ b/setup.py @@ -124,7 +124,8 @@ def find_packages_by_root_package(where): "PyHamcrest >= 2.0.2; python_version >= '3.0'", "PyHamcrest < 2.0; python_version < '3.0'", "pytest-cov", - "tox", + "tox >= 1.8.1,<4.0", # -- HINT: tox >= 4.0 has breaking changes. + "virtualenv < 20.22.0", # -- SUPPORT FOR: Python 2.7, Python <= 3.6 "invoke >=1.7.0,<2.0; python_version < '3.6'", "invoke >=1.7.0; python_version >= '3.6'", # -- HINT, was RENAMED: path.py => path (for python3) diff --git a/tox.ini b/tox.ini index a57cc2f55..c4cdd0ff1 100644 --- a/tox.ini +++ b/tox.ini @@ -16,7 +16,7 @@ [tox] minversion = 2.3 -envlist = py39, py27, py310, py38, pypy3, pypy, docs +envlist = py311, py27, py310, py39, py38, pypy3, pypy, docs skip_missing_interpreters = true @@ -36,24 +36,6 @@ setenv = PYTHONPATH = {toxinidir} -# -- HINT: Script(s) seems to be no longer installed on Python 2.7. -# WEIRD: pip-install seems to need "--user" option. -# RELATED: https://github.com/pypa/virtualenv/issues/2284 -- macOS 12 Monterey related -[testenv:py27] -# MAYBE: platform = darwin -install_command = pip install --user -U {opts} {packages} -changedir = {toxinidir} -commands= - python -m pytest {posargs:tests} - python -m behave --format=progress {posargs:features} - python -m behave --format=progress {posargs:tools/test-features} - python -m behave --format=progress {posargs:issue.features} -deps= - {[testenv]deps} -setenv = - PYTHONPATH = {toxinidir} - - [testenv:docs] changedir = docs commands = From 3335a580da3afce8716d273d941e7c1400f795d8 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 6 May 2023 21:10:16 +0200 Subject: [PATCH 159/240] CONSTRAIN: urllib3 fix for RTD docs building * Constrain requirement to python_version <= '3.8' * RTD currently used python3.7 (with the urllib3 problem) --- py.requirements/docs.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/py.requirements/docs.txt b/py.requirements/docs.txt index 48404d856..75bc980c7 100644 --- a/py.requirements/docs.txt +++ b/py.requirements/docs.txt @@ -11,8 +11,7 @@ sphinx-autobuild sphinx_bootstrap_theme >= 0.6.0 # -- NEEDED FOR: RTD (as temporary fix) -urllib3 < 2.0.0; python_version < '3.10' -urllib3 >= 2.0.0; python_version >= '3.10' +urllib3 < 2.0.0; python_version < '3.8' # -- SUPPORT: sphinx-doc translations (prepared) sphinx-intl >= 0.9.11 From a77117cc35a825f30258d53673f8d8328d5ffd8d Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 6 May 2023 22:36:11 +0200 Subject: [PATCH 160/240] EXAMPLE FOR: #1002, #1045 (duplicate) Provide an example how an EMPTY string can be matched. HINT: parse module no longer supports EMPTY strings. --- issue.features/issue1002.feature | 186 +++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 issue.features/issue1002.feature diff --git a/issue.features/issue1002.feature b/issue.features/issue1002.feature new file mode 100644 index 000000000..f6739223b --- /dev/null +++ b/issue.features/issue1002.feature @@ -0,0 +1,186 @@ +@issue +Feature: Issue #1002 -- ScenarioOutline with Empty Placeholder Values in Examples Table + + SEE: https://github.com/behave/behave/issues/1002 + SEE: https://github.com/behave/behave/issues/1045 (duplicated) + + . COMMENTS: + . * Named placeholders in the "parse" module do not match EMPTY-STRING (anymore) + . + . SOLUTIONS: + . * Use "Cardinality field parser (cfparse) with optional word, like: "{param:Word?}" + . * Use a second step alias that matches empty string, like: + . + . @step(u'I meet with "{name}"') + . @step(u'I meet with ""') + . def step_meet_person_with_name(ctx, name=""): + . if not name: + . name = "NOBODY" + . + . * Use explicit type converters instead of MATCH-ANYTHING (non-empty), like: + . + . @parse.with_pattern(r".*") + . def parse_any_text(text): + . return text + . + . @parse.with_pattern(r'[^"]*') + . def parse_unquoted_or_empty_text(text): + . return text + . + . register_type(AnyText=parse_any_text) + . register_type(Unquoted=parse_unquoted_or_empty_text) + . + . # -- VARIANT 1: + . @step('Passing parameter "{param:AnyText}"') + . def step_use_parameter_v1(context, param): + . print(param) + . + . # -- VARIANT 2 (ALTERNATIVE: either/or): + . @step('Passing parameter "{param:Unquoted}"') + . def step_use_parameter_v2(context, param): + . print(param) + + Background: Test Setup + Given a new working directory + And a file named "features/example_1002.feature" with: + """ + Feature: + Scenario Outline: Meet with + When I meet with "" + + Examples: + | name | case | + | Alice | Non-empty value | + | | Empty string (SYNDROME) | + """ + + Scenario: SOLUTION 1: Use another step binding for empty-string + Given a file named "features/steps/steps.py" with: + """ + # -- FILE: features/steps/steps.py + from behave import step + + @step(u'I meet with "{name}"') + @step(u'I meet with ""') # -- SPECIAL CASE: Match EMPTY-STRING + def step_meet_with_person(ctx, name=""): + ctx.other_person = name + """ + When I run "behave -f plain features/example_1002.feature" + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 0 skipped + 2 steps passed, 0 failed, 0 skipped + """ + And the command output should not contain "NotImplementedError" + + + Scenario: SOLUTION 2: Use a placeholder type -- AnyText + Given a file named "features/steps/steps.py" with: + """ + # -- FILE: features/steps/steps.py + from behave import step, register_type + import parse + + @parse.with_pattern(r".*") + def parse_any_text(text): + # -- SUPPORTS: AnyText including EMPTY string. + return text + + register_type(AnyText=parse_any_text) + + @step(u'I meet with "{name:AnyText}"') + def step_meet_with_person(ctx, name): + ctx.other_person = name + """ + When I run "behave -f plain features/example_1002.feature" + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 0 skipped + 2 steps passed, 0 failed, 0 skipped + """ + And the command output should not contain "NotImplementedError" + + + Scenario: SOLUTION 3: Use a placeholder type -- Unquoted_or_Empty + Given a file named "features/steps/steps.py" with: + """ + # -- FILE: features/steps/steps.py + from behave import step, register_type + import parse + + @parse.with_pattern(r'[^"]*') + def parse_unquoted_or_empty_text(text): + return text + + register_type(Unquoted_or_Empty=parse_unquoted_or_empty_text) + + @step(u'I meet with "{name:Unquoted_or_Empty}"') + def step_meet_with_person(ctx, name): + # -- SUPPORTS: Unquoted text including EMPTY string + ctx.other_person = name + """ + When I run "behave -f plain features/example_1002.feature" + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 0 skipped + 2 steps passed, 0 failed, 0 skipped + """ + And the command output should not contain "NotImplementedError" + + + Scenario: SOLUTION 4: Use a placeholder type -- OptionalUnquoted + Given a file named "features/steps/steps.py" with: + """ + # -- FILE: features/steps/steps.py + # USE: cfparse with cardinality-field support for: Optional + from behave import step, register_type, use_step_matcher + import parse + + @parse.with_pattern(r'[^"]+') + def parse_unquoted(text): + # -- SUPPORTS: Non-empty unquoted-text + return text + + register_type(Unquoted=parse_unquoted) + use_step_matcher("cfparse") # -- SUPPORT FOR: OptionalUnquoted + + @step(u'I meet with "{name:Unquoted?}"') + def step_meet_with_person(ctx, name): + ctx.other_person = name + """ + When I run "behave -f plain features/example_1002.feature" + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 0 skipped + 2 steps passed, 0 failed, 0 skipped + """ + And the command output should not contain "NotImplementedError" + + + Scenario: SOLUTION 5: Use a placeholder type -- OptionalWord + Given a file named "features/steps/steps.py" with: + """ + # -- FILE: features/steps/steps.py + # USE: cfparse (with cardinality-field support for: Optional) + from behave import step, register_type, use_step_matcher + import parse + + @parse.with_pattern(r'[A-Za-z0-9_\-\.]+') + def parse_word(text): + # -- SUPPORTS: Word but not an EMPTY string + return text + + register_type(Word=parse_word) + use_step_matcher("cfparse") # -- NEEDED FOR: Optional + + @step(u'I meet with "{name:Word?}"') + def step_meet_with_person(ctx, name): + ctx.other_person = name + """ + When I run "behave -f plain features/example_1002.feature" + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 0 skipped + 2 steps passed, 0 failed, 0 skipped + """ + And the command output should not contain "NotImplementedError" From 01e47939d97d6fc60ab9c997e9aca640bf93a233 Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 18 May 2023 23:11:14 +0200 Subject: [PATCH 161/240] FIX: setuptools warning with dash-separated params * SCOPE: setup,cfg parameters (like: update-dir instead of: upload_dir) --- setup.cfg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.cfg b/setup.cfg index d8fc33dc2..b61e71fcc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,7 +11,7 @@ formats = gztar universal = true [upload_docs] -upload-dir = build/docs/html +upload_dir = build/docs/html [behave_test] format = progress @@ -19,8 +19,8 @@ tags = -@xfail args = features tools/test-features issue.features [build_sphinx] -source-dir = docs/ -build-dir = build/docs +source_dir = docs/ +build_dir = build/docs builder = html all_files = true From 1e7b6dce0a879ca52bfe56455dedddf2782cba68 Mon Sep 17 00:00:00 2001 From: jenisys Date: Fri, 19 May 2023 09:35:19 +0200 Subject: [PATCH 162/240] CI CodeQL: Update actions/checkout to v3 (wa: v2) --- .github/workflows/codeql-analysis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 6fb4e077c..7cc64f10d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL From 07880ecc8630fd10393a955c6b345788a95a4ff7 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 21 May 2023 12:43:12 +0200 Subject: [PATCH 163/240] behave4cmd0: Modularize steps into multiple modules REFACTOR: command_steps into * command_steps (keep: command related steps only) * environment_steps * filesystem_steps * workdir_steps --- behave4cmd0/__all_steps__.py | 3 + behave4cmd0/command_shell.py | 0 behave4cmd0/command_shell_proc.py | 0 behave4cmd0/command_steps.py | 386 +----------------- behave4cmd0/environment_steps.py | 44 ++ behave4cmd0/filesystem_steps.py | 236 +++++++++++ behave4cmd0/log/steps.py | 11 +- behave4cmd0/setup_command_shell.py | 0 behave4cmd0/step_util.py | 71 ++++ behave4cmd0/workdir_steps.py | 59 +++ features/steps/use_steplib_behave4cmd.py | 10 +- .../steps/use_steplib_behave4cmd.py | 5 + 12 files changed, 445 insertions(+), 380 deletions(-) mode change 100755 => 100644 behave4cmd0/command_shell.py mode change 100755 => 100644 behave4cmd0/command_shell_proc.py create mode 100644 behave4cmd0/environment_steps.py create mode 100644 behave4cmd0/filesystem_steps.py mode change 100755 => 100644 behave4cmd0/setup_command_shell.py create mode 100644 behave4cmd0/step_util.py create mode 100644 behave4cmd0/workdir_steps.py diff --git a/behave4cmd0/__all_steps__.py b/behave4cmd0/__all_steps__.py index 270d04168..7931aa096 100644 --- a/behave4cmd0/__all_steps__.py +++ b/behave4cmd0/__all_steps__.py @@ -10,3 +10,6 @@ import behave4cmd0.command_steps import behave4cmd0.note_steps import behave4cmd0.log.steps +import behave4cmd0.environment_steps +import behave4cmd0.filesystem_steps +import behave4cmd0.workdir_steps diff --git a/behave4cmd0/command_shell.py b/behave4cmd0/command_shell.py old mode 100755 new mode 100644 diff --git a/behave4cmd0/command_shell_proc.py b/behave4cmd0/command_shell_proc.py old mode 100755 new mode 100644 diff --git a/behave4cmd0/command_steps.py b/behave4cmd0/command_steps.py index f3a3797bb..7a01e577a 100644 --- a/behave4cmd0/command_steps.py +++ b/behave4cmd0/command_steps.py @@ -12,17 +12,13 @@ from __future__ import absolute_import, print_function -import codecs -import contextlib -import difflib -import os -import shutil -from behave import given, when, then, step, matchers # pylint: disable=no-name-in-module +from behave import when, then, matchers # pylint: disable=no-name-in-module +from behave4cmd0 import command_shell, command_util, textutil +from behave4cmd0.step_util import (DEBUG, + on_assert_failed_print_details, normalize_text_with_placeholders) from hamcrest import assert_that, equal_to, is_not -from behave4cmd0 import command_shell, command_util, pathutil, textutil -from behave4cmd0.pathutil import posixpath_normpath -from behave4cmd0.command_shell_proc import \ - TextProcessor, BehaveWinCommandOutputProcessor + + # NOT-USED: from hamcrest import contains_string @@ -30,145 +26,6 @@ # INIT: # ----------------------------------------------------------------------------- matchers.register_type(int=int) -DEBUG = False -file_contents_normalizer = None -if BehaveWinCommandOutputProcessor.enabled: - file_contents_normalizer = TextProcessor(BehaveWinCommandOutputProcessor()) - - -# ----------------------------------------------------------------------------- -# UTILITIES: -# ----------------------------------------------------------------------------- -def print_differences(actual, expected): - # diff = difflib.unified_diff(expected.splitlines(), actual.splitlines(), - # "expected", "actual") - diff = difflib.ndiff(expected.splitlines(), actual.splitlines()) - diff_text = u"\n".join(diff) - print(u"DIFF (+ ACTUAL, - EXPECTED):\n{0}\n".format(diff_text)) - if DEBUG: - print(u"expected:\n{0}\n".format(expected)) - print(u"actual:\n{0}\n".format(actual)) - - -@contextlib.contextmanager -def on_assert_failed_print_details(actual, expected): - """ - Print text details in case of assertation failed errors. - - .. sourcecode:: python - - with on_assert_failed_print_details(actual_text, expected_text): - assert actual == expected - """ - try: - yield - except AssertionError: - print_differences(actual, expected) - raise - -@contextlib.contextmanager -def on_error_print_details(actual, expected): - """ - Print text details in case of assertation failed errors. - - .. sourcecode:: python - - with on_error_print_details(actual_text, expected_text): - ... # Do something - """ - try: - yield - except Exception: - print_differences(actual, expected) - raise - - -def is_encoding_valid(encoding): - try: - return bool(codecs.lookup(encoding)) - except LookupError: - return False - - -# ----------------------------------------------------------------------------- -# STEPS: WORKING DIR -# ----------------------------------------------------------------------------- -@given(u'a new working directory') -def step_a_new_working_directory(context): - """Creates a new, empty working directory.""" - command_util.ensure_context_attribute_exists(context, "workdir", None) - # MAYBE: command_util.ensure_workdir_not_exists(context) - command_util.ensure_workdir_exists(context) - # OOPS: - shutil.rmtree(context.workdir, ignore_errors=True) - command_util.ensure_workdir_exists(context) - - -@given(u'I use the current directory as working directory') -def step_use_curdir_as_working_directory(context): - """Uses the current directory as working directory""" - context.workdir = os.path.abspath(".") - command_util.ensure_workdir_exists(context) - - -@step(u'I use the directory "{directory}" as working directory') -def step_use_directory_as_working_directory(context, directory): - """Uses the directory as new working directory""" - command_util.ensure_context_attribute_exists(context, "workdir", None) - current_workdir = context.workdir - if not current_workdir: - current_workdir = os.getcwd() - - if not os.path.isabs(directory): - new_workdir = os.path.join(current_workdir, directory) - exists_relto_current_dir = os.path.isdir(directory) - exists_relto_current_workdir = os.path.isdir(new_workdir) - if exists_relto_current_workdir or not exists_relto_current_dir: - # -- PREFER: Relative to current workdir - workdir = new_workdir - else: - assert exists_relto_current_workdir - workdir = directory - workdir = os.path.abspath(workdir) - - context.workdir = workdir - command_util.ensure_workdir_exists(context) - - -# ----------------------------------------------------------------------------- -# STEPS: Create files with contents -# ----------------------------------------------------------------------------- -@given(u'a file named "{filename}" and encoding="{encoding}" with') -def step_a_file_named_filename_and_encoding_with(context, filename, encoding): - """Creates a textual file with the content provided as docstring.""" - assert context.text is not None, "ENSURE: multiline text is provided." - assert not os.path.isabs(filename) - assert is_encoding_valid(encoding), "INVALID: encoding=%s;" % encoding - command_util.ensure_workdir_exists(context) - filename2 = os.path.join(context.workdir, filename) - pathutil.create_textfile_with_contents(filename2, context.text, encoding) - - -@given(u'a file named "{filename}" with') -def step_a_file_named_filename_with(context, filename): - """Creates a textual file with the content provided as docstring.""" - step_a_file_named_filename_and_encoding_with(context, filename, "UTF-8") - - # -- SPECIAL CASE: For usage with behave steps. - if filename.endswith(".feature"): - command_util.ensure_context_attribute_exists(context, "features", []) - context.features.append(filename) - - -@given(u'an empty file named "{filename}"') -def step_an_empty_file_named_filename(context, filename): - """ - Creates an empty file. - """ - assert not os.path.isabs(filename) - command_util.ensure_workdir_exists(context) - filename2 = os.path.join(context.workdir, filename) - pathutil.create_textfile_with_contents(filename2, "") # ----------------------------------------------------------------------------- @@ -272,17 +129,14 @@ def step_command_output_should_contain_text(context, text): ... Then the command output should contain "TEXT" ''' - expected_text = text - if "{__WORKDIR__}" in expected_text or "{__CWD__}" in expected_text: - expected_text = textutil.template_substitute(text, - __WORKDIR__ = posixpath_normpath(context.workdir), - __CWD__ = posixpath_normpath(os.getcwd()) - ) + expected_text = normalize_text_with_placeholders(context, text) actual_output = context.command_result.output with on_assert_failed_print_details(actual_output, expected_text): textutil.assert_normtext_should_contain(actual_output, expected_text) + + @then(u'the command output should not contain "{text}"') def step_command_output_should_not_contain_text(context, text): ''' @@ -290,12 +144,7 @@ def step_command_output_should_not_contain_text(context, text): ... then the command output should not contain "TEXT" ''' - expected_text = text - if "{__WORKDIR__}" in text or "{__CWD__}" in text: - expected_text = textutil.template_substitute(text, - __WORKDIR__ = posixpath_normpath(context.workdir), - __CWD__ = posixpath_normpath(os.getcwd()) - ) + expected_text = normalize_text_with_placeholders(context, text) actual_output = context.command_result.output with on_assert_failed_print_details(actual_output, expected_text): textutil.assert_normtext_should_not_contain(actual_output, expected_text) @@ -309,12 +158,7 @@ def step_command_output_should_contain_text_multiple_times(context, text, count) Then the command output should contain "TEXT" 3 times ''' assert count >= 0 - expected_text = text - if "{__WORKDIR__}" in expected_text or "{__CWD__}" in expected_text: - expected_text = textutil.template_substitute(text, - __WORKDIR__ = posixpath_normpath(context.workdir), - __CWD__ = posixpath_normpath(os.getcwd()) - ) + expected_text = normalize_text_with_placeholders(context, text) actual_output = context.command_result.output expected_text_part = expected_text with on_assert_failed_print_details(actual_output, expected_text_part): @@ -334,24 +178,14 @@ def step_command_output_should_contain_exactly_text(context, text): When I run "echo Hello" Then the command output should contain "Hello" """ - expected_text = text - if "{__WORKDIR__}" in text or "{__CWD__}" in text: - expected_text = textutil.template_substitute(text, - __WORKDIR__ = posixpath_normpath(context.workdir), - __CWD__ = posixpath_normpath(os.getcwd()) - ) + expected_text = normalize_text_with_placeholders(context, text) actual_output = context.command_result.output textutil.assert_text_should_contain_exactly(actual_output, expected_text) @then(u'the command output should not contain exactly "{text}"') def step_command_output_should_not_contain_exactly_text(context, text): - expected_text = text - if "{__WORKDIR__}" in text or "{__CWD__}" in text: - expected_text = textutil.template_substitute(text, - __WORKDIR__ = posixpath_normpath(context.workdir), - __CWD__ = posixpath_normpath(os.getcwd()) - ) + expected_text = normalize_text_with_placeholders(context, text) actual_output = context.command_result.output textutil.assert_text_should_not_contain_exactly(actual_output, expected_text) @@ -459,197 +293,3 @@ def step_command_output_should_not_match_with_multiline_text(context): assert context.text is not None, "ENSURE: multiline text is provided." pattern = context.text step_command_output_should_not_match_pattern(context, pattern) - - -# ----------------------------------------------------------------------------- -# STEPS FOR: Directories -# ----------------------------------------------------------------------------- -@step(u'I remove the directory "{directory}"') -def step_remove_directory(context, directory): - path_ = directory - if not os.path.isabs(directory): - path_ = os.path.join(context.workdir, os.path.normpath(directory)) - if os.path.isdir(path_): - shutil.rmtree(path_, ignore_errors=True) - assert_that(not os.path.isdir(path_)) - - -@given(u'I ensure that the directory "{directory}" exists') -def step_given_ensure_that_the_directory_exists(context, directory): - path_ = directory - if not os.path.isabs(directory): - path_ = os.path.join(context.workdir, os.path.normpath(directory)) - if not os.path.isdir(path_): - os.makedirs(path_) - assert_that(os.path.isdir(path_)) - - -@given(u'I ensure that the directory "{directory}" does not exist') -def step_given_the_directory_should_not_exist(context, directory): - step_remove_directory(context, directory) - - -@given(u'a directory named "{path}"') -def step_directory_named_dirname(context, path): - assert context.workdir, "REQUIRE: context.workdir" - path_ = os.path.join(context.workdir, os.path.normpath(path)) - if not os.path.exists(path_): - os.makedirs(path_) - assert os.path.isdir(path_) - - -@then(u'the directory "{directory}" should exist') -def step_the_directory_should_exist(context, directory): - path_ = directory - if not os.path.isabs(directory): - path_ = os.path.join(context.workdir, os.path.normpath(directory)) - assert_that(os.path.isdir(path_)) - - -@then(u'the directory "{directory}" should not exist') -def step_the_directory_should_not_exist(context, directory): - path_ = directory - if not os.path.isabs(directory): - path_ = os.path.join(context.workdir, os.path.normpath(directory)) - assert_that(not os.path.isdir(path_)) - - -@step(u'the directory "{directory}" exists') -def step_directory_exists(context, directory): - """ - Verifies that a directory exists. - - .. code-block:: gherkin - - Given the directory "abc.txt" exists - When the directory "abc.txt" exists - """ - step_the_directory_should_exist(context, directory) - - -@step(u'the directory "{directory}" does not exist') -def step_directory_named_does_not_exist(context, directory): - """ - Verifies that a directory does not exist. - - .. code-block:: gherkin - - Given the directory "abc/" does not exist - When the directory "abc/" does not exist - """ - step_the_directory_should_not_exist(context, directory) - - -# ----------------------------------------------------------------------------- -# FILE STEPS: -# ----------------------------------------------------------------------------- -@step(u'a file named "{filename}" exists') -def step_file_named_filename_exists(context, filename): - """ - Verifies that a file with this filename exists. - - .. code-block:: gherkin - - Given a file named "abc.txt" exists - When a file named "abc.txt" exists - """ - step_file_named_filename_should_exist(context, filename) - - -@step(u'a file named "{filename}" does not exist') -@step(u'the file named "{filename}" does not exist') -def step_file_named_filename_does_not_exist(context, filename): - """ - Verifies that a file with this filename does not exist. - - .. code-block:: gherkin - - Given a file named "abc.txt" does not exist - When a file named "abc.txt" does not exist - """ - step_file_named_filename_should_not_exist(context, filename) - - -@then(u'a file named "{filename}" should exist') -def step_file_named_filename_should_exist(context, filename): - command_util.ensure_workdir_exists(context) - filename_ = pathutil.realpath_with_context(filename, context) - assert_that(os.path.exists(filename_) and os.path.isfile(filename_)) - - -@then(u'a file named "{filename}" should not exist') -def step_file_named_filename_should_not_exist(context, filename): - command_util.ensure_workdir_exists(context) - filename_ = pathutil.realpath_with_context(filename, context) - assert_that(not os.path.exists(filename_)) - - -@step(u'I remove the file "{filename}"') -def step_remove_file(context, filename): - path_ = filename - if not os.path.isabs(filename): - path_ = os.path.join(context.workdir, os.path.normpath(filename)) - if os.path.exists(path_) and os.path.isfile(path_): - os.remove(path_) - assert_that(not os.path.isfile(path_)) - - -# ----------------------------------------------------------------------------- -# STEPS FOR FILE CONTENTS: -# ----------------------------------------------------------------------------- -@then(u'the file "{filename}" should contain "{text}"') -def step_file_should_contain_text(context, filename, text): - expected_text = text - if "{__WORKDIR__}" in text or "{__CWD__}" in text: - expected_text = textutil.template_substitute(text, - __WORKDIR__ = posixpath_normpath(context.workdir), - __CWD__ = posixpath_normpath(os.getcwd()) - ) - file_contents = pathutil.read_file_contents(filename, context=context) - file_contents = file_contents.rstrip() - if file_contents_normalizer: - # -- HACK: Inject TextProcessor as text normalizer - file_contents = file_contents_normalizer(file_contents) - with on_assert_failed_print_details(file_contents, expected_text): - textutil.assert_normtext_should_contain(file_contents, expected_text) - - -@then(u'the file "{filename}" should not contain "{text}"') -def step_file_should_not_contain_text(context, filename, text): - file_contents = pathutil.read_file_contents(filename, context=context) - file_contents = file_contents.rstrip() - textutil.assert_normtext_should_not_contain(file_contents, text) - # DISABLED: assert_that(file_contents, is_not(contains_string(text))) - - -@then(u'the file "{filename}" should contain') -def step_file_should_contain_multiline_text(context, filename): - assert context.text is not None, "REQUIRE: multiline text" - step_file_should_contain_text(context, filename, context.text) - - -@then(u'the file "{filename}" should not contain') -def step_file_should_not_contain_multiline_text(context, filename): - assert context.text is not None, "REQUIRE: multiline text" - step_file_should_not_contain_text(context, filename, context.text) - - -# ----------------------------------------------------------------------------- -# ENVIRONMENT VARIABLES -# ----------------------------------------------------------------------------- -@step(u'I set the environment variable "{env_name}" to "{env_value}"') -def step_I_set_the_environment_variable_to(context, env_name, env_value): - if not hasattr(context, "environ"): - context.environ = {} - context.environ[env_name] = env_value - os.environ[env_name] = env_value - - -@step(u'I remove the environment variable "{env_name}"') -def step_I_remove_the_environment_variable(context, env_name): - if not hasattr(context, "environ"): - context.environ = {} - context.environ[env_name] = "" - os.environ[env_name] = "" - del context.environ[env_name] - del os.environ[env_name] diff --git a/behave4cmd0/environment_steps.py b/behave4cmd0/environment_steps.py new file mode 100644 index 000000000..23900d1ec --- /dev/null +++ b/behave4cmd0/environment_steps.py @@ -0,0 +1,44 @@ +# -*- coding: UTF-8 +""" +Behave steps for environment variables (process environment). +""" + +from __future__ import absolute_import, print_function +import os +from behave import given, when, then, step +from hamcrest import assert_that, is_, is_not + + +# ----------------------------------------------------------------------------- +# ENVIRONMENT VARIABLES +# ----------------------------------------------------------------------------- +@step(u'I set the environment variable "{env_name}" to "{env_value}"') +def step_I_set_the_environment_variable_to(context, env_name, env_value): + if not hasattr(context, "environ"): + context.environ = {} + context.environ[env_name] = env_value + os.environ[env_name] = env_value + + +@step(u'I remove the environment variable "{env_name}"') +def step_I_remove_the_environment_variable(context, env_name): + if not hasattr(context, "environ"): + context.environ = {} + context.environ[env_name] = "" + os.environ[env_name] = "" + del context.environ[env_name] + del os.environ[env_name] + + +@given(u'the environment variable "{env_name}" exists') +@then(u'the environment variable "{env_name}" exists') +def step_the_environment_variable_exists(context, env_name): + env_variable_value = os.environ.get(env_name) + assert_that(env_variable_value, is_not(None)) + + +@given(u'the environment variable "{env_name}" does not exist') +@then(u'the environment variable "{env_name}" does not exist') +def step_I_set_the_environment_variable_to(context, env_name): + env_variable_value = os.environ.get(env_name) + assert_that(env_variable_value, is_(None)) diff --git a/behave4cmd0/filesystem_steps.py b/behave4cmd0/filesystem_steps.py new file mode 100644 index 000000000..311104160 --- /dev/null +++ b/behave4cmd0/filesystem_steps.py @@ -0,0 +1,236 @@ + +from __future__ import absolute_import, print_function +import codecs +import os +import os.path +import shutil +from behave import given, when, then, step +from behave4cmd0 import command_util, pathutil, textutil +from behave4cmd0.step_util import ( + on_assert_failed_print_details, normalize_text_with_placeholders) +from behave4cmd0.command_shell_proc import \ + TextProcessor, BehaveWinCommandOutputProcessor +from behave4cmd0.pathutil import posixpath_normpath +from hamcrest import assert_that + + +file_contents_normalizer = None +if BehaveWinCommandOutputProcessor.enabled: + file_contents_normalizer = TextProcessor(BehaveWinCommandOutputProcessor()) + + +def is_encoding_valid(encoding): + try: + return bool(codecs.lookup(encoding)) + except LookupError: + return False + + +# ----------------------------------------------------------------------------- +# STEPS FOR: Directories +# ----------------------------------------------------------------------------- +@step(u'I remove the directory "{directory}"') +def step_remove_directory(context, directory): + path_ = directory + if not os.path.isabs(directory): + path_ = os.path.join(context.workdir, os.path.normpath(directory)) + if os.path.isdir(path_): + shutil.rmtree(path_, ignore_errors=True) + assert_that(not os.path.isdir(path_)) + + +@given(u'I ensure that the directory "{directory}" exists') +def step_given_ensure_that_the_directory_exists(context, directory): + path_ = directory + if not os.path.isabs(directory): + path_ = os.path.join(context.workdir, os.path.normpath(directory)) + if not os.path.isdir(path_): + os.makedirs(path_) + assert_that(os.path.isdir(path_)) + + +@given(u'I ensure that the directory "{directory}" does not exist') +def step_given_the_directory_should_not_exist(context, directory): + step_remove_directory(context, directory) + + +@given(u'a directory named "{path}"') +def step_directory_named_dirname(context, path): + assert context.workdir, "REQUIRE: context.workdir" + path_ = os.path.join(context.workdir, os.path.normpath(path)) + if not os.path.exists(path_): + os.makedirs(path_) + assert os.path.isdir(path_) + + +@given(u'the directory "{directory}" should exist') +@then(u'the directory "{directory}" should exist') +def step_the_directory_should_exist(context, directory): + path_ = directory + if not os.path.isabs(directory): + path_ = os.path.join(context.workdir, os.path.normpath(directory)) + assert_that(os.path.isdir(path_)) + + +@given(u'the directory "{directory}" should not exist') +@then(u'the directory "{directory}" should not exist') +def step_the_directory_should_not_exist(context, directory): + path_ = directory + if not os.path.isabs(directory): + path_ = os.path.join(context.workdir, os.path.normpath(directory)) + assert_that(not os.path.isdir(path_)) + + +@step(u'the directory "{directory}" exists') +def step_directory_exists(context, directory): + """ + Verifies that a directory exists. + + .. code-block:: gherkin + + Given the directory "abc.txt" exists + When the directory "abc.txt" exists + """ + step_the_directory_should_exist(context, directory) + + +@step(u'the directory "{directory}" does not exist') +def step_directory_named_does_not_exist(context, directory): + """ + Verifies that a directory does not exist. + + .. code-block:: gherkin + + Given the directory "abc/" does not exist + When the directory "abc/" does not exist + """ + step_the_directory_should_not_exist(context, directory) + + +# ----------------------------------------------------------------------------- +# FILE STEPS: +# ----------------------------------------------------------------------------- +@step(u'a file named "{filename}" exists') +def step_file_named_filename_exists(context, filename): + """ + Verifies that a file with this filename exists. + + .. code-block:: gherkin + + Given a file named "abc.txt" exists + When a file named "abc.txt" exists + """ + step_file_named_filename_should_exist(context, filename) + + +@step(u'a file named "{filename}" does not exist') +@step(u'the file named "{filename}" does not exist') +def step_file_named_filename_does_not_exist(context, filename): + """ + Verifies that a file with this filename does not exist. + + .. code-block:: gherkin + + Given a file named "abc.txt" does not exist + When a file named "abc.txt" does not exist + """ + step_file_named_filename_should_not_exist(context, filename) + + +@then(u'a file named "{filename}" should exist') +def step_file_named_filename_should_exist(context, filename): + command_util.ensure_workdir_exists(context) + filename_ = pathutil.realpath_with_context(filename, context) + assert_that(os.path.exists(filename_) and os.path.isfile(filename_)) + + +@then(u'a file named "{filename}" should not exist') +def step_file_named_filename_should_not_exist(context, filename): + command_util.ensure_workdir_exists(context) + filename_ = pathutil.realpath_with_context(filename, context) + assert_that(not os.path.exists(filename_)) + + +# ----------------------------------------------------------------------------- +# STEPS FOR EXISTING FILES WITH FILE CONTENTS: +# ----------------------------------------------------------------------------- +@then(u'the file "{filename}" should contain "{text}"') +def step_file_should_contain_text(context, filename, text): + expected_text = normalize_text_with_placeholders(context, text) + file_contents = pathutil.read_file_contents(filename, context=context) + file_contents = file_contents.rstrip() + if file_contents_normalizer: + # -- HACK: Inject TextProcessor as text normalizer + file_contents = file_contents_normalizer(file_contents) + with on_assert_failed_print_details(file_contents, expected_text): + textutil.assert_normtext_should_contain(file_contents, expected_text) + + +@then(u'the file "{filename}" should not contain "{text}"') +def step_file_should_not_contain_text(context, filename, text): + expected_text = normalize_text_with_placeholders(context, text) + file_contents = pathutil.read_file_contents(filename, context=context) + file_contents = file_contents.rstrip() + + with on_assert_failed_print_details(file_contents, expected_text): + textutil.assert_normtext_should_not_contain(file_contents, expected_text) + # DISABLED: assert_that(file_contents, is_not(contains_string(text))) + + +@then(u'the file "{filename}" should contain') +def step_file_should_contain_multiline_text(context, filename): + assert context.text is not None, "REQUIRE: multiline text" + step_file_should_contain_text(context, filename, context.text) + + +@then(u'the file "{filename}" should not contain') +def step_file_should_not_contain_multiline_text(context, filename): + assert context.text is not None, "REQUIRE: multiline text" + step_file_should_not_contain_text(context, filename, context.text) + + +# ----------------------------------------------------------------------------- +# STEPS FOR CREATING FILES WITH FILE CONTENTS: +# ----------------------------------------------------------------------------- +@given(u'a file named "{filename}" and encoding="{encoding}" with') +def step_a_file_named_filename_and_encoding_with(context, filename, encoding): + """Creates a textual file with the content provided as docstring.""" + assert context.text is not None, "ENSURE: multiline text is provided." + assert not os.path.isabs(filename) + assert is_encoding_valid(encoding), "INVALID: encoding=%s;" % encoding + command_util.ensure_workdir_exists(context) + filename2 = os.path.join(context.workdir, filename) + pathutil.create_textfile_with_contents(filename2, context.text, encoding) + + +@given(u'a file named "{filename}" with') +def step_a_file_named_filename_with(context, filename): + """Creates a textual file with the content provided as docstring.""" + step_a_file_named_filename_and_encoding_with(context, filename, "UTF-8") + + # -- SPECIAL CASE: For usage with behave steps. + if filename.endswith(".feature"): + command_util.ensure_context_attribute_exists(context, "features", []) + context.features.append(filename) + + +@given(u'an empty file named "{filename}"') +def step_an_empty_file_named_filename(context, filename): + """ + Creates an empty file. + """ + assert not os.path.isabs(filename) + command_util.ensure_workdir_exists(context) + filename2 = os.path.join(context.workdir, filename) + pathutil.create_textfile_with_contents(filename2, "") + + +@step(u'I remove the file "{filename}"') +@step(u'I remove the file named "{filename}"') +def step_remove_file(context, filename): + path_ = filename + if not os.path.isabs(filename): + path_ = os.path.join(context.workdir, os.path.normpath(filename)) + if os.path.exists(path_) and os.path.isfile(path_): + os.remove(path_) + assert_that(not os.path.isfile(path_)) diff --git a/behave4cmd0/log/steps.py b/behave4cmd0/log/steps.py index cec2cab31..2ec94fa1c 100644 --- a/behave4cmd0/log/steps.py +++ b/behave4cmd0/log/steps.py @@ -57,14 +57,15 @@ | bar | CURRENT | xxx | """ -from __future__ import absolute_import +from __future__ import absolute_import, print_function +import logging from behave import given, when, then, step -from behave4cmd0.command_steps import \ - step_file_should_contain_multiline_text, \ - step_file_should_not_contain_multiline_text from behave.configuration import LogLevel from behave.log_capture import LoggingCapture -import logging +from behave4cmd0.filesystem_steps import ( + step_file_should_contain_multiline_text, + step_file_should_not_contain_multiline_text) + # ----------------------------------------------------------------------------- # STEP UTILS: diff --git a/behave4cmd0/setup_command_shell.py b/behave4cmd0/setup_command_shell.py old mode 100755 new mode 100644 diff --git a/behave4cmd0/step_util.py b/behave4cmd0/step_util.py new file mode 100644 index 000000000..75e06e7aa --- /dev/null +++ b/behave4cmd0/step_util.py @@ -0,0 +1,71 @@ +from __future__ import absolute_import, print_function +import contextlib +import difflib +import os + +from behave4cmd0 import textutil +from behave4cmd0.pathutil import posixpath_normpath + + +# ----------------------------------------------------------------------------- +# CONSTANTS: +# ----------------------------------------------------------------------------- +DEBUG = False + + +# ----------------------------------------------------------------------------- +# UTILITY FUNCTIONS: +# ----------------------------------------------------------------------------- +def print_differences(actual, expected): + # diff = difflib.unified_diff(expected.splitlines(), actual.splitlines(), + # "expected", "actual") + diff = difflib.ndiff(expected.splitlines(), actual.splitlines()) + diff_text = u"\n".join(diff) + print(u"DIFF (+ ACTUAL, - EXPECTED):\n{0}\n".format(diff_text)) + if DEBUG: + print(u"expected:\n{0}\n".format(expected)) + print(u"actual:\n{0}\n".format(actual)) + + +@contextlib.contextmanager +def on_assert_failed_print_details(actual, expected): + """ + Print text details in case of assertation failed errors. + + .. sourcecode:: python + + with on_assert_failed_print_details(actual_text, expected_text): + assert actual == expected + """ + try: + yield + except AssertionError: + print_differences(actual, expected) + raise + + +@contextlib.contextmanager +def on_error_print_details(actual, expected): + """ + Print text details in case of assertation failed errors. + + .. sourcecode:: python + + with on_error_print_details(actual_text, expected_text): + ... # Do something + """ + try: + yield + except Exception: + print_differences(actual, expected) + raise + + +def normalize_text_with_placeholders(ctx, text): + expected_text = text + if "{__WORKDIR__}" in expected_text or "{__CWD__}" in expected_text: + expected_text = textutil.template_substitute(text, + __WORKDIR__=posixpath_normpath(ctx.workdir), + __CWD__=posixpath_normpath(os.getcwd()) + ) + return expected_text diff --git a/behave4cmd0/workdir_steps.py b/behave4cmd0/workdir_steps.py new file mode 100644 index 000000000..7358d5364 --- /dev/null +++ b/behave4cmd0/workdir_steps.py @@ -0,0 +1,59 @@ +""" +Provides :mod:`behave` steps to provide and use "working directory" +as base directory to: + +* Create files +* Create directories +""" + +from __future__ import absolute_import, print_function +import os +import shutil + +from behave import given, step +from behave4cmd0 import command_util + + +# ----------------------------------------------------------------------------- +# STEPS: WORKING DIR +# ----------------------------------------------------------------------------- +@given(u'a new working directory') +def step_a_new_working_directory(context): + """Creates a new, empty working directory.""" + command_util.ensure_context_attribute_exists(context, "workdir", None) + # MAYBE: command_util.ensure_workdir_not_exists(context) + command_util.ensure_workdir_exists(context) + # OOPS: + shutil.rmtree(context.workdir, ignore_errors=True) + command_util.ensure_workdir_exists(context) + + +@given(u'I use the current directory as working directory') +def step_use_curdir_as_working_directory(context): + """Uses the current directory as working directory""" + context.workdir = os.path.abspath(".") + command_util.ensure_workdir_exists(context) + + +@step(u'I use the directory "{directory}" as working directory') +def step_use_directory_as_working_directory(context, directory): + """Uses the directory as new working directory""" + command_util.ensure_context_attribute_exists(context, "workdir", None) + current_workdir = context.workdir + if not current_workdir: + current_workdir = os.getcwd() + + if not os.path.isabs(directory): + new_workdir = os.path.join(current_workdir, directory) + exists_relto_current_dir = os.path.isdir(directory) + exists_relto_current_workdir = os.path.isdir(new_workdir) + if exists_relto_current_workdir or not exists_relto_current_dir: + # -- PREFER: Relative to current workdir + workdir = new_workdir + else: + assert exists_relto_current_workdir + workdir = directory + workdir = os.path.abspath(workdir) + + context.workdir = workdir + command_util.ensure_workdir_exists(context) diff --git a/features/steps/use_steplib_behave4cmd.py b/features/steps/use_steplib_behave4cmd.py index 94d8766af..94aaab362 100644 --- a/features/steps/use_steplib_behave4cmd.py +++ b/features/steps/use_steplib_behave4cmd.py @@ -6,7 +6,13 @@ from __future__ import absolute_import # -- REGISTER-STEPS FROM STEP-LIBRARY: -import behave4cmd0.__all_steps__ -import behave4cmd0.passing_steps +# import behave4cmd0.__all_steps__ +import behave4cmd0.command_steps +import behave4cmd0.environment_steps +import behave4cmd0.filesystem_steps +import behave4cmd0.workdir_steps +import behave4cmd0.log.steps + import behave4cmd0.failing_steps +import behave4cmd0.passing_steps import behave4cmd0.note_steps diff --git a/issue.features/steps/use_steplib_behave4cmd.py b/issue.features/steps/use_steplib_behave4cmd.py index 98174b6ec..8f50a2954 100644 --- a/issue.features/steps/use_steplib_behave4cmd.py +++ b/issue.features/steps/use_steplib_behave4cmd.py @@ -8,6 +8,11 @@ # -- REGISTER-STEPS FROM STEP-LIBRARY: # import behave4cmd0.__all_steps__ import behave4cmd0.command_steps +import behave4cmd0.environment_steps +import behave4cmd0.filesystem_steps +import behave4cmd0.workdir_steps +import behave4cmd0.log.steps + import behave4cmd0.passing_steps import behave4cmd0.failing_steps import behave4cmd0.note_steps From 7388491d5bd2142920102917050bd37419c5e351 Mon Sep 17 00:00:00 2001 From: jenisys Date: Fri, 26 May 2023 14:10:24 +0200 Subject: [PATCH 164/240] ADD STEP-ALIASES TO: behave4cmd0.filesystem_steps Extend the existing @then steps by using step-aliases: * @given('a file named "{filename}" should exist') * @given('a file named "{filename}" should not exist') CLEANUP: * PEP-0582 was rejected. --- .envrc.use_pep0582.disabled | 29 ----------------------------- behave.ini | 1 + behave4cmd0/filesystem_steps.py | 2 ++ 3 files changed, 3 insertions(+), 29 deletions(-) delete mode 100644 .envrc.use_pep0582.disabled diff --git a/.envrc.use_pep0582.disabled b/.envrc.use_pep0582.disabled deleted file mode 100644 index a2e5a03a5..000000000 --- a/.envrc.use_pep0582.disabled +++ /dev/null @@ -1,29 +0,0 @@ -# =========================================================================== -# PROJECT ENVIRONMENT SETUP: .envrc.use_pep0582 -# =========================================================================== -# DESCRIPTION: -# Setup Python search path to use the PEP-0582 sub-directory tree. -# -# ENABLE/DISABLE THIS OPTIONAL PART: -# * TO ENABLE: Rename ".envrc.use_pep0582.disabled" to ".envrc.use_pep0582" -# * TO DISABLE: Rename ".envrc.use_pep0582" to ".envrc.use_pep0582.disabled" -# -# SEE ALSO: -# * https://direnv.net/ -# * https://peps.python.org/pep-0582/ Python local packages directory -# =========================================================================== - -if [ -z "${PYTHON_VERSION}" ]; then - # -- AUTO-DETECT: Default Python3 version - # EXAMPLE: export PYTHON_VERSION="3.9" - export PYTHON_VERSION=$(python3 -c "import sys; print('.'.join([str(x) for x in sys.version_info[:2]]))") -fi -echo "USE: PYTHON_VERSION=${PYTHON_VERSION}" - -# -- HINT: Support PEP-0582 Python local packages directory (supported by: pdm) -path_add PATH __pypackages__/${PYTHON_VERSION}/bin -path_add PYTHONPATH __pypackages__/${PYTHON_VERSION}/lib - -# -- SIMILAR-TO: -# export PATH="${HERE}/__pypackages__/${PYTHON_VERSION}/bin:${PATH}" -# export PYTHONPATH="${HERE}:${HERE}/__pypackages__/${PYTHON_VERSION}/lib:${PYTHONPATH}" diff --git a/behave.ini b/behave.ini index 30f7e5605..b6c6467e6 100644 --- a/behave.ini +++ b/behave.ini @@ -24,6 +24,7 @@ logging_level = INFO # -- ALLURE-FORMATTER REQUIRES: pip install allure-behave # brew install allure +# pip install allure-behave # ALLURE_REPORTS_DIR=allure.reports # behave -f allure -o $ALLURE_REPORTS_DIR ... # allure serve $ALLURE_REPORTS_DIR diff --git a/behave4cmd0/filesystem_steps.py b/behave4cmd0/filesystem_steps.py index 311104160..34e48939d 100644 --- a/behave4cmd0/filesystem_steps.py +++ b/behave4cmd0/filesystem_steps.py @@ -137,6 +137,7 @@ def step_file_named_filename_does_not_exist(context, filename): step_file_named_filename_should_not_exist(context, filename) +@given(u'a file named "{filename}" should exist') @then(u'a file named "{filename}" should exist') def step_file_named_filename_should_exist(context, filename): command_util.ensure_workdir_exists(context) @@ -144,6 +145,7 @@ def step_file_named_filename_should_exist(context, filename): assert_that(os.path.exists(filename_) and os.path.isfile(filename_)) +@given(u'a file named "{filename}" should not exist') @then(u'a file named "{filename}" should not exist') def step_file_named_filename_should_not_exist(context, filename): command_util.ensure_workdir_exists(context) From c6f58c6028eb0c974fcf2611d77de768d9c925e4 Mon Sep 17 00:00:00 2001 From: jenisys Date: Fri, 26 May 2023 14:32:25 +0200 Subject: [PATCH 165/240] CI WORKFLOW: tests * Tweak the push/pull_request events slightly by using paths * Add trigger:workflow_dispatch --- .github/workflows/tests.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9454228a9..26ea1817b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,9 +4,26 @@ name: tests on: + workflow_dispatch: push: + branches: [ "main", "release/**" ] + paths: + - "**/*.py" + - "**/*.feature" + - "py.requirements/**" + - "*.cfg" + - "*.ini" + - "*.toml" pull_request: - branches: [ main ] + types: [opened, reopened, review_requested] + branches: [ "main" ] + paths: + - "**/*.py" + - "**/*.feature" + - "py.requirements/**" + - "*.cfg" + - "*.ini" + - "*.toml" jobs: test: From c4f2b1cb07442bf77f48fdce38fb6b2219aef5f9 Mon Sep 17 00:00:00 2001 From: jenisys Date: Fri, 26 May 2023 14:47:38 +0200 Subject: [PATCH 166/240] CI WORKFLOW: tests-windows * Add path filter to push/pull_request events * Add workflow_dispatch_event --- .github/workflows/tests-windows.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index c309470ab..3e3acad65 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -4,9 +4,27 @@ name: tests-windows on: + workflow_dispatch: push: + branches: [ "main", "release/**" ] + paths: + - "**/*.py" + - "**/*.feature" + - "py.requirements/**" + - "*.cfg" + - "*.ini" + - "*.toml" pull_request: - branches: [ main ] + types: [opened, reopened, review_requested] + branches: [ "main" ] + paths: + - "**/*.py" + - "**/*.feature" + - "py.requirements/**" + - "*.cfg" + - "*.ini" + - "*.toml" + # -- TEST BALLOON: Fix encoding="cp1252" problems by using "UTF-8" env: From cd5f5d981cb8a49c1a5fd49a88713480362031aa Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 27 May 2023 18:01:21 +0200 Subject: [PATCH 167/240] CLEANUP: In feature files * Use "behave --no-color" instead of "behave -c" (short-option) --- features/exploratory_testing.with_table.feature | 2 +- features/runner.default_format.feature | 2 +- features/runner.multiple_formatters.feature | 2 +- features/runner.use_stage_implementations.feature | 8 ++++---- features/scenario_outline.improved.feature | 2 +- features/scenario_outline.parametrized.feature | 6 +++--- issue.features/issue0040.feature | 12 ++++++------ issue.features/issue0041.feature | 6 +++--- issue.features/issue0044.feature | 2 +- issue.features/issue0046.feature | 6 +++--- issue.features/issue0052.feature | 4 ++-- issue.features/issue0063.feature | 4 ++-- issue.features/issue0069.feature | 2 +- issue.features/issue0073.feature | 2 +- issue.features/issue0080.feature | 2 +- issue.features/issue0081.feature | 6 +++--- issue.features/issue0083.feature | 6 +++--- issue.features/issue0085.feature | 2 +- issue.features/issue0096.feature | 10 +++++----- issue.features/issue0112.feature | 4 ++-- issue.features/issue0231.feature | 4 ++-- 21 files changed, 47 insertions(+), 47 deletions(-) diff --git a/features/exploratory_testing.with_table.feature b/features/exploratory_testing.with_table.feature index 35852bc73..a957ddad3 100644 --- a/features/exploratory_testing.with_table.feature +++ b/features/exploratory_testing.with_table.feature @@ -7,7 +7,7 @@ Feature: Exploratory Testing with Tables and Table Annotations . HINT: Does not work with monochrome format in pretty formatter: . behave -f pretty --no-color ... - . behave -c ... + . behave --no-color ... @setup diff --git a/features/runner.default_format.feature b/features/runner.default_format.feature index 225d2b0e3..9e6414530 100644 --- a/features/runner.default_format.feature +++ b/features/runner.default_format.feature @@ -38,7 +38,7 @@ Feature: Default Formatter @no_configfile Scenario: Pretty formatter is used as default formatter if no other is defined Given a file named "behave.ini" does not exist - When I run "behave -c features/" + When I run "behave --no-color features/" Then it should pass with: """ 2 features passed, 0 failed, 0 skipped diff --git a/features/runner.multiple_formatters.feature b/features/runner.multiple_formatters.feature index 55cc5716e..b38c497f2 100644 --- a/features/runner.multiple_formatters.feature +++ b/features/runner.multiple_formatters.feature @@ -214,7 +214,7 @@ Feature: Multiple Formatter with different outputs outfiles = output/plain.out """ And I remove the directory "output" - When I run "behave -c -f pretty -o output/pretty.out -f progress -o output/progress.out features/" + When I run "behave --no-color -f pretty -o output/pretty.out -f progress -o output/progress.out features/" Then it should pass And the file "output/progress.out" should contain: """ diff --git a/features/runner.use_stage_implementations.feature b/features/runner.use_stage_implementations.feature index 5c290ce31..f3b0dd793 100644 --- a/features/runner.use_stage_implementations.feature +++ b/features/runner.use_stage_implementations.feature @@ -72,7 +72,7 @@ Feature: Use Alternate Step Implementations for Each Test Stage assert context.config.stage == "develop" assert context.use_develop_environment """ - When I run "behave -c --stage=develop features/example1.feature" + When I run "behave --no-color --stage=develop features/example1.feature" Then it should pass with: """ 1 feature passed, 0 failed, 0 skipped @@ -87,7 +87,7 @@ Feature: Use Alternate Step Implementations for Each Test Stage Scenario: Use default stage Given I remove the environment variable "BEHAVE_STAGE" - When I run "behave -c features/example1.feature" + When I run "behave --no-color features/example1.feature" Then it should pass with: """ 1 feature passed, 0 failed, 0 skipped @@ -102,7 +102,7 @@ Feature: Use Alternate Step Implementations for Each Test Stage Scenario: Use the BEHAVE_STAGE environment variable to define the test stage Given I set the environment variable "BEHAVE_STAGE" to "develop" - When I run "behave -c features/example1.feature" + When I run "behave --no-color features/example1.feature" Then it should pass with: """ 1 feature passed, 0 failed, 0 skipped @@ -119,7 +119,7 @@ Feature: Use Alternate Step Implementations for Each Test Stage Scenario: Using an unknown stage - When I run "behave -c --stage=unknown features/example1.feature" + When I run "behave --no-color --stage=unknown features/example1.feature" Then it should fail with: """ ConfigError: No unknown_steps directory diff --git a/features/scenario_outline.improved.feature b/features/scenario_outline.improved.feature index c837cc1fe..da1f6dd18 100644 --- a/features/scenario_outline.improved.feature +++ b/features/scenario_outline.improved.feature @@ -81,7 +81,7 @@ Feature: Scenario Outline -- Improvements """ Scenario: Unique File Locations in generated scenarios - When I run "behave -f pretty -c features/named_examples.feature" + When I run "behave -f pretty --no-color features/named_examples.feature" Then it should pass with: """ Scenario Outline: Named Examples -- @1.1 Alice # features/named_examples.feature:7 diff --git a/features/scenario_outline.parametrized.feature b/features/scenario_outline.parametrized.feature index 807769807..e10b5a8d7 100644 --- a/features/scenario_outline.parametrized.feature +++ b/features/scenario_outline.parametrized.feature @@ -3,7 +3,7 @@ Feature: Scenario Outline -- Parametrized Scenarios As a test writer I want to use the DRY principle when writing scenarios So that I am more productive and my work is less error-prone. - + . COMMENT: . A Scenario Outline is basically a parametrized Scenario template. . It is instantiated for each examples row with the corresponding data. @@ -348,7 +348,7 @@ Feature: Scenario Outline -- Parametrized Scenarios | 001 | Alice | | 002 | Bob | """ - When I run "behave -f pretty -c --no-timings features/parametrized_tags.feature" + When I run "behave -f pretty --no-color --no-timings features/parametrized_tags.feature" Then it should pass with: """ @foo @outline.e1 @outline.row.1.1 @outline.ID.001 @@ -382,7 +382,7 @@ Feature: Scenario Outline -- Parametrized Scenarios | 002 | Bob\tMarley | Placeholder value w/ tab | | 003 | Joe\nCocker | Placeholder value w/ newline | """ - When I run "behave -f pretty -c --no-source features/parametrized_tags2.feature" + When I run "behave -f pretty --no-color --no-source features/parametrized_tags2.feature" Then it should pass with: """ @outline.name.Alice_Cooper diff --git a/issue.features/issue0040.feature b/issue.features/issue0040.feature index a2102bebb..372dbb5fa 100644 --- a/issue.features/issue0040.feature +++ b/issue.features/issue0040.feature @@ -41,7 +41,7 @@ Feature: Issue #40 Test Summary Scenario/Step Counts are incorrect for Scenario |Alice| |Bob | """ - When I run "behave -c -f plain features/issue40_1.feature" + When I run "behave --no-color -f plain features/issue40_1.feature" Then it should pass with: """ 2 scenarios passed, 0 failed, 0 skipped @@ -62,7 +62,7 @@ Feature: Issue #40 Test Summary Scenario/Step Counts are incorrect for Scenario |Alice| |Bob | """ - When I run "behave -c -f plain features/issue40_2G.feature" + When I run "behave --no-color -f plain features/issue40_2G.feature" Then it should fail with: """ 0 scenarios passed, 2 failed, 0 skipped @@ -83,7 +83,7 @@ Feature: Issue #40 Test Summary Scenario/Step Counts are incorrect for Scenario |Alice| |Bob | """ - When I run "behave -c -f plain features/issue40_2W.feature" + When I run "behave --no-color -f plain features/issue40_2W.feature" Then it should fail with: """ 0 scenarios passed, 2 failed, 0 skipped @@ -104,7 +104,7 @@ Feature: Issue #40 Test Summary Scenario/Step Counts are incorrect for Scenario |Alice| |Bob | """ - When I run "behave -c -f plain features/issue40_2T.feature" + When I run "behave --no-color -f plain features/issue40_2T.feature" Then it should fail with: """ 0 scenarios passed, 2 failed, 0 skipped @@ -125,7 +125,7 @@ Feature: Issue #40 Test Summary Scenario/Step Counts are incorrect for Scenario |Alice| |Bob | """ - When I run "behave -c -f plain features/issue40_3W.feature" + When I run "behave --no-color -f plain features/issue40_3W.feature" Then it should fail with: """ 1 scenario passed, 1 failed, 0 skipped @@ -146,7 +146,7 @@ Feature: Issue #40 Test Summary Scenario/Step Counts are incorrect for Scenario |Alice| |Bob | """ - When I run "behave -c -f plain features/issue40_3W.feature" + When I run "behave --no-color -f plain features/issue40_3W.feature" Then it should fail with: """ 1 scenario passed, 1 failed, 0 skipped diff --git a/issue.features/issue0041.feature b/issue.features/issue0041.feature index a01288c8c..81d424a34 100644 --- a/issue.features/issue0041.feature +++ b/issue.features/issue0041.feature @@ -37,7 +37,7 @@ Feature: Issue #41 Missing Steps are duplicated in a Scenario Outline |Alice| |Bob | """ - When I run "behave -c -f plain features/issue41_missing1.feature" + When I run "behave --no-color -f plain features/issue41_missing1.feature" Then it should fail with: """ 0 steps passed, 0 failed, 4 skipped, 2 undefined @@ -74,7 +74,7 @@ Feature: Issue #41 Missing Steps are duplicated in a Scenario Outline |Alice| |Bob | """ - When I run "behave -c -f plain features/issue41_missing2.feature" + When I run "behave --no-color -f plain features/issue41_missing2.feature" Then it should fail with: """ 2 steps passed, 0 failed, 2 skipped, 2 undefined @@ -111,7 +111,7 @@ Feature: Issue #41 Missing Steps are duplicated in a Scenario Outline |Alice| |Bob | """ - When I run "behave -c -f plain features/issue41_missing3.feature" + When I run "behave --no-color -f plain features/issue41_missing3.feature" Then it should fail with: """ 4 steps passed, 0 failed, 0 skipped, 2 undefined diff --git a/issue.features/issue0044.feature b/issue.features/issue0044.feature index 81011f70d..3c919bf8e 100644 --- a/issue.features/issue0044.feature +++ b/issue.features/issue0044.feature @@ -38,7 +38,7 @@ Feature: Issue #44 Shell-like comments are removed in Multiline Args Ipsum lorem. """ ''' - When I run "behave -c -f pretty features/issue44_test.feature" + When I run "behave --no-color -f pretty features/issue44_test.feature" Then it should pass And the command output should contain: """ diff --git a/issue.features/issue0046.feature b/issue.features/issue0046.feature index 9553ffdc2..5b0259c6f 100644 --- a/issue.features/issue0046.feature +++ b/issue.features/issue0046.feature @@ -27,7 +27,7 @@ Feature: Issue #46 Behave returns 0 (SUCCESS) even in case of test failures Scenario: Passing Scenario Example Given passing """ - When I run "behave -c -q features/passing.feature" + When I run "behave --no-color -q features/passing.feature" Then it should pass with: """ 1 feature passed, 0 failed, 0 skipped @@ -42,7 +42,7 @@ Feature: Issue #46 Behave returns 0 (SUCCESS) even in case of test failures Scenario: Failing Scenario Example Given failing """ - When I run "behave -c -q features/failing.feature" + When I run "behave --no-color -q features/failing.feature" Then it should fail with: """ 0 features passed, 1 failed, 0 skipped @@ -59,7 +59,7 @@ Feature: Issue #46 Behave returns 0 (SUCCESS) even in case of test failures Scenario: Failing Scenario Example Given failing """ - When I run "behave -c -q features/passing_and_failing.feature" + When I run "behave --no-color -q features/passing_and_failing.feature" Then it should fail with: """ 0 features passed, 1 failed, 0 skipped diff --git a/issue.features/issue0052.feature b/issue.features/issue0052.feature index efe055292..653e93ab7 100644 --- a/issue.features/issue0052.feature +++ b/issue.features/issue0052.feature @@ -35,7 +35,7 @@ Feature: Issue #52 Summary counts are wrong with option --tags Scenario: N2 Given passing """ - When I run "behave --junit -c --tags @done features/tagged_scenario1.feature" + When I run "behave --junit --no-color --tags @done features/tagged_scenario1.feature" Then it should pass with: """ 1 feature passed, 0 failed, 0 skipped @@ -57,7 +57,7 @@ Feature: Issue #52 Summary counts are wrong with option --tags Scenario: N2 Given passing """ - When I run "behave --junit -c --tags @done features/tagged_scenario2.feature" + When I run "behave --junit --no-color --tags @done features/tagged_scenario2.feature" Then it should fail And the command output should contain: """ diff --git a/issue.features/issue0063.feature b/issue.features/issue0063.feature index 1959ae85d..a9d4e8e19 100644 --- a/issue.features/issue0063.feature +++ b/issue.features/issue0063.feature @@ -49,7 +49,7 @@ Feature: Issue #63: 'ScenarioOutline' object has no attribute 'stdout' |Alice| |Bob | """ - When I run "behave -c --junit features/issue63_case1.feature" + When I run "behave --no-color --junit features/issue63_case1.feature" Then it should pass with: """ 2 scenarios passed, 0 failed, 0 skipped @@ -74,7 +74,7 @@ Feature: Issue #63: 'ScenarioOutline' object has no attribute 'stdout' |Alice| |Bob | """ - When I run "behave -c --junit features/issue63_case2.feature" + When I run "behave --no-color --junit features/issue63_case2.feature" Then it should fail with: """ 0 scenarios passed, 2 failed, 0 skipped diff --git a/issue.features/issue0069.feature b/issue.features/issue0069.feature index 7bfb84b2f..ac590c459 100644 --- a/issue.features/issue0069.feature +++ b/issue.features/issue0069.feature @@ -47,7 +47,7 @@ Feature: Issue #69: JUnitReporter: Fault when processing ScenarioOutlines with f |Alice| |Bob | """ - When I run "behave -c --junit features/issue63_case2.feature" + When I run "behave --no-color --junit features/issue63_case2.feature" Then it should fail with: """ 0 scenarios passed, 2 failed, 0 skipped diff --git a/issue.features/issue0073.feature b/issue.features/issue0073.feature index d61939c27..fec932bd4 100644 --- a/issue.features/issue0073.feature +++ b/issue.features/issue0073.feature @@ -202,7 +202,7 @@ Feature: Issue #73: the current_matcher is not predictable When a step passes Then another step passes """ - When I run "behave -c -f pretty --no-timings features/passing3.feature" + When I run "behave --no-color -f pretty --no-timings features/passing3.feature" Then it should pass with: """ 3 scenarios passed, 0 failed, 0 skipped diff --git a/issue.features/issue0080.feature b/issue.features/issue0080.feature index 224718287..99ca3ecc6 100644 --- a/issue.features/issue0080.feature +++ b/issue.features/issue0080.feature @@ -36,7 +36,7 @@ Feature: Issue #80: source file names not properly printed with python3 """ Scenario: Show step locations - When I run "behave -c -f pretty --no-timings features/basic.feature" + When I run "behave --no-color -f pretty --no-timings features/basic.feature" Then it should pass And the command output should contain: """ diff --git a/issue.features/issue0081.feature b/issue.features/issue0081.feature index fa7a2a8f0..1417ccd6e 100644 --- a/issue.features/issue0081.feature +++ b/issue.features/issue0081.feature @@ -62,7 +62,7 @@ Feature: Issue #81: Allow defining steps in a separate library """ from step_library42.alice_steps import * """ - When I run "behave -c -f pretty features/use_step_library.feature" + When I run "behave --no-color -f pretty features/use_step_library.feature" Then it should pass with: """ 1 scenario passed, 0 failed, 0 skipped @@ -101,7 +101,7 @@ Feature: Issue #81: Allow defining steps in a separate library from step_library42.bob_steps import when_I_use_steps_from_this_step_library from step_library42.bob_steps import then_these_steps_are_executed """ - When I run "behave -c -f pretty features/use_step_library.feature" + When I run "behave --no-color -f pretty features/use_step_library.feature" Then it should pass with: """ 1 scenario passed, 0 failed, 0 skipped @@ -122,7 +122,7 @@ Feature: Issue #81: Allow defining steps in a separate library from step_library42.alice_steps import * """ And an empty file named "features/steps/__init__.py" - When I run "behave -c -f pretty features/use_step_library.feature" + When I run "behave --no-color -f pretty features/use_step_library.feature" Then it should pass with: """ 1 scenario passed, 0 failed, 0 skipped diff --git a/issue.features/issue0083.feature b/issue.features/issue0083.feature index 34f2cd676..fdbf6c392 100644 --- a/issue.features/issue0083.feature +++ b/issue.features/issue0083.feature @@ -32,7 +32,7 @@ Feature: Issue #83: behave.__main__:main() Various sys.exit issues When a step passes Then a step passes """ - When I run "behave -c features/passing.feature" + When I run "behave --no-color features/passing.feature" Then it should pass And the command returncode is "0" @@ -44,7 +44,7 @@ Feature: Issue #83: behave.__main__:main() Various sys.exit issues Given a step passes When2 a step passes """ - When I run "behave -c features/invalid_with_ParseError.feature" + When I run "behave --no-color features/invalid_with_ParseError.feature" Then it should fail And the command returncode is non-zero And the command output should contain: @@ -60,7 +60,7 @@ Feature: Issue #83: behave.__main__:main() Various sys.exit issues Scenario: Given a step passes """ - When I run "behave -c features/passing2.feature" + When I run "behave --no-color features/passing2.feature" Then it should fail And the command returncode is non-zero And the command output should contain: diff --git a/issue.features/issue0085.feature b/issue.features/issue0085.feature index f76ced7c8..a35305c67 100644 --- a/issue.features/issue0085.feature +++ b/issue.features/issue0085.feature @@ -102,7 +102,7 @@ Feature: Issue #85: AssertionError with nested regex and pretty formatter """ Scenario: Run regexp steps with --format=pretty - When I run "behave -c --format=pretty features/matching.feature" + When I run "behave --no-color --format=pretty features/matching.feature" Then it should pass with: """ 1 feature passed, 0 failed, 0 skipped diff --git a/issue.features/issue0096.feature b/issue.features/issue0096.feature index 7b10111ac..e73da8192 100644 --- a/issue.features/issue0096.feature +++ b/issue.features/issue0096.feature @@ -67,7 +67,7 @@ Feature: Issue #96: Sub-steps failed without any error info to help debug issue Then a step passes """ ''' - When I run "behave -c features/issue96_case1.feature" + When I run "behave --no-color features/issue96_case1.feature" Then it should fail with: """ Assertion Failed: FAILED SUB-STEP: When a step fails @@ -86,7 +86,7 @@ Feature: Issue #96: Sub-steps failed without any error info to help debug issue Then a step passes """ ''' - When I run "behave -c features/issue96_case2.feature" + When I run "behave --no-color features/issue96_case2.feature" Then it should fail with: """ RuntimeError: Alice is alive @@ -109,7 +109,7 @@ Feature: Issue #96: Sub-steps failed without any error info to help debug issue Then a step passes """ ''' - When I run "behave -c features/issue96_case3.feature" + When I run "behave --no-color features/issue96_case3.feature" Then it should fail with: """ Assertion Failed: FAILED SUB-STEP: When a step fails with stdout "STDOUT: Alice is alive" @@ -134,7 +134,7 @@ Feature: Issue #96: Sub-steps failed without any error info to help debug issue Then a step passes """ ''' - When I run "behave -c features/issue96_case4.feature" + When I run "behave --no-color features/issue96_case4.feature" Then it should fail with: """ Assertion Failed: FAILED SUB-STEP: When a step fails with stderr "STDERR: Alice is alive" @@ -164,7 +164,7 @@ Feature: Issue #96: Sub-steps failed without any error info to help debug issue Then a step fails ''') """ - When I run "behave -c features/issue96_case5.feature" + When I run "behave --no-color features/issue96_case5.feature" Then it should fail with: """ HOOK-ERROR in before_scenario: AssertionError: FAILED SUB-STEP: Then a step fails diff --git a/issue.features/issue0112.feature b/issue.features/issue0112.feature index 339f082db..17cac6e98 100644 --- a/issue.features/issue0112.feature +++ b/issue.features/issue0112.feature @@ -31,7 +31,7 @@ Feature: Issue #112: Improvement to AmbiguousStep error def step_given_I_buy(context, amount, product): pass """ - When I run "behave -c features/syndrome112.feature" + When I run "behave --no-color features/syndrome112.feature" Then it should pass with: """ 1 feature passed, 0 failed, 0 skipped @@ -55,7 +55,7 @@ Feature: Issue #112: Improvement to AmbiguousStep error def step_given_I_buy2(context, number, items): pass """ - When I run "behave -c features/syndrome112.feature" + When I run "behave --no-color features/syndrome112.feature" Then it should fail And the command output should contain: """ diff --git a/issue.features/issue0231.feature b/issue.features/issue0231.feature index c9bd62295..659250603 100644 --- a/issue.features/issue0231.feature +++ b/issue.features/issue0231.feature @@ -51,7 +51,7 @@ Feature: Issue #231: Display the output of the last print command Scenario: Write to stdout without newline - When I run "behave -f pretty -c -T features/syndrome1.feature" + When I run "behave -f pretty --no-color -T features/syndrome1.feature" Then it should fail with: """ 0 scenarios passed, 1 failed, 0 skipped @@ -64,7 +64,7 @@ Feature: Issue #231: Display the output of the last print command """ Scenario: Use print function without newline - When I run "behave -f pretty -c -T features/syndrome2.feature" + When I run "behave -f pretty --no-color -T features/syndrome2.feature" Then it should fail with: """ 0 scenarios passed, 1 failed, 0 skipped From 9c1887df9c3e09c0ec75fd48724de3ae15550007 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 27 May 2023 22:26:42 +0200 Subject: [PATCH 168/240] UPDATE: gherkin_languages.json -- behave/i18n.py * i18n: language=be was added * FIX: DOWNLOAD_URL, repo structure has changed (branch=main, ...) --- behave/i18n.py | 26 ++++++++++-- etc/gherkin/convert_gherkin-languages.py | 20 +++++---- etc/gherkin/gherkin-languages.json | 54 +++++++++++++++++++++++- features/cmdline.lang_list.feature | 1 + tasks/develop.py | 6 +-- 5 files changed, 89 insertions(+), 18 deletions(-) diff --git a/behave/i18n.py b/behave/i18n.py index 524cbc17c..24442a8a6 100644 --- a/behave/i18n.py +++ b/behave/i18n.py @@ -1,11 +1,12 @@ # -*- coding: UTF-8 -*- # -- GENERATED BY: convert_gherkin-languages.py # FROM: "gherkin-languages.json" -# SOURCE: https://raw.githubusercontent.com/cucumber/cucumber/master/gherkin/gherkin-languages.json +# SOURCE: https://raw.githubusercontent.com/cucumber/gherkin/main/gherkin-languages.json # pylint: disable=line-too-long, too-many-lines, missing-docstring, invalid-name +# ruff: noqa: E501 """ Gherkin keywords in the different I18N languages, like: - + * English * French * German @@ -106,6 +107,19 @@ 'scenario_outline': ['Ssenarinin strukturu'], 'then': ['* ', 'O halda '], 'when': ['* ', 'Əgər ', 'Nə vaxt ki ']}, + 'be': {'and': ['* ', 'I ', 'Ды ', 'Таксама '], + 'background': ['Кантэкст'], + 'but': ['* ', 'Але ', 'Інакш '], + 'examples': ['Прыклады'], + 'feature': ['Функцыянальнасць', 'Фіча'], + 'given': ['* ', 'Няхай ', 'Дадзена '], + 'name': 'Belarusian', + 'native': 'Беларуская', + 'rule': ['Правілы'], + 'scenario': ['Сцэнарый', 'Cцэнар'], + 'scenario_outline': ['Шаблон сцэнарыя', 'Узор сцэнара'], + 'then': ['* ', 'Тады '], + 'when': ['* ', 'Калі ']}, 'bg': {'and': ['* ', 'И '], 'background': ['Предистория'], 'but': ['* ', 'Но '], @@ -629,7 +643,7 @@ 'but': ['* ', 'მაგრამ ', 'თუმცა '], 'examples': ['მაგალითები'], 'feature': ['თვისება', 'მოთხოვნა'], - 'given': ['* ', 'მოცემული ', 'Მოცემულია ', 'ვთქვათ '], + 'given': ['* ', 'მოცემული ', 'მოცემულია ', 'ვთქვათ '], 'name': 'Georgian', 'native': 'ქართული', 'rule': ['წესი'], @@ -864,7 +878,11 @@ 'background': ['Предыстория', 'Контекст'], 'but': ['* ', 'Но ', 'А ', 'Иначе '], 'examples': ['Примеры'], - 'feature': ['Функция', 'Функциональность', 'Функционал', 'Свойство'], + 'feature': ['Функция', + 'Функциональность', + 'Функционал', + 'Свойство', + 'Фича'], 'given': ['* ', 'Допустим ', 'Дано ', 'Пусть '], 'name': 'Russian', 'native': 'русский', diff --git a/etc/gherkin/convert_gherkin-languages.py b/etc/gherkin/convert_gherkin-languages.py index 9ef9b0c18..7d674904a 100755 --- a/etc/gherkin/convert_gherkin-languages.py +++ b/etc/gherkin/convert_gherkin-languages.py @@ -11,13 +11,15 @@ * six * PyYAML -.. _cucumber: https://github.com/cucumber/cucumber/ -.. _`gherkin-languages.json`: https://raw.githubusercontent.com/cucumber/cucumber/master/gherkin/gherkin-languages.json +.. _cucumber: https://github.com/cucumber/common +.. _gherkin: https://github.com/cucumber/gherkin +.. _`gherkin-languages.json`: https://raw.githubusercontent.com/cucumber/gherkin/main/gherkin-languages.json .. seealso:: - * https://github.com/cucumber/cucumber/blob/master/gherkin/gherkin-languages.json - * https://raw.githubusercontent.com/cucumber/cucumber/master/gherkin/gherkin-languages.json + * https://github.com/cucumber/gherkin/blob/main/gherkin-languages.json + * https://raw.githubusercontent.com/cucumber/gherkin/main/gherkin-languages.json + * https://github.com/cucumber/common .. note:: @@ -42,7 +44,8 @@ STEP_KEYWORDS = (u"and", u"but", u"given", u"when", u"then") GHERKIN_LANGUAGES_JSON_URL = \ - "https://raw.githubusercontent.com/cucumber/cucumber/master/gherkin/gherkin-languages.json" + "https://raw.githubusercontent.com/cucumber/gherkin/main/gherkin-languages.json" + def download_file(source_url, filename=None): @@ -133,9 +136,10 @@ def gherkin_languages_to_python_module(gherkin_languages_path, output_file=None, # FROM: "gherkin-languages.json" # SOURCE: {gherkin_languages_json_url} # pylint: disable=line-too-long, too-many-lines, missing-docstring, invalid-name +# ruff: noqa: E501 """ Gherkin keywords in the different I18N languages, like: - + * English * French * German @@ -164,8 +168,8 @@ def gherkin_languages_to_python_module(gherkin_languages_path, output_file=None, def main(args=None): - """Main function to generate the "behave/i18n.py" module from the - the "gherkin-languages.json" file. + """Main function to generate the "behave/i18n.py" module + from the "gherkin-languages.json" file. :param args: List of command-line args (if None: Use ``sys.argv``) :return: 0, on success (or sys.exit(NON_ZERO_NUMBER) on failure). diff --git a/etc/gherkin/gherkin-languages.json b/etc/gherkin/gherkin-languages.json index a8541cd2d..209042dd0 100644 --- a/etc/gherkin/gherkin-languages.json +++ b/etc/gherkin/gherkin-languages.json @@ -277,6 +277,55 @@ "Əgər ", "Nə vaxt ki " ] + }, + "be": { + "and": [ + "* ", + "I ", + "Ды ", + "Таксама " + ], + "background": [ + "Кантэкст" + ], + "but": [ + "* ", + "Але ", + "Інакш " + ], + "examples": [ + "Прыклады" + ], + "feature": [ + "Функцыянальнасць", + "Фіча" + ], + "given": [ + "* ", + "Няхай ", + "Дадзена " + ], + "name": "Belarusian", + "native": "Беларуская", + "rule": [ + "Правілы" + ], + "scenario": [ + "Сцэнарый", + "Cцэнар" + ], + "scenarioOutline": [ + "Шаблон сцэнарыя", + "Узор сцэнара" + ], + "then": [ + "* ", + "Тады " + ], + "when": [ + "* ", + "Калі " + ] }, "bg": { "and": [ @@ -2005,7 +2054,7 @@ "given": [ "* ", "მოცემული ", - "Მოცემულია ", + "მოცემულია ", "ვთქვათ " ], "name": "Georgian", @@ -2782,7 +2831,8 @@ "Функция", "Функциональность", "Функционал", - "Свойство" + "Свойство", + "Фича" ], "given": [ "* ", diff --git a/features/cmdline.lang_list.feature b/features/cmdline.lang_list.feature index 3fda63f21..91439c555 100644 --- a/features/cmdline.lang_list.feature +++ b/features/cmdline.lang_list.feature @@ -18,6 +18,7 @@ Feature: Command-line options: Use behave --lang-list ar: العربية / Arabic ast: asturianu / Asturian az: Azərbaycanca / Azerbaijani + be: Беларуская / Belarusian bg: български / Bulgarian bm: Bahasa Melayu / Malay bs: Bosanski / Bosnian diff --git a/tasks/develop.py b/tasks/develop.py index 1f5519df3..6208b1a4c 100644 --- a/tasks/develop.py +++ b/tasks/develop.py @@ -13,9 +13,7 @@ # ----------------------------------------------------------------------------- # CONSTANTS: # ----------------------------------------------------------------------------- -# DISABLED: OLD LOCATION: -# GHERKIN_LANGUAGES_URL = "https://raw.githubusercontent.com/cucumber/cucumber/master/gherkin/gherkin-languages.json" -GHERKIN_LANGUAGES_URL = "https://raw.githubusercontent.com/cucumber/common/main/gherkin/gherkin-languages.json" +GHERKIN_LANGUAGES_URL = "https://raw.githubusercontent.com/cucumber/gherkin/main/gherkin-languages.json" # ----------------------------------------------------------------------------- @@ -38,7 +36,7 @@ def update_gherkin(ctx, dry_run=False, verbose=False): print('Downloading "gherkin-languages.json" from github:cucumber ...') download_request = requests.get(GHERKIN_LANGUAGES_URL) assert download_request.ok - print('Download finished: OK (size={0})'.format(len(download_request.content))) + print("Download finished: OK (size={0})".format(len(download_request.content))) with open(gherkin_languages_file, "wb") as f: f.write(download_request.content) From 8e1152a8148b00530a10f0bed1b079a7dc3fd14a Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 27 May 2023 22:42:36 +0200 Subject: [PATCH 169/240] invoke tasks: Use invoke_cleanup from git-repo RELATED TO: py.requirements * ADDED: assertpy to setup.py, py.requirements/testing.txt * ADDED: check-jsonschema to py.requirements/jsonschema.txt (was: json.txt) * ADDED: ruff to py.requirements/pylinters.txt * UPDATE: .pylintrc to newer config-schema TESTS: Related to tag-expressions (and diagnostics) * ADDED: features/tags.help.feature * ADDED: tests/issues/test_issue1051.py to verify #1054 issue. PREPARED: behave.config.Configuration.has_colored_mode() * PrettyFormatter * main: run_behave() --- .pylintrc | 757 +++++++++++++------ behave/__main__.py | 2 +- behave/formatter/pretty.py | 28 +- features/tags.help.feature | 74 ++ py.requirements/all.txt | 2 +- py.requirements/ci.tox.txt | 2 +- py.requirements/develop.txt | 2 +- py.requirements/{json.txt => jsonschema.txt} | 1 + py.requirements/pylinters.txt | 8 + py.requirements/testing.txt | 1 + setup.py | 1 + tasks/__init__.py | 4 +- tasks/docs.py | 2 +- tasks/invoke_cleanup.py | 447 ----------- tasks/py.requirements.txt | 2 + tasks/release.py | 2 +- tasks/test.py | 2 +- tests/issues/test_issue1054.py | 41 + 18 files changed, 668 insertions(+), 710 deletions(-) create mode 100644 features/tags.help.feature rename py.requirements/{json.txt => jsonschema.txt} (90%) create mode 100644 py.requirements/pylinters.txt delete mode 100644 tasks/invoke_cleanup.py create mode 100644 tests/issues/test_issue1054.py diff --git a/.pylintrc b/.pylintrc index 27334322f..49d5195a2 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,386 +1,667 @@ # ============================================================================= # PYLINT CONFIGURATION # ============================================================================= -# PYLINT-VERSION: 1.5.x +# PYLINT-VERSION: XXX_UPDATE: 1.5.x # SEE ALSO: http://www.pylint.org/ # ============================================================================= -[MASTER] +[MAIN] -# Specify a configuration file. -#rcfile=.pylintrc +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Clear in-memory caches upon conclusion of linting. Useful if running pylint +# in a server-like mode. +clear-cache-post-run=no + +# Load and enable all available extensions. Use --list-extensions to see a list +# all available extensions. +#enable-all-extensions= + +# In error mode, messages with a category besides ERROR or FATAL are +# suppressed, and no reports are done by default. Error mode is compatible with +# disabling specific errors. +#errors-only= + +# Always return a 0 (non-error) status code, even if lint errors are found. +# This is primarily useful in continuous integration scripts. +#exit-zero= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-allow-list= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. (This is an alternative name to extension-pkg-allow-list +# for backward compatibility.) +extension-pkg-whitelist= + +# Return non-zero exit code if any of these messages/categories are detected, +# even if score is above --fail-under value. Syntax same as enable. Messages +# specified are enabled, while categories only check already-enabled messages. +fail-on= + +# Specify a score threshold under which the program will exit with error. +fail-under=10 + +# Interpret the stdin as a python script, whose filename needs to be passed as +# the module_or_package argument. +#from-stdin= + +# Files or directories to be skipped. They should be base names, not paths. +ignore=.git + +# Add files or directories matching the regular expressions patterns to the +# ignore-list. The regex matches against paths and can be in Posix or Windows +# format. Because '\\' represents the directory delimiter on Windows systems, +# it can't be used as an escape character. +ignore-paths= + +# Files or directories matching the regular expression patterns are skipped. +# The regex matches against base names, not paths. The default value ignores +# Emacs file locks +ignore-patterns=^\.# + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis). It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= # Python code to execute, usually for sys.path manipulation such as # pygtk.require(). #init-hook= -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore=.git +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use, and will cap the count on Windows to +# avoid hangs. +jobs=1 -# Pickle collected data for later comparisons. -persistent=yes +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 -# List of plugins (as comma separated values of python modules names) to load, +# List of plugins (as comma separated values of python module names) to load, # usually to register additional checkers. load-plugins= -# Use multiple processes to speed up Pylint. -jobs=1 +# Pickle collected data for later comparisons. +persistent=yes + +# Minimum Python version to use for version dependent checks. Will default to +# the version used to run pylint. +py-version=3.10 + +# Discover python modules and packages in the file system subtree. +recursive=no + +# Add paths to the list of the source roots. Supports globbing patterns. The +# source root is an absolute path or a path relative to the current working +# directory used to determine a package namespace for modules located under the +# source root. +source-roots= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. unsafe-load-any-extension=no -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code -extension-pkg-whitelist= +# In verbose mode, extra non-checker-related info will be displayed. +#verbose= -# Allow optimization of some AST trees. This will activate a peephole AST -# optimizer, which will apply various small optimizations. For instance, it can -# be used to obtain the result of joining multiple strings with the addition -# operator. Joining a lot of strings can lead to a maximum recursion error in -# Pylint and this flag can prevent that. It has one side effect, the resulting -# AST will be different than the one from reality. -optimize-ast=no +[BASIC] -[MESSAGES CONTROL] +# Naming style matching correct argument names. +argument-naming-style=snake_case -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED -confidence= +# Regular expression matching correct argument names. Overrides argument- +# naming-style. If left empty, argument names will be checked with the set +# naming style. +argument-rgx=[a-z_][a-z0-9_]{2,30}$ -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time. See also the "--disable" option for examples. -#enable= +# Naming style matching correct attribute names. +attr-naming-style=snake_case -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once).You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use"--disable=all --enable=classes -# --disable=W" -disable=import-star-module-level,old-octal-literal,oct-method,print-statement,unpacking-in-except,parameter-unpacking,backtick,old-raise-syntax,old-ne-operator,long-suffix,dict-view-method,dict-iter-method,metaclass-assignment,next-method-called,raising-string,indexing-exception,raw_input-builtin,long-builtin,file-builtin,execfile-builtin,coerce-builtin,cmp-builtin,buffer-builtin,basestring-builtin,apply-builtin,filter-builtin-not-iterating,unused-variable,using-cmp-argument,useless-suppression,range-builtin-not-iterating,suppressed-message,missing-docstring,no-absolute-import,old-division,cmp-method,reload-builtin,zip-builtin-not-iterating,intern-builtin,unichr-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,input-builtin,too-few-public-methods,round-builtin,locally-disabled,hex-method,nonzero-method,map-builtin-not-iterating +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. If left empty, attribute names will be checked with the set naming +# style. +attr-rgx=[a-z_][a-z0-9_]{2,30}$ +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata -[REPORTS] +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= -# Set the output format. Available formats are text, parseable, colorized, msvs -# (visual studio) and html. You can also give a reporter class, eg -# mypackage.mymodule.MyReporterClass. -# output-format=text -output-format=colorized +# Naming style matching correct class attribute names. +class-attribute-naming-style=any -# Put messages in a separate file for each module / package specified on the -# command line instead of printing them on stdout. Reports (if any) will be -# written in a file name "pylint_global.[txt|html]". -files-output=no +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. If left empty, class attribute names will be checked +# with the set naming style. +class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,40}|(__.*__))$ -# Tells whether to display a full report or only the messages -reports=yes +# Naming style matching correct class constant names. +class-const-naming-style=UPPER_CASE -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) +# Regular expression matching correct class constant names. Overrides class- +# const-naming-style. If left empty, class constant names will be checked with +# the set naming style. +#class-const-rgx= -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details -#msg-template= +# Naming style matching correct class names. +class-naming-style=PascalCase +# Regular expression matching correct class names. Overrides class-naming- +# style. If left empty, class names will be checked with the set naming style. +class-rgx=[A-Z_][a-zA-Z0-9]+$ -[BASIC] +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. If left empty, constant names will be checked with the set naming +# style. +const-rgx=(([a-zA-Z_][a-zA-Z0-9_]*)|(__.*__))$ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. If left empty, function names will be checked with the set +# naming style. +function-rgx=[a-z_][a-z0-9_]{2,40}$ + +# Good variable names which should always be accepted, separated by a comma. +good-names=c, + d, + f, + h, + i, + j, + k, + m, + n, + o, + p, + r, + s, + v, + w, + x, + y, + e, + ex, + kw, + up, + _ + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=yes + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any -# List of builtins function names that should not be used, separated by a comma -bad-functions=map,filter,apply,input +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. If left empty, inline iteration names will be checked +# with the set naming style. +inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ -# Good variable names which should always be accepted, separated by a comma -good-names=c,d,f,h,i,j,k,m,n,o,p,r,s,v,w,x,y,e,ex,kw,up,_ +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. If left empty, method names will be checked with the set naming style. +method-rgx=[a-z_][a-z0-9_]{2,30}$ -# Bad variable names which should always be refused, separated by a comma -bad-names=foo,bar,baz,toto,tutu,tata +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. If left empty, module names will be checked with the set naming style. +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ # Colon-delimited sets of names that determine each other's naming style when # the name regexes allow several styles. name-group= -# Include a hint for the correct naming format with invalid-name -include-naming-hint=yes +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=__.*__ -# Regular expression matching correct function names -function-rgx=[a-z_][a-z0-9_]{2,40}$ +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Regular expression matching correct type alias names. If left empty, type +# alias names will be checked with the set naming style. +#typealias-rgx= -# Naming hint for function names -function-name-hint=[a-z_][a-z0-9_]{2,40}$ +# Regular expression matching correct type variable names. If left empty, type +# variable names will be checked with the set naming style. +#typevar-rgx= -# Regular expression matching correct variable names +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. If left empty, variable names will be checked with the set +# naming style. variable-rgx=[a-z_][a-z0-9_]{2,40}$ -# Naming hint for variable names -variable-name-hint=[a-z_][a-z0-9_]{2,40}$ -# Regular expression matching correct constant names -const-rgx=(([a-zA-Z_][a-zA-Z0-9_]*)|(__.*__))$ +[CLASSES] -# Naming hint for constant names -const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ +# Warn about protected attribute access inside special methods +check-protected-access-in-special-methods=no -# Regular expression matching correct attribute names -attr-rgx=[a-z_][a-z0-9_]{2,30}$ +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp -# Naming hint for attribute names -attr-name-hint=[a-z_][a-z0-9_]{2,30}$ +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make -# Regular expression matching correct argument names -argument-rgx=[a-z_][a-z0-9_]{2,30}$ +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls -# Naming hint for argument names -argument-name-hint=[a-z_][a-z0-9_]{2,30}$ +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs -# Regular expression matching correct class attribute names -class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,40}|(__.*__))$ -# Naming hint for class attribute names -class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,40}|(__.*__))$ +[DESIGN] -# Regular expression matching correct inline iteration names -inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ +# List of regular expressions of class ancestor names to ignore when counting +# public methods (see R0903) +exclude-too-few-public-methods= -# Naming hint for inline iteration names -inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ +# List of qualified class names to ignore when counting class parents (see +# R0901) +ignored-parents= -# Regular expression matching correct class names -class-rgx=[A-Z_][a-zA-Z0-9]+$ +# Maximum number of arguments for function / method. +max-args=10 -# Naming hint for class names -class-name-hint=[A-Z_][a-zA-Z0-9]+$ +# Maximum number of attributes for a class (see R0902). +max-attributes=10 -# Regular expression matching correct module names -module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 -# Naming hint for module names -module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ +# Maximum number of branch for function / method body. +max-branches=12 -# Regular expression matching correct method names -method-rgx=[a-z_][a-z0-9_]{2,30}$ +# Maximum number of locals for function / method body. +max-locals=15 -# Naming hint for method names -method-name-hint=[a-z_][a-z0-9_]{2,30}$ +# Maximum number of parents for a class (see R0901). +max-parents=7 -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=__.*__ +# Maximum number of public methods for a class (see R0904). +max-public-methods=30 -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 +# Maximum number of return / yield for function / method body. +max-returns=6 +# Maximum number of statements in function / method body. +max-statements=50 -[ELIF] +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 + +[EXCEPTIONS] + +# Exceptions that will emit a warning when caught. +overgeneral-exceptions=builtins.Exception [FORMAT] -# Maximum number of characters on a single line. -max-line-length=85 +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= # Regexp for a line that is allowed to be longer than the limit. ignore-long-lines=^\s*(# )??$ +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=85 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + # Allow the body of an if to be on the same line as the test if there is no # else. single-line-if-stmt=no -# List of optional constructs for which whitespace checking is disabled. `dict- -# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. -# `trailing-comma` allows a space between comma and closing bracket: (a, ). -# `empty-line` allows space-only lines. -no-space-check=trailing-comma,dict-separator -# Maximum number of lines in a module -max-module-lines=1000 +[IMPORTS] -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 +# Allow explicit reexports by alias from a package __init__. +allow-reexport-from-package=no -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules=regsub, + string, + TERMIOS, + Bastion, + rexec + +# Output a graph (.gv or any supported image format) of external dependencies +# to the given file (report RP0402 must not be disabled). +ext-import-graph= + +# Output a graph (.gv or any supported image format) of all (i.e. internal and +# external) dependencies to the given file (report RP0402 must not be +# disabled). +import-graph= + +# Output a graph (.gv or any supported image format) of internal dependencies +# to the given file (report RP0402 must not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= [LOGGING] +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + # Logging modules to check that the string format arguments are in logging -# function parameter format +# function parameter format. logging-modules=logging -[MISCELLANEOUS] +[MESSAGES CONTROL] -# List of note tags to take in consideration, separated by a comma. -notes=FIXME,XXX,TODO +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, +# UNDEFINED. +confidence=HIGH, + CONTROL_FLOW, + INFERENCE, + INFERENCE_FAILURE, + UNDEFINED +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then re-enable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + unused-variable, + missing-module-docstring, + missing-class-docstring, + missing-function-docstring, + too-few-public-methods -[SIMILARITIES] +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member -# Minimum lines number of a similarity. -min-similarity-lines=4 -# Ignore comments when computing similarities. -ignore-comments=yes +[METHOD_ARGS] -# Ignore docstrings when computing similarities. -ignore-docstrings=yes +# List of qualified names (i.e., library.method) which require a timeout +# parameter e.g. 'requests.api.get,requests.api.post' +timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request -# Ignore imports when computing similarities. -ignore-imports=no +[MISCELLANEOUS] -[SPELLING] +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO -# Spelling dictionary name. Available dictionaries: none. To make it working -# install python-enchant package. -spelling-dict= +# Regular expression of note tags to take in consideration. +notes-rgx= -# List of comma separated words that should not be checked. -spelling-ignore-words= -# A path to a file that contains private dictionary; one word per line. -spelling-private-dict-file= +[REFACTORING] -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. -spelling-store-unknown-words=no +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit,argparse.parse_error -[TYPECHECK] -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes +[REPORTS] -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis. It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules= +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'fatal', 'error', 'warning', 'refactor', +# 'convention', and 'info' which contain the number of messages in each +# category, as well as 'statement' which is the total number of statements +# analyzed. This score is used by the global evaluation report (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) -# List of classes names for which member attributes should not be checked -# (useful for classes with attributes dynamically set). This supports can work -# with qualified names. -ignored-classes=SQLObject +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +msg-template= -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members=REQUEST,acl_users,aq_parent +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +#output-format= +# Tells whether to display a full report or only the messages. +reports=yes -[VARIABLES] +# Activate the evaluation score. +score=yes -# Tells whether we should check for unused import in __init__ files. -init-import=no -# A regular expression matching the name of dummy variables (i.e. expectedly -# not used). -dummy-variables-rgx=_|dummy|kwargs +[SIMILARITIES] -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid to define new builtins when possible. -additional-builtins= +# Comments are removed from the similarity computation +ignore-comments=yes -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_,_cb +# Docstrings are removed from the similarity computation +ignore-docstrings=yes +# Imports are removed from the similarity computation +ignore-imports=no -[CLASSES] +# Signatures are removed from the similarity computation +ignore-signatures=yes -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__,__new__,setUp +# Minimum lines number of a similarity. +min-similarity-lines=4 -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=mcs +[SPELLING] -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict,_fields,_replace,_source,_make +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 +# Spelling dictionary name. No available dictionaries : You need to install +# both the python package and the system dependency for enchant to work.. +spelling-dict= -[DESIGN] +# List of comma separated words that should be considered directives if they +# appear at the beginning of a comment and should not be checked. +spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: -# Maximum number of arguments for function / method -max-args=10 +# List of comma separated words that should not be checked. +spelling-ignore-words= -# Argument names that match this expression will be ignored. Default to name -# with leading underscore -ignored-argument-names=_.* +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= -# Maximum number of locals for function / method body -max-locals=15 +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no -# Maximum number of return / yield for function / method body -max-returns=6 -# Maximum number of branch for function / method body -max-branches=12 +[STRING] -# Maximum number of statements in function / method body -max-statements=50 +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no -# Maximum number of parents for a class (see R0901). -max-parents=7 +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no -# Maximum number of attributes for a class (see R0902). -max-attributes=10 -# Minimum number of public methods for a class (see R0903). -min-public-methods=2 +[TYPECHECK] -# Maximum number of public methods for a class (see R0904). -max-public-methods=30 +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager -# Maximum number of boolean expressions in a if statement -max-bool-expr=5 +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members=REQUEST, + acl_users, + aq_parent + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of symbolic message names to ignore for Mixin members. +ignored-checks-for-mixins=no-member, + not-async-context-manager, + not-context-manager, + attribute-defined-outside-init + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=SQLObject +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes -[IMPORTS] +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 -# Deprecated modules which should not be used, separated by a comma -deprecated-modules=regsub,string,TERMIOS,Bastion,rexec +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled) -import-graph= +# Regex pattern to define which classes are considered mixins. +mixin-class-rgx=.*[Mm]ixin -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled) -ext-import-graph= +# List of decorators that change the signature of a decorated function. +signature-mutators= -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled) -int-import-graph= +[VARIABLES] -[EXCEPTIONS] +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of names allowed to shadow builtins +allowed-redefined-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_|dummy|kwargs + +# Argument names that match this expression will be ignored. +ignored-argument-names=_.* + +# Tells whether we should check for unused import in __init__ files. +init-import=no -# Exceptions that will emit a warning when being caught. Defaults to -# "Exception" -overgeneral-exceptions=Exception +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io diff --git a/behave/__main__.py b/behave/__main__.py index 52f4f73d3..bb9367db7 100644 --- a/behave/__main__.py +++ b/behave/__main__.py @@ -141,7 +141,7 @@ def run_behave(config, runner_class=None): if config.show_snippets and runner and runner.undefined_steps: print_undefined_step_snippets(runner.undefined_steps, - colored=config.color) + colored=config.has_colored_mode()) return_code = 0 if failed: diff --git a/behave/formatter/pretty.py b/behave/formatter/pretty.py index b97438aae..9c1c103c5 100644 --- a/behave/formatter/pretty.py +++ b/behave/formatter/pretty.py @@ -2,13 +2,13 @@ from __future__ import absolute_import, division import sys +import six +from six.moves import range, zip from behave.formatter.ansi_escapes import escapes, up from behave.formatter.base import Formatter from behave.model_core import Status from behave.model_describe import escape_cell, escape_triple_quotes -from behave.textutil import indent, make_indentation, text as _text -import six -from six.moves import range, zip +from behave.textutil import indent, text as _text # ----------------------------------------------------------------------------- @@ -66,7 +66,7 @@ def __init__(self, stream_opener, config): super(PrettyFormatter, self).__init__(stream_opener, config) # -- ENSURE: Output stream is open. self.stream = self.open() - self.monochrome = self._get_monochrome(config) + self.colored = config.has_colored_mode(self.stream) self.show_source = config.show_source self.show_timings = config.show_timings self.show_multiline = config.show_multiline @@ -81,14 +81,9 @@ def __init__(self, stream_opener, config): self.indentations = [] self.step_lines = 0 - def _get_monochrome(self, config): - isatty = getattr(self.stream, "isatty", lambda: True) - if config.color == 'always': - return False - elif config.color == 'never': - return True - else: - return not isatty() + @property + def monochrome(self): + return not self.colored def reset(self): # -- UNUSED: self.tag_statement = None @@ -143,11 +138,11 @@ def match(self, match): self._match = match self.print_statement() self.print_step(Status.executing, self._match.arguments, - self._match.location, self.monochrome) + self._match.location, proceed=self.monochrome) self.stream.flush() def result(self, step): - if not self.monochrome: + if self.colored: lines = self.step_lines + 1 if self.show_multiline: if step.table: @@ -160,7 +155,7 @@ def result(self, step): if self._match: arguments = self._match.arguments location = self._match.location - self.print_step(step.status, arguments, location, True) + self.print_step(step.status, arguments, location, proceed=True) if step.error_message: self.stream.write(indent(step.error_message.strip(), u" ")) self.stream.write("\n\n") @@ -170,10 +165,11 @@ def arg_format(self, key): return self.format(key + "_arg") def format(self, key): - if self.monochrome: + if not self.colored: if self.formats is None: self.formats = MonochromeFormat() return self.formats + # -- OTHERWISE: if self.formats is None: self.formats = {} diff --git a/features/tags.help.feature b/features/tags.help.feature new file mode 100644 index 000000000..8b0b98c1e --- /dev/null +++ b/features/tags.help.feature @@ -0,0 +1,74 @@ +Feature: behave --tags-help option + + As a user + I want to understand how to specify tag-expressions on command-line + So that I can select some features, rules or scenarios, etc. + + . IN ADDITION: + . The --tags-help option helps to diagnose tag-expression v2 problems. + + Rule: Use --tags-help option to see tag-expression syntax and examples + Scenario: Shows tag-expression description + When I run "behave --tags-help" + Then it should pass with: + """ + TAG-EXPRESSIONS selects Features/Rules/Scenarios by using their tags. + A TAG-EXPRESSION is a boolean expression that references some tags. + + EXAMPLES: + + --tags=@smoke + --tags="not @xfail" + --tags="@smoke or @wip" + --tags="@smoke and @wip" + --tags="(@slow and not @fixme) or @smoke" + --tags="not (@fixme or @xfail)" + + NOTES: + * The tag-prefix "@" is optional. + * An empty tag-expression is "true" (select-anything). + """ + + Rule: Use --tags-help option to inspect current tag-expression + Scenario: Shows current tag-expression without any tags + When I run "behave --tags-help" + Then it should pass with: + """ + CURRENT TAG_EXPRESSION: true + """ + And note that "an EMPTY tag-expression is always TRUE" + + Scenario: Shows current tag-expression with tags + When I run "behave --tags-help --tags='@one and @two'" + Then it should pass with: + """ + CURRENT TAG_EXPRESSION: (one and two) + """ + + Scenario Outline: Shows more details of current tag-expression in verbose mode + When I run "behave --tags-help --tags='' --verbose" + Then it should pass with: + """ + CURRENT TAG_EXPRESSION: + means: + """ + But note that "the low-level tag-expression details are shown in verbose mode" + + Examples: + | tags | tag_expression | tag_expression.logic | + | @one or @two and @three | (one or (two and three)) | Or(Tag('one'), And(Tag('two'), Tag('three'))) | + | @one and @two or @three | ((one and two) or three) | Or(And(Tag('one'), Tag('two')), Tag('three')) | + + Rule: Use --tags-help option with BAD TAG-EXPRESSION + Scenario: Shows Tag-Expression Error for BAD TAG-EXPRESSION + When I run "behave --tags-help --tags='not @one @two'" + Then it should fail with: + """ + TagExpressionError: Syntax error. Expected operator after one + Expression: ( not one two ) + ______________________^ (HERE) + """ + And note that "the error description indicates where the problem is" + And note that "the correct tag-expression may be: not @one and @two" + But the command output should not contain "Traceback" + diff --git a/py.requirements/all.txt b/py.requirements/all.txt index 4298e3328..de0cec006 100644 --- a/py.requirements/all.txt +++ b/py.requirements/all.txt @@ -12,4 +12,4 @@ -r basic.txt -r develop.txt --r json.txt +-r jsonschema.txt diff --git a/py.requirements/ci.tox.txt b/py.requirements/ci.tox.txt index 87c7d5f01..942588e9f 100644 --- a/py.requirements/ci.tox.txt +++ b/py.requirements/ci.tox.txt @@ -3,4 +3,4 @@ # ============================================================================ -r testing.txt -jsonschema +-r jsonschema.txt diff --git a/py.requirements/develop.txt b/py.requirements/develop.txt index f2df9c199..4048b47a4 100644 --- a/py.requirements/develop.txt +++ b/py.requirements/develop.txt @@ -23,7 +23,7 @@ twine >= 1.13.0 modernize >= 0.5 # -- STATIC CODE ANALYSIS: -pylint +-r pylinters.txt # -- REQUIRES: testing -r testing.txt diff --git a/py.requirements/json.txt b/py.requirements/jsonschema.txt similarity index 90% rename from py.requirements/json.txt rename to py.requirements/jsonschema.txt index e765b951b..6487590f0 100644 --- a/py.requirements/json.txt +++ b/py.requirements/jsonschema.txt @@ -4,3 +4,4 @@ # -- OPTIONAL: For JSON validation jsonschema >= 1.3.0 +# MAYBE NOW: check-jsonschema diff --git a/py.requirements/pylinters.txt b/py.requirements/pylinters.txt new file mode 100644 index 000000000..0c836d79d --- /dev/null +++ b/py.requirements/pylinters.txt @@ -0,0 +1,8 @@ +# ============================================================================ +# PYTHON PACKAGE REQUIREMENTS FOR: behave -- Static Code Analysis Tools +# ============================================================================ +# SEE: https://github.com/charliermarsh/ruff + +# -- STATIC CODE ANALYSIS: +pylint +ruff >= 0.0.270 diff --git a/py.requirements/testing.txt b/py.requirements/testing.txt index 1cea6736c..6dfc32c7f 100644 --- a/py.requirements/testing.txt +++ b/py.requirements/testing.txt @@ -14,6 +14,7 @@ mock < 4.0; python_version < '3.6' mock >= 4.0; python_version >= '3.6' PyHamcrest >= 2.0.2; python_version >= '3.0' PyHamcrest < 2.0; python_version < '3.0' +assertpy >= 1.1 # -- NEEDED: By some tests (as proof of concept) # NOTE: path.py-10.1 is required for python2.6 diff --git a/setup.py b/setup.py index 7bdf2f6ca..1668b9ac4 100644 --- a/setup.py +++ b/setup.py @@ -98,6 +98,7 @@ def find_packages_by_root_package(where): "mock >= 4.0; python_version >= '3.6'", "PyHamcrest >= 2.0.2; python_version >= '3.0'", "PyHamcrest < 2.0; python_version < '3.0'", + "assertpy >= 1.1", # -- HINT: path.py => path (python-install-package was renamed for python3) "path >= 13.1.0; python_version >= '3.5'", diff --git a/tasks/__init__.py b/tasks/__init__.py index 9ae899b9a..e96925fe0 100644 --- a/tasks/__init__.py +++ b/tasks/__init__.py @@ -35,8 +35,8 @@ from invoke import Collection # -- TASK-LIBRARY: -# PREPARED: import invoke_cleanup as cleanup -from . import invoke_cleanup as cleanup +# DISABLED: from . import invoke_cleanup as cleanup +import invoke_cleanup as cleanup from . import docs from . import test from . import release diff --git a/tasks/docs.py b/tasks/docs.py index e3d4c4e63..2c9835b1e 100644 --- a/tasks/docs.py +++ b/tasks/docs.py @@ -12,7 +12,7 @@ # -- TASK-LIBRARY: # PREPARED: from invoke_cleanup import cleanup_tasks, cleanup_dirs -from .invoke_cleanup import cleanup_tasks, cleanup_dirs +from invoke_cleanup import cleanup_tasks, cleanup_dirs # ----------------------------------------------------------------------------- diff --git a/tasks/invoke_cleanup.py b/tasks/invoke_cleanup.py deleted file mode 100644 index 4e631c432..000000000 --- a/tasks/invoke_cleanup.py +++ /dev/null @@ -1,447 +0,0 @@ -# -*- coding: UTF-8 -*- -""" -Provides cleanup tasks for invoke build scripts (as generic invoke tasklet). -Simplifies writing common, composable and extendable cleanup tasks. - -PYTHON PACKAGE DEPENDENCIES: - -* path (python >= 3.5) or path.py >= 11.5.0 (as path-object abstraction) -* pathlib (for ant-like wildcard patterns; since: python > 3.5) -* pycmd (required-by: clean_python()) - - -cleanup task: Add Additional Directories and Files to be removed -------------------------------------------------------------------------------- - -Create an invoke configuration file (YAML of JSON) with the additional -configuration data: - -.. code-block:: yaml - - # -- FILE: invoke.yaml - # USE: cleanup.directories, cleanup.files to override current configuration. - cleanup: - # directories: Default directory patterns (can be overwritten). - # files: Default file patterns (can be ovewritten). - extra_directories: - - **/tmp/ - extra_files: - - **/*.log - - **/*.bak - - -Registration of Cleanup Tasks ------------------------------- - -Other task modules often have an own cleanup task to recover the clean state. -The :meth:`cleanup` task, that is provided here, supports the registration -of additional cleanup tasks. Therefore, when the :meth:`cleanup` task is executed, -all registered cleanup tasks will be executed. - -EXAMPLE:: - - # -- FILE: tasks/docs.py - from __future__ import absolute_import - from invoke import task, Collection - from invoke_cleanup import cleanup_tasks, cleanup_dirs - - @task - def clean(ctx): - "Cleanup generated documentation artifacts." - dry_run = ctx.config.run.dry - cleanup_dirs(["build/docs"], dry_run=dry_run) - - namespace = Collection(clean) - ... - - # -- REGISTER CLEANUP TASK: - cleanup_tasks.add_task(clean, "clean_docs") - cleanup_tasks.configure(namespace.configuration()) -""" - -from __future__ import absolute_import, print_function -import os -import sys -from invoke import task, Collection -from invoke.executor import Executor -from invoke.exceptions import Exit, Failure, UnexpectedExit -from invoke.util import cd -from path import Path - -# -- PYTHON BACKWARD COMPATIBILITY: -python_version = sys.version_info[:2] -python35 = (3, 5) # HINT: python3.8 does not raise OSErrors. -if python_version < python35: # noqa - import pathlib2 as pathlib -else: - import pathlib # noqa - - -# ----------------------------------------------------------------------------- -# CONSTANTS: -# ----------------------------------------------------------------------------- -VERSION = "0.3.6" - - -# ----------------------------------------------------------------------------- -# CLEANUP UTILITIES: -# ----------------------------------------------------------------------------- -def execute_cleanup_tasks(ctx, cleanup_tasks, workdir=".", verbose=False): - """Execute several cleanup tasks as part of the cleanup. - - :param ctx: Context object for the tasks. - :param cleanup_tasks: Collection of cleanup tasks (as Collection). - """ - # pylint: disable=redefined-outer-name - executor = Executor(cleanup_tasks, ctx.config) - failure_count = 0 - with cd(workdir) as cwd: - for cleanup_task in cleanup_tasks.tasks: - try: - print("CLEANUP TASK: %s" % cleanup_task) - executor.execute(cleanup_task) - except (Exit, Failure, UnexpectedExit) as e: - print(e) - print("FAILURE in CLEANUP TASK: %s (GRACEFULLY-IGNORED)" % cleanup_task) - failure_count += 1 - - if failure_count: - print("CLEANUP TASKS: %d failure(s) occured" % failure_count) - - -def make_excluded(excluded, config_dir=None, workdir=None): - workdir = workdir or Path.getcwd() - config_dir = config_dir or workdir - workdir = Path(workdir) - config_dir = Path(config_dir) - - excluded2 = [] - for p in excluded: - assert p, "REQUIRE: non-empty" - p = Path(p) - if p.isabs(): - excluded2.append(p.normpath()) - else: - # -- RELATIVE PATH: - # Described relative to config_dir. - # Recompute it relative to current workdir. - p = Path(config_dir)/p - p = workdir.relpathto(p) - excluded2.append(p.normpath()) - excluded2.append(p.abspath()) - return set(excluded2) - - -def is_directory_excluded(directory, excluded): - directory = Path(directory).normpath() - directory2 = directory.abspath() - if (directory in excluded) or (directory2 in excluded): - return True - # -- OTHERWISE: - return False - - -def cleanup_dirs(patterns, workdir=".", excluded=None, - dry_run=False, verbose=False, show_skipped=False): - """Remove directories (and their contents) recursively. - Skips removal if directories does not exist. - - :param patterns: Directory name patterns, like "**/tmp*" (as list). - :param workdir: Current work directory (default=".") - :param dry_run: Dry-run mode indicator (as bool). - """ - excluded = excluded or [] - excluded = set([Path(p) for p in excluded]) - show_skipped = show_skipped or verbose - current_dir = Path(workdir) - python_basedir = Path(Path(sys.executable).dirname()).joinpath("..").abspath() - warn2_counter = 0 - for dir_pattern in patterns: - for directory in path_glob(dir_pattern, current_dir): - if is_directory_excluded(directory, excluded): - print("SKIP-DIR: %s (excluded)" % directory) - continue - directory2 = directory.abspath() - if sys.executable.startswith(directory2): - # -- PROTECT VIRTUAL ENVIRONMENT (currently in use): - # pylint: disable=line-too-long - print("SKIP-SUICIDE: '%s' contains current python executable" % directory) - continue - elif directory2.startswith(python_basedir): - # -- PROTECT VIRTUAL ENVIRONMENT (currently in use): - # HINT: Limit noise in DIAGNOSTIC OUTPUT to X messages. - if warn2_counter <= 4: # noqa - print("SKIP-SUICIDE: '%s'" % directory) - warn2_counter += 1 - continue - - if not directory.isdir(): - if show_skipped: - print("RMTREE: %s (SKIPPED: Not a directory)" % directory) - continue - - if dry_run: - print("RMTREE: %s (dry-run)" % directory) - else: - try: - # -- MAYBE: directory.rmtree(ignore_errors=True) - print("RMTREE: %s" % directory) - directory.rmtree_p() - except OSError as e: - print("RMTREE-FAILED: %s (for: %s)" % (e, directory)) - - -def cleanup_files(patterns, workdir=".", dry_run=False, verbose=False, show_skipped=False): - """Remove files or files selected by file patterns. - Skips removal if file does not exist. - - :param patterns: File patterns, like "**/*.pyc" (as list). - :param workdir: Current work directory (default=".") - :param dry_run: Dry-run mode indicator (as bool). - """ - show_skipped = show_skipped or verbose - current_dir = Path(workdir) - python_basedir = Path(Path(sys.executable).dirname()).joinpath("..").abspath() - error_message = None - error_count = 0 - for file_pattern in patterns: - for file_ in path_glob(file_pattern, current_dir): - if file_.abspath().startswith(python_basedir): - # -- PROTECT VIRTUAL ENVIRONMENT (currently in use): - continue - if not file_.isfile(): - if show_skipped: - print("REMOVE: %s (SKIPPED: Not a file)" % file_) - continue - - if dry_run: - print("REMOVE: %s (dry-run)" % file_) - else: - print("REMOVE: %s" % file_) - try: - file_.remove_p() - except os.error as e: - message = "%s: %s" % (e.__class__.__name__, e) - print(message + " basedir: "+ python_basedir) - error_count += 1 - if not error_message: - error_message = message - if False and error_message: # noqa - class CleanupError(RuntimeError): - pass - raise CleanupError(error_message) - - -def path_glob(pattern, current_dir=None): - """Use pathlib for ant-like patterns, like: "**/*.py" - - :param pattern: File/directory pattern to use (as string). - :param current_dir: Current working directory (as Path, pathlib.Path, str) - :return Resolved Path (as path.Path). - """ - if not current_dir: # noqa - current_dir = pathlib.Path.cwd() - elif not isinstance(current_dir, pathlib.Path): - # -- CASE: string, path.Path (string-like) - current_dir = pathlib.Path(str(current_dir)) - - pattern_path = Path(pattern) - if pattern_path.isabs(): - # -- SPECIAL CASE: Path.glob() only supports relative-path(s) / pattern(s). - if pattern_path.isdir(): - yield pattern_path - return - - # -- HINT: OSError is no longer raised in pathlib2 or python35.pathlib - # try: - for p in current_dir.glob(pattern): - yield Path(str(p)) - # except OSError as e: - # # -- CORNER-CASE 1: x.glob(pattern) may fail with: - # # OSError: [Errno 13] Permission denied: - # # HINT: Directory lacks excutable permissions for traversal. - # # -- CORNER-CASE 2: symlinked endless loop - # # OSError: [Errno 62] Too many levels of symbolic links: - # print("{0}: {1}".format(e.__class__.__name__, e)) - - -# ----------------------------------------------------------------------------- -# GENERIC CLEANUP TASKS: -# ----------------------------------------------------------------------------- -@task(help={ - "workdir": "Directory to clean(up) (default: $CWD).", - "verbose": "Enable verbose mode (default: OFF).", -}) -def clean(ctx, workdir=".", verbose=False): - """Cleanup temporary dirs/files to regain a clean state.""" - dry_run = ctx.config.run.dry - config_dir = getattr(ctx.config, "config_dir", workdir) - directories = list(ctx.config.cleanup.directories or []) - directories.extend(ctx.config.cleanup.extra_directories or []) - files = list(ctx.config.cleanup.files or []) - files.extend(ctx.config.cleanup.extra_files or []) - excluded_directories = list(ctx.config.cleanup.excluded_directories or []) - excluded_directories = make_excluded(excluded_directories, - config_dir=config_dir, workdir=".") - - # -- PERFORM CLEANUP: - execute_cleanup_tasks(ctx, cleanup_tasks) - cleanup_dirs(directories, workdir=workdir, excluded=excluded_directories, - dry_run=dry_run, verbose=verbose) - cleanup_files(files, workdir=workdir, dry_run=dry_run, verbose=verbose) - - # -- CONFIGURABLE EXTENSION-POINT: - # use_cleanup_python = ctx.config.cleanup.use_cleanup_python or False - # if use_cleanup_python: - # clean_python(ctx) - - -@task(name="all", aliases=("distclean",), - help={ - "workdir": "Directory to clean(up) (default: $CWD).", - "verbose": "Enable verbose mode (default: OFF).", -}) -def clean_all(ctx, workdir=".", verbose=False): - """Clean up everything, even the precious stuff. - NOTE: clean task is executed last. - """ - dry_run = ctx.config.run.dry - config_dir = getattr(ctx.config, "config_dir", workdir) - directories = list(ctx.config.cleanup_all.directories or []) - directories.extend(ctx.config.cleanup_all.extra_directories or []) - files = list(ctx.config.cleanup_all.files or []) - files.extend(ctx.config.cleanup_all.extra_files or []) - excluded_directories = list(ctx.config.cleanup_all.excluded_directories or []) - excluded_directories.extend(ctx.config.cleanup.excluded_directories or []) - excluded_directories = make_excluded(excluded_directories, - config_dir=config_dir, workdir=".") - - # -- PERFORM CLEANUP: - # HINT: Remove now directories, files first before cleanup-tasks. - cleanup_dirs(directories, workdir=workdir, excluded=excluded_directories, - dry_run=dry_run, verbose=verbose) - cleanup_files(files, workdir=workdir, dry_run=dry_run, verbose=verbose) - execute_cleanup_tasks(ctx, cleanup_all_tasks) - clean(ctx, workdir=workdir, verbose=verbose) - - # -- CONFIGURABLE EXTENSION-POINT: - # use_cleanup_python1 = ctx.config.cleanup.use_cleanup_python or False - # use_cleanup_python2 = ctx.config.cleanup_all.use_cleanup_python or False - # if use_cleanup_python2 and not use_cleanup_python1: - # clean_python(ctx) - - -@task(aliases=["python"]) -def clean_python(ctx, workdir=".", verbose=False): - """Cleanup python related files/dirs: *.pyc, *.pyo, ...""" - dry_run = ctx.config.run.dry or False - # MAYBE NOT: "**/__pycache__" - cleanup_dirs(["build", "dist", "*.egg-info", "**/__pycache__"], - workdir=workdir, dry_run=dry_run, verbose=verbose) - if not dry_run: - ctx.run("py.cleanup") - cleanup_files(["**/*.pyc", "**/*.pyo", "**/*$py.class"], - workdir=workdir, dry_run=dry_run, verbose=verbose) - - -@task(help={ - "path": "Path to cleanup.", - "interactive": "Enable interactive mode.", - "force": "Enable force mode.", - "options": "Additional git-clean options", -}) -def git_clean(ctx, path=None, interactive=False, force=False, - dry_run=False, options=None): - """Perform git-clean command to cleanup the worktree of a git repository. - - BEWARE: This may remove any precious files that are not checked in. - WARNING: DANGEROUS COMMAND. - """ - args = [] - force = force or ctx.config.git_clean.force - path = path or ctx.config.git_clean.path or "." - interactive = interactive or ctx.config.git_clean.interactive - dry_run = dry_run or ctx.config.run.dry or ctx.config.git_clean.dry_run - - if interactive: - args.append("--interactive") - if force: - args.append("--force") - if dry_run: - args.append("--dry-run") - args.append(options or "") - args = " ".join(args).strip() - - ctx.run("git clean {options} {path}".format(options=args, path=path)) - - -# ----------------------------------------------------------------------------- -# TASK CONFIGURATION: -# ----------------------------------------------------------------------------- -CLEANUP_EMPTY_CONFIG = { - "directories": [], - "files": [], - "extra_directories": [], - "extra_files": [], - "excluded_directories": [], - "excluded_files": [], - "use_cleanup_python": False, -} -def make_cleanup_config(**kwargs): - config_data = CLEANUP_EMPTY_CONFIG.copy() - config_data.update(kwargs) - return config_data - - -namespace = Collection(clean_all, clean_python) -namespace.add_task(clean, default=True) -namespace.add_task(git_clean) -namespace.configure({ - "cleanup": make_cleanup_config( - files=["**/*.bak", "**/*.log", "**/*.tmp", "**/.DS_Store"], - excluded_directories=[".git", ".hg", ".bzr", ".svn"], - ), - "cleanup_all": make_cleanup_config( - directories=[".venv*", ".tox", "downloads", "tmp"], - ), - "git_clean": { - "interactive": True, - "force": False, - "path": ".", - "dry_run": False, - }, -}) - - -# -- EXTENSION-POINT: CLEANUP TASKS (called by: clean, clean_all task) -# NOTE: Can be used by other tasklets to register cleanup tasks. -cleanup_tasks = Collection("cleanup_tasks") -cleanup_all_tasks = Collection("cleanup_all_tasks") - -# -- EXTEND NORMAL CLEANUP-TASKS: -# DISABLED: cleanup_tasks.add_task(clean_python) - -# ----------------------------------------------------------------------------- -# EXTENSION-POINT: CONFIGURATION HELPERS: Can be used from other task modules -# ----------------------------------------------------------------------------- -def config_add_cleanup_dirs(directories): - # pylint: disable=protected-access - the_cleanup_directories = namespace._configuration["cleanup"]["directories"] - the_cleanup_directories.extend(directories) - -def config_add_cleanup_files(files): - # pylint: disable=protected-access - the_cleanup_files = namespace._configuration["cleanup"]["files"] - the_cleanup_files.extend(files) - # namespace.configure({"cleanup": {"files": files}}) - # print("DIAG cleanup.config.cleanup: %r" % namespace.configuration()) - -def config_add_cleanup_all_dirs(directories): - # pylint: disable=protected-access - the_cleanup_directories = namespace._configuration["cleanup_all"]["directories"] - the_cleanup_directories.extend(directories) - -def config_add_cleanup_all_files(files): - # pylint: disable=protected-access - the_cleanup_files = namespace._configuration["cleanup_all"]["files"] - the_cleanup_files.extend(files) diff --git a/tasks/py.requirements.txt b/tasks/py.requirements.txt index a02e6e0e2..3990858b1 100644 --- a/tasks/py.requirements.txt +++ b/tasks/py.requirements.txt @@ -21,5 +21,7 @@ path.py >= 11.5.0; python_version < '3.5' pathlib; python_version <= '3.4' backports.shutil_which; python_version <= '3.3' +git+https://github.com/jenisys/invoke-cleanup@v0.3.7 + # -- SECTION: develop requests diff --git a/tasks/release.py b/tasks/release.py index e17a46fc1..f8626f347 100644 --- a/tasks/release.py +++ b/tasks/release.py @@ -51,7 +51,7 @@ from __future__ import absolute_import, print_function from invoke import Collection, task -from .invoke_cleanup import path_glob +from invoke_cleanup import path_glob from ._dry_run import DryRunContext diff --git a/tasks/test.py b/tasks/test.py index d6b4189ee..685e8e6eb 100644 --- a/tasks/test.py +++ b/tasks/test.py @@ -10,7 +10,7 @@ # -- TASK-LIBRARY: # PREPARED: from invoke_cleanup import cleanup_tasks, cleanup_dirs, cleanup_files -from .invoke_cleanup import cleanup_tasks, cleanup_dirs, cleanup_files +from invoke_cleanup import cleanup_tasks, cleanup_dirs, cleanup_files # --------------------------------------------------------------------------- diff --git a/tests/issues/test_issue1054.py b/tests/issues/test_issue1054.py new file mode 100644 index 000000000..adf572706 --- /dev/null +++ b/tests/issues/test_issue1054.py @@ -0,0 +1,41 @@ +""" +SEE: https://github.com/behave/behave/issues/1054 +""" + +from __future__ import absolute_import, print_function +from behave.__main__ import run_behave +from behave.configuration import Configuration +from behave.tag_expression import make_tag_expression +import pytest +from assertpy import assert_that + + +def test_syndrome_with_core(capsys): + """Verifies the problem with the core functionality.""" + cmdline_tags = ["fish or fries", "beer and water"] + tag_expression = make_tag_expression(cmdline_tags) + + tag_expression_text1 = tag_expression.to_string() + tag_expression_text2 = repr(tag_expression) + expected1 = "((fish or fries) and (beer and water))" + expected2 = "And(Or(Literal('fish'), Literal('fries')), And(Literal('beer'), Literal('water')))" + assert tag_expression_text1 == expected1 + assert tag_expression_text2 == expected2 + + +@pytest.mark.parametrize("tags_options", [ + ["--tags", "fish or fries", "--tags", "beer and water"], + # ['--tags="fish or fries"', '--tags="beer and water"'], + # ["--tags='fish or fries'", "--tags='beer and water'"], +]) +def test_syndrome_functional(tags_options, capsys): + """Verifies that the issue is fixed.""" + command_args = tags_options + ["--tags-help", "--verbose"] + config = Configuration(command_args, load_config=False) + run_behave(config) + + captured = capsys.readouterr() + expected_part1 = "CURRENT TAG_EXPRESSION: ((fish or fries) and (beer and water))" + expected_part2 = "means: And(Or(Tag('fish'), Tag('fries')), And(Tag('beer'), Tag('water')))" + assert_that(captured.out).contains(expected_part1) + assert_that(captured.out).contains(expected_part2) From 6ba94b9c6d4069cd1a877e6665c5ea17391cbc83 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 28 May 2023 17:40:15 +0200 Subject: [PATCH 170/240] FIX: Captured output from "behave --lang-list" * CAUSED BY: gherkin-languages.json / i14n.py update. --- issue.features/issue0309.feature | 1 + 1 file changed, 1 insertion(+) diff --git a/issue.features/issue0309.feature b/issue.features/issue0309.feature index 2bfc548b4..f64848685 100644 --- a/issue.features/issue0309.feature +++ b/issue.features/issue0309.feature @@ -37,6 +37,7 @@ Feature: Issue #309 -- behave --lang-list fails on Python3 ar: العربية / Arabic ast: asturianu / Asturian az: Azərbaycanca / Azerbaijani + be: Беларуская / Belarusian bg: български / Bulgarian bm: Bahasa Melayu / Malay bs: Bosanski / Bosnian From ab38aee1efaaad179d68c918682480a403ba0c40 Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 8 Jun 2023 14:27:48 +0200 Subject: [PATCH 171/240] CLEANUP: behave.matchers * Provide StepMatcherFactory to better keep track of things * Matcher classes: Provide register_type(), ... to better keep track of Matcher specific type-converters. OTHERWISE: * ADDED: behave.api.step_matchers -- Provides public API for step writers * behave._stepimport: Added SimpleStepContainer to simplify reuse HINT: Moved here from tests.api.testing_support module. --- .gitignore | 1 + .pylintrc | 4 +- behave/__init__.py | 15 +- behave/_stepimport.py | 96 ++++---- behave/api/step_matchers.py | 32 +++ behave/exception.py | 24 +- behave/matchers.py | 383 +++++++++++++++++++++++-------- behave/runner_util.py | 66 +++--- behave/step_registry.py | 4 +- docs/tutorial.rst | 169 ++++++++++---- issue.features/issue0073.feature | 12 +- issue.features/issue0547.feature | 4 +- tests/api/_test_async_step34.py | 34 ++- tests/api/_test_async_step35.py | 24 +- tests/api/test_async_step.py | 8 +- tests/api/testing_support.py | 53 ----- tests/unit/test_matchers.py | 200 ++++++++++++++-- tests/unit/test_step_registry.py | 8 +- 18 files changed, 808 insertions(+), 329 deletions(-) create mode 100644 behave/api/step_matchers.py diff --git a/.gitignore b/.gitignore index 7b08d10ca..d52e7fdb5 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ reports/ tools/virtualenvs .cache/ .direnv/ +.fleet/ .idea/ .pytest_cache/ .tox/ diff --git a/.pylintrc b/.pylintrc index 49d5195a2..bc571b11a 100644 --- a/.pylintrc +++ b/.pylintrc @@ -235,7 +235,7 @@ method-naming-style=snake_case # Regular expression matching correct method names. Overrides method-naming- # style. If left empty, method names will be checked with the set naming style. -method-rgx=[a-z_][a-z0-9_]{2,30}$ +method-rgx=[a-z_][a-z0-9_]{2,40}$ # Naming style matching correct module names. module-naming-style=snake_case @@ -362,7 +362,7 @@ indent-after-paren=4 indent-string=' ' # Maximum number of characters on a single line. -max-line-length=85 +max-line-length=100 # Maximum number of lines in a module. max-module-lines=1000 diff --git a/behave/__init__.py b/behave/__init__.py index c913f986e..f00d6350a 100644 --- a/behave/__init__.py +++ b/behave/__init__.py @@ -17,15 +17,22 @@ """ from __future__ import absolute_import -from behave.step_registry import given, when, then, step, Given, When, Then, Step # pylint: disable=no-name-in-module -from behave.matchers import use_step_matcher, step_matcher, register_type +# pylint: disable=no-name-in-module +from behave.step_registry import given, when, then, step, Given, When, Then, Step +# pylint: enable=no-name-in-module +from behave.api.step_matchers import ( + register_type, + use_default_step_matcher, use_step_matcher, + step_matcher +) from behave.fixture import fixture, use_fixture -from behave.version import VERSION as __version__ +from behave.version import VERSION as __version__ # noqa: F401 # pylint: disable=undefined-all-variable __all__ = [ - "given", "when", "then", "step", "use_step_matcher", "register_type", + "given", "when", "then", "step", "Given", "When", "Then", "Step", + "use_default_step_matcher", "use_step_matcher", "register_type", "fixture", "use_fixture", # -- DEPRECATING: "step_matcher" diff --git a/behave/_stepimport.py b/behave/_stepimport.py index 3015639f6..e4372f63c 100644 --- a/behave/_stepimport.py +++ b/behave/_stepimport.py @@ -1,4 +1,6 @@ # -*- coding: UTF-8 -*- +# pylint: disable=useless-object-inheritance +# pylint: disable=super-with-arguments """ This module provides low-level helper functionality during step imports. @@ -15,10 +17,12 @@ from types import ModuleType import os.path import sys -from behave import step_registry as _step_registry -# from behave import matchers as _matchers import six +from behave import step_registry as _step_registry +from behave.matchers import StepMatcherFactory +from behave.step_registry import StepRegistry + # ----------------------------------------------------------------------------- # UTILITY FUNCTIONS: @@ -26,10 +30,22 @@ def setup_api_with_step_decorators(module, step_registry): _step_registry.setup_step_decorators(module, step_registry) -def setup_api_with_matcher_functions(module, matcher_factory): - module.use_step_matcher = matcher_factory.use_step_matcher - module.step_matcher = matcher_factory.use_step_matcher - module.register_type = matcher_factory.register_type +def setup_api_with_matcher_functions(module, step_matcher_factory): + # -- PUBLIC API: Same as behave.api.step_matchers + module.use_default_step_matcher = step_matcher_factory.use_default_step_matcher + module.use_step_matcher = step_matcher_factory.use_step_matcher + module.step_matcher = step_matcher_factory.use_step_matcher + module.register_type = step_matcher_factory.register_type + + +class SimpleStepContainer(object): + def __init__(self, step_registry=None): + if step_registry is None: + step_registry = StepRegistry() + self.step_matcher_factory = StepMatcherFactory() + self.step_registry = step_registry + self.step_registry.step_matcher_factory = self.step_matcher_factory + # ----------------------------------------------------------------------------- # FAKE MODULE CLASSES: For step imports @@ -60,14 +76,20 @@ def __init__(self, step_registry): class StepMatchersModule(FakeModule): - __all__ = ["use_step_matcher", "register_type", "step_matcher"] + __all__ = [ + "use_default_step_matcher", + "use_step_matcher", + "step_matcher", # -- DEPRECATING + "register_type" + ] - def __init__(self, matcher_factory): + def __init__(self, step_matcher_factory): super(StepMatchersModule, self).__init__("behave.matchers") - self.matcher_factory = matcher_factory - setup_api_with_matcher_functions(self, matcher_factory) - self.use_default_step_matcher = matcher_factory.use_default_step_matcher - self.get_matcher = matcher_factory.make_matcher + self.step_matcher_factory = step_matcher_factory + setup_api_with_matcher_functions(self, step_matcher_factory) + self.make_matcher = step_matcher_factory.make_matcher + # -- DEPRECATED-FUNCTION-COMPATIBILITY + # self.get_matcher = self.make_matcher # self.matcher_mapping = matcher_mapping or _matchers.matcher_mapping.copy() # self.current_matcher = current_matcher or _matchers.current_matcher @@ -78,36 +100,19 @@ def __init__(self, matcher_factory): self.__name__ = "behave.matchers" # self.__path__ = [os.path.abspath(here)] - # def use_step_matcher(self, name): - # self.matcher_factory.use_step_matcher(name) - # # self.current_matcher = self.matcher_mapping[name] - # - # def use_default_step_matcher(self, name=None): - # self.matcher_factory.use_default_step_matcher(name=None) - # - # def get_matcher(self, func, pattern): - # # return self.current_matcher - # return self.matcher_factory.make_matcher(func, pattern) - # - # def register_type(self, **kwargs): - # # _matchers.register_type(**kwargs) - # self.matcher_factory.register_type(**kwargs) - # - # step_matcher = use_step_matcher - class BehaveModule(FakeModule): __all__ = StepRegistryModule.__all__ + StepMatchersModule.__all__ - def __init__(self, step_registry, matcher_factory=None): - if matcher_factory is None: - matcher_factory = step_registry.step_matcher_factory - assert matcher_factory is not None + def __init__(self, step_registry, step_matcher_factory=None): + if step_matcher_factory is None: + step_matcher_factory = step_registry.step_step_matcher_factory + assert step_matcher_factory is not None super(BehaveModule, self).__init__("behave") setup_api_with_step_decorators(self, step_registry) - setup_api_with_matcher_functions(self, matcher_factory) - self.use_default_step_matcher = matcher_factory.use_default_step_matcher - assert step_registry.matcher_factory == matcher_factory + setup_api_with_matcher_functions(self, step_matcher_factory) + self.use_default_step_matcher = step_matcher_factory.use_default_step_matcher + assert step_registry.step_matcher_factory == step_matcher_factory # -- INJECT PYTHON PACKAGE META-DATA: # REQUIRED-FOR: Non-fake submodule imports (__path__). @@ -122,13 +127,13 @@ class StepImportModuleContext(object): def __init__(self, step_container): self.step_registry = step_container.step_registry - self.matcher_factory = step_container.matcher_factory - assert self.step_registry.matcher_factory == self.matcher_factory - self.step_registry.matcher_factory = self.matcher_factory + self.step_matcher_factory = step_container.step_matcher_factory + assert self.step_registry.step_matcher_factory == self.step_matcher_factory + self.step_registry.step_matcher_factory = self.step_matcher_factory step_registry_module = StepRegistryModule(self.step_registry) - step_matchers_module = StepMatchersModule(self.matcher_factory) - behave_module = BehaveModule(self.step_registry, self.matcher_factory) + step_matchers_module = StepMatchersModule(self.step_matcher_factory) + behave_module = BehaveModule(self.step_registry, self.step_matcher_factory) self.modules = { "behave": behave_module, "behave.matchers": step_matchers_module, @@ -137,14 +142,16 @@ def __init__(self, step_container): # self.default_matcher = self.step_matchers_module.current_matcher def reset_current_matcher(self): - self.matcher_factory.use_default_step_matcher() + self.step_matcher_factory.use_default_step_matcher() + _step_import_lock = Lock() unknown = object() @contextmanager def use_step_import_modules(step_container): - """Redirect any step/type registration to the runner's step-context object + """ + Redirect any step/type registration to the runner's step-context object during step imports by using fake modules (instead of using module-globals). This allows that multiple runners can be used without polluting the @@ -161,7 +168,8 @@ def load_step_definitions(self, ...): ... import_context.reset_current_matcher() - :param step_container: Step context object with step_registry, matcher_factory. + :param step_container: + Step context object with step_registry, step_matcher_factory. """ orig_modules = {} import_context = StepImportModuleContext(step_container) diff --git a/behave/api/step_matchers.py b/behave/api/step_matchers.py new file mode 100644 index 000000000..4e898bacd --- /dev/null +++ b/behave/api/step_matchers.py @@ -0,0 +1,32 @@ +# -*- coding: UTF-8 -*- +""" +Official API for step writers that want to use step-matchers. +""" + +from __future__ import absolute_import, print_function +import warnings +from behave import matchers as _step_matchers + + +def register_type(**kwargs): + _step_matchers.register_type(**kwargs) + + +def use_default_step_matcher(name=None): + return _step_matchers.use_default_step_matcher(name=name) + +def use_step_matcher(name): + return _step_matchers.use_step_matcher(name) + +def step_matcher(name): + """DEPRECATED, use :func:`use_step_matcher()` instead.""" + # -- BACKWARD-COMPATIBLE NAME: Mark as deprecated. + warnings.warn("deprecated: Use 'use_step_matcher()' instead", + DeprecationWarning, stacklevel=2) + return use_step_matcher(name) + + +# -- REUSE: API function descriptions (aka: docstrings). +register_type.__doc__ = _step_matchers.register_type.__doc__ +use_step_matcher.__doc__ = _step_matchers.use_step_matcher.__doc__ +use_default_step_matcher.__doc__ = _step_matchers.use_default_step_matcher.__doc__ diff --git a/behave/exception.py b/behave/exception.py index ce159dad3..e00201b83 100644 --- a/behave/exception.py +++ b/behave/exception.py @@ -6,21 +6,30 @@ .. versionadded:: 1.2.7 """ -from __future__ import absolute_import +from __future__ import absolute_import, print_function # -- USE MODERN EXCEPTION CLASSES: # COMPATIBILITY: Emulated if not supported yet by Python version. -from behave.compat.exceptions import FileNotFoundError, ModuleNotFoundError - +from behave.compat.exceptions import ( + FileNotFoundError, ModuleNotFoundError # noqa: F401 +) # --------------------------------------------------------------------------- # EXCEPTION/ERROR CLASSES: # --------------------------------------------------------------------------- class ConstraintError(RuntimeError): - """Used if a constraint/precondition is not fulfilled at runtime. + """ + Used if a constraint/precondition is not fulfilled at runtime. .. versionadded:: 1.2.7 """ +class ResourceExistsError(ConstraintError): + """ + Used if you try to register a resource and another exists already + with the same name. + + .. versionadded:: 1.2.7 + """ class ConfigError(Exception): """Used if the configuration is (partially) invalid.""" @@ -62,3 +71,10 @@ class InvalidClassError(TypeError): * not a class * not subclass of a required class """ + +class NotSupportedWarning(Warning): + """ + Used if a certain functionality is not supported. + + .. versionadded:: 1.2.7 + """ diff --git a/behave/matchers.py b/behave/matchers.py index 0fee0c79c..a136d918e 100644 --- a/behave/matchers.py +++ b/behave/matchers.py @@ -1,4 +1,8 @@ -# -*- coding: utf-8 -*- +# -*- coding: UTF-8 -*- +# pylint: disable=redundant-u-string-prefix +# pylint: disable=super-with-arguments +# pylint: disable=consider-using-f-string +# pylint: disable=useless-object-inheritance """ This module provides the step matchers functionality that matches a step definition (as text) with step-functions that implement this step. @@ -6,12 +10,14 @@ from __future__ import absolute_import, print_function, with_statement import copy +import inspect import re import warnings -import parse import six +import parse from parse_type import cfparse from behave._types import ChainedExceptionUtil, ExceptionUtil +from behave.exception import NotSupportedWarning, ResourceExistsError from behave.model_core import Argument, FileLocation, Replayable @@ -155,6 +161,17 @@ class Matcher(object): """ schema = u"@%s('%s')" # Schema used to describe step definition (matcher) + @classmethod + def register_type(cls, **kwargs): + """Register one (or more) user-defined types used for matching types + in step patterns of this matcher. + """ + raise NotImplementedError() + + @classmethod + def clear_registered_types(cls): + raise NotImplementedError() + def __init__(self, func, pattern, step_type=None): self.func = func self.pattern = pattern @@ -225,6 +242,47 @@ class ParseMatcher(Matcher): custom_types = {} parser_class = parse.Parser + @classmethod + def register_type(cls, **kwargs): + r""" + Register one (or more) user-defined types used for matching types + in step patterns of this matcher. + + A type converter should follow :pypi:`parse` module rules. + In general, a type converter is a function that converts text (as string) + into a value-type (type converted value). + + EXAMPLE: + + .. code-block:: python + + from behave import register_type, given + import parse + + + # -- TYPE CONVERTER: For a simple, positive integer number. + @parse.with_pattern(r"\d+") + def parse_number(text): + return int(text) + + # -- REGISTER TYPE-CONVERTER: With behave + register_type(Number=parse_number) + # ALTERNATIVE: + current_step_matcher = use_step_matcher("parse") + current_step_matcher.register_type(Number=parse_number) + + # -- STEP DEFINITIONS: Use type converter. + @given('{amount:Number} vehicles') + def step_impl(context, amount): + assert isinstance(amount, int) + """ + cls.custom_types.update(**kwargs) + + @classmethod + def clear_registered_types(cls): + cls.custom_types.clear() + + def __init__(self, func, pattern, step_type=None): super(ParseMatcher, self).__init__(func, pattern, step_type) self.parser = self.parser_class(pattern, self.custom_types) @@ -260,44 +318,27 @@ class CFParseMatcher(ParseMatcher): parser_class = cfparse.Parser -def register_type(**kw): - r"""Registers a custom type that will be available to "parse" - for type conversion during step matching. - - Converters should be supplied as ``name=callable`` arguments (or as dict). - - A type converter should follow :pypi:`parse` module rules. - In general, a type converter is a function that converts text (as string) - into a value-type (type converted value). - - EXAMPLE: - - .. code-block:: python - - from behave import register_type, given - import parse - - # -- TYPE CONVERTER: For a simple, positive integer number. - @parse.with_pattern(r"\d+") - def parse_number(text): - return int(text) - - # -- REGISTER TYPE-CONVERTER: With behave - register_type(Number=parse_number) +class RegexMatcher(Matcher): + @classmethod + def register_type(cls, **kwargs): + """ + Register one (or more) user-defined types used for matching types + in step patterns of this matcher. - # -- STEP DEFINITIONS: Use type converter. - @given('{amount:Number} vehicles') - def step_impl(context, amount): - assert isinstance(amount, int) - """ - ParseMatcher.custom_types.update(kw) + NOTE: + This functionality is not supported for :class:`RegexMatcher` classes. + """ + raise NotSupportedWarning("%s.register_type" % cls.__name__) + @classmethod + def clear_registered_types(cls): + pass # -- HINT: GRACEFULLY ignored. -class RegexMatcher(Matcher): def __init__(self, func, pattern, step_type=None): super(RegexMatcher, self).__init__(func, pattern, step_type) self.regex = re.compile(self.pattern) + def check_match(self, step): m = self.regex.match(step) if not m: @@ -314,7 +355,8 @@ def check_match(self, step): return args class SimplifiedRegexMatcher(RegexMatcher): - """Simplified regular expression step-matcher that automatically adds + """ + Simplified regular expression step-matcher that automatically adds start-of-line/end-of-line matcher symbols to string: .. code-block:: python @@ -332,7 +374,8 @@ def __init__(self, func, pattern, step_type=None): class CucumberRegexMatcher(RegexMatcher): - """Compatible to (old) Cucumber style regular expressions. + """ + Compatible to (old) Cucumber style regular expressions. Text must contain start-of-line/end-of-line matcher symbols to string: .. code-block:: python @@ -341,79 +384,231 @@ class CucumberRegexMatcher(RegexMatcher): def step_impl(context): pass """ -matcher_mapping = { - "parse": ParseMatcher, - "cfparse": CFParseMatcher, - "re": SimplifiedRegexMatcher, - # -- BACKWARD-COMPATIBLE REGEX MATCHER: Old Cucumber compatible style. - # To make it the default step-matcher use the following snippet: - # # -- FILE: features/environment.py - # from behave import use_step_matcher - # def before_all(context): - # use_step_matcher("re0") - "re0": CucumberRegexMatcher, -} -current_matcher = ParseMatcher # pylint: disable=invalid-name +# ----------------------------------------------------------------------------- +# STEP MATCHER FACTORY (for public API) +# ----------------------------------------------------------------------------- +class StepMatcherFactory(object): + """ + This class provides functionality for the public API of step-matchers. + + It allows to change the step-matcher class in use + while parsing step definitions. + This allows to use multiple step-matcher classes: + + * in the same steps module + * in different step modules + There are several step-matcher classes available in **behave**: -def use_step_matcher(name): - """Change the parameter matcher used in parsing step text. + * **parse** (the default, based on: :pypi:`parse`): + * **cfparse** (extends: :pypi:`parse`, requires: :pypi:`parse_type`) + * **re** (using regular expressions) - The change is immediate and may be performed between step definitions in - your step implementation modules - allowing adjacent steps to use different - matchers if necessary. + You may `define your own step-matcher class`_. - There are several parsers available in *behave* (by default): + .. _`define your own step-matcher class`: api.html#step-parameters - **parse** (the default, based on: :pypi:`parse`) - Provides a simple parser that replaces regular expressions for - step parameters with a readable syntax like ``{param:Type}``. - The syntax is inspired by the Python builtin ``string.format()`` - function. - Step parameters must use the named fields syntax of :pypi:`parse` - in step definitions. The named fields are extracted, - optionally type converted and then used as step function arguments. + parse + ------ - Supports type conversions by using type converters - (see :func:`~behave.register_type()`). + Provides a simple parser that replaces regular expressions for + step parameters with a readable syntax like ``{param:Type}``. + The syntax is inspired by the Python builtin ``string.format()`` function. + Step parameters must use the named fields syntax of :pypi:`parse` + in step definitions. The named fields are extracted, + optionally type converted and then used as step function arguments. - **cfparse** (extends: :pypi:`parse`, requires: :pypi:`parse_type`) - Provides an extended parser with "Cardinality Field" (CF) support. - Automatically creates missing type converters for related cardinality - as long as a type converter for cardinality=1 is provided. - Supports parse expressions like: + Supports type conversions by using type converters + (see :func:`~behave.register_type()`). - * ``{values:Type+}`` (cardinality=1..N, many) - * ``{values:Type*}`` (cardinality=0..N, many0) - * ``{value:Type?}`` (cardinality=0..1, optional) + cfparse + ------- - Supports type conversions (as above). + Provides an extended parser with "Cardinality Field" (CF) support. + Automatically creates missing type converters for related cardinality + as long as a type converter for cardinality=1 is provided. + Supports parse expressions like: - **re** - This uses full regular expressions to parse the clause text. You will - need to use named groups "(?P...)" to define the variables pulled - from the text and passed to your ``step()`` function. + * ``{values:Type+}`` (cardinality=1..N, many) + * ``{values:Type*}`` (cardinality=0..N, many0) + * ``{value:Type?}`` (cardinality=0..1, optional) - Type conversion is **not supported**. - A step function writer may implement type conversion - inside the step function (implementation). + Supports type conversions (as above). - You may `define your own matcher`_. + re (regex based parser) + ----------------------- - .. _`define your own matcher`: api.html#step-parameters - """ - global current_matcher # pylint: disable=global-statement - current_matcher = matcher_mapping[name] + This uses full regular expressions to parse the clause text. You will + need to use named groups "(?P...)" to define the variables pulled + from the text and passed to your ``step()`` function. -def step_matcher(name): + Type conversion is **not supported**. + A step function writer may implement type conversion + inside the step function (implementation). """ - DEPRECATED, use :func:`use_step_matcher()` instead. - """ - # -- BACKWARD-COMPATIBLE NAME: Mark as deprecated. - warnings.warn("deprecated: Use 'use_step_matcher()' instead", - DeprecationWarning, stacklevel=2) - use_step_matcher(name) + MATCHER_MAPPING = { + "parse": ParseMatcher, + "cfparse": CFParseMatcher, + "re": SimplifiedRegexMatcher, + + # -- BACKWARD-COMPATIBLE REGEX MATCHER: Old Cucumber compatible style. + # To make it the default step-matcher use the following snippet: + # # -- FILE: features/environment.py + # from behave import use_step_matcher + # def before_all(context): + # use_step_matcher("re0") + "re0": CucumberRegexMatcher, + } + DEFAULT_MATCHER_NAME = "parse" + + def __init__(self, matcher_mapping=None, default_matcher_name=None): + if matcher_mapping is None: + matcher_mapping = self.MATCHER_MAPPING.copy() + if default_matcher_name is None: + default_matcher_name = self.DEFAULT_MATCHER_NAME + + self.matcher_mapping = matcher_mapping + self.initial_matcher_name = default_matcher_name + self.default_matcher_name = default_matcher_name + self.default_matcher = matcher_mapping[default_matcher_name] + self._current_matcher = self.default_matcher + assert self.default_matcher in self.matcher_mapping.values() + + def reset(self): + self.use_default_step_matcher(self.initial_matcher_name) + self.clear_registered_types() + + @property + def current_matcher(self): + # -- ENSURE: READ-ONLY access + return self._current_matcher + + def register_type(self, **kwargs): + """ + Registers one (or more) custom type that will be available + by some matcher classes, like the :class:`ParseMatcher` and its + derived classes, for type conversion during step matching. + + Converters should be supplied as ``name=callable`` arguments (or as dict). + A type converter should follow the rules of its :class:`Matcher` class. + """ + self.current_matcher.register_type(**kwargs) + + def clear_registered_types(self): + for step_matcher_class in self.matcher_mapping.values(): + step_matcher_class.clear_registered_types() + + def register_step_matcher_class(self, name, step_matcher_class, + override=False): + """Register a new step-matcher class to use. + + :param name: Name of the step-matcher to use. + :param step_matcher_class: Step-matcher class. + :param override: Use ``True`` to override any existing step-matcher class. + """ + assert inspect.isclass(step_matcher_class) + assert issubclass(step_matcher_class, Matcher), "OOPS: %r" % step_matcher_class + known_class = self.matcher_mapping.get(name, None) + if (not override and + known_class is not None and known_class is not step_matcher_class): + message = "ALREADY REGISTERED: {name}={class_name}".format( + name=name, class_name=known_class.__name__) + raise ResourceExistsError(message) + + self.matcher_mapping[name] = step_matcher_class + + def use_step_matcher(self, name): + """ + Changes the step-matcher class to use while parsing step definitions. + This allows to use multiple step-matcher classes: + + * in the same steps module + * in different step modules + + There are several step-matcher classes available in **behave**: + + * **parse** (the default, based on: :pypi:`parse`): + * **cfparse** (extends: :pypi:`parse`, requires: :pypi:`parse_type`) + * **re** (using regular expressions) + + :param name: Name of the step-matcher class. + :return: Current step-matcher class that is now in use. + """ + self._current_matcher = self.matcher_mapping[name] + return self._current_matcher + + def use_default_step_matcher(self, name=None): + """Use the default step-matcher. + If a :param:`name` is provided, the default step-matcher is defined. + + :param name: Optional, use it to specify the default step-matcher. + :return: Current step-matcher class (or object). + """ + if name: + self.default_matcher = self.matcher_mapping[name] + self.default_matcher_name = name + self._current_matcher = self.default_matcher + return self._current_matcher + + def use_current_step_matcher_as_default(self): + self.default_matcher = self._current_matcher + + def make_matcher(self, func, step_text, step_type=None): + return self.current_matcher(func, step_text, step_type=step_type) + + +# -- MODULE INSTANCE: +_the_matcher_factory = StepMatcherFactory() + + +# ----------------------------------------------------------------------------- +# INTERNAL API FUNCTIONS: +# ----------------------------------------------------------------------------- +def get_matcher_factory(): + return _the_matcher_factory + + +def make_matcher(func, step_text, step_type=None): + return _the_matcher_factory.make_matcher(func, step_text, + step_type=step_type) + + +def use_current_step_matcher_as_default(): + return _the_matcher_factory.use_current_step_matcher_as_default() + + + +# ----------------------------------------------------------------------------- +# PUBLIC API FOR: step-writers +# ----------------------------------------------------------------------------- +def use_step_matcher(name): + return _the_matcher_factory.use_step_matcher(name) + + +def use_default_step_matcher(name=None): + return _the_matcher_factory.use_default_step_matcher(name=name) + + +def register_type(**kwargs): + _the_matcher_factory.register_type(**kwargs) + + +# -- REUSE DOCSTRINGS: +register_type.__doc__ = StepMatcherFactory.register_type.__doc__ +use_step_matcher.__doc__ = StepMatcherFactory.use_step_matcher.__doc__ +use_default_step_matcher.__doc__ = ( + StepMatcherFactory.use_default_step_matcher.__doc__) + + +# ----------------------------------------------------------------------------- +# BEHAVE EXTENSION-POINT: Add your own step-matcher class(es) +# ----------------------------------------------------------------------------- +def register_step_matcher_class(name, step_matcher_class, override=False): + _the_matcher_factory.register_step_matcher_class(name, step_matcher_class, + override=override) + -def get_matcher(func, pattern): - return current_matcher(func, pattern) +# -- REUSE DOCSTRINGS: +register_step_matcher_class.__doc__ = ( + StepMatcherFactory.register_step_matcher_class.__doc__) diff --git a/behave/runner_util.py b/behave/runner_util.py index bb51418e5..9b33d86fe 100644 --- a/behave/runner_util.py +++ b/behave/runner_util.py @@ -1,19 +1,29 @@ -# -*- coding: utf-8 -*- +# -*- coding: UTF-8 -*- +# pylint: disable=redundant-u-string-prefix +# pylint: disable=consider-using-f-string +# pylint: disable=useless-object-inheritance """ Contains utility functions and classes for Runners. """ -from __future__ import absolute_import +from __future__ import absolute_import, print_function from bisect import bisect +from collections import OrderedDict import glob import os.path import re import sys from six import string_types + from behave import parser -from behave.exception import \ - FileNotFoundError, InvalidFileLocationError, InvalidFilenameError +# pylint: disable=redefined-builtin +from behave.exception import ( + FileNotFoundError, + InvalidFileLocationError, InvalidFilenameError +) +# pylint: enable=redefined-builtin from behave.model_core import FileLocation +from behave.model import Feature, Rule, ScenarioOutline, Scenario from behave.textutil import ensure_stream_with_encoder # LAZY: from behave.step_registry import setup_step_decorators @@ -45,10 +55,6 @@ def parse(cls, text): # ----------------------------------------------------------------------------- # CLASSES: # ----------------------------------------------------------------------------- -from collections import OrderedDict -from .model import Feature, Rule, ScenarioOutline, Scenario - - class FeatureLineDatabase(object): """Helper class that supports select-by-location mechanism (FileLocation) within a feature file by storing the feature line numbers for each entity. @@ -70,7 +76,8 @@ def __init__(self, entity=None, line_data=None): def select_run_item_by_line(self, line): """Select one run-items by using the line number. - * Exact match returns run-time entity (Feature, Rule, ScenarioOutline, Scenario) + * Exact match returns run-time entity: + Feature, Rule, ScenarioOutline, Scenario * Any other line in between uses the predecessor entity :param line: Line number in Feature file (as int) @@ -84,8 +91,7 @@ def select_run_item_by_line(self, line): self._line_entities = list(self.data.values()) pos = bisect(self._line_numbers, line) - 1 - if pos < 0: - pos = 0 + pos = max(pos, 0) run_item = self._line_entities[pos] return run_item @@ -207,8 +213,7 @@ def select_scenario_line_for(line, scenario_lines): if not scenario_lines: return 0 # -- Select all scenarios. pos = bisect(scenario_lines, line) - 1 - if pos < 0: - pos = 0 + pos = max(pos, 0) return scenario_lines[pos] def discover_selected_scenarios(self, strict=False): @@ -297,8 +302,7 @@ def select_scenario_line_for(line, scenario_lines): if not scenario_lines: return 0 # -- Select all scenarios. pos = bisect(scenario_lines, line) - 1 - if pos < 0: - pos = 0 + pos = max(pos, 0) return scenario_lines[pos] def discover_selected_scenarios(self, strict=False): @@ -396,7 +400,7 @@ def parse(text, here=None): filename = line.strip() if not filename: continue # SKIP: Over empty line(s). - elif filename.startswith('#'): + if filename.startswith('#'): continue # SKIP: Over comment line(s). if here and not os.path.isabs(filename): @@ -425,10 +429,10 @@ def parse_file(cls, filename): if not os.path.isfile(filename): raise FileNotFoundError(filename) here = os.path.dirname(filename) or "." - # -- MAYBE BETTER: - # contents = codecs.open(filename, "utf-8").read() - contents = open(filename).read() - return cls.parse(contents, here) + # MAYBE: with codecs.open(filename, encoding="UTF-8") as f: + with open(filename) as f: + contents = f.read() + return cls.parse(contents, here) class PathManager(object): @@ -483,7 +487,7 @@ def parse_features(feature_files, language=None): if location.filename == scenario_collector.filename: scenario_collector.add_location(location) continue - elif scenario_collector.feature: + if scenario_collector.feature: # -- NEW FEATURE DETECTED: Add current feature. current_feature = scenario_collector.build_feature() features.append(current_feature) @@ -535,7 +539,7 @@ def collect_feature_locations(paths, strict=True): location = FileLocationParser.parse(path) if not location.filename.endswith(".feature"): raise InvalidFilenameError(location.filename) - elif location.exists(): + if location.exists(): locations.append(location) elif strict: raise FileNotFoundError(path) @@ -562,18 +566,21 @@ def exec_file(filename, globals_=None, locals_=None): def load_step_modules(step_paths): """Load step modules with step definitions from step_paths directories.""" - from behave import matchers + # pylint: disable=import-outside-toplevel + from behave.api.step_matchers import use_step_matcher, use_default_step_matcher + from behave.api.step_matchers import step_matcher + from behave.matchers import use_current_step_matcher_as_default from behave.step_registry import setup_step_decorators step_globals = { - "use_step_matcher": matchers.use_step_matcher, - "step_matcher": matchers.step_matcher, # -- DEPRECATING + "use_step_matcher": use_step_matcher, + "step_matcher": step_matcher, # -- DEPRECATING } setup_step_decorators(step_globals) # -- Allow steps to import other stuff from the steps dir # NOTE: Default matcher can be overridden in "environment.py" hook. with PathManager(step_paths): - default_matcher = matchers.current_matcher + use_current_step_matcher_as_default() for path in step_paths: for name in sorted(os.listdir(path)): if name.endswith(".py"): @@ -584,7 +591,7 @@ def load_step_modules(step_paths): # try: step_module_globals = step_globals.copy() exec_file(os.path.join(path, name), step_module_globals) - matchers.current_matcher = default_matcher + use_default_step_matcher() def make_undefined_step_snippet(step, language=None): @@ -654,6 +661,7 @@ def print_undefined_step_snippets(undefined_steps, stream=None, colored=True): if colored: # -- OOPS: Unclear if stream supports ANSI coloring. + # pylint: disable=import-outside-toplevel from behave.formatter.ansi_escapes import escapes msg = escapes['undefined'] + msg + escapes['reset'] @@ -665,11 +673,11 @@ def reset_runtime(): """Reset runtime environment. Best effort to reset module data to initial state. """ + # pylint: disable=import-outside-toplevel from behave import step_registry from behave import matchers # -- RESET 1: behave.step_registry step_registry.registry = step_registry.StepRegistry() step_registry.setup_step_decorators(None, step_registry.registry) # -- RESET 2: behave.matchers - matchers.ParseMatcher.custom_types = {} - matchers.current_matcher = matchers.ParseMatcher + matchers.get_matcher_factory().reset() diff --git a/behave/step_registry.py b/behave/step_registry.py index b235711b9..41bfd672e 100644 --- a/behave/step_registry.py +++ b/behave/step_registry.py @@ -6,7 +6,7 @@ """ from __future__ import absolute_import -from behave.matchers import Match, get_matcher +from behave.matchers import Match, make_matcher from behave.textutil import text as _text # limit import * to just the decorators @@ -56,7 +56,7 @@ def add_step_definition(self, keyword, step_text, func): existing_step = existing.describe() existing_step += u" at %s" % existing.location raise AmbiguousStep(message % (new_step, existing_step)) - step_definitions.append(get_matcher(func, step_text)) + step_definitions.append(make_matcher(func, step_text)) def find_step_definition(self, step): candidates = self.steps[step.step_type] diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 27698a4b8..355b6f6db 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -281,11 +281,11 @@ the preceding step's keyword (so an "and" following a "given" will become a .. note:: - Step function names do not need to have a unique symbol name, because the - text matching selects the step function from the step registry before it is - called as anonymous function. Hence, when *behave* prints out the missing - step implementations in a test run, it uses "step_impl" for all functions - by default. + Step function names do not need to have a unique symbol name, because the + text matching selects the step function from the step registry before it is + called as anonymous function. Hence, when *behave* prints out the missing + step implementations in a test run, it uses "step_impl" for all functions + by default. If you find you'd like your step implementation to invoke another step you may do so with the :class:`~behave.runner.Context` method @@ -307,77 +307,152 @@ the other two steps as though they had also appeared in the scenario file. .. _docid.tutorial.step-parameters: +.. _`step parameters`: Step Parameters --------------- -You may find that your feature steps sometimes include very common phrases -with only some variation. For example: +Steps sometimes include very common phrases with only one variation +(one word is different or some words are different). +For example: .. code-block:: gherkin - Scenario: look up a book - Given I search for a valid book - Then the result page will include "success" + # -- FILE: features/example_step_parameters.feature + Scenario: look up a book + Given I search for a valid book + Then the result page will include "success" - Scenario: look up an invalid book - Given I search for a invalid book - Then the result page will include "failure" + Scenario: look up an invalid book + Given I search for a invalid book + Then the result page will include "failure" -You may define a single Python step that handles both of those Then -clauses (with a Given step that puts some text into -``context.response``): +You can define one Python step-definition that handles both cases by using `step parameters`_ . +In this case, the *Then* step verifies the ``context.response`` parameter +that was stored in the ``context`` by the *Given* step: .. code-block:: python + # -- FILE: features/steps/example_steps_with_step_parameters.py + # HINT: Step-matcher "parse" is the DEFAULT step-matcher class. + from behave import then + @then('the result page will include "{text}"') def step_impl(context, text): if text not in context.response: fail('%r not in %r' % (text, context.response)) -There are several parsers available in *behave* (by default): +There are several step-matcher classes available in **behave** +that can be used for `step parameters`_. +You can select another step-matcher class by using +the :func:`behave.use_step_matcher()` function: + +.. code-block:: python + + # -- FILE: features/steps/example_use_step_matcher_in_steps.py + # HINTS: + # * "parse" in the DEFAULT step-matcher + # * Use "use_step_matcher(...)" in "features/environment.py" file + # to define your own own default step-matcher. + from behave import given, when, use_step_matcher + + use_step_matcher("cfparse") + + @given('some event named "{event_name}" happens') + def step_given_some_event_named_happens(context, event_name): + pass # ... DETAILS LEFT OUT HERE. + + use_step_matcher("re") + + @when('a person named "(?P...)" enters the room') + def step_when_person_enters_room(context, name): + pass # ... DETAILS LEFT OUT HERE. + + +Step-matchers +-------------- + +There are several step-matcher classes available in **behave** +that can be used for parsing `step parameters`_: + +* **parse** (default step-matcher class, based on: :pypi:`parse`): +* **cfparse** (extends: :pypi:`parse`, requires: :pypi:`parse_type`): +* **re** (step-matcher class is based on regular expressions): + + +Step-matcher: parse +~~~~~~~~~~~~~~~~~~~ + +This step-matcher class provides a parser based on: :pypi:`parse` module. + +It provides a simple parser that replaces regular expressions +for step parameters with a readable syntax like ``{param:Type}``. -**parse** (the default, based on: :pypi:`parse`) - Provides a simple parser that replaces regular expressions for step parameters - with a readable syntax like ``{param:Type}``. - The syntax is inspired by the Python builtin ``string.format()`` function. - Step parameters must use the named fields syntax of :pypi:`parse` - in step definitions. The named fields are extracted, - optionally type converted and then used as step function arguments. +The syntax is inspired by the Python builtin ``string.format()`` function. +Step parameters must use the named fields syntax of :pypi:`parse` +in step definitions. The named fields are extracted, +optionally type converted and then used as step function arguments. - Supports type conversions by using type converters - (see :func:`~behave.register_type()`). +FEATURES: -**cfparse** (extends: :pypi:`parse`, requires: :pypi:`parse_type`) - Provides an extended parser with "Cardinality Field" (CF) support. - Automatically creates missing type converters for related cardinality - as long as a type converter for cardinality=1 is provided. - Supports parse expressions like: +* Supports named step parameters (and unnamed step parameters) +* Supports **type conversions** by using type converters + (see :func:`~behave.register_type()`). - * ``{values:Type+}`` (cardinality=1..N, many) - * ``{values:Type*}`` (cardinality=0..N, many0) - * ``{value:Type?}`` (cardinality=0..1, optional). - Supports type conversions (as above). +Step-matcher: cfparse +~~~~~~~~~~~~~~~~~~~~~ -**re** - This uses full regular expressions to parse the clause text. You will - need to use named groups "(?P...)" to define the variables pulled - from the text and passed to your ``step()`` function. +This step-matcher class extends the ``parse`` step-matcher +and provides an extended parser with "Cardinality Field" (CF) support. + +It automatically creates missing type converters for other cardinalities +as long as a type converter for cardinality=1 is provided. + +It supports parse expressions like: + +* ``{values:Type+}`` (cardinality=1..N, many) +* ``{values:Type*}`` (cardinality=0..N, many0) +* ``{value:Type?}`` (cardinality=0..1, optional). + +FEATURES: + +* Supports named step parameters (and unnamed step parameters) +* Supports **type conversions** by using type converters + (see :func:`~behave.register_type()`). + + + +Step-matcher: re +~~~~~~~~~~~~~~~~~~~~~ + +This step-matcher provides step-matcher class is based on regular expressions. +It uses full regular expressions to parse the clause text. +You will need to use named groups "(?P...)" to define the variables pulled +from the text and passed to your ``step()`` function. + +.. hint:: Type conversion is **not supported**. - Type conversion is **not supported**. A step function writer may implement type conversion inside the step function (implementation). -To specify which parser to use invoke :func:`~behave.use_step_matcher` -with the name of the matcher to use. You may change matcher to suit -specific step functions - the last call to ``use_step_matcher`` before a step -function declaration will be the one it uses. -.. note:: +To specify which parser to use, +call the :func:`~behave.use_step_matcher()` function with the name +of the step-matcher class to use. + +You can change the step-matcher class at any time to suit your needs. +The following step-definitions use the current step-matcher class. + +FEATURES: + +* Supports named step parameters (and unnamed step parameters) +* Supports no type conversions + +VARIANTS: - The function :func:`~behave.matchers.step_matcher()` is becoming deprecated. - Use :func:`~behave.use_step_matcher()` instead. +* ``"re0"``: Provides a regex matcher that is compatible with ``cucumber`` + (regex based step-matcher). Context diff --git a/issue.features/issue0073.feature b/issue.features/issue0073.feature index fec932bd4..0194fef25 100644 --- a/issue.features/issue0073.feature +++ b/issue.features/issue0073.feature @@ -45,8 +45,8 @@ Feature: Issue #73: the current_matcher is not predictable Given a new working directory And a file named "features/environment.py" with: """ - from behave import use_step_matcher - use_step_matcher("re") + from behave import use_default_step_matcher + use_default_step_matcher("re") """ And a file named "features/steps/regexp_steps.py" with: """ @@ -76,8 +76,8 @@ Feature: Issue #73: the current_matcher is not predictable Given a new working directory And a file named "features/environment.py" with: """ - from behave import use_step_matcher - use_step_matcher("re") + from behave import use_default_step_matcher + use_default_step_matcher("re") """ And a file named "features/steps/eparse_steps.py" with: """ @@ -125,8 +125,8 @@ Feature: Issue #73: the current_matcher is not predictable Given a new working directory And a file named "features/environment.py" with: """ - from behave import use_step_matcher - use_step_matcher("re") + from behave import use_default_step_matcher + use_default_step_matcher("re") """ And a file named "features/steps/given_steps.py" with: """ diff --git a/issue.features/issue0547.feature b/issue.features/issue0547.feature index 69f79c3b2..e02d4e0d6 100644 --- a/issue.features/issue0547.feature +++ b/issue.features/issue0547.feature @@ -7,14 +7,14 @@ Feature: Issue 547 -- behave crashes when adding a step definition with optional Given a new working directory And a file named "features/environment.py" with: """ - from behave import register_type, use_step_matcher + from behave import register_type, use_default_step_matcher import parse @parse.with_pattern(r"optional\s+") def parse_optional_word(text): return text.strip() - use_step_matcher("cfparse") + use_default_step_matcher("cfparse") register_type(opt_=parse_optional_word) """ And a file named "features/steps/steps.py" with: diff --git a/tests/api/_test_async_step34.py b/tests/api/_test_async_step34.py index 30db3f535..abf124fa8 100644 --- a/tests/api/_test_async_step34.py +++ b/tests/api/_test_async_step34.py @@ -1,4 +1,5 @@ # -*- coding: UTF-8 -*- +# pylint: disable=invalid-name """ Unit tests for :mod:`behave.api.async_test`. """ @@ -6,14 +7,15 @@ # -- IMPORTS: from __future__ import absolute_import, print_function import sys -from behave.api.async_step import AsyncContext, use_or_create_async_context -from behave._stepimport import use_step_import_modules -from behave.runner import Context, Runner +from unittest.mock import Mock from hamcrest import assert_that, close_to -from mock import Mock import pytest -from .testing_support import StopWatch, SimpleStepContainer +from behave.api.async_step import AsyncContext, use_or_create_async_context +from behave._stepimport import use_step_import_modules, SimpleStepContainer +from behave.runner import Context, Runner + +from .testing_support import StopWatch from .testing_support_async import AsyncStepTheory @@ -42,7 +44,8 @@ PYTHON_3_5 = (3, 5) PYTHON_3_8 = (3, 8) python_version = sys.version_info[:2] -requires_py34_to_py37 = pytest.mark.skipif(not (PYTHON_3_5 <= python_version < PYTHON_3_8), +requires_py34_to_py37 = pytest.mark.skipif( + not (PYTHON_3_5 <= python_version < PYTHON_3_8), reason="Supported only for python.versions: 3.4 .. 3.7 (inclusive)") @@ -55,13 +58,14 @@ # TESTSUITE: # ----------------------------------------------------------------------------- @requires_py34_to_py37 -class TestAsyncStepDecoratorPy34(object): +class TestAsyncStepDecoratorPy34: def test_step_decorator_async_run_until_complete2(self): step_container = SimpleStepContainer() with use_step_import_modules(step_container): # -- STEP-DEFINITIONS EXAMPLE (as MODULE SNIPPET): # VARIANT 2: Use @asyncio.coroutine def step_impl() + # pylint: disable=import-outside-toplevel, unused-argument from behave import step from behave.api.async_step import async_run_until_complete import asyncio @@ -72,6 +76,7 @@ def test_step_decorator_async_run_until_complete2(self): def step_async_step_waits_seconds2(context, duration): yield from asyncio.sleep(duration) + # pylint: enable=import-outside-toplevel, unused-argument # -- USES: async def step_impl(...) as async-step (coroutine) AsyncStepTheory.validate(step_async_step_waits_seconds2) @@ -85,13 +90,14 @@ def step_async_step_waits_seconds2(context, duration): assert_that(stop_watch.duration, close_to(0.2, delta=SLEEP_DELTA)) -class TestAsyncContext(object): +class TestAsyncContext: @staticmethod def make_context(): return Context(runner=Runner(config={})) def test_use_or_create_async_context__when_missing(self): # -- CASE: AsynContext attribute is created with default_name + # pylint: disable=protected-access context = self.make_context() context._push() @@ -142,7 +148,7 @@ def test_use_or_create_async_context__when_exists_with_name(self): @requires_py34_to_py37 -class TestAsyncStepRunPy34(object): +class TestAsyncStepRunPy34: """Ensure that execution of async-steps works as expected.""" def test_async_step_passes(self): @@ -151,6 +157,7 @@ def test_async_step_passes(self): with use_step_import_modules(step_container): # -- STEP-DEFINITIONS EXAMPLE (as MODULE SNIPPET): # VARIANT 1: Use async def step_impl() + # pylint: disable=import-outside-toplevel, unused-argument from behave import given, when from behave.api.async_step import async_run_until_complete import asyncio @@ -167,7 +174,9 @@ def given_async_step_passes(context): def when_async_step_passes(context): context.traced_steps.append("async-step2") - # -- RUN ASYNC-STEP: Verify that async-steps can be execution without problems. + # pylint: enable=import-outside-toplevel, unused-argument + # -- RUN ASYNC-STEP: + # Verify that async-steps can be execution without problems. context = Context(runner=Runner(config={})) context.traced_steps = [] given_async_step_passes(context) @@ -181,6 +190,7 @@ def test_async_step_fails(self): with use_step_import_modules(step_container): # -- STEP-DEFINITIONS EXAMPLE (as MODULE SNIPPET): # VARIANT 1: Use async def step_impl() + # pylint: disable=import-outside-toplevel, unused-argument from behave import when from behave.api.async_step import async_run_until_complete import asyncio @@ -191,6 +201,7 @@ def test_async_step_fails(self): def when_async_step_fails(context): assert False, "XFAIL in async-step" + # pylint: enable=import-outside-toplevel, unused-argument # -- RUN ASYNC-STEP: Verify that AssertionError is detected. context = Context(runner=Runner(config={})) with pytest.raises(AssertionError): @@ -202,6 +213,7 @@ def test_async_step_raises_exception(self): with use_step_import_modules(step_container): # -- STEP-DEFINITIONS EXAMPLE (as MODULE SNIPPET): # VARIANT 1: Use async def step_impl() + # pylint: disable=import-outside-toplevel, unused-argument from behave import when from behave.api.async_step import async_run_until_complete import asyncio @@ -210,8 +222,10 @@ def test_async_step_raises_exception(self): @async_run_until_complete @asyncio.coroutine def when_async_step_raises_exception(context): + # pylint: disable=pointless-statement 1 / 0 # XFAIL-HERE: Raises ZeroDivisionError + # pylint: enable=import-outside-toplevel, unused-argument # -- RUN ASYNC-STEP: Verify that raised exeception is detected. context = Context(runner=Runner(config={})) with pytest.raises(ZeroDivisionError): diff --git a/tests/api/_test_async_step35.py b/tests/api/_test_async_step35.py index 7f9219e4e..dc07dc41d 100644 --- a/tests/api/_test_async_step35.py +++ b/tests/api/_test_async_step35.py @@ -1,4 +1,5 @@ # -*- coding: UTF-8 -*- +# pylint: disable=invalid-name """ Unit tests for :mod:`behave.api.async_test` for Python 3.5 (or newer). """ @@ -7,11 +8,11 @@ from __future__ import absolute_import, print_function import sys from hamcrest import assert_that, close_to -from behave._stepimport import use_step_import_modules -from behave.runner import Context, Runner import pytest -from .testing_support import StopWatch, SimpleStepContainer +from behave._stepimport import use_step_import_modules, SimpleStepContainer +from behave.runner import Context, Runner +from .testing_support import StopWatch from .testing_support_async import AsyncStepTheory @@ -38,7 +39,8 @@ # ----------------------------------------------------------------------------- PYTHON_3_5 = (3, 5) python_version = sys.version_info[:2] -py35_or_newer = pytest.mark.skipif(python_version < PYTHON_3_5, reason="Needs Python >= 3.5") +py35_or_newer = pytest.mark.skipif(python_version < PYTHON_3_5, + reason="Needs Python >= 3.5") SLEEP_DELTA = 0.050 if sys.platform.startswith("win"): @@ -49,13 +51,14 @@ # TESTSUITE: # ----------------------------------------------------------------------------- @py35_or_newer -class TestAsyncStepDecoratorPy35(object): +class TestAsyncStepDecoratorPy35: def test_step_decorator_async_run_until_complete1(self): step_container = SimpleStepContainer() with use_step_import_modules(step_container): # -- STEP-DEFINITIONS EXAMPLE (as MODULE SNIPPET): # VARIANT 1: Use async def step_impl() + # pylint: disable=import-outside-toplevel, unused-argument from behave import step from behave.api.async_step import async_run_until_complete import asyncio @@ -65,6 +68,7 @@ def test_step_decorator_async_run_until_complete1(self): async def step_async_step_waits_seconds(context, duration): await asyncio.sleep(duration) + # pylint: enable=import-outside-toplevel, unused-argument # -- USES: async def step_impl(...) as async-step (coroutine) AsyncStepTheory.validate(step_async_step_waits_seconds) @@ -78,7 +82,7 @@ async def step_async_step_waits_seconds(context, duration): @py35_or_newer -class TestAsyncStepRunPy35(object): +class TestAsyncStepRunPy35: """Ensure that execution of async-steps works as expected.""" def test_async_step_passes(self): @@ -87,6 +91,7 @@ def test_async_step_passes(self): with use_step_import_modules(step_container): # -- STEP-DEFINITIONS EXAMPLE (as MODULE SNIPPET): # VARIANT 1: Use async def step_impl() + # pylint: disable=import-outside-toplevel, unused-argument from behave import given, when from behave.api.async_step import async_run_until_complete @@ -100,7 +105,7 @@ async def given_async_step_passes(context): async def when_async_step_passes(context): context.traced_steps.append("async-step2") - + # pylint: enable=import-outside-toplevel, unused-argument # -- RUN ASYNC-STEP: Verify that async-steps can be executed. context = Context(runner=Runner(config={})) context.traced_steps = [] @@ -115,6 +120,7 @@ def test_async_step_fails(self): with use_step_import_modules(step_container): # -- STEP-DEFINITIONS EXAMPLE (as MODULE SNIPPET): # VARIANT 1: Use async def step_impl() + # pylint: disable=import-outside-toplevel, unused-argument from behave import when from behave.api.async_step import async_run_until_complete @@ -123,6 +129,7 @@ def test_async_step_fails(self): async def when_async_step_fails(context): assert False, "XFAIL in async-step" + # pylint: enable=import-outside-toplevel, unused-argument # -- RUN ASYNC-STEP: Verify that AssertionError is detected. context = Context(runner=Runner(config={})) with pytest.raises(AssertionError): @@ -135,14 +142,17 @@ def test_async_step_raises_exception(self): with use_step_import_modules(step_container): # -- STEP-DEFINITIONS EXAMPLE (as MODULE SNIPPET): # VARIANT 1: Use async def step_impl() + # pylint: disable=import-outside-toplevel, unused-argument from behave import when from behave.api.async_step import async_run_until_complete @when('an async-step raises exception') @async_run_until_complete async def when_async_step_raises_exception(context): + # pylint: disable=pointless-statement 1 / 0 # XFAIL-HERE: Raises ZeroDivisionError + # pylint: enable=import-outside-toplevel, unused-argument # -- RUN ASYNC-STEP: Verify that raised exception is detected. context = Context(runner=Runner(config={})) with pytest.raises(ZeroDivisionError): diff --git a/tests/api/test_async_step.py b/tests/api/test_async_step.py index 1b82d4a4d..a45698936 100644 --- a/tests/api/test_async_step.py +++ b/tests/api/test_async_step.py @@ -1,4 +1,5 @@ # -*- coding: UTF-8 -*- +# pylint: disable=wildcard-import,unused-wildcard-import """ Unit test facade to protect pytest runner from Python 3.4/3.5 grammar changes. @@ -15,10 +16,11 @@ if _python_version >= (3, 4): # -- PROTECTED-IMPORT: # Older Python version have problems with grammer extensions (yield-from). - # from ._test_async_step34 import TestAsyncStepDecorator34, TestAsyncContext, TestAsyncStepRun34 - from ._test_async_step34 import * + # from ._test_async_step34 import TestAsyncStepDecorator34 + # from ._test_async_step34 import TestAsyncContext, TestAsyncStepRun34 + from ._test_async_step34 import * # noqa: F403 if _python_version >= (3, 5): # -- PROTECTED-IMPORT: # Older Python version have problems with grammer extensions (async/await). # from ._test_async_step35 import TestAsyncStepDecorator35, TestAsyncStepRun35 - from ._test_async_step35 import * + from ._test_async_step35 import * # noqa: F403 diff --git a/tests/api/testing_support.py b/tests/api/testing_support.py index 585c53879..e220b0a03 100644 --- a/tests/api/testing_support.py +++ b/tests/api/testing_support.py @@ -5,8 +5,6 @@ # -- IMPORTS: from __future__ import absolute_import -from behave.step_registry import StepRegistry -from behave.matchers import ParseMatcher, CFParseMatcher, RegexMatcher import time @@ -41,54 +39,3 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, exc_tb): self.stop() -# -- NEEDED-UNTIL: stepimport functionality is completly provided. -class MatcherFactory(object): - matcher_mapping = { - "parse": ParseMatcher, - "cfparse": CFParseMatcher, - "re": RegexMatcher, - } - default_matcher = ParseMatcher - - def __init__(self, matcher_mapping=None, default_matcher=None): - self.matcher_mapping = matcher_mapping or self.matcher_mapping - self.default_matcher = default_matcher or self.default_matcher - self.current_matcher = self.default_matcher - self.type_registry = {} - # self.type_registry = ParseMatcher.custom_types.copy() - - def register_type(self, **kwargs): - self.type_registry.update(**kwargs) - - def use_step_matcher(self, name): - self.current_matcher = self.matcher_mapping[name] - - def use_default_step_matcher(self, name=None): - if name: - self.default_matcher = self.matcher_mapping[name] - self.current_matcher = self.default_matcher - - def make_matcher(self, func, step_text, step_type=None): - return self.current_matcher(func, step_text, step_type=step_type, - custom_types=self.type_registry) - - def step_matcher(self, name): - """ - DEPRECATED, use :method:`~MatcherFactory.use_step_matcher()` instead. - """ - # -- BACKWARD-COMPATIBLE NAME: Mark as deprecated. - import warnings - warnings.warn("Use 'use_step_matcher()' instead", - PendingDeprecationWarning, stacklevel=2) - self.use_step_matcher(name) - - -class SimpleStepContainer(object): - def __init__(self, step_registry=None): - if step_registry is None: - step_registry = StepRegistry() - matcher_factory = MatcherFactory() - - self.step_registry = step_registry - self.step_registry.matcher_factory = matcher_factory - self.matcher_factory = matcher_factory diff --git a/tests/unit/test_matchers.py b/tests/unit/test_matchers.py index 815581caa..97ba49eb1 100644 --- a/tests/unit/test_matchers.py +++ b/tests/unit/test_matchers.py @@ -3,8 +3,11 @@ import pytest from mock import Mock, patch import parse -from behave.matchers import Match, Matcher, ParseMatcher, RegexMatcher, \ - SimplifiedRegexMatcher, CucumberRegexMatcher +from behave.exception import NotSupportedWarning +from behave.matchers import ( + Match, Matcher, + ParseMatcher, CFParseMatcher, + RegexMatcher, SimplifiedRegexMatcher, CucumberRegexMatcher) from behave import matchers, runner @@ -38,6 +41,7 @@ def test_returns_match_object_if_check_match_returns_arguments(self): class TestParseMatcher(object): # pylint: disable=invalid-name, no-self-use + STEP_MATCHER_CLASS = ParseMatcher def setUp(self): self.recorded_args = None @@ -45,17 +49,29 @@ def setUp(self): def record_args(self, *args, **kwargs): self.recorded_args = (args, kwargs) + def test_register_type__can_register_own_type_converters(self): + def parse_number(text): + return int(text) + + # -- EXPECT: + this_matcher_class = self.STEP_MATCHER_CLASS + this_matcher_class.custom_types.clear() + this_matcher_class.register_type(Number=parse_number) + assert "Number" in this_matcher_class.custom_types + def test_returns_none_if_parser_does_not_match(self): # pylint: disable=redefined-outer-name # REASON: parse - matcher = ParseMatcher(None, 'a string') + this_matcher_class = self.STEP_MATCHER_CLASS + matcher = this_matcher_class(None, 'a string') with patch.object(matcher.parser, 'parse') as parse: parse.return_value = None assert matcher.match('just a random step') is None def test_returns_arguments_based_on_matches(self): + this_matcher_class = self.STEP_MATCHER_CLASS func = lambda x: -x - matcher = ParseMatcher(func, 'foo') + matcher = this_matcher_class(func, 'foo') results = parse.Result([1, 2, 3], {'foo': 'bar', 'baz': -45.3}, { @@ -83,8 +99,9 @@ def test_returns_arguments_based_on_matches(self): assert have == expected def test_named_arguments(self): + this_matcher_class = self.STEP_MATCHER_CLASS text = "has a {string}, an {integer:d} and a {decimal:f}" - matcher = ParseMatcher(self.record_args, text) + matcher = this_matcher_class(self.record_args, text) context = runner.Context(Mock()) m = matcher.match("has a foo, an 11 and a 3.14159") @@ -95,31 +112,174 @@ def test_named_arguments(self): 'decimal': 3.14159 }) + def test_named_arguments_with_own_types(self): + @parse.with_pattern(r"[A-Za-z][A-Za-z0-9_\-]*") + def parse_word(text): + return text.strip() + + @parse.with_pattern(r"\d+") + def parse_number(text): + return int(text) + + this_matcher_class = self.STEP_MATCHER_CLASS + this_matcher_class.register_type(Number=parse_number, + Word=parse_word) + + pattern = "has a {word:Word}, a {number:Number}" + matcher = this_matcher_class(self.record_args, pattern) + context = runner.Context(Mock()) + + m = matcher.match("has a foo, a 42") + m.run(context) + expected = { + "word": "foo", + "number": 42, + } + assert self.recorded_args, ((context,) == expected) + + def test_positional_arguments(self): + this_matcher_class = self.STEP_MATCHER_CLASS text = "has a {}, an {:d} and a {:f}" - matcher = ParseMatcher(self.record_args, text) + matcher = this_matcher_class(self.record_args, text) context = runner.Context(Mock()) m = matcher.match("has a foo, an 11 and a 3.14159") m.run(context) assert self.recorded_args == ((context, 'foo', 11, 3.14159), {}) + +class TestCFParseMatcher(TestParseMatcher): + STEP_MATCHER_CLASS = CFParseMatcher + + # def test_ + def test_named_optional__without_value(self): + @parse.with_pattern(r"\d+") + def parse_number(text): + return int(text) + + this_matcher_class = self.STEP_MATCHER_CLASS + this_matcher_class.register_type(Number=parse_number) + + pattern = "has an optional number={number:Number?}." + matcher = this_matcher_class(self.record_args, pattern) + context = runner.Context(Mock()) + + m = matcher.match("has an optional number=.") + m.run(context) + expected = { + "number": None, + } + assert self.recorded_args, ((context,) == expected) + + + def test_named_optional__with_value(self): + @parse.with_pattern(r"\d+") + def parse_number(text): + return int(text) + + this_matcher_class = self.STEP_MATCHER_CLASS + this_matcher_class.register_type(Number=parse_number) + + pattern = "has an optional number={number:Number?}." + matcher = this_matcher_class(self.record_args, pattern) + context = runner.Context(Mock()) + + m = matcher.match("has an optional number=42.") + m.run(context) + expected = { + "number": 42, + } + assert self.recorded_args, ((context,) == expected) + + def test_named_many__with_values(self): + @parse.with_pattern(r"\d+") + def parse_number(text): + return int(text) + + this_matcher_class = self.STEP_MATCHER_CLASS + this_matcher_class.register_type(Number=parse_number) + + pattern = "has numbers={number:Number+};" + matcher = this_matcher_class(self.record_args, pattern) + context = runner.Context(Mock()) + + m = matcher.match("has numbers=1, 2, 3;") + m.run(context) + expected = { + "numbers": [1, 2, 3], + } + assert self.recorded_args, ((context,) == expected) + + def test_named_many0__with_empty_list(self): + @parse.with_pattern(r"\d+") + def parse_number(text): + return int(text) + + this_matcher_class = self.STEP_MATCHER_CLASS + this_matcher_class.register_type(Number=parse_number) + + pattern = "has numbers={number:Number*};" + matcher = this_matcher_class(self.record_args, pattern) + context = runner.Context(Mock()) + + m = matcher.match("has numbers=;") + m.run(context) + expected = { + "numbers": [], + } + assert self.recorded_args, ((context,) == expected) + + + def test_named_many0__with_values(self): + @parse.with_pattern(r"\d+") + def parse_number(text): + return int(text) + + this_matcher_class = self.STEP_MATCHER_CLASS + this_matcher_class.register_type(Number=parse_number) + + pattern = "has numbers={number:Number+};" + matcher = this_matcher_class(self.record_args, pattern) + context = runner.Context(Mock()) + + m = matcher.match("has numbers=1, 2, 3;") + m.run(context) + expected = { + "numbers": [1, 2, 3], + } + assert self.recorded_args, ((context,) == expected) + + class TestRegexMatcher(object): # pylint: disable=invalid-name, no-self-use - MATCHER_CLASS = RegexMatcher + STEP_MATCHER_CLASS = RegexMatcher + + def test_register_type__is_not_supported(self): + def parse_number(text): + return int(text) + + this_matcher_class = self.STEP_MATCHER_CLASS + with pytest.raises(NotSupportedWarning) as exc_info: + this_matcher_class.register_type(Number=parse_number) + + excecption_text = exc_info.exconly() + class_name = this_matcher_class.__name__ + expected = "NotSupportedWarning: {0}.register_type".format(class_name) + assert expected in excecption_text def test_returns_none_if_regex_does_not_match(self): - RegexMatcher = self.MATCHER_CLASS - matcher = RegexMatcher(None, 'a string') + this_matcher_class = self.STEP_MATCHER_CLASS + matcher = this_matcher_class(None, 'a string') regex = Mock() regex.match.return_value = None matcher.regex = regex assert matcher.match('just a random step') is None def test_returns_arguments_based_on_groups(self): - RegexMatcher = self.MATCHER_CLASS + this_matcher_class = self.STEP_MATCHER_CLASS func = lambda x: -x - matcher = RegexMatcher(func, 'foo') + matcher = this_matcher_class(func, 'foo') regex = Mock() regex.groupindex = {'foo': 4, 'baz': 5} @@ -156,7 +316,7 @@ def test_returns_arguments_based_on_groups(self): class TestSimplifiedRegexMatcher(TestRegexMatcher): - MATCHER_CLASS = SimplifiedRegexMatcher + STEP_MATCHER_CLASS = SimplifiedRegexMatcher def test_steps_with_same_prefix_are_not_ordering_sensitive(self): # -- RELATED-TO: issue #280 @@ -193,7 +353,7 @@ def test_step_should_not_use_regex_begin_and_end_marker(self): class TestCucumberRegexMatcher(TestRegexMatcher): - MATCHER_CLASS = CucumberRegexMatcher + STEP_MATCHER_CLASS = CucumberRegexMatcher def test_steps_with_same_prefix_are_not_ordering_sensitive(self): # -- RELATED-TO: issue #280 @@ -227,10 +387,14 @@ def test_step_should_use_regex_begin_and_end_marker(self): def test_step_matcher_current_matcher(): - current_matcher = matchers.current_matcher - for name, klass in list(matchers.matcher_mapping.items()): - matchers.use_step_matcher(name) - matcher = matchers.get_matcher(lambda x: -x, 'foo') + step_matcher_factory = matchers.get_matcher_factory() + for name, klass in list(step_matcher_factory.matcher_mapping.items()): + current_matcher1 = matchers.use_step_matcher(name) + current_matcher2 = step_matcher_factory.current_matcher + matcher = matchers.make_matcher(lambda x: -x, "foo") assert isinstance(matcher, klass) + assert current_matcher1 is klass + assert current_matcher2 is klass - matchers.current_matcher = current_matcher + # -- CLEANUP: Revert to default matcher + step_matcher_factory.use_default_step_matcher() diff --git a/tests/unit/test_step_registry.py b/tests/unit/test_step_registry.py index 6f85729e6..59d09e157 100644 --- a/tests/unit/test_step_registry.py +++ b/tests/unit/test_step_registry.py @@ -12,19 +12,19 @@ class TestStepRegistry(object): def test_add_step_definition_adds_to_lowercased_keyword(self): registry = step_registry.StepRegistry() # -- MONKEYPATCH-PROBLEM: - # with patch('behave.matchers.get_matcher') as get_matcher: - with patch('behave.step_registry.get_matcher') as get_matcher: + # with patch('behave.matchers.make_matcher') as make_matcher: + with patch('behave.step_registry.make_matcher') as make_matcher: func = lambda x: -x pattern = 'just a test string' magic_object = object() - get_matcher.return_value = magic_object + make_matcher.return_value = magic_object for step_type in list(registry.steps.keys()): l = [] registry.steps[step_type] = l registry.add_step_definition(step_type.upper(), pattern, func) - get_matcher.assert_called_with(func, pattern) + make_matcher.assert_called_with(func, pattern) assert l == [magic_object] def test_find_match_with_specific_step_type_also_searches_generic(self): From 5e40eb7dc317388ecbbbc35817b1d9c1c7cbf3ad Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 8 Jun 2023 18:55:30 +0200 Subject: [PATCH 172/240] RELATED TO: tag-expressions ADDED: TagExpressionProtocol * Supports "any" (v1 and v2) and "strict" (only v2) mode * Helps to better control which tag-expression versions are usable HINTS: - Tag-expressions v2 provide much better parser diagnostics - Some parse errors are unnoticable if "any" mode is active. Some parser errors are mapped to "tag-expression v1". IMPROVED: --tags-help command-line option * tags-help: Improve textual description and remove some old parts * tags-help: Helps now to diagnose tag-expression related problems FIXED: * issue #1054: TagExpressions v2: AND concatenation is faulty * FIX main: Avoid stacktrace when a BAD TAG-EXPRESSION is used. * Not.to_string conversion: Double-parenthesis problem Double-parenthensis are used if Not contains a binary operator (And, Or). REASON: Binary operators already put parenthensis aroung their terms. --- CHANGES.rst | 2 + behave/__main__.py | 87 +++++++++-------- behave/exception.py | 28 +++++- behave/tag_expression/__init__.py | 115 ++++++++++++++++++----- behave/tag_expression/model.py | 32 ++++++- behave/tag_expression/parser.py | 2 +- behave/tag_expression/v1.py | 34 ++++++- features/tags.help.feature | 3 + tests/unit/tag_expression/test_basics.py | 12 +-- tests/unit/tag_expression/test_parser.py | 3 +- 10 files changed, 240 insertions(+), 78 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index bacdebd70..c3a7928bd 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -62,6 +62,7 @@ FIXED: * FIXED: Some tests related to python3.9 * FIXED: active-tag logic if multiple tags with same category exists. * issue #1061: Scenario should inherit Rule tags (submitted by: testgitdl) +* issue #1054: TagExpressions v2: AND concatenation is faulty (submitted by: janoskut) * pull #967: Update __init__.py in behave import to fix pylint (provided by: dsayling) * issue #955: setup: Remove attribute 'use_2to3' (submitted by: krisgesling) * issue #772: ScenarioOutline.Examples without table (submitted by: The-QA-Geek) @@ -77,6 +78,7 @@ FIXED: MINOR: * issue #1047: Step type is inherited for generic step if possible (submitted by: zettseb) +* issue #958: Replace dashes with underscores to comply with setuptools v54.1.0 #958 (submitted by: arrooney) * issue #800: Cleanups related to Gherkin parser/ParseError question (submitted by: otstanteplz) * pull #767: FIX: use_fixture_by_tag didn't return the actual fixture in all cases (provided by: jgentil) * pull #751: gherkin: Adding Rule keyword translation in portuguese and spanish to gherkin-languages.json (provided by: dunossauro) diff --git a/behave/__main__.py b/behave/__main__.py index bb9367db7..723ea855c 100644 --- a/behave/__main__.py +++ b/behave/__main__.py @@ -6,47 +6,44 @@ import six from behave.version import VERSION as BEHAVE_VERSION from behave.configuration import Configuration -from behave.exception import ConstraintError, ConfigError, \ - FileNotFoundError, InvalidFileLocationError, InvalidFilenameError, \ - ModuleNotFoundError, ClassNotFoundError, InvalidClassError +from behave.exception import (ConstraintError, ConfigError, + FileNotFoundError, InvalidFileLocationError, InvalidFilenameError, + ModuleNotFoundError, ClassNotFoundError, InvalidClassError, + TagExpressionError) +from behave.importer import make_scoped_class_name from behave.parser import ParserError -from behave.runner import Runner +from behave.runner import Runner # noqa: F401 from behave.runner_util import print_undefined_step_snippets, reset_runtime -from behave.textutil import compute_words_maxsize, text as _text from behave.runner_plugin import RunnerPlugin -# PREPARED: from behave.importer import make_scoped_class_name +from behave.textutil import compute_words_maxsize, text as _text # --------------------------------------------------------------------------- # CONSTANTS: # --------------------------------------------------------------------------- DEBUG = __debug__ -TAG_HELP = """ -Scenarios inherit tags that are declared on the Feature level. -The simplest TAG_EXPRESSION is simply a tag:: - - --tags=@dev - -You may even leave off the "@" - behave doesn't mind. - -You can also exclude all features / scenarios that have a tag, -by using boolean NOT:: - - --tags="not @dev" - -A tag expression can also use a logical OR:: - - --tags="@dev or @wip" - -The --tags option can be specified several times, -and this represents logical AND, -for instance this represents the boolean expression:: - - --tags="(@foo or not @bar) and @zap" - -You can also exclude several tags:: - - --tags="not (@fixme or @buggy)" +TAG_EXPRESSIONS_HELP = """ +TAG-EXPRESSIONS selects Features/Rules/Scenarios by using their tags. +A TAG-EXPRESSION is a boolean expression that references some tags. + +EXAMPLES: + + --tags=@smoke + --tags="not @xfail" + --tags="@smoke or @wip" + --tags="@smoke and @wip" + --tags="(@slow and not @fixme) or @smoke" + --tags="not (@fixme or @xfail)" + +NOTES: +* The tag-prefix "@" is optional. +* An empty tag-expression is "true" (select-anything). + +TAG-INHERITANCE: +* A Rule inherits the tags of its Feature +* A Scenario inherits the tags of its Feature or Rule. +* A Scenario of a ScenarioOutline/ScenarioTemplate inherit tags + from this ScenarioOutline/ScenarioTemplate and its Example table. """.strip() @@ -69,7 +66,7 @@ def run_behave(config, runner_class=None): return 0 if config.tags_help: - print(TAG_HELP) + print_tags_help(config) return 0 if config.lang == "help" or config.lang_list: @@ -109,7 +106,7 @@ def run_behave(config, runner_class=None): try: reset_runtime() runner = RunnerPlugin(runner_class).make_runner(config) - # print("USING RUNNER: {0}".format(make_scoped_class_name(runner))) + print("USING RUNNER: {0}".format(make_scoped_class_name(runner))) failed = runner.run() except ParserError as e: print(u"ParserError: %s" % e) @@ -152,6 +149,17 @@ def run_behave(config, runner_class=None): # --------------------------------------------------------------------------- # MAIN SUPPORT FOR: run_behave() # --------------------------------------------------------------------------- +def print_tags_help(config): + print(TAG_EXPRESSIONS_HELP) + + current_tag_expression = config.tag_expression.to_string() + print("\nCURRENT TAG_EXPRESSION: {0}".format(current_tag_expression)) + if config.verbose: + # -- SHOW LOW-LEVEL DETAILS: + text = repr(config.tag_expression).replace("Literal(", "Tag(") + print(" means: {0}".format(text)) + + def print_language_list(file=None): """Print list of supported languages, like: @@ -275,9 +283,14 @@ def main(args=None): :param args: Command-line args (or string) to use. :return: 0, if successful. Non-zero, in case of errors/failures. """ - config = Configuration(args) - return run_behave(config) - + try: + config = Configuration(args) + return run_behave(config) + except ConfigError as e: + print("ConfigError: %s" % e) + except TagExpressionError as e: + print("TagExpressionError: %s" % e) + return 1 # FAILED: if __name__ == "__main__": # -- EXAMPLE: main("--version") diff --git a/behave/exception.py b/behave/exception.py index e00201b83..b2375469d 100644 --- a/behave/exception.py +++ b/behave/exception.py @@ -1,4 +1,5 @@ # -*- coding: UTF-8 -*- +# ruff: noqa: F401 # pylint: disable=redefined-builtin,unused-import """ Behave exception classes. @@ -7,11 +8,28 @@ """ from __future__ import absolute_import, print_function -# -- USE MODERN EXCEPTION CLASSES: -# COMPATIBILITY: Emulated if not supported yet by Python version. -from behave.compat.exceptions import ( - FileNotFoundError, ModuleNotFoundError # noqa: F401 -) +# -- RE-EXPORT: Exception class(es) here (provided in other places). +# USE MODERN EXCEPTION CLASSES: FileNotFoundError, ModuleNotFoundError +# COMPATIBILITY: Emulated if not supported yet by Python version. +from behave.compat.exceptions import (FileNotFoundError, ModuleNotFoundError) # noqa: F401 +from behave.tag_expression.parser import TagExpressionError + + +__all__ = [ + "ClassNotFoundError", + "ConfigError", + "ConstraintError", + "FileNotFoundError", + "InvalidClassError", + "InvalidFileLocationError", + "InvalidFilenameError", + "ModuleNotFoundError", + "NotSupportedWarning", + "ObjectNotFoundError", + "ResourceExistsError", + "TagExpressionError", +] + # --------------------------------------------------------------------------- # EXCEPTION/ERROR CLASSES: diff --git a/behave/tag_expression/__init__.py b/behave/tag_expression/__init__.py index c68521ecb..c2d7e5f16 100644 --- a/behave/tag_expression/__init__.py +++ b/behave/tag_expression/__init__.py @@ -1,4 +1,5 @@ # -*- coding: UTF-8 -*- +# pylint: disable=C0209 """ Common module for tag-expressions: @@ -12,25 +13,83 @@ """ from __future__ import absolute_import +from enum import Enum import six # -- NEW CUCUMBER TAG-EXPRESSIONS (v2): from .parser import TagExpressionParser -# -- OLD-STYLE TAG-EXPRESSIONS (v1): -# HINT: BACKWARD-COMPATIBLE (deprecating) +from .model import Expression # noqa: F401 +# -- DEPRECATING: OLD-STYLE TAG-EXPRESSIONS (v1): +# BACKWARD-COMPATIBLE SUPPORT from .v1 import TagExpression +# ----------------------------------------------------------------------------- +# CLASS: TagExpressionProtocol +# ----------------------------------------------------------------------------- +class TagExpressionProtocol(Enum): + """Used to specify which tag-expression versions to support: + + * ANY: Supports tag-expressions v2 and v1 (as compatibility mode) + * STRICT: Supports only tag-expressions v2 (better diagnostics) + + NOTE: + * Some errors are not caught in ANY mode. + """ + ANY = 1 + STRICT = 2 + DEFAULT = ANY + + @classmethod + def choices(cls): + return [member.name.lower() for member in cls] + + @classmethod + def parse(cls, name): + name2 = name.upper() + for member in cls: + if name2 == member.name: + return member + # -- OTHERWISE: + message = "{0} (expected: {1})".format(name, ", ".join(cls.choices())) + raise ValueError(message) + + def select_parser(self, tag_expression_text_or_seq): + if self is self.STRICT: + return parse_tag_expression_v2 + # -- CASE: TagExpressionProtocol.ANY + return select_tag_expression_parser4any(tag_expression_text_or_seq) + + + # -- SINGLETON FUNCTIONALITY: + @classmethod + def current(cls): + """Return currently selected protocol instance.""" + return getattr(cls, "_current", cls.DEFAULT) + + @classmethod + def use(cls, member): + """Specify which TagExpression protocol to use.""" + if isinstance(member, six.string_types): + name = member + member = cls.parse(name) + assert isinstance(member, TagExpressionProtocol), "%s:%s" % (type(member), member) + setattr(cls, "_current", member) + + + # ----------------------------------------------------------------------------- # FUNCTIONS: # ----------------------------------------------------------------------------- -def make_tag_expression(tag_expression_text): +def make_tag_expression(text_or_seq): """Build a TagExpression object by parsing the tag-expression (as text). - :param tag_expression_text: Tag expression text to parse (as string). + :param text_or_seq: + Tag expression text(s) to parse (as string, sequence). + :param protocol: Tag-expression protocol to use. :return: TagExpression object to use. """ - parse_tag_expression = select_tag_expression_parser(tag_expression_text) - return parse_tag_expression(tag_expression_text) + parse_tag_expression = TagExpressionProtocol.current().select_parser(text_or_seq) + return parse_tag_expression(text_or_seq) def parse_tag_expression_v1(tag_expression_parts): @@ -38,27 +97,33 @@ def parse_tag_expression_v1(tag_expression_parts): # -- HINT: DEPRECATING if isinstance(tag_expression_parts, six.string_types): tag_expression_parts = tag_expression_parts.split() + elif not isinstance(tag_expression_parts, (list, tuple)): + raise TypeError("EXPECTED: string, sequence", tag_expression_parts) + # print("parse_tag_expression_v1: %s" % " ".join(tag_expression_parts)) return TagExpression(tag_expression_parts) -def parse_tag_expression_v2(tag_expression_text): +def parse_tag_expression_v2(text_or_seq): """Parse cucumber-tag-expressions and build a TagExpression object.""" - text = tag_expression_text - if not isinstance(text, six.string_types): + text = text_or_seq + if isinstance(text, (list, tuple)): # -- ASSUME: List of strings - assert isinstance(text, (list, tuple)) - text = " and ".join(text) + sequence = text_or_seq + terms = ["({0})".format(term) for term in sequence] + text = " and ".join(terms) + elif not isinstance(text, six.string_types): + raise TypeError("EXPECTED: string, sequence", text) if "@" in text: # -- NORMALIZE: tag-expression text => Remove '@' tag decorators. text = text.replace("@", "") text = text.replace(" ", " ") - # print("parse_tag_expression_v2: %s" % text) + # DIAG: print("parse_tag_expression_v2: %s" % text) return TagExpressionParser.parse(text) -def check_for_complete_keywords(words, keywords): +def is_any_equal_to_keyword(words, keywords): for keyword in keywords: for word in words: if keyword == word: @@ -66,10 +131,11 @@ def check_for_complete_keywords(words, keywords): return False -def select_tag_expression_parser(tag_expression_text): +# -- CASE: TagExpressionProtocol.ANY +def select_tag_expression_parser4any(text_or_seq): """Select/Auto-detect which version of tag-expressions is used. - :param tag_expression_text: Tag expression text (as string) + :param text_or_seq: Tag expression text (as string, sequence) :return: TagExpression parser to use (as function). """ TAG_EXPRESSION_V1_KEYWORDS = [ @@ -79,19 +145,18 @@ def select_tag_expression_parser(tag_expression_text): "and", "or", "not", "(", ")" ] - text = tag_expression_text - if not isinstance(text, six.string_types): - # -- ASSUME: List of strings - assert isinstance(text, (list, tuple)) - text = " ".join(text) + text = text_or_seq + if isinstance(text, (list, tuple)): + # -- CASE: sequence -- Sequence of tag_expression parts + parts = text_or_seq + text = " ".join(parts) + elif not isinstance(text, six.string_types): + raise TypeError("EXPECTED: string, sequence", text) text = text.replace("(", " ( ").replace(")", " ) ") words = text.split() - contains_v1_keywords = any([(k in text) for k in TAG_EXPRESSION_V1_KEYWORDS]) - contains_v2_keywords = check_for_complete_keywords(words, TAG_EXPRESSION_V2_KEYWORDS) - # contains_v2_keywords = any([(k in text) for k in TAG_EXPRESSION_V2_KEYWORDS]) - # DIAG: print("XXX select_tag_expression_parser: v1=%r, v2=%r, words.size=%d (tags: %r)" % \ - # DIAG: (contains_v1_keywords, contains_v2_keywords, len(words), text)) + contains_v1_keywords = any((k in text) for k in TAG_EXPRESSION_V1_KEYWORDS) + contains_v2_keywords = is_any_equal_to_keyword(words, TAG_EXPRESSION_V2_KEYWORDS) if contains_v2_keywords: # -- USE: Use cucumber-tag-expressions return parse_tag_expression_v2 diff --git a/behave/tag_expression/model.py b/behave/tag_expression/model.py index 3b22f9e72..56477d87f 100644 --- a/behave/tag_expression/model.py +++ b/behave/tag_expression/model.py @@ -1,10 +1,11 @@ # -*- coding: UTF-8 -*- +# ruff: noqa: F401 # HINT: Import adapter only -from cucumber_tag_expressions.model import Expression, Literal, And, Or, Not +from cucumber_tag_expressions.model import Expression, Literal, And, Or, Not, True_ # ----------------------------------------------------------------------------- -# PATCH TAG-EXPRESSION BASE-CLASS: +# PATCH TAG-EXPRESSION BASE-CLASS: Expression # ----------------------------------------------------------------------------- def _Expression_check(self, tags): """Checks if tags match this tag-expression. @@ -16,5 +17,32 @@ def _Expression_check(self, tags): """ return self.evaluate(tags) +def _Expression_to_string(self, pretty=True): + """Provide nicer string conversion(s).""" + text = str(self) + if pretty: + # -- REMOVE WHITESPACE: Around parenthensis + text = text.replace("( ", "(").replace(" )", ")") + return text + +# -- MONKEY-PATCH: Expression.check = _Expression_check +Expression.to_string = _Expression_to_string + + +# ----------------------------------------------------------------------------- +# PATCH TAG-EXPRESSION CLASS: Not +# ----------------------------------------------------------------------------- +def _Not_to_string(self): + """Provide nicer/more compact output if Literal(s) are involved.""" + # MAYBE: Literal/True_ need no parenthesis + schema = "not ( {0} )" + if isinstance(self.term, (And, Or)): + # -- REASON: And/Or term have parenthesis already. + schema = "not {0}" + return schema.format(self.term) + + +# -- MONKEY-PATCH: +Not.__str__ = _Not_to_string diff --git a/behave/tag_expression/parser.py b/behave/tag_expression/parser.py index 690b0881f..6854b8b63 100644 --- a/behave/tag_expression/parser.py +++ b/behave/tag_expression/parser.py @@ -17,7 +17,7 @@ from cucumber_tag_expressions.parser import ( TagExpressionParser as _TagExpressionParser, # PROVIDE: Similar interface like: cucumber_tag_expressions.parser - TagExpressionError + TagExpressionError # noqa: F401 ) from cucumber_tag_expressions.model import Literal from .model_ext import Matcher diff --git a/behave/tag_expression/v1.py b/behave/tag_expression/v1.py index 5ffe3fa38..489d00ef0 100644 --- a/behave/tag_expression/v1.py +++ b/behave/tag_expression/v1.py @@ -105,6 +105,38 @@ def __str__(self): and_parts.append(u",".join(or_terms)) return u" ".join(and_parts) + def __repr__(self): + class_name = self.__class__.__name__ +"_v1" + and_parts = [] + # TODO + # for or_terms in self.ands: + # or_parts = [] + # for or_term in or_terms.split(): + # + # or_expression = u"Or(%s)" % u",".join(or_terms) + # and_parts.append(or_expression) + if len(self.ands) == 0: + expression = u"True()" + elif len(self.ands) >= 1: + and_parts = [] + for or_terms in self.ands: + or_parts = [] + for or_term in or_terms: + or_parts.extend(or_term.split()) + and_parts.append(u"Or(%s)" % ", ".join(or_parts)) + expression = u"And(%s)" % u",".join([and_part for and_part in and_parts]) + if len(self.ands) == 1: + expression = and_parts[0] + + # expression = u"And(%s)" % u",".join([or_term.split() + # for or_terms in self.ands + # for or_term in or_terms]) + return "<%s: expression=%s>" % (class_name, expression) + if six.PY2: __unicode__ = __str__ - __str__ = lambda self: self.__unicode__().encode("utf-8") + __str__ = lambda self: self.__unicode__().encode("utf-8") # noqa: E731 + + # -- API COMPATIBILITY TO: TagExpressions v2 + def to_string(self, pretty=True): + return str(self) diff --git a/features/tags.help.feature b/features/tags.help.feature index 8b0b98c1e..2b9c44062 100644 --- a/features/tags.help.feature +++ b/features/tags.help.feature @@ -7,6 +7,9 @@ Feature: behave --tags-help option . IN ADDITION: . The --tags-help option helps to diagnose tag-expression v2 problems. + Background: + Given a new working directory + Rule: Use --tags-help option to see tag-expression syntax and examples Scenario: Shows tag-expression description When I run "behave --tags-help" diff --git a/tests/unit/tag_expression/test_basics.py b/tests/unit/tag_expression/test_basics.py index 69b3256d7..6ebc4a896 100644 --- a/tests/unit/tag_expression/test_basics.py +++ b/tests/unit/tag_expression/test_basics.py @@ -1,7 +1,7 @@ # -*- coding: UTF-8 -*- from behave.tag_expression import ( - make_tag_expression, select_tag_expression_parser, + make_tag_expression, select_tag_expression_parser4any, parse_tag_expression_v1, parse_tag_expression_v2 ) from behave.tag_expression.v1 import TagExpression as TagExpressionV1 @@ -19,7 +19,7 @@ def test_make_tag_expression__with_v2(): # ----------------------------------------------------------------------------- -# TEST SUITE FOR: select_tag_expression_parser() +# TEST SUITE FOR: select_tag_expression_parser4any() # ----------------------------------------------------------------------------- @pytest.mark.parametrize("text", [ "@foo @bar", @@ -31,8 +31,8 @@ def test_make_tag_expression__with_v2(): "@foo,@bar", "-@xfail -@not_implemented", ]) -def test_select_tag_expression_parser__with_v1(text): - parser = select_tag_expression_parser(text) +def test_select_tag_expression_parser4any__with_v1(text): + parser = select_tag_expression_parser4any(text) assert parser is parse_tag_expression_v1, "tag_expression: %s" % text @@ -45,6 +45,6 @@ def test_select_tag_expression_parser__with_v1(text): "(@foo and @bar) or @baz", "not @xfail or not @not_implemented" ]) -def test_select_tag_expression_parser__with_v2(text): - parser = select_tag_expression_parser(text) +def test_select_tag_expression_parser4any__with_v2(text): + parser = select_tag_expression_parser4any(text) assert parser is parse_tag_expression_v2, "tag_expression: %s" % text diff --git a/tests/unit/tag_expression/test_parser.py b/tests/unit/tag_expression/test_parser.py index 49e3fe7a0..dfe14f4d2 100644 --- a/tests/unit/tag_expression/test_parser.py +++ b/tests/unit/tag_expression/test_parser.py @@ -164,7 +164,8 @@ def test_parse__empty_is_always_true(self, text): ("a or not b", "( a or not ( b ) )"), ("not a and b", "( not ( a ) and b )"), ("not a or b", "( not ( a ) or b )"), - ("not (a and b) or c", "( not ( ( a and b ) ) or c )"), + ("not (a and b) or c", "( not ( a and b ) or c )"), + # OLD: ("not (a and b) or c", "( not ( ( a and b ) ) or c )"), ]) def test_parse__ensure_precedence(self, text, expected): """Ensures that the operation precedence is parsed correctly.""" From 1e8ac9e5e92f037a3ba66056359a0aff84326dcb Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 8 Jun 2023 19:05:44 +0200 Subject: [PATCH 173/240] CLEANUP: behave.configuration * Constructor: Simplify, move parts to own "setup_()" methods * Config-file processing: Simplify and fix some bugs HINT: Negated-option descriptions were processed, ... * Color-mode: Simplify detection and use for others. --- behave/__main__.py | 2 + behave/configuration.py | 703 +++++++++++++++++++----------- behave/tag_expression/__init__.py | 7 +- docs/behave.rst | 58 +-- docs/update_behave_rst.py | 43 +- tests/unit/test_configuration.py | 162 ++++++- 6 files changed, 669 insertions(+), 306 deletions(-) diff --git a/behave/__main__.py b/behave/__main__.py index 723ea855c..346533d7f 100644 --- a/behave/__main__.py +++ b/behave/__main__.py @@ -36,10 +36,12 @@ --tags="not (@fixme or @xfail)" NOTES: + * The tag-prefix "@" is optional. * An empty tag-expression is "true" (select-anything). TAG-INHERITANCE: + * A Rule inherits the tags of its Feature * A Scenario inherits the tags of its Feature or Rule. * A Scenario of a ScenarioOutline/ScenarioTemplate inherit tags diff --git a/behave/configuration.py b/behave/configuration.py index 4bdaf1648..3b28409e4 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -1,10 +1,22 @@ -# -*- coding: utf-8 -*- +# -*- coding: UTF-8 -*- +# pylint: disable=redundant-u-string-prefix +# pylint: disable=consider-using-f-string +# pylint: disable=too-many-lines +# pylint: disable=useless-object-inheritance +# pylint: disable=use-dict-literal +""" +This module provides the configuration for :mod:`behave`: + +* Configuration object(s) +* config-file loading and storing params in Configuration object(s) +* command-line parsing and storing params in Configuration object(s) +""" from __future__ import absolute_import, print_function import argparse -import inspect import json import logging +from logging.config import fileConfig as logging_config_fileConfig import os import re import sys @@ -14,11 +26,11 @@ from behave.model import ScenarioOutline from behave.model_core import FileLocation -from behave.reporter.junit import JUnitReporter -from behave.reporter.summary import SummaryReporter -from behave.tag_expression import make_tag_expression from behave.formatter.base import StreamOpener from behave.formatter import _registry as _format_registry +from behave.reporter.junit import JUnitReporter +from behave.reporter.summary import SummaryReporter +from behave.tag_expression import make_tag_expression, TagExpressionProtocol from behave.userdata import UserData, parse_user_define from behave._types import Unknown from behave.textutil import select_best_encoding, to_texts @@ -26,19 +38,21 @@ # -- PYTHON 2/3 COMPATIBILITY: # SINCE Python 3.2: ConfigParser = SafeConfigParser ConfigParser = configparser.ConfigParser -if six.PY2: +if six.PY2: # pragma: no cover ConfigParser = configparser.SafeConfigParser -try: - if sys.version_info >= (3, 11): - import tomllib - elif sys.version_info < (3, 0): - import toml as tomllib - else: - import tomli as tomllib - _TOML_AVAILABLE = True -except ImportError: - _TOML_AVAILABLE = False +# -- OPTIONAL TOML SUPPORT: Using "pyproject.toml" as config-file +_TOML_AVAILABLE = True +if _TOML_AVAILABLE: # pragma: no cover + try: + if sys.version_info >= (3, 11): + import tomllib + elif sys.version_info < (3, 0): + import toml as tomllib + else: + import tomli as tomllib + except ImportError: + _TOML_AVAILABLE = False # ----------------------------------------------------------------------------- @@ -92,17 +106,22 @@ def positive_number(text): # ----------------------------------------------------------------------------- # CONFIGURATION SCHEMA: # ----------------------------------------------------------------------------- -options = [ - (("-c", "--no-color"), - dict(action="store_false", dest="color", - help="Disable the use of ANSI color escapes.")), +COLOR_CHOICES = ["auto", "on", "off", "always", "never"] +COLOR_DEFAULT = os.getenv("BEHAVE_COLOR", "auto") +COLOR_DEFAULT_OFF = "off" +COLOR_ON_VALUES = ("on", "always") +COLOR_OFF_VALUES = ("off", "never") + + +OPTIONS = [ + (("-C", "--no-color"), + dict(dest="color", action="store_const", const=COLOR_DEFAULT_OFF, + help="Disable colored mode.")), (("--color",), - dict(dest="color", choices=["never", "always", "auto"], - default=os.getenv('BEHAVE_COLOR'), const="auto", nargs="?", - help="""Use ANSI color escapes. Defaults to %(const)r. - This switch is used to override a - configuration file setting.""")), + dict(dest="color", choices=COLOR_CHOICES, + default=COLOR_DEFAULT, const=COLOR_DEFAULT, nargs="?", + help="""Use colored mode or not (default: %(default)s).""")), (("-d", "--dry-run"), dict(action="store_true", @@ -143,7 +162,8 @@ def positive_number(text): (("-j", "--jobs", "--parallel"), dict(metavar="NUMBER", dest="jobs", default=1, type=positive_number, help="""Number of concurrent jobs to use (default: %(default)s). - Only supported by test runners that support parallel execution.""")), + Only supported by test runners that support parallel execution. + """)), ((), # -- CONFIGFILE only dict(dest="default_format", default="pretty", @@ -151,13 +171,13 @@ def positive_number(text): (("-f", "--format"), - dict(action="append", + dict(dest="format", action="append", help="""Specify a formatter. If none is specified the default formatter is used. Pass "--format help" to get a list of available formatters.""")), (("--steps-catalog",), - dict(action="store_true", dest="steps_catalog", + dict(dest="steps_catalog", action="store_true", help="""Show a catalog of all available step definitions. SAME AS: --format=steps.catalog --dry-run --no-summary -q""")), @@ -167,7 +187,7 @@ def positive_number(text): (default="{name} -- @{row.id} {examples.name}").""")), (("--no-skipped",), - dict(action="store_false", dest="show_skipped", + dict(dest="show_skipped", action="store_false", help="Don't print skipped steps (due to tags).")), (("--show-skipped",), @@ -177,69 +197,69 @@ def positive_number(text): override a configuration file setting.""")), (("--no-snippets",), - dict(action="store_false", dest="show_snippets", + dict(dest="show_snippets", action="store_false", help="Don't print snippets for unimplemented steps.")), (("--snippets",), - dict(action="store_true", dest="show_snippets", + dict(dest="show_snippets", action="store_true", help="""Print snippets for unimplemented steps. This is the default behaviour. This switch is used to override a configuration file setting.""")), (("--no-multiline",), - dict(action="store_false", dest="show_multiline", + dict(dest="show_multiline", action="store_false", help="""Don't print multiline strings and tables under steps.""")), (("--multiline", ), - dict(action="store_true", dest="show_multiline", + dict(dest="show_multiline", action="store_true", help="""Print multiline strings and tables under steps. This is the default behaviour. This switch is used to override a configuration file setting.""")), (("-n", "--name"), - dict(action="append", metavar="NAME_PATTERN", + dict(dest="name", action="append", metavar="NAME_PATTERN", help="""Select feature elements (scenarios, ...) to run which match part of the given name (regex pattern). If this option is given more than once, it will match against all the given names.""")), (("--no-capture",), - dict(action="store_false", dest="stdout_capture", + dict(dest="stdout_capture", action="store_false", help="""Don't capture stdout (any stdout output will be printed immediately.)""")), (("--capture",), - dict(action="store_true", dest="stdout_capture", + dict(dest="stdout_capture", action="store_true", help="""Capture stdout (any stdout output will be printed if there is a failure.) This is the default behaviour. This switch is used to override a configuration file setting.""")), (("--no-capture-stderr",), - dict(action="store_false", dest="stderr_capture", + dict(dest="stderr_capture", action="store_false", help="""Don't capture stderr (any stderr output will be printed immediately.)""")), (("--capture-stderr",), - dict(action="store_true", dest="stderr_capture", + dict(dest="stderr_capture", action="store_true", help="""Capture stderr (any stderr output will be printed if there is a failure.) This is the default behaviour. This switch is used to override a configuration file setting.""")), (("--no-logcapture",), - dict(action="store_false", dest="log_capture", + dict(dest="log_capture", action="store_false", help="""Don't capture logging. Logging configuration will be left intact.""")), (("--logcapture",), - dict(action="store_true", dest="log_capture", + dict(dest="log_capture", action="store_true", help="""Capture logging. All logging during a step will be captured and displayed in the event of a failure. This is the default behaviour. This switch is used to override a configuration file setting.""")), (("--logging-level",), - dict(type=LogLevel.parse_type, + dict(type=LogLevel.parse_type, default=logging.INFO, help="""Specify a level to capture logging at. The default is INFO - capturing everything.""")), @@ -288,12 +308,21 @@ def positive_number(text): help="""Display the summary at the end of the run.""")), (("-o", "--outfile"), - dict(action="append", dest="outfiles", metavar="FILE", + dict(dest="outfiles", action="append", metavar="FILE", help="Write to specified file instead of stdout.")), ((), # -- CONFIGFILE only - dict(action="append", dest="paths", + dict(dest="paths", action="append", help="Specify default feature paths, used when none are provided.")), + ((), # -- CONFIGFILE only + dict(dest="tag_expression_protocol", type=TagExpressionProtocol.parse, + choices=TagExpressionProtocol.choices(), + default=TagExpressionProtocol.default().name.lower(), + help="""\ +Specify the tag-expression protocol to use (default: %(default)s). +With "any", tag-expressions v2 and v2 are supported (in auto-detect mode). +With "strict", only tag-expressions v2 is supported (better error diagnostics). +""")), (("-q", "--quiet"), dict(action="store_true", @@ -305,12 +334,12 @@ def positive_number(text): help='Use own runner class, like: "behave.runner:Runner"')), (("--no-source",), - dict(action="store_false", dest="show_source", + dict( dest="show_source", action="store_false", help="""Don't print the file and line of the step definition with the steps.""")), (("--show-source",), - dict(action="store_true", dest="show_source", + dict(dest="show_source", action="store_true", help="""Print the file and line of the step definition with the steps. This is the default behaviour. This switch is used to override a @@ -346,11 +375,11 @@ def positive_number(text): tag expressions in configuration files.""")), (("-T", "--no-timings"), - dict(action="store_false", dest="show_timings", + dict( dest="show_timings", action="store_false", help="""Don't print the time taken for each step.""")), (("--show-timings",), - dict(action="store_true", dest="show_timings", + dict(dest="show_timings", action="store_true", help="""Print the time taken, in seconds, of each step after the step has completed. This is the default behaviour. This switch is used to override a configuration file @@ -386,81 +415,109 @@ def positive_number(text): dict(action="store_true", help="Show version.")), ] + +# -- CONFIG-FILE SKIPS: +# * Skip SOME_HELP options, like: --tags-help, --lang-list, ... +# * Skip --no- options (action: "store_false", "store_const") +CONFIGFILE_EXCLUDED_OPTIONS = set([ + "tags_help", "lang_list", "lang_help", + "version", + "userdata_defines", +]) +CONFIGFILE_EXCLUDED_ACTIONS = set(["store_false", "store_const"]) + # -- OPTIONS: With raw value access semantics in configuration file. -raw_value_options = frozenset([ +RAW_VALUE_OPTIONS = frozenset([ "logging_format", "logging_datefmt", # -- MAYBE: "scenario_outline_annotation_schema", ]) -def values_to_str(d): - return json.loads( - json.dumps(d), +def _values_to_str(data): + return json.loads(json.dumps(data), parse_float=str, parse_int=str, parse_constant=str ) -def decode_options(config): - for fixed, keywords in options: - if "dest" in keywords: +def has_negated_option(option_words): + return any([word.startswith("--no-") for word in option_words]) + + +def derive_dest_from_long_option(fixed_options): + for option_name in fixed_options: + if option_name.startswith("--"): + return option_name[2:].replace("-", "_") + return None + +# -- TEST-BALLOON: +from collections import namedtuple +ConfigFileOption = namedtuple("ConfigFileOption", ("dest", "action", "type")) + + +def configfile_options_iter(config): + skip_missing = bool(config) + def config_has_param(config, param_name): + try: + return param_name in config["behave"] + except AttributeError as exc: # pragma: no cover + # H-- INT: PY27: SafeConfigParser instance has no attribute "__getitem__" + return config.has_option("behave", param_name) + except KeyError: + return False + + for fixed, keywords in OPTIONS: + action = keywords.get("action", "store") + if has_negated_option(fixed) or action == "store_false": + # -- SKIP NEGATED OPTIONS, like: --no-color + continue + elif "dest" in keywords: dest = keywords["dest"] else: - dest = None - for opt in fixed: - if opt.startswith("--"): - dest = opt[2:].replace("-", "_") - else: - assert len(opt) == 2 - dest = opt[1:] - if ( - not dest - ) or ( - dest in "tags_help lang_list lang_help version".split() - ): + # -- CASE: dest=... keyword is missing + # DERIVE IT FROM: fixed-option words. + dest = derive_dest_from_long_option(fixed) + if not dest or (dest in CONFIGFILE_EXCLUDED_OPTIONS): continue - try: - if dest not in config["behave"]: - continue - except AttributeError as exc: - # SafeConfigParser instance has no attribute '__getitem__' (py27) - if "__getitem__" not in str(exc): - raise - if not config.has_option("behave", dest): - continue - except KeyError: + elif skip_missing and not config_has_param(config, dest): continue + + # -- FINALLY: action = keywords.get("action", "store") - yield dest, action + value_type = keywords.get("type", None) + # OLD: yield dest, action, value_type + yield ConfigFileOption(dest, action, value_type) -def format_outfiles_coupling(result, config_dir): +def format_outfiles_coupling(config_data, config_dir): # -- STEP: format/outfiles coupling - if "format" in result: + if "format" in config_data: # -- OPTIONS: format/outfiles are coupled in configuration file. - formatters = result["format"] + formatters = config_data["format"] formatter_size = len(formatters) - outfiles = result.get("outfiles", []) + outfiles = config_data.get("outfiles", []) outfiles_size = len(outfiles) if outfiles_size < formatter_size: for formatter_name in formatters[outfiles_size:]: outfile = "%s.output" % formatter_name outfiles.append(outfile) - result["outfiles"] = outfiles + config_data["outfiles"] = outfiles elif len(outfiles) > formatter_size: print("CONFIG-ERROR: Too many outfiles (%d) provided." % outfiles_size) - result["outfiles"] = outfiles[:formatter_size] + config_data["outfiles"] = outfiles[:formatter_size] for paths_name in ("paths", "outfiles"): - if paths_name in result: + if paths_name in config_data: # -- Evaluate relative paths relative to location. # NOTE: Absolute paths are preserved by os.path.join(). - paths = result[paths_name] - result[paths_name] = \ - [os.path.normpath(os.path.join(config_dir, p)) for p in paths] + paths = config_data[paths_name] + config_data[paths_name] = [ + os.path.normpath(os.path.join(config_dir, p)) + for p in paths + ] def read_configparser(path): @@ -468,25 +525,32 @@ def read_configparser(path): config = ConfigParser() config.optionxform = str # -- SUPPORT: case-sensitive keys config.read(path) - config_dir = os.path.dirname(path) - result = {} + this_config = {} + + for dest, action, value_type in configfile_options_iter(config): + param_name = dest + if dest == "tags": + # -- SPECIAL CASE: Distinguish config-file tags from command-line. + param_name = "config_tags" - for dest, action in decode_options(config): if action == "store": - result[dest] = config.get( - "behave", dest, raw=dest in raw_value_options - ) - elif action in ("store_true", "store_false"): - result[dest] = config.getboolean("behave", dest) + raw_mode = dest in RAW_VALUE_OPTIONS + value = config.get("behave", dest, raw=raw_mode) + if value_type: + value = value_type(value) # May raise ParseError/ValueError, etc. + this_config[param_name] = value + elif action == "store_true": + # -- HINT: Only non-negative options are used in config-file. + # SKIPS: --no-color, --no-snippets, ... + this_config[param_name] = config.getboolean("behave", dest) elif action == "append": - if dest == "userdata_defines": - continue # -- SKIP-CONFIGFILE: Command-line only option. - result[dest] = \ - [s.strip() for s in config.get("behave", dest).splitlines()] - else: + value_parts = config.get("behave", dest).splitlines() + this_config[param_name] = [part.strip() for part in value_parts] + elif action not in CONFIGFILE_EXCLUDED_ACTIONS: # pragma: no cover raise ValueError('action "%s" not implemented' % action) - format_outfiles_coupling(result, config_dir) + config_dir = os.path.dirname(path) + format_outfiles_coupling(this_config, config_dir) # -- STEP: Special additional configuration sections. # SCHEMA: config_section: data_name @@ -496,77 +560,95 @@ def read_configparser(path): "behave.userdata": "userdata", } for section_name, data_name in special_config_section_map.items(): - result[data_name] = {} + this_config[data_name] = {} if config.has_section(section_name): - result[data_name].update(config.items(section_name)) + this_config[data_name].update(config.items(section_name)) - return result + return this_config -def read_toml(path): - """Read configuration from pyproject.toml file. +def read_toml_config(path): + """ + Read configuration from "pyproject.toml" file. + The "behave" configuration should be stored in TOML table(s): - Configuration should be stored inside the 'tool.behave' table. + * "tool.behave" + * "tool.behave.*" - See https://www.python.org/dev/peps/pep-0518/#tool-table + SEE: https://www.python.org/dev/peps/pep-0518/#tool-table """ # pylint: disable=too-many-locals, too-many-branches - with open(path, "rb") as tomlfile: - config = json.loads(json.dumps(tomllib.load(tomlfile))) # simple dict + with open(path, "rb") as toml_file: + # -- HINT: Use simple dictionary for "config". + config = json.loads(json.dumps(tomllib.load(toml_file))) - config = config['tool'] - config_dir = os.path.dirname(path) - result = {} + config_tool = config["tool"] + this_config = {} - for dest, action in decode_options(config): - raw = config["behave"][dest] + for dest, action, value_type in configfile_options_iter(config_tool): + param_name = dest + if dest == "tags": + # -- SPECIAL CASE: Distinguish config-file tags from command-line. + param_name = "config_tags" + + raw_value = config_tool["behave"][dest] if action == "store": - result[dest] = str(raw) + this_config[param_name] = str(raw_value) elif action in ("store_true", "store_false"): - result[dest] = bool(raw) + this_config[param_name] = bool(raw_value) elif action == "append": - if dest == "userdata_defines": - continue # -- SKIP-CONFIGFILE: Command-line only option. - # toml has native arrays and quoted strings, so there's no - # need to split by newlines or strip values - result[dest] = raw - else: + # -- TOML SPECIFIC: + # TOML has native arrays and quoted strings. + # There is no need to split by newlines or strip values. + this_config[param_name] = raw_value + elif action not in CONFIGFILE_EXCLUDED_ACTIONS: raise ValueError('action "%s" not implemented' % action) - format_outfiles_coupling(result, config_dir) + + config_dir = os.path.dirname(path) + format_outfiles_coupling(this_config, config_dir) # -- STEP: Special additional configuration sections. # SCHEMA: config_section: data_name special_config_section_map = { "formatters": "more_formatters", + "runners": "more_runners", "userdata": "userdata", } for section_name, data_name in special_config_section_map.items(): - result[data_name] = {} + this_config[data_name] = {} try: - result[data_name] = values_to_str(config["behave"][section_name]) + section_data = config_tool["behave"][section_name] + this_config[data_name] = _values_to_str(section_data) except KeyError: - result[data_name] = {} + this_config[data_name] = {} + + return this_config - return result + +CONFIG_FILE_PARSERS = { + "ini": read_configparser, + "cfg": read_configparser, + "behaverc": read_configparser, +} +if _TOML_AVAILABLE: + CONFIG_FILE_PARSERS["toml"] = read_toml_config def read_configuration(path, verbose=False): - ext = path.split(".")[-1] - parsers = { - "ini": read_configparser, - "cfg": read_configparser, - "behaverc": read_configparser, - } + """ + Read the "behave" config from a config-file. - if _TOML_AVAILABLE: - parsers["toml"] = read_toml - parse_func = parsers.get(ext, None) + :param path: Path to the config-file + """ + file_extension = path.split(".")[-1] + parse_func = CONFIG_FILE_PARSERS.get(file_extension, None) if not parse_func: if verbose: - print('Unable to find a parser for "%s"' % path) + print("MISSING CONFIG-FILE PARSER FOR: %s" % path) return {} - parsed = parse_func(path) + # -- NORMAL CASE: + parsed = parse_func(path) return parsed @@ -598,35 +680,55 @@ def load_configuration(defaults, verbose=False): def setup_parser(): # construct the parser - # usage = "%(prog)s [options] [ [FILE|DIR|URL][:LINE[:LINE]*] ]+" - usage = "%(prog)s [options] [ [DIR|FILE|FILE:LINE] ]+" - description = """\ - Run a number of feature tests with behave.""" - more = """ - EXAMPLES: - behave features/ - behave features/one.feature features/two.feature - behave features/one.feature:10 - behave @features.txt - """ - parser = argparse.ArgumentParser(usage=usage, description=description) - for fixed, keywords in options: + # usage = "%(prog)s [options] [FILE|DIR|FILE:LINE|AT_FILE]+" + usage = "%(prog)s [options] [DIRECTORY|FILE|FILE:LINE|AT_FILE]*" + description = """Run a number of feature tests with behave. + +EXAMPLES: + behave features/ + behave features/one.feature features/two.feature + behave features/one.feature:10 + behave @features.txt +""" + formatter_class = argparse.RawDescriptionHelpFormatter + parser = argparse.ArgumentParser(usage=usage, + description=description, + formatter_class=formatter_class) + for fixed, keywords in OPTIONS: if not fixed: - continue # -- CONFIGFILE only. + # -- SKIP: CONFIG-FILE ONLY OPTION. + continue + if "config_help" in keywords: keywords = dict(keywords) del keywords["config_help"] parser.add_argument(*fixed, **keywords) parser.add_argument("paths", nargs="*", - help="Feature directory, file or file location (FILE:LINE).") + help="Feature directory, file or file-location (FILE:LINE).") return parser +def setup_config_file_parser(): + # -- TEST-BALLOON: Auto-documentation of config-file schema. + # COVERS: config-file.section="behave" + description = "config-file schema" + formatter_class = argparse.RawDescriptionHelpFormatter + parser = argparse.ArgumentParser(description=description, + formatter_class=formatter_class) + for fixed, keywords in configfile_options_iter(None): + if "config_help" in keywords: + keywords = dict(keywords) + config_help = keywords["config_help"] + keywords["help"] = config_help + del keywords["config_help"] + parser.add_argument(*fixed, **keywords) + return parser + class Configuration(object): """Configuration object for behave and behave runners.""" # pylint: disable=too-many-instance-attributes defaults = dict( - color='never' if sys.platform == "win32" else os.getenv('BEHAVE_COLOR', 'auto'), + color=os.getenv("BEHAVE_COLOR", COLOR_DEFAULT), jobs=1, show_snippets=True, show_skipped=True, @@ -641,12 +743,14 @@ class Configuration(object): runner=DEFAULT_RUNNER_CLASS_NAME, steps_catalog=False, summary=True, + tag_expression_protocol=TagExpressionProtocol.default(), junit=False, stage=None, userdata={}, # -- SPECIAL: default_format="pretty", # -- Used when no formatters are configured. default_tags="", # -- Used when no tags are defined. + config_tags=None, scenario_outline_annotation_schema=u"{name} -- @{row.id} {examples.name}" ) cmdline_only_options = set("userdata_defines") @@ -666,33 +770,49 @@ def __init__(self, command_args=None, load_config=True, verbose=None, :param verbose: Indicate if diagnostic output is enabled :param kwargs: Used to hand-over/overwrite default values. """ - # pylint: disable=too-many-branches, too-many-statements - if command_args is None: - command_args = sys.argv[1:] - elif isinstance(command_args, six.string_types): - encoding = select_best_encoding() or "utf-8" - if six.PY2 and isinstance(command_args, six.text_type): - command_args = command_args.encode(encoding) - elif six.PY3 and isinstance(command_args, six.binary_type): - command_args = command_args.decode(encoding) - command_args = shlex.split(command_args) - elif isinstance(command_args, (list, tuple)): - command_args = to_texts(command_args) + self.init(verbose=verbose, **kwargs) - if verbose is None: - # -- AUTO-DISCOVER: Verbose mode from command-line args. - verbose = ("-v" in command_args) or ("--verbose" in command_args) + # -- STEP: Load config-file(s) and parse command-line + command_args = self.make_command_args(command_args, verbose=verbose) + if load_config: + load_configuration(self.defaults, verbose=verbose) + parser = setup_parser() + parser.set_defaults(**self.defaults) + args = parser.parse_args(command_args) + for key, value in six.iteritems(args.__dict__): + if key.startswith("_") and key not in self.cmdline_only_options: + continue + setattr(self, key, value) - # Allow commands like `--color features/whizbang.feature` to work - # Without this, argparse will treat the positional arg as the value to - # --color and we'd get: - # argument --color: invalid choice: 'features/whizbang.feature' - # (choose from 'never', 'always', 'auto') - if '--color' in command_args: - color_arg_pos = command_args.index('--color') - if os.path.exists(command_args[color_arg_pos + 1]): - command_args.insert(color_arg_pos + 1, '--') + self.paths = [os.path.normpath(path) for path in self.paths] + self.setup_outputs(args.outfiles) + if self.steps_catalog: + self.setup_steps_catalog_mode() + if self.wip: + self.setup_wip_mode() + if self.quiet: + self.show_source = False + self.show_snippets = False + + self.setup_tag_expression() + self.setup_select_by_filters() + self.setup_stage(self.stage) + self.setup_model() + self.setup_userdata() + self.setup_runner_aliases() + + # -- FINALLY: Setup Reporters and Formatters + # NOTE: Reporters and Formatters can now use userdata information. + self.setup_reporters() + self.setup_formats() + self.show_bad_formats_and_fail(parser) + + def init(self, verbose=None, **kwargs): + """ + (Re-)Init this configuration object. + """ + self.defaults = self.make_defaults(**kwargs) self.version = None self.tags_help = None self.lang_list = None @@ -701,17 +821,18 @@ def __init__(self, command_args=None, load_config=True, verbose=None, self.junit = None self.logging_format = None self.logging_datefmt = None + self.logging_level = None self.name = None - self.scope = None + self.stage = None self.steps_catalog = None + self.tag_expression_protocol = None + self.tag_expression = None + self.tags = None + self.config_tags = None + self.default_tags = None self.userdata = None self.wip = None - self.verbose = verbose - - defaults = self.defaults.copy() - for name, value in six.iteritems(kwargs): - defaults[name] = value - self.defaults = defaults + self.verbose = verbose or False self.formatters = [] self.reporters = [] self.name_re = None @@ -724,79 +845,103 @@ def __init__(self, command_args=None, load_config=True, verbose=None, self.userdata_defines = None self.more_formatters = None self.more_runners = None - self.runner_aliases = dict(default=DEFAULT_RUNNER_CLASS_NAME) - if load_config: - load_configuration(self.defaults, verbose=verbose) - parser = setup_parser() - parser.set_defaults(**self.defaults) - args = parser.parse_args(command_args) - for key, value in six.iteritems(args.__dict__): - if key.startswith("_") and key not in self.cmdline_only_options: - continue - setattr(self, key, value) + self.runner_aliases = { + "default": DEFAULT_RUNNER_CLASS_NAME + } - # -- ATTRIBUTE-NAME-CLEANUP: - self.tag_expression = None - self._tags = self.tags - self.tags = None - if isinstance(self.default_tags, six.string_types): - self.default_tags = self.default_tags.split() + @classmethod + def make_defaults(cls, **kwargs): + data = cls.defaults.copy() + for name, value in six.iteritems(kwargs): + data[name] = value + return data - self.paths = [os.path.normpath(path) for path in self.paths] - self.setup_outputs(args.outfiles) + def has_colored_mode(self, file=None): + if self.color in COLOR_ON_VALUES: + return True + elif self.color in COLOR_OFF_VALUES: + return False + else: + # -- AUTO-DETECT: color="auto" + output_file = file or sys.stdout + isatty = getattr(output_file, "isatty", lambda: True) + colored = isatty() + return colored - if self.steps_catalog: - # -- SHOW STEP-CATALOG: As step summary. - self.default_format = "steps.catalog" - if self.format: - self.format.append("steps.catalog") - else: - self.format = ["steps.catalog"] - self.dry_run = True - self.summary = False - self.show_skipped = False - self.quiet = True + def make_command_args(self, command_args=None, verbose=None): + # pylint: disable=too-many-branches, too-many-statements + if command_args is None: + command_args = sys.argv[1:] + elif isinstance(command_args, six.string_types): + encoding = select_best_encoding() or "utf-8" + if six.PY2 and isinstance(command_args, six.text_type): + command_args = command_args.encode(encoding) + elif six.PY3 and isinstance(command_args, six.binary_type): + command_args = command_args.decode(encoding) + command_args = shlex.split(command_args) + elif isinstance(command_args, (list, tuple)): + command_args = to_texts(command_args) - if self.wip: - # Only run scenarios tagged with "wip". - # Additionally: - # * use the "plain" formatter (per default) - # * do not capture stdout or logging output and - # * stop at the first failure. - self.default_format = "plain" - self._tags = ["wip"] + self.default_tags - self.color = False - self.stop = True - self.log_capture = False - self.stdout_capture = False - - self.tag_expression = make_tag_expression(self._tags or self.default_tags) - # -- BACKWARD-COMPATIBLE (BAD-NAMING STYLE; deprecating): - self.tags = self.tag_expression + # -- SUPPORT OPTION: --color=VALUE and --color (without VALUE) + # HACK: Should be handled in command-line parser specification. + # OPTION: --color=value, --color (hint: with optional value) + # SUPPORTS: + # behave --color features/some.feature # PROBLEM-POINT + # behave --color=auto features/some.feature # NO_PROBLEM + # behave --color auto features/some.feature # NO_PROBLEM + if "--color" in command_args: + color_arg_pos = command_args.index("--color") + next_arg = command_args[color_arg_pos + 1] + if os.path.exists(next_arg): + command_args.insert(color_arg_pos + 1, "--") - if self.quiet: - self.show_source = False - self.show_snippets = False + if verbose is None: + # -- AUTO-DISCOVER: Verbose mode from command-line args. + verbose = ("-v" in command_args) or ("--verbose" in command_args) + self.verbose = verbose + return command_args + + def setup_wip_mode(self): + # Only run scenarios tagged with "wip". + # Additionally: + # * use the "plain" formatter (per default) + # * do not capture stdout or logging output and + # * stop at the first failure. + self.default_format = "plain" + self.color = "off" + self.stop = True + self.log_capture = False + self.stdout_capture = False + + # -- EXTEND TAG-EXPRESSION: Add @wip tag + self.tags = self.tags or [] + if self.tags and isinstance(self.tags, six.string_types): + self.tags = [self.tags] + self.tags.append("@wip") + + def setup_steps_catalog_mode(self): + # -- SHOW STEP-CATALOG: As step summary. + self.default_format = "steps.catalog" + self.format = self.format or [] + if self.format: + self.format.append("steps.catalog") + else: + self.format = ["steps.catalog"] + self.dry_run = True + self.summary = False + self.show_skipped = False + self.quiet = True + def setup_select_by_filters(self): if self.exclude_re: self.exclude_re = re.compile(self.exclude_re) - if self.include_re: self.include_re = re.compile(self.include_re) if self.name: # -- SELECT: Scenario-by-name, build regular expression. self.name_re = self.build_name_re(self.name) - if self.stage is None: # pylint: disable=access-member-before-definition - # -- USE ENVIRONMENT-VARIABLE, if stage is undefined. - self.stage = os.environ.get("BEHAVE_STAGE", None) - self.setup_stage(self.stage) - self.setup_model() - self.setup_userdata() - self.setup_runner_aliases() - - # -- FINALLY: Setup Reporters and Formatters - # NOTE: Reporters and Formatters can now use userdata information. + def setup_reporters(self): if self.junit: # Buffer the output (it will be put into Junit report) self.stdout_capture = True @@ -806,7 +951,10 @@ def __init__(self, command_args=None, load_config=True, verbose=None, if self.summary: self.reporters.append(SummaryReporter(self)) - self.setup_formats() + def show_bad_formats_and_fail(self, parser): + """ + Show any BAD-FORMATTER(s) and fail with ``ParseError``if any exists. + """ bad_formats_and_errors = self.select_bad_formats_with_errors() if bad_formats_and_errors: bad_format_parts = [] @@ -815,6 +963,54 @@ def __init__(self, command_args=None, load_config=True, verbose=None, bad_format_parts.append(message) parser.error("BAD_FORMAT=%s" % ", ".join(bad_format_parts)) + def setup_tag_expression(self, tags=None): + """ + Build the tag_expression object from: + + * command-line tags (as tag-expression text) + * config-file tags (as tag-expression text) + """ + config_tags = self.config_tags or self.default_tags or "" + tags = tags or self.tags or config_tags + # DISABLED: tags = self._normalize_tags(tags) + + # -- STEP: Support that tags on command-line can use config-file.tags + TagExpressionProtocol.use(self.tag_expression_protocol) + config_tag_expression = make_tag_expression(config_tags) + placeholder = "{config.tags}" + placeholder_value = "{0}".format(config_tag_expression) + if isinstance(tags, six.string_types) and placeholder in tags: + tags = tags.replace(placeholder, placeholder_value) + elif isinstance(tags, (list, tuple)): + for index, item in enumerate(tags): + if placeholder in item: + new_item = item.replace(placeholder, placeholder_value) + tags[index] = new_item + + # -- STEP: Make tag-expression + self.tag_expression = make_tag_expression(tags) + self.tags = tags + + # def _normalize_tags(self, tags): + # if isinstance(tags, six.string_types): + # if tags.startswith('"') and tags.endswith('"'): + # return tags[1:-1] + # elif tags.startswith("'") and tags.endswith("'"): + # return tags[1:-1] + # return tags + # elif not isinstance(tags, (list, tuple)): + # raise TypeError("EXPECTED: string, sequence", tags) + # + # # -- CASE: sequence + # unquote_needed = (any('"' in part for part in tags) or + # any("'" in part for part in tags)) + # if unquote_needed: + # parts = [] + # for part in tags: + # parts.append(self._normalize_tags(part)) + # tags = parts + # return tags + def setup_outputs(self, args_outfiles=None): if self.outputs: assert not args_outfiles, "ONLY-ONCE" @@ -852,7 +1048,7 @@ def select_bad_formats_with_errors(self): try: _ = _format_registry.select_formatter_class(format_name) bad_formats.append((format_name, "InvalidClassError")) - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught formatter_error = e.__class__.__name__ if formatter_error == "KeyError": formatter_error = "LookupError" @@ -911,8 +1107,7 @@ def before_all(context): level = self.logging_level # pylint: disable=no-member if configfile: - from logging.config import fileConfig - fileConfig(configfile) + logging_config_fileConfig(configfile) else: # pylint: disable=no-member format_ = kwargs.pop("format", self.logging_format) @@ -928,7 +1123,8 @@ def setup_model(self): ScenarioOutline.annotation_schema = name_schema.strip() def setup_stage(self, stage=None): - """Setup the test stage that selects a different set of + """ + Set up the test stage that selects a different set of steps and environment implementations. :param stage: Name of current test stage (as string or None). @@ -946,6 +1142,10 @@ def setup_stage(self, stage=None): assert config.steps_dir == "product_steps" assert config.environment_file == "product_environment.py" """ + if stage is None: + # -- USE ENVIRONMENT-VARIABLE, if stage is undefined. + stage = os.environ.get("BEHAVE_STAGE", None) + steps_dir = "steps" environment_file = "environment.py" if stage: @@ -953,6 +1153,9 @@ def setup_stage(self, stage=None): prefix = stage + "_" steps_dir = prefix + steps_dir environment_file = prefix + environment_file + + # -- STORE STAGE-CONFIGURATION: + self.stage = stage self.steps_dir = steps_dir self.environment_file = environment_file diff --git a/behave/tag_expression/__init__.py b/behave/tag_expression/__init__.py index c2d7e5f16..bf6d7d704 100644 --- a/behave/tag_expression/__init__.py +++ b/behave/tag_expression/__init__.py @@ -37,7 +37,10 @@ class TagExpressionProtocol(Enum): """ ANY = 1 STRICT = 2 - DEFAULT = ANY + + @classmethod + def default(cls): + return cls.ANY @classmethod def choices(cls): @@ -64,7 +67,7 @@ def select_parser(self, tag_expression_text_or_seq): @classmethod def current(cls): """Return currently selected protocol instance.""" - return getattr(cls, "_current", cls.DEFAULT) + return getattr(cls, "_current", cls.default()) @classmethod def use(cls, member): diff --git a/docs/behave.rst b/docs/behave.rst index 93a25a9c6..cc80eafc2 100644 --- a/docs/behave.rst +++ b/docs/behave.rst @@ -15,14 +15,13 @@ Command-Line Arguments You may see the same information presented below at any time using ``behave -h``. -.. option:: -c, --no-color +.. option:: -C, --no-color - Disable the use of ANSI color escapes. + Disable colored mode. .. option:: --color - Use ANSI color escapes. Defaults to %(const)r. This switch is used to - override a configuration file setting. + Use colored mode or not (default: auto). .. option:: -d, --dry-run @@ -255,31 +254,29 @@ You may see the same information presented below at any time using ``behave Tag Expression -------------- -Scenarios inherit tags that are declared on the Feature level. -The simplest TAG_EXPRESSION is simply a tag:: - - --tags=@dev - -You may even leave off the "@" - behave doesn't mind. - -You can also exclude all features / scenarios that have a tag, -by using boolean NOT:: - - --tags="not @dev" +TAG-EXPRESSIONS selects Features/Rules/Scenarios by using their tags. +A TAG-EXPRESSION is a boolean expression that references some tags. -A tag expression can also use a logical OR:: +EXAMPLES: - --tags="@dev or @wip" + --tags=@smoke + --tags="not @xfail" + --tags="@smoke or @wip" + --tags="@smoke and @wip" + --tags="(@slow and not @fixme) or @smoke" + --tags="not (@fixme or @xfail)" -The --tags option can be specified several times, -and this represents logical AND, -for instance this represents the boolean expression:: +NOTES: - --tags="(@foo or not @bar) and @zap" +* The tag-prefix "@" is optional. +* An empty tag-expression is "true" (select-anything). -You can also exclude several tags:: +TAG-INHERITANCE: - --tags="not (@fixme or @buggy)" +* A Rule inherits the tags of its Feature +* A Scenario inherits the tags of its Feature or Rule. +* A Scenario of a ScenarioOutline/ScenarioTemplate inherit tags + from this ScenarioOutline/ScenarioTemplate and its Example table. .. _docid.behave.configuration-files: @@ -355,10 +352,9 @@ Configuration Parameters .. index:: single: configuration param; color -.. describe:: color : text +.. describe:: color : Colored (Enum) - Use ANSI color escapes. Defaults to %(const)r. This switch is used to - override a configuration file setting. + Use colored mode or not (default: auto). .. index:: single: configuration param; dry_run @@ -573,6 +569,16 @@ Configuration Parameters Specify default feature paths, used when none are provided. +.. index:: + single: configuration param; tag_expression_protocol + +.. describe:: tag_expression_protocol : TagExpressionProtocol (Enum) + + Specify the tag-expression protocol to use (default: any). With "any", + tag-expressions v2 and v2 are supported (in auto-detect mode). + With "strict", only tag-expressions v2 is supported (better error + diagnostics). + .. index:: single: configuration param; quiet diff --git a/docs/update_behave_rst.py b/docs/update_behave_rst.py index 90965f00f..d0e97cf3e 100755 --- a/docs/update_behave_rst.py +++ b/docs/update_behave_rst.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +# TODO: # -*- coding: UTF-8 -*- """ Generates documentation of behave's @@ -15,7 +16,7 @@ import conf import textwrap from behave import configuration -from behave.__main__ import TAG_HELP +from behave.__main__ import TAG_EXPRESSIONS_HELP positive_number = configuration.positive_number @@ -36,15 +37,23 @@ {text} """ +def is_no_option(fixed_options): + return any([opt.startswith("--no") for opt in fixed_options]) + + # -- STEP: Collect information and preprocess it. -for fixed, keywords in configuration.options: +for fixed, keywords in configuration.OPTIONS: skip = False + config_file_param = True + if is_no_option(fixed): + # -- EXCLUDE: --no-xxx option + config_file_param = False + if "dest" in keywords: dest = keywords["dest"] else: for opt in fixed: if opt.startswith("--no"): - option_case = False skip = True if opt.startswith("--"): dest = opt[2:].replace("-", "_") @@ -54,22 +63,30 @@ dest = opt[1:] # -- COMMON PART: + type_name_default = "text" + type_name_map = { + "color": "Colored (Enum)", + "tag_expression_protocol": "TagExpressionProtocol (Enum)", + } + type_name = "string" action = keywords.get("action", "store") data_type = keywords.get("type", None) default_value = keywords.get("default", None) - if action == "store": - type = "text" + if action in ("store", "store_const"): + type_name = "text" if data_type is positive_number: - type = "positive_number" - if data_type is int: - type = "number" + type_name = "positive_number" + elif data_type is int: + type_name = "number" + else: + type_name = type_name_map.get(dest, type_name_default) elif action in ("store_true","store_false"): - type = "bool" + type_name = "bool" default_value = False if action == "store_true": default_value = True elif action == "append": - type = "sequence" + type_name = "sequence" else: raise ValueError("unknown action %s" % action) @@ -90,7 +107,7 @@ continue # -- CASE: configuration-file parameter - if action == "store_false": + if not config_file_param or action == "store_false": # -- AVOID: Duplicated descriptions, use only case:true. continue @@ -99,7 +116,7 @@ if default_value and "%(default)s" in text: text = text.replace("%(default)s", str(default_value)) text = textwrap.fill(text, 70, initial_indent="", subsequent_indent=indent) - config.append(config_param_schema.format(param=dest, type=type, text=text)) + config.append(config_param_schema.format(param=dest, type=type_name, text=text)) # -- STEP: Generate documentation. @@ -109,7 +126,7 @@ values = dict( cmdline="\n".join(cmdline), - tag_expression=TAG_HELP, + tag_expression=TAG_EXPRESSIONS_HELP, config="\n".join(config), ) with open("behave.rst", "w") as f: diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index e66b3f80f..50bce2f58 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -1,24 +1,30 @@ +from __future__ import absolute_import, print_function +from contextlib import contextmanager import os.path +from pathlib import Path import sys import six import pytest from behave import configuration -from behave.configuration import Configuration, UserData +from behave.configuration import ( + Configuration, + ConfigFileOption, + UserData, + configfile_options_iter +) +from behave.tag_expression import TagExpressionProtocol from unittest import TestCase # one entry of each kind handled # configparser and toml TEST_CONFIGS = [ - ( - ".behaverc", - """[behave] + (".behaverc", """[behave] outfiles= /absolute/path1 relative/path2 paths = /absolute/path3 relative/path4 -default_tags = @foo,~@bar - @zap +default_tags = (@foo and not @bar) or @zap format=pretty tag-counter stdout_capture=no @@ -28,12 +34,12 @@ foo = bar answer = 42 """), - ( - "pyproject.toml", - """[tool.behave] + + # -- TOML CONFIG-FILE: + ("pyproject.toml", """[tool.behave] outfiles = ["/absolute/path1", "relative/path2"] paths = ["/absolute/path3", "relative/path4"] -default_tags = ["@foo,~@bar", "@zap"] +default_tags = ["(@foo and not @bar) or @zap"] format = ["pretty", "tag-counter"] stdout_capture = false bogus = "spam" @@ -57,22 +63,46 @@ ROOTDIR_PREFIX = os.environ.get("BEHAVE_ROOTDIR_PREFIX", ROOTDIR_PREFIX_DEFAULT) +@contextmanager +def use_current_directory(directory_path): + """Use directory as current directory. + + :: + + with use_current_directory("/tmp/some_directory"): + pass # DO SOMETHING in current directory. + # -- ON EXIT: Restore old current-directory. + """ + # -- COMPATIBILITY: Use directory-string instead of Path + initial_directory = str(Path.cwd()) + try: + os.chdir(str(directory_path)) + yield directory_path + finally: + os.chdir(initial_directory) + + # ----------------------------------------------------------------------------- # TEST SUITE: # ----------------------------------------------------------------------------- class TestConfiguration(object): - @pytest.mark.parametrize( - ("filename", "contents"), - list(TEST_CONFIGS) - ) + @pytest.mark.parametrize(("filename", "contents"), list(TEST_CONFIGS)) def test_read_file(self, filename, contents, tmp_path): tndir = str(tmp_path) file_path = os.path.normpath(os.path.join(tndir, filename)) with open(file_path, "w") as fp: fp.write(contents) + # -- WINDOWS-REQUIRES: normpath + # DISABLED: pprint(d, sort_dicts=True) + from pprint import pprint + extra_kwargs = {} + if six.PY3: + extra_kwargs = {"sort_dicts": True} + d = configuration.read_configuration(file_path) + pprint(d, **extra_kwargs) assert d["outfiles"] == [ os.path.normpath(ROOTDIR_PREFIX + "/absolute/path1"), os.path.normpath(os.path.join(tndir, "relative/path2")), @@ -82,7 +112,7 @@ def test_read_file(self, filename, contents, tmp_path): os.path.normpath(os.path.join(tndir, "relative/path4")), ] assert d["format"] == ["pretty", "tag-counter"] - assert d["default_tags"] == ["@foo,~@bar", "@zap"] + assert d["default_tags"] == ["(@foo and not @bar) or @zap"] assert d["stdout_capture"] is False assert "bogus" not in d assert d["userdata"] == {"foo": "bar", "answer": "42"} @@ -204,3 +234,105 @@ def test_update_userdata__without_cmdline_defines(self): expected_data = dict(person1="Alice", person2="Bob", person3="Charly") assert config.userdata == expected_data assert config.userdata_defines is None + + +class TestConfigFileParser(object): + + def test_configfile_iter__verify_option_names(self): + config_options = configfile_options_iter(None) + config_options_names = [opt[0] for opt in config_options] + expected_names = [ + "color", + "default_format", + "default_tags", + "dry_run", + "exclude_re", + "format", + "include_re", + "jobs", + "junit", + "junit_directory", + "lang", + "log_capture", + "logging_clear_handlers", + "logging_datefmt", + "logging_filter", + "logging_format", + "logging_level", + "name", + "outfiles", + "paths", + "quiet", + "runner", + "scenario_outline_annotation_schema", + "show_multiline", + "show_skipped", + "show_snippets", + "show_source", + "show_timings", + "stage", + "stderr_capture", + "stdout_capture", + "steps_catalog", + "stop", + "summary", + "tag_expression_protocol", + "tags", + "verbose", + "wip", + ] + assert sorted(config_options_names) == expected_names + + +class TestConfigFile(object): + + @staticmethod + def make_config_file_with_tag_expression_protocol(value, tmp_path): + config_file = tmp_path / "behave.ini" + config_file.write_text(u""" +[behave] +tag_expression_protocol = {value} +""".format(value=value)) + assert config_file.exists() + + @classmethod + def check_tag_expression_protocol_with_valid_value(cls, value, tmp_path): + TagExpressionProtocol.use(TagExpressionProtocol.default()) + cls.make_config_file_with_tag_expression_protocol(value, tmp_path) + with use_current_directory(tmp_path): + config = Configuration() + print("USE: tag_expression_protocol.value={0}".format(value)) + print("USE: config.tag_expression_protocol={0}".format( + config.tag_expression_protocol)) + + assert config.tag_expression_protocol in TagExpressionProtocol + assert TagExpressionProtocol.current() is config.tag_expression_protocol + + @pytest.mark.parametrize("value", TagExpressionProtocol.choices()) + def test_tag_expression_protocol(self, value, tmp_path): + self.check_tag_expression_protocol_with_valid_value(value, tmp_path) + + @pytest.mark.parametrize("value", ["Any", "ANY", "Strict", "STRICT"]) + def test_tag_expression_protocol__is_not_case_sensitive(self, value, tmp_path): + self.check_tag_expression_protocol_with_valid_value(value, tmp_path) + + @pytest.mark.parametrize("value", [ + "__UNKNOWN__", "v1", "v2", + # -- SIMILAR: to valid values + ".any", "any.", "_strict", "strict_" + ]) + def test_tag_expression_protocol__with_invalid_value_raises_error(self, value, tmp_path): + default_value = TagExpressionProtocol.default() + TagExpressionProtocol.use(default_value) + self.make_config_file_with_tag_expression_protocol(value, tmp_path) + with use_current_directory(tmp_path): + with pytest.raises(ValueError) as exc_info: + config = Configuration() + print("USE: tag_expression_protocol.value={0}".format(value)) + print("USE: config.tag_expression_protocol={0}".format( + config.tag_expression_protocol)) + + assert TagExpressionProtocol.current() is default_value + expected = "{value} (expected: any, strict)".format(value=value) + assert exc_info.type is ValueError + assert expected in str(exc_info.value) From 99d02ca8e01c24525076a406495fbdb02b975c06 Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 8 Jun 2023 22:43:19 +0200 Subject: [PATCH 174/240] FIXED #1116: behave erroring in pretty format in pyproject.toml * Provide feature-file for problem * Improve diagnostics if wrong type is used for config-param --- CHANGES.rst | 1 + behave/__main__.py | 3 +- behave/configuration.py | 15 ++++-- behave/exception.py | 4 ++ issue.features/issue1116.feature | 93 ++++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 issue.features/issue1116.feature diff --git a/CHANGES.rst b/CHANGES.rst index c3a7928bd..7bf80c0da 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -61,6 +61,7 @@ FIXED: * FIXED: Some tests related to python3.11 * FIXED: Some tests related to python3.9 * FIXED: active-tag logic if multiple tags with same category exists. +* issue #1116: behave erroring in pretty format in pyproject.toml (submitted by: morning-sunn) * issue #1061: Scenario should inherit Rule tags (submitted by: testgitdl) * issue #1054: TagExpressions v2: AND concatenation is faulty (submitted by: janoskut) * pull #967: Update __init__.py in behave import to fix pylint (provided by: dsayling) diff --git a/behave/__main__.py b/behave/__main__.py index 346533d7f..dfc59ccc2 100644 --- a/behave/__main__.py +++ b/behave/__main__.py @@ -289,7 +289,8 @@ def main(args=None): config = Configuration(args) return run_behave(config) except ConfigError as e: - print("ConfigError: %s" % e) + exception_class_name = e.__class__.__name__ + print("%s: %s" % (exception_class_name, e)) except TagExpressionError as e: print("TagExpressionError: %s" % e) return 1 # FAILED: diff --git a/behave/configuration.py b/behave/configuration.py index 3b28409e4..69ddcb551 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -24,6 +24,8 @@ import six from six.moves import configparser +from behave._types import Unknown +from behave.exception import ConfigParamTypeError from behave.model import ScenarioOutline from behave.model_core import FileLocation from behave.formatter.base import StreamOpener @@ -31,9 +33,8 @@ from behave.reporter.junit import JUnitReporter from behave.reporter.summary import SummaryReporter from behave.tag_expression import make_tag_expression, TagExpressionProtocol -from behave.userdata import UserData, parse_user_define -from behave._types import Unknown from behave.textutil import select_best_encoding, to_texts +from behave.userdata import UserData, parse_user_define # -- PYTHON 2/3 COMPATIBILITY: # SINCE Python 3.2: ConfigParser = SafeConfigParser @@ -487,7 +488,6 @@ def config_has_param(config, param_name): # -- FINALLY: action = keywords.get("action", "store") value_type = keywords.get("type", None) - # OLD: yield dest, action, value_type yield ConfigFileOption(dest, action, value_type) @@ -545,7 +545,8 @@ def read_configparser(path): this_config[param_name] = config.getboolean("behave", dest) elif action == "append": value_parts = config.get("behave", dest).splitlines() - this_config[param_name] = [part.strip() for part in value_parts] + value_type = value_type or six.text_type + this_config[param_name] = [value_type(part.strip()) for part in value_parts] elif action not in CONFIGFILE_EXCLUDED_ACTIONS: # pragma: no cover raise ValueError('action "%s" not implemented' % action) @@ -600,6 +601,12 @@ def read_toml_config(path): # -- TOML SPECIFIC: # TOML has native arrays and quoted strings. # There is no need to split by newlines or strip values. + value_type = value_type or six.text_type + if not isinstance(raw_value, list): + message = "%s = %r (expected: list<%s>, was: %s)" % \ + (param_name, raw_value, value_type.__name__, + type(raw_value).__name__) + raise ConfigParamTypeError(message) this_config[param_name] = raw_value elif action not in CONFIGFILE_EXCLUDED_ACTIONS: raise ValueError('action "%s" not implemented' % action) diff --git a/behave/exception.py b/behave/exception.py index b2375469d..1a2d17c72 100644 --- a/behave/exception.py +++ b/behave/exception.py @@ -18,6 +18,7 @@ __all__ = [ "ClassNotFoundError", "ConfigError", + "ConfigTypeError", "ConstraintError", "FileNotFoundError", "InvalidClassError", @@ -52,6 +53,9 @@ class ResourceExistsError(ConstraintError): class ConfigError(Exception): """Used if the configuration is (partially) invalid.""" +class ConfigParamTypeError(ConfigError): + """Used if a config-param has the wrong type.""" + # --------------------------------------------------------------------------- # EXCEPTION/ERROR CLASSES: Related to File Handling diff --git a/issue.features/issue1116.feature b/issue.features/issue1116.feature new file mode 100644 index 000000000..d83f8662a --- /dev/null +++ b/issue.features/issue1116.feature @@ -0,0 +1,93 @@ +@issue +@user.failure +Feature: Issue #1116 -- behave erroring in pretty format in pyproject.toml + + . DESCRIPTION OF OBSERVED BEHAVIOR: + . * I am using a "pyproject.toml" with behave-configuration + . * I am using 'format = "pretty"' in the TOML config + . * When I run it with "behave", I get the following error message: + . + . behave: error: BAD_FORMAT=p (problem: LookupError), r (problem: LookupError), ... + . + . PROBLEM ANALYSIS: + . * Config-param: format : sequence = ${default_format} + . * Wrong type "string" was used for "format" config-param. + . + . PROBLEM RESOLUTION: + . * Works fine if the correct type is used. + . * BUT: Improve diagnostics if wrong type is used. + + Background: Setup + Given a new working directory + And a file named "features/steps/use_step_library.py" with: + """ + import behave4cmd0.passing_steps + """ + And a file named "features/simple.feature" with: + """ + Feature: F1 + Scenario: S1 + Given a step passes + When another step passes + """ + + # @use.with_python.min_version="3.0" + @use.with_python3=true + Scenario: Use Problematic Config-File (case: Python 3.x) + Given a file named "pyproject.toml" with: + """ + [tool.behave] + format = "pretty" + """ + When I run "behave features/simple.feature" + Then it should fail with: + """ + ConfigParamTypeError: format = 'pretty' (expected: list, was: str) + """ + And the command output should not contain: + """ + behave: error: BAD_FORMAT=p (problem: LookupError), r (problem: LookupError), + """ + But note that "format config-param uses a string type (expected: list)" + + + # @not.with_python.min_version="3.0" + @use.with_python2=true + Scenario: Use Problematic Config-File (case: Python 2.7) + Given a file named "pyproject.toml" with: + """ + [tool.behave] + format = "pretty" + """ + When I run "behave features/simple.feature" + Then it should fail with: + """ + ConfigParamTypeError: format = u'pretty' (expected: list, was: unicode) + """ + And the command output should not contain: + """ + behave: error: BAD_FORMAT=p (problem: LookupError), r (problem: LookupError), + """ + But note that "format config-param uses a string type (expected: list)" + + + Scenario: Use Good Config-File + Given a file named "pyproject.toml" with: + """ + [tool.behave] + format = ["pretty"] + """ + When I run "behave features/simple.feature" + Then it should pass with: + """ + 1 scenario passed, 0 failed, 0 skipped + """ + And the command output should contain: + """ + Feature: F1 # features/simple.feature:1 + + Scenario: S1 # features/simple.feature:2 + Given a step passes # ../behave4cmd0/passing_steps.py:23 + When another step passes # ../behave4cmd0/passing_steps.py:23 + """ + But note that "the correct format config-param type was used now" From f85234179db0eae5aafd1e378feb228db20e2258 Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 8 Jun 2023 22:45:24 +0200 Subject: [PATCH 175/240] ADDED: pyproject.toml * Used only for ruff linter configuration (for now) --- pyproject.toml | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..23abedbcd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +# ----------------------------------------------------------------------------- +# SECTION: ruff -- Python linter +# ----------------------------------------------------------------------------- +# SEE: https://github.com/charliermarsh/ruff +# Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. +[tool.ruff] +select = ["E", "F"] +ignore = [] + +# Allow autofix for all enabled rules (when `--fix`) is provided. +fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", + "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", + "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", + "TCH", "TID", "TRY", "UP", "YTT" +] +unfixable = [] + +# Exclude a variety of commonly ignored directories. +exclude = [ + ".direnv", + ".eggs", + ".git", + ".ruff_cache", + ".tox", + ".venv*", + "__pypackages__", + "build", + "dist", + "venv", +] +per-file-ignores = {} + +# Same as Black. +# WAS: line-length = 88 +line-length = 100 + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" +target-version = "py310" + +[tool.ruff.mccabe] +max-complexity = 10 From cbc0262eaa02001a007f2a8af09e35e78a21dc25 Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 8 Jun 2023 22:55:32 +0200 Subject: [PATCH 176/240] UPDATE: #1070 status --- CHANGES.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.rst b/CHANGES.rst index 7bf80c0da..8b7bba822 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -61,6 +61,7 @@ FIXED: * FIXED: Some tests related to python3.11 * FIXED: Some tests related to python3.9 * FIXED: active-tag logic if multiple tags with same category exists. +* issue #1070: Color support detection: Fails for WindowsTerminal (provided by: jenisys) * issue #1116: behave erroring in pretty format in pyproject.toml (submitted by: morning-sunn) * issue #1061: Scenario should inherit Rule tags (submitted by: testgitdl) * issue #1054: TagExpressions v2: AND concatenation is faulty (submitted by: janoskut) From a48a2cec7150b017a7ff117713a6928343b38666 Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 8 Jun 2023 23:20:44 +0200 Subject: [PATCH 177/240] UPDATE: CHANGES * Add info on goals for next "behave" release (in the future) --- CHANGES.rst | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 8b7bba822..7069b8891 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,32 @@ Version History =============================================================================== +Version: 1.4.0 (planning) +------------------------------------------------------------------------------- + +GOALS: + +* Drop support for Python 2.7 +* MAYBE: Requires Python >= 3.7 (at least) + +DEPRECATIONS: + +* DEPRECATED: ``tag-expressions v1`` (old-style tag-expressions) + + +Version: 1.3.0 (planning) +------------------------------------------------------------------------------- + +GOALS: + +* Will be released on https://pypi.org +* Inlude all changes from behave v1.2.7 development +* Last version minor version with Python 2.7 support +* ``tag-expressions v2``: Enabled by default ("strict" mode: only v2 supported). +* ``tag-expressions v1``: Disabled by default (in "strict" mode). + BUT: Can be enabled via config-file parameter in "any" mode (supports: v1 and v2). + + Version: 1.2.7 (unreleased) ------------------------------------------------------------------------------- @@ -102,9 +128,10 @@ DOCUMENTATION: BREAKING CHANGES (naming): -* behave.runner.Context._push(layer=None): Was Context._push(layer_name=None) +* behave.configuration.OPTIONS: was ``behave.configuration.options`` +* behave.runner.Context._push(layer=None): was Context._push(layer_name=None) * behave.runner.scoped_context_layer(context, layer=None): - Was scoped_context_layer(context.layer_name=None) + was scoped_context_layer(context.layer_name=None) .. _`cucumber-tag-expressions`: https://pypi.org/project/cucumber-tag-expressions/ From 61f90d830f90cafe60f71b3bfc17b0ad30909c02 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 10 Jun 2023 08:53:19 +0200 Subject: [PATCH 178/240] CLEANUP: configuration * Tweak the CLI help output for "tag_expression_protocol" * show_bad_formats_and_fail(): Check expected-type first * Move import statement to TOP of file. FIXED: * Linter warnings --- behave/configuration.py | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/behave/configuration.py b/behave/configuration.py index 69ddcb551..c62c23ecc 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -14,6 +14,7 @@ from __future__ import absolute_import, print_function import argparse +from collections import namedtuple import json import logging from logging.config import fileConfig as logging_config_fileConfig @@ -321,8 +322,8 @@ def positive_number(text): default=TagExpressionProtocol.default().name.lower(), help="""\ Specify the tag-expression protocol to use (default: %(default)s). -With "any", tag-expressions v2 and v2 are supported (in auto-detect mode). -With "strict", only tag-expressions v2 is supported (better error diagnostics). +With "any", tag-expressions v1 and v2 are supported (in auto-detect mode). +With "strict", only tag-expressions v2 are supported (better error diagnostics). """)), (("-q", "--quiet"), @@ -444,7 +445,7 @@ def _values_to_str(data): def has_negated_option(option_words): - return any([word.startswith("--no-") for word in option_words]) + return any(word.startswith("--no-") for word in option_words) def derive_dest_from_long_option(fixed_options): @@ -453,8 +454,7 @@ def derive_dest_from_long_option(fixed_options): return option_name[2:].replace("-", "_") return None -# -- TEST-BALLOON: -from collections import namedtuple + ConfigFileOption = namedtuple("ConfigFileOption", ("dest", "action", "type")) @@ -463,7 +463,7 @@ def configfile_options_iter(config): def config_has_param(config, param_name): try: return param_name in config["behave"] - except AttributeError as exc: # pragma: no cover + except AttributeError: # pragma: no cover # H-- INT: PY27: SafeConfigParser instance has no attribute "__getitem__" return config.has_option("behave", param_name) except KeyError: @@ -474,7 +474,7 @@ def config_has_param(config, param_name): if has_negated_option(fixed) or action == "store_false": # -- SKIP NEGATED OPTIONS, like: --no-color continue - elif "dest" in keywords: + if "dest" in keywords: dest = keywords["dest"] else: # -- CASE: dest=... keyword is missing @@ -482,7 +482,7 @@ def config_has_param(config, param_name): dest = derive_dest_from_long_option(fixed) if not dest or (dest in CONFIGFILE_EXCLUDED_OPTIONS): continue - elif skip_missing and not config_has_param(config, dest): + if skip_missing and not config_has_param(config, dest): continue # -- FINALLY: @@ -866,14 +866,14 @@ def make_defaults(cls, **kwargs): def has_colored_mode(self, file=None): if self.color in COLOR_ON_VALUES: return True - elif self.color in COLOR_OFF_VALUES: + if self.color in COLOR_OFF_VALUES: return False - else: - # -- AUTO-DETECT: color="auto" - output_file = file or sys.stdout - isatty = getattr(output_file, "isatty", lambda: True) - colored = isatty() - return colored + + # -- OTHERWISE in AUTO-DETECT mode: color="auto" + output_file = file or sys.stdout + isatty = getattr(output_file, "isatty", lambda: True) + colored = isatty() + return colored def make_command_args(self, command_args=None, verbose=None): # pylint: disable=too-many-branches, too-many-statements @@ -962,6 +962,11 @@ def show_bad_formats_and_fail(self, parser): """ Show any BAD-FORMATTER(s) and fail with ``ParseError``if any exists. """ + # -- SANITY-CHECK FIRST: Is correct type used for "config.format" + if self.format is not None and not isinstance(self.format, list): + parser.error("CONFIG-PARAM-TYPE-ERROR: format = %r (expected: list<%s>, was: %s)" % + (self.format, six.text_type, type(self.format).__name__)) + bad_formats_and_errors = self.select_bad_formats_with_errors() if bad_formats_and_errors: bad_format_parts = [] From d7785f60c957f53daa26d592382e38dcf6f2aa2f Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 21 Jun 2023 18:52:33 +0200 Subject: [PATCH 179/240] ADDED: Config-file for read-the-docs * SEE: https://blog.readthedocs.com/migrate-configuration-v2/ --- .readthedocs.yaml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..570518df8 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,28 @@ +# ============================================================================= +# READTHEDOCS CONFIG-FILE: .readthedocs.yaml +# ============================================================================= +# SEE ALSO: +# * https://docs.readthedocs.io/en/stable/config-file/v2.html +# * https://blog.readthedocs.com/migrate-configuration-v2/ +# ============================================================================= + +version: 2 +build: + os: ubuntu-20.04 + tools: + python: "3.11" + +python: + install: + - requirements: py.requirements/docs.txt + - method: pip + path: . + +sphinx: + configuration: docs/conf.py + builder: dirhtml + fail_on_warning: true + +# -- PREPARED: Additional formats to generate +# formats: +# - pdf From 22e530dc897fbdba38c87003013a94edcbb2a414 Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 12 Jun 2023 13:12:23 +0200 Subject: [PATCH 180/240] UPDATE: README.rst * Describe procedure in more detail * Correct old hyperlinks with current usable ones. --- etc/gherkin/README.rst | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/etc/gherkin/README.rst b/etc/gherkin/README.rst index 7ec21081b..3d098049e 100644 --- a/etc/gherkin/README.rst +++ b/etc/gherkin/README.rst @@ -1,4 +1,23 @@ -SOURCE: +behave i18n (gherkin-languages.json) +===================================================================================== -* https://github.com/cucumber/cucumber/blob/master/gherkin/gherkin-languages.json -* https://raw.githubusercontent.com/cucumber/cucumber/master/gherkin/gherkin-languages.json +`behave`_ uses the official `cucumber`_ `gherkin-languages.json`_ file +to keep track of step keywords for any I18n spoken language. + +Use the following procedure if any language keywords are missing/should-be-corrected, etc. + +**PROCEDURE:** + +* Make pull-request on: https://github.com/cucumber/gherkin repository +* After it is merged, I pull the new version of `gherkin-languages.json` and generate `behave/i18n.py` from it +* OPTIONAL: Give an info that it is merged (if I am missing this state-change) + +SEE ALSO: + +* https://github.com/cucumber/gherkin +* https://github.com/cucumber/gherkin/blob/main/gherkin-languages.json +* https://raw.githubusercontent.com/cucumber/gherkin/main/gherkin-languages.json + +.. _behave: https://github.com/behave/behave +.. _cucumber: https://github.com/cucumber/common +.. _gherkin-languages.json: https://github.com/cucumber/gherkin/blob/main/gherkin-languages.json From c101f2cf12bcbda09bf0ef41c26f38ca7088826e Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 9 Jul 2023 12:25:54 +0200 Subject: [PATCH 181/240] CI: Remove python.version=2.7 from test pipeline * REASON: No longer supported by Github Actions (date: 2023-07) OTHERWISE: * .envrc.use_venv: Now used by default RENAMED FROM: .envrc.use_venv.disabled --- .envrc.use_venv.disabled => .envrc.use_venv | 0 .github/workflows/tests.yml | 8 ++------ .gitignore | 2 -- CHANGES.rst | 2 ++ 4 files changed, 4 insertions(+), 8 deletions(-) rename .envrc.use_venv.disabled => .envrc.use_venv (100%) diff --git a/.envrc.use_venv.disabled b/.envrc.use_venv similarity index 100% rename from .envrc.use_venv.disabled rename to .envrc.use_venv diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 26ea1817b..f0cee853c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,13 +33,9 @@ jobs: fail-fast: false matrix: # PREPARED: os: [ubuntu-latest, macos-latest, windows-latest] - # PREPARED: python-version: ['3.9', '2.7', '3.10', '3.8', 'pypy-2.7', 'pypy-3.8'] - # PREPARED: os: [ubuntu-latest, windows-latest] + # PREPARED: python-version: ["3.11", "3.10", "3.9", "3.8", "pypy-3.10"] os: [ubuntu-latest] - python-version: ["3.11", "3.10", "3.9", "2.7"] - exclude: - - os: windows-latest - python-version: "2.7" + python-version: ["3.11", "3.10", "3.9"] steps: - uses: actions/checkout@v3 # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} diff --git a/.gitignore b/.gitignore index d52e7fdb5..4e6e89900 100644 --- a/.gitignore +++ b/.gitignore @@ -25,8 +25,6 @@ tools/virtualenvs .venv*/ .vscode/ .done.* -.envrc.use_venv -.envrc.use_pep0582 .DS_Store .coverage rerun.txt diff --git a/CHANGES.rst b/CHANGES.rst index 7069b8891..434ce2277 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -52,6 +52,8 @@ DEVELOPMENT: * Renamed default branch of Git repository to "main" (was: "master"). * Use github-actions as CI/CD pipeline (and remove Travis as CI). +* CI: Remove python.version=2.7 for CI pipeline + (reason: No longer supported by Github Actions, date: 2023-07). CLEANUPS: From e50eef03bcf905fbcccf1d1037f04c7c37e00792 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 9 Jul 2023 12:42:39 +0200 Subject: [PATCH 182/240] CI: Add python.version="pypy-3.10" to test pipeline * PREPARED, but DISABLED: python.version="pypy-2.7" REASON: Some behave.tests are failing. --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f0cee853c..7db600491 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,9 +33,9 @@ jobs: fail-fast: false matrix: # PREPARED: os: [ubuntu-latest, macos-latest, windows-latest] - # PREPARED: python-version: ["3.11", "3.10", "3.9", "3.8", "pypy-3.10"] + # PREPARED: python-version: ["3.11", "3.10", "3.9", "pypy-3.10", "pypy-2.7"] os: [ubuntu-latest] - python-version: ["3.11", "3.10", "3.9"] + python-version: ["3.11", "3.10", "3.9", "pypy-3.10"] steps: - uses: actions/checkout@v3 # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} From 612ed04c6c66e7a95e238aad0e8b05ff0836e834 Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 10 Jul 2023 06:49:07 +0200 Subject: [PATCH 183/240] CI: Enable python.version="pypy-2.7" for workflow "tests" * FIX: 2 minor issues where pypy-2.7 exception output differs from normal cpython-2.7. --- .github/workflows/tests.yml | 3 +-- features/formatter.help.feature | 7 +++++++ features/runner.help.feature | 15 +++++++++++---- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7db600491..1a683d52f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,9 +33,8 @@ jobs: fail-fast: false matrix: # PREPARED: os: [ubuntu-latest, macos-latest, windows-latest] - # PREPARED: python-version: ["3.11", "3.10", "3.9", "pypy-3.10", "pypy-2.7"] os: [ubuntu-latest] - python-version: ["3.11", "3.10", "3.9", "pypy-3.10"] + python-version: ["3.11", "3.10", "3.9", "pypy-3.10", "pypy-2.7"] steps: - uses: actions/checkout@v3 # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} diff --git a/features/formatter.help.feature b/features/formatter.help.feature index a5ca43591..dd1730752 100644 --- a/features/formatter.help.feature +++ b/features/formatter.help.feature @@ -108,6 +108,13 @@ Feature: Help Formatter | bad_formatter1 | behave4me.unknown:Formatter | ModuleNotFoundError | No module named 'behave4me.unknown' | @not.with_python.min_version=3.6 + @use.with_pypy=true + Examples: For Python < 3.6 + | formatter_name | formatter_class | formatter_syndrome | problem_description | + | bad_formatter1 | behave4me.unknown:Formatter | ModuleNotFoundError | No module named 'behave4me.unknown' | + + @not.with_python.min_version=3.6 + @not.with_pypy=true Examples: For Python < 3.6 | formatter_name | formatter_class | formatter_syndrome | problem_description | | bad_formatter1 | behave4me.unknown:Formatter | ModuleNotFoundError | No module named 'unknown' | diff --git a/features/runner.help.feature b/features/runner.help.feature index 410a4c485..5d368ab8c 100644 --- a/features/runner.help.feature +++ b/features/runner.help.feature @@ -93,13 +93,20 @@ Feature: Runner Help @use.with_python.min_version=3.0 Examples: For Python >= 3.0 - | runner_name | runner_class | runner_syndrome | problem_description | - | bad_runner1 | behave4me.unknown:Runner | ModuleNotFoundError | No module named 'behave4me.unknown' | + | runner_name | runner_class | runner_syndrome | problem_description | + | bad_runner1 | behave4me.unknown:Runner | ModuleNotFoundError | No module named 'behave4me.unknown' | @not.with_python.min_version=3.0 + @use.with_pypy=true Examples: For Python < 3.0 - | runner_name | runner_class | runner_syndrome | problem_description | - | bad_runner1 | behave4me.unknown:Runner | ModuleNotFoundError | No module named 'unknown' | + | runner_name | runner_class | runner_syndrome | problem_description | + | bad_runner1 | behave4me.unknown:Runner | ModuleNotFoundError | No module named 'behave4me.unknown' | + + @not.with_python.min_version=3.0 + @not.with_pypy=true + Examples: For Python < 3.0 + | runner_name | runner_class | runner_syndrome | problem_description | + | bad_runner1 | behave4me.unknown:Runner | ModuleNotFoundError | No module named 'unknown' | Examples: | runner_name | runner_class | runner_syndrome | problem_description | From fdc40951847df021ef0f86295fabbd27f7f5c72a Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 12 Jul 2023 22:54:10 +0200 Subject: [PATCH 184/240] FIX #1120: Logging ignoring level set in setup_logging Configuration.setup_logging(level, ...): * Need to reassign new level to self.logging_level REASON: config.logging_level is used in "behave.log_capture.LoggingCapture" and "behave.log_capture.capture" --- CHANGES.rst | 1 + behave/configuration.py | 7 +++ behave/log_capture.py | 17 ++++--- issue.features/issue1120.feature | 87 ++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 issue.features/issue1120.feature diff --git a/CHANGES.rst b/CHANGES.rst index 434ce2277..355911679 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -89,6 +89,7 @@ FIXED: * FIXED: Some tests related to python3.11 * FIXED: Some tests related to python3.9 * FIXED: active-tag logic if multiple tags with same category exists. +* issue #1120: Logging ignoring level set in setup_logging (submitted by: j7an) * issue #1070: Color support detection: Fails for WindowsTerminal (provided by: jenisys) * issue #1116: behave erroring in pretty format in pyproject.toml (submitted by: morning-sunn) * issue #1061: Scenario should inherit Rule tags (submitted by: testgitdl) diff --git a/behave/configuration.py b/behave/configuration.py index c62c23ecc..63319ff0c 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -18,6 +18,7 @@ import json import logging from logging.config import fileConfig as logging_config_fileConfig +from logging import _checkLevel as logging_check_level import os import re import sys @@ -1117,6 +1118,9 @@ def before_all(context): """ if level is None: level = self.logging_level # pylint: disable=no-member + else: + # pylint: disable=import-outside-toplevel + level = logging_check_level(level) if configfile: logging_config_fileConfig(configfile) @@ -1127,7 +1131,10 @@ def before_all(context): logging.basicConfig(format=format_, datefmt=datefmt, **kwargs) # -- ENSURE: Default log level is set # (even if logging subsystem is already configured). + # -- HINT: Ressign to self.logging_level + # NEEDED FOR: behave.log_capture.LoggingCapture, capture logging.getLogger().setLevel(level) + self.logging_level = level # pylint: disable=W0201 def setup_model(self): if self.scenario_outline_annotation_schema: diff --git a/behave/log_capture.py b/behave/log_capture.py index 0a751f77a..7f02129bf 100644 --- a/behave/log_capture.py +++ b/behave/log_capture.py @@ -180,9 +180,10 @@ def abandon(self): def capture(*args, **kw): """Decorator to wrap an *environment file function* in log file capture. - It configures the logging capture using the *behave* context - the first - argument to the function being decorated (so don't use this to decorate - something that doesn't have *context* as the first argument.) + It configures the logging capture using the *behave* context, + the first argument to the function being decorated + (so don't use this to decorate something that + doesn't have *context* as the first argument). The basic usage is: @@ -192,9 +193,9 @@ def capture(*args, **kw): def after_scenario(context, scenario): ... - The function prints any captured logging (at the level determined by the - ``log_level`` configuration setting) directly to stdout, regardless of - error conditions. + The function prints any captured logging + (at the level determined by the ``log_level`` configuration setting) + directly to stdout, regardless of error conditions. It is mostly useful for debugging in situations where you are seeing a message like:: @@ -210,8 +211,8 @@ def after_scenario(context, scenario): def after_scenario(context, scenario): ... - This would limit the logging captured to just ERROR and above, and thus - only display logged events if they are interesting. + This would limit the logging captured to just ERROR and above, + and thus only display logged events if they are interesting. """ def create_decorator(func, level=None): def f(context, *args): diff --git a/issue.features/issue1120.feature b/issue.features/issue1120.feature new file mode 100644 index 000000000..d1b4abf2a --- /dev/null +++ b/issue.features/issue1120.feature @@ -0,0 +1,87 @@ +@issue +Feature: Issue #1120 -- Logging ignoring level set in setup_logging + + . DESCRIPTION OF SYNDROME (OBSERVED BEHAVIOR): + . * I setup logging-level in "before_all()" hook w/ context.config.setup_logging() + . * I use logging in "after_scenario()" hook + . * Even levels below "logging.WARNING" are shown + + Background: Setup + Given a new working directory + And a file named "features/steps/use_step_library.py" with: + """ + import behave4cmd0.passing_steps + import behave4cmd0.failing_steps + """ + And a file named "features/simple.feature" with: + """ + Feature: F1 + Scenario: S1 + Given a step passes + When another step passes + """ + + Scenario: Check Syndrome + Given a file named "features/environment.py" with: + """ + from __future__ import absolute_import, print_function + import logging + from behave.log_capture import capture + + def before_all(context): + context.config.setup_logging(logging.WARNING) + + @capture + def after_scenario(context, scenario): + logging.debug("THIS_LOG_MESSAGE::debug") + logging.info("THIS_LOG_MESSAGE::info") + logging.warning("THIS_LOG_MESSAGE::warning") + logging.error("THIS_LOG_MESSAGE::error") + logging.critical("THIS_LOG_MESSAGE::critical") + """ + When I run "behave features/simple.feature" + Then it should pass with: + """ + 1 feature passed, 0 failed, 0 skipped + """ + And the command output should contain "THIS_LOG_MESSAGE::critical" + And the command output should contain "THIS_LOG_MESSAGE::error" + And the command output should contain "THIS_LOG_MESSAGE::warning" + But the command output should not contain "THIS_LOG_MESSAGE::debug" + And the command output should not contain "THIS_LOG_MESSAGE::info" + + + Scenario: Workaround for Syndrome (works without fix) + Given a file named "features/environment.py" with: + """ + from __future__ import absolute_import, print_function + import logging + from behave.log_capture import capture + + def before_all(context): + # -- HINT: Use behave.config.logging_level from config-file + context.config.setup_logging() + + @capture + def after_scenario(context, scenario): + logging.debug("THIS_LOG_MESSAGE::debug") + logging.info("THIS_LOG_MESSAGE::info") + logging.warning("THIS_LOG_MESSAGE::warning") + logging.error("THIS_LOG_MESSAGE::error") + logging.critical("THIS_LOG_MESSAGE::critical") + """ + And a file named "behave.ini" with: + """ + [behave] + logging_level = WARNING + """ + When I run "behave features/simple.feature" + Then it should pass with: + """ + 1 feature passed, 0 failed, 0 skipped + """ + And the command output should contain "THIS_LOG_MESSAGE::critical" + And the command output should contain "THIS_LOG_MESSAGE::error" + And the command output should contain "THIS_LOG_MESSAGE::warning" + But the command output should not contain "THIS_LOG_MESSAGE::debug" + And the command output should not contain "THIS_LOG_MESSAGE::info" From 6db3e3815d43762fccf9c32dc38dd76954ee3d58 Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 12 Jul 2023 23:07:28 +0200 Subject: [PATCH 185/240] BUMP-VERSION: 1.2.7.dev4 (was: 1.2.7.dev3) --- .bumpversion.cfg | 4 ++-- VERSION.txt | 2 +- behave/version.py | 2 +- pytest.ini | 1 - setup.py | 2 +- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 8225c378b..e9675ff66 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] -current_version = 1.2.7.dev3 -files = behave/version.py setup.py VERSION.txt pytest.ini .bumpversion.cfg +current_version = 1.2.7.dev4 +files = behave/version.py setup.py VERSION.txt .bumpversion.cfg parse = (?P\d+)\.(?P\d+)\.(?P\d+)(?P\w*) serialize = {major}.{minor}.{patch}{drop} commit = False diff --git a/VERSION.txt b/VERSION.txt index 1c6178fbf..c4a872d5b 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.2.7.dev3 +1.2.7.dev4 diff --git a/behave/version.py b/behave/version.py index 4e19db26a..91cb2ed68 100644 --- a/behave/version.py +++ b/behave/version.py @@ -1,2 +1,2 @@ # -- BEHAVE-VERSION: -VERSION = "1.2.7.dev3" +VERSION = "1.2.7.dev4" diff --git a/pytest.ini b/pytest.ini index 712acba91..641915b6a 100644 --- a/pytest.ini +++ b/pytest.ini @@ -21,7 +21,6 @@ testpaths = tests python_files = test_*.py junit_family = xunit2 addopts = --metadata PACKAGE_UNDER_TEST behave - --metadata PACKAGE_VERSION 1.2.7.dev3 --html=build/testing/report.html --self-contained-html --junit-xml=build/testing/report.xml markers = diff --git a/setup.py b/setup.py index 1668b9ac4..024cde4e9 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ def find_packages_by_root_package(where): # ----------------------------------------------------------------------------- setup( name="behave", - version="1.2.7.dev3", + version="1.2.7.dev4", description="behave is behaviour-driven development, Python style", long_description=description, author="Jens Engel, Benno Rice and Richard Jones", From d4c04ef568d4a2a4c7545f060aadc6e4b5857712 Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 31 Jul 2023 21:48:07 +0200 Subject: [PATCH 186/240] ADDED: pyproject.toml * Duplicates data from "setup.py" (for now; until: Python 2.7 support is dropped) * NEEDED FOR: Newer pip versions * HINT: "setup.py" will become DEPRECATED soon (approx. 2023-09). OTHERWISE: * Add SPDX-License-Identifier to "behave/__init__.py" * UPDATE/TWEAK: py.requirements/*.txt --- .ruff.toml | 43 +++++ LICENSE | 3 +- MANIFEST.in | 2 + behave/__init__.py | 4 +- docs/install.rst | 39 ++++- py.requirements/all.txt | 3 +- py.requirements/basic.txt | 2 +- py.requirements/behave_extensions.txt | 15 ++ py.requirements/ci.tox.txt | 2 +- py.requirements/develop.txt | 4 +- py.requirements/docs.txt | 2 +- py.requirements/jsonschema.txt | 4 +- pyproject.toml | 243 +++++++++++++++++++++----- setup.py | 15 +- 14 files changed, 322 insertions(+), 59 deletions(-) create mode 100644 .ruff.toml create mode 100644 py.requirements/behave_extensions.txt diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 000000000..2cde028ed --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,43 @@ +# ----------------------------------------------------------------------------- +# SECTION: ruff -- Python linter +# ----------------------------------------------------------------------------- +# SEE: https://github.com/charliermarsh/ruff +# SEE: https://beta.ruff.rs/docs/configuration/#using-rufftoml +# Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. + +select = ["E", "F"] +ignore = [] + +# Allow autofix for all enabled rules (when `--fix`) is provided. +fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", + "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", + "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", + "TCH", "TID", "TRY", "UP", "YTT" +] +unfixable = [] + +# Exclude a variety of commonly ignored directories. +exclude = [ + ".direnv", + ".eggs", + ".git", + ".ruff_cache", + ".tox", + ".venv*", + "__pypackages__", + "build", + "dist", + "venv", +] +per-file-ignores = {} + +# Same as Black. +# WAS: line-length = 88 +line-length = 100 + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" +target-version = "py310" + +[mccabe] +max-complexity = 10 diff --git a/LICENSE b/LICENSE index 0870e5377..387e41d67 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,5 @@ -Copyright (c) 2012-2014 Benno Rice, Richard Jones, Jens Engel and others, except where noted. +Copyright (c) 2012-2014 Benno Rice, Richard Jones and others, except where noted. +Copyright (c) 2014-2023 Jens Engel and others, except where noted. All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/MANIFEST.in b/MANIFEST.in index 84d20f49b..fee66c76f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -13,6 +13,7 @@ include *.rst include *.txt include *.yml include *.yaml +exclude __*.txt include bin/behave* include bin/invoke* recursive-include .ci *.yml @@ -33,3 +34,4 @@ recursive-include py.requirements *.txt *.rst prune .tox prune .venv* +prune __* diff --git a/behave/__init__.py b/behave/__init__.py index f00d6350a..2e9b56d5a 100644 --- a/behave/__init__.py +++ b/behave/__init__.py @@ -1,5 +1,7 @@ # -*- coding: UTF-8 -*- -"""behave is behaviour-driven development, Python style +# SPDX-License-Identifier: BSD-2-Clause +""" +behave is behaviour-driven development, Python style Behavior-driven development (or BDD) is an agile software development technique that encourages collaboration between developers, QA and diff --git a/docs/install.rst b/docs/install.rst index cb111446c..495f0c42b 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -48,13 +48,13 @@ to install the newest version from the `GitHub repository`_:: To install a tagged version from the `GitHub repository`_, use:: - pip install git+https://github.com/behave/behave@ + pip install git+https://github.com/behave/behave@ -where is the placeholder for an `existing tag`_. +where is the placeholder for an `existing tag`_. -When installing extras, use ``#egg=behave[...]``, e.g.:: +When installing extras, use ``#egg=behave[...]``, e.g.:: - pip install git+https://github.com/behave/behave@v1.2.7.dev3#egg=behave[toml] + pip install git+https://github.com/behave/behave@v1.2.7.dev4#egg=behave[toml] .. _`GitHub repository`: https://github.com/behave/behave .. _`existing tag`: https://github.com/behave/behave/tags @@ -79,3 +79,34 @@ Installation Target Description .. _`behave-contrib`: https://github.com/behave-contrib .. _`pep-518`: https://peps.python.org/pep-0518/#tool-table + + +Specify Dependency to "behave" +------------------------------ + +Use the following recipe in the ``"pyproject.toml"`` config-file if: + +* your project depends on `behave`_ and +* you use a ``version`` from the git-repository (or a ``git branch``) + +EXAMPLE: + +.. code-block:: toml + + # -- FILE: my-project/pyproject.toml + # SCHEMA: Use "behave" from git-repository (instead of: https://pypi.org/ ) + # "behave @ git+https://github.com/behave/behave.git@" + # "behave @ git+https://github.com/behave/behave.git@" + # "behave[VARIANT] @ git+https://github.com/behave/behave.git@" # with VARIANT=develop, docs, ... + # SEE: https://peps.python.org/pep-0508/ + + [project] + name = "my-project" + ... + dependencies = [ + "behave @ git+https://github.com/behave/behave.git@v1.2.7.dev4", + # OR: "behave[develop] @ git+https://github.com/behave/behave.git@main", + ... + ] + +.. _behave: https://github.com/behave/behave diff --git a/py.requirements/all.txt b/py.requirements/all.txt index de0cec006..a09fa5d82 100644 --- a/py.requirements/all.txt +++ b/py.requirements/all.txt @@ -5,11 +5,12 @@ # pip install -r # # SEE ALSO: -# * http://www.pip-installer.org/ +# * https://pip.pypa.io/en/stable/user_guide/ # ============================================================================ # ALREADY: -r testing.txt # ALREADY: -r docs.txt -r basic.txt +-r behave_extensions.txt -r develop.txt -r jsonschema.txt diff --git a/py.requirements/basic.txt b/py.requirements/basic.txt index 84b134fd9..86bf15383 100644 --- a/py.requirements/basic.txt +++ b/py.requirements/basic.txt @@ -5,7 +5,7 @@ # pip install -r # # SEE ALSO: -# * http://www.pip-installer.org/ +# * https://pip.pypa.io/en/stable/user_guide/ # ============================================================================ cucumber-tag-expressions >= 4.1.0 diff --git a/py.requirements/behave_extensions.txt b/py.requirements/behave_extensions.txt new file mode 100644 index 000000000..f80938598 --- /dev/null +++ b/py.requirements/behave_extensions.txt @@ -0,0 +1,15 @@ + +# ============================================================================ +# PYTHON PACKAGE REQUIREMENTS: behave extensions +# ============================================================================ +# DESCRIPTION: +# pip install -r +# +# SEE ALSO: +# * https://pip.pypa.io/en/stable/user_guide/ +# ============================================================================ + +# -- FORMATTERS: +# DISABLED: allure-behave +behave-html-formatter >= 0.9.10; python_version >= '3.6' +behave-html-pretty-formatter >= 1.9.1; python_version >= '3.6' diff --git a/py.requirements/ci.tox.txt b/py.requirements/ci.tox.txt index 942588e9f..77226b71b 100644 --- a/py.requirements/ci.tox.txt +++ b/py.requirements/ci.tox.txt @@ -1,5 +1,5 @@ # ============================================================================ -# BEHAVE: PYTHON PACKAGE REQUIREMENTS: ci.tox.txt +# PYTHON PACKAGE REQUIREMENTS: behave -- ci.tox.txt # ============================================================================ -r testing.txt diff --git a/py.requirements/develop.txt b/py.requirements/develop.txt index 4048b47a4..22d49386d 100644 --- a/py.requirements/develop.txt +++ b/py.requirements/develop.txt @@ -15,9 +15,9 @@ bump2version >= 0.5.6 # -- RELEASE MANAGEMENT: Push package to pypi. twine >= 1.13.0 +build >= 0.5.1 # -- DEVELOPMENT SUPPORT: - # -- PYTHON2/3 COMPATIBILITY: pypa/modernize # python-futurize modernize >= 0.5 @@ -27,7 +27,7 @@ modernize >= 0.5 # -- REQUIRES: testing -r testing.txt -coverage >= 4.2 +coverage >= 5.0 pytest-cov tox >= 1.8.1,<4.0 # -- HINT: tox >= 4.0 has breaking changes. virtualenv < 20.22.0 # -- SUPPORT FOR: Python 2.7, Python <= 3.6 diff --git a/py.requirements/docs.txt b/py.requirements/docs.txt index 75bc980c7..f325f7746 100644 --- a/py.requirements/docs.txt +++ b/py.requirements/docs.txt @@ -1,5 +1,5 @@ # ============================================================================ -# BEHAVE: PYTHON PACKAGE REQUIREMENTS: For documentation generation +# PYTHON PACKAGE REQUIREMENTS: behave -- For documentation generation # ============================================================================ # REQUIRES: pip >= 8.0 # AVOID: sphinx v4.4.0 and newer -- Problems w/ new link check suggestion warnings diff --git a/py.requirements/jsonschema.txt b/py.requirements/jsonschema.txt index 6487590f0..db45dafcc 100644 --- a/py.requirements/jsonschema.txt +++ b/py.requirements/jsonschema.txt @@ -3,5 +3,7 @@ # ============================================================================ # -- OPTIONAL: For JSON validation +# DEPRECATING: jsonschema +# USE INSTEAD: check-jsonschema jsonschema >= 1.3.0 -# MAYBE NOW: check-jsonschema +check-jsonschema diff --git a/pyproject.toml b/pyproject.toml index 23abedbcd..f028bfefc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,42 +1,205 @@ +# ============================================================================= +# PACKAGE: behave +# ============================================================================= +# SPDX-License-Identifier: BSD-2-Clause +# DESCRIPTION: +# Provides a "pyproject.toml" for packaging usecases of this package. +# +# REASONS: +# * Python project will need a "pyproject.toml" soon to be installable with "pip". +# * Currently, duplicates information from "setup.py" here. +# * "setup.py" is kept until Python 2.7 support is dropped +# * "setup.py" is sometimes needed in some weird cases (old pip version, ...) +# +# SEE ALSO: +# * https://packaging.python.org/en/latest/tutorials/packaging-projects/ +# * https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html +# * https://pip.pypa.io/en/stable/reference/build-system/pyproject-toml/ +# +# RELATED: Project-Metadata Schema +# * https://packaging.python.org/en/latest/specifications/declaring-project-metadata/ +# * https://packaging.python.org/en/latest/specifications/core-metadata/ +# * https://pypi.org/classifiers/ +# * https://spdx.org/licenses/preview/ +# +# PEPs: https://peps.python.org/pep-XXXX/ +# * PEP 508 – Dependency specification for Python Software Packages +# * PEP 621 – Storing project metadata in pyproject.toml => CURRENT-SPEC: declaring-project-metadata +# * PEP 631 – Dependency specification in pyproject.toml based on PEP 508 +# * PEP 639 – Improving License Clarity with Better Package Metadata +# ============================================================================= +# MAYBE: requires = ["setuptools", "setuptools-scm"] +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + + +[project] +name = "behave" +authors = [ + {name = "Jens Engel", email = "jenisys@noreply.github.com"}, + {name = "Benno Rice"}, + {name = "Richard Jones"}, +] +maintainers = [ + {name = "Jens Engel", email = "jenisys@noreply.github.com"}, + {name = "Peter Bittner", email = "bittner@noreply.github.com"}, +] +description = "behave is behaviour-driven development, Python style" +readme = "README.rst" +requires-python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +keywords = [ + "BDD", "behavior-driven-development", "bdd-framework", + "behave", "gherkin", "cucumber-like" +] +license = {text = "BSD-2-Clause"} +# DISABLED: license-files = ["LICENSE"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: Jython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Software Development :: Testing", + "License :: OSI Approved :: BSD License", +] +dependencies = [ + "cucumber-tag-expressions >= 4.1.0", + "enum34; python_version < '3.4'", + "parse >= 1.18.0", + "parse-type >= 0.6.0", + "six >= 1.15.0", + "traceback2; python_version < '3.0'", + + # -- PREPARED: + "win_unicode_console; python_version <= '3.9'", + "contextlib2; python_version < '3.5'", + "colorama >= 0.3.7", + + # -- SUPPORT: "pyproject.toml" (or: "behave.toml") + "tomli>=1.1.0; python_version >= '3.0' and python_version < '3.11'", + "toml>=0.10.2; python_version < '3.0'", # py27 support +] +dynamic = ["version"] + + +[project.urls] +Homepage = "https://github.com/behave/behave" +Download = "https://pypi.org/project/behave/" +"Source Code" = "https://github.com/behave/behave" +"Issue Tracker" = "https://github.com/behave/behave/issues/" + + +[project.scripts] +behave = "behave.__main__:main" + +[project.entry-points."distutils.commands"] +behave_test = "setuptools_behave:behave_test" + + +[project.optional-dependencies] +develop = [ + "build >= 0.5.1", + "twine >= 1.13.0", + "coverage >= 5.0", + "pytest >=4.2,<5.0; python_version < '3.0'", + "pytest >= 5.0; python_version >= '3.0'", + "pytest-html >= 1.19.0,<2.0; python_version < '3.0'", + "pytest-html >= 2.0; python_version >= '3.0'", + "mock < 4.0; python_version < '3.6'", + "mock >= 4.0; python_version >= '3.6'", + "PyHamcrest >= 2.0.2; python_version >= '3.0'", + "PyHamcrest < 2.0; python_version < '3.0'", + "pytest-cov", + "tox >= 1.8.1,<4.0", # -- HINT: tox >= 4.0 has breaking changes. + "virtualenv < 20.22.0", # -- SUPPORT FOR: Python 2.7, Python <= 3.6 + "invoke >=1.7.0,<2.0; python_version < '3.6'", + "invoke >=1.7.0; python_version >= '3.6'", + # -- HINT, was RENAMED: path.py => path (for python3) + "path >= 13.1.0; python_version >= '3.5'", + "path.py >= 11.5.0; python_version < '3.5'", + "pycmd", + "pathlib; python_version <= '3.4'", + "modernize >= 0.5", + "pylint", + "ruff; python_version >= '3.7'", +] +docs = [ + "Sphinx >=1.6", + "sphinx_bootstrap_theme >= 0.6.0" +] +formatters = [ + "behave-html-formatter >= 0.9.10; python_version >= '3.6'", + "behave-html-pretty-formatter >= 1.9.1; python_version >= '3.6'" +] +testing = [ + "pytest < 5.0; python_version < '3.0'", # >= 4.2 + "pytest >= 5.0; python_version >= '3.0'", + "pytest-html >= 1.19.0,<2.0; python_version < '3.0'", + "pytest-html >= 2.0; python_version >= '3.0'", + "mock < 4.0; python_version < '3.6'", + "mock >= 4.0; python_version >= '3.6'", + "PyHamcrest >= 2.0.2; python_version >= '3.0'", + "PyHamcrest < 2.0; python_version < '3.0'", + "assertpy >= 1.1", + + # -- HINT: path.py => path (python-install-package was renamed for python3) + "path >= 13.1.0; python_version >= '3.5'", + "path.py >=11.5.0,<13.0; python_version < '3.5'", + # -- PYTHON2 BACKPORTS: + "pathlib; python_version <= '3.4'", +] +# -- BACKWORD-COMPATIBLE SECTION: Can be removed in the future +# HINT: Package-requirements are now part of "dependencies" parameter above. +toml = [ + "tomli>=1.1.0; python_version >= '3.0' and python_version < '3.11'", + "toml>=0.10.2; python_version < '3.0'", +] + + +[tool.distutils.bdist_wheel] +universal = true + + # ----------------------------------------------------------------------------- -# SECTION: ruff -- Python linter +# PACAKING TOOL SPECIFIC PARTS: # ----------------------------------------------------------------------------- -# SEE: https://github.com/charliermarsh/ruff -# Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. -[tool.ruff] -select = ["E", "F"] -ignore = [] - -# Allow autofix for all enabled rules (when `--fix`) is provided. -fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", - "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", - "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", - "TCH", "TID", "TRY", "UP", "YTT" -] -unfixable = [] - -# Exclude a variety of commonly ignored directories. -exclude = [ - ".direnv", - ".eggs", - ".git", - ".ruff_cache", - ".tox", - ".venv*", - "__pypackages__", - "build", - "dist", - "venv", -] -per-file-ignores = {} - -# Same as Black. -# WAS: line-length = 88 -line-length = 100 - -# Allow unused variables when underscore-prefixed. -dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" -target-version = "py310" - -[tool.ruff.mccabe] -max-complexity = 10 +[tool.setuptools] +platforms = ["any"] +py-modules = ["setuptools_behave"] +zip-safe = true + +[tool.setuptools.cmdclass] +behave_test = "setuptools_behave.behave_test" + +[tool.setuptools.dynamic] +version = {attr = "behave.version.VERSION"} + +[tool.setuptools.packages.find] +where = ["."] +include = ["behave*"] +exclude = ["behave4cmd0*", "tests*"] +namespaces = false + + + +# ----------------------------------------------------------------------------- +# PYLINT: +# ----------------------------------------------------------------------------- +[tool.pylint.messages_control] +disable = "C0330, C0326" + +[tool.pylint.format] +max-line-length = "100" diff --git a/setup.py b/setup.py index 024cde4e9..3ad5257d2 100644 --- a/setup.py +++ b/setup.py @@ -60,7 +60,7 @@ def find_packages_by_root_package(where): long_description=description, author="Jens Engel, Benno Rice and Richard Jones", author_email="behave-users@googlegroups.com", - url="http://github.com/behave/behave", + url="https://github.com/behave/behave", provides = ["behave", "setuptools_behave"], packages = find_packages_by_root_package(BEHAVE), py_modules = ["setuptools_behave"], @@ -79,7 +79,7 @@ def find_packages_by_root_package(where): "cucumber-tag-expressions >= 4.1.0", "enum34; python_version < '3.4'", "parse >= 1.18.0", - "parse_type >= 0.6.0", + "parse-type >= 0.6.0", "six >= 1.15.0", "traceback2; python_version < '3.0'", @@ -90,7 +90,7 @@ def find_packages_by_root_package(where): "colorama >= 0.3.7", ], tests_require=[ - "pytest < 5.0; python_version < '3.0'", # >= 4.2 + "pytest < 5.0; python_version < '3.0'", # USE: pytest >= 4.2 "pytest >= 5.0; python_version >= '3.0'", "pytest-html >= 1.19.0,<2.0; python_version < '3.0'", "pytest-html >= 2.0; python_version >= '3.0'", @@ -115,8 +115,10 @@ def find_packages_by_root_package(where): "sphinx_bootstrap_theme >= 0.6" ], "develop": [ - "coverage", - "pytest >=4.2,<5.0; python_version < '3.0' # pytest >= 4.2", + "build >= 0.5.1", + "twine >= 1.13.0", + "coverage >= 5.0", + "pytest >=4.2,<5.0; python_version < '3.0'", # pytest >= 4.2 "pytest >= 5.0; python_version >= '3.0'", "pytest-html >= 1.19.0,<2.0; python_version < '3.0'", "pytest-html >= 2.0; python_version >= '3.0'", @@ -147,7 +149,7 @@ def find_packages_by_root_package(where): }, license="BSD", classifiers=[ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "Operating System :: OS Independent", @@ -160,6 +162,7 @@ def find_packages_by_root_package(where): "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: Jython", "Programming Language :: Python :: Implementation :: PyPy", From 1260df49b49b4e36d759e736d9ea90070f562511 Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 31 Jul 2023 22:06:53 +0200 Subject: [PATCH 187/240] BUMP-VERSION: 1.2.7.dev5 (was: 1.2.7.dev4) * ADDED: pyproject.toml support --- .bumpversion.cfg | 2 +- CHANGES.rst | 1 + VERSION.txt | 2 +- behave/version.py | 2 +- docs/install.rst | 4 ++-- setup.py | 2 +- 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index e9675ff66..6fac22e71 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.2.7.dev4 +current_version = 1.2.7.dev5 files = behave/version.py setup.py VERSION.txt .bumpversion.cfg parse = (?P\d+)\.(?P\d+)\.(?P\d+)(?P\w*) serialize = {major}.{minor}.{patch}{drop} diff --git a/CHANGES.rst b/CHANGES.rst index 355911679..90ed72bf9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -54,6 +54,7 @@ DEVELOPMENT: * Use github-actions as CI/CD pipeline (and remove Travis as CI). * CI: Remove python.version=2.7 for CI pipeline (reason: No longer supported by Github Actions, date: 2023-07). +* ADDED: pyproject.toml support (hint: "setup.py" will become DEPRECATED soon) CLEANUPS: diff --git a/VERSION.txt b/VERSION.txt index c4a872d5b..e353c6873 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.2.7.dev4 +1.2.7.dev5 diff --git a/behave/version.py b/behave/version.py index 91cb2ed68..72c278160 100644 --- a/behave/version.py +++ b/behave/version.py @@ -1,2 +1,2 @@ # -- BEHAVE-VERSION: -VERSION = "1.2.7.dev4" +VERSION = "1.2.7.dev5" diff --git a/docs/install.rst b/docs/install.rst index 495f0c42b..7139fafdf 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -54,7 +54,7 @@ where is the placeholder for an `existing tag`_. When installing extras, use ``#egg=behave[...]``, e.g.:: - pip install git+https://github.com/behave/behave@v1.2.7.dev4#egg=behave[toml] + pip install git+https://github.com/behave/behave@v1.2.7.dev5#egg=behave[toml] .. _`GitHub repository`: https://github.com/behave/behave .. _`existing tag`: https://github.com/behave/behave/tags @@ -104,7 +104,7 @@ EXAMPLE: name = "my-project" ... dependencies = [ - "behave @ git+https://github.com/behave/behave.git@v1.2.7.dev4", + "behave @ git+https://github.com/behave/behave.git@v1.2.7.dev5", # OR: "behave[develop] @ git+https://github.com/behave/behave.git@main", ... ] diff --git a/setup.py b/setup.py index 3ad5257d2..f0c843270 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ def find_packages_by_root_package(where): # ----------------------------------------------------------------------------- setup( name="behave", - version="1.2.7.dev4", + version="1.2.7.dev5", description="behave is behaviour-driven development, Python style", long_description=description, author="Jens Engel, Benno Rice and Richard Jones", From 1d49706157a19549b21b450ce40ea88750f56193 Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 12 Sep 2023 21:14:31 +0200 Subject: [PATCH 188/240] invoke tasks: * invoke.yaml: Use yamllint to provide correct YAML format * test.behave: grouped_by_prefix(): Support list (and string) for args param --- bin/invoke_cmd.py | 6 ++++ invoke.yaml | 75 +++++++++++++++++++++++++---------------------- tasks/test.py | 11 +++++-- 3 files changed, 55 insertions(+), 37 deletions(-) create mode 100755 bin/invoke_cmd.py diff --git a/bin/invoke_cmd.py b/bin/invoke_cmd.py new file mode 100755 index 000000000..876bf1211 --- /dev/null +++ b/bin/invoke_cmd.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 + +if __name__ == "__main__": + import sys + from invoke.main import program + sys.exit(program.run()) diff --git a/invoke.yaml b/invoke.yaml index 890619b2f..a8571f415 100644 --- a/invoke.yaml +++ b/invoke.yaml @@ -9,50 +9,55 @@ # ===================================================== # MAYBE: tasks: auto_dash_names: false +--- project: - name: behave + name: behave run: - echo: true - # DISABLED: pty: true + echo: true sphinx: - sourcedir: "docs" - destdir: "build/docs" - language: en - languages: - - de - # PREPARED: - zh-CN + sourcedir: "docs" + destdir: "build/docs" + language: en + languages: + - en + - de + # PREPARED: - zh-CN cleanup: - extra_directories: - - "build" - - "dist" - - "__WORKDIR__" - - reports + extra_directories: + - "build" + - "dist" + - "__WORKDIR__" + - reports - extra_files: - - "etc/gherkin/gherkin*.json.SAVED" - - "etc/gherkin/i18n.py" + extra_files: + - "etc/gherkin/gherkin*.json.SAVED" + - "etc/gherkin/i18n.py" cleanup_all: - extra_directories: - - .hypothesis - - .pytest_cache - - .direnv - - extra_files: - - "**/testrun*.json" - - ".done.*" - - "*.lock" - - "*.log" - - .coverage - - rerun.txt + extra_directories: + - .hypothesis + - .pytest_cache + - .direnv + - .tox + - ".venv*" + + extra_files: + - "**/testrun*.json" + - ".done.*" + - "*.lock" + - "*.log" + - .coverage + - rerun.txt behave_test: - scopes: - - features - - tools/test-features - - issue.features - args: features tools/test-features issue.features - + scopes: + - features + - tools/test-features + - issue.features + args: + - features + - tools/test-features + - issue.features diff --git a/tasks/test.py b/tasks/test.py index 685e8e6eb..adc3642b3 100644 --- a/tasks/test.py +++ b/tasks/test.py @@ -6,6 +6,8 @@ from __future__ import print_function import os.path import sys + +import six from invoke import task, Collection # -- TASK-LIBRARY: @@ -137,9 +139,14 @@ def select_by_prefix(args, prefixes): def grouped_by_prefix(args, prefixes): """Group behave args by (directory) scope into multiple test-runs.""" + if isinstance(args, six.string_types): + args = args.strip().split() + if not isinstance(args, list): + raise TypeError("args.type=%s (expected: list, string)" % type(args)) + group_args = [] current_scope = None - for arg in args.strip().split(): + for arg in args: assert not arg.startswith("-"), "REQUIRE: arg, not options" scope = select_prefix_for(arg, prefixes) if scope != current_scope: @@ -180,7 +187,7 @@ def grouped_by_prefix(args, prefixes): # "behave_test": behave.namespace._configuration["behave_test"], "behave_test": { "scopes": ["features", "issue.features"], - "args": "features issue.features", + "args": ["features", "issue.features"], "format": "progress", "options": "", # -- NOTE: Overide in configfile "invoke.yaml" "coverage_options": "", From e82ace608d9c020df166068fb3876f17118a566e Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 12 Sep 2023 21:21:21 +0200 Subject: [PATCH 189/240] invoke: Add yamllint as dependency * NEEDED FOR: Development support --- tasks/py.requirements.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tasks/py.requirements.txt b/tasks/py.requirements.txt index 3990858b1..263331d34 100644 --- a/tasks/py.requirements.txt +++ b/tasks/py.requirements.txt @@ -25,3 +25,6 @@ git+https://github.com/jenisys/invoke-cleanup@v0.3.7 # -- SECTION: develop requests + +# -- DEVELOPMENT SUPPORT: Check "invoke.yaml" config-file(s) +yamllint >= 1.32.0; python_version >= '3.7' From 1aa2964da8d97a9066041b30853320a3d6d3d397 Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 12 Sep 2023 22:54:24 +0200 Subject: [PATCH 190/240] behave.runner.Context: Add use_or_create_param(), use_or_assign_param() * Add helper methods to simplify to add context-params if needed only RELATED TO: * Discussion #1136 --- behave/runner.py | 39 ++- tests/unit/test_runner.py | 470 +------------------------- tests/unit/test_runner_context.py | 545 ++++++++++++++++++++++++++++++ 3 files changed, 584 insertions(+), 470 deletions(-) create mode 100644 tests/unit/test_runner_context.py diff --git a/behave/runner.py b/behave/runner.py index 35f2a0814..3defac330 100644 --- a/behave/runner.py +++ b/behave/runner.py @@ -4,7 +4,6 @@ """ from __future__ import absolute_import, print_function, with_statement - import contextlib import os.path import sys @@ -192,6 +191,44 @@ def abort(self, reason=None): """ self._set_root_attribute("aborted", True) + def use_or_assign_param(self, name, value): + """Use an existing context parameter (aka: attribute) or + assign a value to new context parameter (if it does not exist yet). + + :param name: Context parameter name (as string) + :param value: Parameter value for new parameter. + :return: Existing or newly created parameter. + + .. versionadded:: 1.2.7 + """ + if name not in self: + # -- CASE: New, missing param -- Assign parameter-value. + setattr(self, name, value) + return value + # -- OTHERWISE: Use existing param + return getattr(self, name, None) + + + def use_or_create_param(self, name, factory_func, *args, **kwargs): + """Use an existing context parameter (aka: attribute) or + create a new parameter if it does not exist yet. + + :param name: Context parameter name (as string) + :param factory_func: Factory function, used if parameter is created. + :param args: Positional args for ``factory_func()`` on create. + :param kwargs: Named args for ``factory_func()`` on create. + :return: Existing or newly created parameter. + + .. versionadded:: 1.2.7 + """ + if name not in self: + # -- CASE: New, missing param -- Create it. + param_value = factory_func(*args, **kwargs) + setattr(self, name, param_value) + return param_value + # -- OTHERWISE: Use existing param + return getattr(self, name, None) + @staticmethod def ignore_cleanup_error(context, cleanup_func, exception): pass diff --git a/tests/unit/test_runner.py b/tests/unit/test_runner.py index 98eba082d..03b7aae3f 100644 --- a/tests/unit/test_runner.py +++ b/tests/unit/test_runner.py @@ -3,487 +3,19 @@ from __future__ import absolute_import, print_function, with_statement from collections import defaultdict -from platform import python_implementation import os.path import sys -import warnings import unittest import six from six import StringIO import pytest from mock import Mock, patch from behave import runner_util -from behave.model import Table -from behave.step_registry import StepRegistry -from behave import parser, runner -from behave.runner import ContextMode +from behave import runner from behave.exception import ConfigError from behave.formatter.base import StreamOpener -# -- CONVENIENCE-ALIAS: -_text = six.text_type - - -class TestContext(unittest.TestCase): - # pylint: disable=invalid-name, protected-access, no-self-use - - def setUp(self): - r = Mock() - self.config = r.config = Mock() - r.config.verbose = False - self.context = runner.Context(r) - - def test_user_mode_shall_restore_behave_mode(self): - # -- CASE: No exception is raised. - initial_mode = ContextMode.BEHAVE - assert self.context._mode == initial_mode - with self.context.use_with_user_mode(): - assert self.context._mode == ContextMode.USER - self.context.thing = "stuff" - assert self.context._mode == initial_mode - - def test_user_mode_shall_restore_behave_mode_if_assert_fails(self): - initial_mode = ContextMode.BEHAVE - assert self.context._mode == initial_mode - try: - with self.context.use_with_user_mode(): - assert self.context._mode == ContextMode.USER - assert False, "XFAIL" - except AssertionError: - assert self.context._mode == initial_mode - - def test_user_mode_shall_restore_behave_mode_if_exception_is_raised(self): - initial_mode = ContextMode.BEHAVE - assert self.context._mode == initial_mode - try: - with self.context.use_with_user_mode(): - assert self.context._mode == ContextMode.USER - raise RuntimeError("XFAIL") - except RuntimeError: - assert self.context._mode == initial_mode - - def test_use_with_user_mode__shall_restore_initial_mode(self): - # -- CASE: No exception is raised. - # pylint: disable=protected-access - initial_mode = ContextMode.BEHAVE - self.context._mode = initial_mode - with self.context.use_with_user_mode(): - assert self.context._mode == ContextMode.USER - self.context.thing = "stuff" - assert self.context._mode == initial_mode - - def test_use_with_user_mode__shall_restore_initial_mode_with_error(self): - # -- CASE: Exception is raised. - # pylint: disable=protected-access - initial_mode = ContextMode.BEHAVE - self.context._mode = initial_mode - try: - with self.context.use_with_user_mode(): - assert self.context._mode == ContextMode.USER - raise RuntimeError("XFAIL") - except RuntimeError: - assert self.context._mode == initial_mode - - def test_use_with_behave_mode__shall_restore_initial_mode(self): - # -- CASE: No exception is raised. - # pylint: disable=protected-access - initial_mode = ContextMode.USER - self.context._mode = initial_mode - with self.context._use_with_behave_mode(): - assert self.context._mode == ContextMode.BEHAVE - self.context.thing = "stuff" - assert self.context._mode == initial_mode - - def test_use_with_behave_mode__shall_restore_initial_mode_with_error(self): - # -- CASE: Exception is raised. - # pylint: disable=protected-access - initial_mode = ContextMode.USER - self.context._mode = initial_mode - try: - with self.context._use_with_behave_mode(): - assert self.context._mode == ContextMode.BEHAVE - raise RuntimeError("XFAIL") - except RuntimeError: - assert self.context._mode == initial_mode - - def test_context_contains(self): - assert "thing" not in self.context - self.context.thing = "stuff" - assert "thing" in self.context - self.context._push() - assert "thing" in self.context - - def test_attribute_set_at_upper_level_visible_at_lower_level(self): - self.context.thing = "stuff" - self.context._push() - assert self.context.thing == "stuff" - - def test_attribute_set_at_lower_level_not_visible_at_upper_level(self): - self.context._push() - self.context.thing = "stuff" - self.context._pop() - assert getattr(self.context, "thing", None) is None - - def test_attributes_set_at_upper_level_visible_at_lower_level(self): - self.context.thing = "stuff" - self.context._push() - assert self.context.thing == "stuff" - self.context.other_thing = "more stuff" - self.context._push() - assert self.context.thing == "stuff" - assert self.context.other_thing == "more stuff" - self.context.third_thing = "wombats" - self.context._push() - assert self.context.thing == "stuff" - assert self.context.other_thing == "more stuff" - assert self.context.third_thing == "wombats" - - def test_attributes_set_at_lower_level_not_visible_at_upper_level(self): - self.context.thing = "stuff" - - self.context._push() - self.context.other_thing = "more stuff" - - self.context._push() - self.context.third_thing = "wombats" - assert self.context.thing == "stuff" - assert self.context.other_thing == "more stuff" - assert self.context.third_thing == "wombats" - - self.context._pop() - assert self.context.thing == "stuff" - assert self.context.other_thing == "more stuff" - assert getattr(self.context, "third_thing", None) is None, "%s is not None" % self.context.third_thing - - self.context._pop() - assert self.context.thing == "stuff" - assert getattr(self.context, "other_thing", None) is None, "%s is not None" % self.context.other_thing - assert getattr(self.context, "third_thing", None) is None, "%s is not None" % self.context.third_thing - - def test_masking_existing_user_attribute_when_verbose_causes_warning(self): - warns = [] - - def catch_warning(*args, **kwargs): - warns.append(args[0]) - - old_showwarning = warnings.showwarning - warnings.showwarning = catch_warning - - # pylint: disable=protected-access - self.config.verbose = True - with self.context.use_with_user_mode(): - self.context.thing = "stuff" - self.context._push() - self.context.thing = "other stuff" - - warnings.showwarning = old_showwarning - - print(repr(warns)) - assert warns, "warns is empty!" - warning = warns[0] - assert isinstance(warning, runner.ContextMaskWarning), "warning is not a ContextMaskWarning" - info = warning.args[0] - assert info.startswith("user code"), "%r doesn't start with 'user code'" % info - assert "'thing'" in info, "%r not in %r" % ("'thing'", info) - assert "tutorial" in info, '"tutorial" not in %r' % (info, ) - - def test_masking_existing_user_attribute_when_not_verbose_causes_no_warning(self): - warns = [] - - def catch_warning(*args, **kwargs): - warns.append(args[0]) - - old_showwarning = warnings.showwarning - warnings.showwarning = catch_warning - - # explicit - # pylint: disable=protected-access - self.config.verbose = False - with self.context.use_with_user_mode(): - self.context.thing = "stuff" - self.context._push() - self.context.thing = "other stuff" - - warnings.showwarning = old_showwarning - - assert not warns - - def test_behave_masking_user_attribute_causes_warning(self): - warns = [] - - def catch_warning(*args, **kwargs): - warns.append(args[0]) - - old_showwarning = warnings.showwarning - warnings.showwarning = catch_warning - - with self.context.use_with_user_mode(): - self.context.thing = "stuff" - # pylint: disable=protected-access - self.context._push() - self.context.thing = "other stuff" - - warnings.showwarning = old_showwarning - - print(repr(warns)) - assert warns, "OOPS: warns is empty, but expected non-empty" - warning = warns[0] - assert isinstance(warning, runner.ContextMaskWarning), "warning is not a ContextMaskWarning" - info = warning.args[0] - assert info.startswith("behave runner"), "%r doesn't start with 'behave runner'" % info - assert "'thing'" in info, "%r not in %r" % ("'thing'", info) - filename = __file__.rsplit(".", 1)[0] - if python_implementation() == "Jython": - filename = filename.replace("$py", ".py") - assert filename in info, "%r not in %r" % (filename, info) - - def test_setting_root_attribute_that_masks_existing_causes_warning(self): - # pylint: disable=protected-access - warns = [] - - def catch_warning(*args, **kwargs): - warns.append(args[0]) - - old_showwarning = warnings.showwarning - warnings.showwarning = catch_warning - - with self.context.use_with_user_mode(): - self.context._push() - self.context.thing = "teak" - self.context._set_root_attribute("thing", "oak") - - warnings.showwarning = old_showwarning - - print(repr(warns)) - assert warns - warning = warns[0] - assert isinstance(warning, runner.ContextMaskWarning) - info = warning.args[0] - assert info.startswith("behave runner"), "%r doesn't start with 'behave runner'" % info - assert "'thing'" in info, "%r not in %r" % ("'thing'", info) - filename = __file__.rsplit(".", 1)[0] - if python_implementation() == "Jython": - filename = filename.replace("$py", ".py") - assert filename in info, "%r not in %r" % (filename, info) - - def test_context_deletable(self): - assert "thing" not in self.context - self.context.thing = "stuff" - assert "thing" in self.context - del self.context.thing - assert "thing" not in self.context - - # OLD: @raises(AttributeError) - def test_context_deletable_raises(self): - # pylint: disable=protected-access - assert "thing" not in self.context - self.context.thing = "stuff" - assert "thing" in self.context - self.context._push() - assert "thing" in self.context - with pytest.raises(AttributeError): - del self.context.thing - - -class ExampleSteps(object): - text = None - table = None - - @staticmethod - def step_passes(context): # pylint: disable=unused-argument - pass - - @staticmethod - def step_fails(context): # pylint: disable=unused-argument - assert False, "XFAIL" - - @classmethod - def step_with_text(cls, context): - assert context.text is not None, "REQUIRE: multi-line text" - cls.text = context.text - - @classmethod - def step_with_table(cls, context): - assert context.table, "REQUIRE: table" - cls.table = context.table - - @classmethod - def register_steps_with(cls, step_registry): - # pylint: disable=bad-whitespace - step_definitions = [ - ("step", "a step passes", cls.step_passes), - ("step", "a step fails", cls.step_fails), - ("step", "a step with text", cls.step_with_text), - ("step", "a step with a table", cls.step_with_table), - ] - for keyword, pattern, func in step_definitions: - step_registry.add_step_definition(keyword, pattern, func) - - -class TestContext_ExecuteSteps(unittest.TestCase): - """ - Test the behave.runner.Context.execute_steps() functionality. - """ - # pylint: disable=invalid-name, no-self-use - step_registry = None - - def setUp(self): - if not self.step_registry: - # -- SETUP ONCE: - self.step_registry = StepRegistry() - ExampleSteps.register_steps_with(self.step_registry) - ExampleSteps.text = None - ExampleSteps.table = None - - runner_ = Mock() - self.config = runner_.config = Mock() - runner_.config.verbose = False - runner_.config.stdout_capture = False - runner_.config.stderr_capture = False - runner_.config.log_capture = False - runner_.config.logging_format = None - runner_.config.logging_datefmt = None - runner_.step_registry = self.step_registry - - self.context = runner.Context(runner_) - runner_.context = self.context - self.context.feature = Mock() - self.context.feature.parser = parser.Parser() - self.context.runner = runner_ - # self.context.text = None - # self.context.table = None - - def test_execute_steps_with_simple_steps(self): - doc = u""" -Given a step passes -Then a step passes -""".lstrip() - with patch("behave.step_registry.registry", self.step_registry): - result = self.context.execute_steps(doc) - assert result is True - - def test_execute_steps_with_failing_step(self): - doc = u""" -Given a step passes -When a step fails -Then a step passes -""".lstrip() - with patch("behave.step_registry.registry", self.step_registry): - try: - result = self.context.execute_steps(doc) - except AssertionError as e: - assert "FAILED SUB-STEP: When a step fails" in _text(e) - - def test_execute_steps_with_undefined_step(self): - doc = u""" -Given a step passes -When a step is undefined -Then a step passes -""".lstrip() - with patch("behave.step_registry.registry", self.step_registry): - try: - result = self.context.execute_steps(doc) - except AssertionError as e: - assert "UNDEFINED SUB-STEP: When a step is undefined" in _text(e) - - def test_execute_steps_with_text(self): - doc = u''' -Given a step passes -When a step with text: - """ - Lorem ipsum - Ipsum lorem - """ -Then a step passes -'''.lstrip() - with patch("behave.step_registry.registry", self.step_registry): - result = self.context.execute_steps(doc) - expected_text = "Lorem ipsum\nIpsum lorem" - assert result is True - assert expected_text == ExampleSteps.text - - def test_execute_steps_with_table(self): - doc = u""" -Given a step with a table: - | Name | Age | - | Alice | 12 | - | Bob | 23 | -Then a step passes -""".lstrip() - with patch("behave.step_registry.registry", self.step_registry): - # pylint: disable=bad-whitespace, bad-continuation - result = self.context.execute_steps(doc) - expected_table = Table([u"Name", u"Age"], 0, [ - [u"Alice", u"12"], - [u"Bob", u"23"], - ]) - assert result is True - assert expected_table == ExampleSteps.table - - def test_context_table_is_restored_after_execute_steps_without_table(self): - doc = u""" -Given a step passes -Then a step passes -""".lstrip() - with patch("behave.step_registry.registry", self.step_registry): - original_table = "" - self.context.table = original_table - self.context.execute_steps(doc) - assert self.context.table == original_table - - def test_context_table_is_restored_after_execute_steps_with_table(self): - doc = u""" -Given a step with a table: - | Name | Age | - | Alice | 12 | - | Bob | 23 | -Then a step passes -""".lstrip() - with patch("behave.step_registry.registry", self.step_registry): - original_table = "" - self.context.table = original_table - self.context.execute_steps(doc) - assert self.context.table == original_table - - def test_context_text_is_restored_after_execute_steps_without_text(self): - doc = u""" -Given a step passes -Then a step passes -""".lstrip() - with patch("behave.step_registry.registry", self.step_registry): - original_text = "" - self.context.text = original_text - self.context.execute_steps(doc) - assert self.context.text == original_text - - def test_context_text_is_restored_after_execute_steps_with_text(self): - doc = u''' -Given a step passes -When a step with text: - """ - Lorem ipsum - Ipsum lorem - """ -'''.lstrip() - with patch("behave.step_registry.registry", self.step_registry): - original_text = "" - self.context.text = original_text - self.context.execute_steps(doc) - assert self.context.text == original_text - - - # OLD: @raises(ValueError) - def test_execute_steps_should_fail_when_called_without_feature(self): - doc = u""" -Given a passes -Then a step passes -""".lstrip() - with patch("behave.step_registry.registry", self.step_registry): - self.context.feature = None - with pytest.raises(ValueError): - self.context.execute_steps(doc) - def create_mock_config(): config = Mock() diff --git a/tests/unit/test_runner_context.py b/tests/unit/test_runner_context.py new file mode 100644 index 000000000..b128547a6 --- /dev/null +++ b/tests/unit/test_runner_context.py @@ -0,0 +1,545 @@ +""" +Unit tests for :class:`behave.runner.Context`. +""" + +from __future__ import absolute_import, print_function +import unittest +import warnings +from platform import python_implementation + +from mock import Mock, patch +import pytest +import six + +from behave import runner, parser +from behave.model import Table +from behave.runner import Context, ContextMode, scoped_context_layer +from behave.step_registry import StepRegistry + + +# -- CONVENIENCE-ALIAS: +_text = six.text_type + + +class TestContext(object): + @staticmethod + def make_runner(config=None): + if config is None: + config = Mock() + # MAYBE: the_runner = runner.Runner(config) + the_runner = Mock() + the_runner.config = config + return the_runner + + @classmethod + def make_context(cls, runner=None, **runner_kwargs): + the_runner = runner + if the_runner is None: + the_runner = cls.make_runner(**runner_kwargs) + context = Context(the_runner) + return context + + # -- TESTSUITE FOR: behave.runner.Context (PART 1) + def test_use_or_assign_param__with_existing_param_uses_param(self): + param_name = "some_param" + context = self.make_context() + with context.use_with_user_mode(): + context.some_param = 12 + with scoped_context_layer(context, "scenario"): + assert param_name in context + param = context.use_or_assign_param(param_name, 123) + assert param_name in context + assert param == 12 + + def test_use_or_assign_param__with_nonexisting_param_assigns_param(self): + param_name = "other_param" + context = self.make_context() + with context.use_with_user_mode(): + with scoped_context_layer(context, "scenario"): + assert param_name not in context + param = context.use_or_assign_param(param_name, 123) + assert param_name in context + assert param == 123 + + def test_use_or_create_param__with_existing_param_uses_param(self): + param_name = "some_param" + context = self.make_context() + with context.use_with_user_mode(): + context.some_param = 12 + with scoped_context_layer(context, "scenario"): + assert param_name in context + param = context.use_or_create_param(param_name, int, 123) + assert param_name in context + assert param == 12 + + def test_use_or_create_param__with_nonexisting_param_creates_param(self): + param_name = "other_param" + context = self.make_context() + with context.use_with_user_mode(): + with scoped_context_layer(context, "scenario"): + assert param_name not in context + param = context.use_or_create_param(param_name, int, 123) + assert param_name in context + assert param == 123 + + def test_context_contains(self): + context = self.make_context() + assert "thing" not in context + context.thing = "stuff" + assert "thing" in context + context._push() + assert "thing" in context + + +class TestContext2(unittest.TestCase): + # pylint: disable=invalid-name, protected-access, no-self-use + + def setUp(self): + r = Mock() + self.config = r.config = Mock() + r.config.verbose = False + self.context = runner.Context(r) + + # -- TESTSUITE FOR: behave.runner.Context (PART 2) + def test_user_mode_shall_restore_behave_mode(self): + # -- CASE: No exception is raised. + initial_mode = ContextMode.BEHAVE + assert self.context._mode == initial_mode + with self.context.use_with_user_mode(): + assert self.context._mode == ContextMode.USER + self.context.thing = "stuff" + assert self.context._mode == initial_mode + + def test_user_mode_shall_restore_behave_mode_if_assert_fails(self): + initial_mode = ContextMode.BEHAVE + assert self.context._mode == initial_mode + try: + with self.context.use_with_user_mode(): + assert self.context._mode == ContextMode.USER + assert False, "XFAIL" + except AssertionError: + assert self.context._mode == initial_mode + + def test_user_mode_shall_restore_behave_mode_if_exception_is_raised(self): + initial_mode = ContextMode.BEHAVE + assert self.context._mode == initial_mode + try: + with self.context.use_with_user_mode(): + assert self.context._mode == ContextMode.USER + raise RuntimeError("XFAIL") + except RuntimeError: + assert self.context._mode == initial_mode + + def test_use_with_user_mode__shall_restore_initial_mode(self): + # -- CASE: No exception is raised. + # pylint: disable=protected-access + initial_mode = ContextMode.BEHAVE + self.context._mode = initial_mode + with self.context.use_with_user_mode(): + assert self.context._mode == ContextMode.USER + self.context.thing = "stuff" + assert self.context._mode == initial_mode + + def test_use_with_user_mode__shall_restore_initial_mode_with_error(self): + # -- CASE: Exception is raised. + # pylint: disable=protected-access + initial_mode = ContextMode.BEHAVE + self.context._mode = initial_mode + try: + with self.context.use_with_user_mode(): + assert self.context._mode == ContextMode.USER + raise RuntimeError("XFAIL") + except RuntimeError: + assert self.context._mode == initial_mode + + def test_use_with_behave_mode__shall_restore_initial_mode(self): + # -- CASE: No exception is raised. + # pylint: disable=protected-access + initial_mode = ContextMode.USER + self.context._mode = initial_mode + with self.context._use_with_behave_mode(): + assert self.context._mode == ContextMode.BEHAVE + self.context.thing = "stuff" + assert self.context._mode == initial_mode + + def test_use_with_behave_mode__shall_restore_initial_mode_with_error(self): + # -- CASE: Exception is raised. + # pylint: disable=protected-access + initial_mode = ContextMode.USER + self.context._mode = initial_mode + try: + with self.context._use_with_behave_mode(): + assert self.context._mode == ContextMode.BEHAVE + raise RuntimeError("XFAIL") + except RuntimeError: + assert self.context._mode == initial_mode + + def test_attribute_set_at_upper_level_visible_at_lower_level(self): + self.context.thing = "stuff" + self.context._push() + assert self.context.thing == "stuff" + + def test_attribute_set_at_lower_level_not_visible_at_upper_level(self): + self.context._push() + self.context.thing = "stuff" + self.context._pop() + assert getattr(self.context, "thing", None) is None + + def test_attributes_set_at_upper_level_visible_at_lower_level(self): + self.context.thing = "stuff" + self.context._push() + assert self.context.thing == "stuff" + self.context.other_thing = "more stuff" + self.context._push() + assert self.context.thing == "stuff" + assert self.context.other_thing == "more stuff" + self.context.third_thing = "wombats" + self.context._push() + assert self.context.thing == "stuff" + assert self.context.other_thing == "more stuff" + assert self.context.third_thing == "wombats" + + def test_attributes_set_at_lower_level_not_visible_at_upper_level(self): + self.context.thing = "stuff" + + self.context._push() + self.context.other_thing = "more stuff" + + self.context._push() + self.context.third_thing = "wombats" + assert self.context.thing == "stuff" + assert self.context.other_thing == "more stuff" + assert self.context.third_thing == "wombats" + + self.context._pop() + assert self.context.thing == "stuff" + assert self.context.other_thing == "more stuff" + assert getattr(self.context, "third_thing", None) is None, "%s is not None" % self.context.third_thing + + self.context._pop() + assert self.context.thing == "stuff" + assert getattr(self.context, "other_thing", None) is None, "%s is not None" % self.context.other_thing + assert getattr(self.context, "third_thing", None) is None, "%s is not None" % self.context.third_thing + + def test_masking_existing_user_attribute_when_verbose_causes_warning(self): + warns = [] + + def catch_warning(*args, **kwargs): + warns.append(args[0]) + + old_showwarning = warnings.showwarning + warnings.showwarning = catch_warning + + # pylint: disable=protected-access + self.config.verbose = True + with self.context.use_with_user_mode(): + self.context.thing = "stuff" + self.context._push() + self.context.thing = "other stuff" + + warnings.showwarning = old_showwarning + + print(repr(warns)) + assert warns, "warns is empty!" + warning = warns[0] + assert isinstance(warning, runner.ContextMaskWarning), "warning is not a ContextMaskWarning" + info = warning.args[0] + assert info.startswith("user code"), "%r doesn't start with 'user code'" % info + assert "'thing'" in info, "%r not in %r" % ("'thing'", info) + assert "tutorial" in info, '"tutorial" not in %r' % (info, ) + + def test_masking_existing_user_attribute_when_not_verbose_causes_no_warning(self): + warns = [] + + def catch_warning(*args, **kwargs): + warns.append(args[0]) + + old_showwarning = warnings.showwarning + warnings.showwarning = catch_warning + + # explicit + # pylint: disable=protected-access + self.config.verbose = False + with self.context.use_with_user_mode(): + self.context.thing = "stuff" + self.context._push() + self.context.thing = "other stuff" + + warnings.showwarning = old_showwarning + + assert not warns + + def test_behave_masking_user_attribute_causes_warning(self): + warns = [] + + def catch_warning(*args, **kwargs): + warns.append(args[0]) + + old_showwarning = warnings.showwarning + warnings.showwarning = catch_warning + + with self.context.use_with_user_mode(): + self.context.thing = "stuff" + # pylint: disable=protected-access + self.context._push() + self.context.thing = "other stuff" + + warnings.showwarning = old_showwarning + + print(repr(warns)) + assert warns, "OOPS: warns is empty, but expected non-empty" + warning = warns[0] + assert isinstance(warning, runner.ContextMaskWarning), "warning is not a ContextMaskWarning" + info = warning.args[0] + assert info.startswith("behave runner"), "%r doesn't start with 'behave runner'" % info + assert "'thing'" in info, "%r not in %r" % ("'thing'", info) + filename = __file__.rsplit(".", 1)[0] + if python_implementation() == "Jython": + filename = filename.replace("$py", ".py") + assert filename in info, "%r not in %r" % (filename, info) + + def test_setting_root_attribute_that_masks_existing_causes_warning(self): + # pylint: disable=protected-access + warns = [] + + def catch_warning(*args, **kwargs): + warns.append(args[0]) + + old_showwarning = warnings.showwarning + warnings.showwarning = catch_warning + + with self.context.use_with_user_mode(): + self.context._push() + self.context.thing = "teak" + self.context._set_root_attribute("thing", "oak") + + warnings.showwarning = old_showwarning + + print(repr(warns)) + assert warns + warning = warns[0] + assert isinstance(warning, runner.ContextMaskWarning) + info = warning.args[0] + assert info.startswith("behave runner"), "%r doesn't start with 'behave runner'" % info + assert "'thing'" in info, "%r not in %r" % ("'thing'", info) + filename = __file__.rsplit(".", 1)[0] + if python_implementation() == "Jython": + filename = filename.replace("$py", ".py") + assert filename in info, "%r not in %r" % (filename, info) + + def test_context_deletable(self): + assert "thing" not in self.context + self.context.thing = "stuff" + assert "thing" in self.context + del self.context.thing + assert "thing" not in self.context + + # OLD: @raises(AttributeError) + def test_context_deletable_raises(self): + # pylint: disable=protected-access + assert "thing" not in self.context + self.context.thing = "stuff" + assert "thing" in self.context + self.context._push() + assert "thing" in self.context + with pytest.raises(AttributeError): + del self.context.thing + + +class ExampleSteps(object): + text = None + table = None + + @staticmethod + def step_passes(context): # pylint: disable=unused-argument + pass + + @staticmethod + def step_fails(context): # pylint: disable=unused-argument + assert False, "XFAIL" + + @classmethod + def step_with_text(cls, context): + assert context.text is not None, "REQUIRE: multi-line text" + cls.text = context.text + + @classmethod + def step_with_table(cls, context): + assert context.table, "REQUIRE: table" + cls.table = context.table + + @classmethod + def register_steps_with(cls, step_registry): + # pylint: disable=bad-whitespace + step_definitions = [ + ("step", "a step passes", cls.step_passes), + ("step", "a step fails", cls.step_fails), + ("step", "a step with text", cls.step_with_text), + ("step", "a step with a table", cls.step_with_table), + ] + for keyword, pattern, func in step_definitions: + step_registry.add_step_definition(keyword, pattern, func) + + +class TestContext_ExecuteSteps(unittest.TestCase): + """ + Test the behave.runner.Context.execute_steps() functionality. + """ + # pylint: disable=invalid-name, no-self-use + step_registry = None + + def setUp(self): + if not self.step_registry: + # -- SETUP ONCE: + self.step_registry = StepRegistry() + ExampleSteps.register_steps_with(self.step_registry) + ExampleSteps.text = None + ExampleSteps.table = None + + runner_ = Mock() + self.config = runner_.config = Mock() + runner_.config.verbose = False + runner_.config.stdout_capture = False + runner_.config.stderr_capture = False + runner_.config.log_capture = False + runner_.config.logging_format = None + runner_.config.logging_datefmt = None + runner_.step_registry = self.step_registry + + self.context = runner.Context(runner_) + runner_.context = self.context + self.context.feature = Mock() + self.context.feature.parser = parser.Parser() + self.context.runner = runner_ + # self.context.text = None + # self.context.table = None + + def test_execute_steps_with_simple_steps(self): + doc = u""" +Given a step passes +Then a step passes +""".lstrip() + with patch("behave.step_registry.registry", self.step_registry): + result = self.context.execute_steps(doc) + assert result is True + + def test_execute_steps_with_failing_step(self): + doc = u""" +Given a step passes +When a step fails +Then a step passes +""".lstrip() + with patch("behave.step_registry.registry", self.step_registry): + try: + result = self.context.execute_steps(doc) + except AssertionError as e: + assert "FAILED SUB-STEP: When a step fails" in _text(e) + + def test_execute_steps_with_undefined_step(self): + doc = u""" +Given a step passes +When a step is undefined +Then a step passes +""".lstrip() + with patch("behave.step_registry.registry", self.step_registry): + try: + result = self.context.execute_steps(doc) + except AssertionError as e: + assert "UNDEFINED SUB-STEP: When a step is undefined" in _text(e) + + def test_execute_steps_with_text(self): + doc = u''' +Given a step passes +When a step with text: + """ + Lorem ipsum + Ipsum lorem + """ +Then a step passes +'''.lstrip() + with patch("behave.step_registry.registry", self.step_registry): + result = self.context.execute_steps(doc) + expected_text = "Lorem ipsum\nIpsum lorem" + assert result is True + assert expected_text == ExampleSteps.text + + def test_execute_steps_with_table(self): + doc = u""" +Given a step with a table: + | Name | Age | + | Alice | 12 | + | Bob | 23 | +Then a step passes +""".lstrip() + with patch("behave.step_registry.registry", self.step_registry): + # pylint: disable=bad-whitespace, bad-continuation + result = self.context.execute_steps(doc) + expected_table = Table([u"Name", u"Age"], 0, [ + [u"Alice", u"12"], + [u"Bob", u"23"], + ]) + assert result is True + assert expected_table == ExampleSteps.table + + def test_context_table_is_restored_after_execute_steps_without_table(self): + doc = u""" +Given a step passes +Then a step passes +""".lstrip() + with patch("behave.step_registry.registry", self.step_registry): + original_table = "" + self.context.table = original_table + self.context.execute_steps(doc) + assert self.context.table == original_table + + def test_context_table_is_restored_after_execute_steps_with_table(self): + doc = u""" +Given a step with a table: + | Name | Age | + | Alice | 12 | + | Bob | 23 | +Then a step passes +""".lstrip() + with patch("behave.step_registry.registry", self.step_registry): + original_table = "" + self.context.table = original_table + self.context.execute_steps(doc) + assert self.context.table == original_table + + def test_context_text_is_restored_after_execute_steps_without_text(self): + doc = u""" +Given a step passes +Then a step passes +""".lstrip() + with patch("behave.step_registry.registry", self.step_registry): + original_text = "" + self.context.text = original_text + self.context.execute_steps(doc) + assert self.context.text == original_text + + def test_context_text_is_restored_after_execute_steps_with_text(self): + doc = u''' +Given a step passes +When a step with text: + """ + Lorem ipsum + Ipsum lorem + """ +'''.lstrip() + with patch("behave.step_registry.registry", self.step_registry): + original_text = "" + self.context.text = original_text + self.context.execute_steps(doc) + assert self.context.text == original_text + + + # OLD: @raises(ValueError) + def test_execute_steps_should_fail_when_called_without_feature(self): + doc = u""" +Given a passes +Then a step passes +""".lstrip() + with patch("behave.step_registry.registry", self.step_registry): + self.context.feature = None + with pytest.raises(ValueError): + self.context.execute_steps(doc) From df9c71e36be38d9786ef3e638221de39cdb3bb3c Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 7 Oct 2023 17:11:15 +0200 Subject: [PATCH 191/240] FIX TESTS FOR: Python 3.12 * Some exception messages changed (related to: TypeError: abstract method) * configparser.SafeConfigParser was removed --- CHANGES.rst | 5 +++-- features/runner.use_runner_class.feature | 22 +++++++++++++++------- features/userdata.feature | 7 ++----- tests/unit/test_runner_plugin.py | 24 ++++++++++++++++++------ 4 files changed, 38 insertions(+), 20 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 90ed72bf9..5337d12ce 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -87,8 +87,9 @@ CLARIFICATION: FIXED: -* FIXED: Some tests related to python3.11 -* FIXED: Some tests related to python3.9 +* FIXED: Some tests for python-3.12 +* FIXED: Some tests related to python-3.11 +* FIXED: Some tests related to python-3.9 * FIXED: active-tag logic if multiple tags with same category exists. * issue #1120: Logging ignoring level set in setup_logging (submitted by: j7an) * issue #1070: Color support detection: Fails for WindowsTerminal (provided by: jenisys) diff --git a/features/runner.use_runner_class.feature b/features/runner.use_runner_class.feature index 58d11f5ec..04df1e355 100644 --- a/features/runner.use_runner_class.feature +++ b/features/runner.use_runner_class.feature @@ -325,19 +325,27 @@ Feature: User-provided Test Runner Class (extension-point) | BAD_CLASS | behave_example.bad_runner:NotRunner2 | InvalidClassError: is not a subclass-of 'behave.api.runner:ITestRunner' | Runner class does not behave properly. | | BAD_FUNCTION | behave_example.bad_runner:return_none | InvalidClassError: is not a class | runner_class is a function. | | BAD_VALUE | behave_example.bad_runner:CONSTANT_1 | InvalidClassError: is not a class | runner_class is a constant number. | - | INCOMPLETE_CLASS | behave_example.incomplete:IncompleteRunner1 | TypeError: Can't instantiate abstract class IncompleteRunner1 with abstract method(s)? __init__ | Constructor is missing | - | INCOMPLETE_CLASS | behave_example.incomplete:IncompleteRunner2 | TypeError: Can't instantiate abstract class IncompleteRunner2 with abstract method(s)? run | run() method is missing | | INVALID_CLASS | behave_example.incomplete:IncompleteRunner4 | InvalidClassError: run\(\) is not callable | run is a bool-value (no method) | + Examples: BAD_CASE (python <= 3.11) + | syndrome | runner_class | failure_message | case | + | INCOMPLETE_CLASS | behave_example.incomplete:IncompleteRunner1 | TypeError: Can't instantiate abstract class IncompleteRunner1 (with\|without an implementation for) abstract method(s)? (')?__init__(')? | Constructor is missing | + | INCOMPLETE_CLASS | behave_example.incomplete:IncompleteRunner2 | TypeError: Can't instantiate abstract class IncompleteRunner2 (with\|without an implementation for) abstract method(s)? (')?run(')? | run() method is missing | + @use.with_python.min_version=3.3 - Examples: BAD_CASE2 + # DISABLED: @use.with_python.max_version=3.11 + Examples: BAD_CASE4 | syndrome | runner_class | failure_message | case | - | INCOMPLETE_CLASS | behave_example.incomplete:IncompleteRunner3 | TypeError: Can't instantiate abstract class IncompleteRunner3 with abstract method(s)? undefined_steps | undefined_steps property is missing | + | INCOMPLETE_CLASS | behave_example.incomplete:IncompleteRunner3 | TypeError: Can't instantiate abstract class IncompleteRunner3 (with\|without an implementation for) abstract method(s)? (')?undefined_steps(')? | undefined_steps property is missing | # -- PYTHON VERSION SENSITIVITY on INCOMPLETE_CLASS with API TypeError exception: # Since Python 3.9: "... methods ..." is only used in plural case (if multiple methods are missing). # "TypeError: Can't instantiate abstract class with abstract method " ( for Python.version >= 3.9) # "TypeError: Can't instantiate abstract class with abstract methods " (for Python.version < 3.9) + # + # Since Python 3.12: + # NEW: "TypeError: Can't instantiate abstract class without implementation for abstract method ''" + # OLD: "TypeError: Can't instantiate abstract class with abstract methods " (for Python.version < 3.12) Rule: Use own Test Runner-by-Name (BAD CASES) @@ -450,11 +458,11 @@ Feature: User-provided Test Runner Class (extension-point) Examples: BAD_CASE | runner_name | runner_class | syndrome | problem_description | case | - | NAME_FOR_INCOMPLETE_CLASS_1 | behave_example.incomplete:IncompleteRunner1 | TypeError | Can't instantiate abstract class IncompleteRunner1 with abstract method(s)? __init__ | Constructor is missing | - | NAME_FOR_INCOMPLETE_CLASS_2 | behave_example.incomplete:IncompleteRunner2 | TypeError | Can't instantiate abstract class IncompleteRunner2 with abstract method(s)? run | run() method is missing | + | NAME_FOR_INCOMPLETE_CLASS_1 | behave_example.incomplete:IncompleteRunner1 | TypeError | Can't instantiate abstract class IncompleteRunner1 (with\|without an implementation for) abstract method(s)? (')?__init__(')? | Constructor is missing | + | NAME_FOR_INCOMPLETE_CLASS_2 | behave_example.incomplete:IncompleteRunner2 | TypeError | Can't instantiate abstract class IncompleteRunner2 (with\|without an implementation for) abstract method(s)? (')?run(')? | run() method is missing | | NAME_FOR_INCOMPLETE_CLASS_4 | behave_example.incomplete:IncompleteRunner4 | InvalidClassError | run\(\) is not callable | run is a bool-value (no method) | @use.with_python.min_version=3.3 Examples: BAD_CASE2 | runner_name | runner_class | syndrome | problem_description | case | - | NAME_FOR_INCOMPLETE_CLASS_3 | behave_example.incomplete:IncompleteRunner3 | TypeError | Can't instantiate abstract class IncompleteRunner3 with abstract method(s)? undefined_steps | missing-property | + | NAME_FOR_INCOMPLETE_CLASS_3 | behave_example.incomplete:IncompleteRunner3 | TypeError | Can't instantiate abstract class IncompleteRunner3 (with\|without an implementation for) abstract method(s)? (')?undefined_steps(')? | missing-property | diff --git a/features/userdata.feature b/features/userdata.feature index e02295357..400811e2b 100644 --- a/features/userdata.feature +++ b/features/userdata.feature @@ -233,16 +233,13 @@ Feature: User-specific Configuration Data (userdata) """ And a file named "features/environment.py" with: """ - try: - import configparser - except: - import ConfigParser as configparser # -- PY2 + from behave.configuration import ConfigParser def before_all(context): userdata = context.config.userdata configfile = userdata.get("configfile", "userconfig.ini") section = userdata.get("config_section", "behave.userdata") - parser = configparser.SafeConfigParser() + parser = ConfigParser() parser.read(configfile) if parser.has_section(section): userdata.update(parser.items(section)) diff --git a/tests/unit/test_runner_plugin.py b/tests/unit/test_runner_plugin.py index 0c43fe133..892386e10 100644 --- a/tests/unit/test_runner_plugin.py +++ b/tests/unit/test_runner_plugin.py @@ -45,6 +45,21 @@ def use_current_directory(directory_path): os.chdir(initial_directory) +def make_exception_message4abstract_method(class_name, method_name): + """ + Creates a regexp matcher object for the TypeError exception message + that is raised if an abstract method is encountered. + """ + # -- RAISED AS: TypeError + # UNTIL python 3.11: Can't instantiate abstract class with abstract method + # FROM python 3.12: Can't instantiate abstract class without an implementation for abstract method '' + message = """ +Can't instantiate abstract class {class_name} (with|without an implementation for) abstract method(s)? (')?{method_name}(')? +""".format(class_name=class_name, method_name=method_name).strip() + return message + + + # ----------------------------------------------------------------------------- # TEST SUPPORT: TEST RUNNER CLASS CANDIDATES -- GOOD EXAMPLES # ----------------------------------------------------------------------------- @@ -264,8 +279,7 @@ def test_make_runner_fails_if_runner_class_has_no_ctor(self): config = Configuration(["--runner=%s:%s" % (self.THIS_MODULE_NAME, class_name)]) RunnerPlugin().make_runner(config) - expected = "Can't instantiate abstract class %s with abstract method(s)? __init__" % \ - class_name + expected = make_exception_message4abstract_method(class_name, method_name="__init__") assert exc_info.type is TypeError assert exc_info.match(expected) @@ -275,8 +289,7 @@ def test_make_runner_fails_if_runner_class_has_no_run_method(self): config = Configuration(["--runner=%s:%s" % (self.THIS_MODULE_NAME, class_name)]) RunnerPlugin().make_runner(config) - expected = "Can't instantiate abstract class %s with abstract method(s)? run" % \ - class_name + expected = make_exception_message4abstract_method(class_name, method_name="run") assert exc_info.type is TypeError assert exc_info.match(expected) @@ -287,7 +300,6 @@ def test_make_runner_fails_if_runner_class_has_no_undefined_steps(self): config = Configuration(["--runner=%s:%s" % (self.THIS_MODULE_NAME, class_name)]) RunnerPlugin().make_runner(config) - expected = "Can't instantiate abstract class %s with abstract method(s)? undefined_steps" % \ - class_name + expected = make_exception_message4abstract_method(class_name, "undefined_steps") assert exc_info.type is TypeError assert exc_info.match(expected) From 0c377c00b6b688215315db85c469c31d0b20ba17 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 7 Oct 2023 17:22:18 +0200 Subject: [PATCH 192/240] CI: Use Python 3.12 * USE: actions/checkout@v4 --- .github/workflows/tests-windows.yml | 4 ++-- .github/workflows/tests.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index 3e3acad65..6e3ee190a 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -37,9 +37,9 @@ jobs: fail-fast: false matrix: os: [windows-latest] - python-version: ["3.11", "3.10", "3.9"] + python-version: ["3.12", "3.11", "3.10", "3.9"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} - uses: actions/setup-python@v4 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1a683d52f..24af4931e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,9 +34,9 @@ jobs: matrix: # PREPARED: os: [ubuntu-latest, macos-latest, windows-latest] os: [ubuntu-latest] - python-version: ["3.11", "3.10", "3.9", "pypy-3.10", "pypy-2.7"] + python-version: ["3.12", "3.11", "3.10", "3.9", "pypy-3.10", "pypy-2.7"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} - uses: actions/setup-python@v4 with: @@ -45,7 +45,7 @@ jobs: cache-dependency-path: 'py.requirements/*.txt' # -- DISABLED: # - name: Show Python version - # run: python --version + # run: python --version - name: Install Python package dependencies run: | python -m pip install -U pip setuptools wheel From c0719bf9c2d32a1e2c386d75563c5f8bf36f6341 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 7 Oct 2023 17:34:46 +0200 Subject: [PATCH 193/240] CI: Tweak conditions to run workflows * ADDED: .github workflow files --- .github/workflows/tests-windows.yml | 2 ++ .github/workflows/tests.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index 6e3ee190a..e3d1f382e 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -8,6 +8,7 @@ on: push: branches: [ "main", "release/**" ] paths: + - ".github/**/*.yml" - "**/*.py" - "**/*.feature" - "py.requirements/**" @@ -18,6 +19,7 @@ on: types: [opened, reopened, review_requested] branches: [ "main" ] paths: + - ".github/**/*.yml" - "**/*.py" - "**/*.feature" - "py.requirements/**" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 24af4931e..2190d852e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,6 +8,7 @@ on: push: branches: [ "main", "release/**" ] paths: + - ".github/**/*.yml" - "**/*.py" - "**/*.feature" - "py.requirements/**" @@ -18,6 +19,7 @@ on: types: [opened, reopened, review_requested] branches: [ "main" ] paths: + - ".github/**/*.yml" - "**/*.py" - "**/*.feature" - "py.requirements/**" From b25a776309986373e868a17b2e12331f1178c664 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 7 Oct 2023 17:38:12 +0200 Subject: [PATCH 194/240] pyproject.toml: Mark support for Python 3.12 * Same for: setup.py --- pyproject.toml | 1 + setup.py | 1 + 2 files changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index f028bfefc..0eaca19cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: Jython", "Programming Language :: Python :: Implementation :: PyPy", diff --git a/setup.py b/setup.py index f0c843270..2655f0446 100644 --- a/setup.py +++ b/setup.py @@ -163,6 +163,7 @@ def find_packages_by_root_package(where): "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: Jython", "Programming Language :: Python :: Implementation :: PyPy", From 2a10bdcfb5bfad8a86a97d70aca26609e439fa33 Mon Sep 17 00:00:00 2001 From: Gaomengsuijia Date: Thu, 9 Nov 2023 15:43:56 +0800 Subject: [PATCH 195/240] Update formatters.rst Misspell --- docs/formatters.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/formatters.rst b/docs/formatters.rst index af3e755a6..2614aa229 100644 --- a/docs/formatters.rst +++ b/docs/formatters.rst @@ -138,7 +138,7 @@ For example: .. code-block:: python # -- FILE: features/steps/screenshot_example_steps.py - from behave import fiven, when + from behave import given, when from behave4example.web_browser.util import take_screenshot_and_attach_to_scenario @given(u'I open the Google webpage') From 08902cede276e69277597475c2ee3dfd86623ecb Mon Sep 17 00:00:00 2001 From: jenisys Date: Fri, 19 Jan 2024 09:37:19 +0100 Subject: [PATCH 196/240] FIX #1154: Config-files are not shown in verbose mode --- CHANGES.rst | 1 + behave/configuration.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 5337d12ce..55acbb77d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -91,6 +91,7 @@ FIXED: * FIXED: Some tests related to python-3.11 * FIXED: Some tests related to python-3.9 * FIXED: active-tag logic if multiple tags with same category exists. +* issue #1154: Config-files are not shown in verbose mode (submitted by: soblom) * issue #1120: Logging ignoring level set in setup_logging (submitted by: j7an) * issue #1070: Color support detection: Fails for WindowsTerminal (provided by: jenisys) * issue #1116: behave erroring in pretty format in pyproject.toml (submitted by: morning-sunn) diff --git a/behave/configuration.py b/behave/configuration.py index 63319ff0c..62a91d7d1 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -783,7 +783,7 @@ def __init__(self, command_args=None, load_config=True, verbose=None, # -- STEP: Load config-file(s) and parse command-line command_args = self.make_command_args(command_args, verbose=verbose) if load_config: - load_configuration(self.defaults, verbose=verbose) + load_configuration(self.defaults, verbose=self.verbose) parser = setup_parser() parser.set_defaults(**self.defaults) args = parser.parse_args(command_args) From 47add70dc7b032ffc3de6799786d6d56285ceeb9 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 3 Feb 2024 17:50:35 +0100 Subject: [PATCH 197/240] Issue #1158: Provide feature file to check problem (MISTAKEN) --- issue.features/issue1158.feature | 57 ++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 issue.features/issue1158.feature diff --git a/issue.features/issue1158.feature b/issue.features/issue1158.feature new file mode 100644 index 000000000..91d444feb --- /dev/null +++ b/issue.features/issue1158.feature @@ -0,0 +1,57 @@ +@issue @mistaken +Feature: Issue #1158 -- ParseMatcher failing on steps with type annotations + + . DESCRIPTION OF SYNDROME (OBSERVED BEHAVIOR): + . * AmbiguousStep exception occurs when using the ParseMatcher + . * MISTAKEN: No such problem exists + . * PROBABLY: Error on the user side + + Scenario: Check Syndrome + Given a new working directory + And a file named "features/steps/steps.py" with: + """ + from __future__ import absolute_import, print_function + from behave import then, register_type, use_step_matcher + from parse_type import TypeBuilder + from enum import Enum + + class CommunicationState(Enum): + ALIVE = 1 + SUSPICIOUS = 2 + DEAD = 3 + UNKNOWN = 4 + + parse_communication_state = TypeBuilder.make_enum(CommunicationState) + register_type(CommunicationState=parse_communication_state) + use_step_matcher("parse") + + @then(u'the SCADA reports that the supervisory controls communication status is {com_state:CommunicationState}') + def step1_reports_communication_status(ctx, com_state): + print("STEP_1: com_state={com_state}".format(com_state=com_state)) + + @then(u'the SCADA finally reports that the supervisory controls communication status is {com_state:CommunicationState}') + def step2_finally_reports_communication_status(ctx, com_state): + print("STEP_2: com_state={com_state}".format(com_state=com_state)) + """ + And a file named "features/syndrome_1158.feature" with: + """ + Feature: F1 + Scenario Outline: STEP_1 and STEP_2 with com_state= + Then the SCADA reports that the supervisory controls communication status is + And the SCADA finally reports that the supervisory controls communication status is + + Examples: + | communication_state | + | ALIVE | + | SUSPICIOUS | + | DEAD | + | UNKNOWN | + """ + When I run "behave features/syndrome_1158.feature" + Then it should pass with: + """ + 1 feature passed, 0 failed, 0 skipped + 4 scenarios passed, 0 failed, 0 skipped + 8 steps passed, 0 failed, 0 skipped + """ + And the command output should not contain "AmbiguousStep" From 55a74f55938fe0f94f308b10738b402a64ca5c9e Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 3 Feb 2024 18:44:09 +0100 Subject: [PATCH 198/240] FIX: sphinxcontrib-applehelp dependency problem * Add explicit constraints until Sphinx >= 4.4 can be used * Apply sphinx-version constraint to "setup.py", "pyproject.toml" (was missed when sphinx version was limited in requirements-file) --- py.requirements/docs.txt | 9 +++++++++ pyproject.toml | 12 ++++++++++-- setup.py | 12 ++++++++++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/py.requirements/docs.txt b/py.requirements/docs.txt index f325f7746..4b53b407a 100644 --- a/py.requirements/docs.txt +++ b/py.requirements/docs.txt @@ -15,3 +15,12 @@ urllib3 < 2.0.0; python_version < '3.8' # -- SUPPORT: sphinx-doc translations (prepared) sphinx-intl >= 0.9.11 + +# -- CONSTRAINTS UNTIL: sphinx > 5.0 can be used +# PROBLEM: sphinxcontrib-applehelp v1.0.8 requires sphinx > 5.0 +# SEE: https://stackoverflow.com/questions/77848565/sphinxcontrib-applehelp-breaking-sphinx-builds-with-sphinx-version-less-than-5-0 +sphinxcontrib-applehelp==1.0.4 +sphinxcontrib-devhelp==1.0.2 +sphinxcontrib-htmlhelp==2.0.1 +sphinxcontrib-qthelp==1.0.3 +sphinxcontrib-serializinghtml==1.1.5 diff --git a/pyproject.toml b/pyproject.toml index 0eaca19cc..e472d14ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,8 +138,16 @@ develop = [ "ruff; python_version >= '3.7'", ] docs = [ - "Sphinx >=1.6", - "sphinx_bootstrap_theme >= 0.6.0" + "Sphinx >=1.6,<4.4", + "sphinx_bootstrap_theme >= 0.6.0", + # -- CONSTRAINTS UNTIL: sphinx > 5.0 is usable -- 2024-01 + # PROBLEM: sphinxcontrib-applehelp v1.0.8 requires sphinx > 5.0 + # SEE: https://stackoverflow.com/questions/77848565/sphinxcontrib-applehelp-breaking-sphinx-builds-with-sphinx-version-less-than-5-0 + "sphinxcontrib-applehelp==1.0.4", + "sphinxcontrib-devhelp==1.0.2", + "sphinxcontrib-htmlhelp==2.0.1", + "sphinxcontrib-qthelp==1.0.3", + "sphinxcontrib-serializinghtml==1.1.5", ] formatters = [ "behave-html-formatter >= 0.9.10; python_version >= '3.6'", diff --git a/setup.py b/setup.py index 2655f0446..ecf412b54 100644 --- a/setup.py +++ b/setup.py @@ -111,8 +111,16 @@ def find_packages_by_root_package(where): }, extras_require={ "docs": [ - "sphinx >= 1.6", - "sphinx_bootstrap_theme >= 0.6" + "sphinx >= 1.6,<4.4", + "sphinx_bootstrap_theme >= 0.6", + # -- CONSTRAINTS UNTIL: sphinx > 5.0 can be used -- 2024-01 + # PROBLEM: sphinxcontrib-applehelp v1.0.8 requires sphinx > 5.0 + # SEE: https://stackoverflow.com/questions/77848565/sphinxcontrib-applehelp-breaking-sphinx-builds-with-sphinx-version-less-than-5-0 + "sphinxcontrib-applehelp==1.0.4", + "sphinxcontrib-devhelp==1.0.2", + "sphinxcontrib-htmlhelp==2.0.1", + "sphinxcontrib-qthelp==1.0.3", + "sphinxcontrib-serializinghtml==1.1.5", ], "develop": [ "build >= 0.5.1", From 135151777288be02d5a25083137d0e23ddd49925 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 3 Feb 2024 19:07:44 +0100 Subject: [PATCH 199/240] =?UTF-8?q?CI=20github-actions:=20Use=20actions/se?= =?UTF-8?q?tup-python=C2=ABv5=20(was:=20v4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/tests-windows.yml | 2 +- .github/workflows/tests.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index e3d1f382e..d3bc9c03a 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -43,7 +43,7 @@ jobs: steps: - uses: actions/checkout@v4 # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2190d852e..5e4183b0c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -40,7 +40,7 @@ jobs: steps: - uses: actions/checkout@v4 # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' From 152e85ee147ecfa5fc1473987e1f563764e88e52 Mon Sep 17 00:00:00 2001 From: Karel Hovorka Date: Sat, 18 Nov 2023 13:45:15 +0100 Subject: [PATCH 200/240] Added __contains__ to Row. --- behave/model.py | 3 +++ tests/unit/test_model.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/behave/model.py b/behave/model.py index 6940a76d0..0c5b93240 100644 --- a/behave/model.py +++ b/behave/model.py @@ -2081,6 +2081,9 @@ def __getitem__(self, name): raise KeyError('"%s" is not a row heading' % name) return self.cells[index] + def __contains__(self, item): + return item in self.headings + def __repr__(self): return "" % (self.cells,) diff --git a/tests/unit/test_model.py b/tests/unit/test_model.py index 21d6c2768..3d43e346c 100644 --- a/tests/unit/test_model.py +++ b/tests/unit/test_model.py @@ -762,3 +762,9 @@ def test_as_dict(self): assert data1["name"] == u"Alice" assert data1["sex"] == u"female" assert data1["age"] == u"12" + + def test_contains(self): + assert "name" in self.row + assert "sex" in self.row + assert "age" in self.row + assert "non-existent-header" not in self.row From a3f51845e00dc1e97118f6fe8583e7c049a43fab Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 28 Jan 2024 00:15:00 +0000 Subject: [PATCH 201/240] Update actions/checkout action to v4 --- .github/workflows/codeql-analysis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 7cc64f10d..ccf2f3ca8 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL From af3706afb8e18627240dcafb3d5d629aac50c20d Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 15 Apr 2024 23:01:09 +0200 Subject: [PATCH 202/240] Issue #1170: Add test to reproduce problem * WORKAROUND: Use "tag_expression_protocol = strict" --- issue.features/issue1170.feature | 61 ++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 issue.features/issue1170.feature diff --git a/issue.features/issue1170.feature b/issue.features/issue1170.feature new file mode 100644 index 000000000..a5c63095a --- /dev/null +++ b/issue.features/issue1170.feature @@ -0,0 +1,61 @@ +@issue +Feature: Issue #1170 -- Tag Expression Auto Detection Problem + + . DESCRIPTION OF SYNDROME (OBSERVED BEHAVIOR): + . TagExpression v2 wildcard matching does not work if one dashed-tag is used. + . + . WORKAROUND: + . * Use TagExpression auto-detection in strict mode + + + Background: Setup + Given a new working directory + And a file named "features/steps/steps.py" with: + """ + from __future__ import absolute_import + import behave4cmd0.passing_steps + """ + And a file named "features/syndrome_1170.feature" with: + """ + Feature: F1 + + @file-test_1 + Scenario: S1 + Given a step passes + + @file-test_2 + Scenario: S2 + When another step passes + + Scenario: S3 -- Untagged + Then some step passes + """ + + @xfailed + Scenario: Use one TagExpression Term with Wildcard -- BROKEN + When I run `behave --tags="file-test*" features/syndrome_1170.feature` + Then it should pass with: + """ + 0 features passed, 0 failed, 1 skipped + 0 scenarios passed, 0 failed, 3 skipped + """ + And note that "TagExpression auto-detection seems to select TagExpressionV1" + And note that "no scenarios is selected/executed" + But note that "first two scenarios should have been executed" + + + Scenario: Use one TagExpression Term with Wildcard -- Strict Mode + Given a file named "behave.ini" with: + """ + # -- ENSURE: Only TagExpression v2 is used (with auto-detection in strict mode) + [behave] + tag_expression_protocol = strict + """ + When I run `behave --tags="file-test*" features/syndrome_1170.feature` + Then it should pass with: + """ + 1 feature passed, 0 failed, 0 skipped + 2 scenarios passed, 0 failed, 1 skipped + """ + And note that "TagExpression auto-detection seems to select TagExpressionV2" + And note that "first two scenarios are selected/executed" From 6fbec03d461b52f51765b3a2f86bcfc39952cb0e Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 12 May 2024 18:09:36 +0200 Subject: [PATCH 203/240] FIX #1170: Auto-detection of Tag-Expressions * Simplify/cleanup TagExpressionProtocol and make_tag_expression() * Add TagExpressionProtocol values for "v1" and "v2" * Move core functionality to "behave.tag_expression.builder" module * Provide additional tests --- CHANGES.rst | 1 + behave/configuration.py | 13 +- behave/tag_expression/__init__.py | 163 +------------ behave/tag_expression/builder.py | 220 ++++++++++++++++++ behave/tag_expression/model.py | 116 +++++++++ behave/tag_expression/model_ext.py | 93 -------- behave/tag_expression/parser.py | 5 +- docs/behave.rst | 8 +- features/steps/behave_tag_expression_steps.py | 2 + issue.features/issue1170.feature | 57 +++-- tasks/py.requirements.txt | 4 +- tests/issues/test_issue1054.py | 2 +- tests/unit/tag_expression/test_basics.py | 50 ---- tests/unit/tag_expression/test_builder.py | 181 ++++++++++++++ tests/unit/tag_expression/test_model_ext.py | 15 +- tests/unit/tag_expression/test_parser.py | 5 +- .../test_tag_expression_v1_part1.py | 44 ++-- .../test_tag_expression_v1_part2.py | 34 +-- tests/unit/test_configuration.py | 21 +- tox.ini | 3 +- 20 files changed, 654 insertions(+), 383 deletions(-) create mode 100644 behave/tag_expression/builder.py delete mode 100644 behave/tag_expression/model_ext.py delete mode 100644 tests/unit/tag_expression/test_basics.py create mode 100644 tests/unit/tag_expression/test_builder.py diff --git a/CHANGES.rst b/CHANGES.rst index 55acbb77d..a39d18861 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -91,6 +91,7 @@ FIXED: * FIXED: Some tests related to python-3.11 * FIXED: Some tests related to python-3.9 * FIXED: active-tag logic if multiple tags with same category exists. +* issue #1170: TagExpression auto-detection is not working properly (submitted by: Luca-morphy) * issue #1154: Config-files are not shown in verbose mode (submitted by: soblom) * issue #1120: Logging ignoring level set in setup_logging (submitted by: j7an) * issue #1070: Color support detection: Fails for WindowsTerminal (provided by: jenisys) diff --git a/behave/configuration.py b/behave/configuration.py index 62a91d7d1..0762dfd89 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -34,7 +34,7 @@ from behave.formatter import _registry as _format_registry from behave.reporter.junit import JUnitReporter from behave.reporter.summary import SummaryReporter -from behave.tag_expression import make_tag_expression, TagExpressionProtocol +from behave.tag_expression import TagExpressionProtocol, make_tag_expression from behave.textutil import select_best_encoding, to_texts from behave.userdata import UserData, parse_user_define @@ -318,13 +318,14 @@ def positive_number(text): dict(dest="paths", action="append", help="Specify default feature paths, used when none are provided.")), ((), # -- CONFIGFILE only - dict(dest="tag_expression_protocol", type=TagExpressionProtocol.parse, + dict(dest="tag_expression_protocol", type=TagExpressionProtocol.from_name, choices=TagExpressionProtocol.choices(), - default=TagExpressionProtocol.default().name.lower(), + default=TagExpressionProtocol.DEFAULT.name.lower(), help="""\ Specify the tag-expression protocol to use (default: %(default)s). -With "any", tag-expressions v1 and v2 are supported (in auto-detect mode). -With "strict", only tag-expressions v2 are supported (better error diagnostics). +With "v1", only tag-expressions v1 are supported. +With "v2", only tag-expressions v2 are supported. +With "auto_detect", tag-expressions v1 and v2 are auto-detected. """)), (("-q", "--quiet"), @@ -751,7 +752,7 @@ class Configuration(object): runner=DEFAULT_RUNNER_CLASS_NAME, steps_catalog=False, summary=True, - tag_expression_protocol=TagExpressionProtocol.default(), + tag_expression_protocol=TagExpressionProtocol.DEFAULT, junit=False, stage=None, userdata={}, diff --git a/behave/tag_expression/__init__.py b/behave/tag_expression/__init__.py index bf6d7d704..5b10686da 100644 --- a/behave/tag_expression/__init__.py +++ b/behave/tag_expression/__init__.py @@ -4,7 +4,7 @@ Common module for tag-expressions: * v1: old tag expressions (deprecating; superceeded by: cucumber-tag-expressions) -* v2: cucumber-tag-expressions +* v2: cucumber-tag-expressions (with wildcard extension) .. seealso:: @@ -13,161 +13,8 @@ """ from __future__ import absolute_import -from enum import Enum -import six -# -- NEW CUCUMBER TAG-EXPRESSIONS (v2): -from .parser import TagExpressionParser -from .model import Expression # noqa: F401 -# -- DEPRECATING: OLD-STYLE TAG-EXPRESSIONS (v1): -# BACKWARD-COMPATIBLE SUPPORT -from .v1 import TagExpression +from .builder import TagExpressionProtocol, make_tag_expression # noqa: F401 - -# ----------------------------------------------------------------------------- -# CLASS: TagExpressionProtocol -# ----------------------------------------------------------------------------- -class TagExpressionProtocol(Enum): - """Used to specify which tag-expression versions to support: - - * ANY: Supports tag-expressions v2 and v1 (as compatibility mode) - * STRICT: Supports only tag-expressions v2 (better diagnostics) - - NOTE: - * Some errors are not caught in ANY mode. - """ - ANY = 1 - STRICT = 2 - - @classmethod - def default(cls): - return cls.ANY - - @classmethod - def choices(cls): - return [member.name.lower() for member in cls] - - @classmethod - def parse(cls, name): - name2 = name.upper() - for member in cls: - if name2 == member.name: - return member - # -- OTHERWISE: - message = "{0} (expected: {1})".format(name, ", ".join(cls.choices())) - raise ValueError(message) - - def select_parser(self, tag_expression_text_or_seq): - if self is self.STRICT: - return parse_tag_expression_v2 - # -- CASE: TagExpressionProtocol.ANY - return select_tag_expression_parser4any(tag_expression_text_or_seq) - - - # -- SINGLETON FUNCTIONALITY: - @classmethod - def current(cls): - """Return currently selected protocol instance.""" - return getattr(cls, "_current", cls.default()) - - @classmethod - def use(cls, member): - """Specify which TagExpression protocol to use.""" - if isinstance(member, six.string_types): - name = member - member = cls.parse(name) - assert isinstance(member, TagExpressionProtocol), "%s:%s" % (type(member), member) - setattr(cls, "_current", member) - - - -# ----------------------------------------------------------------------------- -# FUNCTIONS: -# ----------------------------------------------------------------------------- -def make_tag_expression(text_or_seq): - """Build a TagExpression object by parsing the tag-expression (as text). - - :param text_or_seq: - Tag expression text(s) to parse (as string, sequence). - :param protocol: Tag-expression protocol to use. - :return: TagExpression object to use. - """ - parse_tag_expression = TagExpressionProtocol.current().select_parser(text_or_seq) - return parse_tag_expression(text_or_seq) - - -def parse_tag_expression_v1(tag_expression_parts): - """Parse old style tag-expressions and build a TagExpression object.""" - # -- HINT: DEPRECATING - if isinstance(tag_expression_parts, six.string_types): - tag_expression_parts = tag_expression_parts.split() - elif not isinstance(tag_expression_parts, (list, tuple)): - raise TypeError("EXPECTED: string, sequence", tag_expression_parts) - - # print("parse_tag_expression_v1: %s" % " ".join(tag_expression_parts)) - return TagExpression(tag_expression_parts) - - -def parse_tag_expression_v2(text_or_seq): - """Parse cucumber-tag-expressions and build a TagExpression object.""" - text = text_or_seq - if isinstance(text, (list, tuple)): - # -- ASSUME: List of strings - sequence = text_or_seq - terms = ["({0})".format(term) for term in sequence] - text = " and ".join(terms) - elif not isinstance(text, six.string_types): - raise TypeError("EXPECTED: string, sequence", text) - - if "@" in text: - # -- NORMALIZE: tag-expression text => Remove '@' tag decorators. - text = text.replace("@", "") - text = text.replace(" ", " ") - # DIAG: print("parse_tag_expression_v2: %s" % text) - return TagExpressionParser.parse(text) - - -def is_any_equal_to_keyword(words, keywords): - for keyword in keywords: - for word in words: - if keyword == word: - return True - return False - - -# -- CASE: TagExpressionProtocol.ANY -def select_tag_expression_parser4any(text_or_seq): - """Select/Auto-detect which version of tag-expressions is used. - - :param text_or_seq: Tag expression text (as string, sequence) - :return: TagExpression parser to use (as function). - """ - TAG_EXPRESSION_V1_KEYWORDS = [ - "~", "-", "," - ] - TAG_EXPRESSION_V2_KEYWORDS = [ - "and", "or", "not", "(", ")" - ] - - text = text_or_seq - if isinstance(text, (list, tuple)): - # -- CASE: sequence -- Sequence of tag_expression parts - parts = text_or_seq - text = " ".join(parts) - elif not isinstance(text, six.string_types): - raise TypeError("EXPECTED: string, sequence", text) - - text = text.replace("(", " ( ").replace(")", " ) ") - words = text.split() - contains_v1_keywords = any((k in text) for k in TAG_EXPRESSION_V1_KEYWORDS) - contains_v2_keywords = is_any_equal_to_keyword(words, TAG_EXPRESSION_V2_KEYWORDS) - if contains_v2_keywords: - # -- USE: Use cucumber-tag-expressions - return parse_tag_expression_v2 - elif contains_v1_keywords or len(words) > 1: - # -- CASE 1: "-@foo", "~@foo" (negated) - # -- CASE 2: "@foo @bar" - return parse_tag_expression_v1 - - # -- OTHERWISSE: Use cucumber-tag-expressions - # CASE: "@foo" (1 tag) - return parse_tag_expression_v2 +# -- BACKWARD-COMPATIBLE SUPPORT: +# DEPRECATING: OLD-STYLE TAG-EXPRESSIONS (v1) +from .v1 import TagExpression # noqa: F401 diff --git a/behave/tag_expression/builder.py b/behave/tag_expression/builder.py new file mode 100644 index 000000000..3bb7d358e --- /dev/null +++ b/behave/tag_expression/builder.py @@ -0,0 +1,220 @@ +from __future__ import absolute_import +from enum import Enum +import six + +# -- NEW TAG-EXPRESSIONSx v2 (cucumber-tag-expressions with extensions): +from .parser import TagExpressionParser, TagExpressionError +from .model import Matcher as _MatcherV2 +# -- BACKWARD-COMPATIBLE SUPPORT: +# DEPRECATING: OLD-STYLE TAG-EXPRESSIONS (v1) +from .v1 import TagExpression as _TagExpressionV1 + + +# ----------------------------------------------------------------------------- +# CLASS: TagExpression Parsers +# ----------------------------------------------------------------------------- +def _parse_tag_expression_v1(tag_expression_parts): + """Parse old style tag-expressions and build a TagExpression object.""" + # -- HINT: DEPRECATING + if isinstance(tag_expression_parts, six.string_types): + tag_expression_parts = tag_expression_parts.split() + elif not isinstance(tag_expression_parts, (list, tuple)): + raise TypeError("EXPECTED: string, sequence", tag_expression_parts) + + # print("_parse_tag_expression_v1: %s" % " ".join(tag_expression_parts)) + return _TagExpressionV1(tag_expression_parts) + + +def _parse_tag_expression_v2(text_or_seq): + """ + Parse TagExpressions v2 (cucumber-tag-expressions) and + build a TagExpression object. + """ + text = text_or_seq + if isinstance(text, (list, tuple)): + # -- BACKWARD-COMPATIBLE: Sequence mode will be removed (DEPRECATING) + # ASSUME: List of strings + sequence = text_or_seq + terms = ["({0})".format(term) for term in sequence] + text = " and ".join(terms) + elif not isinstance(text, six.string_types): + raise TypeError("EXPECTED: string, sequence", text) + + if "@" in text: + # -- NORMALIZE: tag-expression text => Remove '@' tag decorators. + text = text.replace("@", "") + text = text.replace(" ", " ") + # DIAG: print("_parse_tag_expression_v2: %s" % text) + return TagExpressionParser.parse(text) + + +# ----------------------------------------------------------------------------- +# CLASS: TagExpressionProtocol +# ----------------------------------------------------------------------------- +class TagExpressionProtocol(Enum): + """Used to specify which tag-expression versions to support: + + * AUTO_DETECT: Supports tag-expressions v2 and v1 (as compatibility mode) + * STRICT: Supports only tag-expressions v2 (better diagnostics) + + NOTE: + * Some errors are not caught in AUTO_DETECT mode. + """ + __order__ = "V1, V2, AUTO_DETECT" + V1 = (_parse_tag_expression_v1,) + V2 = (_parse_tag_expression_v2,) + AUTO_DETECT = (None,) # -- AUTO-DETECT: V1 or V2 + + # -- ALIASES: For backward compatibility. + STRICT = V2 + DEFAULT = AUTO_DETECT + + def __init__(self, parse_func): + self._parse_func = parse_func + + def parse(self, text_or_seq): + """ + Parse a TagExpression as string (or sequence-of-strings) + and return the TagExpression object. + """ + parse_func = self._parse_func + if self is self.AUTO_DETECT: + parse_func = _select_tag_expression_parser4auto(text_or_seq) + return parse_func(text_or_seq) + + # -- CLASS-SUPPORT: + @classmethod + def choices(cls): + """Returns a list of TagExpressionProtocol enum-value names.""" + return [member.name.lower() for member in cls] + + @classmethod + def from_name(cls, name): + """Parse the Enum-name and return the Enum-Value.""" + name2 = name.upper() + for member in cls: + if name2 == member.name: + return member + + # -- SPECIAL-CASE: ALIASES + if name2 == "STRICT": + return cls.STRICT + + # -- OTHERWISE: + message = "{0} (expected: {1})".format(name, ", ".join(cls.choices())) + raise ValueError(message) + + # -- SINGLETON FUNCTIONALITY: + @classmethod + def current(cls): + """Return the currently selected protocol default value.""" + return getattr(cls, "_current", cls.DEFAULT) + + @classmethod + def use(cls, member): + """Specify which TagExpression protocol to use per default.""" + if isinstance(member, six.string_types): + name = member + member = cls.from_name(name) + assert isinstance(member, TagExpressionProtocol), "%s:%s" % (type(member), member) + setattr(cls, "_current", member) + + +# ----------------------------------------------------------------------------- +# FUNCTIONS: +# ----------------------------------------------------------------------------- +def make_tag_expression(text_or_seq, protocol=None): + """ + Build a TagExpression object by parsing the tag-expression (as text). + The current TagExpressionProtocol is used to parse the tag-expression. + + :param text_or_seq: + Tag expression text(s) to parse (as string, sequence). + :param protocol: TagExpressionProtocol value to use (or None). + If None is used, the the current TagExpressionProtocol is used. + :return: TagExpression object to use. + """ + if protocol is None: + protocol = TagExpressionProtocol.current() + return protocol.parse(text_or_seq) + + +# ----------------------------------------------------------------------------- +# SUPPORT CASE: TagExpressionProtocol.AUTO_DETECT +# ----------------------------------------------------------------------------- +def _any_word_is_keyword(words, keywords): + """Checks if any word is a keyword.""" + for keyword in keywords: + for word in words: + if keyword == word: + return True + return False + + +def _any_word_contains_keyword(words, keywords): + for keyword in keywords: + for word in words: + if keyword in word: + return True + return False + + +def _any_word_contains_wildcards(words): + """ + Checks if any word (as tag) contains wildcard(s) supported by TagExpression v2. + + :param words: List of words/tags. + :return: True, if any word contains wildcard(s). + """ + return any([_MatcherV2.contains_wildcards(word) for word in words]) + + +def _any_word_starts_with(words, prefixes): + for prefix in prefixes: + if any([w.startswith(prefix) for w in words]): + return True + return False + + +def _select_tag_expression_parser4auto(text_or_seq): + """Select/Auto-detect which version of tag-expressions is used. + + :param text_or_seq: Tag expression text (as string, sequence) + :return: TagExpression parser to use (as function). + """ + TAG_EXPRESSION_V1_NOT_PREFIXES = ["~", "-"] + TAG_EXPRESSION_V1_OTHER_KEYWORDS = [","] + TAG_EXPRESSION_V2_KEYWORDS = [ + "and", "or", "not", "(", ")" + ] + + text = text_or_seq + if isinstance(text, (list, tuple)): + # -- CASE: sequence -- Sequence of tag_expression parts + parts = text_or_seq + text = " ".join(parts) + elif not isinstance(text, six.string_types): + raise TypeError("EXPECTED: string, sequence", text) + + text = text.replace("(", " ( ").replace(")", " ) ") + words = text.split() + contains_v1_prefixes = _any_word_starts_with(words, TAG_EXPRESSION_V1_NOT_PREFIXES) + contains_v1_keywords = (_any_word_contains_keyword(words, TAG_EXPRESSION_V1_OTHER_KEYWORDS) or + # any((k in text) for k in TAG_EXPRESSION_V1_OTHER_KEYWORDS) or + contains_v1_prefixes) + contains_v2_keywords = (_any_word_is_keyword(words, TAG_EXPRESSION_V2_KEYWORDS) or + _any_word_contains_wildcards(words)) + + if contains_v1_prefixes and contains_v2_keywords: + raise TagExpressionError("Contains TagExpression v2 and v1 NOT-PREFIX: %s" % text) + + if contains_v2_keywords: + # -- USE: Use cucumber-tag-expressions + return _parse_tag_expression_v2 + elif contains_v1_keywords or len(words) > 1: + # -- CASE 1: "-@foo", "~@foo" (negated) + # -- CASE 2: "@foo @bar" + return _parse_tag_expression_v1 + + # -- OTHERWISE: Use cucumber-tag-expressions -- One tag/term (CASE: "@foo") + return _parse_tag_expression_v2 diff --git a/behave/tag_expression/model.py b/behave/tag_expression/model.py index 56477d87f..115ddef87 100644 --- a/behave/tag_expression/model.py +++ b/behave/tag_expression/model.py @@ -1,9 +1,51 @@ # -*- coding: UTF-8 -*- # ruff: noqa: F401 # HINT: Import adapter only +""" +Provides TagExpression v2 model classes with some extensions. +Extensions: + +* :class:`Matcher` as tag-matcher, like: ``@a.*`` + +.. code-block:: python + + # -- Expression := a and b + expression = And(Literal("a"), Literal("b")) + assert True == expression.evaluate(["a", "b"]) + assert False == expression.evaluate(["a"]) + assert False == expression.evaluate([]) + + # -- Expression := a or b + expression = Or(Literal("a"), Literal("b")) + assert True == expression.evaluate(["a", "b"]) + assert True == expression.evaluate(["a"]) + assert False == expression.evaluate([]) + + # -- Expression := not a + expression = Not(Literal("a")) + assert False == expression.evaluate(["a"]) + assert True == expression.evaluate(["other"]) + assert True == expression.evaluate([]) + + # -- Expression := (a or b) and c + expression = And(Or(Literal("a"), Literal("b")), Literal("c")) + assert True == expression.evaluate(["a", "c"]) + assert False == expression.evaluate(["c", "other"]) + assert False == expression.evaluate([]) + + # -- Expression := (a.* or b) and c + expression = And(Or(Matcher("a.*"), Literal("b")), Literal("c")) + assert True == expression.evaluate(["a.one", "c"]) +""" + +from __future__ import absolute_import, print_function +from fnmatch import fnmatchcase +import glob +# -- INJECT: Cucumber TagExpression model classes from cucumber_tag_expressions.model import Expression, Literal, And, Or, Not, True_ + # ----------------------------------------------------------------------------- # PATCH TAG-EXPRESSION BASE-CLASS: Expression # ----------------------------------------------------------------------------- @@ -17,6 +59,7 @@ def _Expression_check(self, tags): """ return self.evaluate(tags) + def _Expression_to_string(self, pretty=True): """Provide nicer string conversion(s).""" text = str(self) @@ -46,3 +89,76 @@ def _Not_to_string(self): # -- MONKEY-PATCH: Not.__str__ = _Not_to_string + + +# ----------------------------------------------------------------------------- +# TAG-EXPRESSION EXTENSION: +# ----------------------------------------------------------------------------- +class Matcher(Expression): + """Matches one or more similar tags by using wildcards. + Supports simple filename-matching / globbing wildcards only. + + .. code-block:: python + + # -- CASE: Tag starts-with "foo." + matcher1 = Matcher("foo.*") + assert True == matcher1.evaluate(["foo.bar"]) + + # -- CASE: Tag ends-with ".foo" + matcher2 = Matcher("*.foo") + assert True == matcher2.evaluate(["bar.foo"]) + assert True == matcher2.evaluate(["bar.baz_more.foo"]) + + # -- CASE: Tag contains "foo" + matcher3 = Matcher("*.foo.*") + assert True == matcher3.evaluate(["bar.foo.more"]) + assert True == matcher3.evaluate(["bar.foo"]) + + .. see:: :mod:`fnmatch` + """ + # pylint: disable=too-few-public-methods + def __init__(self, pattern): + super(Matcher, self).__init__() + self.pattern = pattern + + @property + def name(self): + return self.pattern + + def evaluate(self, values): + for value in values: + # -- REQUIRE: case-sensitive matching + if fnmatchcase(value, self.pattern): + return True + # -- OTHERWISE: no-match + return False + + def __str__(self): + return self.pattern + + def __repr__(self): + return "Matcher('%s')" % self.pattern + + @staticmethod + def contains_wildcards(text): + """Indicates if text contains supported wildcards.""" + # -- NOTE: :mod:`glob` wildcards are same as :mod:`fnmatch` + return glob.has_magic(text) + + +# ----------------------------------------------------------------------------- +# TAG-EXPRESSION EXTENSION: +# ----------------------------------------------------------------------------- +class Never(Expression): + """ + A TagExpression which always returns False. + """ + + def evaluate(self, _values): + return False + + def __str__(self): + return "never" + + def __repr__(self): + return "Never()" diff --git a/behave/tag_expression/model_ext.py b/behave/tag_expression/model_ext.py deleted file mode 100644 index ea18c1f67..000000000 --- a/behave/tag_expression/model_ext.py +++ /dev/null @@ -1,93 +0,0 @@ -# -*- coding: UTF-8 -*- -# pylint: disable=missing-docstring -""" -Extended tag-expression model that supports tag-matchers. - -Provides model classes to evaluate parsed boolean tag expressions. - -.. code-block:: python - - # -- Expression := a and b - expression = And(Literal("a"), Literal("b")) - assert True == expression.evaluate(["a", "b"]) - assert False == expression.evaluate(["a"]) - assert False == expression.evaluate([]) - - # -- Expression := a or b - expression = Or(Literal("a"), Literal("b")) - assert True == expression.evaluate(["a", "b"]) - assert True == expression.evaluate(["a"]) - assert False == expression.evaluate([]) - - # -- Expression := not a - expression = Not(Literal("a")) - assert False == expression.evaluate(["a"]) - assert True == expression.evaluate(["other"]) - assert True == expression.evaluate([]) - - # -- Expression := (a or b) and c - expression = And(Or(Literal("a"), Literal("b")), Literal("c")) - assert True == expression.evaluate(["a", "c"]) - assert False == expression.evaluate(["c", "other"]) - assert False == expression.evaluate([]) -""" - -from __future__ import absolute_import -from fnmatch import fnmatchcase -import glob -from .model import Expression - - -# ----------------------------------------------------------------------------- -# TAG-EXPRESSION MODEL CLASSES: -# ----------------------------------------------------------------------------- -class Matcher(Expression): - """Matches one or more similar tags by using wildcards. - Supports simple filename-matching / globbing wildcards only. - - .. code-block:: python - - # -- CASE: Tag starts-with "foo." - matcher1 = Matcher("foo.*") - assert True == matcher1.evaluate(["foo.bar"]) - - # -- CASE: Tag ends-with ".foo" - matcher2 = Matcher("*.foo") - assert True == matcher2.evaluate(["bar.foo"]) - assert True == matcher2.evaluate(["bar.baz_more.foo"]) - - # -- CASE: Tag contains "foo" - matcher3 = Matcher("*.foo.*") - assert True == matcher3.evaluate(["bar.foo.more"]) - assert True == matcher3.evaluate(["bar.foo"]) - - .. see:: :mod:`fnmatch` - """ - # pylint: disable=too-few-public-methods - def __init__(self, pattern): - super(Matcher, self).__init__() - self.pattern = pattern - - @property - def name(self): - return self.pattern - - def evaluate(self, values): - for value in values: - # -- REQUIRE: case-sensitive matching - if fnmatchcase(value, self.pattern): - return True - # -- OTHERWISE: no-match - return False - - def __str__(self): - return self.pattern - - def __repr__(self): - return "Matcher('%s')" % self.pattern - - @staticmethod - def contains_wildcards(text): - """Indicates if text contains supported wildcards.""" - # -- NOTE: :mod:`glob` wildcards are same as :mod:`fnmatch` - return glob.has_magic(text) diff --git a/behave/tag_expression/parser.py b/behave/tag_expression/parser.py index 6854b8b63..33776509e 100644 --- a/behave/tag_expression/parser.py +++ b/behave/tag_expression/parser.py @@ -16,11 +16,10 @@ from __future__ import absolute_import from cucumber_tag_expressions.parser import ( TagExpressionParser as _TagExpressionParser, - # PROVIDE: Similar interface like: cucumber_tag_expressions.parser + # -- PROVIDE: Similar interface like: cucumber_tag_expressions.parser TagExpressionError # noqa: F401 ) -from cucumber_tag_expressions.model import Literal -from .model_ext import Matcher +from .model import Literal, Matcher class TagExpressionParser(_TagExpressionParser): diff --git a/docs/behave.rst b/docs/behave.rst index cc80eafc2..30d65cfaf 100644 --- a/docs/behave.rst +++ b/docs/behave.rst @@ -574,10 +574,10 @@ Configuration Parameters .. describe:: tag_expression_protocol : TagExpressionProtocol (Enum) - Specify the tag-expression protocol to use (default: any). With "any", - tag-expressions v2 and v2 are supported (in auto-detect mode). - With "strict", only tag-expressions v2 is supported (better error - diagnostics). + Specify the tag-expression protocol to use (default: auto_detect). + With "v1", only tag-expressions v1 are supported. With "v2", only + tag-expressions v2 are supported. With "auto_detect", tag- + expressions v1 and v2 are auto-detected. .. index:: single: configuration param; quiet diff --git a/features/steps/behave_tag_expression_steps.py b/features/steps/behave_tag_expression_steps.py index e7e5f2a15..833f15fa0 100644 --- a/features/steps/behave_tag_expression_steps.py +++ b/features/steps/behave_tag_expression_steps.py @@ -42,6 +42,8 @@ def __init__(self, name, tags=None): # ----------------------------------------------------------------------------- def convert_tag_expression(text): return make_tag_expression(text.strip()) + + register_type(TagExpression=convert_tag_expression) diff --git a/issue.features/issue1170.feature b/issue.features/issue1170.feature index a5c63095a..bed8030fd 100644 --- a/issue.features/issue1170.feature +++ b/issue.features/issue1170.feature @@ -4,8 +4,8 @@ Feature: Issue #1170 -- Tag Expression Auto Detection Problem . DESCRIPTION OF SYNDROME (OBSERVED BEHAVIOR): . TagExpression v2 wildcard matching does not work if one dashed-tag is used. . - . WORKAROUND: - . * Use TagExpression auto-detection in strict mode + . WORKAROUND-UNTIL-FIXED: + . * Use TagExpression auto-detection in v2 mode (or strict mode) Background: Setup @@ -31,31 +31,62 @@ Feature: Issue #1170 -- Tag Expression Auto Detection Problem Then some step passes """ - @xfailed - Scenario: Use one TagExpression Term with Wildcard -- BROKEN + + Scenario: Use one TagExpression Term with Wildcard in default mode (AUTO-DETECT) When I run `behave --tags="file-test*" features/syndrome_1170.feature` Then it should pass with: """ - 0 features passed, 0 failed, 1 skipped - 0 scenarios passed, 0 failed, 3 skipped + 2 scenarios passed, 0 failed, 1 skipped + """ + And note that "TagExpression auto-detection should to select TagExpressionV2" + And note that "first two scenarios should have been executed" + But note that "last scenario should be skipped" + + + Scenario: Use one TagExpression Term with Wildcard in AUTO Mode (explicit: auto-detect) + Given a file named "behave.ini" with: + """ + # -- ENSURE: Use TagExpression v1 or v2 (with auto-detection) + [behave] + tag_expression_protocol = auto_detect + """ + When I run `behave --tags="file-test*" features/syndrome_1170.feature` + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 1 skipped """ - And note that "TagExpression auto-detection seems to select TagExpressionV1" - And note that "no scenarios is selected/executed" - But note that "first two scenarios should have been executed" + And note that "TagExpression auto-detection should to select TagExpressionV2" + And note that "first two scenarios should have been executed" + But note that "last scenario should be skipped" + + + Scenario: Use one TagExpression Term with Wildcard in V2 Mode + Given a file named "behave.ini" with: + """ + # -- ENSURE: Only TagExpressions v2 is used + [behave] + tag_expression_protocol = v2 + """ + When I run `behave --tags="file-test*" features/syndrome_1170.feature` + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 1 skipped + """ + And note that "TagExpressions v2 are used" + And note that "first two scenarios are selected/executed" - Scenario: Use one TagExpression Term with Wildcard -- Strict Mode + Scenario: Use one TagExpression Term with Wildcard in STRICT Mode Given a file named "behave.ini" with: """ - # -- ENSURE: Only TagExpression v2 is used (with auto-detection in strict mode) + # -- ENSURE: Only TagExpressions v2 is used with strict mode [behave] tag_expression_protocol = strict """ When I run `behave --tags="file-test*" features/syndrome_1170.feature` Then it should pass with: """ - 1 feature passed, 0 failed, 0 skipped 2 scenarios passed, 0 failed, 1 skipped """ - And note that "TagExpression auto-detection seems to select TagExpressionV2" + And note that "TagExpressions v2 are used" And note that "first two scenarios are selected/executed" diff --git a/tasks/py.requirements.txt b/tasks/py.requirements.txt index 263331d34..fdabed241 100644 --- a/tasks/py.requirements.txt +++ b/tasks/py.requirements.txt @@ -14,8 +14,8 @@ pycmd six >= 1.15.0 # -- HINT, was RENAMED: path.py => path (for python3) -path >= 13.1.0; python_version >= '3.5' -path.py >= 11.5.0; python_version < '3.5' +path.py >=11.5.0,<13.0; python_version < '3.5' +path >= 13.1.0; python_version >= '3.5' # -- PYTHON2 BACKPORTS: pathlib; python_version <= '3.4' diff --git a/tests/issues/test_issue1054.py b/tests/issues/test_issue1054.py index adf572706..5dd943969 100644 --- a/tests/issues/test_issue1054.py +++ b/tests/issues/test_issue1054.py @@ -5,7 +5,7 @@ from __future__ import absolute_import, print_function from behave.__main__ import run_behave from behave.configuration import Configuration -from behave.tag_expression import make_tag_expression +from behave.tag_expression.builder import make_tag_expression import pytest from assertpy import assert_that diff --git a/tests/unit/tag_expression/test_basics.py b/tests/unit/tag_expression/test_basics.py deleted file mode 100644 index 6ebc4a896..000000000 --- a/tests/unit/tag_expression/test_basics.py +++ /dev/null @@ -1,50 +0,0 @@ -# -*- coding: UTF-8 -*- - -from behave.tag_expression import ( - make_tag_expression, select_tag_expression_parser4any, - parse_tag_expression_v1, parse_tag_expression_v2 -) -from behave.tag_expression.v1 import TagExpression as TagExpressionV1 -from behave.tag_expression.model_ext import Expression as TagExpressionV2 -import pytest - -# ----------------------------------------------------------------------------- -# TEST SUITE FOR: make_tag_expression() -# ----------------------------------------------------------------------------- -def test_make_tag_expression__with_v1(): - pass - -def test_make_tag_expression__with_v2(): - pass - - -# ----------------------------------------------------------------------------- -# TEST SUITE FOR: select_tag_expression_parser4any() -# ----------------------------------------------------------------------------- -@pytest.mark.parametrize("text", [ - "@foo @bar", - "foo bar", - "-foo", - "~foo", - "-@foo", - "~@foo", - "@foo,@bar", - "-@xfail -@not_implemented", -]) -def test_select_tag_expression_parser4any__with_v1(text): - parser = select_tag_expression_parser4any(text) - assert parser is parse_tag_expression_v1, "tag_expression: %s" % text - - -@pytest.mark.parametrize("text", [ - "@foo", - "foo", - "not foo", - "foo and bar", - "@foo or @bar", - "(@foo and @bar) or @baz", - "not @xfail or not @not_implemented" -]) -def test_select_tag_expression_parser4any__with_v2(text): - parser = select_tag_expression_parser4any(text) - assert parser is parse_tag_expression_v2, "tag_expression: %s" % text diff --git a/tests/unit/tag_expression/test_builder.py b/tests/unit/tag_expression/test_builder.py new file mode 100644 index 000000000..06c39fa04 --- /dev/null +++ b/tests/unit/tag_expression/test_builder.py @@ -0,0 +1,181 @@ +""" +Test if TagExpression protocol/version is detected correctly. +""" + +from __future__ import absolute_import, print_function +import pytest +from behave.tag_expression.builder import TagExpressionProtocol, make_tag_expression +from behave.tag_expression.v1 import TagExpression as TagExpressionV1 +from behave.tag_expression.model import Expression as TagExpressionV2 +from behave.tag_expression.parser import TagExpressionError as TagExpressionError + + +# ----------------------------------------------------------------------------- +# TEST DATA +# ----------------------------------------------------------------------------- +# -- USED FOR: TagExpressionProtocol.AUTO_DETECT +TAG_EXPRESSION_V1_GOOD_EXAMPLES_FOR_AUTO_DETECT = [ + "@a,@b", + "@a @b", + "-@a", + "~@a", +] +TAG_EXPRESSION_V2_GOOD_EXAMPLES_FOR_AUTO_DETECT = [ + "@a", + "@a.*", + "@dashed-tag", + "@a and @b", + "@a or @b", + "@a or (@b and @c)", + "not @a", +] +# -- CHECK-SOME: Mixtures of TagExpression v1 and v2 +TAG_EXPRESSION_V2_BAD_EXAMPLES_FOR_AUTO_DETECT = [ + "-@a and @b", + "@a and -@b", + "~@a or @b", + "@a or ~@b", + "@a and not -@b", +] + +# -- USED FOR: TagExpressionProtocol.V1 +TAG_EXPRESSION_V1_GOOD_EXAMPLES = [ + "@a", + "@one-and-more", +] + TAG_EXPRESSION_V1_GOOD_EXAMPLES_FOR_AUTO_DETECT + +# -- USED FOR: TagExpressionProtocol.V2 +TAG_EXPRESSION_V2_GOOD_EXAMPLES = TAG_EXPRESSION_V2_GOOD_EXAMPLES_FOR_AUTO_DETECT + + +# ----------------------------------------------------------------------------- +# TEST SUPPORT +# ----------------------------------------------------------------------------- +def assert_is_tag_expression_v1(tag_expression): + assert isinstance(tag_expression, TagExpressionV1), "%r" % tag_expression + + +def assert_is_tag_expression_v2(tag_expression): + assert isinstance(tag_expression, TagExpressionV2), "%r" % tag_expression + + +def assert_is_tag_expression_for_protocol(tag_expression, expected_tag_expression_protocol): + # -- STEP 1: Select assert-function + def assert_false(tag_expression): + assert False, "UNEXPECTED: %r (for: %s)" % \ + (expected_tag_expression_protocol, tag_expression) + + assert_func = assert_false + if expected_tag_expression_protocol is TagExpressionProtocol.V1: + assert_func = assert_is_tag_expression_v1 + elif expected_tag_expression_protocol is TagExpressionProtocol.V2: + assert_func = assert_is_tag_expression_v2 + + # -- STEP 2: Apply assert-function + assert_func(tag_expression) + + +# ----------------------------------------------------------------------------- +# TEST SUITE +# ----------------------------------------------------------------------------- +class TestTagExpressionProtocol(object): + """ + Test TagExpressionProtocol class. + """ + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V1_GOOD_EXAMPLES_FOR_AUTO_DETECT) + def test_parse_using_protocol_auto_detect_builds_v1(self, text): + this_protocol = TagExpressionProtocol.AUTO_DETECT + tag_expression = this_protocol.parse(text) + assert_is_tag_expression_v1(tag_expression) + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V2_GOOD_EXAMPLES_FOR_AUTO_DETECT) + def test_parse_using_protocol_auto_detect_builds_v2(self, text): + this_protocol = TagExpressionProtocol.AUTO_DETECT + tag_expression = this_protocol.parse(text) + assert_is_tag_expression_v2(tag_expression) + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V2_BAD_EXAMPLES_FOR_AUTO_DETECT) + def test_parse_using_protocol_auto_detect_raises_error_if_v1_and_v2_are_used(self, text): + this_protocol = TagExpressionProtocol.AUTO_DETECT + with pytest.raises(TagExpressionError) as e: + _tag_expression = this_protocol.parse(text) + + print("CAUGHT-EXCEPTION: %s" % e.value) + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V1_GOOD_EXAMPLES) + def test_parse_using_protocol_v1_builds_v1(self, text): + print("tag_expression: %s" % text) + this_protocol = TagExpressionProtocol.V1 + tag_expression = this_protocol.parse(text) + assert_is_tag_expression_v1(tag_expression) + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V2_GOOD_EXAMPLES) + def test_parse_using_protocol_v2_builds_v2(self, text): + print("tag_expression: %s" % text) + this_protocol = TagExpressionProtocol.V2 + tag_expression = this_protocol.parse(text) + assert_is_tag_expression_v2(tag_expression) + + +# ----------------------------------------------------------------------------- +# TEST SUITE FOR: make_tag_expression() +# ----------------------------------------------------------------------------- +class TestMakeTagExpression(object): + """Test :func:`make_tag_expression()`.""" + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V1_GOOD_EXAMPLES) + def test_with_protocol_v1(self, text): + tag_expression = make_tag_expression(text, TagExpressionProtocol.V1) + assert_is_tag_expression_v1(tag_expression) + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V2_GOOD_EXAMPLES) + def test_with_protocol_v2(self, text): + tag_expression = make_tag_expression(text, TagExpressionProtocol.V2) + assert_is_tag_expression_v2(tag_expression) + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V1_GOOD_EXAMPLES_FOR_AUTO_DETECT) + def test_with_protocol_auto_detect_for_v1(self, text): + tag_expression = make_tag_expression(text, TagExpressionProtocol.AUTO_DETECT) + assert_is_tag_expression_v1(tag_expression) + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V2_GOOD_EXAMPLES_FOR_AUTO_DETECT) + def test_with_protocol_auto_detect_for_v2(self, text): + tag_expression = make_tag_expression(text, TagExpressionProtocol.AUTO_DETECT) + assert_is_tag_expression_v2(tag_expression) + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V1_GOOD_EXAMPLES) + def test_with_default_protocol_v1(self, text): + TagExpressionProtocol.use(TagExpressionProtocol.V1) + tag_expression = make_tag_expression(text) + assert_is_tag_expression_v1(tag_expression) + assert TagExpressionProtocol.current() == TagExpressionProtocol.V1 + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V2_GOOD_EXAMPLES) + def test_with_default_protocol_v2(self, text): + TagExpressionProtocol.use(TagExpressionProtocol.V2) + tag_expression = make_tag_expression(text) + assert_is_tag_expression_v2(tag_expression) + assert TagExpressionProtocol.current() == TagExpressionProtocol.V2 + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V1_GOOD_EXAMPLES_FOR_AUTO_DETECT) + def test_with_default_protocol_auto_and_tag_expression_v1(self, text): + TagExpressionProtocol.use(TagExpressionProtocol.AUTO_DETECT) + tag_expression = make_tag_expression(text) + assert_is_tag_expression_for_protocol(tag_expression, TagExpressionProtocol.V1) + assert TagExpressionProtocol.current() == TagExpressionProtocol.AUTO_DETECT + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V2_GOOD_EXAMPLES_FOR_AUTO_DETECT) + def test_with_default_protocol_auto_and_tag_expression_v2(self, text): + TagExpressionProtocol.use(TagExpressionProtocol.AUTO_DETECT) + tag_expression = make_tag_expression(text) + assert_is_tag_expression_for_protocol(tag_expression, TagExpressionProtocol.V2) + assert TagExpressionProtocol.current() == TagExpressionProtocol.AUTO_DETECT + + @pytest.mark.parametrize("text", TAG_EXPRESSION_V2_BAD_EXAMPLES_FOR_AUTO_DETECT) + def test_with_default_protocol_auto_and_bad_tag_expression_with_v1_and_v2(self, text): + TagExpressionProtocol.use(TagExpressionProtocol.AUTO_DETECT) + with pytest.raises(TagExpressionError) as e: + _tag_expression = make_tag_expression(text) + + print("CAUGHT-EXCEPTION: %s" % e.value) diff --git a/tests/unit/tag_expression/test_model_ext.py b/tests/unit/tag_expression/test_model_ext.py index 71193d483..50ff68bbd 100644 --- a/tests/unit/tag_expression/test_model_ext.py +++ b/tests/unit/tag_expression/test_model_ext.py @@ -2,10 +2,7 @@ # pylint: disable=bad-whitespace from __future__ import absolute_import -from behave.tag_expression.model import Expression, Literal -from behave.tag_expression.model_ext import Matcher -# NOT-NEEDED: from cucumber_tag_expressions.model import Literal, Matcher -# NOT-NEEDED: from cucumber_tag_expressions.model import And, Or, Not, True_ +from behave.tag_expression.model import Literal, Matcher, Never import pytest @@ -54,3 +51,13 @@ def test_evaluate_with_endswith_pattern(self, expected, tag, case): def test_evaluate_with_contains_pattern(self, expected, tag, case): expression = Matcher("*.foo.*") assert expression.evaluate([tag]) == expected + +class TestNever(object): + @pytest.mark.parametrize("tags, case", [ + ([], "no_tags"), + (["foo", "bar"], "some tags"), + (["foo", "other"], "some tags2"), + ]) + def test_evaluate_returns_false(self, tags, case): + expression = Never() + assert expression.evaluate(tags) is False diff --git a/tests/unit/tag_expression/test_parser.py b/tests/unit/tag_expression/test_parser.py index dfe14f4d2..ee626da2d 100644 --- a/tests/unit/tag_expression/test_parser.py +++ b/tests/unit/tag_expression/test_parser.py @@ -1,13 +1,14 @@ # -*- coding: UTF-8 -*- # pylint: disable=bad-whitespace """ -Unit tests for tag-expression parser. +Unit tests for tag-expression parser for TagExpression v2. """ from __future__ import absolute_import, print_function from behave.tag_expression.parser import TagExpressionParser, TagExpressionError -from cucumber_tag_expressions.parser import \ +from cucumber_tag_expressions.parser import ( Token, Associative, TokenType +) import pytest diff --git a/tests/unit/tag_expression/test_tag_expression_v1_part1.py b/tests/unit/tag_expression/test_tag_expression_v1_part1.py index 56fb85d5b..6b36e3674 100644 --- a/tests/unit/tag_expression/test_tag_expression_v1_part1.py +++ b/tests/unit/tag_expression/test_tag_expression_v1_part1.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import -from behave.tag_expression import TagExpression +from behave.tag_expression.v1 import TagExpression as TagExpressionV1 import pytest import unittest @@ -11,7 +11,7 @@ # ---------------------------------------------------------------------------- class TestTagExpressionNoTags(unittest.TestCase): def setUp(self): - self.e = TagExpression([]) + self.e = TagExpressionV1([]) def test_should_match_empty_tags(self): assert self.e.check([]) @@ -22,7 +22,7 @@ def test_should_match_foo(self): class TestTagExpressionFoo(unittest.TestCase): def setUp(self): - self.e = TagExpression(['foo']) + self.e = TagExpressionV1(['foo']) def test_should_not_match_no_tags(self): assert not self.e.check([]) @@ -36,7 +36,7 @@ def test_should_not_match_bar(self): class TestTagExpressionNotFoo(unittest.TestCase): def setUp(self): - self.e = TagExpression(['-foo']) + self.e = TagExpressionV1(['-foo']) def test_should_match_no_tags(self): assert self.e.check([]) @@ -55,7 +55,7 @@ class TestTagExpressionFooAndBar(unittest.TestCase): # -- LOGIC: @foo and @bar def setUp(self): - self.e = TagExpression(['foo', 'bar']) + self.e = TagExpressionV1(['foo', 'bar']) def test_should_not_match_no_tags(self): assert not self.e.check([]) @@ -108,7 +108,7 @@ class TestTagExpressionFooAndNotBar(unittest.TestCase): # -- LOGIC: @foo and not @bar def setUp(self): - self.e = TagExpression(['foo', '-bar']) + self.e = TagExpressionV1(['foo', '-bar']) def test_should_not_match_no_tags(self): assert not self.e.check([]) @@ -162,14 +162,14 @@ class TestTagExpressionNotBarAndFoo(TestTagExpressionFooAndNotBar): # LOGIC: not @bar and @foo == @foo and not @bar def setUp(self): - self.e = TagExpression(['-bar', 'foo']) + self.e = TagExpressionV1(['-bar', 'foo']) class TestTagExpressionNotFooAndNotBar(unittest.TestCase): # -- LOGIC: not @bar and not @foo def setUp(self): - self.e = TagExpression(['-foo', '-bar']) + self.e = TagExpressionV1(['-foo', '-bar']) def test_should_match_no_tags(self): assert self.e.check([]) @@ -223,7 +223,7 @@ class TestTagExpressionNotBarAndNotFoo(TestTagExpressionNotFooAndNotBar): # LOGIC: not @bar and not @foo == not @foo and not @bar def setUp(self): - self.e = TagExpression(['-bar', '-foo']) + self.e = TagExpressionV1(['-bar', '-foo']) # ---------------------------------------------------------------------------- @@ -231,7 +231,7 @@ def setUp(self): # ---------------------------------------------------------------------------- class TestTagExpressionFooOrBar(unittest.TestCase): def setUp(self): - self.e = TagExpression(['foo,bar']) + self.e = TagExpressionV1(['foo,bar']) def test_should_not_match_no_tags(self): assert not self.e.check([]) @@ -284,12 +284,12 @@ class TestTagExpressionBarOrFoo(TestTagExpressionFooOrBar): # -- REUSE: Test suite due to symmetry in reversed expression # LOGIC: @bar or @foo == @foo or @bar def setUp(self): - self.e = TagExpression(['bar,foo']) + self.e = TagExpressionV1(['bar,foo']) class TestTagExpressionFooOrNotBar(unittest.TestCase): def setUp(self): - self.e = TagExpression(['foo,-bar']) + self.e = TagExpressionV1(['foo,-bar']) def test_should_match_no_tags(self): assert self.e.check([]) @@ -342,12 +342,12 @@ class TestTagExpressionNotBarOrFoo(TestTagExpressionFooOrNotBar): # -- REUSE: Test suite due to symmetry in reversed expression # LOGIC: not @bar or @foo == @foo or not @bar def setUp(self): - self.e = TagExpression(['-bar,foo']) + self.e = TagExpressionV1(['-bar,foo']) class TestTagExpressionNotFooOrNotBar(unittest.TestCase): def setUp(self): - self.e = TagExpression(['-foo,-bar']) + self.e = TagExpressionV1(['-foo,-bar']) def test_should_match_no_tags(self): assert self.e.check([]) @@ -400,7 +400,7 @@ class TestTagExpressionNotBarOrNotFoo(TestTagExpressionNotFooOrNotBar): # -- REUSE: Test suite due to symmetry in reversed expression # LOGIC: not @bar or @foo == @foo or not @bar def setUp(self): - self.e = TagExpression(['-bar,-foo']) + self.e = TagExpressionV1(['-bar,-foo']) # ---------------------------------------------------------------------------- @@ -408,7 +408,7 @@ def setUp(self): # ---------------------------------------------------------------------------- class TestTagExpressionFooOrBarAndNotZap(unittest.TestCase): def setUp(self): - self.e = TagExpression(['foo,bar', '-zap']) + self.e = TagExpressionV1(['foo,bar', '-zap']) def test_should_match_foo(self): assert self.e.check(['foo']) @@ -473,7 +473,7 @@ def test_should_not_match_zap_baz_other(self): # ---------------------------------------------------------------------------- class TestTagExpressionFoo3OrNotBar4AndZap5(unittest.TestCase): def setUp(self): - self.e = TagExpression(['foo:3,-bar', 'zap:5']) + self.e = TagExpressionV1(['foo:3,-bar', 'zap:5']) def test_should_count_tags_for_positive_tags(self): assert self.e.limits == {'foo': 3, 'zap': 5} @@ -484,7 +484,7 @@ def test_should_match_foo_zap(self): class TestTagExpressionParsing(unittest.TestCase): def setUp(self): - self.e = TagExpression([' foo:3 , -bar ', ' zap:5 ']) + self.e = TagExpressionV1([' foo:3 , -bar ', ' zap:5 ']) def test_should_have_limits(self): assert self.e.limits == {'zap': 5, 'foo': 3} @@ -492,18 +492,18 @@ def test_should_have_limits(self): class TestTagExpressionTagLimits(unittest.TestCase): def test_should_be_counted_for_negative_tags(self): - e = TagExpression(['-todo:3']) + e = TagExpressionV1(['-todo:3']) assert e.limits == {'todo': 3} def test_should_be_counted_for_positive_tags(self): - e = TagExpression(['todo:3']) + e = TagExpressionV1(['todo:3']) assert e.limits == {'todo': 3} def test_should_raise_an_error_for_inconsistent_limits(self): with pytest.raises(Exception): - _ = TagExpression(['todo:3', '-todo:4']) + _ = TagExpressionV1(['todo:3', '-todo:4']) def test_should_allow_duplicate_consistent_limits(self): - e = TagExpression(['todo:3', '-todo:3']) + e = TagExpressionV1(['todo:3', '-todo:3']) assert e.limits == {'todo': 3} diff --git a/tests/unit/tag_expression/test_tag_expression_v1_part2.py b/tests/unit/tag_expression/test_tag_expression_v1_part2.py index cf619da95..9f58e713b 100644 --- a/tests/unit/tag_expression/test_tag_expression_v1_part2.py +++ b/tests/unit/tag_expression/test_tag_expression_v1_part2.py @@ -9,7 +9,7 @@ import itertools from six.moves import range import pytest -from behave.tag_expression import TagExpression +from behave.tag_expression.v1 import TagExpression as TagExpressionV1 has_combinations = hasattr(itertools, "combinations") @@ -96,7 +96,7 @@ class TestTagExpressionWith1Term(TagExpressionTestCase): tag_combinations = all_combinations(tags) def test_matches__foo(self): - tag_expression = TagExpression(["@foo"]) + tag_expression = TagExpressionV1(["@foo"]) expected = [ # -- WITH 0 tags: None "@foo", @@ -106,7 +106,7 @@ def test_matches__foo(self): self.tag_combinations, expected) def test_matches__not_foo(self): - tag_expression = TagExpression(["-@foo"]) + tag_expression = TagExpressionV1(["-@foo"]) expected = [ NO_TAGS, "@other", @@ -127,7 +127,7 @@ class TestTagExpressionWith2Terms(TagExpressionTestCase): # -- LOGICAL-OR CASES: def test_matches__foo_or_bar(self): - tag_expression = TagExpression(["@foo,@bar"]) + tag_expression = TagExpressionV1(["@foo,@bar"]) expected = [ # -- WITH 0 tags: None "@foo", "@bar", @@ -138,7 +138,7 @@ def test_matches__foo_or_bar(self): self.tag_combinations, expected) def test_matches__foo_or_not_bar(self): - tag_expression = TagExpression(["@foo,-@bar"]) + tag_expression = TagExpressionV1(["@foo,-@bar"]) expected = [ NO_TAGS, "@foo", "@other", @@ -149,7 +149,7 @@ def test_matches__foo_or_not_bar(self): self.tag_combinations, expected) def test_matches__not_foo_or_not_bar(self): - tag_expression = TagExpression(["-@foo,-@bar"]) + tag_expression = TagExpressionV1(["-@foo,-@bar"]) expected = [ NO_TAGS, "@foo", "@bar", "@other", @@ -160,7 +160,7 @@ def test_matches__not_foo_or_not_bar(self): # -- LOGICAL-AND CASES: def test_matches__foo_and_bar(self): - tag_expression = TagExpression(["@foo", "@bar"]) + tag_expression = TagExpressionV1(["@foo", "@bar"]) expected = [ # -- WITH 0 tags: None # -- WITH 1 tag: None @@ -171,7 +171,7 @@ def test_matches__foo_and_bar(self): self.tag_combinations, expected) def test_matches__foo_and_not_bar(self): - tag_expression = TagExpression(["@foo", "-@bar"]) + tag_expression = TagExpressionV1(["@foo", "-@bar"]) expected = [ # -- WITH 0 tags: None # -- WITH 1 tag: None @@ -183,7 +183,7 @@ def test_matches__foo_and_not_bar(self): self.tag_combinations, expected) def test_matches__not_foo_and_not_bar(self): - tag_expression = TagExpression(["-@foo", "-@bar"]) + tag_expression = TagExpressionV1(["-@foo", "-@bar"]) expected = [ NO_TAGS, "@other", @@ -211,7 +211,7 @@ class TestTagExpressionWith3Terms(TagExpressionTestCase): # -- LOGICAL-OR CASES: def test_matches__foo_or_bar_or_zap(self): - tag_expression = TagExpression(["@foo,@bar,@zap"]) + tag_expression = TagExpressionV1(["@foo,@bar,@zap"]) matched = [ # -- WITH 0 tags: None # -- WITH 1 tag: @@ -242,7 +242,7 @@ def test_matches__foo_or_bar_or_zap(self): self.tag_combinations, mismatched) def test_matches__foo_or_not_bar_or_zap(self): - tag_expression = TagExpression(["@foo,-@bar,@zap"]) + tag_expression = TagExpressionV1(["@foo,-@bar,@zap"]) matched = [ # -- WITH 0 tags: NO_TAGS, @@ -275,7 +275,7 @@ def test_matches__foo_or_not_bar_or_zap(self): def test_matches__foo_or_not_bar_or_not_zap(self): - tag_expression = TagExpression(["foo,-@bar,-@zap"]) + tag_expression = TagExpressionV1(["foo,-@bar,-@zap"]) matched = [ # -- WITH 0 tags: NO_TAGS, @@ -306,7 +306,7 @@ def test_matches__foo_or_not_bar_or_not_zap(self): self.tag_combinations, mismatched) def test_matches__not_foo_or_not_bar_or_not_zap(self): - tag_expression = TagExpression(["-@foo,-@bar,-@zap"]) + tag_expression = TagExpressionV1(["-@foo,-@bar,-@zap"]) matched = [ # -- WITH 0 tags: NO_TAGS, @@ -337,7 +337,7 @@ def test_matches__not_foo_or_not_bar_or_not_zap(self): self.tag_combinations, mismatched) def test_matches__foo_and_bar_or_zap(self): - tag_expression = TagExpression(["@foo", "@bar,@zap"]) + tag_expression = TagExpressionV1(["@foo", "@bar,@zap"]) matched = [ # -- WITH 0 tags: # -- WITH 1 tag: @@ -368,7 +368,7 @@ def test_matches__foo_and_bar_or_zap(self): self.tag_combinations, mismatched) def test_matches__foo_and_bar_or_not_zap(self): - tag_expression = TagExpression(["@foo", "@bar,-@zap"]) + tag_expression = TagExpressionV1(["@foo", "@bar,-@zap"]) matched = [ # -- WITH 0 tags: # -- WITH 1 tag: @@ -401,7 +401,7 @@ def test_matches__foo_and_bar_or_not_zap(self): self.tag_combinations, mismatched) def test_matches__foo_and_bar_and_zap(self): - tag_expression = TagExpression(["@foo", "@bar", "@zap"]) + tag_expression = TagExpressionV1(["@foo", "@bar", "@zap"]) matched = [ # -- WITH 0 tags: # -- WITH 1 tag: @@ -432,7 +432,7 @@ def test_matches__foo_and_bar_and_zap(self): self.tag_combinations, mismatched) def test_matches__not_foo_and_not_bar_and_not_zap(self): - tag_expression = TagExpression(["-@foo", "-@bar", "-@zap"]) + tag_expression = TagExpressionV1(["-@foo", "-@bar", "-@zap"]) matched = [ # -- WITH 0 tags: NO_TAGS, diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index 50bce2f58..f55c1ac5b 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -297,7 +297,7 @@ def make_config_file_with_tag_expression_protocol(value, tmp_path): @classmethod def check_tag_expression_protocol_with_valid_value(cls, value, tmp_path): - TagExpressionProtocol.use(TagExpressionProtocol.default()) + TagExpressionProtocol.use(TagExpressionProtocol.DEFAULT) cls.make_config_file_with_tag_expression_protocol(value, tmp_path) with use_current_directory(tmp_path): config = Configuration() @@ -312,17 +312,25 @@ def check_tag_expression_protocol_with_valid_value(cls, value, tmp_path): def test_tag_expression_protocol(self, value, tmp_path): self.check_tag_expression_protocol_with_valid_value(value, tmp_path) - @pytest.mark.parametrize("value", ["Any", "ANY", "Strict", "STRICT"]) + @pytest.mark.parametrize("value", [ + "v1", "V1", + "v2", "V2", + "auto_detect", "AUTO_DETECT", "Auto_detect", + # -- DEPRECATING: + "strict", "STRICT", "Strict", + ]) def test_tag_expression_protocol__is_not_case_sensitive(self, value, tmp_path): self.check_tag_expression_protocol_with_valid_value(value, tmp_path) @pytest.mark.parametrize("value", [ - "__UNKNOWN__", "v1", "v2", + "__UNKNOWN__", # -- SIMILAR: to valid values - ".any", "any.", "_strict", "strict_" + "v1_", "_v2", + ".auto", "auto_detect.", + "_strict", "strict_" ]) def test_tag_expression_protocol__with_invalid_value_raises_error(self, value, tmp_path): - default_value = TagExpressionProtocol.default() + default_value = TagExpressionProtocol.DEFAULT TagExpressionProtocol.use(default_value) self.make_config_file_with_tag_expression_protocol(value, tmp_path) with use_current_directory(tmp_path): @@ -332,7 +340,8 @@ def test_tag_expression_protocol__with_invalid_value_raises_error(self, value, t print("USE: config.tag_expression_protocol={0}".format( config.tag_expression_protocol)) + choices = ", ".join(TagExpressionProtocol.choices()) + expected = "{value} (expected: {choices})".format(value=value, choices=choices) assert TagExpressionProtocol.current() is default_value - expected = "{value} (expected: any, strict)".format(value=value) assert exc_info.type is ValueError assert expected in str(exc_info.value) diff --git a/tox.ini b/tox.ini index c4cdd0ff1..eb838df8d 100644 --- a/tox.ini +++ b/tox.ini @@ -7,7 +7,6 @@ # # USAGE: # tox -e py39 #< Run tests with python3.9 -# tox -e py27 #< Run tests with python2.7 # # SEE ALSO: # * https://tox.wiki/en/latest/config.html @@ -16,7 +15,7 @@ [tox] minversion = 2.3 -envlist = py311, py27, py310, py39, py38, pypy3, pypy, docs +envlist = py312, py311, py27, py310, py39, pypy3, pypy, docs skip_missing_interpreters = true From 3aa4c0de8fb8ba4f4ebb2af8c14d373c62365673 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 12 May 2024 18:56:59 +0200 Subject: [PATCH 204/240] CLEANUP: py.requirements * Add more python-version constraints NEEDED-FOR: python 2.7 * Cleanup duplicates of path/path.py requirements --- py.requirements/behave_extensions.txt | 1 - py.requirements/develop.txt | 4 ---- py.requirements/docs.txt | 10 +++++----- py.requirements/jsonschema.txt | 2 +- py.requirements/pylinters.txt | 2 +- 5 files changed, 7 insertions(+), 12 deletions(-) diff --git a/py.requirements/behave_extensions.txt b/py.requirements/behave_extensions.txt index f80938598..ab834a630 100644 --- a/py.requirements/behave_extensions.txt +++ b/py.requirements/behave_extensions.txt @@ -1,4 +1,3 @@ - # ============================================================================ # PYTHON PACKAGE REQUIREMENTS: behave extensions # ============================================================================ diff --git a/py.requirements/develop.txt b/py.requirements/develop.txt index 22d49386d..c1ce9533c 100644 --- a/py.requirements/develop.txt +++ b/py.requirements/develop.txt @@ -5,10 +5,6 @@ # -- BUILD-SYSTEM: invoke -r invoke.txt -# -- HINT: path.py => path (python-install-package was renamed for python3) -path.py >= 11.5.0; python_version < '3.5' -path >= 13.1.0; python_version >= '3.5' - # -- CONFIGURATION MANAGEMENT (helpers): # FORMER: bumpversion >= 0.4.0 bump2version >= 0.5.6 diff --git a/py.requirements/docs.txt b/py.requirements/docs.txt index 4b53b407a..31354cc17 100644 --- a/py.requirements/docs.txt +++ b/py.requirements/docs.txt @@ -19,8 +19,8 @@ sphinx-intl >= 0.9.11 # -- CONSTRAINTS UNTIL: sphinx > 5.0 can be used # PROBLEM: sphinxcontrib-applehelp v1.0.8 requires sphinx > 5.0 # SEE: https://stackoverflow.com/questions/77848565/sphinxcontrib-applehelp-breaking-sphinx-builds-with-sphinx-version-less-than-5-0 -sphinxcontrib-applehelp==1.0.4 -sphinxcontrib-devhelp==1.0.2 -sphinxcontrib-htmlhelp==2.0.1 -sphinxcontrib-qthelp==1.0.3 -sphinxcontrib-serializinghtml==1.1.5 +sphinxcontrib-applehelp==1.0.4; python_version >= '3.7' +sphinxcontrib-devhelp==1.0.2; python_version >= '3.7' +sphinxcontrib-htmlhelp==2.0.1; python_version >= '3.7' +sphinxcontrib-qthelp==1.0.3; python_version >= '3.7' +sphinxcontrib-serializinghtml==1.1.5; python_version >= '3.7' diff --git a/py.requirements/jsonschema.txt b/py.requirements/jsonschema.txt index db45dafcc..d9505cd16 100644 --- a/py.requirements/jsonschema.txt +++ b/py.requirements/jsonschema.txt @@ -6,4 +6,4 @@ # DEPRECATING: jsonschema # USE INSTEAD: check-jsonschema jsonschema >= 1.3.0 -check-jsonschema +check-jsonschema; python_version >= '3.7' diff --git a/py.requirements/pylinters.txt b/py.requirements/pylinters.txt index 0c836d79d..fcdebb195 100644 --- a/py.requirements/pylinters.txt +++ b/py.requirements/pylinters.txt @@ -5,4 +5,4 @@ # -- STATIC CODE ANALYSIS: pylint -ruff >= 0.0.270 +ruff >= 0.0.270; python_version >= '3.7' From 651a41ca4fe124bdff19fad9d5bf7524cc5b57fe Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 12 May 2024 20:09:44 +0200 Subject: [PATCH 205/240] FIX docs: code-block with TOML warning * Support newer Sphinx versions (>= 7.3.7) * Tweak dependencies to silence Sphinx warnings for v8 deprecation READTHEDOCS: * Use os: ubuntu-lts-latest * Use python 3.12 --- .readthedocs.yaml | 4 ++-- docs/conf.py | 12 ++++++------ docs/install.rst | 3 +-- py.requirements/basic.txt | 8 +++----- py.requirements/docs.txt | 20 ++++++++++++++------ py.requirements/testing.txt | 7 ++----- pyproject.toml | 15 +++++++++------ setup.py | 19 +++++++++++++------ 8 files changed, 50 insertions(+), 38 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 570518df8..4b36928a3 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -8,9 +8,9 @@ version: 2 build: - os: ubuntu-20.04 + os: ubuntu-lts-latest tools: - python: "3.11" + python: "3.12" python: install: diff --git a/docs/conf.py b/docs/conf.py index cd89a0d03..90a58ebf1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -54,15 +54,15 @@ extlinks = { "behave": ("https://github.com/behave/behave", None), "behave.example": ("https://github.com/behave/behave.example", None), - "issue": ("https://github.com/behave/behave/issues/%s", "issue #"), - "pull": ("https://github.com/behave/behave/issues/%s", "PR #"), + "issue": ("https://github.com/behave/behave/issues/%s", "issue #%s"), + "pull": ("https://github.com/behave/behave/issues/%s", "PR #%s"), "github": ("https://github.com/%s", "github:/"), - "pypi": ("https://pypi.org/project/%s", ""), - "youtube": ("https://www.youtube.com/watch?v=%s", "youtube:video="), - "behave": ("https://github.com/behave/behave", None), + "pypi": ("https://pypi.org/project/%s", None), + "youtube": ("https://www.youtube.com/watch?v=%s", "youtube:video=%s"), + # -- CUCUMBER RELATED: "cucumber": ("https://github.com/cucumber/common/", None), - "cucumber.issue": ("https://github.com/cucumber/common/issues/%s", "cucumber issue #"), + "cucumber.issue": ("https://github.com/cucumber/common/issues/%s", "cucumber issue #%s"), } intersphinx_mapping = { diff --git a/docs/install.rst b/docs/install.rst index 7139fafdf..f587c98ef 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -102,11 +102,10 @@ EXAMPLE: [project] name = "my-project" - ... dependencies = [ "behave @ git+https://github.com/behave/behave.git@v1.2.7.dev5", # OR: "behave[develop] @ git+https://github.com/behave/behave.git@main", - ... ] + .. _behave: https://github.com/behave/behave diff --git a/py.requirements/basic.txt b/py.requirements/basic.txt index 86bf15383..08fded02e 100644 --- a/py.requirements/basic.txt +++ b/py.requirements/basic.txt @@ -19,8 +19,6 @@ contextlib2; python_version < '3.5' win_unicode_console >= 0.5; python_version < '3.6' colorama >= 0.3.7 -# -- DISABLED PYTHON 2.6 SUPPORT: -# REQUIRES: pip >= 6.0 -# argparse; python_version <= '2.6' -# ordereddict; python_version <= '2.6' -# importlib; python_version <= '2.6' +# -- SUPPORT: "pyproject.toml" (or: "behave.toml") +tomli>=1.1.0; python_version >= '3.0' and python_version < '3.11' +toml>=0.10.2; python_version < '3.0' # py27 support diff --git a/py.requirements/docs.txt b/py.requirements/docs.txt index 31354cc17..6d7e6a7df 100644 --- a/py.requirements/docs.txt +++ b/py.requirements/docs.txt @@ -6,7 +6,12 @@ # urllib3 v2.0+ only supports OpenSSL 1.1.1+, 'ssl' module is compiled with # v1.0.2, see: https://github.com/urllib3/urllib3/issues/2168 -sphinx >=1.6,<4.4 +# -- NEEDS: +-r basic.txt + +# -- DOCUMENTATION DEPENDENCIES: +sphinx >= 7.3.7; python_version >= '3.7' +sphinx >=1.6,<4.4; python_version < '3.7' sphinx-autobuild sphinx_bootstrap_theme >= 0.6.0 @@ -19,8 +24,11 @@ sphinx-intl >= 0.9.11 # -- CONSTRAINTS UNTIL: sphinx > 5.0 can be used # PROBLEM: sphinxcontrib-applehelp v1.0.8 requires sphinx > 5.0 # SEE: https://stackoverflow.com/questions/77848565/sphinxcontrib-applehelp-breaking-sphinx-builds-with-sphinx-version-less-than-5-0 -sphinxcontrib-applehelp==1.0.4; python_version >= '3.7' -sphinxcontrib-devhelp==1.0.2; python_version >= '3.7' -sphinxcontrib-htmlhelp==2.0.1; python_version >= '3.7' -sphinxcontrib-qthelp==1.0.3; python_version >= '3.7' -sphinxcontrib-serializinghtml==1.1.5; python_version >= '3.7' +# DISABLED: sphinxcontrib-applehelp==1.0.4; python_version >= '3.7' +# DISABLED: sphinxcontrib-devhelp==1.0.2; python_version >= '3.7' +# DISABLED: sphinxcontrib-htmlhelp==2.0.1; python_version >= '3.7' +# DISABLED: sphinxcontrib-qthelp==1.0.3; python_version >= '3.7' +# DISABLED: sphinxcontrib-serializinghtml==1.1.5; python_version >= '3.7' + +sphinxcontrib-applehelp >= 1.0.8; python_version >= '3.7' +sphinxcontrib-htmlhelp >= 2.0.5; python_version >= '3.7' diff --git a/py.requirements/testing.txt b/py.requirements/testing.txt index 6dfc32c7f..f1cc09cdd 100644 --- a/py.requirements/testing.txt +++ b/py.requirements/testing.txt @@ -2,6 +2,8 @@ # PYTHON PACKAGE REQUIREMENTS FOR: behave -- For testing only # ============================================================================ +-r basic.txt + # -- TESTING: Unit tests and behave self-tests. # PREPARED-FUTURE: behave4cmd0, behave4cmd pytest < 5.0; python_version < '3.0' # pytest >= 4.2 @@ -22,11 +24,6 @@ assertpy >= 1.1 path.py >=11.5.0,<13.0; python_version < '3.5' path >= 13.1.0; python_version >= '3.5' -# NOTE: toml extra for pyproject.toml-based config -# DISABLED: .[toml] -tomli >= 1.1.0; python_version >= '3.0' and python_version < '3.11' -toml >= 0.10.2; python_version < '3.0' - # -- PYTHON2 BACKPORTS: pathlib; python_version <= '3.4' diff --git a/pyproject.toml b/pyproject.toml index e472d14ea..bdae32b84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,16 +138,19 @@ develop = [ "ruff; python_version >= '3.7'", ] docs = [ - "Sphinx >=1.6,<4.4", + "sphinx >= 7.3.7; python_version >= '3.7'", + "sphinx >=1.6,<4.4; python_version < '3.7'", "sphinx_bootstrap_theme >= 0.6.0", # -- CONSTRAINTS UNTIL: sphinx > 5.0 is usable -- 2024-01 # PROBLEM: sphinxcontrib-applehelp v1.0.8 requires sphinx > 5.0 # SEE: https://stackoverflow.com/questions/77848565/sphinxcontrib-applehelp-breaking-sphinx-builds-with-sphinx-version-less-than-5-0 - "sphinxcontrib-applehelp==1.0.4", - "sphinxcontrib-devhelp==1.0.2", - "sphinxcontrib-htmlhelp==2.0.1", - "sphinxcontrib-qthelp==1.0.3", - "sphinxcontrib-serializinghtml==1.1.5", + "sphinxcontrib-applehelp >= 1.0.8; python_version >= '3.7'", + "sphinxcontrib-htmlhelp >= 2.0.5; python_version >= '3.7'", + # DISABLED: "sphinxcontrib-applehelp==1.0.4", + # DISABLED: "sphinxcontrib-devhelp==1.0.2", + # DISABLED: "sphinxcontrib-htmlhelp==2.0.1", + # DISABLED: "sphinxcontrib-qthelp==1.0.3", + # DISABLED: "sphinxcontrib-serializinghtml==1.1.5", ] formatters = [ "behave-html-formatter >= 0.9.10; python_version >= '3.6'", diff --git a/setup.py b/setup.py index ecf412b54..cf953cbd9 100644 --- a/setup.py +++ b/setup.py @@ -88,6 +88,10 @@ def find_packages_by_root_package(where): "contextlib2; python_version < '3.5'", # DISABLED: "contextlib2 >= 21.6.0; python_version < '3.5'", "colorama >= 0.3.7", + + # -- SUPPORT: "pyproject.toml" (or: "behave.toml") + "tomli>=1.1.0; python_version >= '3.0' and python_version < '3.11'", + "toml>=0.10.2; python_version < '3.0'", # py27 support ], tests_require=[ "pytest < 5.0; python_version < '3.0'", # USE: pytest >= 4.2 @@ -111,16 +115,19 @@ def find_packages_by_root_package(where): }, extras_require={ "docs": [ - "sphinx >= 1.6,<4.4", + "sphinx >= 7.3.7; python_version >= '3.7'", + "sphinx >=1.6,<4.4; python_version < '3.7'", "sphinx_bootstrap_theme >= 0.6", # -- CONSTRAINTS UNTIL: sphinx > 5.0 can be used -- 2024-01 # PROBLEM: sphinxcontrib-applehelp v1.0.8 requires sphinx > 5.0 # SEE: https://stackoverflow.com/questions/77848565/sphinxcontrib-applehelp-breaking-sphinx-builds-with-sphinx-version-less-than-5-0 - "sphinxcontrib-applehelp==1.0.4", - "sphinxcontrib-devhelp==1.0.2", - "sphinxcontrib-htmlhelp==2.0.1", - "sphinxcontrib-qthelp==1.0.3", - "sphinxcontrib-serializinghtml==1.1.5", + "sphinxcontrib-applehelp >= 1.0.8; python_version >= '3.7'", + "sphinxcontrib-htmlhelp >= 2.0.5; python_version >= '3.7'", + # DISABLED: "sphinxcontrib-applehelp==1.0.4", + # DISABLED: "sphinxcontrib-devhelp==1.0.2", + # DISABLED: "sphinxcontrib-htmlhelp==2.0.1", + # DISABLED: "sphinxcontrib-qthelp==1.0.3", + # DISABLED: "sphinxcontrib-serializinghtml==1.1.5", ], "develop": [ "build >= 0.5.1", From f465c531436bf00bdb7d147bece23fdcc6f982f3 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 12 May 2024 20:28:06 +0200 Subject: [PATCH 206/240] CLEANUP: .envrc* files for direnv support --- .envrc | 6 +++--- .envrc.use_venv | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.envrc b/.envrc index 8cca8e7aa..e2a9c9077 100644 --- a/.envrc +++ b/.envrc @@ -2,7 +2,7 @@ # PROJECT ENVIRONMENT SETUP: .envrc # =========================================================================== # SHELL: bash (or similiar) -# REQUIRES: direnv >= 2.26.0 -- NEEDED FOR: dotenv_if_exists +# REQUIRES: direnv >= 2.21.0 -- NEEDED FOR: path_add, venv support # USAGE: # # -- BETTER: Use direnv (requires: Setup in bash -- $HOME/.bashrc) # # BASH PROFILE NEEDS: eval "$(direnv hook bash)" @@ -16,9 +16,9 @@ # MAYBE: HERE="${PWD}" # -- USE OPTIONAL PARTS (if exist/enabled): -dotenv_if_exists .env +# REQUIRES: direnv >= 2.26.0 -- NEEDED FOR: dotenv_if_exists +# DISABLED: dotenv_if_exists .env source_env_if_exists .envrc.use_venv -source_env_if_exists .envrc.use_pep0582 # -- SETUP-PYTHON: Prepend ${HERE} to PYTHONPATH (as PRIMARY search path) # SIMILAR TO: export PYTHONPATH="${HERE}:${PYTHONPATH}" diff --git a/.envrc.use_venv b/.envrc.use_venv index e9834503d..f65b57d33 100644 --- a/.envrc.use_venv +++ b/.envrc.use_venv @@ -1,6 +1,7 @@ # =========================================================================== # PROJECT ENVIRONMENT SETUP: .envrc.use_venv # =========================================================================== +# REQUIRES: direnv >= 2.21.0 -- NEEDED FOR: venv support # DESCRIPTION: # Setup and use a Python virtual environment (venv). # On entering the directory: Creates and activates a venv for a python version. From 5d08f05fe53585e25bc8e3e090a2903f2a69b031 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 12 May 2024 22:29:40 +0200 Subject: [PATCH 207/240] TWEAK: Badges on README --- README.rst | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/README.rst b/README.rst index d1e182f70..36a9e38d6 100644 --- a/README.rst +++ b/README.rst @@ -2,30 +2,35 @@ behave ====== +.. |badge.latest_version| image:: https://img.shields.io/pypi/v/behave.svg + :target: https://pypi.python.org/pypi/behave + :alt: Latest Version + +.. |badge.license| image:: https://img.shields.io/pypi/l/behave.svg + :target: https://pypi.python.org/pypi/behave/ + :alt: License -.. image:: https://github.com/behave/behave/actions/workflows/tests.yml/badge.svg +.. |badge.CI_status| image:: https://github.com/behave/behave/actions/workflows/tests.yml/badge.svg :target: https://github.com/behave/behave/actions/workflows/tests.yml :alt: CI Build Status -.. image:: https://readthedocs.org/projects/behave/badge/?version=latest +.. |badge.docs_status| image:: https://readthedocs.org/projects/behave/badge/?version=latest :target: http://behave.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status -.. image:: https://img.shields.io/pypi/v/behave.svg - :target: https://pypi.python.org/pypi/behave - :alt: Latest Version +.. |badge.discussions| image:: https://img.shields.io/badge/chat-github_discussions-darkgreen + :target: https://github.com/behave/behave/discussions + :alt: Discussions at https://github.com/behave/behave/discussions -.. image:: https://img.shields.io/pypi/l/behave.svg - :target: https://pypi.python.org/pypi/behave/ - :alt: License - -.. image:: https://badges.gitter.im/join_chat.svg - :alt: Join the chat at https://gitter.im/behave/behave +.. |badge.gitter| image:: https://badges.gitter.im/join_chat.svg :target: https://app.gitter.im/#/room/#behave_behave:gitter.im + :alt: Chat at https://gitter.im/behave/behave .. |logo| image:: https://raw.github.com/behave/behave/master/docs/_static/behave_logo1.png +|badge.latest_version| |badge.license| |badge.CI_status| |badge.docs_status| |badge.discussions| |badge.gitter| + behave is behavior-driven development, Python style. |logo| From 221ce7dbca4cbf7f04f3e52af4ac360f0a8a3536 Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Mon, 13 May 2024 11:28:56 +0200 Subject: [PATCH 208/240] README: Upgrade URLs to https, fix broken link to PDF --- README.rst | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.rst b/README.rst index 36a9e38d6..8af80f037 100644 --- a/README.rst +++ b/README.rst @@ -101,10 +101,10 @@ we recommend the `tutorial`_ and then the `feature testing language`_ and `api`_ references. -.. _`Install *behave*.`: http://behave.readthedocs.io/en/stable/install.html -.. _`tutorial`: http://behave.readthedocs.io/en/stable/tutorial.html#features -.. _`feature testing language`: http://behave.readthedocs.io/en/stable/gherkin.html -.. _`api`: http://behave.readthedocs.io/en/stable/api.html +.. _`Install *behave*.`: https://behave.readthedocs.io/en/stable/install.html +.. _`tutorial`: https://behave.readthedocs.io/en/stable/tutorial.html#features +.. _`feature testing language`: https://behave.readthedocs.io/en/stable/gherkin.html +.. _`api`: https://behave.readthedocs.io/en/stable/api.html More Information @@ -115,10 +115,10 @@ More Information * `changelog`_ (latest changes) -.. _behave documentation: http://behave.readthedocs.io/ -.. _changelog: https://github.com/behave/behave/blob/master/CHANGES.rst +.. _behave documentation: https://behave.readthedocs.io/ +.. _changelog: https://github.com/behave/behave/blob/main/CHANGES.rst .. _behave.example: https://github.com/behave/behave.example -.. _`latest edition`: http://behave.readthedocs.io/en/latest/ -.. _`stable edition`: http://behave.readthedocs.io/en/stable/ -.. _PDF: https://media.readthedocs.org/pdf/behave/latest/behave.pdf +.. _`latest edition`: https://behave.readthedocs.io/en/latest/ +.. _`stable edition`: https://behave.readthedocs.io/en/stable/ +.. _PDF: https://behave.readthedocs.io/_/downloads/en/stable/pdf/ From cd1e1ccbe5972795f681110154e65b65581ca8f9 Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 14 May 2024 22:15:43 +0200 Subject: [PATCH 209/240] docs: Fix pypi-extlink * Shows now again the name of the pypi package * WAS: Showing the URL to the pypi package --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 90a58ebf1..f58519da1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -57,7 +57,7 @@ "issue": ("https://github.com/behave/behave/issues/%s", "issue #%s"), "pull": ("https://github.com/behave/behave/issues/%s", "PR #%s"), "github": ("https://github.com/%s", "github:/"), - "pypi": ("https://pypi.org/project/%s", None), + "pypi": ("https://pypi.org/project/%s", "%s"), "youtube": ("https://www.youtube.com/watch?v=%s", "youtube:video=%s"), # -- CUCUMBER RELATED: From d577320d5ada7dac07ab36c72844b832175e165c Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 15 May 2024 00:11:44 +0200 Subject: [PATCH 210/240] docs: Improve Tag-Expressions v2 description * Add Tag-Expressions v1 END-OF-LIFE support * Describe "{config.tags}" placeholder for --tags option on command-line * Descripe "tag_expression_protocol" parameter in config-file CLEANUP: * Use "https:" instead of "http:" URL prefix where possible * Gherkin v6 aliases: Use table (was: unnumbered-list) --- docs/_common_extlinks.rst | 10 ++-- docs/_content.tag_expressions_v2.rst | 82 +++++++++++++++++++++++++--- docs/new_and_noteworthy_v1.2.7.rst | 13 +++-- 3 files changed, 88 insertions(+), 17 deletions(-) diff --git a/docs/_common_extlinks.rst b/docs/_common_extlinks.rst index 26bb5bcb7..baea4f59b 100644 --- a/docs/_common_extlinks.rst +++ b/docs/_common_extlinks.rst @@ -9,15 +9,13 @@ .. _`C++ scope guard`: https://en.wikibooks.org/wiki/More_C++_Idioms/Scope_Guard .. _Cucumber: https://cucumber.io/ -.. _Lettuce: http://lettuce.it/ - -.. _Selenium: http://docs.seleniumhq.org/ +.. _Selenium: https://docs.seleniumhq.org/ .. _PyCharm: https://www.jetbrains.com/pycharm/ -.. _Eclipse: http://www.eclipse.org/ +.. _Eclipse: https://www.eclipse.org/ .. _VisualStudio: https://www.visualstudio.com/ .. _`PyCharm BDD`: https://blog.jetbrains.com/pycharm/2014/09/feature-spotlight-behavior-driven-development-in-pycharm/ -.. _`Cucumber-Eclipse`: http://cucumber.github.io/cucumber-eclipse/ +.. _`Cucumber-Eclipse`: https://cucumber.github.io/cucumber-eclipse/ -.. _ctags: http://ctags.sourceforge.net/ +.. _ctags: https://ctags.sourceforge.net/ diff --git a/docs/_content.tag_expressions_v2.rst b/docs/_content.tag_expressions_v2.rst index a6f6f10f6..ffa5e181d 100644 --- a/docs/_content.tag_expressions_v2.rst +++ b/docs/_content.tag_expressions_v2.rst @@ -1,23 +1,37 @@ Tag-Expressions v2 ------------------------------------------------------------------------------- -:pypi:`cucumber-tag-expressions` are now supported and supersedes the old-style -tag-expressions (which are deprecating). :pypi:`cucumber-tag-expressions` are much -more readable and flexible to select tags on command-line. +Tag-Expressions v2 are based on :pypi:`cucumber-tag-expressions` with some extensions: + +* Tag-Expressions v2 provide `boolean logic expression` + (with ``and``, ``or`` and ``not`` operators and parenthesis for grouping expressions) +* Tag-Expressions v2 are far more readable and composable than Tag-Expressions v1 +* Some boolean-logic-expressions where not possible with Tag-Expressions v1 +* Therefore, Tag-Expressions v2 supersedes the old-style tag-expressions. + +EXAMPLES: .. code-block:: sh # -- SIMPLE TAG-EXPRESSION EXAMPLES: + # EXAMPLE 1: Select features/scenarios that have the tags: @a and @b @a and @b - @a or @b + + # EXAMPLE 2: Select features/scenarios that have the tag: @a or @b + @a or @b + + # EXAMPLE 3: Select features/scenarios that do not have the tag: @a not @a # -- MORE TAG-EXPRESSION EXAMPLES: # HINT: Boolean expressions can be grouped with parenthesis. + # EXAMPLE 4: Select features/scenarios that have the tags: @a but not @b @a and not @b + + # EXAMPLE 5: Select features/scenarios that have the tags: (@a or @b) but not @c (@a or @b) and not @c -Example: +COMMAND-LINE EXAMPLE: .. code-block:: sh @@ -25,16 +39,36 @@ Example: # Select all features / scenarios with both "@foo" and "@bar" tags. $ behave --tags="@foo and @bar" features/ + # -- EXAMPLE: Use default_tags from config-file "behave.ini". + # Use placeholder "{config.tags}" to refer to this tag-expression. + # HERE: config.tags = "not (@xfail or @not_implemented)" + $ behave --tags="(@foo or @bar) and {config.tags}" --tags-help + ... + CURRENT TAG_EXPRESSION: ((foo or bar) and not (xfail or not_implemented)) + + # -- EXAMPLE: Uses Tag-Expression diagnostics with --tags-help option + $ behave --tags="(@foo and @bar) or @baz" --tags-help + $ behave --tags="(@foo and @bar) or @baz" --tags-help --verbose .. seealso:: * https://docs.cucumber.io/cucumber/api/#tag-expressions + * :pypi:`cucumber-tag-expressions` (Python package) Tag Matching with Tag-Expressions ------------------------------------------------------------------------------- -The new tag-expressions also support **partial string/tag matching** with wildcards. +Tag-Expressions v2 support **partial string/tag matching** with wildcards. +This supports tag-expressions: + +=================== ======================== +Tag Matching Idiom Tag-Expression Example +=================== ======================== +``tag-starts-with`` ``@foo.*`` or ``foo.*`` +``tag-ends-with`` ``@*.one`` or ``*.one`` +``tag-contains`` ``@*foo*`` or ``*foo*`` +=================== ======================== .. code-block:: gherkin @@ -69,9 +103,43 @@ that start with "@foo.": # -- HINT: Only Alice.1 and Alice.2 are matched (not: Alice.3). -.. hint:: +.. note:: * Filename matching wildcards are supported. See :mod:`fnmatch` (Unix style filename matching). * The tag matching functionality is an extension to :pypi:`cucumber-tag-expressions`. + + +Select the Tag-Expression Version to Use +------------------------------------------------------------------------------- + +The tag-expression version, that should be used by :pypi:`behave`, +can be specified in the :pypi:`behave` config-file. + +This allows a user to select: + +* Tag-Expressions v1 (if needed) +* Tag-Expressions v2 when it is feasible + +EXAMPLE: + +.. code-block:: ini + + # -- FILE: behave.ini + # SPECIFY WHICH TAG-EXPRESSION-PROTOCOL SHOULD BE USED: + # SUPPORTED VALUES: v1, v2, auto_detect + # CURRENT DEFAULT: auto_detect + [behave] + tag_expression_protocol = v1 # -- Use Tag-Expressions v1. + + +Tag-Expressions v1 +------------------------------------------------------------------------------- + +Tag-Expressions v1 are becoming deprecated (but are currently still supported). +Use **Tag-Expressions v2** instead. + +.. note:: + + Tag-Expressions v1 support will be dropped in ``behave v1.4.0``. diff --git a/docs/new_and_noteworthy_v1.2.7.rst b/docs/new_and_noteworthy_v1.2.7.rst index 08d3411d5..fc462bad8 100644 --- a/docs/new_and_noteworthy_v1.2.7.rst +++ b/docs/new_and_noteworthy_v1.2.7.rst @@ -36,11 +36,16 @@ A Rule (or: business rule) allows to group multiple Scenario(s)/Example(s):: Scenario* #< CARDINALITY: 0..N (many) ScenarioOutline* #< CARDINALITY: 0..N (many) -Gherkin v6 keyword aliases:: +Gherkin v6 keyword aliases: + +================== =================== ====================== +Concept Preferred Keyword Alias(es) +================== =================== ====================== +Scenario Example Scenario +Scenario Outline Scenario Outline Scenario Template +Examples Examples Scenarios +================== =================== ====================== - | Concept | Preferred Keyword | Alias(es) | - | Scenario | Example | Scenario | - | Scenario Outline | Scenario Outline | Scenario Template | Example: From b1d6e791e9d5ce2c66bca829134b0a7a56affbbf Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Tue, 14 May 2024 09:44:55 +0200 Subject: [PATCH 211/240] Avoid being vulnerable by using yaml.load() This code might be old and obsolete. If not fixed we may consider deleting it instead. --- .attic/convert_i18n_yaml.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.attic/convert_i18n_yaml.py b/.attic/convert_i18n_yaml.py index d6a6713e4..f75675dd7 100755 --- a/.attic/convert_i18n_yaml.py +++ b/.attic/convert_i18n_yaml.py @@ -50,7 +50,7 @@ def main(args=None): parser.error("YAML file not found: %s" % options.yaml_file) # -- STEP 1: Load YAML data. - languages = yaml.load(open(options.yaml_file)) + languages = yaml.safe_load(open(options.yaml_file)) languages = yaml_normalize(languages) # -- STEP 2: Generate python module with i18n data. From e417168bef9f7fb1124ce708212d414d5a168561 Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 22 May 2024 11:43:44 +0200 Subject: [PATCH 212/240] FIX #1177: MatchWithError is turned into AmbiguousStepError * CAUSED BY: Bad type-converter pattern using named-params HINT: parse module raises NotImplementedError exception. * NotImplementedError is raised for Python >= 3.11 --- CHANGES.rst | 1 + behave/matchers.py | 5 +- behave/step_registry.py | 13 ++++- tests/issues/test_issue1177.py | 103 +++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 tests/issues/test_issue1177.py diff --git a/CHANGES.rst b/CHANGES.rst index a39d18861..324cbd791 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -91,6 +91,7 @@ FIXED: * FIXED: Some tests related to python-3.11 * FIXED: Some tests related to python-3.9 * FIXED: active-tag logic if multiple tags with same category exists. +* issue #1177: Bad type-converter pattern: MatchWithError is turned into AmbiguousStep (submitted by: omrischwarz) * issue #1170: TagExpression auto-detection is not working properly (submitted by: Luca-morphy) * issue #1154: Config-files are not shown in verbose mode (submitted by: soblom) * issue #1120: Logging ignoring level set in setup_logging (submitted by: j7an) diff --git a/behave/matchers.py b/behave/matchers.py index a136d918e..130ade627 100644 --- a/behave/matchers.py +++ b/behave/matchers.py @@ -47,7 +47,6 @@ def __init__(self, text=None, exc_cause=None): ChainedExceptionUtil.set_cause(self, exc_cause) - # ----------------------------------------------------------------------------- # SECTION: Model Elements # ----------------------------------------------------------------------------- @@ -208,7 +207,6 @@ def describe(self, schema=None): schema = self.schema return schema % (step_type, self.pattern) - def check_match(self, step): """Match me against the "step" name supplied. @@ -224,7 +222,8 @@ def match(self, step): # -- PROTECT AGAINST: Type conversion errors (with ParseMatcher). try: result = self.check_match(step) - except Exception as e: # pylint: disable=broad-except + except (StepParseError, ValueError, TypeError) as e: + # -- TYPE-CONVERTER ERROR occurred. return MatchWithError(self.func, e) if result is None: diff --git a/behave/step_registry.py b/behave/step_registry.py index 41bfd672e..2d6fd3982 100644 --- a/behave/step_registry.py +++ b/behave/step_registry.py @@ -6,7 +6,7 @@ """ from __future__ import absolute_import -from behave.matchers import Match, make_matcher +from behave.matchers import Match, MatchWithError, make_matcher from behave.textutil import text as _text # limit import * to just the decorators @@ -49,7 +49,16 @@ def add_step_definition(self, keyword, step_text, func): # -- EXACT-STEP: Same step function is already registered. # This may occur when a step module imports another one. return - elif existing.match(step_text): # -- SIMPLISTIC + + matched = existing.match(step_text) + if matched is None or isinstance(matched, MatchWithError): + # -- CASES: + # - step-mismatch (None) + # - matching the step caused a type-converter function error + # REASON: Bad type-converter function is used. + continue + + if isinstance(matched, Match): message = u"%s has already been defined in\n existing step %s" new_step = u"@%s('%s')" % (step_type, step_text) existing.step_type = step_type diff --git a/tests/issues/test_issue1177.py b/tests/issues/test_issue1177.py new file mode 100644 index 000000000..6866d132f --- /dev/null +++ b/tests/issues/test_issue1177.py @@ -0,0 +1,103 @@ +""" +Test issue #1177. + +.. seealso:: https://github.com/behave/behave/issues/1177 +""" +# -- IMPORTS: +from __future__ import absolute_import, print_function + +import sys + +from behave._stepimport import use_step_import_modules, SimpleStepContainer +import parse +import pytest + + +@parse.with_pattern(r"true|false") +def parse_bool_good(text): + return text == "true" + + +@parse.with_pattern(r"(?P(?i)true|(?i)false)", regex_group_count=1) +def parse_bool_bad(text): + return text == "true" + + +@pytest.mark.parametrize("parse_bool", [parse_bool_good]) # DISABLED:, parse_bool_bad]) +def test_parse_expr(parse_bool): + parser = parse.Parser("Light is on: {answer:Bool}", + extra_types=dict(Bool=parse_bool)) + result = parser.parse("Light is on: true") + assert result["answer"] == True + result = parser.parse("Light is on: false") + assert result["answer"] == False + result = parser.parse("Light is on: __NO_MATCH__") + assert result is None + + +# -- SYNDROME: NotImplementedError is only raised for Python >= 3.11 +@pytest.mark.skipif(sys.version_info < (3, 11), + reason="Python >= 3.11: NotImplementedError is raised") +def test_syndrome(): + """ + Ensure that no AmbiguousStepError is raised + when another step is added after the one with the BAD TYPE-CONVERTER PATTERN. + """ + step_container = SimpleStepContainer() + this_step_registry = step_container.step_registry + with use_step_import_modules(step_container): + from behave import then, register_type + + register_type(Bool=parse_bool_bad) + + @then(u'first step is "{value:Bool}"') + def then_first_step(ctx, value): + assert isinstance(value, bool), "%r" % value + + with pytest.raises(NotImplementedError) as excinfo1: + # -- CASE: Another step is added + # EXPECTED: No AmbiguousStepError is raised. + @then(u'first step and more') + def then_second_step(ctx, value): + assert isinstance(value, bool), "%r" % value + + # -- CASE: Manually add step to step-registry + # EXPECTED: No AmbiguousStepError is raised. + with pytest.raises(NotImplementedError) as excinfo2: + step_text = u'first step and other' + def then_third_step(ctx, value): pass + this_step_registry.add_step_definition("then", step_text, then_third_step) + + assert "Group names (e.g. (?P) can cause failure" in str(excinfo1.value) + assert "Group names (e.g. (?P) can cause failure" in str(excinfo2.value) + + +@pytest.mark.skipif(sys.version_info >= (3, 11), + reason="Python < 3.11 -- NotImpplementedError is not raised") +def test_syndrome_for_py310_and_older(): + """ + Ensure that no AmbiguousStepError is raised + when another step is added after the one with the BAD TYPE-CONVERTER PATTERN. + """ + step_container = SimpleStepContainer() + this_step_registry = step_container.step_registry + with use_step_import_modules(step_container): + from behave import then, register_type + + register_type(Bool=parse_bool_bad) + + @then(u'first step is "{value:Bool}"') + def then_first_step(ctx, value): + assert isinstance(value, bool), "%r" % value + + # -- CASE: Another step is added + # EXPECTED: No AmbiguousStepError is raised. + @then(u'first step and mpre') + def then_second_step(ctx, value): + assert isinstance(value, bool), "%r" % value + + # -- CASE: Manually add step to step-registry + # EXPECTED: No AmbiguousStepError is raised. + step_text = u'first step and other' + def then_third_step(ctx, value): pass + this_step_registry.add_step_definition("then", step_text, then_third_step) From 3f3b7be6c80213f8032c5b069504b1525a924634 Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 22 May 2024 12:23:38 +0200 Subject: [PATCH 213/240] CI WORKFLOW UPDATE: actions/upload-artifact@v4 (was: v3) --- .github/workflows/tests-windows.yml | 13 +++++++------ .github/workflows/tests.yml | 3 ++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index d3bc9c03a..411f698b4 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -64,16 +64,17 @@ jobs: behave --format=progress3 tools/test-features behave --format=progress3 issue.features - name: Upload test reports - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: test reports path: | build/testing/report.xml build/testing/report.html + # MAYBE: build/behave.reports/ if: ${{ job.status == 'failure' }} # MAYBE: if: ${{ always() }} - - name: Upload behave test reports - uses: actions/upload-artifact@v3 - with: - name: behave.reports - path: build/behave.reports/ +# - name: Upload behave test reports +# uses: actions/upload-artifact@v4 +# with: +# name: behave.reports +# path: build/behave.reports/ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5e4183b0c..5b53d504d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -61,11 +61,12 @@ jobs: behave --format=progress tools/test-features behave --format=progress issue.features - name: Upload test reports - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: test reports path: | build/testing/report.xml build/testing/report.html + # MAYBE: build/behave.reports/ if: ${{ job.status == 'failure' }} # MAYBE: if: ${{ always() }} From fabb10cb9ae8b72491d2d7305e1f6b698697df13 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 26 May 2024 15:47:45 +0200 Subject: [PATCH 214/240] ruff: Update config to use "[lint]" sections where needed --- .ruff.toml | 42 +++++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/.ruff.toml b/.ruff.toml index 2cde028ed..97547054a 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -2,21 +2,10 @@ # SECTION: ruff -- Python linter # ----------------------------------------------------------------------------- # SEE: https://github.com/charliermarsh/ruff +# SEE: https://docs.astral.sh/ruff/configuration/ # SEE: https://beta.ruff.rs/docs/configuration/#using-rufftoml # Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. -select = ["E", "F"] -ignore = [] - -# Allow autofix for all enabled rules (when `--fix`) is provided. -fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", - "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", - "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", - "TCH", "TID", "TRY", "UP", "YTT" -] -unfixable = [] - -# Exclude a variety of commonly ignored directories. exclude = [ ".direnv", ".eggs", @@ -29,15 +18,34 @@ exclude = [ "dist", "venv", ] -per-file-ignores = {} - -# Same as Black. # WAS: line-length = 88 line-length = 100 +indent-width = 4 +target-version = "py312" + + +[lint] +select = ["E", "F"] +ignore = [] +# Allow autofix for all enabled rules (when `--fix`) is provided. +fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", + "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", + "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", + "TCH", "TID", "TRY", "UP", "YTT" +] +unfixable = [] + +# Exclude a variety of commonly ignored directories. +per-file-ignores = {} # Allow unused variables when underscore-prefixed. dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" -target-version = "py310" -[mccabe] + +[lint.mccabe] max-complexity = 10 + + +[format] +quote-style = "double" +indent-style = "space" From 7487232e374c2c1b4f4cb0c746e2aff6a465b3c1 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 26 May 2024 15:50:42 +0200 Subject: [PATCH 215/240] RELATED TO #1177: Improve behaviour on BAD STEP-DEFINITIONS * Newer Python versions (=> 3.11) raise "re.error" exceptions when bad regular-expressions are used. NOTE: This may occur in the regex pattern of a type-converter function. * parse-expressions: On compiling the internal regular-expression, this will fail consistently when `parser.parse("...")` is called. This may cause always problems when any step should be matched. CHANGED BEHAVIOUR: * behave.matchers.Matcher class: Provides `compile()` method to enforce that the regular-expression can be compiled early. Derived classes must implement this method. NOTES: - This was done for `behave` derived classes. - Lazy-compiling of regexp was partly used in the past. * behave.step_registry.StepRegistry: Checks now for BAD STEP-DEFINITION on calling `add_step_definition()`. BAD STEP-DEFINITION(s) are reported and ignored. --- behave/matchers.py | 303 +++++++++++++++++++++------- behave/step_registry.py | 126 +++++++++--- tests/issues/test_issue1177.py | 88 +++++--- tests/unit/test_matchers.py | 24 ++- tests/unit/test_step_registry.py | 13 +- tools/test-features/outline.feature | 10 +- tools/test-features/steps/steps.py | 29 ++- 7 files changed, 439 insertions(+), 154 deletions(-) diff --git a/behave/matchers.py b/behave/matchers.py index 130ade627..5d557c863 100644 --- a/behave/matchers.py +++ b/behave/matchers.py @@ -142,23 +142,41 @@ def run(self, context): raise StepParseError(exc_cause=self.stored_error) - - # ----------------------------------------------------------------------------- -# SECTION: Matchers +# SECTION: Step Matchers # ----------------------------------------------------------------------------- class Matcher(object): - """Pull parameters out of step names. + """ + Provides an abstract base class for step-matcher classes. + + Matches steps from "*.feature" files (Gherkin files) + and extracts step-parameters for these steps. + + RESPONSIBILITIES: + + * Matches steps from "*.feature" files (or not) + * Returns :class:`Match` objects if this step-matcher matches + that is used to run the step-definition function w/ its parameters. + * Compile parse-expression/regular-expression to detect + BAD STEP-DEFINITION(s) early. .. attribute:: pattern - The match pattern attached to the step function. + The match pattern attached to the step function. .. attribute:: func - The step function the pattern is being attached to. + The associated step-definition function to use for this pattern. + + .. attribute:: location + + File location of the step-definition function. """ - schema = u"@%s('%s')" # Schema used to describe step definition (matcher) + # -- DESCRIBE-SCHEMA FOR STEP-DEFINITIONS (step-matchers): + SCHEMA = u"@{this.step_type}('{this.pattern}')" + SCHEMA_AT_LOCATION = SCHEMA + u" at {this.location}" + SCHEMA_WITH_LOCATION = SCHEMA + u" # {this.location}" + SCHEMA_AS_STEP = u"{this.step_type} {this.pattern}" @classmethod def register_type(cls, **kwargs): @@ -174,7 +192,7 @@ def clear_registered_types(cls): def __init__(self, func, pattern, step_type=None): self.func = func self.pattern = pattern - self.step_type = step_type + self.step_type = step_type or "step" self._location = None # -- BACKWARD-COMPATIBILITY: @@ -202,44 +220,115 @@ def describe(self, schema=None): :param schema: Text schema to use. :return: Textual description of this step definition (matcher). """ - step_type = self.step_type or "step" if not schema: - schema = self.schema - return schema % (step_type, self.pattern) + schema = self.SCHEMA + + # -- SUPPORT: schema = "{this.step_type} {this.pattern}" + return schema.format(this=self) + + def compile(self): + """ + Compiles the regular-expression pattern (if necessary). + + NOTES: + - This allows to detect some errors with BAD regular expressions early. + - Must be implemneted by derived classes. + + :return: Self (to support daisy-chaining) + """ + raise NotImplementedError() - def check_match(self, step): - """Match me against the "step" name supplied. + def check_match(self, step_text): + """ + Match me against the supplied "step_text". Return None, if I don't match otherwise return a list of matches as :class:`~behave.model_core.Argument` instances. The return value from this function will be converted into a :class:`~behave.matchers.Match` instance by *behave*. + + :param step_text: Step text that should be matched (as string). + :return: A list of matched-arguments (on match). None, on mismatch. + :raises: ValueError, re.error, ... """ - raise NotImplementedError + raise NotImplementedError() - def match(self, step): + def match(self, step_text): # -- PROTECT AGAINST: Type conversion errors (with ParseMatcher). try: - result = self.check_match(step) - except (StepParseError, ValueError, TypeError) as e: + matched_args = self.check_match(step_text) + # MAYBE: except (StepParseError, ValueError, TypeError) as e: + except NotImplementedError: + # -- CASES: + # - check_match() is not implemented + # - check_match() raises NotImplementedError (on: re.error) + raise + except Exception as e: # -- TYPE-CONVERTER ERROR occurred. return MatchWithError(self.func, e) - if result is None: + if matched_args is None: return None # -- NO-MATCH - return Match(self.func, result) + return Match(self.func, matched_args) + + def matches(self, step_text): + """ + Checks if :param:`step_text` matches this step-definition/step-matcher. + + :param step_text: Step text to check. + :return: True, if step is matched. False, otherwise. + """ + if self.pattern == step_text: + # -- SIMPLISTIC CASE: No step-parameters. + return True + + # -- HINT: Ignore MatchWithError here. + matched = self.match(step_text) + return (matched and isinstance(matched, Match) and + not isinstance(matched, MatchWithError)) def __repr__(self): return u"<%s: %r>" % (self.__class__.__name__, self.pattern) class ParseMatcher(Matcher): - """Uses :class:`~parse.Parser` class to be able to use simpler - parse expressions compared to normal regular expressions. + r""" + Provides a step-matcher that uses parse-expressions. + Parse-expressions provide a simpler syntax compared to regular expressions. + Parse-expressions are :func:`string.format()` expressions but for parsing. + + RESPONSIBILITIES: + + * Provides parse-expressions, like: "a positive number {number:PositiveNumber}" + * Support for custom type-converter functions + + COLLABORATORS: + + * :class:`~parse.Parser` to support parse-expressions. + + EXAMPLE: + + .. code-block:: python + + from behave import register_type, given, use_step_matcher + import parse + + # -- TYPE CONVERTER: For a simple, positive integer number. + @parse.with_pattern(r"\d+") + def parse_number(text): + return int(text) + + register_type(Number=parse_number) + + @given('{amount:Number} vehicles') + def step_given_amount_vehicles(ctx, amount): + assert isinstance(amount, int) + print("{amount} vehicles".format(amount=amount))} """ custom_types = {} parser_class = parse.Parser + case_sensitive = True @classmethod def register_type(cls, **kwargs): @@ -250,30 +339,6 @@ def register_type(cls, **kwargs): A type converter should follow :pypi:`parse` module rules. In general, a type converter is a function that converts text (as string) into a value-type (type converted value). - - EXAMPLE: - - .. code-block:: python - - from behave import register_type, given - import parse - - - # -- TYPE CONVERTER: For a simple, positive integer number. - @parse.with_pattern(r"\d+") - def parse_number(text): - return int(text) - - # -- REGISTER TYPE-CONVERTER: With behave - register_type(Number=parse_number) - # ALTERNATIVE: - current_step_matcher = use_step_matcher("parse") - current_step_matcher.register_type(Number=parse_number) - - # -- STEP DEFINITIONS: Use type converter. - @given('{amount:Number} vehicles') - def step_impl(context, amount): - assert isinstance(amount, int) """ cls.custom_types.update(**kwargs) @@ -281,43 +346,102 @@ def step_impl(context, amount): def clear_registered_types(cls): cls.custom_types.clear() - def __init__(self, func, pattern, step_type=None): super(ParseMatcher, self).__init__(func, pattern, step_type) - self.parser = self.parser_class(pattern, self.custom_types) + self.parser = self.parser_class(pattern, self.custom_types, + case_sensitive=self.case_sensitive) @property def regex_pattern(self): # -- OVERWRITTEN: Pattern as regex text. return self.parser._expression # pylint: disable=protected-access - def check_match(self, step): + def compile(self): + """ + Compiles internal regular-expression. + + Compiles "parser._match_re" which may lead to error (always) + if a BAD regular expression is used (or: BAD TYPE-CONVERTER). + """ + # -- HINT: Triggers implicit compile of "self.parser._match_re" + _ = self.parser.parse("") + return self + + def check_match(self, step_text): + """ + Checks if the :param:`step_text` is matched (or not). + + :param step_text: Step text to check. + :return: step-args if step was matched, None otherwise. + :raises ValueError: If type-converter functions fails. + """ # -- FAILURE-POINT: Type conversion of parameters may fail here. # NOTE: Type converter should raise ValueError in case of PARSE ERRORS. - result = self.parser.parse(step) - if not result: + matched = self.parser.parse(step_text) + if not matched: return None args = [] - for index, value in enumerate(result.fixed): - start, end = result.spans[index] - args.append(Argument(start, end, step[start:end], value)) - for name, value in result.named.items(): - start, end = result.spans[name] - args.append(Argument(start, end, step[start:end], value, name)) + for index, value in enumerate(matched.fixed): + start, end = matched.spans[index] + args.append(Argument(start, end, step_text[start:end], value)) + for name, value in matched.named.items(): + start, end = matched.spans[name] + args.append(Argument(start, end, step_text[start:end], value, name)) args.sort(key=lambda x: x.start) return args class CFParseMatcher(ParseMatcher): - """Uses :class:`~parse_type.cfparse.Parser` instead of "parse.Parser". - Provides support for automatic generation of type variants - for fields with CardinalityField part. + """ + Provides a step-matcher that uses parse-expressions with cardinality-fields. + Parse-expressions use simpler syntax compared to normal regular expressions. + + Cardinality-fields provide a compact syntax for cardinalities: + + * many: "+" (cardinality: ``1..N``) + * many0: "*" (cardinality: ``0..N``) + * optional: "?" (cardinality: ``0..1``) + + Regular expressions and type-converters for cardinality-fields are + generated by the parser if a type-converter for the cardinality=1 is registered. + + COLLABORATORS: + + * :class:`~parse_type.cfparse.Parser` is used to support parse-expressions + with cardinality-field support. + + EXAMPLE: + + .. code-block:: python + + from behave import register_type, given, use_step_matcher + use_step_matcher("cfparse") + # ... -- OMITTED: Provide type-converter function for Number + + @given(u'{amount:Number+} as numbers') # CARDINALITY-FIELD: Many-Numbers + def step_many_numbers(ctx, numbers): + assert isinstance(numbers, list) + assert isinstance(numbers[0], int) + print("numbers = %r" % numbers) + + step_matcher = CFParseMatcher(step_many_numbers, "{amount:Number+} as numbers") + matched = step_matcher.matches("1, 2, 3 as numbers") + assert matched is True + # -- STEP MATCHES: numbers = [1, 2, 3] """ parser_class = cfparse.Parser class RegexMatcher(Matcher): + """ + Provides a step-matcher that uses regular-expressions + + RESPONSIBILITIES: + + * Custom type-converters are NOT SUPPORTED. + """ + @classmethod def register_type(cls, **kwargs): """ @@ -335,50 +459,81 @@ def clear_registered_types(cls): def __init__(self, func, pattern, step_type=None): super(RegexMatcher, self).__init__(func, pattern, step_type) - self.regex = re.compile(self.pattern) + self._regex = None # -- HINT: Defer re.compile(self.pattern) + + @property + def regex(self): + if self._regex is None: + # self._regex = re.compile(self.pattern) + self._regex = re.compile(self.pattern, re.UNICODE) + return self._regex + @regex.setter + def regex(self, value): + self._regex = value - def check_match(self, step): - m = self.regex.match(step) - if not m: + @property + def regex_pattern(self): + """Return the regex pattern that is used for matching steps.""" + return self.regex.pattern + + def compile(self): + # -- HINT: Compiles "parser._match_re" which may lead to error (always). + _ = self.regex # -- HINT: IMPLICIT-COMPILE + return self + + def check_match(self, step_text): + matched = self.regex.match(step_text) + if not matched: return None - groupindex = dict((y, x) for x, y in self.regex.groupindex.items()) + group_index = dict((y, x) for x, y in self.regex.groupindex.items()) args = [] - for index, group in enumerate(m.groups()): + for index, group in enumerate(matched.groups()): index += 1 - name = groupindex.get(index, None) - args.append(Argument(m.start(index), m.end(index), group, - group, name)) + name = group_index.get(index, None) + args.append(Argument(matched.start(index), matched.end(index), + group, group, name)) return args + class SimplifiedRegexMatcher(RegexMatcher): """ Simplified regular expression step-matcher that automatically adds - start-of-line/end-of-line matcher symbols to string: + START_OF_LINE/END_OF_LINE regular-expression markers to the string. + + EXAMPLE: .. code-block:: python - @when(u'a step passes') # re.pattern = "^a step passes$" - def step_impl(context): pass + from behave import when, use_step_matcher + use_step_matcher("re") + + @when(u'a step passes') # re.pattern = "^a step passes$" + def step_impl(context): + pass """ def __init__(self, func, pattern, step_type=None): assert not (pattern.startswith("^") or pattern.endswith("$")), \ "Regular expression should not use begin/end-markers: "+ pattern - expression = "^%s$" % pattern + expression = r"^%s$" % pattern super(SimplifiedRegexMatcher, self).__init__(func, expression, step_type) - self.pattern = pattern class CucumberRegexMatcher(RegexMatcher): """ Compatible to (old) Cucumber style regular expressions. - Text must contain start-of-line/end-of-line matcher symbols to string: + Step-text must contain START_OF_LINE/END_OF_LINE markers. + + EXAMPLE: .. code-block:: python + from behave import when, use_step_matcher + use_step_matcher("re0") + @when(u'^a step passes$') # re.pattern = "^a step passes$" def step_impl(context): pass """ diff --git a/behave/step_registry.py b/behave/step_registry.py index 2d6fd3982..0dffb0a14 100644 --- a/behave/step_registry.py +++ b/behave/step_registry.py @@ -5,8 +5,10 @@ step implementations (step definitions). This is necessary to execute steps. """ -from __future__ import absolute_import -from behave.matchers import Match, MatchWithError, make_matcher +from __future__ import absolute_import, print_function +import sys + +from behave.matchers import make_matcher from behave.textutil import text as _text # limit import * to just the decorators @@ -14,7 +16,7 @@ # names = "given when then step" # names = names + " " + names.title() # __all__ = names.split() -__all__ = [ +__all__ = [ # noqa: F822 "given", "when", "then", "step", # PREFERRED. "Given", "When", "Then", "Step" # Also possible. ] @@ -24,14 +26,62 @@ class AmbiguousStep(ValueError): pass +class BadStepDefinitionErrorHandler(object): + BAD_STEP_DEFINITION_MESSAGE = """\ +BAD-STEP-DEFINITION: {step} + LOCATION: {step_location} +""".strip() + BAD_STEP_DEFINITION_MESSAGE_WITH_ERROR = BAD_STEP_DEFINITION_MESSAGE + """ +RAISED EXCEPTION: {error.__class__.__name__}:{error}""" + + def __init__(self): + self.bad_step_definitions = [] + + def clear(self): + self.bad_step_definitions = [] + + def on_error(self, step_matcher, error): + self.bad_step_definitions.append(step_matcher) + self.print(step_matcher, error) + + def print_all(self): + print("BAD STEP-DEFINITIONS[%d]:" % len(self.bad_step_definitions)) + for index, bad_step_definition in enumerate(self.bad_step_definitions): + print("%d. " % index, end="") + self.print(bad_step_definition, error=None) + + # -- CLASS METHODS: + @classmethod + def print(cls, step_matcher, error=None): + message = cls.BAD_STEP_DEFINITION_MESSAGE_WITH_ERROR + if error is None: + message = cls.BAD_STEP_DEFINITION_MESSAGE + + print(message.format(step=step_matcher.describe(), + step_location=step_matcher.location, + error=error), file=sys.stderr) + + @classmethod + def raise_error(cls, step_matcher, error): + cls.print(step_matcher, error) + raise error + + class StepRegistry(object): + BAD_STEP_DEFINITION_HANDLER_CLASS = BadStepDefinitionErrorHandler + RAISE_ERROR_ON_BAD_STEP_DEFINITION = False + def __init__(self): - self.steps = { - "given": [], - "when": [], - "then": [], - "step": [], - } + self.steps = dict(given=[], when=[], then=[], step=[]) + self.error_handler = self.BAD_STEP_DEFINITION_HANDLER_CLASS() + + def clear(self): + """ + Forget any step-definitions (step-matchers) and + forget any bad step-definitions. + """ + self.steps = dict(given=[], when=[], then=[], step=[]) + self.error_handler.clear() @staticmethod def same_step_definition(step, other_pattern, other_location): @@ -39,33 +89,57 @@ def same_step_definition(step, other_pattern, other_location): step.location == other_location and other_location.filename != "") + def on_bad_step_definition(self, step_matcher, error): + # -- STEP: Select on_error() function + on_error = self.error_handler.on_error + if self.RAISE_ERROR_ON_BAD_STEP_DEFINITION: + on_error = self.error_handler.raise_error + + on_error(step_matcher, error) + + def is_good_step_definition(self, step_matcher): + """ + Check if a :param:`step_matcher` provides a good step definition. + + PROBLEM: + * :func:`Parser.parse()` may always raise an exception + (cases: :exc:`NotImplementedError` caused by :exc:`re.error`, ...). + * regex errors (from :mod:`re`) are more enforced since Python >= 3.11 + + :param step_matcher: Step-matcher (step-definition) to check. + :return: True, if step-matcher is good to use; False, otherwise. + """ + try: + step_matcher.compile() + return True + except Exception as error: + self.on_bad_step_definition(step_matcher, error) + return False + def add_step_definition(self, keyword, step_text, func): - step_location = Match.make_location(func) - step_type = keyword.lower() + new_step_type = keyword.lower() step_text = _text(step_text) - step_definitions = self.steps[step_type] + new_step_matcher = make_matcher(func, step_text, new_step_type) + if not self.is_good_step_definition(new_step_matcher): + # -- CASE: BAD STEP-DEFINITION -- Ignore it. + return + + # -- CURRENT: + step_location = new_step_matcher.location + step_definitions = self.steps[new_step_type] for existing in step_definitions: if self.same_step_definition(existing, step_text, step_location): # -- EXACT-STEP: Same step function is already registered. # This may occur when a step module imports another one. return - matched = existing.match(step_text) - if matched is None or isinstance(matched, MatchWithError): - # -- CASES: - # - step-mismatch (None) - # - matching the step caused a type-converter function error - # REASON: Bad type-converter function is used. - continue - - if isinstance(matched, Match): + if existing.matches(step_text): + # WHY: existing.step_type = new_step_type message = u"%s has already been defined in\n existing step %s" - new_step = u"@%s('%s')" % (step_type, step_text) - existing.step_type = step_type - existing_step = existing.describe() - existing_step += u" at %s" % existing.location + new_step = new_step_matcher.describe() + existing_step = existing.describe(existing.SCHEMA_AT_LOCATION) raise AmbiguousStep(message % (new_step, existing_step)) - step_definitions.append(make_matcher(func, step_text)) + step_definitions.append(new_step_matcher) def find_step_definition(self, step): candidates = self.steps[step.step_type] diff --git a/tests/issues/test_issue1177.py b/tests/issues/test_issue1177.py index 6866d132f..5bb27900d 100644 --- a/tests/issues/test_issue1177.py +++ b/tests/issues/test_issue1177.py @@ -9,6 +9,10 @@ import sys from behave._stepimport import use_step_import_modules, SimpleStepContainer +from behave.configuration import Configuration +from behave.matchers import Match, StepParseError +from behave.parser import parse_step +from behave.runner import Context, ModelRunner import parse import pytest @@ -35,10 +39,24 @@ def test_parse_expr(parse_bool): assert result is None +@pytest.mark.skipif(sys.version_info < (3, 11), reason="REQUIRES: Python >= 3.11") +def test_parse_with_bad_type_converter_pattern_raises_not_implemented_error(): + # -- HINT: re.error is only raised for Python >= 3.11 + # FAILURE-POINT: parse.Parser._match_re property -- compiles _match_re + parser = parse.Parser("Light is on: {answer:Bool}", + extra_types=dict(Bool=parse_bool_bad)) + + # -- PROBLEM POINT: + with pytest.raises(NotImplementedError) as exc_info: + _ = parser.parse("Light is on: true") + + expected = "Group names (e.g. (?P) can cause failure, as they are not escaped properly:" + assert expected in str(exc_info.value) + + # -- SYNDROME: NotImplementedError is only raised for Python >= 3.11 -@pytest.mark.skipif(sys.version_info < (3, 11), - reason="Python >= 3.11: NotImplementedError is raised") -def test_syndrome(): +@pytest.mark.skipif(sys.version_info < (3, 11), reason="REQUIRES: Python >= 3.11") +def test_syndrome(capsys): """ Ensure that no AmbiguousStepError is raised when another step is added after the one with the BAD TYPE-CONVERTER PATTERN. @@ -54,30 +72,26 @@ def test_syndrome(): def then_first_step(ctx, value): assert isinstance(value, bool), "%r" % value - with pytest.raises(NotImplementedError) as excinfo1: - # -- CASE: Another step is added - # EXPECTED: No AmbiguousStepError is raised. - @then(u'first step and more') - def then_second_step(ctx, value): - assert isinstance(value, bool), "%r" % value + # -- ENSURE: No AmbiguousStepError is raised when another step is added. + @then(u'first step and more') + def then_second_step(ctx): + pass - # -- CASE: Manually add step to step-registry - # EXPECTED: No AmbiguousStepError is raised. - with pytest.raises(NotImplementedError) as excinfo2: - step_text = u'first step and other' - def then_third_step(ctx, value): pass - this_step_registry.add_step_definition("then", step_text, then_third_step) + # -- ENSURE: BAD-STEP-DEFINITION is not registered in step_registry + step = parse_step(u'Then this step is "true"') + assert this_step_registry.find_step_definition(step) is None - assert "Group names (e.g. (?P) can cause failure" in str(excinfo1.value) - assert "Group names (e.g. (?P) can cause failure" in str(excinfo2.value) + # -- ENSURE: BAD-STEP-DEFINITION is shown in output. + captured = capsys.readouterr() + expected = """BAD-STEP-DEFINITION: @then('first step is "{value:Bool}"')""" + assert expected in captured.err + assert "RAISED EXCEPTION: NotImplementedError:Group names (e.g. (?P)" in captured.err -@pytest.mark.skipif(sys.version_info >= (3, 11), - reason="Python < 3.11 -- NotImpplementedError is not raised") -def test_syndrome_for_py310_and_older(): +@pytest.mark.skipif(sys.version_info < (3, 11), reason="REQUIRES: Python >= 3.11") +def test_bad_step_is_not_registered_if_regex_compile_fails(capsys): """ - Ensure that no AmbiguousStepError is raised - when another step is added after the one with the BAD TYPE-CONVERTER PATTERN. + Ensure that step-definition is not registered if parse-expression compile fails. """ step_container = SimpleStepContainer() this_step_registry = step_container.step_registry @@ -90,14 +104,26 @@ def test_syndrome_for_py310_and_older(): def then_first_step(ctx, value): assert isinstance(value, bool), "%r" % value - # -- CASE: Another step is added - # EXPECTED: No AmbiguousStepError is raised. - @then(u'first step and mpre') - def then_second_step(ctx, value): + # -- ENSURE: Step-definition is not registered in step-registry. + step = parse_step(u'Then first step is "true"') + step_matcher = this_step_registry.find_step_definition(step) + assert step_matcher is None + + +@pytest.mark.skipif(sys.version_info >= (3, 11), reason="REQUIRES: Python < 3.11") +def test_bad_step_is_registered_if_regex_compile_succeeds(capsys): + step_container = SimpleStepContainer() + this_step_registry = step_container.step_registry + with use_step_import_modules(step_container): + from behave import then, register_type + + register_type(Bool=parse_bool_bad) + + @then(u'first step is "{value:Bool}"') + def then_first_step(ctx, value): assert isinstance(value, bool), "%r" % value - # -- CASE: Manually add step to step-registry - # EXPECTED: No AmbiguousStepError is raised. - step_text = u'first step and other' - def then_third_step(ctx, value): pass - this_step_registry.add_step_definition("then", step_text, then_third_step) + # -- ENSURE: Step-definition is not registered in step-registry. + step = parse_step(u'Then first step is "true"') + step_matcher = this_step_registry.find_step_definition(step) + assert step_matcher is not None diff --git a/tests/unit/test_matchers.py b/tests/unit/test_matchers.py index 97ba49eb1..b737c44ca 100644 --- a/tests/unit/test_matchers.py +++ b/tests/unit/test_matchers.py @@ -324,18 +324,20 @@ def test_steps_with_same_prefix_are_not_ordering_sensitive(self): def step_func1(context): pass # pylint: disable=multiple-statements def step_func2(context): pass # pylint: disable=multiple-statements # pylint: enable=unused-argument - matcher1 = SimplifiedRegexMatcher(step_func1, "I do something") - matcher2 = SimplifiedRegexMatcher(step_func2, "I do something more") + text1 = u"I do something" + text2 = u"I do something more" + matcher1 = SimplifiedRegexMatcher(step_func1, text1) + matcher2 = SimplifiedRegexMatcher(step_func2, text2) # -- CHECK: ORDERING SENSITIVITY - matched1 = matcher1.match(matcher2.pattern) - matched2 = matcher2.match(matcher1.pattern) + matched1 = matcher1.match(text2) + matched2 = matcher2.match(text1) assert matched1 is None assert matched2 is None # -- CHECK: Can match itself (if step text is simple) - matched1 = matcher1.match(matcher1.pattern) - matched2 = matcher2.match(matcher2.pattern) + matched1 = matcher1.match(text1) + matched2 = matcher2.match(text2) assert isinstance(matched1, Match) assert isinstance(matched2, Match) @@ -363,16 +365,18 @@ def step_func2(context): pass # pylint: disable=multiple-statements # pylint: enable=unused-argument matcher1 = CucumberRegexMatcher(step_func1, "^I do something$") matcher2 = CucumberRegexMatcher(step_func2, "^I do something more$") + text1 = matcher1.pattern[1:-1] + text2 = matcher2.pattern[1:-1] # -- CHECK: ORDERING SENSITIVITY - matched1 = matcher1.match(matcher2.pattern[1:-1]) - matched2 = matcher2.match(matcher1.pattern[1:-1]) + matched1 = matcher1.match(text2) + matched2 = matcher2.match(text1) assert matched1 is None assert matched2 is None # -- CHECK: Can match itself (if step text is simple) - matched1 = matcher1.match(matcher1.pattern[1:-1]) - matched2 = matcher2.match(matcher2.pattern[1:-1]) + matched1 = matcher1.match(text1) + matched2 = matcher2.match(text2) assert isinstance(matched1, Match) assert isinstance(matched2, Match) diff --git a/tests/unit/test_step_registry.py b/tests/unit/test_step_registry.py index 59d09e157..106f91c47 100644 --- a/tests/unit/test_step_registry.py +++ b/tests/unit/test_step_registry.py @@ -4,6 +4,7 @@ from mock import Mock, patch from six.moves import range # pylint: disable=redefined-builtin from behave import step_registry +from behave.matchers import ParseMatcher class TestStepRegistry(object): @@ -15,17 +16,17 @@ def test_add_step_definition_adds_to_lowercased_keyword(self): # with patch('behave.matchers.make_matcher') as make_matcher: with patch('behave.step_registry.make_matcher') as make_matcher: func = lambda x: -x - pattern = 'just a test string' - magic_object = object() + pattern = u"just a test string" + magic_object = Mock() make_matcher.return_value = magic_object for step_type in list(registry.steps.keys()): - l = [] - registry.steps[step_type] = l + registered_steps = [] + registry.steps[step_type] = registered_steps registry.add_step_definition(step_type.upper(), pattern, func) - make_matcher.assert_called_with(func, pattern) - assert l == [magic_object] + make_matcher.assert_called_with(func, pattern, step_type) + assert registered_steps == [magic_object] def test_find_match_with_specific_step_type_also_searches_generic(self): registry = step_registry.StepRegistry() diff --git a/tools/test-features/outline.feature b/tools/test-features/outline.feature index 410cb0e73..522895fae 100644 --- a/tools/test-features/outline.feature +++ b/tools/test-features/outline.feature @@ -1,25 +1,25 @@ Feature: support scenario outlines Scenario Outline: run scenarios with one example table - Given Some text + Given some text When we add some text Then we should get the Examples: some simple examples | prefix | suffix | combination | | go | ogle | google | - | onomat | opoeia | onomatopoeia | + | onomat | opoeia | onomatopoeia | | comb | ination | combination | Scenario Outline: run scenarios with examples - Given Some text + Given some text When we add some text Then we should get the Examples: some simple examples | prefix | suffix | combination | | go | ogle | google | - | onomat | opoeia | onomatopoeia | + | onomat | opoeia | onomatopoeia | | comb | ination | combination | Examples: some other examples @@ -29,7 +29,7 @@ Feature: support scenario outlines @xfail Scenario Outline: scenarios that reference invalid subs - Given Some text + Given some text When we add try to use a reference Then it won't work diff --git a/tools/test-features/steps/steps.py b/tools/test-features/steps/steps.py index c382277e6..62bb80308 100644 --- a/tools/test-features/steps/steps.py +++ b/tools/test-features/steps/steps.py @@ -1,71 +1,88 @@ # -*- coding: UTF-8 -*- from __future__ import absolute_import -from behave import given, when, then import logging +from behave import given, when, then, register_type from six.moves import zip + spam_log = logging.getLogger('spam') ham_log = logging.getLogger('ham') + @given("I am testing stuff") def step_impl(context): context.testing_stuff = True + @given("some stuff is set up") def step_impl(context): context.stuff_set_up = True + @given("stuff has been set up") def step_impl(context): assert context.testing_stuff is True assert context.stuff_set_up is True + @when("I exercise it work") def step_impl(context): spam_log.error('logging!') ham_log.error('logging!') + @then("it will work") def step_impl(context): pass + @given("some text {prefix}") def step_impl(context, prefix): context.prefix = prefix + @when('we add some text {suffix}') def step_impl(context, suffix): context.combination = context.prefix + suffix + @then('we should get the {combination}') def step_impl(context, combination): assert context.combination == combination + @given('some body of text') def step_impl(context): assert context.text context.saved_text = context.text + TEXT = ''' Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.''' + + @then('the text is as expected') def step_impl(context): assert context.saved_text, 'context.saved_text is %r!!' % (context.saved_text, ) context.saved_text.assert_equals(TEXT) + @given('some initial data') def step_impl(context): assert context.table context.saved_table = context.table + TABLE_DATA = [ dict(name='Barry', department='Beer Cans'), dict(name='Pudey', department='Silly Walks'), dict(name='Two-Lumps', department='Silly Walks'), ] + + @then('we will have the expected data') def step_impl(context): assert context.saved_table, 'context.saved_table is %r!!' % (context.saved_table, ) @@ -73,6 +90,7 @@ def step_impl(context): assert expected['name'] == got['name'] assert expected['department'] == got['department'] + @then('the text is substituted as expected') def step_impl(context): assert context.saved_text, 'context.saved_text is %r!!' % (context.saved_text, ) @@ -85,6 +103,8 @@ def step_impl(context): dict(name='Pudey', department='Silly Walks'), dict(name='Two-Lumps', department='Silly Walks'), ] + + @then('we will have the substituted data') def step_impl(context): assert context.saved_table, 'context.saved_table is %r!!' % (context.saved_table, ) @@ -93,27 +113,32 @@ def step_impl(context): assert context.saved_table[0]['department'] == expected, '%r != %r' % ( context.saved_table[0]['department'], expected) + @given('the tag "{tag}" is set') def step_impl(context, tag): assert tag in context.tags, '%r NOT present in %r!' % (tag, context.tags) if tag == 'spam': assert context.is_spammy + @given('the tag "{tag}" is not set') def step_impl(context, tag): assert tag not in context.tags, '%r IS present in %r!' % (tag, context.tags) + @given('a string {argument} an argument') def step_impl(context, argument): context.argument = argument -from behave.matchers import register_type + register_type(custom=lambda s: s.upper()) + @given('a string {argument:custom} a custom type') def step_impl(context, argument): context.argument = argument + @then('we get "{argument}" parsed') def step_impl(context, argument): assert context.argument == argument From 3ec30b3b93fef375661eea7ebb70de7d55858f4b Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 26 May 2024 16:20:27 +0200 Subject: [PATCH 216/240] CI: Try to use "uv" to speed-up package installations. * Drop using Python 3.9 * Move pypy-27 on ubuntu-latest to own workflow (not supported by: uv) --- .github/workflows/tests-pypy27.yml | 65 +++++++++++++++++++++++++++++ .github/workflows/tests-windows.yml | 21 ++++++---- .github/workflows/tests.yml | 15 +++++-- 3 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/tests-pypy27.yml diff --git a/.github/workflows/tests-pypy27.yml b/.github/workflows/tests-pypy27.yml new file mode 100644 index 000000000..0f9247b00 --- /dev/null +++ b/.github/workflows/tests-pypy27.yml @@ -0,0 +1,65 @@ +# -- TEST-VARIANT: pypy-27 on ubuntu-latest +# BASED ON: tests.yml + +name: tests-pypy27 +on: + workflow_dispatch: + push: + branches: [ "main", "release/**" ] + paths: + - ".github/**/*.yml" + - "**/*.py" + - "**/*.feature" + - "py.requirements/**" + - "*.cfg" + - "*.ini" + - "*.toml" + pull_request: + types: [opened, reopened, review_requested] + branches: [ "main" ] + paths: + - ".github/**/*.yml" + - "**/*.py" + - "**/*.feature" + - "py.requirements/**" + - "*.cfg" + - "*.ini" + - "*.toml" + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["pypy-2.7"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: 'py.requirements/*.txt' + + - name: Install Python package dependencies + run: | + python -m pip install -U pip setuptools wheel + pip install --upgrade -r py.requirements/ci.github.testing.txt + pip install -e . + - name: Run tests + run: pytest + - name: Run behave tests + run: | + behave --format=progress features + behave --format=progress tools/test-features + behave --format=progress issue.features + - name: Upload test reports + uses: actions/upload-artifact@v4 + with: + name: test reports + path: | + build/testing/report.xml + build/testing/report.html + # MAYBE: build/behave.reports/ + if: ${{ job.status == 'failure' }} + # MAYBE: if: ${{ always() }} diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index 411f698b4..cd396ad9a 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -39,7 +39,7 @@ jobs: fail-fast: false matrix: os: [windows-latest] - python-version: ["3.12", "3.11", "3.10", "3.9"] + python-version: ["3.12", "3.11", "3.10"] steps: - uses: actions/checkout@v4 # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} @@ -51,18 +51,25 @@ jobs: # -- DISABLED: # - name: Show Python version # run: python --version + # -- SPEED-UP: Use "uv" to speed up installation of package dependencies. + - name: Install uv + run: python -m pip install -U uv - name: Install Python package dependencies run: | - python -m pip install -U pip setuptools wheel - pip install --upgrade -r py.requirements/ci.github.testing.txt - pip install -e . + python -m uv pip install -U pip setuptools wheel + python -m uv pip install --upgrade -r py.requirements/ci.github.testing.txt + python -m uv pip install -e . + # -- OLD: + # python -m pip install -U pip setuptools wheel + # pip install --upgrade -r py.requirements/ci.github.testing.txt + # pip install -e . - name: Run tests run: pytest - name: Run behave tests run: | - behave --format=progress3 features - behave --format=progress3 tools/test-features - behave --format=progress3 issue.features + behave --format=progress features + behave --format=progress tools/test-features + behave --format=progress issue.features - name: Upload test reports uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5b53d504d..10650ac3c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,7 +36,7 @@ jobs: matrix: # PREPARED: os: [ubuntu-latest, macos-latest, windows-latest] os: [ubuntu-latest] - python-version: ["3.12", "3.11", "3.10", "3.9", "pypy-3.10", "pypy-2.7"] + python-version: ["3.12", "3.11", "3.10", "pypy-3.10"] steps: - uses: actions/checkout@v4 # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} @@ -48,11 +48,18 @@ jobs: # -- DISABLED: # - name: Show Python version # run: python --version + # -- SPEED-UP: Use "uv" to speed up installation of package dependencies. + - name: Install uv + run: python -m pip install -U uv - name: Install Python package dependencies run: | - python -m pip install -U pip setuptools wheel - pip install --upgrade -r py.requirements/ci.github.testing.txt - pip install -e . + python -m uv pip install -U pip setuptools wheel + python -m uv pip install --upgrade -r py.requirements/ci.github.testing.txt + python -m uv pip install -e . + # -- OLD: + # python -m pip install -U pip setuptools wheel + # pip install --upgrade -r py.requirements/ci.github.testing.txt + # pip install -e . - name: Run tests run: pytest - name: Run behave tests From 17db17aea9e1859d579a03ae80297b753d33d289 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 26 May 2024 17:13:38 +0200 Subject: [PATCH 217/240] BUMP-VERSION: 1.2.7.dev6 (was: 1.2.7.dev5) --- .bumpversion.cfg | 2 +- VERSION.txt | 2 +- behave/version.py | 2 +- setup.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 6fac22e71..55b264263 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.2.7.dev5 +current_version = 1.2.7.dev6 files = behave/version.py setup.py VERSION.txt .bumpversion.cfg parse = (?P\d+)\.(?P\d+)\.(?P\d+)(?P\w*) serialize = {major}.{minor}.{patch}{drop} diff --git a/VERSION.txt b/VERSION.txt index e353c6873..e7e6efeb1 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.2.7.dev5 +1.2.7.dev6 diff --git a/behave/version.py b/behave/version.py index 72c278160..4bc7659a4 100644 --- a/behave/version.py +++ b/behave/version.py @@ -1,2 +1,2 @@ # -- BEHAVE-VERSION: -VERSION = "1.2.7.dev5" +VERSION = "1.2.7.dev6" diff --git a/setup.py b/setup.py index cf953cbd9..6c121468c 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ def find_packages_by_root_package(where): # ----------------------------------------------------------------------------- setup( name="behave", - version="1.2.7.dev5", + version="1.2.7.dev6", description="behave is behaviour-driven development, Python style", long_description=description, author="Jens Engel, Benno Rice and Richard Jones", From 5b842a7031e4045708f3fb005fc427b6ca677a5a Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 26 May 2024 17:23:39 +0200 Subject: [PATCH 218/240] CI: Tweak install-packages w/ "uv" --- .github/workflows/tests-windows.yml | 10 +++------- .github/workflows/tests.yml | 10 +++------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index cd396ad9a..e6560eff9 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -51,18 +51,14 @@ jobs: # -- DISABLED: # - name: Show Python version # run: python --version + # -- SPEED-UP: Use "uv" to speed up installation of package dependencies. - - name: Install uv - run: python -m pip install -U uv - - name: Install Python package dependencies + - name: "Install Python package dependencies (with: uv)" run: | + python -m pip install -U uv python -m uv pip install -U pip setuptools wheel python -m uv pip install --upgrade -r py.requirements/ci.github.testing.txt python -m uv pip install -e . - # -- OLD: - # python -m pip install -U pip setuptools wheel - # pip install --upgrade -r py.requirements/ci.github.testing.txt - # pip install -e . - name: Run tests run: pytest - name: Run behave tests diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 10650ac3c..9a55e3b11 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -48,18 +48,14 @@ jobs: # -- DISABLED: # - name: Show Python version # run: python --version + # -- SPEED-UP: Use "uv" to speed up installation of package dependencies. - - name: Install uv - run: python -m pip install -U uv - - name: Install Python package dependencies + - name: "Install Python package dependencies (with: uv)" run: | + python -m pip install -U uv python -m uv pip install -U pip setuptools wheel python -m uv pip install --upgrade -r py.requirements/ci.github.testing.txt python -m uv pip install -e . - # -- OLD: - # python -m pip install -U pip setuptools wheel - # pip install --upgrade -r py.requirements/ci.github.testing.txt - # pip install -e . - name: Run tests run: pytest - name: Run behave tests From f0007da3d387c6eb342dd18469a6c52871e01a88 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 26 May 2024 20:04:50 +0200 Subject: [PATCH 219/240] ADAPT #1097: Support And-Step as initial Scenario step Usable if any background step with Given/When/Then step_type exists. parser: * Extract-method "_select_last_background_step_type()" for core functionality. * Extend functionality to cover all cases with/without bachground steps tests/unit/test_parser.py: * Extend tests and move them into "TestParser4AndButSteps" class * CLEANUP: "test_parser.py" -- Simplify imports --- CHANGES.rst | 1 + behave/model.py | 2 +- behave/parser.py | 43 +++- tests/unit/test_parser.py | 427 +++++++++++++++++++++++--------------- 4 files changed, 290 insertions(+), 183 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 324cbd791..3e32c7638 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -73,6 +73,7 @@ ENHANCEMENTS: * Use cucumber "gherkin-languages.json" now (simplify: Gherkin v6 aliases, language usage) * Support emojis in ``*.feature`` files and steps * Select-by-location: Add support for "Scenario container" (Feature, Rule, ScenarioOutline) (related to: #391) +* pull #1097: Support And-Step as initial Scenario step if Background Steps exist (provided-by: aneeshdurg) * pull #988: setup.py: Add category to install additional formatters (html) (provided-by: bittner) * pull #895: UPDATE: i18n/gherkin-languages.json from cucumber repository #895 (related to: #827) * issue #889: Warn or error about incorrectly configured formatter aliases (provided by: jenisys, submitted by: bittner) diff --git a/behave/model.py b/behave/model.py index 0c5b93240..fa365f2f9 100644 --- a/behave/model.py +++ b/behave/model.py @@ -826,7 +826,7 @@ def iter_steps(self): @property def all_steps(self): - return self.iter_steps() + return list(self.iter_steps()) @property def duration(self): diff --git a/behave/parser.py b/behave/parser.py index ba0e60b67..e468f5c09 100644 --- a/behave/parser.py +++ b/behave/parser.py @@ -782,6 +782,7 @@ def parse_step(self, line): line.lower().startswith(kw.lower())): # -- CASE: Line does not start w/ a step-keyword. continue + # -- HINT: Trailing SPACE is used for most keywords. # BUT: Keywords in some languages (like Chinese, Japanese, ...) # do not need a whitespace as word separator. @@ -792,18 +793,16 @@ def parse_step(self, line): step_type = self.last_step_type elif step_type in ("and", "but"): if not self.last_step_type: - found_step_type = False - if self.scenario_container and self.scenario_container.background: - if self.scenario_container.background.steps: - # -- HINT: Rule may have default background w/o steps. - last_background_step = self.scenario_container.background.steps[-1] - self.last_step_type = last_background_step.step_type - found_step_type = True - - if not found_step_type: - raise ParserError(u"No previous step", + # -- BEST-EFFORT: Try to use last background.step. + self.last_step_type = self._select_last_background_step_type() + if not self.last_step_type: + msg = u"{step_type}-STEP REQUIRES: An previous Given/When/Then step." + raise ParserError(msg.format(step_type=step_type.upper()), self.line, self.filename) + + assert self.last_step_type is not None step_type = self.last_step_type + assert step_type is not None else: self.last_step_type = step_type @@ -813,6 +812,30 @@ def parse_step(self, line): return step return None + def _select_last_background_step_type(self): + # -- CASES: + # * CASE 1: With background.steps/background.inherited_steps + # - Feature with Background and background.steps + # - Rule with Background and background.steps + # - Rule with Background w/o background.steps but w/ inherited steps + # + # * CASE 2: No background.steps + # - Feature without Background + # - Feature with Background but w/o background.steps + # - Rule without Background + # - Rule with Background w/o background.steps and w/o inherited steps + last_background_step_type = None + if self.scenario_container and self.scenario_container.background: + # -- HINT: Consider background.steps and inherited steps. + this_background = self.scenario_container.background + this_background_steps = (this_background.steps or + this_background.inherited_steps) + if this_background_steps: + # -- HINT: Feature/Rule may have background w/o steps. + last_background_step = this_background_steps[-1] + last_background_step_type = last_background_step.step_type + return last_background_step_type + def parse_steps(self, text, filename=None): """Parse support for execute_steps() functionality that supports step with: diff --git a/tests/unit/test_parser.py b/tests/unit/test_parser.py index 21b1533ab..cf994ff5a 100644 --- a/tests/unit/test_parser.py +++ b/tests/unit/test_parser.py @@ -6,19 +6,21 @@ from __future__ import absolute_import, print_function import pytest -from behave import i18n, model, parser +from behave import i18n +from behave.model import Table, Tag +from behave.parser import ( + DEFAULT_LANGUAGE, + ParserError, + parse_feature, + parse_steps, + parse_tags, +) # --------------------------------------------------------------------------- # TEST SUPPORT # --------------------------------------------------------------------------- -def parse_tags(line): - the_parser = parser.Parser() - return the_parser.parse_tags(line.strip()) - - def assert_compare_steps(steps, expected): - # OLD: have = [(s.step_type, s.keyword, s.name, s.text, s.table) for s in steps] have = [(s.step_type, s.keyword.strip(), s.name, s.text, s.table) for s in steps] assert have == expected @@ -30,11 +32,11 @@ class TestParser(object): # pylint: disable=too-many-public-methods, no-self-use def test_parses_feature_name(self): - feature = parser.parse_feature(u"Feature: Stuff\n") + feature = parse_feature(u"Feature: Stuff\n") assert feature.name == "Stuff" def test_parses_feature_name_without_newline(self): - feature = parser.parse_feature(u"Feature: Stuff") + feature = parse_feature(u"Feature: Stuff") assert feature.name == "Stuff" def test_parses_feature_description(self): @@ -44,7 +46,7 @@ def test_parses_feature_description(self): As an entity I want to do stuff """.strip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" assert feature.description == [ "In order to thing", @@ -60,14 +62,14 @@ def test_parses_feature_with_a_tag(self): As an entity I want to do stuff """.strip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" assert feature.description == [ "In order to thing", "As an entity", "I want to do stuff" ] - assert feature.tags == [model.Tag(u'foo', 1)] + assert feature.tags == [Tag(u'foo', 1)] def test_parses_feature_with_more_tags(self): doc = u""" @@ -77,7 +79,7 @@ def test_parses_feature_with_more_tags(self): As an entity I want to do stuff """.strip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" assert feature.description == [ "In order to thing", @@ -85,7 +87,7 @@ def test_parses_feature_with_more_tags(self): "I want to do stuff" ] assert feature.tags == [ - model.Tag(name, 1) + Tag(name, 1) for name in (u'foo', u'bar', u'baz', u'qux', u'winkle_pickers', u'number8') ] @@ -97,14 +99,14 @@ def test_parses_feature_with_a_tag_and_comment(self): As an entity I want to do stuff """.strip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" assert feature.description == [ "In order to thing", "As an entity", "I want to do stuff" ] - assert feature.tags, [model.Tag(u'foo', 1)] + assert feature.tags, [Tag(u'foo', 1)] def test_parses_feature_with_more_tags_and_comment(self): doc = u""" @@ -114,7 +116,7 @@ def test_parses_feature_with_more_tags_and_comment(self): As an entity I want to do stuff """.strip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" assert feature.description == [ "In order to thing", @@ -122,7 +124,7 @@ def test_parses_feature_with_more_tags_and_comment(self): "I want to do stuff" ] assert feature.tags == [ - model.Tag(name, 1) + Tag(name, 1) for name in (u'foo', u'bar', u'baz', u'qux', u'winkle_pickers') ] # -- NOT A TAG: u'number8' @@ -135,7 +137,7 @@ def test_parses_feature_with_background(self): When I do stuff Then stuff happens """.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" assert feature.background assert_compare_steps(feature.background.steps, [ @@ -154,7 +156,7 @@ def test_parses_feature_with_description_and_background(self): When I do stuff Then stuff happens """.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" assert feature.description == ["This... is... STUFF!"] assert feature.background @@ -173,7 +175,7 @@ def test_parses_feature_with_a_scenario(self): When I do stuff Then stuff happens """.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" assert len(feature.scenarios) == 1 assert feature.scenarios[0].name == "Doing stuff" @@ -192,7 +194,7 @@ def test_parses_lowercase_step_keywords(self): when I do stuff tHEn stuff happens """.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" assert len(feature.scenarios) == 1 assert feature.scenarios[0].name == "Doing stuff" @@ -211,7 +213,7 @@ def test_parses_ja_keywords(self): もしI do stuff ならばstuff happens """.lstrip() - feature = parser.parse_feature(doc, language='ja') + feature = parse_feature(doc, language='ja') assert feature.name == "Stuff" assert len(feature.scenarios) == 1 assert feature.scenarios[0].name, "Doing stuff" @@ -234,7 +236,7 @@ def test_parses_feature_with_description_and_background_and_scenario(self): When I do stuff Then stuff happens """.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" assert feature.description == ["Oh my god, it's full of stuff..."] assert feature.background @@ -267,7 +269,7 @@ def test_parses_feature_with_multiple_scenarios(self): Given stuff Then who gives a stuff """.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" assert len(feature.scenarios) == 3 @@ -309,7 +311,7 @@ def test_parses_feature_with_multiple_scenarios_with_tags(self): Given stuff Then who gives a stuff """.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" assert len(feature.scenarios) == 3 @@ -321,7 +323,7 @@ def test_parses_feature_with_multiple_scenarios_with_tags(self): ]) assert feature.scenarios[1].name == "Doing other stuff" - assert feature.scenarios[1].tags == [model.Tag(u"one_tag", 1)] + assert feature.scenarios[1].tags == [Tag(u"one_tag", 1)] assert_compare_steps(feature.scenarios[1].steps, [ ('when', 'When', 'stuff happens', None, None), ('then', 'Then', 'I am stuffed', None, None), @@ -329,7 +331,7 @@ def test_parses_feature_with_multiple_scenarios_with_tags(self): assert feature.scenarios[2].name == "Doing different stuff" assert feature.scenarios[2].tags == [ - model.Tag(n, 1) for n in (u'lots', u'of', u'tags')] + Tag(n, 1) for n in (u'lots', u'of', u'tags')] assert_compare_steps(feature.scenarios[2].steps, [ ('given', 'Given', 'stuff', None, None), ('then', 'Then', 'who gives a stuff', None, None), @@ -356,7 +358,7 @@ def test_parses_feature_with_multiple_scenarios_and_other_bits(self): Given stuff Then who gives a stuff """.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" assert feature.description, ["Stuffing"] @@ -385,58 +387,6 @@ def test_parses_feature_with_multiple_scenarios_and_other_bits(self): ('then', 'Then', 'who gives a stuff', None, None), ]) - def test_parses_feature_with_a_scenario_with_and_and_but(self): - doc = u""" -Feature: Stuff - - Scenario: Doing stuff - Given there is stuff - And some other stuff - When I do stuff - Then stuff happens - But not the bad stuff -""".lstrip() - feature = parser.parse_feature(doc) - assert feature.name == "Stuff" - assert len(feature.scenarios) == 1 - assert feature.scenarios[0].name == "Doing stuff" - assert_compare_steps(feature.scenarios[0].steps, [ - ('given', 'Given', 'there is stuff', None, None), - ('given', 'And', 'some other stuff', None, None), - ('when', 'When', 'I do stuff', None, None), - ('then', 'Then', 'stuff happens', None, None), - ('then', 'But', 'not the bad stuff', None, None), - ]) - - def test_parses_feature_with_a_scenario_with_background_and_and(self): - doc = u""" -Feature: Stuff - Background: - Given some background - And more background - - Scenario: Doing stuff - And with the background - When I do stuff - Then stuff happens -""".lstrip() - feature = parser.parse_feature(doc) - assert feature - assert feature.name == "Stuff" - assert len(feature.scenarios) == 1 - assert feature.background - assert_compare_steps(feature.background.steps, [ - ('given', 'Given', 'some background', None, None), - ('given', 'And', 'more background', None, None) - ]) - - assert feature.scenarios[0].name == "Doing stuff" - assert_compare_steps(feature.scenarios[0].steps, [ - ('given', 'And', 'with the background', None, None), - ('when', 'When', 'I do stuff', None, None), - ('then', 'Then', 'stuff happens', None, None), - ]) - def test_parses_feature_with_a_step_with_a_string_argument(self): doc = u''' Feature: Stuff @@ -450,7 +400,7 @@ def test_parses_feature_with_a_step_with_a_string_argument(self): """ Then stuff happens '''.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" assert len(feature.scenarios) == 1 assert feature.scenarios[0].name == "Doing stuff" @@ -474,7 +424,7 @@ def test_parses_string_argument_correctly_handle_whitespace(self): """ Then stuff happens '''.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" assert len(feature.scenarios) == 1 assert feature.scenarios[0].name == "Doing stuff" @@ -500,7 +450,7 @@ def test_parses_feature_with_a_step_with_a_string_with_blank_lines(self): """ Then stuff happens '''.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" assert len(feature.scenarios) == 1 assert feature.scenarios[0].name == "Doing stuff" @@ -528,7 +478,7 @@ def test_parses_string_argument_without_stripping_empty_lines(self): """ Then empty middle lines are not stripped '''.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Multiline" assert len(feature.scenarios) == 1 assert feature.scenarios[0].name == "Multiline Text with Comments" @@ -554,7 +504,7 @@ def test_parses_feature_with_a_step_with_a_string_with_comments(self): """ Then stuff happens '''.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" assert len(feature.scenarios) == 1 assert feature.scenarios[0].name == "Doing stuff" @@ -575,11 +525,11 @@ def test_parses_feature_with_a_step_with_a_table_argument(self): | green | variable | awkward | Then stuff is in buckets '''.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" assert len(feature.scenarios) == 1 assert feature.scenarios[0].name == "Doing stuff" - table = model.Table( + table = Table( [u'type of stuff', u'awesomeness', u'ridiculousness'], 0, [ @@ -607,9 +557,9 @@ def test_parses_feature_with_table_and_escaped_pipe_in_cell_values(self): | charly | one\\|| | doro | one\\|two\\|three\\|four | '''.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert len(feature.scenarios) == 1 - table = model.Table( + table = Table( [u"name", u"value"], 0, [ @@ -639,12 +589,12 @@ def test_parses_feature_with_a_scenario_outline(self): | wood | paper | | explosives | hilarity | '''.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" assert len(feature.scenarios) == 1 assert feature.scenarios[0].name == "Doing all sorts of stuff" - table = model.Table( + table = Table( [u'Stuff', u'Things'], 0, [ @@ -681,7 +631,7 @@ def test_parses_feature_with_a_scenario_outline_with_multiple_examples(self): | wood | paper | | explosives | hilarity | '''.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" assert len(feature.scenarios) == 1 @@ -692,7 +642,7 @@ def test_parses_feature_with_a_scenario_outline_with_multiple_examples(self): ('then', 'Then', 'we have ', None, None), ]) - table = model.Table( + table = Table( [u'Stuff', u'Things'], 0, [ @@ -703,7 +653,7 @@ def test_parses_feature_with_a_scenario_outline_with_multiple_examples(self): assert feature.scenarios[0].examples[0].name == "Some stuff" assert feature.scenarios[0].examples[0].table == table - table = model.Table( + table = Table( [u'Stuff', u'Things'], 0, [ @@ -731,13 +681,13 @@ def test_parses_feature_with_a_scenario_outline_with_tags(self): | wood | paper | | explosives | hilarity | '''.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" assert len(feature.scenarios) == 1 assert feature.scenarios[0].name == "Doing all sorts of stuff" assert feature.scenarios[0].tags == [ - model.Tag(u'stuff', 1), model.Tag(u'derp', 1) + Tag(u'stuff', 1), Tag(u'derp', 1) ] assert_compare_steps(feature.scenarios[0].steps, [ ('given', 'Given', 'we have ', None, None), @@ -745,7 +695,7 @@ def test_parses_feature_with_a_scenario_outline_with_tags(self): ('then', 'Then', 'we have ', None, None), ]) - table = model.Table( + table = Table( [u'Stuff', u'Things'], 0, [ @@ -773,18 +723,18 @@ def test_parses_scenario_outline_with_tagged_examples1(self): | wool | felt | | cotton | thread | '''.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Alice" assert len(feature.scenarios) == 1 scenario_outline = feature.scenarios[0] assert scenario_outline.name == "Bob" - assert scenario_outline.tags == [model.Tag(u"foo", 1)] + assert scenario_outline.tags == [Tag(u"foo", 1)] assert_compare_steps(scenario_outline.steps, [ ("given", "Given", "we have ", None, None), ]) - table = model.Table( + table = Table( [u"Stuff", u"Things"], 0, [ [u"wool", u"felt"], @@ -793,12 +743,12 @@ def test_parses_scenario_outline_with_tagged_examples1(self): ) assert scenario_outline.examples[0].name == "Charly" assert scenario_outline.examples[0].table == table - assert scenario_outline.examples[0].tags == [model.Tag(u"bar", 1)] + assert scenario_outline.examples[0].tags == [Tag(u"bar", 1)] # -- ScenarioOutline.scenarios: # Inherit tags from ScenarioOutline and Examples element. assert len(scenario_outline.scenarios) == 2 - expected_tags = [model.Tag(u"foo", 1), model.Tag(u"bar", 1)] + expected_tags = [Tag(u"foo", 1), Tag(u"bar", 1)] assert set(scenario_outline.scenarios[0].tags) == set(expected_tags) assert set(scenario_outline.scenarios[1].tags), set(expected_tags) @@ -818,18 +768,18 @@ def test_parses_scenario_outline_with_tagged_examples2(self): | wool | felt | | cotton | thread | '''.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Alice" assert len(feature.scenarios) == 1 scenario_outline = feature.scenarios[0] assert scenario_outline.name == "Bob" - assert scenario_outline.tags == [model.Tag(u"foo", 1)] + assert scenario_outline.tags == [Tag(u"foo", 1)] assert_compare_steps(scenario_outline.steps, [ ("given", "Given", "we have ", None, None), ]) - table = model.Table( + table = Table( [u"Stuff", u"Things"], 0, [ [u"wool", u"felt"], @@ -838,16 +788,16 @@ def test_parses_scenario_outline_with_tagged_examples2(self): ) assert scenario_outline.examples[0].name == "Charly" assert scenario_outline.examples[0].table == table - expected_tags = [model.Tag(u"bar", 1), model.Tag(u"baz", 1)] + expected_tags = [Tag(u"bar", 1), Tag(u"baz", 1)] assert scenario_outline.examples[0].tags == expected_tags # -- ScenarioOutline.scenarios: # Inherit tags from ScenarioOutline and Examples element. assert len(scenario_outline.scenarios) == 2 expected_tags = [ - model.Tag(u"foo", 1), - model.Tag(u"bar", 1), - model.Tag(u"baz", 1) + Tag(u"foo", 1), + Tag(u"bar", 1), + Tag(u"baz", 1) ] assert set(scenario_outline.scenarios[0].tags) == set(expected_tags) assert set(scenario_outline.scenarios[1].tags), set(expected_tags) @@ -916,9 +866,9 @@ def test_parses_feature_with_the_lot(self): | wood | paper | | explosives | hilarity | '''.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Stuff" - assert feature.tags == [model.Tag(u'derp', 1)] + assert feature.tags == [Tag(u'derp', 1)] assert feature.description == [ "In order to test my parser", "As a test runner", @@ -933,7 +883,7 @@ def test_parses_feature_with_the_lot(self): assert len(feature.scenarios) == 4 assert feature.scenarios[0].name == 'Testing stuff' - assert feature.scenarios[0].tags == [model.Tag(u'fred', 1)] + assert feature.scenarios[0].tags == [Tag(u'fred', 1)] string = '\n'.join([ 'Yarr, my hovercraft be full of stuff.', "Also, I be feelin' this pirate schtick be a mite overdone, " + \ @@ -950,7 +900,7 @@ def test_parses_feature_with_the_lot(self): assert feature.scenarios[1].name == "Gosh this is long" assert feature.scenarios[1].tags == [] - table = model.Table( + table = Table( [u'length', u'width', u'height'], 0, [ @@ -960,7 +910,7 @@ def test_parses_feature_with_the_lot(self): ) assert feature.scenarios[1].examples[0].name == "Initial" assert feature.scenarios[1].examples[0].table == table - table = model.Table( + table = Table( [u'length', u'width', u'height'], 0, [ @@ -982,7 +932,7 @@ def test_parses_feature_with_the_lot(self): ('then', 'Then', "we don't really mind", None, None), ]) - table = model.Table( + table = Table( [u'Stuff', u'Things'], 0, [ @@ -993,10 +943,10 @@ def test_parses_feature_with_the_lot(self): ] ) assert feature.scenarios[3].name == "Doing all sorts of stuff" - assert feature.scenarios[3].tags == [model.Tag(u'stuff', 1), model.Tag(u'derp', 1)] + assert feature.scenarios[3].tags == [Tag(u'stuff', 1), Tag(u'derp', 1)] assert feature.scenarios[3].examples[0].name == "Some stuff" assert feature.scenarios[3].examples[0].table == table - table = model.Table( + table = Table( [u'a', u'b', u'c', u'd', u'e'], 0, [ @@ -1018,8 +968,8 @@ def test_fails_to_parse_when_and_is_out_of_order(self): Scenario: Failing at stuff And we should fail """.lstrip() - with pytest.raises(parser.ParserError): - parser.parse_feature(text) + with pytest.raises(ParserError): + parse_feature(text) def test_fails_to_parse_when_but_is_out_of_order(self): text = u""" @@ -1028,8 +978,8 @@ def test_fails_to_parse_when_but_is_out_of_order(self): Scenario: Failing at stuff But we shall fail """.lstrip() - with pytest.raises(parser.ParserError): - parser.parse_feature(text) + with pytest.raises(ParserError): + parse_feature(text) def test_fails_to_parse_when_examples_is_in_the_wrong_place(self): text = u""" @@ -1041,8 +991,141 @@ def test_fails_to_parse_when_examples_is_in_the_wrong_place(self): Examples: Failure | Fail | Wheel| """.lstrip() - with pytest.raises(parser.ParserError): - parser.parse_feature(text) + with pytest.raises(ParserError): + parse_feature(text) + + +class TestParser4AndButSteps(object): + def test_parse_scenario_with_and_and_but(self): + doc = u""" +Feature: Stuff + + Scenario: Doing stuff + Given there is stuff + And some other stuff + When I do stuff + Then stuff happens + But not the bad stuff +""".lstrip() + feature = parse_feature(doc) + assert feature.name == "Stuff" + assert len(feature.scenarios) == 1 + assert feature.scenarios[0].name == "Doing stuff" + assert_compare_steps(feature.scenarios[0].steps, [ + ('given', 'Given', 'there is stuff', None, None), + ('given', 'And', 'some other stuff', None, None), + ('when', 'When', 'I do stuff', None, None), + ('then', 'Then', 'stuff happens', None, None), + ('then', 'But', 'not the bad stuff', None, None), + ]) + + @pytest.mark.parametrize("step_keyword", ["And", "But"]) + def test_parse_scenario_starts_with_and_step__without_background_steps_raises_error(self, step_keyword): + doc = u""" +Feature: Scenario first step uses And/But without background.steps + Scenario: S1 + {step_keyword} with the background +""".lstrip().format(step_keyword=step_keyword) + + with pytest.raises(ParserError) as exc_info: + _ = parse_feature(doc) + + expected = "{keyword}-STEP REQUIRES: An previous Given/When/Then step.".format( + keyword=step_keyword.upper() + ) + assert expected in str(exc_info.value) + + @pytest.mark.parametrize("step_keyword", ["And", "But"]) + def test_parse_scenario_starts_with_and_step__with_feature_background_steps(self, step_keyword): + doc = u""" +Feature: Scenario first step uses And/But + Background: + Given some background + And more background + + Scenario: S1 + {step_keyword} with the background + When I do stuff +""".lstrip().format(step_keyword=step_keyword) + + feature = parse_feature(doc) + assert feature is not None + assert feature.background is not None + assert feature.background.steps + assert_compare_steps(feature.background.steps, [ + ("given", "Given", "some background", None, None), + ("given", "And", "more background", None, None) + ]) + + this_scenario = feature.scenarios[0] + assert_compare_steps(this_scenario.steps, [ + ("given", step_keyword, "with the background", None, None), + ("when", "When", "I do stuff", None, None), + ]) + + @pytest.mark.parametrize("step_keyword", ["And", "But"]) + def test_parse_scenario_starts_with_and_step__with_rule_background_steps(self, step_keyword): + doc = u""" +Feature: Scenario first step uses And/But + Rule: R1 + Background: + Given some background + And more background + + Scenario: R1.S1 + {step_keyword} with the background + When I do stuff +""".lstrip().format(step_keyword=step_keyword) + + feature = parse_feature(doc) + assert feature is not None + assert feature.rules + assert feature.rules[0].background is not None + assert feature.rules[0].background.steps + this_background = feature.rules[0].background + assert_compare_steps(this_background.steps, [ + ("given", "Given", "some background", None, None), + ("given", "And", "more background", None, None) + ]) + + this_scenario = feature.rules[0].scenarios[0] + assert_compare_steps(this_scenario.steps, [ + ("given", step_keyword, "with the background", None, None), + ("when", "When", "I do stuff", None, None), + ]) + + @pytest.mark.parametrize("step_keyword", ["And", "But"]) + def test_parse_scenario_starts_with_and_step__with_rule_inherited_steps(self, step_keyword): + doc = u""" +Feature: Scenario first step uses And/But + Background: + Given some background + And more background + + Rule: R1 + Scenario: R1.S1 + {step_keyword} with the background + When I do stuff +""".lstrip().format(step_keyword=step_keyword) + + feature = parse_feature(doc) + assert feature is not None + assert feature.rules + assert feature.rules[0].background is not None + + this_background = feature.rules[0].background + assert not this_background.steps + assert this_background.inherited_steps + assert_compare_steps(this_background.inherited_steps, [ + ("given", "Given", "some background", None, None), + ("given", "And", "more background", None, None) + ]) + + this_scenario = feature.rules[0].scenarios[0] + assert_compare_steps(this_scenario.steps, [ + ("given", step_keyword, "with the background", None, None), + ("when", "When", "I do stuff", None, None), + ]) class TestForeign(object): @@ -1055,7 +1138,7 @@ def test_first_line_comment_sets_language(self): Oh my god, it's full of stuff... """ - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "testing stuff" assert feature.description == ["Oh my god, it's full of stuff..."] @@ -1068,7 +1151,7 @@ def test_multiple_language_comments(self): Oh my god, it's full of stuff... """ - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "testing stuff" assert feature.description == ["Oh my god, it's full of stuff..."] @@ -1079,7 +1162,7 @@ def test_language_comment_wins_over_commandline(self): Oh my god, it's full of stuff... """ - feature = parser.parse_feature(doc, language="de") + feature = parse_feature(doc, language="de") assert feature.name == "testing stuff" assert feature.description == ["Oh my god, it's full of stuff..."] @@ -1092,7 +1175,7 @@ def test_whitespace_before_first_line_comment_still_sets_language(self): Oh my god, it's full of stuff... """ - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "testing stuff" assert feature.description == ["Oh my god, it's full of stuff..."] @@ -1104,11 +1187,11 @@ def test_anything_before_language_comment_makes_it_not_count(self): Arwedd: testing stuff Oh my god, it's full of stuff... """ - with pytest.raises(parser.ParserError): - parser.parse_feature(text) + with pytest.raises(ParserError): + parse_feature(text) def test_defaults_to_DEFAULT_LANGUAGE(self): - feature_kwd = i18n.languages[parser.DEFAULT_LANGUAGE]['feature'][0] + feature_kwd = i18n.languages[DEFAULT_LANGUAGE]['feature'][0] doc = u""" @wombles @@ -1117,7 +1200,7 @@ def test_defaults_to_DEFAULT_LANGUAGE(self): Oh my god, it's full of stuff... """ % feature_kwd - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "testing stuff" assert feature.description == ["Oh my god, it's full of stuff..."] @@ -1128,7 +1211,7 @@ def test_whitespace_in_the_language_comment_is_flexible_1(self): Oh my god, it's full of stuff... """ - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "testing stuff" assert feature.description == ["Oh my god, it's full of stuff..."] @@ -1139,7 +1222,7 @@ def test_whitespace_in_the_language_comment_is_flexible_2(self): Oh my god, it's full of stuff... """ - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "testing stuff" assert feature.description == ["Oh my god, it's full of stuff..."] @@ -1150,7 +1233,7 @@ def test_whitespace_in_the_language_comment_is_flexible_3(self): Oh my god, it's full of stuff... """ - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "testing stuff" assert feature.description == ["Oh my god, it's full of stuff..."] @@ -1161,7 +1244,7 @@ def test_whitespace_in_the_language_comment_is_flexible_4(self): Oh my god, it's full of stuff... """ - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "testing stuff" assert feature.description == ["Oh my god, it's full of stuff..."] @@ -1181,7 +1264,7 @@ def test_parses_french(self): Soit I am testing stuff Alors it will work """.lstrip() - feature = parser.parse_feature(doc, 'fr') + feature = parse_feature(doc, 'fr') assert feature.name == "testing stuff" assert feature.description == ["Oh my god, it's full of stuff..."] assert feature.background @@ -1205,7 +1288,7 @@ def test_parses_french_multi_word(self): Etant donn\xe9 I am testing stuff Alors it should work """.lstrip() - feature = parser.parse_feature(doc, 'fr') + feature = parse_feature(doc, 'fr') assert feature.name == "testing stuff" assert feature.description == ["Oh my god, it's full of stuff..."] @@ -1231,7 +1314,7 @@ def __checkOLD_properly_handles_whitespace_on_keywords_that_do_not_want_it(self) \u4f46\u662fI should take it well """ - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "I have no idea what I'm saying" assert len(feature.scenarios) == 1 @@ -1273,7 +1356,7 @@ def test_properly_handles_whitespace_on_keywords_that_do_not_want_it(self): 並且I should take it well """ - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "I have no idea what I'm saying" assert len(feature.scenarios) == 1 @@ -1305,7 +1388,7 @@ def test_parse_scenario_description(self): When we do stuff Then we have things '''.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Scenario Description" assert len(feature.scenarios) == 1 @@ -1337,7 +1420,7 @@ def test_parse_scenario_with_description_but_without_steps(self): When we do stuff Then we have things '''.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Scenario Description" assert len(feature.scenarios) == 2 @@ -1374,7 +1457,7 @@ def test_parse_scenario_with_description_but_without_steps_followed_by_scenario_ When we do stuff Then we have things '''.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Scenario Description" assert len(feature.scenarios) == 2 @@ -1412,7 +1495,7 @@ def test_parse_two_scenarios_with_description(self): When we do stuff Then we have things '''.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Scenario Description" assert len(feature.scenarios) == 2 @@ -1448,7 +1531,7 @@ def test_parse_tags_with_more_tags(self): tags = parse_tags('@one @two.three-four @xxx') assert len(tags) == 3 assert tags == [ - model.Tag(name, 1) + Tag(name, 1) for name in (u'one', u'two.three-four', u'xxx' ) ] @@ -1461,12 +1544,12 @@ def test_parse_tags_with_tags_and_comment(self): tags = parse_tags('@one @two.three-four @xxx # @fake-tag-in-comment xxx') assert len(tags) == 3 assert tags == [ - model.Tag(name, 1) + Tag(name, 1) for name in (u'one', u'two.three-four', u'xxx') ] def test_parse_tags_with_invalid_tags(self): - with pytest.raises(parser.ParserError): + with pytest.raises(ParserError): parse_tags('@one invalid.tag boom') @@ -1489,7 +1572,7 @@ def test_parse_background(self): When we do stuff Then we have things '''.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Background" assert feature.description == [ "A feature description line 1.", @@ -1528,7 +1611,7 @@ def test_parse_background_with_description(self): Scenario: One '''.lstrip() - feature = parser.parse_feature(doc) + feature = parse_feature(doc) assert feature.name == "Background" assert feature.description == [ "A feature description line 1.", @@ -1559,8 +1642,8 @@ def test_parse_background_with_tags_should_fail(self): Background: One Given we init stuff '''.lstrip() - with pytest.raises(parser.ParserError): - parser.parse_feature(text) + with pytest.raises(ParserError): + parse_feature(text) def test_parse_two_background_should_fail(self): @@ -1575,8 +1658,8 @@ def test_parse_two_background_should_fail(self): Background: Two When we init more stuff '''.lstrip() - with pytest.raises(parser.ParserError): - parser.parse_feature(text) + with pytest.raises(ParserError): + parse_feature(text) def test_parse_background_after_scenario_should_fail(self): @@ -1591,8 +1674,8 @@ def test_parse_background_after_scenario_should_fail(self): Background: Two When we init more stuff '''.lstrip() - with pytest.raises(parser.ParserError): - parser.parse_feature(text) + with pytest.raises(ParserError): + parse_feature(text) def test_parse_background_after_scenario_outline_should_fail(self): @@ -1610,13 +1693,13 @@ def test_parse_background_after_scenario_outline_should_fail(self): Background: Two When we init more stuff '''.lstrip() - with pytest.raises(parser.ParserError): - parser.parse_feature(text) + with pytest.raises(ParserError): + parse_feature(text) class TestParser4Steps(object): """ - Tests parser.parse_steps() and parser.Parser.parse_steps() functionality. + Tests parse_steps() and Parser.parse_steps() functionality. """ # pylint: disable=no-self-use @@ -1627,7 +1710,7 @@ def test_parse_steps_with_simple_steps(self): And I have another simple step Then every step will be parsed without errors '''.lstrip() - steps = parser.parse_steps(doc) + steps = parse_steps(doc) assert len(steps) == 4 # -- EXPECTED STEP DATA: # SCHEMA: step_type, keyword, name, text, table @@ -1652,7 +1735,7 @@ def test_parse_steps_with_multiline_text(self): """ Then every step will be parsed without errors '''.lstrip() - steps = parser.parse_steps(doc) + steps = parse_steps(doc) assert len(steps) == 3 # -- EXPECTED STEP DATA: # SCHEMA: step_type, keyword, name, text, table @@ -1674,7 +1757,7 @@ def test_parse_steps_when_last_step_has_multiline_text(self): Ipsum lorem """ '''.lstrip() - steps = parser.parse_steps(doc) + steps = parse_steps(doc) assert len(steps) == 2 # -- EXPECTED STEP DATA: # SCHEMA: step_type, keyword, name, text, table @@ -1698,15 +1781,15 @@ def test_parse_steps_with_table(self): | USA | Washington | Then every step will be parsed without errors '''.lstrip() - steps = parser.parse_steps(doc) + steps = parse_steps(doc) assert len(steps) == 3 # -- EXPECTED STEP DATA: # SCHEMA: step_type, keyword, name, text, table - table1 = model.Table([u"Name", u"Age"], 0, [ + table1 = Table([u"Name", u"Age"], 0, [ [ u"Alice", u"12" ], [ u"Bob", u"23" ], ]) - table2 = model.Table([u"Country", u"Capital"], 0, [ + table2 = Table([u"Country", u"Capital"], 0, [ [ u"France", u"Paris" ], [ u"Germany", u"Berlin" ], [ u"Spain", u"Madrid" ], @@ -1727,11 +1810,11 @@ def test_parse_steps_when_last_step_has_a_table(self): | Alonso | Barcelona | | Bred | London | '''.lstrip() - steps = parser.parse_steps(doc) + steps = parse_steps(doc) assert len(steps) == 2 # -- EXPECTED STEP DATA: # SCHEMA: step_type, keyword, name, text, table - table2 = model.Table([u"Name", u"City"], 0, [ + table2 = Table([u"Name", u"City"], 0, [ [ u"Alonso", u"Barcelona" ], [ u"Bred", u"London" ], ]) @@ -1747,8 +1830,8 @@ def test_parse_steps_with_malformed_table_fails(self): | Alonso | Barcelona | 2004 | | Bred | London | 2010 | '''.lstrip() - with pytest.raises(parser.ParserError): - parser.parse_steps(text) + with pytest.raises(ParserError): + parse_steps(text) def test_parse_steps_with_multiline_text_before_any_step_fails(self): text = u''' @@ -1757,8 +1840,8 @@ def test_parse_steps_with_multiline_text_before_any_step_fails(self): """ Given another step '''.lstrip() - with pytest.raises(parser.ParserError) as exc: - parser.parse_steps(text) + with pytest.raises(ParserError) as exc: + parse_steps(text) assert exc.match("Multi-line text before any step") @@ -1769,7 +1852,7 @@ def test_parse_steps_with_datatable_before_any_step_fails(self): | Bob | 2005 | Given another step '''.lstrip() - with pytest.raises(parser.ParserError) as exc: - parser.parse_steps(text) + with pytest.raises(ParserError) as exc: + parse_steps(text) assert exc.match("TABLE-START without step detected") From 26b4c5648cb3f694860311255eb3a1cc6aa23585 Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 27 May 2024 10:32:09 +0200 Subject: [PATCH 220/240] REFACTOR and CLEANUP: behave.matchers module * StepMatcherFactory: Add STEP_MATCHER_CLASSES (replaces: MATCHER_MAPPING) * Matcher class(es): Add class "NAME" needed for STEP_MATCHER_CLASSES * Matcher class(es): Use UPPER_CASE name style for class attributes * Introduce TypeRegistry protocol to simplify using step_matcher classes that support a "TypeRegistry" and others that do not (using: "TypeRegistryNotSupported"). * Matcher class(es): Use "TypeRegistry or "TypeRegistryNotSupported" RENAMED: * get_matcher_factory() -> get_step_matcher_factory() * make_matcher -> make_step_matcher --- behave/_stepimport.py | 10 +- behave/matchers.py | 214 +++++++++++++++++++------------ behave/runner_util.py | 12 +- behave/step_registry.py | 4 +- tests/unit/test_matchers.py | 10 +- tests/unit/test_step_registry.py | 7 +- 6 files changed, 152 insertions(+), 105 deletions(-) diff --git a/behave/_stepimport.py b/behave/_stepimport.py index e4372f63c..7e5a2babe 100644 --- a/behave/_stepimport.py +++ b/behave/_stepimport.py @@ -30,6 +30,7 @@ def setup_api_with_step_decorators(module, step_registry): _step_registry.setup_step_decorators(module, step_registry) + def setup_api_with_matcher_functions(module, step_matcher_factory): # -- PUBLIC API: Same as behave.api.step_matchers module.use_default_step_matcher = step_matcher_factory.use_default_step_matcher @@ -79,19 +80,20 @@ class StepMatchersModule(FakeModule): __all__ = [ "use_default_step_matcher", "use_step_matcher", - "step_matcher", # -- DEPRECATING "register_type" + # -- DEPRECATING: + "step_matcher", ] def __init__(self, step_matcher_factory): super(StepMatchersModule, self).__init__("behave.matchers") self.step_matcher_factory = step_matcher_factory setup_api_with_matcher_functions(self, step_matcher_factory) - self.make_matcher = step_matcher_factory.make_matcher + self.make_step_matcher = step_matcher_factory.make_step_matcher # -- DEPRECATED-FUNCTION-COMPATIBILITY - # self.get_matcher = self.make_matcher - # self.matcher_mapping = matcher_mapping or _matchers.matcher_mapping.copy() # self.current_matcher = current_matcher or _matchers.current_matcher + # self.get_matcher = self.make_step_matcher + # self.matcher_mapping = ... # -- INJECT PYTHON PACKAGE META-DATA: # REQUIRED-FOR: Non-fake submodule imports (__path__). diff --git a/behave/matchers.py b/behave/matchers.py index 5d557c863..dad10511e 100644 --- a/behave/matchers.py +++ b/behave/matchers.py @@ -142,6 +142,35 @@ def run(self, context): raise StepParseError(exc_cause=self.stored_error) +# ----------------------------------------------------------------------------- +# SECTION: TypeRegistry for Step Matchers (provide: TypeRegistry protocol) +# ----------------------------------------------------------------------------- +class TypeRegistry(dict): + def register_type(self, **kwargs): + """ + Register one (or more) user-defined types used for matching types + in step patterns of this matcher. + """ + self.update(**kwargs) + + def has_type(self, name): + return name in self + + +class TypeRegistryNotSupported(): + """ + Placeholder class for a type-registry if custom types are not supported. + """ + def register_type(self, **kwargs): + raise NotSupportedWarning("register_type") + + def has_type(self, name): + return False + + def clear(self): + pass + + # ----------------------------------------------------------------------------- # SECTION: Step Matchers # ----------------------------------------------------------------------------- @@ -149,12 +178,12 @@ class Matcher(object): """ Provides an abstract base class for step-matcher classes. - Matches steps from "*.feature" files (Gherkin files) + Matches steps from ``*.feature`` files (Gherkin files) and extracts step-parameters for these steps. RESPONSIBILITIES: - * Matches steps from "*.feature" files (or not) + * Matches steps from ``*.feature`` files (or not) * Returns :class:`Match` objects if this step-matcher matches that is used to run the step-definition function w/ its parameters. * Compile parse-expression/regular-expression to detect @@ -172,22 +201,36 @@ class Matcher(object): File location of the step-definition function. """ + NAME = None # -- HINT: Must be specified by derived class. + CUSTOM_TYPES = TypeRegistryNotSupported() + # -- DESCRIBE-SCHEMA FOR STEP-DEFINITIONS (step-matchers): SCHEMA = u"@{this.step_type}('{this.pattern}')" SCHEMA_AT_LOCATION = SCHEMA + u" at {this.location}" SCHEMA_WITH_LOCATION = SCHEMA + u" # {this.location}" SCHEMA_AS_STEP = u"{this.step_type} {this.pattern}" + # -- IMPLEMENT ADAPTER FOR: TypeRegistry protocol @classmethod def register_type(cls, **kwargs): """Register one (or more) user-defined types used for matching types in step patterns of this matcher. """ - raise NotImplementedError() + try: + cls.CUSTOM_TYPES.register_type(**kwargs) + except NotSupportedWarning: + # -- HINT: Provide DERIVED_CLASS name as failure context. + message = "{cls.__name__}.register_type".format(cls=cls) + raise NotSupportedWarning(message) + + @classmethod + def has_registered_type(cls, name): + return cls.CUSTOM_TYPES.has_type(name) @classmethod def clear_registered_types(cls): - raise NotImplementedError() + cls.CUSTOM_TYPES.clear() + # -- END-OF: TypeRegistry protocol def __init__(self, func, pattern, step_type=None): self.func = func @@ -274,7 +317,7 @@ def match(self, step_text): def matches(self, step_text): """ - Checks if :param:`step_text` matches this step-definition/step-matcher. + Checks if ``step_text`` parameter matches this step-definition (step-matcher). :param step_text: Step text to check. :return: True, if step is matched. False, otherwise. @@ -326,30 +369,17 @@ def step_given_amount_vehicles(ctx, amount): assert isinstance(amount, int) print("{amount} vehicles".format(amount=amount))} """ - custom_types = {} - parser_class = parse.Parser - case_sensitive = True - - @classmethod - def register_type(cls, **kwargs): - r""" - Register one (or more) user-defined types used for matching types - in step patterns of this matcher. - - A type converter should follow :pypi:`parse` module rules. - In general, a type converter is a function that converts text (as string) - into a value-type (type converted value). - """ - cls.custom_types.update(**kwargs) - - @classmethod - def clear_registered_types(cls): - cls.custom_types.clear() - - def __init__(self, func, pattern, step_type=None): + NAME = "parse" + PARSER_CLASS = parse.Parser + CASE_SENSITIVE = True + CUSTOM_TYPES = TypeRegistry() + + def __init__(self, func, pattern, step_type=None, custom_types=None): + if custom_types is None: + custom_types = self.CUSTOM_TYPES super(ParseMatcher, self).__init__(func, pattern, step_type) - self.parser = self.parser_class(pattern, self.custom_types, - case_sensitive=self.case_sensitive) + self.parser = self.PARSER_CLASS(pattern, extra_types=custom_types, + case_sensitive=self.CASE_SENSITIVE) @property def regex_pattern(self): @@ -369,7 +399,7 @@ def compile(self): def check_match(self, step_text): """ - Checks if the :param:`step_text` is matched (or not). + Checks if the ``step_text`` parameter is matched (or not). :param step_text: Step text to check. :return: step-args if step was matched, None otherwise. @@ -430,7 +460,8 @@ def step_many_numbers(ctx, numbers): assert matched is True # -- STEP MATCHES: numbers = [1, 2, 3] """ - parser_class = cfparse.Parser + NAME = "cfparse" + PARSER_CLASS = cfparse.Parser class RegexMatcher(Matcher): @@ -441,21 +472,8 @@ class RegexMatcher(Matcher): * Custom type-converters are NOT SUPPORTED. """ - - @classmethod - def register_type(cls, **kwargs): - """ - Register one (or more) user-defined types used for matching types - in step patterns of this matcher. - - NOTE: - This functionality is not supported for :class:`RegexMatcher` classes. - """ - raise NotSupportedWarning("%s.register_type" % cls.__name__) - - @classmethod - def clear_registered_types(cls): - pass # -- HINT: GRACEFULLY ignored. + NAME = "re0" + CUSTOM_TYPES = TypeRegistryNotSupported() def __init__(self, func, pattern, step_type=None): super(RegexMatcher, self).__init__(func, pattern, step_type) @@ -514,6 +532,7 @@ class SimplifiedRegexMatcher(RegexMatcher): def step_impl(context): pass """ + NAME = "re" def __init__(self, func, pattern, step_type=None): assert not (pattern.startswith("^") or pattern.endswith("$")), \ @@ -537,6 +556,7 @@ class CucumberRegexMatcher(RegexMatcher): @when(u'^a step passes$') # re.pattern = "^a step passes$" def step_impl(context): pass """ + NAME = "re0" # ----------------------------------------------------------------------------- @@ -601,33 +621,35 @@ class StepMatcherFactory(object): A step function writer may implement type conversion inside the step function (implementation). """ - MATCHER_MAPPING = { - "parse": ParseMatcher, - "cfparse": CFParseMatcher, - "re": SimplifiedRegexMatcher, - - # -- BACKWARD-COMPATIBLE REGEX MATCHER: Old Cucumber compatible style. - # To make it the default step-matcher use the following snippet: - # # -- FILE: features/environment.py - # from behave import use_step_matcher - # def before_all(context): - # use_step_matcher("re0") - "re0": CucumberRegexMatcher, - } + STEP_MATCHER_CLASSES = [ + ParseMatcher, + CFParseMatcher, + SimplifiedRegexMatcher, + CucumberRegexMatcher, # -- SAME AS: RegexMatcher + ] DEFAULT_MATCHER_NAME = "parse" - def __init__(self, matcher_mapping=None, default_matcher_name=None): - if matcher_mapping is None: - matcher_mapping = self.MATCHER_MAPPING.copy() + @classmethod + def make_step_matcher_class_mapping(cls, step_matcher_classes=None): + if step_matcher_classes is None: + step_matcher_classes = cls.STEP_MATCHER_CLASSES + # -- USING: dict-comprehension + return {step_matcher_class.NAME: step_matcher_class + for step_matcher_class in step_matcher_classes} + + def __init__(self, step_matcher_class_mapping=None, default_matcher_name=None): + if step_matcher_class_mapping is None: + step_matcher_class_mapping = self.make_step_matcher_class_mapping() if default_matcher_name is None: default_matcher_name = self.DEFAULT_MATCHER_NAME - self.matcher_mapping = matcher_mapping + assert default_matcher_name in step_matcher_class_mapping + self.step_matcher_class_mapping = step_matcher_class_mapping self.initial_matcher_name = default_matcher_name self.default_matcher_name = default_matcher_name - self.default_matcher = matcher_mapping[default_matcher_name] + self.default_matcher = self.step_matcher_class_mapping[default_matcher_name] self._current_matcher = self.default_matcher - assert self.default_matcher in self.matcher_mapping.values() + assert self.default_matcher in self.step_matcher_class_mapping.values() def reset(self): self.use_default_step_matcher(self.initial_matcher_name) @@ -649,8 +671,11 @@ def register_type(self, **kwargs): """ self.current_matcher.register_type(**kwargs) + def has_registered_type(self, name): + return self.current_matcher.has_registered_type(name) + def clear_registered_types(self): - for step_matcher_class in self.matcher_mapping.values(): + for step_matcher_class in self.step_matcher_class_mapping.values(): step_matcher_class.clear_registered_types() def register_step_matcher_class(self, name, step_matcher_class, @@ -663,14 +688,14 @@ def register_step_matcher_class(self, name, step_matcher_class, """ assert inspect.isclass(step_matcher_class) assert issubclass(step_matcher_class, Matcher), "OOPS: %r" % step_matcher_class - known_class = self.matcher_mapping.get(name, None) + known_class = self.step_matcher_class_mapping.get(name, None) if (not override and known_class is not None and known_class is not step_matcher_class): message = "ALREADY REGISTERED: {name}={class_name}".format( name=name, class_name=known_class.__name__) raise ResourceExistsError(message) - self.matcher_mapping[name] = step_matcher_class + self.step_matcher_class_mapping[name] = step_matcher_class def use_step_matcher(self, name): """ @@ -689,18 +714,20 @@ def use_step_matcher(self, name): :param name: Name of the step-matcher class. :return: Current step-matcher class that is now in use. """ - self._current_matcher = self.matcher_mapping[name] + self._current_matcher = self.step_matcher_class_mapping[name] return self._current_matcher def use_default_step_matcher(self, name=None): """Use the default step-matcher. - If a :param:`name` is provided, the default step-matcher is defined. + + If ``name`` argument is provided, this name is used to define this + step-matcher as the new default step-matcher. :param name: Optional, use it to specify the default step-matcher. :return: Current step-matcher class (or object). """ if name: - self.default_matcher = self.matcher_mapping[name] + self.default_matcher = self.step_matcher_class_mapping[name] self.default_matcher_name = name self._current_matcher = self.default_matcher return self._current_matcher @@ -708,44 +735,48 @@ def use_default_step_matcher(self, name=None): def use_current_step_matcher_as_default(self): self.default_matcher = self._current_matcher - def make_matcher(self, func, step_text, step_type=None): + def make_step_matcher(self, func, step_text, step_type=None): return self.current_matcher(func, step_text, step_type=step_type) + # -- BACKWARD-COMPATIBILITY: + def make_matcher(self, func, step_text, step_type=None): + warnings.warn("DEPRECATED: Use make_step_matchers() instead.", DeprecationWarning) + return self.make_step_matcher(func, step_text, step_type=step_type) + # -- MODULE INSTANCE: -_the_matcher_factory = StepMatcherFactory() +_the_step_matcher_factory = StepMatcherFactory() # ----------------------------------------------------------------------------- -# INTERNAL API FUNCTIONS: +# API FUNCTIONS: # ----------------------------------------------------------------------------- -def get_matcher_factory(): - return _the_matcher_factory +def get_step_matcher_factory(): + return _the_step_matcher_factory -def make_matcher(func, step_text, step_type=None): - return _the_matcher_factory.make_matcher(func, step_text, - step_type=step_type) +def make_step_matcher(func, step_text, step_type=None): + return _the_step_matcher_factory.make_step_matcher(func, step_text, + step_type=step_type) def use_current_step_matcher_as_default(): - return _the_matcher_factory.use_current_step_matcher_as_default() - + return _the_step_matcher_factory.use_current_step_matcher_as_default() # ----------------------------------------------------------------------------- # PUBLIC API FOR: step-writers # ----------------------------------------------------------------------------- def use_step_matcher(name): - return _the_matcher_factory.use_step_matcher(name) + return _the_step_matcher_factory.use_step_matcher(name) def use_default_step_matcher(name=None): - return _the_matcher_factory.use_default_step_matcher(name=name) + return _the_step_matcher_factory.use_default_step_matcher(name=name) def register_type(**kwargs): - _the_matcher_factory.register_type(**kwargs) + _the_step_matcher_factory.register_type(**kwargs) # -- REUSE DOCSTRINGS: @@ -759,10 +790,23 @@ def register_type(**kwargs): # BEHAVE EXTENSION-POINT: Add your own step-matcher class(es) # ----------------------------------------------------------------------------- def register_step_matcher_class(name, step_matcher_class, override=False): - _the_matcher_factory.register_step_matcher_class(name, step_matcher_class, - override=override) + _the_step_matcher_factory.register_step_matcher_class(name, step_matcher_class, + override=override) # -- REUSE DOCSTRINGS: register_step_matcher_class.__doc__ = ( StepMatcherFactory.register_step_matcher_class.__doc__) + + +# ----------------------------------------------------------------------------- +# BACKWARD-COMPATIBILITY: +# ----------------------------------------------------------------------------- +def get_matcher_factory(): + warnings.warn("DEPRECATED: Use get_step_matcher_factory() instead", DeprecationWarning) + return get_step_matcher_factory() + + +def make_matcher(func, step_text, step_type=None): + warnings.warn("DEPRECATED: Use make_step_matcher() instead", DeprecationWarning) + return make_step_matcher(func, step_text, step_type=step_type) diff --git a/behave/runner_util.py b/behave/runner_util.py index 9b33d86fe..6216c597a 100644 --- a/behave/runner_util.py +++ b/behave/runner_util.py @@ -669,15 +669,17 @@ def print_undefined_step_snippets(undefined_steps, stream=None, colored=True): stream.write(msg) stream.flush() + def reset_runtime(): - """Reset runtime environment. + """ + Reset runtime environment. Best effort to reset module data to initial state. """ # pylint: disable=import-outside-toplevel from behave import step_registry - from behave import matchers - # -- RESET 1: behave.step_registry + from behave.matchers import get_step_matcher_factory + # -- RESET STEP 1: behave.step_registry step_registry.registry = step_registry.StepRegistry() step_registry.setup_step_decorators(None, step_registry.registry) - # -- RESET 2: behave.matchers - matchers.get_matcher_factory().reset() + # -- RESET STEP 2: behave.matchers + get_step_matcher_factory().reset() diff --git a/behave/step_registry.py b/behave/step_registry.py index 0dffb0a14..fcaafc995 100644 --- a/behave/step_registry.py +++ b/behave/step_registry.py @@ -8,7 +8,7 @@ from __future__ import absolute_import, print_function import sys -from behave.matchers import make_matcher +from behave.matchers import make_step_matcher from behave.textutil import text as _text # limit import * to just the decorators @@ -119,7 +119,7 @@ def is_good_step_definition(self, step_matcher): def add_step_definition(self, keyword, step_text, func): new_step_type = keyword.lower() step_text = _text(step_text) - new_step_matcher = make_matcher(func, step_text, new_step_type) + new_step_matcher = make_step_matcher(func, step_text, new_step_type) if not self.is_good_step_definition(new_step_matcher): # -- CASE: BAD STEP-DEFINITION -- Ignore it. return diff --git a/tests/unit/test_matchers.py b/tests/unit/test_matchers.py index b737c44ca..b3db63687 100644 --- a/tests/unit/test_matchers.py +++ b/tests/unit/test_matchers.py @@ -55,9 +55,9 @@ def parse_number(text): # -- EXPECT: this_matcher_class = self.STEP_MATCHER_CLASS - this_matcher_class.custom_types.clear() + this_matcher_class.clear_registered_types() this_matcher_class.register_type(Number=parse_number) - assert "Number" in this_matcher_class.custom_types + assert this_matcher_class.has_registered_type("Number") def test_returns_none_if_parser_does_not_match(self): # pylint: disable=redefined-outer-name @@ -391,11 +391,11 @@ def test_step_should_use_regex_begin_and_end_marker(self): def test_step_matcher_current_matcher(): - step_matcher_factory = matchers.get_matcher_factory() - for name, klass in list(step_matcher_factory.matcher_mapping.items()): + step_matcher_factory = matchers.get_step_matcher_factory() + for name, klass in list(step_matcher_factory.step_matcher_class_mapping.items()): current_matcher1 = matchers.use_step_matcher(name) current_matcher2 = step_matcher_factory.current_matcher - matcher = matchers.make_matcher(lambda x: -x, "foo") + matcher = matchers.make_step_matcher(lambda x: -x, "foo") assert isinstance(matcher, klass) assert current_matcher1 is klass assert current_matcher2 is klass diff --git a/tests/unit/test_step_registry.py b/tests/unit/test_step_registry.py index 106f91c47..6326129ff 100644 --- a/tests/unit/test_step_registry.py +++ b/tests/unit/test_step_registry.py @@ -13,19 +13,18 @@ class TestStepRegistry(object): def test_add_step_definition_adds_to_lowercased_keyword(self): registry = step_registry.StepRegistry() # -- MONKEYPATCH-PROBLEM: - # with patch('behave.matchers.make_matcher') as make_matcher: - with patch('behave.step_registry.make_matcher') as make_matcher: + with patch("behave.step_registry.make_step_matcher") as make_step_matcher: func = lambda x: -x pattern = u"just a test string" magic_object = Mock() - make_matcher.return_value = magic_object + make_step_matcher.return_value = magic_object for step_type in list(registry.steps.keys()): registered_steps = [] registry.steps[step_type] = registered_steps registry.add_step_definition(step_type.upper(), pattern, func) - make_matcher.assert_called_with(func, pattern, step_type) + make_step_matcher.assert_called_with(func, pattern, step_type) assert registered_steps == [magic_object] def test_find_match_with_specific_step_type_also_searches_generic(self): From 37e6c457e98b3ec5936695ded591433a6a8557b2 Mon Sep 17 00:00:00 2001 From: jenisys Date: Wed, 29 May 2024 09:15:44 +0200 Subject: [PATCH 221/240] REFACTOR: behave.matchers * RENAME: Matcher.CUSTOM_TYPES -> Matcher.TYPE_REGISTRY --- behave/matchers.py | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/behave/matchers.py b/behave/matchers.py index dad10511e..3126ca0b2 100644 --- a/behave/matchers.py +++ b/behave/matchers.py @@ -145,7 +145,26 @@ def run(self, context): # ----------------------------------------------------------------------------- # SECTION: TypeRegistry for Step Matchers (provide: TypeRegistry protocol) # ----------------------------------------------------------------------------- +# from typing import Protocol, ParamSpec +# from abc import abstractmethod +# P = ParamSpec("P") +# +# class TypeRegistryProtocol(Protocol): +# @abstractmethod +# def register_type(self, **kwargs: P.kwargs) -> None: +# ... +# +# @abstractmethod +# def has_type(self, name: str) -> bool: +# return False +# +# def clear(self) -> None: +# ... +# +# class TypeRegistry(dict): + # -- IMPLEMENTS: TypeRegistryProtocol and dict-Protocol + def register_type(self, **kwargs): """ Register one (or more) user-defined types used for matching types @@ -161,6 +180,7 @@ class TypeRegistryNotSupported(): """ Placeholder class for a type-registry if custom types are not supported. """ + # -- IMPLEMENTS: TypeRegistryProtocol def register_type(self, **kwargs): raise NotSupportedWarning("register_type") @@ -202,7 +222,7 @@ class Matcher(object): File location of the step-definition function. """ NAME = None # -- HINT: Must be specified by derived class. - CUSTOM_TYPES = TypeRegistryNotSupported() + TYPE_REGISTRY = TypeRegistryNotSupported() # -- DESCRIBE-SCHEMA FOR STEP-DEFINITIONS (step-matchers): SCHEMA = u"@{this.step_type}('{this.pattern}')" @@ -217,7 +237,7 @@ def register_type(cls, **kwargs): in step patterns of this matcher. """ try: - cls.CUSTOM_TYPES.register_type(**kwargs) + cls.TYPE_REGISTRY.register_type(**kwargs) except NotSupportedWarning: # -- HINT: Provide DERIVED_CLASS name as failure context. message = "{cls.__name__}.register_type".format(cls=cls) @@ -225,11 +245,11 @@ def register_type(cls, **kwargs): @classmethod def has_registered_type(cls, name): - return cls.CUSTOM_TYPES.has_type(name) + return cls.TYPE_REGISTRY.has_type(name) @classmethod def clear_registered_types(cls): - cls.CUSTOM_TYPES.clear() + cls.TYPE_REGISTRY.clear() # -- END-OF: TypeRegistry protocol def __init__(self, func, pattern, step_type=None): @@ -372,11 +392,11 @@ def step_given_amount_vehicles(ctx, amount): NAME = "parse" PARSER_CLASS = parse.Parser CASE_SENSITIVE = True - CUSTOM_TYPES = TypeRegistry() + TYPE_REGISTRY = TypeRegistry() def __init__(self, func, pattern, step_type=None, custom_types=None): if custom_types is None: - custom_types = self.CUSTOM_TYPES + custom_types = self.TYPE_REGISTRY super(ParseMatcher, self).__init__(func, pattern, step_type) self.parser = self.PARSER_CLASS(pattern, extra_types=custom_types, case_sensitive=self.CASE_SENSITIVE) @@ -473,7 +493,7 @@ class RegexMatcher(Matcher): * Custom type-converters are NOT SUPPORTED. """ NAME = "re0" - CUSTOM_TYPES = TypeRegistryNotSupported() + TYPE_REGISTRY = TypeRegistryNotSupported() def __init__(self, func, pattern, step_type=None): super(RegexMatcher, self).__init__(func, pattern, step_type) From 418a89dd474e7b49cd7cc3b19cd10483ca9b6b09 Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 30 May 2024 12:06:17 +0200 Subject: [PATCH 222/240] LINTER: Fix linter warnings --- behave/formatter/_registry.py | 3 +-- behave/formatter/steps.py | 4 ++-- behave/formatter/tags.py | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/behave/formatter/_registry.py b/behave/formatter/_registry.py index ba5e08a2b..c6f021649 100644 --- a/behave/formatter/_registry.py +++ b/behave/formatter/_registry.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- +# -*- coding: UTF-8 -*- import inspect import sys import warnings @@ -20,7 +20,6 @@ def __init__(self, name, formatter_class): @property def error(self): - from behave.importer import make_scoped_class_name if self._error_text is None: error_text = "" if not inspect.isclass(self.formatter_class): diff --git a/behave/formatter/steps.py b/behave/formatter/steps.py index 319ae3303..969cb103a 100644 --- a/behave/formatter/steps.py +++ b/behave/formatter/steps.py @@ -426,7 +426,7 @@ def report(self): def report_used_step_definitions(self): # -- STEP: Used step definitions. # ORDERING: Sort step definitions by file location. - get_location = lambda x: x[0].location + get_location = lambda x: x[0].location # noqa: E731 step_definition_items = self.step_usage_database.items() step_definition_items = sorted(step_definition_items, key=get_location) @@ -453,7 +453,7 @@ def report_unused_step_definitions(self): # -- STEP: Prepare report for unused step definitions. # ORDERING: Sort step definitions by file location. - get_location = lambda x: x.location + get_location = lambda x: x.location # noqa: E731 step_definitions = sorted(unused_step_definitions, key=get_location) step_texts = [self.describe_step_definition(step_definition) for step_definition in step_definitions] diff --git a/behave/formatter/tags.py b/behave/formatter/tags.py index 886d10df9..7161e2739 100644 --- a/behave/formatter/tags.py +++ b/behave/formatter/tags.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- +# -*- coding: UTF-8 -*- """ Collects data how often a tag count is used and where. @@ -123,7 +123,7 @@ def report_tag_counts(self): def report_tag_counts_by_usage(self): # -- PREPARE REPORT: - compare_tag_counts_size = lambda x: len(self.tag_counts[x]) + compare_tag_counts_size = lambda x: len(self.tag_counts[x]) # noqa: E731 ordered_tags = sorted(list(self.tag_counts.keys()), key=compare_tag_counts_size) tag_maxsize = compute_words_maxsize(ordered_tags) From dc0b885567c18d48482ab6efbfb1bbd86c24f330 Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 30 May 2024 12:36:52 +0200 Subject: [PATCH 223/240] RELATED TO: BAD STEP-DEFINITIONS (issue #1177) NEW FORMATTER: * BadStepsFormatter: Shows BAD STEP DEFINITIONS when needed FEATURE TESTS: * feature/formatter.bad_steps.feature * feature/runner.bad_steps.feature --- behave/formatter/_builtins.py | 1 + behave/formatter/bad_steps.py | 80 ++++++++++++++++ behave/runner.py | 2 + behave/step_registry.py | 35 ++++--- features/formatter.bad_steps.feature | 111 ++++++++++++++++++++++ features/formatter.help.feature | 1 + features/runner.bad_steps.feature | 134 +++++++++++++++++++++++++++ issue.features/issue0031.feature | 1 + 8 files changed, 350 insertions(+), 15 deletions(-) create mode 100644 behave/formatter/bad_steps.py create mode 100644 features/formatter.bad_steps.feature create mode 100644 features/runner.bad_steps.feature diff --git a/behave/formatter/_builtins.py b/behave/formatter/_builtins.py index e83d6083c..d09eb3bf9 100644 --- a/behave/formatter/_builtins.py +++ b/behave/formatter/_builtins.py @@ -29,6 +29,7 @@ ("steps.catalog", "behave.formatter.steps:StepsCatalogFormatter"), ("steps.usage", "behave.formatter.steps:StepsUsageFormatter"), ("sphinx.steps", "behave.formatter.sphinx_steps:SphinxStepsFormatter"), + ("bad_steps", "behave.formatter.bad_steps:BadStepsFormatter"), ] # ----------------------------------------------------------------------------- diff --git a/behave/formatter/bad_steps.py b/behave/formatter/bad_steps.py new file mode 100644 index 000000000..2b404b6ae --- /dev/null +++ b/behave/formatter/bad_steps.py @@ -0,0 +1,80 @@ +""" +Formatter(s) if BAD_STEP_DEFINITIONS are found. + +BAD_STEP_DEFINITION: + +* A BAD STEP-DEFINITION occurs when the regular-expression compile step fails. +* A BAD STEP-DEFINITION is detected during ``StepRegistry.add_step_definition()``. + +POTENTIAL REASONS: + +* Regular expression for this step is wrong/bad. +* Regular expression of a type-converter is wrong/bad (in a parse-expression) + +CAUSED BY: + +* More strict Regular expression checks occur in newer Python versions (>= 3.11). +""" + +from __future__ import absolute_import, print_function +from behave.formatter.base import Formatter +from behave.step_registry import ( + BadStepDefinitionCollector, + registry as the_step_registry, +) + + +class BadStepsFormatter(Formatter): + """ + Formatter that prints BAD_STEP_DEFINITIONS if any exist + at the end of the test-run. + """ + name = "bad_steps" + description = "Show BAD STEP-DEFINITION(s) (if any exist)" + PRINTER_CLASS = BadStepDefinitionCollector + + def __init__(self, stream_opener, config): + super(BadStepsFormatter, self).__init__(stream_opener, config) + self.step_registry = None + + @property + def bad_step_definitions(self): + if not self.step_registry: + return [] + return self.step_registry.error_handler.bad_step_definitions + + def reset(self): + self.step_registry = None + + def discover_bad_step_definitions(self): + if self.step_registry is None: + self.step_registry = the_step_registry + + # -- FORMATTER API: + def feature(self, feature): + if not self.step_registry: + self.discover_bad_step_definitions() + + def close(self): + """Called at end of test run.""" + if not self.step_registry: + self.discover_bad_step_definitions() + + if self.bad_step_definitions: + # -- ENSURE: Output stream is open. + self.stream = self.open() + self.report() + + # -- FINALLY: + self.close_stream() + + # -- REPORT SPECIFIC-API: + def make_printer(self): + return self.PRINTER_CLASS(self.bad_step_definitions, + file=self.stream) + + def report(self): + report_printer = self.make_printer() + report_printer.print_all() + print(file=self.stream) + diff --git a/behave/runner.py b/behave/runner.py index 3defac330..7033ad53b 100644 --- a/behave/runner.py +++ b/behave/runner.py @@ -785,6 +785,8 @@ def run_model(self, features=None): for reporter in self.config.reporters: reporter.end() + # -- MAYBE: BAD STEP-DEFINITIONS: Unused BAD STEPS should not cause FAILURE. + # bad_step_definitions = self.step_registry.error_handler.bad_step_definitions failed = ((failed_count > 0) or self.aborted or (self.hook_failures > 0) or (len(self.undefined_steps) > undefined_steps_initial_size) or cleanups_failed) diff --git a/behave/step_registry.py b/behave/step_registry.py index fcaafc995..20fb73152 100644 --- a/behave/step_registry.py +++ b/behave/step_registry.py @@ -26,40 +26,45 @@ class AmbiguousStep(ValueError): pass -class BadStepDefinitionErrorHandler(object): +class BadStepDefinitionCollector(object): BAD_STEP_DEFINITION_MESSAGE = """\ BAD-STEP-DEFINITION: {step} LOCATION: {step_location} """.strip() BAD_STEP_DEFINITION_MESSAGE_WITH_ERROR = BAD_STEP_DEFINITION_MESSAGE + """ -RAISED EXCEPTION: {error.__class__.__name__}:{error}""" + RAISED EXCEPTION: {error.__class__.__name__}:{error}""" - def __init__(self): - self.bad_step_definitions = [] + def __init__(self, bad_step_definitions=None, file=None): + self.bad_step_definitions = bad_step_definitions or [] + self.file = file or sys.stdout def clear(self): self.bad_step_definitions = [] - def on_error(self, step_matcher, error): - self.bad_step_definitions.append(step_matcher) - self.print(step_matcher, error) - def print_all(self): - print("BAD STEP-DEFINITIONS[%d]:" % len(self.bad_step_definitions)) - for index, bad_step_definition in enumerate(self.bad_step_definitions): - print("%d. " % index, end="") - self.print(bad_step_definition, error=None) + print("BAD STEP-DEFINITIONS[%d]:" % len(self.bad_step_definitions), + file=self.file) + for bad_step_definition in self.bad_step_definitions: + print("- ", end="") + self.print(bad_step_definition, error=None, file=self.file) # -- CLASS METHODS: @classmethod - def print(cls, step_matcher, error=None): + def print(cls, step_matcher, error=None, file=None): message = cls.BAD_STEP_DEFINITION_MESSAGE_WITH_ERROR if error is None: message = cls.BAD_STEP_DEFINITION_MESSAGE print(message.format(step=step_matcher.describe(), step_location=step_matcher.location, - error=error), file=sys.stderr) + error=error), file=file) + + +class BadStepDefinitionErrorHandler(BadStepDefinitionCollector): + + def on_error(self, step_matcher, error): + self.bad_step_definitions.append(step_matcher) + self.print(step_matcher, error, file=self.file) @classmethod def raise_error(cls, step_matcher, error): @@ -73,7 +78,7 @@ class StepRegistry(object): def __init__(self): self.steps = dict(given=[], when=[], then=[], step=[]) - self.error_handler = self.BAD_STEP_DEFINITION_HANDLER_CLASS() + self.error_handler = self.BAD_STEP_DEFINITION_HANDLER_CLASS(file=sys.stderr) def clear(self): """ diff --git a/features/formatter.bad_steps.feature b/features/formatter.bad_steps.feature new file mode 100644 index 000000000..26a717e1b --- /dev/null +++ b/features/formatter.bad_steps.feature @@ -0,0 +1,111 @@ +@use.with_python.min_version=3.11 +Feature: Bad Steps Formatter (aka: Bad Step Definitions Formatter) + + As a test writer + I want a summary if any bad step definitions exist + So that I have an overview what to fix (and look after). + + . DEFINITION: BAD STEP DEFINITION + . * Is a step definition (aka: step matcher) + . where the regular expression compile step fails + . + . CAUSED BY: More checks/enforcements in the "re" module (since: Python >= 3.11). + . + . BEST-PRACTICE: Use BadStepsFormatter in dry-run mode, like: + . + . behave --dry-run -f bad_steps features/ + + + Background: + Given a new working directory + And a file named "features/steps/use_behave4cmd.py" with: + """ + import behave4cmd0.passing_steps + import behave4cmd0.note_steps + """ + And a file named "features/steps/bad_steps1.py" with: + """ + from behave import given, when, then, register_type, use_step_matcher + import parse + + # -- HINT: TYPE-CONVERTER with BAD REGEX PATTERN caused by "(?i)" parts + @parse.with_pattern(r"(?P(?i)ON|(?i)OFF)", regex_group_count=1) + def parse_bad_bool(text): + return text == "ON" + + use_step_matcher("parse") + register_type(BadBool=parse_bad_bool) + + # -- BAD STEP DEFINITION 1: + @given('the bad light is switched {state:BadBool}') + def step_bad_given_light_is_switched_on_off(ctx, state): + pass + """ + And a file named "features/steps/bad_steps2.py" with: + """ + from behave import step, use_step_matcher + + use_step_matcher("re") + + # -- BAD STEP DEFINITION 2: Caused by "(?i)" parts + @step('some bad light is switched (?P(?i)ON|(?i)OFF)') + def step_bad_light_is_switched_using_re(ctx, status): + pass + + @step('good light is switched (?PON|OFF)') + def step_good_light_is_switched_using_re(ctx, status): + pass + """ + And a file named "features/one.feature" with: + """ + Feature: F1 + Scenario: S1 + Given a step passes + When another step passes + """ + + Scenario: Use "bad_steps" formatter in dry-run mode + When I run "behave --dry-run -f bad_steps features/" + Then the command output should contain: + """ + BAD STEP-DEFINITIONS[2]: + - BAD-STEP-DEFINITION: @given('the bad light is switched {state:BadBool}') + LOCATION: features/steps/bad_steps1.py:13 + - BAD-STEP-DEFINITION: @step('^some bad light is switched (?P(?i)ON|(?i)OFF)$') + LOCATION: features/steps/bad_steps2.py:6 + """ + But note that "the formatter shows a list of BAD STEP DEFINITIONS" + + Scenario: Use "bad_steps" formatter in normal mode + When I run "behave -f bad_steps features/" + Then the command output should contain: + """ + BAD STEP-DEFINITIONS[2]: + - BAD-STEP-DEFINITION: @given('the bad light is switched {state:BadBool}') + LOCATION: features/steps/bad_steps1.py:13 + - BAD-STEP-DEFINITION: @step('^some bad light is switched (?P(?i)ON|(?i)OFF)$') + LOCATION: features/steps/bad_steps2.py:6 + + 1 feature passed, 0 failed, 0 skipped + """ + But note that "the formatter shows a list of BAD STEP DEFINITIONS" + + Scenario: Use "bad_steps" formatter with another formatter + When I run "behave -f bad_steps -f plain features/" + Then the command output should contain: + """ + Feature: F1 + + Scenario: S1 + Given a step passes ... passed + When another step passes ... passed + + BAD STEP-DEFINITIONS[2]: + - BAD-STEP-DEFINITION: @given('the bad light is switched {state:BadBool}') + LOCATION: features/steps/bad_steps1.py:13 + - BAD-STEP-DEFINITION: @step('^some bad light is switched (?P(?i)ON|(?i)OFF)$') + LOCATION: features/steps/bad_steps2.py:6 + + 1 feature passed, 0 failed, 0 skipped + """ + But note that "the BAD_STEPS formatter output is shown at the end" diff --git a/features/formatter.help.feature b/features/formatter.help.feature index dd1730752..8aeb4dd93 100644 --- a/features/formatter.help.feature +++ b/features/formatter.help.feature @@ -25,6 +25,7 @@ Feature: Help Formatter And the command output should contain: """ AVAILABLE FORMATTERS: + bad_steps Show BAD STEP-DEFINITION(s) (if any exist) json JSON dump of test run json.pretty JSON dump of test run (human readable) null Provides formatter that does not output anything. diff --git a/features/runner.bad_steps.feature b/features/runner.bad_steps.feature new file mode 100644 index 000000000..5f16eb75c --- /dev/null +++ b/features/runner.bad_steps.feature @@ -0,0 +1,134 @@ +@use.with_python.min_version=3.11 +Feature: Runner should show Bad Step Definitions + + As a test writer + I want to know if any bad step definitions exist + So that I can fix them. + + . DEFINITION: BAD STEP-DEFINITION + . * is a step definition (aka: step matcher) + . where the regular expression compile step fails + . * causes that this step-definition is not registered in the step-registry + . + . TEST RUN OUTCOME: + . * Used BAD STEP-DEFINITION (as undefined step) causes test run to fail. + . * Unused BAD STEP-DEFINITION does not cause the test run to fail. + . + . CAUSED BY: More checks/enforcements in the "re" module (since: Python >= 3.11). + + + Background: + Given a new working directory + And a file named "features/steps/use_behave4cmd.py" with: + """ + import behave4cmd0.passing_steps + import behave4cmd0.note_steps + """ + And a file named "features/steps/bad_steps1.py" with: + """ + from behave import given, when, then, register_type, use_step_matcher + import parse + + # -- HINT: TYPE-CONVERTER with BAD REGEX PATTERN caused by "(?i) parts + # GOOD PATTERN: "(?PON|OFF)" + @parse.with_pattern(r"(?P(?i)ON|(?i)OFF)", regex_group_count=1) + def parse_bad_bool(text): + return text == "ON" + + use_step_matcher("parse") + register_type(BadBool=parse_bad_bool) + + # -- BAD STEP-DEFINITION 1: + @given('the bad light is switched {state:BadBool}') + def step_given_light_is_switched_on_off(ctx, state): + pass + """ + And a file named "features/steps/bad_steps2.py" with: + """ + from behave import step, use_step_matcher + + use_step_matcher("re") + + # -- BAD STEP-DEFINITION 2: Caused by "(?i)" parts + @step('some bad light is switched (?P(?i)ON|(?i)OFF)') + def step_light_is_switched_using_re(ctx, status): + pass + + @step('good light is switched (?PON|OFF)') + def step_light_is_switched_using_re(ctx, status): + pass + """ + And an empty file named "features/none.feature" + + Rule: Unused BAD STEP-DEFINITIONS do not cause test run to fail + Scenario: Runner detects BAD STEP DEFINITIONS in dry-run mode + When I run "behave --dry-run -f plain features/" + Then it should pass with: + """ + BAD-STEP-DEFINITION: @given('the bad light is switched {state:BadBool}') + LOCATION: features/steps/bad_steps1.py:14 + RAISED EXCEPTION: NotImplementedError:Group names (e.g. (?P) can cause failure, as they are not escaped properly: + """ + And the command output should contain: + """ + BAD-STEP-DEFINITION: @step('^some bad light is switched (?P(?i)ON|(?i)OFF)$') + LOCATION: features/steps/bad_steps2.py:6 + RAISED EXCEPTION: error:global flags not at the start of the expression at position 39 + """ + And the command output should not contain: + """ + BAD-STEP-DEFINITION: @step('good light is switched (?PON|OFF)') + """ + But note that "the step-registry error handler shows each BAD STEP DEFINITIONS with their error" + + Scenario: Runner detects BAD STEP DEFINITIONS in normal mode + When I run "behave -f plain features/" + Then it should pass with: + """ + BAD-STEP-DEFINITION: @given('the bad light is switched {state:BadBool}') + LOCATION: features/steps/bad_steps1.py:14 + RAISED EXCEPTION: NotImplementedError:Group names (e.g. (?P) can cause failure, as they are not escaped properly: + """ + And the command output should contain: + """ + BAD-STEP-DEFINITION: @step('^some bad light is switched (?P(?i)ON|(?i)OFF)$') + LOCATION: features/steps/bad_steps2.py:6 + RAISED EXCEPTION: error:global flags not at the start of the expression at position 39 + """ + And the command output should not contain: + """ + BAD-STEP-DEFINITION: @step('good light is switched (?PON|OFF)') + """ + But note that "the step-registry error handler shows each BAD STEP DEFINITIONS with their error" + + + Rule: Used BAD STEP-DEFINITIONS cause test run to fail + Scenario: Test run fails detects BAD STEP DEFINITIONS in normal mode + Given a file named "features/use_bad_step.feature" with: + """ + Feature: Failing + Scenario: Uses BAD STEP -- Expected to fail + Given the bad light is switched ON + When another step passes + """ + When I run "behave -f plain features/use_bad_step.feature" + Then it should fail with: + """ + Failing scenarios: + features/use_bad_step.feature:2 Uses BAD STEP -- Expected to fail + + 0 features passed, 1 failed, 0 skipped + 0 scenarios passed, 1 failed, 0 skipped + 0 steps passed, 0 failed, 1 skipped, 1 undefined + """ + And the command output should contain: + """ + Scenario: Uses BAD STEP -- Expected to fail + Given the bad light is switched ON ... undefined + """ + And the command output should contain: + """ + BAD-STEP-DEFINITION: @given('the bad light is switched {state:BadBool}') + LOCATION: features/steps/bad_steps1.py:14 + RAISED EXCEPTION: NotImplementedError:Group names (e.g. (?P) can cause failure, as they are not escaped properly: + """ diff --git a/issue.features/issue0031.feature b/issue.features/issue0031.feature index 62aabb843..9be8a8b30 100644 --- a/issue.features/issue0031.feature +++ b/issue.features/issue0031.feature @@ -8,6 +8,7 @@ Feature: Issue #31 "behave --format help" raises an error And the command output should contain: """ AVAILABLE FORMATTERS: + bad_steps Show BAD STEP-DEFINITION(s) (if any exist) json JSON dump of test run json.pretty JSON dump of test run (human readable) null Provides formatter that does not output anything. From e23a17fcf2aceb7a2eae0ee29e39c7aac296d2e5 Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 30 May 2024 13:22:39 +0200 Subject: [PATCH 224/240] docs: Update with info on BAD STEP-DEFINITIONS --- docs/formatters.rst | 1 + docs/new_and_noteworthy_v1.2.7.rst | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/docs/formatters.rst b/docs/formatters.rst index 2614aa229..909ac6258 100644 --- a/docs/formatters.rst +++ b/docs/formatters.rst @@ -36,6 +36,7 @@ The following formatters are currently supported: Name Mode Description ============== ======== ================================================================ help normal Shows all registered formatters. +bad_steps dry-run Shows BAD STEP-DEFINITIONS (if any exist). json normal JSON dump of test run json.pretty normal JSON dump of test run (human readable) plain normal Very basic formatter with maximum compatibility diff --git a/docs/new_and_noteworthy_v1.2.7.rst b/docs/new_and_noteworthy_v1.2.7.rst index fc462bad8..0a90d21d6 100644 --- a/docs/new_and_noteworthy_v1.2.7.rst +++ b/docs/new_and_noteworthy_v1.2.7.rst @@ -11,6 +11,7 @@ Summary: * `Support for emojis in feature files and steps`_ * `Improve Active-Tags Logic`_ * `Active-Tags: Use ValueObject for better Comparisons`_ +* `Detect bad step definitions`_ .. _`Example Mapping`: https://cucumber.io/blog/example-mapping-introduction/ .. _`Example Mapping Webinar`: https://cucumber.io/blog/example-mapping-webinar/ @@ -313,3 +314,30 @@ execution of an scenario to a temperature range, like: "supported_payment_method": ValueObject(payment_methods, operator.contains), } ... + + +Detect Bad Step Definitions +------------------------------------------------------------------------------- + +The **regular expression** (:mod:`re`) module in Python has increased the checks +when bad regular expression patterns are used. Since `Python >= 3.11`, +an :class:`re.error` exception may be raised on some regular expressions. +The exception is raised when the bad regular expression is compiled +(on :func:`re.compile()`). + +``behave`` has added the following support: + +* Detects a bad step-definition when they are added to the step-registry. +* Reports a bad step-definition and their exception during this step. +* bad step-definitions are not registered in the step-registry. +* A bad step-definition is like an UNDEFINED step-definition. +* A :class:`~behave.formatter.bad_steps.BadStepsFormatter` formatter was added that shows any BAD STEP DEFINITIONS + + +.. note:: More Information on BAD STEP-DEFINITIONS: + + * `features/formatter.bad_steps.feature`_ + * `features/runner.bad_steps.feature`_ + +.. _`features/formatter.bad_steps.feature`: https://github.com/behave/behave/blob/main/features/formatter.bad_steps.feature +.. _`features/runner.bad_steps.feature`: https://github.com/behave/behave/blob/main/features/runner.bad_steps.feature From e82a220167d5462874f280215e9f49724a30c48e Mon Sep 17 00:00:00 2001 From: jenisys Date: Fri, 31 May 2024 21:20:59 +0200 Subject: [PATCH 225/240] FEATURE: Support for step definitions with CucumberExpressions behave.cucumber_expression module: * StepMatcher4CucumberExpressions class for step-matchers * TypeRegistry4ParameterType w/ TypeRegistry protocol * TypeBuilder: Use parse-functions from parse-expressions * use_step_matcher_for_cucumber_expressions() to enable it SEE ALSO: * features/step_matcher.cucumber_expressions.feature --- CHANGES.rst | 2 + behave/cucumber_expression.py | 223 +++++++++++ behave/matchers.py | 50 +++ features/environment.py | 41 +- .../step_matcher.cucumber_expressions.feature | 301 +++++++++++++++ features/steps/cucumber_expression_steps.py | 211 +++++++++++ py.requirements/basic.txt | 2 + pyproject.toml | 1 + setup.py | 1 + tests/unit/test_cucumber_expression.py | 353 ++++++++++++++++++ 10 files changed, 1183 insertions(+), 2 deletions(-) create mode 100644 behave/cucumber_expression.py create mode 100644 features/step_matcher.cucumber_expressions.feature create mode 100644 features/steps/cucumber_expression_steps.py create mode 100644 tests/unit/test_cucumber_expression.py diff --git a/CHANGES.rst b/CHANGES.rst index 3e32c7638..b2878067a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -65,6 +65,7 @@ CLEANUPS: ENHANCEMENTS: +* Add support for step-definitions (step-matchers) with `CucumberExpressions`_ * User-defined formatters: Improve diagnostics if bad formatter is used (ModuleNotFound, ...) * active-tags: Added ``ValueObject`` class for enhanced control of comparison mechanism (supports: equals, less-than, less-or-equal, greater-than, greater-or-equal, contains, ...) @@ -144,6 +145,7 @@ BREAKING CHANGES (naming): .. _`cucumber-tag-expressions`: https://pypi.org/project/cucumber-tag-expressions/ +.. _`CucumberExpressions`: https://github.com/cucumber/cucumber-expressions Version: 1.2.6 (2018-02-25) diff --git a/behave/cucumber_expression.py b/behave/cucumber_expression.py new file mode 100644 index 000000000..e16fdb3fd --- /dev/null +++ b/behave/cucumber_expression.py @@ -0,0 +1,223 @@ +""" +Provide a step-matcher with `cucumber-expressions`_ for :pypi:`behave`. + +:STATUS: Experimental (incubating) + +.. _cucumber-expressions: https://github.com/cucumber/cucumber-expressions +""" + +from __future__ import absolute_import, print_function +from typing import Callable, List, Optional, Type + +from behave.exception import NotSupportedWarning +from behave.matchers import ( + Matcher, + has_registered_step_matcher_class, + register_step_matcher_class, + use_step_matcher +) +from behave.model_core import Argument + +# -- REQUIRES: Python >= 3.8 +from cucumber_expressions.expression import CucumberExpression +from cucumber_expressions.parameter_type import ParameterType +from cucumber_expressions.parameter_type_registry import ParameterTypeRegistry + +from parse_type import TypeBuilder as _TypeBuilder + + +# ----------------------------------------------------------------------------- +# STEP-MATCHER SUPPORT CLASSES FOR: CucumberExpressions +# ----------------------------------------------------------------------------- +class TypeRegistry4ParameterType(object): + """ + Provides adapter to :class:`ParameterTypeRegistry`. + + RESPONSIBILITIES: + * Implements the "TypeRegistryProtocol" + (used by: StepMatcherFactory/Matcher class) + """ + REGISTRY_CLASS = ParameterTypeRegistry + + def __init__(self, parameter_types: Optional[ParameterTypeRegistry] = None): + if parameter_types is None: + parameter_types = self.REGISTRY_CLASS() + self.parameter_types = parameter_types + + def define_parameter_type(self, parameter_type: ParameterType): + self.parameter_types.define_parameter_type(parameter_type) + + def define_parameter_type_with(self, name: str, regexp: str, type: Type, + transformer: Optional[Callable] = None, + use_for_snippets: bool = True, + prefer_for_regexp_match: bool = False): + this_type = ParameterType(name, regexp=regexp, type=type, + transformer=transformer, + use_for_snippets=use_for_snippets, + prefer_for_regexp_match=prefer_for_regexp_match) + self.define_parameter_type(this_type) + + # -- IMPLEMENT: TypeRegistryProtocol + def register_type(self, **kwargs): + parameter_type = kwargs.pop("parameter_type", None) + if parameter_type is None: + raise NotSupportedWarning("Use define_parameter_type() instead") + self.define_parameter_type(parameter_type) + + def has_registered_type(self, name): + optional_parameter_type = self.parameter_types.lookup_by_type_name(name) + return bool(optional_parameter_type) + + def clear(self): + self.parameter_types = self.REGISTRY_CLASS() + + +class StepMatcher4CucumberExpressions(Matcher): + """ + Provides a step-matcher class that supports `cucumber-expressions`_ + for step parameters. + """ + NAME = "cucumber_expressions" + TYPE_REGISTRY = TypeRegistry4ParameterType() + + def __init__(self, func: Callable, pattern: str, + step_type: Optional[str] = None, + parameter_types: Optional[ParameterTypeRegistry] = None): + if parameter_types is None: + parameter_types = self.TYPE_REGISTRY.parameter_types + super(StepMatcher4CucumberExpressions, self).__init__(func, pattern, + step_type=step_type) + self.cucumber_expression = CucumberExpression(pattern, parameter_types) + + # -- IMPLEMENT: MatcherProtocol + @property + def regex_pattern(self) -> str: + return self.cucumber_expression.regexp + + def compile(self): + # -- ENSURE: No BAD STEP-DEFINITION problem exists. + pass + + def check_match(self, step_text: str) -> Optional[List[Argument]]: + matched = self.cucumber_expression.match(step_text) + if matched is None: + # -- CASE: NO MATCH + return None + + # -- CASE: MATCHED + arguments = [self._make_argument(matched_item) for matched_item in matched] + return arguments + + # -- CLASS METHODS: + @staticmethod + def _make_argument(matched) -> Argument: + # -- HINT: CucumberExpressions arguments are NOT NAMED. + return Argument(start=matched.group.start, + end=matched.group.end, + original=matched.group.value, + value=matched.value) + + +# ----------------------------------------------------------------------------- +# REUSE: +# ----------------------------------------------------------------------------- +class TypeBuilder(_TypeBuilder): + """ + Provides :class:`TypeBuilder` for `CucumberExpressions`_. + + DEFINITION: parse-function (from: parse-expressions) + * A function that converts text into a value of value-type (or raises error). + * A "parse-function" has a pattern attribute that contains its regex pattern. + + RESPONSIBILITIES: + * Creates a new "parse-function" and its regex pattern for a common use cases. + * Composes a regular-expression pattern from parse-functions and their patterns. + + COLLABORATORS: + * Uses :class:`parse_type.TypeBuilder` for "parse-expressions" for core functionality. + """ + + @staticmethod + def _add_pattern_group_to(parse_func: Callable): + # -- HINT: CucumberExpression needs additional grouping for regex pattern. + new_pattern = r"(%s)" % parse_func.pattern + parse_func.pattern = new_pattern + return parse_func + + # -- OVERRIDE: Fix regex patterns for CucumberExpression + @classmethod + def make_variant(cls, converters: List[Callable], **kwargs): + parse_variant = _TypeBuilder.make_variant(converters, **kwargs) + return cls._add_pattern_group_to(parse_variant) + + @classmethod + def with_many(cls, converter: Callable, pattern: Optional[str] = None, + listsep: str =","): + """ + Builds parse-function for many items (cardinality: 1..N) + based on parse-function for one item. + + :param converter: Converter/parse-function for one item. + :param pattern: Regex pattern for one item (or converter.pattern). + :param listsep: List separator between items (as string). + :return: Converter/parse-function with regex pattern for many items. + """ + parse_many = _TypeBuilder.with_many(converter, pattern=pattern, + listsep=listsep) + return cls._add_pattern_group_to(parse_many) + + @classmethod + def with_many0(cls, converter: Callable, pattern: Optional[str] = None, + listsep: str =","): + """ + Builds parse-function for many items (cardinality: 0..N) + based on parse-function for one item. + + :param converter: Converter/parse-function for one item. + :param pattern: Regex pattern for one item (or converter.pattern). + :param listsep: List separator between items (as string). + :return: Converter/parse-function with regex pattern for many items. + """ + parse_many0 = _TypeBuilder.with_many0(converter, pattern=pattern, + listsep=listsep) + return cls._add_pattern_group_to(parse_many0) + + +# ----------------------------------------------------------------------------- +# HELPER FUNCTIONS: +# ----------------------------------------------------------------------------- +def define_parameter_type(parameter_type: ParameterType) -> None: + the_type_registry = StepMatcher4CucumberExpressions.TYPE_REGISTRY + the_type_registry.define_parameter_type(parameter_type) + + +def define_parameter_type_with(name: str, regexp: str, type: Type, + transformer: Optional[Callable] = None, + use_for_snippets: bool = True, + prefer_for_regexp_match: bool = False): + this_type = ParameterType(name, regexp=regexp, type=type, + transformer=transformer, + use_for_snippets=use_for_snippets, + prefer_for_regexp_match=prefer_for_regexp_match) + define_parameter_type(this_type) + + +def use_step_matcher_for_cucumber_expressions(): + this_class = StepMatcher4CucumberExpressions + if not has_registered_step_matcher_class(this_class.NAME): + # -- LAZY AUTO REGISTER: On first use. + register_step_matcher_class(this_class.NAME, this_class) + + use_step_matcher(this_class.NAME) + + +# ----------------------------------------------------------------------------- +# MONKEY-PATCH: +# ----------------------------------------------------------------------------- +def _ParameterType_repr(self): + class_name = self.__class__.__name__ + return fr"<{class_name}: name={self.name}, pattern={self.regexp}, ...>" + + +# -- MONKEY-PATCH (and extend it): +ParameterType.__repr__ = _ParameterType_repr diff --git a/behave/matchers.py b/behave/matchers.py index 3126ca0b2..c23dc250c 100644 --- a/behave/matchers.py +++ b/behave/matchers.py @@ -13,9 +13,11 @@ import inspect import re import warnings + import six import parse from parse_type import cfparse + from behave._types import ChainedExceptionUtil, ExceptionUtil from behave.exception import NotSupportedWarning, ResourceExistsError from behave.model_core import Argument, FileLocation, Replayable @@ -814,6 +816,54 @@ def register_step_matcher_class(name, step_matcher_class, override=False): override=override) +def has_registered_step_matcher_class(name_or_class): + """ + Indicates if a ``step_matcher_class`` is already registered or not. + + This supports to auto-register a ``step_matcher_class`` when it is first used. + + EXAMPLE:: + + # -- FILE: cuke4behave/__init__.py + from behave.matchers import (Matcher, + has_registered_step_matcher_class, + register_step_matcher_class, + use_step_matcher + ) + + class CucumberExpressionsStepMatcher(Matcher): + NAME = "cucumber_expressions" + ... + + def use_step_matcher_for_cucumber_expressions(): + this_class = CucumberExpressionsStepMatcher + if not has_registered_step_matcher_class(this_class.NAME): + # -- AUTO-REGISTER: On first use of this step-matcher-class + register_step_matcher_class(this_class.NAME, the_class) + use_step_matcher(this_class.NAME) + + # -- FILE: features/steps/example_steps.py + from behave import given, when then + from cuke4behave import use_step_matcher_for_cucumber_expressions + + use_step_matcher_for_cucumber_expressions() + + @given('a person named {string}') + def step_given_a_person_named(ctx, name): + pass + """ + step_matcher_class_mapping = _the_step_matcher_factory.step_matcher_class_mapping + if isinstance(name_or_class, six.string_types): + name = name_or_class + return name in step_matcher_class_mapping + if not inspect.isclass(name_or_class): + raise TypeError("%r (expected: string, class" % name_or_class) + + # -- CASE 2: Check if step_matcher_class is registered. + step_matcher_class = name_or_class + return step_matcher_class in step_matcher_class_mapping.values() + + # -- REUSE DOCSTRINGS: register_step_matcher_class.__doc__ = ( StepMatcherFactory.register_step_matcher_class.__doc__) diff --git a/features/environment.py b/features/environment.py index cdc165a57..801cfb6d3 100644 --- a/features/environment.py +++ b/features/environment.py @@ -2,13 +2,18 @@ # FILE: features/environment.py from __future__ import absolute_import, print_function -from behave.tag_matcher import \ - ActiveTagMatcher, setup_active_tag_values, print_active_tags from behave4cmd0.setup_command_shell import setup_command_shell_processors4behave +from behave import fixture import behave.active_tag.python import behave.active_tag.python_feature +from behave.fixture import use_fixture_by_tag +from behave.tag_matcher import \ + ActiveTagMatcher, setup_active_tag_values, print_active_tags +# ----------------------------------------------------------------------------- +# ACTIVE TAGS: +# ----------------------------------------------------------------------------- # -- MATCHES ANY TAGS: @use.with_{category}={value} # NOTE: active_tag_value_provider provides category values for active tags. active_tag_value_provider = {} @@ -21,6 +26,33 @@ def print_active_tags_summary(): print_active_tags(active_tag_value_provider, ["python.version", "os"]) +# ----------------------------------------------------------------------------- +# FIXTURES: +# ----------------------------------------------------------------------------- +@fixture(name="fixture.behave.no_background") +def behave_no_background(ctx): + # -- SETUP-PART-ONLY: Disable background inheritance (for scenarios only). + current_scenario = ctx.scenario + if current_scenario: + print("FIXTURE-HINT: DISABLE-BACKGROUND FOR: %s" % current_scenario.name) + current_scenario.use_background = False + + +@fixture(name="fixture.behave.rule.override_background") +def behave_disable_background_inheritance(ctx): + # -- SETUP-PART-ONLY: Disable background inheritance (for scenarios only). + current_rule = getattr(ctx, "rule", None) + if current_rule and current_rule.background: + # DISABLED: print("DISABLE-BACKGROUND-INHERITANCE FOR RULE: %s" % current_rule.name) + current_rule.background.use_inheritance = False + + +fixture_registry = { + "fixture.behave.no_background": behave_no_background, + "fixture.behave.override_background": behave_disable_background_inheritance, +} + + # ----------------------------------------------------------------------------- # HOOKS: # ----------------------------------------------------------------------------- @@ -44,6 +76,11 @@ def before_scenario(context, scenario): scenario.skip(reason=active_tag_matcher.exclude_reason) +def before_tag(context, tag): + if tag.startswith("fixture."): + return use_fixture_by_tag(tag, context, fixture_registry) + + # ----------------------------------------------------------------------------- # SPECIFIC FUNCTIONALITY: # ----------------------------------------------------------------------------- diff --git a/features/step_matcher.cucumber_expressions.feature b/features/step_matcher.cucumber_expressions.feature new file mode 100644 index 000000000..1893da46f --- /dev/null +++ b/features/step_matcher.cucumber_expressions.feature @@ -0,0 +1,301 @@ +@use.with_python.min_version=3.8 +Feature: Use StepMatcher with CucumberExpressions + + As a test writer + I want to write steps in "*.feature" files with CucumberExpressions + So that I can use a human friendly alternative to regular expressions + And that I can use parameter types and type converters for them. + + . CUCUMBER EXPRESSIONS: + . * Provide a compact, readable placeholder syntax in step definitions + . * Support pre-defined parameter types with type conversion + . * Support to define own parameter types + . + . STEP DEFINITION EXAMPLES WITH CUCUMBER EXPRESSIONS: + . I have {int} cucumbers in my belly + . I have {float} cucumbers in my belly + . I have a {color} ball + . + . PREDEFINED PARAMETER TYPES: + . | ParameterType | Type | Description + . | {int} | int | Matches an 32-bit integer number and converts to it, like: 42 | + . | {float} | float | Matches "float" (as 32-bit float), like: `3.6`, `.8`, `-9.2` | + . | {word} | string | Matches one word without whitespace, like: `banana` (not: `banana split`). + . | {string} | string | Matches double-/single-quoted strings, for example `"banana split"` (not: `banana split`). | + . | {} | string | Matches anything, like `re_pattern = ".*"` | + . | {bigdecimal} | Decimal | Matches "float", but converts to "BigDecimal" if platform supports it. | + . | {double} | float | Matches "float", but converts to 64-bit float number if platform supports it. | + . | {biginteger} | int | Matches "int", but converts to "BigInteger" if platform supports it. | + . | {byte} | int | Matches "int", but converts to 8-bit signed integer if platform supports it. | + . | {short} | int | Matches "int", but converts to 16-bit signed integer if platform supports it. | + . | {long} | int | Matches "int", but converts to 64-bit signed integer if platform supports it. | + . + . STEP DEFINITION EXAMPLES FOR MATCHING OTHER PARTS: + . * MATCHING OPTIONAL TEXT, like: + . I have {int} cucumber(s) in my belly + . MATCHES: + . I have 1 cucumber in my belly + . I have 42 cucumbers in my belly + . + . * ALTERNATIVE TEXT, like: + . I have {int} cucumber(s) in my belly/stomach + . MATCHES: + . I have 1 cucumber in my belly + . I have 42 cucumbers in my stomach + . + . * ESCAPING TO USE: `()` or `{}`, like: + . I have {int} \{what} cucumber(s) in my belly \(amazing!) + . MATCHES: + . I have 1 {what} cucumber in my belly (amazing!) + . I have 42 {what} cucumbers in my belly (amazing!) + . + . SEE ALSO: https://github.com/cucumber/cucumber-expressions + . SIMILAR: parse-expressions + . * https://github.com/r1chardj0n3s/parse + . * https://github.com/jenisys/parse_type + + + Background: + Given a new working directory + And an empty file named "example4me/__init__.py" + And a file named "example4me/color.py" with: + """ + from enum import Enum + + class Color(Enum): + red = 1 + green = 2 + blue = 3 + + @classmethod + def from_name(cls, text: str): + text = text.lower() + for enum_item in iter(cls): + if enum_item.name == text: + return enum_item + # -- OOPS: + raise ValueError("UNEXPECTED: {}".format(text)) + """ + And a file named "features/steps/page_steps.py" with: + """ + from behave import step + + # -- STEP DEFINITIONS: Use ALTERNATIVES + @step("I am on the profile customisation/settings page") + def step_on_profile_settings_page(ctx): + print("STEP: Given I am on profile ... page") + """ + And a file named "features/environment.py" with: + """ + from behave.cucumber_expression import use_step_matcher_for_cucumber_expressions + + # -- HINT: Use StepMatcher4CucumberExpressions as default step-matcher. + use_step_matcher_for_cucumber_expressions() + """ + And a file named "features/cucumber_expression.feature" with: + """ + Feature: Use CucumberExpressions in Step Definitions + Scenario: User selects a color twice + Given I am on the profile settings page + When I select the "red" theme colour + But I select the "blue" theme color + Then the profile color should be "blue" + """ + + @fixture.behave.override_background + Rule: Use predefined ParameterType(s) + + Background: + + Scenario Outline: Number ParameterType: + When I provide an "" value as + Then the stored value equals "" as + + Examples: Integer number + | parameter_type | value | value_type | + | int | 11 | int | + | short | -12 | int | + | long | 13 | int | + | biginteger | -14 | int | + | byte | 15 | int | + + Examples: Floating-point number + | parameter_type | value | value_type | + | float | 1.2 | float | + | double | -10.2 | float | + | bigdecimal | 13.02 | float | + + + Scenario Outline: String-like ParameterType: + When I provide an "" value as + Then the stored value equals "" as string + + Examples: String + | parameter_type | value | value_type | + | word | Alice | string | + | string | Alice and Bob | string | + + Examples: Match anything + | parameter_type | value | value_type | + | any | Alice has 2 | string | + + + Rule: Use own ParameterType(s) + Scenario: Use Step-Definitions with Step-Parameters + And a file named "features/steps/color_steps.py" with: + """ + from behave import given, when, then + from behave.cucumber_expression import ( + ParameterType, + define_parameter_type, + define_parameter_type_with + ) + from example4me.color import Color + + # -- REGISTER PARAMETER TYPES: + # OR: Use define_parameter_type_with(name="color", ...) + define_parameter_type(ParameterType( + name="color", + regexp="red|green|blue", + type=Color, + transformer=Color.from_name + )) + + # -- STEP DEFINITIONS: With OPTIONAL parts. + @when('I select the "{color}" theme colo(u)r') + def step_when_select_color_theme(ctx, color: Color): + assert isinstance(color, Color) + ctx.selected_color = color + + @then('the profile colo(u)r should be "{color}"') + def step_then_profile_color_should_be(ctx, the_color: Color): + assert isinstance(the_color, Color) + assert ctx.selected_color == the_color + """ + When I run "behave -f plain features/cucumber_expression.feature" + Then it should pass with: + """ + Feature: Use CucumberExpressions in Step Definitions + Scenario: User selects a color twice + Given I am on the profile settings page ... passed + When I select the "red" theme colour ... passed + But I select the "blue" theme color ... passed + Then the profile color should be "blue" ... passed + """ + And the command output should contain: + """ + 1 feature passed, 0 failed, 0 skipped + 1 scenario passed, 0 failed, 0 skipped + 4 steps passed, 0 failed, 0 skipped, 0 undefined + """ + And note that "step-definitions with CucumberExpressions can be used" + + + Rule: Use TypeBuilder for ParameterType(s) + + Scenario: Use TypeBuilder for Color enum + And a file named "features/steps/color_steps.py" with: + """ + from behave import given, when, then + from behave.cucumber_expression import define_parameter_type_with + from example4me.color import Color + from parse_type import TypeBuilder + + parse_color = TypeBuilder.make_enum(Color) + + # -- REGISTER PARAMETER TYPES: + define_parameter_type_with( + name="color", + regexp=parse_color.pattern, + type=Color, + transformer=parse_color + ) + + # -- STEP DEFINITIONS: With OPTIONAL parts. + @when('I select the "{color}" theme colo(u)r') + def step_when_select_color_theme(ctx, color: Color): + assert isinstance(color, Color) + ctx.selected_color = color + + @then('the profile colo(u)r should be "{color}"') + def step_then_profile_color_should_be(ctx, the_color: Color): + assert isinstance(the_color, Color) + assert ctx.selected_color == the_color + """ + When I run "behave -f plain features/cucumber_expression.feature" + Then it should pass with: + """ + Feature: Use CucumberExpressions in Step Definitions + Scenario: User selects a color twice + Given I am on the profile settings page ... passed + When I select the "red" theme colour ... passed + But I select the "blue" theme color ... passed + Then the profile color should be "blue" ... passed + """ + And the command output should contain: + """ + 1 feature passed, 0 failed, 0 skipped + 1 scenario passed, 0 failed, 0 skipped + 4 steps passed, 0 failed, 0 skipped, 0 undefined + """ + And note that "step-definitions with CucumberExpressions can be used" + + Scenario: Use TypeBuilder for Many Items + And a file named "features/steps/color_steps.py" with: + """ + from typing import List + from behave import given, when, then + from behave.cucumber_expression import ( + TypeBuilder, + define_parameter_type_with + ) + from example4me.color import Color + from assertpy import assert_that + + parse_color = TypeBuilder.make_enum(Color) + parse_colors = TypeBuilder.with_many(parse_color) + + # -- REGISTER PARAMETER TYPES: + define_parameter_type_with( + name="colors", + regexp=parse_colors.pattern, + type=list, # HINT: List[Color] + transformer=parse_colors + ) + + # -- STEP DEFINITIONS: With OPTIONAL parts. + @when('I select the "{colors}" colo(u)r(s)') + def step_when_select_many_colors(ctx, colors: List[Color]): + assert isinstance(colors, list) + for index, color in enumerate(colors): + assert isinstance(color, Color), "%r (index=%d)" % (color, index) + ctx.selected_colors = colors + + @then('I have selected {int} colo(u)r(s)') + def step_then_count_selected_colors(ctx, number_of_colors: int): + assert isinstance(number_of_colors, int) + assert_that(ctx.selected_colors).is_length(number_of_colors) + """ + And a file named "features/many_colors.feature" with: + """ + Feature: Use TypeBuilder.with_many + Scenario: User selects many colors with cardinality=1 + When I select the "blue" colour + Then I have selected 1 colour + + Scenario: User selects many colors with cardinality=3 + When I select the "red, blue, green" colors + Then I have selected 3 colors + """ + When I run "behave -f plain features/many_colors.feature" + Then it should pass with: + """ + Scenario: User selects many colors with cardinality=1 + When I select the "blue" colour ... passed + Then I have selected 1 colour ... passed + + Scenario: User selects many colors with cardinality=3 + When I select the "red, blue, green" colors ... passed + Then I have selected 3 colors ... passed + """ + And note that "TypeBuilder.with_many() can be used with ParameterType(s)" diff --git a/features/steps/cucumber_expression_steps.py b/features/steps/cucumber_expression_steps.py new file mode 100644 index 000000000..dae7abc53 --- /dev/null +++ b/features/steps/cucumber_expression_steps.py @@ -0,0 +1,211 @@ +""" +Provides some steps for testing step-definitions with `CucumberExpressions`_. + +.. _CucumberExpressions: https://github.com/cucumber/cucumber-expressions +""" + +from __future__ import absolute_import, print_function +from decimal import Decimal +from behave import given, when, then, step +from assertpy import assert_that +import six + +try: + # -- REQUIRES: Python3, Python.version >= 3.8 + from behave.cucumber_expression import use_step_matcher_for_cucumber_expressions + HAVE_CUCUMBER_EXPRESSIONS = True +except (ImportError, SyntaxError): + HAVE_CUCUMBER_EXPRESSIONS = False + + +# ----------------------------------------------------------------------------- +# CONSTANTS +# ----------------------------------------------------------------------------- +BYTE_MAX_VALUE = 255 +BYTE_MIN_VALUE = -256 + +SHORT_MAX_VALUE = int(2**16/2) - 1 +SHORT_MIN_VALUE = -SHORT_MAX_VALUE - 1 + +INT_MAX_VALUE = int(2**32/2) - 1 +INT_MIN_VALUE = -INT_MAX_VALUE - 1 + +LONG_MAX_VALUE = int(2**64/2) - 1 +LONG_MIN_VALUE = -LONG_MAX_VALUE - 1 + + +FLOAT_ACCURACY = 0.00001 + + +# ----------------------------------------------------------------------------- +# SETUP: +# ----------------------------------------------------------------------------- +if HAVE_CUCUMBER_EXPRESSIONS: + use_step_matcher_for_cucumber_expressions() + + +# ----------------------------------------------------------------------------- +# PARAMETER TYPES +# ----------------------------------------------------------------------------- + + +# ----------------------------------------------------------------------------- +# STEP DEFINITIONS: For integer numbers +# ----------------------------------------------------------------------------- +if HAVE_CUCUMBER_EXPRESSIONS: + @given('I provide a/an "{int}" value as int') + @when('I provide a/an "{int}" value as int') + def step_provide_value_as_int(ctx, value): + assert_that(value).is_instance_of(int) + assert_that(value).is_greater_than_or_equal_to(INT_MIN_VALUE) + assert_that(value).is_less_than_or_equal_to(INT_MAX_VALUE) + ctx.value = value + + + @given('I provide a/an "{short}" value as short') + @when('I provide a/an "{short}" value as short') + def step_provide_value_as_short(ctx, value): + assert_that(value).is_instance_of(int) + assert_that(value).is_greater_than_or_equal_to(SHORT_MIN_VALUE) + assert_that(value).is_less_than_or_equal_to(SHORT_MAX_VALUE) + ctx.value = value + + + @given('I provide a/an "{long}" value as long') + @when('I provide a/an "{long}" value as long') + def step_provide_value_as_long(ctx, value): + assert_that(value).is_instance_of(int) + assert_that(value).is_greater_than_or_equal_to(LONG_MIN_VALUE) + assert_that(value).is_less_than_or_equal_to(LONG_MAX_VALUE) + ctx.value = value + + + @given('I provide a/an "{biginteger}" value as biginteger') + @when('I provide a/an "{biginteger}" value as biginteger') + def step_provide_value_as_biginteger(ctx, value): + assert_that(value).is_instance_of(int) + ctx.value = value + + + @given('I provide a/an "{byte}" value as byte') + @when('I provide a/an "{byte}" value as byte') + def step_provide_value_as_byte(ctx, value): + assert_that(value).is_instance_of(int) + assert_that(value).is_greater_than_or_equal_to(BYTE_MIN_VALUE) + assert_that(value).is_less_than_or_equal_to(BYTE_MAX_VALUE) + ctx.value = value + + + # -- THEN STEPS: + @then('the stored value equals "{int}" as int') + def step_then_stored_value_equals_as_int(ctx, expected): + assert_that(expected).is_instance_of(int) + assert_that(ctx.value).is_equal_to(expected) + + + @then('the stored value equals "{short}" as short') + def step_then_stored_value_equals_as_short(ctx, expected): + assert_that(expected).is_instance_of(int) + assert_that(ctx.value).is_equal_to(expected) + + + @then('the stored value equals "{long}" as long') + def step_then_stored_value_equals_as_long(ctx, expected): + assert_that(expected).is_instance_of(int) + assert_that(ctx.value).is_equal_to(expected) + + + @then('the stored value equals "{biginteger}" as biginteger') + def step_then_stored_value_equals_as_biginteger(ctx, expected): + assert_that(expected).is_instance_of(int) + assert_that(ctx.value).is_equal_to(expected) + + +# ----------------------------------------------------------------------------- +# STEP DEFINITIONS: For float numbers +# ----------------------------------------------------------------------------- +if HAVE_CUCUMBER_EXPRESSIONS: + @given('I provide a/an "{float}" value as float') + @when('I provide a/an "{float}" value as float') + def step_provide_value_as_float(ctx, value): + assert_that(value).is_instance_of(float) + ctx.value = value + + + @given('I provide a/an "{double}" value as double') + @when('I provide a/an "{double}" value as double') + def step_provide_value_as_double(ctx, value): + assert_that(value).is_instance_of(float) + ctx.value = value + + + @given('I provide a/an "{bigdecimal}" value as bigdecimal') + @when('I provide a/an "{bigdecimal}" value as bigdecimal') + def step_provide_value_as_bigdecimal(ctx, value): + assert_that(value).is_instance_of(Decimal) + ctx.value = value + + + @then('the stored value equals "{float}" as float') + def step_then_stored_value_equals_as_float(ctx, expected): + assert_that(expected).is_instance_of(float) + assert_that(ctx.value).is_close_to(expected, FLOAT_ACCURACY) + + + @then('the stored value equals "{double}" as double') + def step_then_stored_value_equals_as_double(ctx, expected): + assert_that(expected).is_instance_of(float) + assert_that(ctx.value).is_close_to(expected, FLOAT_ACCURACY) + + + @then('the stored value equals "{bigdecimal}" as bigdecimal') + def step_then_stored_value_equals_as_bigdecimal(ctx, expected): + assert_that(expected).is_instance_of(Decimal) + assert_that(ctx.value).is_close_to(expected, FLOAT_ACCURACY) + + +# ----------------------------------------------------------------------------- +# STEP DEFINITIONS: For string-like parameter types +# ----------------------------------------------------------------------------- +if HAVE_CUCUMBER_EXPRESSIONS: + @given('I provide a/an "{word}" value as word') + @when('I provide a/an "{word}" value as word') + def step_provide_value_as_word(ctx, value): + assert assert_that(value).is_instance_of(str) + ctx.value = value + + + @given('I provide a/an {string} value as string') + @when('I provide a/an {string} value as string') + def step_provide_value_as_string(ctx, value): + assert assert_that(value).is_instance_of(str) + ctx.value = value + + + @then('the stored value equals "{word}" as word') + def step_then_stored_value_equals_as_word(ctx, expected): + assert assert_that(expected).is_instance_of(str) + assert_that(ctx.value).is_equal_to(expected) + + + @then('the stored value equals {string} as string') + def step_then_stored_value_equals_as_string(ctx, expected): + assert assert_that(expected).is_instance_of(str) + assert_that(ctx.value).is_equal_to(expected) + + +# ----------------------------------------------------------------------------- +# STEP DEFINITIONS: For match anything parameter types +# ----------------------------------------------------------------------------- +if HAVE_CUCUMBER_EXPRESSIONS: + @given('I provide a/an "{}" value as any') + @when('I provide a/an "{}" value as any') + def step_provide_value_as_any(ctx, value): + assert assert_that(value).is_instance_of(str) + ctx.value = value + + + @then('the stored value equals "{}" as any') + def step_then_stored_value_equals_as_any(ctx, expected): + assert assert_that(expected).is_instance_of(str) + assert_that(ctx.value).is_equal_to(expected) diff --git a/py.requirements/basic.txt b/py.requirements/basic.txt index 08fded02e..55883a985 100644 --- a/py.requirements/basic.txt +++ b/py.requirements/basic.txt @@ -7,8 +7,10 @@ # SEE ALSO: # * https://pip.pypa.io/en/stable/user_guide/ # ============================================================================ +# MAYBE: cucumber-expressions >= 15.0.0; python_version >='3.5' cucumber-tag-expressions >= 4.1.0 +cucumber-expressions >= 17.1.0; python_version >= '3.8' enum34; python_version < '3.4' parse >= 1.18.0 parse_type >= 0.6.0 diff --git a/pyproject.toml b/pyproject.toml index bdae32b84..d433276a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,7 @@ classifiers = [ ] dependencies = [ "cucumber-tag-expressions >= 4.1.0", + "cucumber-expressions >= 17.1.0; python_version >= '3.8'", "enum34; python_version < '3.4'", "parse >= 1.18.0", "parse-type >= 0.6.0", diff --git a/setup.py b/setup.py index 6c121468c..2fd5f2f80 100644 --- a/setup.py +++ b/setup.py @@ -77,6 +77,7 @@ def find_packages_by_root_package(where): python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*", install_requires=[ "cucumber-tag-expressions >= 4.1.0", + "cucumber-expressions >= 17.1.0; python_version >= '3.8'", "enum34; python_version < '3.4'", "parse >= 1.18.0", "parse-type >= 0.6.0", diff --git a/tests/unit/test_cucumber_expression.py b/tests/unit/test_cucumber_expression.py new file mode 100644 index 000000000..8eb1266b8 --- /dev/null +++ b/tests/unit/test_cucumber_expression.py @@ -0,0 +1,353 @@ +""" +Tests for :mod:`behave.cucumber_expression` module. + +RELATED TO: + +* Step Definitions (aka: step_matcher) with CucumberExpressions +""" + +from __future__ import absolute_import, print_function +from contextlib import contextmanager +from enum import Enum + +import parse +import six +import pytest +# MAYBE: from assertpy import assert_that + +try: + # -- REQUIRES: Python3, Python.version >= 3.8 (probably) + from behave.cucumber_expression import ( + ParameterType, + ParameterTypeRegistry, + StepMatcher4CucumberExpressions, + TypeBuilder, + ) + HAVE_CUCUMBER_EXPRESSIONS = True +except (ImportError, SyntaxError): + # -- GUARD FOR: Python2 and Python3 (< 3.8) + HAVE_CUCUMBER_EXPRESSIONS = False + + +# ----------------------------------------------------------------------------- +# TEST CANDIDATE SUPPORT +# ----------------------------------------------------------------------------- +class Color(Enum): + red = 1 + green = 2 + blue = 3 + + @classmethod + def from_name(cls, text): + for enum_item in iter(cls): + if enum_item.name == text: + return enum_item + # -- NOT-FOUND: + expected_names = [ei.name for ei in iter(cls)] + message = "%r (expected: %s)" % (text, ", ".join(expected_names)) + raise ValueError(message) + + +COLOR_NAMES = [enum_item.name for enum_item in iter(Color)] +COLOR_UPPER_CASE_NAMES = [name.upper() for name in COLOR_NAMES] +COLOR_EXTENDED_NAMES = COLOR_NAMES + COLOR_UPPER_CASE_NAMES + + +@parse.with_pattern(r"\d+") +def parse_number(text): + return int(text) + + +# ----------------------------------------------------------------------------- +# TEST SUPPORT +# ----------------------------------------------------------------------------- +class FakeContext(object): + def __init__(self, **kwargs): + for name, value in kwargs.items(): + setattr(self, name, value) + + @contextmanager + def use_with_user_mode(self): + yield self + + +def step_do_nothing(ctx, *args, **kwargs): + print("STEP CALLED WITH: args=%r, kwargs=%r" % (args, kwargs)) + + +class StepRunner(object): + def __init__(self, step_matcher): + self.step_matcher = step_matcher + + @classmethod + def with_step_matcher(cls, pattern, func=None, step_type=None, + parameter_types=None): + if func is None: + func = step_do_nothing + + step_matcher = StepMatcher4CucumberExpressions(func, pattern, + step_type=step_type, + parameter_types=parameter_types) + return cls(step_matcher) + + def match_and_run(self, step_text): + matched = self.step_matcher.match(step_text) + assert matched is not None, "%r" % matched + ctx = FakeContext() + _result = matched.run(ctx) + return ctx + + def assert_step_is_not_matched(self, step_text): + matched = self.step_matcher.match(step_text) + assert matched is None, "%r" % matched + + +# ----------------------------------------------------------------------------- +# TEST FIXTURES +# ----------------------------------------------------------------------------- +@pytest.fixture +def parameter_type_registry(): + parameter_type_registry = ParameterTypeRegistry() + yield parameter_type_registry + + +# ----------------------------------------------------------------------------- +# TEST SUITE -- REQUIRES: Python3, probably Python.version >= 3.8 +# ----------------------------------------------------------------------------- +if HAVE_CUCUMBER_EXPRESSIONS: + @pytest.mark.skipif(six.PY2, reason="REQUIRES: Python3") + class TestBasics(object): + """Tests that checks basic functionality.""" + pass + + + @pytest.mark.skipif(six.PY2, reason="REQUIRES: Python3") + class TestParameterType4Int(object): + """Using predefined :class:`ParameterType`(s) for integer numbers""" + + + @pytest.mark.skipif(six.PY2, reason="REQUIRES: Python3") + class TestParameterType4Float(object): + """Using predefined :class:`ParameterType`(s) for float numbers""" + + + @pytest.mark.skipif(six.PY2, reason="REQUIRES: Python3") + class TestParameterType4String(object): + """Using predefined :class:`ParameterType`(s) for string(s)""" + pass + + + @pytest.mark.skipif(six.PY2, reason="REQUIRES: Python3") + class TestParameterType4User(object): + """Tests using own, user-defined ParameterType(s).""" + + @pytest.mark.parametrize("color_name", COLOR_NAMES) + def test_enum(self, color_name, parameter_type_registry): + parameter_type_registry.define_parameter_type( + ParameterType( + "color", "red|green|blue", Color, + transformer=Color.from_name + ) + ) + + def this_step_func(ctx, color): + ctx.color = color + + this_step_pattern = 'I use {color} color' + step_runner = StepRunner.with_step_matcher(this_step_pattern, this_step_func, + parameter_types=parameter_type_registry) + + step_text = this_step_pattern.format(color=color_name) + ctx = step_runner.match_and_run(step_text) + assert ctx.color == Color.from_name(color_name) + assert isinstance(ctx.color, Color) + + @pytest.mark.parametrize("bad_color_name", COLOR_UPPER_CASE_NAMES) + def test_enum_is_case_sensitive(self, bad_color_name, parameter_type_registry): + parameter_type_registry.define_parameter_type( + ParameterType( + "color", "red|green|blue", Color, + transformer=Color.from_name + ) + ) + + def this_step_func(ctx, color): + ctx.color = color + + this_step_pattern = 'I use {color} color' + step_runner = StepRunner.with_step_matcher(this_step_pattern, this_step_func, + parameter_types=parameter_type_registry) + + step_text = this_step_pattern.format(color=bad_color_name) + step_runner.assert_step_is_not_matched(step_text) + + + @pytest.mark.skipif(six.PY2, reason="REQUIRES: Python3") + class TestWithTypeBuilder(object): + """ + Use CucumberExpressions with :class:`TypeBuilder`. + Reuses :class:`parse_type.TypeBuilder` for "parse-expressions". + """ + + @pytest.mark.parametrize("color_name", COLOR_NAMES) + def test_make_enum_with_enum_class(self, color_name, parameter_type_registry): + parse_color = TypeBuilder.make_enum(Color) + parameter_type_registry.define_parameter_type(ParameterType( + "color", parse_color.pattern, Color, + transformer=parse_color + )) + + def this_step_func(ctx, color): + ctx.color = color + + this_step_pattern = 'I use {color} color' + step_runner = StepRunner.with_step_matcher(this_step_pattern, this_step_func, + parameter_types=parameter_type_registry) + + step_text = this_step_pattern.format(color=color_name) + ctx = step_runner.match_and_run(step_text) + assert ctx.color == Color.from_name(color_name) + assert isinstance(ctx.color, Color) + + @pytest.mark.parametrize("bad_color_name", COLOR_UPPER_CASE_NAMES) + def test_make_enum_is_case_sensitive(self, bad_color_name, parameter_type_registry): + parse_color = TypeBuilder.make_enum(Color) + parameter_type_registry.define_parameter_type(ParameterType( + "color", parse_color.pattern, type=Color, + transformer=parse_color + )) + + def this_step_func(ctx, color): + ctx.color = color + + this_step_pattern = 'I use {color} color' + step_runner = StepRunner.with_step_matcher(this_step_pattern, this_step_func, + parameter_types=parameter_type_registry) + + step_text = this_step_pattern.format(color=bad_color_name) + step_runner.assert_step_is_not_matched(step_text) + + @pytest.mark.parametrize("state_name, state_value", [("on", True), ("off", False)]) + def test_make_enum_with_mapping(self, state_name, state_value, parameter_type_registry): + parse_state_on = TypeBuilder.make_enum({"on": True, "off": False}) + parameter_type_registry.define_parameter_type(ParameterType( + "state_on", parse_state_on.pattern, type=bool, + transformer=parse_state_on + )) + + def this_step_func(ctx, state): + ctx.state_on = state + + this_step_pattern = 'the light is switched {state_on}' + step_runner = StepRunner.with_step_matcher(this_step_pattern, this_step_func, + parameter_types=parameter_type_registry) + + step_text = this_step_pattern.format(state_on=state_name) + ctx = step_runner.match_and_run(step_text) + assert ctx.state_on == state_value + assert isinstance(ctx.state_on, bool) + + @pytest.mark.parametrize("color_name", COLOR_NAMES) + def test_make_choice(self, color_name, parameter_type_registry): + parse_color_choice = TypeBuilder.make_choice(COLOR_NAMES) + parameter_type_registry.define_parameter_type(ParameterType( + "color_choice", parse_color_choice.pattern, type=str, + transformer=parse_color_choice + )) + + def this_step_func(ctx, color_name): + ctx.color_choice = color_name + + this_step_pattern = 'I use {color_choice} color' + step_runner = StepRunner.with_step_matcher(this_step_pattern, this_step_func, + parameter_types=parameter_type_registry) + + step_text = this_step_pattern.format(color_choice=color_name) + ctx = step_runner.match_and_run(step_text) + assert ctx.color_choice == color_name + assert isinstance(ctx.color_choice, str) + + @pytest.mark.parametrize("variant_text, variant_value", [ + ("0", 0), + ("42", 42), + ("red", Color.red), + ("green", Color.green), + ("blue", Color.blue), + ]) + def test_make_variant(self, variant_text, variant_value, parameter_type_registry): + parse_color = TypeBuilder.make_enum(Color) + parse_variant = TypeBuilder.make_variant([parse_number, parse_color]) + parameter_type_registry.define_parameter_type(ParameterType( + "number_or_color", parse_variant.pattern, type=None, + transformer=parse_variant + )) + + def this_step_func(ctx, number_or_color): + ctx.color = None + ctx.number = None + if isinstance(number_or_color, Color): + ctx.color = number_or_color + elif isinstance(number_or_color, int): + ctx.number = number_or_color + + this_step_pattern = 'I use {number_or_color} apples' + step_runner = StepRunner.with_step_matcher(this_step_pattern, this_step_func, + parameter_types=parameter_type_registry) + + step_text = this_step_pattern.format(number_or_color=variant_text) + ctx = step_runner.match_and_run(step_text) + if isinstance(variant_value, Color): + assert ctx.color == variant_value + assert isinstance(ctx.color, Color) + assert ctx.number is None + else: + assert ctx.number == variant_value + assert isinstance(ctx.number, int) + assert ctx.color is None + + @pytest.mark.parametrize("numbers_text, numbers_value", [ + ("1", [1]), + ("1, 2, 3", [1, 2, 3]), + ]) + def test_make_many(self, numbers_text, numbers_value, parameter_type_registry): + parse_numbers = TypeBuilder.with_many(parse_number) + parse_numbers_pattern = r"(%s)" % parse_numbers.pattern + parameter_type_registry.define_parameter_type(ParameterType( + "numbers", parse_numbers_pattern, list, + transformer=parse_numbers + )) + + def this_step_func(ctx, numbers): + ctx.numbers = numbers + + this_step_pattern = 'I use "{numbers}" as numbers' + step_runner = StepRunner.with_step_matcher(this_step_pattern, this_step_func, + parameter_types=parameter_type_registry) + + step_text = this_step_pattern.format(numbers=numbers_text) + ctx = step_runner.match_and_run(step_text) + assert ctx.numbers == numbers_value + + @pytest.mark.parametrize("numbers_text, numbers_value", [ + ("", []), + ("1", [1]), + ("1, 2, 3", [1, 2, 3]), + ]) + def test_make_many0(self, numbers_text, numbers_value, parameter_type_registry): + parse_numbers = TypeBuilder.with_many0(parse_number) + parse_numbers_pattern = r"(%s)" % parse_numbers.pattern + parameter_type_registry.define_parameter_type(ParameterType( + "numbers", parse_numbers_pattern, list, + transformer=parse_numbers + )) + + def this_step_func(ctx, numbers): + ctx.numbers = numbers + + this_step_pattern = 'I use "{numbers}" as numbers' + step_runner = StepRunner.with_step_matcher(this_step_pattern, this_step_func, + parameter_types=parameter_type_registry) + + step_text = this_step_pattern.format(numbers=numbers_text) + ctx = step_runner.match_and_run(step_text) + assert ctx.numbers == numbers_value From e82a2d58e0dfa9b71dad183ebe0a88c297420e7e Mon Sep 17 00:00:00 2001 From: jenisys Date: Fri, 31 May 2024 22:28:58 +0200 Subject: [PATCH 226/240] CI: Update CodeQL actions to v3 (was: v2) --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ccf2f3ca8..330737fa1 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -53,7 +53,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -67,4 +67,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 From 262b6a29b80c40a7ddcad8526fcf88bdc4afe21f Mon Sep 17 00:00:00 2001 From: jenisys Date: Fri, 31 May 2024 23:45:32 +0200 Subject: [PATCH 227/240] tests: Tweak features/step_matcher.cucumber_expressions.feature --- .../step_matcher.cucumber_expressions.feature | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/features/step_matcher.cucumber_expressions.feature b/features/step_matcher.cucumber_expressions.feature index 1893da46f..b64bed577 100644 --- a/features/step_matcher.cucumber_expressions.feature +++ b/features/step_matcher.cucumber_expressions.feature @@ -92,15 +92,6 @@ Feature: Use StepMatcher with CucumberExpressions # -- HINT: Use StepMatcher4CucumberExpressions as default step-matcher. use_step_matcher_for_cucumber_expressions() """ - And a file named "features/cucumber_expression.feature" with: - """ - Feature: Use CucumberExpressions in Step Definitions - Scenario: User selects a color twice - Given I am on the profile settings page - When I select the "red" theme colour - But I select the "blue" theme color - Then the profile color should be "blue" - """ @fixture.behave.override_background Rule: Use predefined ParameterType(s) @@ -172,6 +163,15 @@ Feature: Use StepMatcher with CucumberExpressions assert isinstance(the_color, Color) assert ctx.selected_color == the_color """ + And a file named "features/cucumber_expression.feature" with: + """ + Feature: Use CucumberExpressions in Step Definitions + Scenario: User selects a color twice + Given I am on the profile settings page + When I select the "red" theme colour + But I select the "blue" theme color + Then the profile color should be "blue" + """ When I run "behave -f plain features/cucumber_expression.feature" Then it should pass with: """ @@ -222,6 +222,15 @@ Feature: Use StepMatcher with CucumberExpressions assert isinstance(the_color, Color) assert ctx.selected_color == the_color """ + And a file named "features/cucumber_expression.feature" with: + """ + Feature: Use CucumberExpressions in Step Definitions + Scenario: User selects a color twice + Given I am on the profile settings page + When I select the "red" theme colour + But I select the "blue" theme color + Then the profile color should be "blue" + """ When I run "behave -f plain features/cucumber_expression.feature" Then it should pass with: """ @@ -241,7 +250,7 @@ Feature: Use StepMatcher with CucumberExpressions And note that "step-definitions with CucumberExpressions can be used" Scenario: Use TypeBuilder for Many Items - And a file named "features/steps/color_steps.py" with: + And a file named "features/steps/many_color_steps.py" with: """ from typing import List from behave import given, when, then From 8ba4c7d56788f6117741aeae332798b7b96d1b7b Mon Sep 17 00:00:00 2001 From: jenisys Date: Fri, 31 May 2024 23:53:35 +0200 Subject: [PATCH 228/240] parse-expression: Provide some basic parse-functions/type-converters --- behave/parameter_type.py | 166 +++++++++++++++++++++++++ tests/unit/test_parameter_type.py | 195 ++++++++++++++++++++++++++++++ 2 files changed, 361 insertions(+) create mode 100644 behave/parameter_type.py create mode 100644 tests/unit/test_parameter_type.py diff --git a/behave/parameter_type.py b/behave/parameter_type.py new file mode 100644 index 000000000..c21f72d87 --- /dev/null +++ b/behave/parameter_type.py @@ -0,0 +1,166 @@ +""" +Provide some "parameter-types" (type-converters) for step parameters +that can be used in ``parse-expressions`` (step_matcher: "parse"/"cfparse"). + +EXAMPLE 1:: + + # -- FILE: features/steps/example_steps1.py + from behave import given, register_type + from behave.parameter_type import parse_number + + register_type(Number=parse_number) + + # -- EXAMPLE: "Given I buy 2 apples" + @given('I buy {amount:Number} apples'): + def step_given_buy_apples(ctx, amount: int): + pass + +EXAMPLE 2:: + + # -- FILE: features/steps/example_steps2.py + from behave import given, register_type + from behave.parameter_type import parse_number + from parse_type import TypeBuilder + + FRUITS = [ + "apple", "banana", "orange", # -- SINGULAR + "apples", "bananas", "oranges", # -- PLURAL + ] + parse_fruits = TypeBuilder.make_choice(FRUITS) + + register_type(Fruit=parse_fruit) + register_type(Number=parse_number) + + # -- EXAMPLE: "Given I sell 1 apple", "Given I sell 2 bananas", ... + @given('I sell {amount:Number} {fruit:Fruit}'): + def step_given_sell_fruits(ctx, amount: int, fruit: str): + pass +""" + +from __future__ import absolute_import, print_function +from collections import namedtuple +import os +import parse +from behave import register_type + + +# ----------------------------------------------------------------------------- +# VALUE OBJECT CLASSES +# ----------------------------------------------------------------------------- +EnvironmentVar = namedtuple("EnvironmentVar", ["name", "value"]) + + +# ----------------------------------------------------------------------------- +# TYPE CONVERTERS +# ----------------------------------------------------------------------------- +@parse.with_pattern(r"\d+") +def parse_number(text): + """ + Type converter that matches an integer number and converts to an "int". + + :param text: Text to use. + :return: Converted number (as int). + :raises: ValueError, if number conversion fails + """ + return int(text) + + +@parse.with_pattern(r".*") +def parse_any_text(text): + """ + Type converter that matches ANY text (even: EMPTY_STRING). + + EXAMPLE: + + .. code-block:: python + + # -- FILE: features/steps/example_steps.py + from behave import step, register_type + from behave.step_parameter import parse_any_text + + register_type(AnyText=parse_any_text) + + @step('a parameter with "{param:AnyText}"') + def step_use_parameter(context, param): + pass + + .. code-block:: gherkin + + # -- FILE: features/example_any_text.feature + ... + Given a parameter with "" + Given a parameter with "one" + Given a parameter with "one two three" + """ + return text + + +@parse.with_pattern(r'[^"]*') +def parse_unquoted_text(text): + """ + Type converter that matches UNQUOTED text (using: double-quotes). + + EXAMPLE: + + .. code-block:: python + + # -- FILE: features/steps/example_steps.py + from behave import step, register_type + from behave.step_parameter import parse_unquoted_text + + register_type(Unquoted=parse_unquoted_text) + + @step('some parameter with "{param:Unquoted}"') + def step_some_parameter(context, param): + pass + """ + return text + + +@parse.with_pattern(r"\$\w+") # -- ONLY FOR: $WORD +def parse_environment_var(text, default=None): + """ + Matches the name of a process environment-variable, like "$HOME". + The name and value of this environment-variable is returned + as value-object. + + If the environment-variable is undefined, its value is None. + + :param: Text to parse/convert (as string). + :returns: EnvironmentVar object with name and value. + + EXAMPLE: + + .. code-block:: gherkin + + # -- FILE: features/example_environment_var.feature + ... + Given I use "$TOP_DIR" as current directory + """ + assert text.startswith("$") + env_name = text[1:] + env_value = os.environ.get(env_name, default) + return EnvironmentVar(env_name, env_value) + + +# ----------------------------------------------------------------------------- +# TYPE REGISTRY: +# ----------------------------------------------------------------------------- +TYPE_REGISTRY = dict( + AnyText=parse_any_text, + Number=parse_number, + Unquoted=parse_unquoted_text, + EnvironmentVar=parse_environment_var, +) + + +def register_all_types(): + register_type(**TYPE_REGISTRY) + + +# ----------------------------------------------------------------------------- +# MODULE INIT: +# ----------------------------------------------------------------------------- +AUTO_REGISTER_TYPE_CONVERTERS = False +if AUTO_REGISTER_TYPE_CONVERTERS: + register_all_types() diff --git a/tests/unit/test_parameter_type.py b/tests/unit/test_parameter_type.py new file mode 100644 index 000000000..a80e149ea --- /dev/null +++ b/tests/unit/test_parameter_type.py @@ -0,0 +1,195 @@ +""" +Unit tests for :mod:`behave.parameter_type` module. +""" + +from __future__ import absolute_import, print_function +from contextlib import contextmanager +import os +from behave.parameter_type import ( + EnvironmentVar, + parse_number, + parse_any_text, + parse_unquoted_text, + parse_environment_var, +) +from parse import Parser +import pytest + + +# ----------------------------------------------------------------------------- +# TEST SUPPORT +# ----------------------------------------------------------------------------- +@contextmanager +def os_environ(): + try: + initial_environ = os.environ.copy() + yield os.environ + finally: + # -- RESTORE: + os.environ = initial_environ + + +# ----------------------------------------------------------------------------- +# TEST SUITE +# ----------------------------------------------------------------------------- +class TestParseNumber(object): + TYPE_REGISTRY = dict(Number=parse_number) + PATTERN = "Number: {number:Number}" + TEXT_TEMPLATE = "Number: {}" + + @classmethod + def assert_match_with_parse_number_and_converts_to_int(cls, text, expected): + parser = Parser(cls.PATTERN, extra_types=cls.TYPE_REGISTRY) + result = parser.parse(cls.TEXT_TEMPLATE.format(text)) + number = result["number"] + assert number == expected + assert isinstance(number, int) + + @classmethod + def assert_mismatch_with_parse_number(cls, text): + parser = Parser(cls.PATTERN, extra_types=cls.TYPE_REGISTRY) + result = parser.parse(cls.TEXT_TEMPLATE.format(text)) + assert result is None + + @pytest.mark.parametrize("text, expected", [ + ("0", 0), + ("12", 12), + ("321", 321), + ]) + def test_parse_number__matches_positive_number_and_zero(self, text, expected): + self.assert_match_with_parse_number_and_converts_to_int(text, expected) + + @pytest.mark.parametrize("text", ["-1", "-12"]) + def test_parse_number__mismatches_negavtive_number(self, text): + self.assert_mismatch_with_parse_number(text) + + +class TestParseAnyText(object): + TYPE_REGISTRY = dict(AnyText=parse_any_text) + PATTERN = 'AnyText: "{some:AnyText}"' + TEXT_TEMPLATE = 'AnyText: "{}"' + + @classmethod + def assert_match_with_parse_any_and_converts_to_string(cls, text, expected): + parser = Parser(cls.PATTERN, extra_types=cls.TYPE_REGISTRY) + result = parser.parse(cls.TEXT_TEMPLATE.format(text)) + actual_value = result["some"] + assert actual_value == expected + assert isinstance(actual_value, str) + + @classmethod + def assert_mismatch_with_parse_any(cls, text): + parser = Parser(cls.PATTERN, extra_types=cls.TYPE_REGISTRY) + result = parser.parse(cls.TEXT_TEMPLATE.format(text)) + assert result is None + + @pytest.mark.parametrize("text", ["Alice", "B_O_B", "charly-123"]) + def test_parse_any_text__matches_word(self, text): + expected = text + self.assert_match_with_parse_any_and_converts_to_string(text, expected) + + @pytest.mark.parametrize("text", ["Alice, Bob", "Alice and Bob"]) + def test_parse_any_text__matches_many_words(self, text): + expected = text + self.assert_match_with_parse_any_and_converts_to_string(text, expected) + + def test_parse_any_text__matches_empty_string(self): + text = "" + expected = text + self.assert_match_with_parse_any_and_converts_to_string(text, expected) + + @pytest.mark.parametrize("text", [" ", " ", "\t", "\n"]) + def test_parse_any_text__matches_whitespace(self, text): + expected = text + self.assert_match_with_parse_any_and_converts_to_string(text, expected) + + +class TestParseUnquotedText(object): + TYPE_REGISTRY = dict(Unquoted=parse_unquoted_text) + PATTERN = 'Unquoted: "{some:Unquoted}"' + TEXT_TEMPLATE = 'Unquoted: "{}"' + + @classmethod + def assert_match_with_parse_unquoted_and_converts_to_string(cls, text, expected): + parser = Parser(cls.PATTERN, extra_types=cls.TYPE_REGISTRY) + result = parser.parse(cls.TEXT_TEMPLATE.format(text)) + actual_value = result["some"] + assert actual_value == expected + assert isinstance(actual_value, str) + + @classmethod + def assert_mismatch_with_parse_unquoted(cls, text): + parser = Parser(cls.PATTERN, extra_types=cls.TYPE_REGISTRY) + result = parser.parse(cls.TEXT_TEMPLATE.format(text)) + assert result is None + + @pytest.mark.parametrize("text", ["Alice", "B_O_B", "charly-123"]) + def test_parse_unquoted_text__matches_word(self, text): + expected = text + self.assert_match_with_parse_unquoted_and_converts_to_string(text, expected) + + @pytest.mark.parametrize("text", ["Alice, Bob", "Alice and Bob"]) + def test_parse_unquoted_text__matches_many_words(self, text): + expected = text + self.assert_match_with_parse_unquoted_and_converts_to_string(text, expected) + + def test_parse_unquoted_text__matches_empty_string(self): + text = "" + expected = text + self.assert_match_with_parse_unquoted_and_converts_to_string(text, expected) + + @pytest.mark.parametrize("text", [" ", " ", "\t", "\n"]) + def test_parse_unquoted_text__matches_whitespace(self, text): + expected = text + self.assert_match_with_parse_unquoted_and_converts_to_string(text, expected) + + @pytest.mark.parametrize("text", ['Some "more', 'Alice "Bob and Charly"']) + def test_parse_unquoted_text__mismatches_string_with_double_quotes(self, text): + self.assert_mismatch_with_parse_unquoted(text) + + +class TestParseEnvironmentVar(object): + TYPE_REGISTRY = dict(EnvironmentVar=parse_environment_var) + PATTERN = 'EnvironmentVar: "{param:EnvironmentVar}"' + TEXT_TEMPLATE = 'EnvironmentVar: "{}"' + + @classmethod + def assert_match_with_parse_environment_var_returns_to_namedtuple(cls, text, expected): + parser = Parser(cls.PATTERN, extra_types=cls.TYPE_REGISTRY) + result = parser.parse(cls.TEXT_TEMPLATE.format(text)) + actual_param = result["param"] + assert actual_param.value == expected + assert isinstance(actual_param.value, str) + assert isinstance(actual_param, EnvironmentVar) + + @classmethod + def assert_match_with_parse_environment_var_and_undefined_returns_namedtuple_with_none(cls, text): + parser = Parser(cls.PATTERN, extra_types=cls.TYPE_REGISTRY) + result = parser.parse(cls.TEXT_TEMPLATE.format(text)) + actual_param = result["param"] + assert actual_param.value is None + assert isinstance(actual_param, EnvironmentVar) + + @classmethod + def assert_mismatch_with_parse_environment_var(cls, text): + parser = Parser(cls.PATTERN, extra_types=cls.TYPE_REGISTRY) + result = parser.parse(cls.TEXT_TEMPLATE.format(text)) + assert result is None + + @pytest.mark.parametrize("env_var", [ + EnvironmentVar("SSH_AGENT", "localhost:12345"), + EnvironmentVar("SSH_PID", "1234"), + ]) + def test_parse_environment_var__uses_defined_variable(self, env_var): + text = "${}".format(env_var.name) + expected = env_var.value + with os_environ() as environ: + environ[env_var.name] = env_var.value + self.assert_match_with_parse_environment_var_returns_to_namedtuple(text, expected) + + def test_parse_environment_var__uses_undefined_variable(self): + env_var = EnvironmentVar("UNDEFINED_VAR", None) + text = "${}".format(env_var.name) + with os_environ() as environ: + assert env_var.name not in environ + self.assert_match_with_parse_environment_var_and_undefined_returns_namedtuple_with_none(text) From 729fe18eb93fc48d7fa032377abdb47caf3fcc6c Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 1 Jun 2024 21:19:05 +0200 Subject: [PATCH 229/240] docs: Use new sphinx-theme for cleaner look * Use html_theme="furo" (was: "bootstrap") * New theme provides a much cleaner, paper-like documentation look CLEANUP: * Remove old theme="kr" and its stored assets --- docs/_themes/LICENSE | 45 -- docs/_themes/kr/layout.html | 17 - docs/_themes/kr/relations.html | 19 - docs/_themes/kr/static/flasky.css_t | 480 ---------------------- docs/_themes/kr/static/small_flask.css | 90 ---- docs/_themes/kr/theme.conf | 7 - docs/_themes/kr_small/layout.html | 22 - docs/_themes/kr_small/static/flasky.css_t | 287 ------------- docs/_themes/kr_small/theme.conf | 10 - docs/conf.py | 55 ++- py.requirements/docs.txt | 8 +- pyproject.toml | 7 +- setup.py | 7 +- 13 files changed, 51 insertions(+), 1003 deletions(-) delete mode 100644 docs/_themes/LICENSE delete mode 100644 docs/_themes/kr/layout.html delete mode 100644 docs/_themes/kr/relations.html delete mode 100644 docs/_themes/kr/static/flasky.css_t delete mode 100644 docs/_themes/kr/static/small_flask.css delete mode 100644 docs/_themes/kr/theme.conf delete mode 100644 docs/_themes/kr_small/layout.html delete mode 100644 docs/_themes/kr_small/static/flasky.css_t delete mode 100644 docs/_themes/kr_small/theme.conf diff --git a/docs/_themes/LICENSE b/docs/_themes/LICENSE deleted file mode 100644 index b160a8eeb..000000000 --- a/docs/_themes/LICENSE +++ /dev/null @@ -1,45 +0,0 @@ -Modifications: - -Copyright (c) 2011 Kenneth Reitz. - - -Original Project: - -Copyright (c) 2010 by Armin Ronacher. - - -Some rights reserved. - -Redistribution and use in source and binary forms of the theme, with or -without modification, are permitted provided that the following conditions -are met: - -* Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following - disclaimer in the documentation and/or other materials provided - with the distribution. - -* The names of the contributors may not be used to endorse or - promote products derived from this software without specific - prior written permission. - -We kindly ask you to only use these themes in an unmodified manner just -for Flask and Flask-related products, not for unrelated projects. If you -like the visual style and want to use it for your own projects, please -consider making some larger changes to the themes (such as changing -font faces, sizes, colors or margins). - -THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE -LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/_themes/kr/layout.html b/docs/_themes/kr/layout.html deleted file mode 100644 index ac8d7adbc..000000000 --- a/docs/_themes/kr/layout.html +++ /dev/null @@ -1,17 +0,0 @@ -{%- extends "basic/layout.html" %} -{%- block extrahead %} - {{ super() }} - {% if theme_touch_icon %} - - {% endif %} - -{% endblock %} -{%- block relbar2 %}{% endblock %} -{%- block footer %} - - - Fork me on GitHub - -{%- endblock %} diff --git a/docs/_themes/kr/relations.html b/docs/_themes/kr/relations.html deleted file mode 100644 index 3bbcde85b..000000000 --- a/docs/_themes/kr/relations.html +++ /dev/null @@ -1,19 +0,0 @@ -

Related Topics

- diff --git a/docs/_themes/kr/static/flasky.css_t b/docs/_themes/kr/static/flasky.css_t deleted file mode 100644 index bb00e9354..000000000 --- a/docs/_themes/kr/static/flasky.css_t +++ /dev/null @@ -1,480 +0,0 @@ -/* - * flasky.css_t - * ~~~~~~~~~~~~ - * - * :copyright: Copyright 2010 by Armin Ronacher. Modifications by Kenneth Reitz. - * :license: Flask Design License, see LICENSE for details. - */ - -{% set page_width = '940px' %} -{% set sidebar_width = '220px' %} - -@import url("basic.css"); - -/* -- page layout ----------------------------------------------------------- */ - -body { - font-family: 'goudy old style', 'minion pro', 'bell mt', Georgia, 'Hiragino Mincho Pro'; - font-size: 17px; - background-color: white; - color: #000; - margin: 0; - padding: 0; -} - -div.document { - width: {{ page_width }}; - margin: 30px auto 0 auto; -} - -div.documentwrapper { - float: left; - width: 100%; -} - -div.bodywrapper { - margin: 0 0 0 {{ sidebar_width }}; -} - -div.sphinxsidebar { - width: {{ sidebar_width }}; -} - -hr { - border: 1px solid #B1B4B6; -} - -div.body { - background-color: #ffffff; - color: #3E4349; - padding: 0 30px 0 30px; -} - -img.floatingflask { - padding: 0 0 10px 10px; - float: right; -} - -div.footer { - width: {{ page_width }}; - margin: 20px auto 30px auto; - font-size: 14px; - color: #888; - text-align: right; -} - -div.footer a { - color: #888; -} - -div.related { - display: none; -} - -div.sphinxsidebar a { - color: #444; - text-decoration: none; - border-bottom: 1px dotted #999; -} - -div.sphinxsidebar a:hover { - border-bottom: 1px solid #999; -} - -div.sphinxsidebar { - font-size: 14px; - line-height: 1.5; -} - -div.sphinxsidebarwrapper { - padding: 18px 10px; -} - -div.sphinxsidebarwrapper p.logo { - padding: 0; - margin: -10px 0 0 -20px; - text-align: center; -} - -div.sphinxsidebar h3, -div.sphinxsidebar h4 { - font-family: 'Garamond', 'Georgia', serif; - color: #444; - font-size: 24px; - font-weight: normal; - margin: 0 0 5px 0; - padding: 0; -} - -div.sphinxsidebar h4 { - font-size: 20px; -} - -div.sphinxsidebar h3 a { - color: #444; -} - -div.sphinxsidebar p.logo a, -div.sphinxsidebar h3 a, -div.sphinxsidebar p.logo a:hover, -div.sphinxsidebar h3 a:hover { - border: none; -} - -div.sphinxsidebar p { - color: #555; - margin: 10px 0; -} - -div.sphinxsidebar ul { - margin: 10px 0; - padding: 0; - color: #000; -} - -div.sphinxsidebar input { - border: 1px solid #ccc; - font-family: 'Georgia', serif; - font-size: 1em; -} - -/* -- body styles ----------------------------------------------------------- */ - -a { - color: #004B6B; - text-decoration: underline; -} - -a:hover { - color: #6D4100; - text-decoration: underline; -} - -div.body h1, -div.body h2, -div.body h3, -div.body h4, -div.body h5, -div.body h6 { - font-family: 'Garamond', 'Georgia', serif; - font-weight: normal; - margin: 30px 0px 10px 0px; - padding: 0; -} - -div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; } -div.body h2 { font-size: 180%; } -div.body h3 { font-size: 150%; } -div.body h4 { font-size: 130%; } -div.body h5 { font-size: 100%; } -div.body h6 { font-size: 100%; } - -a.headerlink { - color: #ddd; - padding: 0 4px; - text-decoration: none; -} - -a.headerlink:hover { - color: #444; - background: #eaeaea; -} - -div.body p, div.body dd, div.body li { - line-height: 1.4em; -} - -div.admonition { - background: #fafafa; - margin: 20px -30px; - padding: 10px 30px; - border-top: 1px solid #ccc; - border-bottom: 1px solid #ccc; -} - -div.admonition tt.xref, div.admonition a tt { - border-bottom: 1px solid #fafafa; -} - -dd div.admonition { - margin-left: -60px; - padding-left: 60px; -} - -div.admonition p.admonition-title { - font-family: 'Garamond', 'Georgia', serif; - font-weight: normal; - font-size: 24px; - margin: 0 0 10px 0; - padding: 0; - line-height: 1; -} - -div.admonition p.last { - margin-bottom: 0; -} - -div.highlight { - background-color: white; -} - -dt:target, .highlight { - background: #FAF3E8; -} - -div.note { - background-color: #eee; - border: 1px solid #ccc; -} - -div.seealso { - background-color: #ffc; - border: 1px solid #ff6; -} - -div.topic { - background-color: #eee; -} - -p.admonition-title { - display: inline; -} - -p.admonition-title:after { - content: ":"; -} - -pre, tt { - font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; - font-size: 0.9em; -} - -img.screenshot { -} - -tt.descname, tt.descclassname { - font-size: 0.95em; -} - -tt.descname { - padding-right: 0.08em; -} - -img.screenshot { - -moz-box-shadow: 2px 2px 4px #eee; - -webkit-box-shadow: 2px 2px 4px #eee; - box-shadow: 2px 2px 4px #eee; -} - -table.docutils { - border: 1px solid #888; - -moz-box-shadow: 2px 2px 4px #eee; - -webkit-box-shadow: 2px 2px 4px #eee; - box-shadow: 2px 2px 4px #eee; -} - -table.docutils td, table.docutils th { - border: 1px solid #888; - padding: 0.25em 0.7em; -} - -table.field-list, table.footnote { - border: none; - -moz-box-shadow: none; - -webkit-box-shadow: none; - box-shadow: none; -} - -table.footnote { - margin: 15px 0; - width: 100%; - border: 1px solid #eee; - background: #fdfdfd; - font-size: 0.9em; -} - -table.footnote + table.footnote { - margin-top: -15px; - border-top: none; -} - -table.field-list th { - padding: 0 0.8em 0 0; -} - -table.field-list td { - padding: 0; -} - -table.footnote td.label { - width: 0px; - padding: 0.3em 0 0.3em 0.5em; -} - -table.footnote td { - padding: 0.3em 0.5em; -} - -dl { - margin: 0; - padding: 0; -} - -dl dd { - margin-left: 30px; -} - -blockquote { - margin: 0 0 0 30px; - padding: 0; -} - -ul, ol { - margin: 10px 0 10px 30px; - padding: 0; -} - -pre { - background: #eee; - padding: 7px 30px; - margin: 15px -30px; - line-height: 1.3em; -} - -dl pre, blockquote pre, li pre { - margin-left: -60px; - padding-left: 60px; -} - -dl dl pre { - margin-left: -90px; - padding-left: 90px; -} - -tt { - background-color: #ecf0f3; - color: #222; - /* padding: 1px 2px; */ -} - -tt.xref, a tt { - background-color: #FBFBFB; - border-bottom: 1px solid white; -} - -a.reference { - text-decoration: none; - border-bottom: 1px dotted #004B6B; -} - -a.reference:hover { - border-bottom: 1px solid #6D4100; -} - -a.footnote-reference { - text-decoration: none; - font-size: 0.7em; - vertical-align: top; - border-bottom: 1px dotted #004B6B; -} - -a.footnote-reference:hover { - border-bottom: 1px solid #6D4100; -} - -a:hover tt { - background: #EEE; -} - - -@media screen and (max-width: 600px) { - - div.sphinxsidebar { - display: none; - } - - div.document { - width: 100%; - - } - - div.documentwrapper { - margin-left: 0; - margin-top: 0; - margin-right: 0; - margin-bottom: 0; - } - - div.bodywrapper { - margin-top: 0; - margin-right: 0; - margin-bottom: 0; - margin-left: 0; - } - - ul { - margin-left: 0; - } - - .document { - width: auto; - } - - .footer { - width: auto; - } - - .bodywrapper { - margin: 0; - } - - .footer { - width: auto; - } - - .github { - display: none; - } - -} - - -/* scrollbars */ - -::-webkit-scrollbar { - width: 6px; - height: 6px; -} - -::-webkit-scrollbar-button:start:decrement, -::-webkit-scrollbar-button:end:increment { - display: block; - height: 10px; -} - -::-webkit-scrollbar-button:vertical:increment { - background-color: #fff; -} - -::-webkit-scrollbar-track-piece { - background-color: #eee; - -webkit-border-radius: 3px; -} - -::-webkit-scrollbar-thumb:vertical { - height: 50px; - background-color: #ccc; - -webkit-border-radius: 3px; -} - -::-webkit-scrollbar-thumb:horizontal { - width: 50px; - background-color: #ccc; - -webkit-border-radius: 3px; -} - -/* misc. */ - -.revsys-inline { - display: none!important; -} \ No newline at end of file diff --git a/docs/_themes/kr/static/small_flask.css b/docs/_themes/kr/static/small_flask.css deleted file mode 100644 index 8d55e95fb..000000000 --- a/docs/_themes/kr/static/small_flask.css +++ /dev/null @@ -1,90 +0,0 @@ -/* - * small_flask.css_t - * ~~~~~~~~~~~~~~~~~ - * - * :copyright: Copyright 2010 by Armin Ronacher. - * :license: Flask Design License, see LICENSE for details. - */ - -body { - margin: 0; - padding: 20px 30px; -} - -div.documentwrapper { - float: none; - background: white; -} - -div.sphinxsidebar { - display: block; - float: none; - width: 102.5%; - margin: 50px -30px -20px -30px; - padding: 10px 20px; - background: #333; - color: white; -} - -div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p, -div.sphinxsidebar h3 a { - color: white; -} - -div.sphinxsidebar a { - color: #aaa; -} - -div.sphinxsidebar p.logo { - display: none; -} - -div.document { - width: 100%; - margin: 0; -} - -div.related { - display: block; - margin: 0; - padding: 10px 0 20px 0; -} - -div.related ul, -div.related ul li { - margin: 0; - padding: 0; -} - -div.footer { - display: none; -} - -div.bodywrapper { - margin: 0; -} - -div.body { - min-height: 0; - padding: 0; -} - -.rtd_doc_footer { - display: none; -} - -.document { - width: auto; -} - -.footer { - width: auto; -} - -.footer { - width: auto; -} - -.github { - display: none; -} \ No newline at end of file diff --git a/docs/_themes/kr/theme.conf b/docs/_themes/kr/theme.conf deleted file mode 100644 index 307a1f0d6..000000000 --- a/docs/_themes/kr/theme.conf +++ /dev/null @@ -1,7 +0,0 @@ -[theme] -inherit = basic -stylesheet = flasky.css -pygments_style = flask_theme_support.FlaskyStyle - -[options] -touch_icon = diff --git a/docs/_themes/kr_small/layout.html b/docs/_themes/kr_small/layout.html deleted file mode 100644 index aa1716aaf..000000000 --- a/docs/_themes/kr_small/layout.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends "basic/layout.html" %} -{% block header %} - {{ super() }} - {% if pagename == 'index' %} -
- {% endif %} -{% endblock %} -{% block footer %} - {% if pagename == 'index' %} -
- {% endif %} -{% endblock %} -{# do not display relbars #} -{% block relbar1 %}{% endblock %} -{% block relbar2 %} - {% if theme_github_fork %} - Fork me on GitHub - {% endif %} -{% endblock %} -{% block sidebar1 %}{% endblock %} -{% block sidebar2 %}{% endblock %} diff --git a/docs/_themes/kr_small/static/flasky.css_t b/docs/_themes/kr_small/static/flasky.css_t deleted file mode 100644 index fe2141c56..000000000 --- a/docs/_themes/kr_small/static/flasky.css_t +++ /dev/null @@ -1,287 +0,0 @@ -/* - * flasky.css_t - * ~~~~~~~~~~~~ - * - * Sphinx stylesheet -- flasky theme based on nature theme. - * - * :copyright: Copyright 2007-2010 by the Sphinx team, see AUTHORS. - * :license: BSD, see LICENSE for details. - * - */ - -@import url("basic.css"); - -/* -- page layout ----------------------------------------------------------- */ - -body { - font-family: 'Georgia', serif; - font-size: 17px; - color: #000; - background: white; - margin: 0; - padding: 0; -} - -div.documentwrapper { - float: left; - width: 100%; -} - -div.bodywrapper { - margin: 40px auto 0 auto; - width: 700px; -} - -hr { - border: 1px solid #B1B4B6; -} - -div.body { - background-color: #ffffff; - color: #3E4349; - padding: 0 30px 30px 30px; -} - -img.floatingflask { - padding: 0 0 10px 10px; - float: right; -} - -div.footer { - text-align: right; - color: #888; - padding: 10px; - font-size: 14px; - width: 650px; - margin: 0 auto 40px auto; -} - -div.footer a { - color: #888; - text-decoration: underline; -} - -div.related { - line-height: 32px; - color: #888; -} - -div.related ul { - padding: 0 0 0 10px; -} - -div.related a { - color: #444; -} - -/* -- body styles ----------------------------------------------------------- */ - -a { - color: #004B6B; - text-decoration: underline; -} - -a:hover { - color: #6D4100; - text-decoration: underline; -} - -div.body { - padding-bottom: 40px; /* saved for footer */ -} - -div.body h1, -div.body h2, -div.body h3, -div.body h4, -div.body h5, -div.body h6 { - font-family: 'Garamond', 'Georgia', serif; - font-weight: normal; - margin: 30px 0px 10px 0px; - padding: 0; -} - -{% if theme_index_logo %} -div.indexwrapper h1 { - text-indent: -999999px; - background: url({{ theme_index_logo }}) no-repeat center center; - height: {{ theme_index_logo_height }}; -} -{% endif %} - -div.body h2 { font-size: 180%; } -div.body h3 { font-size: 150%; } -div.body h4 { font-size: 130%; } -div.body h5 { font-size: 100%; } -div.body h6 { font-size: 100%; } - -a.headerlink { - color: white; - padding: 0 4px; - text-decoration: none; -} - -a.headerlink:hover { - color: #444; - background: #eaeaea; -} - -div.body p, div.body dd, div.body li { - line-height: 1.4em; -} - -div.admonition { - background: #fafafa; - margin: 20px -30px; - padding: 10px 30px; - border-top: 1px solid #ccc; - border-bottom: 1px solid #ccc; -} - -div.admonition p.admonition-title { - font-family: 'Garamond', 'Georgia', serif; - font-weight: normal; - font-size: 24px; - margin: 0 0 10px 0; - padding: 0; - line-height: 1; -} - -div.admonition p.last { - margin-bottom: 0; -} - -div.highlight{ - background-color: white; -} - -dt:target, .highlight { - background: #FAF3E8; -} - -div.note { - background-color: #eee; - border: 1px solid #ccc; -} - -div.seealso { - background-color: #ffc; - border: 1px solid #ff6; -} - -div.topic { - background-color: #eee; -} - -div.warning { - background-color: #ffe4e4; - border: 1px solid #f66; -} - -p.admonition-title { - display: inline; -} - -p.admonition-title:after { - content: ":"; -} - -pre, tt { - font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; - font-size: 0.85em; -} - -img.screenshot { -} - -tt.descname, tt.descclassname { - font-size: 0.95em; -} - -tt.descname { - padding-right: 0.08em; -} - -img.screenshot { - -moz-box-shadow: 2px 2px 4px #eee; - -webkit-box-shadow: 2px 2px 4px #eee; - box-shadow: 2px 2px 4px #eee; -} - -table.docutils { - border: 1px solid #888; - -moz-box-shadow: 2px 2px 4px #eee; - -webkit-box-shadow: 2px 2px 4px #eee; - box-shadow: 2px 2px 4px #eee; -} - -table.docutils td, table.docutils th { - border: 1px solid #888; - padding: 0.25em 0.7em; -} - -table.field-list, table.footnote { - border: none; - -moz-box-shadow: none; - -webkit-box-shadow: none; - box-shadow: none; -} - -table.footnote { - margin: 15px 0; - width: 100%; - border: 1px solid #eee; -} - -table.field-list th { - padding: 0 0.8em 0 0; -} - -table.field-list td { - padding: 0; -} - -table.footnote td { - padding: 0.5em; -} - -dl { - margin: 0; - padding: 0; -} - -dl dd { - margin-left: 30px; -} - -pre { - padding: 0; - margin: 15px -30px; - padding: 8px; - line-height: 1.3em; - padding: 7px 30px; - background: #eee; - border-radius: 2px; - -moz-border-radius: 2px; - -webkit-border-radius: 2px; -} - -dl pre { - margin-left: -60px; - padding-left: 60px; -} - -tt { - background-color: #ecf0f3; - color: #222; - /* padding: 1px 2px; */ -} - -tt.xref, a tt { - background-color: #FBFBFB; -} - -a:hover tt { - background: #EEE; -} diff --git a/docs/_themes/kr_small/theme.conf b/docs/_themes/kr_small/theme.conf deleted file mode 100644 index 542b46251..000000000 --- a/docs/_themes/kr_small/theme.conf +++ /dev/null @@ -1,10 +0,0 @@ -[theme] -inherit = basic -stylesheet = flasky.css -nosidebar = true -pygments_style = flask_theme_support.FlaskyStyle - -[options] -index_logo = '' -index_logo_height = 120px -github_fork = '' diff --git a/docs/conf.py b/docs/conf.py index f58519da1..46ee04b2d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -52,6 +52,7 @@ extlinks = { + "this": ("https://github.com/behave/behave/blob/main/%s", "%s"), "behave": ("https://github.com/behave/behave", None), "behave.example": ("https://github.com/behave/behave.example", None), "issue": ("https://github.com/behave/behave/issues/%s", "issue #%s"), @@ -112,7 +113,7 @@ def setup(app): # ----------------------------------------------------------------------------- project = u"behave" authors = u"Jens Engel, Benno Rice and Richard Jones" -copyright = u"2012-2021, %s" % authors +copyright = u"2012-2024, %s" % authors # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -152,9 +153,11 @@ def setup(app): # output. They are ignored by default. #show_authors = False -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = "sphinx" -# MAYBE STYLES: friendly, vs, xcode, vs, tango +# -- PYGMENTS_STYLE: The name of the Pygments (syntax highlighting) style to use. +# LIGHT THEME CANDIDATES: tango, stata-light, default, vs +# DARK THEME CANDIDATES: lightbulb, monokai, stata-dark, zenburn +pygments_style = "tango" +pygments_dark_style = "lightbulb" # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False @@ -166,14 +169,44 @@ def setup(app): # ------------------------------------------------------------------------------ # OPTIONS FOR: HTML OUTPUT # ------------------------------------------------------------------------------ -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -html_theme = "kr" -html_theme = "bootstrap" +# The theme to use for HTML and HTML Help pages. +# SEE: https://www.sphinx-doc.org/en/master/usage/theming.html +# SEE: https://sphinx-themes.org +# DISABLED: html_theme = "bootstrap" +# DISABLED: html_theme = "sphinx_nefertiti" +html_theme = "furo" + if ON_READTHEDOCS: html_theme = "default" -if html_theme == "bootstrap": +if html_theme == "furo": + # -- SEE: https://pradyunsg.me/furo/customisation/ + html_theme_options = { + "navigation_with_keys": True, + # DISABLED: "light_logo": "behave_logo1.png", + # DISABLED: "dark_logo": "behave_logo2.png", + } +elif html_theme == "sphinx_nefertiti": + pygments_style = "vs" + pygments_dark_style = "monokai" + html_theme_options = { + "style": "blue", + "sans_serif_font": "Arial", + "monospace_font": "Ubuntu Mono", + "doc_headers_font": "Arial", + "monospace_font_size": "1.05rem", + "documentation_font_size": "1.05rem", + # -- SHOW: REPO INFO + "repository_url": "https://github.com/behave/behave", + "repository_name": "behave/behave", + "versions": [ + # ("latest", "https://github.com/behave/behave/"), + ("v1.2.7.dev5", "https://github.com/behave/behave/releases/tag/v1.2.7.dev5"), + ("v1.2.7.dev4", "https://github.com/behave/behave/releases/tag/v1.2.7.dev4"), + ("v1.2.6", "https://pypi.org/project/behave/v1.2.6/"), + ] + } +elif html_theme == "bootstrap": # See sphinx-bootstrap-theme for documentation of these options # https://github.com/ryan-roemer/sphinx-bootstrap-theme import sphinx_bootstrap_theme @@ -185,10 +218,6 @@ def setup(app): # Add any paths that contain custom themes here, relative to this directory. html_theme_path = sphinx_bootstrap_theme.get_html_theme_path() -elif html_theme in ("default", "kr"): - html_theme_path = ["_themes"] - html_logo = "_static/behave_logo1.png" - # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the diff --git a/py.requirements/docs.txt b/py.requirements/docs.txt index 6d7e6a7df..7aa178007 100644 --- a/py.requirements/docs.txt +++ b/py.requirements/docs.txt @@ -13,7 +13,13 @@ sphinx >= 7.3.7; python_version >= '3.7' sphinx >=1.6,<4.4; python_version < '3.7' sphinx-autobuild -sphinx_bootstrap_theme >= 0.6.0 + +# -- SPHINX-THEMES: +# SEE: https://www.sphinx-doc.org/en/master/usage/theming.html +# SEE: https://sphinx-themes.org +furo >= 2024.04.27; python_version >= '3.8' +# DISABLED: sphinx-nefertiti >= 0.3.3; python_version >= '3.9' +# DISABLED: sphinx_bootstrap_theme >= 0.6.0 # -- NEEDED FOR: RTD (as temporary fix) urllib3 < 2.0.0; python_version < '3.8' diff --git a/pyproject.toml b/pyproject.toml index d433276a9..1afc13ec2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -141,17 +141,12 @@ develop = [ docs = [ "sphinx >= 7.3.7; python_version >= '3.7'", "sphinx >=1.6,<4.4; python_version < '3.7'", - "sphinx_bootstrap_theme >= 0.6.0", + "furo >= 2024.04.27; python_version >= '3.8'", # -- CONSTRAINTS UNTIL: sphinx > 5.0 is usable -- 2024-01 # PROBLEM: sphinxcontrib-applehelp v1.0.8 requires sphinx > 5.0 # SEE: https://stackoverflow.com/questions/77848565/sphinxcontrib-applehelp-breaking-sphinx-builds-with-sphinx-version-less-than-5-0 "sphinxcontrib-applehelp >= 1.0.8; python_version >= '3.7'", "sphinxcontrib-htmlhelp >= 2.0.5; python_version >= '3.7'", - # DISABLED: "sphinxcontrib-applehelp==1.0.4", - # DISABLED: "sphinxcontrib-devhelp==1.0.2", - # DISABLED: "sphinxcontrib-htmlhelp==2.0.1", - # DISABLED: "sphinxcontrib-qthelp==1.0.3", - # DISABLED: "sphinxcontrib-serializinghtml==1.1.5", ] formatters = [ "behave-html-formatter >= 0.9.10; python_version >= '3.6'", diff --git a/setup.py b/setup.py index 2fd5f2f80..24942a1a0 100644 --- a/setup.py +++ b/setup.py @@ -118,17 +118,12 @@ def find_packages_by_root_package(where): "docs": [ "sphinx >= 7.3.7; python_version >= '3.7'", "sphinx >=1.6,<4.4; python_version < '3.7'", - "sphinx_bootstrap_theme >= 0.6", + "furo >= 2024.04.27; python_version >= '3.8'", # -- CONSTRAINTS UNTIL: sphinx > 5.0 can be used -- 2024-01 # PROBLEM: sphinxcontrib-applehelp v1.0.8 requires sphinx > 5.0 # SEE: https://stackoverflow.com/questions/77848565/sphinxcontrib-applehelp-breaking-sphinx-builds-with-sphinx-version-less-than-5-0 "sphinxcontrib-applehelp >= 1.0.8; python_version >= '3.7'", "sphinxcontrib-htmlhelp >= 2.0.5; python_version >= '3.7'", - # DISABLED: "sphinxcontrib-applehelp==1.0.4", - # DISABLED: "sphinxcontrib-devhelp==1.0.2", - # DISABLED: "sphinxcontrib-htmlhelp==2.0.1", - # DISABLED: "sphinxcontrib-qthelp==1.0.3", - # DISABLED: "sphinxcontrib-serializinghtml==1.1.5", ], "develop": [ "build >= 0.5.1", From 9bb7dd912ba26ee3196c387cec4971a03391d83b Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 2 Jun 2024 00:11:03 +0200 Subject: [PATCH 230/240] docs: Add sphinx-copybutton extensions * Simplifies to copy code/code-block sections by providing an inlined button to copy it. OTHERWISE: * Use ":caption:" attribute in code-blocks to provide titles and improve readability --- docs/_content.tag_expressions_v2.rst | 38 ++++++++++++++-------------- docs/conf.py | 3 ++- py.requirements/docs.txt | 5 ++++ pyproject.toml | 2 ++ setup.py | 2 ++ 5 files changed, 30 insertions(+), 20 deletions(-) diff --git a/docs/_content.tag_expressions_v2.rst b/docs/_content.tag_expressions_v2.rst index ffa5e181d..806b48119 100644 --- a/docs/_content.tag_expressions_v2.rst +++ b/docs/_content.tag_expressions_v2.rst @@ -9,31 +9,30 @@ Tag-Expressions v2 are based on :pypi:`cucumber-tag-expressions` with some exten * Some boolean-logic-expressions where not possible with Tag-Expressions v1 * Therefore, Tag-Expressions v2 supersedes the old-style tag-expressions. -EXAMPLES: -.. code-block:: sh +.. code-block:: gherkin + :caption: TAG-EXPRESSION EXAMPLES - # -- SIMPLE TAG-EXPRESSION EXAMPLES: - # EXAMPLE 1: Select features/scenarios that have the tags: @a and @b + # -- EXAMPLE 1: Select features/scenarios that have the tags: @a and @b @a and @b - # EXAMPLE 2: Select features/scenarios that have the tag: @a or @b + # -- EXAMPLE 2: Select features/scenarios that have the tag: @a or @b @a or @b - # EXAMPLE 3: Select features/scenarios that do not have the tag: @a + # -- EXAMPLE 3: Select features/scenarios that do not have the tag: @a not @a - # -- MORE TAG-EXPRESSION EXAMPLES: - # HINT: Boolean expressions can be grouped with parenthesis. - # EXAMPLE 4: Select features/scenarios that have the tags: @a but not @b + # -- EXAMPLE 4: Select features/scenarios that have the tags: @a but not @b @a and not @b - # EXAMPLE 5: Select features/scenarios that have the tags: (@a or @b) but not @c + # -- EXAMPLE 5: Select features/scenarios that have the tags: (@a or @b) but not @c + # HINT: Boolean expressions can be grouped with parenthesis. (@a or @b) and not @c COMMAND-LINE EXAMPLE: .. code-block:: sh + :caption: USING: Tag-Expressions v2 with ``behave`` # -- SELECT-BY-TAG-EXPRESSION (with tag-expressions v2): # Select all features / scenarios with both "@foo" and "@bar" tags. @@ -62,17 +61,17 @@ Tag Matching with Tag-Expressions Tag-Expressions v2 support **partial string/tag matching** with wildcards. This supports tag-expressions: -=================== ======================== -Tag Matching Idiom Tag-Expression Example -=================== ======================== -``tag-starts-with`` ``@foo.*`` or ``foo.*`` -``tag-ends-with`` ``@*.one`` or ``*.one`` -``tag-contains`` ``@*foo*`` or ``*foo*`` -=================== ======================== +=================== =========== =========== =================================================== +Tag Matching Idiom Example 1 Example 2 Description +=================== =========== =========== =================================================== +``tag.starts_with`` ``@foo.*`` ``foo.*`` Search for tags that start with a ``prefix``. +``tag.ends_with`` ``@*.one`` ``*.one`` Search for tags that end with a ``suffix``. +``tag.contains`` ``@*foo*`` ``*foo*`` Search for tags that contain a ``part``. +=================== =========== =========== =================================================== .. code-block:: gherkin + :caption: FILE: features/one.feature - # -- FILE: features/one.feature Feature: Alice @foo.one @@ -91,6 +90,7 @@ The following command-line will select all features / scenarios with tags that start with "@foo.": .. code-block:: sh + :caption: USAGE EXAMPLE: Run behave with tag-matching expressions $ behave -f plain --tags="@foo.*" features/one.feature Feature: Alice @@ -125,8 +125,8 @@ This allows a user to select: EXAMPLE: .. code-block:: ini + :caption: FILE: behave.ini - # -- FILE: behave.ini # SPECIFY WHICH TAG-EXPRESSION-PROTOCOL SHOULD BE USED: # SUPPORTED VALUES: v1, v2, auto_detect # CURRENT DEFAULT: auto_detect diff --git a/docs/conf.py b/docs/conf.py index 46ee04b2d..8f2cb4277 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,6 +35,7 @@ "sphinx.ext.extlinks", "sphinx.ext.todo", "sphinx.ext.intersphinx", + "sphinx_copybutton", ] optional_extensions = [ # -- DISABLED: "sphinxcontrib.youtube", @@ -52,7 +53,7 @@ extlinks = { - "this": ("https://github.com/behave/behave/blob/main/%s", "%s"), + "this": ("https://github.com/behave/behave/blob/main/%s", "%s"), # AKA: this_repo "behave": ("https://github.com/behave/behave", None), "behave.example": ("https://github.com/behave/behave.example", None), "issue": ("https://github.com/behave/behave/issues/%s", "issue #%s"), diff --git a/py.requirements/docs.txt b/py.requirements/docs.txt index 7aa178007..fbaeb7c8b 100644 --- a/py.requirements/docs.txt +++ b/py.requirements/docs.txt @@ -21,6 +21,11 @@ furo >= 2024.04.27; python_version >= '3.8' # DISABLED: sphinx-nefertiti >= 0.3.3; python_version >= '3.9' # DISABLED: sphinx_bootstrap_theme >= 0.6.0 +# -- SPHINX-EXTENSIONS: +# SPHINX-COPYBUTTON: +# SEE: https://github.com/executablebooks/sphinx-copybutton +sphinx-copybutton >= 0.5.2; python_version >= '3.7' + # -- NEEDED FOR: RTD (as temporary fix) urllib3 < 2.0.0; python_version < '3.8' diff --git a/pyproject.toml b/pyproject.toml index 1afc13ec2..f44648721 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -147,6 +147,8 @@ docs = [ # SEE: https://stackoverflow.com/questions/77848565/sphinxcontrib-applehelp-breaking-sphinx-builds-with-sphinx-version-less-than-5-0 "sphinxcontrib-applehelp >= 1.0.8; python_version >= '3.7'", "sphinxcontrib-htmlhelp >= 2.0.5; python_version >= '3.7'", + # -- SPHINX-EXTENSIONS: + "sphinx-copybutton >= 0.5.2; python_version >= '3.7'", ] formatters = [ "behave-html-formatter >= 0.9.10; python_version >= '3.6'", diff --git a/setup.py b/setup.py index 24942a1a0..4817ad9eb 100644 --- a/setup.py +++ b/setup.py @@ -124,6 +124,8 @@ def find_packages_by_root_package(where): # SEE: https://stackoverflow.com/questions/77848565/sphinxcontrib-applehelp-breaking-sphinx-builds-with-sphinx-version-less-than-5-0 "sphinxcontrib-applehelp >= 1.0.8; python_version >= '3.7'", "sphinxcontrib-htmlhelp >= 2.0.5; python_version >= '3.7'", + # -- SPHINX-EXTENSIONS: + "sphinx-copybutton >= 0.5.2; python_version >= '3.7'", ], "develop": [ "build >= 0.5.1", From 246e5d5221742a370f80807e7a3f4719d216c36e Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 2 Jun 2024 00:19:01 +0200 Subject: [PATCH 231/240] docs: Use html_theme="furo" for readthedocs now --- docs/conf.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 8f2cb4277..44b65ae9e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -177,8 +177,9 @@ def setup(app): # DISABLED: html_theme = "sphinx_nefertiti" html_theme = "furo" -if ON_READTHEDOCS: - html_theme = "default" +# -- DISABLED: Use html_theme = "furo" now. +# if ON_READTHEDOCS: +# html_theme = "default" if html_theme == "furo": # -- SEE: https://pradyunsg.me/furo/customisation/ From 4964a9cf743227ea2874c1e2b222d944de19f156 Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 3 Jun 2024 22:16:56 +0200 Subject: [PATCH 232/240] CLEANUP: Remove support for pychecker (as lint tool). --- .pycheckrc | 242 ------------------------------------------- behave/importer.py | 2 - behave/model_core.py | 1 - 3 files changed, 245 deletions(-) delete mode 100644 .pycheckrc diff --git a/.pycheckrc b/.pycheckrc deleted file mode 100644 index 22219fc2d..000000000 --- a/.pycheckrc +++ /dev/null @@ -1,242 +0,0 @@ -# ======================================================================================== -# PYCHECKER CONFIGURATION -# ======================================================================================== -# .pycheckrc file created by PyChecker v0.8.18 -# .pycheckrc file created by PyChecker v0.8.19 -# -# It should be placed in your home directory (value of $HOME). -# If $HOME is not set, it will look in the current directory. -# -# SEE ALSO: -# * http://pychecker.sourceforge.net/ -# * http://sourceforge.net/projects/pychecker -# ======================================================================================== - -# only warn about files passed on the command line -only = 0 - -# the maximum number of warnings to be displayed -limit = 100 - -# list of evil C extensions that crash the interpreter -evil = [] - -# ignore import errors -ignoreImportErrors = 0 - -# unused imports -importUsed = 1 - -# unused imports from __init__.py -packageImportUsed = 1 - -# module imports itself -reimportSelf = 1 - -# reimporting a module -moduleImportErrors = 1 - -# module does import and from ... import -mixImport = 1 - -# unused local variables, except tuples -localVariablesUsed = 1 - -# all unused local variables, including tuples -unusedLocalTuple = 0 - -# all unused class data members -membersUsed = 0 - -# all unused module variables -allVariablesUsed = 0 - -# unused private module variables -privateVariableUsed = 1 - -# report each occurrence of global warnings -reportAllGlobals = 0 - -# functions called with named arguments (like keywords) -namedArgs = 0 - -# Attributes (members) must be defined in __init__() -onlyCheckInitForMembers = 0 - -# Subclass.__init__() not defined -initDefinedInSubclass = 0 - -# Baseclass.__init__() not called -baseClassInitted = 1 - -# Subclass needs to override methods that only throw exceptions -abstractClasses = 1 - -# Return None from __init__() -returnNoneFromInit = 1 - -# unreachable code -unreachableCode = 0 - -# a constant is used in a conditional statement -constantConditions = 1 - -# 1 is used in a conditional statement (if 1: or while 1:) -constant1 = 0 - -# check if iterating over a string -stringIteration = 1 - -# check improper use of string.find() -stringFind = 1 - -# Calling data members as functions -callingAttribute = 0 - -# class attribute does not exist -classAttrExists = 1 - -# First argument to methods -methodArgName = 'self' - -# First argument to classmethods -classmethodArgNames = ['cls', 'klass'] - -# unused method/function arguments -argumentsUsed = 1 - -# unused method/function variable arguments -varArgumentsUsed = 1 - -# ignore if self is unused in methods -ignoreSelfUnused = 0 - -# check if overridden methods have the same signature -checkOverridenMethods = 1 - -# check if __special__ methods exist and have the correct signature -checkSpecialMethods = 1 - -# check if function/class/method names are reused -redefiningFunction = 1 - -# check if using unary positive (+) which is usually meaningless -unaryPositive = 1 - -# check if modify (call method) on a parameter that has a default value -modifyDefaultValue = 1 - -# check if variables are set to different types -inconsistentTypes = 0 - -# check if unpacking a non-sequence -unpackNonSequence = 1 - -# check if unpacking sequence with the wrong length -unpackLength = 1 - -# check if raising or catching bad exceptions -badExceptions = 1 - -# check if statement appears to have no effect -noEffect = 1 - -# check if using (expr % 1), it has no effect on integers and strings -modulo1 = 1 - -# check if using (expr is const-literal), doesn't always work on integers and strings -isLiteral = 1 - -# check if a constant string is passed to getattr()/setattr() -constAttr = 1 - -# check consistent return values -checkReturnValues = 1 - -# check if using implict and explicit return values -checkImplicitReturns = 1 - -# check that attributes of objects exist -checkObjectAttrs = 1 - -# various warnings about incorrect usage of __slots__ -slots = 1 - -# using properties with classic classes -classicProperties = 1 - -# check if __slots__ is empty -emptySlots = 1 - -# check if using integer division -intDivide = 1 - -# check if local variable shadows a global -shadows = 1 - -# check if a variable shadows a builtin -shadowBuiltins = 1 - -# check if input() is used -usesInput = 1 - -# check if the exec statement is used -usesExec = 0 - -# ignore warnings from files under standard library -ignoreStandardLibrary = 1 - -# ignore warnings from the list of modules -blacklist = ['Tkinter', 'wxPython', 'gtk', 'GTK', 'GDK'] - -# ignore global variables not used if name is one of these values -variablesToIgnore = ['__version__', '__warningregistry__', '__all__', '__credits__', '__test__', '__author__', '__email__', '__revision__', '__id__', '__copyright__', '__license__', '__date__'] - -# ignore unused locals/arguments if name is one of these values -unusedNames = ['_', 'empty', 'unused', 'dummy', 'crap'] - -# ignore missing class attributes if name is one of these values -missingAttrs = [] - -# ignore use of deprecated modules/functions -deprecated = 1 - -# maximum lines in a function -maxLines = 200 - -# maximum branches in a function -maxBranches = 50 - -# maximum returns in a function -maxReturns = 10 - -# maximum # of arguments to a function -maxArgs = 10 - -# maximum # of locals in a function -maxLocals = 40 - -# maximum # of identifier references (Law of Demeter) -maxReferences = 5 - -# no module doc strings -noDocModule = 0 - -# no class doc strings -noDocClass = 0 - -# no function/method doc strings -noDocFunc = 0 - -# print internal checker parse structures -printParse = 0 - -# turn on debugging for checker -debug = 0 - -# print each class object to find one that crashes -findEvil = 0 - -# turn off all output except warnings -quiet = 0 - diff --git a/behave/importer.py b/behave/importer.py index 3779fa3f9..f1d8b2c2d 100644 --- a/behave/importer.py +++ b/behave/importer.py @@ -88,7 +88,6 @@ def __get__(self, obj=None, type=None): # pylint: disable=redefined-builtin :raise ModuleNotFoundError: If module is not found or cannot be imported. :raise ClassNotFoundError: If class/object is not found in module. """ - __pychecker__ = "unusednames=obj,type" resolved_object = None if not self.resolved_object: # -- SETUP-ONCE: Lazy load the real object. @@ -108,7 +107,6 @@ def __get__(self, obj=None, type=None): # pylint: disable=redefined-builtin def __set__(self, obj, value): """Implement descriptor protocol.""" - __pychecker__ = "unusednames=obj" self.resolved_object = value def get(self): diff --git a/behave/model_core.py b/behave/model_core.py index d4a9d4f71..725c3020e 100644 --- a/behave/model_core.py +++ b/behave/model_core.py @@ -136,7 +136,6 @@ class FileLocation(object): * "{filename}:{line}" or * "{filename}" (if line number is not present) """ - __pychecker__ = "missingattrs=line" # -- Ignore warnings for 'line'. def __init__(self, filename, line=None): if PLATFORM_WIN: From 33947418ff3ad79895084501c32f908cf27fffb6 Mon Sep 17 00:00:00 2001 From: jenisys Date: Mon, 3 Jun 2024 22:22:18 +0200 Subject: [PATCH 233/240] CLEANUP: Outdated/attic files and directories. REMOVED: * .attic: Only i18n language config-file and conversion tool * .ci/ -- Support for AppVeyor (NOT USED). * behate.attic.tag_matcher:OnlyWithTagMatcher * tests.attic/ -- USED FOR: OnlyWithTagMatcher --- .attic/convert_i18n_yaml.py | 77 ---- .attic/i18n.yml | 635 --------------------------- .ci/appveyor.yml | 65 --- behave/attic/__init__.py | 0 behave/attic/tag_matcher.py | 181 -------- tests.attic/__init__.py | 0 tests.attic/unit/__init__.py | 0 tests.attic/unit/test_tag_matcher.py | 280 ------------ 8 files changed, 1238 deletions(-) delete mode 100755 .attic/convert_i18n_yaml.py delete mode 100644 .attic/i18n.yml delete mode 100644 .ci/appveyor.yml delete mode 100644 behave/attic/__init__.py delete mode 100644 behave/attic/tag_matcher.py delete mode 100644 tests.attic/__init__.py delete mode 100644 tests.attic/unit/__init__.py delete mode 100644 tests.attic/unit/test_tag_matcher.py diff --git a/.attic/convert_i18n_yaml.py b/.attic/convert_i18n_yaml.py deleted file mode 100755 index f75675dd7..000000000 --- a/.attic/convert_i18n_yaml.py +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env python -# -*- coding: UTF-8 -*- -# USAGE: convert_i18n_yaml.py [--data=i18n.yml] behave/i18n.py -""" -Generates I18N python module based on YAML description (i18n.yml). - -REQUIRES: - * argparse - * six - * PyYAML -""" - -from __future__ import absolute_import, print_function -import argparse -import os.path -import six -import sys -import pprint -import yaml - -HERE = os.path.dirname(__file__) -NAME = os.path.basename(__file__) -__version__ = "1.0" - -def yaml_normalize(data): - for part in data: - keywords = data[part] - for k in keywords: - v = keywords[k] - # bloody YAML parser returns a mixture of unicode and str - if not isinstance(v, six.text_type): - v = v.decode("UTF-8") - keywords[k] = v.split("|") - return data - -def main(args=None): - if args is None: - args = sys.argv[1:] - parser = argparse.ArgumentParser(prog=NAME, - description="Generate python module i18n from YAML based data") - parser.add_argument("-d", "--data", dest="yaml_file", - default=os.path.join(HERE, "i18n.yml"), - help="Path to i18n.yml file (YAML file).") - parser.add_argument("output_file", default="stdout", - help="Filename of Python I18N module (as output).") - parser.add_argument("--version", action="version", version=__version__) - - options = parser.parse_args(args) - if not os.path.isfile(options.yaml_file): - parser.error("YAML file not found: %s" % options.yaml_file) - - # -- STEP 1: Load YAML data. - languages = yaml.safe_load(open(options.yaml_file)) - languages = yaml_normalize(languages) - - # -- STEP 2: Generate python module with i18n data. - contents = u"""# -*- coding: UTF-8 -*- -# -- FILE GENERATED BY: convert_i18n_yaml.py with i18n.yml -# pylint: disable=line-too-long - -languages = \\ -""" - if options.output_file in ("-", "stdout"): - i18n_py = sys.stdout - should_close = False - else: - i18n_py = open(options.output_file, "w") - should_close = True - i18n_py.write(contents.encode("UTF-8")) - i18n_py.write(pprint.pformat(languages).encode("UTF-8")) - i18n_py.write(u"\n") - if should_close: - i18n_py.close() - return 0 - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.attic/i18n.yml b/.attic/i18n.yml deleted file mode 100644 index 82345a406..000000000 --- a/.attic/i18n.yml +++ /dev/null @@ -1,635 +0,0 @@ -# encoding: UTF-8 -# -# We use ISO 639-1 (language) and ISO 3166 alpha-2 (region - if applicable): -# http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes -# http://en.wikipedia.org/wiki/ISO_3166-1 -# -# If you want several aliases for a keyword, just separate them -# with a | character. The * is a step keyword alias for all translations. -# -# If you do *not* want a trailing space after a keyword, end it with a < character. -# (See Chinese for examples). -# -# This file copyright (c) 2009-2011 Mike Sassak, Gregory Hnatiuk, Aslak Hellesøy -# -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -"en": - name: English - native: English - feature: Feature - background: Background - scenario: Scenario - scenario_outline: Scenario Outline|Scenario Template - examples: Examples|Scenarios - given: "*|Given" - when: "*|When" - then: "*|Then" - and: "*|And" - but: "*|But" - -# Please keep the grammars in alphabetical order by name from here and down. - -"ar": - name: Arabic - native: العربية - feature: خاصية - background: الخلفية - scenario: سيناريو - scenario_outline: سيناريو مخطط - examples: امثلة - given: "*|بفرض" - when: "*|متى|عندما" - then: "*|اذاً|ثم" - and: "*|و" - but: "*|لكن" -"bg": - name: Bulgarian - native: български - feature: Функционалност - background: Предистория - scenario: Сценарий - scenario_outline: Рамка на сценарий - examples: Примери - given: "*|Дадено" - when: "*|Когато" - then: "*|То" - and: "*|И" - but: "*|Но" -"ca": - name: Catalan - native: català - background: Rerefons|Antecedents - feature: Característica|Funcionalitat - scenario: Escenari - scenario_outline: Esquema de l'escenari - examples: Exemples - given: "*|Donat|Donada|Atès|Atesa" - when: "*|Quan" - then: "*|Aleshores|Cal" - and: "*|I" - but: "*|Però" -"cy-GB": - name: Welsh - native: Cymraeg - background: Cefndir - feature: Arwedd - scenario: Scenario - scenario_outline: Scenario Amlinellol - examples: Enghreifftiau - given: "*|Anrhegedig a" - when: "*|Pryd" - then: "*|Yna" - and: "*|A" - but: "*|Ond" -"cs": - name: Czech - native: Česky - feature: Požadavek - background: Pozadí|Kontext - scenario: Scénář - scenario_outline: Náčrt Scénáře|Osnova scénáře - examples: Příklady - given: "*|Pokud|Za předpokladu" - when: "*|Když" - then: "*|Pak" - and: "*|A|A také" - but: "*|Ale" -"da": - name: Danish - native: dansk - feature: Egenskab - background: Baggrund - scenario: Scenarie - scenario_outline: Abstrakt Scenario - examples: Eksempler - given: "*|Givet" - when: "*|Når" - then: "*|Så" - and: "*|Og" - but: "*|Men" -"de": - name: German - native: Deutsch - feature: Funktionalität - background: Grundlage - scenario: Szenario - scenario_outline: Szenariogrundriss - examples: Beispiele - given: "*|Angenommen|Gegeben sei" - when: "*|Wenn" - then: "*|Dann" - and: "*|Und" - but: "*|Aber" -"en-au": - name: Australian - native: Australian - feature: Crikey - background: Background - scenario: Mate - scenario_outline: Blokes - examples: Cobber - given: "*|Ya know how" - when: "*|When" - then: "*|Ya gotta" - and: "*|N" - but: "*|Cept" -"en-lol": - name: LOLCAT - native: LOLCAT - feature: OH HAI - background: B4 - scenario: MISHUN - scenario_outline: MISHUN SRSLY - examples: EXAMPLZ - given: "*|I CAN HAZ" - when: "*|WEN" - then: "*|DEN" - and: "*|AN" - but: "*|BUT" -"en-pirate": - name: Pirate - native: Pirate - feature: Ahoy matey! - background: Yo-ho-ho - scenario: Heave to - scenario_outline: Shiver me timbers - examples: Dead men tell no tales - given: "*|Gangway!" - when: "*|Blimey!" - then: "*|Let go and haul" - and: "*|Aye" - but: "*|Avast!" -"en-Scouse": - name: Scouse - native: Scouse - feature: Feature - background: "Dis is what went down" - scenario: "The thing of it is" - scenario_outline: "Wharrimean is" - examples: Examples - given: "*|Givun|Youse know when youse got" - when: "*|Wun|Youse know like when" - then: "*|Dun|Den youse gotta" - and: "*|An" - but: "*|Buh" -"en-tx": - name: Texan - native: Texan - feature: Feature - background: Background - scenario: Scenario - scenario_outline: All y'all - examples: Examples - given: "*|Given y'all" - when: "*|When y'all" - then: "*|Then y'all" - and: "*|And y'all" - but: "*|But y'all" -"eo": - name: Esperanto - native: Esperanto - feature: Trajto - background: Fono - scenario: Scenaro - scenario_outline: Konturo de la scenaro - examples: Ekzemploj - given: "*|Donitaĵo" - when: "*|Se" - then: "*|Do" - and: "*|Kaj" - but: "*|Sed" -"es": - name: Spanish - native: español - background: Antecedentes - feature: Característica - scenario: Escenario - scenario_outline: Esquema del escenario - examples: Ejemplos - given: "*|Dado|Dada|Dados|Dadas" - when: "*|Cuando" - then: "*|Entonces" - and: "*|Y" - but: "*|Pero" -"et": - name: Estonian - native: eesti keel - feature: Omadus - background: Taust - scenario: Stsenaarium - scenario_outline: Raamstsenaarium - examples: Juhtumid - given: "*|Eeldades" - when: "*|Kui" - then: "*|Siis" - and: "*|Ja" - but: "*|Kuid" -"fi": - name: Finnish - native: suomi - feature: Ominaisuus - background: Tausta - scenario: Tapaus - scenario_outline: Tapausaihio - examples: Tapaukset - given: "*|Oletetaan" - when: "*|Kun" - then: "*|Niin" - and: "*|Ja" - but: "*|Mutta" -"fr": - name: French - native: français - feature: Fonctionnalité - background: Contexte - scenario: Scénario - scenario_outline: Plan du scénario|Plan du Scénario - examples: Exemples - given: "*|Soit|Etant donné|Etant donnée|Etant donnés|Etant données|Étant donné|Étant donnée|Étant donnés|Étant données" - when: "*|Quand|Lorsque|Lorsqu'<" - then: "*|Alors" - and: "*|Et" - but: "*|Mais" -"gl": - name: Galician - native: galego - feature: Característica - background: Contexto - scenario: Escenario - scenario_outline: "Esbozo do escenario" - examples: Exemplos - given: "*|Dado|Dada|Dados|Dadas" - when: "*|Cando" - then: "*|Entón|Logo" - and: "*|E" - but: "*|Mais|Pero" - -"he": - name: Hebrew - native: עברית - feature: תכונה - background: רקע - scenario: תרחיש - scenario_outline: תבנית תרחיש - examples: דוגמאות - given: "*|בהינתן" - when: "*|כאשר" - then: "*|אז|אזי" - and: "*|וגם" - but: "*|אבל" -"hr": - name: Croatian - native: hrvatski - feature: Osobina|Mogućnost|Mogucnost - background: Pozadina - scenario: Scenarij - scenario_outline: Skica|Koncept - examples: Primjeri|Scenariji - given: "*|Zadan|Zadani|Zadano" - when: "*|Kada|Kad" - then: "*|Onda" - and: "*|I" - but: "*|Ali" -"hu": - name: Hungarian - native: magyar - feature: Jellemző - background: Háttér - scenario: Forgatókönyv - scenario_outline: Forgatókönyv vázlat - examples: Példák - given: "*|Amennyiben|Adott" - when: "*|Majd|Ha|Amikor" - then: "*|Akkor" - and: "*|És" - but: "*|De" -"id": - name: Indonesian - native: Bahasa Indonesia - feature: Fitur - background: Dasar - scenario: Skenario - scenario_outline: Skenario konsep - examples: Contoh - given: "*|Dengan" - when: "*|Ketika" - then: "*|Maka" - and: "*|Dan" - but: "*|Tapi" -"is": - name: Icelandic - native: Íslenska - feature: Eiginleiki - background: Bakgrunnur - scenario: Atburðarás - scenario_outline: Lýsing Atburðarásar|Lýsing Dæma - examples: Dæmi|Atburðarásir - given: "*|Ef" - when: "*|Þegar" - then: "*|Þá" - and: "*|Og" - but: "*|En" -"it": - name: Italian - native: italiano - feature: Funzionalità - background: Contesto - scenario: Scenario - scenario_outline: Schema dello scenario - examples: Esempi - given: "*|Dato|Data|Dati|Date" - when: "*|Quando" - then: "*|Allora" - and: "*|E" - but: "*|Ma" -"ja": - name: Japanese - native: 日本語 - feature: フィーチャ|機能 - background: 背景 - scenario: シナリオ - scenario_outline: シナリオアウトライン|シナリオテンプレート|テンプレ|シナリオテンプレ - examples: 例|サンプル - given: "*|前提<" - when: "*|もし<" - then: "*|ならば<" - and: "*|かつ<" - but: "*|しかし<|但し<|ただし<" -"ko": - name: Korean - native: 한국어 - background: 배경 - feature: 기능 - scenario: 시나리오 - scenario_outline: 시나리오 개요 - examples: 예 - given: "*|조건<|먼저<" - when: "*|만일<|만약<" - then: "*|그러면<" - and: "*|그리고<" - but: "*|하지만<|단<" -"lt": - name: Lithuanian - native: lietuvių kalba - feature: Savybė - background: Kontekstas - scenario: Scenarijus - scenario_outline: Scenarijaus šablonas - examples: Pavyzdžiai|Scenarijai|Variantai - given: "*|Duota" - when: "*|Kai" - then: "*|Tada" - and: "*|Ir" - but: "*|Bet" -"lu": - name: Luxemburgish - native: Lëtzebuergesch - feature: Funktionalitéit - background: Hannergrond - scenario: Szenario - scenario_outline: Plang vum Szenario - examples: Beispiller - given: "*|ugeholl" - when: "*|wann" - then: "*|dann" - and: "*|an|a" - but: "*|awer|mä" -"lv": - name: Latvian - native: latviešu - feature: Funkcionalitāte|Fīča - background: Konteksts|Situācija - scenario: Scenārijs - scenario_outline: Scenārijs pēc parauga - examples: Piemēri|Paraugs - given: "*|Kad" - when: "*|Ja" - then: "*|Tad" - and: "*|Un" - but: "*|Bet" -"nl": - name: Dutch - native: Nederlands - feature: Functionaliteit - background: Achtergrond - scenario: Scenario - scenario_outline: Abstract Scenario - examples: Voorbeelden - given: "*|Gegeven|Stel" - when: "*|Als" - then: "*|Dan" - and: "*|En" - but: "*|Maar" -"no": - name: Norwegian - native: norsk - feature: Egenskap - background: Bakgrunn - scenario: Scenario - scenario_outline: Scenariomal|Abstrakt Scenario - examples: Eksempler - given: "*|Gitt" - when: "*|Når" - then: "*|Så" - and: "*|Og" - but: "*|Men" -"pl": - name: Polish - native: polski - feature: Właściwość - background: Założenia - scenario: Scenariusz - scenario_outline: Szablon scenariusza - examples: Przykłady - given: "*|Zakładając|Mając" - when: "*|Jeżeli|Jeśli" - then: "*|Wtedy" - and: "*|Oraz|I" - but: "*|Ale" -"pt": - name: Portuguese - native: português - background: Contexto - feature: Funcionalidade - scenario: Cenário|Cenario - scenario_outline: Esquema do Cenário|Esquema do Cenario - examples: Exemplos - given: "*|Dado|Dada|Dados|Dadas" - when: "*|Quando" - then: "*|Então|Entao" - and: "*|E" - but: "*|Mas" -"ro": - name: Romanian - native: română - background: Context - feature: Functionalitate|Funcționalitate|Funcţionalitate - scenario: Scenariu - scenario_outline: Structura scenariu|Structură scenariu - examples: Exemple - given: "*|Date fiind|Dat fiind|Dati fiind|Dați fiind|Daţi fiind" - when: "*|Cand|Când" - then: "*|Atunci" - and: "*|Si|Și|Şi" - but: "*|Dar" -"ru": - name: Russian - native: русский - feature: Функция|Функционал|Свойство - background: Предыстория|Контекст - scenario: Сценарий - scenario_outline: Структура сценария - examples: Примеры - given: "*|Допустим|Дано|Пусть" - when: "*|Если|Когда" - then: "*|То|Тогда" - and: "*|И|К тому же" - but: "*|Но|А" -"sv": - name: Swedish - native: Svenska - feature: Egenskap - background: Bakgrund - scenario: Scenario - scenario_outline: Abstrakt Scenario|Scenariomall - examples: Exempel - given: "*|Givet" - when: "*|När" - then: "*|Så" - and: "*|Och" - but: "*|Men" -"sk": - name: Slovak - native: Slovensky - feature: Požiadavka - background: Pozadie - scenario: Scenár - scenario_outline: Náčrt Scenáru - examples: Príklady - given: "*|Pokiaľ" - when: "*|Keď" - then: "*|Tak" - and: "*|A" - but: "*|Ale" -"sr-Latn": - name: Serbian (Latin) - native: Srpski (Latinica) - feature: Funkcionalnost|Mogućnost|Mogucnost|Osobina - background: Kontekst|Osnova|Pozadina - scenario: Scenario|Primer - scenario_outline: Struktura scenarija|Skica|Koncept - examples: Primeri|Scenariji - given: "*|Zadato|Zadate|Zatati" - when: "*|Kada|Kad" - then: "*|Onda" - and: "*|I" - but: "*|Ali" -"sr-Cyrl": - name: Serbian - native: Српски - feature: Функционалност|Могућност|Особина - background: Контекст|Основа|Позадина - scenario: Сценарио|Пример - scenario_outline: Структура сценарија|Скица|Концепт - examples: Примери|Сценарији - given: "*|Задато|Задате|Задати" - when: "*|Када|Кад" - then: "*|Онда" - and: "*|И" - but: "*|Али" -"tr": - name: Turkish - native: Türkçe - feature: Özellik - background: Geçmiş - scenario: Senaryo - scenario_outline: Senaryo taslağı - examples: Örnekler - given: "*|Diyelim ki" - when: "*|Eğer ki" - then: "*|O zaman" - and: "*|Ve" - but: "*|Fakat|Ama" -"uk": - name: Ukrainian - native: Українська - feature: Функціонал - background: Передумова - scenario: Сценарій - scenario_outline: Структура сценарію - examples: Приклади - given: "*|Припустимо|Припустимо, що|Нехай|Дано" - when: "*|Якщо|Коли" - then: "*|То|Тоді" - and: "*|І|А також|Та" - but: "*|Але" -"uz": - name: Uzbek - native: Узбекча - feature: Функционал - background: Тарих - scenario: Сценарий - scenario_outline: Сценарий структураси - examples: Мисоллар - given: "*|Агар" - when: "*|Агар" - then: "*|Унда" - and: "*|Ва" - but: "*|Лекин|Бирок|Аммо" -"vi": - name: Vietnamese - native: Tiếng Việt - feature: Tính năng - background: Bối cảnh - scenario: Tình huống|Kịch bản - scenario_outline: Khung tình huống|Khung kịch bản - examples: Dữ liệu - given: "*|Biết|Cho" - when: "*|Khi" - then: "*|Thì" - and: "*|Và" - but: "*|Nhưng" -"zh-CN": - name: Chinese simplified - native: 简体中文 - feature: 功能 - background: 背景 - scenario: 场景 - scenario_outline: 场景大纲 - examples: 例子 - given: "*|假如<" - when: "*|当<" - then: "*|那么<" - and: "*|而且<" - but: "*|但是<" -"zh-TW": - name: Chinese traditional - native: 繁體中文 - feature: 功能 - background: 背景 - scenario: 場景|劇本 - scenario_outline: 場景大綱|劇本大綱 - examples: 例子 - given: "*|假設<" - when: "*|當<" - then: "*|那麼<" - and: "*|而且<|並且<" - but: "*|但是<" diff --git a/.ci/appveyor.yml b/.ci/appveyor.yml deleted file mode 100644 index 26b385e32..000000000 --- a/.ci/appveyor.yml +++ /dev/null @@ -1,65 +0,0 @@ -# ============================================================================= -# CI-SERVER CONFIGURATION: behave -# ============================================================================= -# OS: Windows -# SEE ALSO: -# * http://www.appveyor.com/docs/build-configuration -# * http://www.appveyor.com/docs/installed-software#python -# * http://www.appveyor.com/docs/appveyor-yml -# -# VALIDATE: appveyor.yml -# * https://ci.appveyor.com/tools/validate-yaml -# ============================================================================= -# https://bootstrap.pypa.io/get-pip.py - -version: 1.2.6.dev0.{build}-{branch} -clone_folder: C:\projects\behave.ci -# clone_depth: 2 -# shallow_clone: true - -environment: - PYTHONPATH: ".;%CD%" - BEHAVE_ROOTDIR_PREFIX: "C:" - matrix: - - PYTHON_DIR: C:\Python36-x64 - PYTHON: C:\Python36-x64\python.exe - - PYTHON_DIR: C:\Python27-x64 - PYTHON: C:\Python27-x64\python.exe - BEHAVE_ROOTDIR_PREFIX: "c:" - -# -- TEMPORARILY DISABLED: environment matrix: -# - PYTHON_DIR: C:\Python35-x64 -# PYTHON: C:\Python35-x64\python.exe -# - PYTHON_DIR: C:\Python35 -# PYTHON: C:\Python35\python.exe - -# -- TEMPORARILY DISABLED: environment matrix: -# - PYTHON_DIR: C:\Python36 -# - PYTHON_DIR: C:\Python27-x64 -# - PYTHON_DIR: C:\Python36-x64 - -init: - - cmd: "echo TESTING-WITH %PYTHON_DIR%, %PYTHON%" - - cmd: "%PYTHON_DIR%\\python.exe --version" - - cmd: "%PYTHON% --version" - - cmd: "echo CD=%CD%" - - cmd: "echo PYTHONPATH=%PYTHONPATH%" - - cmd: set - -# -- TEMPORARILY DISABLED: Python variants discovery -# - cmd: "@echo AVAILABLE PYTHON VERSIONS" -# - cmd: "@dir C:\Python*" -# - path - -install: - - cmd: "%PYTHON_DIR%\\python.exe -m pip install pytest mock PyHamcrest nose" - - cmd: "%PYTHON_DIR%\\python.exe -m pip install ." - - cmd: "%PYTHON_DIR%\\python.exe bin/explore_platform_encoding.py" - -# NOT-NEEDED: - cmd: "%PYTHON_DIR%\\python.exe -m pip install parse" - -build: off -test_script: - - cmd: "%PYTHON_DIR%\\Scripts\\pytest.exe test tests" - - cmd: "%PYTHON_DIR%\\Scripts\\behave.exe -f progress3 --junit features" - - cmd: "%PYTHON_DIR%\\Scripts\\behave.exe -f progress3 --junit issue.features" diff --git a/behave/attic/__init__.py b/behave/attic/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/behave/attic/tag_matcher.py b/behave/attic/tag_matcher.py deleted file mode 100644 index f07dcbfb8..000000000 --- a/behave/attic/tag_matcher.py +++ /dev/null @@ -1,181 +0,0 @@ -# ----------------------------------------------------------------------------- -# PROTOTYPING CLASSES: Should no longer be used -# ----------------------------------------------------------------------------- - -import warnings -from behave.tag_matcher import TagMatcher - - -class OnlyWithCategoryTagMatcher(TagMatcher): - """ - Provides a tag matcher that allows to determine if feature/scenario - should run or should be excluded from the run-set (at runtime). - - .. deprecated:: Use :class:`ActiveTagMatcher` instead. - - EXAMPLE: - -------- - - Run some scenarios only when runtime conditions are met: - - * Run scenario Alice only on Windows OS - * Run scenario Bob only on MACOSX - - .. code-block:: gherkin - - # -- FILE: features/alice.feature - # TAG SCHEMA: @only.with_{category}={current_value} - Feature: - - @only.with_os=win32 - Scenario: Alice (Run only on Windows) - Given I do something - ... - - @only.with_os=darwin - Scenario: Bob (Run only on MACOSX) - Given I do something else - ... - - - .. code-block:: python - - # -- FILE: features/environment.py - from behave.tag_matcher import OnlyWithCategoryTagMatcher - import sys - - # -- MATCHES TAGS: @only.with_{category}=* = @only.with_os=* - active_tag_matcher = OnlyWithCategoryTagMatcher("os", sys.platform) - - def before_scenario(context, scenario): - if active_tag_matcher.should_exclude_with(scenario.effective_tags): - scenario.skip() #< LATE-EXCLUDE from run-set. - """ - tag_prefix = "only.with_" - value_separator = "=" - - def __init__(self, category, value, tag_prefix=None, value_sep=None): - warnings.warn("Use ActiveTagMatcher instead.", DeprecationWarning) - super(OnlyWithCategoryTagMatcher, self).__init__() - self.active_tag = self.make_category_tag(category, value, - tag_prefix, value_sep) - self.category_tag_prefix = self.make_category_tag(category, None, - tag_prefix, value_sep) - - def should_exclude_with(self, tags): - category_tags = self.select_category_tags(tags) - if category_tags and self.active_tag not in category_tags: - return True - # -- OTHERWISE: feature/scenario with theses tags should run. - return False - - def select_category_tags(self, tags): - return [tag for tag in tags - if tag.startswith(self.category_tag_prefix)] - - @classmethod - def make_category_tag(cls, category, value=None, tag_prefix=None, - value_sep=None): - if tag_prefix is None: - tag_prefix = cls.tag_prefix - if value_sep is None: - value_sep = cls.value_separator - value = value or "" - return "%s%s%s%s" % (tag_prefix, category, value_sep, value) - - -class OnlyWithAnyCategoryTagMatcher(TagMatcher): - """ - Provides a tag matcher that matches any category that follows the - "@only.with_" tag schema and determines if it should run or - should be excluded from the run-set (at runtime). - - TAG SCHEMA: @only.with_{category}={value} - - .. seealso:: OnlyWithCategoryTagMatcher - .. deprecated:: Use :class:`ActiveTagMatcher` instead. - - EXAMPLE: - -------- - - Run some scenarios only when runtime conditions are met: - - * Run scenario Alice only on Windows OS - * Run scenario Bob only with browser Chrome - - .. code-block:: gherkin - - # -- FILE: features/alice.feature - # TAG SCHEMA: @only.with_{category}={current_value} - Feature: - - @only.with_os=win32 - Scenario: Alice (Run only on Windows) - Given I do something - ... - - @only.with_browser=chrome - Scenario: Bob (Run only with Web-Browser Chrome) - Given I do something else - ... - - - .. code-block:: python - - # -- FILE: features/environment.py - from behave.tag_matcher import OnlyWithAnyCategoryTagMatcher - import sys - - # -- MATCHES ANY TAGS: @only.with_{category}={value} - # NOTE: active_tag_value_provider provides current category values. - active_tag_value_provider = { - "browser": os.environ.get("BEHAVE_BROWSER", "chrome"), - "os": sys.platform, - } - active_tag_matcher = OnlyWithAnyCategoryTagMatcher(active_tag_value_provider) - - def before_scenario(context, scenario): - if active_tag_matcher.should_exclude_with(scenario.effective_tags): - scenario.skip() #< LATE-EXCLUDE from run-set. - """ - - def __init__(self, value_provider, tag_prefix=None, value_sep=None): - warnings.warn("Use ActiveTagMatcher instead.", DeprecationWarning) - super(OnlyWithAnyCategoryTagMatcher, self).__init__() - if value_sep is None: - value_sep = OnlyWithCategoryTagMatcher.value_separator - self.value_provider = value_provider - self.tag_prefix = tag_prefix or OnlyWithCategoryTagMatcher.tag_prefix - self.value_separator = value_sep - - def should_exclude_with(self, tags): - exclude_decision_map = {} - for category_tag in self.select_category_tags(tags): - category, value = self.parse_category_tag(category_tag) - active_value = self.value_provider.get(category, None) - if active_value is None: - # -- CASE: Unknown category, ignore it. - continue - elif active_value == value: - # -- CASE: Active category value selected, decision should run. - exclude_decision_map[category] = False - else: - # -- CASE: Inactive category value selected, may exclude it. - if category not in exclude_decision_map: - exclude_decision_map[category] = True - return any(exclude_decision_map.values()) - - def select_category_tags(self, tags): - return [tag for tag in tags - if tag.startswith(self.tag_prefix)] - - def parse_category_tag(self, tag): - assert tag and tag.startswith(self.tag_prefix) - category_value = tag[len(self.tag_prefix):] - if self.value_separator in category_value: - category, value = category_value.split(self.value_separator, 1) - else: - # -- OOPS: TAG SCHEMA FORMAT MISMATCH - category = category_value - value = None - return category, value diff --git a/tests.attic/__init__.py b/tests.attic/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests.attic/unit/__init__.py b/tests.attic/unit/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests.attic/unit/test_tag_matcher.py b/tests.attic/unit/test_tag_matcher.py deleted file mode 100644 index d767fa741..000000000 --- a/tests.attic/unit/test_tag_matcher.py +++ /dev/null @@ -1,280 +0,0 @@ -# ----------------------------------------------------------------------------- -# PROTOTYPING CLASSES (deprecating) -- Should no longer be used. -# ----------------------------------------------------------------------------- - -import warnings -from unittest import TestCase -from behave.attic.tag_matcher import \ - OnlyWithCategoryTagMatcher, OnlyWithAnyCategoryTagMatcher - - -class TestOnlyWithCategoryTagMatcher(TestCase): - TagMatcher = OnlyWithCategoryTagMatcher - - def setUp(self): - category = "xxx" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - self.tag_matcher = OnlyWithCategoryTagMatcher(category, "alice") - self.enabled_tag = self.TagMatcher.make_category_tag(category, "alice") - self.similar_tag = self.TagMatcher.make_category_tag(category, "alice2") - self.other_tag = self.TagMatcher.make_category_tag(category, "other") - self.category = category - - def test_should_exclude_with__returns_false_with_enabled_tag(self): - tags = [ self.enabled_tag ] - self.assertEqual(False, self.tag_matcher.should_exclude_with(tags)) - - def test_should_exclude_with__returns_false_with_enabled_tag_and_more(self): - test_patterns = [ - ([ self.enabled_tag, self.other_tag ], "case: first"), - ([ self.other_tag, self.enabled_tag ], "case: last"), - ([ "foo", self.enabled_tag, self.other_tag, "bar" ], "case: middle"), - ] - for tags, case in test_patterns: - self.assertEqual(False, self.tag_matcher.should_exclude_with(tags), - "%s: tags=%s" % (case, tags)) - - def test_should_exclude_with__returns_true_with_other_tag(self): - tags = [ self.other_tag ] - self.assertEqual(True, self.tag_matcher.should_exclude_with(tags)) - - def test_should_exclude_with__returns_true_with_other_tag_and_more(self): - test_patterns = [ - ([ self.other_tag, "foo" ], "case: first"), - ([ "foo", self.other_tag ], "case: last"), - ([ "foo", self.other_tag, "bar" ], "case: middle"), - ] - for tags, case in test_patterns: - self.assertEqual(True, self.tag_matcher.should_exclude_with(tags), - "%s: tags=%s" % (case, tags)) - - def test_should_exclude_with__returns_true_with_similar_tag(self): - tags = [ self.similar_tag ] - self.assertEqual(True, self.tag_matcher.should_exclude_with(tags)) - - def test_should_exclude_with__returns_true_with_similar_and_more(self): - test_patterns = [ - ([ self.similar_tag, "foo" ], "case: first"), - ([ "foo", self.similar_tag ], "case: last"), - ([ "foo", self.similar_tag, "bar" ], "case: middle"), - ] - for tags, case in test_patterns: - self.assertEqual(True, self.tag_matcher.should_exclude_with(tags), - "%s: tags=%s" % (case, tags)) - - def test_should_exclude_with__returns_false_without_category_tag(self): - test_patterns = [ - ([ ], "case: No tags"), - ([ "foo" ], "case: One tag"), - ([ "foo", "bar" ], "case: Two tags"), - ] - for tags, case in test_patterns: - self.assertEqual(False, self.tag_matcher.should_exclude_with(tags), - "%s: tags=%s" % (case, tags)) - - def test_should_run_with__negates_result_of_should_exclude_with(self): - test_patterns = [ - ([ ], "case: No tags"), - ([ "foo" ], "case: One non-category tag"), - ([ "foo", "bar" ], "case: Two non-category tags"), - ([ self.enabled_tag ], "case: enabled tag"), - ([ self.enabled_tag, self.other_tag ], "case: enabled and other tag"), - ([ self.enabled_tag, "foo" ], "case: enabled and foo tag"), - ([ self.other_tag ], "case: other tag"), - ([ self.other_tag, "foo" ], "case: other and foo tag"), - ([ self.similar_tag ], "case: similar tag"), - ([ "foo", self.similar_tag ], "case: foo and similar tag"), - ] - for tags, case in test_patterns: - result1 = self.tag_matcher.should_run_with(tags) - result2 = self.tag_matcher.should_exclude_with(tags) - self.assertEqual(result1, not result2, "%s: tags=%s" % (case, tags)) - self.assertEqual(not result1, result2, "%s: tags=%s" % (case, tags)) - - def test_make_category_tag__returns_category_tag_prefix_without_value(self): - category = "xxx" - tag1 = OnlyWithCategoryTagMatcher.make_category_tag(category) - tag2 = OnlyWithCategoryTagMatcher.make_category_tag(category, None) - tag3 = OnlyWithCategoryTagMatcher.make_category_tag(category, value=None) - self.assertEqual("only.with_xxx=", tag1) - self.assertEqual("only.with_xxx=", tag2) - self.assertEqual("only.with_xxx=", tag3) - self.assertTrue(tag1.startswith(OnlyWithCategoryTagMatcher.tag_prefix)) - - def test_make_category_tag__returns_category_tag_with_value(self): - category = "xxx" - tag1 = OnlyWithCategoryTagMatcher.make_category_tag(category, "alice") - tag2 = OnlyWithCategoryTagMatcher.make_category_tag(category, "bob") - self.assertEqual("only.with_xxx=alice", tag1) - self.assertEqual("only.with_xxx=bob", tag2) - - def test_make_category_tag__returns_category_tag_with_tag_prefix(self): - my_tag_prefix = "ONLY_WITH." - category = "xxx" - TagMatcher = OnlyWithCategoryTagMatcher - tag0 = TagMatcher.make_category_tag(category, tag_prefix=my_tag_prefix) - tag1 = TagMatcher.make_category_tag(category, "alice", my_tag_prefix) - tag2 = TagMatcher.make_category_tag(category, "bob", tag_prefix=my_tag_prefix) - self.assertEqual("ONLY_WITH.xxx=", tag0) - self.assertEqual("ONLY_WITH.xxx=alice", tag1) - self.assertEqual("ONLY_WITH.xxx=bob", tag2) - self.assertTrue(tag1.startswith(my_tag_prefix)) - - def test_ctor__with_tag_prefix(self): - tag_prefix = "ONLY_WITH." - tag_matcher = OnlyWithCategoryTagMatcher("xxx", "alice", tag_prefix) - - tags = ["foo", "ONLY_WITH.xxx=foo", "only.with_xxx=bar", "bar"] - actual_tags = tag_matcher.select_category_tags(tags) - self.assertEqual(["ONLY_WITH.xxx=foo"], actual_tags) - - -class Traits4OnlyWithAnyCategoryTagMatcher(object): - """Test data for OnlyWithAnyCategoryTagMatcher.""" - - TagMatcher0 = OnlyWithCategoryTagMatcher - TagMatcher = OnlyWithAnyCategoryTagMatcher - category1_enabled_tag = TagMatcher0.make_category_tag("foo", "alice") - category1_similar_tag = TagMatcher0.make_category_tag("foo", "alice2") - category1_disabled_tag = TagMatcher0.make_category_tag("foo", "bob") - category2_enabled_tag = TagMatcher0.make_category_tag("bar", "BOB") - category2_similar_tag = TagMatcher0.make_category_tag("bar", "BOB2") - category2_disabled_tag = TagMatcher0.make_category_tag("bar", "CHARLY") - unknown_category_tag = TagMatcher0.make_category_tag("UNKNOWN", "one") - - -class TestOnlyWithAnyCategoryTagMatcher(TestCase): - TagMatcher = OnlyWithAnyCategoryTagMatcher - traits = Traits4OnlyWithAnyCategoryTagMatcher - - def setUp(self): - value_provider = { - "foo": "alice", - "bar": "BOB", - } - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - self.tag_matcher = self.TagMatcher(value_provider) - - # def test_deprecating_warning_is_issued(self): - # value_provider = {"foo": "alice"} - # with warnings.catch_warnings(record=True) as recorder: - # warnings.simplefilter("always", DeprecationWarning) - # tag_matcher = OnlyWithAnyCategoryTagMatcher(value_provider) - # self.assertEqual(len(recorder), 1) - # last_warning = recorder[-1] - # assert issubclass(last_warning.category, DeprecationWarning) - # assert "deprecated" in str(last_warning.message) - - def test_should_exclude_with__returns_false_with_enabled_tag(self): - traits = self.traits - tags1 = [ traits.category1_enabled_tag ] - tags2 = [ traits.category2_enabled_tag ] - self.assertEqual(False, self.tag_matcher.should_exclude_with(tags1)) - self.assertEqual(False, self.tag_matcher.should_exclude_with(tags2)) - - def test_should_exclude_with__returns_false_with_enabled_tag_and_more(self): - traits = self.traits - test_patterns = [ - ([ traits.category1_enabled_tag, traits.category1_disabled_tag ], "case: first"), - ([ traits.category1_disabled_tag, traits.category1_enabled_tag ], "case: last"), - ([ "foo", traits.category1_enabled_tag, traits.category1_disabled_tag, "bar" ], "case: middle"), - ] - for tags, case in test_patterns: - self.assertEqual(False, self.tag_matcher.should_exclude_with(tags), - "%s: tags=%s" % (case, tags)) - - def test_should_exclude_with__returns_true_with_other_tag(self): - traits = self.traits - tags = [ traits.category1_disabled_tag ] - self.assertEqual(True, self.tag_matcher.should_exclude_with(tags)) - - def test_should_exclude_with__returns_true_with_other_tag_and_more(self): - traits = self.traits - test_patterns = [ - ([ traits.category1_disabled_tag, "foo" ], "case: first"), - ([ "foo", traits.category1_disabled_tag ], "case: last"), - ([ "foo", traits.category1_disabled_tag, "bar" ], "case: middle"), - ] - for tags, case in test_patterns: - self.assertEqual(True, self.tag_matcher.should_exclude_with(tags), - "%s: tags=%s" % (case, tags)) - - def test_should_exclude_with__returns_true_with_similar_tag(self): - traits = self.traits - tags = [ traits.category1_similar_tag ] - self.assertEqual(True, self.tag_matcher.should_exclude_with(tags)) - - def test_should_exclude_with__returns_true_with_similar_and_more(self): - traits = self.traits - test_patterns = [ - ([ traits.category1_similar_tag, "foo" ], "case: first"), - ([ "foo", traits.category1_similar_tag ], "case: last"), - ([ "foo", traits.category1_similar_tag, "bar" ], "case: middle"), - ] - for tags, case in test_patterns: - self.assertEqual(True, self.tag_matcher.should_exclude_with(tags), - "%s: tags=%s" % (case, tags)) - - def test_should_exclude_with__returns_false_without_category_tag(self): - test_patterns = [ - ([ ], "case: No tags"), - ([ "foo" ], "case: One tag"), - ([ "foo", "bar" ], "case: Two tags"), - ] - for tags, case in test_patterns: - self.assertEqual(False, self.tag_matcher.should_exclude_with(tags), - "%s: tags=%s" % (case, tags)) - - def test_should_exclude_with__returns_false_with_unknown_category_tag(self): - """Tags from unknown categories, not supported by value_provider, - should not be excluded. - """ - traits = self.traits - tags = [ traits.unknown_category_tag ] - self.assertEqual("only.with_UNKNOWN=one", traits.unknown_category_tag) - self.assertEqual(None, self.tag_matcher.value_provider.get("UNKNOWN")) - self.assertEqual(False, self.tag_matcher.should_exclude_with(tags)) - - def test_should_exclude_with__combinations_of_2_categories(self): - traits = self.traits - test_patterns = [ - ("case 00: 2 disabled category tags", True, - [ traits.category1_disabled_tag, traits.category2_disabled_tag]), - ("case 01: disabled and enabled category tags", True, - [ traits.category1_disabled_tag, traits.category2_enabled_tag]), - ("case 10: enabled and disabled category tags", True, - [ traits.category1_enabled_tag, traits.category2_disabled_tag]), - ("case 11: 2 enabled category tags", False, # -- SHOULD-RUN - [ traits.category1_enabled_tag, traits.category2_enabled_tag]), - # -- SPECIAL CASE: With unknown category - ("case 0x: disabled and unknown category tags", True, - [ traits.category1_disabled_tag, traits.unknown_category_tag]), - ("case 1x: enabled and unknown category tags", False, # SHOULD-RUN - [ traits.category1_enabled_tag, traits.unknown_category_tag]), - ] - for case, expected, tags in test_patterns: - actual_result = self.tag_matcher.should_exclude_with(tags) - self.assertEqual(expected, actual_result, - "%s: tags=%s" % (case, tags)) - - def test_should_run_with__negates_result_of_should_exclude_with(self): - traits = self.traits - test_patterns = [ - ([ ], "case: No tags"), - ([ "foo" ], "case: One non-category tag"), - ([ "foo", "bar" ], "case: Two non-category tags"), - ([ traits.category1_enabled_tag ], "case: enabled tag"), - ([ traits.category1_enabled_tag, traits.category1_disabled_tag ], "case: enabled and other tag"), - ([ traits.category1_enabled_tag, "foo" ], "case: enabled and foo tag"), - ([ traits.category1_disabled_tag ], "case: other tag"), - ([ traits.category1_disabled_tag, "foo" ], "case: other and foo tag"), - ([ traits.category1_similar_tag ], "case: similar tag"), - ([ "foo", traits.category1_similar_tag ], "case: foo and similar tag"), - ] - for tags, case in test_patterns: - result1 = self.tag_matcher.should_run_with(tags) - result2 = self.tag_matcher.should_exclude_with(tags) - self.assertEqual(result1, not result2, "%s: tags=%s" % (case, tags)) - self.assertEqual(not result1, result2, "%s: tags=%s" % (case, tags)) From a7996b0cfa9e64e909fa9860be689f51996a587c Mon Sep 17 00:00:00 2001 From: jenisys Date: Thu, 13 Jun 2024 20:58:16 +0200 Subject: [PATCH 234/240] features/runner.context_cleanup.feature: Add BAD CASES * RELATED TO: #1179 -- Additional diagnostics --- features/runner.context_cleanup.feature | 879 +++++++++++++++--------- 1 file changed, 550 insertions(+), 329 deletions(-) diff --git a/features/runner.context_cleanup.feature b/features/runner.context_cleanup.feature index 05a9752b0..12ce0d81d 100644 --- a/features/runner.context_cleanup.feature +++ b/features/runner.context_cleanup.feature @@ -23,350 +23,571 @@ Feature: Perform Context.cleanups at the end of a test-run, feature or scenario . | scenario | In step hooks | After after_scenario() hook is executed. | - @setup - Scenario: Test Setup + Background: Test Setup Given a new working directory - And a file named "features/environment.py" with: - """ - from __future__ import print_function - - # -- CLEANUP FUNCTIONS: - class CleanupFuntion(object): - def __init__(self, name=None): - self.name = name or "" - - def __call__(self): - print("CALLED: CleanupFunction:%s" % self.name) - - def cleanup_after_testrun(): - print("CALLED: cleanup_after_testrun") - - def cleanup_foo(): - print("CALLED: cleanup_foo") - - def cleanup_bar(): - print("CALLED: cleanup_bar") - - # -- HOOKS: - def before_all(context): - print("CALLED-HOOK: before_all") - userdata = context.config.userdata - use_cleanup = userdata.getbool("use_cleanup_after_testrun") - if use_cleanup: - print("REGISTER-CLEANUP: cleanup_after_testrun") - context.add_cleanup(cleanup_after_testrun) - - def before_feature(context, feature): - print("CALLED-HOOK: before_feature:%s" % feature.name) - userdata = context.config.userdata - use_cleanup = userdata.getbool("use_cleanup_after_feature") - if use_cleanup and "cleanup.after_feature" in feature.tags: - print("REGISTER-CLEANUP: cleanup_foo") - context.add_cleanup(cleanup_foo) - - def after_feature(context, feature): - print("CALLED-HOOK: after_feature: %s" % feature.name) - - def after_all(context): - print("CALLED-HOOK: after_all") - """ - And a file named "features/steps/use_steps.py" with: - """ - import behave4cmd0.passing_steps - """ - And a file named "features/alice.feature" with: - """ - Feature: Alice - Scenario: A1 - Given a step passes - - Scenario: A2 - When another step passes - """ And a file named "behave.ini" with: """ [behave] show_timings = false stdout_capture = true """ - - @cleanup.after_testrun - Scenario: Cleanup registered in before_all hook - When I run "behave -D use_cleanup_after_testrun -f plain features/alice.feature" - Then it should pass with: - """ - CALLED-HOOK: before_all - REGISTER-CLEANUP: cleanup_after_testrun - CALLED-HOOK: before_feature:Alice - Feature: Alice - """ - And the command output should contain: - """ - Scenario: A2 - When another step passes ... passed - - CALLED-HOOK: after_feature: Alice - CALLED-HOOK: after_all - CALLED: cleanup_after_testrun + And a file named "features/steps/use_steps.py" with: """ - - @cleanup.after_feature - Scenario: Cleanup registered in before_feature hook - Given a file named "features/environment.py" with: + import behave4cmd0.passing_steps """ - from __future__ import print_function - # -- CLEANUP FUNCTIONS: - def cleanup_foo(): - print("CALLED: cleanup_foo") - - # -- HOOKS: - def before_feature(context, feature): - print("CALLED-HOOK: before_feature:%s" % feature.name) - if "cleanup.after_feature" in feature.tags: - print("REGISTER-CLEANUP: cleanup_foo") - context.add_cleanup(cleanup_foo) - - def after_feature(context, feature): - print("CALLED-HOOK: after_feature:%s" % feature.name) - - def after_all(context): - print("CALLED-HOOK: after_all") - """ - And a file named "features/bob.feature" with: - """ - @cleanup.after_feature - Feature: Bob + Rule: Good cases + Background: Rule Setup + Given a file named "features/environment.py" with: + """ + from __future__ import print_function + + # -- CLEANUP FUNCTIONS: + class CleanupFuntion(object): + def __init__(self, name=None): + self.name = name or "" + + def __call__(self): + print("CALLED: CleanupFunction:%s" % self.name) + + def cleanup_after_testrun(): + print("CALLED: cleanup_after_testrun") + + def cleanup_foo(): + print("CALLED: cleanup_foo") + + def cleanup_bar(): + print("CALLED: cleanup_bar") + + # -- HOOKS: + def before_all(context): + print("CALLED-HOOK: before_all") + userdata = context.config.userdata + use_cleanup = userdata.getbool("use_cleanup_after_testrun") + if use_cleanup: + print("REGISTER-CLEANUP: cleanup_after_testrun") + context.add_cleanup(cleanup_after_testrun) + + def before_feature(context, feature): + print("CALLED-HOOK: before_feature:%s" % feature.name) + userdata = context.config.userdata + use_cleanup = userdata.getbool("use_cleanup_after_feature") + if use_cleanup and "cleanup.after_feature" in feature.tags: + print("REGISTER-CLEANUP: cleanup_foo") + context.add_cleanup(cleanup_foo) + + def after_feature(context, feature): + print("CALLED-HOOK: after_feature: %s" % feature.name) + + def after_all(context): + print("CALLED-HOOK: after_all") + """ + And a file named "features/alice.feature" with: + """ + Feature: Alice + Scenario: A1 + Given a step passes + + Scenario: A2 + When another step passes + """ + + @cleanup.after_testrun + Scenario: Cleanup registered in before_all hook + When I run "behave -D use_cleanup_after_testrun -f plain features/alice.feature" + Then it should pass with: + """ + CALLED-HOOK: before_all + REGISTER-CLEANUP: cleanup_after_testrun + CALLED-HOOK: before_feature:Alice + Feature: Alice + """ + And the command output should contain: + """ + Scenario: A2 + When another step passes ... passed + + CALLED-HOOK: after_feature: Alice + CALLED-HOOK: after_all + CALLED: cleanup_after_testrun + """ + + @cleanup.after_feature + Scenario: Cleanup registered in before_feature hook + Given a file named "features/environment.py" with: + """ + from __future__ import print_function + + # -- CLEANUP FUNCTIONS: + def cleanup_foo(): + print("CALLED: cleanup_foo") + + # -- HOOKS: + def before_feature(context, feature): + print("CALLED-HOOK: before_feature:%s" % feature.name) + if "cleanup.after_feature" in feature.tags: + print("REGISTER-CLEANUP: cleanup_foo") + context.add_cleanup(cleanup_foo) + + def after_feature(context, feature): + print("CALLED-HOOK: after_feature:%s" % feature.name) + + def after_all(context): + print("CALLED-HOOK: after_all") + """ + And a file named "features/bob.feature" with: + """ + @cleanup.after_feature + Feature: Bob + Scenario: B1 + Given a step passes + """ + When I run "behave -f plain features/bob.feature" + Then it should pass with: + """ + CALLED-HOOK: before_feature:Bob + REGISTER-CLEANUP: cleanup_foo + Feature: Bob + """ + And the command output should contain: + """ Scenario: B1 - Given a step passes - """ - When I run "behave -f plain features/bob.feature" - Then it should pass with: - """ - CALLED-HOOK: before_feature:Bob - REGISTER-CLEANUP: cleanup_foo - Feature: Bob - """ - And the command output should contain: - """ - Scenario: B1 - Given a step passes ... passed - - CALLED-HOOK: after_feature:Bob - CALLED: cleanup_foo - CALLED-HOOK: after_all - """ - - - @cleanup.after_scenario - Scenario: Cleanup registered in before_scenario hook - Given a file named "features/environment.py" with: - """ - from __future__ import print_function - - # -- CLEANUP FUNCTIONS: - def cleanup_foo(): - print("CALLED: cleanup_foo") - - # -- HOOKS: - def before_scenario(context, scenario): - print("CALLED-HOOK: before_scenario:%s" % scenario.name) - if "cleanup_foo" in scenario.tags: - print("REGISTER-CLEANUP: cleanup_foo") - context.add_cleanup(cleanup_foo) - - def after_scenario(context, scenario): - print("CALLED-HOOK: after_scenario:%s" % scenario.name) - """ - And a file named "features/charly.feature" with: - """ - Feature: Charly - @cleanup_foo + Given a step passes ... passed + + CALLED-HOOK: after_feature:Bob + CALLED: cleanup_foo + CALLED-HOOK: after_all + """ + + + @cleanup.after_scenario + Scenario: Cleanup registered in before_scenario hook + Given a file named "features/environment.py" with: + """ + from __future__ import print_function + + # -- CLEANUP FUNCTIONS: + def cleanup_foo(): + print("CALLED: cleanup_foo") + + # -- HOOKS: + def before_scenario(context, scenario): + print("CALLED-HOOK: before_scenario:%s" % scenario.name) + if "cleanup_foo" in scenario.tags: + print("REGISTER-CLEANUP: cleanup_foo") + context.add_cleanup(cleanup_foo) + + def after_scenario(context, scenario): + print("CALLED-HOOK: after_scenario:%s" % scenario.name) + """ + And a file named "features/charly.feature" with: + """ + Feature: Charly + @cleanup_foo + Scenario: C1 + Given a step passes + + Scenario: C2 + When a step passes + """ + When I run "behave -f plain features/charly.feature" + Then it should pass with: + """ + CALLED-HOOK: before_scenario:C1 + REGISTER-CLEANUP: cleanup_foo Scenario: C1 - Given a step passes - - Scenario: C2 - When a step passes - """ - When I run "behave -f plain features/charly.feature" - Then it should pass with: - """ - CALLED-HOOK: before_scenario:C1 - REGISTER-CLEANUP: cleanup_foo - Scenario: C1 - """ - And the command output should contain: - """ - Scenario: C1 - Given a step passes ... passed - - CALLED-HOOK: after_scenario:C1 - CALLED: cleanup_foo - CALLED-HOOK: before_scenario:C2 - """ - - @cleanup.after_scenario - Scenario: Cleanups are executed in reverse registration order - - Given a file named "features/environment.py" with: - """ - from __future__ import print_function - - # -- CLEANUP FUNCTIONS: - def cleanup_foo(): - print("CALLED: cleanup_foo") - - def cleanup_bar(): - print("CALLED: cleanup_bar") - - # -- HOOKS: - def before_scenario(context, scenario): - print("CALLED-HOOK: before_scenario:%s" % scenario.name) - if "cleanup_foo" in scenario.tags: - print("REGISTER-CLEANUP: cleanup_foo") - context.add_cleanup(cleanup_foo) - if "cleanup_bar" in scenario.tags: - print("REGISTER-CLEANUP: cleanup_bar") - context.add_cleanup(cleanup_bar) - - def after_scenario(context, scenario): - print("CALLED-HOOK: after_scenario:%s" % scenario.name) - """ - And a file named "features/dodo.feature" with: - """ - Feature: Dodo - @cleanup_foo - @cleanup_bar + """ + And the command output should contain: + """ + Scenario: C1 + Given a step passes ... passed + + CALLED-HOOK: after_scenario:C1 + CALLED: cleanup_foo + CALLED-HOOK: before_scenario:C2 + """ + + @cleanup.after_scenario + Scenario: Cleanups are executed in reverse registration order + + Given a file named "features/environment.py" with: + """ + from __future__ import print_function + + # -- CLEANUP FUNCTIONS: + def cleanup_foo(): + print("CALLED: cleanup_foo") + + def cleanup_bar(): + print("CALLED: cleanup_bar") + + # -- HOOKS: + def before_scenario(context, scenario): + print("CALLED-HOOK: before_scenario:%s" % scenario.name) + if "cleanup_foo" in scenario.tags: + print("REGISTER-CLEANUP: cleanup_foo") + context.add_cleanup(cleanup_foo) + if "cleanup_bar" in scenario.tags: + print("REGISTER-CLEANUP: cleanup_bar") + context.add_cleanup(cleanup_bar) + + def after_scenario(context, scenario): + print("CALLED-HOOK: after_scenario:%s" % scenario.name) + """ + And a file named "features/dodo.feature" with: + """ + Feature: Dodo + @cleanup_foo + @cleanup_bar + Scenario: D1 + Given a step passes + + Scenario: D2 + When a step passes + """ + When I run "behave -f plain features/dodo.feature" + Then it should pass with: + """ + CALLED-HOOK: before_scenario:D1 + REGISTER-CLEANUP: cleanup_foo + REGISTER-CLEANUP: cleanup_bar Scenario: D1 - Given a step passes - - Scenario: D2 - When a step passes - """ - When I run "behave -f plain features/dodo.feature" - Then it should pass with: - """ - CALLED-HOOK: before_scenario:D1 - REGISTER-CLEANUP: cleanup_foo - REGISTER-CLEANUP: cleanup_bar - Scenario: D1 - """ - And the command output should contain: - """ - Scenario: D1 - Given a step passes ... passed - - CALLED-HOOK: after_scenario:D1 - CALLED: cleanup_bar - CALLED: cleanup_foo - CALLED-HOOK: before_scenario:D2 - """ - And the command output should contain 1 times: - """ - CALLED: cleanup_bar - CALLED: cleanup_foo - """ - - @cleanup.after_scenario - Scenario: Cleanup registered in step implementation - Given a file named "features/environment.py" with: - """ - from __future__ import print_function - - # -- HOOKS: - def before_scenario(context, scenario): - print("CALLED-HOOK: before_scenario:%s" % scenario.name) - - def after_scenario(context, scenario): - print("CALLED-HOOK: after_scenario:%s" % scenario.name) - """ - And a file named "features/steps/cleanup_steps.py" with: - """ - from behave import given - - # -- CLEANUP FUNCTIONS: - def cleanup_foo(): - print("CALLED: cleanup_foo") - - # -- STEPS: - @given(u'I register a cleanup "{cleanup_name}"') - def step_register_cleanup(context, cleanup_name): - if cleanup_name == "cleanup_foo": - context.add_cleanup(cleanup_foo) - else: - raise KeyError("Unknown_cleanup:%s" % cleanup_name) - """ - And a file named "features/emily.feature" with: - """ - Feature: Emily + """ + And the command output should contain: + """ + Scenario: D1 + Given a step passes ... passed + + CALLED-HOOK: after_scenario:D1 + CALLED: cleanup_bar + CALLED: cleanup_foo + CALLED-HOOK: before_scenario:D2 + """ + And the command output should contain 1 times: + """ + CALLED: cleanup_bar + CALLED: cleanup_foo + """ + + @cleanup.after_scenario + Scenario: Cleanup registered in step implementation + Given a file named "features/environment.py" with: + """ + from __future__ import print_function + + # -- HOOKS: + def before_scenario(context, scenario): + print("CALLED-HOOK: before_scenario:%s" % scenario.name) + + def after_scenario(context, scenario): + print("CALLED-HOOK: after_scenario:%s" % scenario.name) + """ + And a file named "features/steps/cleanup_steps.py" with: + """ + from behave import given + + # -- CLEANUP FUNCTIONS: + def cleanup_foo(): + print("CALLED: cleanup_foo") + + # -- STEPS: + @given(u'I register a cleanup "{cleanup_name}"') + def step_register_cleanup(context, cleanup_name): + if cleanup_name == "cleanup_foo": + context.add_cleanup(cleanup_foo) + else: + raise KeyError("Unknown_cleanup:%s" % cleanup_name) + """ + And a file named "features/emily.feature" with: + """ + Feature: Emily + Scenario: E1 + Given I register a cleanup "cleanup_foo" + + Scenario: E2 + When a step passes + """ + When I run "behave -f plain features/emily.feature" + Then it should pass with: + """ Scenario: E1 - Given I register a cleanup "cleanup_foo" - - Scenario: E2 - When a step passes - """ - When I run "behave -f plain features/emily.feature" - Then it should pass with: - """ - Scenario: E1 - Given I register a cleanup "cleanup_foo" ... passed + Given I register a cleanup "cleanup_foo" ... passed + + CALLED-HOOK: after_scenario:E1 + CALLED: cleanup_foo + CALLED-HOOK: before_scenario:E2 + """ + And the command output should contain 1 times: + """ + CALLED: cleanup_foo + """ + + @cleanup.after_scenario + Scenario: Registered cleanup function args are passed to cleanup + Given a file named "features/environment.py" with: + """ + from __future__ import print_function + + # -- CLEANUP FUNCTIONS: + def cleanup_foo(text): + print('CALLED: cleanup_foo("%s")' % text) + + # -- HOOKS: + def before_scenario(context, scenario): + print("CALLED-HOOK: before_scenario:%s" % scenario.name) + if "cleanup_foo" in scenario.tags: + print('REGISTER-CLEANUP: cleanup_foo("Alice")') + context.add_cleanup(cleanup_foo, "Alice") + print('REGISTER-CLEANUP: cleanup_foo("Bob")') + context.add_cleanup(cleanup_foo, "Bob") + + def after_scenario(context, scenario): + print("CALLED-HOOK: after_scenario:%s" % scenario.name) + """ + And a file named "features/frank.feature" with: + """ + Feature: Frank + @cleanup_foo + Scenario: F1 + Given a step passes + + Scenario: F2 + When a step passes + """ + When I run "behave -f plain features/frank.feature" + Then it should pass with: + """ + CALLED-HOOK: before_scenario:F1 + REGISTER-CLEANUP: cleanup_foo("Alice") + REGISTER-CLEANUP: cleanup_foo("Bob") + Scenario: F1 + """ + And the command output should contain: + """ + Scenario: F1 + Given a step passes ... passed + + CALLED-HOOK: after_scenario:F1 + CALLED: cleanup_foo("Bob") + CALLED: cleanup_foo("Alice") + CALLED-HOOK: before_scenario:F2 + """ + + Rule: Bad Cases + + @error.in.cleanup_function + @cleanup.after_scenario + Scenario: Cleanup function raises Error + INTENTION: All registered cleanups must be called. + + Given a file named "features/environment.py" with: + """ + from __future__ import print_function + + # -- CLEANUP FUNCTIONS: + def cleanup_foo(): + print("CALLED: cleanup_foo") + + def bad_cleanup_bar(): + print("CALLED: bad_cleanup_bar -- PART_1") + raise ValueError("CLEANUP-OOPS") + print("CALLED: bad_cleanup_bar -- PART_2 -- NOT_REACHED") + + # -- HOOKS: + def before_scenario(context, scenario): + print("CALLED-HOOK: before_scenario:%s" % scenario.name) + if "cleanup_foo" in scenario.tags: + print("REGISTER-CLEANUP: cleanup_foo") + context.add_cleanup(cleanup_foo) + if "cleanup_bar" in scenario.tags: + print("REGISTER-CLEANUP: bad_cleanup_bar") + context.add_cleanup(bad_cleanup_bar) + + def after_scenario(context, scenario): + print("CALLED-HOOK: after_scenario:%s" % scenario.name) + """ + And a file named "features/bad_cleanup.feature" with: + """ + Feature: Bad Cleanup + @cleanup_foo + @cleanup_bar + Scenario: E1 + Given a step passes + + Scenario: E2 + When a step passes + """ + When I run "behave -f plain features/bad_cleanup.feature" + Then it should fail with: + """ + CALLED-HOOK: before_scenario:E1 + REGISTER-CLEANUP: cleanup_foo + REGISTER-CLEANUP: bad_cleanup_bar - CALLED-HOOK: after_scenario:E1 - CALLED: cleanup_foo - CALLED-HOOK: before_scenario:E2 - """ - And the command output should contain 1 times: - """ - CALLED: cleanup_foo - """ + Scenario: E1 + """ + And the command output should contain: + """ + CALLED-HOOK: after_scenario:E1 + CALLED: bad_cleanup_bar -- PART_1 + CLEANUP-ERROR in bad_cleanup_bar: ValueError: CLEANUP-OOPS + Traceback (most recent call last): + File "{__CWD__}/behave/runner.py", line 275, in _do_cleanups + cleanup_func() + File "features/environment.py", line 9, in bad_cleanup_bar + raise ValueError("CLEANUP-OOPS") + ValueError: CLEANUP-OOPS + CALLED: cleanup_foo + CALLED-HOOK: before_scenario:E2 + """ + And the command output should contain 1 times: + """ + CALLED: cleanup_foo + """ + And the command output should not contain: + """ + CALLED: bad_cleanup_bar -- PART_2 -- NOT_REACHED + """ + But note that "all cleanup functions are called when bad_cleanup raises an error" + + + @error.in.before_scenario + @cleanup.after_scenario + Scenario: Hook before_scenario raises Error + INTENTION: Registered cleanups are performed. + + Given a file named "features/environment.py" with: + """ + from __future__ import print_function + + # -- CLEANUP FUNCTIONS: + def cleanup_foo(): + print("CALLED: cleanup_foo") + + def cleanup_bar(): + print("CALLED: cleanup_bar") + + # -- HOOKS: + def before_scenario(context, scenario): + print("CALLED-HOOK: before_scenario:%s" % scenario.name) + if "cleanup_foo" in scenario.tags: + print("REGISTER-CLEANUP: cleanup_foo") + context.add_cleanup(cleanup_foo) + if "cleanup_bar" in scenario.tags: + print("REGISTER-CLEANUP: cleanup_bar") + context.add_cleanup(cleanup_bar) + + # -- PROBLEM-POINT: + if "problem_point" in scenario.tags: + raise ValueError("OOPS") + + def after_scenario(context, scenario): + print("CALLED-HOOK: after_scenario:%s" % scenario.name) + """ + And a file named "features/bad_antony.feature" with: + """ + Feature: Bad Antony + @cleanup_foo + @cleanup_bar + @problem_point + Scenario: E1 + Given a step passes + + Scenario: E2 + When a step passes + """ + When I run "behave -f plain features/bad_antony.feature" + Then it should fail with: + """ + CALLED-HOOK: before_scenario:E1 + REGISTER-CLEANUP: cleanup_foo + REGISTER-CLEANUP: cleanup_bar + HOOK-ERROR in before_scenario: ValueError: OOPS - @cleanup.after_scenario - Scenario: Registered cleanup function args are passed to cleanup - Given a file named "features/environment.py" with: - """ - from __future__ import print_function - - # -- CLEANUP FUNCTIONS: - def cleanup_foo(text): - print('CALLED: cleanup_foo("%s")' % text) - - # -- HOOKS: - def before_scenario(context, scenario): - print("CALLED-HOOK: before_scenario:%s" % scenario.name) - if "cleanup_foo" in scenario.tags: - print('REGISTER-CLEANUP: cleanup_foo("Alice")') - context.add_cleanup(cleanup_foo, "Alice") - print('REGISTER-CLEANUP: cleanup_foo("Bob")') - context.add_cleanup(cleanup_foo, "Bob") - - def after_scenario(context, scenario): - print("CALLED-HOOK: after_scenario:%s" % scenario.name) - """ - And a file named "features/frank.feature" with: - """ - Feature: Frank - @cleanup_foo - Scenario: F1 - Given a step passes + Scenario: E1 + """ + And the command output should contain: + """ + Scenario: E1 - Scenario: F2 - When a step passes - """ - When I run "behave -f plain features/frank.feature" - Then it should pass with: - """ - CALLED-HOOK: before_scenario:F1 - REGISTER-CLEANUP: cleanup_foo("Alice") - REGISTER-CLEANUP: cleanup_foo("Bob") - Scenario: F1 - """ - And the command output should contain: - """ - Scenario: F1 - Given a step passes ... passed + CALLED-HOOK: after_scenario:E1 + CALLED: cleanup_bar + CALLED: cleanup_foo + + CALLED-HOOK: before_scenario:E2 + """ + And the command output should contain 1 times: + """ + CALLED: cleanup_bar + CALLED: cleanup_foo + """ + But note that "cleanup functions are called even if ValueError is raised in before_scenario() hook" + + @error.in.after_scenario + @cleanup.after_scenario + Scenario: Hook after_scenario raises Error + INTENTION: Registered cleanups are performed. + + Given a file named "features/environment.py" with: + """ + from __future__ import print_function + + # -- CLEANUP FUNCTIONS: + def cleanup_foo(): + print("CALLED: cleanup_foo") + + def cleanup_bar(): + print("CALLED: cleanup_bar") + + # -- HOOKS: + def before_scenario(context, scenario): + print("CALLED-HOOK: before_scenario:%s" % scenario.name) + if "cleanup_foo" in scenario.tags: + print("REGISTER-CLEANUP: cleanup_foo") + context.add_cleanup(cleanup_foo) + if "cleanup_bar" in scenario.tags: + print("REGISTER-CLEANUP: cleanup_bar") + context.add_cleanup(cleanup_bar) + + def after_scenario(context, scenario): + print("CALLED-HOOK: after_scenario:%s" % scenario.name) + + # -- PROBLEM-POINT: + if "problem_point" in scenario.tags: + raise ValueError("OOPS") + """ + And a file named "features/bad_bob.feature" with: + """ + Feature: Bad Bob + @cleanup_foo + @cleanup_bar + @problem_point + Scenario: E1 + Given a step passes + + Scenario: E2 + When a step passes + """ + When I run "behave -f plain features/bad_bob.feature" + Then it should fail with: + """ + CALLED-HOOK: before_scenario:E1 + REGISTER-CLEANUP: cleanup_foo + REGISTER-CLEANUP: cleanup_bar + + Scenario: E1 + Given a step passes ... passed + CALLED-HOOK: after_scenario:E1 + HOOK-ERROR in after_scenario: ValueError: OOPS + CALLED: cleanup_bar + CALLED: cleanup_foo + + CALLED-HOOK: before_scenario:E2 + """ + And the command output should contain 1 times: + """ + CALLED: cleanup_bar + CALLED: cleanup_foo + """ + But note that "cleanup functions are called even if ValueError is raised in after_scenario() hook" - CALLED-HOOK: after_scenario:F1 - CALLED: cleanup_foo("Bob") - CALLED: cleanup_foo("Alice") - CALLED-HOOK: before_scenario:F2 - """ From fbc6ec1a94ecb526c37ab38e6a72362e4d56b8df Mon Sep 17 00:00:00 2001 From: jenisys Date: Sat, 15 Jun 2024 09:25:15 +0200 Subject: [PATCH 235/240] Issue #1180: freezegun.freeze_time() causes Summary.duration to be negative * Provide feature to analyse this problem * Provide a solution to this problem SOLUTION: * freezegun must ignore one/some behave module(s) --- issue.features/issue1180.feature | 94 ++++++++++++++++++++++++++++++++ py.requirements/testing.txt | 3 + pyproject.toml | 3 + setup.py | 3 + 4 files changed, 103 insertions(+) create mode 100644 issue.features/issue1180.feature diff --git a/issue.features/issue1180.feature b/issue.features/issue1180.feature new file mode 100644 index 000000000..881dd60a6 --- /dev/null +++ b/issue.features/issue1180.feature @@ -0,0 +1,94 @@ +@issue +@use.with_python.min_version=3.7 +Feature: Issue #1180 -- Negative Time Problem with Summary Reporter + + . DESCRIPTION OF SYNDROME (OBSERVED BEHAVIOR): + . When I use "freezegun.freeze_time()" in one of my steps + . Then the summary report may show negative duration for the test-run duration. + . + . ANALYSIS OF THE PROBLEM (and solution): + . Freezegun must be configured to ignore one/some behave module(s). + . + . SEE ALSO: + . * https://github.com/behave/behave/issues/1180 + . * https://github.com/spulec/freezegun + + + Background: Setup + Given a new working directory + And a file named "features/steps/use_behave4cmd_steps.py" with: + """ + from __future__ import absolute_import + import behave4cmd0.passing_steps + """ + And a file named "features/steps/freeze_time_steps.py" with: + """ + from __future__ import absolute_import + import datetime + import os + from behave import given, when, then + from freezegun import freeze_time + import freezegun + from assertpy import assert_that + + FREEZEGUN_IGNORE_BEHAVE = os.environ.get("FREEZEGUN_IGNORE_BEHAVE", "no") == "yes" + if FREEZEGUN_IGNORE_BEHAVE: + print("FREEZEGUN: Ignore behave modules ...") + freezegun.configure(extend_ignore_list=["behave.model"]) + + @given(u'current time is fixed at "{isotime:ti}"') + @when(u'current time is fixed at "{isotime:ti}"') + def step_current_time(ctx, isotime): + time_patcher = freeze_time(isotime, real_asyncio=True) + time_patcher.start() + + def restore_time(): + print("FREEZEGUN: Restore time") + time_patcher.stop() + ctx.add_cleanup(restore_time) + + @then(u'today is "{today:ti}"') + def step_then_today_is(ctx, today): + now = datetime.datetime.now() + assert_that(today).is_equal_to(now) + """ + And a file named "features/syndrome_1180.feature" with: + """ + Feature: Check syndrome with freezegun + + Scenario: T1 + Given current time is fixed at "2001-09-11" + Then today is "2001-09-11" + + Scenario: T2 + Given current time is fixed at "1980-01-01" + Then today is "1980-01-01" + """ + + + @syndrome + @xfail.without.freezegun.ignore_behave_module + Scenario: Use freezegun.freeze_time to check syndrome (proof-of-concept) + Given I set the environment variable "FREEZEGUN_IGNORE_BEHAVE" to "no" + When I run `behave -f plain features/syndrome_1180.feature` + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 0 skipped + 4 steps passed, 0 failed, 0 skipped, 0 undefined + """ + But the command output should match /Took -\d+m\d+\.\d+s/ + And note that "Test run duration is negative" + + @syndrome.fixed + Scenario: Use freezegun.freeze_time to check syndrome (case: FIXED) + Given I set the environment variable "FREEZEGUN_IGNORE_BEHAVE" to "yes" + When I run `behave -f plain features/syndrome_1180.feature` + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 0 skipped + 4 steps passed, 0 failed, 0 skipped, 0 undefined + """ + And the command output should match /Took \d+m\d+\.\d+s/ + But the command output should not match /Took -\d+m\d+\.\d+s/ + And note that "Test run duration is positive" + diff --git a/py.requirements/testing.txt b/py.requirements/testing.txt index f1cc09cdd..4c7a89017 100644 --- a/py.requirements/testing.txt +++ b/py.requirements/testing.txt @@ -27,4 +27,7 @@ path >= 13.1.0; python_version >= '3.5' # -- PYTHON2 BACKPORTS: pathlib; python_version <= '3.4' +# -- EXTRA PYTHON MODULES: +freezegun >= 1.5.1; python_version > '3.7' + -r ../issue.features/py.requirements.txt diff --git a/pyproject.toml b/pyproject.toml index f44648721..3f36f0546 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -170,6 +170,9 @@ testing = [ "path.py >=11.5.0,<13.0; python_version < '3.5'", # -- PYTHON2 BACKPORTS: "pathlib; python_version <= '3.4'", + + # -- EXTRA PYTHON PACKAGES: Used for some tests + "freezegun >= 1.5.1; python_version > '3.7'", ] # -- BACKWORD-COMPATIBLE SECTION: Can be removed in the future # HINT: Package-requirements are now part of "dependencies" parameter above. diff --git a/setup.py b/setup.py index 4817ad9eb..3eaa02f7e 100644 --- a/setup.py +++ b/setup.py @@ -110,6 +110,9 @@ def find_packages_by_root_package(where): "path.py >=11.5.0,<13.0; python_version < '3.5'", # -- PYTHON2 BACKPORTS: "pathlib; python_version <= '3.4'", + + # -- EXTRA PYTHON PACKAGES: Used for some tests + "freezegun >= 1.5.1; python_version > '3.7'", ], cmdclass = { "behave_test": behave_test, From c4db7cbf3f34a4ef5be3de8ca8652aa7b9ad8147 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 16 Jun 2024 11:58:20 +0200 Subject: [PATCH 236/240] DEVELOP: Add dep-tree tool as optional dependency * Add dep-tree tool to explore the "behave" internal dependencies (as: test balloon) SEE: https://github.com/gabotechs/dep-tree --- py.requirements/develop.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/py.requirements/develop.txt b/py.requirements/develop.txt index c1ce9533c..03be1272c 100644 --- a/py.requirements/develop.txt +++ b/py.requirements/develop.txt @@ -21,6 +21,10 @@ modernize >= 0.5 # -- STATIC CODE ANALYSIS: -r pylinters.txt +# -- CODE EXPLORATIONS: +# SEE: https://github.com/gabotechs/dep-tree +python-dep-tree; python_version >= '3.7' + # -- REQUIRES: testing -r testing.txt coverage >= 5.0 From 32256be4e243a416b3c9a8eba633a23406c7d8e1 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 16 Jun 2024 16:39:15 +0200 Subject: [PATCH 237/240] NEW FORMATTER: steps.code * Shows steps together with a code-section of the step-function. * Removes step-function documentation from code-section (if present). * Can be used in dry-run mode. * Shows result in normal mode (similar to: plain). --- CHANGES.rst | 1 + behave/formatter/_builtins.py | 1 + behave/formatter/steps_code.py | 157 ++++++++++ docs/formatters.rst | 2 + features/formatter.help.feature | 1 + features/formatter.steps_code.feature | 425 ++++++++++++++++++++++++++ 6 files changed, 587 insertions(+) create mode 100644 behave/formatter/steps_code.py create mode 100644 features/formatter.steps_code.feature diff --git a/CHANGES.rst b/CHANGES.rst index b2878067a..335e71215 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -66,6 +66,7 @@ CLEANUPS: ENHANCEMENTS: * Add support for step-definitions (step-matchers) with `CucumberExpressions`_ +* Add formatter: steps.code -- Shows steps with code-section. * User-defined formatters: Improve diagnostics if bad formatter is used (ModuleNotFound, ...) * active-tags: Added ``ValueObject`` class for enhanced control of comparison mechanism (supports: equals, less-than, less-or-equal, greater-than, greater-or-equal, contains, ...) diff --git a/behave/formatter/_builtins.py b/behave/formatter/_builtins.py index d09eb3bf9..f2a40701c 100644 --- a/behave/formatter/_builtins.py +++ b/behave/formatter/_builtins.py @@ -27,6 +27,7 @@ ("steps", "behave.formatter.steps:StepsFormatter"), ("steps.doc", "behave.formatter.steps:StepsDocFormatter"), ("steps.catalog", "behave.formatter.steps:StepsCatalogFormatter"), + ("steps.code", "behave.formatter.steps_code:StepWithCodeFormatter"), ("steps.usage", "behave.formatter.steps:StepsUsageFormatter"), ("sphinx.steps", "behave.formatter.sphinx_steps:SphinxStepsFormatter"), ("bad_steps", "behave.formatter.bad_steps:BadStepsFormatter"), diff --git a/behave/formatter/steps_code.py b/behave/formatter/steps_code.py new file mode 100644 index 000000000..88c0b294c --- /dev/null +++ b/behave/formatter/steps_code.py @@ -0,0 +1,157 @@ +# -*- coding: UTF-8 -*- +""" +Provides a formatter, like the "plain" formatter, that: + +* Shows the step (and its result) +* Shows the step implementation (as code section) +""" + +from __future__ import absolute_import, print_function +import inspect +import sys + +from behave.model_core import Status +from behave.model_describe import ModelPrinter +from behave.textutil import indent, make_indentation +from behave.step_registry import registry as the_step_registry +from .plain import PlainFormatter + + +class StepModelPrinter(ModelPrinter): + INDENT_SIZE = 2 + SHOW_ALIGNED_KEYWORDS = False + SHOW_MULTILINE = True + SHOW_SKIPPED_CODE = False + + def __init__(self, stream=None, indent_size=None, step_indent_level=0, + show_aligned_keywords=None, show_multiline=None, + step_registry=None): + if stream is None: + stream = sys.stdout + if indent_size is None: + indent_size = self.INDENT_SIZE + super(StepModelPrinter, self).__init__(stream) + self.indent_size = indent_size + self.step_indent_level = step_indent_level + self.show_aligned_keywords = show_aligned_keywords or self.SHOW_ALIGNED_KEYWORDS + self.show_multiline = show_multiline or self.SHOW_MULTILINE + self.step_registry = step_registry or the_step_registry + + def _print_step_with_schema(self, step, schema, in_rule=False): + step_indent_level = self.step_indent_level + if in_rule: + step_indent_level += 1 + + prefix = make_indentation(self.indent_size * step_indent_level) + text = schema.format(prefix=prefix, step=step) + print(text, file=self.stream) + has_multiline = bool(step.text or step.table) + if self.show_multiline and has_multiline: + prefix += " " * self.indent_size + if step.table: + self.print_table(step.table, indentation=prefix) + elif step.text: + self.print_docstring(step.text, indentation=prefix) + + def print_step(self, step, in_rule=False): + schema = u"{prefix}{step.keyword} {step.name}" + if self.show_aligned_keywords: + schema = u"{prefix}{step.keyword:6s} {step.name}" + self._print_step_with_schema(step, schema, in_rule=in_rule) + + def print_step_with_result(self, step, in_rule=False): + schema = u"{prefix}{step.keyword} {step.name} ... {step.status.name}" + if self.show_aligned_keywords: + schema = u"{prefix}{step.keyword:6s} {step.name} ... {step.status.name}" + self._print_step_with_schema(step, schema, in_rule=in_rule) + + @classmethod + def get_code_without_docstring(cls, func): + func_code = inspect.getsource(func) + show_skipped_code = cls.SHOW_SKIPPED_CODE + if func.__doc__: + # -- STRIP: function-docstring + selected = [] + docstring_markers = ['"""', "'''"] + docstring_marker = None + inside_docstring = False + docstring_done = False + for line in func_code.splitlines(): + text = line.strip() + if not docstring_done: + if inside_docstring: + if text.startswith(docstring_marker): + inside_docstring = False + docstring_done = True + if show_skipped_code: + print("SKIP-CODE-LINE: {}".format(line)) + continue + + for this_marker in docstring_markers: + if text.startswith(this_marker): + docstring_marker = this_marker + inside_docstring = True + break + + if inside_docstring: + if text.endswith(docstring_marker) and text != docstring_marker: + # -- CASE: One line docstring, like: """One line.""" + inside_docstring = False + docstring_done = True + if show_skipped_code: + print("SKIP-CODE-LINE: {}".format(line)) + continue + selected.append(line) + func_code = "\n".join(selected) + return func_code + + def print_step_code(self, step, in_rule=False): + step_match = self.step_registry.find_match(step) + if not step_match: + return + + step_indent_level = self.step_indent_level + if in_rule: + step_indent_level += 1 + + code_indent_level = step_indent_level + 1 + prefix = make_indentation(self.indent_size * code_indent_level) + # DISABLED: step_code = inspect.getsource(step_match.func) + step_code = self.get_code_without_docstring(step_match.func) + print("{prefix}# -- CODE: {step_match.location}".format( + step_match=step_match, prefix=prefix), + file=self.stream) + + code_text = indent(step_code, prefix=prefix) + print(code_text, file=self.stream) + + +class StepWithCodeFormatter(PlainFormatter): + """ + Provides a formatter, like the "plain" formatter, that: + + * Shows the step (and its result) + * Shows the step implementation (as code section) + """ + name = "steps.code" + description = "Shows executed steps combined with their code." + + def __init__(self, stream_opener, config, **kwargs): + super(StepWithCodeFormatter, self).__init__(stream_opener, config, **kwargs) + self.printer = StepModelPrinter(self.stream, step_indent_level=2) + # PREPARED: self.suppress_duplicated_code = False + + # -- IMPLEMENT-INTERFACE FOR: Formatter + def result(self, step): + self.print_step(step) + + # -- INTERNALS: + def print_step(self, step): + contained_in_rule = bool(self.current_rule) + print_step = self.printer.print_step_with_result + if step.status is Status.untested: + print_step = self.printer.print_step + print_step(step, in_rule=contained_in_rule) + self.printer.print_step_code(step, in_rule=contained_in_rule) + + diff --git a/docs/formatters.rst b/docs/formatters.rst index 909ac6258..36694def2 100644 --- a/docs/formatters.rst +++ b/docs/formatters.rst @@ -47,6 +47,8 @@ progress3 normal Shows detailed progress for each step of a scenario. rerun normal Emits scenario file locations of failing scenarios sphinx.steps dry-run Generate sphinx-based documentation for step definitions. steps dry-run Shows step definitions (step implementations). +steps.catalog dry-run Shows non-technical documentation for step definitions. +steps.code dry-run Shows executed steps combined with their code. steps.doc dry-run Shows documentation for step definitions. steps.usage dry-run Shows how step definitions are used by steps (in feature files). tags dry-run Shows tags (and how often they are used). diff --git a/features/formatter.help.feature b/features/formatter.help.feature index 8aeb4dd93..8424242d6 100644 --- a/features/formatter.help.feature +++ b/features/formatter.help.feature @@ -38,6 +38,7 @@ Feature: Help Formatter sphinx.steps Generate sphinx-based documentation for step definitions. steps Shows step definitions (step implementations). steps.catalog Shows non-technical documentation for step definitions. + steps.code Shows executed steps combined with their code. steps.doc Shows documentation for step definitions. steps.usage Shows how step definitions are used by steps. tags Shows tags (and how often they are used). diff --git a/features/formatter.steps_code.feature b/features/formatter.steps_code.feature new file mode 100644 index 000000000..89099bb01 --- /dev/null +++ b/features/formatter.steps_code.feature @@ -0,0 +1,425 @@ +@sequential +Feature: StepWithCode Formatter + + As a tester + I want to know which python code is executed + when I run my scenario steps in a Gherkin file (aka: Feature file) + So that I can better understand how everything fits together + And that I can inspected the step-definition source code parts + + NOTE: Primarily intended for dry-run mode. + + Rule: Good Cases + Background: Feature Setup + Given a new working directory + And a file named "features/steps/passing_steps.py" with: + """ + from behave import step + + @step(u'{word:w} step passes') + def step_passes(ctx, word): + pass + """ + And an empty file named "example4me/__init__.py" + And a file named "example4me/calculator.py" with: + """ + class Calculator(object): + def __init__(self, initial_value=0): + self.initial_value = initial_value + self.result = initial_value + + def clear(self): + self.initial_value = 0 + self.result = 0 + + def add(self, number): + self.result += number + """ + And a file named "example4me/calculator_steps.py" with: + """ + from behave import given, when, then, step, register_type + from behave.parameter_type import parse_number + from example4me.calculator import Calculator + from assertpy import assert_that + + register_type(Number=parse_number) + + @given(u'I use the calculator') + def step_given_reset_calculator(ctx): + ctx.calculator = Calculator() + + @when(u'I add the number "{number:Number}"') + def step_when_add_number(ctx, number): + ctx.calculator.add(number) + + @then(u'the calculator shows "{expected:Number}" as result') + def step_when_add_number(ctx, expected): + assert_that(ctx.calculator.result).is_equal_to(expected) + """ + And a file named "features/steps/use_calculator_steps.py" with: + """ + import example4me.calculator_steps + """ + And a file named "features/calculator.feature" with: + """ + Feature: Calculator + Scenario: C1 + Given I use the calculator + When I add the number "1" + And I add the number "2" + Then the calculator shows "3" as result + """ + + Scenario: Use StepsWithCode formatter in dry-run mode + When I run "behave -f steps.code --dry-run features/calculator.feature" + Then it should pass with: + """ + Feature: Calculator + Scenario: C1 + Given I use the calculator + # -- CODE: example4me/calculator_steps.py:8 + @given(u'I use the calculator') + def step_given_reset_calculator(ctx): + ctx.calculator = Calculator() + + When I add the number "1" + # -- CODE: example4me/calculator_steps.py:12 + @when(u'I add the number "{number:Number}"') + def step_when_add_number(ctx, number): + ctx.calculator.add(number) + + And I add the number "2" + # -- CODE: example4me/calculator_steps.py:12 + @when(u'I add the number "{number:Number}"') + def step_when_add_number(ctx, number): + ctx.calculator.add(number) + + Then the calculator shows "3" as result + # -- CODE: example4me/calculator_steps.py:16 + @then(u'the calculator shows "{expected:Number}" as result') + def step_when_add_number(ctx, expected): + assert_that(ctx.calculator.result).is_equal_to(expected) + """ + But note that "each steps contains a CODE section that shows the step implementation." + + Scenario: Use StepsWithCode formatter in normal mode + When I run "behave -f steps.code features/calculator.feature" + Then it should pass with: + """ + Feature: Calculator + Scenario: C1 + Given I use the calculator ... passed + # -- CODE: example4me/calculator_steps.py:8 + @given(u'I use the calculator') + def step_given_reset_calculator(ctx): + ctx.calculator = Calculator() + + When I add the number "1" ... passed + # -- CODE: example4me/calculator_steps.py:12 + @when(u'I add the number "{number:Number}"') + def step_when_add_number(ctx, number): + ctx.calculator.add(number) + + And I add the number "2" ... passed + # -- CODE: example4me/calculator_steps.py:12 + @when(u'I add the number "{number:Number}"') + def step_when_add_number(ctx, number): + ctx.calculator.add(number) + + Then the calculator shows "3" as result ... passed + # -- CODE: example4me/calculator_steps.py:16 + @then(u'the calculator shows "{expected:Number}" as result') + def step_when_add_number(ctx, expected): + assert_that(ctx.calculator.result).is_equal_to(expected) + """ + But note that "each steps contains a CODE section that shows the step implementation" + And note that "the step results are shown for each step (after execution)" + + Scenario: Use StepsWithCode formatter with step.table + Given a file named "features/steps/table_steps.py" with: + """ + from behave import given + from assertpy import assert_that + + class Person(object): + def __init__(self, name, role=None): + self.name = name + self.role = role + + @given(u'a company with the following persons') + def step_given_company_with_persons(ctx): + assert_that(ctx.table).is_not_none() + company_persons = [] + for row in ctx.table.rows: + name = row["Name"] + role = row["Role"] + person = Person(name, role) + company_persons.append(person) + ctx.company_persons = company_persons + """ + And a file named "features/table_steps.feature" with: + """ + Feature: step.table + Scenario: S1 + Given a company with the following persons: + | Name | Role | + | Alice | CEO | + | Bob | Developer | + """ + When I run "behave -f steps.code features/table_steps.feature" + Then it should pass with: + """ + Feature: step.table + Scenario: S1 + Given a company with the following persons ... passed + | Name | Role | + | Alice | CEO | + | Bob | Developer | + # -- CODE: features/steps/table_steps.py:9 + @given(u'a company with the following persons') + def step_given_company_with_persons(ctx): + assert_that(ctx.table).is_not_none() + company_persons = [] + for row in ctx.table.rows: + name = row["Name"] + role = row["Role"] + person = Person(name, role) + company_persons.append(person) + ctx.company_persons = company_persons + """ + But note that "each steps contains a CODE section that shows the step implementation" + And note that "the step results are shown for each step (after execution)" + + + Scenario: Use StepsWithCode formatter with step.text + Given a file named "features/steps/text_steps.py" with: + """ + from behave import given + from io import open + + @given(u'a special file named "{filename}" with') + def step_given_file_named_with_contents(ctx, filename): + with open(filename, "w+", encoding="UTF-8") as f: + f.write(ctx.text) + + # -- ALTERNATIVE: + # filename_path = Path(filename) + # filename_path.write_text(ctx.text) + """ + And a file named "features/text_steps.feature" with: + ''' + Feature: step.text + Scenario: T1 + Given a special file named "example.some_file.txt" with: + """ + Lorem ipsum. + Ipsum lorem ... + """ + ''' + When I run "behave -f steps.code features/text_steps.feature" + Then it should pass with: + ''' + Feature: step.text + Scenario: T1 + Given a special file named "example.some_file.txt" with ... passed + """ + Lorem ipsum. + Ipsum lorem ... + """ + # -- CODE: features/steps/text_steps.py:4 + @given(u'a special file named "{filename}" with') + def step_given_file_named_with_contents(ctx, filename): + with open(filename, "w+", encoding="UTF-8") as f: + f.write(ctx.text) + ''' + But note that "each steps contains a CODE section that shows the step implementation" + And note that "the step results are shown for each step (after execution)" + + + Scenario: Use StepsWithCode formatter with rule (in normal mode) + Given a file named "features/with_rule.feature" with: + """ + Feature: Calculator with Rule + Rule: Some + Scenario: R1 + Given I use the calculator + When I add the number "42" + Then the calculator shows "42" as result + """ + When I run "behave -f steps.code features/with_rule.feature" + Then it should pass with: + """ + Feature: Calculator with Rule + Rule: Some + Scenario: R1 + Given I use the calculator ... passed + # -- CODE: example4me/calculator_steps.py:8 + @given(u'I use the calculator') + def step_given_reset_calculator(ctx): + ctx.calculator = Calculator() + + When I add the number "42" ... passed + # -- CODE: example4me/calculator_steps.py:12 + @when(u'I add the number "{number:Number}"') + def step_when_add_number(ctx, number): + ctx.calculator.add(number) + + Then the calculator shows "42" as result ... passed + # -- CODE: example4me/calculator_steps.py:16 + @then(u'the calculator shows "{expected:Number}" as result') + def step_when_add_number(ctx, expected): + assert_that(ctx.calculator.result).is_equal_to(expected) + """ + But note that "each step is indented correctly" + And note that "each step code-section is indented correctly" + + + Scenario: Use StepsWithCode formatter with steps that have documentation + INTENTION: step-function.__doc__ is not shown in code-section. + + Given a file named "features/steps/documented_steps.py" with: + ''' + from behave import given, when, then + from assertpy import assert_that + + @given(u'a person named "{name}"') + def step_given_person_named(ctx, name): + """ + __DOCSTRING_HERE: is not shown + """ + # -- CODE: STARTS HERE. + ctx.person_name = name + + @then(u'I have met "{expected}"') + def step_then_met_person(ctx, expected): + """__DOCSTRING_HERE: is not shown""" + # -- CODE: STARTS HERE. + assert_that(ctx.person_name).is_equal_to(expected) + ''' + Given a file named "features/with_rule.feature" with: + """ + Feature: Using steps with docstring (not shown) + Scenario: D1 + Given a person named "Alice" + Then I have met "Alice" + """ + When I run "behave -f steps.code features/with_rule.feature" + Then it should pass with: + """ + Feature: Using steps with docstring (not shown) + Scenario: D1 + Given a person named "Alice" ... passed + # -- CODE: features/steps/documented_steps.py:4 + @given(u'a person named "{name}"') + def step_given_person_named(ctx, name): + # -- CODE: STARTS HERE. + ctx.person_name = name + Then I have met "Alice" ... passed + # -- CODE: features/steps/documented_steps.py:12 + @then(u'I have met "{expected}"') + def step_then_met_person(ctx, expected): + # -- CODE: STARTS HERE. + assert_that(ctx.person_name).is_equal_to(expected) + """ + But note that "the step-function docstring is not shown in the code-section" + + + Rule: Bad Cases + Background: Feature Setup + Given a new working directory + And a file named "features/steps/passing_steps.py" with: + """ + from behave import step + + @step(u'{word:w} step passes') + def step_passes(ctx, word): + pass + """ + And a file named "features/passing.feature" with: + """ + Feature: Passing steps + Scenario: P1 + Given a step passes + When another step passes + """ + + Scenario: Use StepsWithCode formatter if some step fails + Given a file named "features/steps/failing_steps.py" with: + """ + from behave import step + from assertpy import assert_that + + @step(u'{word:w} step fails') + def step_fails(ctx, word): + assert_that(word).is_equal_to("__ALWAYS_FAILS__") + """ + Given a file named "features/failing.feature" with: + """ + Feature: Failing step + @problematic + Scenario: F1 with failing step + Given a step passes + When another step fails + Then another step passes + """ + When I run "behave -f steps.code features/failing.feature" + Then it should fail with: + """ + 0 scenarios passed, 1 failed, 0 skipped + 1 step passed, 1 failed, 1 skipped, 0 undefined + """ + And the command output should contain: + """ + Feature: Failing step + Scenario: F1 with failing step + Given a step passes ... passed + # -- CODE: features/steps/passing_steps.py:3 + @step(u'{word:w} step passes') + def step_passes(ctx, word): + pass + + When another step fails ... failed + # -- CODE: features/steps/failing_steps.py:4 + @step(u'{word:w} step fails') + def step_fails(ctx, word): + assert_that(word).is_equal_to("__ALWAYS_FAILS__") + + Failing scenarios: + features/failing.feature:3 F1 with failing step + """ + But note that "the failing step is shown with code-section" + And note that "the next steps after the failing step are not shown" + + + Scenario: Use StepsWithCode formatter if some steps are undefined + Given a file named "features/undefined.feature" with: + """ + Feature: Undefined steps + @problematic + Scenario: With undefined step + Given a step passes + When a step is UNDEFINED + Then another step passes + """ + When I run "behave -f steps.code features/undefined.feature" + Then it should fail with: + """ + 0 scenarios passed, 1 failed, 0 skipped + 1 step passed, 0 failed, 1 skipped, 1 undefined + """ + And the command output should contain: + """ + Feature: Undefined steps + Scenario: With undefined step + Given a step passes ... passed + # -- CODE: features/steps/passing_steps.py:3 + @step(u'{word:w} step passes') + def step_passes(ctx, word): + pass + + When a step is UNDEFINED ... undefined + + Failing scenarios: + features/undefined.feature:3 With undefined step + """ + But note that "the undefined step is shown without code-section" From 93e121861c3bd4eb60423999a22493b9958c33e9 Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 9 Jun 2024 18:05:23 +0200 Subject: [PATCH 238/240] docstrings: Use codespell to detect spelling errors CORRECTED: * behave/*.py * behave4cmd0/*.py * tests/*.py --- behave/_types.py | 13 ++- behave/capture.py | 2 +- behave/contrib/formatter_missing_steps.py | 4 +- behave/fixture.py | 4 +- behave/formatter/_builtins.py | 4 +- behave/formatter/bad_steps.py | 4 +- behave/formatter/progress.py | 4 +- behave/formatter/steps.py | 4 +- behave/matchers.py | 2 +- behave/model.py | 10 +- behave/model_core.py | 2 +- behave/parser.py | 4 +- behave/tag_expression/__init__.py | 2 +- behave4cmd0/step_util.py | 4 +- behave4cmd0/textutil.py | 2 +- features/formatter.bad_steps.feature | 111 ---------------------- features/formatter.help.feature | 3 +- features/formatter.steps_bad.feature | 111 ++++++++++++++++++++++ issue.features/issue0031.feature | 1 - py.requirements/docs.txt | 8 ++ tests/api/_test_async_step34.py | 2 +- tests/api/test_async_step.py | 4 +- tests/issues/test_issue0449.py | 2 +- tests/issues/test_issue0495.py | 2 +- tests/unit/test_ansi_escapes.py | 2 +- tests/unit/test_parser.py | 13 ++- tests/unit/test_textutil.py | 6 +- 27 files changed, 173 insertions(+), 157 deletions(-) delete mode 100644 features/formatter.bad_steps.feature create mode 100644 features/formatter.steps_bad.feature diff --git a/behave/_types.py b/behave/_types.py index 629f3a450..4fe1edef4 100644 --- a/behave/_types.py +++ b/behave/_types.py @@ -11,7 +11,8 @@ class Unknown(object): - """Placeholder for unknown/missing information, distinguishable from None. + """ + Placeholder for unknown/missing information, distinguishable from None. .. code-block:: python @@ -24,9 +25,10 @@ class Unknown(object): class ExceptionUtil(object): - """Provides a utility class for accessing/modifying exception information. + """ + Provides a utility class for accessing/modifying exception information. - .. seealso:: PEP-3134 Chained excpetions + .. seealso:: PEP-3134 Chained exceptions """ # pylint: disable=no-init @@ -66,10 +68,11 @@ def describe(cls, exception, use_traceback=False, prefix=""): class ChainedExceptionUtil(ExceptionUtil): - """Provides a utility class for accessing/modifying exception information + """ + Provides a utility class for accessing/modifying exception information related to chained exceptions. - .. seealso:: PEP-3134 Chained excpetions + .. seealso:: PEP-3134 Chained exceptions """ # pylint: disable=no-init diff --git a/behave/capture.py b/behave/capture.py index d0a3b7bf4..78b378f07 100644 --- a/behave/capture.py +++ b/behave/capture.py @@ -68,7 +68,7 @@ def add(self, captured): return self def make_report(self): - """Makes a detailled report of the captured output data. + """Makes a detailed report of the captured output data. :returns: Report as string. """ diff --git a/behave/contrib/formatter_missing_steps.py b/behave/contrib/formatter_missing_steps.py index e830e75e5..b6ef36e97 100644 --- a/behave/contrib/formatter_missing_steps.py +++ b/behave/contrib/formatter_missing_steps.py @@ -40,8 +40,8 @@ class MissingStepsFormatter(StepsUsageFormatter): {step_snippets} """ - name = "missing-steps" - description = "Writes implementation for missing step definitions." + name = "steps.missing" + description = "Shows undefined/missing steps definitions, implements them." template = STEP_MODULE_TEMPLATE scope = "behave.formatter.missing_steps" diff --git a/behave/fixture.py b/behave/fixture.py index 51e18bfb5..74e444df0 100644 --- a/behave/fixture.py +++ b/behave/fixture.py @@ -188,7 +188,7 @@ def use_fixture(fixture_func, context, *fixture_args, **fixture_kwargs): """Use fixture (function) and call it to perform its setup-part. The fixture-function is similar to a :func:`contextlib.contextmanager` - (and contains a yield-statement to seperate setup and cleanup part). + (and contains a yield-statement to separate setup and cleanup part). If it contains a yield-statement, it registers a context-cleanup function to the context object to perform the fixture-cleanup at the end of the current scoped when the context layer is removed @@ -292,7 +292,7 @@ def use_composite_fixture_with(context, fixture_funcs_with_params): safe-cleanup is needed even if an setup-fixture-error occurs. This function ensures that fixture-cleanup is performed - for every fixture that was setup before the setup-error occured. + for every fixture that was setup before the setup-error occurred. .. code-block:: python diff --git a/behave/formatter/_builtins.py b/behave/formatter/_builtins.py index f2a40701c..aac5546fc 100644 --- a/behave/formatter/_builtins.py +++ b/behave/formatter/_builtins.py @@ -26,13 +26,15 @@ ("tags.location", "behave.formatter.tags:TagsLocationFormatter"), ("steps", "behave.formatter.steps:StepsFormatter"), ("steps.doc", "behave.formatter.steps:StepsDocFormatter"), + ("steps.bad", "behave.formatter.bad_steps:BadStepsFormatter"), ("steps.catalog", "behave.formatter.steps:StepsCatalogFormatter"), ("steps.code", "behave.formatter.steps_code:StepWithCodeFormatter"), + ("steps.missing", "behave.contrib.formatter_missing_steps:MissingStepsFormatter"), ("steps.usage", "behave.formatter.steps:StepsUsageFormatter"), ("sphinx.steps", "behave.formatter.sphinx_steps:SphinxStepsFormatter"), - ("bad_steps", "behave.formatter.bad_steps:BadStepsFormatter"), ] + # ----------------------------------------------------------------------------- # FUNCTIONS: # ----------------------------------------------------------------------------- diff --git a/behave/formatter/bad_steps.py b/behave/formatter/bad_steps.py index 2b404b6ae..a39748960 100644 --- a/behave/formatter/bad_steps.py +++ b/behave/formatter/bad_steps.py @@ -29,8 +29,8 @@ class BadStepsFormatter(Formatter): Formatter that prints BAD_STEP_DEFINITIONS if any exist at the end of the test-run. """ - name = "bad_steps" - description = "Show BAD STEP-DEFINITION(s) (if any exist)" + name = "steps.bad" + description = "Shows BAD STEP-DEFINITION(s) (if any exist)." PRINTER_CLASS = BadStepDefinitionCollector def __init__(self, stream_opener, config): diff --git a/behave/formatter/progress.py b/behave/formatter/progress.py index 3b471edc8..bbfd4cd37 100644 --- a/behave/formatter/progress.py +++ b/behave/formatter/progress.py @@ -261,7 +261,7 @@ def scenario(self, scenario): # self.stream.write("\n") # -- PROGRESS FORMATTER DETAILS: - # @overriden + # @override def report_feature_completed(self): # -- SKIP: self.report_feature_duration() has_scenarios = self.current_feature and self.current_scenario @@ -294,6 +294,6 @@ def report_failures(self): unicode_errors += 1 if unicode_errors: - msg = u"HINT: %d unicode errors occured during failure reporting.\n" + msg = u"HINT: %d unicode errors occurred during failure reporting.\n" self.stream.write(msg % unicode_errors) self.stream.flush() diff --git a/behave/formatter/steps.py b/behave/formatter/steps.py index 969cb103a..5d9bb3003 100644 --- a/behave/formatter/steps.py +++ b/behave/formatter/steps.py @@ -275,8 +275,8 @@ class StepsCatalogFormatter(StepsDocFormatter): step definitions. The primary purpose is to provide help for a test writer. In order to ease work for non-programmer testers, the technical details of - the steps (i.e. function name, source location) are ommited and the - steps are shown as they would apprear in a feature file (no noisy '@', + the steps (i.e. function name, source location) are omitted and the + steps are shown as they would appear in a feature file (no noisy '@', or '(', etc.). Also, the output is sorted by step type (Given, When, Then) diff --git a/behave/matchers.py b/behave/matchers.py index c23dc250c..3e571be76 100644 --- a/behave/matchers.py +++ b/behave/matchers.py @@ -130,7 +130,7 @@ class MatchWithError(Match): """Match class when error occur during step-matching REASON: - * Type conversion error occured. + * Type conversion error occurred. * ... """ def __init__(self, func, error): diff --git a/behave/model.py b/behave/model.py index fa365f2f9..340816e71 100644 --- a/behave/model.py +++ b/behave/model.py @@ -144,7 +144,7 @@ class ScenarioContainer(TagAndStatusStatement, Replayable): .. attribute:: hook_failed - Indicates if a hook failure occured while running this feature. + Indicates if a hook failure occurred while running this feature. .. attribute:: filename @@ -503,7 +503,7 @@ class Feature(ScenarioContainer): .. attribute:: hook_failed - Indicates if a hook failure occured while running this feature. + Indicates if a hook failure occurred while running this feature. .. versionadded:: 1.2.6 @@ -647,7 +647,7 @@ class Rule(ScenarioContainer): .. attribute:: hook_failed - Indicates if a hook failure occured while running this feature. + Indicates if a hook failure occurred while running this feature. .. versionadded:: 1.2.6 @@ -896,7 +896,7 @@ class Scenario(TagAndStatusStatement, Replayable): .. attribute:: hook_failed - Indicates if a hook failure occured while running this scenario. + Indicates if a hook failure occurred while running this scenario. .. versionadded:: 1.2.6 @@ -1693,7 +1693,7 @@ class Step(BasicStatement, Replayable): .. attribute:: hook_failed - Indicates if a hook failure occured while running this step. + Indicates if a hook failure occurred while running this step. .. versionadded:: 1.2.6 diff --git a/behave/model_core.py b/behave/model_core.py index 725c3020e..ffd123f83 100644 --- a/behave/model_core.py +++ b/behave/model_core.py @@ -44,7 +44,7 @@ class Status(Enum): * executing: Marks the steps during execution (used in a formatter) .. versionadded:: 1.2.6 - Superceeds string-based status values. + Supersedes string-based status values. """ untested = 0 skipped = 1 diff --git a/behave/parser.py b/behave/parser.py index e468f5c09..313464f4f 100644 --- a/behave/parser.py +++ b/behave/parser.py @@ -354,7 +354,7 @@ def ask_parse_failure_oracle(self, line): Oracle, oracle, ... what went wrong? - :param line: Text line where parse failure occured (as string). + :param line: Text line where parse failure occurred (as string). :return: Reason (as string) if an explanation is found. Otherwise, empty string or None. """ @@ -699,7 +699,7 @@ def action_table(self, line): self.feature.filename, self.line) # -- SUPPORT: Escaped-pipe(s) in Gherkin cell values. - # Search for pipe(s) that are not preceeded with an escape char. + # Search for pipe(s) that are not preceded with an escape char. cells = [cell.replace("\\|", "|").strip() for cell in re.split(r"(? bytes, encoded stream output. + # -- MAYBE: command.output => bytes, encoded stream output. text = codecs.decode(text) lines = [ line.strip() for line in text.splitlines() if line.strip() ] return "\n".join(lines) diff --git a/features/formatter.bad_steps.feature b/features/formatter.bad_steps.feature deleted file mode 100644 index 26a717e1b..000000000 --- a/features/formatter.bad_steps.feature +++ /dev/null @@ -1,111 +0,0 @@ -@use.with_python.min_version=3.11 -Feature: Bad Steps Formatter (aka: Bad Step Definitions Formatter) - - As a test writer - I want a summary if any bad step definitions exist - So that I have an overview what to fix (and look after). - - . DEFINITION: BAD STEP DEFINITION - . * Is a step definition (aka: step matcher) - . where the regular expression compile step fails - . - . CAUSED BY: More checks/enforcements in the "re" module (since: Python >= 3.11). - . - . BEST-PRACTICE: Use BadStepsFormatter in dry-run mode, like: - . - . behave --dry-run -f bad_steps features/ - - - Background: - Given a new working directory - And a file named "features/steps/use_behave4cmd.py" with: - """ - import behave4cmd0.passing_steps - import behave4cmd0.note_steps - """ - And a file named "features/steps/bad_steps1.py" with: - """ - from behave import given, when, then, register_type, use_step_matcher - import parse - - # -- HINT: TYPE-CONVERTER with BAD REGEX PATTERN caused by "(?i)" parts - @parse.with_pattern(r"(?P(?i)ON|(?i)OFF)", regex_group_count=1) - def parse_bad_bool(text): - return text == "ON" - - use_step_matcher("parse") - register_type(BadBool=parse_bad_bool) - - # -- BAD STEP DEFINITION 1: - @given('the bad light is switched {state:BadBool}') - def step_bad_given_light_is_switched_on_off(ctx, state): - pass - """ - And a file named "features/steps/bad_steps2.py" with: - """ - from behave import step, use_step_matcher - - use_step_matcher("re") - - # -- BAD STEP DEFINITION 2: Caused by "(?i)" parts - @step('some bad light is switched (?P(?i)ON|(?i)OFF)') - def step_bad_light_is_switched_using_re(ctx, status): - pass - - @step('good light is switched (?PON|OFF)') - def step_good_light_is_switched_using_re(ctx, status): - pass - """ - And a file named "features/one.feature" with: - """ - Feature: F1 - Scenario: S1 - Given a step passes - When another step passes - """ - - Scenario: Use "bad_steps" formatter in dry-run mode - When I run "behave --dry-run -f bad_steps features/" - Then the command output should contain: - """ - BAD STEP-DEFINITIONS[2]: - - BAD-STEP-DEFINITION: @given('the bad light is switched {state:BadBool}') - LOCATION: features/steps/bad_steps1.py:13 - - BAD-STEP-DEFINITION: @step('^some bad light is switched (?P(?i)ON|(?i)OFF)$') - LOCATION: features/steps/bad_steps2.py:6 - """ - But note that "the formatter shows a list of BAD STEP DEFINITIONS" - - Scenario: Use "bad_steps" formatter in normal mode - When I run "behave -f bad_steps features/" - Then the command output should contain: - """ - BAD STEP-DEFINITIONS[2]: - - BAD-STEP-DEFINITION: @given('the bad light is switched {state:BadBool}') - LOCATION: features/steps/bad_steps1.py:13 - - BAD-STEP-DEFINITION: @step('^some bad light is switched (?P(?i)ON|(?i)OFF)$') - LOCATION: features/steps/bad_steps2.py:6 - - 1 feature passed, 0 failed, 0 skipped - """ - But note that "the formatter shows a list of BAD STEP DEFINITIONS" - - Scenario: Use "bad_steps" formatter with another formatter - When I run "behave -f bad_steps -f plain features/" - Then the command output should contain: - """ - Feature: F1 - - Scenario: S1 - Given a step passes ... passed - When another step passes ... passed - - BAD STEP-DEFINITIONS[2]: - - BAD-STEP-DEFINITION: @given('the bad light is switched {state:BadBool}') - LOCATION: features/steps/bad_steps1.py:13 - - BAD-STEP-DEFINITION: @step('^some bad light is switched (?P(?i)ON|(?i)OFF)$') - LOCATION: features/steps/bad_steps2.py:6 - - 1 feature passed, 0 failed, 0 skipped - """ - But note that "the BAD_STEPS formatter output is shown at the end" diff --git a/features/formatter.help.feature b/features/formatter.help.feature index 8424242d6..6c34c0684 100644 --- a/features/formatter.help.feature +++ b/features/formatter.help.feature @@ -25,7 +25,6 @@ Feature: Help Formatter And the command output should contain: """ AVAILABLE FORMATTERS: - bad_steps Show BAD STEP-DEFINITION(s) (if any exist) json JSON dump of test run json.pretty JSON dump of test run (human readable) null Provides formatter that does not output anything. @@ -37,9 +36,11 @@ Feature: Help Formatter rerun Emits scenario file locations of failing scenarios sphinx.steps Generate sphinx-based documentation for step definitions. steps Shows step definitions (step implementations). + steps.bad Shows BAD STEP-DEFINITION(s) (if any exist). steps.catalog Shows non-technical documentation for step definitions. steps.code Shows executed steps combined with their code. steps.doc Shows documentation for step definitions. + steps.missing Shows undefined/missing steps definitions, implements them. steps.usage Shows how step definitions are used by steps. tags Shows tags (and how often they are used). tags.location Shows tags and the location where they are used. diff --git a/features/formatter.steps_bad.feature b/features/formatter.steps_bad.feature new file mode 100644 index 000000000..b449a77d6 --- /dev/null +++ b/features/formatter.steps_bad.feature @@ -0,0 +1,111 @@ +@use.with_python.min_version=3.11 +Feature: Bad Steps Formatter (aka: Bad Step Definitions Formatter) + + As a test writer + I want a summary if any bad step definitions exist + So that I have an overview what to fix (and look after). + + . DEFINITION: BAD STEP DEFINITION + . * Is a step definition (aka: step matcher) + . where the regular expression compile step fails + . + . CAUSED BY: More checks/enforcements in the "re" module (since: Python >= 3.11). + . + . BEST-PRACTICE: Use BadStepsFormatter in dry-run mode, like: + . + . behave --dry-run -f steps.bad features/ + + + Background: + Given a new working directory + And a file named "features/steps/use_behave4cmd.py" with: + """ + import behave4cmd0.passing_steps + import behave4cmd0.note_steps + """ + And a file named "features/steps/bad_steps1.py" with: + """ + from behave import given, when, then, register_type, use_step_matcher + import parse + + # -- HINT: TYPE-CONVERTER with BAD REGEX PATTERN caused by "(?i)" parts + @parse.with_pattern(r"(?P(?i)ON|(?i)OFF)", regex_group_count=1) + def parse_bad_bool(text): + return text == "ON" + + use_step_matcher("parse") + register_type(BadBool=parse_bad_bool) + + # -- BAD STEP DEFINITION 1: + @given('the bad light is switched {state:BadBool}') + def step_bad_given_light_is_switched_on_off(ctx, state): + pass + """ + And a file named "features/steps/bad_steps2.py" with: + """ + from behave import step, use_step_matcher + + use_step_matcher("re") + + # -- BAD STEP DEFINITION 2: Caused by "(?i)" parts + @step('some bad light is switched (?P(?i)ON|(?i)OFF)') + def step_bad_light_is_switched_using_re(ctx, status): + pass + + @step('good light is switched (?PON|OFF)') + def step_good_light_is_switched_using_re(ctx, status): + pass + """ + And a file named "features/one.feature" with: + """ + Feature: F1 + Scenario: S1 + Given a step passes + When another step passes + """ + + Scenario: Use "bad_steps" formatter in dry-run mode + When I run "behave --dry-run -f steps.bad features/" + Then the command output should contain: + """ + BAD STEP-DEFINITIONS[2]: + - BAD-STEP-DEFINITION: @given('the bad light is switched {state:BadBool}') + LOCATION: features/steps/bad_steps1.py:13 + - BAD-STEP-DEFINITION: @step('^some bad light is switched (?P(?i)ON|(?i)OFF)$') + LOCATION: features/steps/bad_steps2.py:6 + """ + But note that "the formatter shows a list of BAD STEP DEFINITIONS" + + Scenario: Use "bad_steps" formatter in normal mode + When I run "behave -f steps.bad features/" + Then the command output should contain: + """ + BAD STEP-DEFINITIONS[2]: + - BAD-STEP-DEFINITION: @given('the bad light is switched {state:BadBool}') + LOCATION: features/steps/bad_steps1.py:13 + - BAD-STEP-DEFINITION: @step('^some bad light is switched (?P(?i)ON|(?i)OFF)$') + LOCATION: features/steps/bad_steps2.py:6 + + 1 feature passed, 0 failed, 0 skipped + """ + But note that "the formatter shows a list of BAD STEP DEFINITIONS" + + Scenario: Use "bad_steps" formatter with another formatter + When I run "behave -f steps.bad -f plain features/" + Then the command output should contain: + """ + Feature: F1 + + Scenario: S1 + Given a step passes ... passed + When another step passes ... passed + + BAD STEP-DEFINITIONS[2]: + - BAD-STEP-DEFINITION: @given('the bad light is switched {state:BadBool}') + LOCATION: features/steps/bad_steps1.py:13 + - BAD-STEP-DEFINITION: @step('^some bad light is switched (?P(?i)ON|(?i)OFF)$') + LOCATION: features/steps/bad_steps2.py:6 + + 1 feature passed, 0 failed, 0 skipped + """ + But note that "the BAD_STEPS formatter output is shown at the end" diff --git a/issue.features/issue0031.feature b/issue.features/issue0031.feature index 9be8a8b30..62aabb843 100644 --- a/issue.features/issue0031.feature +++ b/issue.features/issue0031.feature @@ -8,7 +8,6 @@ Feature: Issue #31 "behave --format help" raises an error And the command output should contain: """ AVAILABLE FORMATTERS: - bad_steps Show BAD STEP-DEFINITION(s) (if any exist) json JSON dump of test run json.pretty JSON dump of test run (human readable) null Provides formatter that does not output anything. diff --git a/py.requirements/docs.txt b/py.requirements/docs.txt index fbaeb7c8b..5aca72bfe 100644 --- a/py.requirements/docs.txt +++ b/py.requirements/docs.txt @@ -43,3 +43,11 @@ sphinx-intl >= 0.9.11 sphinxcontrib-applehelp >= 1.0.8; python_version >= '3.7' sphinxcontrib-htmlhelp >= 2.0.5; python_version >= '3.7' + +# EXPERIMENTAL: +# -- DOCUMENTATION WRITING HELPERS: +# SEE: https://github.com/codespell-project/codespell +codespell >= 2.3.0; python_version >= '3.8' + +# SEE: https://github.com/amperser/proselint +proselint >= 0.14.0; python_version >= '3.8' diff --git a/tests/api/_test_async_step34.py b/tests/api/_test_async_step34.py index abf124fa8..3ea61ba64 100644 --- a/tests/api/_test_async_step34.py +++ b/tests/api/_test_async_step34.py @@ -226,7 +226,7 @@ def when_async_step_raises_exception(context): 1 / 0 # XFAIL-HERE: Raises ZeroDivisionError # pylint: enable=import-outside-toplevel, unused-argument - # -- RUN ASYNC-STEP: Verify that raised exeception is detected. + # -- RUN ASYNC-STEP: Verify that raised exception is detected. context = Context(runner=Runner(config={})) with pytest.raises(ZeroDivisionError): when_async_step_raises_exception(context) diff --git a/tests/api/test_async_step.py b/tests/api/test_async_step.py index a45698936..ca346e7d9 100644 --- a/tests/api/test_async_step.py +++ b/tests/api/test_async_step.py @@ -15,12 +15,12 @@ _python_version = sys.version_info[:2] if _python_version >= (3, 4): # -- PROTECTED-IMPORT: - # Older Python version have problems with grammer extensions (yield-from). + # Older Python version have problems with grammar extensions (yield-from). # from ._test_async_step34 import TestAsyncStepDecorator34 # from ._test_async_step34 import TestAsyncContext, TestAsyncStepRun34 from ._test_async_step34 import * # noqa: F403 if _python_version >= (3, 5): # -- PROTECTED-IMPORT: - # Older Python version have problems with grammer extensions (async/await). + # Older Python version have problems with grammar extensions (async/await). # from ._test_async_step35 import TestAsyncStepDecorator35, TestAsyncStepRun35 from ._test_async_step35 import * # noqa: F403 diff --git a/tests/issues/test_issue0449.py b/tests/issues/test_issue0449.py index 5a9ef6b67..90196b2a3 100644 --- a/tests/issues/test_issue0449.py +++ b/tests/issues/test_issue0449.py @@ -19,7 +19,7 @@ def foo(stop): And I also have UTF-8 as my console charset. Running this code leads to "Assertion Failed: 'ascii' codec can't encode characters in position 0-5: ordinal not in range(128)" error. -That is becase behave.textutil.text returns six.text_type(e) where 'e' is exception (https://github.com/behave/behave/blob/master/behave/textutil.py#L83). +That is because behave.textutil.text returns six.text_type(e) where 'e' is exception (https://github.com/behave/behave/blob/master/behave/textutil.py#L83). Changing line 83 to six.text_type(value) solves this issue. """ diff --git a/tests/issues/test_issue0495.py b/tests/issues/test_issue0495.py index baba18688..889214d97 100644 --- a/tests/issues/test_issue0495.py +++ b/tests/issues/test_issue0495.py @@ -40,7 +40,7 @@ class SimpleContext(object): pass # ----------------------------------------------------------------------------- @pytest.mark.parametrize("log_message", [ u"Hello Alice", # case: unproblematic (GOOD CASE) - u"Ärgernis ist überall", # case: unicode-string + u"Ärgernis ist überall", # case: unicode-string # codespell:ignore ist "Ärgernis", # case: byte-string (use encoding-declaration above) ]) def test_issue(log_message): diff --git a/tests/unit/test_ansi_escapes.py b/tests/unit/test_ansi_escapes.py index 4fb78c291..186bc7375 100644 --- a/tests/unit/test_ansi_escapes.py +++ b/tests/unit/test_ansi_escapes.py @@ -50,7 +50,7 @@ def colorize_text(text, colors=None): # TEST SUITE # -------------------------------------------------------------------------- def test_module_setup(): - """Ensure that the module setup (aliases, escapes) occured.""" + """Ensure that the module setup (aliases, escapes) occurred.""" # colors_count = len(ansi_escapes.colors) aliases_count = len(ansi_escapes.aliases) escapes_count = len(ansi_escapes.escapes) diff --git a/tests/unit/test_parser.py b/tests/unit/test_parser.py index cf994ff5a..6c7d8d1c5 100644 --- a/tests/unit/test_parser.py +++ b/tests/unit/test_parser.py @@ -1280,12 +1280,14 @@ def test_parses_french(self): ]) def test_parses_french_multi_word(self): + # codespell:ignore donné doc = u""" -Fonctionnalit\xe9: testing stuff +Fonctionnalité: testing stuff Oh my god, it's full of stuff... - Sc\xe9nario: test stuff - Etant donn\xe9 I am testing stuff + Scénario: test stuff + # codespell:ignore donné + Etant donné I am testing stuff Alors it should work """.lstrip() feature = parse_feature(doc, 'fr') @@ -1294,9 +1296,10 @@ def test_parses_french_multi_word(self): assert len(feature.scenarios) == 1 assert feature.scenarios[0].name == "test stuff" + # codespell:ignore donné assert_compare_steps(feature.scenarios[0].steps, [ - ('given', u'Etant donn\xe9', 'I am testing stuff', None, None), - ('then', 'Alors', 'it should work', None, None), + ("given", u"Etant donné", u"I am testing stuff", None, None), + ("then", u"Alors", u"it should work", None, None), ]) test_parses_french_multi_word.go = 1 diff --git a/tests/unit/test_textutil.py b/tests/unit/test_textutil.py index 3ffab3cd6..56867dfd1 100644 --- a/tests/unit/test_textutil.py +++ b/tests/unit/test_textutil.py @@ -231,20 +231,20 @@ def test_text__with_assert_failed_and_unicode_message(self, message): def test_text__with_assert_failed_and_bytes_message(self, message): # -- ONLY PYTHON2: Use case makes no sense for Python 3. bytes_message = message.encode(self.ENCODING) - decode_error_occured = False + decode_error_occurred = False with pytest.raises(AssertionError) as e: try: assert False, bytes_message except UnicodeDecodeError as uni_error: # -- SINCE: Python 2.7.15 - decode_error_occured = True + decode_error_occurred = True expected_decode_error = "'ascii' codec can't decode byte 0xc3 in position 0" assert expected_decode_error in str(uni_error) assert False, bytes_message.decode(self.ENCODING) # -- FOR: pytest < 5.0 # expected = u"AssertionError: %s" % message - print("decode_error_occured(ascii)=%s" % decode_error_occured) + print("decode_error_occurred(ascii)=%s" % decode_error_occurred) text2 = text(e.value) assert message in text2, "OOPS: text=%r" % text2 From 74fd144545f667c9e906f78d8422cec7e9d5066d Mon Sep 17 00:00:00 2001 From: jenisys Date: Sun, 16 Jun 2024 19:16:58 +0200 Subject: [PATCH 239/240] CI: Tweak test workflows * Split up "behave tests" in 3 parts. * Use format=progress3 * REASON: Failing tests are partly shown as GREEN in job summary. --- .github/workflows/tests-pypy27.yml | 11 ++++++----- .github/workflows/tests-windows.yml | 11 ++++++----- .github/workflows/tests.yml | 11 ++++++----- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/.github/workflows/tests-pypy27.yml b/.github/workflows/tests-pypy27.yml index 0f9247b00..b66a4faa7 100644 --- a/.github/workflows/tests-pypy27.yml +++ b/.github/workflows/tests-pypy27.yml @@ -48,11 +48,12 @@ jobs: pip install -e . - name: Run tests run: pytest - - name: Run behave tests - run: | - behave --format=progress features - behave --format=progress tools/test-features - behave --format=progress issue.features + - name: "Run behave tests: features ..." + run: behave --format=progress3 features + - name: "Run behave tests: issue.features ..." + run: behave --format=progress3 issue.features + - name: "Run behave tests: tools/test-features ..." + run: behave --format=progress3 tools/test-features - name: Upload test reports uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index e6560eff9..28e47a137 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -61,11 +61,12 @@ jobs: python -m uv pip install -e . - name: Run tests run: pytest - - name: Run behave tests - run: | - behave --format=progress features - behave --format=progress tools/test-features - behave --format=progress issue.features + - name: "Run behave tests: features ..." + run: behave --format=progress3 features + - name: "Run behave tests: issue.features ..." + run: behave --format=progress3 issue.features + - name: "Run behave tests: tools/test-features ..." + run: behave --format=progress3 tools/test-features - name: Upload test reports uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9a55e3b11..4716c7691 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -58,11 +58,12 @@ jobs: python -m uv pip install -e . - name: Run tests run: pytest - - name: Run behave tests - run: | - behave --format=progress features - behave --format=progress tools/test-features - behave --format=progress issue.features + - name: "Run behave tests: features ..." + run: behave --format=progress3 features + - name: "Run behave tests: issue.features ..." + run: behave --format=progress3 issue.features + - name: "Run behave tests: tools/test-features ..." + run: behave --format=progress3 tools/test-features - name: Upload test reports uses: actions/upload-artifact@v4 with: From 0fca1fe7c3d35cbe57dbbdbe1cc1614cf4067a4a Mon Sep 17 00:00:00 2001 From: jenisys Date: Tue, 18 Jun 2024 21:08:57 +0200 Subject: [PATCH 240/240] PROOF-OF-CONCEPT: Adding a formatter late RELATED TO: * https://github.com/behave-contrib/behave-html-pretty-formatter/issues/72 --- issue.features/issue1181.feature | 59 ++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 issue.features/issue1181.feature diff --git a/issue.features/issue1181.feature b/issue.features/issue1181.feature new file mode 100644 index 000000000..a6270c9f6 --- /dev/null +++ b/issue.features/issue1181.feature @@ -0,0 +1,59 @@ +@question +Feature: Issue #1181 -- Can I add a Formatter in the before_all() Hook + + . WARNING: + . * BEWARE: This is not a valid use case. + . * Adding another formatter from the "environment.py" file is a hack + . * You should never really need to do this. + . + . SEE ALSO: + . * https://github.com/behave-contrib/behave-html-pretty-formatter/issues/72 + + + Background: + Given a new working directory + And a file named "features/steps/use_behave4cmd_steps.py" with: + """ + from __future__ import absolute_import + import behave4cmd0.passing_steps + """ + And a file named "features/environment.py" with: + """ + from __future__ import absolute_import, print_function + from behave.formatter.base import StreamOpener + from behave.formatter.progress import ScenarioStepProgressFormatter + + def before_all(ctx): + stream_opener = StreamOpener("build/report4me.txt") + new_formatter = ScenarioStepProgressFormatter(stream_opener, ctx.config) + ctx._runner.formatters.append(new_formatter) + """ + And a file named "features/example.feature" with: + """ + Feature: Example + Scenario: E1 -- Ensure that all steps pass + Given a step passes + When another step passes + Then some step passes + + Scenario: E2 -- Now every step must pass + When some step passes + Then another step passes + """ + + + Scenario: Use new Formatter from the Environment (as POC) + When I run `behave -f plain features/example.feature` + Then it should pass with: + """ + 2 scenarios passed, 0 failed, 0 skipped + 5 steps passed, 0 failed, 0 skipped, 0 undefined + """ + And a file named "build/report4me.txt" should exist + And the file "build/report4me.txt" should contain: + """ + Example # features/example.feature + E1 -- Ensure that all steps pass ... + E2 -- Now every step must pass .. + """ + And note that "the formatter from the environment could write its report"