diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 3251aa6..9b8b048 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -457,6 +457,90 @@ For instance, ``snowflake-connector-python[pandas]>=2.2.8,<2.3.9`` might be repl --deps scikit-learn \ --export +Testing lower bounds of packages +-------------------------------- + +.. warning:: + + This feature is experimental and may change in future releases. Limited functionality is available. + +Suppose you want to test if a new feature is compatible with dependencies' lower bounds. +You can use an edgetest environment to test the lower bounds of dependencies in your ``setup.cfg`` or ``pyproject.toml`` as follows: + +.. tabs:: + + .. tab:: .cfg + + .. code-block:: ini + + [edgetest.envs.pandas_lower] + lower = + pandas + + .. tab:: .toml + + .. code-block:: toml + + [edgetest.envs.pandas_lower] + lower = [ + "pandas" + ] + +Running the edgetest command using this environment specification will: + +1. Create your virtual environment (as in the standard case), +2. Install the local package, +3. Install the lower version of ``pandas`` as specified in your project configuration file, and +4. Run the ``pytest`` in the environment. + +For example, if your project configuration looks like this: + +.. tabs:: + + .. tab:: .cfg + + .. code-block:: ini + + [options] + install_requires = + pandas>=0.24.3 + + [edgetest.envs.pandas_lower] + lower = + pandas + + .. tab:: .toml + + .. code-block:: toml + + [project] + dependencies = [ + "pandas>=0.24.3", + ] + + [edgetest.envs.pandas_lower] + lower = + pandas + +then edgetest will: + +1. Create the ``pandas_lower`` virtual environment, +2. Install the local package, +3. Install ``pandas==0.24.3``, and +4. Run the ``pytest`` in the environment. + +Testing lower bounds of dependencies currently has the following limitations: + +- Can only parse main dependencies. Edgetest cannot parse lower bounds specified in extras. +- Can only install lower bounds of packages specified with ``>=``. Edgetest cannot test the lower bounds of packages specified with ``>``. +- Cannot automatically update or export lower bounds of dependencies to a project configuration file. + +.. note:: + It is hard to install lower bounds of dependencies if they are very outdated. + Older versions of dependencies like pandas may be incompatible with newer Python versions + or may not be able to be installed with a given numpy version. In the case where installation + is not possible, edgetest will report that the setup was unsuccessful and move on to the next environment. + Using plugins ------------- diff --git a/edgetest/core.py b/edgetest/core.py index cc3ca90..cbc3c30 100644 --- a/edgetest/core.py +++ b/edgetest/core.py @@ -25,6 +25,9 @@ class TestPackage: The name of the conda environment to create/use. upgrade : list The list of packages to upgrade + lower : list + The list of packages to install lower bounds alongside the lower bound value. + E.g. ``["pandas==1.5.2"]``. package_dir : str, optional (default None) The location of the local package to install and test. @@ -41,7 +44,8 @@ def __init__( self, hook: _HookRelay, envname: str, - upgrade: List[str], + upgrade: Optional[List[str]] = None, + lower: Optional[List[str]] = None, package_dir: Optional[str] = None, ): """Init method.""" @@ -49,9 +53,16 @@ def __init__( self._basedir = Path(Path.cwd(), ".edgetest") self._basedir.mkdir(exist_ok=True) self.envname = envname + + if (upgrade is None and lower is None) or ( + upgrade is not None and lower is not None + ): + raise ValueError("Exactly one of upgrade and lower must be provided.") self.upgrade = upgrade + self.lower = lower self.package_dir = package_dir or "." + self.setup_status: bool = False self.status: bool = False @property @@ -69,6 +80,7 @@ def setup( self, extras: Optional[List[str]] = None, deps: Optional[List[str]] = None, + skip: bool = False, **options, ) -> None: """Set up the testing environment. @@ -79,6 +91,9 @@ def setup( The list of extra installations to include. deps : list, optional (default None) A list of additional dependencies to install via ``pip`` + skip : bool, optional (default False) + Whether to skip setup as a pre-made environment has already been + created. **options Additional options for ``self.hook.create_environment``. @@ -91,12 +106,22 @@ def setup( RuntimeError This error will be raised if any part of the set up process fails. """ + if skip: + self.setup_status = True + return # Create the conda environment - LOG.info(f"Creating the following environment: {self.envname}...") - self.hook.create_environment( - basedir=self._basedir, envname=self.envname, conf=options - ) - LOG.info(f"Successfully created {self.envname}") + try: + LOG.info(f"Creating the following environment: {self.envname}...") + self.hook.create_environment( + basedir=self._basedir, envname=self.envname, conf=options + ) + LOG.info(f"Successfully created {self.envname}") + except RuntimeError: + LOG.exception( + "Could not create the following environment: %s", self.envname + ) + self.setup_status = False + return # Install the local package with pushd(self.package_dir): @@ -105,31 +130,85 @@ def setup( pkg += f"[{', '.join(extras)}]" if deps: LOG.info( - f"Installing specified additional dependencies into {self.envname}..." + "Installing specified additional dependencies into %s: %s", + self.envname, + ", ".join(deps), ) split = [shlex.split(dep) for dep in deps] - _run_command( - self.python_path, - "-m", - "pip", - "install", - *[itm for lst in split for itm in lst], + try: + _run_command( + self.python_path, + "-m", + "pip", + "install", + *[itm for lst in split for itm in lst], + ) + except RuntimeError: + LOG.exception( + "Unable to install specified additional dependencies in %s", + self.envname, + ) + self.setup_status = False + return + LOG.info( + f"Successfully installed specified additional dependencies into {self.envname}" ) + LOG.info(f"Installing the local package into {self.envname}...") - _run_command(self.python_path, "-m", "pip", "install", pkg) - LOG.info(f"Successfully installed the local package into {self.envname}...") - - # Upgrade package(s) - LOG.info( - f"Upgrading the following packages in {self.envname}: {', '.join(self.upgrade)}" - ) - self.hook.run_update( - basedir=self._basedir, - envname=self.envname, - upgrade=self.upgrade, - conf=options, - ) - LOG.info(f"Successfully upgraded packages in {self.envname}") + try: + _run_command(self.python_path, "-m", "pip", "install", pkg) + LOG.info( + f"Successfully installed the local package into {self.envname}..." + ) + except RuntimeError: + LOG.exception( + "Unable to install the local package into %s", self.envname + ) + self.setup_status = False + return + + if self.upgrade: + # Upgrade package(s) + LOG.info( + f"Upgrading the following packages in {self.envname}: {', '.join(self.upgrade)}" + ) + try: + self.hook.run_update( + basedir=self._basedir, + envname=self.envname, + upgrade=self.upgrade, + conf=options, + ) + self.setup_status = True + except RuntimeError: + self.setup_status = False + LOG.exception("Unable to upgrade packages in %s", self.envname) + return + LOG.info("Successfully upgraded packages in %s", self.envname) + else: + # Install lower bounds of package(s) + LOG.info( + "Installing lower bounds of packages in %s: %s", + {self.envname}, + ", ".join(self.lower), # type:ignore + ) + try: + self.hook.run_install_lower( + basedir=self._basedir, + envname=self.envname, + lower=self.lower, + conf=options, + ) + self.setup_status = True + except RuntimeError: + self.setup_status = False + LOG.exception( + "Unable to install lower bounds of packages in %s", self.envname + ) + return + LOG.info( + f"Successfully installed lower bounds of packages in {self.envname}" + ) def upgraded_packages(self) -> List[Dict[str, str]]: """Get the list of upgraded packages for the test environment. @@ -144,6 +223,8 @@ def upgraded_packages(self) -> List[Dict[str, str]]: The output of ``pip list --format json``, filtered to the packages upgraded for this environment. """ + if self.upgrade is None: + return [] # Get the version for the upgraded package(s) out, _ = _run_command(self.python_path, "-m", "pip", "list", "--format", "json") outjson = json.loads(out) @@ -155,6 +236,21 @@ def upgraded_packages(self) -> List[Dict[str, str]]: if _isin_case_dashhyphen_ins(pkg.get("name", ""), upgrade_wo_extras) ] + def lowered_packages(self) -> List[Dict[str, str]]: + """Get list of lowered packages for the test environment. + + Returns + ------- + List[Dict[str, str]] + List of lowered packages and their versions + """ + if self.lower is None: + return [] + packages_split = (pkg_str.split("==") for pkg_str in self.lower) + return [ + {"name": pkg_info[0], "version": pkg_info[1]} for pkg_info in packages_split + ] + def run_tests(self, command: str) -> int: """Run the tests in the package directory. @@ -168,6 +264,8 @@ def run_tests(self, command: str) -> int: int The exit code """ + if not self.setup_status: + raise RuntimeError("Environment setup failed. Cannot run tests.") with pushd(self.package_dir): popen = Popen( (self.python_path, "-m", *shlex.split(command)), universal_newlines=True diff --git a/edgetest/hookspecs.py b/edgetest/hookspecs.py index 244b04f..d64a36c 100644 --- a/edgetest/hookspecs.py +++ b/edgetest/hookspecs.py @@ -95,6 +95,25 @@ def run_update(basedir: str, envname: str, upgrade: List, conf: Dict): """ +@hookspec(firstresult=True) +def run_install_lower(basedir: str, envname: str, lower: Dict[str, str], conf: Dict): + """Install lower bounds of packages provided. + + Parameters + ---------- + basedir : str + The base directory location for the environment. + envname : str + Environment to install into. + lower_bounds : Dict[str, str] + Lower bounds of packages to install. + conf : Dict + The configuration dictionary for the environment. This is useful if you + want to add configuration arguments for additional dependencies that can + only be installed through the environment manager (e.g. Conda). + """ + + @hookspec def post_run_hook(testers: List, conf: Dict): """Post testing hook. diff --git a/edgetest/interface.py b/edgetest/interface.py index 63106fb..3d7cbd5 100644 --- a/edgetest/interface.py +++ b/edgetest/interface.py @@ -150,17 +150,19 @@ def cli( TestPackage( hook=pm.hook, envname=env["name"], - upgrade=env["upgrade"], + upgrade=env.get("upgrade"), + lower=env.get("lower"), package_dir=env["package_dir"], ) ) # Set up the test environment if nosetup: click.echo(f"Using existing environment for {env['name']}...") + testers[-1].setup(skip=True, **env) else: testers[-1].setup(**env) # Run the tests - if notest: + if notest or not testers[-1].setup_status: click.echo(f"Skipping tests for {env['name']}") else: testers[-1].run_tests(env["command"]) diff --git a/edgetest/lib.py b/edgetest/lib.py index 187fb43..dc9e97b 100644 --- a/edgetest/lib.py +++ b/edgetest/lib.py @@ -80,3 +80,33 @@ def run_update(basedir: str, envname: str, upgrade: List, conf: Dict): ) except Exception: raise RuntimeError(f"Unable to pip upgrade: {upgrade}") + + +@hookimpl(trylast=True) +def run_install_lower(basedir: str, envname: str, lower: List[str], conf: Dict): + """Install lower bounds of packages provided. + + Parameters + ---------- + basedir : str + The base directory location for the environment. + envname : str + Environment to install into. + lower : List[str] + Lower bounds of packages to install. + conf : Dict + The configuration dictionary for the environment. This is useful if you + want to add configuration arguments for additional dependencies that can + only be installed through the environment manager (e.g. Conda). + """ + python_path = path_to_python(basedir, envname) + try: + _run_command( + python_path, + "-m", + "pip", + "install", + *lower, + ) + except Exception: + raise RuntimeError(f"Unable to pip install: {lower}") diff --git a/edgetest/report.py b/edgetest/report.py index 559ab9d..c67b3f1 100644 --- a/edgetest/report.py +++ b/edgetest/report.py @@ -28,11 +28,39 @@ def gen_report(testers: List[TestPackage], output_type: str = "rst") -> Any: if output_type not in VALID_OUTPUTS: raise ValueError(f"Invalid output_type provided: {output_type}") - headers = ["Environment", "Passing tests", "Upgraded packages", "Package version"] + headers = [ + "Environment", + "Setup successful", + "Passing tests", + "Upgraded packages", + "Lowered packages", + "Package version", + ] rows: List[List] = [] for env in testers: upgraded = env.upgraded_packages() + lowered = env.lowered_packages() for pkg in upgraded: - rows.append([env.envname, env.status, pkg["name"], pkg["version"]]) + rows.append( + [ + env.envname, + env.setup_status, + env.status, + pkg["name"], + "", + pkg["version"], + ] + ) + for pkg in lowered: + rows.append( + [ + env.envname, + env.setup_status, + env.status, + "", + pkg["name"], + pkg["version"], + ] + ) return tabulate(rows, headers=headers, tablefmt=output_type) diff --git a/edgetest/schema.py b/edgetest/schema.py index f8126c8..e1eda93 100644 --- a/edgetest/schema.py +++ b/edgetest/schema.py @@ -19,6 +19,14 @@ }, "coerce": "listify", "required": True, + "excludes": "lower", + }, + "lower": { + "type": "list", + "schema": {"type": "string"}, + "coerce": "listify", + "required": True, + "excludes": "upgrade", }, "extras": { "type": "list", diff --git a/edgetest/utils.py b/edgetest/utils.py index 118edfd..ae7a50f 100644 --- a/edgetest/utils.py +++ b/edgetest/utils.py @@ -215,6 +215,15 @@ def parse_cfg(filename: str = "setup.cfg", requirements: Optional[str] = None) - if section_name[1] == "envs": output["envs"].append(dict(config[section])) output["envs"][-1]["name"] = section_name[2] + if ( + "lower" in output["envs"][-1] + and "options" in config + and "install_requires" in config["options"] + ): + output["envs"][-1]["lower"] = get_lower_bounds( + config.get("options", "install_requires"), + output["envs"][-1]["lower"], + ) else: output[section_name[1]] = dict(config[section]) if len(output["envs"]) == 0: @@ -334,13 +343,23 @@ def parse_toml( ) output["envs"].append(dict(config["edgetest"]["envs"][env])) # type: ignore output["envs"][-1]["name"] = env + if ( + "lower" in output["envs"][-1] + and "project" in config + and "dependencies" in config["project"] # type: ignore + ): + output["envs"][-1]["lower"] = get_lower_bounds( + dict(config["project"])["dependencies"], # type: ignore + output["envs"][-1]["lower"], + ) elif isinstance(config["edgetest"][section], Table): # type: ignore output[section] = dict(config["edgetest"][section]) # type: ignore if len(output["envs"]) == 0: if config.get("project").get("dependencies"): # type: ignore output = convert_requirements( - requirements="\n".join(config["project"]["dependencies"]), conf=output # type: ignore # noqa: E501 + requirements="\n".join(config["project"]["dependencies"]), # type: ignore + conf=output, # type: ignore # noqa: E501 ) elif requirements: req_conf = gen_requirements_config(fname_or_buf=requirements) @@ -500,3 +519,44 @@ def _isin_case_dashhyphen_ins(a: str, vals: List[str]) -> bool: if a.replace("_", "-").lower() == b.replace("_", "-").lower(): return True return False + + +def get_lower_bounds(requirements: str, lower: str) -> str: + r"""Get lower bounds of requested packages from installation requirements. + + Parses through the project ``requirements`` and the newline-delimited + packages requested in ``lower`` to find the lower bounds. + + Parameters + ---------- + requirements : str + Project setup requirements, + e.g. ``"pandas>=1.5.1,<=1.4.2\nnumpy>=1.22.1,<=1.25.4"`` + lower : str + Newline-delimited packages requested, + e.g. ``"pandas\nnumpy"``. + + Returns + ------- + str + The packages along with the lower bound, e.g. ``"pandas==1.5.1\nnumpy==1.22.1"``. + """ + all_lower_bounds = { + pkg.project_name + (f"[{','.join(pkg.extras)}]" if pkg.extras else ""): dict( + pkg.specs + ).get(">=") + for pkg in parse_requirements(requirements) + } + + lower_with_bounds = "" + for pkg_name, lower_bound in all_lower_bounds.items(): + # TODO: Parse through extra requirements as well to get lower bounds + if lower_bound is None: + LOG.warning( + "Requested %s lower bound, but did not find in install requirements.", + pkg_name, + ) + elif _isin_case_dashhyphen_ins(pkg_name, lower.split("\n")): + lower_with_bounds += f"{pkg_name}=={lower_bound}\n" + + return lower_with_bounds diff --git a/setup.cfg b/setup.cfg index abc6cee..534aa97 100644 --- a/setup.cfg +++ b/setup.cfg @@ -161,3 +161,16 @@ extras = tests deps = pip-tools + +[edgetest.envs.low] +python_version = 3.9 +lower = + Cerberus + click + pluggy + tabulate + packaging +extras = + tests +deps = + pip-tools diff --git a/tests/conftest.py b/tests/conftest.py index e7666c4..5c9480e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,6 +34,96 @@ def run_update(self, basedir: str, envname: str, upgrade: List): """Update packages from upgrade list.""" pass + @hookimpl + def run_install_lower(basedir: str, envname: str, lower: Dict[str, str]): + """Install lower bounds of packages from lower list.""" + pass + + +class FakeHookEnvironmentError: + """Create a series of fake hooks that raise errors.""" + + @hookimpl + def path_to_python(self, basedir: str, envname: str) -> str: + """Return the path to the python executable.""" + # return str(Path(basedir, envname, "bin", "python")) + if platform.system() == "Windows": + return str(Path(basedir) / envname / "Scripts" / "python") + else: + return str(Path(basedir) / envname / "bin" / "python") + + @hookimpl + def create_environment(self, basedir: str, envname: str, conf: Dict): + """Create the virtual environment for testing.""" + raise RuntimeError("Could not create virtual environment.") + + @hookimpl + def run_update(self, basedir: str, envname: str, upgrade: List): + """Update packages from upgrade list.""" + pass + + @hookimpl + def run_install_lower(basedir: str, envname: str, lower: Dict[str, str]): + """Install lower bounds of packages from lower list.""" + pass + + +class FakeHookUpdateError: + """Create a series of fake hooks that raise errors.""" + + @hookimpl + def path_to_python(self, basedir: str, envname: str) -> str: + """Return the path to the python executable.""" + # return str(Path(basedir, envname, "bin", "python")) + if platform.system() == "Windows": + return str(Path(basedir) / envname / "Scripts" / "python") + else: + return str(Path(basedir) / envname / "bin" / "python") + + @hookimpl + def create_environment(self, basedir: str, envname: str, conf: Dict): + """Create the virtual environment for testing.""" + pass + + @hookimpl + def run_update(self, basedir: str, envname: str, upgrade: List): + """Update packages from upgrade list.""" + raise RuntimeError("Could not update packages.") + + @hookimpl + def run_install_lower(basedir: str, envname: str, lower: Dict[str, str]): + """Install lower bounds of packages from lower list.""" + pass + + +class FakeHookLowerError: + """Create a series of fake hooks that raise errors.""" + + @hookimpl + def path_to_python(self, basedir: str, envname: str) -> str: + """Return the path to the python executable.""" + # return str(Path(basedir, envname, "bin", "python")) + if platform.system() == "Windows": + return str(Path(basedir) / envname / "Scripts" / "python") + else: + return str(Path(basedir) / envname / "bin" / "python") + + @hookimpl + def create_environment(self, basedir: str, envname: str, conf: Dict): + """Create the virtual environment for testing.""" + pass + + @hookimpl + def run_update(self, basedir: str, envname: str, upgrade: List): + """Update packages from upgrade list.""" + pass + + @hookimpl + def run_install_lower(basedir: str, envname: str, lower: Dict[str, str]): + """Install lower bounds of packages from lower list.""" + raise RuntimeError("Could not install lower bounds of packages.") + + @pytest.fixture def plugin_manager(): @@ -43,3 +133,30 @@ def plugin_manager(): pm.register(FakeHook()) return pm + +@pytest.fixture +def plugin_manager_environment_error(): + """The plugin manager for our fake hook that raises errors.""" + pm = pluggy.PluginManager("edgetest") + pm.add_hookspecs(hookspecs) + pm.register(FakeHookEnvironmentError()) + + return pm + +@pytest.fixture +def plugin_manager_upgrade_error(): + """The plugin manager for our fake hook that raises errors.""" + pm = pluggy.PluginManager("edgetest") + pm.add_hookspecs(hookspecs) + pm.register(FakeHookUpdateError()) + + return pm + +@pytest.fixture +def plugin_manager_lower_error(): + """The plugin manager for our fake hook that raises errors.""" + pm = pluggy.PluginManager("edgetest") + pm.add_hookspecs(hookspecs) + pm.register(FakeHookLowerError()) + + return pm \ No newline at end of file diff --git a/tests/test_core.py b/tests/test_core.py index 6c9805a..9819c67 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -9,6 +9,42 @@ from edgetest.core import TestPackage +@patch.object(Path, "cwd") +def test_init(mock_path, tmpdir, plugin_manager): + """Test init.""" + location = tmpdir.mkdir("mydir") + mock_path.return_value = Path(str(location)) + tester_upgrade = TestPackage( + hook=plugin_manager.hook, envname="myenv", upgrade=["myupgrade"] + ) + tester_lower = TestPackage( + hook=plugin_manager.hook, envname="myenv_lower", lower=["mylower"] + ) + + for tester in (tester_upgrade, tester_lower): + assert tester.hook == plugin_manager.hook + assert tester._basedir == Path(str(location)) / ".edgetest" + assert tester.package_dir == "." + assert not tester.setup_status + assert not tester.status + + assert tester_upgrade.envname == "myenv" + assert tester_upgrade.upgrade == ["myupgrade"] + assert tester_upgrade.lower is None + + assert tester_lower.envname == "myenv_lower" + assert tester_lower.upgrade is None + assert tester_lower.lower == ["mylower"] + + with pytest.raises(ValueError): + _tester_error = TestPackage( + hook=plugin_manager.hook, + envname="myenv", + upgrade=["myupgrade"], + lower=["mylower"], + ) + + @patch.object(Path, "cwd") @patch("edgetest.utils.Popen", autospec=True) def test_basic_setup(mock_popen, mock_path, tmpdir, plugin_manager): @@ -39,25 +75,75 @@ def test_basic_setup(mock_popen, mock_path, tmpdir, plugin_manager): universal_newlines=True, ), ] + assert tester.setup_status @patch.object(Path, "cwd") @patch("edgetest.utils.Popen", autospec=True) -def test_basic_setup_error(mock_popen, mock_path, tmpdir, plugin_manager): +def test_basic_setup_create_environment_error( + mock_popen, mock_path, tmpdir, plugin_manager_environment_error +): """Test failure in environment creation.""" location = tmpdir.mkdir("mydir") mock_path.return_value = Path(str(location)) mock_popen.return_value.communicate.return_value = ("output", "error") - type(mock_popen.return_value).returncode = PropertyMock(return_value=1) + type(mock_popen.return_value).returncode = PropertyMock(return_value=0) tester = TestPackage( - hook=plugin_manager.hook, envname="myenv", upgrade=["myupgrade"] + hook=plugin_manager_environment_error.hook, + envname="myenv", + upgrade=["myupgrade"], ) assert tester._basedir == Path(str(location)) / ".edgetest" - with pytest.raises(RuntimeError): - tester.setup() + tester.setup() + assert not tester.setup_status + + +@patch.object(Path, "cwd") +@patch("edgetest.utils.Popen", autospec=True) +def test_basic_setup_upgrade_error( + mock_popen, mock_path, tmpdir, plugin_manager_upgrade_error +): + """Test failure in environment creation.""" + location = tmpdir.mkdir("mydir") + mock_path.return_value = Path(str(location)) + mock_popen.return_value.communicate.return_value = ("output", "error") + type(mock_popen.return_value).returncode = PropertyMock(return_value=0) + + tester = TestPackage( + hook=plugin_manager_upgrade_error.hook, + envname="myenv", + upgrade=["myupgrade"], + ) + + assert tester._basedir == Path(str(location)) / ".edgetest" + + tester.setup() + assert not tester.setup_status + +@patch.object(Path, "cwd") +@patch("edgetest.utils.Popen", autospec=True) +def test_basic_setup_lower_error( + mock_popen, mock_path, tmpdir, plugin_manager_lower_error +): + """Test failure in environment creation.""" + location = tmpdir.mkdir("mydir") + mock_path.return_value = Path(str(location)) + mock_popen.return_value.communicate.return_value = ("output", "error") + type(mock_popen.return_value).returncode = PropertyMock(return_value=0) + + tester = TestPackage( + hook=plugin_manager_lower_error.hook, + envname="myenv", + lower=["mylower"], + ) + + assert tester._basedir == Path(str(location)) / ".edgetest" + + tester.setup() + assert not tester.setup_status @patch.object(Path, "cwd") @@ -91,6 +177,8 @@ def test_setup_extras(mock_popen, mock_path, tmpdir, plugin_manager): ), ] + assert tester.setup_status + @patch.object(Path, "cwd") @patch("edgetest.utils.Popen", autospec=True) @@ -137,6 +225,51 @@ def test_setup_pip_deps(mock_popen, mock_path, tmpdir, plugin_manager): ), ] + assert tester.setup_status + + +@patch.object(Path, "cwd") +@patch("edgetest.utils.Popen", autospec=True) +def test_setup_pip_deps_error(mock_popen, mock_path, tmpdir, plugin_manager): + """Test creating an environment with pip dependencies.""" + location = tmpdir.mkdir("mydir") + mock_path.return_value = Path(str(location)) + mock_popen.return_value.communicate.return_value = ("output", "error") + type(mock_popen.return_value).returncode = PropertyMock(return_value=1) + + tester = TestPackage( + hook=plugin_manager.hook, envname="myenv", upgrade=["myupgrade"] + ) + + assert tester._basedir == Path(str(location)) / ".edgetest" + + tester.setup(deps=["-r requirements.txt", "otherpkg"]) + + env_loc = str(Path(str(location)) / ".edgetest" / "myenv") + + if platform.system() == "Windows": + py_loc = Path(env_loc) / "Scripts" / "python" + else: + py_loc = Path(env_loc) / "bin" / "python" + + assert mock_popen.call_args_list == [ + call( + ( + f"{py_loc}", + "-m", + "pip", + "install", + "-r", + "requirements.txt", + "otherpkg", + ), + stdout=-1, + universal_newlines=True, + ), + ] + + assert not tester.setup_status + @patch.object(Path, "cwd") @patch("edgetest.core.Popen", autospec=True) @@ -157,8 +290,13 @@ def test_run_tests(mock_popen, mock_path, tmpdir, plugin_manager): else: py_loc = Path(env_loc) / "bin" / "python" - out = tester.run_tests(command="pytest tests -m 'not integration'") + # If environment not setup successfully, then there should be an error + with pytest.raises(RuntimeError): + tester.run_tests(command="pytest tests -m 'not integration'") + # Manually specify that environment was setup successfully + tester.setup_status = True + out = tester.run_tests(command="pytest tests -m 'not integration'") assert out == 0 assert mock_popen.call_args_list == [ call( diff --git a/tests/test_integration_cfg.py b/tests/test_integration_cfg.py index 149ed0c..d9f1682 100644 --- a/tests/test_integration_cfg.py +++ b/tests/test_integration_cfg.py @@ -32,6 +32,34 @@ tests """ +SETUP_CFG_LOWER = """ +[metadata] +name = toy_package +version = 0.1.0 +description = Fake description +python_requires = + >=3.7.0 + +[options] +zip_safe = False +include_package_data = True +packages = find: +install_requires = + pandas<=1.2.0,>=1.0.0 + +[options.extras_require] +tests = + pytest + +[edgetest] +extras = + tests + +[edgetest.envs.lower_env] +lower = + pandas +""" + SETUP_CFG_DASK = """ [metadata] @@ -47,7 +75,7 @@ packages = find: install_requires = scikit-learn>=1.0,<=1.2.0 - dask[dataframe]<=2022.1.0 + dask[dataframe]<=2022.1.0,>=2021.6.1 [options.extras_require] tests = @@ -59,6 +87,13 @@ upgrade = Scikit_Learn Dask[DataFrame] + +[edgetest.envs.lower_env] +extras = + tests +lower = + scikit_Learn + Dask[DataFrame] """ @@ -117,6 +152,40 @@ def test_toy_package(): ).is_dir() +@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows") +@pytest.mark.integration +def test_toy_package_lower(): + """Test lower bounds with a toy package.""" + runner = CliRunner() + + with runner.isolated_filesystem() as loc: + with open("setup.cfg", "w") as outfile: + outfile.write(SETUP_CFG_LOWER) + with open("setup.py", "w") as outfile: + outfile.write(SETUP_PY) + # Make a directory for the module + Path(loc, "toy_package").mkdir() + with open(Path(loc, "toy_package", "__init__.py"), "w") as outfile: + outfile.write(MODULE_CODE) + # Make a directory for the tests + Path(loc, "tests").mkdir() + with open(Path(loc, "tests", "test_main.py"), "w") as outfile: + outfile.write(TEST_CODE) + + # Run the CLI + result = runner.invoke(cli, ["--config=setup.cfg"]) + + assert result.exit_code == 0 + assert Path(loc, ".edgetest").is_dir() + assert Path(loc, ".edgetest", "lower_env").is_dir() + assert "pandas" in result.stdout + + if not sys.platform == "win32": + assert Path( + loc, ".edgetest", "lower_env", "lib", PY_VER, "site-packages", "pandas" + ).is_dir() + + @pytest.mark.integration def test_toy_package_dask(): """Test using edgetest with a toy package.""" @@ -141,18 +210,20 @@ def test_toy_package_dask(): assert result.exit_code == 0 assert Path(loc, ".edgetest").is_dir() - assert Path(loc, ".edgetest", "core").is_dir() - if not sys.platform == "win32": - assert Path( - loc, ".edgetest", "core", "lib", PY_VER, "site-packages", "dask" - ).is_dir() - assert Path( - loc, ".edgetest", "core", "lib", PY_VER, "site-packages", "pandas" - ).is_dir() - assert Path( - loc, ".edgetest", "core", "lib", PY_VER, "site-packages", "sklearn" - ).is_dir() + for envname in ("core", "lower_env"): + assert Path(loc, ".edgetest", envname).is_dir() + + if not sys.platform == "win32": + assert Path( + loc, ".edgetest", envname, "lib", PY_VER, "site-packages", "dask" + ).is_dir() + assert Path( + loc, ".edgetest", envname, "lib", PY_VER, "site-packages", "pandas" + ).is_dir() + assert Path( + loc, ".edgetest", envname, "lib", PY_VER, "site-packages", "sklearn" + ).is_dir() config = configparser.ConfigParser() config.read("setup.cfg") @@ -160,6 +231,13 @@ def test_toy_package_dask(): assert "scikit-learn" in config["options"]["install_requires"] assert "dask[dataframe]" in config["options"]["install_requires"] assert config["edgetest.envs.core"]["extras"] == "\ntests" - assert config["edgetest.envs.core"]["upgrade"] == "\nScikit_Learn\nDask[DataFrame]" + assert ( + config["edgetest.envs.core"]["upgrade"] == "\nScikit_Learn\nDask[DataFrame]" + ) + assert config["edgetest.envs.lower_env"]["extras"] == "\ntests" + assert ( + config["edgetest.envs.lower_env"]["lower"] + == "\nscikit_Learn\nDask[DataFrame]" + ) assert "dask" in result.stdout assert "scikit-learn" in result.stdout diff --git a/tests/test_integration_toml.py b/tests/test_integration_toml.py index e31ad9d..bbc0796 100644 --- a/tests/test_integration_toml.py +++ b/tests/test_integration_toml.py @@ -24,6 +24,23 @@ extras = ["tests"] """ +SETUP_TOML_LOWER = """ +[project] +name = "toy_package" +version = "0.1.0" +description = "Fake description" +requires-python = ">=3.7.0" +dependencies = ["pandas<=1.2.0,>=1.0.0"] + +[project.optional-dependencies] +tests = ["pytest"] + +[edgetest] +extras = ["tests"] + +[edgetest.envs.lower_env] +lower = ["pandas"] +""" SETUP_TOML_DASK = """ [project] @@ -31,7 +48,7 @@ version = "0.1.0" description = "Fake description" requires-python = ">=3.7.0" -dependencies = ["Scikit_Learn>=1.0,<=1.2.0", "Dask[dataframe]<=2022.1.0"] +dependencies = ["Scikit_Learn>=1.0,<=1.2.0", "Dask[dataframe]<=2022.1.0,>=2021.6.1"] [project.optional-dependencies] tests = ["pytest"] @@ -39,6 +56,10 @@ [edgetest.envs.core] extras = ["tests"] upgrade = ["scikit-learn", "dask[dataframe]"] + +[edgetest.envs.lower_env] +extras = ["tests"] +lower = ["scikit-learn", "dask[dataframe]"] """ @@ -97,14 +118,15 @@ def test_toy_package(): ).is_dir() +@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows") @pytest.mark.integration -def test_toy_package_dask(): +def test_toy_package_lower(): """Test using edgetest with a toy package.""" runner = CliRunner() with runner.isolated_filesystem() as loc: with open("pyproject.toml", "w") as outfile: - outfile.write(SETUP_TOML_DASK) + outfile.write(SETUP_TOML_LOWER) with open("setup.py", "w") as outfile: outfile.write(SETUP_PY) # Make a directory for the module @@ -117,23 +139,58 @@ def test_toy_package_dask(): outfile.write(TEST_CODE) # Run the CLI - result = runner.invoke(cli, ["--config=pyproject.toml", "--export"]) + result = runner.invoke(cli, ["--config=pyproject.toml"]) assert result.exit_code == 0 assert Path(loc, ".edgetest").is_dir() - assert Path(loc, ".edgetest", "core").is_dir() + assert Path(loc, ".edgetest", "lower_env").is_dir() + assert "pandas" in result.stdout if not sys.platform == "win32": assert Path( - loc, ".edgetest", "core", "lib", PY_VER, "site-packages", "dask" - ).is_dir() - assert Path( - loc, ".edgetest", "core", "lib", PY_VER, "site-packages", "pandas" - ).is_dir() - assert Path( - loc, ".edgetest", "core", "lib", PY_VER, "site-packages", "sklearn" + loc, ".edgetest", "lower_env", "lib", PY_VER, "site-packages", "pandas" ).is_dir() + +@pytest.mark.integration +def test_toy_package_dask(): + """Test using edgetest with a toy package.""" + runner = CliRunner() + + with runner.isolated_filesystem() as loc: + with open("pyproject.toml", "w") as outfile: + outfile.write(SETUP_TOML_DASK) + with open("setup.py", "w") as outfile: + outfile.write(SETUP_PY) + # Make a directory for the module + Path(loc, "toy_package").mkdir() + with open(Path(loc, "toy_package", "__init__.py"), "w") as outfile: + outfile.write(MODULE_CODE) + # Make a directory for the tests + Path(loc, "tests").mkdir() + with open(Path(loc, "tests", "test_main.py"), "w") as outfile: + outfile.write(TEST_CODE) + + # Run the CLI + result = runner.invoke(cli, ["--config=pyproject.toml", "--export"]) + + assert result.exit_code == 0 + assert Path(loc, ".edgetest").is_dir() + + for envname in ("core", "lower_env"): + assert Path(loc, ".edgetest", envname).is_dir() + + if not sys.platform == "win32": + assert Path( + loc, ".edgetest", envname, "lib", PY_VER, "site-packages", "dask" + ).is_dir() + assert Path( + loc, ".edgetest", envname, "lib", PY_VER, "site-packages", "pandas" + ).is_dir() + assert Path( + loc, ".edgetest", envname, "lib", PY_VER, "site-packages", "sklearn" + ).is_dir() + config = load(open("pyproject.toml")) assert "Scikit_Learn" in config["project"]["dependencies"][0] @@ -143,5 +200,10 @@ def test_toy_package_dask(): "scikit-learn", "dask[dataframe]", ] + assert config["edgetest"]["envs"]["lower_env"]["extras"] == ["tests"] + assert config["edgetest"]["envs"]["lower_env"]["lower"] == [ + "scikit-learn", + "dask[dataframe]", + ] assert "dask" in result.stdout assert "scikit-learn" in result.stdout diff --git a/tests/test_interface_cfg.py b/tests/test_interface_cfg.py index 57c47c0..1998ee1 100644 --- a/tests/test_interface_cfg.py +++ b/tests/test_interface_cfg.py @@ -22,6 +22,19 @@ pytest tests -m 'not integration' """ +SETUP_CFG_LOWER = """ +[options] +install_requires = + myupgrade + mylower<=0.1.0,>=0.0.1 + +[edgetest.envs.myenv_lower] +lower = + mylower +command = + pytest tests -m 'not integration' +""" + SETUP_CFG_REQS = """ [options] install_requires = @@ -60,30 +73,49 @@ TABLE_OUTPUT = """ -============= =============== =================== ================= -Environment Passing tests Upgraded packages Package version -============= =============== =================== ================= -myenv True myupgrade 0.2.0 -============= =============== =================== ================= +============= ================== =============== =================== ================== ================= +Environment Setup successful Passing tests Upgraded packages Lowered packages Package version +============= ================== =============== =================== ================== ================= +myenv True True myupgrade 0.2.0 +============= ================== =============== =================== ================== ================= +""" + +TABLE_OUTPUT_LOWER = """ + +============= ================== =============== =================== ================== ================= +Environment Setup successful Passing tests Upgraded packages Lowered packages Package version +============= ================== =============== =================== ================== ================= +myenv_lower True True mylower 0.0.1 +============= ================== =============== =================== ================== ================= """ TABLE_OUTPUT_NOTEST = """ -============= =============== =================== ================= -Environment Passing tests Upgraded packages Package version -============= =============== =================== ================= -myenv False myupgrade 0.2.0 -============= =============== =================== ================= +============= ================== =============== =================== ================== ================= +Environment Setup successful Passing tests Upgraded packages Lowered packages Package version +============= ================== =============== =================== ================== ================= +myenv True False myupgrade 0.2.0 +============= ================== =============== =================== ================== ================= """ +TABLE_OUTPUT_NOTEST_LOWER = """ + +============= ================== =============== =================== ================== ================= +Environment Setup successful Passing tests Upgraded packages Lowered packages Package version +============= ================== =============== =================== ================== ================= +myenv_lower True False mylower 0.0.1 +============= ================== =============== =================== ================== ================= +""" + + TABLE_OUTPUT_REQS = """ -================ =============== =================== ================= -Environment Passing tests Upgraded packages Package version -================ =============== =================== ================= -myupgrade True myupgrade 0.2.0 -all-requirements True myupgrade 0.2.0 -================ =============== =================== ================= +================ ================== =============== =================== ================== ================= +Environment Setup successful Passing tests Upgraded packages Lowered packages Package version +================ ================== =============== =================== ================== ================= +myupgrade True True myupgrade 0.2.0 +all-requirements True True myupgrade 0.2.0 +================ ================== =============== =================== ================== ================= """ @@ -155,6 +187,68 @@ def test_cli_basic(mock_popen, mock_cpopen, mock_builder): assert result.output == TABLE_OUTPUT +@patch("edgetest.lib.EnvBuilder", autospec=True) +@patch("edgetest.core.Popen", autospec=True) +@patch("edgetest.utils.Popen", autospec=True) +def test_cli_basic_lower(mock_popen, mock_cpopen, mock_builder): + """Test creating a basic environment.""" + mock_popen.return_value.communicate.return_value = (PIP_LIST, "error") + type(mock_popen.return_value).returncode = PropertyMock(return_value=0) + mock_cpopen.return_value.communicate.return_value = ("output", "error") + type(mock_cpopen.return_value).returncode = PropertyMock(return_value=0) + + runner = CliRunner() + + with runner.isolated_filesystem() as loc: + with open("setup.cfg", "w") as outfile: + outfile.write(SETUP_CFG_LOWER) + + result = runner.invoke(cli, ["--config=setup.cfg"]) + + assert result.exit_code == 0 + + env_loc = Path(loc) / ".edgetest" / "myenv_lower" + if platform.system() == "Windows": + py_loc = env_loc / "Scripts" / "python" + else: + py_loc = env_loc / "bin" / "python" + + mock_builder.return_value.create.assert_called_with(env_loc) + assert mock_popen.call_args_list == [ + call( + (f"{str(py_loc)}", "-m", "pip", "install", "."), + stdout=-1, + universal_newlines=True, + ), + call( + ( + f"{str(py_loc)}", + "-m", + "pip", + "install", + "mylower==0.0.1", + ), + stdout=-1, + universal_newlines=True, + ), + ] + assert mock_cpopen.call_args_list == [ + call( + ( + f"{str(py_loc)}", + "-m", + "pytest", + "tests", + "-m", + "not integration", + ), + universal_newlines=True, + ) + ] + + assert result.output == TABLE_OUTPUT_LOWER + + @patch("edgetest.lib.EnvBuilder", autospec=True) @patch("edgetest.core.Popen", autospec=True) @patch("edgetest.utils.Popen", autospec=True) @@ -367,6 +461,44 @@ def test_cli_nosetup(mock_popen, mock_cpopen): ) +@patch("edgetest.core.Popen", autospec=True) +@patch("edgetest.utils.Popen", autospec=True) +def test_cli_nosetup_lower(mock_popen, mock_cpopen): + """Test creating a basic environment.""" + mock_popen.return_value.communicate.return_value = (PIP_LIST, "error") + type(mock_popen.return_value).returncode = PropertyMock(return_value=0) + mock_cpopen.return_value.communicate.return_value = ("output", "error") + type(mock_cpopen.return_value).returncode = PropertyMock(return_value=0) + + runner = CliRunner() + + with runner.isolated_filesystem() as loc: + with open("setup.cfg", "w") as outfile: + outfile.write(SETUP_CFG_LOWER) + + result = runner.invoke(cli, ["--config=setup.cfg", "--nosetup"]) + + assert result.exit_code == 0 + + env_loc = str(Path(loc) / ".edgetest" / "myenv_lower") + if platform.system() == "Windows": + py_loc = Path(env_loc) / "Scripts" / "python" + else: + py_loc = Path(env_loc) / "bin" / "python" + + assert mock_cpopen.call_args_list == [ + call( + (f"{py_loc}", "-m", "pytest", "tests", "-m", "not integration"), + universal_newlines=True, + ) + ] + + assert ( + result.output + == f"""Using existing environment for myenv_lower...\n{TABLE_OUTPUT_LOWER}""" + ) + + @patch("edgetest.lib.EnvBuilder", autospec=True) @patch("edgetest.utils.Popen", autospec=True) def test_cli_notest(mock_popen, mock_builder): @@ -417,3 +549,52 @@ def test_cli_notest(mock_popen, mock_builder): ] assert result.output == f"""Skipping tests for myenv\n{TABLE_OUTPUT_NOTEST}""" + + +@patch("edgetest.lib.EnvBuilder", autospec=True) +@patch("edgetest.utils.Popen", autospec=True) +def test_cli_notest_lower(mock_popen, mock_builder): + """Test creating a basic environment.""" + mock_popen.return_value.communicate.return_value = (PIP_LIST, "error") + type(mock_popen.return_value).returncode = PropertyMock(return_value=0) + + runner = CliRunner() + + with runner.isolated_filesystem() as loc: + with open("setup.cfg", "w") as outfile: + outfile.write(SETUP_CFG_LOWER) + + result = runner.invoke(cli, ["--config=setup.cfg", "--notest"]) + + assert result.exit_code == 0 + + env_loc = Path(loc) / ".edgetest" / "myenv_lower" + if platform.system() == "Windows": + py_loc = env_loc / "Scripts" / "python" + else: + py_loc = env_loc / "bin" / "python" + + mock_builder.return_value.create.assert_called_with(env_loc) + assert mock_popen.call_args_list == [ + call( + (f"{str(py_loc)}", "-m", "pip", "install", "."), + stdout=-1, + universal_newlines=True, + ), + call( + ( + f"{str(py_loc)}", + "-m", + "pip", + "install", + "mylower==0.0.1", + ), + stdout=-1, + universal_newlines=True, + ), + ] + + assert ( + result.output + == f"""Skipping tests for myenv_lower\n{TABLE_OUTPUT_NOTEST_LOWER}""" + ) diff --git a/tests/test_interface_toml.py b/tests/test_interface_toml.py index d201914..1216d7c 100644 --- a/tests/test_interface_toml.py +++ b/tests/test_interface_toml.py @@ -20,6 +20,15 @@ command = "pytest tests -m 'not integration'" """ +SETUP_TOML_LOWER = """ +[project] +dependencies = ["myupgrade<=0.1.5", "mylower<=0.1.0,>=0.0.1"] + +[edgetest.envs.myenv_lower] +lower = ["mylower"] +command = "pytest tests -m 'not integration'" +""" + SETUP_TOML_REQS = """ [project] dependencies = ["myupgrade<=0.1.5"] @@ -57,30 +66,48 @@ TABLE_OUTPUT = """ -============= =============== =================== ================= -Environment Passing tests Upgraded packages Package version -============= =============== =================== ================= -myenv True myupgrade 0.2.0 -============= =============== =================== ================= +============= ================== =============== =================== ================== ================= +Environment Setup successful Passing tests Upgraded packages Lowered packages Package version +============= ================== =============== =================== ================== ================= +myenv True True myupgrade 0.2.0 +============= ================== =============== =================== ================== ================= +""" + +TABLE_OUTPUT_LOWER = """ + +============= ================== =============== =================== ================== ================= +Environment Setup successful Passing tests Upgraded packages Lowered packages Package version +============= ================== =============== =================== ================== ================= +myenv_lower True True mylower 0.0.1 +============= ================== =============== =================== ================== ================= """ TABLE_OUTPUT_NOTEST = """ -============= =============== =================== ================= -Environment Passing tests Upgraded packages Package version -============= =============== =================== ================= -myenv False myupgrade 0.2.0 -============= =============== =================== ================= +============= ================== =============== =================== ================== ================= +Environment Setup successful Passing tests Upgraded packages Lowered packages Package version +============= ================== =============== =================== ================== ================= +myenv True False myupgrade 0.2.0 +============= ================== =============== =================== ================== ================= +""" + +TABLE_OUTPUT_NOTEST_LOWER = """ + +============= ================== =============== =================== ================== ================= +Environment Setup successful Passing tests Upgraded packages Lowered packages Package version +============= ================== =============== =================== ================== ================= +myenv_lower True False mylower 0.0.1 +============= ================== =============== =================== ================== ================= """ TABLE_OUTPUT_REQS = """ -================ =============== =================== ================= -Environment Passing tests Upgraded packages Package version -================ =============== =================== ================= -myupgrade True myupgrade 0.2.0 -all-requirements True myupgrade 0.2.0 -================ =============== =================== ================= +================ ================== =============== =================== ================== ================= +Environment Setup successful Passing tests Upgraded packages Lowered packages Package version +================ ================== =============== =================== ================== ================= +myupgrade True True myupgrade 0.2.0 +all-requirements True True myupgrade 0.2.0 +================ ================== =============== =================== ================== ================= """ @@ -152,6 +179,68 @@ def test_cli_basic(mock_popen, mock_cpopen, mock_builder): assert result.output == TABLE_OUTPUT +@patch("edgetest.lib.EnvBuilder", autospec=True) +@patch("edgetest.core.Popen", autospec=True) +@patch("edgetest.utils.Popen", autospec=True) +def test_cli_basic_lower(mock_popen, mock_cpopen, mock_builder): + """Test creating a basic environment.""" + mock_popen.return_value.communicate.return_value = (PIP_LIST, "error") + type(mock_popen.return_value).returncode = PropertyMock(return_value=0) + mock_cpopen.return_value.communicate.return_value = ("output", "error") + type(mock_cpopen.return_value).returncode = PropertyMock(return_value=0) + + runner = CliRunner() + + with runner.isolated_filesystem() as loc: + with open("pyproject.toml", "w") as outfile: + outfile.write(SETUP_TOML_LOWER) + + result = runner.invoke(cli, ["--config=pyproject.toml"]) + + assert result.exit_code == 0 + + env_loc = Path(loc) / ".edgetest" / "myenv_lower" + if platform.system() == "Windows": + py_loc = env_loc / "Scripts" / "python" + else: + py_loc = env_loc / "bin" / "python" + + mock_builder.return_value.create.assert_called_with(env_loc) + assert mock_popen.call_args_list == [ + call( + (f"{str(py_loc)}", "-m", "pip", "install", "."), + stdout=-1, + universal_newlines=True, + ), + call( + ( + f"{str(py_loc)}", + "-m", + "pip", + "install", + "mylower==0.0.1", + ), + stdout=-1, + universal_newlines=True, + ), + ] + assert mock_cpopen.call_args_list == [ + call( + ( + f"{str(py_loc)}", + "-m", + "pytest", + "tests", + "-m", + "not integration", + ), + universal_newlines=True, + ) + ] + + assert result.output == TABLE_OUTPUT_LOWER + + @patch("edgetest.lib.EnvBuilder", autospec=True) @patch("edgetest.core.Popen", autospec=True) @patch("edgetest.utils.Popen", autospec=True) @@ -364,6 +453,44 @@ def test_cli_nosetup(mock_popen, mock_cpopen): ) +@patch("edgetest.core.Popen", autospec=True) +@patch("edgetest.utils.Popen", autospec=True) +def test_cli_nosetup_lower(mock_popen, mock_cpopen): + """Test creating a basic environment.""" + mock_popen.return_value.communicate.return_value = (PIP_LIST, "error") + type(mock_popen.return_value).returncode = PropertyMock(return_value=0) + mock_cpopen.return_value.communicate.return_value = ("output", "error") + type(mock_cpopen.return_value).returncode = PropertyMock(return_value=0) + + runner = CliRunner() + + with runner.isolated_filesystem() as loc: + with open("pyproject.toml", "w") as outfile: + outfile.write(SETUP_TOML_LOWER) + + result = runner.invoke(cli, ["--config=pyproject.toml", "--nosetup"]) + + assert result.exit_code == 0 + + env_loc = str(Path(loc) / ".edgetest" / "myenv_lower") + if platform.system() == "Windows": + py_loc = Path(env_loc) / "Scripts" / "python" + else: + py_loc = Path(env_loc) / "bin" / "python" + + assert mock_cpopen.call_args_list == [ + call( + (f"{py_loc}", "-m", "pytest", "tests", "-m", "not integration"), + universal_newlines=True, + ) + ] + + assert ( + result.output + == f"""Using existing environment for myenv_lower...\n{TABLE_OUTPUT_LOWER}""" + ) + + @patch("edgetest.lib.EnvBuilder", autospec=True) @patch("edgetest.utils.Popen", autospec=True) def test_cli_notest(mock_popen, mock_builder): @@ -414,3 +541,52 @@ def test_cli_notest(mock_popen, mock_builder): ] assert result.output == f"""Skipping tests for myenv\n{TABLE_OUTPUT_NOTEST}""" + + +@patch("edgetest.lib.EnvBuilder", autospec=True) +@patch("edgetest.utils.Popen", autospec=True) +def test_cli_notest_lower(mock_popen, mock_builder): + """Test creating a basic environment.""" + mock_popen.return_value.communicate.return_value = (PIP_LIST, "error") + type(mock_popen.return_value).returncode = PropertyMock(return_value=0) + + runner = CliRunner() + + with runner.isolated_filesystem() as loc: + with open("pyproject.toml", "w") as outfile: + outfile.write(SETUP_TOML_LOWER) + + result = runner.invoke(cli, ["--config=pyproject.toml", "--notest"]) + + assert result.exit_code == 0 + + env_loc = Path(loc) / ".edgetest" / "myenv_lower" + if platform.system() == "Windows": + py_loc = env_loc / "Scripts" / "python" + else: + py_loc = env_loc / "bin" / "python" + + mock_builder.return_value.create.assert_called_with(env_loc) + assert mock_popen.call_args_list == [ + call( + (f"{str(py_loc)}", "-m", "pip", "install", "."), + stdout=-1, + universal_newlines=True, + ), + call( + ( + f"{str(py_loc)}", + "-m", + "pip", + "install", + "mylower==0.0.1", + ), + stdout=-1, + universal_newlines=True, + ), + ] + + assert ( + result.output + == f"""Skipping tests for myenv_lower\n{TABLE_OUTPUT_NOTEST_LOWER}""" + ) diff --git a/tests/test_lib.py b/tests/test_lib.py index 9c0f148..5e1da0a 100644 --- a/tests/test_lib.py +++ b/tests/test_lib.py @@ -3,7 +3,12 @@ import pytest -from edgetest.lib import create_environment, path_to_python, run_update +from edgetest.lib import ( + create_environment, + path_to_python, + run_install_lower, + run_update, +) @patch("edgetest.lib.platform", autospec=True) @@ -45,3 +50,23 @@ def test_run_update(mock_run): mock_run.side_effect = RuntimeError() with pytest.raises(RuntimeError): run_update("test", "test", ["1", "2"], {"test": "test"}) + + +@patch("edgetest.lib._run_command", autospec=True) +def test_run_install_lower(mock_run): + python_path = path_to_python("test", "test") + run_install_lower("test", "test", ["package1==1", "package2==2"], {"test": "test"}) + mock_run.assert_called_with( + python_path, + "-m", + "pip", + "install", + "package1==1", + "package2==2", + ) + + mock_run.side_effect = RuntimeError() + with pytest.raises(RuntimeError): + run_install_lower( + "test", "test", ["package1==1", "package2==2"], {"test": "test"} + ) diff --git a/tests/test_report.py b/tests/test_report.py index 188be89..e8325d5 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -8,36 +8,31 @@ @patch("edgetest.report.tabulate", autospec=True) @patch("edgetest.core.TestPackage.upgraded_packages", autospec=True) -def test_report(mock_test_package, mock_tabulate, plugin_manager): +@patch("edgetest.core.TestPackage.lowered_packages", autospec=True) +def test_report( + mock_test_package_lowered, mock_test_package, mock_tabulate, plugin_manager +): """Test gen_report function""" tester_list = [ TestPackage(hook=plugin_manager.hook, envname="myenv", upgrade=["myupgrade1"]), TestPackage(hook=plugin_manager.hook, envname="myenv", upgrade=["myupgrade2"]), + TestPackage(hook=plugin_manager.hook, envname="myenv_lower", lower=["mylower"]), + ] + + expected_headers = [ + "Environment", + "Setup successful", + "Passing tests", + "Upgraded packages", + "Lowered packages", + "Package version", ] gen_report(tester_list) - mock_tabulate.assert_called_with( - [], - headers=[ - "Environment", - "Passing tests", - "Upgraded packages", - "Package version", - ], - tablefmt="rst", - ) + mock_tabulate.assert_called_with([], headers=expected_headers, tablefmt="rst") gen_report(tester_list, output_type="github") - mock_tabulate.assert_called_with( - [], - headers=[ - "Environment", - "Passing tests", - "Upgraded packages", - "Package version", - ], - tablefmt="github", - ) - - with pytest.raises(ValueError) as e: + mock_tabulate.assert_called_with([], expected_headers, tablefmt="github") + + with pytest.raises(ValueError): gen_report(tester_list, output_type="bad") diff --git a/tests/test_utils.py b/tests/test_utils.py index a9f0c63..4a822ec 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -8,12 +8,13 @@ from edgetest.schema import BASE_SCHEMA, EdgetestValidator, Schema from edgetest.utils import ( _convert_toml_array_to_string, + _isin_case_dashhyphen_ins, gen_requirements_config, + get_lower_bounds, parse_cfg, parse_toml, upgrade_pyproject_toml, upgrade_setup_cfg, - _isin_case_dashhyphen_ins, ) REQS = """ @@ -27,6 +28,12 @@ myupgrade command = pytest tests -m 'not integration' + +[edgetest.envs.myenv_lower] +lower = + mylower +command = + pytest tests -m 'not integration' """ CFG_REQS = """ @@ -59,6 +66,12 @@ myupgrade command = pytest tests + +[edgetest.envs.myenv_lower] +lower = + mylower +command = + pytest tests """ CFG_CUSTOM = """ @@ -74,6 +87,10 @@ [edgetest.envs.myenv] upgrade = myupgrade + +[edgetest.envs.myenv_lower] +lower = + mylower """ @@ -83,6 +100,10 @@ "myupgrade" ] command = "pytest tests -m 'not integration'" + +[edgetest.envs.myenv_lower] +lower = ["mylower"] +command = "pytest tests -m 'not integration'" """ @@ -111,6 +132,10 @@ [edgetest.envs.myenv] upgrade = ["myupgrade"] command = "pytest tests" + +[edgetest.envs.myenv_lower] +lower = ["mylower"] +command = "pytest tests" """ TOML_CUSTOM = """ @@ -123,6 +148,9 @@ [edgetest.envs.myenv] upgrade = ["myupgrade"] + +[edgetest.envs.myenv_lower] +lower = ["mylower"] """ @@ -149,6 +177,18 @@ ] """ +REQS_NORMAL = """ +pandas>=1.0.0,<=2.0 +numpy<=0.24,>=0.01 +""" + + +def test_get_lower_bounds(): + """Test getting lower bound from reqs.""" + assert get_lower_bounds(REQS_NORMAL, "pandas\nnumpy\n") == "pandas==1.0.0\nnumpy==0.01\n" + assert get_lower_bounds(REQS_NORMAL, "pandas") == "pandas==1.0.0\n" + assert get_lower_bounds(REQS_NORMAL, "") == "" + assert get_lower_bounds(REQS, "mydep2") == "" @patch("edgetest.utils.Path") def test_parse_reqs(mock_pathlib): @@ -184,7 +224,12 @@ def test_parse_cfg(tmpdir): "name": "myenv", "upgrade": "\nmyupgrade", "command": "\npytest tests -m 'not integration'", - } + }, + { + "name": "myenv_lower", + "lower": "\nmylower", + "command": "\npytest tests -m 'not integration'", + }, ] } @@ -209,7 +254,13 @@ def test_parse_cfg_default(tmpdir): "upgrade": "\nmyupgrade", "extras": "\ntests", "command": "\npytest tests", - } + }, + { + "name": "myenv_lower", + "lower": "\nmylower", + "extras": "\ntests", + "command": "\npytest tests", + }, ] } @@ -287,7 +338,13 @@ def test_parse_custom_cfg(tmpdir): "upgrade": "\nmyupgrade", "extras": "\ntests", "command": "\npytest tests -m 'not integration'", - } + }, + { + "name": "myenv_lower", + "lower": "\nmylower", + "extras": "\ntests", + "command": "\npytest tests -m 'not integration'", + }, ], } @@ -316,7 +373,12 @@ def test_parse_toml(tmpdir): "name": "myenv", "upgrade": "myupgrade", "command": "pytest tests -m 'not integration'", - } + }, + { + "name": "myenv_lower", + "lower": "mylower", + "command": "pytest tests -m 'not integration'", + }, ] } @@ -341,7 +403,13 @@ def test_parse_toml_default(tmpdir): "upgrade": "myupgrade", "extras": "tests", "command": "pytest tests", - } + }, + { + "name": "myenv_lower", + "lower": "mylower", + "extras": "tests", + "command": "pytest tests", + }, ] } @@ -419,7 +487,13 @@ def test_parse_custom_toml(tmpdir): "upgrade": "myupgrade", "extras": "tests", "command": "pytest tests -m 'not integration'", - } + }, + { + "name": "myenv_lower", + "lower": "mylower", + "extras": "tests", + "command": "pytest tests -m 'not integration'", + }, ], } @@ -493,4 +567,4 @@ def test_isin_case_dashhyphen_ins(): assert _isin_case_dashhyphen_ins("python-dateutil", vals) assert _isin_case_dashhyphen_ins("Python_Dateutil", vals) assert not _isin_case_dashhyphen_ins("Python_Dateut1l", vals) - assert not _isin_case_dashhyphen_ins("pandaspython-dateutil", vals) \ No newline at end of file + assert not _isin_case_dashhyphen_ins("pandaspython-dateutil", vals)