diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 7d632a8c..00000000 --- a/.coveragerc +++ /dev/null @@ -1,9 +0,0 @@ -[run] -branch = True -omit = - nox/_typing.py - -[report] -exclude_lines = - pragma: no cover - if _typing.TYPE_CHECKING: diff --git a/.flake8 b/.flake8 deleted file mode 100644 index aa15c81d..00000000 --- a/.flake8 +++ /dev/null @@ -1,6 +0,0 @@ -[flake8] -# Ignore black styles. -ignore = E501, W503, E203 -# Imports -import-order-style = google -application-import-names = nox,tests diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..a9ed9ba8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +name: CI +on: [push, pull_request] +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-20.04, windows-2019] + python-version: [3.6, 3.7, 3.8, 3.9] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Set up Miniconda + uses: conda-incubator/setup-miniconda@v2 + with: + auto-update-conda: true + python-version: ${{ matrix.python-version }} + - name: Install Nox-under-test + run: | + python -m pip install --disable-pip-version-check . + - name: Run tests on ${{ matrix.os }} + run: nox --non-interactive --session "tests-${{ matrix.python-version }}" -- --full-trace + + build-py310: + name: Build python 3.10 + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-20.04, windows-2019] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: "3.10" + # Conda does not support 3.10 yet, hence why it's skipped here + # TODO: Merge the two build jobs when 3.10 is released for conda + - name: Install Nox-under-test + run: | + python -m pip install --disable-pip-version-check . + - name: Run tests on ${{ matrix.os }} + run: nox --non-interactive --session "tests-3.10" -- --full-trace + + lint: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Install Nox-under-test + run: | + python -m pip install --disable-pip-version-check . + - name: Lint + run: nox --non-interactive --session "lint" + docs: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Install Nox-under-test + run: | + python -m pip install --disable-pip-version-check . + - name: Docs + run: nox --non-interactive --session "docs" + deploy: + needs: build + runs-on: ubuntu-20.04 + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + steps: + - uses: actions/checkout@v2 + - name: Build sdist and wheel + run: pipx run build + - name: Publish distribution PyPI + uses: pypa/gh-action-pypi-publish@master + with: + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index ba2778dc..00000000 --- a/.isort.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[settings] -multi_line_output=3 -include_trailing_comma=True -force_grid_wrap=0 -use_parentheses=True -line_length=88 diff --git a/.readthedocs.yml b/.readthedocs.yml index 85dfcbd3..97ca7a58 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -4,7 +4,7 @@ build: image: latest python: - version: 3.6 + version: 3.8 pip_install: true requirements_file: requirements-test.txt diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 0a3b27af..00000000 --- a/.travis.yml +++ /dev/null @@ -1,37 +0,0 @@ -language: python -dist: xenial -matrix: - include: - - python: '3.5' - env: NOXSESSION="tests-3.5" - - python: '3.6' - env: NOXSESSION="tests-3.6" - - python: '3.7' - env: NOXSESSION="tests-3.7" - - python: '3.8' - env: NOXSESSION="tests-3.8" - - python: '3.8' - env: NOXSESSION="lint" - - python: '3.8' - env: NOXSESSION="docs" -before_install: - - wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -O miniconda.sh - - chmod +x miniconda.sh - - ./miniconda.sh -b - # Prefer the built-in python binary. - - export PATH="$PATH:/home/travis/miniconda3/bin" - - conda update --yes conda -install: - - pip install --upgrade pip setuptools - - pip install . -script: nox --non-interactive --session "$NOXSESSION" -deploy: - provider: pypi - user: theacodes - password: - secure: ETRTnYg+8cilT0/HidhyPljERgE/u0boKdH9TW+JrY0De40Km5C+TUmPagKJuwPx1Gw8HNN1vN7M1pqaQ/flQaY9iNbuJZr5ZaApiZW1pw5/nO2wWoANx0hiChdjvwbJZdqUFEoba6MS9aBY7TroFlLjW6dUg8MZFSiUFRQDF9rTCyzB/juC7wiLTgrjlFpOvaOmf1qpVOajY5kfn8MLELms8itRUa04X4kqqgtOfifoA1CevObrScGSXlpPtqmoxUrCmwbnHu9qnqgAWLHe3y7fI4ZqscYQv/JCW8NdJgqMTn0jctLXibHt5vC/DtUYo47mFSRBfn55ZwAFiV6IiwVbtDKby0ZdNO2uIFn4B/7l0qrLTwnZbRy4vkPqEeJoS75vl4JQrauGmI+hgdtesHjZxLzs94H4vINVt0fGpkYqbgtMQO8HUQnnj0FJXcGKo4A5OuLjnk5+rgTSvLT/5qNg/cyve5BXkn1ib6ecah21MHSQyhl5CxIFBH6S6BRrGoxXluLqXPVv/w+QA0lxXCpAPfbHuMt4r9522YN/XfGQNHfNqK/836UbLEX5ZXZiZLl01IvVPl+3eC/Qmpc+tNXb51d53Qsm89VtaNGGvuV2eLPBR+gfXcQ8wFB1HW3Q3oshHCGW4KKApyzyYKWq27JPlGV13Yh+NMHWs9PGHyI= - on: - tags: true - distributions: sdist bdist_wheel - repo: theacodes/nox - condition: "$NOXSESSION = \"tests-3.6\"" diff --git a/CHANGELOG.md b/CHANGELOG.md index 11046e1c..de973122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,102 @@ # Changelog +## 2021.10.1 + +New features: +- Add `session.warn` to output warnings (#482) +- Add a shared session cache directory (#476) +- Add `session.invoked_from` (#472) + +Improvements: +- Conda logs now respect `nox.options.verbose` (#466) +- Add `session.notify` example to docs (#467) +- Add friendlier message if no `noxfile.py` is found (#463) +- Show the `noxfile.py` docstring when using `nox -l` (#459) +- Mention more projects that use Nox in the docs (#460) + +Internal changes: +- Move configs into pyproject.toml or setup.cfg (flake8) (#484) +- Decouple `test_session_completer` from project level noxfile (#480) +- Run Flynt to convert str.format to f-strings (#464) +- Add python 3.10.0-rc2 to GitHub Actions (#475, #479) +- Simplify CI build (#461) +- Use PEP 517 build system, remove `setup.py`, use `setup.cfg` (#456, #457, #458) +- Upgrade to mypy 0.902 (#455) + +Special thanks to our contributors: +- @henryiii +- @cjolowicz +- @FollowTheProcess +- @franekmagiera +- @DiddiLeija + +## 2021.6.12 + +- Fix crash on Python 2 when reusing environments. (#450) +- Hide staleness check behind a feature flag. (#451) +- Group command-line options in `--help` message by function. (#442) +- Avoid polluting tests with a .nox directory. (#445) + +## 2021.6.6 + +- Add option `--no-install` to skip install commands in reused environments. (#432) +- Add option `--force-python` as shorthand for `--python` and `--extra-python`. (#427) +- Do not reuse environments if the interpreter or the environment type has changed. (#418, #425, #428) +- Allow common variations in session names with parameters, such as double quotes instead of single quotes. Session names are considered equal if they produce the same Python AST. (#417, #434) +- Preserve the order of parameters in session names. (#401) +- Allow `@nox.parametrize` to select the session Python. (#413) +- Allow passing `posargs` when scheduling another session via `session.notify`. (#397) +- Prevent sessions from modifying each other's posargs. (#439) +- Add `nox.needs_version` to specify Nox version requirements. (#388) +- Add `session.name` to get the session name. (#386) +- Gracefully shutdown child processes. (#393) +- Decode command output using the system locale if UTF-8 decoding fails. (#380) +- Fix creation of Conda environments when `venv_params` is used. (#420) +- Various improvements to Nox's type annotations. (#376, #377, #378) +- Remove outdated notes on Windows compatibility from the documentation. (#382) +- Increase Nox's test coverage on Windows. (#300) +- Avoid mypy searching for configuration files in other directories. (#402) +- Replace AppVeyor and Travis CI by GitHub Actions. (#389, #390, #403) +- Allow colorlog <7.0.0. (#431) +- Drop contexter from test requirements. (#426) +- Upgrade linters to the latest version. (#438) + +## 2020.12.31 + +- Fix `NoxColoredFormatter.format` (#374) +- Use conda remove to clean up existing conda environments (#373) +- Support users specifying an undeclared parametrization of python via `--extra-python` (#361) +- Support double-digit minor version in `python` keyword (#367) +- Add `py.typed` to `manifest.in` (#360) +- Update nox to latest supported python versions. (#362) +- Decouple merging of `--python` with `nox.options` from `--sessions` and `--keywords` (#359) +- Do not merge command-line options in place (#357) + +## 2020.8.22 + +- `conda_install` and `install` args are now automatically double-quoted when needed. (#312) +- Offline mode is now auto-detected by default by `conda_install`. This allows users to continue executing Nox sessions on already installed environments. (#314) +- Fix the default paths for Conda on Windows where the `python.exe` found was not the correct one. (#310) +- Add the `--add-timestamp` option (#323) +- Add `Session.run_always()`. (#331) + +## 2020.5.24 + +- Add new options for `venv_backend`, including the ability to set the backend globally. (#326) +- Fix various typos in the documentation. (#325, #326, #281) +- Add `session.create_tmp`. (#320) +- Place all of Nox's command-line options into argparse groups. (#306) +- Add the `--pythons` command-line option to allow specifying which versions of Python to run. (#304) +- Add a significant amount of type annotations. (#297, #294, #290, #282, #274) +- Stop building universal wheels since we don't support Python 2. (#293) +- Add the ability to specify additional options for the virtualenv backend using `venv_params`. (#280) +- Prefer `importlib.metadata` for metadata loading, removing our dependency on `pkg_resources`. (#277) +- Add OmegaConf and Hydra to list of projects that use Nox. (#279) +- Use a more accurate error message, along with the cause, if loading of noxfile runs into error. (#272) +- Test against Python 3.8. (#270) +- Fix a syntax highlighting mistake in configuration docs. (#268) +- Use `stdout.isatty` to finalize color instead of `stdin.isatty`. (#267) + ## 2019.11.9 - Fix example installation call for pip. (#259) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 46b79f0d..a8a4069b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,12 +39,13 @@ To just check for lint errors, run: To run against a particular Python version: - nox --session tests-3.5 nox --session tests-3.6 nox --session tests-3.7 nox --session tests-3.8 + nox --session tests-3.9 -When you send a pull request Travis will handle running everything, but it is + +When you send a pull request the CI will handle running everything, but it is recommended to test as much as possible locally before pushing. ## Getting a sticker diff --git a/MANIFEST.in b/MANIFEST.in index 33cd59d9..053167fa 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ include LICENSE recursive-include nox *.jinja2 +include nox/py.typed diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 90cdf763..00000000 --- a/appveyor.yml +++ /dev/null @@ -1,87 +0,0 @@ -version: 1.0.{build}.{branch} - -matrix: - fast_finish: true - -environment: - matrix: - - # Pre-installed Python versions, which AppVeyor may upgrade to - # a later point release. - # See: http://www.appveyor.com/docs/installed-software#python - - - PYTHON: "C:\\Python38" - # There is no miniconda for python3.8 at this time - CONDA: "C:\\Miniconda37" - NOX_SESSION: "tests-3.8" - - - PYTHON: "C:\\Python38-x64" - # There is no miniconda for python3.8 at this time - CONDA: "C:\\Miniconda37-x64" - NOX_SESSION: "tests-3.8" - - - PYTHON: "C:\\Python37" - CONDA: "C:\\Miniconda37" - NOX_SESSION: "tests-3.7" - - - PYTHON: "C:\\Python37-x64" - CONDA: "C:\\Miniconda37-x64" - NOX_SESSION: "tests-3.7" - - - PYTHON: "C:\\Python36" - CONDA: "C:\\Miniconda36" - NOX_SESSION: "tests-3.6" - - - PYTHON: "C:\\Python36-x64" - CONDA: "C:\\Miniconda36-x64" - NOX_SESSION: "tests-3.6" - - - PYTHON: "C:\\Python35" - CONDA: "C:\\Miniconda35" - NOX_SESSION: "tests-3.5" - - - PYTHON: "C:\\Python35-x64" - CONDA: "C:\\Miniconda35-x64" - NOX_SESSION: "tests-3.5" - -install: - # Add conda command to path. - # https://www.tjelvarolsson.com/blog/how-to-continuously-test-your-python-code-on-windows-using-appveyor/ - - "SET PATH=%CONDA%;%CONDA%\\Scripts;%PATH%" - - conda config --set changeps1 no - - conda update -q --yes conda - # Get Python from conda-forge. - - conda config --add channels conda-forge - - conda info -a - - # Prepend newly installed Python to the PATH of this build (this cannot be - # done from inside the powershell script as it would require to restart - # the parent CMD process). - - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" - - # Check that we have the expected version and architecture for Python - - "python --version" - - "python -c \"import struct; print(struct.calcsize('P') * 8)\"" - - # Upgrade to the latest version of pip to avoid it displaying warnings - # about it being out of date. - - "python -m pip install --disable-pip-version-check --user --upgrade pip" - - # Install the build dependencies of the project. If some dependencies contain - # compiled extensions and are not provided as pre-built wheel packages, - # pip will build them from source using the MSVC compiler matching the - # target Python version and architecture - - "python -m pip install wheel" - -# init: -# - ps: iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-rdp.ps1')) - -# on_finish: -# - ps: $blockRdp = $true; iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-rdp.ps1')) - -build_script: - - "python -m pip install ." - -test_script: - # Run the project tests - - "nox.exe --session \"%NOX_SESSION%\"" diff --git a/docs/conf.py b/docs/conf.py index a7029aaf..421b6fae 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -26,48 +26,47 @@ # Note: even though nox is installed when the docs are built, there's a # possibility it's installed as a bytecode-compiled binary (.egg). So, # include the source anyway. -sys.path.insert(0, os.path.abspath('..')) -sys.path.insert(0, os.path.abspath('.')) +sys.path.insert(0, os.path.abspath("..")) +sys.path.insert(0, os.path.abspath(".")) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.napoleon', - 'recommonmark', + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "recommonmark", ] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: -# source_suffix = ['.rst', '.md'] -source_suffix = ['.rst', '.md'] +source_suffix = [".rst", ".md"] # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'Nox' -copyright = u'2016, Alethea Katherine Flowers' -author = u'Alethea Katherine Flowers' +project = "Nox" +copyright = "2016, Alethea Katherine Flowers" +author = "Alethea Katherine Flowers" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = metadata.version('nox') +version = metadata.version("nox") # The full version, including alpha/beta/rc tags. release = version @@ -80,20 +79,20 @@ # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all # documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). @@ -101,16 +100,16 @@ # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'witchhazel.WitchHazelStyle' +pygments_style = "witchhazel.WitchHazelStyle" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# keep_warnings = False # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False @@ -120,179 +119,172 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'alabaster' +html_theme = "alabaster" # 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 # documentation. html_theme_options = { - 'logo': 'alice.png', - 'logo_name': True, - 'description': 'Flexible test automation', - 'github_user': 'theacodes', - 'github_repo': 'nox', - 'github_banner': True, - 'github_button': False, - 'travis_button': False, - 'codecov_button': False, - 'analytics_id': False, # TODO - 'font_family': "'Roboto', Georgia, sans", - 'head_font_family': "'Roboto', Georgia, serif", - 'code_font_family': "'Roboto Mono', 'Consolas', monospace", - 'pre_bg': '#433e56' + "logo": "alice.png", + "logo_name": True, + "description": "Flexible test automation", + "github_user": "theacodes", + "github_repo": "nox", + "github_banner": True, + "github_button": False, + "travis_button": False, + "codecov_button": False, + "analytics_id": False, # TODO + "font_family": "'Roboto', Georgia, sans", + "head_font_family": "'Roboto', Georgia, serif", + "code_font_family": "'Roboto Mono', 'Consolas', monospace", + "pre_bg": "#433e56", } # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. -#html_extra_path = [] +# html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. html_sidebars = { - '**': [ - 'about.html', - 'navigation.html', - 'relations.html', - 'searchbox.html', - 'donate.html', + "**": [ + "about.html", + "navigation.html", + "relations.html", + "searchbox.html", + "donate.html", ] } # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Language to be used for generating the HTML full-text search index. # Sphinx supports the following languages: # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' -#html_search_language = 'en' +# html_search_language = 'en' # A dictionary with options for the search language support, empty by default. # Now only 'ja' uses this config value -#html_search_options = {'type': 'default'} +# html_search_options = {'type': 'default'} # The name of a javascript file (relative to the configuration directory) that # implements a search results scorer. If empty, the default will be used. -#html_search_scorer = 'scorer.js' +# html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. -htmlhelp_basename = 'noxdoc' +htmlhelp_basename = "noxdoc" # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', - -# Latex figure (float) alignment -#'figure_align': 'htbp', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + #'preamble': '', + # Latex figure (float) alignment + #'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'nox.tex', u'nox Documentation', - u'Alethea Katherine Flowers', 'manual'), + (master_doc, "nox.tex", "nox Documentation", "Alethea Katherine Flowers", "manual"), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'nox', u'nox Documentation', - [author], 1) -] +man_pages = [(master_doc, "nox", "nox Documentation", [author], 1)] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------- @@ -301,19 +293,25 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'nox', u'nox Documentation', - author, 'nox', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + "nox", + "nox Documentation", + author, + "nox", + "One line description of project.", + "Miscellaneous", + ), ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# texinfo_no_detailmenu = False diff --git a/docs/config.rst b/docs/config.rst index e2a83386..d64d2f88 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -88,7 +88,7 @@ Configuring a session's virtualenv By default, Nox will create a new virtualenv for each session using the same interpreter that Nox uses. If you installed Nox using Python 3.6, Nox will use Python 3.6 by default for all of your sessions. -You can tell Nox to use a different Python interpreter/version by specifying the ``python`` argument (or its alias ``py``) to ``@nox.session``: +You can tell Nox to use a different Python interpreter/version by specifying the ``python`` argument (or its alias ``py``) to ``@nox.session``: .. code-block:: python @@ -96,6 +96,15 @@ You can tell Nox to use a different Python interpreter/version by specifying the def tests(session): pass +.. note:: + + The Python binaries on Windows are found via the Python `Launcher`_ for + Windows (``py``). For example, Python 3.9 can be found by determining which + executable is invoked by ``py -3.9``. If a given test needs to use the 32-bit + version of a given Python, then ``X.Y-32`` should be used as the version. + + .. _Launcher: https://docs.python.org/3/using/windows.html#python-launcher-for-windows + You can also tell Nox to run your session against multiple Python interpreters. Nox will create a separate virtualenv and run the session for each interpreter you specify. For example, this session will run twice - once for Python 2.7 and once for Python 3.6: .. code-block:: python @@ -116,7 +125,7 @@ When collecting your sessions, Nox will create a separate session for each inter .. code-block:: python - @nox.session(python=['2.7', '3.5', '3.6', '3.7', '3.8']) + @nox.session(python=['2.7', '3.6', '3.7', '3.8', '3.9']) def tests(session): pass @@ -125,14 +134,14 @@ Will produce these sessions: .. code-block:: console * tests-2.7 - * tests-3.5 * tests-3.6 * tests-3.7 * tests-3.8 + * tests-3.9 Note that this expansion happens *before* parameterization occurs, so you can still parametrize sessions with multiple interpreters. -If you want to disable virtualenv creation altogether, you can set ``python`` to ``False``: +If you want to disable virtualenv creation altogether, you can set ``python`` to ``False``, or set ``venv_backend`` to ``"none"``, both are equivalent. Note that this can be done temporarily through the :ref:`--no-venv ` commandline flag, too. .. code-block:: python @@ -237,10 +246,10 @@ When you run ``nox``, it will create a two distinct sessions: $ nox nox > Running session tests(django='1.9') - nox > pip install django==1.9 + nox > python -m pip install django==1.9 ... nox > Running session tests(django='2.0') - nox > pip install django==2.0 + nox > python -m pip install django==2.0 :func:`nox.parametrize` has an interface and usage intentionally similar to `pytest's parametrize `_. @@ -330,6 +339,43 @@ Produces these sessions when running ``nox --list``: * tests(mysql, new) +Parametrizing the session Python +-------------------------------- + +You can use parametrization to select the Python interpreter for a session. +These two examples are equivalent: + +.. code-block:: python + + @nox.session + @nox.parametrize("python", ["3.6", "3.7", "3.8"]) + def tests(session): + ... + + @nox.session(python=["3.6", "3.7", "3.8"]) + def tests(session): + ... + +The first form can be useful if you need to exclude some combinations of Python +versions with other parameters. For example, you may want to test against +multiple versions of a dependency, but the latest version doesn't run on older +Pythons: + +.. code-block:: python + + @nox.session + @nox.parametrize( + "python,dependency", + [ + (python, dependency) + for python in ("3.6", "3.7", "3.8") + for dependency in ("1.0", "2.0") + if (python, dependency) != ("3.6", "2.0") + ], + ) + def tests(session, dependency): + ... + The session object ------------------ @@ -375,6 +421,8 @@ The following options can be specified in the Noxfile: * ``nox.options.sessions`` is equivalent to specifying :ref:`-s or --sessions `. * ``nox.options.pythons`` is equivalent to specifying :ref:`-p or --pythons `. * ``nox.options.keywords`` is equivalent to specifying :ref:`-k or --keywords `. +* ``nox.options.default_venv_backend`` is equivalent to specifying :ref:`-db or --default-venv-backend `. +* ``nox.options.force_venv_backend`` is equivalent to specifying :ref:`-fb or --force-venv-backend `. * ``nox.options.reuse_existing_virtualenvs`` is equivalent to specifying :ref:`--reuse-existing-virtualenvs `. You can force this off by specifying ``--no-reuse-existing-virtualenvs`` during invocation. * ``nox.options.stop_on_first_error`` is equivalent to specifying :ref:`--stop-on-first-error `. You can force this off by specifying ``--no-stop-on-first-error`` during invocation. * ``nox.options.error_on_missing_interpreters`` is equivalent to specifying :ref:`--error-on-missing-interpreters `. You can force this off by specifying ``--no-error-on-missing-interpreters`` during invocation. @@ -383,3 +431,29 @@ The following options can be specified in the Noxfile: When invoking ``nox``, any options specified on the command line take precedence over the options specified in the Noxfile. If either ``--sessions`` or ``--keywords`` is specified on the command line, *both* options specified in the Noxfile will be ignored. + + +Nox version requirements +------------------------ + +Nox version requirements can be specified in your Noxfile by setting +``nox.needs_version``. If the Nox version does not satisfy the requirements, Nox +exits with a friendly error message. For example: + +.. code-block:: python + + import nox + + nox.needs_version = ">=2019.5.30" + + @nox.session(name="test") # name argument was added in 2019.5.30 + def pytest(session): + session.run("pytest") + +Any of the version specifiers defined in `PEP 440`_ can be used. + +.. warning:: Version requirements *must* be specified as a string literal, + using a simple assignment to ``nox.needs_version`` at the module level. This + allows Nox to check the version without importing the Noxfile. + +.. _PEP 440: https://www.python.org/dev/peps/pep-0440/ diff --git a/docs/index.rst b/docs/index.rst index 5da9abb6..59e51542 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -50,18 +50,24 @@ Projects that use Nox Nox is lucky to have several wonderful projects that use it and provide feedback and contributions. -- `Bezier `__ +- `Bézier `__ +- `cibuildwheel `__ - `gapic-generator-python `__ - `gdbgui `__ - `Google Assistant SDK `__ - `google-cloud-python `__ - `google-resumable-media-python `__ - `Hydra `__ +- `manylinux `__ - `OmegaConf `__ - `OpenCensus Python `__ -- `packaging.python.org `__ -- `pipx `__ +- `packaging `__ +- `packaging.python.org `__ +- `pip `__ +- `pipx `__ - `Salt `__ +- `Scikit-build `__ +- `Scikit-HEP `__ - `Subpar `__ - `Urllib3 `__ - `Zazo `__ diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 47a7ae81..0eead976 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -110,7 +110,7 @@ To install a ``requirements.txt`` file: @nox.session def tests(session): - # same as pip install -r -requirements.txt + # same as pip install -r requirements.txt session.install("-r", "requirements.txt") ... @@ -124,6 +124,30 @@ If your project is a Python package and you want to install it: session.install(".") ... +In some cases such as Python binary extensions, your package may depend on +code compiled outside of the Python ecosystem. To make sure a low-level +dependency (e.g. ``libfoo``) is available during installation + +.. code-block:: python + + @nox.session + def tests(session): + ... + session.run_always( + "cmake", "-DCMAKE_BUILD_TYPE=Debug", + "-S", libfoo_src_dir, + "-B", build_dir, + external=True, + ) + session.run_always( + "cmake", + "--build", build_dir, + "--config", "Debug", + "--target", "install", + external=True, + ) + session.install(".") + ... Running commands ---------------- @@ -258,7 +282,7 @@ If you want your session to run against multiple versions of Python: .. code-block:: python - @nox.session(python=["2.7", "3.5", "3.7"]) + @nox.session(python=["2.7", "3.6", "3.7"]) def test(session): ... @@ -270,7 +294,7 @@ been expanded into three distinct sessions: Sessions defined in noxfile.py: * test-2.7 - * test-3.5 + * test-3.6 * test-3.7 You can run all of the ``test`` sessions using ``nox --sessions test`` or run diff --git a/docs/usage.rst b/docs/usage.rst index 6f0e3418..8103cb2a 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -105,12 +105,51 @@ Then running ``nox --session tests`` will actually run all parametrized versions nox --session "tests(django='2.0')" +.. _opt-default-venv-backend: + +Changing the sessions default backend +------------------------------------- + +By default nox uses ``virtualenv`` as the virtual environment backend for the sessions, but it also supports ``conda`` and ``venv`` as well as no backend (passthrough to whatever python environment nox is running on). You can change the default behaviour by using ``-db `` or ``--default-venv-backend ``. Supported names are ``('none', 'virtualenv', 'conda', 'venv')``. + +.. code-block:: console + + nox -db conda + nox --default-venv-backend conda + + +You can also set this option in the Noxfile with ``nox.options.default_venv_backend``. In case both are provided, the commandline argument takes precedence. + +Note that using this option does not change the backend for sessions where ``venv_backend`` is explicitly set. + + +.. _opt-force-venv-backend: + +Forcing the sessions backend +---------------------------- + +You might work in a different environment than a project's default continuous integration setttings, and might wish to get a quick way to execute the same tasks but on a different venv backend. For this purpose, you can temporarily force the backend used by **all** sessions in the current nox execution by using ``-fb `` or ``--force-venv-backend ``. No exceptions are made, the backend will be forced for all sessions run whatever the other options values and nox file configuration. Supported names are ``('none', 'virtualenv', 'conda', 'venv')``. + +.. code-block:: console + + nox -fb conda + nox --force-venv-backend conda + + +You can also set this option in the Noxfile with ``nox.options.force_venv_backend``. In case both are provided, the commandline argument takes precedence. + +Finally note that the ``--no-venv`` flag is a shortcut for ``--force-venv-backend none`` and allows to temporarily run all selected sessions on the current python interpreter (the one running nox). + +.. code-block:: console + + nox --no-venv + .. _opt-reuse-existing-virtualenvs: Re-using virtualenvs -------------------- -By default nox deletes and recreates virtualenvs every time it is run. This is usually fine for most projects and continuous integration environments as `pip's caching `_ makes re-install rather quick. However, there are some situations where it is advantageous to re-use the virtualenvs between runs. Use ``-r`` or ``--reuse-existing-virtualenvs``: +By default, Nox deletes and recreates virtualenvs every time it is run. This is usually fine for most projects and continuous integration environments as `pip's caching `_ makes re-install rather quick. However, there are some situations where it is advantageous to re-use the virtualenvs between runs. Use ``-r`` or ``--reuse-existing-virtualenvs``: .. code-block:: console @@ -120,6 +159,48 @@ By default nox deletes and recreates virtualenvs every time it is run. This is u If the Noxfile sets ``nox.options.reuse_existing_virtualenvs``, you can override the Noxfile setting from the command line by using ``--no-reuse-existing-virtualenvs``. +Additionally, you can skip the re-installation of packages when a virtualenv is reused. Use ``-R`` or ``--reuse-existing-virtualenvs --no-install``: + +.. code-block:: console + + nox -R + nox --reuse-existing-virtualenvs --no-install + +The ``--no-install`` option causes the following session methods to return early: + +- :func:`session.install ` +- :func:`session.conda_install ` +- :func:`session.run_always ` + +This option has no effect if the virtualenv is not being reused. + +.. _opt-running-extra-pythons: + +Running additional Python versions +---------------------------------- + +In addition to Nox supporting executing single sessions, it also supports running Python versions that aren't specified using ``--extra-pythons``. + +.. code-block:: console + + nox --extra-pythons 3.8 3.9 + +This will, in addition to specified Python versions in the Noxfile, also create sessions for the specified versions. + +This option can be combined with ``--python`` to replace, instead of appending, the Python interpreter for a given session:: + + nox --python 3.10 --extra-python 3.10 -s lint + +Instead of passing both options, you can use the ``--force-python`` shorthand:: + + nox --force-python 3.10 -s lint + +Also, you can specify ``python`` in place of a specific version. This will run the session +using the ``python`` specified for the current ``PATH``:: + + nox --force-python python -s lint + + .. _opt-stop-on-first-error: Stopping if any session fails @@ -210,8 +291,8 @@ Would run both ``install`` commands, but skip the ``run`` command: nox > Running session tests nox > Creating virtualenv using python3.7 in ./.nox/tests - nox > pip install pytest - nox > pip install . + nox > python -m pip install pytest + nox > python -m pip install . nox > Skipping pytest run, as --install-only is set. nox > Session tests was successful. @@ -268,8 +349,8 @@ However, this will never output colorful logs: .. _opt-report: -Controling commands verbosity ------------------------------ +Controlling commands verbosity +------------------------------ By default, Nox will only show output of commands that fail, or, when the commands get passed ``silent=False``. By passing ``--verbose`` to Nox, all output of all commands run is shown, regardless of the silent argument. @@ -285,23 +366,6 @@ You can output a report in ``json`` format by specifying ``--report``: nox --report status.json -Windows -------- - -Nox has provisional support for running on Windows. However, depending on your Windows, Python, and virtualenv versions there may be issues. See the following threads for more info: - -* `tox issue 260 `_ -* `Python issue 24493 `_ -* `Virtualenv issue 774 `_ - -The Python binaries on Windows are found via the Python `Launcher`_ for -Windows (``py``). For example, Python 3.5 can be found by determining which -executable is invoked by ``py -3.5``. If a given test needs to use the 32-bit -version of a given Python, then ``X.Y-32`` should be used as the version. - -.. _Launcher: https://docs.python.org/3/using/windows.html#python-launcher-for-windows - - Converting from tox ------------------- diff --git a/nox/__init__.py b/nox/__init__.py index d83dbebb..78c5283d 100644 --- a/nox/__init__.py +++ b/nox/__init__.py @@ -12,9 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Optional + from nox._options import noxfile_options as options from nox._parametrize import Param as param from nox._parametrize import parametrize_decorator as parametrize from nox.registry import session_decorator as session +from nox.sessions import Session + +needs_version: Optional[str] = None -__all__ = ["parametrize", "param", "session", "options"] +__all__ = ["needs_version", "parametrize", "param", "session", "options", "Session"] diff --git a/nox/__main__.py b/nox/__main__.py index f81412c9..d6043ac0 100644 --- a/nox/__main__.py +++ b/nox/__main__.py @@ -22,13 +22,9 @@ import sys from nox import _options, tasks, workflow +from nox._version import get_nox_version from nox.logger import setup_logging -try: - import importlib.metadata as metadata -except ImportError: # pragma: no cover - import importlib_metadata as metadata - def main() -> None: args = _options.options.parse_args() @@ -38,10 +34,12 @@ def main() -> None: return if args.version: - print(metadata.version("nox"), file=sys.stderr) + print(get_nox_version(), file=sys.stderr) return - setup_logging(color=args.color, verbose=args.verbose) + setup_logging( + color=args.color, verbose=args.verbose, add_timestamp=args.add_timestamp + ) # Execute the appropriate tasks. exit_code = workflow.execute( diff --git a/nox/_decorators.py b/nox/_decorators.py index 32773f83..cedd1fb9 100644 --- a/nox/_decorators.py +++ b/nox/_decorators.py @@ -1,7 +1,8 @@ import copy import functools +import inspect import types -from typing import Any, Callable, Iterable, List, Optional, cast +from typing import Any, Callable, Dict, Iterable, List, Optional, cast from . import _typing @@ -17,7 +18,7 @@ def __new__( return cast("FunctionDecorator", functools.wraps(func)(obj)) -def _copy_func(src: Callable, name: str = None) -> Callable: +def _copy_func(src: Callable, name: Optional[str] = None) -> Callable: dst = types.FunctionType( src.__code__, src.__globals__, # type: ignore @@ -26,7 +27,7 @@ def _copy_func(src: Callable, name: str = None) -> Callable: closure=src.__closure__, # type: ignore ) dst.__dict__.update(copy.deepcopy(src.__dict__)) - dst = functools.update_wrapper(dst, src) # type: ignore + dst = functools.update_wrapper(dst, src) dst.__kwdefaults__ = src.__kwdefaults__ # type: ignore return dst @@ -40,17 +41,19 @@ def __init__( name: Optional[str] = None, venv_backend: Any = None, venv_params: Any = None, + should_warn: Optional[Dict[str, Any]] = None, ): self.func = func self.python = python self.reuse_venv = reuse_venv self.venv_backend = venv_backend self.venv_params = venv_params + self.should_warn = should_warn or dict() def __call__(self, *args: Any, **kwargs: Any) -> Any: return self.func(*args, **kwargs) - def copy(self, name: str = None) -> "Func": + def copy(self, name: Optional[str] = None) -> "Func": return Func( _copy_func(self.func, name), self.python, @@ -58,24 +61,41 @@ def copy(self, name: str = None) -> "Func": name, self.venv_backend, self.venv_params, + self.should_warn, ) class Call(Func): def __init__(self, func: Func, param_spec: "Param") -> None: + call_spec = param_spec.call_spec + session_signature = f"({param_spec})" + + # Determine the Python interpreter for the session using either @session + # or @parametrize. For backwards compatibility, we only use a "python" + # parameter in @parametrize if the session function does not expect it + # as a normal argument, and if the @session decorator does not already + # specify `python`. + + python = func.python + if python is None and "python" in call_spec: + signature = inspect.signature(func.func) + if "python" not in signature.parameters: + python = call_spec.pop("python") + super().__init__( func, - func.python, + python, func.reuse_venv, None, func.venv_backend, func.venv_params, + func.should_warn, ) - self.param_spec = param_spec - self.session_signature = "({})".format(param_spec) + self.call_spec = call_spec + self.session_signature = session_signature def __call__(self, *args: Any, **kwargs: Any) -> Any: - kwargs.update(self.param_spec.call_spec) + kwargs.update(self.call_spec) return super().__call__(*args, **kwargs) @classmethod diff --git a/nox/_option_set.py b/nox/_option_set.py index 79ae39bd..91dcc153 100644 --- a/nox/_option_set.py +++ b/nox/_option_set.py @@ -20,12 +20,28 @@ import argparse import collections import functools -from argparse import ArgumentError, ArgumentParser, Namespace, _ArgumentGroup -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from argparse import ArgumentError as ArgumentError +from argparse import ArgumentParser, Namespace +from typing import Any, Callable, List, Optional, Tuple, Union import argcomplete +class OptionGroup: + """A single group for command-line options. + + Args: + name (str): The name used to refer to the group. + args: Passed through to``ArgumentParser.add_argument_group``. + kwargs: Passed through to``ArgumentParser.add_argument_group``. + """ + + def __init__(self, name: str, *args: Any, **kwargs: Any) -> None: + self.name = name + self.args = args + self.kwargs = kwargs + + class Option: """A single option that can be specified via command-line or configuration file. @@ -35,8 +51,8 @@ class Option: object. flags (Sequence[str]): The list of flags used by argparse. Effectively the ``*args`` for ``ArgumentParser.add_argument``. + group (OptionGroup): The argument group this option belongs to. help (str): The help string pass to argparse. - group (str): The argument group this option belongs to, if any. noxfile (bool): Whether or not this option can be set in the configuration file. merge_func (Callable[[Namespace, Namespace], Any]): A function that @@ -61,20 +77,20 @@ def __init__( self, name: str, *flags: str, + group: Optional[OptionGroup], help: Optional[str] = None, - group: Optional[str] = None, noxfile: bool = False, merge_func: Optional[Callable[[Namespace, Namespace], Any]] = None, finalizer_func: Optional[Callable[[Any, Namespace], Any]] = None, default: Union[Any, Callable[[], Any]] = None, hidden: bool = False, completer: Optional[Callable[..., List[str]]] = None, - **kwargs: Any + **kwargs: Any, ) -> None: self.name = name self.flags = flags - self.help = help self.group = group + self.help = help self.noxfile = noxfile self.merge_func = merge_func self.finalizer_func = finalizer_func @@ -140,14 +156,14 @@ def make_flag_pair( name: str, enable_flags: Union[Tuple[str, str], Tuple[str]], disable_flags: Tuple[str], - **kwargs: Any + **kwargs: Any, ) -> Tuple[Option, Option]: """Returns two options - one to enable a behavior and another to disable it. The positive option is considered to be available to the noxfile, as there isn't much point in doing flag pairs without it. """ - disable_name = "no_{}".format(name) + disable_name = f"no_{name}" kwargs["action"] = "store_true" enable_option = Option( @@ -155,12 +171,10 @@ def make_flag_pair( *enable_flags, noxfile=True, merge_func=functools.partial(flag_pair_merge_func, name, disable_name), - **kwargs + **kwargs, ) - kwargs["help"] = "Disables {} if it is enabled in the Noxfile.".format( - enable_flags[-1] - ) + kwargs["help"] = f"Disables {enable_flags[-1]} if it is enabled in the Noxfile." disable_option = Option(disable_name, *disable_flags, **kwargs) return enable_option, disable_option @@ -182,7 +196,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: ) # type: collections.OrderedDict[str, Option] self.groups = ( collections.OrderedDict() - ) # type: collections.OrderedDict[str, Tuple[Tuple[Any, ...], Dict[str, Any]]] + ) # type: collections.OrderedDict[str, OptionGroup] def add_options(self, *args: Option) -> None: """Adds a sequence of Options to the OptionSet. @@ -193,23 +207,14 @@ def add_options(self, *args: Option) -> None: for option in args: self.options[option.name] = option - def add_group(self, name: str, *args: Any, **kwargs: Any) -> None: - """Adds a new argument group. + def add_groups(self, *args: OptionGroup) -> None: + """Adds a sequence of OptionGroups to the OptionSet. - When :func:`parser` is invoked, the OptionSet will turn all distinct - argument groups into separate sections in the ``--help`` output using - ``ArgumentParser.add_argument_group``. + Args: + args (Sequence[OptionGroup]) """ - self.groups[name] = (args, kwargs) - - def _add_to_parser( - self, parser: Union[_ArgumentGroup, ArgumentParser], option: Option - ) -> None: - argument = parser.add_argument( - *option.flags, help=option.help, default=option.default, **option.kwargs - ) - if getattr(option, "completer"): - setattr(argument, "completer", option.completer) + for option_group in args: + self.groups[option_group.name] = option_group def parser(self) -> ArgumentParser: """Returns an ``ArgumentParser`` for this option set. @@ -220,18 +225,25 @@ def parser(self) -> ArgumentParser: parser = argparse.ArgumentParser(*self.parser_args, **self.parser_kwargs) groups = { - name: parser.add_argument_group(*args, **kwargs) - for name, (args, kwargs) in self.groups.items() + name: parser.add_argument_group(*option_group.args, **option_group.kwargs) + for name, option_group in self.groups.items() } for option in self.options.values(): - if option.hidden: + if option.hidden is True: continue - if option.group is not None: - self._add_to_parser(groups[option.group], option) - else: - self._add_to_parser(parser, option) + # Every option must have a group (except for hidden options) + if option.group is None: + raise ValueError( + f"Option {option.name} must either have a group or be hidden." + ) + + argument = groups[option.group.name].add_argument( + *option.flags, help=option.help, default=option.default, **option.kwargs + ) + if getattr(option, "completer"): + setattr(argument, "completer", option.completer) return parser @@ -277,7 +289,7 @@ def namespace(self, **kwargs: Any) -> argparse.Namespace: # used in tests. for key, value in kwargs.items(): if key not in args: - raise KeyError("{} is not an option.".format(key)) + raise KeyError(f"{key} is not an option.") args[key] = value return argparse.Namespace(**args) @@ -297,13 +309,16 @@ def merge_namespaces( self, command_args: Namespace, noxfile_args: Namespace ) -> None: """Merges the command-line options with the noxfile options.""" + command_args_copy = Namespace(**vars(command_args)) for name, option in self.options.items(): if option.merge_func: setattr( - command_args, name, option.merge_func(command_args, noxfile_args) + command_args, + name, + option.merge_func(command_args_copy, noxfile_args), ) elif option.noxfile: - value = getattr(command_args, name, None) or getattr( + value = getattr(command_args_copy, name, None) or getattr( noxfile_args, name, None ) setattr(command_args, name, value) diff --git a/nox/_options.py b/nox/_options.py index 168890a9..353ed647 100644 --- a/nox/_options.py +++ b/nox/_options.py @@ -27,37 +27,102 @@ description="Nox is a Python automation toolkit.", add_help=False ) -options.add_group( - "primary", - "Primary arguments", - "These are the most common arguments used when invoking Nox.", -) -options.add_group( - "secondary", - "Additional arguments & flags", - "These arguments are used to control Nox's behavior or control advanced features.", +options.add_groups( + _option_set.OptionGroup( + "general", + "General options", + "These are general arguments used when invoking Nox.", + ), + _option_set.OptionGroup( + "sessions", + "Sessions options", + "These arguments are used to control which Nox session(s) to execute.", + ), + _option_set.OptionGroup( + "python", + "Python options", + "These arguments are used to control which Python version(s) to use.", + ), + _option_set.OptionGroup( + "environment", + "Environment options", + "These arguments are used to control Nox's creation and usage of virtual environments.", + ), + _option_set.OptionGroup( + "execution", + "Execution options", + "These arguments are used to control execution of sessions.", + ), + _option_set.OptionGroup( + "reporting", + "Reporting options", + "These arguments are used to control Nox's reporting during execution.", + ), ) -def _session_filters_merge_func( +def _sessions_and_keywords_merge_func( key: str, command_args: argparse.Namespace, noxfile_args: argparse.Namespace ) -> List[str]: - """Only return the Noxfile value for sessions/pythons/keywords if neither sessions, - pythons or keywords are specified on the command-line. + """Only return the Noxfile value for sessions/keywords if neither sessions + or keywords are specified on the command-line. Args: - key (str): This function is used for the "sessions", "pythons" and "keywords" + key (str): This function is used for both the "sessions" and "keywords" options, this allows using ``funtools.partial`` to pass the same function for both options. command_args (_option_set.Namespace): The options specified on the command-line. - noxfile_args (_option_set.Namespace): The options specified in the + noxfile_Args (_option_set.Namespace): The options specified in the Noxfile.""" - if not any((command_args.sessions, command_args.pythons, command_args.keywords)): + if not command_args.sessions and not command_args.keywords: return getattr(noxfile_args, key) return getattr(command_args, key) +def _default_venv_backend_merge_func( + command_args: argparse.Namespace, noxfile_args: argparse.Namespace +) -> str: + """Merge default_venv_backend from command args and nox file. Default is "virtualenv". + + Args: + command_args (_option_set.Namespace): The options specified on the + command-line. + noxfile_Args (_option_set.Namespace): The options specified in the + Noxfile. + """ + return ( + command_args.default_venv_backend + or noxfile_args.default_venv_backend + or "virtualenv" + ) + + +def _force_venv_backend_merge_func( + command_args: argparse.Namespace, noxfile_args: argparse.Namespace +) -> str: + """Merge force_venv_backend from command args and nox file. Default is None. + + Args: + command_args (_option_set.Namespace): The options specified on the + command-line. + noxfile_Args (_option_set.Namespace): The options specified in the + Noxfile. + """ + if command_args.no_venv: + if ( + command_args.force_venv_backend is not None + and command_args.force_venv_backend != "none" + ): + raise ValueError( + "You can not use `--no-venv` with a non-none `--force-venv-backend`" + ) + else: + return "none" + else: + return command_args.force_venv_backend or noxfile_args.force_venv_backend + + def _envdir_merge_func( command_args: argparse.Namespace, noxfile_args: argparse.Namespace ) -> str: @@ -103,6 +168,22 @@ def _color_finalizer(value: bool, args: argparse.Namespace) -> bool: return sys.stdout.isatty() +def _force_pythons_finalizer( + value: Sequence[str], args: argparse.Namespace +) -> Sequence[str]: + """Propagate ``--force-python`` to ``--python`` and ``--extra-python``.""" + if value: + args.pythons = args.extra_pythons = value + return value + + +def _R_finalizer(value: bool, args: argparse.Namespace) -> bool: + """Propagate -R to --reuse-existing-virtualenvs and --no-install.""" + if value: + args.reuse_existing_virtualenvs = args.no_install = value + return value + + def _posargs_finalizer( value: Sequence[Any], args: argparse.Namespace ) -> Union[Sequence[Any], List[Any]]: @@ -116,14 +197,14 @@ def _posargs_finalizer( if "--" not in posargs: unexpected_posargs = posargs raise _option_set.ArgumentError( - None, "Unknown argument(s) '{}'.".format(" ".join(unexpected_posargs)) + None, f"Unknown argument(s) '{' '.join(unexpected_posargs)}'." ) dash_index = posargs.index("--") if dash_index != 0: unexpected_posargs = posargs[0:dash_index] raise _option_set.ArgumentError( - None, "Unknown argument(s) '{}'.".format(" ".join(unexpected_posargs)) + None, f"Unknown argument(s) '{' '.join(unexpected_posargs)}'." ) return posargs[dash_index + 1 :] @@ -148,14 +229,14 @@ def _session_completer( "help", "-h", "--help", - group="primary", + group=options.groups["general"], action="store_true", help="Show this help message and exit.", ), _option_set.Option( "version", "--version", - group="primary", + group=options.groups["general"], action="store_true", help="Show the Nox version and exit.", ), @@ -164,7 +245,7 @@ def _session_completer( "-l", "--list-sessions", "--list", - group="primary", + group=options.groups["sessions"], action="store_true", help="List all available sessions and exit.", ), @@ -174,9 +255,9 @@ def _session_completer( "-e", "--sessions", "--session", - group="primary", + group=options.groups["sessions"], noxfile=True, - merge_func=functools.partial(_session_filters_merge_func, "sessions"), + merge_func=functools.partial(_sessions_and_keywords_merge_func, "sessions"), nargs="*", default=_sessions_default, help="Which sessions to run. By default, all sessions will run.", @@ -187,9 +268,8 @@ def _session_completer( "-p", "--pythons", "--python", - group="primary", + group=options.groups["python"], noxfile=True, - merge_func=functools.partial(_session_filters_merge_func, "pythons"), nargs="*", help="Only run sessions that use the given python interpreter versions.", ), @@ -197,14 +277,15 @@ def _session_completer( "keywords", "-k", "--keywords", + group=options.groups["sessions"], noxfile=True, - merge_func=functools.partial(_session_filters_merge_func, "keywords"), + merge_func=functools.partial(_sessions_and_keywords_merge_func, "keywords"), help="Only run sessions that match the given expression.", ), _option_set.Option( "posargs", "posargs", - group="primary", + group=options.groups["general"], nargs=argparse.REMAINDER, help="Arguments following ``--`` that are passed through to the session(s).", finalizer_func=_posargs_finalizer, @@ -213,23 +294,76 @@ def _session_completer( "verbose", "-v", "--verbose", - group="secondary", + group=options.groups["reporting"], action="store_true", help="Logs the output of all commands run including commands marked silent.", noxfile=True, ), + _option_set.Option( + "add_timestamp", + "-ts", + "--add-timestamp", + group=options.groups["reporting"], + action="store_true", + help="Adds a timestamp to logged output.", + noxfile=True, + ), + _option_set.Option( + "default_venv_backend", + "-db", + "--default-venv-backend", + group=options.groups["environment"], + noxfile=True, + merge_func=_default_venv_backend_merge_func, + help="Virtual environment backend to use by default for nox sessions, this is ``'virtualenv'`` by default but " + "any of ``('virtualenv', 'conda', 'venv')`` are accepted.", + choices=["none", "virtualenv", "conda", "venv"], + ), + _option_set.Option( + "force_venv_backend", + "-fb", + "--force-venv-backend", + group=options.groups["environment"], + noxfile=True, + merge_func=_force_venv_backend_merge_func, + help="Virtual environment backend to force-use for all nox sessions in this run, overriding any other venv " + "backend declared in the nox file and ignoring the default backend. Any of ``('virtualenv', 'conda', 'venv')`` " + "are accepted.", + choices=["none", "virtualenv", "conda", "venv"], + ), + _option_set.Option( + "no_venv", + "--no-venv", + group=options.groups["environment"], + default=False, + action="store_true", + help="Runs the selected sessions directly on the current interpreter, without creating a venv. This is an alias " + "for '--force-venv-backend none'.", + ), *_option_set.make_flag_pair( "reuse_existing_virtualenvs", ("-r", "--reuse-existing-virtualenvs"), ("--no-reuse-existing-virtualenvs",), - group="secondary", + group=options.groups["environment"], help="Re-use existing virtualenvs instead of recreating them.", ), + _option_set.Option( + "R", + "-R", + default=False, + group=options.groups["environment"], + action="store_true", + help=( + "Re-use existing virtualenvs and skip package re-installation." + " This is an alias for '--reuse-existing-virtualenvs --no-install'." + ), + finalizer_func=_R_finalizer, + ), _option_set.Option( "noxfile", "-f", "--noxfile", - group="secondary", + group=options.groups["general"], default="noxfile.py", help="Location of the Python file containing nox sessions.", ), @@ -238,48 +372,80 @@ def _session_completer( "--envdir", noxfile=True, merge_func=_envdir_merge_func, - group="secondary", + group=options.groups["environment"], help="Directory where nox will store virtualenvs, this is ``.nox`` by default.", ), + _option_set.Option( + "extra_pythons", + "--extra-pythons", + "--extra-python", + group=options.groups["python"], + nargs="*", + help="Additionally, run sessions using the given python interpreter versions.", + ), + _option_set.Option( + "force_pythons", + "--force-pythons", + "--force-python", + group=options.groups["python"], + nargs="*", + help=( + "Run sessions with the given interpreters instead of those listed in the Noxfile." + " This is a shorthand for ``--python=X.Y --extra-python=X.Y``." + ), + finalizer_func=_force_pythons_finalizer, + ), *_option_set.make_flag_pair( "stop_on_first_error", ("-x", "--stop-on-first-error"), ("--no-stop-on-first-error",), - group="secondary", + group=options.groups["execution"], help="Stop after the first error.", ), *_option_set.make_flag_pair( "error_on_missing_interpreters", ("--error-on-missing-interpreters",), ("--no-error-on-missing-interpreters",), - group="secondary", + group=options.groups["execution"], help="Error instead of skipping sessions if an interpreter can not be located.", ), *_option_set.make_flag_pair( "error_on_external_run", ("--error-on-external-run",), ("--no-error-on-external-run",), - group="secondary", + group=options.groups["execution"], help="Error if run() is used to execute a program that isn't installed in a session's virtualenv.", ), _option_set.Option( "install_only", "--install-only", - group="secondary", + group=options.groups["execution"], action="store_true", help="Skip session.run invocations in the Noxfile.", ), + _option_set.Option( + "no_install", + "--no-install", + default=False, + group=options.groups["execution"], + action="store_true", + help=( + "Skip invocations of session methods for installing packages" + " (session.install, session.conda_install, session.run_always)" + " when a virtualenv is being reused." + ), + ), _option_set.Option( "report", "--report", - group="secondary", + group=options.groups["reporting"], noxfile=True, help="Output a report of all sessions to the given filename.", ), _option_set.Option( "non_interactive", "--non-interactive", - group="secondary", + group=options.groups["execution"], action="store_true", help="Force session.interactive to always be False, even in interactive sessions.", ), @@ -287,7 +453,7 @@ def _session_completer( "nocolor", "--nocolor", "--no-color", - group="secondary", + group=options.groups["reporting"], default=lambda: "NO_COLOR" in os.environ, action="store_true", help="Disable all color output.", @@ -296,13 +462,25 @@ def _session_completer( "forcecolor", "--forcecolor", "--force-color", - group="secondary", + group=options.groups["reporting"], default=False, action="store_true", help="Force color output, even if stdout is not an interactive terminal.", ), _option_set.Option( - "color", "--color", hidden=True, finalizer_func=_color_finalizer + "color", + "--color", + group=options.groups["reporting"], + hidden=True, + finalizer_func=_color_finalizer, + ), + # Stores the original working directory that Nox was invoked from, + # since it could be different from the Noxfile's directory. + _option_set.Option( + "invoked_from", + group=None, + hidden=True, + default=lambda: os.getcwd(), ), ) diff --git a/nox/_parametrize.py b/nox/_parametrize.py index 9f2cd631..82022402 100644 --- a/nox/_parametrize.py +++ b/nox/_parametrize.py @@ -32,7 +32,7 @@ def __init__( self, *args: Any, arg_names: Optional[Sequence[str]] = None, - id: Optional[str] = None + id: Optional[str] = None, ) -> None: self.args = tuple(args) self.id = id @@ -51,8 +51,7 @@ def __str__(self) -> str: return self.id else: call_spec = self.call_spec - keys = sorted(call_spec.keys(), key=str) - args = ["{}={}".format(k, repr(call_spec[k])) for k in keys] + args = [f"{k}={call_spec[k]!r}" for k in call_spec.keys()] return ", ".join(args) __repr__ = __str__ diff --git a/nox/_version.py b/nox/_version.py new file mode 100644 index 00000000..2e247534 --- /dev/null +++ b/nox/_version.py @@ -0,0 +1,113 @@ +# Copyright 2021 Alethea Katherine Flowers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import ast +import contextlib +import sys +from typing import Optional + +from packaging.specifiers import InvalidSpecifier, SpecifierSet +from packaging.version import InvalidVersion, Version + +if sys.version_info >= (3, 8): # pragma: no cover + import importlib.metadata as metadata +else: # pragma: no cover + import importlib_metadata as metadata + + +class VersionCheckFailed(Exception): + """The Nox version does not satisfy what ``nox.needs_version`` specifies.""" + + +class InvalidVersionSpecifier(Exception): + """The ``nox.needs_version`` specifier cannot be parsed.""" + + +def get_nox_version() -> str: + """Return the version of the installed Nox package.""" + return metadata.version("nox") # type: ignore + + +def _parse_string_constant(node: ast.AST) -> Optional[str]: # pragma: no cover + """Return the value of a string constant.""" + if sys.version_info < (3, 8): + if isinstance(node, ast.Str) and isinstance(node.s, str): + return node.s + elif isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + return None + + +def _parse_needs_version(source: str, filename: str = "") -> Optional[str]: + """Parse ``nox.needs_version`` from the user's noxfile.""" + value: Optional[str] = None + module: ast.Module = ast.parse(source, filename=filename) + for statement in module.body: + if isinstance(statement, ast.Assign): + for target in statement.targets: + if ( + isinstance(target, ast.Attribute) + and isinstance(target.value, ast.Name) + and target.value.id == "nox" + and target.attr == "needs_version" + ): + value = _parse_string_constant(statement.value) + return value + + +def _read_needs_version(filename: str) -> Optional[str]: + """Read ``nox.needs_version`` from the user's noxfile.""" + with open(filename) as io: + source = io.read() + + return _parse_needs_version(source, filename=filename) + + +def _check_nox_version_satisfies(needs_version: str) -> None: + """Check if the Nox version satisfies the given specifiers.""" + version = Version(get_nox_version()) + + try: + specifiers = SpecifierSet(needs_version) + except InvalidSpecifier as error: + message = f"Cannot parse `nox.needs_version`: {error}" + with contextlib.suppress(InvalidVersion): + Version(needs_version) + message += f", did you mean '>= {needs_version}'?" + raise InvalidVersionSpecifier(message) + + if not specifiers.contains(version, prereleases=True): + raise VersionCheckFailed( + f"The Noxfile requires Nox {specifiers}, you have {version}" + ) + + +def check_nox_version(filename: str) -> None: + """Check if ``nox.needs_version`` in the user's noxfile is satisfied. + + Args: + + filename: The location of the user's noxfile. ``nox.needs_version`` is + read from the noxfile by parsing the AST. + + Raises: + VersionCheckFailed: The Nox version does not satisfy what + ``nox.needs_version`` specifies. + InvalidVersionSpecifier: The ``nox.needs_version`` specifier cannot be + parsed. + """ + needs_version = _read_needs_version(filename) + + if needs_version is not None: + _check_nox_version_satisfies(needs_version) diff --git a/nox/command.py b/nox/command.py index 766481ea..b26f58df 100644 --- a/nox/command.py +++ b/nox/command.py @@ -13,28 +13,35 @@ # limitations under the License. import os +import shlex import sys -from typing import Any, Iterable, Optional, Sequence, Union +from typing import Any, Iterable, List, Optional, Sequence, Union import py + from nox.logger import logger from nox.popen import popen +if sys.version_info < (3, 8): # pragma: no cover + from typing_extensions import Literal +else: # pragma: no cover + from typing import Literal + class CommandFailed(Exception): """Raised when an executed command returns a non-success status code.""" - def __init__(self, reason: str = None) -> None: + def __init__(self, reason: Optional[str] = None) -> None: super(CommandFailed, self).__init__(reason) self.reason = reason -def which(program: str, path: Optional[str]) -> str: +def which(program: str, paths: Optional[List[str]]) -> str: """Finds the full path to an executable.""" full_path = None - if path: - full_path = py.path.local.sysfind(program, paths=[path]) + if paths: + full_path = py.path.local.sysfind(program, paths=paths) if full_path: return full_path.strpath @@ -44,8 +51,8 @@ def which(program: str, path: Optional[str]) -> str: if full_path: return full_path.strpath - logger.error("Program {} not found.".format(program)) - raise CommandFailed("Program {} not found".format(program)) + logger.error(f"Program {program} not found.") + raise CommandFailed(f"Program {program} not found") def _clean_env(env: Optional[dict]) -> Optional[dict]: @@ -61,16 +68,21 @@ def _clean_env(env: Optional[dict]) -> Optional[dict]: return clean_env +def _shlex_join(args: Sequence[str]) -> str: + # shlex.join() was added in Python 3.8 + return " ".join(shlex.quote(arg) for arg in args) + + def run( args: Sequence[str], *, env: Optional[dict] = None, silent: bool = False, - path: Optional[str] = None, + paths: Optional[List[str]] = None, success_codes: Optional[Iterable[int]] = None, log: bool = True, - external: bool = False, - **popen_kws: Any + external: Union[Literal["error"], bool] = False, + **popen_kws: Any, ) -> Union[str, bool]: """Run a command-line program.""" @@ -78,29 +90,27 @@ def run( success_codes = [0] cmd, args = args[0], args[1:] - full_cmd = "{} {}".format(cmd, " ".join(args)) + full_cmd = f"{cmd} {_shlex_join(args)}" - cmd_path = which(cmd, path) + cmd_path = which(cmd, paths) if log: logger.info(full_cmd) - is_external_tool = path is not None and not cmd_path.startswith(path) + is_external_tool = paths is not None and not any( + cmd_path.startswith(path) for path in paths + ) if is_external_tool: if external == "error": logger.error( - "Error: {} is not installed into the virtualenv, it is located at {}. " - "Pass external=True into run() to explicitly allow this.".format( - cmd, cmd_path - ) + f"Error: {cmd} is not installed into the virtualenv, it is located at {cmd_path}. " + "Pass external=True into run() to explicitly allow this." ) raise CommandFailed("External program disallowed.") elif external is False: logger.warning( - "Warning: {} is not installed into the virtualenv, it is located at {}. This might cause issues! " - "Pass external=True into run() to silence this message.".format( - cmd, cmd_path - ) + f"Warning: {cmd} is not installed into the virtualenv, it is located at {cmd_path}. This might cause issues! " + "Pass external=True into run() to silence this message." ) env = _clean_env(env) @@ -111,16 +121,15 @@ def run( ) if return_code not in success_codes: + suffix = ":" if silent else "" logger.error( - "Command {} failed with exit code {}{}".format( - full_cmd, return_code, ":" if silent else "" - ) + f"Command {full_cmd} failed with exit code {return_code}{suffix}" ) if silent: sys.stderr.write(output) - raise CommandFailed("Returned code {}".format(return_code)) + raise CommandFailed(f"Returned code {return_code}") if output: logger.output(output) diff --git a/nox/logger.py b/nox/logger.py index 9d24105b..599cc238 100644 --- a/nox/logger.py +++ b/nox/logger.py @@ -21,20 +21,59 @@ OUTPUT = logging.DEBUG - 1 -class NoxFormatter(ColoredFormatter): - def __init__(self, *args: Any, **kwargs: Any) -> None: - super(NoxFormatter, self).__init__(*args, **kwargs) +def _get_format(colorlog: bool, add_timestamp: bool) -> str: + if colorlog: + if add_timestamp: + return "%(cyan)s%(name)s > [%(asctime)s] %(log_color)s%(message)s" + else: + return "%(cyan)s%(name)s > %(log_color)s%(message)s" + else: + if add_timestamp: + return "%(name)s > [%(asctime)s] %(message)s" + else: + return "%(name)s > %(message)s" + + +class NoxFormatter(logging.Formatter): + def __init__(self, add_timestamp: bool = False) -> None: + super().__init__(fmt=_get_format(colorlog=False, add_timestamp=add_timestamp)) + self._simple_fmt = logging.Formatter("%(message)s") + + def format(self, record: Any) -> str: + if record.levelname == "OUTPUT": + return self._simple_fmt.format(record) + return super().format(record) + + +class NoxColoredFormatter(ColoredFormatter): + def __init__( + self, + datefmt: Any = None, + style: Any = None, + log_colors: Any = None, + reset: bool = True, + secondary_log_colors: Any = None, + add_timestamp: bool = False, + ) -> None: + super().__init__( + fmt=_get_format(colorlog=True, add_timestamp=add_timestamp), + datefmt=datefmt, + style=style, + log_colors=log_colors, + reset=reset, + secondary_log_colors=secondary_log_colors, + ) self._simple_fmt = logging.Formatter("%(message)s") def format(self, record: Any) -> str: if record.levelname == "OUTPUT": return self._simple_fmt.format(record) - return super(NoxFormatter, self).format(record) + return super().format(record) class LoggerWithSuccessAndOutput(logging.getLoggerClass()): # type: ignore def __init__(self, name: str, level: int = logging.NOTSET): - super(LoggerWithSuccessAndOutput, self).__init__(name, level) + super().__init__(name, level) logging.addLevelName(SUCCESS, "SUCCESS") logging.addLevelName(OUTPUT, "OUTPUT") @@ -55,23 +94,9 @@ def output(self, msg: str, *args: Any, **kwargs: Any) -> None: logger = cast(LoggerWithSuccessAndOutput, logging.getLogger("nox")) -def setup_logging(color: bool, verbose: bool = False) -> None: # pragma: no cover - """Setup logging. - - Args: - color (bool): If true, the output will be colored using - colorlog. Otherwise, it will be plaintext. - """ - root_logger = logging.getLogger() - if verbose: - root_logger.setLevel(OUTPUT) - else: - root_logger.setLevel(logging.DEBUG) - handler = logging.StreamHandler() - +def _get_formatter(color: bool, add_timestamp: bool) -> logging.Formatter: if color is True: - formatter = NoxFormatter( - "%(cyan)s%(name)s > %(log_color)s%(message)s", + return NoxColoredFormatter( reset=True, log_colors={ "DEBUG": "cyan", @@ -81,10 +106,31 @@ def setup_logging(color: bool, verbose: bool = False) -> None: # pragma: no cov "CRITICAL": "red,bg_white", "SUCCESS": "green", }, + style="%", + secondary_log_colors=None, + add_timestamp=add_timestamp, ) + else: + return NoxFormatter(add_timestamp=add_timestamp) - handler.setFormatter(formatter) +def setup_logging( + color: bool, verbose: bool = False, add_timestamp: bool = False +) -> None: # pragma: no cover + """Setup logging. + + Args: + color (bool): If true, the output will be colored using + colorlog. Otherwise, it will be plaintext. + """ + root_logger = logging.getLogger() + if verbose: + root_logger.setLevel(OUTPUT) + else: + root_logger.setLevel(logging.DEBUG) + handler = logging.StreamHandler() + + handler.setFormatter(_get_formatter(color, add_timestamp)) root_logger.addHandler(handler) # Silence noisy loggers diff --git a/nox/manifest.py b/nox/manifest.py index d90ba346..72c3d05b 100644 --- a/nox/manifest.py +++ b/nox/manifest.py @@ -13,13 +13,33 @@ # limitations under the License. import argparse +import ast import collections.abc import itertools -from typing import Any, Iterable, Iterator, List, Mapping, Sequence, Set, Tuple, Union +from collections import OrderedDict +from typing import ( + Any, + Iterable, + Iterator, + List, + Mapping, + Optional, + Sequence, + Set, + Tuple, + Union, +) from nox._decorators import Call, Func from nox.sessions import Session, SessionRunner +WARN_PYTHONS_IGNORED = "python_ignored" + + +def _unique_list(*args: str) -> List[str]: + """Return a list without duplicates, while preserving order.""" + return list(OrderedDict.fromkeys(args)) + class Manifest: """Session manifest. @@ -35,15 +55,21 @@ class Manifest: session_functions (Mapping[str, function]): The registry of discovered session functions. global_config (.nox.main.GlobalConfig): The global configuration. + module_docstring (Optional[str]): The user noxfile.py docstring. + Defaults to `None`. """ def __init__( - self, session_functions: Mapping[str, "Func"], global_config: argparse.Namespace + self, + session_functions: Mapping[str, "Func"], + global_config: argparse.Namespace, + module_docstring: Optional[str] = None, ) -> None: self._all_sessions = [] # type: List[SessionRunner] self._queue = [] # type: List[SessionRunner] self._consumed = [] # type: List[SessionRunner] self._config = global_config # type: argparse.Namespace + self.module_docstring = module_docstring # type: Optional[str] # Create the sessions based on the provided session functions. for name, func in session_functions.items(): @@ -114,23 +140,29 @@ def filter_by_name(self, specified_sessions: Iterable[str]) -> None: queue = [] for session_name in specified_sessions: for session in self._queue: - if session_name == session.name or session_name in set( - session.signatures - ): + if _normalized_session_match(session_name, session): queue.append(session) - self._queue = queue # If a session was requested and was not found, complain loudly. all_sessions = set( - itertools.chain( - [x.name for x in self._all_sessions if x.name], - *[x.signatures for x in self._all_sessions], + map( + _normalize_arg, + ( + itertools.chain( + [x.name for x in self._all_sessions if x.name], + *[x.signatures for x in self._all_sessions], + ) + ), ) ) - missing_sessions = set(specified_sessions) - all_sessions + missing_sessions = [ + session_name + for session_name in specified_sessions + if _normalize_arg(session_name) not in all_sessions + ] if missing_sessions: - raise KeyError("Sessions not found: {}".format(", ".join(missing_sessions))) + raise KeyError(f"Sessions not found: {', '.join(missing_sessions)}") def filter_by_python_interpreter(self, specified_pythons: Sequence[str]) -> None: """Filter sessions in the queue based on the user-specified @@ -170,6 +202,33 @@ def make_session( """ sessions = [] + # if backend is none we wont parametrize the pythons + backend = ( + self._config.force_venv_backend + or func.venv_backend + or self._config.default_venv_backend + ) + if backend == "none" and isinstance(func.python, (list, tuple, set)): + # we can not log a warning here since the session is maybe deselected. + # instead let's set a flag, to warn later when session is actually run. + func.should_warn[WARN_PYTHONS_IGNORED] = func.python + func.python = False + + if self._config.extra_pythons: + # If extra python is provided, expand the func.python list to + # include additional python interpreters + extra_pythons = self._config.extra_pythons # type: List[str] + if isinstance(func.python, (list, tuple, set)): + func.python = _unique_list(*func.python, *extra_pythons) + elif not multi and func.python: + # If this is multi, but there is only a single interpreter, it + # is the reentrant case. The extra_python interpreter shouldn't + # be added in that case. If func.python is False, the session + # has no backend; if None, it uses the same interpreter as Nox. + # Otherwise, add the extra specified python. + assert isinstance(func.python, str) + func.python = _unique_list(func.python, *extra_pythons) + # If the func has the python attribute set to a list, we'll need # to expand them. if isinstance(func.python, (list, tuple, set)): @@ -188,7 +247,7 @@ def make_session( if not multi: long_names.append(name) if func.python: - long_names.append("{}-{}".format(name, func.python)) + long_names.append(f"{name}-{func.python}") return [SessionRunner(name, long_names, func, self._config, self)] @@ -199,13 +258,11 @@ def make_session( for call in calls: long_names = [] if not multi: - long_names.append("{}{}".format(name, call.session_signature)) + long_names.append(f"{name}{call.session_signature}") if func.python: - long_names.append( - "{}-{}{}".format(name, func.python, call.session_signature) - ) + long_names.append(f"{name}-{func.python}{call.session_signature}") # Ensure that specifying session-python will run all parameterizations. - long_names.append("{}-{}".format(name, func.python)) + long_names.append(f"{name}-{func.python}") sessions.append(SessionRunner(name, long_names, call, self._config, self)) @@ -222,7 +279,9 @@ def make_session( def next(self) -> SessionRunner: return self.__next__() - def notify(self, session: Union[str, SessionRunner]) -> bool: + def notify( + self, session: Union[str, SessionRunner], posargs: Optional[List[str]] = None + ) -> bool: """Enqueue the specified session in the queue. If the session is already in the queue, or has been run already, @@ -231,6 +290,10 @@ def notify(self, session: Union[str, SessionRunner]) -> bool: Args: session (Union[str, ~nox.session.Session]): The session to be enqueued. + posargs (Optional[List[str]]): If given, sets the positional + arguments *only* for the queued session. Otherwise, the + standard globally available positional arguments will be + used instead. Returns: bool: Whether the session was added to the queue. @@ -247,11 +310,13 @@ def notify(self, session: Union[str, SessionRunner]) -> bool: # the end of the queue. for s in self._all_sessions: if s == session or s.name == session or session in s.signatures: + if posargs is not None: + s.posargs = posargs self._queue.append(s) return True # The session was not found in the list of sessions. - raise ValueError("Session {} not found.".format(session)) + raise ValueError(f"Session {session} not found.") class KeywordLocals(collections.abc.Mapping): @@ -290,4 +355,24 @@ def _null_session_func_(session: Session) -> None: session.skip("This session had no parameters available.") +def _normalized_session_match(session_name: str, session: SessionRunner) -> bool: + """Checks if session_name matches session.""" + if session_name == session.name or session_name in session.signatures: + return True + for name in session.signatures: + equal_rep = _normalize_arg(session_name) == _normalize_arg(name) + if equal_rep: + return True + # Exhausted + return False + + +def _normalize_arg(arg: str) -> Union[str]: + """Normalize arg for comparison.""" + try: + return str(ast.dump(ast.parse(arg))) + except (TypeError, SyntaxError): + return arg + + _null_session_func = Func(_null_session_func_, python=False) diff --git a/nox/popen.py b/nox/popen.py index 48fc9726..010dd321 100644 --- a/nox/popen.py +++ b/nox/popen.py @@ -12,16 +12,51 @@ # See the License for the specific language governing permissions and # limitations under the License. +import contextlib +import locale import subprocess import sys -from typing import IO, Mapping, Sequence, Tuple, Union +from typing import IO, Mapping, Optional, Sequence, Tuple, Union + + +def shutdown_process(proc: subprocess.Popen) -> Tuple[bytes, bytes]: + """Gracefully shutdown a child process.""" + + with contextlib.suppress(subprocess.TimeoutExpired): + return proc.communicate(timeout=0.3) + + proc.terminate() + + with contextlib.suppress(subprocess.TimeoutExpired): + return proc.communicate(timeout=0.2) + + proc.kill() + + return proc.communicate() + + +def decode_output(output: bytes) -> str: + """Try to decode the given bytes with encodings from the system. + + :param output: output to decode + :raises UnicodeDecodeError: if all encodings fail + :return: decoded string + """ + try: + return output.decode("utf-8") + except UnicodeDecodeError: + second_encoding = locale.getpreferredencoding() + if second_encoding.casefold() in ("utf8", "utf-8"): + raise + + return output.decode(second_encoding) def popen( args: Sequence[str], - env: Mapping[str, str] = None, + env: Optional[Mapping[str, str]] = None, silent: bool = False, - stdout: Union[int, IO] = None, + stdout: Optional[Union[int, IO]] = None, stderr: Union[int, IO] = subprocess.STDOUT, ) -> Tuple[int, str]: if silent and stdout is not None: @@ -39,10 +74,10 @@ def popen( sys.stdout.flush() except KeyboardInterrupt: - proc.terminate() - proc.wait() - raise + out, err = shutdown_process(proc) + if proc.returncode != 0: + raise return_code = proc.wait() - return return_code, out.decode("utf-8") if out else "" + return return_code, decode_output(out) if out else "" diff --git a/nox/registry.py b/nox/registry.py index 3a3d0a54..f08b188a 100644 --- a/nox/registry.py +++ b/nox/registry.py @@ -15,23 +15,43 @@ import collections import copy import functools -from typing import Any, Callable, Optional +from typing import Any, Callable, Optional, TypeVar, Union, overload from ._decorators import Func from ._typing import Python +F = TypeVar("F", bound=Callable[..., Any]) + _REGISTRY = collections.OrderedDict() # type: collections.OrderedDict[str, Func] +@overload +def session_decorator(__func: F) -> F: + ... + + +@overload +def session_decorator( + __func: None = ..., + python: Python = ..., + py: Python = ..., + reuse_venv: Optional[bool] = ..., + name: Optional[str] = ..., + venv_backend: Any = ..., + venv_params: Any = ..., +) -> Callable[[F], F]: + ... + + def session_decorator( - func: Optional[Callable] = None, + func: Optional[F] = None, python: Python = None, py: Python = None, reuse_venv: Optional[bool] = None, name: Optional[str] = None, venv_backend: Any = None, venv_params: Any = None, -) -> Callable: +) -> Union[F, Callable[[F], F]]: """Designate the decorated function as a session.""" # If `func` is provided, then this is the decorator call with the function # being sent as part of the Python syntax (`@nox.session`). diff --git a/nox/sessions.py b/nox/sessions.py index 59a5cf4b..08c9cb42 100644 --- a/nox/sessions.py +++ b/nox/sessions.py @@ -16,6 +16,7 @@ import enum import hashlib import os +import pathlib import re import sys import unicodedata @@ -28,15 +29,17 @@ Mapping, Optional, Sequence, + Tuple, Union, ) -import nox.command import py + +import nox.command from nox import _typing from nox._decorators import Func from nox.logger import logger -from nox.virtualenv import CondaEnv, ProcessEnv, VirtualEnv +from nox.virtualenv import CondaEnv, PassthroughEnv, ProcessEnv, VirtualEnv if _typing.TYPE_CHECKING: from nox.manifest import Manifest @@ -61,14 +64,42 @@ def _normalize_path(envdir: str, path: Union[str, bytes]) -> str: logger.warning("The virtualenv name was hashed to avoid being too long.") else: logger.error( - "The virtualenv path {} is too long and will cause issues on " + f"The virtualenv path {full_path} is too long and will cause issues on " "some environments. Use the --envdir path to modify where " - "nox stores virtualenvs.".format(full_path) + "nox stores virtualenvs." ) return full_path +def _dblquote_pkg_install_args(args: Tuple[str, ...]) -> Tuple[str, ...]: + """Double-quote package install arguments in case they contain '>' or '<' symbols""" + + # routine used to handle a single arg + def _dblquote_pkg_install_arg(pkg_req_str: str) -> str: + # sanity check: we need an even number of double-quotes + if pkg_req_str.count('"') % 2 != 0: + raise ValueError( + f"ill-formated argument with odd number of quotes: {pkg_req_str}" + ) + + if "<" in pkg_req_str or ">" in pkg_req_str: + if pkg_req_str[0] == '"' and pkg_req_str[-1] == '"': + # already double-quoted string + return pkg_req_str + else: + # need to double-quote string + if '"' in pkg_req_str: + raise ValueError(f"Cannot escape requirement string: {pkg_req_str}") + return f'"{pkg_req_str}"' + else: + # no dangerous char: no need to double-quote string + return pkg_req_str + + # double-quote all args that need to be and return the result + return tuple(_dblquote_pkg_install_arg(a) for a in args) + + class _SessionQuit(Exception): pass @@ -106,6 +137,11 @@ def __dict__(self) -> "Dict[str, SessionRunner]": # type: ignore """ return {"_runner": self._runner} + @property + def name(self) -> str: + """The name of this session.""" + return self._runner.friendly_name + @property def env(self) -> dict: """A dictionary of environment variables to pass into all commands.""" @@ -113,9 +149,8 @@ def env(self) -> dict: @property def posargs(self) -> List[str]: - """This is set to any extra arguments - passed to ``nox`` on the commandline.""" - return self._runner.global_config.posargs + """Any extra arguments from the ``nox`` commandline or :class:`Session.notify`.""" + return self._runner.posargs @property def virtualenv(self) -> ProcessEnv: @@ -131,18 +166,52 @@ def python(self) -> Optional[Union[str, Sequence[str], bool]]: return self._runner.func.python @property - def bin(self) -> Optional[str]: - """The bin directory for the virtualenv.""" - return self.virtualenv.bin + def bin_paths(self) -> Optional[List[str]]: + """The bin directories for the virtualenv.""" + return self.virtualenv.bin_paths + + @property + def bin(self) -> str: + """The first bin directory for the virtualenv.""" + paths = self.bin_paths + if paths is None: + raise ValueError("The environment does not have a bin directory.") + return paths[0] + + def create_tmp(self) -> str: + """Create, and return, a temporary directory.""" + tmpdir = os.path.join(self._runner.envdir, "tmp") + os.makedirs(tmpdir, exist_ok=True) + self.env["TMPDIR"] = tmpdir + return tmpdir + + @property + def cache_dir(self) -> pathlib.Path: + """Create and return a 'shared cache' directory to be used across sessions.""" + path = pathlib.Path(self._runner.global_config.envdir).joinpath(".cache") + path.mkdir(exist_ok=True) + return path @property def interactive(self) -> bool: """Returns True if Nox is being run in an interactive session or False otherwise.""" return not self._runner.global_config.non_interactive and sys.stdin.isatty() - def chdir(self, dir: str) -> None: + @property + def invoked_from(self) -> str: + """The directory that Nox was originally invoked from. + + Since you can use the ``--noxfile / -f`` command-line + argument to run a Noxfile in a location different from your shell's + current working directory, Nox automatically changes the working directory + to the Noxfile's directory before running any sessions. This gives + you the original working directory that Nox was invoked form. + """ + return self._runner.global_config.invoked_from + + def chdir(self, dir: Union[str, os.PathLike]) -> None: """Change the current working directory.""" - self.log("cd {}".format(dir)) + self.log(f"cd {dir}") os.chdir(dir) cd = chdir @@ -152,15 +221,15 @@ def _run_func( self, func: Callable, args: Iterable[Any], kwargs: Mapping[str, Any] ) -> Any: """Legacy support for running a function through :func`run`.""" - self.log("{}(args={!r}, kwargs={!r})".format(func, args, kwargs)) + self.log(f"{func}(args={args!r}, kwargs={kwargs!r})") try: return func(*args, **kwargs) except Exception as e: - logger.exception("Function {!r} raised {!r}.".format(func, e)) + logger.exception(f"Function {func!r} raised {e!r}.") raise nox.command.CommandFailed() def run( - self, *args: str, env: Mapping[str, str] = None, **kwargs: Any + self, *args: str, env: Optional[Mapping[str, str]] = None, **kwargs: Any ) -> Optional[Any]: """Run a command. @@ -212,12 +281,57 @@ def run( raise ValueError("At least one argument required to run().") if self._runner.global_config.install_only: - logger.info("Skipping {} run, as --install-only is set.".format(args[0])) + logger.info(f"Skipping {args[0]} run, as --install-only is set.") + return None + + return self._run(*args, env=env, **kwargs) + + def run_always( + self, *args: str, env: Optional[Mapping[str, str]] = None, **kwargs: Any + ) -> Optional[Any]: + """Run a command **always**. + + This is a variant of :meth:`run` that runs even in the presence of + ``--install-only``. This method returns early if ``--no-install`` is + specified and the virtualenv is being reused. + + Here are some cases where this method is useful: + + - You need to install packages using a command other than ``pip + install`` or ``conda install``. + - You need to run a command as a prerequisite of package installation, + such as building a package or compiling a binary extension. + + :param env: A dictionary of environment variables to expose to the + command. By default, all environment variables are passed. + :type env: dict or None + :param bool silent: Silence command output, unless the command fails. + ``False`` by default. + :param success_codes: A list of return codes that are considered + successful. By default, only ``0`` is considered success. + :type success_codes: list, tuple, or None + :param external: If False (the default) then programs not in the + virtualenv path will cause a warning. If True, no warning will be + emitted. These warnings can be turned into errors using + ``--error-on-external-run``. This has no effect for sessions that + do not have a virtualenv. + :type external: bool + """ + if ( + self._runner.global_config.no_install + and self._runner.venv is not None + and self._runner.venv._reused + ): return None + if not args: + raise ValueError("At least one argument required to run_always().") + return self._run(*args, env=env, **kwargs) - def _run(self, *args: str, env: Mapping[str, str] = None, **kwargs: Any) -> Any: + def _run( + self, *args: str, env: Optional[Mapping[str, str]] = None, **kwargs: Any + ) -> Any: """Like run(), except that it runs even if --install-only is provided.""" # Legacy support - run a function given. if callable(args[0]): @@ -243,9 +357,11 @@ def _run(self, *args: str, env: Mapping[str, str] = None, **kwargs: Any) -> Any: kwargs["external"] = True # Run a shell command. - return nox.command.run(args, env=env, path=self.bin, **kwargs) + return nox.command.run(args, env=env, paths=self.bin_paths, **kwargs) - def conda_install(self, *args: str, **kwargs: Any) -> None: + def conda_install( + self, *args: str, auto_offline: bool = True, **kwargs: Any + ) -> None: """Install invokes `conda install`_ to install packages inside of the session's environment. @@ -260,6 +376,10 @@ def conda_install(self, *args: str, **kwargs: Any) -> None: session.conda_install('--file', 'requirements.txt') session.conda_install('--file', 'requirements-dev.txt') + By default this method will detect when internet connection is not + available and will add the `--offline` flag automatically in that case. + To disable this behaviour, set `auto_offline=False`. + To install the current package without clobbering conda-installed dependencies:: @@ -272,25 +392,43 @@ def conda_install(self, *args: str, **kwargs: Any) -> None: .. _conda install: """ venv = self._runner.venv - if not isinstance(venv, CondaEnv): + + prefix_args = () # type: Tuple[str, ...] + if isinstance(venv, CondaEnv): + prefix_args = ("--prefix", venv.location) + elif not isinstance(venv, PassthroughEnv): # pragma: no cover raise ValueError( "A session without a conda environment can not install dependencies from conda." ) + if not args: raise ValueError("At least one argument required to install().") + if self._runner.global_config.no_install and venv._reused: + return None + + # Escape args that should be (conda-specific; pip install does not need this) + args = _dblquote_pkg_install_args(args) + if "silent" not in kwargs: kwargs["silent"] = True + extraopts = () # type: Tuple[str, ...] + if auto_offline and venv.is_offline(): + logger.warning( + "Automatically setting the `--offline` flag as conda repo seems unreachable." + ) + extraopts = ("--offline",) + self._run( "conda", "install", "--yes", - "--prefix", - venv.location, + *extraopts, + *prefix_args, *args, external="error", - **kwargs + **kwargs, ) def install(self, *args: str, **kwargs: Any) -> None: @@ -318,35 +456,74 @@ def install(self, *args: str, **kwargs: Any) -> None: .. _pip: https://pip.readthedocs.org """ - if not isinstance(self._runner.venv, (CondaEnv, VirtualEnv)): + venv = self._runner.venv + + if not isinstance( + venv, (CondaEnv, VirtualEnv, PassthroughEnv) + ): # pragma: no cover raise ValueError( "A session without a virtualenv can not install dependencies." ) if not args: raise ValueError("At least one argument required to install().") + if self._runner.global_config.no_install and venv._reused: + return None + if "silent" not in kwargs: kwargs["silent"] = True - self._run("pip", "install", *args, external="error", **kwargs) + self._run("python", "-m", "pip", "install", *args, external="error", **kwargs) - def notify(self, target: "Union[str, SessionRunner]") -> None: + def notify( + self, + target: "Union[str, SessionRunner]", + posargs: Optional[Iterable[str]] = None, + ) -> None: """Place the given session at the end of the queue. This method is idempotent; multiple notifications to the same session have no effect. + A common use case is to notify a code coverage analysis session + from a test session:: + + @nox.session + def test(session): + session.run("pytest") + session.notify("coverage") + + @nox.session + def coverage(session): + session.run("coverage") + + Now if you run `nox -s test`, the coverage session will run afterwards. + Args: target (Union[str, Callable]): The session to be notified. This may be specified as the appropriate string (same as used for ``nox -s``) or using the function object. + posargs (Optional[Iterable[str]]): If given, sets the positional + arguments *only* for the queued session. Otherwise, the + standard globally available positional arguments will be + used instead. """ - self._runner.manifest.notify(target) + if posargs is not None: + posargs = list(posargs) + self._runner.manifest.notify(target, posargs) def log(self, *args: Any, **kwargs: Any) -> None: """Outputs a log during the session.""" logger.info(*args, **kwargs) + def warn(self, *args: Any, **kwargs: Any) -> None: + """Outputs a warning during the session.""" + logger.warning(*args, **kwargs) + + def debug(self, *args: Any, **kwargs: Any) -> None: + """Outputs a debug-level message during the session.""" + logger.debug(*args, **kwargs) + def error(self, *args: Any) -> "_typing.NoReturn": """Immediately aborts the session and optionally logs an error.""" raise _SessionQuit(*args) @@ -370,7 +547,8 @@ def __init__( self.func = func self.global_config = global_config self.manifest = manifest - self.venv = None # type: Optional[ProcessEnv] + self.venv: Optional[ProcessEnv] = None + self.posargs: List[str] = global_config.posargs[:] @property def description(self) -> Optional[str]: @@ -382,39 +560,48 @@ def description(self) -> Optional[str]: def __str__(self) -> str: sigs = ", ".join(self.signatures) - return "Session(name={}, signatures={})".format(self.name, sigs) + return f"Session(name={self.name}, signatures={sigs})" @property def friendly_name(self) -> str: return self.signatures[0] if self.signatures else self.name + @property + def envdir(self) -> str: + return _normalize_path(self.global_config.envdir, self.friendly_name) + def _create_venv(self) -> None: - if self.func.python is False: - self.venv = ProcessEnv() + backend = ( + self.global_config.force_venv_backend + or self.func.venv_backend + or self.global_config.default_venv_backend + ) + + if backend == "none" or self.func.python is False: + self.venv = PassthroughEnv() return - path = _normalize_path(self.global_config.envdir, self.friendly_name) reuse_existing = ( self.func.reuse_venv or self.global_config.reuse_existing_virtualenvs ) - if not self.func.venv_backend or self.func.venv_backend == "virtualenv": + if backend is None or backend == "virtualenv": self.venv = VirtualEnv( - path, + self.envdir, interpreter=self.func.python, # type: ignore reuse_existing=reuse_existing, venv_params=self.func.venv_params, ) - elif self.func.venv_backend == "conda": + elif backend == "conda": self.venv = CondaEnv( - path, + self.envdir, interpreter=self.func.python, # type: ignore reuse_existing=reuse_existing, venv_params=self.func.venv_params, ) - elif self.func.venv_backend == "venv": + elif backend == "venv": self.venv = VirtualEnv( - path, + self.envdir, interpreter=self.func.python, # type: ignore reuse_existing=reuse_existing, venv=True, @@ -422,15 +609,13 @@ def _create_venv(self) -> None: ) else: raise ValueError( - "Expected venv_backend one of ('virtualenv', 'conda', 'venv'), but got '{}'.".format( - self.func.venv_backend - ) + f"Expected venv_backend one of ('virtualenv', 'conda', 'venv'), but got '{backend}'." ) self.venv.create() def execute(self) -> "Result": - logger.warning("Running session {}".format(self.friendly_name)) + logger.warning(f"Running session {self.friendly_name}") try: # By default, nox should quietly change to the directory where @@ -463,13 +648,11 @@ def execute(self) -> "Result": return Result(self, Status.FAILED) except KeyboardInterrupt: - logger.error("Session {} interrupted.".format(self.friendly_name)) + logger.error(f"Session {self.friendly_name} interrupted.") raise except Exception as exc: - logger.exception( - "Session {} raised exception {!r}".format(self.friendly_name, exc) - ) + logger.exception(f"Session {self.friendly_name} raised exception {exc!r}") return Result(self, Status.FAILED) @@ -508,7 +691,7 @@ def imperfect(self) -> str: return "was successful" status = self.status.name.lower() if self.reason: - return "{}: {}".format(status, self.reason) + return f"{status}: {self.reason}" else: return status diff --git a/nox/tasks.py b/nox/tasks.py index 3cbbf3ef..e237e4d3 100644 --- a/nox/tasks.py +++ b/nox/tasks.py @@ -12,22 +12,62 @@ # See the License for the specific language governing permissions and # limitations under the License. -import importlib.machinery +import ast +import importlib.util import io import json import os +import sys import types from argparse import Namespace from typing import List, Union -import nox from colorlog.escape_codes import parse_colors + +import nox from nox import _options, registry +from nox._version import InvalidVersionSpecifier, VersionCheckFailed, check_nox_version from nox.logger import logger -from nox.manifest import Manifest +from nox.manifest import WARN_PYTHONS_IGNORED, Manifest from nox.sessions import Result +def _load_and_exec_nox_module(global_config: Namespace) -> types.ModuleType: + """ + Loads, executes, then returns the global_config nox module. + + Args: + global_config (Namespace): The global config. + + Raises: + IOError: If the nox module cannot be loaded. This + exception is chosen such that it will be caught + by load_nox_module and logged appropriately. + + Returns: + types.ModuleType: The initialised nox module. + """ + spec = importlib.util.spec_from_file_location( + "user_nox_module", global_config.noxfile + ) + if not spec: + raise IOError(f"Could not get module spec from {global_config.noxfile}") + + module = importlib.util.module_from_spec(spec) + if not module: + raise IOError(f"Noxfile {global_config.noxfile} is not a valid python module.") + + sys.modules["user_nox_module"] = module + + loader = spec.loader + if not loader: # pragma: no cover + raise IOError(f"Could not get module loader for {global_config.noxfile}") + # See https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly + # unsure why mypy doesn't like this + loader.exec_module(module) # type: ignore + return module + + def load_nox_module(global_config: Namespace) -> Union[types.ModuleType, int]: """Load the user's noxfile and return the module object for it. @@ -50,18 +90,30 @@ def load_nox_module(global_config: Namespace) -> Union[types.ModuleType, int]: # Be sure to expand variables os.path.expandvars(global_config.noxfile) ) + noxfile_parent_dir = os.path.realpath(os.path.dirname(global_config.noxfile)) + + # Check ``nox.needs_version`` by parsing the AST. + check_nox_version(global_config.noxfile) # Move to the path where the Noxfile is. # This will ensure that the Noxfile's path is on sys.path, and that # import-time path resolutions work the way the Noxfile author would - # guess. - os.chdir(os.path.realpath(os.path.dirname(global_config.noxfile))) - return importlib.machinery.SourceFileLoader( - "user_nox_module", global_config.noxfile - ).load_module() # type: ignore + # guess. The original working directory (the directory that Nox was + # invoked from) gets stored by the .invoke_from "option" in _options. + os.chdir(noxfile_parent_dir) + return _load_and_exec_nox_module(global_config) + + except (VersionCheckFailed, InvalidVersionSpecifier) as error: + logger.error(str(error)) + return 2 + except FileNotFoundError: + logger.error( + f"Failed to load Noxfile {global_config.noxfile}, no such file exists." + ) + return 2 except (IOError, OSError): - logger.exception("Failed to load Noxfile {}".format(global_config.noxfile)) + logger.exception(f"Failed to load Noxfile {global_config.noxfile}") return 2 @@ -96,8 +148,11 @@ def discover_manifest( # sorted by decorator call time. functions = registry.get() + # Get the docstring from the noxfile + module_docstring = module.__doc__ + # Return the final dictionary of session functions. - return Manifest(functions, global_config) + return Manifest(functions, global_config, module_docstring) def filter_manifest( @@ -132,9 +187,17 @@ def filter_manifest( manifest.filter_by_python_interpreter(global_config.pythons) # Filter by keywords. - # This function never errors, but may cause an empty list of sessions - # (which is an error condition later). if global_config.keywords: + try: + ast.parse(global_config.keywords, mode="eval") + except SyntaxError: + logger.error( + "Error while collecting sessions: keywords argument must be a Python expression." + ) + return 3 + + # This function never errors, but may cause an empty list of sessions + # (which is an error condition later). manifest.filter_by_keywords(global_config.keywords) # Return the modified manifest. @@ -158,9 +221,11 @@ def honor_list_request( return manifest # If the user just asked for a list of sessions, print that - # and be done. + # and any docstring specified in noxfile.py and be done. + if manifest.module_docstring: + print(manifest.module_docstring.strip(), end="\n\n") - print("Sessions defined in {noxfile}:\n".format(noxfile=global_config.noxfile)) + print(f"Sessions defined in {global_config.noxfile}:\n") reset = parse_colors("reset") if global_config.color else "" selected_color = parse_colors("cyan") if global_config.color else "" @@ -190,9 +255,7 @@ def honor_list_request( ) print( - "\nsessions marked with {selected_color}*{reset} are selected, sessions marked with {skipped_color}-{reset} are skipped.".format( - selected_color=selected_color, skipped_color=skipped_color, reset=reset - ) + f"\nsessions marked with {selected_color}*{reset} are selected, sessions marked with {skipped_color}-{reset} are skipped." ) return 0 @@ -233,12 +296,17 @@ def run_manifest(manifest: Manifest, global_config: Namespace) -> List[Result]: # Note that it is possible for the manifest to be altered in any given # iteration. for session in manifest: - result = session.execute() - result.log( - "Session {name} {status}.".format( - name=session.friendly_name, status=result.imperfect + # possibly raise warnings associated with this session + if WARN_PYTHONS_IGNORED in session.func.should_warn: + logger.warning( + f"Session {session.name} is set to run with venv_backend='none', " + f"IGNORING its python={session.func.should_warn[WARN_PYTHONS_IGNORED]} parametrization. " ) - ) + + result = session.execute() + name = session.friendly_name + status = result.imperfect + result.log(f"Session {name} {status}.") results.append(result) # Sanity check: If we are supposed to stop on the first error case, @@ -269,11 +337,9 @@ def print_summary(results: List[Result], global_config: Namespace) -> List[Resul # human-readable way. logger.warning("Ran multiple sessions:") for result in results: - result.log( - "* {name}: {status}".format( - name=result.session.friendly_name, status=result.status.name.lower() - ) - ) + name = result.session.friendly_name + status = result.status.name.lower() + result.log(f"* {name}: {status}") # Return the results that were sent to this function. return results diff --git a/nox/tox_to_nox.py b/nox/tox_to_nox.py index 222f5fb9..7e121901 100644 --- a/nox/tox_to_nox.py +++ b/nox/tox_to_nox.py @@ -29,7 +29,7 @@ def wrapjoin(seq: Iterator[Any]) -> str: - return ", ".join(["'{}'".format(item) for item in seq]) + return ", ".join([f"'{item}'" for item in seq]) def main() -> None: diff --git a/nox/virtualenv.py b/nox/virtualenv.py index 918525e4..93b700ef 100644 --- a/nox/virtualenv.py +++ b/nox/virtualenv.py @@ -17,10 +17,13 @@ import re import shutil import sys -from typing import Any, Mapping, Optional, Tuple, Union +from socket import gethostbyname +from typing import Any, List, Mapping, Optional, Tuple, Union -import nox.command import py + +import nox +import nox.command from nox.logger import logger from . import _typing @@ -31,26 +34,32 @@ ["PIP_RESPECT_VIRTUALENV", "PIP_REQUIRE_VIRTUALENV", "__PYVENV_LAUNCHER__"] ) _SYSTEM = platform.system() +_ENABLE_STALENESS_CHECK = "NOX_ENABLE_STALENESS_CHECK" in os.environ class InterpreterNotFound(OSError): def __init__(self, interpreter: str) -> None: - super().__init__("Python interpreter {} not found".format(interpreter)) + super().__init__(f"Python interpreter {interpreter} not found") self.interpreter = interpreter class ProcessEnv: """A environment with a 'bin' directory and a set of 'env' vars.""" + location: str + # Does this environment provide any process isolation? is_sandboxed = False # Special programs that aren't included in the environment. allowed_globals = () # type: _typing.ClassVar[Tuple[Any, ...]] - def __init__(self, bin: None = None, env: Mapping[str, str] = None) -> None: - self._bin = bin + def __init__( + self, bin_paths: None = None, env: Optional[Mapping[str, str]] = None + ) -> None: + self._bin_paths = bin_paths self.env = os.environ.copy() + self._reused = False if env is not None: self.env.update(env) @@ -58,15 +67,25 @@ def __init__(self, bin: None = None, env: Mapping[str, str] = None) -> None: for key in _BLACKLISTED_ENV_VARS: self.env.pop(key, None) - if self.bin: - self.env["PATH"] = os.pathsep.join([self.bin, self.env.get("PATH", "")]) + if self.bin_paths: + self.env["PATH"] = os.pathsep.join( + self.bin_paths + [self.env.get("PATH", "")] + ) + + @property + def bin_paths(self) -> Optional[List[str]]: + return self._bin_paths @property - def bin(self) -> Optional[str]: - return self._bin + def bin(self) -> str: + """The first bin directory for the virtualenv.""" + paths = self.bin_paths + if paths is None: + raise ValueError("The environment does not have a bin directory.") + return paths[0] def create(self) -> bool: - raise NotImplementedError("ProcessEnv.create should be overwitten in subclass") + raise NotImplementedError("ProcessEnv.create should be overwritten in subclass") def locate_via_py(version: str) -> Optional[str]: @@ -120,7 +139,7 @@ def locate_using_path_and_version(version: str) -> Optional[str]: path_python = py.path.local.sysfind("python") if path_python: try: - prefix = "{}.".format(version) + prefix = f"{version}." version_string = path_python.sysexec("-c", script).strip() if version_string.startswith(prefix): return str(path_python) @@ -130,19 +149,21 @@ def locate_using_path_and_version(version: str) -> Optional[str]: return None -def _clean_location(self: "Union[CondaEnv, VirtualEnv]") -> bool: - """Deletes any existing path-based environment""" - if os.path.exists(self.location): - if self.reuse_existing: - return False - else: - shutil.rmtree(self.location) +class PassthroughEnv(ProcessEnv): + """Represents the environment used to run nox itself + + For now, this class is empty but it might contain tools to grasp some + hints about the actual env. + """ - return True + @staticmethod + def is_offline() -> bool: + """As of now this is only used in conda_install""" + return CondaEnv.is_offline() # pragma: no cover class CondaEnv(ProcessEnv): - """Conda environemnt management class. + """Conda environment management class. Args: location (str): The location on the filesystem where the conda environment @@ -178,49 +199,75 @@ def __init__( self.venv_params = venv_params if venv_params else [] super(CondaEnv, self).__init__() - _clean_location = _clean_location + def _clean_location(self) -> bool: + """Deletes existing conda environment""" + if os.path.exists(self.location): + if self.reuse_existing: + return False + else: + cmd = ["conda", "remove", "--yes", "--prefix", self.location, "--all"] + nox.command.run(cmd, silent=True, log=False) + # Make sure that location is clean + try: + shutil.rmtree(self.location) + except FileNotFoundError: + pass + + return True @property - def bin(self) -> str: + def bin_paths(self) -> List[str]: """Returns the location of the conda env's bin folder.""" + # see https://docs.anaconda.com/anaconda/user-guide/tasks/integration/python-path/#examples if _SYSTEM == "Windows": - return os.path.join(self.location, "Scripts") + return [self.location, os.path.join(self.location, "Scripts")] else: - return os.path.join(self.location, "bin") + return [os.path.join(self.location, "bin")] def create(self) -> bool: """Create the conda env.""" if not self._clean_location(): - logger.debug( - "Re-using existing conda env at {}.".format(self.location_name) - ) + logger.debug(f"Re-using existing conda env at {self.location_name}.") + + self._reused = True + return False - cmd = [ - "conda", - "create", - "--yes", - "--prefix", - self.location, - # Ensure the pip package is installed. - "pip", - ] + cmd = ["conda", "create", "--yes", "--prefix", self.location] cmd.extend(self.venv_params) + # Ensure the pip package is installed. + cmd.append("pip") + if self.interpreter: - python_dep = "python={}".format(self.interpreter) + python_dep = f"python={self.interpreter}" else: python_dep = "python" cmd.append(python_dep) - logger.info( - "Creating conda env in {} with {}".format(self.location_name, python_dep) - ) - nox.command.run(cmd, silent=True, log=False) + logger.info(f"Creating conda env in {self.location_name} with {python_dep}") + nox.command.run(cmd, silent=True, log=nox.options.verbose or False) return True + @staticmethod + def is_offline() -> bool: + """Return `True` if we are sure that the user is not able to connect to https://repo.anaconda.com. + + Since an HTTP proxy might be correctly configured for `conda` using the `.condarc` `proxy_servers` section, + while not being correctly configured in the OS environment variables used by all other tools including python + `urllib` or `requests`, we are basically not able to do much more than testing the DNS resolution. + + See details in this explanation: https://stackoverflow.com/a/62486343/7262247 + """ + try: + # DNS resolution to detect situation (1) or (2). + host = gethostbyname("repo.anaconda.com") + return host is None + except: # pragma: no cover # noqa E722 + return True + class VirtualEnv(ProcessEnv): """Virtualenv management class. @@ -251,7 +298,7 @@ def __init__( reuse_existing: bool = False, *, venv: bool = False, - venv_params: Any = None + venv_params: Any = None, ): self.location_name = location self.location = os.path.abspath(location) @@ -262,7 +309,66 @@ def __init__( self.venv_params = venv_params if venv_params else [] super(VirtualEnv, self).__init__(env={"VIRTUAL_ENV": self.location}) - _clean_location = _clean_location + def _clean_location(self) -> bool: + """Deletes any existing virtual environment""" + if os.path.exists(self.location): + if self.reuse_existing and not _ENABLE_STALENESS_CHECK: + return False + if ( + self.reuse_existing + and self._check_reused_environment_type() + and self._check_reused_environment_interpreter() + ): + return False + else: + shutil.rmtree(self.location) + + return True + + def _check_reused_environment_type(self) -> bool: + """Check if reused environment type is the same.""" + path = os.path.join(self.location, "pyvenv.cfg") + if not os.path.isfile(path): + # virtualenv < 20.0 does not create pyvenv.cfg + old_env = "virtualenv" + else: + pattern = re.compile("virtualenv[ \t]*=") + with open(path) as fp: + old_env = ( + "virtualenv" if any(pattern.match(line) for line in fp) else "venv" + ) + return old_env == self.venv_or_virtualenv + + def _check_reused_environment_interpreter(self) -> bool: + """Check if reused environment interpreter is the same.""" + original = self._read_base_prefix_from_pyvenv_cfg() + program = ( + "import sys; sys.stdout.write(getattr(sys, 'real_prefix', sys.base_prefix))" + ) + + if original is None: + output = nox.command.run( + [self._resolved_interpreter, "-c", program], silent=True, log=False + ) + assert isinstance(output, str) + original = output + + created = nox.command.run( + ["python", "-c", program], silent=True, log=False, paths=self.bin_paths + ) + + return original == created + + def _read_base_prefix_from_pyvenv_cfg(self) -> Optional[str]: + """Return the base-prefix entry from pyvenv.cfg, if present.""" + path = os.path.join(self.location, "pyvenv.cfg") + if os.path.isfile(path): + with open(path) as io: + for line in io: + key, _, value = line.partition("=") + if key.strip() == "base-prefix": + return value.strip() + return None @property def _resolved_interpreter(self) -> str: @@ -290,10 +396,10 @@ def _resolved_interpreter(self) -> str: # If this is just a X, X.Y, or X.Y.Z string, extract just the X / X.Y # part and add Python to the front of it. - match = re.match(r"^(?P\d(\.\d)?)(\.\d+)?$", self.interpreter) + match = re.match(r"^(?P\d(\.\d+)?)(\.\d+)?$", self.interpreter) if match: xy_version = match.group("xy_ver") - cleaned_interpreter = "python{}".format(xy_version) + cleaned_interpreter = f"python{xy_version}" # If the cleaned interpreter is on the PATH, go ahead and return it. if py.path.local.sysfind(cleaned_interpreter): @@ -307,7 +413,7 @@ def _resolved_interpreter(self) -> str: raise self._resolved # Allow versions of the form ``X.Y-32`` for Windows. - match = re.match(r"^\d\.\d-32?$", cleaned_interpreter) + match = re.match(r"^\d\.\d+-32?$", cleaned_interpreter) if match: # preserve the "-32" suffix, as the Python launcher expects # it. @@ -329,21 +435,22 @@ def _resolved_interpreter(self) -> str: raise self._resolved @property - def bin(self) -> str: + def bin_paths(self) -> List[str]: """Returns the location of the virtualenv's bin folder.""" if _SYSTEM == "Windows": - return os.path.join(self.location, "Scripts") + return [os.path.join(self.location, "Scripts")] else: - return os.path.join(self.location, "bin") + return [os.path.join(self.location, "bin")] def create(self) -> bool: """Create the virtualenv or venv.""" if not self._clean_location(): logger.debug( - "Re-using existing virtual environment at {}.".format( - self.location_name - ) + f"Re-using existing virtual environment at {self.location_name}." ) + + self._reused = True + return False if self.venv_or_virtualenv == "virtualenv": @@ -354,13 +461,11 @@ def create(self) -> bool: cmd = [self._resolved_interpreter, "-m", "venv", self.location] cmd.extend(self.venv_params) + resolved_interpreter_name = os.path.basename(self._resolved_interpreter) + logger.info( - "Creating virtual environment ({}) using {} in {}".format( - self.venv_or_virtualenv, - os.path.basename(self._resolved_interpreter), - self.location_name, - ) + f"Creating virtual environment ({self.venv_or_virtualenv}) using {resolved_interpreter_name} in {self.location_name}" ) - nox.command.run(cmd, silent=True, log=False) + nox.command.run(cmd, silent=True, log=nox.options.verbose or False) return True diff --git a/noxfile.py b/noxfile.py index e20e5c1a..ec301bd5 100644 --- a/noxfile.py +++ b/noxfile.py @@ -12,11 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. + +import functools import os +import platform +import sys import nox -ON_APPVEYOR = os.environ.get("APPVEYOR") == "True" +ON_WINDOWS_CI = "CI" in os.environ and platform.system() == "Windows" def is_python_version(session, version): @@ -27,9 +31,10 @@ def is_python_version(session, version): return py_version.startswith(version) -@nox.session(python=["3.5", "3.6", "3.7", "3.8"]) +@nox.session(python=["3.6", "3.7", "3.8", "3.9", "3.10"]) def tests(session): """Run test suite with pytest.""" + session.create_tmp() session.install("-r", "requirements-test.txt") session.install("-e", ".[tox_to_nox]") tests = session.posargs or ["tests/"] @@ -37,18 +42,25 @@ def tests(session): session.run("pytest", *tests) return session.run( - "pytest", "--cov=nox", "--cov-config", ".coveragerc", "--cov-report=", *tests + "pytest", + "--cov=nox", + "--cov-config", + "pyproject.toml", + "--cov-report=", + *tests, + env={"COVERAGE_FILE": f".coverage.{session.python}"}, ) session.notify("cover") -@nox.session(python=["3.5", "3.6", "3.7", "3.8"], venv_backend="conda") +# TODO: When conda supports 3.10 on GHA, add here too +@nox.session(python=["3.6", "3.7", "3.8", "3.9"], venv_backend="conda") def conda_tests(session): """Run test suite with pytest.""" + session.create_tmp() session.conda_install( "--file", "requirements-conda-test.txt", "--channel", "conda-forge" ) - session.install("contexter", "--no-deps") session.install("-e", ".", "--no-deps") tests = session.posargs or ["tests/"] session.run("pytest", *tests) @@ -57,47 +69,61 @@ def conda_tests(session): @nox.session def cover(session): """Coverage analysis.""" - session.install("coverage") - if ON_APPVEYOR: - fail_under = "--fail-under=99" - else: - fail_under = "--fail-under=100" - session.run("coverage", "report", fail_under, "--show-missing") + if ON_WINDOWS_CI: + return + + # 3.10 produces different coverage results for some reason + # see https://github.com/theacodes/nox/issues/478 + fail_under = 100 + py_version = sys.version_info + if py_version.major == 3 and py_version.minor == 10: + fail_under = 99 + + session.install("coverage[toml]") + session.run("coverage", "combine") + session.run("coverage", "report", f"--fail-under={fail_under}", "--show-missing") session.run("coverage", "erase") @nox.session(python="3.8") def blacken(session): - """Run black code formater.""" - session.install("black==19.3b0", "isort==4.3.21") - files = ["nox", "tests", "noxfile.py", "setup.py"] + """Run black code formatter.""" + session.install("black==21.5b2", "isort==5.8.0") + files = ["nox", "tests", "noxfile.py", "docs/conf.py"] session.run("black", *files) - session.run("isort", "--recursive", *files) + session.run("isort", *files) @nox.session(python="3.8") def lint(session): - session.install("flake8==3.7.8", "black==19.3b0", "mypy==0.720") - session.run( - "mypy", - "--disallow-untyped-defs", - "--warn-unused-ignores", - "--ignore-missing-imports", - "nox", + session.install( + "flake8==3.9.2", + "black==21.6b0", + "isort==5.8.0", + "mypy==0.902", + "types-jinja2", + "packaging", + "importlib_metadata", ) - files = ["nox", "tests", "noxfile.py", "setup.py"] + session.run("mypy") + files = ["nox", "tests", "noxfile.py"] session.run("black", "--check", *files) - session.run("flake8", "nox", *files) + session.run("isort", "--check", *files) + session.run("flake8", *files) @nox.session(python="3.8") def docs(session): """Build the documentation.""" - session.run("rm", "-rf", "docs/_build", external=True) + output_dir = os.path.join(session.create_tmp(), "output") + doctrees, html = map( + functools.partial(os.path.join, output_dir), ["doctrees", "html"] + ) + session.run("rm", "-rf", output_dir, external=True) session.install("-r", "requirements-test.txt") session.install(".") session.cd("docs") - sphinx_args = ["-b", "html", "-W", "-d", "_build/doctrees", ".", "_build/html"] + sphinx_args = ["-b", "html", "-W", "-d", doctrees, ".", html] if not session.interactive: sphinx_cmd = "sphinx-build" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..a163ffe9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = [ + "setuptools>=42", + "wheel", +] +build-backend = "setuptools.build_meta" + +[tool.isort] +profile = "black" + +[tool.coverage.run] +branch = true +omit = [ + "nox/_typing.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if _typing.TYPE_CHECKING:", + "@overload", +] + +[tool.mypy] +files = ["nox"] +python_version = "3.6" +warn_unused_configs = true +disallow_any_generics = false +disallow_subclassing_any = false +disallow_untyped_calls = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_return_any = false +no_implicit_reexport = true +strict_equality = true + +[[tool.mypy.overrides]] +module = [ "argcomplete", "colorlog.*", "py", "tox.*" ] +ignore_missing_imports = true \ No newline at end of file diff --git a/requirements-test.txt b/requirements-test.txt index d49e0d07..6a0bc991 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,7 +1,6 @@ flask pytest pytest-cov -contexter sphinx sphinx-autobuild recommonmark diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..76cbc883 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,69 @@ +[metadata] +name = nox +version = 2021.10.1 +description = Flexible test automation. +long_description = file: README.rst +long_description_content_type = text/x-rst +url = https://nox.thea.codes +author = Alethea Katherine Flowers +author_email = me@thea.codes +license = Apache-2.0 +license_file = LICENSE +classifiers = + Development Status :: 5 - Production/Stable + Environment :: Console + Intended Audience :: Developers + License :: OSI Approved :: Apache Software License + Operating System :: MacOS + Operating System :: Microsoft :: Windows + Operating System :: POSIX + Operating System :: Unix + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + 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 + Topic :: Software Development :: Testing +keywords = testing automation tox +project_urls = + Documentation = https://nox.thea.codes + Source Code = https://github.com/theacodes/nox + Bug Tracker = https://github.com/theacodes/nox/issues + +[options] +packages = + nox +install_requires = + argcomplete>=1.9.4,<2.0 + colorlog>=2.6.1,<7.0.0 + packaging>=20.9 + py>=1.4.0,<2.0.0 + typing_extensions>=3.7.4;python_version < '3.8' + virtualenv>=14.0.0 + importlib_metadata;python_version < '3.8' +python_requires = >=3.6 +include_package_data = True +zip_safe = False + +[options.entry_points] +console_scripts = + nox = nox.__main__:main + tox-to-nox = nox.tox_to_nox:main [tox_to_nox] + +[options.extras_require] +tox_to_nox = + jinja2 + tox + +[options.package_data] +nox = py.typed + +[flake8] +# Ignore black styles. +ignore = E501, W503, E203 +# Imports +import-order-style = google +application-import-names = nox,tests diff --git a/setup.py b/setup.py deleted file mode 100644 index c39ab6f6..00000000 --- a/setup.py +++ /dev/null @@ -1,72 +0,0 @@ -# Copyright 2016 Alethea Katherine Flowers -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from codecs import open - -from setuptools import setup - -long_description = open("README.rst", "r", encoding="utf-8").read() - -setup( - name="nox", - version="2019.11.9", - description="Flexible test automation.", - long_description=long_description, - url="https://nox.thea.codes", - author="Alethea Katherine Flowers", - author_email="me@thea.codes", - license="Apache Software License", - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "License :: OSI Approved :: Apache Software License", - "Topic :: Software Development :: Testing", - "Environment :: Console", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Operating System :: POSIX", - "Operating System :: MacOS", - "Operating System :: Unix", - "Operating System :: Microsoft :: Windows", - ], - keywords="testing automation tox", - packages=["nox"], - package_data={"nox": ["py.typed"]}, - include_package_data=True, - zip_safe=False, - install_requires=[ - "argcomplete>=1.9.4,<2.0", - "colorlog>=2.6.1,<5.0.0", - "py>=1.4.0,<2.0.0", - "virtualenv>=14.0.0", - "importlib_metadata; python_version < '3.8'", - ], - extras_require={"tox_to_nox": ["jinja2", "tox"]}, - entry_points={ - "console_scripts": [ - "nox=nox.__main__:main", - "tox-to-nox=nox.tox_to_nox:main [tox_to_nox]", - ] - }, - project_urls={ - "Documentation": "https://nox.thea.codes", - "Source Code": "https://github.com/theacodes/nox", - "Bug Tracker": "https://github.com/theacodes/nox/issues", - }, - python_requires=">=3.5", -) diff --git a/tests/resources/noxfile_multiple_sessions.py b/tests/resources/noxfile_multiple_sessions.py new file mode 100644 index 00000000..47a6da75 --- /dev/null +++ b/tests/resources/noxfile_multiple_sessions.py @@ -0,0 +1,33 @@ +# Copyright 2018 Alethea Katherine Flowers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import nox + +# Deliberately giving these silly names so we know this is not confused +# with the projects noxfile + + +@nox.session +def testytest(session): + session.log("Testing") + + +@nox.session +def lintylint(session): + session.log("Linting") + + +@nox.session +def typeytype(session): + session.log("Type Checking") diff --git a/tests/resources/noxfile_nested.py b/tests/resources/noxfile_nested.py index b7e70327..7a254acd 100644 --- a/tests/resources/noxfile_nested.py +++ b/tests/resources/noxfile_nested.py @@ -18,4 +18,4 @@ @nox.session(py=False) @nox.parametrize("cheese", ["cheddar", "jack", "brie"]) def snack(unused_session, cheese): - print("Noms, {} so good!".format(cheese)) + print(f"Noms, {cheese} so good!") diff --git a/tests/resources/noxfile_normalization.py b/tests/resources/noxfile_normalization.py new file mode 100644 index 00000000..36a24880 --- /dev/null +++ b/tests/resources/noxfile_normalization.py @@ -0,0 +1,16 @@ +import datetime + +import nox + + +class Foo: + pass + + +@nox.session(venv_backend="none") +@nox.parametrize( + "arg", + ["Jane", "Joe's", '"hello world"', datetime.datetime(1980, 1, 1), [42], Foo()], +) +def test(session, arg): + pass diff --git a/tests/resources/noxfile_options_pythons.py b/tests/resources/noxfile_options_pythons.py new file mode 100644 index 00000000..000d4683 --- /dev/null +++ b/tests/resources/noxfile_options_pythons.py @@ -0,0 +1,28 @@ +# Copyright 2020 Alethea Katherine Flowers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import nox + +nox.options.sessions = ["{default_session}"] +nox.options.pythons = ["{default_python}"] + + +@nox.session(python=["{default_python}", "{alternate_python}"]) +def test(session): + pass + + +@nox.session(python=["{default_python}", "{alternate_python}"]) +def launch_rocket(session): + pass diff --git a/tests/resources/noxfile_pythons.py b/tests/resources/noxfile_pythons.py new file mode 100644 index 00000000..a9a03d30 --- /dev/null +++ b/tests/resources/noxfile_pythons.py @@ -0,0 +1,7 @@ +import nox + + +@nox.session(python=["3.6"]) +@nox.parametrize("cheese", ["cheddar", "jack", "brie"]) +def snack(unused_session, cheese): + print(f"Noms, {cheese} so good!") diff --git a/tests/resources/noxfile_spaces.py b/tests/resources/noxfile_spaces.py index 8c006f74..f11ca9a4 100644 --- a/tests/resources/noxfile_spaces.py +++ b/tests/resources/noxfile_spaces.py @@ -18,4 +18,4 @@ @nox.session(py=False, name="cheese list") @nox.parametrize("cheese", ["cheddar", "jack", "brie"]) def snack(unused_session, cheese): - print("Noms, {} so good!".format(cheese)) + print(f"Noms, {cheese} so good!") diff --git a/tests/test__option_set.py b/tests/test__option_set.py index 3bcf4f21..99d2d95a 100644 --- a/tests/test__option_set.py +++ b/tests/test__option_set.py @@ -12,17 +12,28 @@ # See the License for the specific language governing permissions and # limitations under the License. +from pathlib import Path + import pytest + from nox import _option_set, _options # The vast majority of _option_set is tested by test_main, but the test helper # :func:`OptionSet.namespace` needs a bit of help to get to full coverage. +RESOURCES = Path(__file__).parent.joinpath("resources") + + class TestOptionSet: def test_namespace(self): optionset = _option_set.OptionSet() - optionset.add_options(_option_set.Option("option_a", default="meep")) + optionset.add_groups(_option_set.OptionGroup("group_a")) + optionset.add_options( + _option_set.Option( + "option_a", group=optionset.groups["group_a"], default="meep" + ) + ) namespace = optionset.namespace() @@ -32,7 +43,12 @@ def test_namespace(self): def test_namespace_values(self): optionset = _option_set.OptionSet() - optionset.add_options(_option_set.Option("option_a", default="meep")) + optionset.add_groups(_option_set.OptionGroup("group_a")) + optionset.add_options( + _option_set.Option( + "option_a", group=optionset.groups["group_a"], default="meep" + ) + ) namespace = optionset.namespace(option_a="moop") @@ -44,18 +60,45 @@ def test_namespace_non_existant_options_with_values(self): with pytest.raises(KeyError): optionset.namespace(non_existant_option="meep") + def test_parser_hidden_option(self): + optionset = _option_set.OptionSet() + optionset.add_options( + _option_set.Option( + "oh_boy_i_am_hidden", hidden=True, group=None, default="meep" + ) + ) + + parser = optionset.parser() + namespace = parser.parse_args([]) + optionset._finalize_args(namespace) + + assert namespace.oh_boy_i_am_hidden == "meep" + + def test_parser_groupless_option(self): + optionset = _option_set.OptionSet() + optionset.add_options( + _option_set.Option("oh_no_i_have_no_group", group=None, default="meep") + ) + + with pytest.raises(ValueError): + optionset.parser() + def test_session_completer(self): - parsed_args = _options.options.namespace(sessions=(), keywords=()) - all_nox_sessions = _options._session_completer( + parsed_args = _options.options.namespace( + posargs=[], + noxfile=str(RESOURCES.joinpath("noxfile_multiple_sessions.py")), + ) + actual_sessions_from_file = _options._session_completer( prefix=None, parsed_args=parsed_args ) - # if noxfile.py changes, this will have to change as well since these are - # some of the actual sessions found in noxfile.py - some_expected_sessions = ["cover", "blacken", "lint", "docs"] - assert len(set(some_expected_sessions) - set(all_nox_sessions)) == 0 + + expected_sessions = ["testytest", "lintylint", "typeytype"] + assert expected_sessions == actual_sessions_from_file def test_session_completer_invalid_sessions(self): - parsed_args = _options.options.namespace(sessions=("baz",), keywords=()) + parsed_args = _options.options.namespace( + sessions=("baz",), keywords=(), posargs=[] + ) all_nox_sessions = _options._session_completer( prefix=None, parsed_args=parsed_args ) diff --git a/tests/test__parametrize.py b/tests/test__parametrize.py index 2a7f69b3..e581380b 100644 --- a/tests/test__parametrize.py +++ b/tests/test__parametrize.py @@ -15,7 +15,8 @@ from unittest import mock import pytest -from nox import _decorators, _parametrize + +from nox import _decorators, _parametrize, parametrize, session @pytest.mark.parametrize( @@ -200,19 +201,19 @@ def test_generate_calls_multiple_args(): f = mock.Mock() f.__name__ = "f" - arg_names = ("abc", "foo") + arg_names = ("foo", "abc") call_specs = [ - _parametrize.Param(1, "a", arg_names=arg_names), - _parametrize.Param(2, "b", arg_names=arg_names), - _parametrize.Param(3, "c", arg_names=arg_names), + _parametrize.Param("a", 1, arg_names=arg_names), + _parametrize.Param("b", 2, arg_names=arg_names), + _parametrize.Param("c", 3, arg_names=arg_names), ] calls = _decorators.Call.generate_calls(f, call_specs) assert len(calls) == 3 - assert calls[0].session_signature == "(abc=1, foo='a')" - assert calls[1].session_signature == "(abc=2, foo='b')" - assert calls[2].session_signature == "(abc=3, foo='c')" + assert calls[0].session_signature == "(foo='a', abc=1)" + assert calls[1].session_signature == "(foo='b', abc=2)" + assert calls[2].session_signature == "(foo='c', abc=3)" calls[0]() f.assert_called_with(abc=1, foo="a") @@ -242,3 +243,69 @@ def test_generate_calls_ids(): f.assert_called_with(foo=1) calls[1]() f.assert_called_with(foo=2) + + +def test_generate_calls_session_python(): + called_with = [] + + @session + @parametrize("python,dependency", [("3.8", "0.9"), ("3.9", "0.9"), ("3.9", "1.0")]) + def f(session, dependency): + called_with.append((session, dependency)) + + calls = _decorators.Call.generate_calls(f, f.parametrize) + + assert len(calls) == 3 + + assert calls[0].python == "3.8" + assert calls[1].python == "3.9" + assert calls[2].python == "3.9" + + assert calls[0].session_signature == "(python='3.8', dependency='0.9')" + assert calls[1].session_signature == "(python='3.9', dependency='0.9')" + assert calls[2].session_signature == "(python='3.9', dependency='1.0')" + + session_ = () + + calls[0](session_) + calls[1](session_) + calls[2](session_) + + assert len(called_with) == 3 + + assert called_with[0] == (session_, "0.9") + assert called_with[1] == (session_, "0.9") + assert called_with[2] == (session_, "1.0") + + +def test_generate_calls_python_compatibility(): + called_with = [] + + @session + @parametrize("python,dependency", [("3.8", "0.9"), ("3.9", "0.9"), ("3.9", "1.0")]) + def f(session, python, dependency): + called_with.append((session, python, dependency)) + + calls = _decorators.Call.generate_calls(f, f.parametrize) + + assert len(calls) == 3 + + assert calls[0].python is None + assert calls[1].python is None + assert calls[2].python is None + + assert calls[0].session_signature == "(python='3.8', dependency='0.9')" + assert calls[1].session_signature == "(python='3.9', dependency='0.9')" + assert calls[2].session_signature == "(python='3.9', dependency='1.0')" + + session_ = () + + calls[0](session_) + calls[1](session_) + calls[2](session_) + + assert len(called_with) == 3 + + assert called_with[0] == (session_, "3.8", "0.9") + assert called_with[1] == (session_, "3.9", "0.9") + assert called_with[2] == (session_, "3.9", "1.0") diff --git a/tests/test__version.py b/tests/test__version.py new file mode 100644 index 00000000..b4497d02 --- /dev/null +++ b/tests/test__version.py @@ -0,0 +1,136 @@ +# Copyright 2021 Alethea Katherine Flowers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path +from textwrap import dedent +from typing import Optional + +import pytest + +from nox import needs_version +from nox._version import ( + InvalidVersionSpecifier, + VersionCheckFailed, + _parse_needs_version, + check_nox_version, + get_nox_version, +) + + +@pytest.fixture +def temp_noxfile(tmp_path: Path): + def make_temp_noxfile(content: str) -> str: + path = tmp_path / "noxfile.py" + path.write_text(content) + return str(path) + + return make_temp_noxfile + + +def test_needs_version_default() -> None: + """It is None by default.""" + assert needs_version is None + + +def test_get_nox_version() -> None: + """It returns something that looks like a Nox version.""" + result = get_nox_version() + year, month, day = [int(part) for part in result.split(".")[:3]] + assert year >= 2020 + + +@pytest.mark.parametrize( + "text,expected", + [ + ("", None), + ( + dedent( + """ + import nox + nox.needs_version = '>=2020.12.31' + """ + ), + ">=2020.12.31", + ), + ( + dedent( + """ + import nox + nox.needs_version = 'bogus' + nox.needs_version = '>=2020.12.31' + """ + ), + ">=2020.12.31", + ), + ( + dedent( + """ + import nox.sessions + nox.needs_version = '>=2020.12.31' + """ + ), + ">=2020.12.31", + ), + ( + dedent( + """ + import nox as _nox + _nox.needs_version = '>=2020.12.31' + """ + ), + None, + ), + ], +) +def test_parse_needs_version(text: str, expected: Optional[str]) -> None: + """It is parsed successfully.""" + assert expected == _parse_needs_version(text) + + +@pytest.mark.parametrize("specifiers", ["", ">=2020.12.31", ">=2020.12.31,<9999.99.99"]) +def test_check_nox_version_succeeds(temp_noxfile, specifiers: str) -> None: + """It does not raise if the version specifiers are satisfied.""" + text = dedent( + f""" + import nox + nox.needs_version = "{specifiers}" + """ + ) + check_nox_version(temp_noxfile(text)) + + +@pytest.mark.parametrize("specifiers", [">=9999.99.99"]) +def test_check_nox_version_fails(temp_noxfile, specifiers: str) -> None: + """It raises an exception if the version specifiers are not satisfied.""" + text = dedent( + f""" + import nox + nox.needs_version = "{specifiers}" + """ + ) + with pytest.raises(VersionCheckFailed): + check_nox_version(temp_noxfile(text)) + + +@pytest.mark.parametrize("specifiers", ["invalid", "2020.12.31"]) +def test_check_nox_version_invalid(temp_noxfile, specifiers: str) -> None: + """It raises an exception if the version specifiers cannot be parsed.""" + text = dedent( + f""" + import nox + nox.needs_version = "{specifiers}" + """ + ) + with pytest.raises(InvalidVersionSpecifier): + check_nox_version(temp_noxfile(text)) diff --git a/tests/test_command.py b/tests/test_command.py index 3124f904..db49a874 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -12,16 +12,33 @@ # See the License for the specific language governing permissions and # limitations under the License. +import ctypes import logging import os +import platform +import signal +import subprocess import sys +import time +from textwrap import dedent from unittest import mock -import nox.command import pytest +import nox.command +import nox.popen + PYTHON = sys.executable +skip_on_windows_primary_console_session = pytest.mark.skipif( + platform.system() == "Windows" and "SECONDARY_CONSOLE_SESSION" not in os.environ, + reason="On Windows, this test must run in a separate console session.", +) + +only_on_windows = pytest.mark.skipif( + platform.system() != "Windows", reason="Only run this test on Windows." +) + def test_run_defaults(capsys): result = nox.command.run([PYTHON, "-c", "print(123)"]) @@ -97,7 +114,7 @@ def test_run_env_unicode(): result = nox.command.run( [PYTHON, "-c", 'import os; print(os.environ["SIGIL"])'], silent=True, - env={u"SIGIL": u"123"}, + env={"SIGIL": "123"}, ) assert "123" in result @@ -122,7 +139,7 @@ def test_run_path_nonexistent(): result = nox.command.run( [PYTHON, "-c", "import sys; print(sys.executable)"], silent=True, - path="/non/existent", + paths=["/non/existent"], ) assert "/non/existent" not in result @@ -135,14 +152,14 @@ def test_run_path_existent(tmpdir, monkeypatch): with mock.patch("nox.command.popen") as mock_command: mock_command.return_value = (0, "") - nox.command.run(["testexc"], silent=True, path=tmpdir.strpath) + nox.command.run(["testexc"], silent=True, paths=[tmpdir.strpath]) mock_command.assert_called_with([executable.strpath], env=None, silent=True) def test_run_external_warns(tmpdir, caplog): caplog.set_level(logging.WARNING) - nox.command.run([PYTHON, "--version"], silent=True, path=tmpdir.strpath) + nox.command.run([PYTHON, "--version"], silent=True, paths=[tmpdir.strpath]) assert "external=True" in caplog.text @@ -151,7 +168,7 @@ def test_run_external_silences(tmpdir, caplog): caplog.set_level(logging.WARNING) nox.command.run( - [PYTHON, "--version"], silent=True, path=tmpdir.strpath, external=True + [PYTHON, "--version"], silent=True, paths=[tmpdir.strpath], external=True ) assert "external=True" not in caplog.text @@ -162,7 +179,7 @@ def test_run_external_raises(tmpdir, caplog): with pytest.raises(nox.command.CommandFailed): nox.command.run( - [PYTHON, "--version"], silent=True, path=tmpdir.strpath, external="error" + [PYTHON, "--version"], silent=True, paths=[tmpdir.strpath], external="error" ) assert "external=True" in caplog.text @@ -195,13 +212,153 @@ def test_fail_with_silent(capsys): assert "err" in err -def test_interrupt(): - mock_proc = mock.Mock() - mock_proc.communicate.side_effect = KeyboardInterrupt() +@pytest.fixture +def marker(tmp_path): + """A marker file for process communication.""" + return tmp_path / "marker" + + +def enable_ctrl_c(enabled): + """Enable keyboard interrupts (CTRL-C) on Windows.""" + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + + if not kernel32.SetConsoleCtrlHandler(None, not enabled): + raise ctypes.WinError(ctypes.get_last_error()) + + +def interrupt_process(proc): + """Send SIGINT or CTRL_C_EVENT to the process.""" + if platform.system() == "Windows": + # Disable Ctrl-C so we don't terminate ourselves. + enable_ctrl_c(False) + + # Send the keyboard interrupt to all processes attached to the current + # console session. + os.kill(0, signal.CTRL_C_EVENT) + else: + proc.send_signal(signal.SIGINT) + + +@pytest.fixture +def command_with_keyboard_interrupt(monkeypatch, marker): + """Monkeypatch Popen.communicate to raise KeyboardInterrupt.""" + if platform.system() == "Windows": + # Enable Ctrl-C because the child inherits the setting from us. + enable_ctrl_c(True) + + communicate = subprocess.Popen.communicate + + def wrapper(proc, *args, **kwargs): + # Raise the interrupt only on the first call, so Nox has a chance to + # shut down the child process subsequently. + + if wrapper.firstcall: + wrapper.firstcall = False + + # Give the child time to install its signal handlers. + while not marker.exists(): + time.sleep(0.05) + + # Send a real keyboard interrupt to the child. + interrupt_process(proc) + + # Fake a keyboard interrupt in the parent. + raise KeyboardInterrupt + + return communicate(proc, *args, **kwargs) + + wrapper.firstcall = True + + monkeypatch.setattr("subprocess.Popen.communicate", wrapper) + + +def format_program(program, marker): + """Preprocess the Python program run by the child process.""" + main = f""" + import time + from pathlib import Path + + Path({str(marker)!r}).touch() + time.sleep(3) + """ + return dedent(program).format(MAIN=dedent(main)) + + +def run_pytest_in_new_console_session(test): + """Run the given test in a separate console session.""" + env = dict(os.environ, SECONDARY_CONSOLE_SESSION="") + creationflags = ( + subprocess.CREATE_NO_WINDOW + if sys.version_info[:2] >= (3, 7) + else subprocess.CREATE_NEW_CONSOLE + ) + + subprocess.run( + [sys.executable, "-m", "pytest", f"{__file__}::{test}"], + env=env, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + creationflags=creationflags, + ) + + +@skip_on_windows_primary_console_session +@pytest.mark.parametrize( + "program", + [ + """ + {MAIN} + """, + """ + import signal + + signal.signal(signal.SIGINT, signal.SIG_IGN) + + {MAIN} + """, + """ + import signal + + signal.signal(signal.SIGINT, signal.SIG_IGN) + signal.signal(signal.SIGTERM, signal.SIG_IGN) + + {MAIN} + """, + ], +) +def test_interrupt_raises(command_with_keyboard_interrupt, program, marker): + """It kills the process and reraises the keyboard interrupt.""" + with pytest.raises(KeyboardInterrupt): + nox.command.run([PYTHON, "-c", format_program(program, marker)]) + + +@only_on_windows +def test_interrupt_raises_on_windows(): + """It kills the process and reraises the keyboard interrupt.""" + run_pytest_in_new_console_session("test_interrupt_raises") + - with mock.patch("subprocess.Popen", return_value=mock_proc): - with pytest.raises(KeyboardInterrupt): - nox.command.run([PYTHON, "-c" "123"]) +@skip_on_windows_primary_console_session +def test_interrupt_handled(command_with_keyboard_interrupt, marker): + """It does not raise if the child handles the keyboard interrupt.""" + program = """ + import signal + + def exithandler(sig, frame): + raise SystemExit() + + signal.signal(signal.SIGINT, exithandler) + + {MAIN} + """ + nox.command.run([PYTHON, "-c", format_program(program, marker)]) + + +@only_on_windows +def test_interrupt_handled_on_windows(): + """It does not raise if the child handles the keyboard interrupt.""" + run_pytest_in_new_console_session("test_interrupt_handled") def test_custom_stdout(capsys, tmpdir): @@ -294,3 +451,43 @@ def test_custom_stderr_failed_command(capsys, tmpdir): tempfile_contents = stderr.read().decode("utf-8") assert "out" not in tempfile_contents assert "err" in tempfile_contents + + +def test_output_decoding() -> None: + result = nox.popen.decode_output(b"abc") + + assert result == "abc" + + +def test_output_decoding_non_ascii() -> None: + result = nox.popen.decode_output("ü".encode("utf-8")) + + assert result == "ü" + + +def test_output_decoding_utf8_only_fail(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(nox.popen.locale, "getpreferredencoding", lambda: "utf8") + + with pytest.raises(UnicodeDecodeError) as exc: + nox.popen.decode_output(b"\x95") + + assert exc.value.encoding == "utf-8" + + +def test_output_decoding_utf8_fail_cp1252_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(nox.popen.locale, "getpreferredencoding", lambda: "cp1252") + + result = nox.popen.decode_output(b"\x95") + + assert result == "•" # U+2022 + + +def test_output_decoding_both_fail(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(nox.popen.locale, "getpreferredencoding", lambda: "ascii") + + with pytest.raises(UnicodeDecodeError) as exc: + nox.popen.decode_output(b"\x95") + + assert exc.value.encoding == "ascii" diff --git a/tests/test_logger.py b/tests/test_logger.py index 9d8b6fc7..eef9dcf3 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -15,6 +15,8 @@ import logging from unittest import mock +import pytest + from nox import logger @@ -39,6 +41,7 @@ def test_formatter(caplog): logs = [rec for rec in caplog.records if rec.levelname in ("INFO", "OUTPUT")] assert len(logs) == 1 + assert not hasattr(logs[0], "asctime") caplog.clear() with caplog.at_level(logger.OUTPUT): @@ -52,3 +55,38 @@ def test_formatter(caplog): assert len(logs) == 1 # Make sure output level log records are not nox prefixed assert "nox" not in logs[0].message + + +@pytest.mark.parametrize( + "color", + [ + # This currently fails due to some incompatibility between caplog and colorlog + # that causes caplog to not collect the asctime from colorlog. + pytest.param(True, id="color", marks=pytest.mark.xfail), + pytest.param(False, id="no-color"), + ], +) +def test_no_color_timestamp(caplog, color): + logger.setup_logging(color=color, add_timestamp=True) + caplog.clear() + with caplog.at_level(logging.DEBUG): + logger.logger.info("bar") + logger.logger.output("foo") + + logs = [rec for rec in caplog.records if rec.levelname in ("INFO", "OUTPUT")] + assert len(logs) == 1 + assert hasattr(logs[0], "asctime") + + caplog.clear() + with caplog.at_level(logger.OUTPUT): + logger.logger.info("bar") + logger.logger.output("foo") + + logs = [rec for rec in caplog.records if rec.levelname != "OUTPUT"] + assert len(logs) == 1 + assert hasattr(logs[0], "asctime") + + logs = [rec for rec in caplog.records if rec.levelname == "OUTPUT"] + assert len(logs) == 1 + # no timestamp for output + assert not hasattr(logs[0], "asctime") diff --git a/tests/test_main.py b/tests/test_main.py index 78002581..0b61e804 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -12,17 +12,19 @@ # See the License for the specific language governing permissions and # limitations under the License. +import contextlib import os import sys +from pathlib import Path from unittest import mock -import contexter +import pytest + import nox import nox.__main__ import nox._options import nox.registry import nox.sessions -import pytest try: import importlib.metadata as metadata @@ -55,6 +57,7 @@ def test_main_no_args(monkeypatch): config = execute.call_args[1]["global_config"] assert config.noxfile == "noxfile.py" assert config.sessions is None + assert not config.no_venv assert not config.reuse_existing_virtualenvs assert not config.stop_on_first_error assert config.posargs == [] @@ -70,6 +73,11 @@ def test_main_long_form_args(): "--sessions", "1", "2", + "--default-venv-backend", + "venv", + "--force-venv-backend", + "none", + "--no-venv", "--reuse-existing-virtualenvs", "--stop-on-first-error", ] @@ -87,14 +95,72 @@ def test_main_long_form_args(): assert config.noxfile == "noxfile.py" assert config.envdir.endswith(".other") assert config.sessions == ["1", "2"] + assert config.default_venv_backend == "venv" + assert config.force_venv_backend == "none" + assert config.no_venv is True assert config.reuse_existing_virtualenvs is True assert config.stop_on_first_error is True assert config.posargs == [] +def test_main_no_venv(monkeypatch, capsys): + # Check that --no-venv overrides force_venv_backend + monkeypatch.setattr( + sys, + "argv", + [ + "nox", + "--noxfile", + os.path.join(RESOURCES, "noxfile_pythons.py"), + "--no-venv", + "-s", + "snack(cheese='cheddar')", + ], + ) + + with mock.patch("sys.exit") as sys_exit: + nox.__main__.main() + stdout, stderr = capsys.readouterr() + assert stdout == "Noms, cheddar so good!\n" + assert ( + "Session snack is set to run with venv_backend='none', IGNORING its python" + in stderr + ) + assert "Session snack(cheese='cheddar') was successful." in stderr + sys_exit.assert_called_once_with(0) + + +def test_main_no_venv_error(): + # Check that --no-venv can not be set together with a non-none --force-venv-backend + sys.argv = [ + sys.executable, + "--noxfile", + "noxfile.py", + "--force-venv-backend", + "conda", + "--no-venv", + ] + with pytest.raises(ValueError, match="You can not use"): + nox.__main__.main() + + def test_main_short_form_args(monkeypatch): monkeypatch.setattr( - sys, "argv", [sys.executable, "-f", "noxfile.py", "-s", "1", "2", "-r"] + sys, + "argv", + [ + sys.executable, + "-f", + "noxfile.py", + "-s", + "1", + "2", + "-db", + "venv", + "-fb", + "conda", + "-r", + ], ) with mock.patch("nox.workflow.execute") as execute: execute.return_value = 0 @@ -109,6 +175,8 @@ def test_main_short_form_args(monkeypatch): config = execute.call_args[1]["global_config"] assert config.noxfile == "noxfile.py" assert config.sessions == ["1", "2"] + assert config.default_venv_backend == "venv" + assert config.force_venv_backend == "conda" assert config.reuse_existing_virtualenvs is True @@ -229,7 +297,7 @@ def test_main_positional_flag_like_with_double_hyphen(monkeypatch): def test_main_version(capsys, monkeypatch): monkeypatch.setattr(sys, "argv", [sys.executable, "--version"]) - with contexter.ExitStack() as stack: + with contextlib.ExitStack() as stack: execute = stack.enter_context(mock.patch("nox.workflow.execute")) exit_mock = stack.enter_context(mock.patch("sys.exit")) nox.__main__.main() @@ -242,7 +310,7 @@ def test_main_version(capsys, monkeypatch): def test_main_help(capsys, monkeypatch): monkeypatch.setattr(sys, "argv", [sys.executable, "--help"]) - with contexter.ExitStack() as stack: + with contextlib.ExitStack() as stack: execute = stack.enter_context(mock.patch("nox.workflow.execute")) exit_mock = stack.enter_context(mock.patch("sys.exit")) nox.__main__.main() @@ -303,6 +371,78 @@ def test_main_session_with_names(capsys, monkeypatch): sys_exit.assert_called_once_with(0) +@pytest.fixture +def run_nox(capsys, monkeypatch): + def _run_nox(*args): + monkeypatch.setattr(sys, "argv", ["nox", *args]) + + with mock.patch("sys.exit") as sys_exit: + nox.__main__.main() + stdout, stderr = capsys.readouterr() + returncode = sys_exit.call_args[0][0] + + return returncode, stdout, stderr + + return _run_nox + + +@pytest.mark.parametrize( + ("normalized_name", "session"), + [ + ("test(arg='Jane')", "test(arg='Jane')"), + ("test(arg='Jane')", 'test(arg="Jane")'), + ("test(arg='Jane')", 'test(arg = "Jane")'), + ("test(arg='Jane')", 'test ( arg = "Jane" )'), + ('test(arg="Joe\'s")', 'test(arg="Joe\'s")'), + ('test(arg="Joe\'s")', "test(arg='Joe\\'s')"), + ("test(arg='\"hello world\"')", "test(arg='\"hello world\"')"), + ("test(arg='\"hello world\"')", 'test(arg="\\"hello world\\"")'), + ("test(arg=[42])", "test(arg=[42])"), + ("test(arg=[42])", "test(arg=[42,])"), + ("test(arg=[42])", "test(arg=[ 42 ])"), + ("test(arg=[42])", "test(arg=[0x2a])"), + ( + "test(arg=datetime.datetime(1980, 1, 1, 0, 0))", + "test(arg=datetime.datetime(1980, 1, 1, 0, 0))", + ), + ( + "test(arg=datetime.datetime(1980, 1, 1, 0, 0))", + "test(arg=datetime.datetime(1980,1,1,0,0))", + ), + ( + "test(arg=datetime.datetime(1980, 1, 1, 0, 0))", + "test(arg=datetime.datetime(1980, 1, 1, 0, 0x0))", + ), + ], +) +def test_main_with_normalized_session_names(run_nox, normalized_name, session): + noxfile = os.path.join(RESOURCES, "noxfile_normalization.py") + returncode, _, stderr = run_nox(f"--noxfile={noxfile}", f"--session={session}") + assert returncode == 0 + assert normalized_name in stderr + + +@pytest.mark.parametrize( + "session", + [ + "syntax error", + "test(arg=Jane)", + "test(arg='Jane ')", + "_test(arg='Jane')", + "test(arg=42)", + "test(arg=[42.0])", + "test(arg=[43])", + "test(arg=)", + "test(arg=datetime.datetime(1980, 1, 1))", + ], +) +def test_main_with_bad_session_names(run_nox, session): + noxfile = os.path.join(RESOURCES, "noxfile_normalization.py") + returncode, _, stderr = run_nox(f"--noxfile={noxfile}", f"--session={session}") + assert returncode != 0 + assert session in stderr + + def test_main_noxfile_options(monkeypatch): monkeypatch.setattr( sys, @@ -378,6 +518,88 @@ def test_main_noxfile_options_sessions(monkeypatch): assert config.sessions == ["test"] +@pytest.fixture +def generate_noxfile_options_pythons(tmp_path): + """Generate noxfile.py with test and launch_rocket sessions. + + The sessions are defined for both the default and alternate Python versions. + The ``default_session`` and ``default_python`` parameters determine what + goes into ``nox.options.sessions`` and ``nox.options.pythons``, respectively. + """ + + def generate_noxfile(default_session, default_python, alternate_python): + path = Path(RESOURCES) / "noxfile_options_pythons.py" + text = path.read_text() + text = text.format( + default_session=default_session, + default_python=default_python, + alternate_python=alternate_python, + ) + path = tmp_path / "noxfile.py" + path.write_text(text) + return str(path) + + return generate_noxfile + + +python_current_version = f"{sys.version_info.major}.{sys.version_info.minor}" +python_next_version = f"{sys.version_info.major}.{sys.version_info.minor + 1}" + + +def test_main_noxfile_options_with_pythons_override( + capsys, monkeypatch, generate_noxfile_options_pythons +): + noxfile = generate_noxfile_options_pythons( + default_session="test", + default_python=python_next_version, + alternate_python=python_current_version, + ) + + monkeypatch.setattr( + sys, "argv", ["nox", "--noxfile", noxfile, "--python", python_current_version] + ) + + with mock.patch("sys.exit") as sys_exit: + nox.__main__.main() + _, stderr = capsys.readouterr() + sys_exit.assert_called_once_with(0) + + for python_version in [python_current_version, python_next_version]: + for session in ["test", "launch_rocket"]: + line = f"Running session {session}-{python_version}" + if session == "test" and python_version == python_current_version: + assert line in stderr + else: + assert line not in stderr + + +def test_main_noxfile_options_with_sessions_override( + capsys, monkeypatch, generate_noxfile_options_pythons +): + noxfile = generate_noxfile_options_pythons( + default_session="test", + default_python=python_current_version, + alternate_python=python_next_version, + ) + + monkeypatch.setattr( + sys, "argv", ["nox", "--noxfile", noxfile, "--session", "launch_rocket"] + ) + + with mock.patch("sys.exit") as sys_exit: + nox.__main__.main() + _, stderr = capsys.readouterr() + sys_exit.assert_called_once_with(0) + + for python_version in [python_current_version, python_next_version]: + for session in ["test", "launch_rocket"]: + line = f"Running session {session}-{python_version}" + if session == "launch_rocket" and python_version == python_current_version: + assert line in stderr + else: + assert line not in stderr + + @pytest.mark.parametrize(("isatty_value", "expected"), [(True, True), (False, False)]) def test_main_color_from_isatty(monkeypatch, isatty_value, expected): monkeypatch.setattr(sys, "argv", [sys.executable]) @@ -429,3 +651,21 @@ def test_main_color_conflict(capsys, monkeypatch): _, err = capsys.readouterr() assert "color" in err + + +def test_main_force_python(monkeypatch): + monkeypatch.setattr(sys, "argv", ["nox", "--force-python=3.10"]) + with mock.patch("nox.workflow.execute", return_value=0) as execute: + with mock.patch.object(sys, "exit"): + nox.__main__.main() + config = execute.call_args[1]["global_config"] + assert config.pythons == config.extra_pythons == ["3.10"] + + +def test_main_reuse_existing_virtualenvs_no_install(monkeypatch): + monkeypatch.setattr(sys, "argv", ["nox", "-R"]) + with mock.patch("nox.workflow.execute", return_value=0) as execute: + with mock.patch.object(sys, "exit"): + nox.__main__.main() + config = execute.call_args[1]["global_config"] + assert config.reuse_existing_virtualenvs and config.no_install diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 9864191c..4c2cf151 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -15,22 +15,55 @@ import collections from unittest import mock -import nox import pytest + +import nox from nox._decorators import Func -from nox.manifest import KeywordLocals, Manifest, _null_session_func +from nox.manifest import ( + WARN_PYTHONS_IGNORED, + KeywordLocals, + Manifest, + _normalize_arg, + _normalized_session_match, + _null_session_func, +) def create_mock_sessions(): sessions = collections.OrderedDict() - sessions["foo"] = mock.Mock(spec=(), python=None) - sessions["bar"] = mock.Mock(spec=(), python=None) + sessions["foo"] = mock.Mock(spec=(), python=None, venv_backend=None) + sessions["bar"] = mock.Mock(spec=(), python=None, venv_backend=None) return sessions +def create_mock_config(): + cfg = mock.sentinel.MOCKED_CONFIG + cfg.force_venv_backend = None + cfg.default_venv_backend = None + cfg.extra_pythons = None + cfg.posargs = [] + return cfg + + +def test__normalize_arg(): + assert _normalize_arg('test(foo="bar")') == _normalize_arg('test(foo="bar")') + + # In the case of SyntaxError it should fallback to strng + assert ( + _normalize_arg("datetime.datetime(1990; 2, 18),") + == "datetime.datetime(1990; 2, 18)," + ) + + +def test__normalized_session_match(): + session_mock = mock.MagicMock() + session_mock.signatures = ['test(foo="bar")'] + assert _normalized_session_match("test(foo='bar')", session_mock) + + def test_init(): sessions = create_mock_sessions() - manifest = Manifest(sessions, mock.sentinel.CONFIG) + manifest = Manifest(sessions, create_mock_config()) # Assert that basic properties look correctly. assert len(manifest) == 2 @@ -40,7 +73,7 @@ def test_init(): def test_contains(): sessions = create_mock_sessions() - manifest = Manifest(sessions, mock.sentinel.CONFIG) + manifest = Manifest(sessions, create_mock_config()) # Establish that contains works pre-iteration. assert "foo" in manifest @@ -60,7 +93,7 @@ def test_contains(): def test_getitem(): sessions = create_mock_sessions() - manifest = Manifest(sessions, mock.sentinel.CONFIG) + manifest = Manifest(sessions, create_mock_config()) # Establish that each session is present, and a made-up session # is not. @@ -79,7 +112,7 @@ def test_getitem(): def test_iteration(): sessions = create_mock_sessions() - manifest = Manifest(sessions, mock.sentinel.CONFIG) + manifest = Manifest(sessions, create_mock_config()) # There should be two sessions in the queue. assert len(manifest._queue) == 2 @@ -109,7 +142,7 @@ def test_iteration(): def test_len(): sessions = create_mock_sessions() - manifest = Manifest(sessions, mock.sentinel.CONFIG) + manifest = Manifest(sessions, create_mock_config()) assert len(manifest) == 2 for session in manifest: assert len(manifest) == 2 @@ -117,7 +150,7 @@ def test_len(): def test_filter_by_name(): sessions = create_mock_sessions() - manifest = Manifest(sessions, mock.sentinel.CONFIG) + manifest = Manifest(sessions, create_mock_config()) manifest.filter_by_name(("foo",)) assert "foo" in manifest assert "bar" not in manifest @@ -125,21 +158,21 @@ def test_filter_by_name(): def test_filter_by_name_maintains_order(): sessions = create_mock_sessions() - manifest = Manifest(sessions, mock.sentinel.CONFIG) + manifest = Manifest(sessions, create_mock_config()) manifest.filter_by_name(("bar", "foo")) assert [session.name for session in manifest] == ["bar", "foo"] def test_filter_by_name_not_found(): sessions = create_mock_sessions() - manifest = Manifest(sessions, mock.sentinel.CONFIG) + manifest = Manifest(sessions, create_mock_config()) with pytest.raises(KeyError): manifest.filter_by_name(("baz",)) def test_filter_by_python_interpreter(): sessions = create_mock_sessions() - manifest = Manifest(sessions, mock.sentinel.CONFIG) + manifest = Manifest(sessions, create_mock_config()) manifest["foo"].func.python = "3.8" manifest["bar"].func.python = "3.7" manifest.filter_by_python_interpreter(("3.8",)) @@ -149,7 +182,7 @@ def test_filter_by_python_interpreter(): def test_filter_by_keyword(): sessions = create_mock_sessions() - manifest = Manifest(sessions, mock.sentinel.CONFIG) + manifest = Manifest(sessions, create_mock_config()) assert len(manifest) == 2 manifest.filter_by_keywords("foo or bar") assert len(manifest) == 2 @@ -159,7 +192,7 @@ def test_filter_by_keyword(): def test_list_all_sessions_with_filter(): sessions = create_mock_sessions() - manifest = Manifest(sessions, mock.sentinel.CONFIG) + manifest = Manifest(sessions, create_mock_config()) assert len(manifest) == 2 manifest.filter_by_keywords("foo") assert len(manifest) == 1 @@ -171,15 +204,15 @@ def test_list_all_sessions_with_filter(): def test_add_session_plain(): - manifest = Manifest({}, mock.sentinel.CONFIG) - session_func = mock.Mock(spec=(), python=None) + manifest = Manifest({}, create_mock_config()) + session_func = mock.Mock(spec=(), python=None, venv_backend=None) for session in manifest.make_session("my_session", session_func): manifest.add_session(session) assert len(manifest) == 1 def test_add_session_multiple_pythons(): - manifest = Manifest({}, mock.sentinel.CONFIG) + manifest = Manifest({}, create_mock_config()) def session_func(): pass @@ -191,8 +224,42 @@ def session_func(): assert len(manifest) == 2 +@pytest.mark.parametrize( + "python,extra_pythons,expected", + [ + (None, [], [None]), + (None, ["3.8"], [None]), + (None, ["3.8", "3.9"], [None]), + (False, [], [False]), + (False, ["3.8"], [False]), + (False, ["3.8", "3.9"], [False]), + ("3.5", [], ["3.5"]), + ("3.5", ["3.8"], ["3.5", "3.8"]), + ("3.5", ["3.8", "3.9"], ["3.5", "3.8", "3.9"]), + (["3.5", "3.9"], [], ["3.5", "3.9"]), + (["3.5", "3.9"], ["3.8"], ["3.5", "3.9", "3.8"]), + (["3.5", "3.9"], ["3.8", "3.4"], ["3.5", "3.9", "3.8", "3.4"]), + (["3.5", "3.9"], ["3.5", "3.9"], ["3.5", "3.9"]), + ], +) +def test_extra_pythons(python, extra_pythons, expected): + cfg = create_mock_config() + cfg.extra_pythons = extra_pythons + + manifest = Manifest({}, cfg) + + def session_func(): + pass + + func = Func(session_func, python=python) + for session in manifest.make_session("my_session", func): + manifest.add_session(session) + + assert expected == [session.func.python for session in manifest._all_sessions] + + def test_add_session_parametrized(): - manifest = Manifest({}, mock.sentinel.CONFIG) + manifest = Manifest({}, create_mock_config()) # Define a session with parameters. @nox.parametrize("param", ("a", "b", "c")) @@ -208,7 +275,7 @@ def my_session(session, param): def test_add_session_parametrized_multiple_pythons(): - manifest = Manifest({}, mock.sentinel.CONFIG) + manifest = Manifest({}, create_mock_config()) # Define a session with parameters. @nox.parametrize("param", ("a", "b")) @@ -224,7 +291,7 @@ def my_session(session, param): def test_add_session_parametrized_noop(): - manifest = Manifest({}, mock.sentinel.CONFIG) + manifest = Manifest({}, create_mock_config()) # Define a session without any parameters. @nox.parametrize("param", ()) @@ -232,6 +299,7 @@ def my_session(session, param): pass my_session.python = None + my_session.venv_backend = None # Add the session to the manifest. for session in manifest.make_session("my_session", my_session): @@ -244,18 +312,20 @@ def my_session(session, param): def test_notify(): - manifest = Manifest({}, mock.sentinel.CONFIG) + manifest = Manifest({}, create_mock_config()) # Define a session. def my_session(session): pass my_session.python = None + my_session.venv_backend = None def notified(session): pass notified.python = None + notified.venv_backend = None # Add the sessions to the manifest. for session in manifest.make_session("my_session", my_session): @@ -274,13 +344,14 @@ def notified(session): def test_notify_noop(): - manifest = Manifest({}, mock.sentinel.CONFIG) + manifest = Manifest({}, create_mock_config()) # Define a session and add it to the manifest. def my_session(session): pass my_session.python = None + my_session.venv_backend = None for session in manifest.make_session("my_session", my_session): manifest.add_session(session) @@ -292,15 +363,30 @@ def my_session(session): assert len(manifest) == 1 +def test_notify_with_posargs(): + cfg = create_mock_config() + manifest = Manifest({}, cfg) + + session = manifest.make_session("my_session", Func(lambda session: None))[0] + manifest.add_session(session) + + # delete my_session from the queue + manifest.filter_by_name(()) + + assert session.posargs == cfg.posargs + assert manifest.notify("my_session", posargs=["--an-arg"]) + assert session.posargs == ["--an-arg"] + + def test_notify_error(): - manifest = Manifest({}, mock.sentinel.CONFIG) + manifest = Manifest({}, create_mock_config()) with pytest.raises(ValueError): manifest.notify("does_not_exist") def test_add_session_idempotent(): - manifest = Manifest({}, mock.sentinel.CONFIG) - session_func = mock.Mock(spec=(), python=None) + manifest = Manifest({}, create_mock_config()) + session_func = mock.Mock(spec=(), python=None, venv_backend=None) for session in manifest.make_session("my_session", session_func): manifest.add_session(session) manifest.add_session(session) @@ -322,3 +408,22 @@ def test_keyword_locals_iter(): values = ["foo", "bar"] kw = KeywordLocals(values) assert list(kw) == values + + +def test_no_venv_backend_but_some_pythons(): + manifest = Manifest({}, create_mock_config()) + + # Define a session and add it to the manifest. + def my_session(session): + pass + + # the session sets "no venv backend" but declares some pythons + my_session.python = ["3.7", "3.8"] + my_session.venv_backend = "none" + my_session.should_warn = dict() + + sessions = manifest.make_session("my_session", my_session) + + # check that the pythons were correctly removed (a log warning is also emitted) + assert sessions[0].func.python is False + assert sessions[0].func.should_warn == {WARN_PYTHONS_IGNORED: ["3.7", "3.8"]} diff --git a/tests/test_registry.py b/tests/test_registry.py index 652633e1..3c36f9a1 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -13,6 +13,7 @@ # limitations under the License. import pytest + from nox import registry diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 8af3f858..6bcd916d 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -14,16 +14,20 @@ import argparse import logging +import operator import os import sys +import tempfile +from pathlib import Path from unittest import mock +import pytest + import nox.command import nox.manifest import nox.registry import nox.sessions import nox.virtualenv -import pytest from nox import _options from nox.logger import logger @@ -63,25 +67,62 @@ def make_session_and_runner(self): signatures=["test"], func=func, global_config=_options.options.namespace( - posargs=mock.sentinel.posargs, + posargs=[], error_on_external_run=False, install_only=False, + invoked_from=os.getcwd(), ), manifest=mock.create_autospec(nox.manifest.Manifest), ) runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv) runner.venv.env = {} - runner.venv.bin = "/no/bin/for/you" + runner.venv.bin_paths = ["/no/bin/for/you"] return nox.sessions.Session(runner=runner), runner + def test_create_tmp(self): + session, runner = self.make_session_and_runner() + with tempfile.TemporaryDirectory() as root: + runner.global_config.envdir = root + tmpdir = session.create_tmp() + assert session.env["TMPDIR"] == tmpdir + assert tmpdir.startswith(root) + + def test_create_tmp_twice(self): + session, runner = self.make_session_and_runner() + with tempfile.TemporaryDirectory() as root: + runner.global_config.envdir = root + runner.venv.bin = bin + session.create_tmp() + tmpdir = session.create_tmp() + assert session.env["TMPDIR"] == tmpdir + assert tmpdir.startswith(root) + def test_properties(self): session, runner = self.make_session_and_runner() + with tempfile.TemporaryDirectory() as root: + runner.global_config.envdir = root + + assert session.name is runner.friendly_name + assert session.env is runner.venv.env + assert session.posargs == runner.global_config.posargs + assert session.virtualenv is runner.venv + assert session.bin_paths is runner.venv.bin_paths + assert session.bin is runner.venv.bin_paths[0] + assert session.python is runner.func.python + assert session.invoked_from is runner.global_config.invoked_from + assert session.cache_dir == Path(runner.global_config.envdir).joinpath( + ".cache" + ) - assert session.env is runner.venv.env - assert session.posargs is runner.global_config.posargs - assert session.virtualenv is runner.venv - assert session.bin is runner.venv.bin - assert session.python is runner.func.python + def test_no_bin_paths(self): + session, runner = self.make_session_and_runner() + + runner.venv.bin_paths = None + with pytest.raises( + ValueError, match=r"^The environment does not have a bin directory\.$" + ): + session.bin + assert session.bin_paths is None def test_virtualenv_as_none(self): session, runner = self.make_session_and_runner() @@ -123,6 +164,28 @@ def test_chdir(self, tmpdir): assert os.getcwd() == cdto os.chdir(current_cwd) + def test_invoked_from(self, tmpdir): + cdto = str(tmpdir.join("cdbby").ensure(dir=True)) + current_cwd = os.getcwd() + + session, _ = self.make_session_and_runner() + + session.chdir(cdto) + + assert session.invoked_from == current_cwd + os.chdir(current_cwd) + + def test_chdir_pathlib(self, tmpdir): + cdto = str(tmpdir.join("cdbby").ensure(dir=True)) + current_cwd = os.getcwd() + + session, _ = self.make_session_and_runner() + + session.chdir(Path(cdto)) + + assert os.getcwd() == cdto + os.chdir(current_cwd) + def test_run_bad_args(self): session, _ = self.make_session_and_runner() @@ -132,7 +195,7 @@ def test_run_bad_args(self): def test_run_with_func(self): session, _ = self.make_session_and_runner() - assert session.run(lambda a, b: a + b, 1, 2) == 3 + assert session.run(operator.add, 1, 2) == 3 def test_run_with_func_error(self): session, _ = self.make_session_and_runner() @@ -149,7 +212,7 @@ def test_run_install_only(self, caplog): runner.global_config.install_only = True with mock.patch.object(nox.command, "run") as run: - session.run("spam", "eggs") + assert session.run("spam", "eggs") is None run.assert_not_called() @@ -164,10 +227,10 @@ def test_run_install_only_should_install(self): session.run("spam", "eggs") run.assert_called_once_with( - ("pip", "install", "spam"), + ("python", "-m", "pip", "install", "spam"), env=mock.ANY, external=mock.ANY, - path=mock.ANY, + paths=mock.ANY, silent=mock.ANY, ) @@ -205,7 +268,7 @@ def test_run_external_not_a_virtualenv(self): session.run(sys.executable, "--version") run.assert_called_once_with( - (sys.executable, "--version"), external=True, env=mock.ANY, path=None + (sys.executable, "--version"), external=True, env=mock.ANY, paths=None ) def test_run_external_condaenv(self): @@ -214,14 +277,17 @@ def test_run_external_condaenv(self): runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv) runner.venv.allowed_globals = ("conda",) runner.venv.env = {} - runner.venv.bin = "/path/to/env/bin" + runner.venv.bin_paths = ["/path/to/env/bin"] runner.venv.create.return_value = True with mock.patch("nox.command.run", autospec=True) as run: session.run("conda", "--version") run.assert_called_once_with( - ("conda", "--version"), external=True, env=mock.ANY, path="/path/to/env/bin" + ("conda", "--version"), + external=True, + env=mock.ANY, + paths=["/path/to/env/bin"], ) def test_run_external_with_error_on_external_run(self): @@ -236,20 +302,76 @@ def test_run_external_with_error_on_external_run_condaenv(self): session, runner = self.make_session_and_runner() runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv) runner.venv.env = {} - runner.venv.bin = "/path/to/env/bin" + runner.venv.bin_paths = ["/path/to/env/bin"] runner.global_config.error_on_external_run = True with pytest.raises(nox.command.CommandFailed, match="External"): session.run(sys.executable, "--version") + def test_run_always_bad_args(self): + session, _ = self.make_session_and_runner() + + with pytest.raises(ValueError) as exc_info: + session.run_always() + + exc_args = exc_info.value.args + assert exc_args == ("At least one argument required to run_always().",) + + def test_run_always_success(self): + session, _ = self.make_session_and_runner() + + assert session.run_always(operator.add, 1300, 37) == 1337 + + def test_run_always_install_only(self, caplog): + session, runner = self.make_session_and_runner() + runner.global_config.install_only = True + + assert session.run_always(operator.add, 23, 19) == 42 + + @pytest.mark.parametrize( + ("no_install", "reused", "run_called"), + [ + (True, True, False), + (True, False, True), + (False, True, True), + (False, False, True), + ], + ) + def test_run_always_no_install(self, no_install, reused, run_called): + session, runner = self.make_session_and_runner() + runner.global_config.no_install = no_install + runner.venv._reused = reused + + with mock.patch.object(nox.command, "run") as run: + session.run_always("python", "-m", "pip", "install", "requests") + + assert run.called is run_called + def test_conda_install_bad_args(self): session, runner = self.make_session_and_runner() runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv) + runner.venv.location = "dummy" with pytest.raises(ValueError, match="arg"): session.conda_install() + def test_conda_install_bad_args_odd_nb_double_quotes(self): + session, runner = self.make_session_and_runner() + runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv) + runner.venv.location = "./not/a/location" + + with pytest.raises(ValueError, match="odd number of quotes"): + session.conda_install('a"a') + + def test_conda_install_bad_args_cannot_escape(self): + session, runner = self.make_session_and_runner() + runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv) + runner.venv.location = "./not/a/location" + + with pytest.raises(ValueError, match="Cannot escape"): + session.conda_install('a"o"=9999.99.99" def test_discover_session_functions_decorator(): @@ -88,7 +198,7 @@ def notasession(): mock_module = argparse.Namespace( __name__=foo.__module__, foo=foo, bar=bar, notasession=notasession ) - config = _options.options.namespace(sessions=(), keywords=()) + config = _options.options.namespace(sessions=(), keywords=(), posargs=[]) # Get the manifest and establish that it looks like what we expect. manifest = tasks.discover_manifest(mock_module, config) @@ -98,7 +208,9 @@ def notasession(): def test_filter_manifest(): - config = _options.options.namespace(sessions=(), pythons=(), keywords=()) + config = _options.options.namespace( + sessions=(), pythons=(), keywords=(), posargs=[] + ) manifest = Manifest({"foo": session_func, "bar": session_func}, config) return_value = tasks.filter_manifest(manifest, config) assert return_value is manifest @@ -106,14 +218,18 @@ def test_filter_manifest(): def test_filter_manifest_not_found(): - config = _options.options.namespace(sessions=("baz",), pythons=(), keywords=()) + config = _options.options.namespace( + sessions=("baz",), pythons=(), keywords=(), posargs=[] + ) manifest = Manifest({"foo": session_func, "bar": session_func}, config) return_value = tasks.filter_manifest(manifest, config) assert return_value == 3 def test_filter_manifest_pythons(): - config = _options.options.namespace(sessions=(), pythons=("3.8",), keywords=()) + config = _options.options.namespace( + sessions=(), pythons=("3.8",), keywords=(), posargs=[] + ) manifest = Manifest( {"foo": session_func_with_python, "bar": session_func, "baz": session_func}, config, @@ -124,7 +240,9 @@ def test_filter_manifest_pythons(): def test_filter_manifest_keywords(): - config = _options.options.namespace(sessions=(), pythons=(), keywords="foo or bar") + config = _options.options.namespace( + sessions=(), pythons=(), keywords="foo or bar", posargs=[] + ) manifest = Manifest( {"foo": session_func, "bar": session_func, "baz": session_func}, config ) @@ -133,6 +251,15 @@ def test_filter_manifest_keywords(): assert len(manifest) == 2 +def test_filter_manifest_keywords_syntax_error(): + config = _options.options.namespace( + sessions=(), pythons=(), keywords="foo:bar", posargs=[] + ) + manifest = Manifest({"foo_bar": session_func, "foo_baz": session_func}, config) + return_value = tasks.filter_manifest(manifest, config) + assert return_value == 3 + + def test_honor_list_request_noop(): config = _options.options.namespace(list_sessions=False) manifest = {"thing": mock.sentinel.THING} @@ -140,12 +267,21 @@ def test_honor_list_request_noop(): assert return_value is manifest -@pytest.mark.parametrize("description", [None, "bar"]) -def test_honor_list_request(description): +@pytest.mark.parametrize( + "description, module_docstring", + [ + (None, None), + (None, "hello docstring"), + ("Bar", None), + ("Bar", "hello docstring"), + ], +) +def test_honor_list_request(description, module_docstring): config = _options.options.namespace( list_sessions=True, noxfile="noxfile.py", color=False ) manifest = mock.create_autospec(Manifest) + manifest.module_docstring = module_docstring manifest.list_all_sessions.return_value = [ (argparse.Namespace(friendly_name="foo", description=description), True) ] @@ -158,6 +294,7 @@ def test_honor_list_request_skip_and_selected(capsys): list_sessions=True, noxfile="noxfile.py", color=False ) manifest = mock.create_autospec(Manifest) + manifest.module_docstring = None manifest.list_all_sessions.return_value = [ (argparse.Namespace(friendly_name="foo", description=None), True), (argparse.Namespace(friendly_name="bar", description=None), False), @@ -171,6 +308,44 @@ def test_honor_list_request_skip_and_selected(capsys): assert "- bar" in out +def test_honor_list_request_prints_docstring_if_present(capsys): + config = _options.options.namespace( + list_sessions=True, noxfile="noxfile.py", color=False + ) + manifest = mock.create_autospec(Manifest) + manifest.module_docstring = "Hello I'm a docstring" + manifest.list_all_sessions.return_value = [ + (argparse.Namespace(friendly_name="foo", description=None), True), + (argparse.Namespace(friendly_name="bar", description=None), False), + ] + + return_value = tasks.honor_list_request(manifest, global_config=config) + assert return_value == 0 + + out = capsys.readouterr().out + + assert "Hello I'm a docstring" in out + + +def test_honor_list_request_doesnt_print_docstring_if_not_present(capsys): + config = _options.options.namespace( + list_sessions=True, noxfile="noxfile.py", color=False + ) + manifest = mock.create_autospec(Manifest) + manifest.module_docstring = None + manifest.list_all_sessions.return_value = [ + (argparse.Namespace(friendly_name="foo", description=None), True), + (argparse.Namespace(friendly_name="bar", description=None), False), + ] + + return_value = tasks.honor_list_request(manifest, global_config=config) + assert return_value == 0 + + out = capsys.readouterr().out + + assert "Hello I'm a docstring" not in out + + def test_verify_manifest_empty(): config = _options.options.namespace(sessions=(), keywords=()) manifest = Manifest({}, config) @@ -179,13 +354,14 @@ def test_verify_manifest_empty(): def test_verify_manifest_nonempty(): - config = _options.options.namespace(sessions=(), keywords=()) + config = _options.options.namespace(sessions=(), keywords=(), posargs=[]) manifest = Manifest({"session": session_func}, config) return_value = tasks.verify_manifest_nonempty(manifest, global_config=config) assert return_value == manifest -def test_run_manifest(): +@pytest.mark.parametrize("with_warnings", [False, True], ids="with_warnings={}".format) +def test_run_manifest(with_warnings): # Set up a valid manifest. config = _options.options.namespace(stop_on_first_error=False) sessions_ = [ @@ -200,6 +376,12 @@ def test_run_manifest(): mock_session.execute.return_value = sessions.Result( session=mock_session, status=sessions.Status.SUCCESS ) + # we need the should_warn attribute, add some func + if with_warnings: + mock_session.name = "hello" + mock_session.func = session_func_venv_pythons_warning + else: + mock_session.func = session_func # Run the manifest. results = tasks.run_manifest(manifest, global_config=config) @@ -228,6 +410,8 @@ def test_run_manifest_abort_on_first_failure(): mock_session.execute.return_value = sessions.Result( session=mock_session, status=sessions.Status.FAILED ) + # we need the should_warn attribute, add some func + mock_session.func = session_func # Run the manifest. results = tasks.run_manifest(manifest, global_config=config) diff --git a/tests/test_tox_to_nox.py b/tests/test_tox_to_nox.py index 1e5ff7a0..57ec2bf7 100644 --- a/tests/test_tox_to_nox.py +++ b/tests/test_tox_to_nox.py @@ -16,6 +16,7 @@ import textwrap import pytest + from nox import tox_to_nox diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py index 543d66f2..8fe1c410 100644 --- a/tests/test_virtualenv.py +++ b/tests/test_virtualenv.py @@ -15,12 +15,14 @@ import os import shutil import sys +from textwrap import dedent from unittest import mock -import nox.virtualenv import py import pytest +import nox.virtualenv + IS_WINDOWS = nox.virtualenv._SYSTEM == "Windows" HAS_CONDA = shutil.which("conda") is not None RAISE_ERROR = "RAISE_ERROR" @@ -120,11 +122,18 @@ def mock_sysfind(arg): def test_process_env_constructor(): penv = nox.virtualenv.ProcessEnv() - assert not penv.bin + assert not penv.bin_paths + with pytest.raises( + ValueError, match=r"^The environment does not have a bin directory\.$" + ): + penv.bin penv = nox.virtualenv.ProcessEnv(env={"SIGIL": "123"}) assert penv.env["SIGIL"] == "123" + penv = nox.virtualenv.ProcessEnv(bin_paths=["/bin"]) + assert penv.bin == "/bin" + def test_process_env_create(): penv = nox.virtualenv.ProcessEnv() @@ -172,21 +181,61 @@ def test_condaenv_create(make_conda): venv.reuse_existing = True venv.create() assert dir_.join("test.txt").check() + assert venv._reused + + +@pytest.mark.skipif(not HAS_CONDA, reason="Missing conda command.") +def test_condaenv_create_with_params(make_conda): + venv, dir_ = make_conda(venv_params=["--verbose"]) + venv.create() + if IS_WINDOWS: + assert dir_.join("python.exe").check() + assert dir_.join("Scripts", "pip.exe").check() + else: + assert dir_.join("bin", "python").check() + assert dir_.join("bin", "pip").check() -@pytest.mark.skipif(IS_WINDOWS, reason="Not testing multiple interpreters on Windows.") @pytest.mark.skipif(not HAS_CONDA, reason="Missing conda command.") def test_condaenv_create_interpreter(make_conda): venv, dir_ = make_conda(interpreter="3.7") venv.create() - assert dir_.join("bin", "python").check() - assert dir_.join("bin", "python3.7").check() + if IS_WINDOWS: + assert dir_.join("python.exe").check() + assert dir_.join("python37.dll").check() + assert dir_.join("python37.pdb").check() + assert not dir_.join("python37.exe").check() + else: + assert dir_.join("bin", "python").check() + assert dir_.join("bin", "python3.7").check() + + +@pytest.mark.skipif(not HAS_CONDA, reason="Missing conda command.") +def test_conda_env_create_verbose(make_conda): + venv, dir_ = make_conda() + with mock.patch("nox.virtualenv.nox.command.run") as mock_run: + venv.create() + + args, kwargs = mock_run.call_args + assert kwargs["log"] is False + + nox.options.verbose = True + with mock.patch("nox.virtualenv.nox.command.run") as mock_run: + venv.create() + + args, kwargs = mock_run.call_args + assert kwargs["log"] @mock.patch("nox.virtualenv._SYSTEM", new="Windows") def test_condaenv_bin_windows(make_conda): venv, dir_ = make_conda() - assert dir_.join("Scripts").strpath == venv.bin + assert [dir_.strpath, dir_.join("Scripts").strpath] == venv.bin_paths + + +def test_condaenv_(make_conda): + venv, dir_ = make_conda() + assert not venv.is_offline() def test_constructor_defaults(make_one): @@ -209,13 +258,16 @@ def test_env(monkeypatch, make_one): monkeypatch.setenv("SIGIL", "123") venv, _ = make_one() assert venv.env["SIGIL"] == "123" - assert venv.bin in venv.env["PATH"] - assert venv.bin not in os.environ["PATH"] + assert len(venv.bin_paths) == 1 + assert venv.bin_paths[0] in venv.env["PATH"] + assert venv.bin_paths[0] not in os.environ["PATH"] def test_blacklisted_env(monkeypatch, make_one): monkeypatch.setenv("__PYVENV_LAUNCHER__", "meep") venv, _ = make_one() + assert len(venv.bin_paths) == 1 + assert venv.bin_paths[0] == venv.bin assert "__PYVENV_LAUNCHER__" not in venv.bin @@ -224,6 +276,14 @@ def test__clean_location(monkeypatch, make_one): # Don't re-use existing, but doesn't currently exist. # Should return True indicating that the venv needs to be created. + monkeypatch.setattr( + nox.virtualenv.VirtualEnv, "_check_reused_environment_type", mock.MagicMock() + ) + monkeypatch.setattr( + nox.virtualenv.VirtualEnv, + "_check_reused_environment_interpreter", + mock.MagicMock(), + ) monkeypatch.delattr(nox.virtualenv.shutil, "rmtree") assert not dir_.check() assert venv._clean_location() @@ -249,9 +309,12 @@ def test__clean_location(monkeypatch, make_one): assert venv._clean_location() -def test_bin(make_one): +def test_bin_paths(make_one): venv, dir_ = make_one() + assert len(venv.bin_paths) == 1 + assert venv.bin_paths[0] == venv.bin + if IS_WINDOWS: assert dir_.join("Scripts").strpath == venv.bin else: @@ -261,10 +324,12 @@ def test_bin(make_one): @mock.patch("nox.virtualenv._SYSTEM", new="Windows") def test_bin_windows(make_one): venv, dir_ = make_one() + assert len(venv.bin_paths) == 1 + assert venv.bin_paths[0] == venv.bin assert dir_.join("Scripts").strpath == venv.bin -def test_create(make_one): +def test_create(monkeypatch, make_one): venv, dir_ = make_one() venv.create() @@ -287,10 +352,140 @@ def test_create(make_one): dir_.ensure("test.txt") assert dir_.join("test.txt").check() venv.reuse_existing = True + venv.create() + + assert venv._reused assert dir_.join("test.txt").check() +def test_create_reuse_environment(make_one): + venv, location = make_one(reuse_existing=True) + venv.create() + + reused = not venv.create() + + assert reused + + +@pytest.fixture +def _enable_staleness_check(monkeypatch): + monkeypatch.setattr("nox.virtualenv._ENABLE_STALENESS_CHECK", True) + + +enable_staleness_check = pytest.mark.usefixtures("_enable_staleness_check") + + +@enable_staleness_check +def test_create_reuse_environment_with_different_interpreter(make_one, monkeypatch): + venv, location = make_one(reuse_existing=True) + venv.create() + + # Pretend that the environment was created with a different interpreter. + monkeypatch.setattr(venv, "_check_reused_environment_interpreter", lambda: False) + + # Create a marker file. It should be gone after the environment is re-created. + location.join("marker").ensure() + + reused = not venv.create() + + assert not reused + assert not location.join("marker").check() + + +@enable_staleness_check +def test_create_reuse_stale_venv_environment(make_one): + venv, location = make_one(reuse_existing=True) + venv.create() + + # Drop a venv-style pyvenv.cfg into the environment. + pyvenv_cfg = """\ + home = /usr/bin + include-system-site-packages = false + version = 3.9.6 + """ + location.join("pyvenv.cfg").write(dedent(pyvenv_cfg)) + + reused = not venv.create() + + # The environment is not reused because it does not look like a + # virtualenv-style environment. + assert not reused + + +@enable_staleness_check +def test_create_reuse_stale_virtualenv_environment(make_one): + venv, location = make_one(reuse_existing=True, venv=True) + venv.create() + + # Drop a virtualenv-style pyvenv.cfg into the environment. + pyvenv_cfg = """\ + home = /usr + implementation = CPython + version_info = 3.9.6.final.0 + virtualenv = 20.4.6 + include-system-site-packages = false + base-prefix = /usr + base-exec-prefix = /usr + base-executable = /usr/bin/python3.9 + """ + location.join("pyvenv.cfg").write(dedent(pyvenv_cfg)) + + reused = not venv.create() + + # The environment is not reused because it does not look like a + # venv-style environment. + assert not reused + + +@enable_staleness_check +def test_create_reuse_venv_environment(make_one): + venv, location = make_one(reuse_existing=True, venv=True) + venv.create() + + # Place a spurious occurrence of "virtualenv" in the pyvenv.cfg. + pyvenv_cfg = location.join("pyvenv.cfg") + pyvenv_cfg.write(pyvenv_cfg.read() + "bogus = virtualenv\n") + + reused = not venv.create() + + # The environment should be detected as venv-style and reused. + assert reused + + +@enable_staleness_check +@pytest.mark.skipif(IS_WINDOWS, reason="Avoid 'No pyvenv.cfg file' error on Windows.") +def test_create_reuse_oldstyle_virtualenv_environment(make_one): + venv, location = make_one(reuse_existing=True) + venv.create() + + pyvenv_cfg = location.join("pyvenv.cfg") + if not pyvenv_cfg.check(): + pytest.skip("Requires virtualenv >= 20.0.0.") + + # virtualenv < 20.0.0 does not create a pyvenv.cfg file. + pyvenv_cfg.remove() + + reused = not venv.create() + + # The environment is detected as virtualenv-style and reused. + assert reused + + +@enable_staleness_check +def test_create_reuse_python2_environment(make_one): + venv, location = make_one(reuse_existing=True, interpreter="2.7") + + try: + venv.create() + except nox.virtualenv.InterpreterNotFound: + pytest.skip("Requires Python 2.7 installation.") + + reused = not venv.create() + + assert reused + + def test_create_venv_backend(make_one): venv, dir_ = make_one(venv=True) venv.create() @@ -317,6 +512,7 @@ def test__resolved_interpreter_none(make_one): ("3", "python3"), ("3.6", "python3.6"), ("3.6.2", "python3.6"), + ("3.10", "python3.10"), ("2.7.15", "python2.7"), ], )