diff --git a/.conda/meta.yaml b/.conda/meta.yaml deleted file mode 100644 index 1b6741a..0000000 --- a/.conda/meta.yaml +++ /dev/null @@ -1,46 +0,0 @@ -{% set data = load_setup_py_data() %} - -package: - name: pytask-environment - version: {{ data.get('version') }} - -source: - # git_url is nice in that it won't capture devenv stuff. However, it only captures - # committed code, so pay attention. - git_url: ../ - -build: - noarch: python - number: 0 - script: {{ PYTHON }} setup.py install --single-version-externally-managed --record record.txt - -requirements: - host: - - python - - pip - - setuptools - - run: - - python >=3.6 - - pytask >=0.0.7 - -test: - requires: - - pytest - source_files: - - tox.ini - - tests - commands: - - pytask --version - - pytask --help - - pytask clean - - pytask markers - - - pytest tests - -about: - home: https://github.com/pytask-dev/pytask-environment - license: MIT - license_file: LICENSE - summary: Ensure checks on the current Python environment. - dev_url: https://github.com/pytask-dev/pytask-environment/ diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index c13bfc4..0000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -src/pytask_environment/_version.py export-subst diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml index 207e780..d5a52e3 100644 --- a/.github/workflows/continuous-integration-workflow.yml +++ b/.github/workflows/continuous-integration-workflow.yml @@ -7,6 +7,10 @@ on: branches: - '*' +concurrency: + group: ${{ github.head_ref }} + cancel-in-progress: true + jobs: run-tests: @@ -34,7 +38,7 @@ jobs: - name: Run end-to-end tests. shell: bash -l {0} - run: tox -e pytest -- -m end_to_end --cov=./ --cov-report=xml -n auto + run: tox -e pytest -- -m end_to_end --cov=./ --cov-report=xml - name: Upload coverage reports of end-to-end tests. if: runner.os == 'Linux' && matrix.python-version == '3.8' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5206fd8..441eaf9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,6 @@ repos: args: ['--maxkb=100'] - id: check-merge-conflict - id: check-yaml - exclude: meta.yaml - id: debug-statements - id: end-of-file-fixer - repo: https://github.com/pre-commit/pygrep-hooks diff --git a/CHANGES.rst b/CHANGES.rst index b21de40..e7bd584 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -7,12 +7,25 @@ reverse chronological order. Releases follow `semantic versioning `_. -0.0.5 - 2021-07-23 +0.1.0 - 2022-01-25 +------------------ + +- :gh:`10` replaces the input prompts with configuration values and flags, removes the + conda recipe, and abort simultaneously running builds. + + +0.0.6 - 2021-07-23 ------------------ - :gh:`8` replaces versioneer with setuptools-scm. +0.0.5 - 2021-07-23 +------------------ + +- :gh:`7` adds some pre-commit updates. + + 0.0.4 - 2021-03-05 ------------------ diff --git a/MANIFEST.in b/MANIFEST.in index 4ac73fe..f450948 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,3 @@ -prune .conda prune tests exclude *.rst @@ -8,3 +7,5 @@ exclude tox.ini include README.rst include LICENSE + +recursive-include _static *.png diff --git a/README.rst b/README.rst index 84fa95c..6a599ce 100644 --- a/README.rst +++ b/README.rst @@ -57,16 +57,30 @@ with Usage ----- -If the user attempts to build the project and the Python version has been cached in the -database in a previous run, an invocation with a different environment will produce the -following command line output. +If the user attempts to build the project with ``pytask build`` and the Python version +has been cached in the database in a previous run, an invocation with a different +environment will produce the following command line output. + +.. image:: _static/error.png + +Running .. code-block:: console - $ pytask build - Your Python environment seems to have changed. The Python version has - changed. The path to the Python executable has changed. Do you want - to continue with the current environment? [y/N]: + $ pytask --update-environment + +will update the information on the environment. + +To disable either checking the path or the version, set the following configuration to a +falsy value. + +.. code-block:: ini + + # Content of pytask.ini, setup.cfg, or tox.ini + + check_python_version = false # true by default + + check_environment = false # true by default Future development diff --git a/_static/error.png b/_static/error.png new file mode 100644 index 0000000..74ebe1f Binary files /dev/null and b/_static/error.png differ diff --git a/environment.yml b/environment.yml index e6a972e..6d13e82 100644 --- a/environment.yml +++ b/environment.yml @@ -24,4 +24,3 @@ dependencies: - pdbpp - pre-commit - pytest-cov - - pytest-xdist diff --git a/src/pytask_environment/collect.py b/src/pytask_environment/collect.py deleted file mode 100644 index 34375d7..0000000 --- a/src/pytask_environment/collect.py +++ /dev/null @@ -1,63 +0,0 @@ -import sys - -import click -from _pytask.config import hookimpl -from _pytask.exceptions import CollectionError -from pony import orm -from pytask_environment.database import Environment - - -@hookimpl(trylast=True) -def pytask_log_session_header(): - """Use the entry-point to implement an early exit. - - The solution is hacky. Exploit the first entry-point in the build process after the - database is created. - - Check if the version and path of the Python interpreter have changed and if so, ask - the user whether she wants to proceed. - - """ - same_version, same_path = have_version_or_path_changed( - "python", sys.version, sys.executable - ) - if not same_version or not same_path: - message = "\nYour Python environment seems to have changed." - message += " The Python version has changed." if not same_version else "" - message += ( - " The path to the Python executable has changed." if not same_path else "" - ) - message += " Do you want to continue with the current environment?" - - if click.confirm(message): - create_or_update_state("python", sys.version, sys.executable) - else: - raise CollectionError - - -@orm.db_session -def have_version_or_path_changed(name, version, path): - """Return booleans indicating whether the version or path of a package changed.""" - try: - package = Environment[name] - except orm.ObjectNotFound: - Environment(name=name, version=version, path=path) - same_version = True - same_path = True - else: - same_version = package.version == version - same_path = package.path == path - - return same_version, same_path - - -@orm.db_session -def create_or_update_state(name, version, path): - """Create or update a state.""" - try: - package_in_db = Environment[name] - except orm.ObjectNotFound: - Environment(name=name, version=version, path=path) - else: - package_in_db.version = version - package_in_db.path = path diff --git a/src/pytask_environment/config.py b/src/pytask_environment/config.py new file mode 100644 index 0000000..37b7c90 --- /dev/null +++ b/src/pytask_environment/config.py @@ -0,0 +1,42 @@ +import click +from _pytask.config import hookimpl +from _pytask.shared import convert_truthy_or_falsy_to_bool +from _pytask.shared import get_first_non_none_value + + +@hookimpl +def pytask_extend_command_line_interface(cli): + """Extend the cli.""" + cli.commands["build"].params.append( + click.Option( + ["--update-environment"], + is_flag=True, + default=None, + help="Update the information on the environment stored in the database.", + ) + ) + + +@hookimpl +def pytask_parse_config(config, config_from_file, config_from_cli): + """Parse the configuration.""" + config["check_python_version"] = get_first_non_none_value( + config_from_file, + key="check_python_version", + default=True, + callback=convert_truthy_or_falsy_to_bool, + ) + + config["check_environment"] = get_first_non_none_value( + config_from_file, + key="check_environment", + default=True, + callback=convert_truthy_or_falsy_to_bool, + ) + + config["update_environment"] = get_first_non_none_value( + config_from_cli, + key="update_environment", + default=False, + callback=convert_truthy_or_falsy_to_bool, + ) diff --git a/src/pytask_environment/logging.py b/src/pytask_environment/logging.py new file mode 100644 index 0000000..98dbc44 --- /dev/null +++ b/src/pytask_environment/logging.py @@ -0,0 +1,89 @@ +import sys + +from _pytask.config import hookimpl +from _pytask.console import console +from pony import orm +from pytask_environment.database import Environment + + +_ERROR_MSG = """\ +Aborted execution due to a bad state of the environment. Either switch to the correct \ +environment or update the information on the environment using the --update-environment\ + flag. +""" + + +@hookimpl(trylast=True) +def pytask_log_session_header(session) -> None: + """Check environment and python version. + + The solution is hacky. Exploit the first entry-point in the build process after the + database is created. + + Check if the version and path of the Python interpreter have changed and if so, ask + the user whether she wants to proceed. + + """ + __tracebackhide__ = True + + # If no checks are requested, skip. + if ( + not session.config["check_python_version"] + and not session.config["check_environment"] + ): + return None + + package = retrieve_package("python") + + same_version = False if package is None else sys.version == package.version + same_path = False if package is None else sys.executable == package.path + + # Bail out if everything is fine. + if same_version and same_path: + return None + + msg = "" + if not same_version and session.config["check_python_version"]: + msg += "The Python version has changed " + if package is not None: + msg += f"from\n\n{package.version}\n\n" + msg += f"to\n\n{sys.version}\n\n" + if not same_path and session.config["check_environment"]: + msg += "The path to the Python interpreter has changed " + if package is not None: + msg += f"from\n\n{package.path}\n\n" + msg += f"to\n\n{sys.executable}." + + if msg: + msg = "Your Python environment has changed. " + msg + + if session.config["update_environment"] or package is None: + console.print("Updating the information in the database.") + create_or_update_state("python", sys.version, sys.executable) + elif not msg: + pass + else: + console.print() + raise Exception(msg + "\n\n" + _ERROR_MSG) from None + + +@orm.db_session +def retrieve_package(name): + """Return booleans indicating whether the version or path of a package changed.""" + try: + package = Environment[name] + except orm.ObjectNotFound: + package = None + return package + + +@orm.db_session +def create_or_update_state(name, version, path): + """Create or update a state.""" + try: + package_in_db = Environment[name] + except orm.ObjectNotFound: + Environment(name=name, version=version, path=path) + else: + package_in_db.version = version + package_in_db.path = path diff --git a/src/pytask_environment/plugin.py b/src/pytask_environment/plugin.py index 705eb92..9fa319e 100644 --- a/src/pytask_environment/plugin.py +++ b/src/pytask_environment/plugin.py @@ -1,11 +1,13 @@ """Entry-point for the plugin.""" from _pytask.config import hookimpl -from pytask_environment import collect +from pytask_environment import config from pytask_environment import database +from pytask_environment import logging @hookimpl def pytask_add_hooks(pm): """Register some plugins.""" - pm.register(collect) + pm.register(logging) + pm.register(config) pm.register(database) diff --git a/tests/test_collect.py b/tests/test_collect.py deleted file mode 100644 index 560a043..0000000 --- a/tests/test_collect.py +++ /dev/null @@ -1,111 +0,0 @@ -import os -import sys -import textwrap - -import pytest -from _pytask.database import create_database -from pony import orm -from pytask import cli -from pytask_environment.database import Environment - - -@pytest.mark.end_to_end -def test_existence_of_python_executable_in_db(tmp_path, runner): - """Test that the Python executable is stored in the database.""" - source = """ - import pytask - - def task_dummy(): - pass - """ - task_path = tmp_path.joinpath("task_dummy.py") - task_path.write_text(textwrap.dedent(source)) - - os.chdir(tmp_path) - result = runner.invoke(cli) - - assert result.exit_code == 0 - - orm.db_session.__enter__() - - create_database( - "sqlite", tmp_path.joinpath(".pytask.sqlite3").as_posix(), True, False - ) - - python = Environment["python"] - assert python.version == sys.version - assert python.path == sys.executable - - orm.db_session.__exit__() - - -@pytest.mark.skipif( - sys.platform == "win32" and sys.version_info[:2] == (3, 6), - reason="Error on Windows with Python 3.6", -) -@pytest.mark.end_to_end -def test_prompt_when_python_version_has_changed(monkeypatch, tmp_path, runner): - """Test the whole use-case. - - 1. Run a simple task to cache the Python version and path. - 2. Pretend to use a different Python environment and decline to continue. Check that - values in database have not been altered. - 3. Pretend that environment has changed, confirm to continue and check that new - version and path are in database. - - """ - real_python_version = sys.version - real_python_executable = sys.executable - fake_version = ( - "2.7.8 | packaged by conda-forge | (default, Jul 31 2020, 01:53:57) " - "[MSC v.1916 64 bit (AMD64)]" - ) - - source = """ - import pytask - - def task_dummy(): - pass - """ - task_path = tmp_path.joinpath("task_dummy.py") - task_path.write_text(textwrap.dedent(source)) - - os.chdir(tmp_path) - result = runner.invoke(cli) - - assert result.exit_code == 0 - - monkeypatch.setattr("pytask_environment.collect.sys.version", fake_version) - - result = runner.invoke(cli, input="N") - assert result.exit_code == 3 - - orm.db_session.__enter__() - - create_database( - "sqlite", tmp_path.joinpath(".pytask.sqlite3").as_posix(), True, False - ) - - python = Environment["python"] - assert python.version == real_python_version - assert python.path == real_python_executable - - orm.db_session.__exit__() - - monkeypatch.setattr("pytask_environment.collect.sys.version", fake_version) - monkeypatch.setattr("pytask_environment.collect.sys.executable", "new_path") - - result = runner.invoke(cli, input="y") - assert result.exit_code == 0 - - orm.db_session.__enter__() - - create_database( - "sqlite", tmp_path.joinpath(".pytask.sqlite3").as_posix(), True, False - ) - - python = Environment["python"] - assert python.version == fake_version - assert python.path == "new_path" - - orm.db_session.__exit__() diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..b160884 --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,154 @@ +import sys +import textwrap + +import pytest +from _pytask.database import db +from pony import orm +from pytask import cli +from pytask_environment.database import Environment + + +@pytest.mark.end_to_end +def test_existence_of_python_executable_in_db(tmp_path, runner): + """Test that the Python executable is stored in the database.""" + task_path = tmp_path.joinpath("task_dummy.py") + task_path.write_text(textwrap.dedent("def task_dummy(): pass")) + tmp_path.joinpath("pytask.ini").write_text("[pytask]") + + result = runner.invoke(cli, [tmp_path.as_posix()]) + + assert result.exit_code == 0 + + with orm.db_session: + python = Environment["python"] + + assert python.version == sys.version + assert python.path == sys.executable + + orm.rollback() + for entity in db.entities.values(): + orm.delete(e for e in entity) + + +@pytest.mark.skipif( + sys.platform == "win32" and sys.version_info[:2] == (3, 6), + reason="Error on Windows with Python 3.6", +) +@pytest.mark.end_to_end +def test_flow_when_python_version_has_changed(monkeypatch, tmp_path, runner): + """Test the whole use-case. + + 1. Run a simple task to cache the Python version and path. + 2. Pretend to use a different Python environment and decline to continue. Check that + values in database have not been altered. + 3. Pretend that environment has changed, confirm to continue and check that new + version and path are in database. + + """ + real_python_version = sys.version + real_python_executable = sys.executable + fake_version = ( + "2.7.8 | packaged by conda-forge | (default, Jul 31 2020, 01:53:57) " + "[MSC v.1916 64 bit (AMD64)]" + ) + + tmp_path.joinpath("pytask.ini").write_text("[pytask]") + source = "def task_dummy(): pass" + task_path = tmp_path.joinpath("task_dummy.py") + task_path.write_text(textwrap.dedent(source)) + + # Run without knowing the python version and without updating the environment. + result = runner.invoke(cli, [tmp_path.as_posix()]) + assert result.exit_code == 0 + assert "Updating the information" in result.output + + # Run with a fake version and not updating the environment. + monkeypatch.setattr("pytask_environment.logging.sys.version", fake_version) + + result = runner.invoke(cli) + assert result.exit_code == 1 + + with orm.db_session: + python = Environment["python"] + + assert python.version == real_python_version + assert python.path == real_python_executable + + # Run with a fake version and updating the environment. + monkeypatch.setattr("pytask_environment.logging.sys.version", fake_version) + monkeypatch.setattr("pytask_environment.logging.sys.executable", "new_path") + + result = runner.invoke(cli, ["--update-environment"]) + assert result.exit_code == 0 + + with orm.db_session: + python = Environment["python"] + + assert python.version == fake_version + assert python.path == "new_path" + + orm.rollback() + for entity in db.entities.values(): + orm.delete(e for e in entity) + + +@pytest.mark.end_to_end +@pytest.mark.parametrize("check_python_version, expected", [("true", 1), ("false", 0)]) +def test_python_version_changed( + monkeypatch, tmp_path, runner, check_python_version, expected +): + fake_version = ( + "2.7.8 | packaged by conda-forge | (default, Jul 31 2020, 01:53:57) " + "[MSC v.1916 64 bit (AMD64)]" + ) + tmp_path.joinpath("pytask.ini").write_text( + f"[pytask]\ncheck_python_version = {check_python_version}" + ) + source = "def task_dummy(): pass" + task_path = tmp_path.joinpath("task_dummy.py") + task_path.write_text(textwrap.dedent(source)) + + # Run without knowing the python version and without updating the environment. + result = runner.invoke(cli, [tmp_path.as_posix()]) + assert result.exit_code == 0 + assert "Updating the information" in result.output + + # Run with a fake version and not updating the environment. + monkeypatch.setattr("pytask_environment.logging.sys.version", fake_version) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + assert result.exit_code == expected + + with orm.db_session: + orm.rollback() + for entity in db.entities.values(): + orm.delete(e for e in entity) + + +@pytest.mark.end_to_end +@pytest.mark.parametrize("check_python_version, expected", [("true", 1), ("false", 0)]) +def test_environment_changed( + monkeypatch, tmp_path, runner, check_python_version, expected +): + tmp_path.joinpath("pytask.ini").write_text( + f"[pytask]\ncheck_environment = {check_python_version}" + ) + source = "def task_dummy(): pass" + task_path = tmp_path.joinpath("task_dummy.py") + task_path.write_text(textwrap.dedent(source)) + + # Run without knowing the python version and without updating the environment. + result = runner.invoke(cli, [tmp_path.as_posix()]) + assert result.exit_code == 0 + assert "Updating the information" in result.output + + # Run with a fake version and not updating the environment. + monkeypatch.setattr("pytask_environment.logging.sys.executable", "new_path") + + result = runner.invoke(cli, [tmp_path.as_posix()]) + assert result.exit_code == expected + + with orm.db_session: + orm.rollback() + for entity in db.entities.values(): + orm.delete(e for e in entity) diff --git a/tox.ini b/tox.ini index e774957..15c3f82 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = pytest, pre-commit +envlist = pytest skipsdist = True skip_missing_interpreters = True @@ -12,7 +12,6 @@ conda_deps = pytask >=0.0.7 pytest pytest-cov - pytest-xdist conda_channels = conda-forge nodefaults @@ -20,13 +19,9 @@ commands = pip install -e . pytest {posargs} -[testenv:pre-commit] -deps = pre-commit -commands = pre-commit run --all-files - [doc8] ignore = D002, D004 -max-line-length = 89 +max-line-length = 88 [flake8] docstring-convention = numpy @@ -46,6 +41,8 @@ addopts = --doctest-modules filterwarnings = ignore: the imp module is deprecated in favour of importlib ignore: Using or importing the ABCs from 'collections' instead of from + ignore: The parser module is deprecated and will + ignore: The symbol module is deprecated markers = wip: Tests that are work-in-progress. unit: Flag for unit tests which target mainly a single function.