Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix unresolved ClassVar attribute error #919

Merged
merged 8 commits into from
Apr 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Changelog

## [Unreleased]
- Fix unresolved ClassVar attribute error [[#919](https://github.com/koxudaxi/pydantic-pycharm-plugin/pull/919)]

## [0.4.12] - 2024-03-14

Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ pluginGroup = com.koxudaxi.pydantic
pluginName = Pydantic
pluginRepositoryUrl = https://github.com/koxudaxi/pydantic-pycharm-plugin
# SemVer format -> https://semver.org
pluginVersion = 0.4.12
pluginVersion = 0.4.13

# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
pluginSinceBuild = 233.11799.241
Expand Down
61 changes: 33 additions & 28 deletions src/com/koxudaxi/pydantic/Pydantic.kt
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ const val ANY_Q_NAME = "typing.Any"
const val OPTIONAL_Q_NAME = "typing.Optional"
const val UNION_Q_NAME = "typing.Union"
const val ANNOTATED_Q_NAME = "typing.Annotated"
const val CLASSVAR_Q_NAME = "typing.ClassVar"
const val GENERIC_Q_NAME = "typing.Generic"
const val TYPE_Q_NAME = "typing.Type"
const val TUPLE_Q_NAME = "typing.Tuple"
Expand Down Expand Up @@ -319,11 +318,11 @@ internal fun isValidatorField(stringLiteralExpression: StringLiteralExpression,
return isPydanticModel(pyClass, true, typeEvalContext)
}

internal fun getClassVariables(pyClass: PyClass, context: TypeEvalContext): Sequence<PyTargetExpression> {
internal fun getClassVariables(pyClass: PyClass, context: TypeEvalContext, includeClassVar: Boolean): Sequence<PyTargetExpression> {
return pyClass.classAttributes
.asReversed()
.asSequence()
.filterNot { PyTypingTypeProvider.isClassVar(it, context) }
.asReversed()
.asSequence()
.filter { includeClassVar || !PyTypingTypeProvider.isClassVar(it, context) }
}

private fun getAliasedFieldName(
Expand All @@ -335,14 +334,14 @@ private fun getAliasedFieldName(
val assignedField = field.findAssignedValue()?.let {
getFieldFromPyExpression(it, context, pydanticVersion)
} ?: (field.annotation?.value as? PySubscriptionExpression)
?.takeIf { getQualifiedName(it, context) == ANNOTATED_Q_NAME }
?.let { getFieldFromAnnotated(it, context) }
?.takeIf { getQualifiedName(it, context) == ANNOTATED_Q_NAME }
?.let { getFieldFromAnnotated(it, context) }
?: return fieldName

return when (val alias = assignedField.getKeywordArgument("alias")) {
is StringLiteralExpression -> alias.stringValue
is PyReferenceExpression -> ((alias.reference.resolve() as? PyTargetExpressionImpl)
?.findAssignedValue() as? StringLiteralExpression)?.stringValue
?.findAssignedValue() as? StringLiteralExpression)?.stringValue
//TODO Support dynamic assigned Value. eg: Schema(..., alias=get_alias_name(field_name))
else -> fieldName
} ?: fieldName
Expand All @@ -358,7 +357,7 @@ fun getResolvedPsiElements(referenceExpression: PyReferenceExpression, context:
) {
val resolveContext = PyResolveContext.defaultContext(context)
PyUtil.filterTopPriorityResults(
referenceExpression.getReference(resolveContext).multiResolve(false)
referenceExpression.getReference(resolveContext).multiResolve(false)
)
} ?: emptyList()
}
Expand Down Expand Up @@ -386,7 +385,7 @@ val PyType.isNullable: Boolean

fun isPydanticSchemaByPsiElement(psiElement: PsiElement, context: TypeEvalContext): Boolean {
return (psiElement as? PyClass ?: PsiTreeUtil.getContextOfType(psiElement, PyClass::class.java))
?.let { isPydanticSchema(it, context) } ?: false
?.let { isPydanticSchema(it, context) } ?: false

}

Expand Down Expand Up @@ -434,12 +433,12 @@ fun getPsiElementByQualifiedName(
return qualifiedName.resolveToElement(QNameResolveContext(contextAnchor, pythonSdk, context))
}

fun isValidField(field: PyTargetExpression, context: TypeEvalContext, isV2: Boolean): Boolean {
fun isValidField(field: PyTargetExpression, context: TypeEvalContext, isV2: Boolean, includeClassVar: Boolean): Boolean {
if (field.name?.isValidFieldName(isV2) != true) return false

val annotationValue = field.annotation?.value ?: return true
// TODO Support a variable.
return getQualifiedName(annotationValue, context) != CLASSVAR_Q_NAME
// TODO Support a variable.
if (includeClassVar) return true
return !PyTypingTypeProvider.isClassVar(field, context)
}

fun String.isValidFieldName(isV2: Boolean): Boolean = (!startsWith('_') || this == CUSTOM_ROOT_FIELD) && !(isV2 && this.startsWith(MODEL_FIELD_PREFIX))
Expand All @@ -457,6 +456,7 @@ fun getConfigValue(name: String, value: Any?, context: TypeEvalContext): Any? {
is Boolean -> value
else -> null
}

ConfigType.LIST_PYTYPE -> {
if (value is PyElement) {
when (val tupleValue = PsiTreeUtil.findChildOfType(value, PyTupleExpression::class.java)) {
Expand All @@ -465,6 +465,7 @@ fun getConfigValue(name: String, value: Any?, context: TypeEvalContext): Any? {
}
} else null
}

ConfigType.EXTRA -> {
when ((value as? PyStringLiteralExpression)?.stringValue) {
"allow" -> EXTRA.ALLOW
Expand All @@ -481,8 +482,8 @@ fun validateConfig(pyClass: PyClass, context: TypeEvalContext): List<PsiElement>
val configClass = pyClass.nestedClasses.firstOrNull { it.isConfigClass } ?: return null
val allowedConfigKwargs = PydanticCacheService.getAllowedConfigKwargs(pyClass.project, context) ?: return null
val configKwargs = pyClass.superClassExpressions.filterIsInstance<PyKeywordArgument>()
.filter { allowedConfigKwargs.contains(it.name) }
.takeIf { it.isNotEmpty() } ?: return null
.filter { allowedConfigKwargs.contains(it.name) }
.takeIf { it.isNotEmpty() } ?: return null

val results: MutableList<PsiElement> = configKwargs.toMutableList()
configClass.nameNode?.psi?.let { results.add(it) }
Expand Down Expand Up @@ -581,6 +582,7 @@ fun getFieldName(
config["allow_population_by_alias"] == true -> field.name
else -> getAliasedFieldName(field, context, pydanticVersion)
}

2 -> when {
config["populate_by_name"] == true -> field.name
else -> getAliasedFieldName(field, context, pydanticVersion)
Expand All @@ -598,7 +600,8 @@ fun getPydanticBaseConfig(project: Project, context: TypeEvalContext): PyClass?
}

fun getPydanticConfigDictDefaults(project: Project, context: TypeEvalContext): PyCallExpression? {
val targetExpression = getPyTargetExpressionFromQualifiedName(CONFIG_DICT_DEFAULTS_QUALIFIED_NAME, project, context) ?: return null
val targetExpression = getPyTargetExpressionFromQualifiedName(CONFIG_DICT_DEFAULTS_QUALIFIED_NAME, project, context)
?: return null
return targetExpression.findAssignedValue() as? PyCallExpression
}

Expand All @@ -620,6 +623,7 @@ fun getPyClassFromQualifiedName(qualifiedName: QualifiedName, project: Project,
fun getPyTargetExpressionFromQualifiedName(qualifiedName: QualifiedName, project: Project, context: TypeEvalContext): PyTargetExpression? {
return getPsiElementFromQualifiedName(qualifiedName, project, context) as? PyTargetExpression
}

fun getPyClassByAttribute(pyPsiElement: PsiElement?): PyClass? {
return pyPsiElement?.parent?.parent as? PyClass
}
Expand Down Expand Up @@ -723,9 +727,9 @@ internal fun isUntouchedClass(
}

internal fun getFieldFromPyExpression(
psiElement: PsiElement,
context: TypeEvalContext,
pydanticVersion: KotlinVersion?,
psiElement: PsiElement,
context: TypeEvalContext,
pydanticVersion: KotlinVersion?,
): PyCallExpression? {
if (psiElement !is PyCallExpression) return null
val versionZero = pydanticVersion?.major == 0
Expand Down Expand Up @@ -768,7 +772,7 @@ internal fun getDefaultFromField(field: PyCallExpression, context: TypeEvalConte
}

internal fun getDefaultFactoryFromField(field: PyCallExpression): PyExpression? =
field.getKeywordArgument("default_factory")
field.getKeywordArgument("default_factory")

internal fun getQualifiedName(pyExpression: PyExpression, context: TypeEvalContext): String? {
return when (pyExpression) {
Expand Down Expand Up @@ -822,17 +826,18 @@ val KotlinVersion?.isV2: Boolean

val Sdk.pydanticVersion: String?
get() = PyPackageManagers.getInstance()
.forSdk(this).packages?.find { it.name == "pydantic" }?.version
.forSdk(this).packages?.find { it.name == "pydantic" }?.version

internal fun isInInit(field: PyTargetExpression): Boolean {
val assignedValue = field.findAssignedValue() as? PyCallExpression ?: return true
val initValue = assignedValue.getKeywordArgument("init") ?: return true
return PyEvaluator.evaluateAsBoolean(initValue, true)
}

internal fun getPydanticField(pyClass: PyClass, typeEvalContext: TypeEvalContext, config: HashMap<String, Any?>, isV2: Boolean, isDataClass: Boolean, name: String? = null): Sequence<PyTargetExpression> =
getClassVariables(pyClass, typeEvalContext)
.filter { if (name is String) it.name == name else it.name != null }
.filterNot { isUntouchedClass(it.findAssignedValue(), config, typeEvalContext) }
.filter { isValidField(it, typeEvalContext, isV2) }
.filter { !isDataClass || isInInit(it) }
internal fun getPydanticField(pyClass: PyClass, typeEvalContext: TypeEvalContext, config: HashMap<String, Any?>, isV2: Boolean, isDataClass: Boolean, name: String? = null): Sequence<PyTargetExpression> {
return getClassVariables(pyClass, typeEvalContext, true)
.filter { if (name is String) it.name == name else it.name != null }
.filterNot { isUntouchedClass(it.findAssignedValue(), config, typeEvalContext) }
.filter { isValidField(it, typeEvalContext, isV2, true) }
.filter { !isDataClass || isInInit(it) }
}
8 changes: 4 additions & 4 deletions src/com/koxudaxi/pydantic/PydanticCompletionContributor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ class PydanticCompletionContributor : CompletionContributor() {
typeEvalContext)
}
.filter { attribute ->
isValidField(attribute, typeEvalContext, isV2)
isValidField(attribute, typeEvalContext, isV2, true)
}
.mapNotNull { attribute -> attribute?.name })
}
Expand All @@ -221,7 +221,7 @@ class PydanticCompletionContributor : CompletionContributor() {

fieldElements.addAll(pyClass.classAttributes
.filterNot { isUntouchedClass(it.findAssignedValue(), config, typeEvalContext) }
.filter { isValidField(it, typeEvalContext, isV2) }
.filter { isValidField(it, typeEvalContext, isV2, true) }
.mapNotNull { attribute -> attribute?.name })

result.runRemainingContributors(parameters)
Expand Down Expand Up @@ -437,10 +437,10 @@ class PydanticCompletionContributor : CompletionContributor() {
private fun addFieldCompletions(
pyClass: PyClass, typeEvalContext: TypeEvalContext, config: HashMap<String, Any?>, isV2: Boolean, isDataclass: Boolean, excludes: HashSet<String>, newElements: LinkedHashMap<String, LookupElement>) {

getClassVariables(pyClass, typeEvalContext)
getClassVariables(pyClass, typeEvalContext, true)
.filter { it.name != null }
.filterNot { isUntouchedClass(it.findAssignedValue(), config, typeEvalContext) }
.filter { isValidField(it, typeEvalContext, isV2) }
.filter { isValidField(it, typeEvalContext, isV2, true) }
.filter { !isDataclass || isInInit(it) }
.forEach {
val elementName = it.name!!
Expand Down
37 changes: 26 additions & 11 deletions src/com/koxudaxi/pydantic/PydanticInspection.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package com.koxudaxi.pydantic
import com.intellij.codeInspection.LocalInspectionToolSession
import com.intellij.codeInspection.ProblemHighlightType
import com.intellij.codeInspection.ProblemsHolder
import com.intellij.diff.comparison.trimEnd
import com.intellij.psi.PsiElementVisitor
import com.jetbrains.python.PyNames
import com.jetbrains.python.codeInsight.stdlib.PyDataclassTypeProvider
Expand All @@ -16,7 +15,10 @@ import com.jetbrains.python.psi.impl.PyCallExpressionImpl
import com.jetbrains.python.psi.impl.PyEvaluator
import com.jetbrains.python.psi.impl.PyTargetExpressionImpl
import com.jetbrains.python.psi.resolve.PyResolveContext
import com.jetbrains.python.psi.types.*
import com.jetbrains.python.psi.types.PyCallableType
import com.jetbrains.python.psi.types.PyClassType
import com.jetbrains.python.psi.types.PyTypeChecker
import com.jetbrains.python.psi.types.TypeEvalContext


class PydanticInspection : PyInspection() {
Expand Down Expand Up @@ -226,9 +228,9 @@ class PydanticInspection : PyInspection() {
pyClass.getAncestorClasses(myTypeEvalContext)
val parameters = (getAncestorPydanticModels(pyClass, false, myTypeEvalContext) + pyClass)
.flatMap { pydanticModel ->
getClassVariables(pydanticModel, myTypeEvalContext)
getClassVariables(pydanticModel, myTypeEvalContext, false)
.filter { it.name != null }
.filter { isValidField(it, myTypeEvalContext, pydanticCacheService.isV2) }
.filter { isValidField(it, myTypeEvalContext, pydanticCacheService.isV2, false) }
.map { it.name }
}.toSet()
pyCallExpression.arguments
Expand Down Expand Up @@ -426,21 +428,34 @@ class PydanticInspection : PyInspection() {
private fun inspectModelAttribute(node: PyQualifiedExpression) {
val qualifier = node.qualifier ?: return
val name = node.name ?: return
val pyClass = (myTypeEvalContext.getType(qualifier) as? PyClassType)?.pyClass ?: return
val pyClassType = myTypeEvalContext.getType(qualifier) as? PyClassType ?: return
val pyClass = pyClassType.pyClass
if (!isPydanticModel(pyClass, false, myTypeEvalContext)) return
if (pyClass.findNestedClass(name, true) is PyClass) return
if (pyClass.findProperty(name, true, myTypeEvalContext) != null) return
if (pyClass.findMethodByName(name, true, myTypeEvalContext) != null) return
val field = pyClass.findClassAttribute(name, true, myTypeEvalContext)
if (field is PyAnnotationOwner && PyTypingTypeProvider.isClassVar(field, myTypeEvalContext)) return

val pydanticVersion = PydanticCacheService.getVersion(pyClass.project)
val config = getConfig(pyClass, myTypeEvalContext, true)
getAncestorPydanticModels(pyClass, true, myTypeEvalContext).forEach {
if (hasAttribute(it, config, pydanticVersion.isV2, name)) return

// Check private field or model fields
if (name.startsWith("_") || pydanticVersion.isV2 && name.startsWith(MODEL_FIELD_PREFIX)) return

if (pyClassType.isDefinition) {
if(field == null && node.reference?.resolve() is PyTargetExpression) return
} else {
val config = getConfig(pyClass, myTypeEvalContext, true)
getAncestorPydanticModels(pyClass, true, myTypeEvalContext).forEach {
if (hasAttribute(it, config, pydanticVersion.isV2, name)) return
}
if (hasAttribute(pyClass, config, pydanticVersion.isV2, name)) return
}
if (hasAttribute(pyClass, config, pydanticVersion.isV2, name)) return
registerProblem(node.node.lastChildNode.psi, "Unresolved attribute reference '${name}' for class '${pyClass.name}' ")
}
private fun hasAttribute(pyClass: PyClass, config: HashMap<String, Any?>, isV2: Boolean, name: String): Boolean =
getPydanticField(pyClass, myTypeEvalContext, config, isV2, false, name)
.any()
getPydanticField(pyClass, myTypeEvalContext, config, isV2, false, name).any()

}

// override fun createOptionsPanel(): JComponent? {
Expand Down
6 changes: 3 additions & 3 deletions src/com/koxudaxi/pydantic/PydanticTypeProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ class PydanticTypeProvider : PyTypeProviderBase() {
baseClassCollected.putAll(current.attributes)
continue
}
baseClassCollected.putAll(getClassVariables(current, context)
baseClassCollected.putAll(getClassVariables(current, context, false)
.map { it to dynamicModelFieldToParameter(it, context, typed) }
.mapNotNull { (field, parameter) ->
parameter.name?.let { name -> Triple(field, parameter, name) }
Expand Down Expand Up @@ -508,7 +508,7 @@ class PydanticTypeProvider : PyTypeProviderBase() {
val current = currentType.pyClass
if (!isPydanticModel(current, false, context)) continue

getClassVariables(current, context)
getClassVariables(current, context, false)
.filterNot { isUntouchedClass(it.findAssignedValue(), config, context) }
.mapNotNull {
dynamicModelFieldToParameter(
Expand Down Expand Up @@ -549,7 +549,7 @@ class PydanticTypeProvider : PyTypeProviderBase() {
typed: Boolean = true,
isDataclass: Boolean = false,
): PyCallableParameter? {
if (!isValidField(field, context, pydanticVersion.isV2)) return null
if (!isValidField(field, context, pydanticVersion.isV2, false)) return null
if (!hasAnnotationValue(field) && !field.hasAssignedValue()) return null // skip fields that are invalid syntax

val defaultValueFromField =
Expand Down
6 changes: 3 additions & 3 deletions testData/inspection/readOnlyProperty.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ class G(BaseModel):
class Config:
allow_mutation=False
G.abc =<EOLError descr="Expression expected"></EOLError>
G.<warning descr="Unresolved attribute reference 'abc' for class 'G'">abc</warning>.lower()
<error descr="Cannot assign to function call">G.<warning descr="Unresolved attribute reference 'abc' for class 'G'">abc</warning>.lower()</error> = 'efg'

G.abc = "test"
G.abc.lower()
<error descr="Cannot assign to function call">G.abc.lower()</error> = 'efg'

class H:
class Config:
Expand Down
6 changes: 3 additions & 3 deletions testData/inspectionv18/readOnlyProperty.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ class G(BaseModel):
class Config:
allow_mutation=False
G.abc =<EOLError descr="Expression expected"></EOLError>
G.<warning descr="Unresolved attribute reference 'abc' for class 'G'">abc</warning>.lower()
<error descr="Cannot assign to function call">G.<warning descr="Unresolved attribute reference 'abc' for class 'G'">abc</warning>.lower()</error> = 'efg'

G.abc = "test"
G.abc.lower()
<error descr="Cannot assign to function call">G.abc.lower()</error> = 'efg'

class H:
class Config:
Expand Down
6 changes: 3 additions & 3 deletions testData/inspectionv18/readOnlyPropertyFrozen.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ class G(BaseModel):
class Config:
frozen=False
G.abc =<EOLError descr="Expression expected"></EOLError>
G.<warning descr="Unresolved attribute reference 'abc' for class 'G'">abc</warning>.lower()
<error descr="Cannot assign to function call">G.<warning descr="Unresolved attribute reference 'abc' for class 'G'">abc</warning>.lower()</error> = 'efg'

G.abc = "test"
G.abc.lower()
<error descr="Cannot assign to function call">G.abc.lower()</error> = 'efg'

class H:
class Config:
Expand Down
Loading