Source code for gridtest.main.generate
"""
Copyright (C) 2020 Vanessa Sochat.
This Source Code Form is subject to the terms of the
Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed
with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
"""
from gridtest.utils import recursive_find, write_yaml, read_yaml
import importlib
import inspect
import os
import logging
import re
import sys
import types
import yaml
logger = logging.getLogger(__name__)
[docs]def get_function_typing(func):
"""Given a function that is inspected or otherwise present, return
a lookup of arguments with any expected default types. This is
done at runtime and done as a check, and done here so we don't need
to install mypy.
Arguments:
- func (function) : loaded function to return types for
Returns: lookup dictionary of variable names and types. Return
is in the lookup and corresponds to the value of the return.
"""
return inspect.getfullargspec(func).annotations
[docs]def import_module(name):
"""Import module will try import of a module based on a name. If import
fails and there are no ., we expect that it could be a script in the
present working directory and add .<script>
Arguments:
- name (str) : the name of the module to import
"""
try:
module = importlib.import_module(name)
except:
sys.exit(f"Unrecognizable file, directory, or module name {name}")
return module
[docs]def generate_tests(
module, output=None, include_private=False, force=False, include_classes=True
):
"""Generate a test output file for some input module. If an output file
is specified and already has existing content, in the case that check is
used, we only print section names that have not been written. If check
is used and the file doesn't exist, we print the tests to create to the
screen. If an existing file is found and check isn't used, we only
update it with tests not yet written. This functionality is provided
so that the user can easily update a testing file without erasing old
tests. A "module" input variable can be:
- a script path explitly
- a directory path with files to be recursively discovered
- a module name
By default, if a testing file is provided that already has sections defined,
they will not be overwritten (but new sections will be added). If the
user wants to produce a new (reset) template, the file should be deleted
and generate run freshly.
Arguments:
- module (str) : a file, directory, or module name to parse
- output (str) : a path to a yaml file to save to
- include_private (bool) : include "private" functions
- force (bool) : force overwrite existing functions (default False)
- include_classes (bool) : extract classes to write tests too
"""
if output and not re.search("[.](yml|yaml)$", output):
sys.exit("Output file must have yml|yaml extension.")
files = []
# Case 1: the module is a filename
if os.path.isfile(module):
files.append(os.path.relpath(module))
# Case 2: Recursively add python files
elif os.path.isdir(module):
files += list(recursive_find(module))
# Case 3: assume it's a module name
else:
files = [module]
# We will build up a test specification (or read in existing) based on filename
spec = {}
# Update the old test yaml
if output and os.path.exists(output) and not force:
sys.exit(
f"{output} exists! use --force to overwrite, or gridtest update instead."
)
# Import each file as a module, or a module name, exit on error
for filename in files:
name = re.sub("[.]py$", "", filename.replace("/", "."))
spec[name] = extract_functions(
filename, include_private=include_private, include_classes=include_classes
)
# Write to output file
if output:
write_yaml(spec, output)
else:
print("\n" + yaml.dump(spec))
return spec
[docs]def formulate_arg(arg, default=None):
"""Return a data structure (dictionary) with the argument as key,
and a default defined, along with a random value to test.
"""
return {arg: default}
[docs]def extract_modulename(filename, input_dir=None):
"""Extract a module, file, or relative path for a filename. First
Arguments:
- filename (str) : a filename or module name to parse
- input_dir (str) : an input directory with the recipe, in case
of a local file.
"""
input_dir = input_dir or ""
# Case 1: the filename already exists
if os.path.exists(filename):
return filename
# Case 2: It's a module installed, return module name
if "site-packages" in filename:
return [x for x in filename.split("site-packages")[-1].split("/") if x][0]
# Case 3: It's a local file in some input directory
filename = os.path.join(input_dir, os.path.basename(filename))
if not os.path.exists(filename):
sys.exit(f"Cannot find module {filename}")
return filename
[docs]def extract_functions(
filename, include_private=False, quiet=False, include_classes=True,
):
"""Given a filename, extract a module and associated functions with it
into a grid test. This means creating a structure with function
names and (if provided) default inputs. The user will fill in
the rest of the file. The function can be used easily recursively by calling
itself to get metadata for a subclass, and passing along the (already
imported) module.
Arguments:
- filename (str) : a filename or module name to parse
- include_private (bool) : include "private" functions
- quiet (bool) : suppress additional output
- include_classes (bool) : extract classes
"""
sys.path.insert(1, os.getcwd())
meta = {}
tests = {}
# Try importing the module, fall back to relative path
try:
name = re.sub(".py$", "", filename).replace("/", ".")
module = import_module(name)
except:
name = re.sub(".py$", "", os.path.relpath(filename)).replace("/", ".")
module = import_module(name)
# Generate tuples with (name, module, fullname)
functions = [(name, module, name)]
meta["filename"] = inspect.getfile(module)
module_dir = os.path.dirname(meta["filename"])
# Don't re-add functions that are seen
seen = set()
while functions:
funcname, func, fullname = functions.pop(0)
if funcname.startswith("_") and not include_private:
continue
# Skip over functions / modules that aren't a part of original module
try:
if module_dir not in inspect.getfile(func):
continue
except:
continue
# If it's a module, add functions to list (first pop)
if isinstance(func, types.ModuleType):
for member in inspect.getmembers(func):
if member[0] not in seen:
functions.append(member + ("%s.%s" % (funcname, member[0]),))
seen.add(member[0])
continue
if not include_function(
funcname,
func,
include_classes=include_classes,
include_private=include_private,
):
continue
# Extract arguments for function or class, add to matrix
try:
args = inspect.getfullargspec(func)
if not quiet:
logger.info(f"Extracting {funcname} from {name}")
if funcname.startswith("_"):
print(f"Extracting {funcname} from {name}")
tests[fullname] = []
defaults = args.defaults or []
argdict = {}
# self is specific to classes, add reference to original class
if args.args and args.args[0] == "self":
if defaults and defaults[0] != "self":
args.args.pop(0)
for idx in range(len(args.args)):
default = None
if len(defaults) > idx:
default = defaults[idx]
argdict.update(formulate_arg(args.args[idx], default))
tests[fullname].append({"args": argdict})
# Exceptions will throw type errors
except TypeError:
continue
# If it's a class and we are including classes
if isinstance(func, object):
try:
for member in inspect.getmembers(func):
if member[0] not in seen:
functions.append(member + ("%s.%s" % (funcname, member[0]),))
seen.add(member[0])
except:
print(f"Cannot get members for {func}")
# Add the tests to the final meta object
meta["tests"] = tests
return meta
[docs]def include_function(funcname, func, include_classes=True, include_private=False):
"""A helper to determine if a function (or class) should be included.
Returns True for yes, False otherwise.
"""
if funcname.startswith("__"):
return False
if funcname.startswith("_") and not include_private:
return False
if not isinstance(func, types.FunctionType) and not include_classes:
return False
if isinstance(func, types.FunctionType):
return True
if isinstance(func, object) and not include_classes:
return False
if isinstance(func, (int, float, bytes, str, list)):
return False
return True