"""Generate mypy config.""" from __future__ import annotations from collections.abc import Iterable import configparser import io import os from pathlib import Path from typing import Final from homeassistant.const import REQUIRED_PYTHON_VER from .model import Config, Integration # Component modules which should set no_implicit_reexport = true. NO_IMPLICIT_REEXPORT_MODULES: set[str] = { "homeassistant.components", "homeassistant.components.application_credentials.*", "homeassistant.components.diagnostics.*", "homeassistant.components.spotify.*", "homeassistant.components.stream.*", "homeassistant.components.update.*", } HEADER: Final = """ # Automatically generated by hassfest. # # To update, run python3 -m script.hassfest -p mypy_config """.lstrip() GENERAL_SETTINGS: Final[dict[str, str]] = { "python_version": ".".join(str(x) for x in REQUIRED_PYTHON_VER[:2]), "platform": "linux", "plugins": "pydantic.mypy", "show_error_codes": "true", "follow_imports": "normal", # "enable_incomplete_feature": ", ".join( # noqa: FLY002 # [] # ), # Enable some checks globally. "local_partial_types": "true", "strict_equality": "true", "no_implicit_optional": "true", "report_deprecated_as_error": "true", "warn_incomplete_stub": "true", "warn_redundant_casts": "true", "warn_unused_configs": "true", "warn_unused_ignores": "true", "enable_error_code": ", ".join( # noqa: FLY002 [ "ignore-without-code", "redundant-self", "truthy-iterable", ] ), "disable_error_code": ", ".join( # noqa: FLY002 [ "annotation-unchecked", "import-not-found", "import-untyped", ] ), # Impractical in real code # E.g. this breaks passthrough ParamSpec typing with Concatenate "extra_checks": "false", } # This is basically the list of checks which is enabled for "strict=true". # "strict=false" in config files does not turn strict settings off if they've been # set in a more general section (it instead means as if strict was not specified at # all), so we need to list all checks manually to be able to flip them wholesale. STRICT_SETTINGS: Final[list[str]] = [ "check_untyped_defs", "disallow_incomplete_defs", "disallow_subclassing_any", "disallow_untyped_calls", "disallow_untyped_decorators", "disallow_untyped_defs", "warn_return_any", "warn_unreachable", # TODO: turn these on, address issues # "disallow_any_generics", # "no_implicit_reexport", ] # Strict settings are already applied for core files. # To enable granular typing, add additional settings if core files are given. STRICT_SETTINGS_CORE: Final[list[str]] = [ "disallow_any_generics", ] # Plugin specific settings # Bump mypy cache when updating! Some plugins don't invalidate the cache properly. # pydantic: https://docs.pydantic.dev/mypy_plugin/#plugin-settings PLUGIN_CONFIG: Final[dict[str, dict[str, str]]] = { "pydantic-mypy": { "init_forbid_extra": "true", "init_typed": "true", "warn_required_dynamic_aliases": "true", "warn_untyped_fields": "true", } } def _sort_within_sections(line_iter: Iterable[str]) -> Iterable[str]: """Sort lines within sections. Sections are defined as anything not delimited by a blank line or an octothorpe-prefixed comment line. """ section: list[str] = [] for line in line_iter: if line.startswith("#") or not line.strip(): yield from sorted(section) section.clear() yield line continue section.append(line) yield from sorted(section) def _get_strict_typing_path(config: Config) -> Path: return config.root / ".strict-typing" def _get_mypy_ini_path(config: Config) -> Path: return config.root / "mypy.ini" def _generate_and_validate_strict_typing(config: Config) -> str: """Validate and generate strict_typing.""" lines = [ line.strip() for line in _get_strict_typing_path(config).read_text().splitlines() ] return "\n".join(_sort_within_sections(lines)) + "\n" def _generate_and_validate_mypy_config(config: Config) -> str: """Validate and generate mypy config.""" # Filter empty and commented lines. parsed_modules: list[str] = [ line.strip() for line in config.cache["strict_typing"].splitlines() if line.strip() != "" and not line.startswith("#") ] strict_modules: list[str] = [] strict_core_modules: list[str] = [] for module in parsed_modules: if module.startswith("homeassistant.components"): strict_modules.append(module) else: strict_core_modules.append(module) # Validate that all modules exist. all_modules = ( strict_modules + strict_core_modules + list(NO_IMPLICIT_REEXPORT_MODULES) ) for module in all_modules: if module.endswith(".*"): module_path = Path(module[:-2].replace(".", os.path.sep)) if not module_path.is_dir(): config.add_error("mypy_config", f"Module '{module} is not a folder") else: module = module.replace(".", os.path.sep) module_path = Path(f"{module}.py") if module_path.is_file(): continue module_path = Path(module) / "__init__.py" if not module_path.is_file(): config.add_error("mypy_config", f"Module '{module} doesn't exist") # Don't generate mypy.ini if there're errors found because it will likely crash. if any(err.plugin == "mypy_config" for err in config.errors): return "" mypy_config = configparser.ConfigParser() general_section = "mypy" mypy_config.add_section(general_section) for key, value in GENERAL_SETTINGS.items(): mypy_config.set(general_section, key, value) for key in STRICT_SETTINGS: mypy_config.set(general_section, key, "true") for plugin_name, plugin_config in PLUGIN_CONFIG.items(): if not plugin_config: continue mypy_config.add_section(plugin_name) for key, value in plugin_config.items(): mypy_config.set(plugin_name, key, value) # By default enable no_implicit_reexport only for homeassistant.* # Disable it afterwards for all components components_section = "mypy-homeassistant.*" mypy_config.add_section(components_section) mypy_config.set(components_section, "no_implicit_reexport", "true") for core_module in strict_core_modules: core_section = f"mypy-{core_module}" mypy_config.add_section(core_section) for key in STRICT_SETTINGS_CORE: mypy_config.set(core_section, key, "true") # By default strict checks are disabled for components. components_section = "mypy-homeassistant.components.*" mypy_config.add_section(components_section) for key in STRICT_SETTINGS: mypy_config.set(components_section, key, "false") mypy_config.set(components_section, "no_implicit_reexport", "false") for strict_module in strict_modules: strict_section = f"mypy-{strict_module}" mypy_config.add_section(strict_section) for key in STRICT_SETTINGS: mypy_config.set(strict_section, key, "true") if strict_module in NO_IMPLICIT_REEXPORT_MODULES: mypy_config.set(strict_section, "no_implicit_reexport", "true") for reexport_module in sorted( NO_IMPLICIT_REEXPORT_MODULES.difference(strict_modules) ): reexport_section = f"mypy-{reexport_module}" mypy_config.add_section(reexport_section) mypy_config.set(reexport_section, "no_implicit_reexport", "true") # Disable strict checks for tests tests_section = "mypy-tests.*" mypy_config.add_section(tests_section) for key in STRICT_SETTINGS: mypy_config.set(tests_section, key, "false") with io.StringIO() as fp: mypy_config.write(fp) fp.seek(0) return f"{HEADER}{fp.read().strip()}\n" def validate(integrations: dict[str, Integration], config: Config) -> None: """Validate strict_typing and mypy config.""" strict_typing_content = _generate_and_validate_strict_typing(config) config.cache["strict_typing"] = strict_typing_content mypy_content = _generate_and_validate_mypy_config(config) config.cache["mypy_config"] = mypy_content if any(err.plugin == "mypy_config" for err in config.errors): return if _get_strict_typing_path(config).read_text() != strict_typing_content: config.add_error( "mypy_config", "File .strict_typing is not up to date. Run python3 -m script.hassfest", fixable=True, ) if _get_mypy_ini_path(config).read_text() != mypy_content: config.add_error( "mypy_config", "File mypy.ini is not up to date. Run python3 -m script.hassfest", fixable=True, ) def generate(integrations: dict[str, Integration], config: Config) -> None: """Generate strict_typing and mypy config.""" _get_mypy_ini_path(config).write_text(config.cache["mypy_config"]) _get_strict_typing_path(config).write_text(config.cache["strict_typing"])