diff --git a/README.md b/README.md index 2dc1f99f..40f9882a 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ * Support same features as `pydantic.BaseModel` * (After PyCharm 2020.1 and this plugin version 0.1.0, PyCharm treats `pydantic.dataclasses.dataclass` as third-party dataclass.) -### Inspection for type-checking +### Inspection for type-checking (Experimental) **In version 0.1.1, This feature is broken. Please use it in [0.1.2](https://github.com/koxudaxi/pydantic-pycharm-plugin/releases/tag/0.1.2) or later.** This plugin provides an inspection for type-checking, which is compatible with pydantic. @@ -36,7 +36,7 @@ Don't use this type checker with a builtin type checker same time. ![inspection 1](https://raw.githubusercontent.com/koxudaxi/pydantic-pycharm-plugin/master/docs/inspection1.png) -### Parsable Type +### Parsable Type (Experimental) Pydantic has lots of support for coercing types. However, PyCharm gives a message saying only `Expected type "x," got "y" instead:` When you set parsable-type on a type, then the message will be changed to `Field is of type "x", "y" may not be parsable to "x"` @@ -56,6 +56,48 @@ str = ["int", "float"] # datetime.datetime field may parse int "datetime.datetime" = [ "int" ] + +# your_module.your_type field may parse str +"your_module.your_type" = [ "str" ] + +[tool.pydantic-pycharm-plugin] +# You can set higlith level (default is "warning") +# You can select it from "warning", "weak_warning", "disable" +parsable-type-highlight = "warning" + +## If you set acceptable-type-highlight then, you have to set it at same depth. +acceptable-type-highlight = "disable" +``` + +### Acceptable Type (Experimental) +**This feature is in version [0.1.3](https://github.com/koxudaxi/pydantic-pycharm-plugin/releases/tag/0.1.3) or later.** + +Pydantic can always parse a few types to other types. For example, `int` to `str`. It always succeeds. +You can set it as an acceptable type. The message is `Field is of type 'x', 'y' is set as an acceptable type in pyproject.toml`. +Also,You may want to disable the message.You can do it, by setting "disable" on `acceptable-type-highlight`. + +#### Set acceptable-type in pyproject.toml +You should create `pyproject.toml` in your project root. +And, you define acceptable-type like a example. + +exapmle: + +```toml +[tool.pydantic-pycharm-plugin.acceptable-types] + +# str field accepts to parse int and float +str = ["int", "float"] + +# datetime.datetime field may parse int +"datetime.datetime" = [ "int" ] + +[tool.pydantic-pycharm-plugin] +# You can set higlith level (default is "weak_warning") +# You can select it from "warning", "weak_warning", "disable" +acceptable-type-highlight = "disable" + +# If you set parsable-type-highlight then, you have to set it at same depth. +parsable-type-highlight = "warning" ``` #### Related issues diff --git a/resources/META-INF/plugin.xml b/resources/META-INF/plugin.xml index 45643bf2..ca87d01e 100644 --- a/resources/META-INF/plugin.xml +++ b/resources/META-INF/plugin.xml @@ -7,6 +7,7 @@

version 0.1.3

Features

version 0.1.2

