Skip to content

Commit

Permalink
Merge pull request #51 from jepperaskdk/pr/optional-keyword
Browse files Browse the repository at this point in the history
Added support for optional argument for all 3 parsers.
  • Loading branch information
jepperaskdk authored Aug 10, 2024
2 parents c9a0bc3 + 3fae3bf commit 62d822e
Show file tree
Hide file tree
Showing 20 changed files with 182 additions and 41 deletions.
12 changes: 5 additions & 7 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,11 @@ jobs:
- name: Test typing with mypy
run: |
mypy --config-file mypy.ini
# - name: Test docstrings with pydoctest
# run: |
# pydoctest
- name: Test with pytest and coverage
run: |
coverage run -m pytest
- name: "Upload coverage to Codecov"
uses: codecov/codecov-action@v1
with:
fail_ci_if_error: true
# - name: "Upload coverage to Codecov"
# uses: codecov/codecov-action@v4
# with:
# fail_ci_if_error: true
# token: ${{ secrets.CODECOV_TOKEN }} # required
11 changes: 11 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Changelog

## [0.2.0] - 2024-08-09

### Added

- [Breaking] Support for "optional" in all Google, Numpy and Sphinx parsers. This is breaking since it will start requiring optional parameters to be marked as optional in docstrings.

## [<=0.1.22]

