diff options
author | Joris Roovers <joris.roovers@gmail.com> | 2023-04-11 09:53:04 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-04-11 09:53:04 +0200 |
commit | 7f55b0155c69a3b3e56fd4779fb062058291b9b5 (patch) | |
tree | d13902e6ac94541ef5c17b7ed51c2647d81d9a8c | |
parent | f9ffd4fe06166dcd213b7ea4a04b0c21a621df2d (diff) |
Static type checking using mypy (#482)
- Adds mypy for type checking, runs in CI on every commit.
- Configures mypy for gitlint (excludes, ignores, tuned strictness)
- Fixes several typing issues
- Updates contributing docs with how to use mypy
-rw-r--r-- | .devcontainer/devcontainer.json | 1 | ||||
-rw-r--r-- | .github/workflows/ci.yml | 3 | ||||
-rw-r--r-- | docs/contributing.md | 9 | ||||
-rw-r--r-- | gitlint-core/gitlint/cache.py | 7 | ||||
-rw-r--r-- | gitlint-core/gitlint/cli.py | 2 | ||||
-rw-r--r-- | gitlint-core/gitlint/config.py | 3 | ||||
-rw-r--r-- | gitlint-core/gitlint/contrib/rules/authors_commit.py | 10 | ||||
-rw-r--r-- | gitlint-core/gitlint/rules.py | 6 | ||||
-rw-r--r-- | gitlint-core/gitlint/utils.py | 8 | ||||
-rw-r--r-- | pyproject.toml | 55 | ||||
-rw-r--r-- | qa/utils.py | 6 |
11 files changed, 89 insertions, 21 deletions
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 65fcc1c..e389544 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -40,6 +40,7 @@ "extensions": [ "ms-python.python", "ms-python.vscode-pylance", + "ms-python.mypy-type-checker", "charliermarsh.ruff", "tamasfe.even-better-toml" ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7a6447..2f4ac80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,9 @@ jobs: - name: Code linting (ruff) run: hatch run test:lint + - name: Static type checking (mypy) + run: hatch run test:type-check + - name: Install local gitlint for integration tests run: | hatch run qa:install-local diff --git a/docs/contributing.md b/docs/contributing.md index cf4755a..bdc9246 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -20,6 +20,7 @@ When contributing code, please consider all the parts that are typically require - [Integration tests](https://github.com/jorisroovers/gitlint/tree/main/qa) (also automatically [enforced by CI](https://github.com/jorisroovers/gitlint/actions)). Again, please consider writing new ones for your functionality, not only updating existing ones to make the build pass. +- Code style checks: linting, formatting, type-checking - [Documentation](https://github.com/jorisroovers/gitlint/tree/main/docs). Since we want to maintain a high standard of quality, all of these things will have to be done regardless before code @@ -124,9 +125,15 @@ hatch run qa:integration-tests # Run integration tests # Formatting check (black) hatch run test:format # Run formatting checks -# Linting (ruff) +# Linting (ruff) hatch run test:lint # Run Ruff +# Type Check (mypy) +hatch run test:type-check # Run MyPy + +# Run unit-tests & all checks +hatch run test:all # Run unit-tests and all style checks (format, lint, type-check) + # Project stats hatch run test:stats ``` diff --git a/gitlint-core/gitlint/cache.py b/gitlint-core/gitlint/cache.py index 5c20896..ec5e273 100644 --- a/gitlint-core/gitlint/cache.py +++ b/gitlint-core/gitlint/cache.py @@ -1,11 +1,12 @@ from dataclasses import dataclass, field +from typing import Any, Callable, Dict, Optional @dataclass class PropertyCache: """Mixin class providing a simple cache.""" - _cache: dict = field(init=False, default_factory=dict) + _cache: Dict[str, Any] = field(init=False, default_factory=dict) def _try_cache(self, cache_key, cache_populate_func): """Tries to get a value from the cache identified by `cache_key`. @@ -16,7 +17,7 @@ class PropertyCache: return self._cache[cache_key] -def cache(original_func=None, cachekey=None): +def cache(original_func: Optional[Callable[[Any], Any]] = None, cachekey: Optional[str] = None) -> Any: """Cache decorator. Caches function return values. Requires the parent class to extend and initialize PropertyCache. Usage: @@ -33,7 +34,7 @@ def cache(original_func=None, cachekey=None): # Decorators with optional arguments are a bit convoluted in python, see some of the links below for details. - def cache_decorator(func): + def cache_decorator(func: Callable[[Any], Any]) -> Any: # Use 'nonlocal' keyword to access parent function variable: # https://stackoverflow.com/a/14678445/381010 nonlocal cachekey diff --git a/gitlint-core/gitlint/cli.py b/gitlint-core/gitlint/cli.py index 82f35ce..d3de5ac 100644 --- a/gitlint-core/gitlint/cli.py +++ b/gitlint-core/gitlint/cli.py @@ -48,7 +48,7 @@ class GitLintUsageError(GitlintError): """Exception indicating there is an issue with how gitlint is used.""" -def setup_logging(): +def setup_logging() -> None: """Setup gitlint logging""" # Root log, mostly used for debug diff --git a/gitlint-core/gitlint/config.py b/gitlint-core/gitlint/config.py index 4637af2..7d1ba3b 100644 --- a/gitlint-core/gitlint/config.py +++ b/gitlint-core/gitlint/config.py @@ -7,6 +7,7 @@ from configparser import ConfigParser from configparser import Error as ConfigParserError from dataclasses import dataclass, field from typing import ClassVar, Optional +from typing import OrderedDict as OrderedDictType from gitlint import ( options, @@ -427,7 +428,7 @@ class LintConfigBuilder: """ RULE_QUALIFIER_SYMBOL: ClassVar[str] = ":" - _config_blueprint: OrderedDict = field(init=False, default_factory=OrderedDict) + _config_blueprint: OrderedDictType[str, OrderedDictType[str, str]] = field(init=False, default_factory=OrderedDict) _config_path: Optional[str] = field(init=False, default=None) def set_option(self, section, option_name, option_value): diff --git a/gitlint-core/gitlint/contrib/rules/authors_commit.py b/gitlint-core/gitlint/contrib/rules/authors_commit.py index 5c4a150..d1e8cfa 100644 --- a/gitlint-core/gitlint/contrib/rules/authors_commit.py +++ b/gitlint-core/gitlint/contrib/rules/authors_commit.py @@ -1,7 +1,8 @@ import re from pathlib import Path -from typing import Tuple +from typing import Set, Tuple +from gitlint.git import GitContext from gitlint.rules import CommitRule, RuleViolation @@ -16,9 +17,12 @@ class AllowedAuthors(CommitRule): id = "CC3" @classmethod - def _read_authors_from_file(cls, git_ctx) -> Tuple[str, str]: + def _read_authors_from_file(cls, git_ctx: GitContext) -> Tuple[Set[str], str]: for file_name in cls.authors_file_names: - path = Path(git_ctx.repository_path) / file_name + if git_ctx.repository_path: + path = Path(git_ctx.repository_path) / file_name + else: + path = Path(file_name) if path.exists(): authors_file = path break diff --git a/gitlint-core/gitlint/rules.py b/gitlint-core/gitlint/rules.py index 58b334f..9ac9a79 100644 --- a/gitlint-core/gitlint/rules.py +++ b/gitlint-core/gitlint/rules.py @@ -2,7 +2,7 @@ import copy import logging import re from dataclasses import dataclass, field -from typing import ClassVar, Dict, List, Optional +from typing import ClassVar, Dict, List, Optional, Type from gitlint.deprecation import Deprecation from gitlint.exception import GitlintError @@ -21,10 +21,10 @@ class Rule: """Class representing gitlint rules.""" # Class attributes - options_spec: ClassVar[List] = [] + options_spec: ClassVar[List[RuleOption]] = [] id: ClassVar[str] name: ClassVar[str] - target: ClassVar[Optional["LineRuleTarget"]] = None + target: ClassVar[Optional[Type["LineRuleTarget"]]] = None _log: ClassVar[Optional[logging.Logger]] = None # Instance attributes diff --git a/gitlint-core/gitlint/utils.py b/gitlint-core/gitlint/utils.py index ba4f956..e55983a 100644 --- a/gitlint-core/gitlint/utils.py +++ b/gitlint-core/gitlint/utils.py @@ -15,7 +15,7 @@ LOG_FORMAT = "%(levelname)s: %(name)s %(message)s" # PLATFORM_IS_WINDOWS -def platform_is_windows(): +def platform_is_windows() -> bool: return "windows" in platform.system().lower() @@ -26,7 +26,7 @@ PLATFORM_IS_WINDOWS = platform_is_windows() # Encoding used for terminal encoding/decoding. -def getpreferredencoding(): +def getpreferredencoding() -> str: """Modified version of local.getpreferredencoding() that takes into account LC_ALL, LC_CTYPE, LANG env vars on windows and falls back to UTF-8.""" fallback_encoding = "UTF-8" @@ -38,8 +38,8 @@ def getpreferredencoding(): if PLATFORM_IS_WINDOWS: preferred_encoding = fallback_encoding for env_var in ["LC_ALL", "LC_CTYPE", "LANG"]: - encoding = os.environ.get(env_var, False) - if encoding: + encoding = os.environ.get(env_var, None) + if encoding is not None: # Support dotted (C.UTF-8) and non-dotted (C or UTF-8) charsets: # If encoding contains a dot: split and use second part, otherwise use everything dot_index = encoding.find(".") diff --git a/pyproject.toml b/pyproject.toml index 10a7e81..391d2e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,8 @@ dependencies = [ "ruff==0.0.252", "radon==5.1.0", "pdbr==0.8.2; sys_platform != \"win32\"", + "mypy==1.1.1", + "types-python-dateutil==2.8.19.12" ] [tool.hatch.envs.test.scripts] @@ -101,16 +103,18 @@ u = "unit-tests" unit-tests-no-cov = "pytest -rw -s {args:gitlint-core}" format = "black --check --diff {args:.}" lint = "ruff {args:gitlint-core/gitlint qa}" +type-check = "mypy {args}" autoformat = "black {args:.}" autofix = [ "- ruff --fix {args:gitlint-core/gitlint qa}", - "autoformat", # + "autoformat", # ] all = [ "unit-tests", "format", - "lint", # + "lint", + "type-check", # ] stats = ["./tools/stats.sh"] @@ -202,3 +206,50 @@ branch = true # measure branch coverage in addition to statement coverage [tool.coverage.report] fail_under = 97 show_missing = true + +[tool.mypy] +# Selectively enable strictness options +warn_unused_configs = true +warn_redundant_casts = true +warn_unused_ignores = true +no_implicit_optional = true +strict_equality = true +strict_concatenate = true +disallow_subclassing_any = true +disallow_untyped_decorators = true +disallow_any_generics = true +warn_return_any = true +disallow_untyped_calls = true +disallow_incomplete_defs = true + +# The following options are disabled because they're too strict for now +# check_untyped_defs = true +# disallow_untyped_defs = true +# no_implicit_reexport = true + +exclude = [ + "hatch_build.py", + "tools/*", + "gitlint-core/gitlint/tests/samples/user_rules/import_exception/invalid_python.py", +] +files = ["."] +# Minimum supported python version by gitlint is 3.7, so enforce 3.7 typing semantics +python_version = "3.7" + +[[tool.mypy.overrides]] +# Ignore "Dataclass attribute may only be overridden by another attribute" errors in git.py +module = "gitlint.git" +disable_error_code = "misc" + +[[tool.mypy.overrides]] +# Ignore in gitlint/__init__.py: +# - Cannot find implementation or library stub for module named "importlib_metadata" [import] +# - Call to untyped function "version" in typed context [no-untyped-call]" (Python 3.7) +module = "gitlint" +disable_error_code = ["import", "no-untyped-call"] + +[[tool.mypy.overrides]] +# Ignore all errors in qa/shell.py (excluding this file isn't working because mypy include/exclude semantics +# are unintuitive, so we ignore all errors instead) +module = "qa.shell" +ignore_errors = true diff --git a/qa/utils.py b/qa/utils.py index d560d86..752e1bb 100644 --- a/qa/utils.py +++ b/qa/utils.py @@ -6,7 +6,7 @@ import platform # PLATFORM_IS_WINDOWS -def platform_is_windows(): +def platform_is_windows() -> bool: return "windows" in platform.system().lower() @@ -19,7 +19,7 @@ PLATFORM_IS_WINDOWS = platform_is_windows() # However, we want to be able to overwrite this behavior for testing using the GITLINT_QA_USE_SH_LIB env var. -def use_sh_library(): +def use_sh_library() -> bool: gitlint_use_sh_lib_env = os.environ.get("GITLINT_QA_USE_SH_LIB", None) if gitlint_use_sh_lib_env: return gitlint_use_sh_lib_env == "1" @@ -33,7 +33,7 @@ USE_SH_LIB = use_sh_library() # Encoding for reading gitlint command output -def getpreferredencoding(): +def getpreferredencoding() -> str: """Use local.getpreferredencoding() or fallback to UTF-8.""" return locale.getpreferredencoding() or "UTF-8" |