Skip to content

Commit

Permalink
Merge pull request #37 from jepperaskdk/feature/exclude-patterns
Browse files Browse the repository at this point in the history
Feature/exclude patterns
  • Loading branch information
jepperaskdk authored Oct 8, 2022
2 parents 7491303 + fb8a765 commit 08b18ef
Show file tree
Hide file tree
Showing 11 changed files with 240 additions and 32 deletions.
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,16 @@ Docstring format can be specified with the `--parser` argument:
Currently, only google, numpy and sphinx are supported.

Full list of configuration options:
- "include_paths": [ List of strings ] # Pattern to search modules with. Defaults to `[**/*.py]`
- "exclude_paths": [ List of strings ] # Pattern to exclude modules with. Defaults to `["**/__init__.py", "**/setup.py"]`
- "include_paths": [ List of strings ] # Patterns to search modules with. Defaults to `[**/*.py]`
- "exclude_paths": [ List of strings ] # Patterns to exclude modules with. Defaults to `["**/__init__.py", "**/setup.py"]`
- "verbosity": [ 0 | 1 | 2 ] # How much to print, 0 = quiet, 1 = show failed, 2 = show all.
- "parser": [ "google" (default) | "sphinx" | "numpy" ] # Docstring format to use. Please raise an issue if you need other formats implemented.
- "fail_on_missing_docstring": [ true | false (default) ] # Mark a function as failed, if it does not have a docstring.
- "fail_on_missing_summary": [ true | false (default) ] # Mark a function as failed, if it does have a docstring, but no summary.
- "fail_on_raises_section": [ true (default) | false ] # Mark a function as failed, if docstring doesn't mention raised exceptions correctly.
- "exclude_classes": [ List of strings ] # Patterns to exclude classes with, e.g. `["Test*]"`
- "exclude_methods": [ List of strings ] # Patterns to exclude class methods with, e.g. for private methods you would use `["__*]"`
- "exclude_functions": [ List of strings ] # Patterns to exclude functions with, e.g. for private methods you would use `["__*]"`

CLI
------------
Expand Down
9 changes: 9 additions & 0 deletions pydoctest/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ def __init__(self) -> None:
# Throw an error if 'raises' section does not list all exceptions
self.fail_on_raises_section = True

# List of patterns to exclude classes from being analyzed by
self.exclude_classes: List[str] = []

# List of patterns to exclude methods from being analyzed by
self.exclude_methods: List[str] = []

# List of patterns to exclude functions from being analyzed by
self.exclude_functions: List[str] = []

@staticmethod
def get_default_configuration(root_dir: Optional[str] = None) -> 'Configuration':
"""Returns a configuration with default values.
Expand Down
45 changes: 24 additions & 21 deletions pydoctest/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from pydoctest.reporters.json_reporter import JSONReporter
from pydoctest.reporters.text_reporter import TextReporter
from pydoctest.validation import ModuleValidationResult, Result, ResultType, ValidationResult, validate_class, validate_function
from pydoctest.utilities import parse_cli_list
from pydoctest.utilities import is_excluded_class, is_excluded_function, parse_cli_list, is_excluded_path

# We always want to exclude setup.py
DEFAULT_EXCLUDE_PATHS = [ "**/setup.py" ]
Expand Down Expand Up @@ -111,6 +111,7 @@ def validate_module(self, module_path: str) -> ModuleValidationResult:

# Validate top-level classes in module
classes = self.get_classes(module_type)

for cl in classes:
class_result = validate_class(cl, self.config, module_type)
if class_result.result == ResultType.FAILED:
Expand All @@ -130,7 +131,9 @@ def get_global_functions(self, module: ModuleType) -> List[FunctionType]:
"""
fns = []
for name, obj in inspect.getmembers(module, lambda x: inspect.isfunction(x) and x.__module__ == module.__name__):
fns.append(obj)
# Check if function is excluded
if not is_excluded_function(name, self.config.exclude_functions):
fns.append(obj)
return fns