- Versions below 0.2.0 were not tracked by this document. See [releases](https://github.com/jepperaskdk/pydoctest/releases) on GitHub.
6 changes: 3 additions & 3 deletions pydoctest/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def validate(self, modules: Optional[List[str]] = None) -> ValidationResult:
"""Validate the found modules using the provided reporter.
Args:
modules (Optional[List[str]]): Optionally, specify directly the modules rather than discover.
modules (Optional[List[str]], optional): Optionally, specify directly the modules rather than discover.
Returns:
ValidationResult: Information about whether validation succeeded.
Expand Down Expand Up @@ -183,7 +183,7 @@ def get_configuration(root_dir: str, config_path: Optional[str] = None) -> Confi
Args:
root_dir (str): The directory to search in.
config_path (Optional[str]): [description]. Defaults to None.
config_path (Optional[str], optional): [description]. Defaults to None.
Returns:
Configuration: Either a configuration matching the specified/found one, or a default one.
Expand All @@ -207,7 +207,7 @@ def get_reporter(config: Configuration, reporter: Optional[str] = None) -> Repor
Args:
config (Configuration): The configuration currently used.
reporter (Optional[str]): Desired reporter [text | json]
reporter (Optional[str], optional): Desired reporter [text | json]
Raises:
Exception: Raised if desired reporter does not exist.
Expand Down
8 changes: 3 additions & 5 deletions pydoctest/parsers/google_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,7 @@
# Regex matches [name] ([type]): [description] with some extra whitespace
# e.g. this would also match: a ( int ) : kmdkfmdf
# It terminates with .* meaning a new match is the terminator. This should support multiline descriptions without having to consider tabs/indentation.
if sys.version_info[:2] >= (3,10):
ARGUMENT_REGEX = re.compile(r"\s*(?P<name>(\w+))\s*\((?P<type>[\w\.\[\], \'\|]+)\)\s*:(.*)")
else:
ARGUMENT_REGEX = re.compile(r"\s*(?P<name>(\w+))\s*\((?P<type>[\w\.\[\], \']+)\)\s*:(.*)")
ARGUMENT_REGEX = re.compile(r"\s*(?P<name>(\w+))\s*\((?P<type>[\w\.\[\], \'\|^\w]+?)(?P<optional>, optional)?\)\s*:(.*)")


class GoogleParser(Parser):
Expand Down Expand Up @@ -145,10 +142,11 @@ def get_parameters(self, doc: str, module_type: ModuleType) -> List[Parameter]:
start, end = match.span()
name = match.group('name').strip()
type = match.group('type').strip()
optional = match.group('optional')

try:
located_type = get_type_from_module(type, module_type)
parameters.append(Parameter(name, located_type.type))
parameters.append(Parameter(name, located_type.type, optional is not None))
except UnknownTypeException:
raise ParseException(f"Unknown type '{type}' in '{sections[Section.ARGUMENTS][start:end].strip()}'")
except Exception:
Expand Down
26 changes: 13 additions & 13 deletions pydoctest/parsers/numpy_parser.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import re
import os
import sys

from types import ModuleType
Expand Down Expand Up @@ -28,12 +29,8 @@ def __init__(self) -> None:
]
# Split by word, newline, a number of '-' and newline
self.section_regex = re.compile("([a-zA-Z]+)\n[-]+\n")
if sys.version_info[:2] >= (3,10):
self.parameter_regex = re.compile(r"(\w+)\s*:\s*([\w\[\], \|]+)")
self.returns_with_name_regex = re.compile(r"(\w+)\s*:\s*([\w\[\], \|]+)")
else:
self.parameter_regex = re.compile(r"(\w+)\s*:\s*([\w\[\], ]+)")
self.returns_with_name_regex = re.compile(r"(\w+)\s*:\s*([\w\[\], ]+)")
self.parameter_regex = re.compile(r"(\w+)\s*:\s*([\w\[\], \| \^\w]+?)(?:(, optional)|$)")
self.returns_with_name_regex = re.compile(r"(\w+)\s*:\s*([\w\[\], \|]+)")

def get_exceptions_raised(self, doc: str) -> List[str]:
"""Returns the exceptions listed as raised in the docstring.
Expand Down Expand Up @@ -115,16 +112,19 @@ def get_parameters(self, doc: str, module_type: ModuleType) -> List[Parameter]:
raise ParseException()

parameters_section = splits[parameters_idx + 1]
matches = self.parameter_regex.findall(parameters_section)
parameters: List[Parameter] = []

for parameters_line in parameters_section.split('\n'):
matches = self.parameter_regex.findall(parameters_line)

for param_name, param_type, optional in matches:
located_type = get_type_from_module(param_type, module_type)
parameters.append(Parameter(param_name, located_type.type, len(optional.strip()) > 0))

# If we have a parameters section with text, but no matches, we must have bad formatting
if len(parameters_section) > 0 and len(matches) == 0:
if len(parameters_section) > 0 and len(parameters) == 0:
raise ParseException()

parameters: List[Parameter] = []
for param_name, param_type in matches:
located_type = get_type_from_module(param_type, module_type)
parameters.append(Parameter(param_name, located_type.type))

return parameters
except Exception:
raise ParseException()
Expand Down
4 changes: 3 additions & 1 deletion pydoctest/parsers/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@ class Section(IntEnum):


class Parameter():
def __init__(self, name: str, t: Type) -> None:
def __init__(self, name: str, t: Type, is_optional: bool) -> None:
"""Instantiates a function parameter.
Args:
name (str): The name of the argument.
t (Type): The type of the argument
is_optional (bool): Whether the argument is optional.
"""
self.name = name
self.type = t
self.is_optional = is_optional


class Parser():
Expand Down
11 changes: 4 additions & 7 deletions pydoctest/parsers/sphinx_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,8 @@ def __init__(self) -> None:
super().__init__()
self.parameter_name_regex = re.compile(r":param\s+(\w+):")

if sys.version_info[:2] >= (3,10):
self.parameter_type_regex = re.compile(r":type\s+\w+:\s*([\w\[\], \|]+)")
self.return_type_regex = re.compile(r":rtype:\s*([\w\[\], \|]+)")
else:
self.parameter_type_regex = re.compile(r":type\s+\w+:\s*([\w\[\], ]+)")
self.return_type_regex = re.compile(r":rtype:\s*([\w\[\], ]+)")
self.parameter_type_regex = re.compile(r":type\s+\w+:\s*([\w\[\], \|\^\w]+?)(?:(, optional)|$)")
self.return_type_regex = re.compile(r":rtype:\s*([\w\[\], \|]+)")

self.raises_regex = re.compile(r":raises\s+(\w+):")

Expand Down Expand Up @@ -105,8 +101,9 @@ def get_parameters(self, doc: str, module_type: ModuleType) -> List[Parameter]:
match = self.parameter_type_regex.match(line)
if match is not None:
var_type = match.groups()[0]
var_optional = match.groups()[1]
located_type = get_type_from_module(var_type, module_type)
parameters.append(Parameter(var_name, located_type.type))
parameters.append(Parameter(var_name, located_type.type, var_optional is not None))
var_name = None
if var_name is not None:
# There must have been an error parsing :type:
Expand Down
2 changes: 1 addition & 1 deletion pydoctest/reporters/text_reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def get_function_output(self, result: FunctionValidationResult, class_name: Opti
Args:
result (FunctionValidationResult): The result from running Pydoctest on the function.
class_name (Optional[str]): Optionally which class this function is run within.
class_name (Optional[str], optional): Optionally which class this function is run within.
Returns:
str: The output from the function.
"""
Expand Down
2 changes: 1 addition & 1 deletion pydoctest/utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ def parse_cli_list(content: str, separator: str = ',') -> List[str]:
Args:
content (str): The string coming from a cli command.
separator (str): The separator to split the list by. Defaults to ','.
separator (str, optional): The separator to split the list by. Defaults to ','.
Returns:
List[str]: The list-items.
Expand Down
12 changes: 10 additions & 2 deletions pydoctest/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ def __get_docstring_range(fn: FunctionType, module_type: ModuleType, docstring:
Args:
fn (FunctionType): The function to validate.
module_type (ModuleType): The module from which the function was extracted.
docstring (Optional[str]): Optionally, the docstring.
docstring (Optional[str], optional): Optionally, the docstring.
Returns:
Optional[Range]: The range, if found.
Expand Down Expand Up @@ -279,7 +279,7 @@ def validate_function(fn: FunctionType, config: Configuration, module_type: Modu
return result

sig = inspect.signature(fn)
sig_parameters = [Parameter(name, proxy.annotation) for name, proxy in sig.parameters.items() if name != "self"]
sig_parameters = [Parameter(name, proxy.annotation, proxy.default is not inspect.Parameter.empty) for name, proxy in sig.parameters.items() if name != "self"]
sig_return_type = type(None) if sig.return_annotation is None else sig.return_annotation

try:
Expand Down Expand Up @@ -319,6 +319,14 @@ def validate_function(fn: FunctionType, config: Configuration, module_type: Modu
result.fail_reason = f"Argument type differ. Argument '{sigparam.name}' was expected (from signature) to have type '{sigparam.type}', but has (in docs) type '{docparam.type}'"
result.range = __get_docstring_range(fn, module_type, doc)
return result

if sigparam.is_optional != docparam.is_optional:
result.result = ResultType.FAILED
sig_optional = 'optional' if sigparam.is_optional else 'not optional'
doc_optional = 'optional' if docparam.is_optional else 'not optional'
result.fail_reason = f"Argument optional differs. Argument '{sigparam.name}' was expected (from signature) to be {sig_optional}, but is (in docs) {doc_optional}"
result.range = __get_docstring_range(fn, module_type, doc)
return result

# Validate exceptions raised
if config.fail_on_raises_section:
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.22"
VERSION = "0.2.0"
11 changes: 11 additions & 0 deletions tests/test_class/correct_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,14 @@ def func_has_union_arg(self, a: Union[int, str]) -> None:
a (int | str): [description]
"""
pass

def func_with_optional(self, a: int = 0) -> int:
"""[summary]
Args:
a (int, optional): [description]
Returns:
int: [description]
"""
pass
22 changes: 22 additions & 0 deletions tests/test_class/incorrect_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,25 @@ def func_parse_exception(self, a: int) -> int:
THISDOESNTPARSE
"""
pass

def func_optional_mismatch(self, a: int = 0) -> int:
"""[summary]
Args:
a (int): [description] <-- a should be marked optional
Returns:
int: [description]
"""
pass

def func_optional_mismatch2(self, a: int) -> int:
"""[summary]
Args:
a (int, optional): [description] <-- a should not be marked optional
Returns:
int: [description]
"""
pass
12 changes: 12 additions & 0 deletions tests/test_class/test_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,18 @@ def test_incorrect_class_func_type_mismatch(self) -> None:
assert result.result == ResultType.FAILED
assert result.fail_reason == "Argument type differ. Argument 'a' was expected (from signature) to have type '<class 'int'>', but has (in docs) type '<class 'float'>'"

def test_incorrect_class_func_optional_mismatch1(self) -> None:
config = Configuration.get_default_configuration()
result = validate_function(tests.test_class.incorrect_class.IncorrectTestClass.func_optional_mismatch, config, tests.test_class.incorrect_class)
assert result.result == ResultType.FAILED
assert result.fail_reason == "Argument optional differs. Argument 'a' was expected (from signature) to be optional, but is (in docs) not optional"

def test_incorrect_class_func_optional_mismatch2(self) -> None:
config = Configuration.get_default_configuration()
result = validate_function(tests.test_class.incorrect_class.IncorrectTestClass.func_optional_mismatch2, config, tests.test_class.incorrect_class)
assert result.result == ResultType.FAILED
assert result.fail_reason == "Argument optional differs. Argument 'a' was expected (from signature) to be not optional, but is (in docs) optional"

# Test counts_class
def test_counts_class(self) -> None:
config = Configuration.get_configuration_from_path("tests/test_class/pydoctest_get_counts.json")
Expand Down
9 changes: 9 additions & 0 deletions tests/test_parsers/google_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,12 @@ def function_with_pipe(self, a: Union[int, float]) -> Union[int, float]:
"""

pass

def func_optional_argument(self, a: int, b: int = 0) -> None:
"""Function with optional argument
Args:
a (int): [description]
b (int, optional): [description]
"""
pass
24 changes: 24 additions & 0 deletions tests/test_parsers/numpy_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,18 @@ def func_parse_exception(self, a: int) -> int:
"""
pass

def func_missing_optional_argument(self, a: int, b: int = 0) -> None:
"""Function with optional argument
Parameters
----------
a : int
[description]
b : int
Missing ", optional" on this argument.
"""
pass


class CorrectTestClass():

Expand Down Expand Up @@ -275,6 +287,18 @@ def func_no_summary(self) -> None:
"""
pass

def func_optional_argument(self, a: int, b: int = 0) -> None:
"""Function with optional argument
Parameters
----------
a : int
[description]
b : int, optional
[description]
"""
pass


class RaisesClass():

Expand Down
10 changes: 10 additions & 0 deletions tests/test_parsers/sphinx_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,16 @@ def func_no_summary(self) -> None:
"""
pass

def func_optional_argument(self, a: int, b: int = 0) -> None:
"""Function with optional argument
:param a: [description]
:type a: int
:param b: [description]
:type b: int, optional
"""
pass


class RaisesClass():

Expand Down
13 changes: 13 additions & 0 deletions tests/test_parsers/test_google_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,16 @@ def test_function_with_pipe(self) -> None:
assert len(params) == 1
assert params[0].name == 'a'
assert params[0].type == int | float

def test_func_with_optional_argument(self) -> None:
parser = GoogleParser()
doc = pydoc.getdoc(tests.test_parsers.google_class.GoogleClass.func_optional_argument)
parameters = parser.get_parameters(doc, tests.test_parsers.google_class)
assert len(parameters) == 2
assert parameters[0].name == 'a'
assert parameters[0].type == int
assert parameters[0].is_optional is False

assert parameters[1].name == 'b'
assert parameters[1].type == int
assert parameters[1].is_optional is True
13 changes: 13 additions & 0 deletions tests/test_parsers/test_numpy_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,16 @@ def test_func_with_generics(self) -> None:

return_type = parser.get_return_type(doc, tests.test_parsers.numpy_class)
assert return_type == Dict[str, Any]

def test_func_with_optional_argument(self) -> None:
parser = NumpyParser()
doc = pydoc.getdoc(tests.test_parsers.numpy_class.CorrectTestClass.func_optional_argument)
parameters = parser.get_parameters(doc, tests.test_parsers.numpy_class)
assert len(parameters) == 2
assert parameters[0].name == 'a'
assert parameters[0].type == int
assert parameters[0].is_optional is False

assert parameters[1].name == 'b'
assert parameters[1].type == int
assert parameters[1].is_optional is True
Loading

0 comments on commit 62d822e

Please sign in to comment.