diff --git a/src/com/koxudaxi/pydantic/PydanticConfigService.kt b/src/com/koxudaxi/pydantic/PydanticConfigService.kt index a21162fd..2fa6cd82 100644 --- a/src/com/koxudaxi/pydantic/PydanticConfigService.kt +++ b/src/com/koxudaxi/pydantic/PydanticConfigService.kt @@ -15,6 +15,9 @@ class PydanticConfigService : PersistentStateComponent { var pyprojectToml: String? = null var parsableTypeMap = mutableMapOf>() var parsableTypeHighlightType: ProblemHighlightType = ProblemHighlightType.WARNING + var acceptableTypeMap = mutableMapOf>() + var acceptableTypeHighlightType: ProblemHighlightType = ProblemHighlightType.WEAK_WARNING + override fun getState(): PydanticConfigService { return this } diff --git a/src/com/koxudaxi/pydantic/PydanticInitializer.kt b/src/com/koxudaxi/pydantic/PydanticInitializer.kt index 7c2e4333..d89c2434 100644 --- a/src/com/koxudaxi/pydantic/PydanticInitializer.kt +++ b/src/com/koxudaxi/pydantic/PydanticInitializer.kt @@ -50,25 +50,45 @@ class PydanticInitializer : StartupActivity { if (configFile is VirtualFile) { loadPyprojecToml(configFile, configService) } else { - configService.parsableTypeMap.clear() - configService.parsableTypeHighlightType = ProblemHighlightType.WARNING + clear(configService) } } + private fun clear(configService: PydanticConfigService) { + configService.parsableTypeMap.clear() + configService.acceptableTypeMap.clear() + configService.parsableTypeHighlightType = ProblemHighlightType.WARNING + configService.acceptableTypeHighlightType = ProblemHighlightType.WEAK_WARNING + } + private fun loadPyprojecToml(config: VirtualFile, configService: PydanticConfigService) { val result: TomlParseResult = Toml.parse(config.inputStream) - val table = result.getTableOrEmpty("tool.pydantic-pycharm-plugin") ?: return + val table = result.getTableOrEmpty("tool.pydantic-pycharm-plugin") + if (table.isEmpty) { + clear(configService) + return + } + val temporaryParsableTypeMap = getTypeMap("parsable-types", table) if (configService.parsableTypeMap != temporaryParsableTypeMap) { configService.parsableTypeMap = temporaryParsableTypeMap } - configService.parsableTypeHighlightType = getHighlightLevel(table, "parsable-type-highlight") + val temporaryAcceptableTypeMap = getTypeMap("acceptable-types", table) + if (configService.acceptableTypeMap != temporaryAcceptableTypeMap) { + configService.acceptableTypeMap = temporaryAcceptableTypeMap + } + + configService.parsableTypeHighlightType = getHighlightLevel(table, "parsable-type-highlight", ProblemHighlightType.WARNING) + configService.acceptableTypeHighlightType = getHighlightLevel(table, "acceptable-type-highlight", ProblemHighlightType.WEAK_WARNING) } - private fun getHighlightLevel(table: TomlTable, path: String): ProblemHighlightType { + private fun getHighlightLevel(table: TomlTable, path: String, default: ProblemHighlightType): ProblemHighlightType { return when (table.get(path) as? String) { + "warning" -> { + ProblemHighlightType.WARNING + } "weak_warning" -> { ProblemHighlightType.WEAK_WARNING } @@ -76,7 +96,7 @@ class PydanticInitializer : StartupActivity { ProblemHighlightType.INFORMATION } else -> { - ProblemHighlightType.WARNING + default } } } diff --git a/src/com/koxudaxi/pydantic/PydanticTypeCheckerInspection.kt b/src/com/koxudaxi/pydantic/PydanticTypeCheckerInspection.kt index b86bf84b..0c72bc56 100644 --- a/src/com/koxudaxi/pydantic/PydanticTypeCheckerInspection.kt +++ b/src/com/koxudaxi/pydantic/PydanticTypeCheckerInspection.kt @@ -1,9 +1,9 @@ package com.koxudaxi.pydantic import com.intellij.codeInspection.LocalInspectionToolSession -import com.intellij.codeInspection.ProblemHighlightType import com.intellij.codeInspection.ProblemsHolder import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project import com.intellij.openapi.util.Key import com.intellij.psi.PsiElementVisitor import com.jetbrains.python.codeInsight.typing.matchingProtocolDefinitions @@ -45,19 +45,39 @@ class PydanticTypeCheckerInspection : PyTypeCheckerInspection() { .forEach { mapping: PyArgumentsMapping -> analyzeCallee(callSite, mapping) } } + private fun getParsableTypeFromTypeMap(typeForParameter: PyType, cache: MutableMap): PyType? { + return getTypeFromTypeMap( + { project: Project -> PydanticConfigService.getInstance(project).parsableTypeMap }, + typeForParameter, + cache + ) + } - private fun getParsableType(typeForParameter: PyType): PyType? { - val pyTypes: MutableSet = mutableSetOf(typeForParameter) - val project = holder!!.project - getPyClassTypeByPyTypes(typeForParameter).toSet().forEach { type -> - val classQName: String? = type.classQName + private fun getAcceptableTypeFromTypeMap(typeForParameter: PyType, cache: MutableMap): PyType? { + return getTypeFromTypeMap( + { project: Project -> PydanticConfigService.getInstance(project).acceptableTypeMap }, + typeForParameter, + cache + ) + } - PydanticConfigService.getInstance(project).parsableTypeMap[classQName]?.mapNotNull { - createPyClassTypeImpl(it, project, myTypeEvalContext) - ?: createPyClassTypeImpl(it, project, myTypeEvalContext) - }?.toCollection(pyTypes) + private fun getTypeFromTypeMap(getTypeMap: (project: Project) -> (MutableMap>), typeForParameter: PyType, cache: MutableMap): PyType? { + return when { + cache.containsKey(typeForParameter) -> { + cache[typeForParameter] + } + else -> { + val project = holder!!.project + val typeMap = getTypeMap(project) + val unionType = PyUnionType.union(getPyClassTypeByPyTypes(typeForParameter).toSet().flatMap { type -> + typeMap[type.classQName]?.mapNotNull { + createPyClassTypeImpl(it, project, myTypeEvalContext) + } as? List ?: listOf() + }) + cache[typeForParameter] = unionType + unionType + } } - return PyUnionType.union(pyTypes) } private fun analyzeCallee(callSite: PyCallSiteExpression, mapping: PyArgumentsMapping) { @@ -65,32 +85,52 @@ class PydanticTypeCheckerInspection : PyTypeCheckerInspection() { val receiver = callSite.getReceiver(callableType.callable) val substitutions = PyTypeChecker.unifyReceiver(receiver, myTypeEvalContext) val mappedParameters = mapping.mappedParameters + val cachedParsableTypeMap = mutableMapOf() + val cachedAcceptableTypeMap = mutableMapOf() for ((argument, parameter) in PyCallExpressionHelper.getRegularMappedParameters(mappedParameters)) { val expected = parameter.getArgumentType(myTypeEvalContext) - val parsableType = expected?.let { getParsableType(it) } val actual = promoteToLiteral(argument, expected, myTypeEvalContext) val strictMatched = matchParameterAndArgument(expected, actual, argument, substitutions) val strictResult = AnalyzeArgumentResult(argument, expected, substituteGenerics(expected, substitutions), actual, strictMatched) if (!strictResult.isMatched) { val expectedType = PythonDocumentationProvider.getTypeName(strictResult.expectedType, myTypeEvalContext) val actualType = PythonDocumentationProvider.getTypeName(strictResult.actualType, myTypeEvalContext) - val parsableMatched = matchParameterAndArgument(parsableType, actual, argument, substitutions) - val parsableResult = AnalyzeArgumentResult(argument, parsableType, substituteGenerics(parsableType, substitutions), actual, parsableMatched) - if (parsableResult.isMatched) { - registerProblem( - argument, - String.format("Field is of type '%s', '%s' may not be parsable to '%s'", - expectedType, - actualType, - expectedType), - pydanticConfigService.parsableTypeHighlightType - ) - } else { - registerProblem(argument, String.format("Expected type '%s', got '%s' instead", - expectedType, - actualType) - ) + if (expected is PyType) { + val parsableType = getParsableTypeFromTypeMap(expected, cachedParsableTypeMap) + if (parsableType != null) { + val parsableMatched = matchParameterAndArgument(parsableType, actual, argument, substitutions) + if (AnalyzeArgumentResult(argument, parsableType, substituteGenerics(parsableType, substitutions), actual, parsableMatched).isMatched) { + registerProblem( + argument, + String.format("Field is of type '%s', '%s' may not be parsable to '%s'", + expectedType, + actualType, + expectedType), + pydanticConfigService.parsableTypeHighlightType + ) + continue + } + } + val acceptableType = getAcceptableTypeFromTypeMap(expected, cachedAcceptableTypeMap) + if (acceptableType != null) { + val acceptableMatched = matchParameterAndArgument(acceptableType, actual, argument, substitutions) + if (AnalyzeArgumentResult(argument, acceptableType, substituteGenerics(acceptableType, substitutions), actual, acceptableMatched).isMatched) { + registerProblem( + argument, + String.format("Field is of type '%s', '%s' is set as an acceptable type in pyproject.toml", + expectedType, + actualType, + expectedType), + pydanticConfigService.acceptableTypeHighlightType + ) + continue + } + } } + registerProblem(argument, String.format("Expected type '%s', got '%s' instead", + expectedType, + actualType) + ) } } } diff --git a/testData/typecheckerinspection/acceptableType.py b/testData/typecheckerinspection/acceptableType.py new file mode 100644 index 00000000..a652429b --- /dev/null +++ b/testData/typecheckerinspection/acceptableType.py @@ -0,0 +1,12 @@ +from builtins import * +from typing import Union + +from pydantic import BaseModel + + +class A(BaseModel): + a: str + + +A(a=str('123')) +A(a=int(123)) diff --git a/testData/typecheckerinspection/acceptableTypeDisable.py b/testData/typecheckerinspection/acceptableTypeDisable.py new file mode 100644 index 00000000..2e60eb16 --- /dev/null +++ b/testData/typecheckerinspection/acceptableTypeDisable.py @@ -0,0 +1,12 @@ +from builtins import * +from typing import Union + +from pydantic import BaseModel + + +class A(BaseModel): + a: str + b: str + +A(a=str('123'), b=str('123')) +A(a=int(123), b=int(123)) diff --git a/testData/typecheckerinspection/acceptableTypeInvalid.py b/testData/typecheckerinspection/acceptableTypeInvalid.py new file mode 100644 index 00000000..1c410a84 --- /dev/null +++ b/testData/typecheckerinspection/acceptableTypeInvalid.py @@ -0,0 +1,11 @@ +from builtins import * +from typing import Union + +from pydantic import BaseModel + + +class A(BaseModel): + a: str + +A(a=str('123')) +A(a=bytes(123)) diff --git a/testData/typecheckerinspection/acceptableTypeWarning.py b/testData/typecheckerinspection/acceptableTypeWarning.py new file mode 100644 index 00000000..f565a65e --- /dev/null +++ b/testData/typecheckerinspection/acceptableTypeWarning.py @@ -0,0 +1,12 @@ +from builtins import * +from typing import Union + +from pydantic import BaseModel + + +class A(BaseModel): + a: str + + +A(a=str('123')) +A(a=int(123)) diff --git a/testData/typecheckerinspection/parsableTypeDisable.py b/testData/typecheckerinspection/parsableTypeDisable.py index 4c544405..2e60eb16 100644 --- a/testData/typecheckerinspection/parsableTypeDisable.py +++ b/testData/typecheckerinspection/parsableTypeDisable.py @@ -6,7 +6,7 @@ class A(BaseModel): a: str + b: str - -A(a=str('123')) -A(a=int(123)) +A(a=str('123'), b=str('123')) +A(a=int(123), b=int(123)) diff --git a/testSrc/com/koxudaxi/pydantic/PydanticTypeCheckerInspectionTest.kt b/testSrc/com/koxudaxi/pydantic/PydanticTypeCheckerInspectionTest.kt index 7fa13db4..80b1bcde 100644 --- a/testSrc/com/koxudaxi/pydantic/PydanticTypeCheckerInspectionTest.kt +++ b/testSrc/com/koxudaxi/pydantic/PydanticTypeCheckerInspectionTest.kt @@ -30,10 +30,33 @@ open class PydanticTypeCheckerInspectionTest : PydanticInspectionBase() { fun testParsableTypeInvalid() { val pydanticConfigService = PydanticConfigService.getInstance(myFixture!!.project) - pydanticConfigService.parsableTypeMap["builtins.str"] = arrayListOf("int") + pydanticConfigService.parsableTypeMap["builtins.str"] = arrayListOf("builtins.int") + doTest() + } + + fun testAcceptableType() { + val pydanticConfigService = PydanticConfigService.getInstance(myFixture!!.project) + pydanticConfigService.acceptableTypeMap["builtins.str"] = arrayListOf("builtins.int") + doTest() + } + fun testAcceptableTypeWarning() { + val pydanticConfigService = PydanticConfigService.getInstance(myFixture!!.project) + pydanticConfigService.acceptableTypeMap["builtins.str"] = arrayListOf("builtins.int") + pydanticConfigService.acceptableTypeHighlightType = ProblemHighlightType.WARNING + doTest() + } + fun testAcceptableTypeDisable() { + val pydanticConfigService = PydanticConfigService.getInstance(myFixture!!.project) + pydanticConfigService.acceptableTypeMap["builtins.str"] = arrayListOf("builtins.int") + pydanticConfigService.acceptableTypeHighlightType = ProblemHighlightType.INFORMATION doTest() } + fun testAcceptableTypeInvalid() { + val pydanticConfigService = PydanticConfigService.getInstance(myFixture!!.project) + pydanticConfigService.acceptableTypeMap["builtins.str"] = arrayListOf("builtins.int") + doTest() + } fun testField() { doTest() }