def get_classes(self, module: ModuleType) -> List[Type]:
Expand All @@ -148,21 +151,11 @@ def get_classes(self, module: ModuleType) -> List[Type]:
if issubclass(obj, Enum):
# Ignore enums
continue
classes.append(obj)
return classes

def __is_excluded_path(self, path: pathlib.Path, exclude_paths: List[str]) -> bool:
"""
Returns whether the found path is excluded by any of the exclude_paths.
Args:
path (pathlib.Path): The path to test
exclude_paths (List[str]): The exclude paths

Returns:
bool: If path is excluded.
"""
return any(path.match(e_p) for e_p in exclude_paths)
# Check if class is excluded before adding
if not is_excluded_class(obj.__name__, self.config.exclude_classes):
classes.append(obj)
return classes

def discover_modules(self) -> List[str]:
"""Discovers modules using the configuration include/exclude paths.
Expand All @@ -174,11 +167,12 @@ def discover_modules(self) -> List[str]:

include_paths = self.config.include_paths
exclude_paths = self.config.exclude_paths + DEFAULT_EXCLUDE_PATHS
abs_exclude_paths = [os.path.join(self.config.working_directory, p) for p in exclude_paths]

for include_path in include_paths:
path = pathlib.Path(self.config.working_directory)
disovered_paths = list(path.glob(include_path))
allowed_paths = [str(p) for p in disovered_paths if not self.__is_excluded_path(p, exclude_paths)]
discovered_paths = list(path.glob(include_path))
allowed_paths = [str(p) for p in discovered_paths if not is_excluded_path(str(p), abs_exclude_paths)]
include_file_paths.extend(allowed_paths)

return include_file_paths
Expand Down Expand Up @@ -243,9 +237,11 @@ def main() -> None: # pragma: no cover
parser.add_argument("--file", help="Analyze single file")
parser.add_argument("--parser", help="Docstring format, either: google|sphinx|numpy")

# TODO: Implement splitting by comma and
parser.add_argument("--include-paths", help="Pattern to include paths by, defaults to \"**/*.py\"")
parser.add_argument("--exclude-paths", help="Pattern to exclude paths by, defaults to \"**/__init__.py, **/setup.py\"")
parser.add_argument("--include-paths", help="Patterns to include paths by, defaults to \"**/*.py\"")
parser.add_argument("--exclude-paths", help="Patterns to exclude paths by, defaults to \"**/__init__.py, **/setup.py\"")
parser.add_argument("--exclude-classes", help="Patterns to exclude classes by")
parser.add_argument("--exclude-methods", help="Patterns to exclude methods by")
parser.add_argument("--exclude-functions", help="Patterns to exclude functions by")

args = parser.parse_args()

Expand Down Expand Up @@ -276,6 +272,13 @@ def main() -> None: # pragma: no cover
if args.exclude_paths:
config.exclude_paths = parse_cli_list(args.exclude_paths)

if args.exclude_classes:
config.exclude_classes = parse_cli_list(args.exclude_classes)
if args.exclude_methods:
config.exclude_methods = parse_cli_list(args.exclude_methods)
if args.exclude_functions:
config.exclude_functions = parse_cli_list(args.exclude_functions)

# Check that parser exists before running.
config.get_parser()

Expand Down
76 changes: 76 additions & 0 deletions pydoctest/utilities.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from collections import deque
import re
import inspect
import ast
import textwrap
Expand Down Expand Up @@ -144,3 +145,78 @@ def parse_cli_list(content: str, separator: str = ',') -> List[str]:
"""
items = content.split(separator)
return [i.strip() for i in items if i]


def pattern_matches(pattern: str, test_string: str) -> bool:
"""
Returns whether the string matches the pattern.
Inspired by this answer, by Mathew Wicks (and Nizam Mohamed): https://stackoverflow.com/a/72400344/3717691
pathlib.Path.match and fnmatch incorrectly returns recursive results for e.g. *.py.
Args:
pattern (str): The pattern, e.g. "**/*.py"
test_string (str): The string being tested, e.g. "a/b/c/abc.py"
Returns:
bool: If test_string is matched by pattern.
"""
i, n = 0, len(pattern)
res = ''
while i < n:
c = pattern[i]
i = i + 1
if c == '*':
j = i
if j < n and pattern[j] == '*':
res = res + '.*/?'
i = j + 2
else:
res = res + '[^/]*'
else:
res = res + re.escape(c)
regex = r'(?s:%s)\Z' % res
glob_re = re.compile(regex)
return bool(glob_re.match(test_string))


