diff --git a/Makefile b/Makefile index 00f4ca9..ce6d186 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,8 @@ .DEFAULT_GOAL := help +PYTHON_FILES = ./code_annotations/ setup.py tests/ test_utils/ + define BROWSER_PYSCRIPT import os, webbrowser, sys try: @@ -42,25 +44,28 @@ COMMON_CONSTRAINTS_TXT=requirements/common_constraints.txt $(COMMON_CONSTRAINTS_TXT): wget -O "$(@)" https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt || touch "$(@)" -upgrade: export CUSTOM_COMPILE_COMMAND=make upgrade -upgrade: $(COMMON_CONSTRAINTS_TXT) # update the requirements/*.txt files with the latest packages satisfying requirements/*.in +compile-requirements: export CUSTOM_COMPILE_COMMAND=make upgrade +compile-requirements: $(COMMON_CONSTRAINTS_TXT) ## Re-compile *.in requirements to *.txt pip install -qr requirements/pip-tools.txt # Make sure to compile files after any other files they include! - pip-compile --upgrade --allow-unsafe -o requirements/pip.txt requirements/pip.in - pip-compile --upgrade -o requirements/pip-tools.txt requirements/pip-tools.in + pip-compile ${COMPILE_OPTS} --allow-unsafe requirements/pip.in + pip-compile ${COMPILE_OPTS} requirements/pip-tools.in pip install -qr requirements/pip.txt pip install -qr requirements/pip-tools.txt - pip-compile --upgrade -o requirements/base.txt requirements/base.in - pip-compile --upgrade -o requirements/django.txt requirements/django.in - pip-compile --upgrade -o requirements/test.txt requirements/test.in - pip-compile --upgrade -o requirements/doc.txt requirements/doc.in - pip-compile --upgrade -o requirements/quality.txt requirements/quality.in - pip-compile --upgrade -o requirements/ci.txt requirements/ci.in - pip-compile --upgrade -o requirements/dev.txt requirements/dev.in + pip-compile ${COMPILE_OPTS} requirements/base.in + pip-compile ${COMPILE_OPTS} requirements/django.in + pip-compile ${COMPILE_OPTS} requirements/test.in + pip-compile ${COMPILE_OPTS} requirements/doc.in + pip-compile ${COMPILE_OPTS} requirements/quality.in + pip-compile ${COMPILE_OPTS} requirements/ci.in + pip-compile ${COMPILE_OPTS} requirements/dev.in # Let tox control the Django version for tests sed '/^[dD]jango==/d' requirements/test.txt > requirements/test.tmp mv requirements/test.tmp requirements/test.txt +upgrade: $(COMMON_CONSTRAINTS_TXT) ## update the requirements/*.txt files with the latest packages satisfying requirements/*.in + $(MAKE) compile-requirements COMPILE_OPTS="--upgrade" + quality: ## check coding style with pycodestyle and pylint tox -e quality @@ -70,9 +75,28 @@ requirements: ## install development environment requirements pip-sync requirements/dev.txt requirements/test.txt requirements/private.* pip install -e . -test: clean ## run tests in the current virtualenv +test: clean test-unit test-quality ## run tests in the current virtualenv + +test-unit: ## run unit tests pytest +test-quality: test-lint test-types test-codestyle test-docstyle test-isort selfcheck ## run all quality tests + +test-codestyle: ## run pycodestyle tests + pycodestyle ${PYTHON_FILES} + +test-docstyle: ## run pydocstyle tests + pydocstyle ${PYTHON_FILES} + +test-isort: ## run isort tests + isort --check-only --diff ${PYTHON_FILES} + +test-lint: ## run pylint tests + pylint ${PYTHON_FILES} + +test-types: ## run mypy tests on the whole codebase + mypy --ignore-missing-imports --strict ${PYTHON_FILES} + diff_cover: test ## find diff lines that need test coverage diff-cover coverage.xml diff --git a/code_annotations/annotation_errors.py b/code_annotations/annotation_errors.py index 16fae58..560555b 100644 --- a/code_annotations/annotation_errors.py +++ b/code_annotations/annotation_errors.py @@ -3,20 +3,20 @@ """ from collections import namedtuple -AnnotationErrorType = namedtuple( +AnnotationError = namedtuple( "AnnotationError", ["message", "symbol", "description"] ) -# The TYPES list should contain all AnnotationErrorType instances. This list can then be parsed by others, for instance +# The TYPES list should contain all AnnotationError instances. This list can then be parsed by others, for instance # to expose errors to pylint. -TYPES = [] +TYPES: list[AnnotationError] = [] -def add_error_type(message, symbol, description): +def add_error_type(message: str, symbol: str, description: str) -> AnnotationError: """ - Create an AnnotationErrorType instance and add it to TYPES. + Create an AnnotationError instance and add it to TYPES. """ - error_type = AnnotationErrorType( + error_type = AnnotationError( message, symbol, description, @@ -32,32 +32,32 @@ def add_error_type(message, symbol, description): # It is important to preserve the insertion order of these error types in the TYPES list, as edx-lint uses the error # type indices to generate numerical pylint IDs. If the insertion order is changed, the pylint IDs will change too, # which might cause incompatibilities down the road. Thus, new items should be added at the end. -InvalidChoice = add_error_type( +InvalidChoice: AnnotationError = add_error_type( '"%s" is not a valid choice for "%s". Expected one of %s.', "annotation-invalid-choice", "Emitted when the value of a choice field is not one of the valid choices", ) -DuplicateChoiceValue = add_error_type( +DuplicateChoiceValue: AnnotationError = add_error_type( '"%s" is already present in this annotation.', "annotation-duplicate-choice-value", "Emitted when duplicate values are found in a choice field", ) -MissingChoiceValue = add_error_type( +MissingChoiceValue: AnnotationError = add_error_type( 'no value found for "%s". Expected one of %s.', "annotation-missing-choice-value", "Emitted when a choice field does not have any value", ) -InvalidToken = add_error_type( +InvalidToken: AnnotationError = add_error_type( "'%s' token does not belong to group '%s'. Expected one of: %s", "annotation-invalid-token", "Emitted when a token is found in a group for which it is not valid", ) -DuplicateToken = add_error_type( +DuplicateToken: AnnotationError = add_error_type( "found duplicate token '%s'", "annotation-duplicate-token", "Emitted when a token is found twice in a group", ) -MissingToken = add_error_type( +MissingToken: AnnotationError = add_error_type( "missing non-optional annotation: '%s'", "annotation-missing-token", "Emitted when a required token is missing from a group", diff --git a/code_annotations/base.py b/code_annotations/base.py index 6f7964d..f517bde 100644 --- a/code_annotations/base.py +++ b/code_annotations/base.py @@ -5,6 +5,7 @@ import errno import os import re +import typing as t from abc import ABCMeta, abstractmethod import yaml @@ -23,7 +24,13 @@ class AnnotationConfig: Configuration shared among all Code Annotations commands. """ - def __init__(self, config_file_path, report_path_override=None, verbosity=1, source_path_override=None): + def __init__( + self, + config_file_path: str, + report_path_override: str | None = None, + verbosity: int = 1, + source_path_override: str | None = None + ) -> None: """ Initialize AnnotationConfig. @@ -33,50 +40,51 @@ def __init__(self, config_file_path, report_path_override=None, verbosity=1, sou verbosity: Verbosity level from the command line source_path_override: Path to search if we're static code searching, if overridden on the command line """ - self.groups = {} - self.choices = {} - self.optional_groups = [] - self.annotation_tokens = [] - self.annotation_regexes = [] - self.mgr = None + self.groups: dict[str, list[str]] = {} + self.choices: dict[str, list[str]] = {} + self.optional_groups: list[str] = [] + self.annotation_tokens: list[str] = [] + self.annotation_regexes: list[str] = [] + self.mgr: named.NamedExtensionManager | None = None + self.coverage_target: float | None = None # Global logger, other objects can hold handles to this self.echo = VerboseEcho() with open(config_file_path) as config_file: - raw_config = yaml.safe_load(config_file) + raw_config: dict[str, t.Any] = yaml.safe_load(config_file) self._check_raw_config_keys(raw_config) - self.safelist_path = raw_config['safelist_path'] - self.extensions = raw_config['extensions'] + self.safelist_path: str = raw_config['safelist_path'] + self.extensions: dict[str, t.Any] = raw_config['extensions'] self.verbosity = verbosity self.echo.set_verbosity(verbosity) - self.report_path = report_path_override if report_path_override else raw_config['report_path'] + self.report_path: str = report_path_override if report_path_override else raw_config['report_path'] self.echo(f"Configured for report path: {self.report_path}") - self.source_path = source_path_override if source_path_override else raw_config['source_path'] + self.source_path: str = source_path_override if source_path_override else raw_config['source_path'] self.echo(f"Configured for source path: {self.source_path}") self._configure_coverage(raw_config.get('coverage_target', None)) - self.rendered_report_format = raw_config.get('rendered_report_format', 'rst') - self.report_template_dir = raw_config.get( + self.rendered_report_format: str = raw_config.get('rendered_report_format', 'rst') + self.report_template_dir: str = raw_config.get( 'report_template_dir', os.path.join(DEFAULT_TEMPLATE_DIR, self.rendered_report_format) ) - self.rendered_report_dir = raw_config.get('rendered_report_dir', 'annotation_reports') - self.rendered_report_source_link_prefix = raw_config.get('rendered_report_source_link_prefix', None) - self.trim_filename_prefixes = raw_config.get('trim_filename_prefixes', []) - self.third_party_package_location = raw_config.get('third_party_package_location', "site-packages") - self.rendered_report_file_extension = f".{self.rendered_report_format}" + self.rendered_report_dir: str = raw_config.get('rendered_report_dir', 'annotation_reports') + self.rendered_report_source_link_prefix: str | None = raw_config.get('rendered_report_source_link_prefix', None) + self.trim_filename_prefixes: list[str] = raw_config.get('trim_filename_prefixes', []) + self.third_party_package_location: str = raw_config.get('third_party_package_location', "site-packages") + self.rendered_report_file_extension: str = f".{self.rendered_report_format}" self._configure_annotations(raw_config) self._configure_extensions() - def _check_raw_config_keys(self, raw_config): + def _check_raw_config_keys(self, raw_config: dict[str, t.Any]) -> None: """ Validate that all required keys exist in the configuration file. @@ -86,7 +94,7 @@ def _check_raw_config_keys(self, raw_config): Raises: ConfigurationException on any missing keys """ - errors = [] + errors: list[str] = [] for k in ('report_path', 'source_path', 'safelist_path', 'annotations', 'extensions'): if k not in raw_config: errors.append(k) @@ -98,7 +106,7 @@ def _check_raw_config_keys(self, raw_config): ) ) - def _is_annotation_group(self, token_or_group): + def _is_annotation_group(self, token_or_group: t.Any) -> bool: """ Determine if an annotation is a group or not. @@ -110,7 +118,7 @@ def _is_annotation_group(self, token_or_group): """ return isinstance(token_or_group, list) - def _is_choice_group(self, token_or_group): + def _is_choice_group(self, token_or_group: t.Any) -> bool: """ Determine if an annotation is a choice group. @@ -122,7 +130,7 @@ def _is_choice_group(self, token_or_group): """ return isinstance(token_or_group, dict) and "choices" in token_or_group - def _is_optional_group(self, token_or_group): + def _is_optional_group(self, token_or_group: t.Any) -> bool: """ Determine if an annotation is an optional group. @@ -134,7 +142,7 @@ def _is_optional_group(self, token_or_group): """ return isinstance(token_or_group, dict) and bool(token_or_group.get("optional")) - def _is_annotation_token(self, token_or_group): + def _is_annotation_token(self, token_or_group: t.Any) -> bool: """ Determine if an annotation has the right format. @@ -151,20 +159,20 @@ def _is_annotation_token(self, token_or_group): return set(token_or_group.keys()).issubset({"choices", "optional"}) return False - def _add_annotation_token(self, token): + def _add_annotation_token(self, token: str) -> None: if token in self.annotation_tokens: raise ConfigurationException(f'{token} is configured more than once, tokens must be unique.') self.annotation_tokens.append(token) - def _configure_coverage(self, coverage_target): + def _configure_coverage(self, coverage_target: str | int | float | None) -> None: """ Set coverage_target to the specified value. Args: - coverage_target: - - Returns: + coverage_target: The coverage target value to configure + Raises: + ConfigurationException: When the coverage target is invalid """ if coverage_target: try: @@ -181,7 +189,7 @@ def _configure_coverage(self, coverage_target): else: self.coverage_target = None - def _configure_group(self, group_name, group): + def _configure_group(self, group_name: str, group: list[dict[str, t.Any]]) -> None: """ Perform group configuration and add annotations from the group to global configuration. @@ -190,7 +198,7 @@ def _configure_group(self, group_name, group): group: The list of annotations that comprise the group Raises: - TypeError if the group is misconfigured + ConfigurationException: If the group is misconfigured """ self.groups[group_name] = [] @@ -215,7 +223,7 @@ def _configure_group(self, group_name, group): self._add_annotation_token(annotation_token) self.annotation_regexes.append(re.escape(annotation_token)) - def _configure_choices(self, annotation_token, annotation): + def _configure_choices(self, annotation_token: str, annotation: dict[str, t.Any]) -> None: """ Configure the choices list for an annotation. @@ -225,16 +233,16 @@ def _configure_choices(self, annotation_token, annotation): """ self.choices[annotation_token] = annotation['choices'] - def _configure_annotations(self, raw_config): + def _configure_annotations(self, raw_config: dict[str, t.Any]) -> None: """ Transform the configured annotations into more usable pieces and validate. Args: raw_config: The dictionary form of our configuration file Raises: - TypeError if annotations are misconfigured + TypeError: If annotations are misconfigured """ - annotation_tokens = raw_config['annotations'] + annotation_tokens: dict[str, t.Any] = raw_config['annotations'] for annotation_token_or_group_name in annotation_tokens: annotation = annotation_tokens[annotation_token_or_group_name] @@ -259,7 +267,7 @@ def _configure_annotations(self, raw_config): self.echo.echo_v(f"Choices configured: {self.choices}") self.echo.echo_v(f"Annotation tokens configured: {self.annotation_tokens}") - def _plugin_load_failed_handler(self, *args, **kwargs): + def _plugin_load_failed_handler(self, *args: t.Any, **kwargs: t.Any) -> None: """ Handle failures to load an extension. @@ -267,22 +275,22 @@ def _plugin_load_failed_handler(self, *args, **kwargs): errors just fail silently. Args: - *args: - **kwargs: + *args: Variable positional arguments + **kwargs: Variable keyword arguments Raises: - ConfigurationException + ConfigurationException: When a plugin fails to load """ self.echo(str(args), fg='red') self.echo(str(kwargs), fg='red') raise ConfigurationException('Failed to load a plugin, aborting.') - def _configure_extensions(self): + def _configure_extensions(self) -> None: """ Configure the Stevedore NamedExtensionManager. Raises: - ConfigurationException + ConfigurationException: When extensions cannot be loaded """ # These are the names of all of our configured extensions configured_extension_names = self.extensions.keys() @@ -316,7 +324,7 @@ class BaseSearch(metaclass=ABCMeta): Base class for searchers. """ - def __init__(self, config): + def __init__(self, config: AnnotationConfig) -> None: """ Initialize for StaticSearch. @@ -326,12 +334,16 @@ def __init__(self, config): self.config = config self.echo = self.config.echo # errors contains formatted error messages - self.errors = [] - # annotation_errors contains (annotation, AnnotationErrorType, args) tuples + self.errors: list[str] = [] + # annotation_errors contains (annotation, AnnotationError, args) tuples # This attribute may be parsed by 3rd-parties, such as edx-lint. - self.annotation_errors = [] + self.annotation_errors: list[tuple[dict[str, t.Any], annotation_errors.AnnotationError, tuple[t.Any, ...]]] = [] - def format_file_results(self, all_results, results): + def format_file_results( + self, + all_results: dict[str, list[dict[str, t.Any]]], + results: list[list[dict[str, t.Any]]] + ) -> None: """ Add all extensions' search results for a file to the overall results. @@ -364,7 +376,7 @@ def format_file_results(self, all_results, results): # Stevedore extension is working on the same file type all_results[file_path].extend(annotations) - def _check_results_choices(self, annotation): + def _check_results_choices(self, annotation: dict[str, t.Any]) -> None: """ Check that a search result has appropriate choices. @@ -383,7 +395,7 @@ def _check_results_choices(self, annotation): return None token = annotation['annotation_token'] - found_valid_choices = [] + found_valid_choices: list[str] = [] # If the line begins with an annotation token that should have choices, but has no text after the token, # the first split will be empty. @@ -407,21 +419,21 @@ def _check_results_choices(self, annotation): ) return None - def _get_group_children(self): + def _get_group_children(self) -> list[str]: """ Create a list of all annotation tokens that are part of a group. Returns: List of annotation tokens that are configured to be in groups """ - group_children = [] + group_children: list[str] = [] for group in self.config.groups: group_children.extend(self.config.groups[group]) return group_children - def _get_group_for_token(self, token): + def _get_group_for_token(self, token: str) -> str | None: """ Find out which group, if any, an annotation token belongs to. @@ -436,7 +448,7 @@ def _get_group_for_token(self, token): return group return None - def check_results(self, all_results): + def check_results(self, all_results: dict[str, list[dict[str, t.Any]]]) -> bool: """ Spin through all search results, confirm that they all match configuration. @@ -456,7 +468,7 @@ def check_results(self, all_results): self.check_group(annotations) return not self.errors - def iter_groups(self, annotations): + def iter_groups(self, annotations: list[dict[str, t.Any]]) -> t.Iterator[list[dict[str, t.Any]]]: """ Iterate on groups of annotations. @@ -467,9 +479,9 @@ def iter_groups(self, annotations): Yield: annotations (annotation list) """ - current_group = [] - current_line_number = None - current_object_id = None + current_group: list[dict[str, t.Any]] = [] + current_line_number: int | None = None + current_object_id: str | None = None for annotation in annotations: line_number = annotation["line_number"] object_id = annotation.get("extra", {}).get("object_id") @@ -486,7 +498,7 @@ def iter_groups(self, annotations): if current_group: yield current_group - def check_group(self, annotations): + def check_group(self, annotations: list[dict[str, t.Any]]) -> None: """ Perform several linting checks on a group of annotations. @@ -497,9 +509,9 @@ def check_group(self, annotations): - There is no duplicate - All non-optional tokens are present """ - found_tokens = set() - group_tokens = [] - group_name = None + found_tokens: set[str] = set() + group_tokens: list[str] = [] + group_name: str | None = None for annotation in annotations: token = annotation["annotation_token"] if not group_name: @@ -543,15 +555,19 @@ def check_group(self, annotations): (token,) ) - def _add_annotation_error(self, annotation, error_type, args=None): + def _add_annotation_error( + self, + annotation: dict[str, t.Any], + error_type: annotation_errors.AnnotationError, + args: tuple[t.Any, ...] | None = None + ) -> None: """ Add an error message to self.errors, formatted nicely. Args: annotation: A single annotation dict found in search() - error_type (annotation_errors.AnnotationErrorType): error type from which the error message will be - generated. - args (tuple): arguments for error message formatting. + error_type: Error type from which the error message will be generated + args: Arguments for error message formatting """ args = args or tuple() error_message = error_type.message % args @@ -560,7 +576,7 @@ def _add_annotation_error(self, annotation, error_type, args=None): self.annotation_errors.append((annotation, error_type, args)) self._add_error(message) - def _add_error(self, message): + def _add_error(self, message: str) -> None: """ Add an error message to self.errors. @@ -570,7 +586,7 @@ def _add_error(self, message): self.errors.append(message) @abstractmethod - def search(self): + def search(self) -> dict[str, list[dict[str, t.Any]]]: """ Walk the source tree, send known file types to extensions. @@ -578,7 +594,10 @@ def search(self): Dict of {filename: annotations} for all files with found annotations. """ - def _format_results_for_report(self, all_results): + def _format_results_for_report( + self, + all_results: dict[str, list[dict[str, t.Any]]] + ) -> dict[str, list[dict[str, t.Any]]]: """ Format the given results dict for reporting purposes. @@ -588,7 +607,7 @@ def _format_results_for_report(self, all_results): Returns: Dict of results arranged for reporting """ - formatted_results = {} + formatted_results: dict[str, list[dict[str, t.Any]]] = {} current_group_id = 0 for filename in all_results: self.echo.echo_vv(f"report_format: formatting {filename}") @@ -613,7 +632,11 @@ def _format_results_for_report(self, all_results): return formatted_results - def report(self, all_results, report_prefix=''): + def report( + self, + all_results: dict[str, list[dict[str, t.Any]]], + report_prefix: str = '' + ) -> str: """ Generate the YAML report of all search results. diff --git a/code_annotations/cli.py b/code_annotations/cli.py index 9e56f02..9c3ef84 100644 --- a/code_annotations/cli.py +++ b/code_annotations/cli.py @@ -5,10 +5,12 @@ import datetime import sys import traceback +from io import TextIOWrapper import click -from code_annotations.base import AnnotationConfig, ConfigurationException +from code_annotations.base import AnnotationConfig +from code_annotations.exceptions import ConfigurationException from code_annotations.find_django import DjangoSearch from code_annotations.find_static import StaticSearch from code_annotations.generate_docs import ReportRenderer @@ -16,7 +18,7 @@ @click.group() -def entry_point(): +def entry_point() -> None: """ Top level click command for the code annotation tools. """ @@ -67,16 +69,16 @@ def entry_point(): show_default=True, ) def django_find_annotations( - config_file, - seed_safelist, - list_local_models, - app_name, - report_path, - verbosity, - lint, - report, - coverage, -): + config_file: str, + seed_safelist: bool, + list_local_models: bool, + app_name: str | None, + report_path: str | None, + verbosity: int, + lint: bool, + report: bool, + coverage: bool, +) -> None: """ Subcommand for dealing with annotations in Django models. """ @@ -135,7 +137,7 @@ def django_find_annotations( click.echo("Coverage passed without errors.") if report: - searcher.report(annotated_models, app_name) + searcher.report(annotated_models, app_name or "") annotation_count = 0 @@ -149,7 +151,7 @@ def django_find_annotations( ) ) except Exception as exc: - click.echo(traceback.print_exc()) + traceback.print_exc() fail(str(exc)) @@ -180,8 +182,13 @@ def django_find_annotations( show_default=True, ) def static_find_annotations( - config_file, source_path, report_path, verbosity, lint, report -): + config_file: str, + source_path: str, + report_path: str | None, + verbosity: int, + lint: bool, + report: bool +) -> None: """ Subcommand to find annotations via static file analysis. """ @@ -219,7 +226,7 @@ def static_find_annotations( click.echo(f"Search found {annotation_count} annotations in {elapsed}.") except Exception as exc: - click.echo(traceback.print_exc()) + traceback.print_exc() fail(str(exc)) @@ -232,14 +239,18 @@ def static_find_annotations( ) @click.option("-v", "--verbosity", count=True, help="Verbosity level (-v through -vvv)") @click.argument("report_files", type=click.File("r"), nargs=-1) -def generate_docs(config_file, verbosity, report_files): +def generate_docs( + config_file: str, + verbosity: int, + report_files: tuple[TextIOWrapper, ...] +) -> None: """ Generate documentation from a code annotations report. """ start_time = datetime.datetime.utcnow() try: - config = AnnotationConfig(config_file, verbosity) + config = AnnotationConfig(config_file, verbosity=verbosity) if not report_files: raise ConfigurationException( @@ -267,5 +278,5 @@ def generate_docs(config_file, verbosity, report_files): elapsed = datetime.datetime.utcnow() - start_time click.echo(f"Report rendered in {elapsed.total_seconds()} seconds.") except Exception as exc: - click.echo(traceback.print_exc()) + traceback.print_exc() fail(str(exc)) diff --git a/code_annotations/contrib/config/feature_toggle_annotations.yaml b/code_annotations/contrib/config/feature_toggle_annotations.yaml index c30aeb8..7097dba 100644 --- a/code_annotations/contrib/config/feature_toggle_annotations.yaml +++ b/code_annotations/contrib/config/feature_toggle_annotations.yaml @@ -18,7 +18,7 @@ annotations: - ".. toggle_removal_ticket:": optional: true - ".. toggle_target_removal_date:": - optional: true + optional: true - ".. toggle_warning:": optional: true - ".. toggle_tickets:": diff --git a/code_annotations/contrib/sphinx/extensions/base.py b/code_annotations/contrib/sphinx/extensions/base.py index 898bba7..6736089 100644 --- a/code_annotations/contrib/sphinx/extensions/base.py +++ b/code_annotations/contrib/sphinx/extensions/base.py @@ -2,27 +2,37 @@ Base utilities for building annotation-based Sphinx extensions. """ +import typing as t from code_annotations.base import AnnotationConfig from code_annotations.find_static import StaticSearch -def find_annotations(source_path, config_path, group_by_key): +def find_annotations( + source_path: str, + config_path: str | t.Any, + group_by_key: str +) -> dict[str, dict[str, t.Any]]: """ Find the feature toggles as defined in the configuration file. - Return: - toggles (dict): feature toggles indexed by name. + Args: + source_path: Path to the source code to search + config_path: Path to the configuration file or a Traversable object + group_by_key: Key in annotations to group by + + Returns: + Toggles indexed by name """ config = AnnotationConfig( - config_path, verbosity=-1, source_path_override=source_path + str(config_path), verbosity=-1, source_path_override=source_path ) search = StaticSearch(config) all_results = search.search() - toggles = {} + toggles: dict[str, dict[str, t.Any]] = {} for filename in all_results: for annotations in search.iter_groups(all_results[filename]): - current_entry = {} + current_entry: dict[str, t.Any] = {} for annotation in annotations: key = annotation["annotation_token"] value = annotation["annotation_data"] @@ -37,9 +47,15 @@ def find_annotations(source_path, config_path, group_by_key): return toggles -def quote_value(value): +def quote_value(value: t.Any) -> str: """ Quote a Python object if it is string-like. + + Args: + value: Value to potentially quote + + Returns: + Quoted string if the value is string-like, otherwise str representation """ if value in ("True", "False", "None"): return str(value) diff --git a/code_annotations/contrib/sphinx/extensions/featuretoggles.py b/code_annotations/contrib/sphinx/extensions/featuretoggles.py index 9ce68f9..fee3411 100644 --- a/code_annotations/contrib/sphinx/extensions/featuretoggles.py +++ b/code_annotations/contrib/sphinx/extensions/featuretoggles.py @@ -2,6 +2,7 @@ Sphinx extension for viewing feature toggle annotations. """ import os +import typing as t from docutils import nodes from sphinx.util.docutils import SphinxDirective @@ -11,12 +12,15 @@ from .base import find_annotations, quote_value -def find_feature_toggles(source_path): +def find_feature_toggles(source_path: str) -> dict[str, dict[str, t.Any]]: """ Find the feature toggles as defined in the configuration file. - Return: - toggles (dict): feature toggles indexed by name. + Args: + source_path: Path to the source code to search + + Returns: + Feature toggles indexed by name. """ return find_annotations( source_path, FEATURE_TOGGLE_ANNOTATIONS_CONFIG_PATH, ".. toggle_name:" @@ -55,18 +59,21 @@ class FeatureToggles(SphinxDirective): optional_arguments = 0 option_spec = {} - def run(self): + def run(self) -> list[nodes.section]: """ Public interface of the Directive class. - Return: - nodes (list): nodes to be appended to the resulting document. + Returns: + List of nodes to be appended to the resulting document. """ return list(self.iter_nodes()) - def iter_nodes(self): + def iter_nodes(self) -> t.Iterator[nodes.section]: """ Iterate on the docutils nodes generated by this directive. + + Yields: + Node for each feature toggle """ toggles = find_feature_toggles(self.env.config.featuretoggles_source_path) for toggle_name in sorted(toggles): @@ -117,9 +124,15 @@ def iter_nodes(self): yield toggle_section -def setup(app): +def setup(app: t.Any) -> dict[str, str | bool]: """ Declare the Sphinx extension. + + Args: + app: The Sphinx application instance + + Returns: + Extension metadata """ app.add_config_value( "featuretoggles_source_path", diff --git a/code_annotations/contrib/sphinx/extensions/openedx_events.py b/code_annotations/contrib/sphinx/extensions/openedx_events.py index 5f1a770..216bbb3 100644 --- a/code_annotations/contrib/sphinx/extensions/openedx_events.py +++ b/code_annotations/contrib/sphinx/extensions/openedx_events.py @@ -2,6 +2,7 @@ Sphinx extension for viewing openedx events annotations. """ import os +import typing as t from docutils import nodes from sphinx.util.docutils import SphinxDirective @@ -11,12 +12,15 @@ from .base import find_annotations -def find_events(source_path): +def find_events(source_path: str) -> dict[str, dict[str, t.Any]]: """ Find the events as defined in the configuration file. - Return: - events (dict): found events indexed by event type. + Args: + source_path: Path to the source code to search + + Returns: + Found events indexed by event type. """ return find_annotations( source_path, OPENEDX_EVENTS_ANNOTATIONS_CONFIG_PATH, ".. event_type:" @@ -53,27 +57,29 @@ class OpenedxEvents(SphinxDirective): required_arguments = 0 optional_arguments = 0 - option_spec = {} - def run(self): + def run(self) -> list[nodes.Node]: """ Public interface of the Directive class. - Return: - nodes (list): nodes to be appended to the resulting document. + Returns: + List of nodes to be appended to the resulting document. """ return list(self.iter_nodes()) - def iter_nodes(self): + def iter_nodes(self) -> t.Iterator[nodes.section]: """ Iterate on the docutils nodes generated by this directive. + + Yields: + Node for each domain """ events = find_events(self.env.config.openedxevents_source_path) current_domain = "" - domain_header = None + domain_header: nodes.section | None = None current_subject = "" - subject_header = None + subject_header: nodes.section | None = None for event_type in sorted(events): domain = event_type.split(".")[2] @@ -90,6 +96,7 @@ def iter_nodes(self): subject_header = nodes.section("", ids=[f"openedxevent-subject" f"-{subject}"]) subject_header += nodes.title(text=f"Subject: {subject}") + assert domain_header is not None domain_header += subject_header event = events[event_type] @@ -156,15 +163,22 @@ def iter_nodes(self): "", nodes.paragraph("", event[".. event_warning:"]), ids=[f"warning-{event_name}"] ) + assert subject_header is not None subject_header += event_section if domain_header: yield domain_header -def setup(app): +def setup(app: t.Any) -> dict[str, str | bool]: """ Declare the Sphinx extension. + + Args: + app: The Sphinx application instance + + Returns: + Extension metadata """ app.add_config_value( "openedxevents_source_path", diff --git a/code_annotations/contrib/sphinx/extensions/settings.py b/code_annotations/contrib/sphinx/extensions/settings.py index 299ef99..d628d66 100644 --- a/code_annotations/contrib/sphinx/extensions/settings.py +++ b/code_annotations/contrib/sphinx/extensions/settings.py @@ -2,6 +2,7 @@ Sphinx extension for viewing (non-toggle) setting annotations. """ import os +import typing as t from docutils import nodes from docutils.parsers.rst import directives @@ -12,12 +13,15 @@ from .base import find_annotations, quote_value -def find_settings(source_path): +def find_settings(source_path: str) -> dict[str, dict[str, t.Any]]: """ Find the Django settings as defined in the configuration file. - Return: - settings (dict): Django settings indexed by name. + Args: + source_path: Path to the source code to search + + Returns: + Django settings indexed by name. """ return find_annotations( source_path, SETTING_ANNOTATIONS_CONFIG_PATH, ".. setting_name:" @@ -57,18 +61,21 @@ class Settings(SphinxDirective): optional_arguments = 1 option_spec = {"folder_path": directives.unchanged} - def run(self): + def run(self) -> list[nodes.section]: """ Public interface of the Directive class. - Return: - nodes (list): nodes to be appended to the resulting document. + Returns: + List of nodes to be appended to the resulting document. """ return list(self.iter_nodes()) - def iter_nodes(self): + def iter_nodes(self) -> t.Iterator[nodes.section]: """ Iterate on the docutils nodes generated by this directive. + + Yields: + Node for each setting """ folder_path = self.options.get("folder_path", "") source_path = os.path.join(self.env.config.settings_source_path, folder_path) @@ -119,9 +126,15 @@ def iter_nodes(self): yield setting_section -def setup(app): +def setup(app: t.Any) -> dict[str, str | bool]: """ Declare the Sphinx extension. + + Args: + app: The Sphinx application instance + + Returns: + Extension metadata """ app.add_config_value( "settings_source_path", diff --git a/code_annotations/extensions/base.py b/code_annotations/extensions/base.py index 0f08090..2d627d1 100644 --- a/code_annotations/extensions/base.py +++ b/code_annotations/extensions/base.py @@ -2,9 +2,12 @@ Abstract and base classes to support plugins. """ import re +import typing as t from abc import ABCMeta, abstractmethod +from io import TextIOWrapper -from code_annotations.helpers import clean_abs_path, clean_annotation, get_annotation_regex +from code_annotations.base import AnnotationConfig +from code_annotations.helpers import VerboseEcho, clean_abs_path, clean_annotation, get_annotation_regex class AnnotationExtension(metaclass=ABCMeta): @@ -12,9 +15,9 @@ class AnnotationExtension(metaclass=ABCMeta): Abstract base class that annotation extensions will inherit from. """ - extension_name = None + extension_name: str | None = None - def __init__(self, config, echo): + def __init__(self, config: AnnotationConfig, echo: VerboseEcho) -> None: """ Initialize this base object, save a handle to configuration. @@ -26,9 +29,15 @@ def __init__(self, config, echo): self.ECHO = echo @abstractmethod - def search(self, file_handle): # pragma: no cover + def search(self, file_handle: TextIOWrapper) -> list[dict[str, t.Any]]: # pragma: no cover """ Search for annotations in the given file. + + Args: + file_handle: The file to search for annotations + + Returns: + List of dictionaries containing annotation information """ raise NotImplementedError('search called on base class!') @@ -40,7 +49,7 @@ class SimpleRegexAnnotationExtension(AnnotationExtension, metaclass=ABCMeta): # These are the language-specific comment definitions that are defined in the child classes. See the # Javascript and Python extensions for examples. - lang_comment_definition = None + lang_comment_definition: dict[str, str] | None = None # This format string/regex finds all comments in the file. The format tokens will be replaced with the # language-specific comment definitions defined in the sub-classes. @@ -64,7 +73,7 @@ class SimpleRegexAnnotationExtension(AnnotationExtension, metaclass=ABCMeta): ) """ - def __init__(self, config, echo): + def __init__(self, config: AnnotationConfig, echo: VerboseEcho) -> None: """ Set up the extension and create the regexes used to do searches. @@ -77,6 +86,8 @@ def __init__(self, config, echo): if self.lang_comment_definition is None: # pragma: no cover raise ValueError('Subclasses of SimpleRegexAnnotationExtension must define lang_comment_definition!') + assert self.lang_comment_definition is not None # For mypy + self.comment_regex = re.compile( self.comment_regex_fmt.format(**self.lang_comment_definition), flags=re.VERBOSE @@ -93,7 +104,7 @@ def __init__(self, config, echo): self.ECHO.echo_v(f"{self.extension_name} extension regex query: {self.query.pattern}") - def search(self, file_handle): + def search(self, file_handle: TextIOWrapper) -> list[dict[str, t.Any]]: """ Search for annotations in the given file. @@ -105,7 +116,7 @@ def search(self, file_handle): """ txt = file_handle.read() - found_annotations = [] + found_annotations: list[dict[str, t.Any]] = [] # Fast out if no annotations exist in the file if any(anno in txt for anno in self.config.annotation_tokens): @@ -141,12 +152,15 @@ def search(self, file_handle): return found_annotations - def _find_comment_content(self, match): + def _find_comment_content(self, match: re.Match[str]) -> str: """ Return the comment content as text. Args: - match (sre.SRE_MATCH): one of the matches of the self.comment_regex regular expression. + match: One of the matches of the self.comment_regex regular expression. + + Returns: + The content of the comment without the comment tokens """ comment_content = match.groupdict()["comment"] if comment_content: @@ -156,11 +170,14 @@ def _find_comment_content(self, match): comment_content = match.groupdict()["prefixed_comment"] return self._strip_single_line_comment_tokens(comment_content) - def _strip_single_line_comment_tokens(self, content): + def _strip_single_line_comment_tokens(self, content: str) -> str: """ Strip the leading single-line comment tokens from a comment text. Args: - content (str): token-prefixed multi-line comment string. + content: Token-prefixed multi-line comment string. + + Returns: + The content without the comment tokens """ return self.prefixed_comment_regex.sub("", content) diff --git a/code_annotations/extensions/javascript.py b/code_annotations/extensions/javascript.py index d65c1d6..3fefc90 100644 --- a/code_annotations/extensions/javascript.py +++ b/code_annotations/extensions/javascript.py @@ -11,9 +11,9 @@ class JavascriptAnnotationExtension(SimpleRegexAnnotationExtension): Annotation extension for Javascript source files. """ - extension_name = 'javascript' + extension_name: str = 'javascript' - lang_comment_definition = { + lang_comment_definition: dict[str, str] = { 'multi_start': re.escape('/*'), 'multi_end': re.escape('*/'), 'single': re.escape('//') diff --git a/code_annotations/extensions/python.py b/code_annotations/extensions/python.py index 2642d63..1334609 100644 --- a/code_annotations/extensions/python.py +++ b/code_annotations/extensions/python.py @@ -11,9 +11,9 @@ class PythonAnnotationExtension(SimpleRegexAnnotationExtension): Annotation extension for Python source files. """ - extension_name = 'python' + extension_name: str = 'python' - lang_comment_definition = { + lang_comment_definition: dict[str, str] = { 'multi_start': re.escape('"""'), 'multi_end': re.escape('"""'), 'single': re.escape('#') diff --git a/code_annotations/find_django.py b/code_annotations/find_django.py index 0e1dc7f..26135b0 100644 --- a/code_annotations/find_django.py +++ b/code_annotations/find_django.py @@ -4,14 +4,16 @@ import inspect import os +import re import sys +import typing as t import django import yaml from django.apps import apps from django.db import models -from code_annotations.base import BaseSearch +from code_annotations.base import AnnotationConfig, BaseSearch from code_annotations.helpers import clean_annotation, fail, get_annotation_regex DEFAULT_SAFELIST_FILE_PATH = ".annotation_safe_list.yml" @@ -22,15 +24,17 @@ class DjangoSearch(BaseSearch): Handles Django model comment searching for annotations. """ - def __init__(self, config, app_name=None): + def __init__(self, config: AnnotationConfig, app_name: str | None = None) -> None: """ Initialize for DjangoSearch. """ super().__init__(config) + self.local_models: set[type[models.Model]] + self.non_local_models: set[type[models.Model]] self.local_models, self.non_local_models, total, annotation_eligible = ( self.get_models_requiring_annotations(app_name) ) - self.model_counts = { + self.model_counts: dict[str, int] = { "total": total, "annotated": 0, "unannotated": 0, @@ -38,7 +42,7 @@ def __init__(self, config, app_name=None): "not_annotation_eligible": total - len(annotation_eligible), "safelisted": 0, } - self.uncovered_model_ids = set() + self.uncovered_model_ids: set[str] = set() self.echo.echo_vvv( "Local models:\n " + "\n ".join([str(m) for m in self.local_models]) @@ -55,10 +59,10 @@ def __init__(self, config, app_name=None): + "\n" ) - def _increment_count(self, count_type, incr_by=1): + def _increment_count(self, count_type: str, incr_by: int = 1) -> None: self.model_counts[count_type] += incr_by - def seed_safelist(self): + def seed_safelist(self) -> None: """ Seed a new safelist file with all non-local models that need to be vetted. """ @@ -71,7 +75,7 @@ def seed_safelist(self): ) ) - safelist_data = { + safelist_data: dict[str, dict[str, str]] = { self.get_model_id(model): {} for model in self.non_local_models } @@ -103,7 +107,7 @@ def seed_safelist(self): ) self.echo(" 2) Annotate any LOCAL models (see --list_local_models).", fg="red") - def list_local_models(self): + def list_local_models(self) -> None: """ Dump a list of models in the local code tree that are annotation eligible to stdout. """ @@ -120,7 +124,13 @@ def list_local_models(self): else: self.echo("No local models requiring annotations.") - def _append_model_annotations(self, model_type, model_id, query, model_annotations): + def _append_model_annotations( + self, + model_type: type[models.Model], + model_id: str, + query: re.Pattern[str], + model_annotations: list[dict[str, t.Any]] + ) -> None: """ Find the given model's annotations in the file and add them to model_annotations. @@ -132,6 +142,8 @@ def _append_model_annotations(self, model_type, model_id, query, model_annotatio """ # Read in the source file to get the line number filename = inspect.getsourcefile(model_type) + assert filename + with open(filename) as file_handle: txt = file_handle.read() @@ -142,7 +154,8 @@ def _append_model_annotations(self, model_type, model_id, query, model_annotatio # It is slow and should be replaced if we can find a better way that is accurate. line = txt.count("\n", 0, txt.find(inspect.getsource(model_type))) + 1 - for inner_match in query.finditer(model_type.__doc__): + model_type_doc = model_type.__doc__ or "" + for inner_match in query.finditer(model_type_doc): try: annotation_token = inner_match.group("token") annotation_data = inner_match.group("data") @@ -164,14 +177,17 @@ def _append_model_annotations(self, model_type, model_id, query, model_annotatio "annotation_data": annotation_data, "extra": { "object_id": model_id, - "full_comment": model_type.__doc__.strip(), + "full_comment": model_type_doc.strip(), }, } ) def _append_safelisted_model_annotations( - self, safelisted_models, model_id, model_annotations - ): + self, + safelisted_models: dict[str, dict[str, str]], + model_id: str, + model_annotations: list[dict[str, t.Any]] + ) -> None: """ Append the safelisted annotations for the given model id to model_annotations. @@ -196,17 +212,20 @@ def _append_safelisted_model_annotations( } ) - def _read_safelist(self): + def _read_safelist(self) -> dict[str, dict[str, str]]: """ Read the safelist and return the found models and their annotations. Returns: The Python representation of the safelist + + Raises: + Exception: If the safelist file is not found """ if os.path.exists(self.config.safelist_path): self.echo(f"Found safelist at {self.config.safelist_path}. Reading.\n") with open(self.config.safelist_path) as safelist_file: - safelisted_models = yaml.safe_load(safelist_file) + safelisted_models: dict[str, dict[str, str]] = yaml.safe_load(safelist_file) or {} self._increment_count("safelisted", len(safelisted_models)) if safelisted_models: @@ -222,7 +241,7 @@ def _read_safelist(self): "Safelist not found! Generate one with the --seed_safelist command." ) - def search(self): + def search(self) -> dict[str, list[dict[str, t.Any]]]: """ Introspect the configured Django model docstrings for annotations. @@ -234,7 +253,7 @@ def search(self): annotation_regexes = self.config.annotation_regexes query = get_annotation_regex(annotation_regexes) - annotated_models = {} + annotated_models: dict[str, list[dict[str, t.Any]]] = {} self.echo.echo_vv("Searching models and their parent classes...") @@ -243,7 +262,7 @@ def search(self): model_id = self.get_model_id(model) self.echo.echo_vv(" " + model_id) hierarchy = inspect.getmro(model) - model_annotations = [] + model_annotations: list[dict[str, t.Any]] = [] # If any annotations exist in the docstring add them to annotated_models for obj in hierarchy: @@ -299,7 +318,7 @@ def search(self): return annotated_models - def check_coverage(self): + def check_coverage(self) -> bool: """ Perform checking of coverage percentage based on stats collected in setup and search. @@ -339,7 +358,7 @@ def check_coverage(self): + "\n ".join(displayed_uncovereds) ) - if pct < float(self.config.coverage_target): + if self.config.coverage_target is not None and pct < float(self.config.coverage_target): self.echo( "\nCoverage threshold not met! Needed {}, actually {}!".format( self.config.coverage_target, pct @@ -350,7 +369,7 @@ def check_coverage(self): return True @staticmethod - def requires_annotations(model): + def requires_annotations(model: type[t.Any]) -> bool: """ Return true if the given model actually requires annotations, according to PLAT-2344. """ @@ -368,7 +387,7 @@ def requires_annotations(model): ) @staticmethod - def is_non_local(model): + def is_non_local(model: type[models.Model]) -> bool: """ Determine if the given model is non-local to the current IDA. @@ -379,38 +398,39 @@ def is_non_local(model): project. Args: - model (django.db.models.Model): A model to check. + model: A model to check. Returns: - bool: True if the given model is non-local. + True if the given model is non-local. """ # If the model _was_ local to the current IDA repository, it should be # defined somewhere under sys.prefix + '/src/' or in a path that points to # the current checked-out code. On Posix systems according to our testing, # non-local packages get installed to paths containing either # "site-packages" or "dist-packages". - non_local_path_prefixes = [] + non_local_path_prefixes: list[str] = [] for path in sys.path: if "dist-packages" in path or "site-packages" in path: non_local_path_prefixes.append(path) model_source_path = inspect.getsourcefile(model) + assert model_source_path is not None return model_source_path.startswith(tuple(non_local_path_prefixes)) @staticmethod - def get_model_id(model): + def get_model_id(model: type[models.Model]) -> str: """ Construct the django standard model identifier in "app_label.ModelClassName" notation. Args: - model (django.db.models.Model): A model for which to create an identifier. + model: A model for which to create an identifier. Returns: - str: identifier string for the given model. + Identifier string for the given model. """ return f"{model._meta.app_label}.{model._meta.object_name}" @staticmethod - def setup_django(): + def setup_django() -> None: """ Prepare to make django library function calls. @@ -426,7 +446,14 @@ def setup_django(): django.setup() @staticmethod - def get_models_requiring_annotations(app_name=None): + def get_models_requiring_annotations( + app_name: str | None = None + ) -> tuple[ + set[type[models.Model]], + set[type[models.Model]], + int, + list[str] + ]: """ Determine all local and non-local models via django model introspection. @@ -434,11 +461,17 @@ def get_models_requiring_annotations(app_name=None): edX). This is a compromise in accuracy in order to simplify the generation of this list, and also to ease the transition from zero to 100% annotations in edX satellite repositories. + + Args: + app_name: Optional app name to filter models by + + Returns: + Tuple of local models, non-local models, total model count, and annotation eligible model IDs """ DjangoSearch.setup_django() - local_models = set() - non_local_models = set() - annotation_eligible_models = [] + local_models: set[type[models.Model]] = set() + non_local_models: set[type[models.Model]] = set() + annotation_eligible_models: list[str] = [] total_models = 0 for app in apps.get_app_configs(): diff --git a/code_annotations/find_static.py b/code_annotations/find_static.py index 2c554db..70daa70 100644 --- a/code_annotations/find_static.py +++ b/code_annotations/find_static.py @@ -3,6 +3,10 @@ """ import os +import typing as t +from io import TextIOWrapper + +from stevedore.extension import Extension from code_annotations.base import BaseSearch @@ -12,7 +16,13 @@ class StaticSearch(BaseSearch): Handles static code searching for annotations. """ - def search_extension(self, ext, file_handle, file_extensions_map, filename_extension): + def search_extension( + self, + ext: Extension, + file_handle: TextIOWrapper, + file_extensions_map: dict[str, list[str]], + filename_extension: str + ) -> tuple[str, list[dict[str, t.Any]] | None]: """ Execute a search on the given file using the given extension. @@ -38,7 +48,13 @@ def search_extension(self, ext, file_handle, file_extensions_map, filename_exten return ext.name, None - def _search_one_file(self, full_name, known_extensions, file_extensions_map, all_results): + def _search_one_file( + self, + full_name: str, + known_extensions: set[str], + file_extensions_map: dict[str, list[str]], + all_results: dict[str, list[dict[str, t.Any]]] + ) -> None: """ Perform an annotation search on a single file, using all extensions it is configured for. @@ -61,7 +77,13 @@ def _search_one_file(self, full_name, known_extensions, file_extensions_map, all # TODO: This should probably be a generator so we don't have to store all results in memory with open(full_name) as file_handle: # Call search_extension on all loaded extensions - results = self.config.mgr.map(self.search_extension, file_handle, file_extensions_map, filename_extension) + assert self.config.mgr is not None + results = self.config.mgr.map( + self.search_extension, + file_handle, + file_extensions_map, + filename_extension + ) # Strip out plugin name, as it's already in the annotation results = [r for _, r in results] @@ -69,7 +91,7 @@ def _search_one_file(self, full_name, known_extensions, file_extensions_map, all # Format and add the results to our running full set self.format_file_results(all_results, results) - def search(self): + def search(self) -> dict[str, list[dict[str, t.Any]]]: """ Walk the source tree, send known file types to extensions. @@ -77,13 +99,13 @@ def search(self): Dict of all found annotations keyed by filename """ # Index the results by extension name - file_extensions_map = {} - known_extensions = set() + file_extensions_map: dict[str, list[str]] = {} + known_extensions: set[str] = set() for extension_name in self.config.extensions: file_extensions_map[extension_name] = self.config.extensions[extension_name] known_extensions.update(self.config.extensions[extension_name]) - all_results = {} + all_results: dict[str, list[dict[str, t.Any]]] = {} if os.path.isfile(self.config.source_path): self._search_one_file(self.config.source_path, known_extensions, file_extensions_map, all_results) diff --git a/code_annotations/generate_docs.py b/code_annotations/generate_docs.py index e32dc37..f54febf 100644 --- a/code_annotations/generate_docs.py +++ b/code_annotations/generate_docs.py @@ -5,18 +5,23 @@ import collections import datetime import os +import typing as t +from collections.abc import Iterable +from io import TextIOWrapper import jinja2 import yaml from slugify import slugify +from code_annotations.base import AnnotationConfig + class ReportRenderer: """ Generates human readable documentation from YAML reports. """ - def __init__(self, config, report_files): + def __init__(self, config: AnnotationConfig, report_files: Iterable[TextIOWrapper]) -> None: """ Initialize a ReportRenderer. @@ -28,7 +33,7 @@ def __init__(self, config, report_files): self.echo = self.config.echo self.report_files = report_files self.create_time = datetime.datetime.now(tz=datetime.timezone.utc) - self.full_report = self._aggregate_reports() + self.full_report: dict[str, list[dict[str, t.Any]]] = self._aggregate_reports() self.jinja_environment = jinja2.Environment( autoescape=False, @@ -37,8 +42,8 @@ def __init__(self, config, report_files): trim_blocks=True ) self.top_level_template = self.jinja_environment.get_template('annotation_list.tpl') - self.all_choices = [] - self.group_mapping = {} + self.all_choices: list[str] = [] + self.group_mapping: dict[str, str] = {} for token in self.config.choices: self.all_choices.extend(self.config.choices[token]) @@ -47,19 +52,20 @@ def __init__(self, config, report_files): for token in self.config.groups[group_name]: self.group_mapping[token] = group_name - def _add_report_file_to_full_report(self, report_file, report): + def _add_report_file_to_full_report( + self, + report_file: TextIOWrapper, + report: dict[str, list[dict[str, t.Any]]] + ) -> None: """ Add a specified report file to a report. Args: - report_file: - report: - - Returns: - + report_file: File handle to the YAML report file + report: Report dictionary to add the file contents to """ loaded_report = yaml.safe_load(report_file) - + assert loaded_report is not None for filename in loaded_report: trimmed_filename = filename for prefix in self.config.trim_filename_prefixes: @@ -83,11 +89,14 @@ def _add_report_file_to_full_report(self, report_file, report): else: report[trimmed_filename] = loaded_report[filename] - def _aggregate_reports(self): + def _aggregate_reports(self) -> dict[str, list[dict[str, t.Any]]]: """ Combine all of the given report files into a single report object. + + Returns: + A combined report dictionary """ - report = collections.defaultdict(list) + report: dict[str, list[dict[str, t.Any]]] = collections.defaultdict(list) # Combine report files into a single dict. If there are duplicate annotations, make sure we have the superset # of data. @@ -96,7 +105,12 @@ def _aggregate_reports(self): return report - def _write_doc_file(self, doc_title, doc_filename, doc_data): + def _write_doc_file( + self, + doc_title: str, + doc_filename: str, + doc_data: dict[str, list[dict[str, t.Any]]] + ) -> None: """ Write out a single report file with the given data. This is rendered using the configured top level template. @@ -127,12 +141,12 @@ def _write_doc_file(self, doc_title, doc_filename, doc_data): third_party_package_location=self.config.third_party_package_location, )) - def _generate_per_choice_docs(self): + def _generate_per_choice_docs(self) -> None: """ Generate a page of documentation for each configured annotation choice. """ for choice in self.all_choices: - choice_report = collections.defaultdict(list) + choice_report: dict[str, list[dict[str, t.Any]]] = collections.defaultdict(list) for filename in self.full_report: for annotation in self.full_report[filename]: if isinstance(annotation['annotation_data'], list) and choice in annotation['annotation_data']: @@ -140,12 +154,12 @@ def _generate_per_choice_docs(self): self._write_doc_file(f"All References to Choice '{choice}'", f'choice_{choice}', choice_report) - def _generate_per_annotation_docs(self): + def _generate_per_annotation_docs(self) -> None: """ Generate a page of documentation for each configured annotation. """ for annotation in self.config.annotation_tokens: - annotation_report = collections.defaultdict(list) + annotation_report: dict[str, list[dict[str, t.Any]]] = collections.defaultdict(list) for filename in self.full_report: for report_annotation in self.full_report[filename]: if report_annotation['annotation_token'] == annotation: @@ -155,7 +169,7 @@ def _generate_per_annotation_docs(self): f"All References to Annotation '{annotation}'", f'annotation_{annotation}', annotation_report ) - def render(self): + def render(self) -> None: """ Perform the rendering of all documentation using the configured Jinja2 templates. """ diff --git a/code_annotations/helpers.py b/code_annotations/helpers.py index 26b0d7e..98713cb 100644 --- a/code_annotations/helpers.py +++ b/code_annotations/helpers.py @@ -4,13 +4,14 @@ import os import re import sys +import typing as t from io import StringIO from pprint import pprint import click -def fail(msg): +def fail(msg: str) -> None: """ Log the message and exit. @@ -26,9 +27,9 @@ class VerboseEcho: Helper to handle verbosity-dependent logging. """ - verbosity = 1 + verbosity: int = 1 - def __call__(self, output, **kwargs): + def __call__(self, output: str, **kwargs: t.Any) -> None: """ Echo the given output regardless of verbosity level. @@ -40,18 +41,17 @@ def __call__(self, output, **kwargs): """ self.echo(output, **kwargs) - def set_verbosity(self, verbosity): + def set_verbosity(self, verbosity: int) -> None: """ Override the default verbosity level. Args: verbosity: The verbosity level to set to - kwargs: Any additional keyword args to pass to click.echo """ self.verbosity = verbosity self.echo_v(f"Verbosity level set to {verbosity}") - def echo(self, output, verbosity_level=0, **kwargs): + def echo(self, output: str, verbosity_level: int = 0, **kwargs: t.Any) -> None: """ Echo the given output, if over the verbosity threshold. @@ -63,7 +63,7 @@ def echo(self, output, verbosity_level=0, **kwargs): if verbosity_level <= self.verbosity: click.secho(output, **kwargs) - def echo_v(self, output, **kwargs): + def echo_v(self, output: str, **kwargs: t.Any) -> None: """ Echo the given output if verbosity level is >= 1. @@ -73,7 +73,7 @@ def echo_v(self, output, **kwargs): """ self.echo(output, 1, **kwargs) - def echo_vv(self, output, **kwargs): + def echo_vv(self, output: str, **kwargs: t.Any) -> None: """ Echo the given output if verbosity level is >= 2. @@ -83,7 +83,7 @@ def echo_vv(self, output, **kwargs): """ self.echo(output, 2, **kwargs) - def echo_vvv(self, output, **kwargs): + def echo_vvv(self, output: str, **kwargs: t.Any) -> None: """ Echo the given output if verbosity level is >= 3. @@ -93,7 +93,7 @@ def echo_vvv(self, output, **kwargs): """ self.echo(output, 3, **kwargs) - def pprint(self, data, indent=4, verbosity_level=0): + def pprint(self, data: t.Any, indent: int = 4, verbosity_level: int = 0) -> None: """ Pretty-print some data with the given verbosity level. """ @@ -103,7 +103,7 @@ def pprint(self, data, indent=4, verbosity_level=0): self.echo(formatted.read(), verbosity_level=verbosity_level) -def clean_abs_path(filename_to_clean, parent_path): +def clean_abs_path(filename_to_clean: str, parent_path: str) -> str: """ Safely strips the parent path from the given filename, leaving only the relative path. @@ -121,7 +121,7 @@ def clean_abs_path(filename_to_clean, parent_path): return os.path.relpath(filename_to_clean, parent_path) -def get_annotation_regex(annotation_regexes): +def get_annotation_regex(annotation_regexes: list[str]) -> re.Pattern[str]: """ Return the full regex to search inside comments for configured annotations. @@ -166,7 +166,7 @@ def get_annotation_regex(annotation_regexes): return re.compile(annotation_regex, flags=re.VERBOSE) -def clean_annotation(token, data): +def clean_annotation(token: str, data: str) -> tuple[str, str]: """ Clean annotation token and data by stripping all trailing/prefix empty spaces. diff --git a/code_annotations/py.typed b/code_annotations/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/docs/testing.rst b/docs/testing.rst index e7db29c..70f3105 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -15,7 +15,7 @@ To run just the unit tests: .. code-block:: bash - $ make test + $ make test-unit To run just the unit tests and check diff coverage @@ -27,7 +27,17 @@ To run just the code quality checks: .. code-block:: bash - $ make quality + $ make test-quality + +Alternatively, run quality tests one by one: + +.. code-block:: bash + + $ make test-lint + $ make test-types + $ make test-codestyle + $ make test-docstyle + $ make test-isort To run the unit tests under every supported Python version and the code quality checks: @@ -42,3 +52,5 @@ test cases: .. code-block:: bash $ make coverage + + diff --git a/requirements/dev.txt b/requirements/dev.txt index 40f19e7..594e038 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -4,6 +4,10 @@ # # make upgrade # +alabaster==1.0.0 + # via + # -r requirements/quality.txt + # sphinx asgiref==3.8.1 # via # -r requirements/quality.txt @@ -13,6 +17,10 @@ astroid==3.3.10 # -r requirements/quality.txt # pylint # pylint-celery +babel==2.17.0 + # via + # -r requirements/quality.txt + # sphinx build==1.2.2.post1 # via # -r requirements/pip-tools.txt @@ -21,11 +29,19 @@ cachetools==6.0.0 # via # -r requirements/ci.txt # tox +certifi==2025.4.26 + # via + # -r requirements/quality.txt + # requests chardet==5.2.0 # via # -r requirements/ci.txt # diff-cover # tox +charset-normalizer==3.4.2 + # via + # -r requirements/quality.txt + # requests click==8.2.1 # via # -r requirements/pip-tools.txt @@ -64,6 +80,10 @@ django==4.2.21 # via # -c requirements/common_constraints.txt # -r requirements/quality.txt +docutils==0.21.2 + # via + # -r requirements/quality.txt + # sphinx edx-lint==5.6.0 # via -r requirements/quality.txt filelock==3.18.0 @@ -71,6 +91,14 @@ filelock==3.18.0 # -r requirements/ci.txt # tox # virtualenv +idna==3.10 + # via + # -r requirements/quality.txt + # requests +imagesize==1.4.1 + # via + # -r requirements/quality.txt + # sphinx iniconfig==2.1.0 # via # -r requirements/quality.txt @@ -84,6 +112,7 @@ jinja2==3.1.6 # -r requirements/quality.txt # code-annotations # diff-cover + # sphinx markupsafe==3.0.2 # via # -r requirements/quality.txt @@ -94,6 +123,12 @@ mccabe==0.7.0 # pylint mock==5.2.0 # via -r requirements/quality.txt +mypy==1.15.0 + # via -r requirements/quality.txt +mypy-extensions==1.1.0 + # via + # -r requirements/quality.txt + # mypy packaging==25.0 # via # -r requirements/ci.txt @@ -102,6 +137,7 @@ packaging==25.0 # build # pyproject-api # pytest + # sphinx # tox pbr==6.1.1 # via @@ -128,7 +164,10 @@ pycodestyle==2.13.0 pydocstyle==6.3.0 # via -r requirements/quality.txt pygments==2.19.1 - # via diff-cover + # via + # -r requirements/quality.txt + # diff-cover + # sphinx pylint==3.3.7 # via # -r requirements/quality.txt @@ -172,6 +211,14 @@ pyyaml==6.0.2 # via # -r requirements/quality.txt # code-annotations +requests==2.32.3 + # via + # -r requirements/quality.txt + # sphinx +roman-numerals-py==3.1.0 + # via + # -r requirements/quality.txt + # sphinx six==1.17.0 # via # -r requirements/quality.txt @@ -180,6 +227,33 @@ snowballstemmer==3.0.1 # via # -r requirements/quality.txt # pydocstyle + # sphinx +sphinx==8.2.3 + # via -r requirements/quality.txt +sphinxcontrib-applehelp==2.0.0 + # via + # -r requirements/quality.txt + # sphinx +sphinxcontrib-devhelp==2.0.0 + # via + # -r requirements/quality.txt + # sphinx +sphinxcontrib-htmlhelp==2.1.0 + # via + # -r requirements/quality.txt + # sphinx +sphinxcontrib-jsmath==1.0.1 + # via + # -r requirements/quality.txt + # sphinx +sphinxcontrib-qthelp==2.0.0 + # via + # -r requirements/quality.txt + # sphinx +sphinxcontrib-serializinghtml==2.0.0 + # via + # -r requirements/quality.txt + # sphinx sqlparse==0.5.3 # via # -r requirements/quality.txt @@ -198,6 +272,21 @@ tomlkit==0.13.2 # pylint tox==4.26.0 # via -r requirements/ci.txt +types-docutils==0.21.0.20250516 + # via -r requirements/quality.txt +types-pyyaml==6.0.12.20250516 + # via -r requirements/quality.txt +types-setuptools==80.7.0.20250516 + # via -r requirements/quality.txt +typing-extensions==4.13.2 + # via + # -r requirements/quality.txt + # mypy +urllib3==2.2.3 + # via + # -c requirements/common_constraints.txt + # -r requirements/quality.txt + # requests virtualenv==20.31.2 # via # -r requirements/ci.txt diff --git a/requirements/pip.txt b/requirements/pip.txt index 1176ddc..963d3c9 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -10,7 +10,7 @@ wheel==0.45.1 # The following packages are considered to be unsafe in a requirements file: pip==24.2 # via - # -c /home/runner/work/code-annotations/code-annotations/requirements/common_constraints.txt + # -c requirements/common_constraints.txt # -r requirements/pip.in setuptools==80.8.0 # via -r requirements/pip.in diff --git a/requirements/quality.in b/requirements/quality.in index 738b13f..665a12f 100644 --- a/requirements/quality.in +++ b/requirements/quality.in @@ -5,5 +5,10 @@ edx-lint # edX pylint rules and plugins isort # to standardize order of imports +mypy # Typing utilities pycodestyle # PEP 8 compliance validation pydocstyle # PEP 257 compliance validation +types-docutils +types-PyYAML +types-setuptools +Sphinx # Required for type-checking of Sphinx utilities diff --git a/requirements/quality.txt b/requirements/quality.txt index d6f6c95..3dd9945 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -4,6 +4,8 @@ # # make upgrade # +alabaster==1.0.0 + # via sphinx asgiref==3.8.1 # via # -r requirements/test.txt @@ -12,6 +14,12 @@ astroid==3.3.10 # via # pylint # pylint-celery +babel==2.17.0 + # via sphinx +certifi==2025.4.26 + # via requests +charset-normalizer==3.4.2 + # via requests click==8.2.1 # via # -r requirements/test.txt @@ -32,8 +40,14 @@ django==4.2.21 # via # -c requirements/common_constraints.txt # -r requirements/test.txt +docutils==0.21.2 + # via sphinx edx-lint==5.6.0 # via -r requirements/quality.in +idna==3.10 + # via requests +imagesize==1.4.1 + # via sphinx iniconfig==2.1.0 # via # -r requirements/test.txt @@ -46,6 +60,7 @@ jinja2==3.1.6 # via # -r requirements/test.txt # code-annotations + # sphinx markupsafe==3.0.2 # via # -r requirements/test.txt @@ -54,10 +69,15 @@ mccabe==0.7.0 # via pylint mock==5.2.0 # via -r requirements/test.txt +mypy==1.15.0 + # via -r requirements/quality.in +mypy-extensions==1.1.0 + # via mypy packaging==25.0 # via # -r requirements/test.txt # pytest + # sphinx pbr==6.1.1 # via # -r requirements/test.txt @@ -72,6 +92,8 @@ pycodestyle==2.13.0 # via -r requirements/quality.in pydocstyle==6.3.0 # via -r requirements/quality.in +pygments==2.19.1 + # via sphinx pylint==3.3.7 # via # edx-lint @@ -100,10 +122,30 @@ pyyaml==6.0.2 # via # -r requirements/test.txt # code-annotations +requests==2.32.3 + # via sphinx +roman-numerals-py==3.1.0 + # via sphinx six==1.17.0 # via edx-lint snowballstemmer==3.0.1 - # via pydocstyle + # via + # pydocstyle + # sphinx +sphinx==8.2.3 + # via -r requirements/quality.in +sphinxcontrib-applehelp==2.0.0 + # via sphinx +sphinxcontrib-devhelp==2.0.0 + # via sphinx +sphinxcontrib-htmlhelp==2.1.0 + # via sphinx +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==2.0.0 + # via sphinx +sphinxcontrib-serializinghtml==2.0.0 + # via sphinx sqlparse==0.5.3 # via # -r requirements/test.txt @@ -118,6 +160,18 @@ text-unidecode==1.3 # python-slugify tomlkit==0.13.2 # via pylint +types-docutils==0.21.0.20250516 + # via -r requirements/quality.in +types-pyyaml==6.0.12.20250516 + # via -r requirements/quality.in +types-setuptools==80.7.0.20250516 + # via -r requirements/quality.in +typing-extensions==4.13.2 + # via mypy +urllib3==2.2.3 + # via + # -c requirements/common_constraints.txt + # requests # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/setup.py b/setup.py index 9d32f44..a64f6d7 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ from setuptools import setup -def get_version(*file_paths): +def get_version(*file_paths: str) -> str: """ Extract the version string from the file at the given relative path fragments. """ @@ -22,14 +22,14 @@ def get_version(*file_paths): raise RuntimeError("Unable to find version string.") -def load_requirements(*requirements_paths): +def load_requirements(*requirements_paths: str) -> list[str]: """ Load all requirements from the specified requirements files. Returns: list: Requirements file relative path strings """ - requirements = set() + requirements: set[str] = set() for path in requirements_paths: requirements.update( line.split("#")[0].strip() @@ -39,7 +39,7 @@ def load_requirements(*requirements_paths): return list(requirements) -def is_requirement(line): +def is_requirement(line: str) -> bool: """ Return True if the requirement line is a package requirement. diff --git a/tests/extensions/test_base_extensions.py b/tests/extensions/test_base_extensions.py index 78ce501..626acda 100644 --- a/tests/extensions/test_base_extensions.py +++ b/tests/extensions/test_base_extensions.py @@ -10,16 +10,16 @@ class FakeExtension(SimpleRegexAnnotationExtension): - extension_name = 'fake_extension' + extension_name: str = 'fake_extension' - lang_comment_definition = { + lang_comment_definition: dict[str, str] = { 'multi_start': re.escape('foo'), 'multi_end': re.escape('bar'), 'single': re.escape('baz') } -def test_nothing_found(): +def test_nothing_found() -> None: """ Make sure nothing fails when no annotation is found. """ @@ -30,7 +30,7 @@ def test_nothing_found(): r.search(f) -def test_strip_single_line_comment_tokens(): +def test_strip_single_line_comment_tokens() -> None: config = FakeConfig() extension = FakeExtension(config, VerboseEcho()) diff --git a/tests/extensions/test_extension_javascript.py b/tests/extensions/test_extension_javascript.py index d53c2a0..054079c 100644 --- a/tests/extensions/test_extension_javascript.py +++ b/tests/extensions/test_extension_javascript.py @@ -1,6 +1,7 @@ """ Tests for the Javascript static extension """ + import pytest from tests.helpers import EXIT_CODE_FAILURE, EXIT_CODE_SUCCESS, call_script @@ -20,7 +21,7 @@ ('choice_failures_4.js', EXIT_CODE_FAILURE, '"terrible" is already present in this annotation'), ('choice_failures_5.js', EXIT_CODE_FAILURE, 'no value found for ".. ignored:"'), ]) -def test_grouping_and_choice_failures(test_file, expected_exit_code, expected_message): +def test_grouping_and_choice_failures(test_file: str, expected_exit_code: int, expected_message: str) -> None: result = call_script(( 'static_find_annotations', '--config_file', diff --git a/tests/extensions/test_extension_python.py b/tests/extensions/test_extension_python.py index 2045a96..1f2e304 100644 --- a/tests/extensions/test_extension_python.py +++ b/tests/extensions/test_extension_python.py @@ -21,7 +21,7 @@ ('choice_failures_4.pyt', EXIT_CODE_FAILURE, '"terrible" is already present in this annotation'), ('choice_failures_5.pyt', EXIT_CODE_FAILURE, 'no value found for ".. ignored:"'), ]) -def test_grouping_and_choice_failures(test_file, expected_exit_code, expected_message): +def test_grouping_and_choice_failures(test_file: str, expected_exit_code: int, expected_message: str) -> None: result = call_script(( 'static_find_annotations', '--config_file', @@ -86,7 +86,7 @@ def test_grouping_and_choice_failures(test_file, expected_exit_code, expected_me ] ), ]) -def test_multi_line_annotations(test_file, annotations): +def test_multi_line_annotations(test_file: str, annotations: list[tuple[str, str]]) -> None: config = AnnotationConfig('tests/test_configurations/.annotations_test') annotator = PythonAnnotationExtension(config, VerboseEcho()) diff --git a/tests/helpers.py b/tests/helpers.py index 939ba59..a6316ff 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -3,11 +3,19 @@ """ import os import re +import typing as t +from collections.abc import Callable, Sequence from click.testing import CliRunner +from click.testing import Result as ClickTestResult +from stevedore import named -from code_annotations.base import BaseSearch, VerboseEcho +from code_annotations.base import AnnotationConfig, BaseSearch from code_annotations.cli import entry_point +from code_annotations.helpers import VerboseEcho + +# Re-export Result for convenience +Result = ClickTestResult EXIT_CODE_SUCCESS = 0 EXIT_CODE_FAILURE = 1 @@ -34,16 +42,23 @@ """.format(DEFAULT_FAKE_SAFELIST_PATH) -class FakeConfig: +class FakeConfig(AnnotationConfig): """ Simple config for testing without reading a config file. """ - annotations = {} - annotation_regexes = [] - annotation_tokens = [] - groups = [] - echo = VerboseEcho() + def __init__(self) -> None: # pylint: disable=super-init-not-called + """ + Override the base __init__ to skip reading and parsing the config. + """ + self.groups: dict[str, list[str]] = {} + self.choices: dict[str, list[str]] = {} + self.optional_groups: list[str] = [] + self.annotation_tokens: list[str] = [] + self.annotation_regexes: list[str] = [] + self.mgr: named.NamedExtensionManager | None = None + self.coverage_target: float | None = None + self.echo = VerboseEcho() class FakeSearch(BaseSearch): @@ -51,13 +66,17 @@ class FakeSearch(BaseSearch): Simple test class for directly testing BaseSearch since it's abstract. """ - def search(self): + def search(self) -> dict[str, list[dict[str, t.Any]]]: """ Override for abstract base method. + + Returns: + Empty dict to satisfy the abstract method requirement """ + return {} -def delete_report_files(file_extension): +def delete_report_files(file_extension: str) -> None: """ Delete all files with the given extension from the test_reports directory. @@ -76,7 +95,7 @@ def delete_report_files(file_extension): pass -def call_script(args_list, delete_test_reports=True, delete_test_docs=True): +def call_script(args_list: Sequence[str], delete_test_reports: bool = True, delete_test_docs: bool = True) -> Result: """ Call the code_annotations script with the given params and a generic config file. @@ -108,11 +127,11 @@ def call_script(args_list, delete_test_reports=True, delete_test_docs=True): def call_script_isolated( - args_list, - test_filesystem_cb=None, - test_filesystem_report_cb=None, - fake_safelist_data="{}" -): + args_list: list[str], + test_filesystem_cb: Callable[[], None] | None = None, + test_filesystem_report_cb: Callable[[str], None] | None = None, + fake_safelist_data: str = "{}" +) -> Result: """ Call the code_annotations script with the given params and a generic config file. @@ -123,8 +142,6 @@ def call_script_isolated( test_filesystem_report_cb: Callback function, called after the command is run, before the temp filesystem is cleared. Callback is called with the raw text contents of the report file. fake_safelist_data: Raw text to write to the safelist file before the command is called. - safelist_path: File path to write the safelist to. Used when writing a fake safelist, but not automatically - passed to the command. Returns: click.testing.Result: Result from the `CliRunner.invoke()` call. @@ -151,7 +168,9 @@ def call_script_isolated( if test_filesystem_report_cb: try: - report_file = re.search(r'Generating report to (.*)', result.output).groups()[0] + report_match = re.search(r'Generating report to (.*)', result.output) + assert report_match is not None + report_file = report_match.groups()[0] with open(report_file) as f: report_contents = f.read() @@ -163,7 +182,7 @@ def call_script_isolated( return result -def get_report_filename_from_output(output): +def get_report_filename_from_output(output: str) -> str | None: """ Find the report filename in a find_static or find_django output and return it. @@ -172,9 +191,10 @@ def get_report_filename_from_output(output): Returns: Filename of the found report, or None of no name is found - """ + match = re.search(r'Generating report to (.*)', output) + assert match is not None try: - return re.search(r'Generating report to (.*)', output).groups()[0] + return match.groups()[0] except IndexError: return None diff --git a/tests/test_base.py b/tests/test_base.py index 2cc7cf1..6078a12 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,21 +1,23 @@ """ Tests for code_annotations/base.py """ +import typing as t from collections import OrderedDict import pytest -from code_annotations.base import AnnotationConfig, ConfigurationException +from code_annotations.base import AnnotationConfig +from code_annotations.exceptions import ConfigurationException from tests.helpers import FakeConfig, FakeSearch -def test_get_group_for_token_missing_token(): +def test_get_group_for_token_missing_token() -> None: config = FakeConfig() search = FakeSearch(config) assert search._get_group_for_token('foo') is None # pylint: disable=protected-access -def test_get_group_for_token_multiple_groups(): +def test_get_group_for_token_multiple_groups() -> None: config = FakeConfig() config.groups = { 'group1': ['token1'], @@ -30,7 +32,7 @@ def test_get_group_for_token_multiple_groups(): ('.annotations_test_missing_report_path', "report_path"), ('.annotations_test_missing_safelist_path', "safelist_path"), ]) -def test_missing_config(test_config, expected_message): +def test_missing_config(test_config: str, expected_message: str) -> None: with pytest.raises(ConfigurationException) as exception: AnnotationConfig(f'tests/test_configurations/{test_config}', None, 3) @@ -44,7 +46,7 @@ def test_missing_config(test_config, expected_message): ('.annotations_test_coverage_over_100', "Invalid coverage target. 150.0 is not between 0 and 100."), ('.annotations_test_coverage_nan', 'Coverage target must be a number between 0 and 100 not "not a number".'), ]) -def test_bad_coverage_targets(test_config, expected_message): +def test_bad_coverage_targets(test_config: str, expected_message: str) -> None: with pytest.raises(ConfigurationException) as exception: AnnotationConfig(f'tests/test_configurations/{test_config}', None, 3) @@ -52,7 +54,7 @@ def test_bad_coverage_targets(test_config, expected_message): assert expected_message in exc_msg -def test_coverage_target_int(): +def test_coverage_target_int() -> None: # We just care that this doesn't throw an exception AnnotationConfig('tests/test_configurations/{}'.format('.annotations_test_coverage_int'), None, 3) @@ -64,7 +66,7 @@ def test_coverage_target_int(): ('.annotations_test_group_one_token', 'Group "pii_group" must have more than one annotation.'), ('.annotations_test_group_bad_type', "{'.. pii:': ['bad', 'type']} is an unknown annotation type."), ]) -def test_annotation_configuration_errors(test_config, expected_message): +def test_annotation_configuration_errors(test_config: str, expected_message: str) -> None: with pytest.raises(ConfigurationException) as exception: AnnotationConfig(f'tests/test_configurations/{test_config}', None, 3) @@ -72,7 +74,7 @@ def test_annotation_configuration_errors(test_config, expected_message): assert expected_message in exc_msg -def test_format_results_for_report(): +def test_format_results_for_report() -> None: """ Test that report formatting puts annotations into groups correctly """ @@ -86,7 +88,7 @@ def test_format_results_for_report(): search = FakeSearch(config) # Create a fake result set for _format_results_for_report to work on - fake_results = OrderedDict() + fake_results: OrderedDict[str, list[dict[str, t.Any]]] = OrderedDict() # First file has 6 annotations. expected_group_id is a special key for this test, allowing us to loop through # these below and know what group each result should be in. diff --git a/tests/test_django_coverage.py b/tests/test_django_coverage.py index fb9addd..e63f1a0 100644 --- a/tests/test_django_coverage.py +++ b/tests/test_django_coverage.py @@ -1,7 +1,8 @@ """ Tests for the DjangoSearch coverage functionality. """ -from unittest.mock import DEFAULT, patch +import typing as t +from unittest.mock import DEFAULT, MagicMock, patch import pytest @@ -21,7 +22,10 @@ ) from tests.helpers import EXIT_CODE_FAILURE, EXIT_CODE_SUCCESS, call_script_isolated -ALL_FAKE_MODELS = ( +# Type for our fake model classes +FakeModelClass = type[t.Any] + +ALL_FAKE_MODELS: tuple[FakeModelClass, ...] = ( FakeBaseModelAbstract, FakeBaseModelBoring, FakeBaseModelBoringWithAnnotations, @@ -40,10 +44,15 @@ @patch('code_annotations.find_django.DjangoSearch.setup_django') @patch('code_annotations.find_django.DjangoSearch.is_non_local') @patch('code_annotations.find_django.django.apps.apps.get_app_configs') -def test_coverage_all_models(mock_get_app_configs, mock_is_non_local, mock_setup_django, mock_issubclass): +def test_coverage_all_models( + mock_get_app_configs: MagicMock, + mock_is_non_local: MagicMock, + mock_setup_django: MagicMock, + mock_issubclass: MagicMock +) -> None: # Lots of fakery going on here. This class mocks Django AppConfigs to deliver our fake models. class FakeAppConfig: - def get_models(self): + def get_models(self) -> tuple[FakeModelClass, ...]: return ALL_FAKE_MODELS # This lets us deterministically decide that one model is local, and the other isn't, for testing both branches. @@ -107,8 +116,13 @@ def get_models(self): "Coverage is 100.0%" ), ]) -def test_coverage_thresholds(local_models, should_succeed, expected_message, **kwargs): - mock_get_models_requiring_annotations = kwargs['get_models_requiring_annotations'] +def test_coverage_thresholds( + local_models: list[FakeModelClass], + should_succeed: bool, + expected_message: str, + **kwargs: t.Any +) -> None: + mock_get_models_requiring_annotations: MagicMock = kwargs['get_models_requiring_annotations'] mock_get_models_requiring_annotations.return_value = ( set(local_models), set(), diff --git a/tests/test_django_generate_safelist.py b/tests/test_django_generate_safelist.py index e6f0b08..1843664 100644 --- a/tests/test_django_generate_safelist.py +++ b/tests/test_django_generate_safelist.py @@ -3,6 +3,7 @@ Tests for seeding the safelist. """ import os +import typing as t from unittest.mock import DEFAULT, MagicMock, patch import pytest @@ -34,11 +35,15 @@ [], # No non-local models to add to the safelist. ), ]) -def test_seeding_safelist(local_models, non_local_models, **kwargs): +def test_seeding_safelist( + local_models: list[MagicMock], + non_local_models: list[MagicMock], + **kwargs: t.Any +) -> None: """ Test the success case for seeding the safelist. """ - mock_get_models_requiring_annotations = kwargs['get_models_requiring_annotations'] + mock_get_models_requiring_annotations: MagicMock = kwargs['get_models_requiring_annotations'] mock_get_models_requiring_annotations.return_value = ( local_models, non_local_models, @@ -46,7 +51,7 @@ def test_seeding_safelist(local_models, non_local_models, **kwargs): set() # List of model ids that are eligible for annotation, irrelevant here ) - def test_safelist_callback(): + def test_safelist_callback() -> None: assert os.path.exists(DEFAULT_FAKE_SAFELIST_PATH) with open(DEFAULT_FAKE_SAFELIST_PATH) as fake_safelist_file: fake_safelist = fake_safelist_file.read() @@ -58,7 +63,7 @@ def test_safelist_callback(): result = call_script_isolated( ['django_find_annotations', '--config_file', 'test_config.yml', '--seed_safelist'], test_filesystem_cb=test_safelist_callback, - fake_safelist_data=None + fake_safelist_data="" ) assert result.exit_code == EXIT_CODE_SUCCESS assert 'Successfully created safelist file' in result.output @@ -68,11 +73,11 @@ def test_safelist_callback(): 'code_annotations.find_django.DjangoSearch', get_models_requiring_annotations=DEFAULT, ) -def test_safelist_exists(**kwargs): +def test_safelist_exists(**kwargs: t.Any) -> None: """ Test the success case for seeding the safelist. """ - mock_get_models_requiring_annotations = kwargs['get_models_requiring_annotations'] + mock_get_models_requiring_annotations: MagicMock = kwargs['get_models_requiring_annotations'] mock_get_models_requiring_annotations.return_value = (set(), set(), 0, []) result = call_script_isolated( diff --git a/tests/test_django_list_local_models.py b/tests/test_django_list_local_models.py index bea3f7e..b9fa116 100644 --- a/tests/test_django_list_local_models.py +++ b/tests/test_django_list_local_models.py @@ -2,6 +2,7 @@ """ Tests for listing local models. """ +import typing as t from unittest.mock import DEFAULT, MagicMock, patch import pytest @@ -32,11 +33,15 @@ ], ), ]) -def test_listing_local_models(local_model_ids, non_local_model_ids, **kwargs): +def test_listing_local_models( + local_model_ids: list[MagicMock], + non_local_model_ids: list[MagicMock], + **kwargs: t.Any +) -> None: """ Test the success case for listing local models. """ - mock_get_models_requiring_annotations = kwargs['get_models_requiring_annotations'] + mock_get_models_requiring_annotations: MagicMock = kwargs['get_models_requiring_annotations'] mock_get_models_requiring_annotations.return_value = ( local_model_ids, non_local_model_ids, diff --git a/tests/test_find_django.py b/tests/test_find_django.py index bf94e44..8ae4bcf 100644 --- a/tests/test_find_django.py +++ b/tests/test_find_django.py @@ -3,7 +3,8 @@ Tests for find annotations in Django models. """ import sys -from unittest.mock import DEFAULT, patch +import typing as t +from unittest.mock import DEFAULT, MagicMock, patch from code_annotations.find_django import DjangoSearch from tests.fake_models import ( @@ -26,7 +27,7 @@ 'code_annotations.find_django.DjangoSearch', get_models_requiring_annotations=DEFAULT ) -def test_find_django_simple_success(**kwargs): +def test_find_django_simple_success(**kwargs: MagicMock) -> None: """ Tests the basic case where all models have annotations, with an empty safelist. """ @@ -44,15 +45,15 @@ def test_find_django_simple_success(**kwargs): [DjangoSearch.get_model_id(m) for m in test_models] ) - def report_callback(report_contents): + def report_callback(report_contents: str) -> None: """ Get the text of the report and make sure all of the expected models are in it. Args: - report_contents: + report_contents: Raw text contents of the report file Returns: - Raw text contents of the generated report file + None """ for model in test_models: assert 'object_id: {}'.format(DjangoSearch.get_model_id(model)) in report_contents @@ -78,7 +79,7 @@ def report_callback(report_contents): 'code_annotations.find_django.DjangoSearch', get_models_requiring_annotations=DEFAULT ) -def test_find_django_no_viable_models(**kwargs): +def test_find_django_no_viable_models(**kwargs: MagicMock) -> None: """ Tests the basic case where all models have annotations, with an empty safelist. """ @@ -104,7 +105,7 @@ def test_find_django_no_viable_models(**kwargs): 'code_annotations.find_django.DjangoSearch', get_models_requiring_annotations=DEFAULT ) -def test_find_django_model_not_annotated(**kwargs): +def test_find_django_model_not_annotated(**kwargs: MagicMock) -> None: """ Test that a non-annotated model fails. """ @@ -131,7 +132,7 @@ def test_find_django_model_not_annotated(**kwargs): 'code_annotations.find_django.DjangoSearch', get_models_requiring_annotations=DEFAULT ) -def test_find_django_model_in_safelist_not_annotated(**kwargs): +def test_find_django_model_in_safelist_not_annotated(**kwargs: MagicMock) -> None: """ Test that a safelisted model with no annotations fails. """ @@ -165,7 +166,7 @@ def test_find_django_model_in_safelist_not_annotated(**kwargs): 'code_annotations.find_django.DjangoSearch', get_models_requiring_annotations=DEFAULT ) -def test_find_django_model_in_safelist_annotated(**kwargs): +def test_find_django_model_in_safelist_annotated(**kwargs: MagicMock) -> None: """ Test that a safelisted model succeeds. """ @@ -199,7 +200,7 @@ def test_find_django_model_in_safelist_annotated(**kwargs): 'code_annotations.find_django.DjangoSearch', get_models_requiring_annotations=DEFAULT ) -def test_find_django_no_safelist(**kwargs): +def test_find_django_no_safelist(**kwargs: MagicMock) -> None: """ Test that we fail when there is no safelist. """ @@ -208,7 +209,7 @@ def test_find_django_no_safelist(**kwargs): result = call_script_isolated( ['django_find_annotations', '--config_file', 'test_config.yml', '--lint', '--report'], - fake_safelist_data=None, + fake_safelist_data="", ) assert result.exit_code == EXIT_CODE_FAILURE @@ -220,7 +221,7 @@ def test_find_django_no_safelist(**kwargs): 'code_annotations.find_django.DjangoSearch', get_models_requiring_annotations=DEFAULT ) -def test_find_django_in_safelist_and_annotated(**kwargs): +def test_find_django_in_safelist_and_annotated(**kwargs: MagicMock) -> None: """ Test that a model which is annotated and also in the safelist fails. """ @@ -248,7 +249,7 @@ def test_find_django_in_safelist_and_annotated(**kwargs): 'code_annotations.find_django.DjangoSearch', get_models_requiring_annotations=DEFAULT ) -def test_find_django_no_docstring(**kwargs): +def test_find_django_no_docstring(**kwargs: MagicMock) -> None: """ Test that a model with no docstring doesn't break anything. """ @@ -274,7 +275,7 @@ def test_find_django_no_docstring(**kwargs): 'code_annotations.find_django.DjangoSearch', get_models_requiring_annotations=DEFAULT ) -def test_find_django_ordering_error(**kwargs): +def test_find_django_ordering_error(**kwargs: MagicMock) -> None: """ Tests broken annotations to make sure the error paths work. """ @@ -299,7 +300,7 @@ def test_find_django_ordering_error(**kwargs): 'code_annotations.find_django.DjangoSearch', get_models_requiring_annotations=DEFAULT ) -def test_find_django_without_linting(**kwargs): +def test_find_django_without_linting(**kwargs: MagicMock) -> None: """ Tests to make sure reports will be written in the case of errors, if linting is off. """ @@ -325,7 +326,7 @@ def test_find_django_without_linting(**kwargs): 'code_annotations.find_django.DjangoSearch', get_models_requiring_annotations=DEFAULT ) -def test_find_django_without_report(**kwargs): +def test_find_django_without_report(**kwargs: MagicMock) -> None: """ Tests to make sure reports will be written in the case of errors, if linting is off. """ @@ -348,7 +349,7 @@ def test_find_django_without_report(**kwargs): @patch('code_annotations.find_django.issubclass') -def test_requires_annotations_abstract(mock_issubclass): +def test_requires_annotations_abstract(mock_issubclass: MagicMock) -> None: """ Abstract classes should not require annotations """ @@ -357,7 +358,7 @@ def test_requires_annotations_abstract(mock_issubclass): @patch('code_annotations.find_django.issubclass') -def test_requires_annotations_proxy(mock_issubclass): +def test_requires_annotations_proxy(mock_issubclass: MagicMock) -> None: """ Proxy classes should not require annotations """ @@ -366,7 +367,7 @@ def test_requires_annotations_proxy(mock_issubclass): @patch('code_annotations.find_django.issubclass') -def test_requires_annotations_normal(mock_issubclass): +def test_requires_annotations_normal(mock_issubclass: MagicMock) -> None: """ Non-abstract, non-proxy models should require annotations """ @@ -374,14 +375,14 @@ def test_requires_annotations_normal(mock_issubclass): assert DjangoSearch.requires_annotations(FakeBaseModelBoring) is True -def test_requires_annotations_not_a_model(): +def test_requires_annotations_not_a_model() -> None: """ Things which are not models should not require annotations """ assert DjangoSearch.requires_annotations(dict) is False -def test_is_non_local_simple(): +def test_is_non_local_simple() -> None: """ Our model is local, should show up as such """ @@ -389,7 +390,7 @@ def test_is_non_local_simple(): @patch('code_annotations.find_django.inspect.getsourcefile') -def test_is_non_local_site(mock_getsourcefile): +def test_is_non_local_site(mock_getsourcefile: MagicMock) -> None: """ Try to test the various non-local paths, if the environment allows. """ @@ -413,10 +414,15 @@ def test_is_non_local_site(mock_getsourcefile): @patch('code_annotations.find_django.DjangoSearch.setup_django') @patch('code_annotations.find_django.DjangoSearch.is_non_local') @patch('code_annotations.find_django.django.apps.apps.get_app_configs') -def test_get_models_requiring_annotations(mock_get_app_configs, mock_is_non_local, mock_setup_django, mock_issubclass): +def test_get_models_requiring_annotations( + mock_get_app_configs: MagicMock, + mock_is_non_local: MagicMock, + mock_setup_django: MagicMock, + mock_issubclass: MagicMock +) -> None: # Lots of fakery going on here. This class mocks Django AppConfigs to deliver our fake models. class FakeAppConfig: - def get_models(self): + def get_models(self) -> list[t.Any]: return [FakeBaseModelBoring, FakeBaseModelBoringWithAnnotations] # This lets us deterministically decide that one model is local, and the other isn't, for testing both branches. @@ -445,7 +451,7 @@ def get_models(self): @patch('code_annotations.find_django.DjangoSearch.setup_django') @patch('code_annotations.find_django.DjangoSearch.get_models_requiring_annotations') -def test_find_django_no_coverage_configured(mock_get_models, mock_setup_django): +def test_find_django_no_coverage_configured(mock_get_models: MagicMock, mock_setup_django: MagicMock) -> None: """ Tests the basic case where all models have annotations, with an empty safelist. """ @@ -466,7 +472,7 @@ def test_find_django_no_coverage_configured(mock_get_models, mock_setup_django): @patch('code_annotations.find_django.django.setup') -def test_setup_django(mock_django_setup): +def test_setup_django(mock_django_setup: MagicMock) -> None: """ This is really just for coverage. """ @@ -478,7 +484,7 @@ def test_setup_django(mock_django_setup): 'code_annotations.find_django.DjangoSearch', get_models_requiring_annotations=DEFAULT ) -def test_find_django_no_action(**kwargs): +def test_find_django_no_action(**kwargs: MagicMock) -> None: """ Test that we fail when there is no action specified. """ diff --git a/tests/test_find_static.py b/tests/test_find_static.py index 78f2f43..f7d6c46 100644 --- a/tests/test_find_static.py +++ b/tests/test_find_static.py @@ -6,7 +6,7 @@ from tests.helpers import EXIT_CODE_FAILURE, EXIT_CODE_SUCCESS, call_script -def test_missing_extension(): +def test_missing_extension() -> None: result = call_script(( 'static_find_annotations', '--config_file', @@ -18,7 +18,7 @@ def test_missing_extension(): assert 'Not all configured extensions could be loaded!' in result.output -def test_bad_extension(): +def test_bad_extension() -> None: with patch('code_annotations.extensions.javascript.JavascriptAnnotationExtension.__init__') as js_init: js_init.side_effect = Exception('Fake failed to load javascript extension') result = call_script(( @@ -32,7 +32,7 @@ def test_bad_extension(): assert 'Failed to load a plugin, aborting.' in result.output -def test_unknown_file_extension(): +def test_unknown_file_extension() -> None: result = call_script(( 'static_find_annotations', '--config_file', @@ -45,7 +45,7 @@ def test_unknown_file_extension(): assert 'Search found 0 annotations' in result.output -def test_file_walking(): +def test_file_walking() -> None: result = call_script(( 'static_find_annotations', '--config_file', @@ -60,7 +60,7 @@ def test_file_walking(): assert 'choice_failures_1' in result.output -def test_source_path_from_file(): +def test_source_path_from_file() -> None: result = call_script(( 'static_find_annotations', '--config_file', @@ -72,7 +72,7 @@ def test_source_path_from_file(): assert "Configured for source path: tests/extensions/javascript_test_files/" in result.output -def test_report_path_from_command(): +def test_report_path_from_command() -> None: result = call_script(( 'static_find_annotations', '--config_file', @@ -84,7 +84,7 @@ def test_report_path_from_command(): assert "report path: test_reports_2" in result.output -def test_no_extension_results(): +def test_no_extension_results() -> None: result = call_script(( 'static_find_annotations', '--config_file', @@ -96,7 +96,7 @@ def test_no_extension_results(): assert "Search found 0 annotations" in result.output -def test_no_report(): +def test_no_report() -> None: result = call_script(( 'static_find_annotations', '--config_file', @@ -111,7 +111,7 @@ def test_no_report(): assert "Linting passed without errors." in result.output -def test_no_lint(): +def test_no_lint() -> None: result = call_script(( 'static_find_annotations', '--config_file', diff --git a/tests/test_generate_docs.py b/tests/test_generate_docs.py index f352cb3..81ef1d2 100644 --- a/tests/test_generate_docs.py +++ b/tests/test_generate_docs.py @@ -15,29 +15,30 @@ ) -def test_generate_report_simple(): +def test_generate_report_simple() -> None: find_result = call_script( - ( + [ 'static_find_annotations', '--config_file', 'tests/test_configurations/.annotations_test_python_only', '--source_path=tests/extensions/python_test_files/simple_success.pyt', '--no_lint', - ), + ], delete_test_reports=False) assert find_result.exit_code == EXIT_CODE_SUCCESS assert "Writing report..." in find_result.output report_file = get_report_filename_from_output(find_result.output) + assert report_file is not None, "Failed to get report filename from output" report_result = call_script( - ( + [ 'generate_docs', report_file, '--config_file', 'tests/test_configurations/.annotations_test_success_with_report_docs', '-vv' - ), + ], delete_test_docs=False ) @@ -49,29 +50,30 @@ def test_generate_report_simple(): assert os.path.exists(created_doc) -def test_generate_report_simple_html(): +def test_generate_report_simple_html() -> None: find_result = call_script( - ( + [ 'static_find_annotations', '--config_file', 'tests/test_configurations/.annotations_test_python_only', '--source_path=tests/extensions/python_test_files/simple_success.pyt', '--no_lint', - ), + ], delete_test_reports=False) assert find_result.exit_code == EXIT_CODE_SUCCESS assert "Writing report..." in find_result.output report_file = get_report_filename_from_output(find_result.output) + assert report_file is not None, "Failed to get report filename from output" report_result = call_script( - ( + [ 'generate_docs', report_file, '--config_file', 'tests/test_configurations/.annotations_test_success_with_report_docs_html', '-vv' - ), + ], delete_test_docs=False ) @@ -83,20 +85,21 @@ def test_generate_report_simple_html(): assert os.path.exists(created_doc) -def _do_find(source_path, new_report_path): +def _do_find(source_path: str, new_report_path: str) -> None: """ Do a static annotation search with report, rename the report to a distinct name. Args: source_path: Path to the test file to run the report on + new_report_path: Path to save the report to """ - find_result_1 = call_script(( + find_result_1 = call_script([ 'static_find_annotations', '--config_file', 'tests/test_configurations/.annotations_test_python_only', f'--source_path={source_path}', '--no_lint', - ), False) + ], False) assert find_result_1.exit_code == EXIT_CODE_SUCCESS assert "Writing report..." in find_result_1.output @@ -104,10 +107,11 @@ def _do_find(source_path, new_report_path): # These will usually all run within 1 second and end up with the same filename, so rename them to something # distinct before they get overwritten original_report_filename = get_report_filename_from_output(find_result_1.output) + assert original_report_filename is not None os.rename(original_report_filename, new_report_path) -def test_generate_report_multiple_files(): +def test_generate_report_multiple_files() -> None: report_file_1 = 'test_reports/test1.yaml' _do_find('tests/extensions/python_test_files/simple_success.pyt', report_file_1) @@ -138,7 +142,7 @@ def test_generate_report_multiple_files(): yaml.safe_dump(tmp_report, out_tmp) report_result = call_script( - ( + [ 'generate_docs', report_file_1, report_file_2, @@ -146,7 +150,7 @@ def test_generate_report_multiple_files(): report_file_4, '--config_file', 'tests/test_configurations/.annotations_test_success_with_report_docs', - ), + ], delete_test_docs=False ) @@ -178,27 +182,29 @@ def test_generate_report_multiple_files(): delete_report_files('.rst') -def test_generate_report_missing_key(): - find_result = call_script(( +def test_generate_report_missing_key() -> None: + find_result = call_script([ 'static_find_annotations', '--config_file', 'tests/test_configurations/.annotations_test_python_only', '--source_path=tests/extensions/python_test_files/simple_success.pyt', '--no_lint', '-v', - ), False) + ], False) assert find_result.exit_code == EXIT_CODE_SUCCESS assert "Writing report..." in find_result.output - report_file = re.search(r'Generating report to (.*)', find_result.output).groups()[0] + match = re.search(r'Generating report to (.*)', find_result.output) + assert match is not None + report_file = match.groups()[0] assert os.path.exists(report_file) - report_result = call_script(( + report_result = call_script([ 'generate_docs', report_file, '--config_file', 'tests/test_configurations/.annotations_test_python_only', - )) + ]) assert report_result.exit_code == EXIT_CODE_FAILURE assert "No rendered_report_source_link_prefix key in" in report_result.output diff --git a/tests/test_search.py b/tests/test_search.py index 45a48c6..0a8d6ae 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -7,7 +7,7 @@ from code_annotations.find_static import StaticSearch -def test_annotation_errors(): +def test_annotation_errors() -> None: config = AnnotationConfig( "tests/test_configurations/.annotations_test", verbosity=-1, @@ -34,7 +34,7 @@ def test_annotation_errors(): ) == args -def test_annotation_errors_ordering(): +def test_annotation_errors_ordering() -> None: # You should modify the value below every time a new annotation error type is added. assert 6 == len(annotation_errors.TYPES) # The value below must not be modified, ever. The number of annotation error types should NEVER exceed 10. Read the diff --git a/tests/test_sphinx.py b/tests/test_sphinx.py index a0388bb..b92418e 100644 --- a/tests/test_sphinx.py +++ b/tests/test_sphinx.py @@ -4,7 +4,7 @@ from code_annotations.contrib.sphinx.extensions.base import find_annotations, quote_value -def test_collect_pii_for_sphinx(): +def test_collect_pii_for_sphinx() -> None: annotations = find_annotations( "tests/extensions/python_test_files/simple_success.pyt", "tests/test_configurations/.annotations_test", @@ -27,7 +27,7 @@ def test_collect_pii_for_sphinx(): assert 5 == len(annotations) -def test_quote_value(): +def test_quote_value() -> None: assert "True" == quote_value("True") assert "None" == quote_value("None") assert "1" == quote_value("1") diff --git a/tox.ini b/tox.ini index a36db7c..0f4aaad 100644 --- a/tox.ini +++ b/tox.ini @@ -50,8 +50,4 @@ deps = setuptools -r{toxinidir}/requirements/quality.txt commands = - pylint code_annotations tests test_utils setup.py - pycodestyle code_annotations tests setup.py - pydocstyle code_annotations tests setup.py - isort --check-only --diff tests test_utils code_annotations setup.py - make selfcheck + make test-quality