"""Command-line interface."""
from pathlib import Path
import click
import pluggy
import pyproject_fmt
from tomlkit import dumps
from edgetest import hookspecs, lib
from edgetest.core import TestPackage
from edgetest.logger import get_logger
from edgetest.report import gen_report
from edgetest.schema import EdgetestValidator, Schema
from edgetest.utils import (
aggregate_upgrades,
gen_requirements_config,
parse_toml,
upgrade_pyproject_toml,
upgrade_requirements,
)
LOG = get_logger(__name__)
[docs]
def get_plugin_manager() -> pluggy.PluginManager:
"""Get the plugin manager.
Registers the default ``uv`` plugin.
Returns
-------
PluginManager
The plugin manager.
"""
pm = pluggy.PluginManager("edgetest")
pm.add_hookspecs(hookspecs)
pm.load_setuptools_entrypoints("edgetest")
pm.register(lib)
return pm
@click.command()
@click.option(
"--config",
"-c",
default=None,
type=click.Path(exists=True),
help="Path to the test configuration file",
)
@click.option(
"--requirements",
"-r",
default="requirements.txt",
type=click.Path(),
help="Path to a requirements file",
)
@click.option(
"--environment",
"-e",
default=None,
help="Name of a specific environment to run",
)
@click.option(
"--notest",
is_flag=True,
help="Whether or not to run the test command for each environment",
)
@click.option(
"--nosetup",
is_flag=True,
help="Whether or not to only set up the conda environment(s)",
)
@click.option(
"--extras",
type=str,
multiple=True,
default=None,
help="List of extra installations for the local package. Only used if using ``requirements``",
)
@click.option(
"--deps",
"-d",
type=str,
multiple=True,
default=None,
help="Additional `pip` dependencies to install. Only used if using ``requirements``.",
)
@click.option(
"--command",
type=str,
default="pytest",
help="The test command to use in each environment. Only used if using ``requirements``.",
)
@click.option(
"--export",
is_flag=True,
help="Whether or not to export the updated requirements file. Overwrites input requirements.",
)
@click.option(
"--ignore-cooldown",
is_flag=True,
help="Allow the user to bypass the 'dependency cooldown' that may be implemented via `uv`'s ``exclude-newer`` parameter",
)
def cli(
config,
requirements,
environment,
notest,
nosetup,
extras,
deps,
command,
export,
ignore_cooldown,
):
"""Create the environments and test.
If you do not supply a configuration file, this package will search for a
``requirements.txt`` file and create a conda environment for each package in that file.
"""
# Get the hooks
pm = get_plugin_manager()
if config and Path(config).suffix == ".toml":
conf = parse_toml(filename=config, requirements=requirements)
else:
# Find the path to the local directory using the requirements file
conf = gen_requirements_config(
fname_or_buf=requirements,
extras=extras,
deps=deps,
command=command,
package_dir=str(Path(requirements).parent),
)
# Validate the configuration file
docstructure = Schema()
pm.hook.addoption(schema=docstructure)
validator = EdgetestValidator(schema=docstructure.schema)
if not validator.validate(conf):
click.echo(f"Unable to validate configuration file. Error: {validator.errors}")
raise ValueError("Unable to validate configuration file.")
conf = validator.document
# Run the pre-test hook
pm.hook.pre_run_hook(conf=conf)
if environment:
conf["envs"] = [env for env in conf["envs"] if env["name"] == environment]
testers: list[TestPackage] = []
for env in conf["envs"]:
testers.append(
TestPackage(
hook=pm.hook,
envname=env["name"],
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 or not testers[-1].setup_status:
click.echo(f"Skipping tests for {env['name']}")
else:
testers[-1].run_tests(env["command"])
report = gen_report(testers)
click.echo(f"\n\n{report}")
upgraded_packages: list[dict[str, str]] = []
if export and all(tester.status for tester in testers):
# Aggregate upgrades across every environment that passed. Reading only
# the last tester drops upgrades whenever a ``lower`` env runs after the
# upgrade env, since a lower-bound env reports no upgrades of its own.
#
# Every env must pass before anything is written. A widened upper bound
# claims the package works at that version across the whole configured
# matrix, so one red env makes the claim unproven even if another env
# is green.
upgraded_packages = aggregate_upgrades(testers)
if config is not None and Path(config).name == "pyproject.toml":
parser = upgrade_pyproject_toml(
upgraded_packages=upgraded_packages,
filename=config,
)
with open(config, "w") as outfile:
outfile.write(dumps(parser))
if "project" not in parser or not parser.get("project").get("dependencies"):
click.echo(
"No dependencies in ``pyproject.toml`` to update. Updating "
f"{requirements}"
)
upgraded = upgrade_requirements(
fname_or_buf=requirements,
upgraded_packages=upgraded_packages,
)
with open(requirements, "w") as outfile:
outfile.write(upgraded)
# Run the formatter
if "pyproject-fmt" in parser.get("tool", {}):
pyproject_fmt.run([config])
else:
click.echo(f"Overwriting the requirements file {requirements}...")
upgraded = upgrade_requirements(
fname_or_buf=requirements,
upgraded_packages=upgraded_packages,
)
with open(requirements, "w") as outfile:
outfile.write(upgraded)
# Run the post-test hook
pm.hook.post_run_hook(testers=testers, conf=conf)