def is_excluded_path(path: str, exclude_paths: List[str]) -> bool:
"""
Returns whether the found path is excluded by any of the exclude_paths.
Args:
path (str): The path to test
exclude_paths (List[str]): The exclude paths
Returns:
bool: If path is excluded.
"""
return any(pattern_matches(e_p, path) for e_p in exclude_paths)


def is_excluded_class(class_name: str, exclude_classes: List[str]) -> bool:
"""
Returns whether the class is excluded by any of the exclude_classes patterns.
Args:
class_name (str): The class_name to test
exclude_classes (List[str]): The exclude class patterns
Returns:
bool: If class is excluded.
"""
return any(pattern_matches(e_p, class_name) for e_p in exclude_classes)


def is_excluded_function(function_name: str, exclude_functions: List[str]) -> bool:
"""
Returns whether the function (or method) is excluded by any of the exclude_functions patterns.
Args:
function_name (str): The function_name to test
exclude_functions (List[str]): The exclude function patterns
Returns:
bool: If function is excluded.
"""
return any(pattern_matches(e_p, function_name) for e_p in exclude_functions)
9 changes: 7 additions & 2 deletions pydoctest/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from pydoctest.configuration import Configuration
from pydoctest.parsers.parser import Parameter
from pydoctest.exceptions import ParseException
from pydoctest.utilities import get_exceptions_raised
from pydoctest.utilities import get_exceptions_raised, is_excluded_function


