├── .gitignore ├── CHANGELOG.rst ├── LICENSE ├── README.rst ├── docs ├── Makefile ├── conf.py ├── index.rst ├── plugin.rst └── shell.rst ├── example_test_set ├── tests │ ├── subsets │ │ ├── subsubset │ │ │ └── test_setB.py │ │ └── test_setA.py │ ├── test_inheritance.py │ └── test_set_root.py └── tests2 │ ├── __init__.py │ └── test_set_root2.py ├── ideas.txt ├── interactive ├── __init__.py ├── plugin.py └── shell.py ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | 45 | # Rope 46 | .ropeproject 47 | 48 | # Django stuff: 49 | *.log 50 | *.pot 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Change Log 2 | ========== 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on `Keep a Changelog`_ and this project adheres to 6 | `Semantic Versioning`_. 7 | 8 | .. _Keep a Changelog: http://keepachangelog.com/en/1.0.0/ 9 | .. _Semantic Versioning: https://semver.org/ 10 | 11 | 12 | 0.1.4 - 2017-11-30 13 | ------------------ 14 | Fixed 15 | ***** 16 | - Hot fix for hard coding of wrong root node name (issue `#21`_). 17 | 18 | .. _#21: https://github.com/tgoodlet/pytest-interactive/issues/21 19 | 20 | 21 | 0.1.3 - 2017-11-26 22 | ------------------ 23 | Added 24 | ***** 25 | - Add ``pytest`` cache integration support (PR `#16`_). 26 | 27 | Changed 28 | ******* 29 | - Look for ``pytest.Item`` not ``pytest.Function``. Thanks to `@vodik`_ for 30 | PR `#17`_. 31 | 32 | Fixed 33 | ***** 34 | - Use ``node.name`` attribute to key test items/nodes (PR `#20`_). 35 | 36 | .. _#16: https://github.com/tgoodlet/pytest-interactive/pull/16 37 | .. _#17: https://github.com/tgoodlet/pytest-interactive/pull/17 38 | .. _#20: https://github.com/tgoodlet/pytest-interactive/pull/20 39 | .. _@vodik: https://github.com/vodik 40 | 41 | 42 | 0.1.2 - 2017-11-26 43 | ------------------ 44 | Botched release - ignore. 45 | 46 | 47 | 0.1.1 - 2016-09-19 48 | ------------------ 49 | Changed 50 | ******* 51 | - Move to IPython 5.0+ and use the new `prompts`_ API. No support has 52 | been kept for previous IPython versions but this does not affect 53 | the plugin's cli in any noticable way. 54 | 55 | .. _prompts: http://ipython.readthedocs.io/en/stable/config/details.html#custom-prompts 56 | 57 | 58 | 0.1 - 2016-08-02 59 | ---------------- 60 | Added 61 | ***** 62 | - Initial plugin release which supports up to IPython 5.0 and includes 63 | docs but no unit tests. 64 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 goodboy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pytest-interactive 2 | ================== 3 | |pypi| |versions| |docs| 4 | 5 | A ``pytest`` plugin for interactively selecting tests to run using ``IPython``. 6 | Read the `docs`_ to find out more! 7 | 8 | Installation 9 | ------------ 10 | :: 11 | 12 | pip install pytest-interactive 13 | 14 | Usage 15 | ----- 16 | Run ``pytest`` with ``--interactive`` or the shorthand ``--ia`` 17 | 18 | .. |versions| image:: 19 | https://img.shields.io/pypi/pyversions/pytest-interactive.svg 20 | :target: https://pypi.python.org/pypi/pytest-interactive 21 | 22 | .. |pypi| image:: https://img.shields.io/pypi/v/pytest-interactive.svg 23 | :target: https://pypi.python.org/pypi/pytest-interactive 24 | 25 | .. |docs| image:: 26 | https://readthedocs.org/projects/pytest-interactive/badge/?version=latest 27 | :target: http://pytest-interactive.readthedocs.io/en/latest/?badge=latest 28 | 29 | .. _docs: 30 | http://pytest-interactive.readthedocs.io/en/latest/?badge=latest 31 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/pytest-interactive.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/pytest-interactive.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/pytest-interactive" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/pytest-interactive" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # pytest-interactive documentation build configuration file, created by 5 | # sphinx-quickstart on Sun Oct 19 15:27:45 2014. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | import sys 17 | import os 18 | import re 19 | # import IPython 20 | 21 | # If extensions (or modules to document with autodoc) are in another directory, 22 | # add these directories to sys.path here. If the directory is relative to the 23 | # documentation root, use os.path.abspath to make it absolute, like shown here. 24 | #sys.path.insert(0, os.path.abspath('.')) 25 | 26 | # -- General configuration ------------------------------------------------ 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | #needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | 'sphinx.ext.coverage', 36 | 'sphinx.ext.viewcode', 37 | 'sphinx.ext.autodoc', 38 | # 'IPython.sphinxext.ipython_console_highlighting', 39 | # 'IPython.sphinxext.ipython_directive', 40 | ] 41 | 42 | # ipython directive config 43 | # ipython_promptin = "'%d' selected >>>" 44 | # ipython_rgxin = re.compile("'(.+)' selected >>>\s?(.*)") 45 | # ipython_holdcount = True 46 | autodoc_member_order = 'bysource' 47 | 48 | # Add any paths that contain templates here, relative to this directory. 49 | templates_path = ['_templates'] 50 | 51 | # The suffix of source filenames. 52 | source_suffix = '.rst' 53 | 54 | # The encoding of source files. 55 | #source_encoding = 'utf-8-sig' 56 | 57 | # The master toctree document. 58 | master_doc = 'index' 59 | 60 | # General information about the project. 61 | project = 'pytest-interactive' 62 | copyright = '2014, Tyler Goodlet' 63 | 64 | # The version info for the project you're documenting, acts as replacement for 65 | # |version| and |release|, also used in various other places throughout the 66 | # built documents. 67 | # 68 | # The short X.Y version. 69 | version = '0.1' 70 | # The full version, including alpha/beta/rc tags. 71 | release = '0.1.4' 72 | 73 | # The language for content autogenerated by Sphinx. Refer to documentation 74 | # for a list of supported languages. 75 | #language = None 76 | 77 | # There are two options for replacing |today|: either, you set today to some 78 | # non-false value, then it is used: 79 | #today = '' 80 | # Else, today_fmt is used as the format for a strftime call. 81 | #today_fmt = '%B %d, %Y' 82 | 83 | # List of patterns, relative to source directory, that match files and 84 | # directories to ignore when looking for source files. 85 | exclude_patterns = ['_build'] 86 | 87 | # The reST default role (used for this markup: `text`) to use for all 88 | # documents. 89 | #default_role = None 90 | 91 | # If true, '()' will be appended to :func: etc. cross-reference text. 92 | #add_function_parentheses = True 93 | 94 | # If true, the current module name will be prepended to all description 95 | # unit titles (such as .. function::). 96 | #add_module_names = True 97 | 98 | # If true, sectionauthor and moduleauthor directives will be shown in the 99 | # output. They are ignored by default. 100 | #show_authors = False 101 | 102 | # The name of the Pygments (syntax highlighting) style to use. 103 | pygments_style = 'sphinx' 104 | 105 | # A list of ignored prefixes for module index sorting. 106 | #modindex_common_prefix = [] 107 | 108 | # If true, keep warnings as "system message" paragraphs in the built documents. 109 | #keep_warnings = False 110 | 111 | 112 | # -- Options for HTML output ---------------------------------------------- 113 | 114 | # The theme to use for HTML and HTML Help pages. See the documentation for 115 | # a list of builtin themes. 116 | html_theme = 'alabaster' 117 | 118 | # Theme options are theme-specific and customize the look and feel of a theme 119 | # further. For a list of options available for each theme, see the 120 | # documentation. 121 | html_theme_options = { 122 | 'github_user': 'tgoodlet', 123 | 'github_repo': 'pytest-interactive', 124 | 'github_button': 'true', 125 | 'github_banner': 'true', 126 | 'page_width': '1080px', 127 | 'fixed_sidebar': 'true', 128 | } 129 | 130 | # Add any paths that contain custom themes here, relative to this directory. 131 | #html_theme_path = [] 132 | 133 | # The name for this set of Sphinx documents. If None, it defaults to 134 | # " v documentation". 135 | #html_title = None 136 | 137 | # A shorter title for the navigation bar. Default is the same as html_title. 138 | #html_short_title = None 139 | 140 | # The name of an image file (relative to this directory) to place at the top 141 | # of the sidebar. 142 | #html_logo = None 143 | 144 | # The name of an image file (within the static path) to use as favicon of the 145 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 146 | # pixels large. 147 | #html_favicon = None 148 | 149 | # Add any paths that contain custom static files (such as style sheets) here, 150 | # relative to this directory. They are copied after the builtin static files, 151 | # so a file named "default.css" will overwrite the builtin "default.css". 152 | html_static_path = ['_static'] 153 | 154 | # Add any extra paths that contain custom files (such as robots.txt or 155 | # .htaccess) here, relative to this directory. These files are copied 156 | # directly to the root of the documentation. 157 | #html_extra_path = [] 158 | 159 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 160 | # using the given strftime format. 161 | #html_last_updated_fmt = '%b %d, %Y' 162 | 163 | # If true, SmartyPants will be used to convert quotes and dashes to 164 | # typographically correct entities. 165 | #html_use_smartypants = True 166 | 167 | # Custom sidebar templates, maps document names to template names. 168 | #html_sidebars = {} 169 | 170 | # Additional templates that should be rendered to pages, maps page names to 171 | # template names. 172 | #html_additional_pages = {} 173 | 174 | # If false, no module index is generated. 175 | #html_domain_indices = True 176 | 177 | # If false, no index is generated. 178 | #html_use_index = True 179 | 180 | # If true, the index is split into individual pages for each letter. 181 | #html_split_index = False 182 | 183 | # If true, links to the reST sources are added to the pages. 184 | #html_show_sourcelink = True 185 | 186 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 187 | #html_show_sphinx = True 188 | 189 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 190 | #html_show_copyright = True 191 | 192 | # If true, an OpenSearch description file will be output, and all pages will 193 | # contain a tag referring to it. The value of this option must be the 194 | # base URL from which the finished HTML is served. 195 | #html_use_opensearch = '' 196 | 197 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 198 | #html_file_suffix = None 199 | 200 | # Output file base name for HTML help builder. 201 | htmlhelp_basename = 'pytest-interactivedoc' 202 | 203 | 204 | # -- Options for LaTeX output --------------------------------------------- 205 | 206 | latex_elements = { 207 | # The paper size ('letterpaper' or 'a4paper'). 208 | #'papersize': 'letterpaper', 209 | 210 | # The font size ('10pt', '11pt' or '12pt'). 211 | #'pointsize': '10pt', 212 | 213 | # Additional stuff for the LaTeX preamble. 214 | #'preamble': '', 215 | } 216 | 217 | # Grouping the document tree into LaTeX files. List of tuples 218 | # (source start file, target name, title, 219 | # author, documentclass [howto, manual, or own class]). 220 | latex_documents = [ 221 | ('index', 'pytest-interactive.tex', 'pytest-interactive Documentation', 222 | 'Tyler Goodlet', 'manual'), 223 | ] 224 | 225 | # The name of an image file (relative to this directory) to place at the top of 226 | # the title page. 227 | #latex_logo = None 228 | 229 | # For "manual" documents, if this is true, then toplevel headings are parts, 230 | # not chapters. 231 | #latex_use_parts = False 232 | 233 | # If true, show page references after internal links. 234 | #latex_show_pagerefs = False 235 | 236 | # If true, show URL addresses after external links. 237 | #latex_show_urls = False 238 | 239 | # Documents to append as an appendix to all manuals. 240 | #latex_appendices = [] 241 | 242 | # If false, no module index is generated. 243 | #latex_domain_indices = True 244 | 245 | 246 | # -- Options for manual page output --------------------------------------- 247 | 248 | # One entry per manual page. List of tuples 249 | # (source start file, name, description, authors, manual section). 250 | man_pages = [ 251 | ('index', 'pytest-interactive', 'pytest-interactive Documentation', 252 | ['Tyler Goodlet'], 1) 253 | ] 254 | 255 | # If true, show URL addresses after external links. 256 | #man_show_urls = False 257 | 258 | 259 | # -- Options for Texinfo output ------------------------------------------- 260 | 261 | # Grouping the document tree into Texinfo files. List of tuples 262 | # (source start file, target name, title, author, 263 | # dir menu entry, description, category) 264 | texinfo_documents = [ 265 | ('index', 'pytest-interactive', 'pytest-interactive Documentation', 266 | 'Tyler Goodlet', 'pytest-interactive', 'One line description of project.', 267 | 'Miscellaneous'), 268 | ] 269 | 270 | # Documents to append as an appendix to all manuals. 271 | #texinfo_appendices = [] 272 | 273 | # If false, no module index is generated. 274 | #texinfo_domain_indices = True 275 | 276 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 277 | #texinfo_show_urls = 'footnote' 278 | 279 | # If true, do not generate a @detailmenu in the "Top" node's menu. 280 | #texinfo_no_detailmenu = False 281 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | pytest-interactive: select tests to run using a REPL 2 | ==================================================== 3 | This plugin allows for the selection and run of ``pytest`` tests 4 | using the command line facilities available in `IPython `_. 5 | This includes tab completion along the ``pytest`` node hierarchy and test callspec 6 | ids as well as the use of standard Python subscript and slice syntax for selection. 7 | 8 | Upon invocation with either the ``--interactive`` or shorthand ``--ia`` arguments, 9 | you will enter an IPython shell which allows for navigation of the test tree built 10 | during the ``pytest`` collection phase. 11 | 12 | Enjoy and feel free to submit PRs, ask for features, or report bugs on the `github page`_. 13 | 14 | .. _github page: https://github.com/tgoodlet/pytest-interactive 15 | 16 | 17 | Quickstart 18 | ---------- 19 | To invoke the interactive plugin simply run pytest as normal from the top of 20 | your test tree like so 21 | 22 | .. code-block:: console 23 | 24 | $ py.test -vvvs --interactive example_test_set/ 25 | 26 | or more compactly 27 | 28 | .. code-block:: console 29 | 30 | $ py.test -vvvs --ia example_test_set/ 31 | 32 | 33 | Pytest will execute normally up until the end of the collection phase at 34 | which point it will enter a slightly customized ``IPython`` shell: 35 | 36 | :: 37 | 38 | ============================ test session starts ============================ 39 | platform linux -- Python 3.4.1 -- py-1.4.20 -- pytest-2.5.2 -- /usr/bin/python 40 | plugins: interactive 41 | collected 63 items 42 | Building test tree... 43 | Entering IPython shell... 44 | 45 | Welcome to pytest-interactive, the pytest + IPython sensation! 46 | Please explore the test (collection) tree using tt. 47 | When finished tabbing to a test node, simply __call__ it to have 48 | pytest invoke all tests collected under that node. 49 | 50 | 51 | .. code-block:: python 52 | 53 | '0' selected >>> 54 | 55 | '0' selected >>> tt. 56 | tt.test_pinky_strength tt.test_dogs_breath 57 | tt.test_manliness tt.test_cats_meow 58 | 59 | '0' selected >>> tt.testsdir.test_pinky_strength. 60 | tt.test_pinky_strength.test_contrived_name_0 61 | tt.test_pinky_strength.test_the_readers_patience 62 | 63 | That's right, jacked pinky finger here you come... 64 | 65 | 66 | Select tests via tab-completion 67 | ----------------------------------- 68 | Basic tab completion should allow you to navigate to the test(s) of interest 69 | as well as aid in exploring the overall ``pytest`` collection tree. 70 | 71 | Tab completion works along python packages, modules, classes and test functions. 72 | The latter three types are collected as nodes by pytest out of the box but as an 73 | extra aid, intermediary nodes are created for packages containing tests as well. 74 | This is helpful to distinguish between different groups of tests in the file system. 75 | 76 | .. note:: 77 | The binding ``tt`` (abbreviation for *test tree*) is a reference to the 78 | root of a tree of nodes which roughly corresponds to the collection tree 79 | gathered by ``pytest``. 80 | 81 | If you'd like to see all tests included by a particular node simply 82 | evaluate it on the shell to trigger a pretty print ``__repr__()`` method: 83 | 84 | .. code-block:: python 85 | 86 | '0' selected >>> tt.tests.subsets.test_setA 87 | Out [0]: 88 | 89 | 0 90 | 1 91 | 2 92 | 3 93 | 4 94 | 5 95 | 96 | 6 97 | 7 98 | 8 99 | 9 100 | 10 101 | 11 102 | 12 103 | 13 104 | 14 105 | 106 | Total 15 tests 107 | 108 | When ready to run pytest, simply :func:`__call__()` the current node to exit the shell 109 | and invoke all tests below it in the tree: 110 | 111 | .. code-block:: python 112 | 113 | '0' selected >>> tt.test_setB.test_modes() 114 | 115 | :: 116 | 117 | example_test_set/tests/subsets/subsubset/test_setB.py::test_modes[a] 118 | example_test_set/tests/subsets/subsubset/test_setB.py::test_modes[b] 119 | example_test_set/tests/subsets/subsubset/test_setB.py::test_modes[c] 120 | 121 | You have selected the above 3 test(s) to be run. 122 | Would you like to run pytest now? ([y]/n)? 123 | 124 | 125 | example_test_set/tests/subsets/subsubset/test_setB.py:41: test_modes[a] PASSED 126 | example_test_set/tests/subsets/subsubset/test_setB.py:41: test_modes[b] PASSED 127 | example_test_set/tests/subsets/subsubset/test_setB.py:41: test_modes[c] FAILED 128 | 129 | 130 | Selection by index or slice 131 | --------------------------- 132 | Tests can also be selected by slice or subscript notation. This is handy 133 | if you see the test(s) you'd like to run in the pretty print output but 134 | don't feel like tab-completing all the way down the tree to the 135 | necessary leaf node. 136 | 137 | Take the following subtree of tests for example: 138 | 139 | .. code-block:: python 140 | 141 | '0' selected >>> tt.test_setB.test_modes 142 | Out[1]: 143 | 144 | 0 145 | 1 146 | 2 147 | 148 | Total 3 tests 149 | 150 | Now let's select the last test 151 | 152 | .. code-block:: python 153 | 154 | '0' selected >>> tt.test_setB.test_modes[-1] 155 | Out[2]: 156 | 157 | 0 158 | 159 | Total 1 tests 160 | 161 | 162 | Or how about the first two 163 | 164 | .. code-block:: python 165 | 166 | '0' selected >>> tt.test_setB.test_modes[:2] 167 | Out[52]: 168 | 169 | 0 170 | 1 171 | 172 | Total 2 tests 173 | 174 | You can of course :func:`__call__()` the indexed node as well to immediately run 175 | all tests in the selection. 176 | 177 | 178 | Filtering by parameterized test callspec ids 179 | -------------------------------------------- 180 | Tests which are generated at runtime (aka parametrized) can be filtered 181 | by their callspec ids. Normally the ids are shown inside the 182 | braces ``[...]`` of the test *nodeid* which often looks something like: 183 | 184 | ```` 185 | 186 | (i.e. what you get for output when using the ``--collectonly`` arg) 187 | 188 | .. not sure why the params ref below doesn't link internally ... 189 | 190 | To access the available ids use the node's 191 | :py:attr:`~interactive.plugin.TestSet.params` attribute. 192 | 193 | .. code-block:: python 194 | 195 | '0' selected >>> tt.params. 196 | tt.params.a tt.params.b tt.params.c tt.params.cat 197 | tt.params.dog tt.params.mouse 198 | 199 | '0' selected >>> tt.params.a 200 | Out[2]: 201 | 202 | 0 203 | 204 | 1 205 | 2 206 | 3 207 | 208 | 4 209 | 210 | 5 211 | 6 212 | 7 213 | 214 | 8 215 | 216 | 9 217 | 10 218 | 11 219 | 220 | 12 221 | 222 | 13 223 | 14 224 | 15 225 | 226 | Total 16 tests 227 | 228 | You can continue to filter in this way as much as is possible 229 | 230 | .. code-block:: python 231 | 232 | '0' selected >>> tt.params.a.params. 233 | tt.params.a.params.cat tt.params.a.params.dog 234 | tt.params.a.params.mouse 235 | 236 | '0' selected >>> tt.params.a.params.mouse 237 | Out[3]: 238 | 239 | 240 | 0 241 | 242 | Total 1 tests 243 | 244 | .. warning:: 245 | There is one stipulation with using id filtering which is that 246 | the id tokens must be valid python literals. Otherwise the 247 | :py:meth:`__getattr__` overloading of the node will not work. 248 | It is recomended that you give your parameterized tests tab 249 | completion friendly ids using the `ids kwarg`_ as documented on the 250 | pytest site. 251 | 252 | .. _ids kwarg: http://pytest.org/latest/parametrize.html 253 | #_pytest.python.Metafunc.parametrize 254 | 255 | 256 | Multiple selections and magics 257 | ------------------------------ 258 | So by now I'm sure you've thought *but what if I want to select tests from 259 | totally different parts of the tree?* 260 | 261 | Well lucky for you some %magics have been added to the shell to help with just 262 | that problem: 263 | 264 | .. code-block:: python 265 | 266 | '0' selected >>> tt.test_setB.test_modes 267 | Out[1]: 268 | 269 | 0 270 | 1 271 | 2 272 | 273 | Total 3 tests 274 | 275 | '0' selected >>> add tt.test_setB.test_modes[-2:] 276 | 277 | '2' selected >>> 278 | 279 | You can easily show the contents of your selection 280 | 281 | .. code-block:: python 282 | 283 | '2' selected >>> show 284 | 285 | 0 286 | 1 287 | 288 | '2' selected >>> 289 | 290 | You can also remove tests from the current selection by index 291 | 292 | .. code-block:: python 293 | 294 | '2' selected >>> remove 1 295 | 296 | '1' selected >>> show 297 | 298 | 0 299 | 300 | '1' selected >>> 301 | 302 | When ready to run your tests simply exit the shell 303 | 304 | .. code-block:: python 305 | 306 | '1' selected >>> exit 307 | 308 | For additional docs on the above shell %magics simply use the ``%?`` magic 309 | syntax available in the IPython shell (i.e. ``add?`` or ``remove?`` or 310 | ``show?``). 311 | 312 | 313 | ``lastfailed`` tests 314 | -------------------- 315 | ``pytest`` exposes the list of failed test from the most recent run 316 | and stores them in its `cache`_. Normally you can select only those 317 | test using the ``--lf`` flag. ``pytest-interactive`` always wraps 318 | the `last failed` test set in its shell's local namespace using under 319 | name ``lastfailed``. 320 | 321 | 322 | Using the ``pytest`` cache 323 | -------------------------- 324 | You can store test sets for access across sessions using the ``pytest`` 325 | `cache`_. A magic ``%cache`` is available for creating and deleting 326 | entries: 327 | 328 | .. code-block:: python 329 | 330 | '0' selected >>> cache add tt.test_setB.test_modes setb 331 | 332 | '0' selected >>> cache 333 | 334 | Summary: 335 | setb -> 3 items 336 | 337 | '0' selected >>> setb # the local namespace is auto-updated 338 | 339 | 0 340 | 1 341 | 2 342 | 343 | '0' selected >>> exit 344 | 345 | 346 | # ... come back in a later session ... 347 | 348 | 349 | # the local namespace is auto-populated with cache entries 350 | '0' selected >>> setb 351 | 352 | 0 353 | 1 354 | 2 355 | 356 | '0' selected >>> cache del setb 357 | Deleted cache entry for 'setb' 358 | 359 | See ``%cache?`` for full command details. 360 | 361 | API reference 362 | ------------- 363 | .. toctree:: 364 | :maxdepth: 2 365 | 366 | plugin 367 | shell 368 | 369 | .. links 370 | .. _cache: 371 | http://doc.pytest.org/en/latest/cache.html 372 | -------------------------------------------------------------------------------- /docs/plugin.rst: -------------------------------------------------------------------------------- 1 | main plugin module 2 | ------------------ 3 | 4 | .. automodule:: interactive.plugin 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/shell.rst: -------------------------------------------------------------------------------- 1 | custom IPython shell 2 | -------------------- 3 | 4 | .. automodule:: interactive.shell 5 | :members: 6 | :show-inheritance: 7 | -------------------------------------------------------------------------------- /example_test_set/tests/subsets/subsubset/test_setB.py: -------------------------------------------------------------------------------- 1 | # test set for plugin testing 2 | # from IPython import embed 3 | import pytest 4 | 5 | class Dut(object): 6 | 'fake a device under test' 7 | 8 | _allowed = ('a', 'b', 'c') 9 | 10 | def __init__(self, mode=None): 11 | self._mode = mode 12 | 13 | def get_mode(self): 14 | return self._mode 15 | 16 | def set_mode(self, val): 17 | self._mode = val 18 | 19 | def check_mode(self): 20 | assert self._mode in self._allowed 21 | 22 | # fixtures 23 | @pytest.fixture 24 | def dut(request): 25 | return Dut('c') 26 | 27 | 28 | @pytest.yield_fixture(params=('a', 'b', 'c')) 29 | def mode(request, dut): 30 | orig_mode = dut.get_mode() 31 | dut.set_mode(request.param) 32 | yield dut 33 | dut.set_mode(orig_mode) 34 | 35 | 36 | @pytest.yield_fixture(params=[1, 2, 3]) 37 | def inputs(request): 38 | yield request.param 39 | 40 | 41 | def test_modes(mode): 42 | assert mode.check_mode() 43 | 44 | 45 | def test_inputs(inputs): 46 | assert inputs < 2 47 | 48 | 49 | class TestBoth(object): 50 | def test_m(self, mode, inputs): 51 | assert mode.check_mode() 52 | assert inputs < 2 53 | -------------------------------------------------------------------------------- /example_test_set/tests/subsets/test_setA.py: -------------------------------------------------------------------------------- 1 | # test set for plugin testing 2 | # from IPython import embed 3 | import pytest 4 | 5 | class Dut(object): 6 | 'fake a device under test' 7 | 8 | _allowed = ('a', 'b', 'c') 9 | 10 | def __init__(self, mode=None): 11 | self._mode = mode 12 | 13 | def get_mode(self): 14 | return self._mode 15 | 16 | def set_mode(self, val): 17 | self._mode = val 18 | 19 | def check_mode(self): 20 | assert self._mode in self._allowed 21 | 22 | # fixtures 23 | @pytest.fixture 24 | def dut(request): 25 | return Dut('c') 26 | 27 | 28 | @pytest.yield_fixture(params=('a', 'b', 'c')) 29 | def mode(request, dut): 30 | orig_mode = dut.get_mode() 31 | dut.set_mode(request.param) 32 | yield dut 33 | dut.set_mode(orig_mode) 34 | 35 | 36 | @pytest.yield_fixture(params=[1, 2, 3]) 37 | def inputs(request): 38 | yield request.param 39 | 40 | 41 | def test_modes(mode): 42 | assert mode.check_mode() 43 | 44 | 45 | def test_inputs(inputs): 46 | assert inputs < 2 47 | 48 | 49 | class TestBoth(object): 50 | def test_m(self, mode, inputs): 51 | assert mode.check_mode() 52 | assert inputs < 2 53 | -------------------------------------------------------------------------------- /example_test_set/tests/test_inheritance.py: -------------------------------------------------------------------------------- 1 | # test set for inheritance bug 2 | import pytest 3 | 4 | # thanks to: 5 | # http://stackoverflow.com/questions/11281698/python-dynamic-class-methods 6 | class HookMeta(type): 7 | def __init__(cls, name, bases, dct): 8 | super(HookMeta, cls).__init__(name, bases, dct) 9 | 10 | # from types import MethodType 11 | # def __getattr__(cls, attr): 12 | # print('getting!') 13 | # print(HookMeta.old_set) 14 | # if attr is '__setattr__': 15 | # print('got setattr!') 16 | # def set_hook(cls, key, value): 17 | # ipdb.set_trace() 18 | # # old_set(cls, key, value) 19 | # # setattr(cls, attr, classmethod(set_hook)) 20 | # return MethodType(set_hook, cls, cls.__metaclass__) 21 | # # return classmethod(set_hook)#getattr(cls, attr) 22 | # return super(HookMeta, cls).__getattribute__(attr) 23 | 24 | def __setattr__(cls, attr, value): 25 | print("attr: '{}', value: '{}'".format(attr, value)) 26 | print("class: '{}'".format(cls)) 27 | # ipdb.set_trace() 28 | return super(HookMeta, cls).__setattr__(attr, value) 29 | 30 | 31 | class TestBase(object): 32 | __metaclass__ = HookMeta 33 | 34 | def test_cls(self): 35 | assert 1 36 | 37 | @pytest.fixture 38 | def doggy(): 39 | return 'doggy' 40 | 41 | @pytest.mark.usefixtures('doggy') 42 | class TestChild(TestBase): 43 | pass 44 | 45 | class TestSibling(TestBase): 46 | pass 47 | -------------------------------------------------------------------------------- /example_test_set/tests/test_set_root.py: -------------------------------------------------------------------------------- 1 | # test set for plugin testing 2 | # from IPython import embed 3 | import pytest 4 | 5 | class Dut(object): 6 | 'fake a device under test' 7 | 8 | _allowed = ('a', 'b', 'c') 9 | 10 | def __init__(self, mode=None): 11 | self._mode = mode 12 | 13 | def get_mode(self): 14 | return self._mode 15 | 16 | def set_mode(self, val): 17 | self._mode = val 18 | 19 | def check_mode(self): 20 | assert self._mode in self._allowed 21 | 22 | # fixtures 23 | @pytest.fixture 24 | def dut(request): 25 | return Dut('c') 26 | 27 | 28 | @pytest.yield_fixture(params=('a', 'b', 'c')) 29 | def mode(request, dut): 30 | orig_mode = dut.get_mode() 31 | dut.set_mode(request.param) 32 | yield dut 33 | dut.set_mode(orig_mode) 34 | 35 | 36 | @pytest.yield_fixture(params=['dog', 'cat', 'mouse']) 37 | def inputs(request): 38 | yield request.param 39 | 40 | 41 | def test_modes(mode): 42 | assert mode.check_mode() 43 | 44 | 45 | def test_inputs(inputs): 46 | assert inputs < 2 47 | 48 | 49 | class TestBoth(object): 50 | def test_m(self, mode, inputs): 51 | assert mode.check_mode() 52 | assert inputs < 2 53 | -------------------------------------------------------------------------------- /example_test_set/tests2/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goodboy/pytest-interactive/59d2819d7df6bbf1b4f3abdd4554719f56e7e07f/example_test_set/tests2/__init__.py -------------------------------------------------------------------------------- /example_test_set/tests2/test_set_root2.py: -------------------------------------------------------------------------------- 1 | # test set for plugin testing 2 | # from IPython import embed 3 | import pytest 4 | 5 | class Dut(object): 6 | 'fake a device under test' 7 | 8 | _allowed = ('a', 'b', 'c') 9 | 10 | def __init__(self, mode=None): 11 | self._mode = mode 12 | 13 | def get_mode(self): 14 | return self._mode 15 | 16 | def set_mode(self, val): 17 | self._mode = val 18 | 19 | def check_mode(self): 20 | assert self._mode in self._allowed 21 | 22 | # fixtures 23 | @pytest.fixture 24 | def dut(request): 25 | return Dut('c') 26 | 27 | 28 | @pytest.yield_fixture(params=('a', 'b', 'c')) 29 | def mode(request, dut): 30 | orig_mode = dut.get_mode() 31 | dut.set_mode(request.param) 32 | yield dut 33 | dut.set_mode(orig_mode) 34 | 35 | 36 | @pytest.yield_fixture(params=[1, 2, 3]) 37 | def inputs(request): 38 | yield request.param 39 | 40 | 41 | def test_modes(mode): 42 | assert mode.check_mode() 43 | 44 | 45 | def test_inputs(inputs): 46 | assert inputs < 2 47 | 48 | 49 | class TestBoth(object): 50 | def test_m(self, mode, inputs): 51 | assert mode.check_mode() 52 | assert inputs < 2 53 | -------------------------------------------------------------------------------- /ideas.txt: -------------------------------------------------------------------------------- 1 | outstandings: 2 | - don't require a -s so output can still be supressed 3 | - overall usage docs 4 | - magics docs 5 | 6 | 7 | features: 8 | - consider running tests from a fixture such as 9 | this:http://pytest.org/latest/example/special.html 10 | - ability to invoke fixtures for a test from ipshell 11 | -> switch to state A, run some tests, restore, switch to state B 12 | run some other tests...etc. 13 | - instead of 'tt' as the base ref why not use the test dir name? 14 | -> obvs means announcing it at the splash and inserting it in the shell ns 15 | (we can keep tt there as well) 16 | - rerun the last pytest selection without exitting from the parent 17 | process (i.e. resume the ipshell with it's current state 18 | -> is there a way to save this state across pytest sessions/processes? 19 | - when debugger is hit offer a list of fixturevalues which can be 20 | played with to see the state of resources/devices involved in the test 21 | -> maybe allow user to enter into the previous ipshell+state? 22 | - pytest session time shouldn't include ipython time?? 23 | 24 | 25 | DONE - select subsets of a parametrized test instances by callspec id 26 | DONE - history from this shell saved for exclusive re-use 27 | DONE - pretty printer for default __repr__ 28 | DONE - move ipshell stuff to separate module and only load when config.capture != 'no' 29 | DONE - show item selection in the ipython prompt 30 | DONE - allow for index/slice selection of any test subset 31 | -------------------------------------------------------------------------------- /interactive/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goodboy/pytest-interactive/59d2819d7df6bbf1b4f3abdd4554719f56e7e07f/interactive/__init__.py -------------------------------------------------------------------------------- /interactive/plugin.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import math 3 | import errno 4 | import re 5 | import os 6 | from os.path import expanduser, join 7 | from operator import attrgetter, itemgetter 8 | from collections import OrderedDict, namedtuple 9 | 10 | 11 | def pytest_addoption(parser): 12 | parser.addoption("--interactive", "--ia", action="store_true", 13 | dest='interactive', 14 | help="enable iteractive selection of tests after" 15 | " collection") 16 | 17 | 18 | @pytest.hookimpl(trylast=True) 19 | def pytest_collection_modifyitems(session, config, items): 20 | """called after collection has been performed, may filter or re-order 21 | the items in-place. 22 | """ 23 | if not (config.option.interactive and items): 24 | return 25 | 26 | capman = config.pluginmanager.getplugin("capturemanager") 27 | if capman: 28 | if getattr(capman, 'suspendcapture', False): 29 | capman.suspendcapture(in_=True) 30 | else: 31 | capman.suspend_global_capture(in_=True) 32 | 33 | tr = config.pluginmanager.getplugin('terminalreporter') 34 | 35 | from .shell import PytestShellEmbed, SelectionMagics 36 | # prep a separate ipython history file 37 | fname = 'shell_history.sqlite' 38 | confdir = join(expanduser('~'), '.config', 'pytest_interactive') 39 | try: 40 | os.makedirs(confdir) 41 | except OSError as e: # py2 compat 42 | if e.errno == errno.EEXIST: 43 | pass 44 | else: 45 | raise 46 | 47 | selection = FuncCollection() 48 | 49 | PytestShellEmbed.pytest_hist_file = join(confdir, fname) 50 | ipshell = PytestShellEmbed(banner1='Entering IPython shell...') 51 | ipshell.register_magics(SelectionMagics) 52 | # shell needs ref to curr selection 53 | ipshell.selection = selection 54 | 55 | # build a tree of test items 56 | tr.write_line("Building test tree...") 57 | # test tree needs ref to shell 58 | tree = TestTree(items, tr, ipshell, selection, config) 59 | 60 | intro = """Welcome to pytest-interactive, the pytest + IPython sensation!\n 61 | Please explore the collected test tree using tt. 62 | HINT: when finished tabbing to a test node, simply __call__() it to have 63 | pytest invoke all tests collected under that node.""" 64 | 65 | user_ns = { 66 | '_tree': tree, 67 | 'tt': tree._root, 68 | 'shell': ipshell, 69 | 'config': config, 70 | 'session': session, 71 | '_selection': selection, 72 | 'lastfailed': tree.get_cache_items(path='cache/lastfailed'), 73 | } 74 | 75 | # preload cached test sets 76 | for name, testnames in tree.get_cache_dict().items(): 77 | user_ns[name] = tree.get_cache_items(key=name) 78 | 79 | # embed and block until user exits 80 | ipshell(intro, local_ns=user_ns) 81 | 82 | # submit final selection 83 | if selection: 84 | items[:] = list(selection.values())[:] 85 | else: 86 | items[:] = [] 87 | 88 | if capman: 89 | if getattr(capman, 'resumecapture', False): 90 | capman.resumecapture 91 | else: 92 | capman.resume_global_capture() 93 | 94 | 95 | _root_ids = ('.', '') 96 | Package = namedtuple('Package', 'name path node parent') 97 | 98 | 99 | def gen_nodes(item, cache, root_name): 100 | '''generate all parent objs of this node up to the root/session 101 | ''' 102 | path = () 103 | # pytest node api - lists path items in order 104 | chain = item.listchain() 105 | for node in chain: 106 | # when either Instance or non-packaged module 107 | if isinstance(node, pytest.Instance): 108 | # leave out Instances, later versions 'should' drop them 109 | continue 110 | try: 111 | name = node.name.replace(os.path.sep, '.') 112 | if '.py' in name: 113 | name = name.rstrip('.py') 114 | except AttributeError as ae: 115 | if node.nodeid in _root_ids: 116 | name = root_name 117 | else: # XXX should never get here 118 | raise ae 119 | # packaged module 120 | if '.' in name and isinstance(node, pytest.Module): 121 | # FIXME: this should be cwd dependent!!! 122 | # (i.e. don't add package objects we're below in the fs) 123 | prefix = tuple(name.split('.')) 124 | lpath = node.fspath 125 | fspath = str(lpath) 126 | # don't include the mod name in path 127 | for level in prefix[:-1]: 128 | name = '{}{}'.format(fspath[:fspath.index(level)], level) 129 | path += (level,) 130 | yield path, Package(name, lpath, node, node.parent) 131 | name = prefix[-1] # this mod's name 132 | # func item 133 | elif isinstance(node, pytest.Item): 134 | name = node.name 135 | if '[' in name: 136 | funcname = name.split('[')[0] 137 | try: 138 | # TODO: look up the pf based on the vanilla func obj 139 | # (should be an attr on the _pyfuncitem...) 140 | pf = cache[path + (funcname,)] 141 | except KeyError: 142 | # parametrized func is a collection of funcs 143 | pf = FuncCollection() 144 | pf.name = funcname 145 | pf.parent = node.parent # set parent like other nodes 146 | pf.append(node) 147 | path += (funcname,) 148 | yield path, pf 149 | # all other nodes 150 | path += (name,) 151 | yield path, node 152 | 153 | 154 | def dirinfo(obj): 155 | """return relevant __dir__ info for obj 156 | """ 157 | return sorted(set(dir(type(obj)) + list(obj.__dict__.keys()))) 158 | 159 | 160 | def tosymbol(ident): 161 | """Replace illegal python characters with underscores 162 | in the provided string identifier and return 163 | """ 164 | ident = str(ident) 165 | ident = ident.replace(' ', '_') 166 | ident = re.sub('[^a-zA-Z0-9_]', '_', ident) 167 | if ident and ident[0].isdigit(): 168 | return '' 169 | return ident 170 | 171 | 172 | class FuncCollection(object): 173 | '''A selection of functions 174 | ''' 175 | def __init__(self, funcitems=None): 176 | self.funcs = OrderedDict() 177 | if funcitems: 178 | if not isinstance(funcitems, list): 179 | funcitems = [funcitems] 180 | for item in funcitems: 181 | self.append(item) 182 | 183 | def append(self, item, attr_path='nodeid'): 184 | # self.funcs[tosymbol(attrgetter(attr_path)(item))] = item 185 | self.funcs[attrgetter(attr_path)(item)] = item 186 | 187 | def addtests(self, test_set): 188 | for item in test_set._items: 189 | self.append(item) 190 | 191 | def remove(self, item): 192 | self.funcs.pop(item.nodeid, None) 193 | 194 | def removetests(self, test_set): 195 | for item in test_set._items: 196 | self.remove(item) 197 | 198 | def clear(self): 199 | self.funcs.clear() 200 | 201 | def keys(self): 202 | return self.funcs.keys() 203 | 204 | def values(self): 205 | return self.funcs.values() 206 | 207 | def __len__(self): 208 | return len(self.funcs) 209 | 210 | def __getitem__(self, key): 211 | if isinstance(key, int): 212 | return self.enumitems()[key][1] 213 | if isinstance(key, (int, slice)): 214 | return list(map(itemgetter(1), self.enumitems()[key])) 215 | return self.funcs[key] 216 | 217 | def __dir__(self): 218 | return dirinfo(self) 219 | 220 | def items(self): 221 | return self.funcs.items() 222 | 223 | def enumitems(self, items=None): 224 | if not items: 225 | items = self.funcs.values() 226 | return [(i, node) for i, node in enumerate(items)] 227 | 228 | 229 | class TestTree(object): 230 | '''A tree of all collected tests 231 | ''' 232 | def __init__(self, funcitems, termrep, shell, selection, config): 233 | self._funcitems = funcitems # never modify this 234 | self._selection = selection # items must be unique 235 | self._path2items = OrderedDict() 236 | self._item2paths = {} 237 | self._path2children = {} 238 | self._nodes = {} 239 | self._cache = {} 240 | root_name = funcitems[0].listchain()[0].name if funcitems else '' 241 | for item in funcitems: 242 | for path, node in gen_nodes(item, self._nodes, root_name): 243 | self._path2items.setdefault(path, []).append(item) 244 | self._item2paths.setdefault(item, []).append(path) 245 | if path not in self._nodes: 246 | self._nodes[path] = node 247 | # map parent path to set of children paths 248 | self._path2children.setdefault(path[:-1], []).append(path) 249 | self._root = TestSet(self, (root_name,)) 250 | self.__class__.__getitem__ = self._root.__getitem__ 251 | # pytest terminal reporter 252 | self._tr = termrep 253 | self._shell = shell 254 | self._config = config 255 | 256 | def from_items(self, items): 257 | return type(self)(items, self._tr, self._shell, self._selection, 258 | self._config) 259 | 260 | def __getattr__(self, key): 261 | try: 262 | object.__getattribute__(self, key) 263 | except AttributeError: 264 | return getattr(self._root, key) 265 | 266 | def _tprint(self, items, tr=None): 267 | '''extended from 268 | pytest.terminal.TerminalReporter._printcollecteditems 269 | ''' 270 | if not tr: 271 | tr = self._tr 272 | if not items: 273 | tr.write('ERROR: ', red=True) 274 | tr.write_line("not enough items to display") 275 | return 276 | stack = [] 277 | indent = "" 278 | ncols = int(math.ceil(math.log10(len(items)))) 279 | for i, item in enumerate(items): 280 | needed_collectors = item.listchain()[1:] # strip root node 281 | while stack: 282 | if stack == needed_collectors[:len(stack)]: 283 | break 284 | stack.pop() 285 | for col in needed_collectors[len(stack):]: 286 | if col.name == "()": 287 | continue 288 | stack.append(col) 289 | indent = (len(stack) - 1) * " " 290 | if col == item: 291 | index = "{}".format(i) 292 | else: 293 | index = '' 294 | indent = indent[:-len(index) or None] + (ncols+1) * " " 295 | tr.write("{}".format(index), green=True) 296 | tr.write_line("{}{}".format(indent, col)) 297 | 298 | def err(self, msg): 299 | self._tr.write("ERROR: ", red=True) 300 | self._tr.write_line(msg) 301 | 302 | def get_cache_dict(self, path=None): 303 | if path is None: 304 | path = '/'.join(['pytest-interactive', 'cache']) 305 | 306 | cache = self._config.cache 307 | cachedict = cache.get(path, None) 308 | if cachedict is None: 309 | cache.set(path, {}) 310 | cachedict = cache.get(path, None) 311 | 312 | return cachedict 313 | 314 | def get_cache_items(self, path=None, key=None): 315 | entry = self.get_cache_dict(path=path) 316 | testnames = entry.get(key) if key else entry 317 | 318 | if not testnames: 319 | return self.err( 320 | "No cache entry for '{}'" 321 | .format('{}[key={}]'.format(path, key))) 322 | 323 | items_dict = OrderedDict( 324 | [(item.nodeid, item) for item in self._funcitems]) 325 | 326 | return self.from_items( 327 | [items_dict.get(name) for name in testnames 328 | if items_dict.get(name)] 329 | )._root 330 | 331 | def set_cache_items(self, key, testset): 332 | """Enter test items for the given name into the cache under 333 | the provided key. If ``bool(testset) == False`` delete the entry. 334 | """ 335 | cachedict = self.get_cache_dict() 336 | names = [] 337 | if testset: 338 | for item in testset._items: 339 | names.append(item.nodeid) 340 | if not names: 341 | # remove entry when empty 342 | cachedict.pop(key, None) 343 | else: 344 | cachedict[key] = names 345 | cache = self._config.cache 346 | cache.set("pytest-interactive/cache", cachedict) 347 | 348 | 349 | def item2params(item): 350 | cs = getattr(item, 'callspec', None) 351 | # return map(tosymbol, cs.params.values()) 352 | return tuple(map(tosymbol, cs.id.split('-'))) if cs else () 353 | 354 | 355 | def by_name(idents): 356 | if idents: 357 | def predicate(item): 358 | params = item2params(item) 359 | for ident in idents: 360 | if ident not in params: 361 | return False 362 | return True 363 | return predicate 364 | else: 365 | return lambda item: True 366 | 367 | 368 | class TestSet(object): 369 | '''Represent a pytest node/item test set for use as a tab complete-able 370 | object in ipython. An internal reference is kept to the pertaining pytest 371 | Node and hierarchical lookups are delegated to the containing TestTree. 372 | ''' 373 | def __init__(self, tree, path, indices=None, params=()): 374 | self._tree = tree 375 | self._path = path 376 | self._len = len(path) 377 | if indices is None: 378 | indices = slice(indices) 379 | elif isinstance(indices, int): 380 | # create a slice which will slice out a single element 381 | # (the 'or' expr is here for the 'indices = -1' case) 382 | indices = slice(indices, indices + 1 or None) 383 | self._ind = indices # might be a slice 384 | self._params = params 385 | self._paramf = by_name(params) 386 | 387 | def __str__(self): 388 | return "<{} with {} items>".format( 389 | type(self).__name__, len(self._items)) 390 | 391 | def __repr__(self): 392 | """Pretty print the current set to console 393 | """ 394 | self._tree._tr.write_line("") 395 | items = self._items 396 | self._tree._tprint(items) 397 | self._tree._tr.write_line("") 398 | # nodename = getattr(self._node, 'name', None) 399 | # TODO: it'd be nice if we could render the std pytest cli selection 400 | # syntax here for copy paste to a direct shell invocation. 401 | ident = "Total {} tests".format(len(items)) 402 | return ident 403 | 404 | def __dir__(self): 405 | if isinstance(self._node, FuncCollection): 406 | return dir(self.params) 407 | return self._childkeys + ['params'] 408 | 409 | @property 410 | def _childkeys(self): 411 | '''sorted list of child keys 412 | ''' 413 | return sorted([key[self._len] for key in self._iterchildren()]) 414 | 415 | @property 416 | def params(self): 417 | """Return a `CallSpecParameters` object who's instance variables are 418 | named according to available 'callspec parameters' in child nodes and 419 | who's values are `TestSets` corresponding to tests which contain those 420 | parameters 421 | """ 422 | def _new(ident): 423 | """Closure who delivers a func who returns new `TestSets` based on 424 | this one but with an extended `_params` according to `ident` 425 | """ 426 | @property 427 | def test_set(pself): 428 | return self._new(params=self._params + (ident,)) 429 | return test_set 430 | 431 | ns = {} 432 | for item in self._items: 433 | ns.update({ident: _new(ident) for ident in item2params(item) 434 | if ident and ident not in self._params}) 435 | return type('CallspecParameters', (), ns)() 436 | 437 | def _iterchildren(self): 438 | # if we have callspec ids in our getattr chain, filter out any 439 | # children who's items are not in our set by checking the 440 | # intersection of our items with child items 441 | for path in self._tree._path2children[self._path]: 442 | if set(self._tree._path2items[path]) & set(self._items): 443 | yield path 444 | 445 | def __iter__(self): 446 | for path in self._iterchildren(): 447 | yield self._new(path=path) 448 | 449 | @property 450 | def _items(self): 451 | # XXX might it be possible here to do something more efficient here? 452 | return [item for item in filter(self._paramf, 453 | self._tree._path2items[self._path])][self._ind] 454 | 455 | def _enumitems(self): 456 | return self._tree._selection.enumitems(self._items) 457 | 458 | def __getitem__(self, key): 459 | '''Return a new subset/node 460 | ''' 461 | if isinstance(key, str): 462 | if key is 'parent': 463 | return self._new(path=self._path[:-1]) 464 | elif key in self._childkeys: # key is a subchild name 465 | return self._new(path=self._path + (key,)) 466 | else: 467 | if key in dir(self.params): 468 | return self._new(params=self._params + (key,)) 469 | raise KeyError(key) 470 | elif isinstance(key, (int, slice)): 471 | return self._new(indices=key) 472 | 473 | def _new(self, tree=None, path=None, indices=None, params=None): 474 | # do caching? return self._tree._cache.setdefault(args*, ... 475 | return type(self)( 476 | tree or self._tree, 477 | path or self._path, 478 | indices, 479 | params or self._params) 480 | 481 | def __getattr__(self, attr): 482 | try: 483 | return object.__getattribute__(self, attr) 484 | except AttributeError: 485 | try: 486 | return self[attr] 487 | except KeyError as ke: 488 | raise AttributeError(ke) 489 | 490 | @property 491 | def _node(self, path=None): 492 | return self._tree._nodes[path or self._path] 493 | 494 | def __call__(self, key=None): 495 | """Select and run all tests under this node 496 | plus any already previously selected once shell exits 497 | """ 498 | self._tree._selection.addtests(self) 499 | self._tree._shell.exit() 500 | if self._tree._shell.keep_running: 501 | # if user aborts remove all tests from this set 502 | self._tree._selection.removetests(self) 503 | -------------------------------------------------------------------------------- /interactive/shell.py: -------------------------------------------------------------------------------- 1 | """ 2 | An extended shell for test selection 3 | """ 4 | import keyword 5 | import re 6 | from IPython.terminal.embed import InteractiveShellEmbed 7 | from IPython.core.magic import (Magics, magics_class, line_magic) 8 | from IPython.core.history import HistoryManager 9 | from IPython.terminal.prompts import Prompts, Token 10 | 11 | 12 | class TestCounterPrompt(Prompts): 13 | def in_prompt_tokens(self, cli=None): 14 | """Render a simple prompt which reports the number of currently 15 | selected tests. 16 | """ 17 | return [ 18 | (Token.PromptNum, '{}'.format( 19 | len(self.shell.user_ns['_selection']))), 20 | (Token.Prompt, ' selected >>> '), 21 | ] 22 | 23 | 24 | class PytestShellEmbed(InteractiveShellEmbed): 25 | """Custom ip shell with a slightly altered exit message 26 | """ 27 | prompts_class = TestCounterPrompt 28 | # cause if you don't use it shame on you 29 | editing_mode = 'vi' 30 | 31 | def init_history(self): 32 | """Sets up the command history, and starts regular autosaves. 33 | 34 | .. note:: 35 | A separate history db is allocated for this plugin separate 36 | from regular shell sessions such that only relevant commands 37 | are retained. 38 | """ 39 | self.history_manager = HistoryManager( 40 | shell=self, parent=self, hist_file=self.pytest_hist_file) 41 | self.configurables.append(self.history_manager) 42 | 43 | def exit(self): 44 | """Handle interactive exit. 45 | This method calls the ``ask_exit`` callback and if applicable prompts 46 | the user to verify the current test selection 47 | """ 48 | if getattr(self, 'selection', None): 49 | print(" \n".join(self.selection.keys())) 50 | msg = "\nYou have selected the above {} test(s) to be run."\ 51 | "\nWould you like to run pytest now? ([y]/n)?"\ 52 | .format(len(self.selection)) 53 | else: 54 | msg = 'Do you really want to exit ([y]/n)?' 55 | if self.ask_yes_no(msg, 'y'): 56 | # sets self.keep_running to False 57 | self.ask_exit() 58 | 59 | 60 | @magics_class 61 | class SelectionMagics(Magics): 62 | """Custom magics for performing multiple test selections 63 | within a single session 64 | """ 65 | # XXX do we actually need this or can we do `user_ns` lookups? 66 | def ns_eval(self, line): 67 | '''Evalutate line in the embedded ns and return result 68 | ''' 69 | ns = self.shell.user_ns 70 | return eval(line, ns) 71 | 72 | @property 73 | def tt(self): 74 | return self.ns_eval('_tree') 75 | 76 | @property 77 | def selection(self): 78 | return self.tt._selection 79 | 80 | @property 81 | def tr(self): 82 | return self.tt._tr 83 | 84 | def err(self, msg="No tests selected"): 85 | self.tt.err(msg) 86 | 87 | @line_magic 88 | def add(self, line): 89 | '''Add tests from a test set to the current selection. 90 | 91 | Usage: 92 | 93 | add tt : add all tests in the current tree 94 | add tt[4] : add 5th test in the current tree 95 | add tt.tests[1:10] : add tests 1-9 found under the 'tests' module 96 | ''' 97 | if line: 98 | ts = self.ns_eval(line) 99 | if ts: 100 | self.selection.addtests(ts) 101 | else: 102 | raise TypeError("'{}' is not a test set".format(ts)) 103 | else: 104 | print("No test set provided?") 105 | 106 | @line_magic 107 | def remove(self, line, delim=','): 108 | """Remove tests from the current selection using a slice syntax 109 | using a ',' delimiter instead of ':'. 110 | 111 | Usage: 112 | 113 | remove : remove all tests from the current selection 114 | remove -1 : remove the last item from the selection 115 | remove 1, : remove all but the first item (same as [1:]) 116 | remove ,,-3 : remove every third item (same as [::-3]) 117 | """ 118 | selection = self.selection 119 | if not self.selection: 120 | self.err() 121 | return 122 | if not line: 123 | selection.clear() 124 | return 125 | # parse out slice 126 | if delim in line: 127 | slc = slice(*map(lambda x: int(x.strip()) if x.strip() else None, 128 | line.split(delim))) 129 | for item in selection[slc]: 130 | selection.remove(item) 131 | else: # just an index 132 | try: 133 | selection.remove(selection[int(line)]) 134 | except ValueError: 135 | self.err("'{}' is not and index or slice?".format(line)) 136 | 137 | @line_magic 138 | def show(self, test_set): 139 | '''Show all currently selected test by pretty printing 140 | to the console. 141 | 142 | Usage: 143 | 144 | show: print currently selected tests 145 | ''' 146 | items = self.selection.values() 147 | if items: 148 | self.tt._tprint(items) 149 | else: 150 | self.err() 151 | 152 | @line_magic 153 | def cache(self, line, ident='pytest/interactive'): 154 | """Store a set of tests in the pytest cache for retrieval in another 155 | session. 156 | 157 | Usage: 158 | 159 | cache: show a summary of names previously stored in the cache. 160 | 161 | cache del : deletes the named entry from the cache. 162 | 163 | cache add : stores the named tests as target name. 164 | """ 165 | cachedict = self.tt.get_cache_dict() 166 | if line: 167 | tokens = line.split() 168 | subcmd, name = tokens[0], tokens[1] 169 | 170 | if subcmd == 'del': # delete an entry 171 | name = tokens[1] 172 | self.tt.set_cache_items(name, None) # delete 173 | self.shell.user_ns.pop(name) 174 | self.tr.write( 175 | "Deleted cache entry for '{}'\n".format(name)) 176 | return 177 | 178 | elif subcmd == 'add': # create a new entry 179 | target = tokens[2] 180 | if not re.match("[_A-Za-z][_a-zA-Z0-9]*$", target) \ 181 | and not keyword.iskeyword(name): 182 | self.tt.err("'{}' is not a valid identifier" 183 | .format(target)) 184 | return 185 | 186 | testset = self.ns_eval(name) 187 | self.tt.set_cache_items(target, testset) 188 | 189 | # update the local shell's ns 190 | if testset: 191 | self.shell.user_ns[target] = testset 192 | self.tr.write( 193 | "Created cache entry for '{}'\n".format(name)) 194 | return 195 | 196 | self.tt.err("'{}' is invalid. See %cache? for usage.".format(line)) 197 | else: 198 | tr = self.tr 199 | tr.write("\nSummary:\n", green=True) 200 | for name, testnames in cachedict.items(): 201 | tr.write('{} -> {} items\n'.format(name, len(testnames))) 202 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | with open('README.rst') as f: 5 | readme = f.read() 6 | 7 | 8 | setup( 9 | name="pytest-interactive", 10 | version='0.1.4', 11 | description='A pytest plugin for console based interactive test selection' 12 | ' just after the collection phase', 13 | long_description=readme, 14 | license='MIT', 15 | author='Tyler Goodlet', 16 | author_email='tgoodlet@gmail.com', 17 | url='https://github.com/tgoodlet/pytest-interactive', 18 | platforms=['linux'], 19 | packages=['interactive'], 20 | entry_points={'pytest11': [ 21 | 'interactive = interactive.plugin' 22 | ]}, 23 | zip_safe=False, 24 | install_requires=['pytest>=2.4.2', 'ipython>=5.0'], 25 | classifiers=[ 26 | 'Development Status :: 3 - Alpha', 27 | 'Intended Audience :: Developers', 28 | 'License :: OSI Approved :: MIT License', 29 | 'Operating System :: POSIX', 30 | 'Operating System :: Microsoft :: Windows', 31 | 'Operating System :: MacOS :: MacOS X', 32 | 'Topic :: Software Development :: Testing', 33 | 'Programming Language :: Python :: 2.7', 34 | 'Programming Language :: Python :: 3', 35 | 'Environment :: Console', 36 | ], 37 | ) 38 | --------------------------------------------------------------------------------