"""
GRASS Python testing framework module for running from command line
Copyright (C) 2014-2021 by the GRASS Development Team
This program is free software under the GNU General Public
License (>=v2). Read the file COPYING that comes with GRASS
for details.
:authors: Vaclav Petras
"""
import os
import sys
import argparse
import configparser
from pathlib import Path
from unittest.main import TestProgram
import grass.script.core as gs
from .loader import GrassTestLoader
from .runner import (
GrassTestRunner,
KeyValueTestResult,
MultiTestResult,
TextTestResult,
_WritelnDecorator,
)
from .invoker import GrassTestFilesInvoker
from .utils import silent_rmtree
from .reporters import FileAnonymizer
[docs]
class GrassTestProgram(TestProgram):
"""A class to be used by individual test files (wrapped in the function)"""
def __init__(
self,
exit_at_end,
grass_location,
clean_outputs=True,
unittest_argv=None,
module=None,
verbosity=1,
failfast=None,
catchbreak=None,
*,
tb_locals=False,
**kwargs,
):
"""Prepares the tests in GRASS way and then runs the tests.
:param bool clean_outputs: if outputs in mapset and in ?
"""
self.test = None
self.grass_location = grass_location
# it is unclear what the exact behavior is in unittest
# buffer stdout and stderr during tests
buffer_stdout_stderr = False
grass_loader = GrassTestLoader(grass_location=self.grass_location)
text_result = TextTestResult(
stream=_WritelnDecorator(sys.stderr), descriptions=True, verbosity=verbosity
)
with open("test_keyvalue_result.txt", "w", encoding="utf-8") as keyval_file:
keyval_result = KeyValueTestResult(stream=keyval_file)
result = MultiTestResult(results=[text_result, keyval_result])
grass_runner = GrassTestRunner(
verbosity=verbosity,
failfast=failfast,
buffer=buffer_stdout_stderr,
result=result,
)
super().__init__(
module=module,
argv=unittest_argv,
testRunner=grass_runner,
testLoader=grass_loader,
exit=exit_at_end,
verbosity=verbosity,
failfast=failfast,
catchbreak=catchbreak,
buffer=buffer_stdout_stderr,
tb_locals=tb_locals,
**kwargs,
)
[docs]
def test():
"""Run a test of a module."""
# TODO: put the link to to the report only if available
# TODO: how to disable Python code coverage for module and C tests?
# TODO: we probably need to have different test functions for C, Python modules,
# and Python code
# TODO: combine the results using python -m coverage --help | grep combine
# TODO: function to anonymize/beautify file names (in content and actual filenames)
# TODO: implement coverage but only when requested by invoker and only if
# it makes sense for tests (need to know what is tested)
# doing_coverage = False
# try:
# import coverage
# doing_coverage = True
# cov = coverage.coverage(omit="*testsuite*")
# cov.start()
# except ImportError:
# pass
# TODO: add some message somewhere
# TODO: enable passing omit to exclude also gunittest or nothing
program = GrassTestProgram(
module="__main__", exit_at_end=False, grass_location="all"
)
# TODO: check if we are in the directory where the test file is
# this will ensure that data directory is available when it is requested
# if doing_coverage:
# cov.stop()
# cov.html_report(directory='testcodecoverage')
# TODO: is sys.exit the right thing here
sys.exit(not program.result.wasSuccessful())
test.__test__ = False # prevent running this function as a test in pytest
[docs]
def discovery():
"""Recursively find all tests in testsuite directories and run them
Everything is imported and runs in this process.
Runs using::
python main.py discovery [start_directory]
"""
program = GrassTestProgram(grass_location="nc", exit_at_end=False)
sys.exit(not program.result.wasSuccessful())
CONFIG_FILENAME = ".gunittest.cfg"
[docs]
def get_config(start_directory, config_file):
"""Read configuration if available, return empty section proxy if not
If file is explicitly specified, it must exist.
:raises OSError: if file is not accessible, e.g., if it exists,
but there is an issue with permissions.
:raises ValueError: If neither start_directory nor config_file are set.
"""
config_parser = configparser.ConfigParser()
if config_file:
with open(config_file, encoding="utf-8") as file:
config_parser.read_file(file)
elif start_directory:
config_file = Path(start_directory) / CONFIG_FILENAME
# Does not check presence of the file
config_parser.read(config_file)
else:
msg = "Either start_directory or config_file must be set"
raise ValueError(msg)
if "gunittest" not in config_parser:
# Create an empty section if file is not available or section is not present.
config_parser.read_dict({"gunittest": {}})
return config_parser["gunittest"]
[docs]
def main():
parser = argparse.ArgumentParser(
description="Run test files in all testsuite directories starting"
" from the current one"
" (runs on active GRASS session)"
)
parser.add_argument(
"--location",
dest="location",
action="store",
help="Name of location where to perform test",
required=True,
)
parser.add_argument(
"--location-type",
dest="location_type",
action="store",
default="nc",
help="Type of tests which should be run (tag corresponding to location)",
)
parser.add_argument(
"--grassdata",
dest="gisdbase",
action="store",
default=None,
help="GRASS data(base) (GISDBASE) directory (current GISDBASE by default)",
)
parser.add_argument(
"--output",
dest="output",
action="store",
default="testreport",
help="Output directory",
)
parser.add_argument(
"--min-success",
dest="min_success",
action="store",
default="100",
type=float,
help=(
"Minimum success percentage (lower percentage"
" than this will result in a non-zero return code; values 0-100)"
),
)
parser.add_argument(
"--config",
dest="config",
action="store",
type=str,
help=f"Path to a configuration file (default: {CONFIG_FILENAME})",
)
args = parser.parse_args()
gisdbase = args.gisdbase
if gisdbase is None:
# here we already rely on being in GRASS session
gisdbase = gs.gisenv()["GISDBASE"]
location = args.location
location_type = args.location_type
if not gisdbase:
return "GISDBASE (grassdata directory) cannot be empty string\n"
if not os.path.exists(gisdbase):
return f"GISDBASE (grassdata directory) <{gisdbase}> does not exist\n"
if not os.path.exists(os.path.join(gisdbase, location)):
return (
f"GRASS Location <{location}>"
f" does not exist in GRASS Database <{gisdbase}>\n"
)
results_dir = args.output
silent_rmtree(results_dir) # TODO: too brute force?
start_dir = "."
abs_start_dir = os.path.abspath(start_dir)
try:
config = get_config(start_directory=start_dir, config_file=args.config)
except OSError as error:
return f"Error reading configuration: {error}"
invoker = GrassTestFilesInvoker(
start_dir=start_dir,
file_anonymizer=FileAnonymizer(paths_to_remove=[abs_start_dir]),
timeout=config.getfloat("timeout", None),
)
# TODO: remove also results dir from files
# as an enhancement
# we can just iterate over all locations available in database
# but the we don't know the right location type (category, label, shortcut)
reporter = invoker.run_in_location(
gisdbase=gisdbase,
location=location,
location_type=location_type,
results_dir=results_dir,
exclude=config.get("exclude", "").split(),
)
if not reporter.test_files:
return "No tests found or executed"
if reporter.file_pass_per >= args.min_success:
return 0
return 1
if __name__ == "__main__":
sys.exit(main())