diff --git a/.attic/convert_i18n_yaml.py b/.attic/convert_i18n_yaml.py deleted file mode 100755 index d6a6713e4..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.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/.bumpversion.cfg b/.bumpversion.cfg index 4f2bb76df..55b264263 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,9 +1,8 @@ [bumpversion] -current_version = 1.2.7.dev2 -files = behave/version.py setup.py VERSION.txt pytest.ini .bumpversion.cfg +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} commit = False tag = False allow_dirty = True - 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/.envrc b/.envrc new file mode 100644 index 000000000..e2a9c9077 --- /dev/null +++ b/.envrc @@ -0,0 +1,27 @@ +# =========================================================================== +# PROJECT ENVIRONMENT SETUP: .envrc +# =========================================================================== +# SHELL: bash (or similiar) +# 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)" +# direnv allow . +# +# 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): +# REQUIRES: direnv >= 2.26.0 -- NEEDED FOR: dotenv_if_exists +# DISABLED: dotenv_if_exists .env +source_env_if_exists .envrc.use_venv + +# -- SETUP-PYTHON: Prepend ${HERE} to PYTHONPATH (as PRIMARY search path) +# SIMILAR TO: export PYTHONPATH="${HERE}:${PYTHONPATH}" +path_add PYTHONPATH . + +source_env_if_exists .envrc.override diff --git a/.envrc.use_venv b/.envrc.use_venv new file mode 100644 index 000000000..f65b57d33 --- /dev/null +++ b/.envrc.use_venv @@ -0,0 +1,22 @@ +# =========================================================================== +# 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. +# 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/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 000000000..b2108eb7c --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,24 @@ +# 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 + +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. + +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 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/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 000000000..330737fa1 --- /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@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + 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. + # 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@v3 + + # ℹ️ 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@v3 diff --git a/.github/workflows/tests-pypy27.yml b/.github/workflows/tests-pypy27.yml new file mode 100644 index 000000000..b66a4faa7 --- /dev/null +++ b/.github/workflows/tests-pypy27.yml @@ -0,0 +1,66 @@ +# -- 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: 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: + 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 new file mode 100644 index 000000000..28e47a137 --- /dev/null +++ b/.github/workflows/tests-windows.yml @@ -0,0 +1,84 @@ +# -- 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: + 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" + + +# -- TEST BALLOON: Fix encoding="cp1252" problems by using "UTF-8" +env: + PYTHONUTF8: 1 + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [windows-latest] + python-version: ["3.12", "3.11", "3.10"] + steps: + - uses: actions/checkout@v4 + # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: 'py.requirements/*.txt' + # -- DISABLED: + # - name: Show Python version + # run: python --version + + # -- SPEED-UP: Use "uv" to speed up installation of 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 . + - name: Run tests + run: pytest + - 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: + 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@v4 +# with: +# name: behave.reports +# path: build/behave.reports/ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..4716c7691 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,76 @@ +# -- 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: + 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: + # -- EXAMPLE: runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # PREPARED: os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest] + 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 }} + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: 'py.requirements/*.txt' + # -- DISABLED: + # - name: Show Python version + # run: python --version + + # -- SPEED-UP: Use "uv" to speed up installation of 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 . + - name: Run tests + run: pytest + - 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: + 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/.gitignore b/.gitignore index 8d7c28e2c..4e6e89900 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/ +.fleet/ .idea/ .pytest_cache/ .tox/ .venv*/ .vscode/ +.done.* .DS_Store .coverage -.ropeproject -nosetests.xml rerun.txt testrun*.json 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/.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/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..4b36928a3 --- /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-lts-latest + tools: + python: "3.12" + +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 diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 000000000..97547054a --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,51 @@ +# ----------------------------------------------------------------------------- +# 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. + +exclude = [ + ".direnv", + ".eggs", + ".git", + ".ruff_cache", + ".tox", + ".venv*", + "__pypackages__", + "build", + "dist", + "venv", +] +# 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]+?))$" + + +[lint.mccabe] +max-complexity = 10 + + +[format] +quote-style = "double" +indent-style = "space" 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 ff821328d..335e71215 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,18 +1,46 @@ 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) ------------------------------------------------------------------------------- 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). +* CLI: Cleanup command-line short-options that are seldom used + (short-options for: --no-skipped (-k), --no-multiline (-m), --no-source (-s)). GOALS: @@ -20,15 +48,37 @@ GOALS: - 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"). +* 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: + +* CLI: Remove unused option ``--expand`` +* Remove ``stdout_capture``, ``stderr_capture``, ``log_capture`` + attributes from ``behave.runner.Context`` class + (use: ``captured`` attribute instead). 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, ...) * 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) * 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) * 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) @@ -40,19 +90,34 @@ CLARIFICATION: FIXED: -* 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 #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) +* 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) +* 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) 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) @@ -62,6 +127,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) @@ -73,12 +139,14 @@ 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/ +.. _`CucumberExpressions`: https://github.com/cucumber/cucumber-expressions Version: 1.2.6 (2018-02-25) 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/README.rst b/README.rst index 22b035236..8af80f037 100644 --- a/README.rst +++ b/README.rst @@ -1,30 +1,36 @@ ====== -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://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 +.. |badge.latest_version| image:: https://img.shields.io/pypi/v/behave.svg :target: https://pypi.python.org/pypi/behave :alt: Latest Version -.. image:: https://img.shields.io/pypi/l/behave.svg +.. |badge.license| image:: https://img.shields.io/pypi/l/behave.svg :target: https://pypi.python.org/pypi/behave/ :alt: License -.. image:: https://badges.gitter.im/Join%20Chat.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 +.. |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 + +.. |badge.docs_status| image:: https://readthedocs.org/projects/behave/badge/?version=latest + :target: http://behave.readthedocs.io/en/latest/?badge=latest + :alt: Documentation Status + +.. |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 + +.. |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| @@ -95,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 @@ -109,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/ diff --git a/VERSION.txt b/VERSION.txt index c4e75f6de..e7e6efeb1 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.2.7.dev2 +1.2.7.dev6 diff --git a/behave.ini b/behave.ini index 952240d6e..b6c6467e6 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 @@ -22,7 +22,7 @@ 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 @@ -32,10 +32,20 @@ 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 + +# -- RUNNER ALIASES: +# SCHEMA: runner_alias = scoped_runner.module_name:class_name +# EXAMPLE: default = behave.runner:Runner +[behave.runners] + -# PREPARED: +# -- PREPARED: # [behave] # format = ... missing_steps ... # output = ... features/steps/missing_steps.py ... diff --git a/behave/__init__.py b/behave/__init__.py index 53a533724..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 @@ -17,15 +19,22 @@ """ from __future__ import absolute_import -from behave.step_registry import * # pylint: disable=wildcard-import -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/__main__.py b/behave/__main__.py index 3cae36d3b..dfc59ccc2 100644 --- a/behave/__main__.py +++ b/behave/__main__.py @@ -6,43 +6,46 @@ import six from behave.version import VERSION as BEHAVE_VERSION from behave.configuration import Configuration -from behave.exception import ConstraintError, ConfigError, \ - FileNotFoundError, InvalidFileLocationError, InvalidFilenameError +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.runner_plugin import RunnerPlugin from behave.textutil import compute_words_maxsize, text as _text # --------------------------------------------------------------------------- # CONSTANTS: # --------------------------------------------------------------------------- -TAG_HELP = """ -Scenarios inherit tags that are declared on the Feature level. -The simplest TAG_EXPRESSION is simply a tag:: +DEBUG = __debug__ +TAG_EXPRESSIONS_HELP = """ +TAG-EXPRESSIONS selects Features/Rules/Scenarios by using their tags. +A TAG-EXPRESSION is a boolean expression that references some tags. - --tags=@dev +EXAMPLES: -You may even leave off the "@" - behave doesn't mind. + --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)" -You can also exclude all features / scenarios that have a tag, -by using boolean NOT:: +NOTES: - --tags="not @dev" +* The tag-prefix "@" is optional. +* An empty tag-expression is "true" (select-anything). -A tag expression can also use a logical OR:: +TAG-INHERITANCE: - --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)" +* 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() @@ -59,23 +62,23 @@ 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) return 0 if config.tags_help: - print(TAG_HELP) + print_tags_help(config) 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: @@ -87,7 +90,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): @@ -95,11 +98,17 @@ 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 = runner_class(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: print(u"ParserError: %s" % e) @@ -111,6 +120,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,9 +138,9 @@ 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) + colored=config.has_colored_mode()) return_code = 0 if failed: @@ -132,7 +151,18 @@ def run_behave(config, runner_class=None): # --------------------------------------------------------------------------- # MAIN SUPPORT FOR: run_behave() # --------------------------------------------------------------------------- -def print_language_list(stream=None): +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: * English @@ -142,67 +172,108 @@ 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 config.lang_help not in languages: - print("%s is not a recognised language: try --lang-list" % \ - config.lang_help, file=stream) + # 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 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, stream=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 - 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" + problematic_formatters = [] - 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: + print_("AVAILABLE FORMATTERS:") + 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. + 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): + """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) + + runner_names = sorted(runner_aliases.keys()) + column_size = compute_words_maxsize(runner_names) + schema1 = u" %-"+ _text(column_size) +"s = %s%s" + schema2 = u" %-"+ _text(column_size) +"s %s" + problematic_runners = [] + + print_("AVAILABLE RUNNERS:") + for runner_name in runner_names: + scoped_class_name = runner_aliases[runner_name] + problem = RunnerPlugin.make_problem_description(scoped_class_name, use_details=True) + if problem: + 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)) # --------------------------------------------------------------------------- @@ -214,9 +285,15 @@ 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: + exception_class_name = e.__class__.__name__ + print("%s: %s" % (exception_class_name, e)) + except TagExpressionError as e: + print("TagExpressionError: %s" % e) + return 1 # FAILED: if __name__ == "__main__": # -- EXAMPLE: main("--version") diff --git a/behave/_stepimport.py b/behave/_stepimport.py index 3015639f6..7e5a2babe 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,23 @@ 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,16 +77,23 @@ def __init__(self, step_registry): class StepMatchersModule(FakeModule): - __all__ = ["use_step_matcher", "register_type", "step_matcher"] + __all__ = [ + "use_default_step_matcher", + "use_step_matcher", + "register_type" + # -- DEPRECATING: + "step_matcher", + ] - 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.matcher_mapping = matcher_mapping or _matchers.matcher_mapping.copy() + self.step_matcher_factory = step_matcher_factory + setup_api_with_matcher_functions(self, step_matcher_factory) + self.make_step_matcher = step_matcher_factory.make_step_matcher + # -- DEPRECATED-FUNCTION-COMPATIBILITY # 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__). @@ -78,36 +102,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 +129,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 +144,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 +170,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/_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/attic/__init__.py b/behave/active_tag/__init__.py similarity index 100% rename from behave/attic/__init__.py rename to behave/active_tag/__init__.py 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/api/async_step.py b/behave/api/async_step.py index 85e2b2a92..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,9 @@ def step_async_step_waits_seconds2(context, duration): except ImportError: has_asyncio = False +_PYTHON_VERSION = sys.version_info[:2] + + # ----------------------------------------------------------------------------- # ASYNC STEP DECORATORS: # ----------------------------------------------------------------------------- @@ -116,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: @@ -156,14 +165,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 +236,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 +255,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. diff --git a/behave/api/runner.py b/behave/api/runner.py new file mode 100644 index 000000000..1538a3027 --- /dev/null +++ b/behave/api/runner.py @@ -0,0 +1,57 @@ +# -*- 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 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: + + * 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 + # MAYBE: self.undefined_steps = [] + + @abstractmethod + def run(self): + """Run the selected features. + + :return: True, if test-run failed. False, on success. + :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/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/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/behave/capture.py b/behave/capture.py index 35cbae21c..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. """ @@ -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/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 65e2e3e96..0762dfd89 100644 --- a/behave/configuration.py +++ b/behave/configuration.py @@ -1,8 +1,24 @@ -# -*- coding: utf-8 -*- - -from __future__ import print_function +# -*- 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 +from collections import namedtuple +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 @@ -10,26 +26,46 @@ 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.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.userdata import UserData, parse_user_define -from behave._types import Unknown +from behave.reporter.junit import JUnitReporter +from behave.reporter.summary import SummaryReporter +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 # -- 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 +# -- 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 + # ----------------------------------------------------------------------------- -# CONFIGURATION DATA TYPES: +# CONSTANTS: +# ----------------------------------------------------------------------------- +DEFAULT_RUNNER_CLASS_NAME = "behave.runner:Runner" + + +# ----------------------------------------------------------------------------- +# CONFIGURATION DATA TYPES and TYPE CONVERTERS: # ----------------------------------------------------------------------------- class LogLevel(object): names = [ @@ -62,19 +98,33 @@ 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: # ----------------------------------------------------------------------------- -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(action="store_true", dest="color", - help="""Use ANSI color escapes. This is the default - behaviour. 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", @@ -112,19 +162,25 @@ 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"), - 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""")), @@ -133,8 +189,8 @@ def to_string(level): help="""Specify name annotation schema for scenario outline (default="{name} -- @{row.id} {examples.name}").""")), - (("-k", "--no-skipped"), - dict(action="store_false", dest="show_skipped", + (("--no-skipped",), + dict(dest="show_skipped", action="store_false", help="Don't print skipped steps (due to tags).")), (("--show-skipped",), @@ -144,70 +200,69 @@ def to_string(level): 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.""")), - (("-m", "--no-multiline"), - dict(action="store_false", dest="show_multiline", - help="""Don't print multiline strings and tables under - steps.""")), + (("--no-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.""")), @@ -256,24 +311,39 @@ def to_string(level): 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.from_name, + choices=TagExpressionProtocol.choices(), + default=TagExpressionProtocol.DEFAULT.name.lower(), + help="""\ +Specify the tag-expression protocol to use (default: %(default)s). +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"), dict(action="store_true", help="Alias for --no-snippets --no-source.")), - (("-s", "--no-source"), - dict(action="store_false", dest="show_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"')), + + (("--no-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 @@ -309,11 +379,11 @@ def to_string(level): 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 @@ -329,10 +399,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.")), @@ -353,86 +419,246 @@ def to_string(level): 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 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 = {} - for fixed, keywords in options: +def _values_to_str(data): + return json.loads(json.dumps(data), + parse_float=str, + parse_int=str, + parse_constant=str + ) + + +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 + + +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: # 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 if "dest" in keywords: dest = keywords["dest"] else: - 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(): + # -- 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 - if not config.has_option("behave", dest): + if skip_missing and not config_has_param(config, dest): continue + + # -- FINALLY: 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) + value_type = keywords.get("type", None) + yield ConfigFileOption(dest, action, value_type) + +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): + # pylint: disable=too-many-locals, too-many-branches + config = ConfigParser() + config.optionxform = str # -- SUPPORT: case-sensitive keys + config.read(path) + 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" + + if action == "store": + 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": + value_parts = config.get("behave", dest).splitlines() + 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) + + 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 = { "behave.formatters": "more_formatters", + "behave.runners": "more_runners", "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 this_config + + +def read_toml_config(path): + """ + Read configuration from "pyproject.toml" file. + The "behave" configuration should be stored in TOML table(s): + + * "tool.behave" + * "tool.behave.*" + + SEE: https://www.python.org/dev/peps/pep-0518/#tool-table + """ + # pylint: disable=too-many-locals, too-many-branches + with open(path, "rb") as toml_file: + # -- HINT: Use simple dictionary for "config". + config = json.loads(json.dumps(tomllib.load(toml_file))) + + config_tool = config["tool"] + this_config = {} + + 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": + this_config[param_name] = str(raw_value) + elif action in ("store_true", "store_false"): + this_config[param_name] = bool(raw_value) + elif action == "append": + # -- 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) + + 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(): + this_config[data_name] = {} + try: + section_data = config_tool["behave"][section_name] + this_config[data_name] = _values_to_str(section_data) + except KeyError: + this_config[data_name] = {} - return result + return this_config + + +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): + """ + Read the "behave" config from a config-file. + + :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("MISSING CONFIG-FILE PARSER FOR: %s" % path) + return {} + + # -- NORMAL CASE: + parsed = parse_func(path) + return parsed def config_filenames(): @@ -441,8 +667,9 @@ def config_filenames(): 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 @@ -452,45 +679,66 @@ 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 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(): # 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=sys.platform != "win32", + color=os.getenv("BEHAVE_COLOR", COLOR_DEFAULT), + jobs=1, show_snippets=True, show_skipped=True, dry_run=False, @@ -501,14 +749,17 @@ 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, + 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") @@ -528,23 +779,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=self.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.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 @@ -553,16 +830,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 - - 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 @@ -574,77 +853,104 @@ def __init__(self, command_args=None, load_config=True, verbose=None, self.environment_file = "environment.py" self.userdata_defines = None self.more_formatters = None - 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.more_runners = None + 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 + if self.color in COLOR_OFF_VALUES: + return False - 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 + # -- OTHERWISE in AUTO-DETECT mode: color="auto" + output_file = file or sys.stdout + isatty = getattr(output_file, "isatty", lambda: True) + colored = isatty() + return colored - 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 + 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.quiet: - self.show_source = False - self.show_snippets = False + # -- 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 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() - - # -- 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 @@ -654,11 +960,70 @@ def __init__(self, command_args=None, load_config=True, verbose=None, if self.summary: 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)) + 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 = [] + 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_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: @@ -681,15 +1046,30 @@ def setup_formats(self): for name, scoped_class_name in self.more_formatters.items(): _format_registry.register_as(name, scoped_class_name) - def collect_unknown_formats(self): - unknown_formats = [] + 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 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: # pylint: disable=broad-exception-caught + 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): @@ -739,10 +1119,12 @@ 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: - from logging.config import fileConfig - fileConfig(configfile) + logging_config_fileConfig(configfile) else: # pylint: disable=no-member format_ = kwargs.pop("format", self.logging_format) @@ -750,7 +1132,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: @@ -758,7 +1143,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). @@ -776,6 +1162,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: @@ -783,6 +1173,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/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/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/exception.py b/behave/exception.py index ba2120646..1a2d17c72 100644 --- a/behave/exception.py +++ b/behave/exception.py @@ -1,32 +1,69 @@ # -*- coding: UTF-8 -*- +# ruff: noqa: F401 +# pylint: disable=redefined-builtin,unused-import """ Behave exception classes. .. versionadded:: 1.2.7 """ +from __future__ import absolute_import, print_function +# -- 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", + "ConfigTypeError", + "ConstraintError", + "FileNotFoundError", + "InvalidClassError", + "InvalidFileLocationError", + "InvalidFilenameError", + "ModuleNotFoundError", + "NotSupportedWarning", + "ObjectNotFoundError", + "ResourceExistsError", + "TagExpressionError", +] + # --------------------------------------------------------------------------- # 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.""" +class ConfigParamTypeError(ConfigError): + """Used if a config-param has the wrong type.""" + # --------------------------------------------------------------------------- # 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 +75,28 @@ 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 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: + + * 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/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 e83d6083c..aac5546fc 100644 --- a/behave/formatter/_builtins.py +++ b/behave/formatter/_builtins.py @@ -26,11 +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"), ] + # ----------------------------------------------------------------------------- # FUNCTIONS: # ----------------------------------------------------------------------------- diff --git a/behave/formatter/_registry.py b/behave/formatter/_registry.py index 0f7ad942b..c6f021649 100644 --- a/behave/formatter/_registry.py +++ b/behave/formatter/_registry.py @@ -1,26 +1,62 @@ -# -*- coding: utf-8 -*- - +# -*- coding: UTF-8 -*- +import inspect import sys 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 +# ----------------------------------------------------------------------------- +# 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): + 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: # ----------------------------------------------------------------------------- _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) + + # -- 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()) + def register_as(name, formatter_class): """ Register formatter class with given name. @@ -47,9 +83,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 +96,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 +112,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 ImportError("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 @@ -90,14 +132,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): @@ -108,7 +160,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/behave/formatter/bad_steps.py b/behave/formatter/bad_steps.py new file mode 100644 index 000000000..a39748960 --- /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 = "steps.bad" + description = "Shows 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/formatter/pretty.py b/behave/formatter/pretty.py index 794e1d793..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,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.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 @@ -83,6 +81,9 @@ def __init__(self, stream_opener, config): self.indentations = [] self.step_lines = 0 + @property + def monochrome(self): + return not self.colored def reset(self): # -- UNUSED: self.tag_statement = None @@ -137,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: @@ -154,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") @@ -164,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/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 319ae3303..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) @@ -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/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/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) diff --git a/behave/i18n.py b/behave/i18n.py index a572626ae..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 @@ -23,7 +24,7 @@ 'given': ['* ', 'Gegewe '], 'name': 'Afrikaans', 'native': 'Afrikaans', - 'rule': ['Rule'], + 'rule': ['Regel'], 'scenario': ['Voorbeeld', 'Situasie'], 'scenario_outline': ['Situasie Uiteensetting'], 'then': ['* ', 'Dan '], @@ -41,6 +42,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 '], @@ -93,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': ['* ', 'Но '], @@ -101,7 +128,7 @@ 'given': ['* ', 'Дадено '], 'name': 'Bulgarian', 'native': 'български', - 'rule': ['Rule'], + 'rule': ['Правило'], 'scenario': ['Пример', 'Сценарий'], 'scenario_outline': ['Рамка на сценарий'], 'then': ['* ', 'То '], @@ -156,7 +183,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 '], @@ -299,7 +326,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! '], @@ -313,6 +346,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 +378,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 +519,7 @@ 'given': ['* ', 'अगर ', 'यदि ', 'चूंकि '], 'name': 'Hindi', 'native': 'हिंदी', - 'rule': ['Rule'], + 'rule': ['नियम'], 'scenario': ['परिदृश्य'], 'scenario_outline': ['परिदृश्य रूपरेखा'], 'then': ['* ', 'तब ', 'तदा '], @@ -515,7 +563,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,24 +603,24 @@ '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 '], 'when': ['* ', 'Quando ']}, - 'ja': {'and': ['* ', 'かつ'], + 'ja': {'and': ['* ', '且つ', 'かつ'], 'background': ['背景'], - 'but': ['* ', 'しかし', '但し', 'ただし'], + 'but': ['* ', '然し', 'しかし', '但し', 'ただし'], 'examples': ['例', 'サンプル'], 'feature': ['フィーチャ', '機能'], 'given': ['* ', '前提'], 'name': 'Japanese', 'native': '日本語', - 'rule': ['Rule'], + 'rule': ['ルール'], 'scenario': ['シナリオ'], 'scenario_outline': ['シナリオアウトライン', 'シナリオテンプレート', 'テンプレ', 'シナリオテンプレ'], 'then': ['* ', 'ならば'], @@ -590,19 +638,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': ['* ', 'ಆದರೆ '], @@ -720,7 +771,7 @@ 'scenario_outline': ['परिदृश्य रूपरेखा'], 'then': ['* ', 'मग ', 'तेव्हा '], 'when': ['* ', 'जेव्हा ']}, - 'ne': {'and': ['* ', 'र ', 'अनी '], + 'ne': {'and': ['* ', 'र ', 'अनि '], 'background': ['पृष्ठभूमी'], 'but': ['* ', 'तर '], 'examples': ['उदाहरण', 'उदाहरणहरु'], @@ -780,7 +831,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 '], @@ -827,13 +878,17 @@ 'background': ['Предыстория', 'Контекст'], 'but': ['* ', 'Но ', 'А ', 'Иначе '], 'examples': ['Примеры'], - 'feature': ['Функция', 'Функциональность', 'Функционал', 'Свойство'], + 'feature': ['Функция', + 'Функциональность', + 'Функционал', + 'Свойство', + 'Фича'], 'given': ['* ', 'Допустим ', 'Дано ', 'Пусть '], 'name': 'Russian', 'native': 'русский', 'rule': ['Правило'], 'scenario': ['Пример', 'Сценарий'], - 'scenario_outline': ['Структура сценария'], + 'scenario_outline': ['Структура сценария', 'Шаблон сценария'], 'then': ['* ', 'То ', 'Затем ', 'Тогда '], 'when': ['* ', 'Когда ', 'Если ']}, 'sk': {'and': ['* ', 'A ', 'A tiež ', 'A taktiež ', 'A zároveň '], @@ -881,7 +936,7 @@ 'given': ['* ', 'За дато ', 'За дате ', 'За дати '], 'name': 'Serbian', 'native': 'Српски', - 'rule': ['Rule'], + 'rule': ['Правило'], 'scenario': ['Пример', 'Сценарио', 'Пример'], 'scenario_outline': ['Структура сценарија', 'Скица', 'Концепт'], 'then': ['* ', 'Онда '], @@ -894,7 +949,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 +1027,7 @@ 'given': ['* ', 'Diyelim ki '], 'name': 'Turkish', 'native': 'Türkçe', - 'rule': ['Rule'], + 'rule': ['Kural'], 'scenario': ['Örnek', 'Senaryo'], 'scenario_outline': ['Senaryo taslağı'], 'then': ['* ', 'O zaman '], @@ -1021,7 +1076,7 @@ 'but': ['* ', 'Лекин ', 'Бирок ', 'Аммо '], 'examples': ['Мисоллар'], 'feature': ['Функционал'], - 'given': ['* ', 'Агар '], + 'given': ['* ', 'Belgilangan '], 'name': 'Uzbek', 'native': 'Узбекча', 'rule': ['Rule'], @@ -1050,7 +1105,7 @@ 'given': ['* ', '假如', '假设', '假定'], 'name': 'Chinese simplified', 'native': '简体中文', - 'rule': ['Rule'], + 'rule': ['Rule', '规则'], 'scenario': ['场景', '剧本'], 'scenario_outline': ['场景大纲', '剧本大纲'], 'then': ['* ', '那么'], diff --git a/behave/importer.py b/behave/importer.py index 6611ada05..f1d8b2c2d 100644 --- a/behave/importer.py +++ b/behave/importer.py @@ -7,7 +7,10 @@ from __future__ import absolute_import import importlib +import inspect from behave._types import Unknown +from behave.exception import ClassNotFoundError, ModuleNotFoundError + def parse_scoped_name(scoped_name): """ @@ -19,16 +22,52 @@ 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 + return module_name, object_name or "" + + +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__ + 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): @@ -38,29 +77,36 @@ 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): """Implement descriptor protocol.""" - __pychecker__ = "unusednames=obj" self.resolved_object = value def get(self): @@ -68,27 +114,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/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/behave/matchers.py b/behave/matchers.py index 0fee0c79c..3e571be76 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,16 @@ 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 @@ -41,7 +49,6 @@ def __init__(self, text=None, exc_cause=None): ChainedExceptionUtil.set_cause(self, exc_cause) - # ----------------------------------------------------------------------------- # SECTION: Model Elements # ----------------------------------------------------------------------------- @@ -123,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): @@ -137,28 +144,120 @@ def run(self, context): raise StepParseError(exc_cause=self.stored_error) +# ----------------------------------------------------------------------------- +# 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 + 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. + """ + # -- IMPLEMENTS: TypeRegistryProtocol + def register_type(self, **kwargs): + raise NotSupportedWarning("register_type") + + def has_type(self, name): + return False + + def clear(self): + pass # ----------------------------------------------------------------------------- -# 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) + NAME = None # -- HINT: Must be specified by derived class. + TYPE_REGISTRY = 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. + """ + try: + cls.TYPE_REGISTRY.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.TYPE_REGISTRY.has_type(name) + + @classmethod + def clear_registered_types(cls): + cls.TYPE_REGISTRY.clear() + # -- END-OF: TypeRegistry protocol 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: @@ -186,234 +285,598 @@ 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). - def check_match(self, step): - """Match me against the "step" name supplied. + 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_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 Exception as e: # pylint: disable=broad-except + 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 ``step_text`` parameter 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. - """ - custom_types = {} - parser_class = parse.Parser + 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. - def __init__(self, func, pattern, step_type=None): + 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))} + """ + NAME = "parse" + PARSER_CLASS = parse.Parser + CASE_SENSITIVE = True + TYPE_REGISTRY = TypeRegistry() + + def __init__(self, func, pattern, step_type=None, custom_types=None): + if custom_types is None: + custom_types = self.TYPE_REGISTRY super(ParseMatcher, self).__init__(func, pattern, step_type) - self.parser = self.parser_class(pattern, self.custom_types) + self.parser = self.PARSER_CLASS(pattern, extra_types=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 ``step_text`` parameter 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. """ - parser_class = cfparse.Parser + 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``) -def register_type(**kw): - r"""Registers a custom type that will be available to "parse" - for type conversion during step matching. + Regular expressions and type-converters for cardinality-fields are + generated by the parser if a type-converter for the cardinality=1 is registered. - Converters should be supplied as ``name=callable`` arguments (or as dict). + COLLABORATORS: - 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). + * :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 - import parse + from behave import register_type, given, use_step_matcher + use_step_matcher("cfparse") + # ... -- OMITTED: Provide type-converter function for Number - # -- 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) + @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 DEFINITIONS: Use type converter. - @given('{amount:Number} vehicles') - def step_impl(context, amount): - assert isinstance(amount, int) + 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] """ - ParseMatcher.custom_types.update(kw) + NAME = "cfparse" + PARSER_CLASS = cfparse.Parser class RegexMatcher(Matcher): + """ + Provides a step-matcher that uses regular-expressions + + RESPONSIBILITIES: + + * Custom type-converters are NOT SUPPORTED. + """ + NAME = "re0" + TYPE_REGISTRY = TypeRegistryNotSupported() + 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 + + @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): - m = self.regex.match(step) - if not m: + 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: + """ + Simplified regular expression step-matcher that automatically adds + 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 """ + NAME = "re" 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: + """ + Compatible to (old) Cucumber style regular expressions. + 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 """ + NAME = "re0" -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: -def use_step_matcher(name): - """Change the parameter matcher used in parsing step text. + * in the same steps module + * in different step modules - 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. + There are several step-matcher classes available in **behave**: - There are several parsers available in *behave* (by default): + * **parse** (the default, based on: :pypi:`parse`): + * **cfparse** (extends: :pypi:`parse`, requires: :pypi:`parse_type`) + * **re** (using regular expressions) - **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. + You may `define your own step-matcher class`_. - Supports type conversions by using type converters - (see :func:`~behave.register_type()`). + .. _`define your own step-matcher class`: api.html#step-parameters - **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: + parse + ------ - * ``{values:Type+}`` (cardinality=1..N, many) - * ``{values:Type*}`` (cardinality=0..N, many0) - * ``{value:Type?}`` (cardinality=0..1, optional) + 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. - Supports type conversions (as above). + Supports type conversions by using type converters + (see :func:`~behave.register_type()`). - **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. + cfparse + ------- - Type conversion is **not supported**. - A step function writer may implement type conversion - inside the step function (implementation). + 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: - You may `define your own matcher`_. + * ``{values:Type+}`` (cardinality=1..N, many) + * ``{values:Type*}`` (cardinality=0..N, many0) + * ``{value:Type?}`` (cardinality=0..1, optional) - .. _`define your own matcher`: api.html#step-parameters + Supports type conversions (as above). + + re (regex based parser) + ----------------------- + + 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. + + Type conversion is **not supported**. + A step function writer may implement type conversion + inside the step function (implementation). """ - global current_matcher # pylint: disable=global-statement - current_matcher = matcher_mapping[name] + STEP_MATCHER_CLASSES = [ + ParseMatcher, + CFParseMatcher, + SimplifiedRegexMatcher, + CucumberRegexMatcher, # -- SAME AS: RegexMatcher + ] + DEFAULT_MATCHER_NAME = "parse" + + @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 + + 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 = self.step_matcher_class_mapping[default_matcher_name] + self._current_matcher = self.default_matcher + assert self.default_matcher in self.step_matcher_class_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 step_matcher(name): + 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 has_registered_type(self, name): + return self.current_matcher.has_registered_type(name) + + def clear_registered_types(self): + 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, + 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.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.step_matcher_class_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.step_matcher_class_mapping[name] + return self._current_matcher + + def use_default_step_matcher(self, name=None): + """Use the default step-matcher. + + 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.step_matcher_class_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_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_step_matcher_factory = StepMatcherFactory() + + +# ----------------------------------------------------------------------------- +# API FUNCTIONS: +# ----------------------------------------------------------------------------- +def get_step_matcher_factory(): + return _the_step_matcher_factory + + +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_step_matcher_factory.use_current_step_matcher_as_default() + + +# ----------------------------------------------------------------------------- +# PUBLIC API FOR: step-writers +# ----------------------------------------------------------------------------- +def use_step_matcher(name): + return _the_step_matcher_factory.use_step_matcher(name) + + +def use_default_step_matcher(name=None): + return _the_step_matcher_factory.use_default_step_matcher(name=name) + + +def register_type(**kwargs): + _the_step_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_step_matcher_factory.register_step_matcher_class(name, step_matcher_class, + override=override) + + +def has_registered_step_matcher_class(name_or_class): """ - DEPRECATED, use :func:`use_step_matcher()` instead. + 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 """ - # -- BACKWARD-COMPATIBLE NAME: Mark as deprecated. - warnings.warn("deprecated: Use 'use_step_matcher()' instead", - DeprecationWarning, stacklevel=2) - use_step_matcher(name) + 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__) + + +# ----------------------------------------------------------------------------- +# BACKWARD-COMPATIBILITY: +# ----------------------------------------------------------------------------- +def get_matcher_factory(): + warnings.warn("DEPRECATED: Use get_step_matcher_factory() instead", DeprecationWarning) + return get_step_matcher_factory() + -def get_matcher(func, pattern): - return current_matcher(func, pattern) +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/model.py b/behave/model.py index f1ec7256c..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 @@ -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. @@ -500,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 @@ -644,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 @@ -823,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): @@ -893,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 @@ -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 @@ -1687,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 @@ -1808,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) @@ -2075,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,) @@ -2207,13 +2216,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/model_core.py b/behave/model_core.py index 515ab6bac..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 @@ -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: @@ -358,7 +357,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 +372,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/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/behave/parser.py b/behave/parser.py index 58c68be5a..313464f4f 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 @@ -177,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 @@ -206,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 @@ -228,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) @@ -353,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. """ @@ -380,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 @@ -545,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: @@ -582,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]) @@ -600,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(): @@ -629,31 +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) + # -- 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"(?" 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 @@ -397,7 +436,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 +446,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 +459,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/behave/runner.py b/behave/runner.py index c583cafc1..7033ad53b 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 @@ -13,6 +12,7 @@ import six +from behave.api.runner import ITestRunner from behave._types import ExceptionUtil from behave.capture import CaptureController from behave.exception import ConfigError @@ -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 - - 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. - - .. attribute:: stdout_capture + .. attribute:: captured - 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. + 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:: 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 @@ -164,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, @@ -173,24 +163,72 @@ 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 # -- 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 + 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) + + 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 @@ -461,7 +499,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) @@ -469,12 +507,16 @@ 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) + @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. @@ -552,8 +594,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. @@ -562,6 +603,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 @@ -570,7 +619,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) @@ -578,21 +627,39 @@ 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. + + .. 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): @@ -600,7 +667,7 @@ def run_hook(self, name, context, *args): 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 @@ -622,7 +689,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 @@ -654,6 +721,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: @@ -686,7 +760,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 @@ -711,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) @@ -726,20 +802,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: @@ -878,3 +953,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: +# ----------------------------------------------------------------------------- +ITestRunner.register(ModelRunner) +ITestRunner.register(Runner) diff --git a/behave/runner_plugin.py b/behave/runner_plugin.py new file mode 100644 index 000000000..4e9fe5529 --- /dev/null +++ b/behave/runner_plugin.py @@ -0,0 +1,176 @@ +# -*- 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:: ini + + # -- 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 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 + + +class RunnerPlugin(object): + """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:: python + + # -- EXAMPLE: Provide own test runner-class + 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:: python + + # -- 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): + 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 or {} + + @staticmethod + 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 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): + raise InvalidClassError("is not a class") + elif not issubclass(runner_class, ITestRunner): + 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): + 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_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 = load_module(module_name) + runner_class = getattr(module, class_name, Unknown) + if runner_class is Unknown: + raise ClassNotFoundError(runner_class_name) + cls.validate_class(runner_class, runner_class_name) + return runner_class + except (ImportError, InvalidClassError, 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, use_details=False): + """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: + 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): + """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 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_class(scoped_class_name) + else: + self.validate_class(runner_class) + + runner = runner_class(config, **runner_kwargs) + return runner diff --git a/behave/runner_util.py b/behave/runner_util.py index 80b99a0f4..6216c597a 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) @@ -550,25 +554,33 @@ 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_) 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"): @@ -579,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): @@ -649,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'] @@ -656,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.ParseMatcher.custom_types = {} - matchers.current_matcher = matchers.ParseMatcher + # -- RESET STEP 2: behave.matchers + get_step_matcher_factory().reset() diff --git a/behave/step_registry.py b/behave/step_registry.py index b235711b9..20fb73152 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, get_matcher +from __future__ import absolute_import, print_function +import sys + +from behave.matchers import make_step_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,67 @@ class AmbiguousStep(ValueError): pass +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}""" + + 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 print_all(self): + 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, 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=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): + 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(file=sys.stderr) + + 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,24 +94,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_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 + + # -- 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 - elif existing.match(step_text): # -- SIMPLISTIC + + 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(get_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/behave/tag_expression/__init__.py b/behave/tag_expression/__init__.py index c68521ecb..a0e3e6000 100644 --- a/behave/tag_expression/__init__.py +++ b/behave/tag_expression/__init__.py @@ -1,9 +1,10 @@ # -*- coding: UTF-8 -*- +# pylint: disable=C0209 """ Common module for tag-expressions: -* v1: old tag expressions (deprecating; superceeded by: cucumber-tag-expressions) -* v2: cucumber-tag-expressions +* v1: old tag expressions (deprecating; superseded by: cucumber-tag-expressions) +* v2: cucumber-tag-expressions (with wildcard extension) .. seealso:: @@ -12,94 +13,8 @@ """ from __future__ import absolute_import -import six -# -- NEW CUCUMBER TAG-EXPRESSIONS (v2): -from .parser import TagExpressionParser -# -- OLD-STYLE TAG-EXPRESSIONS (v1): -# HINT: BACKWARD-COMPATIBLE (deprecating) -from .v1 import TagExpression +from .builder import TagExpressionProtocol, make_tag_expression # noqa: F401 - -# ----------------------------------------------------------------------------- -# FUNCTIONS: -# ----------------------------------------------------------------------------- -def make_tag_expression(tag_expression_text): - """Build a TagExpression object by parsing the tag-expression (as text). - - :param tag_expression_text: Tag expression text to parse (as string). - :return: TagExpression object to use. - """ - parse_tag_expression = select_tag_expression_parser(tag_expression_text) - return parse_tag_expression(tag_expression_text) - - -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() - # print("parse_tag_expression_v1: %s" % " ".join(tag_expression_parts)) - return TagExpression(tag_expression_parts) - - -def parse_tag_expression_v2(tag_expression_text): - """Parse cucumber-tag-expressions and build a TagExpression object.""" - text = tag_expression_text - if not isinstance(text, six.string_types): - # -- ASSUME: List of strings - assert isinstance(text, (list, tuple)) - text = " and ".join(text) - - if "@" in text: - # -- NORMALIZE: tag-expression text => Remove '@' tag decorators. - text = text.replace("@", "") - text = text.replace(" ", " ") - # print("parse_tag_expression_v2: %s" % text) - return TagExpressionParser.parse(text) - - -def check_for_complete_keywords(words, keywords): - for keyword in keywords: - for word in words: - if keyword == word: - return True - return False - - -def select_tag_expression_parser(tag_expression_text): - """Select/Auto-detect which version of tag-expressions is used. - - :param tag_expression_text: Tag expression text (as string) - :return: TagExpression parser to use (as function). - """ - TAG_EXPRESSION_V1_KEYWORDS = [ - "~", "-", "," - ] - TAG_EXPRESSION_V2_KEYWORDS = [ - "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.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)) - 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 3b22f9e72..115ddef87 100644 --- a/behave/tag_expression/model.py +++ b/behave/tag_expression/model.py @@ -1,10 +1,53 @@ # -*- 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_ -from cucumber_tag_expressions.model import Expression, Literal, And, Or, Not # ----------------------------------------------------------------------------- -# PATCH TAG-EXPRESSION BASE-CLASS: +# PATCH TAG-EXPRESSION BASE-CLASS: Expression # ----------------------------------------------------------------------------- def _Expression_check(self, tags): """Checks if tags match this tag-expression. @@ -17,4 +60,105 @@ 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 + + +# ----------------------------------------------------------------------------- +# 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 22d4e4090..000000000 --- a/behave/tag_expression/model_ext.py +++ /dev/null @@ -1,92 +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 fnmatch -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: - if fnmatch(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 690b0881f..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 - TagExpressionError + # -- 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/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/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/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): diff --git a/behave/version.py b/behave/version.py index 67f4a418f..4bc7659a4 100644 --- a/behave/version.py +++ b/behave/version.py @@ -1,2 +1,2 @@ # -- BEHAVE-VERSION: -VERSION = "1.2.7.dev2" +VERSION = "1.2.7.dev6" 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 index c55bdfba7..c823bfad4 --- 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 = {} 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 19c3e807d..7a01e577a 100644 --- a/behave4cmd0/command_steps.py +++ b/behave4cmd0/command_steps.py @@ -11,16 +11,14 @@ """ from __future__ import absolute_import, print_function -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 @@ -28,138 +26,6 @@ # INIT: # ----------------------------------------------------------------------------- matchers.register_type(int=int) -DEBUG = False -file_contents_normalizer = None -if BehaveWinCommandOutputProcessor.enabled: - file_contents_normalizer = TextProcessor(BehaveWinCommandOutputProcessor()) - - -# ----------------------------------------------------------------------------- -# UTILITIES: -# ----------------------------------------------------------------------------- -@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: - # 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)) - 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: - 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)) - raise - -# ----------------------------------------------------------------------------- -# 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.""" - __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 - 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, "") # ----------------------------------------------------------------------------- @@ -178,40 +44,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): ''' @@ -255,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): ''' @@ -273,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) @@ -292,18 +158,15 @@ 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 - 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): """ @@ -315,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) @@ -366,7 +219,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,197 +236,60 @@ 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) # ----------------------------------------------------------------------------- -# STEPS FOR: Directories +# STEP DEFINITIONS: command output should/should_not match # ----------------------------------------------------------------------------- -@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) +@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``. -@step(u'the directory "{directory}" does not exist') -def step_directory_named_does_not_exist(context, directory): - """ - Verifies that a directory does not exist. + :param pattern: Regular expression pattern to use (as string or compiled). .. code-block:: gherkin - Given the directory "abc/" does not exist - When the directory "abc/" does not exist + # -- STEP-SCHEMA: Then the command output should match /{pattern}/ + Scenario: + When I run `echo Hello world` + Then the command output should match /Hello \\w+/ """ - 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) - + # 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) -# ----------------------------------------------------------------------------- -# 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] +@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) 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..34e48939d --- /dev/null +++ b/behave4cmd0/filesystem_steps.py @@ -0,0 +1,238 @@ + +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) + + +@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) + filename_ = pathutil.realpath_with_context(filename, context) + 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) + 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..2d3761438 --- /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 assertion 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 assertion 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/textutil.py b/behave4cmd0/textutil.py index 7f04dc27c..df475edcd 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 @@ -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: @@ -182,11 +183,16 @@ def text_normalize(text): """ # if not isinstance(text, str): if isinstance(text, bytes): - # -- MAYBE: command.ouput => 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) + +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()) @@ -275,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: # ----------------------------------------------------------------------------- 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/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/docs/_common_extlinks.rst b/docs/_common_extlinks.rst index 5c1dea9ad..baea4f59b 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 @@ -8,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 8b7d91018..806b48119 100644 --- a/docs/_content.tag_expressions_v2.rst +++ b/docs/_content.tag_expressions_v2.rst @@ -1,44 +1,77 @@ Tag-Expressions v2 ------------------------------------------------------------------------------- -:pypi:`cucumber-tag-expressions` are now supported and superceed the old-style -tag-expressions (which are deprecating). :pypi:`cucumber-tag-expressions` are much -more readible and flexible to select tags on command-line. +Tag-Expressions v2 are based on :pypi:`cucumber-tag-expressions` with some extensions: -.. code-block:: sh +* 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. + + +.. code-block:: gherkin + :caption: TAG-EXPRESSION EXAMPLES - # -- 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 + # HINT: Boolean expressions can be grouped with parenthesis. (@a or @b) and not @c -Example: +COMMAND-LINE EXAMPLE: .. code-block:: sh + :caption: USING: Tag-Expressions v2 with ``behave`` # -- 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/ + # -- 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 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 @@ -57,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 @@ -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 + :caption: 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/_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/behave.rst b/docs/behave.rst index 25ce52327..30d65cfaf 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. This is the default behaviour. This switch is - used to override a configuration file setting. + Use colored mode or not (default: auto). .. option:: -d, --dry-run @@ -55,6 +54,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 @@ -65,7 +69,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 +87,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. @@ -176,7 +180,11 @@ You may see the same information presented below at any time using ``behave Alias for --no-snippets --no-source. -.. option:: -s, --no-source +.. option:: -r, --runner + + Use own runner class, like: "behave.runner:Runner" + +.. option:: --no-source Don't print the file and line of the step definition with the steps. @@ -221,10 +229,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. @@ -250,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 +TAG-EXPRESSIONS selects Features/Rules/Scenarios by using their tags. +A TAG-EXPRESSION is a boolean expression that references some tags. -You may even leave off the "@" - behave doesn't mind. +EXAMPLES: -You can also exclude all features / scenarios that have a tag, -by using boolean NOT:: + --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)" - --tags="not @dev" +NOTES: -A tag expression can also use a logical OR:: +* The tag-prefix "@" is optional. +* An empty tag-expression is "true" (select-anything). - --tags="@dev or @wip" +TAG-INHERITANCE: -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)" +* 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: @@ -282,9 +284,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 @@ -303,6 +305,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 ----------------------------- @@ -317,6 +329,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 @@ -330,6 +343,7 @@ The following types are supported (and used): --tags="(@foo or not @bar) and @zap" + TOML: toml can use arrays natively. Configuration Parameters @@ -338,10 +352,9 @@ Configuration Parameters .. index:: single: configuration param; color -.. describe:: color : bool +.. describe:: color : Colored (Enum) - Use ANSI color escapes. This is the default behaviour. This switch is - used to override a configuration file setting. + Use colored mode or not (default: auto). .. index:: single: configuration param; dry_run @@ -388,6 +401,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 @@ -548,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: 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 @@ -555,6 +586,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 @@ -622,13 +660,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 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/conf.py b/docs/conf.py index cece86a4e..44b65ae9e 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,13 +53,18 @@ extlinks = { - "pypi": ("https://pypi.org/project/%s", ""), - "github": ("https://github.com/%s", "github:/"), - "issue": ("https://github.com/behave/behave/issues/%s", "issue #"), - "youtube": ("https://www.youtube.com/watch?v=%s", "youtube:video="), + "this": ("https://github.com/behave/behave/blob/main/%s", "%s"), # AKA: this_repo "behave": ("https://github.com/behave/behave", None), - "cucumber": ("https://github.com/cucumber/cucumber/", None), - "cucumber.issue": ("https://github.com/cucumber/cucumber/issues/%s", "issue #"), + "behave.example": ("https://github.com/behave/behave.example", None), + "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", "%s"), + "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 #%s"), } intersphinx_mapping = { @@ -108,7 +114,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 @@ -148,9 +154,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 @@ -162,14 +170,45 @@ 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" -if ON_READTHEDOCS: - html_theme = "default" - -if 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" + +# -- DISABLED: Use html_theme = "furo" now. +# if ON_READTHEDOCS: +# html_theme = "default" + +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 @@ -181,10 +220,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/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..36694def2 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. @@ -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 @@ -46,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). @@ -109,29 +112,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 given, 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 36cb3d06a..f587c98ef 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -35,23 +35,77 @@ 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`_. - pip install git+https://github.com/behave/behave@ +When installing extras, use ``#egg=behave[...]``, e.g.:: -where is the placeholder for an `existing tag`_. + pip install git+https://github.com/behave/behave@v1.2.7.dev5#egg=behave[toml] -.. _`Github repository`: https://github.com/behave/behave +.. _`GitHub repository`: https://github.com/behave/behave .. _`existing tag`: https://github.com/behave/behave/tags + + +Optional Dependencies +--------------------- + +If needed, additional dependencies ("extras") 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:`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 + + +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.dev5", + # OR: "behave[develop] @ git+https://github.com/behave/behave.git@main", + ] + + +.. _behave: https://github.com/behave/behave 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 diff --git a/docs/new_and_noteworthy_v1.2.7.rst b/docs/new_and_noteworthy_v1.2.7.rst index 2eb94ade3..0a90d21d6 100644 --- a/docs/new_and_noteworthy_v1.2.7.rst +++ b/docs/new_and_noteworthy_v1.2.7.rst @@ -10,6 +10,8 @@ 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`_ +* `Detect bad step definitions`_ .. _`Example Mapping`: https://cucumber.io/blog/example-mapping-introduction/ .. _`Example Mapping Webinar`: https://cucumber.io/blog/example-mapping-webinar/ @@ -35,11 +37,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: @@ -197,3 +204,140 @@ 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 string values) + +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_temperature.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 + # 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): + 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 Min/Max 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 = Sensors().get_temperature() + 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), + } + ... + + +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 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/docs/update_behave_rst.py b/docs/update_behave_rst.py index 9044b87b3..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,8 +16,10 @@ 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 cmdline = [] config = [] indent = " " @@ -34,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("-", "_") @@ -51,9 +62,39 @@ assert len(opt) == 2 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 in ("store", "store_const"): + type_name = "text" + if data_type is positive_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_name = "bool" + default_value = False + if action == "store_true": + default_value = True + elif action == "append": + type_name = "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,24 +107,16 @@ 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": + if not config_file_param or 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)) + config.append(config_param_schema.format(param=dest, type=type_name, text=text)) # -- STEP: Generate documentation. @@ -93,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/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 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 6069664e2..209042dd0 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", @@ -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": [ @@ -303,7 +352,7 @@ "name": "Bulgarian", "native": "български", "rule": [ - "Rule" + "Правило" ], "scenario": [ "Пример", @@ -494,7 +543,7 @@ "name": "Czech", "native": "Česky", "rule": [ - "Rule" + "Pravidlo" ], "scenario": [ "Příklad", @@ -967,9 +1016,12 @@ ], "when": [ "* ", - "Tha ", - "Þa ", - "Ða " + "Bæþsealf ", + "Bæþsealfa ", + "Bæþsealfe ", + "Ciricæw ", + "Ciricæwe ", + "Ciricæwa " ] }, "en-pirate": { @@ -1013,6 +1065,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 +1170,9 @@ "Ejemplos" ], "feature": [ - "Característica" + "Característica", + "Necesidad del negocio", + "Requisito" ], "given": [ "* ", @@ -1090,7 +1184,8 @@ "name": "Spanish", "native": "español", "rule": [ - "Regla" + "Regla", + "Regla de negocio" ], "scenario": [ "Ejemplo", @@ -1520,7 +1615,7 @@ "name": "Hindi", "native": "हिंदी", "rule": [ - "Rule" + "नियम" ], "scenario": [ "परिदृश्य" @@ -1672,7 +1767,7 @@ "name": "Hungarian", "native": "magyar", "rule": [ - "Rule" + "Szabály" ], "scenario": [ "Példa", @@ -1804,7 +1899,9 @@ "Esempi" ], "feature": [ - "Funzionalità" + "Funzionalità", + "Esigenza di Business", + "Abilità" ], "given": [ "* ", @@ -1816,7 +1913,7 @@ "name": "Italian", "native": "italiano", "rule": [ - "Rule" + "Regola" ], "scenario": [ "Esempio", @@ -1837,6 +1934,7 @@ "ja": { "and": [ "* ", + "且つ", "かつ" ], "background": [ @@ -1844,6 +1942,7 @@ ], "but": [ "* ", + "然し", "しかし", "但し", "ただし" @@ -1863,7 +1962,7 @@ "name": "Japanese", "native": "日本語", "rule": [ - "Rule" + "ルール" ], "scenario": [ "シナリオ" @@ -1934,44 +2033,57 @@ "ka": { "and": [ "* ", - "და" + "და ", + "ასევე " ], "background": [ "კონტექსტი" ], "but": [ "* ", - "მაგ­რამ" + "მაგრამ ", + "თუმცა " ], "examples": [ "მაგალითები" ], "feature": [ - "თვისება" + "თვისება", + "მოთხოვნა" ], "given": [ "* ", - "მოცემული" + "მოცემული ", + "მოცემულია ", + "ვთქვათ " ], "name": "Georgian", - "native": "ქართველი", + "native": "ქართული", "rule": [ - "Rule" + "წესი" ], "scenario": [ "მაგალითად", - "სცენარის" + "მაგალითი", + "მაგ", + "სცენარი" ], "scenarioOutline": [ - "სცენარის ნიმუში" + "სცენარის ნიმუში", + "სცენარის შაბლონი", + "ნიმუში", + "შაბლონი" ], "then": [ "* ", - "მაშინ" + "მაშინ " ], "when": [ "* ", - "როდესაც" + "როდესაც ", + "როცა ", + "როგორც კი ", + "თუ " ] }, "kn": { @@ -2350,7 +2462,7 @@ "and": [ "* ", "र ", - "अनी " + "अनि " ], "background": [ "पृष्ठभूमी" @@ -2561,7 +2673,8 @@ "name": "Polish", "native": "polski", "rule": [ - "Rule" + "Zasada", + "Reguła" ], "scenario": [ "Przykład", @@ -2718,7 +2831,8 @@ "Функция", "Функциональность", "Функционал", - "Свойство" + "Свойство", + "Фича" ], "given": [ "* ", @@ -2736,7 +2850,8 @@ "Сценарий" ], "scenarioOutline": [ - "Структура сценария" + "Структура сценария", + "Шаблон сценария" ], "then": [ "* ", @@ -2896,7 +3011,7 @@ "name": "Serbian", "native": "Српски", "rule": [ - "Rule" + "Правило" ], "scenario": [ "Пример", @@ -2951,7 +3066,7 @@ "name": "Serbian (Latin)", "native": "Srpski (Latinica)", "rule": [ - "Rule" + "Pravilo" ], "scenario": [ "Scenario", @@ -3229,7 +3344,7 @@ "name": "Turkish", "native": "Türkçe", "rule": [ - "Rule" + "Kural" ], "scenario": [ "Örnek", @@ -3412,7 +3527,7 @@ ], "given": [ "* ", - "Агар " + "Belgilangan " ], "name": "Uzbek", "native": "Узбекча", @@ -3508,7 +3623,8 @@ "name": "Chinese simplified", "native": "简体中文", "rule": [ - "Rule" + "Rule", + "规则" ], "scenario": [ "场景", @@ -3621,5 +3737,54 @@ "* ", "जेव्हा " ] + }, + "amh": { + "and": [ + "* ", + "እና " + ], + "background": [ + "ቅድመ ሁኔታ", + "መነሻ", + "መነሻ ሀሳብ" + ], + "but": [ + "* ", + "ግን " + ], + "examples": [ + "ምሳሌዎች", + "ሁናቴዎች" + ], + "feature": [ + "ስራ", + "የተፈለገው ስራ", + "የሚፈለገው ድርጊት" + ], + "given": [ + "* ", + "የተሰጠ " + ], + "name": "Amharic", + "native": "አማርኛ", + "rule": [ + "ህግ" + ], + "scenario": [ + "ምሳሌ", + "ሁናቴ" + ], + "scenarioOutline": [ + "ሁናቴ ዝርዝር", + "ሁናቴ አብነት" + ], + "then": [ + "* ", + "ከዚያ " + ], + "when": [ + "* ", + "መቼ " + ] } } 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/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/features/cmdline.lang_list.feature b/features/cmdline.lang_list.feature index 20822ec10..91439c555 100644 --- a/features/cmdline.lang_list.feature +++ b/features/cmdline.lang_list.feature @@ -10,13 +10,15 @@ 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 an: Aragonés / Aragonese ar: العربية / Arabic ast: asturianu / Asturian az: Azərbaycanca / Azerbaijani + be: Беларуская / Belarusian bg: български / Bulgarian bm: Bahasa Melayu / Malay bs: Bosanski / Bosnian @@ -33,6 +35,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 @@ -52,7 +55,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/features/environment.py b/features/environment.py index 72ebaa077..801cfb6d3 100644 --- a/features/environment.py +++ b/features/environment.py @@ -1,23 +1,24 @@ # -*- coding: UTF-8 -*- -# FILE: features/environemnt.py +# FILE: features/environment.py from __future__ import absolute_import, print_function +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 -from behave4cmd0.setup_command_shell import setup_command_shell_processors4behave -from behave import python_feature -import platform -import sys +# ----------------------------------------------------------------------------- +# ACTIVE TAGS: +# ----------------------------------------------------------------------------- # -- 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) @@ -25,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: # ----------------------------------------------------------------------------- @@ -48,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/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/formatter.help.feature b/features/formatter.help.feature index 48f1a02b4..6c34c0684 100644 --- a/features/formatter.help.feature +++ b/features/formatter.help.feature @@ -1,30 +1,147 @@ 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.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. + """ + + 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 + @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' | + + 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.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/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" diff --git a/features/formatter.user_defined.feature b/features/formatter.user_defined.feature index 04f0701c0..4b94d47c5 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 @@ -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: """ @@ -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/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 📚: 🙈🙉🙊 📕: 💃 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/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 - """ 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.help.feature b/features/runner.help.feature new file mode 100644 index 000000000..5d368ab8c --- /dev/null +++ b/features/runner.help.feature @@ -0,0 +1,133 @@ +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 + @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 '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 | + | 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.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.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) """ diff --git a/features/runner.use_runner_class.feature b/features/runner.use_runner_class.feature new file mode 100644 index 000000000..04df1e355 --- /dev/null +++ b/features/runner.use_runner_class.feature @@ -0,0 +1,468 @@ +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 + 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. + + + 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 + """ + And a file named "features/environment.py" with: + """ + from __future__ import absolute_import, print_function + + def before_all(ctx): + 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 + Feature: Alice + Scenario: A1 + Given a step passes + When another step passes + + Scenario: A2 + When some step passes + """ + + + @default_runner + Rule: Use default runner + + Scenario: Use default runner + Given a file named "behave.ini" does not exist + 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 the command output should contain: + """ + TEST_RUNNER_CLASS=behave.runner::Runner + """ + And note that "the DEFAULT TEST_RUNNER CLASS is used" + + 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) + + def run(self): + 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 + """ + [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) + + def run(self): + 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) + + def run(self): + 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 + """ + [behave] + runner = some_runner + + [behave.runners] + some_runner = behave_example3:MyRunner + """ + When I run "behave features/" + Then it should pass with: + """ + THIS_RUNNER_CLASS=behave_example3::MyRunner + """ + 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: + """ + TEST_RUNNER_CLASS=behave.runner::Runner + """ + And note that "the NORMAL RUNNER CLASS is used" + + 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: + """ + from __future__ import print_function + from behave.runner import Runner + + class MyRunner1(Runner): pass + """ + And an empty file named "behave_example/__init__.py" + When I run "behave --runner=behave_example.good_runner:MyRunner1" + Then it should pass with: + """ + TEST_RUNNER_CLASS=behave_example.good_runner::MyRunner1 + """ + + + Scenario: Use --runner=RUNNER_NAME on command-line with 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 + """ + 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 + """ + + 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 = behave_example.good_runner:MyRunner1 + """ + When I run "behave --runner=behave_example.good_runner:MyRunner2" + Then it should pass with: + """ + TEST_RUNNER_CLASS=behave_example.good_runner::MyRunner2 + """ + + + Rule: Use own Test Runner-by-Class (BAD CASES) + + Background: Bad runner classes + 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 + + @property + def undefined_steps(self): + return [] + + class IncompleteRunner3(ITestRunner): # MISSING: undefined_steps + def __init__(self, config): + self.config = config + def run(self): pass + + class IncompleteRunner4(ITestRunner): # BAD-RUN-METHOD + def __init__(self, config): + self.config = config + run = True + + @property + def undefined_steps(self): + return [] + """ + And an empty file named "behave_example/__init__.py" + + Scenario Template: Use BAD-RUNNER-CLASS with --runner= () + When I run "behave --runner=" + Then it should fail + And the command output should match: + """ + + """ + But note that "problem: " + + 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. | + | 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 + # 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\|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) + + 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: + """ + : + """ + + Examples: + | 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) | + + + 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 + + @property + def undefined_steps(self): + return [] + + class IncompleteRunner3(ITestRunner): # MISSING: undefined_steps + def __init__(self, config): + self.config = config + def run(self): pass + + class IncompleteRunner4(ITestRunner): # BAD-RUN-METHOD + def __init__(self, config): + self.config = config + run = True + + @property + def undefined_steps(self): + return [] + """ + 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 match: + """ + : + """ + + 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\|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\|without an implementation for) abstract method(s)? (')?undefined_steps(')? | missing-property | 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/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/features/step_matcher.cucumber_expressions.feature b/features/step_matcher.cucumber_expressions.feature new file mode 100644 index 000000000..b64bed577 --- /dev/null +++ b/features/step_matcher.cucumber_expressions.feature @@ -0,0 +1,310 @@ +@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() + """ + + @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 + """ + 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: + """ + 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 + """ + 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: + """ + 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/many_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/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/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/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/features/tags.help.feature b/features/tags.help.feature new file mode 100644 index 000000000..2b9c44062 --- /dev/null +++ b/features/tags.help.feature @@ -0,0 +1,77 @@ +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. + + 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" + 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/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/invoke.yaml b/invoke.yaml index a32345e5c..a8571f415 100644 --- a/invoke.yaml +++ b/invoke.yaml @@ -9,44 +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 - - extra_files: - - "**/testrun*.json" + 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/issue.features/environment.py b/issue.features/environment.py index ab85e6c9f..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 @@ -60,13 +62,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 @@ -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/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/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..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: """ @@ -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 diff --git a/issue.features/issue0309.feature b/issue.features/issue0309.feature index b50e32d3b..f64848685 100644 --- a/issue.features/issue0309.feature +++ b/issue.features/issue0309.feature @@ -29,13 +29,15 @@ 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 an: Aragonés / Aragonese ar: العربية / Arabic ast: asturianu / Asturian az: Azərbaycanca / Azerbaijani + be: Беларуская / Belarusian bg: български / Bulgarian bm: Bahasa Melayu / Malay bs: Bosanski / Bosnian diff --git a/issue.features/issue0330.feature b/issue.features/issue0330.feature index 56ac23838..2bb507857 100644 --- a/issue.features/issue0330.feature +++ b/issue.features/issue0330.feature @@ -70,9 +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 + # -- 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: @@ -86,9 +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.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: @@ -104,9 +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 + # -- 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: @@ -126,9 +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.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: @@ -151,9 +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 + # -- 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: @@ -174,9 +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.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 d7db76480..c62dc4613 100644 --- a/issue.features/issue0446.feature +++ b/issue.features/issue0446.feature @@ -58,9 +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 + # -- 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: @@ -89,9 +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.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: @@ -124,9 +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 + # -- 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: @@ -157,9 +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.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 c14c7a40c..c706e7453 100644 --- a/issue.features/issue0457.feature +++ b/issue.features/issue0457.feature @@ -24,9 +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 + # -- 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: """ @@ -47,9 +46,7 @@ Feature: Issue #457 -- Double-quotes in error messages of JUnit XML reports = 3.8) Given a file named "features/fails1.feature" with: """ @@ -73,9 +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/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" 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/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/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" 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" 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/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 + """ 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" 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" 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" diff --git a/issue.features/issue1170.feature b/issue.features/issue1170.feature new file mode 100644 index 000000000..bed8030fd --- /dev/null +++ b/issue.features/issue1170.feature @@ -0,0 +1,92 @@ +@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-UNTIL-FIXED: + . * Use TagExpression auto-detection in v2 mode (or 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 + """ + + + 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: + """ + 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 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 in STRICT Mode + Given a file named "behave.ini" with: + """ + # -- 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: + """ + 2 scenarios passed, 0 failed, 1 skipped + """ + And note that "TagExpressions v2 are used" + And note that "first two scenarios are selected/executed" 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/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" 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 diff --git a/justfile b/justfile new file mode 100644 index 000000000..8928ba95b --- /dev/null +++ b/justfile @@ -0,0 +1,107 @@ +# ============================================================================= +# justfile: A makefile like build script -- Command Runner +# ============================================================================= +# REQUIRES: cargo install just +# PLATFORMS: macOS, Linux, Windows, ... +# USAGE: +# just --list +# 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 +set export := true + +# ----------------------------------------------------------------------------- +# CONFIG: +# ----------------------------------------------------------------------------- +HERE := justfile_directory() +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") +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-all + +# 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}} {{HERE}}/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/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/py.requirements/all.txt b/py.requirements/all.txt index 4298e3328..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 json.txt +-r jsonschema.txt diff --git a/py.requirements/basic.txt b/py.requirements/basic.txt index 6c644e04c..55883a985 100644 --- a/py.requirements/basic.txt +++ b/py.requirements/basic.txt @@ -5,21 +5,22 @@ # pip install -r # # SEE ALSO: -# * http://www.pip-installer.org/ +# * https://pip.pypa.io/en/stable/user_guide/ # ============================================================================ +# MAYBE: cucumber-expressions >= 15.0.0; python_version >='3.5' -cucumber-tag-expressions >= 1.1.2 +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.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: -# 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/behave_extensions.txt b/py.requirements/behave_extensions.txt new file mode 100644 index 000000000..ab834a630 --- /dev/null +++ b/py.requirements/behave_extensions.txt @@ -0,0 +1,14 @@ +# ============================================================================ +# 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.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.tox.txt b/py.requirements/ci.tox.txt index 387e90525..77226b71b 100644 --- a/py.requirements/ci.tox.txt +++ b/py.requirements/ci.tox.txt @@ -1,17 +1,6 @@ # ============================================================================ -# BEHAVE: PYTHON PACKAGE REQUIREMENTS: ci.tox.txt +# PYTHON PACKAGE REQUIREMENTS: behave -- ci.tox.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 -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' - -jsonschema +-r testing.txt +-r jsonschema.txt diff --git a/py.requirements/ci.travis.txt b/py.requirements/ci.travis.txt deleted file mode 100644 index cbc60c095..000000000 --- a/py.requirements/ci.travis.txt +++ /dev/null @@ -1,27 +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 >= 2.0 -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/develop.txt b/py.requirements/develop.txt index e7dc41800..03be1272c 100644 --- a/py.requirements/develop.txt +++ b/py.requirements/develop.txt @@ -2,14 +2,8 @@ # PYTHON PACKAGE REQUIREMENTS FOR: behave -- For development only # ============================================================================ -# -- BUILD-TOOL: -invoke == 1.4.1 -pathlib; python_version <= '3.4' -pycmd - -# -- 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' +# -- BUILD-SYSTEM: invoke +-r invoke.txt # -- CONFIGURATION MANAGEMENT (helpers): # FORMER: bumpversion >= 0.4.0 @@ -17,20 +11,26 @@ bump2version >= 0.5.6 # -- RELEASE MANAGEMENT: Push package to pypi. twine >= 1.13.0 +build >= 0.5.1 # -- DEVELOPMENT SUPPORT: -tox >= 1.8.1 -coverage >= 4.2 -pytest-cov - # -- PYTHON2/3 COMPATIBILITY: pypa/modernize # python-futurize modernize >= 0.5 # -- STATIC CODE ANALYSIS: -pylint +-r pylinters.txt -# -- REQUIRES: testing, docs, invoke-task requirements +# -- CODE EXPLORATIONS: +# SEE: https://github.com/gabotechs/dep-tree +python-dep-tree; python_version >= '3.7' + +# -- REQUIRES: testing -r testing.txt +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 + +# -- REQUIRED FOR: docs -r docs.txt --r ../tasks/py.requirements.txt diff --git a/py.requirements/docs.txt b/py.requirements/docs.txt index 1384e00a4..5aca72bfe 100644 --- a/py.requirements/docs.txt +++ b/py.requirements/docs.txt @@ -1,11 +1,53 @@ # ============================================================================ -# 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 +# 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 +# -- 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 + +# -- 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 + +# -- 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' # -- 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 +# 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' + +# 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/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 diff --git a/py.requirements/json.txt b/py.requirements/jsonschema.txt similarity index 73% rename from py.requirements/json.txt rename to py.requirements/jsonschema.txt index e765b951b..d9505cd16 100644 --- a/py.requirements/json.txt +++ b/py.requirements/jsonschema.txt @@ -3,4 +3,7 @@ # ============================================================================ # -- OPTIONAL: For JSON validation +# DEPRECATING: jsonschema +# USE INSTEAD: check-jsonschema jsonschema >= 1.3.0 +check-jsonschema; python_version >= '3.7' diff --git a/py.requirements/pylinters.txt b/py.requirements/pylinters.txt new file mode 100644 index 000000000..fcdebb195 --- /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; python_version >= '3.7' diff --git a/py.requirements/testing.txt b/py.requirements/testing.txt index fc8fd8246..4c7a89017 100644 --- a/py.requirements/testing.txt +++ b/py.requirements/testing.txt @@ -2,20 +2,32 @@ # 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 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' +assertpy >= 1.1 # -- 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' + +# -- 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 new file mode 100644 index 000000000..3f36f0546 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,218 @@ +# ============================================================================= +# 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 :: 3.12", + "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", + "cucumber-expressions >= 17.1.0; python_version >= '3.8'", + "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 >= 7.3.7; python_version >= '3.7'", + "sphinx >=1.6,<4.4; python_version < '3.7'", + "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'", + # -- SPHINX-EXTENSIONS: + "sphinx-copybutton >= 0.5.2; python_version >= '3.7'", +] +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'", + + # -- 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. +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 + + +# ----------------------------------------------------------------------------- +# PACAKING TOOL SPECIFIC PARTS: +# ----------------------------------------------------------------------------- +[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/pytest.ini b/pytest.ini index df2a81fb9..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.dev2 --html=build/testing/report.html --self-contained-html --junit-xml=build/testing/report.xml markers = 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 diff --git a/setup.py b/setup.py index fd89bdad5..3eaa02f7e 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -* +# -*- coding: UTF-8 -* """ Setup script for behave. @@ -55,12 +55,12 @@ def find_packages_by_root_package(where): # ----------------------------------------------------------------------------- setup( name="behave", - version="1.2.7.dev2", + version="1.2.7.dev6", description="behave is behaviour-driven development, Python style", 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"], @@ -76,65 +76,110 @@ 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", + "cucumber-expressions >= 17.1.0; python_version >= '3.8'", + "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", + "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", + + # -- 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'", # >= 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", - "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'", + "assertpy >= 1.1", + # -- 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'", + # -- 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, }, extras_require={ "docs": [ - "sphinx >= 1.6", - "sphinx_bootstrap_theme >= 0.6" + "sphinx >= 7.3.7; python_version >= '3.7'", + "sphinx >=1.6,<4.4; python_version < '3.7'", + "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'", + # -- SPHINX-EXTENSIONS: + "sphinx-copybutton >= 0.5.2; python_version >= '3.7'", ], "develop": [ - "coverage", - "pytest >= 4.2", - "pytest-html >= 1.19.0", + "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'", + "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", + "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", ], + '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 + ], }, - # MAYBE-DISABLE: use_2to3 - use_2to3= bool(python_version >= 3.0), license="BSD", classifiers=[ - "Development Status :: 4 - Beta", + "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.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 :: 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", @@ -143,5 +188,3 @@ def find_packages_by_root_package(where): ], zip_safe = True, ) - - 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/develop.py b/tasks/develop.py index eb5fedd6a..6208b1a4c 100644 --- a/tasks/develop.py +++ b/tasks/develop.py @@ -13,7 +13,7 @@ # ----------------------------------------------------------------------------- # CONSTANTS: # ----------------------------------------------------------------------------- -GHERKIN_LANGUAGES_URL = "https://raw.githubusercontent.com/cucumber/cucumber/master/gherkin/gherkin-languages.json" +GHERKIN_LANGUAGES_URL = "https://raw.githubusercontent.com/cucumber/gherkin/main/gherkin-languages.json" # ----------------------------------------------------------------------------- @@ -36,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) diff --git a/tasks/docs.py b/tasks/docs.py index 77c1d8338..2c9835b1e 100644 --- a/tasks/docs.py +++ b/tasks/docs.py @@ -12,12 +12,14 @@ # -- 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 # ----------------------------------------------------------------------------- # 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() 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 ac19e9469..fdabed241 100644 --- a/tasks/py.requirements.txt +++ b/tasks/py.requirements.txt @@ -8,17 +8,23 @@ # * 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' -path.py >= 11.5.0; python_version < '3.5' +# -- HINT, was RENAMED: path.py => path (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' backports.shutil_which; python_version <= '3.3' +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' 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..adc3642b3 100644 --- a/tasks/test.py +++ b/tasks/test.py @@ -6,11 +6,13 @@ from __future__ import print_function import os.path import sys + +import six from invoke import task, Collection # -- 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 # --------------------------------------------------------------------------- @@ -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": "", 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)) diff --git a/tests/api/_test_async_step34.py b/tests/api/_test_async_step34.py index 1c0c31fc9..3ea61ba64 100644 --- a/tests/api/_test_async_step34.py +++ b/tests/api/_test_async_step34.py @@ -1,18 +1,21 @@ # -*- coding: UTF-8 -*- +# pylint: disable=invalid-name """ Unit tests for :mod:`behave.api.async_test`. """ # -- IMPORTS: from __future__ import absolute_import, print_function -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 mock import Mock +from unittest.mock import Mock +from hamcrest import assert_that, close_to 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 @@ -38,22 +41,31 @@ # 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)") +SLEEP_DELTA = 0.050 +if sys.platform.startswith("win"): + SLEEP_DELTA = 0.100 + + # ----------------------------------------------------------------------------- # 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 @@ -64,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) @@ -72,16 +85,19 @@ 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): +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() @@ -132,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): @@ -141,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 @@ -157,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) @@ -171,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 @@ -181,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): @@ -192,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 @@ -200,9 +222,11 @@ 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 - # -- RUN ASYNC-STEP: Verify that raised exeception is detected. + # 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): when_async_step_raises_exception(context) diff --git a/tests/api/_test_async_step35.py b/tests/api/_test_async_step35.py index f4068db22..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). """ @@ -6,19 +7,15 @@ # -- IMPORTS: from __future__ import absolute_import, print_function import sys -from behave._stepimport import use_step_import_modules -from behave.runner import Context, Runner +from hamcrest import assert_that, close_to 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 -# ----------------------------------------------------------------------------- -# SUPPORT: -# ----------------------------------------------------------------------------- - - # ----------------------------------------------------------------------------- # ASYNC STEP EXAMPLES: # ----------------------------------------------------------------------------- @@ -40,21 +37,28 @@ # ----------------------------------------------------------------------------- # 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"): + SLEEP_DELTA = 0.100 + # ----------------------------------------------------------------------------- # 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 @@ -64,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) @@ -72,11 +77,12 @@ 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 -class TestAsyncStepRunPy35(object): +class TestAsyncStepRunPy35: """Ensure that execution of async-steps works as expected.""" def test_async_step_passes(self): @@ -85,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 @@ -98,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 = [] @@ -113,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 @@ -121,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): @@ -133,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..ca346e7d9 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. @@ -14,11 +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). - # from ._test_async_step34 import TestAsyncStepDecorator34, TestAsyncContext, TestAsyncStepRun34 - from ._test_async_step34 import * + # 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 * + 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/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_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/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" diff --git a/tests/issues/test_issue1054.py b/tests/issues/test_issue1054.py new file mode 100644 index 000000000..5dd943969 --- /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.builder 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) 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) diff --git a/tests/issues/test_issue1177.py b/tests/issues/test_issue1177.py new file mode 100644 index 000000000..5bb27900d --- /dev/null +++ b/tests/issues/test_issue1177.py @@ -0,0 +1,129 @@ +""" +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 +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 + + +@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 + + +@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="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. + """ + 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 + + # -- ENSURE: No AmbiguousStepError is raised when another step is added. + @then(u'first step and more') + def then_second_step(ctx): + pass + + # -- 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 + + # -- 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="REQUIRES: Python >= 3.11") +def test_bad_step_is_not_registered_if_regex_compile_fails(capsys): + """ + Ensure that step-definition is not registered if parse-expression compile fails. + """ + 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 + + # -- 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 + + # -- 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/tag_expression/test_basics.py b/tests/unit/tag_expression/test_basics.py deleted file mode 100644 index 69b3256d7..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_parser, - 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_parser() -# ----------------------------------------------------------------------------- -@pytest.mark.parametrize("text", [ - "@foo @bar", - "foo bar", - "-foo", - "~foo", - "-@foo", - "~@foo", - "@foo,@bar", - "-@xfail -@not_implemented", -]) -def test_select_tag_expression_parser__with_v1(text): - parser = select_tag_expression_parser(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_parser__with_v2(text): - parser = select_tag_expression_parser(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 49e3fe7a0..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 @@ -164,7 +165,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.""" 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_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_configuration.py b/tests/unit/test_configuration.py index c96cf63a5..f55c1ac5b 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -1,21 +1,30 @@ +from __future__ import absolute_import, print_function +from contextlib import contextmanager import os.path +from pathlib import Path import sys -import tempfile 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 -TEST_CONFIG="""[behave] +# configparser and toml +TEST_CONFIGS = [ + (".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 @@ -24,9 +33,26 @@ [behave.userdata] foo = bar answer = 42 -""" +"""), + # -- TOML CONFIG-FILE: + ("pyproject.toml", """[tool.behave] +outfiles = ["/absolute/path1", "relative/path2"] +paths = ["/absolute/path3", "relative/path4"] +default_tags = ["(@foo and not @bar) or @zap"] +format = ["pretty", "tag-counter"] +stdout_capture = false +bogus = "spam" +[tool.behave.userdata] +foo = "bar" +answer = 42 +""") +] + +# ----------------------------------------------------------------------------- +# TEST SUPPORT: +# ----------------------------------------------------------------------------- ROOTDIR_PREFIX = "" if sys.platform.startswith("win"): # -- OR: ROOTDIR_PREFIX = os.path.splitdrive(sys.executable) @@ -37,17 +63,47 @@ 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): - 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, 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 - d = configuration.read_configuration(tn) - assert d["outfiles"] ==[ + # 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")), ] @@ -56,8 +112,8 @@ def test_read_file(self): os.path.normpath(os.path.join(tndir, "relative/path4")), ] assert d["format"] == ["pretty", "tag-counter"] - assert d["default_tags"] == ["@foo,~@bar", "@zap"] - assert d["stdout_capture"] == False + 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"} @@ -93,6 +149,9 @@ def test_settings_with_stage_from_envvar(self): del os.environ["BEHAVE_STAGE"] +# ----------------------------------------------------------------------------- +# TEST SUITE: +# ----------------------------------------------------------------------------- class TestConfigurationUserData(TestCase): """Test userdata aspects in behave.configuration.Configuration class.""" @@ -175,3 +234,114 @@ 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", [ + "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__", + # -- SIMILAR: to valid values + "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 + 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)) + + choices = ", ".join(TagExpressionProtocol.choices()) + expected = "{value} (expected: {choices})".format(value=value, choices=choices) + assert TagExpressionProtocol.current() is default_value + assert exc_info.type is ValueError + assert expected in str(exc_info.value) 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 diff --git a/tests/unit/test_matchers.py b/tests/unit/test_matchers.py index 815581caa..b3db63687 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.clear_registered_types() + this_matcher_class.register_type(Number=parse_number) + assert this_matcher_class.has_registered_type("Number") + 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 @@ -164,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) @@ -193,7 +355,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 @@ -203,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) @@ -227,10 +391,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_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_step_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_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 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) diff --git a/tests/unit/test_parser.py b/tests/unit/test_parser.py index 01006f9bc..6c7d8d1c5 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,29 +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_step_with_a_string_argument(self): doc = u''' Feature: Stuff @@ -421,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" @@ -445,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" @@ -471,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" @@ -499,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" @@ -525,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" @@ -546,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, [ @@ -578,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, [ @@ -610,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, [ @@ -652,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 @@ -663,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, [ @@ -674,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, [ @@ -702,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), @@ -716,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, [ @@ -744,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"], @@ -764,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) @@ -789,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"], @@ -809,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) @@ -887,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", @@ -904,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, " + \ @@ -921,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, [ @@ -931,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, [ @@ -953,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, [ @@ -964,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, [ @@ -989,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""" @@ -999,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""" @@ -1012,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): @@ -1026,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..."] @@ -1039,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..."] @@ -1050,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..."] @@ -1063,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..."] @@ -1075,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 @@ -1088,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..."] @@ -1099,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..."] @@ -1110,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..."] @@ -1121,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..."] @@ -1132,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..."] @@ -1152,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 @@ -1168,23 +1280,26 @@ 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 = 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 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 @@ -1202,7 +1317,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 @@ -1244,7 +1359,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 @@ -1276,7 +1391,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 @@ -1308,7 +1423,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 @@ -1345,7 +1460,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 @@ -1383,7 +1498,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 @@ -1419,7 +1534,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' ) ] @@ -1432,12 +1547,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') @@ -1460,7 +1575,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.", @@ -1499,7 +1614,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.", @@ -1530,8 +1645,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): @@ -1546,8 +1661,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): @@ -1562,8 +1677,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): @@ -1581,13 +1696,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 @@ -1598,7 +1713,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 @@ -1623,7 +1738,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 @@ -1645,7 +1760,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 @@ -1669,15 +1784,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" ], @@ -1698,11 +1813,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" ], ]) @@ -1711,12 +1826,36 @@ def test_parse_steps_when_last_step_has_a_table(self): ("then", "Then", "the last step has a final table", None, table2), ]) - def test_parse_steps_with_malformed_table(self): + def test_parse_steps_with_malformed_table_fails(self): text = u''' Given a step with a malformed table: | Name | City | | 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''' + """ + BAD MULTI-LINE TEXT (before any step) + """ +Given another step +'''.lstrip() + with pytest.raises(ParserError) as exc: + parse_steps(text) + + assert exc.match("Multi-line text before any step") + + def test_parse_steps_with_datatable_before_any_step_fails(self): + text = u''' + | name | birthyear | + | Alice | 1980 | + | Bob | 2005 | + Given another step + '''.lstrip() + with pytest.raises(ParserError) as exc: + parse_steps(text) + + assert exc.match("TABLE-START without step detected") diff --git a/tests/unit/test_runner.py b/tests/unit/test_runner.py index beaff8fc9..03b7aae3f 100644 --- a/tests/unit/test_runner.py +++ b/tests/unit/test_runner.py @@ -3,488 +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 tempfile 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() @@ -623,18 +154,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()) 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) diff --git a/tests/unit/test_runner_plugin.py b/tests/unit/test_runner_plugin.py new file mode 100644 index 000000000..892386e10 --- /dev/null +++ b/tests/unit/test_runner_plugin.py @@ -0,0 +1,305 @@ +# -*- coding: UTF-8 -*- +""" +Unit tests for :mod:`behave.runner_plugin`. +""" + +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 + + +# ----------------------------------------------------------------------------- +# CONSTANTS: +# ----------------------------------------------------------------------------- +PYTHON_VERSION = sys.version_info[:2] + + +# ----------------------------------------------------------------------------- +# TEST SUPPORT: +# ----------------------------------------------------------------------------- +@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) + + +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 +# ----------------------------------------------------------------------------- +class CustomTestRunner(ITestRunner): + """Custom, dummy runner""" + + def __init__(self, config, **kwargs): + self.config = config + + def run(self): + return True # OOPS: Failed. + + @property + def undefined_steps(self): + return [] + + +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) + + @property + def undefined_steps(self): + return self.the_runner.undefined_steps + + +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. + + @property + def undefined_steps(self): + return self.the_runner.undefined_steps + + +# -- REQUIRES REGISTRATION WITH INTERFACE: +# Register as subclass of ITestRunner interface-class. +ITestRunner.register(RegisteredTestRunner) + + +# ----------------------------------------------------------------------------- +# TEST SUPPORT: TEST RUNNERS CANDIDATES -- BAD EXAMPLES +# ----------------------------------------------------------------------------- +# 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): + 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: +# ----------------------------------------------------------------------------- +class TestRunnerPlugin(object): + """Test the runner-plugin configuration.""" + THIS_MODULE_NAME = CustomTestRunner.__module__ + + 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"]) + 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_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: + 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 = "is not a class" + 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 = "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:%s" % (self.THIS_MODULE_NAME, class_name)]) + RunnerPlugin().make_runner(config) + + expected = make_exception_message4abstract_method(class_name, method_name="__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): + class_name = "InvalidTestRunnerWithoutRun" + with pytest.raises(TypeError) as exc_info: + config = Configuration(["--runner=%s:%s" % (self.THIS_MODULE_NAME, class_name)]) + RunnerPlugin().make_runner(config) + + expected = make_exception_message4abstract_method(class_name, method_name="run") + 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 = make_exception_message4abstract_method(class_name, "undefined_steps") + assert exc_info.type is TypeError + assert exc_info.match(expected) diff --git a/tests/unit/test_step_registry.py b/tests/unit/test_step_registry.py index 6f85729e6..6326129ff 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): @@ -12,20 +13,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.step_registry.make_step_matcher") as make_step_matcher: func = lambda x: -x - pattern = 'just a test string' - magic_object = object() - get_matcher.return_value = magic_object + pattern = u"just a test string" + magic_object = Mock() + make_step_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) - get_matcher.assert_called_with(func, pattern) - assert l == [magic_object] + 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): registry = step_registry.StepRegistry() 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) 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 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 diff --git a/tox.ini b/tox.ini index c145b5147..eb838df8d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,64 +1,23 @@ # ============================================================================ # 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 # # SEE ALSO: -# * http://tox.testrun.org/latest/config.html +# * https://tox.wiki/en/latest/config.html # ============================================================================ -# -- ONLINE USAGE: # PIP_INDEX_URL = http://pypi.org/simple [tox] minversion = 2.3 -envlist = py39, py38, py27, 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 = py312, py311, py27, py310, py39, 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: @@ -78,7 +37,8 @@ setenv = [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 @@ -127,6 +87,7 @@ deps= setenv = PYTHONPATH = .:{envdir} + # --------------------------------------------------------------------------- # SELDOM-USED: OPTIONAL TEST ENVIRONMENTS: # ---------------------------------------------------------------------------