class Range():
Expand Down Expand Up @@ -362,14 +362,19 @@ def validate_class(class_instance: Any, config: Configuration, module_type: Modu
log(f"Validating class: {class_instance}")
class_result = ClassValidationResult(class_instance.__name__)

# for name, item in class_instance.__dict__.items():
for name, item in inspect.getmembers(class_instance):
if inspect.isfunction(item) and item.__module__ == module_type.__name__:
if name not in class_instance.__dict__ or item != class_instance.__dict__[name]:
continue

# Check if method is excluded
if is_excluded_function(name, config.exclude_methods):
continue

function_result = validate_function(item, config, module_type)
if function_result.result == ResultType.FAILED:
class_result.result = ResultType.FAILED

class_result.function_results.append(function_result)

# If result has not been changed at this point, it must be OK
Expand Down
2 changes: 1 addition & 1 deletion pydoctest/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VERSION = "0.1.19"
VERSION = "0.1.20"
2 changes: 1 addition & 1 deletion tests/test_cli/example_class_cli_copy.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ def b(d: int) -> int:
pass


class ExampleCLIClass():
class ExampleCLIClassCopy():
def a(self, b: int) -> int:
"""[summary]
Expand Down
23 changes: 23 additions & 0 deletions tests/test_cli/excluded_class_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
def excluded_function(b: int) -> int:
"""[summary]
Args:
b (int): [description]
Returns:
int: [description]
"""
pass


class ExcludedClass():
def excluded_method(self, b: int) -> int:
"""[summary]
Args:
b (int): [description]
Returns:
int: [description]
"""
pass
59 changes: 59 additions & 0 deletions tests/test_cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,31 @@

class TestMain(TestCase):
def test_verbosity_2_argument(self) -> None:
"""
Tests that verbosity of 2 includes output of module, function, class and method.
"""
out, err = self.execute_command('python3 -m pydoctest.main --config tests/test_cli/pydoctest.json --verbosity 2')
assert 'example_class_cli.py::ExampleCLIClass::a' in out
assert 'example_class_cli.py::b' in out
assert 'Succeeded: 2, Failed: 0, Skipped: 0' in out
assert len(err) == 0

def test_verbosity_1_argument(self) -> None:
"""
Tests that verbosity of 1 doesn't include output (unlike verbosity 2).
"""
out, err = self.execute_command('python3 -m pydoctest.main --config tests/test_cli/pydoctest.json --verbosity 1')
assert 'function ExampleCLIClass' not in out
assert 'Succeeded: 2, Failed: 0, Skipped: 0' in out
assert len(err) == 0

def test_reporter_json_argument(self) -> None:
"""
Tests that '--reporter json' returns valid json.
Raises:
Exception: _description_
"""
out, err = self.execute_command('python3 -m pydoctest.main --config tests/test_cli/pydoctest.json --reporter json')
try:
d = json.loads(out)
Expand All @@ -43,3 +55,50 @@ def test_include_exclude_paths_argument(self) -> None:
out, err = self.execute_command('python3 -m pydoctest.main --config tests/test_cli/pydoctest.json --reporter json --include-paths "*.py" --exclude-paths "**/excluded_class_cli.py"')
output = json.loads(out)
assert len(output['module_results']) == 3

def test_exclude_classes_argument(self) -> None:
"""
Tests that the '--exclude-classes' argument is parsed.
"""
out, err = self.execute_command('python3 -m pydoctest.main --config tests/test_cli/pydoctest.json --reporter json --include-paths "*.py"')
output = json.loads(out)
class_names = [c['class_name'] for m in output['module_results'] for c in m['class_results']]
assert len(class_names) == 4

out, err = self.execute_command('python3 -m pydoctest.main --config tests/test_cli/pydoctest.json --reporter json --include-paths "*.py" --exclude-classes "ExcludedClass"')
output = json.loads(out)
class_names = [c['class_name'] for m in output['module_results'] for c in m['class_results']]
assert len(class_names) == 3
assert 'ExcludedClass' not in class_names

def test_exclude_methods_argument(self) -> None:
"""
Tests that the '--exclude-methods' argument is parsed.
"""
out, err = self.execute_command('python3 -m pydoctest.main --config tests/test_cli/pydoctest.json --reporter json --include-paths "excluded_class_cli.py"')
output = json.loads(out)

assert output['module_results'][0]['class_results'][0]['class_name'] == 'ExcludedClass'
assert len(output['module_results'][0]['class_results'][0]['function_results']) == 1
assert 'excluded_method' in output['module_results'][0]['class_results'][0]['function_results'][0]['function']

out, err = self.execute_command('python3 -m pydoctest.main --config tests/test_cli/pydoctest.json --reporter json --include-paths "excluded_class_cli.py" --exclude-methods "excluded_method"')
output = json.loads(out)

assert output['module_results'][0]['class_results'][0]['class_name'] == 'ExcludedClass'
assert len(output['module_results'][0]['class_results'][0]['function_results']) == 0

def test_exclude_functions_argument(self) -> None:
"""
Tests that the '--exclude-functions' argument is parsed.
"""
out, err = self.execute_command('python3 -m pydoctest.main --config tests/test_cli/pydoctest.json --reporter json --include-paths "excluded_class_cli.py"')
output = json.loads(out)

assert len(output['module_results'][0]['function_results']) == 1
assert 'excluded_function' in output['module_results'][0]['function_results'][0]['function']

out, err = self.execute_command('python3 -m pydoctest.main --config tests/test_cli/pydoctest.json --reporter json --include-paths "excluded_class_cli.py" --exclude-functions "excluded_function"')
output = json.loads(out)

assert len(output['module_results'][0]['function_results']) == 0
2 changes: 1 addition & 1 deletion tests/test_discovery/test_exclude_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def test_exclude_paths_wildcard(self) -> None:
config = Configuration.get_default_configuration()
config.working_directory = os.path.abspath(os.path.join(os.path.dirname(__file__), "deep_project"))
config.include_paths = ["a/**/*.py"]
config.exclude_paths = ["file_a_*.py"]
config.exclude_paths = ["**/file_a_*.py"]
service = PyDoctestService(config)
modules = service.discover_modules()

Expand Down
Loading

0 comments on commit 08b18ef

Please sign in to comment.