mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-04-20 13:31:28 +07:00
PY-82580: pyproject.toml refactoring to combine all pyproject.toml EPs to single API
The main idea is to decouple tool implementation from low-level platform APIs: Open project processor, workspace model e.t.c. Implementing `spi.Tool` should be enough to introduce new tool. GitOrigin-RevId: 1177b57fc0eee1ca2c88cac5b20618a6170bf521
This commit is contained in:
committed by
intellij-monorepo-bot
parent
fab7ea8eb3
commit
2888d207d9
@@ -9348,13 +9348,6 @@ jvm_import(
|
||||
source_jar = "@org_antlr-antlr4-runtime-4_13_0_http//file"
|
||||
)
|
||||
|
||||
java_library(
|
||||
name = "tuweni-toml-provided",
|
||||
exports = [":tuweni-toml"],
|
||||
neverlink = True,
|
||||
visibility = ["//visibility:public"]
|
||||
)
|
||||
|
||||
java_library(
|
||||
name = "vavr",
|
||||
exports = [
|
||||
|
||||
@@ -302,7 +302,6 @@ jvm_library(
|
||||
"@lib//:jna",
|
||||
"//plugins/toml",
|
||||
"//plugins/toml/core",
|
||||
"@lib//:tuweni-toml-provided",
|
||||
"@lib//:jsr305",
|
||||
"@lib//:jetbrains-annotations",
|
||||
"@lib//:kotlin-stdlib",
|
||||
|
||||
@@ -61,7 +61,6 @@ jvm_library(
|
||||
"//python/python-pyproject:pyproject",
|
||||
"//python/python-hatch:hatch",
|
||||
"@lib//:io-github-z4kn4fein-semver-jvm-provided",
|
||||
"@lib//:tuweni-toml-provided",
|
||||
"//platform/non-modal-welcome-screen",
|
||||
],
|
||||
runtime_deps = ["//python/python-features-trainer:featuresTrainer"],
|
||||
@@ -128,7 +127,6 @@ jvm_library(
|
||||
"//platform/testFramework/junit5:junit5_test_lib",
|
||||
"@lib//:junit5",
|
||||
"@lib//:io-github-z4kn4fein-semver-jvm-provided",
|
||||
"@lib//:tuweni-toml-provided",
|
||||
"//platform/non-modal-welcome-screen",
|
||||
],
|
||||
plugins = ["@lib//:compose-plugin"]
|
||||
|
||||
@@ -76,7 +76,6 @@
|
||||
<orderEntry type="module" module-name="intellij.platform.testFramework.junit5" scope="TEST" />
|
||||
<orderEntry type="library" scope="TEST" name="JUnit5" level="project" />
|
||||
<orderEntry type="library" scope="PROVIDED" name="io.github.z4kn4fein.semver.jvm" level="project" />
|
||||
<orderEntry type="library" scope="PROVIDED" name="tuweni-toml" level="project" />
|
||||
<orderEntry type="module" module-name="intellij.platform.ide.nonModalWelcomeScreen" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -51,6 +51,7 @@ class PythonSdkConfigurator : DirectoryProjectConfigurator {
|
||||
|
||||
StartupManager.getInstance(project).runWhenProjectIsInitialized {
|
||||
PyPackageCoroutine.launch(project) {
|
||||
if (module.isDisposed) return@launch
|
||||
val extension = findExtension(module)
|
||||
val title = extension?.getIntention(module) ?: PySdkBundle.message("python.configuring.interpreter.progress")
|
||||
withBackgroundProgress(project, title, true) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.intellij.pycharm.community.ide.impl.configuration
|
||||
|
||||
import com.intellij.openapi.application.EDT
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import com.intellij.openapi.extensions.ExtensionNotApplicableException
|
||||
import com.intellij.openapi.module.Module
|
||||
import com.intellij.openapi.projectRoots.Sdk
|
||||
import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil
|
||||
@@ -12,15 +13,16 @@ import com.intellij.platform.ide.progress.withBackgroundProgress
|
||||
import com.intellij.platform.util.progress.reportRawProgress
|
||||
import com.intellij.pycharm.community.ide.impl.PyCharmCommunityCustomizationBundle
|
||||
import com.intellij.python.pyproject.PyProjectToml
|
||||
import com.intellij.python.pyproject.model.internal.projectModelEnabled
|
||||
import com.jetbrains.python.PyBundle
|
||||
import com.jetbrains.python.errorProcessing.PyResult
|
||||
import com.jetbrains.python.poetry.findPoetryLock
|
||||
import com.jetbrains.python.poetry.getPyProjectTomlForPoetry
|
||||
import com.jetbrains.python.sdk.impl.resolvePythonBinary
|
||||
import com.jetbrains.python.sdk.PythonSdkType
|
||||
import com.jetbrains.python.sdk.PythonSdkUtil
|
||||
import com.jetbrains.python.sdk.basePath
|
||||
import com.jetbrains.python.sdk.configuration.PyProjectSdkConfigurationExtension
|
||||
import com.jetbrains.python.sdk.impl.resolvePythonBinary
|
||||
import com.jetbrains.python.sdk.poetry.PyPoetrySdkAdditionalData
|
||||
import com.jetbrains.python.sdk.poetry.getPoetryExecutable
|
||||
import com.jetbrains.python.sdk.poetry.setupPoetry
|
||||
@@ -34,6 +36,10 @@ import kotlin.io.path.pathString
|
||||
|
||||
@ApiStatus.Internal
|
||||
class PyPoetrySdkConfiguration : PyProjectSdkConfigurationExtension {
|
||||
init {
|
||||
if (projectModelEnabled) throw ExtensionNotApplicableException.create()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOGGER = Logger.getInstance(PyPoetrySdkConfiguration::class.java)
|
||||
}
|
||||
|
||||
@@ -4,17 +4,17 @@ package com.intellij.pycharm.community.ide.impl.configuration
|
||||
import com.intellij.codeInspection.util.IntentionName
|
||||
import com.intellij.openapi.application.EDT
|
||||
import com.intellij.openapi.diagnostic.fileLogger
|
||||
import com.intellij.openapi.extensions.ExtensionNotApplicableException
|
||||
import com.intellij.openapi.module.Module
|
||||
import com.intellij.openapi.projectRoots.Sdk
|
||||
import com.intellij.openapi.vfs.readText
|
||||
import com.intellij.pycharm.community.ide.impl.PyCharmCommunityCustomizationBundle
|
||||
import com.intellij.python.pyproject.PyProjectToml
|
||||
import com.intellij.python.pyproject.model.internal.projectModelEnabled
|
||||
import com.jetbrains.python.errorProcessing.MessageError
|
||||
import com.jetbrains.python.errorProcessing.PyResult
|
||||
import com.jetbrains.python.getOrLogException
|
||||
import com.jetbrains.python.onSuccess
|
||||
import com.jetbrains.python.projectModel.enablePyProjectToml
|
||||
import com.jetbrains.python.projectModel.uv.UvProjectModelService
|
||||
import com.jetbrains.python.sdk.PythonSdkUtil
|
||||
import com.jetbrains.python.sdk.basePath
|
||||
import com.jetbrains.python.sdk.configuration.PyProjectSdkConfigurationExtension
|
||||
@@ -33,6 +33,11 @@ private val logger = fileLogger()
|
||||
|
||||
@ApiStatus.Internal
|
||||
class PyUvSdkConfiguration : PyProjectSdkConfigurationExtension {
|
||||
|
||||
init {
|
||||
if (projectModelEnabled) throw ExtensionNotApplicableException.create()
|
||||
}
|
||||
|
||||
override suspend fun getIntention(module: Module): @IntentionName String? {
|
||||
val tomlFile = PyProjectToml.findFile(module) ?: return null
|
||||
getUvExecutable() ?: return null
|
||||
@@ -61,14 +66,7 @@ class PyUvSdkConfiguration : PyProjectSdkConfigurationExtension {
|
||||
override fun supportsHeadlessModel(): Boolean = true
|
||||
|
||||
private suspend fun createUv(module: Module): PyResult<Sdk> {
|
||||
val sdkAssociatedModule: Module
|
||||
if (enablePyProjectToml) {
|
||||
val uvWorkspace = UvProjectModelService.findWorkspace(module)
|
||||
sdkAssociatedModule = uvWorkspace?.root ?: module
|
||||
}
|
||||
else {
|
||||
sdkAssociatedModule = module
|
||||
}
|
||||
val sdkAssociatedModule: Module = module
|
||||
val workingDir: Path? = tryResolvePath(sdkAssociatedModule.basePath)
|
||||
if (workingDir == null) {
|
||||
return PyResult.failure(MessageError("Can't determine working dir for the module"))
|
||||
|
||||
@@ -121,7 +121,6 @@
|
||||
<orderEntry type="library" name="jna" level="project" />
|
||||
<orderEntry type="module" module-name="intellij.toml" />
|
||||
<orderEntry type="module" module-name="intellij.toml.core" />
|
||||
<orderEntry type="library" scope="PROVIDED" name="tuweni-toml" level="project" />
|
||||
<orderEntry type="library" name="jsr305" level="project" />
|
||||
<orderEntry type="library" name="jetbrains-annotations" level="project" />
|
||||
<orderEntry type="library" name="kotlin-stdlib" level="project" />
|
||||
|
||||
@@ -76,19 +76,14 @@
|
||||
<pluginSuggestionProvider order="first" implementation="com.jetbrains.python.suggestions.PycharmProSuggestionProvider"/>
|
||||
<postStartupActivity implementation="com.jetbrains.python.poetry.PoetryPyProjectTomlPostStartupActivity"/>
|
||||
|
||||
<!--Poetry project model-->
|
||||
<projectOpenProcessor implementation="com.jetbrains.python.projectModel.poetry.PoetryProjectOpenProcessor"/>
|
||||
<externalSystemUnlinkedProjectAware implementation="com.jetbrains.python.projectModel.poetry.PoetryUnlinkedProjectAware"/>
|
||||
<postStartupActivity implementation="com.jetbrains.python.projectModel.poetry.PoetryProjectAware$PoetrySyncStartupActivity"/>
|
||||
|
||||
<!--uv project model-->
|
||||
<projectOpenProcessor implementation="com.jetbrains.python.projectModel.uv.UvProjectOpenProcessor"/>
|
||||
<externalSystemUnlinkedProjectAware implementation="com.jetbrains.python.projectModel.uv.UvUnlinkedProjectAware"/>
|
||||
<postStartupActivity implementation="com.jetbrains.python.projectModel.uv.UvProjectAware$UvSyncStartupActivity"/>
|
||||
|
||||
<virtualFileCustomDataProvider implementation="com.jetbrains.python.psi.PyLangLevelVirtualFileCustomDataProvider"/>
|
||||
</extensions>
|
||||
|
||||
<extensions defaultExtensionNs="com.intellij.python.pyproject.model">
|
||||
<tool implementation="com.jetbrains.python.projectModel.poetry.PoetryTool"/>
|
||||
<tool implementation="com.jetbrains.python.projectModel.uv.UvTool"/>
|
||||
</extensions>
|
||||
|
||||
<projectListeners>
|
||||
<listener
|
||||
class="com.jetbrains.python.inspections.PyInterpreterInspection$Visitor$CacheCleaner"
|
||||
@@ -100,10 +95,6 @@
|
||||
topic="com.jetbrains.python.packaging.common.PythonPackageManagementListener"/>
|
||||
<listener class="com.jetbrains.python.statistics.PyPackageDaemonListener"
|
||||
topic="com.intellij.codeInsight.daemon.DaemonCodeAnalyzer$DaemonListener"/>
|
||||
<listener class="com.jetbrains.python.projectModel.poetry.PoetryProjectAware$PoetryListener"
|
||||
topic="com.jetbrains.python.projectModel.poetry.PoetrySettingsListener"/>
|
||||
<listener class="com.jetbrains.python.projectModel.uv.UvProjectAware$UvListener"
|
||||
topic="com.jetbrains.python.projectModel.uv.UvSettingsListener"/>
|
||||
</projectListeners>
|
||||
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
@@ -684,8 +675,6 @@
|
||||
<!-- Setting up cross-module dependencies -->
|
||||
<registryKey key="python.detect.cross.module.dependencies" defaultValue="false"
|
||||
description="Try to detect and automatically set-up module dependencies in a multi-module project"/>
|
||||
<registryKey key="python.pyproject.model" defaultValue="true" restartRequired="true"
|
||||
description="Automatically set up multi-module pyproject.toml-based projects"/>
|
||||
|
||||
<feedback.idleFeedbackSurvey implementation="com.jetbrains.python.statistics.feedback.PythonJobSurvey"/>
|
||||
<statistics.counterUsagesCollector implementationClass="com.jetbrains.python.statistics.feedback.PythonJobStatisticsCollector"/>
|
||||
@@ -1072,12 +1061,6 @@
|
||||
<add-to-group group-id="MarkRootGroup" anchor="after" relative-to-action="MarkSourceRoot"/>
|
||||
</action>
|
||||
|
||||
<action id="Python.PoetrySync" class="com.jetbrains.python.projectModel.poetry.PoetrySyncAction"/>
|
||||
<action id="Python.PoetryLink" class="com.jetbrains.python.projectModel.poetry.PoetryLinkAction"/>
|
||||
|
||||
<action id="Python.UvSync" class="com.jetbrains.python.projectModel.uv.UvSyncAction"/>
|
||||
<action id="Python.UvLink" class="com.jetbrains.python.projectModel.uv.UvLinkAction"/>
|
||||
|
||||
<!--suppress PluginXmlI18n -->
|
||||
<group id="Internal.Python" internal="true" popup="true" text="Python">
|
||||
<!--suppress PluginXmlI18n -->
|
||||
|
||||
@@ -17,7 +17,7 @@ jvm_library(
|
||||
name = "pyproject",
|
||||
module_name = "intellij.python.pyproject",
|
||||
visibility = ["//visibility:public"],
|
||||
srcs = glob(["src/**/*.kt", "src/**/*.java", "src/**/*.form"], allow_empty = True),
|
||||
srcs = glob(["src/**/*.kt", "src/**/*.java", "src/**/*.form", "gen/**/*.kt", "gen/**/*.java"], allow_empty = True),
|
||||
resources = [":pyproject_resources"],
|
||||
deps = [
|
||||
"@lib//:kotlin-stdlib",
|
||||
@@ -28,7 +28,16 @@ jvm_library(
|
||||
"//platform/util",
|
||||
"//plugins/toml/core",
|
||||
"@lib//:tuweni-toml",
|
||||
]
|
||||
"//platform/external-system-api:externalSystem",
|
||||
"//platform/ide-core",
|
||||
"//platform/progress/shared:ide-progress",
|
||||
"//platform/backend/workspace",
|
||||
"//platform/workspace/storage",
|
||||
"//platform/external-system-impl:externalSystem-impl",
|
||||
"//platform/platform-impl:ide-impl",
|
||||
"//platform/workspace/jps",
|
||||
],
|
||||
exports = ["@lib//:tuweni-toml"]
|
||||
)
|
||||
|
||||
jvm_library(
|
||||
@@ -50,7 +59,16 @@ jvm_library(
|
||||
"@lib//:tuweni-toml",
|
||||
"@lib//:junit5",
|
||||
"@lib//:junit5Params",
|
||||
]
|
||||
"//platform/external-system-api:externalSystem",
|
||||
"//platform/ide-core",
|
||||
"//platform/progress/shared:ide-progress",
|
||||
"//platform/backend/workspace",
|
||||
"//platform/workspace/storage",
|
||||
"//platform/external-system-impl:externalSystem-impl",
|
||||
"//platform/platform-impl:ide-impl",
|
||||
"//platform/workspace/jps",
|
||||
],
|
||||
exports = ["@lib//:tuweni-toml"]
|
||||
)
|
||||
### auto-generated section `build intellij.python.pyproject` end
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.intellij.python.pyproject.model.internal.impl
|
||||
|
||||
import com.intellij.platform.workspace.storage.ConnectionId
|
||||
import com.intellij.platform.workspace.storage.WorkspaceEntityInternalApi
|
||||
import com.intellij.platform.workspace.storage.metadata.impl.MetadataStorageBase
|
||||
import com.intellij.platform.workspace.storage.metadata.model.EntityMetadata
|
||||
import com.intellij.platform.workspace.storage.metadata.model.ExtPropertyMetadata
|
||||
import com.intellij.platform.workspace.storage.metadata.model.FinalClassMetadata
|
||||
import com.intellij.platform.workspace.storage.metadata.model.OwnPropertyMetadata
|
||||
import com.intellij.platform.workspace.storage.metadata.model.StorageTypeMetadata
|
||||
import com.intellij.platform.workspace.storage.metadata.model.ValueTypeMetadata
|
||||
|
||||
@OptIn(WorkspaceEntityInternalApi::class)
|
||||
internal object MetadataStorageImpl: MetadataStorageBase() {
|
||||
override fun initializeMetadata() {
|
||||
val primitiveTypeStringNotNullable = ValueTypeMetadata.SimpleType.PrimitiveType(isNullable = false, type = "String")
|
||||
val primitiveTypeMapNotNullable = ValueTypeMetadata.SimpleType.PrimitiveType(isNullable = false, type = "Map")
|
||||
|
||||
var typeMetadata: StorageTypeMetadata
|
||||
|
||||
typeMetadata = FinalClassMetadata.ClassMetadata(fqName = "com.intellij.python.pyproject.model.internal.PyProjectTomlEntitySource", properties = listOf(OwnPropertyMetadata(isComputable = false, isKey = false, isOpen = false, name = "virtualFileUrl", valueType = ValueTypeMetadata.SimpleType.CustomType(isNullable = false, typeMetadata = FinalClassMetadata.KnownClass(fqName = "com.intellij.platform.workspace.storage.url.VirtualFileUrl")), withDefault = false)), supertypes = listOf("com.intellij.platform.workspace.storage.EntitySource"))
|
||||
|
||||
addMetadata(typeMetadata)
|
||||
|
||||
typeMetadata = EntityMetadata(fqName = "com.intellij.python.pyproject.model.internal.PyProjectTomlWorkspaceEntity", entityDataFqName = "com.intellij.python.pyproject.model.internal.impl.PyProjectTomlWorkspaceEntityData", supertypes = listOf("com.intellij.platform.workspace.storage.WorkspaceEntity"), properties = listOf(OwnPropertyMetadata(isComputable = false, isKey = false, isOpen = false, name = "entitySource", valueType = ValueTypeMetadata.SimpleType.CustomType(isNullable = false, typeMetadata = FinalClassMetadata.KnownClass(fqName = "com.intellij.platform.workspace.storage.EntitySource")), withDefault = false),
|
||||
OwnPropertyMetadata(isComputable = false, isKey = false, isOpen = false, name = "participatedTools", valueType = ValueTypeMetadata.ParameterizedType(generics = listOf(ValueTypeMetadata.SimpleType.CustomType(isNullable = false, typeMetadata = FinalClassMetadata.ClassMetadata(fqName = "com.intellij.python.pyproject.model.spi.ToolId", properties = listOf(OwnPropertyMetadata(isComputable = false, isKey = false, isOpen = false, name = "id", valueType = primitiveTypeStringNotNullable, withDefault = false)), supertypes = listOf())),
|
||||
ValueTypeMetadata.SimpleType.CustomType(isNullable = true, typeMetadata = FinalClassMetadata.ClassMetadata(fqName = "com.intellij.platform.workspace.jps.entities.ModuleId", properties = listOf(OwnPropertyMetadata(isComputable = false, isKey = false, isOpen = false, name = "name", valueType = primitiveTypeStringNotNullable, withDefault = false),
|
||||
OwnPropertyMetadata(isComputable = false, isKey = false, isOpen = false, name = "presentableName", valueType = primitiveTypeStringNotNullable, withDefault = false)), supertypes = listOf("com.intellij.platform.workspace.storage.SymbolicEntityId")))), primitive = primitiveTypeMapNotNullable), withDefault = false),
|
||||
OwnPropertyMetadata(isComputable = false, isKey = false, isOpen = false, name = "module", valueType = ValueTypeMetadata.EntityReference(connectionType = ConnectionId.ConnectionType.ONE_TO_ONE, entityFqName = "com.intellij.platform.workspace.jps.entities.ModuleEntity", isChild = false, isNullable = false), withDefault = false)), extProperties = listOf(ExtPropertyMetadata(isComputable = false, isOpen = false, name = "pyProjectTomlEntity", receiverFqn = "com.intellij.platform.workspace.jps.entities.ModuleEntity", valueType = ValueTypeMetadata.EntityReference(connectionType = ConnectionId.ConnectionType.ONE_TO_ONE, entityFqName = "com.intellij.python.pyproject.model.internal.PyProjectTomlWorkspaceEntity", isChild = true, isNullable = false), withDefault = false)), isAbstract = false)
|
||||
|
||||
addMetadata(typeMetadata)
|
||||
}
|
||||
|
||||
override fun initializeMetadataHash() {
|
||||
addMetadataHash(typeFqn = "com.intellij.python.pyproject.model.internal.PyProjectTomlWorkspaceEntity", metadataHash = 775488913)
|
||||
addMetadataHash(typeFqn = "com.intellij.python.pyproject.model.spi.ToolId", metadataHash = 2027768588)
|
||||
addMetadataHash(typeFqn = "com.intellij.platform.workspace.jps.entities.ModuleId", metadataHash = -575206713)
|
||||
addMetadataHash(typeFqn = "com.intellij.platform.workspace.storage.EntitySource", metadataHash = -1282078904)
|
||||
addMetadataHash(typeFqn = "com.intellij.python.pyproject.model.internal.PyProjectTomlEntitySource", metadataHash = -1054650782)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package com.intellij.python.pyproject.model.internal.impl
|
||||
|
||||
import com.intellij.platform.workspace.jps.entities.ModuleEntity
|
||||
import com.intellij.platform.workspace.jps.entities.ModuleId
|
||||
import com.intellij.platform.workspace.storage.*
|
||||
import com.intellij.platform.workspace.storage.annotations.Parent
|
||||
import com.intellij.platform.workspace.storage.impl.EntityLink
|
||||
import com.intellij.platform.workspace.storage.impl.ModifiableWorkspaceEntityBase
|
||||
import com.intellij.platform.workspace.storage.impl.WorkspaceEntityBase
|
||||
import com.intellij.platform.workspace.storage.impl.WorkspaceEntityData
|
||||
import com.intellij.platform.workspace.storage.impl.containers.toMutableWorkspaceSet
|
||||
import com.intellij.platform.workspace.storage.impl.extractOneToOneParent
|
||||
import com.intellij.platform.workspace.storage.impl.updateOneToOneParentOfChild
|
||||
import com.intellij.platform.workspace.storage.instrumentation.EntityStorageInstrumentation
|
||||
import com.intellij.platform.workspace.storage.instrumentation.EntityStorageInstrumentationApi
|
||||
import com.intellij.platform.workspace.storage.instrumentation.MutableEntityStorageInstrumentation
|
||||
import com.intellij.platform.workspace.storage.metadata.model.EntityMetadata
|
||||
import com.intellij.python.pyproject.model.internal.PyProjectTomlWorkspaceEntity
|
||||
import com.intellij.python.pyproject.model.spi.ToolId
|
||||
|
||||
@GeneratedCodeApiVersion(3)
|
||||
@GeneratedCodeImplVersion(7)
|
||||
@OptIn(WorkspaceEntityInternalApi::class)
|
||||
internal class PyProjectTomlWorkspaceEntityImpl(private val dataSource: PyProjectTomlWorkspaceEntityData) : PyProjectTomlWorkspaceEntity, WorkspaceEntityBase(
|
||||
dataSource) {
|
||||
|
||||
private companion object {
|
||||
internal val MODULE_CONNECTION_ID: ConnectionId = ConnectionId.create(ModuleEntity::class.java,
|
||||
PyProjectTomlWorkspaceEntity::class.java,
|
||||
ConnectionId.ConnectionType.ONE_TO_ONE, false)
|
||||
|
||||
private val connections = listOf<ConnectionId>(
|
||||
MODULE_CONNECTION_ID,
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
override val participatedTools: Map<ToolId, ModuleId?>
|
||||
get() {
|
||||
readField("participatedTools")
|
||||
return dataSource.participatedTools
|
||||
}
|
||||
override val module: ModuleEntity
|
||||
get() = snapshot.extractOneToOneParent(MODULE_CONNECTION_ID, this)!!
|
||||
|
||||
override val entitySource: EntitySource
|
||||
get() {
|
||||
readField("entitySource")
|
||||
return dataSource.entitySource
|
||||
}
|
||||
|
||||
override fun connectionIdList(): List<ConnectionId> {
|
||||
return connections
|
||||
}
|
||||
|
||||
|
||||
internal class Builder(result: PyProjectTomlWorkspaceEntityData?) : ModifiableWorkspaceEntityBase<PyProjectTomlWorkspaceEntity, PyProjectTomlWorkspaceEntityData>(
|
||||
result), PyProjectTomlWorkspaceEntity.Builder {
|
||||
internal constructor() : this(PyProjectTomlWorkspaceEntityData())
|
||||
|
||||
override fun applyToBuilder(builder: MutableEntityStorage) {
|
||||
if (this.diff != null) {
|
||||
if (existsInBuilder(builder)) {
|
||||
this.diff = builder
|
||||
return
|
||||
}
|
||||
else {
|
||||
error("Entity PyProjectTomlWorkspaceEntity is already created in a different builder")
|
||||
}
|
||||
}
|
||||
|
||||
this.diff = builder
|
||||
addToBuilder()
|
||||
this.id = getEntityData().createEntityId()
|
||||
// After adding entity data to the builder, we need to unbind it and move the control over entity data to builder
|
||||
// Builder may switch to snapshot at any moment and lock entity data to modification
|
||||
this.currentEntityData = null
|
||||
|
||||
// Process linked entities that are connected without a builder
|
||||
processLinkedEntities(builder)
|
||||
checkInitialization() // TODO uncomment and check failed tests
|
||||
}
|
||||
|
||||
private fun checkInitialization() {
|
||||
val _diff = diff
|
||||
if (!getEntityData().isEntitySourceInitialized()) {
|
||||
error("Field WorkspaceEntity#entitySource should be initialized")
|
||||
}
|
||||
if (!getEntityData().isParticipatedToolsInitialized()) {
|
||||
error("Field PyProjectTomlWorkspaceEntity#participatedTools should be initialized")
|
||||
}
|
||||
if (_diff != null) {
|
||||
if (_diff.extractOneToOneParent<WorkspaceEntityBase>(MODULE_CONNECTION_ID, this) == null) {
|
||||
error("Field PyProjectTomlWorkspaceEntity#module should be initialized")
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (this.entityLinks[EntityLink(false, MODULE_CONNECTION_ID)] == null) {
|
||||
error("Field PyProjectTomlWorkspaceEntity#module should be initialized")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun connectionIdList(): List<ConnectionId> {
|
||||
return connections
|
||||
}
|
||||
|
||||
// Relabeling code, move information from dataSource to this builder
|
||||
override fun relabel(dataSource: WorkspaceEntity, parents: Set<WorkspaceEntity>?) {
|
||||
dataSource as PyProjectTomlWorkspaceEntity
|
||||
if (this.entitySource != dataSource.entitySource) this.entitySource = dataSource.entitySource
|
||||
if (this.participatedTools != dataSource.participatedTools) this.participatedTools = dataSource.participatedTools.toMutableMap()
|
||||
updateChildToParentReferences(parents)
|
||||
}
|
||||
|
||||
|
||||
override var entitySource: EntitySource
|
||||
get() = getEntityData().entitySource
|
||||
set(value) {
|
||||
checkModificationAllowed()
|
||||
getEntityData(true).entitySource = value
|
||||
changedProperty.add("entitySource")
|
||||
|
||||
}
|
||||
|
||||
override var participatedTools: Map<ToolId, ModuleId?>
|
||||
get() = getEntityData().participatedTools
|
||||
set(value) {
|
||||
checkModificationAllowed()
|
||||
getEntityData(true).participatedTools = value
|
||||
changedProperty.add("participatedTools")
|
||||
}
|
||||
|
||||
override var module: ModuleEntity.Builder
|
||||
get() {
|
||||
val _diff = diff
|
||||
return if (_diff != null) {
|
||||
@OptIn(EntityStorageInstrumentationApi::class)
|
||||
((_diff as MutableEntityStorageInstrumentation).getParentBuilder(MODULE_CONNECTION_ID, this) as? ModuleEntity.Builder)
|
||||
?: (this.entityLinks[EntityLink(false, MODULE_CONNECTION_ID)]!! as ModuleEntity.Builder)
|
||||
}
|
||||
else {
|
||||
this.entityLinks[EntityLink(false, MODULE_CONNECTION_ID)]!! as ModuleEntity.Builder
|
||||
}
|
||||
}
|
||||
set(value) {
|
||||
checkModificationAllowed()
|
||||
val _diff = diff
|
||||
if (_diff != null && value is ModifiableWorkspaceEntityBase<*, *> && value.diff == null) {
|
||||
if (value is ModifiableWorkspaceEntityBase<*, *>) {
|
||||
value.entityLinks[EntityLink(true, MODULE_CONNECTION_ID)] = this
|
||||
}
|
||||
// else you're attaching a new entity to an existing entity that is not modifiable
|
||||
_diff.addEntity(value as ModifiableWorkspaceEntityBase<WorkspaceEntity, *>)
|
||||
}
|
||||
if (_diff != null && (value !is ModifiableWorkspaceEntityBase<*, *> || value.diff != null)) {
|
||||
_diff.updateOneToOneParentOfChild(MODULE_CONNECTION_ID, this, value)
|
||||
}
|
||||
else {
|
||||
if (value is ModifiableWorkspaceEntityBase<*, *>) {
|
||||
value.entityLinks[EntityLink(true, MODULE_CONNECTION_ID)] = this
|
||||
}
|
||||
// else you're attaching a new entity to an existing entity that is not modifiable
|
||||
|
||||
this.entityLinks[EntityLink(false, MODULE_CONNECTION_ID)] = value
|
||||
}
|
||||
changedProperty.add("module")
|
||||
}
|
||||
|
||||
override fun getEntityClass(): Class<PyProjectTomlWorkspaceEntity> = PyProjectTomlWorkspaceEntity::class.java
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(WorkspaceEntityInternalApi::class)
|
||||
internal class PyProjectTomlWorkspaceEntityData : WorkspaceEntityData<PyProjectTomlWorkspaceEntity>() {
|
||||
lateinit var participatedTools: Map<ToolId, ModuleId?>
|
||||
|
||||
internal fun isParticipatedToolsInitialized(): Boolean = ::participatedTools.isInitialized
|
||||
|
||||
override fun wrapAsModifiable(diff: MutableEntityStorage): WorkspaceEntity.Builder<PyProjectTomlWorkspaceEntity> {
|
||||
val modifiable = PyProjectTomlWorkspaceEntityImpl.Builder(null)
|
||||
modifiable.diff = diff
|
||||
modifiable.id = createEntityId()
|
||||
return modifiable
|
||||
}
|
||||
|
||||
@OptIn(EntityStorageInstrumentationApi::class)
|
||||
override fun createEntity(snapshot: EntityStorageInstrumentation): PyProjectTomlWorkspaceEntity {
|
||||
val entityId = createEntityId()
|
||||
return snapshot.initializeEntity(entityId) {
|
||||
val entity = PyProjectTomlWorkspaceEntityImpl(this)
|
||||
entity.snapshot = snapshot
|
||||
entity.id = entityId
|
||||
entity
|
||||
}
|
||||
}
|
||||
|
||||
override fun getMetadata(): EntityMetadata {
|
||||
return MetadataStorageImpl.getMetadataByTypeFqn(
|
||||
"com.intellij.python.pyproject.model.internal.PyProjectTomlWorkspaceEntity") as EntityMetadata
|
||||
}
|
||||
|
||||
override fun getEntityInterface(): Class<out WorkspaceEntity> {
|
||||
return PyProjectTomlWorkspaceEntity::class.java
|
||||
}
|
||||
|
||||
override fun createDetachedEntity(parents: List<WorkspaceEntity.Builder<*>>): WorkspaceEntity.Builder<*> {
|
||||
return PyProjectTomlWorkspaceEntity(participatedTools, entitySource) {
|
||||
parents.filterIsInstance<ModuleEntity.Builder>().singleOrNull()?.let { this.module = it }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getRequiredParents(): List<Class<out WorkspaceEntity>> {
|
||||
val res = mutableListOf<Class<out WorkspaceEntity>>()
|
||||
res.add(ModuleEntity::class.java)
|
||||
return res
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null) return false
|
||||
if (this.javaClass != other.javaClass) return false
|
||||
|
||||
other as PyProjectTomlWorkspaceEntityData
|
||||
|
||||
if (this.entitySource != other.entitySource) return false
|
||||
if (this.participatedTools != other.participatedTools) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun equalsIgnoringEntitySource(other: Any?): Boolean {
|
||||
if (other == null) return false
|
||||
if (this.javaClass != other.javaClass) return false
|
||||
|
||||
other as PyProjectTomlWorkspaceEntityData
|
||||
|
||||
if (this.participatedTools != other.participatedTools) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = entitySource.hashCode()
|
||||
result = 31 * result + participatedTools.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun hashCodeIgnoringEntitySource(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + participatedTools.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Workspace internal generated tools, do not touch
|
||||
*/
|
||||
@ApiStatus.Internal
|
||||
package com.intellij.python.pyproject.model.internal.impl;
|
||||
|
||||
import org.jetbrains.annotations.ApiStatus;
|
||||
@@ -6,6 +6,7 @@
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/resources" type="java-resource" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/gen" isTestSource="false" generated="true" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/testResources" type="java-test-resource" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
@@ -17,8 +18,16 @@
|
||||
<orderEntry type="module" module-name="intellij.python.sdk" />
|
||||
<orderEntry type="module" module-name="intellij.platform.util" />
|
||||
<orderEntry type="module" module-name="intellij.toml.core" />
|
||||
<orderEntry type="library" name="tuweni-toml" level="project" />
|
||||
<orderEntry type="library" exported="" name="tuweni-toml" level="project" />
|
||||
<orderEntry type="library" scope="TEST" name="JUnit5" level="project" />
|
||||
<orderEntry type="library" scope="TEST" name="JUnit5Params" level="project" />
|
||||
<orderEntry type="module" module-name="intellij.platform.externalSystem" />
|
||||
<orderEntry type="module" module-name="intellij.platform.ide.core" />
|
||||
<orderEntry type="module" module-name="intellij.platform.ide.progress" />
|
||||
<orderEntry type="module" module-name="intellij.platform.backend.workspace" />
|
||||
<orderEntry type="module" module-name="intellij.platform.workspace.storage" />
|
||||
<orderEntry type="module" module-name="intellij.platform.externalSystem.impl" />
|
||||
<orderEntry type="module" module-name="intellij.platform.ide.impl" />
|
||||
<orderEntry type="module" module-name="intellij.platform.workspace.jps" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -5,4 +5,22 @@
|
||||
<module name="intellij.python.sdk"/>
|
||||
<plugin id="org.toml.lang"/>
|
||||
</dependencies>
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<registryKey defaultValue="false" description="Load project structure from pyproject.toml" key="intellij.python.pyproject.model" restartRequired="true"/>
|
||||
|
||||
<externalSystemUnlinkedProjectAware
|
||||
implementation="com.intellij.python.pyproject.model.internal.PyExternalSystemUnlinkedProjectAware"/>
|
||||
|
||||
<projectOpenProcessor implementation="com.intellij.python.pyproject.model.internal.PyProjectOpenProcessor"/>
|
||||
|
||||
<postStartupActivity implementation="com.intellij.python.pyproject.model.internal.PyProjectSyncActivity"/>
|
||||
</extensions>
|
||||
<actions>
|
||||
<action class="com.intellij.python.pyproject.model.internal.PyProjectTomlSyncAction" id="PyProjectTomlSyncAction"/>
|
||||
</actions>
|
||||
<extensionPoints>
|
||||
<extensionPoint qualifiedName="com.intellij.python.pyproject.model.tool" interface="com.intellij.python.pyproject.model.spi.Tool"
|
||||
dynamic="true"/>
|
||||
</extensionPoints>
|
||||
|
||||
</idea-plugin>
|
||||
@@ -0,0 +1,5 @@
|
||||
intellij.python.pyproject.unlink.model=Unlinking model..
|
||||
intellij.python.pyproject.link.and.sync.model=Synchronizing project structure with pyproject.toml
|
||||
intellij.python.pyproject.name=Generate project from pyproject.toml
|
||||
action.PyProjectTomlSyncAction.text=Sync Project with `pyproject.toml`
|
||||
action.PyProjectTomlSyncAction.description=Rebuild project model based on pyproject.toml
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.intellij.python.pyproject.model.internal
|
||||
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.externalSystem.autoimport.ExternalSystemProjectAware
|
||||
import com.intellij.openapi.externalSystem.autoimport.ExternalSystemProjectId
|
||||
import com.intellij.openapi.externalSystem.autoimport.ExternalSystemProjectListener
|
||||
import com.intellij.openapi.externalSystem.autoimport.ExternalSystemProjectReloadContext
|
||||
import com.intellij.openapi.project.Project
|
||||
import java.nio.file.Path
|
||||
|
||||
internal class PyExternalSystemProjectAware(override val projectId: ExternalSystemProjectId, override val settingsFiles: Set<String>, private val project: Project, private val projectModelRoot: Path) : ExternalSystemProjectAware {
|
||||
|
||||
override fun subscribe(listener: ExternalSystemProjectListener, parentDisposable: Disposable) {
|
||||
project.messageBus.connect(parentDisposable).subscribe(PROJECT_AWARE_TOPIC, listener)
|
||||
}
|
||||
|
||||
override fun reloadProject(context: ExternalSystemProjectReloadContext) {
|
||||
linkProjectWithProgressInBackground(project, projectModelRoot)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.intellij.python.pyproject.model.internal
|
||||
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.diagnostic.fileLogger
|
||||
import com.intellij.openapi.externalSystem.autolink.ExternalSystemProjectLinkListener
|
||||
import com.intellij.openapi.externalSystem.autolink.ExternalSystemUnlinkedProjectAware
|
||||
import com.intellij.openapi.externalSystem.model.ProjectSystemId
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import java.io.IOException
|
||||
import java.nio.file.Path
|
||||
|
||||
|
||||
internal class PyExternalSystemUnlinkedProjectAware : ExternalSystemUnlinkedProjectAware {
|
||||
override val systemId: ProjectSystemId = SYSTEM_ID
|
||||
|
||||
override fun isBuildFile(project: Project, buildFile: VirtualFile): Boolean = PyOpenProjectProvider.canOpenProject(buildFile)
|
||||
|
||||
override fun isLinkedProject(project: Project, externalProjectPath: String): Boolean = isProjectLinked(project)
|
||||
|
||||
override suspend fun unlinkProject(project: Project, externalProjectPath: String) {
|
||||
unlinkProjectWithProgress(project, externalProjectPath)
|
||||
}
|
||||
|
||||
override suspend fun linkAndLoadProjectAsync(project: Project, externalProjectPath: String) {
|
||||
val path = try {
|
||||
Path.of(externalProjectPath)
|
||||
}
|
||||
catch (e: IOException) {
|
||||
logger.warn("Provided path is wrong, probably workspace is broken: ${externalProjectPath}", e)
|
||||
null
|
||||
}
|
||||
linkProjectWithProgressInBackground(project, path)
|
||||
}
|
||||
|
||||
override fun subscribe(project: Project, listener: ExternalSystemProjectLinkListener, parentDisposable: Disposable) {
|
||||
project.messageBus.connect(parentDisposable).subscribe(PROJECT_LINKER_AWARE_TOPIC, listener)
|
||||
}
|
||||
}
|
||||
|
||||
private val logger = fileLogger()
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.intellij.python.pyproject.model.internal
|
||||
|
||||
import com.intellij.ide.impl.runUnderModalProgressIfIsEdt
|
||||
import com.intellij.openapi.externalSystem.importing.AbstractOpenProjectProvider
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.openapi.vfs.isFile
|
||||
import com.intellij.python.pyproject.PY_PROJECT_TOML
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal object PyOpenProjectProvider : AbstractOpenProjectProvider() {
|
||||
override val systemId = SYSTEM_ID
|
||||
|
||||
override fun isProjectFile(file: VirtualFile): Boolean = @Suppress("DEPRECATION") // We have no choice: API is broken
|
||||
(runUnderModalProgressIfIsEdt {
|
||||
file.isFile && file.name == PY_PROJECT_TOML || file.isDirectory && file.findChild(PY_PROJECT_TOML) != null
|
||||
})
|
||||
|
||||
override suspend fun linkProject(projectFile: VirtualFile, project: Project) {
|
||||
val projectDirectory = withContext(Dispatchers.IO) {
|
||||
getProjectDirectory(projectFile)
|
||||
}
|
||||
linkProjectWithProgress(project, projectDirectory.toNioPath())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.intellij.python.pyproject.model.internal
|
||||
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.projectImport.ProjectOpenProcessor
|
||||
import org.jetbrains.annotations.Nls
|
||||
|
||||
internal class PyProjectOpenProcessor : ProjectOpenProcessor() {
|
||||
override val name: @Nls String = PyProjectTomlBundle.message("intellij.python.pyproject.name")
|
||||
|
||||
override fun canOpenProject(file: VirtualFile): Boolean = projectModelEnabled && PyOpenProjectProvider.canOpenProject(file)
|
||||
|
||||
override suspend fun openProjectAsync(virtualFile: VirtualFile, projectToClose: Project?, forceOpenInNewFrame: Boolean): Project? = PyOpenProjectProvider.openProject(virtualFile, projectToClose, forceOpenInNewFrame)
|
||||
|
||||
override fun canImportProjectAfterwards(): Boolean = true
|
||||
override suspend fun importProjectAfterwardsAsync(project: Project, file: VirtualFile) = PyOpenProjectProvider.linkToExistingProjectAsync(file, project)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.intellij.python.pyproject.model.internal
|
||||
|
||||
import com.intellij.openapi.extensions.ExtensionNotApplicableException
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.startup.ProjectActivity
|
||||
|
||||
internal class PyProjectSyncActivity : ProjectActivity {
|
||||
init {
|
||||
if (!projectModelEnabled) {
|
||||
throw ExtensionNotApplicableException.create()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun execute(project: Project) {
|
||||
linkProjectWithProgressInBackground(project)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.intellij.python.pyproject.model.internal;
|
||||
|
||||
import com.intellij.DynamicBundle;
|
||||
import org.jetbrains.annotations.Nls;
|
||||
import org.jetbrains.annotations.NonNls;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.PropertyKey;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
|
||||
final class PyProjectTomlBundle extends DynamicBundle {
|
||||
public static final @NonNls String BUNDLE = "messages.PyProjectTomlBundle";
|
||||
public static final PyProjectTomlBundle INSTANCE = new PyProjectTomlBundle();
|
||||
|
||||
private PyProjectTomlBundle() { super(BUNDLE); }
|
||||
|
||||
public static @NotNull @Nls String message(@NotNull @PropertyKey(resourceBundle = BUNDLE) String key, Object @NotNull ... params) {
|
||||
return INSTANCE.getMessage(key, params);
|
||||
}
|
||||
|
||||
public static @NotNull Supplier<@Nls String> messagePointer(@NotNull @PropertyKey(resourceBundle = BUNDLE) String key, Object @NotNull ... params) {
|
||||
return INSTANCE.getLazyMessage(key, params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.intellij.python.pyproject.model.internal
|
||||
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
|
||||
internal class PyProjectTomlSyncAction : AnAction(PyProjectTomlBundle.message("action.PyProjectTomlSyncAction.text")) {
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val project = e.project ?: return
|
||||
if (!projectModelEnabled) {
|
||||
return
|
||||
}
|
||||
linkProjectWithProgressInBackground(project)
|
||||
}
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
e.presentation.isEnabledAndVisible = e.project != null && projectModelEnabled
|
||||
}
|
||||
|
||||
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.intellij.python.pyproject.model.internal
|
||||
|
||||
import com.intellij.platform.workspace.jps.entities.ModuleEntity
|
||||
import com.intellij.platform.workspace.jps.entities.ModuleId
|
||||
import com.intellij.platform.workspace.storage.*
|
||||
import com.intellij.platform.workspace.storage.annotations.Parent
|
||||
import com.intellij.python.pyproject.model.spi.ToolId
|
||||
|
||||
internal interface PyProjectTomlWorkspaceEntity : WorkspaceEntity {
|
||||
|
||||
// [tool, probablyWorkspaceRoot?]. If root is null -> tool didn't implement workspace, just participated in this entry creation
|
||||
val participatedTools: Map<ToolId, ModuleId?>
|
||||
|
||||
@Parent
|
||||
val module: ModuleEntity
|
||||
|
||||
//region generated code
|
||||
@GeneratedCodeApiVersion(3)
|
||||
interface Builder : WorkspaceEntity.Builder<PyProjectTomlWorkspaceEntity> {
|
||||
override var entitySource: EntitySource
|
||||
var participatedTools: Map<ToolId, ModuleId?>
|
||||
var module: ModuleEntity.Builder
|
||||
}
|
||||
|
||||
companion object : EntityType<PyProjectTomlWorkspaceEntity, Builder>() {
|
||||
@JvmOverloads
|
||||
@JvmStatic
|
||||
@JvmName("create")
|
||||
operator fun invoke(
|
||||
participatedTools: Map<ToolId, ModuleId?>,
|
||||
entitySource: EntitySource,
|
||||
init: (Builder.() -> Unit)? = null,
|
||||
): Builder {
|
||||
val builder = builder()
|
||||
builder.participatedTools = participatedTools
|
||||
builder.entitySource = entitySource
|
||||
init?.invoke(builder)
|
||||
return builder
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
}
|
||||
|
||||
//region generated code
|
||||
internal fun MutableEntityStorage.modifyPyProjectTomlWorkspaceEntity(
|
||||
entity: PyProjectTomlWorkspaceEntity,
|
||||
modification: PyProjectTomlWorkspaceEntity.Builder.() -> Unit,
|
||||
): PyProjectTomlWorkspaceEntity {
|
||||
return modifyEntity(PyProjectTomlWorkspaceEntity.Builder::class.java, entity, modification)
|
||||
}
|
||||
|
||||
internal var ModuleEntity.Builder.pyProjectTomlEntity: PyProjectTomlWorkspaceEntity.Builder
|
||||
by WorkspaceEntity.extensionBuilder(PyProjectTomlWorkspaceEntity::class.java)
|
||||
//endregion
|
||||
|
||||
internal val ModuleEntity.pyProjectTomlEntity: PyProjectTomlWorkspaceEntity
|
||||
by WorkspaceEntity.extension()
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Low level package that connects SPI with the Platform, should never be accessed directly
|
||||
*/
|
||||
@ApiStatus.Internal
|
||||
package com.intellij.python.pyproject.model.internal;
|
||||
|
||||
import org.jetbrains.annotations.ApiStatus;
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.intellij.python.pyproject.model.internal
|
||||
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.externalSystem.autoimport.ExternalSystemProjectListener
|
||||
import com.intellij.openapi.externalSystem.autolink.ExternalSystemProjectLinkListener
|
||||
import com.intellij.openapi.externalSystem.model.ProjectSystemId
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.registry.Registry
|
||||
import com.intellij.platform.ide.progress.withBackgroundProgress
|
||||
import com.intellij.util.messages.Topic
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.nio.file.Path
|
||||
|
||||
// "upper-level" API to called by various actions
|
||||
|
||||
@Topic.ProjectLevel
|
||||
internal val PROJECT_AWARE_TOPIC: Topic<ExternalSystemProjectListener> = Topic(ExternalSystemProjectListener::class.java, Topic.BroadcastDirection.NONE)
|
||||
|
||||
@Topic.ProjectLevel
|
||||
internal val PROJECT_LINKER_AWARE_TOPIC: Topic<ExternalSystemProjectLinkListener> = Topic(ExternalSystemProjectLinkListener::class.java, Topic.BroadcastDirection.NONE)
|
||||
|
||||
|
||||
internal val SYSTEM_ID = ProjectSystemId("PyProjectToml")
|
||||
|
||||
|
||||
val projectModelEnabled: Boolean get() = Registry.`is`("intellij.python.pyproject.model")
|
||||
|
||||
|
||||
internal suspend fun unlinkProjectWithProgress(project: Project, externalProjectPath: String) {
|
||||
withBackgroundProgress(project = project, title = PyProjectTomlBundle.message("intellij.python.pyproject.unlink.model")) {
|
||||
unlinkProject(project, externalProjectPath)
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun linkProjectWithProgress(project: Project, projectModelRoot: Path) {
|
||||
withBackgroundProgress(project = project, title = PyProjectTomlBundle.message("intellij.python.pyproject.link.and.sync.model")) {
|
||||
linkProject(project, projectModelRoot)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun linkProjectWithProgressInBackground(project: Project, projectModelRoot: Path? = null) {
|
||||
ApplicationManager.getApplication().service<MyService>().scope.launch {
|
||||
|
||||
val projectModelRoot = projectModelRoot ?: withContext(Dispatchers.IO) {
|
||||
// can't guess as it might return the src of the first module
|
||||
project.baseDir.toNioPath()
|
||||
}
|
||||
|
||||
linkProjectWithProgress(project, projectModelRoot)
|
||||
}
|
||||
}
|
||||
|
||||
@Service
|
||||
private class MyService(val scope: CoroutineScope)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.intellij.python.pyproject.model.internal
|
||||
|
||||
import com.intellij.openapi.diagnostic.fileLogger
|
||||
import com.intellij.python.pyproject.PY_PROJECT_TOML
|
||||
import com.intellij.python.pyproject.PyProjectToml
|
||||
import com.jetbrains.python.Result
|
||||
import com.jetbrains.python.venvReader.Directory
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
import java.nio.file.FileVisitResult
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.name
|
||||
import kotlin.io.path.readText
|
||||
import kotlin.io.path.visitFileTree
|
||||
|
||||
// Tools to walk FS and parse pyproject.toml
|
||||
|
||||
internal suspend fun walkFileSystem(root: Directory): FSWalkInfo {
|
||||
val files = ArrayList<Path>(10)
|
||||
val excludeDir = ArrayList<Directory>(10)
|
||||
withContext(Dispatchers.IO) {
|
||||
root.visitFileTree {
|
||||
onVisitFile { file, _ ->
|
||||
if (file.name == PY_PROJECT_TOML) {
|
||||
files.add(file)
|
||||
}
|
||||
return@onVisitFile FileVisitResult.CONTINUE
|
||||
}
|
||||
onPostVisitDirectory { directory, _ ->
|
||||
return@onPostVisitDirectory if (directory.name.startsWith(".")) {
|
||||
excludeDir.add(directory)
|
||||
FileVisitResult.SKIP_SUBTREE
|
||||
}
|
||||
else {
|
||||
FileVisitResult.CONTINUE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: with a big number of files, use `chunk` to parse them concurrently
|
||||
val tomlFiles = files.map { file ->
|
||||
val toml = readFile(file) ?: return@map null
|
||||
file to toml
|
||||
}.filterNotNull().toMap()
|
||||
return FSWalkInfo(tomlFiles = tomlFiles, excludeDir.toSet())
|
||||
}
|
||||
|
||||
internal data class FSWalkInfo(val tomlFiles: Map<Path, PyProjectToml>, val excludeDir: Set<Directory>)
|
||||
|
||||
private val logger = fileLogger()
|
||||
|
||||
private suspend fun readFile(file: Path): PyProjectToml? {
|
||||
val content = try {
|
||||
withContext(Dispatchers.IO) { file.readText() }
|
||||
}
|
||||
catch (e: IOException) {
|
||||
logger.warn("Can't read $file", e)
|
||||
return null
|
||||
}
|
||||
return when (val r = withContext(Dispatchers.Default) { PyProjectToml.parse(content) }) {
|
||||
is Result.Failure -> {
|
||||
logger.warn("Errors on $file: ${r.error.joinToString(", ")}")
|
||||
null
|
||||
}
|
||||
is Result.Success -> r.result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.intellij.python.pyproject.model.internal
|
||||
|
||||
import com.intellij.openapi.externalSystem.autoimport.ExternalSystemProjectId
|
||||
import com.intellij.openapi.externalSystem.autoimport.ExternalSystemProjectTracker
|
||||
import com.intellij.openapi.externalSystem.autoimport.ExternalSystemRefreshStatus
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.Key
|
||||
import com.intellij.openapi.util.NlsSafe
|
||||
import com.intellij.openapi.util.removeUserData
|
||||
import com.intellij.platform.backend.workspace.workspaceModel
|
||||
import com.intellij.platform.workspace.jps.entities.*
|
||||
import com.intellij.platform.workspace.storage.EntitySource
|
||||
import com.intellij.platform.workspace.storage.EntityStorage
|
||||
import com.intellij.platform.workspace.storage.MutableEntityStorage
|
||||
import com.intellij.platform.workspace.storage.impl.url.toVirtualFileUrl
|
||||
import com.intellij.platform.workspace.storage.url.VirtualFileUrl
|
||||
import com.intellij.platform.workspace.storage.url.VirtualFileUrlManager
|
||||
import com.intellij.python.pyproject.PyProjectToml
|
||||
import com.intellij.python.pyproject.model.spi.*
|
||||
import com.jetbrains.python.venvReader.Directory
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.name
|
||||
import kotlin.io.path.pathString
|
||||
|
||||
|
||||
// Workspace adapter functions
|
||||
|
||||
internal fun isProjectLinked(project: Project): Boolean =
|
||||
project.workspaceModel.currentSnapshot.entitiesBySource(sourceFilter = { it is PyProjectTomlEntitySource }).any()
|
||||
|
||||
internal suspend fun unlinkProject(project: Project, externalProjectPath: String) {
|
||||
val tracker = ExternalSystemProjectTracker.getInstance(project)
|
||||
project.getUserData(PY_PROJECT_TOML_KEY)?.let { oldProjectId ->
|
||||
tracker.remove(oldProjectId)
|
||||
}
|
||||
project.removeUserData(PY_PROJECT_TOML_KEY)
|
||||
withContext(Dispatchers.Default) {
|
||||
project.workspaceModel.update(PyProjectTomlBundle.message("intellij.python.pyproject.unlink.model")) { storage ->
|
||||
storage.replaceBySource({ it is PyProjectTomlEntitySource }, MutableEntityStorage.create())
|
||||
}
|
||||
}
|
||||
project.messageBus.syncPublisher(PROJECT_LINKER_AWARE_TOPIC).onProjectUnlinked(externalProjectPath)
|
||||
}
|
||||
|
||||
internal suspend fun linkProject(project: Project, projectModelRoot: Path) {
|
||||
val externalProjectPath = projectModelRoot.pathString
|
||||
val (files, excludeDirs) = walkFileSystem(projectModelRoot)
|
||||
val entries = generatePyProjectTomlEntries(files, excludeDirs)
|
||||
unlinkProject(project, externalProjectPath)
|
||||
|
||||
val tracker = ExternalSystemProjectTracker.getInstance(project)
|
||||
val projectAware = PyExternalSystemProjectAware(ExternalSystemProjectId(SYSTEM_ID, externalProjectPath), files.map { it.key.pathString }.toSet(), project, projectModelRoot)
|
||||
tracker.register(projectAware)
|
||||
tracker.activate(projectAware.projectId)
|
||||
project.putUserData(PY_PROJECT_TOML_KEY, projectAware.projectId)
|
||||
val storage = createEntityStorage(project, entries, project.workspaceModel.getVirtualFileUrlManager())
|
||||
val listener = project.messageBus.syncPublisher(PROJECT_AWARE_TOPIC)
|
||||
listener.onProjectReloadStart()
|
||||
try {
|
||||
project.workspaceModel.update(PyProjectTomlBundle.message("action.PyProjectTomlSyncAction.description")) { mutableStorage -> // Fake module entity is added by default if nothing was discovered
|
||||
removeFakeModuleEntity(mutableStorage)
|
||||
mutableStorage.replaceBySource({ it is PyProjectTomlEntitySource }, storage)
|
||||
}
|
||||
}
|
||||
catch (e: Exception) {
|
||||
listener.onProjectReloadFinish(ExternalSystemRefreshStatus.FAILURE)
|
||||
throw e
|
||||
}
|
||||
listener.onProjectReloadFinish(ExternalSystemRefreshStatus.SUCCESS)
|
||||
project.messageBus.syncPublisher(PROJECT_LINKER_AWARE_TOPIC).onProjectLinked(externalProjectPath)
|
||||
}
|
||||
|
||||
private suspend fun generatePyProjectTomlEntries(files: Map<Path, PyProjectToml>, allExcludeDirs: Set<Directory>): Set<PyProjectTomlBasedEntryImpl> = withContext(Dispatchers.Default) {
|
||||
val entries = ArrayList<PyProjectTomlBasedEntryImpl>()
|
||||
// Any tool that helped us somehow must be tracked here
|
||||
for ((tomlFile, toml) in files.entries) {
|
||||
val participatedTools = mutableSetOf<ToolId>()
|
||||
val root = tomlFile.parent
|
||||
var projectNameAsString = toml.project?.name
|
||||
if (projectNameAsString == null) {
|
||||
val toolAndName = getNameFromEP(toml)
|
||||
if (toolAndName != null) {
|
||||
projectNameAsString = toolAndName.second
|
||||
participatedTools.add(toolAndName.first.id)
|
||||
}
|
||||
}
|
||||
val projectName = ProjectName(projectNameAsString ?: "${root.name}@${tomlFile.hashCode()}")
|
||||
val sourceRootsAndTools = Tool.EP.extensionList.flatMap { tool -> tool.getSrcRoots(toml.toml, root).map { Pair(tool, it) } }.toSet()
|
||||
val sourceRoots = sourceRootsAndTools.map { it.second }.toSet()
|
||||
participatedTools.addAll(sourceRootsAndTools.map { it.first.id })
|
||||
val excludedDirs = allExcludeDirs.filter { it.startsWith(root) }
|
||||
val relationsWithTools: List<PyProjectTomlToolRelation> = participatedTools.map { PyProjectTomlToolRelation.SimpleRelation(it) }
|
||||
val entry = PyProjectTomlBasedEntryImpl(tomlFile, HashSet(relationsWithTools), toml, projectName, root, mutableSetOf(), sourceRoots, excludedDirs.toSet())
|
||||
entries.add(entry)
|
||||
}
|
||||
val entriesByName = entries.associateBy { it.name }
|
||||
val namesByDir = entries.associate { Pair(it.root, it.name) }
|
||||
val allNames = entriesByName.keys
|
||||
for (tool in Tool.EP.extensionList) {
|
||||
val (dependencies, workspaceMembers) = tool.getProjectStructure(entriesByName, namesByDir)
|
||||
for ((name, deps) in dependencies) {
|
||||
val orphanNames = deps - allNames
|
||||
assert(orphanNames.isEmpty()) { "Tool $tool retuned wrong project names ${orphanNames.joinToString(", ")}" }
|
||||
val entity = entriesByName[name] ?: error("Tool $tool returned broken name $name")
|
||||
entity.dependencies.addAll(deps)
|
||||
if (deps.isNotEmpty()) {
|
||||
entity.relationsWithTools.add(PyProjectTomlToolRelation.SimpleRelation(tool.id))
|
||||
}
|
||||
workspaceMembers[entity.name]?.let { workspace ->
|
||||
entity.relationsWithTools.add(PyProjectTomlToolRelation.WorkspaceMember(tool.id, workspace))
|
||||
}
|
||||
}
|
||||
}
|
||||
return@withContext entries.toSet()
|
||||
}
|
||||
|
||||
private suspend fun getNameFromEP(projectToml: PyProjectToml): Pair<Tool, @NlsSafe String>? = withContext(Dispatchers.Default) {
|
||||
Tool.EP.extensionList.firstNotNullOfOrNull { tool -> tool.getProjectName(projectToml.toml)?.let { Pair(tool, it) } }
|
||||
}
|
||||
|
||||
private suspend fun createEntityStorage(
|
||||
project: Project,
|
||||
graph: Set<PyProjectTomlBasedEntryImpl>,
|
||||
virtualFileUrlManager: VirtualFileUrlManager,
|
||||
): EntityStorage = withContext(Dispatchers.Default) {
|
||||
val fileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
|
||||
val storage = MutableEntityStorage.create()
|
||||
for (pyProject in graph) {
|
||||
val existingModuleEntity = project.workspaceModel.currentSnapshot
|
||||
.entitiesBySource { it is PyProjectTomlEntitySource }
|
||||
.filterIsInstance<ModuleEntity>()
|
||||
.find { it.name == pyProject.name.name }
|
||||
val existingSdkEntity = existingModuleEntity
|
||||
?.dependencies
|
||||
?.find { it is SdkDependency } as? SdkDependency
|
||||
val sdkDependency = existingSdkEntity ?: InheritedSdkDependency
|
||||
val entitySource = PyProjectTomlEntitySource(pyProject.tomlFile.toVirtualFileUrl(virtualFileUrlManager))
|
||||
val moduleEntity = storage addEntity ModuleEntity(pyProject.name.name, emptyList(), entitySource) {
|
||||
dependencies += sdkDependency
|
||||
dependencies += ModuleSourceDependency
|
||||
for (moduleName in pyProject.dependencies) {
|
||||
dependencies += ModuleDependency(ModuleId(moduleName.name), true, DependencyScope.COMPILE, false)
|
||||
}
|
||||
contentRoots = listOf(ContentRootEntity(pyProject.root.toVirtualFileUrl(fileUrlManager), emptyList(), entitySource) {
|
||||
sourceRoots = pyProject.sourceRoots.map { srcRoot ->
|
||||
SourceRootEntity(srcRoot.toVirtualFileUrl(fileUrlManager), PYTHON_SOURCE_ROOT_TYPE, entitySource)
|
||||
}
|
||||
excludedUrls = pyProject.excludedRoots.map { excludedRoot ->
|
||||
ExcludeUrlEntity(excludedRoot.toVirtualFileUrl(fileUrlManager), entitySource)
|
||||
}
|
||||
})
|
||||
val participatedTools: MutableMap<ToolId, ModuleId?> = pyProject.relationsWithTools.associate { Pair(it.toolId, null) }.toMutableMap()
|
||||
for (relation in pyProject.relationsWithTools) {
|
||||
when (relation) {
|
||||
is PyProjectTomlToolRelation.SimpleRelation -> Unit
|
||||
is PyProjectTomlToolRelation.WorkspaceMember -> {
|
||||
participatedTools[relation.toolId] = ModuleId(relation.workspace.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pyProjectTomlEntity = PyProjectTomlWorkspaceEntity(participatedTools = participatedTools, entitySource)
|
||||
exModuleOptions = ExternalSystemModuleOptionsEntity(entitySource) {
|
||||
externalSystem = PYTHON_SOURCE_ROOT_TYPE.name
|
||||
}
|
||||
}
|
||||
moduleEntity.symbolicId
|
||||
}
|
||||
return@withContext storage
|
||||
}
|
||||
|
||||
private class PyProjectTomlEntitySource(tomlFile: VirtualFileUrl) : EntitySource {
|
||||
override val virtualFileUrl: VirtualFileUrl = tomlFile
|
||||
}
|
||||
|
||||
// For the time being mark them as java-sources to indicate that in the Project tool window
|
||||
private val PYTHON_SOURCE_ROOT_TYPE: SourceRootTypeId = SourceRootTypeId("java-source")
|
||||
private val PY_PROJECT_TOML_KEY = Key.create<ExternalSystemProjectId>("pyProjectTomlAware")
|
||||
|
||||
private data class PyProjectTomlBasedEntryImpl(
|
||||
val tomlFile: Path,
|
||||
val relationsWithTools: MutableSet<PyProjectTomlToolRelation>,
|
||||
override val pyProjectToml: PyProjectToml,
|
||||
val name: ProjectName,
|
||||
override val root: Directory,
|
||||
val dependencies: MutableSet<ProjectName>,
|
||||
val sourceRoots: Set<Directory>,
|
||||
val excludedRoots: Set<Directory>,
|
||||
) : PyProjectTomlProject
|
||||
|
||||
|
||||
/**
|
||||
* Removes the default IJ module created for the root of the project
|
||||
* (that's going to be replaced with a module belonging to a specific project management system).
|
||||
*
|
||||
* @see com.intellij.openapi.project.impl.getOrInitializeModule
|
||||
*/
|
||||
private fun removeFakeModuleEntity(storage: MutableEntityStorage) {
|
||||
val contentRoots = storage
|
||||
.entitiesBySource { it !is PyProjectTomlEntitySource }
|
||||
.filterIsInstance<ContentRootEntity>()
|
||||
.toList()
|
||||
for (entity in contentRoots) {
|
||||
storage.removeEntity(entity.module)
|
||||
storage.removeEntity(entity)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What does [toolId] have to do with a certain projec?
|
||||
*/
|
||||
private sealed interface PyProjectTomlToolRelation {
|
||||
val toolId: ToolId
|
||||
|
||||
/**
|
||||
* There is something tool-specific in `pyproject.toml`
|
||||
*/
|
||||
data class SimpleRelation(override val toolId: ToolId) : PyProjectTomlToolRelation
|
||||
|
||||
/**
|
||||
* This module is a part of workspace, and the root is [workspace]
|
||||
*/
|
||||
data class WorkspaceMember(override val toolId: ToolId, val workspace: WorkspaceName) : PyProjectTomlToolRelation
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Builds project module from `pyproject.toml.
|
||||
* To support new tool, see `spi` package
|
||||
*/
|
||||
@ApiStatus.Internal
|
||||
package com.intellij.python.pyproject.model;
|
||||
|
||||
import org.jetbrains.annotations.ApiStatus;
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.intellij.python.pyproject.model.spi
|
||||
|
||||
import com.intellij.openapi.util.NlsSafe
|
||||
|
||||
/**
|
||||
* project name in pyproject.toml
|
||||
*/
|
||||
@JvmInline
|
||||
value class ProjectName(val name: @NlsSafe String)
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.intellij.python.pyproject.model.spi
|
||||
|
||||
typealias WorkspaceMember = ProjectName
|
||||
typealias WorkspaceName = ProjectName
|
||||
|
||||
data class ProjectStructureInfo(
|
||||
/**
|
||||
* [project -> its dependencies]
|
||||
*/
|
||||
val dependencies: Map<ProjectName, Set<ProjectName>>,
|
||||
/**
|
||||
* Projects that are memvbers of workspace point to the root of workspace
|
||||
*/
|
||||
val membersToWorkspace: Map<WorkspaceMember, WorkspaceName>,
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.intellij.python.pyproject.model.spi
|
||||
|
||||
import com.intellij.python.pyproject.PyProjectToml
|
||||
import com.jetbrains.python.venvReader.Directory
|
||||
|
||||
/**
|
||||
* Particular project described by `pyproject.toml`
|
||||
*/
|
||||
interface PyProjectTomlProject {
|
||||
/**
|
||||
* Wrapper over toml file
|
||||
*/
|
||||
val pyProjectToml: PyProjectToml
|
||||
|
||||
/**
|
||||
* Project root directory: `pyproject.toml` sits in it
|
||||
*/
|
||||
val root: Directory
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.intellij.python.pyproject.model.spi
|
||||
|
||||
import com.intellij.openapi.extensions.ExtensionPointName
|
||||
import com.intellij.openapi.util.NlsSafe
|
||||
import com.jetbrains.python.venvReader.Directory
|
||||
import org.apache.tuweni.toml.TomlTable
|
||||
|
||||
interface Tool {
|
||||
companion object {
|
||||
internal val EP = ExtensionPointName.create<Tool>("com.intellij.python.pyproject.model.tool")
|
||||
}
|
||||
|
||||
val id: ToolId
|
||||
|
||||
/**
|
||||
* Tools that support old (tool-specific) naming should return it here
|
||||
*/
|
||||
suspend fun getProjectName(projectToml: TomlTable): @NlsSafe String?
|
||||
|
||||
/**
|
||||
* Tool uses [entries] ([rootIndex] contains the same data, used as index rooDir->project name) to report project dependencies and workspace members.
|
||||
* All project names must be taken from provided data (use [rootIndex] to get name by directory)
|
||||
*/
|
||||
suspend fun getProjectStructure(entries: Map<ProjectName, PyProjectTomlProject>, rootIndex: Map<Directory, ProjectName>): ProjectStructureInfo
|
||||
|
||||
/**
|
||||
* Tool that supports build systems might return additional src directories
|
||||
*/
|
||||
suspend fun getSrcRoots(toml: TomlTable, projectRoot: Directory): Set<Directory>
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.intellij.python.pyproject.model.spi
|
||||
|
||||
import org.jetbrains.annotations.NonNls
|
||||
|
||||
/**
|
||||
* Each tool must have unique id i.e: `uv`
|
||||
*/
|
||||
@JvmInline
|
||||
value class ToolId(val id: @NonNls String)
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Implement {@link com.intellij.python.pyproject.model.spi.Tool} and register it
|
||||
*/
|
||||
@ApiStatus.Internal
|
||||
package com.intellij.python.pyproject.model.spi;
|
||||
|
||||
import org.jetbrains.annotations.ApiStatus;
|
||||
@@ -22,7 +22,7 @@ interface PyProjectSdkConfigurationExtension {
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
val EP_NAME = ExtensionPointName.create<PyProjectSdkConfigurationExtension>("Pythonid.projectSdkConfigurationExtension")
|
||||
val EP_NAME: ExtensionPointName<PyProjectSdkConfigurationExtension> = ExtensionPointName.create<PyProjectSdkConfigurationExtension>("Pythonid.projectSdkConfigurationExtension")
|
||||
|
||||
@JvmStatic
|
||||
@RequiresBackgroundThread
|
||||
|
||||
@@ -7,11 +7,10 @@ import com.intellij.ide.projectView.impl.ProjectRootsUtil;
|
||||
import com.intellij.openapi.module.Module;
|
||||
import com.intellij.openapi.module.ModuleUtilCore;
|
||||
import com.intellij.openapi.roots.FileIndexFacade;
|
||||
import com.intellij.openapi.util.registry.Registry;
|
||||
import com.intellij.openapi.vfs.VirtualFile;
|
||||
import com.intellij.psi.PsiDirectory;
|
||||
import com.intellij.psi.PsiElement;
|
||||
import com.jetbrains.python.projectModel.ProjectModelKt;
|
||||
import com.intellij.python.pyproject.model.internal.PlatformToolsKt;
|
||||
import com.jetbrains.python.psi.PyUtil;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
@@ -22,7 +21,7 @@ public final class PyDirectoryIconProvider extends IconProvider {
|
||||
@Override
|
||||
public Icon getIcon(@NotNull PsiElement element, int flags) {
|
||||
if (element instanceof PsiDirectory directory) {
|
||||
if (isMultimoduleProjectDetectionEnabled()) {
|
||||
if (PlatformToolsKt.getProjectModelEnabled()) {
|
||||
if (ProjectRootsUtil.isModuleContentRoot(directory.getVirtualFile(), directory.getProject())) {
|
||||
return AllIcons.Nodes.Module;
|
||||
}
|
||||
@@ -36,9 +35,6 @@ public final class PyDirectoryIconProvider extends IconProvider {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isMultimoduleProjectDetectionEnabled() {
|
||||
return ProjectModelKt.getEnablePyProjectToml();
|
||||
}
|
||||
|
||||
private static boolean isSpecialDirectory(@NotNull PsiDirectory directory) {
|
||||
final VirtualFile vFile = directory.getVirtualFile();
|
||||
|
||||
@@ -27,7 +27,6 @@ import com.intellij.openapi.roots.ModuleRootManager;
|
||||
import com.intellij.openapi.roots.ProjectRootManager;
|
||||
import com.intellij.openapi.roots.ui.configuration.ProjectSettingsService;
|
||||
import com.intellij.openapi.roots.ui.configuration.projectRoot.ProjectSdksModel;
|
||||
import com.intellij.openapi.util.NlsSafe;
|
||||
import com.intellij.openapi.util.UserDataHolderBase;
|
||||
import com.intellij.platform.backend.workspace.WorkspaceModelChangeListener;
|
||||
import com.intellij.platform.workspace.jps.entities.ModuleEntity;
|
||||
@@ -42,9 +41,6 @@ import com.intellij.util.containers.ContainerUtil;
|
||||
import com.intellij.workspaceModel.ide.impl.legacyBridge.module.ModuleEntityUtils;
|
||||
import com.jetbrains.python.PyPsiBundle;
|
||||
import com.jetbrains.python.PythonIdeLanguageCustomization;
|
||||
import com.jetbrains.python.projectModel.ProjectModelKt;
|
||||
import com.jetbrains.python.projectModel.uv.UvProjectModelService;
|
||||
import com.jetbrains.python.projectModel.uv.UvProjectModelService.UvWorkspace;
|
||||
import com.jetbrains.python.psi.LanguageLevel;
|
||||
import com.jetbrains.python.psi.PyFile;
|
||||
import com.jetbrains.python.psi.impl.PyBuiltinCache;
|
||||
@@ -123,75 +119,17 @@ public final class PyInterpreterInspection extends PyInspection {
|
||||
else {
|
||||
message = PyPsiBundle.message("INSP.interpreter.no.python.interpreter.configured.for.module");
|
||||
}
|
||||
registerProblemWithCommonFixes(node, message, module, null, fixes, pyCharm);
|
||||
}
|
||||
else {
|
||||
final @NlsSafe String associatedModulePath = PySdkExtKt.getAssociatedModulePath(sdk);
|
||||
if (module != null && !PlatformUtils.isFleetBackend()) {
|
||||
boolean isAlreadyUsedByModule = (PySdkExtKt.getPythonSdk(module) == sdk);
|
||||
boolean isAssociatedWithThisModule = associatedModulePath != null && associatedModulePath.equals(BasePySdkExtKt.getBasePath(module));
|
||||
// TODO: this logic should be generalized via the workspace manager
|
||||
boolean isAssociatedWithUvRoot = associatedModulePath != null && ProjectModelKt.getEnablePyProjectToml() &&
|
||||
isAssociatedWithUvWorkspaceRootModule(associatedModulePath, module);
|
||||
|
||||
if (!isAlreadyUsedByModule && !isAssociatedWithThisModule && !isAssociatedWithUvRoot &&
|
||||
(associatedModulePath == null || PySdkExtKt.isAssociatedWithAnotherModule(sdk, module))) {
|
||||
final PyInterpreterInspectionQuickFixData fixData = PySdkProvider.EP_NAME.getExtensionList().stream()
|
||||
.map(ext -> ext.createEnvironmentAssociationFix(module, sdk, pyCharm, associatedModulePath))
|
||||
.filter(it -> it != null)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
if (fixData != null) {
|
||||
fixes.add(fixData.getQuickFix());
|
||||
registerProblemWithCommonFixes(node, fixData.getMessage(), module, sdk, fixes, pyCharm);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!PySdkExtKt.getSdkSeemsValid(sdk)) {
|
||||
final @InspectionMessage String message;
|
||||
if (pyCharm) {
|
||||
message = PyPsiBundle.message("INSP.interpreter.invalid.python.interpreter.selected.for.project");
|
||||
}
|
||||
else {
|
||||
message = PyPsiBundle.message("INSP.interpreter.invalid.python.interpreter.selected.for.module");
|
||||
}
|
||||
registerProblemWithCommonFixes(node, message, module, sdk, fixes, pyCharm);
|
||||
}
|
||||
else {
|
||||
final LanguageLevel languageLevel = PythonSdkType.getLanguageLevelForSdk(sdk);
|
||||
if (!LanguageLevel.SUPPORTED_LEVELS.contains(languageLevel)) {
|
||||
final @InspectionMessage String message;
|
||||
if (pyCharm) {
|
||||
message = PyPsiBundle.message("INSP.interpreter.python.has.reached.its.end.of.life.and.is.no.longer.supported.in.pycharm",
|
||||
languageLevel);
|
||||
}
|
||||
else {
|
||||
message = PyPsiBundle.message("INSP.interpreter.python.has.reached.its.end.life.and.is.no.longer.supported.in.python.plugin",
|
||||
languageLevel);
|
||||
}
|
||||
registerProblemWithCommonFixes(node, message, module, sdk, fixes, pyCharm);
|
||||
}
|
||||
}
|
||||
registerProblemWithCommonFixes(node, message, module, fixes, pyCharm);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isAssociatedWithUvWorkspaceRootModule(@Nullable String sdkAssociatedPath, @NotNull Module module) {
|
||||
if (sdkAssociatedPath == null) return false;
|
||||
UvWorkspace<@NotNull Module> uvWorkspace = UvProjectModelService.INSTANCE.findWorkspace(module);
|
||||
if (uvWorkspace == null) return false;
|
||||
return sdkAssociatedPath.equals(BasePySdkExtKt.getBasePath(uvWorkspace.getRoot()));
|
||||
}
|
||||
|
||||
private void registerProblemWithCommonFixes(PyFile node,
|
||||
@InspectionMessage String message,
|
||||
@Nullable Module module,
|
||||
Sdk sdk,
|
||||
List<LocalQuickFix> fixes,
|
||||
boolean pyCharm) {
|
||||
if (module != null && pyCharm && sdk == null) {
|
||||
if (module != null && pyCharm) {
|
||||
final String sdkName = ProjectRootManager.getInstance(node.getProject()).getProjectSdkName();
|
||||
ContainerUtil.addIfNotNull(fixes, getSuitableSdkFix(sdkName, module));
|
||||
}
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel
|
||||
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.NlsSafe
|
||||
import com.intellij.platform.backend.workspace.workspaceModel
|
||||
import com.intellij.platform.ide.progress.withBackgroundProgress
|
||||
import com.intellij.platform.workspace.jps.entities.*
|
||||
import com.intellij.platform.workspace.storage.EntitySource
|
||||
import com.intellij.platform.workspace.storage.EntityStorage
|
||||
import com.intellij.platform.workspace.storage.MutableEntityStorage
|
||||
import com.intellij.platform.workspace.storage.impl.url.toVirtualFileUrl
|
||||
import com.jetbrains.python.PyBundle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.annotations.NonNls
|
||||
import org.jetbrains.annotations.SystemIndependent
|
||||
import java.nio.file.Path
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* Syncs the project model described in pyproject.toml files with the IntelliJ project model.
|
||||
*/
|
||||
abstract class BaseProjectModelService<E : EntitySource, P : ExternalProject> {
|
||||
internal abstract val systemName: @NlsSafe String
|
||||
|
||||
internal abstract val projectModelResolver: PythonProjectModelResolver<P>
|
||||
|
||||
internal abstract fun getSettings(project: Project): ProjectModelSettings
|
||||
|
||||
protected abstract fun getSyncListener(project: Project): ProjectModelSyncListener
|
||||
|
||||
protected abstract fun createEntitySource(project: Project, singleProjectRoot: Path): EntitySource
|
||||
|
||||
protected abstract fun getEntitySourceClass(): KClass<out EntitySource>
|
||||
|
||||
suspend fun linkAllProjectModelRoots(project: Project, basePath: @SystemIndependent @NonNls String) {
|
||||
val allProjectRoots = withBackgroundProgress(project = project, title = PyBundle.message("python.project.model.progress.title.discovering.projects", systemName)) {
|
||||
projectModelResolver.discoverIndependentProjectGraphs(Path.of(basePath)).map { it.root }
|
||||
}
|
||||
getSettings(project).setLinkedProjects(allProjectRoots)
|
||||
}
|
||||
|
||||
suspend fun syncAllProjectModelRoots(project: Project) {
|
||||
withBackgroundProgress(project = project, title = PyBundle.message("python.project.model.progress.title.syncing.all.projects", systemName)) {
|
||||
// TODO progress bar, listener with events
|
||||
getSettings(project).getLinkedProjects().forEach {
|
||||
syncProjectModelRootImpl(project, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun syncProjectModelRoot(project: Project, projectModelRoot: Path) {
|
||||
withBackgroundProgress(project = project, title = PyBundle.message("python.project.model.progress.title.syncing.projects.at", systemName, projectModelRoot)) {
|
||||
syncProjectModelRootImpl(project, projectModelRoot)
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun forgetProjectModelRoot(project: Project, projectModelRoot: Path) {
|
||||
withBackgroundProgress(project = project, title = PyBundle.message("python.project.model.progress.title.unlinking.projects.at", systemName, projectModelRoot)) {
|
||||
getSettings(project).removeLinkedProject(projectModelRoot)
|
||||
forgetProjectModelRootImpl(project, projectModelRoot)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun forgetProjectModelRootImpl(project: Project, projectModelRoot: Path) {
|
||||
val source = createEntitySource(project, projectModelRoot)
|
||||
project.workspaceModel.update("Forgetting a $systemName project at $projectModelRoot") { storage ->
|
||||
storage.replaceBySource({ it == source }, MutableEntityStorage.Companion.create())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncProjectModelRootImpl(project: Project, projectRoot: Path) {
|
||||
val listener = getSyncListener(project)
|
||||
listener.onStart(projectRoot)
|
||||
try {
|
||||
val source = createEntitySource(project, projectRoot)
|
||||
val graph = projectModelResolver.discoverIndependentProjectGraphs(projectRoot)
|
||||
if (graph.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val allModules = graph.flatMap { it.projects }
|
||||
val storage = createProjectModel(project, allModules, source)
|
||||
project.workspaceModel.update("$systemName sync at ${projectRoot}") { mutableStorage ->
|
||||
// Fake module entity is added by default if nothing was discovered
|
||||
if (allModules.any { it.root == project.baseNioPath }) {
|
||||
removeFakeModuleEntity(project, mutableStorage)
|
||||
}
|
||||
mutableStorage.replaceBySource({ it == source }, storage)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
listener.onFinish(projectRoot)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createProjectModel(
|
||||
project: Project,
|
||||
graph: List<ExternalProject>,
|
||||
source: EntitySource,
|
||||
): EntityStorage = withContext(Dispatchers.Default) {
|
||||
val fileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
|
||||
val storage = MutableEntityStorage.create()
|
||||
for (extProject in graph) {
|
||||
val existingModuleEntity = project.workspaceModel.currentSnapshot
|
||||
.entitiesBySource { it == source }
|
||||
.filterIsInstance<ModuleEntity>()
|
||||
.find { it.name == extProject.name }
|
||||
val existingSdkEntity = existingModuleEntity
|
||||
?.dependencies
|
||||
?.find { it is SdkDependency } as? SdkDependency
|
||||
val sdkDependency = existingSdkEntity ?: InheritedSdkDependency
|
||||
storage addEntity ModuleEntity(extProject.name, emptyList(), source) {
|
||||
dependencies += sdkDependency
|
||||
dependencies += ModuleSourceDependency
|
||||
for (moduleName in extProject.dependencies) {
|
||||
dependencies += ModuleDependency(ModuleId(moduleName.name), true, DependencyScope.COMPILE, false)
|
||||
}
|
||||
contentRoots = listOf(ContentRootEntity(extProject.root.toVirtualFileUrl(fileUrlManager), emptyList(), source) {
|
||||
sourceRoots = extProject.sourceRoots.map { srcRoot ->
|
||||
SourceRootEntity(srcRoot.toVirtualFileUrl(fileUrlManager), PYTHON_SOURCE_ROOT_TYPE, source)
|
||||
}
|
||||
excludedUrls = extProject.excludedRoots.map { excludedRoot ->
|
||||
ExcludeUrlEntity(excludedRoot.toVirtualFileUrl(fileUrlManager), source)
|
||||
}
|
||||
})
|
||||
exModuleOptions = ExternalSystemModuleOptionsEntity(source) {
|
||||
externalSystem = systemName
|
||||
linkedProjectId = extProject.fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
return@withContext storage
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the default IJ module created for the root of the project
|
||||
* (that's going to be replaced with a module belonging to a specific project management system).
|
||||
*
|
||||
* @see com.intellij.openapi.project.impl.getOrInitializeModule
|
||||
*/
|
||||
internal fun removeFakeModuleEntity(project: Project, storage: MutableEntityStorage) {
|
||||
val virtualFileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
|
||||
val basePathUrl = project.baseNioPath?.toVirtualFileUrl(virtualFileUrlManager) ?: return
|
||||
val contentRoots = storage
|
||||
.entitiesBySource { !getEntitySourceClass().isInstance(it) }
|
||||
.filterIsInstance<ContentRootEntity>()
|
||||
.filter { it.url == basePathUrl }
|
||||
.toList()
|
||||
for (entity in contentRoots) {
|
||||
storage.removeEntity(entity.module)
|
||||
storage.removeEntity(entity)
|
||||
}
|
||||
}
|
||||
|
||||
private val Project.baseNioPath: Path?
|
||||
get() = basePath?.let { Path.of(it) }
|
||||
|
||||
companion object {
|
||||
// For the time being mark them as java-sources to indicate that in the Project tool window
|
||||
val PYTHON_SOURCE_ROOT_TYPE: SourceRootTypeId = SourceRootTypeId("java-source")
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel
|
||||
|
||||
import com.intellij.ide.impl.runUnderModalProgressIfIsEdt
|
||||
import com.intellij.openapi.externalSystem.importing.AbstractOpenProjectProvider
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.projectImport.ProjectOpenProcessor
|
||||
|
||||
/**
|
||||
* Automatically configures a new project without `.idea/` as a project managed by Poetry if there is
|
||||
* a top-level pyproject.toml at the project root.
|
||||
* The user will be asked if
|
||||
* - There are several possible build systems for the project.
|
||||
* - The top-level pyproject.toml is added afterward in a project with existing `.idea/`.
|
||||
* - pyproject.toml files are found in non-top-level directories (requires IJPL-180733).
|
||||
*/
|
||||
internal abstract class PyProjectTomlOpenProcessorBase : ProjectOpenProcessor() {
|
||||
abstract val importProvider: AbstractOpenProjectProvider
|
||||
|
||||
final override fun canOpenProject(file: VirtualFile): Boolean = enablePyProjectToml && importProvider.canOpenProject(file)
|
||||
|
||||
final override suspend fun openProjectAsync(
|
||||
virtualFile: VirtualFile,
|
||||
projectToClose: Project?,
|
||||
forceOpenInNewFrame: Boolean,
|
||||
): Project? {
|
||||
return importProvider.openProject(virtualFile, projectToClose, forceOpenInNewFrame)
|
||||
}
|
||||
|
||||
final override fun canImportProjectAfterwards(): Boolean = true
|
||||
|
||||
// TODO Requires IJPL-180733
|
||||
|
||||
final override suspend fun importProjectAfterwardsAsync(project: Project, file: VirtualFile) {
|
||||
importProvider.linkToExistingProjectAsync(file, project)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.poetry
|
||||
|
||||
import com.intellij.openapi.externalSystem.model.ProjectSystemId
|
||||
|
||||
object PoetryConstants {
|
||||
const val POETRY_LOCK: String = "poetry.lock"
|
||||
val SYSTEM_ID: ProjectSystemId = ProjectSystemId("Poetry")
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.poetry
|
||||
|
||||
import com.intellij.platform.workspace.storage.EntitySource
|
||||
import com.intellij.platform.workspace.storage.url.VirtualFileUrl
|
||||
|
||||
/**
|
||||
* Identifies workspace model entities managed by Poetry.
|
||||
*/
|
||||
class PoetryEntitySource(val projectPath: VirtualFileUrl) : EntitySource {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as PoetryEntitySource
|
||||
|
||||
return projectPath == other.projectPath
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return projectPath.hashCode()
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.poetry
|
||||
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.platform.backend.observation.ActivityKey
|
||||
import com.intellij.platform.backend.observation.launchTracked
|
||||
import com.intellij.platform.backend.observation.trackActivityBlocking
|
||||
import com.jetbrains.python.PyBundle
|
||||
import com.jetbrains.python.projectModel.enablePyProjectToml
|
||||
import com.jetbrains.python.projectModel.poetry.PoetryLinkAction.CoroutineScopeService.Companion.coroutineScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.jetbrains.annotations.Nls
|
||||
|
||||
/**
|
||||
* Discovers and links as managed by Poetry all relevant project roots and saves them in `.idea/poetry.xml`.
|
||||
* For a tree of nested poetry projects, only the topmost directories are linked.
|
||||
*/
|
||||
internal class PoetryLinkAction : AnAction() {
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val project = e.project ?: return
|
||||
val basePath = project.basePath ?: return
|
||||
project.trackActivityBlocking(PoetryLinkActivityKey) {
|
||||
project.coroutineScope.launchTracked {
|
||||
PoetryProjectModelService.linkAllProjectModelRoots(project, basePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
e.presentation.isEnabledAndVisible = enablePyProjectToml
|
||||
}
|
||||
|
||||
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
|
||||
|
||||
object PoetryLinkActivityKey : ActivityKey {
|
||||
override val presentableName: @Nls String
|
||||
get() = PyBundle.message("python.project.model.activity.key.poetry.link")
|
||||
}
|
||||
|
||||
@Service(Service.Level.PROJECT)
|
||||
private class CoroutineScopeService(private val coroutineScope: CoroutineScope) {
|
||||
companion object {
|
||||
val Project.coroutineScope: CoroutineScope
|
||||
get() = service<CoroutineScopeService>().coroutineScope
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.poetry
|
||||
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.extensions.ExtensionNotApplicableException
|
||||
import com.intellij.openapi.externalSystem.autoimport.*
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.startup.ProjectActivity
|
||||
import com.intellij.openapi.util.io.toCanonicalPath
|
||||
import com.intellij.platform.backend.observation.launchTracked
|
||||
import com.intellij.platform.backend.workspace.workspaceModel
|
||||
import com.intellij.platform.workspace.jps.entities.ContentRootEntity
|
||||
import com.intellij.platform.workspace.storage.entities
|
||||
import com.intellij.platform.workspace.storage.impl.url.toVirtualFileUrl
|
||||
import com.intellij.platform.workspace.storage.url.VirtualFileUrl
|
||||
import com.intellij.python.pyproject.PY_PROJECT_TOML
|
||||
import com.intellij.workspaceModel.ide.toPath
|
||||
import com.jetbrains.python.projectModel.enablePyProjectToml
|
||||
import com.jetbrains.python.projectModel.poetry.PoetryProjectAware.CoroutineScopeService.Companion.coroutineScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* Tracks changes in pyproject.toml files and suggests syncing their changes with the project model
|
||||
* according to the `Settings | Build, Execution, Deployment | Build Tools` settings.
|
||||
*/
|
||||
internal class PoetryProjectAware(
|
||||
private val project: Project,
|
||||
override val projectId: ExternalSystemProjectId,
|
||||
) : ExternalSystemProjectAware {
|
||||
|
||||
override val settingsFiles: Set<String>
|
||||
get() = collectSettingFiles()
|
||||
|
||||
override fun subscribe(listener: ExternalSystemProjectListener, parentDisposable: Disposable) {
|
||||
project.messageBus.connect(parentDisposable).subscribe(PoetrySyncListener.TOPIC, object : PoetrySyncListener {
|
||||
override fun onStart(projectRoot: Path) = listener.onProjectReloadStart()
|
||||
override fun onFinish(projectRoot: Path) = listener.onProjectReloadFinish(status = ExternalSystemRefreshStatus.SUCCESS)
|
||||
})
|
||||
}
|
||||
|
||||
override fun reloadProject(context: ExternalSystemProjectReloadContext) {
|
||||
project.coroutineScope.launchTracked {
|
||||
PoetryProjectModelService.syncProjectModelRoot(project, Path.of(projectId.externalProjectPath))
|
||||
}
|
||||
}
|
||||
|
||||
// Called after sync
|
||||
private fun collectSettingFiles(): Set<String> {
|
||||
val source = PoetryEntitySource(projectId.externalProjectPath.toVirtualFileUrl(project))
|
||||
return project.workspaceModel.currentSnapshot
|
||||
.entities<ContentRootEntity>()
|
||||
.filter { it.entitySource == source }
|
||||
.map { it.url.toPath() }
|
||||
.map { it.resolve(PY_PROJECT_TOML) }
|
||||
.map { it.toCanonicalPath() }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
private fun String.toVirtualFileUrl(project: Project): VirtualFileUrl {
|
||||
return Path.of(this).toVirtualFileUrl(project.workspaceModel.getVirtualFileUrlManager())
|
||||
}
|
||||
|
||||
@Service(Service.Level.PROJECT)
|
||||
private class CoroutineScopeService(private val coroutineScope: CoroutineScope) {
|
||||
companion object {
|
||||
val Project.coroutineScope: CoroutineScope
|
||||
get() = service<CoroutineScopeService>().coroutineScope
|
||||
}
|
||||
}
|
||||
|
||||
internal class PoetrySyncStartupActivity : ProjectActivity {
|
||||
init {
|
||||
if (!enablePyProjectToml) {
|
||||
throw ExtensionNotApplicableException.create()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun execute(project: Project) {
|
||||
val projectTracker = ExternalSystemProjectTracker.getInstance(project)
|
||||
project.service<PoetrySettings>().getLinkedProjects().forEach { projectRoot ->
|
||||
val projectId = ExternalSystemProjectId(PoetryConstants.SYSTEM_ID, projectRoot.toCanonicalPath())
|
||||
val projectAware = PoetryProjectAware(project, projectId)
|
||||
projectTracker.register(projectAware)
|
||||
projectTracker.activate(projectId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class PoetryListener(private val project: Project) : PoetrySettingsListener {
|
||||
init {
|
||||
if (!enablePyProjectToml) {
|
||||
throw ExtensionNotApplicableException.create()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLinkedProjectAdded(projectRoot: Path) {
|
||||
val projectTracker = ExternalSystemProjectTracker.getInstance(project)
|
||||
val projectId = ExternalSystemProjectId(PoetryConstants.SYSTEM_ID, projectRoot.toCanonicalPath())
|
||||
val projectAware = PoetryProjectAware(project, projectId)
|
||||
projectTracker.register(projectAware)
|
||||
projectTracker.activate(projectId)
|
||||
}
|
||||
|
||||
override fun onLinkedProjectRemoved(projectRoot: Path) {
|
||||
val projectId = ExternalSystemProjectId(PoetryConstants.SYSTEM_ID, projectRoot.toCanonicalPath())
|
||||
ExternalSystemProjectTracker.getInstance(project).remove(projectId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.poetry
|
||||
|
||||
import com.intellij.python.pyproject.PY_PROJECT_TOML
|
||||
import com.jetbrains.python.projectModel.ExternalProject
|
||||
import com.jetbrains.python.projectModel.ExternalProjectDependency
|
||||
import com.jetbrains.python.projectModel.ExternalProjectGraph
|
||||
import com.jetbrains.python.projectModel.PythonProjectModelResolver
|
||||
import org.apache.tuweni.toml.Toml
|
||||
import org.apache.tuweni.toml.TomlTable
|
||||
import java.net.URI
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.*
|
||||
|
||||
// e.g. "lib @ file:///home/user/projects/main/lib"
|
||||
private val PEP_621_PATH_DEPENDENCY = """([\w-]+) @ (file:.*)""".toRegex()
|
||||
|
||||
data class PoetryProject(
|
||||
override val name: String,
|
||||
override val root: Path,
|
||||
override val dependencies: List<ExternalProjectDependency>,
|
||||
) : ExternalProject {
|
||||
override val sourceRoots: List<Path>
|
||||
get() = listOfNotNull((root / "src").takeIf { it.isDirectory() })
|
||||
override val excludedRoots: List<Path>
|
||||
get() = emptyList()
|
||||
|
||||
// Poetry projects don't have any declarative hierarchical structure
|
||||
override val fullName: String?
|
||||
get() = name
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPathApi::class)
|
||||
internal object PoetryProjectModelResolver : PythonProjectModelResolver<PoetryProject> {
|
||||
override fun discoverProjectRootSubgraph(root: Path): ExternalProjectGraph<PoetryProject>? {
|
||||
if (!root.resolve(PY_PROJECT_TOML).exists()) {
|
||||
return null
|
||||
}
|
||||
val poetryProjects = root.walk()
|
||||
.filter { it.name == PY_PROJECT_TOML }
|
||||
.mapNotNull(::readPoetryPyProjectToml)
|
||||
.toList()
|
||||
if (poetryProjects.isNotEmpty()) {
|
||||
val modules = poetryProjects
|
||||
.map {
|
||||
PoetryProject(
|
||||
name = it.projectName,
|
||||
root = it.root,
|
||||
dependencies = it.editablePathDependencies.map { entry ->
|
||||
ExternalProjectDependency(entry.key, entry.value)
|
||||
})
|
||||
}
|
||||
return ExternalProjectGraph(
|
||||
root = root,
|
||||
projects = modules
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun readPoetryPyProjectToml(pyprojectTomlPath: Path): PoetryPyProjectToml? {
|
||||
val pyprojectToml = Toml.parse(pyprojectTomlPath)
|
||||
val projectName = pyprojectToml.getString("tool.poetry.name") ?: pyprojectToml.getString("project.name")
|
||||
if (projectName == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val moduleDependencies = pyprojectToml.getArrayOrEmpty("project.dependencies")
|
||||
.toList()
|
||||
.filterIsInstance<String>()
|
||||
.mapNotNull { depSpec ->
|
||||
val match = PEP_621_PATH_DEPENDENCY.matchEntire(depSpec)
|
||||
if (match == null) return@mapNotNull null
|
||||
val (depName, depUri) = match.destructured
|
||||
val depPath = runCatching { Path.of(URI(depUri)) }.getOrNull() ?: return@mapNotNull null
|
||||
if (!depPath.isDirectory() || !depPath.resolve(PY_PROJECT_TOML).exists()) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
return@mapNotNull depName to depPath
|
||||
}
|
||||
.toMap()
|
||||
|
||||
val oldStyleModuleDependencies = pyprojectToml.getTableOrEmpty("tool.poetry.dependencies")
|
||||
.toMap().entries
|
||||
.mapNotNull { (depName, depSpec) ->
|
||||
if (depSpec !is TomlTable || depSpec.getBoolean("develop") != true) return@mapNotNull null
|
||||
val depPath = depSpec.getString("path")?.let { pyprojectTomlPath.parent.resolve(it).normalize() }
|
||||
if (depPath == null || !depPath.isDirectory() || !depPath.resolve(PY_PROJECT_TOML).exists()) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
return@mapNotNull depName to depPath
|
||||
}
|
||||
.toMap()
|
||||
|
||||
return PoetryPyProjectToml(
|
||||
projectName = projectName,
|
||||
root = pyprojectTomlPath.parent,
|
||||
editablePathDependencies = moduleDependencies.ifEmpty { oldStyleModuleDependencies }
|
||||
)
|
||||
}
|
||||
|
||||
private data class PoetryPyProjectToml(
|
||||
val projectName: String,
|
||||
val root: Path,
|
||||
val editablePathDependencies: Map<String, Path>,
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.poetry
|
||||
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.NlsSafe
|
||||
import com.intellij.platform.backend.workspace.workspaceModel
|
||||
import com.intellij.platform.workspace.storage.EntitySource
|
||||
import com.intellij.platform.workspace.storage.impl.url.toVirtualFileUrl
|
||||
import com.jetbrains.python.projectModel.BaseProjectModelService
|
||||
import com.jetbrains.python.projectModel.ProjectModelSettings
|
||||
import com.jetbrains.python.projectModel.ProjectModelSyncListener
|
||||
import com.jetbrains.python.projectModel.PythonProjectModelResolver
|
||||
import java.nio.file.Path
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* Syncs the project model described in pyproject.toml files with the IntelliJ project model.
|
||||
*/
|
||||
object PoetryProjectModelService : BaseProjectModelService<PoetryEntitySource, PoetryProject>() {
|
||||
override val projectModelResolver: PythonProjectModelResolver<PoetryProject>
|
||||
get() = PoetryProjectModelResolver
|
||||
|
||||
override val systemName: @NlsSafe String
|
||||
get() = "Poetry"
|
||||
|
||||
override fun getSettings(project: Project): ProjectModelSettings = project.service<PoetrySettings>()
|
||||
|
||||
override fun getSyncListener(project: Project): ProjectModelSyncListener = project.messageBus.syncPublisher(PoetrySyncListener.TOPIC)
|
||||
|
||||
override fun createEntitySource(project: Project, singleProjectRoot: Path): EntitySource {
|
||||
val fileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
|
||||
return PoetryEntitySource(singleProjectRoot.toVirtualFileUrl(fileUrlManager))
|
||||
}
|
||||
|
||||
override fun getEntitySourceClass(): KClass<out EntitySource> = PoetryEntitySource::class
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.poetry
|
||||
|
||||
import com.jetbrains.python.PyBundle
|
||||
import com.jetbrains.python.icons.PythonIcons
|
||||
import com.jetbrains.python.projectModel.PyProjectTomlOpenProcessorBase
|
||||
import org.jetbrains.annotations.Nls
|
||||
import javax.swing.Icon
|
||||
|
||||
|
||||
internal class PoetryProjectOpenProcessor : PyProjectTomlOpenProcessorBase() {
|
||||
override val importProvider = PoetryProjectOpenProvider()
|
||||
override val name: @Nls String = PyBundle.message("python.project.model.poetry")
|
||||
override val icon: Icon = PythonIcons.Python.Origami
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.poetry
|
||||
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.externalSystem.importing.AbstractOpenProjectProvider
|
||||
import com.intellij.openapi.externalSystem.model.ProjectSystemId
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.openapi.vfs.readText
|
||||
import com.intellij.openapi.vfs.toNioPathOrNull
|
||||
import com.intellij.python.pyproject.PY_PROJECT_TOML
|
||||
import java.nio.file.Path
|
||||
|
||||
internal class PoetryProjectOpenProvider() : AbstractOpenProjectProvider() {
|
||||
override val systemId: ProjectSystemId = PoetryConstants.SYSTEM_ID
|
||||
|
||||
override fun isProjectFile(file: VirtualFile): Boolean {
|
||||
return file.name == PoetryConstants.POETRY_LOCK || isPoetrySpecificPyProjectToml(file)
|
||||
}
|
||||
|
||||
private fun isPoetrySpecificPyProjectToml(file: VirtualFile): Boolean {
|
||||
return file.name == PY_PROJECT_TOML && POETRY_TOOL_TABLE_HEADER.find(file.readText()) != null
|
||||
}
|
||||
|
||||
override suspend fun linkProject(projectFile: VirtualFile, project: Project) {
|
||||
val projectDirectory = getProjectDirectory(projectFile)
|
||||
val projectRootPath = projectDirectory.toNioPathOrNull() ?: Path.of(projectDirectory.path)
|
||||
project.service<PoetrySettings>().addLinkedProject(projectRootPath)
|
||||
PoetryProjectModelService.syncProjectModelRoot(project, projectRootPath)
|
||||
}
|
||||
|
||||
suspend fun unlinkProject(project: Project, externalProjectPath: String) {
|
||||
PoetryProjectModelService.forgetProjectModelRoot(project, Path.of(externalProjectPath))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val POETRY_TOOL_TABLE_HEADER: Regex = """\[tool\.poetry[.\w-]*]""".toRegex()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.poetry
|
||||
|
||||
import com.intellij.openapi.components.*
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.jetbrains.python.projectModel.ProjectModelSettings
|
||||
import java.net.URI
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.toPath
|
||||
|
||||
// TODO SerializablePersistentStateComponent
|
||||
@Service(Service.Level.PROJECT)
|
||||
@State(name = "PoetrySettings", storages = [Storage("poetry.xml")])
|
||||
class PoetrySettings(private val project: Project)
|
||||
: SimplePersistentStateComponent<PoetrySettings.State>(State()), ProjectModelSettings {
|
||||
|
||||
class State() : BaseState() {
|
||||
var linkedProjects: MutableList<String> by list()
|
||||
}
|
||||
|
||||
override fun setLinkedProjects(projects: List<Path>) {
|
||||
val oldLinkedProjects = getLinkedProjects()
|
||||
val removedLinkedProjects = oldLinkedProjects - projects
|
||||
val addedLinkedProjects = projects - oldLinkedProjects
|
||||
val listener = project.messageBus.syncPublisher(PoetrySettingsListener.Companion.TOPIC)
|
||||
removedLinkedProjects.forEach { listener.onLinkedProjectRemoved(it) }
|
||||
addedLinkedProjects.forEach { listener.onLinkedProjectAdded(it) }
|
||||
|
||||
state.linkedProjects = projects.map { it.toUri().toString() }.toMutableList()
|
||||
}
|
||||
|
||||
override fun getLinkedProjects(): List<Path> {
|
||||
return state.linkedProjects.map { URI(it).toPath() }
|
||||
}
|
||||
|
||||
override fun addLinkedProject(projectRoot: Path) {
|
||||
val existing = getLinkedProjects()
|
||||
if (projectRoot !in existing) {
|
||||
setLinkedProjects(existing + listOf(projectRoot))
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeLinkedProject(projectRoot: Path) {
|
||||
val existing = getLinkedProjects()
|
||||
if (projectRoot in existing) {
|
||||
setLinkedProjects(existing - listOf(projectRoot))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.poetry
|
||||
|
||||
import com.intellij.util.messages.Topic
|
||||
import java.nio.file.Path
|
||||
|
||||
// TODO Actions for linking/unlinking pyproject.toml files
|
||||
internal interface PoetrySettingsListener {
|
||||
companion object {
|
||||
@Topic.ProjectLevel
|
||||
val TOPIC: Topic<PoetrySettingsListener> = Topic(PoetrySettingsListener::class.java, Topic.BroadcastDirection.NONE)
|
||||
}
|
||||
|
||||
fun onLinkedProjectAdded(projectRoot: Path): Unit = Unit
|
||||
fun onLinkedProjectRemoved(projectRoot: Path): Unit = Unit
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.poetry
|
||||
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.platform.backend.observation.ActivityKey
|
||||
import com.intellij.platform.backend.observation.launchTracked
|
||||
import com.intellij.platform.backend.observation.trackActivityBlocking
|
||||
import com.jetbrains.python.PyBundle
|
||||
import com.jetbrains.python.projectModel.enablePyProjectToml
|
||||
import com.jetbrains.python.projectModel.poetry.PoetrySyncAction.CoroutineScopeService.Companion.coroutineScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.jetbrains.annotations.Nls
|
||||
|
||||
/**
|
||||
* Forcibly syncs all *already linked* Poetry projects, overriding their workspace models.
|
||||
*/
|
||||
internal class PoetrySyncAction : AnAction() {
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val project = e.project ?: return
|
||||
project.trackActivityBlocking(PoetryActivityKey) {
|
||||
project.coroutineScope.launchTracked {
|
||||
PoetryProjectModelService.syncAllProjectModelRoots(project = project)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
e.presentation.isEnabledAndVisible = enablePyProjectToml
|
||||
}
|
||||
|
||||
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
|
||||
|
||||
object PoetryActivityKey : ActivityKey {
|
||||
override val presentableName: @Nls String
|
||||
get() = PyBundle.message("python.project.model.activity.key.poetry.sync")
|
||||
}
|
||||
|
||||
@Service(Service.Level.PROJECT)
|
||||
private class CoroutineScopeService(private val coroutineScope: CoroutineScope) {
|
||||
companion object {
|
||||
val Project.coroutineScope: CoroutineScope
|
||||
get() = service<CoroutineScopeService>().coroutineScope
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.poetry
|
||||
|
||||
import com.intellij.util.messages.Topic
|
||||
import com.jetbrains.python.projectModel.ProjectModelSyncListener
|
||||
import java.nio.file.Path
|
||||
|
||||
internal interface PoetrySyncListener : ProjectModelSyncListener {
|
||||
companion object {
|
||||
@Topic.ProjectLevel
|
||||
val TOPIC: Topic<PoetrySyncListener> = Topic(PoetrySyncListener::class.java, Topic.BroadcastDirection.NONE)
|
||||
}
|
||||
|
||||
// Add onFailure
|
||||
// Add onCancel
|
||||
override fun onStart(projectRoot: Path): Unit = Unit
|
||||
override fun onFinish(projectRoot: Path): Unit = Unit
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.poetry
|
||||
|
||||
import com.intellij.openapi.diagnostic.fileLogger
|
||||
import com.intellij.openapi.util.NlsSafe
|
||||
import com.intellij.python.pyproject.PyProjectToml
|
||||
import com.intellij.python.pyproject.model.spi.*
|
||||
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
|
||||
import com.jetbrains.python.venvReader.Directory
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.tuweni.toml.TomlTable
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import java.nio.file.InvalidPathException
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.toPath
|
||||
|
||||
internal class PoetryTool : Tool {
|
||||
|
||||
override val id: ToolId = ToolId("poetry")
|
||||
|
||||
override suspend fun getSrcRoots(toml: TomlTable, projectRoot: Directory): Set<Directory> = emptySet()
|
||||
|
||||
override suspend fun getProjectName(projectToml: TomlTable): @NlsSafe String? =
|
||||
projectToml.getString("tool.poetry.name")
|
||||
|
||||
override suspend fun getProjectStructure(entries: Map<ProjectName, PyProjectTomlProject>, rootIndex: Map<Directory, ProjectName>): ProjectStructureInfo = withContext(Dispatchers.Default) {
|
||||
val deps = entries.asSequence().map { (name, entry) ->
|
||||
val deps = getDependencies(entry.root, entry.pyProjectToml).mapNotNull { dir ->
|
||||
rootIndex[dir] ?: run {
|
||||
logger.warn("Can't find project for dir $dir")
|
||||
null
|
||||
}
|
||||
}.toSet()
|
||||
Pair(name, deps)
|
||||
}.toMap()
|
||||
return@withContext ProjectStructureInfo(dependencies = deps, membersToWorkspace = emptyMap()) // No workspace info (yet)
|
||||
}
|
||||
|
||||
@RequiresBackgroundThread
|
||||
private fun getDependencies(rootDir: Directory, projectToml: PyProjectToml): Set<Directory> {
|
||||
val depsFromFile = projectToml.project?.dependencies?.project ?: emptyList()
|
||||
val moduleDependencies = depsFromFile
|
||||
.mapNotNull { depSpec ->
|
||||
val match = PEP_621_PATH_DEPENDENCY.matchEntire(depSpec) ?: return@mapNotNull null
|
||||
val (_, depUri) = match.destructured
|
||||
return@mapNotNull parseDepUri(depUri)
|
||||
}
|
||||
|
||||
val oldStyleModuleDependencies = projectToml.toml.getTableOrEmpty("tool.poetry.dependencies")
|
||||
.toMap().entries
|
||||
.mapNotNull { (_, depSpec) ->
|
||||
if (depSpec !is TomlTable || depSpec.getBoolean("develop") != true) return@mapNotNull null
|
||||
depSpec.getString("path")?.let { rootDir.resolve(it).normalize() }
|
||||
}
|
||||
return moduleDependencies.toSet() + oldStyleModuleDependencies.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// e.g. "lib @ file:///home/user/projects/main/lib"
|
||||
private val PEP_621_PATH_DEPENDENCY = """([\w-]+) @ (file:.*)""".toRegex()
|
||||
|
||||
private val logger = fileLogger()
|
||||
private fun parseDepUri(depUri: String): Path? =
|
||||
try {
|
||||
URI(depUri).toPath()
|
||||
}
|
||||
catch (e: InvalidPathException) {
|
||||
logger.info("Dep $depUri points to wrong path", e)
|
||||
null
|
||||
}
|
||||
catch (e: URISyntaxException) {
|
||||
logger.info("Dep $depUri can't be parsed", e)
|
||||
null
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.poetry
|
||||
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.externalSystem.autolink.ExternalSystemProjectLinkListener
|
||||
import com.intellij.openapi.externalSystem.autolink.ExternalSystemUnlinkedProjectAware
|
||||
import com.intellij.openapi.externalSystem.model.ProjectSystemId
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.io.toCanonicalPath
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.jetbrains.python.projectModel.enablePyProjectToml
|
||||
import java.nio.file.Path
|
||||
|
||||
internal class PoetryUnlinkedProjectAware : ExternalSystemUnlinkedProjectAware {
|
||||
private val openProvider = PoetryProjectOpenProvider()
|
||||
|
||||
override val systemId: ProjectSystemId = PoetryConstants.SYSTEM_ID
|
||||
|
||||
override fun isBuildFile(project: Project, buildFile: VirtualFile): Boolean {
|
||||
return enablePyProjectToml && openProvider.canOpenProject(buildFile)
|
||||
}
|
||||
|
||||
override fun isLinkedProject(project: Project, externalProjectPath: String): Boolean {
|
||||
val projectPath = Path.of(externalProjectPath)
|
||||
return project.service<PoetrySettings>().getLinkedProjects().any { it == projectPath }
|
||||
}
|
||||
|
||||
override fun subscribe(project: Project, listener: ExternalSystemProjectLinkListener, parentDisposable: Disposable) {
|
||||
project.messageBus.connect(parentDisposable).subscribe(PoetrySettingsListener.TOPIC, object : PoetrySettingsListener {
|
||||
override fun onLinkedProjectAdded(projectRoot: Path) = listener.onProjectLinked(projectRoot.toCanonicalPath())
|
||||
override fun onLinkedProjectRemoved(projectRoot: Path) = listener.onProjectUnlinked(projectRoot.toCanonicalPath())
|
||||
})
|
||||
}
|
||||
|
||||
override suspend fun linkAndLoadProjectAsync(project: Project, externalProjectPath: String) {
|
||||
openProvider.linkToExistingProjectAsync(externalProjectPath, project)
|
||||
}
|
||||
|
||||
override suspend fun unlinkProject(project: Project, externalProjectPath: String) {
|
||||
openProvider.unlinkProject(project, externalProjectPath)
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel
|
||||
|
||||
import com.intellij.openapi.util.io.FileUtil
|
||||
import com.intellij.openapi.util.registry.Registry
|
||||
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.nio.file.FileVisitResult
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.ExperimentalPathApi
|
||||
import kotlin.io.path.visitFileTree
|
||||
|
||||
|
||||
/**
|
||||
* Convert `pyproject.toml` to modules
|
||||
*/
|
||||
val enablePyProjectToml: Boolean get() = Registry.`is`("python.pyproject.model")
|
||||
|
||||
/**
|
||||
* Represents a graph of modules residing under a common root directory.
|
||||
* These modules might depend on each other, but it's not a requirement.
|
||||
* The root itself can be a valid module root, but it's not a requirement.
|
||||
*/
|
||||
data class ExternalProjectGraph<P : ExternalProject>(val root: Path, val projects: List<P>)
|
||||
|
||||
/**
|
||||
* Defines a project module in a particular directory with its unique name, and a set of module dependencies
|
||||
* (usually editable Python path dependencies to other modules in the same IJ project).
|
||||
*/
|
||||
interface ExternalProject {
|
||||
val name: String
|
||||
val root: Path
|
||||
val dependencies: List<ExternalProjectDependency>
|
||||
val sourceRoots: List<Path>
|
||||
val excludedRoots: List<Path>
|
||||
|
||||
/**
|
||||
* The colon separated fully qualified name of the project in the form `root:subproject:subsubproject`
|
||||
* if there is the given project system supports hierarchical organization of projects (e.g., uv workspaces).
|
||||
*/
|
||||
val fullName: String?
|
||||
}
|
||||
|
||||
data class ExternalProjectDependency(val name: String, val path: Path)
|
||||
|
||||
interface PythonProjectModelResolver<P : ExternalProject> {
|
||||
/**
|
||||
* If the `root` directory is considered a project root in a particular project management system
|
||||
* (e.g. it contains pyproject.toml or other such marker files), traverse it and return a subgraph describing modules
|
||||
* declared inside this root.
|
||||
* All these module roots should reside withing the `root` directory but their dependencies might be outside it.
|
||||
* The root directory of the graph should be `root`.
|
||||
*
|
||||
* For instance, in the following layout (assuming that pyproject.toml indicates a valid project root)
|
||||
* ```
|
||||
* libs/
|
||||
* project1/
|
||||
* pyproject.toml
|
||||
* project2/
|
||||
* pyproject.toml
|
||||
* ```
|
||||
* this method should return `null` for `libs/` but module graphs containing *only* modules `project1` and `project2`
|
||||
* for the directories `project1/` and `project2` respectively, even if there is a dependency between them.
|
||||
*/
|
||||
@RequiresBackgroundThread
|
||||
fun discoverProjectRootSubgraph(root: Path): ExternalProjectGraph<P>?
|
||||
|
||||
/**
|
||||
* Find all project model graphs within the given directory (presumably the root directory of an IJ project).
|
||||
* All these graphs are supposed to be independent components, i.e., they don't depend on each other's modules.
|
||||
* The roots of these modules might not themselves be valid modules, but just plain directories.
|
||||
*
|
||||
* For instance, in the following layout
|
||||
* ```
|
||||
* libs/
|
||||
* project1/
|
||||
* pyproject.toml
|
||||
* project2/
|
||||
* pyproject.toml
|
||||
* ```
|
||||
* If `project1` depends on `project2` (or vice-versa), this methods should return a single graph with its
|
||||
* root in `libs/` containing modules for both `project1` and `project2`.
|
||||
* If these two projects are independents, there will be two graphs for `project1` and `project2` respectively.
|
||||
*/
|
||||
@OptIn(ExperimentalPathApi::class)
|
||||
suspend fun discoverIndependentProjectGraphs(root: Path): List<ExternalProjectGraph<P>> = withContext(Dispatchers.IO) {
|
||||
val graphs = mutableListOf<ExternalProjectGraph<P>>()
|
||||
root.visitFileTree {
|
||||
onPreVisitDirectory { dir, _ ->
|
||||
val buildSystemRoot = discoverProjectRootSubgraph(dir)
|
||||
if (buildSystemRoot != null) {
|
||||
graphs.add(buildSystemRoot)
|
||||
return@onPreVisitDirectory FileVisitResult.SKIP_SUBTREE
|
||||
}
|
||||
return@onPreVisitDirectory FileVisitResult.CONTINUE
|
||||
}
|
||||
}
|
||||
|
||||
// TODO make sure that roots doesn't leave ijProjectRoot boundaries
|
||||
return@withContext mergeRootsReferringToEachOther(graphs)
|
||||
}
|
||||
}
|
||||
|
||||
interface ProjectModelSettings {
|
||||
fun getLinkedProjects(): List<Path>
|
||||
fun setLinkedProjects(projects: List<Path>)
|
||||
fun removeLinkedProject(projectRoot: Path)
|
||||
fun addLinkedProject(projectRoot: Path)
|
||||
}
|
||||
|
||||
interface ProjectModelSyncListener {
|
||||
fun onStart(projectRoot: Path): Unit = Unit
|
||||
fun onFinish(projectRoot: Path): Unit = Unit
|
||||
}
|
||||
|
||||
@RequiresBackgroundThread
|
||||
private fun <P : ExternalProject> mergeRootsReferringToEachOther(roots: List<ExternalProjectGraph<P>>): List<ExternalProjectGraph<P>> {
|
||||
fun commonAncestorPath(paths: Iterable<Path>): Path {
|
||||
val normalized = paths.map { it.normalize() }
|
||||
return normalized.reduce { p1, p2 -> FileUtil.findAncestor(p1, p2)!! }
|
||||
}
|
||||
|
||||
val expandedProjectRoots = roots.map { root ->
|
||||
val allModuleRootsAndDependencies = root.projects.asSequence()
|
||||
.flatMap { module -> listOf(module.root) + module.dependencies.map { it.path } }
|
||||
.distinct()
|
||||
.toList()
|
||||
root.copy(root = commonAncestorPath(allModuleRootsAndDependencies))
|
||||
}
|
||||
|
||||
val expandedProjectRootsByRootPath = expandedProjectRoots.sortedBy { it.root }
|
||||
val mergedProjectRoots = mutableListOf<ExternalProjectGraph<P>>()
|
||||
for (root in expandedProjectRootsByRootPath) {
|
||||
if (mergedProjectRoots.isEmpty()) {
|
||||
mergedProjectRoots.add(root)
|
||||
}
|
||||
else {
|
||||
val lastCluster = mergedProjectRoots.last()
|
||||
if (root.root.startsWith(lastCluster.root)) {
|
||||
mergedProjectRoots[mergedProjectRoots.lastIndex] = lastCluster.copy(projects = lastCluster.projects + root.projects)
|
||||
}
|
||||
else {
|
||||
mergedProjectRoots.add(root)
|
||||
}
|
||||
}
|
||||
}
|
||||
return mergedProjectRoots
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.uv
|
||||
|
||||
import com.intellij.openapi.externalSystem.model.ProjectSystemId
|
||||
|
||||
object UvConstants {
|
||||
const val UV_LOCK: String = "uv.lock"
|
||||
val SYSTEM_ID: ProjectSystemId = ProjectSystemId("uv")
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.uv
|
||||
|
||||
import com.intellij.platform.workspace.storage.EntitySource
|
||||
import com.intellij.platform.workspace.storage.url.VirtualFileUrl
|
||||
|
||||
/**
|
||||
* Identifies workspace model entities managed by uv.
|
||||
*/
|
||||
class UvEntitySource(val projectPath: VirtualFileUrl) : EntitySource {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as UvEntitySource
|
||||
|
||||
return projectPath == other.projectPath
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return projectPath.hashCode()
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.uv
|
||||
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.registry.Registry
|
||||
import com.intellij.platform.backend.observation.ActivityKey
|
||||
import com.intellij.platform.backend.observation.launchTracked
|
||||
import com.intellij.platform.backend.observation.trackActivityBlocking
|
||||
import com.jetbrains.python.PyBundle
|
||||
import com.jetbrains.python.projectModel.enablePyProjectToml
|
||||
import com.jetbrains.python.projectModel.uv.UvLinkAction.CoroutineScopeService.Companion.coroutineScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.jetbrains.annotations.Nls
|
||||
|
||||
/**
|
||||
* Discovers and links as managed by uv all relevant project roots and saves them in `.idea/uv.xml`.
|
||||
* For a tree of nested uv projects, only the topmost directories are linked.
|
||||
*/
|
||||
internal class UvLinkAction : AnAction() {
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val project = e.project ?: return
|
||||
val basePath = project.basePath ?: return
|
||||
project.trackActivityBlocking(UvLinkActivityKey) {
|
||||
project.coroutineScope.launchTracked {
|
||||
UvProjectModelService.linkAllProjectModelRoots(project, basePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
e.presentation.isEnabledAndVisible = enablePyProjectToml
|
||||
}
|
||||
|
||||
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
|
||||
|
||||
object UvLinkActivityKey : ActivityKey {
|
||||
override val presentableName: @Nls String
|
||||
get() = PyBundle.message("python.project.model.activity.key.uv.link")
|
||||
}
|
||||
|
||||
@Service(Service.Level.PROJECT)
|
||||
private class CoroutineScopeService(private val coroutineScope: CoroutineScope) {
|
||||
companion object {
|
||||
val Project.coroutineScope: CoroutineScope
|
||||
get() = service<CoroutineScopeService>().coroutineScope
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.uv
|
||||
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.extensions.ExtensionNotApplicableException
|
||||
import com.intellij.openapi.externalSystem.autoimport.*
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.startup.ProjectActivity
|
||||
import com.intellij.openapi.util.io.toCanonicalPath
|
||||
import com.intellij.platform.backend.observation.launchTracked
|
||||
import com.intellij.platform.backend.workspace.workspaceModel
|
||||
import com.intellij.platform.workspace.jps.entities.ContentRootEntity
|
||||
import com.intellij.platform.workspace.storage.entities
|
||||
import com.intellij.platform.workspace.storage.impl.url.toVirtualFileUrl
|
||||
import com.intellij.platform.workspace.storage.url.VirtualFileUrl
|
||||
import com.intellij.python.pyproject.PY_PROJECT_TOML
|
||||
import com.intellij.workspaceModel.ide.toPath
|
||||
import com.jetbrains.python.projectModel.enablePyProjectToml
|
||||
import com.jetbrains.python.projectModel.uv.UvProjectAware.CoroutineScopeService.Companion.coroutineScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* Tracks changes in pyproject.toml files and suggests syncing their changes with the project model
|
||||
* according to the `Settings | Build, Execution, Deployment | Build Tools` settings.
|
||||
*/
|
||||
internal class UvProjectAware(
|
||||
private val project: Project,
|
||||
override val projectId: ExternalSystemProjectId,
|
||||
) : ExternalSystemProjectAware {
|
||||
|
||||
override val settingsFiles: Set<String>
|
||||
get() = collectSettingFiles()
|
||||
|
||||
override fun subscribe(listener: ExternalSystemProjectListener, parentDisposable: Disposable) {
|
||||
project.messageBus.connect(parentDisposable).subscribe(UvSyncListener.Companion.TOPIC, object : UvSyncListener {
|
||||
override fun onStart(projectRoot: Path) = listener.onProjectReloadStart()
|
||||
override fun onFinish(projectRoot: Path) = listener.onProjectReloadFinish(status = ExternalSystemRefreshStatus.SUCCESS)
|
||||
})
|
||||
}
|
||||
|
||||
override fun reloadProject(context: ExternalSystemProjectReloadContext) {
|
||||
project.coroutineScope.launchTracked {
|
||||
UvProjectModelService.syncProjectModelRoot(project, Path.of(projectId.externalProjectPath))
|
||||
}
|
||||
}
|
||||
|
||||
// Called after sync
|
||||
private fun collectSettingFiles(): Set<String> {
|
||||
val source = UvEntitySource(projectId.externalProjectPath.toVirtualFileUrl(project))
|
||||
return project.workspaceModel.currentSnapshot
|
||||
.entities<ContentRootEntity>()
|
||||
.filter { it.entitySource == source }
|
||||
.map { it.url.toPath() }
|
||||
.map { it.resolve(PY_PROJECT_TOML) }
|
||||
.map { it.toCanonicalPath() }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
private fun String.toVirtualFileUrl(project: Project): VirtualFileUrl {
|
||||
return Path.of(this).toVirtualFileUrl(project.workspaceModel.getVirtualFileUrlManager())
|
||||
}
|
||||
|
||||
@Service(Service.Level.PROJECT)
|
||||
private class CoroutineScopeService(private val coroutineScope: CoroutineScope) {
|
||||
companion object {
|
||||
val Project.coroutineScope: CoroutineScope
|
||||
get() = service<CoroutineScopeService>().coroutineScope
|
||||
}
|
||||
}
|
||||
|
||||
internal class UvSyncStartupActivity : ProjectActivity {
|
||||
init {
|
||||
if (!enablePyProjectToml) {
|
||||
throw ExtensionNotApplicableException.create()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun execute(project: Project) {
|
||||
val projectTracker = ExternalSystemProjectTracker.getInstance(project)
|
||||
project.service<UvSettings>().getLinkedProjects().forEach { projectRoot ->
|
||||
val projectId = ExternalSystemProjectId(UvConstants.SYSTEM_ID, projectRoot.toCanonicalPath())
|
||||
val projectAware = UvProjectAware(project, projectId)
|
||||
projectTracker.register(projectAware)
|
||||
projectTracker.activate(projectId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class UvListener(private val project: Project) : UvSettingsListener {
|
||||
init {
|
||||
if (!enablePyProjectToml) {
|
||||
throw ExtensionNotApplicableException.create()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLinkedProjectAdded(projectRoot: Path) {
|
||||
val projectTracker = ExternalSystemProjectTracker.getInstance(project)
|
||||
val projectId = ExternalSystemProjectId(UvConstants.SYSTEM_ID, projectRoot.toCanonicalPath())
|
||||
val projectAware = UvProjectAware(project, projectId)
|
||||
projectTracker.register(projectAware)
|
||||
projectTracker.activate(projectId)
|
||||
}
|
||||
|
||||
override fun onLinkedProjectRemoved(projectRoot: Path) {
|
||||
val projectId = ExternalSystemProjectId(UvConstants.SYSTEM_ID, projectRoot.toCanonicalPath())
|
||||
ExternalSystemProjectTracker.getInstance(project).remove(projectId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.uv
|
||||
|
||||
import com.intellij.openapi.util.getPathMatcher
|
||||
import com.intellij.python.pyproject.PY_PROJECT_TOML
|
||||
import com.jetbrains.python.projectModel.ExternalProject
|
||||
import com.jetbrains.python.projectModel.ExternalProjectDependency
|
||||
import com.jetbrains.python.projectModel.ExternalProjectGraph
|
||||
import com.jetbrains.python.projectModel.PythonProjectModelResolver
|
||||
import org.apache.tuweni.toml.Toml
|
||||
import org.apache.tuweni.toml.TomlTable
|
||||
import java.nio.file.FileVisitResult
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.PathMatcher
|
||||
import kotlin.io.path.*
|
||||
|
||||
private const val DEFAULT_VENV_DIR = ".venv"
|
||||
|
||||
data class UvProject(
|
||||
override val name: String,
|
||||
override val root: Path,
|
||||
override val dependencies: List<ExternalProjectDependency>,
|
||||
override val fullName: String?,
|
||||
val isWorkspace: Boolean,
|
||||
val parentWorkspace: UvProject?,
|
||||
) : ExternalProject {
|
||||
override val sourceRoots: List<Path>
|
||||
get() = listOfNotNull((root / "src").takeIf { it.isDirectory() })
|
||||
override val excludedRoots: List<Path>
|
||||
get() = listOfNotNull((root / DEFAULT_VENV_DIR).takeIf { it.isDirectory() })
|
||||
}
|
||||
|
||||
private data class UvPyProjectToml(
|
||||
val projectName: String,
|
||||
val root: Path,
|
||||
val workspaceDependencies: List<String>,
|
||||
val pathDependencies: Map<String, Path>,
|
||||
val workspaceMemberPathMatchers: List<PathMatcher>,
|
||||
val workspaceExcludePathMatchers: List<PathMatcher>,
|
||||
) {
|
||||
val isWorkspace = workspaceMemberPathMatchers.isNotEmpty()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPathApi::class)
|
||||
object UvProjectModelResolver : PythonProjectModelResolver<UvProject> {
|
||||
override fun discoverProjectRootSubgraph(root: Path): ExternalProjectGraph<UvProject>? {
|
||||
if (!root.resolve(PY_PROJECT_TOML).exists()) {
|
||||
return null
|
||||
}
|
||||
val workspaceMembers = mutableMapOf<UvPyProjectToml, MutableMap<String, UvPyProjectToml>>()
|
||||
val standaloneProjects = mutableListOf<UvPyProjectToml>()
|
||||
val workspaceStack = ArrayDeque<UvPyProjectToml>()
|
||||
root.visitFileTree {
|
||||
onPreVisitDirectory { dir, _ ->
|
||||
if (dir.name == DEFAULT_VENV_DIR) {
|
||||
return@onPreVisitDirectory FileVisitResult.SKIP_SUBTREE
|
||||
}
|
||||
val projectToml = readUvPyProjectToml(dir / PY_PROJECT_TOML)
|
||||
if (projectToml == null) {
|
||||
return@onPreVisitDirectory FileVisitResult.CONTINUE
|
||||
}
|
||||
if (projectToml.isWorkspace) {
|
||||
workspaceStack.add(projectToml)
|
||||
workspaceMembers.put(projectToml, mutableMapOf())
|
||||
return@onPreVisitDirectory FileVisitResult.CONTINUE
|
||||
}
|
||||
if (workspaceStack.isNotEmpty()) {
|
||||
val closestWorkspace = workspaceStack.last()
|
||||
val relProjectPath = projectToml.root.relativeTo(closestWorkspace.root)
|
||||
val isWorkspaceMember = closestWorkspace.workspaceExcludePathMatchers.none { it.matches(relProjectPath) } &&
|
||||
closestWorkspace.workspaceMemberPathMatchers.any { it.matches(relProjectPath) }
|
||||
if (isWorkspaceMember) {
|
||||
workspaceMembers[closestWorkspace]!!.put(projectToml.projectName, projectToml)
|
||||
return@onPreVisitDirectory FileVisitResult.CONTINUE
|
||||
}
|
||||
}
|
||||
standaloneProjects.add(projectToml)
|
||||
return@onPreVisitDirectory FileVisitResult.CONTINUE
|
||||
}
|
||||
|
||||
onPostVisitDirectory { dir, _ ->
|
||||
if (workspaceStack.lastOrNull()?.root == dir) {
|
||||
workspaceStack.removeLast()
|
||||
}
|
||||
FileVisitResult.CONTINUE
|
||||
}
|
||||
}
|
||||
val allUvProjects = mutableListOf<UvProject>()
|
||||
standaloneProjects.mapTo(allUvProjects) {
|
||||
UvProject(
|
||||
name = it.projectName,
|
||||
root = it.root,
|
||||
dependencies = it.pathDependencies.map { dep -> ExternalProjectDependency(name = dep.key, path = dep.value) },
|
||||
isWorkspace = false,
|
||||
parentWorkspace = null,
|
||||
fullName = it.projectName,
|
||||
)
|
||||
}
|
||||
|
||||
for ((wsRootToml, wsMembersByNames) in workspaceMembers) {
|
||||
fun resolved(wsDependencies: List<String>): Map<String, Path> {
|
||||
return wsDependencies.mapNotNull { name -> wsMembersByNames[name]?.let { name to it.root } }.toMap()
|
||||
}
|
||||
val wsRootProject = UvProject(
|
||||
name = wsRootToml.projectName,
|
||||
root = wsRootToml.root,
|
||||
dependencies = (wsRootToml.pathDependencies + resolved(wsRootToml.workspaceDependencies))
|
||||
.map { ExternalProjectDependency(name = it.key, path = it.value) },
|
||||
isWorkspace = true,
|
||||
parentWorkspace = null,
|
||||
fullName = wsRootToml.projectName,
|
||||
)
|
||||
allUvProjects.add(wsRootProject)
|
||||
for ((_, wsMemberToml) in wsMembersByNames) {
|
||||
allUvProjects.add(UvProject(
|
||||
name = wsMemberToml.projectName,
|
||||
root = wsMemberToml.root,
|
||||
dependencies = (wsMemberToml.pathDependencies + resolved(wsMemberToml.workspaceDependencies))
|
||||
.map { ExternalProjectDependency(name = it.key, path = it.value) },
|
||||
isWorkspace = false,
|
||||
parentWorkspace = wsRootProject,
|
||||
fullName = "${wsRootProject.name}:${wsMemberToml.projectName}"
|
||||
))
|
||||
}
|
||||
}
|
||||
return ExternalProjectGraph(root = root, projects = allUvProjects)
|
||||
}
|
||||
|
||||
private fun readUvPyProjectToml(pyprojectTomlPath: Path): UvPyProjectToml? {
|
||||
if (!(pyprojectTomlPath.exists())) {
|
||||
return null
|
||||
}
|
||||
val pyprojectToml = Toml.parse(pyprojectTomlPath)
|
||||
val projectName = pyprojectToml.getString("project.name")
|
||||
if (projectName == null) {
|
||||
return null
|
||||
}
|
||||
val workspaceTable = pyprojectToml.getTable("tool.uv.workspace")
|
||||
val includeGlobs: List<PathMatcher>
|
||||
val excludeGlobs: List<PathMatcher>
|
||||
if (workspaceTable != null) {
|
||||
includeGlobs = workspaceTable.getArrayOrEmpty("members")
|
||||
.toList()
|
||||
.filterIsInstance<String>()
|
||||
.map { getPathMatcher(it) }
|
||||
excludeGlobs = workspaceTable.getArrayOrEmpty("exclude")
|
||||
.toList()
|
||||
.filterIsInstance<String>()
|
||||
.map { getPathMatcher(it) }
|
||||
}
|
||||
else {
|
||||
includeGlobs = emptyList()
|
||||
excludeGlobs = emptyList()
|
||||
}
|
||||
val workspaceDependencies = mutableListOf<String>()
|
||||
val editablePathDependencies = mutableMapOf<String, Path>()
|
||||
pyprojectToml.getTableOrEmpty("tool.uv.sources")
|
||||
.toMap().entries
|
||||
.forEach { (depName, depSpec) ->
|
||||
if (depSpec is TomlTable) {
|
||||
if (depSpec.getBoolean("workspace") == true) {
|
||||
workspaceDependencies.add(depName)
|
||||
}
|
||||
else if (depSpec.getBoolean("editable") == true) {
|
||||
val depPath = depSpec.getString("path")?.let { pyprojectTomlPath.parent.resolve(it).normalize() }
|
||||
if (depPath != null && depPath.isDirectory() && (depPath / PY_PROJECT_TOML).exists()) {
|
||||
editablePathDependencies[depName] = depPath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return UvPyProjectToml(
|
||||
projectName = projectName,
|
||||
root = pyprojectTomlPath.parent,
|
||||
workspaceDependencies = workspaceDependencies,
|
||||
pathDependencies = editablePathDependencies,
|
||||
workspaceMemberPathMatchers = includeGlobs,
|
||||
workspaceExcludePathMatchers = excludeGlobs,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.uv
|
||||
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.module.Module
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.NlsSafe
|
||||
import com.intellij.platform.backend.workspace.workspaceModel
|
||||
import com.intellij.platform.workspace.jps.entities.ModuleEntity
|
||||
import com.intellij.platform.workspace.jps.entities.ModuleId
|
||||
import com.intellij.platform.workspace.jps.entities.exModuleOptions
|
||||
import com.intellij.platform.workspace.storage.EntitySource
|
||||
import com.intellij.platform.workspace.storage.impl.url.toVirtualFileUrl
|
||||
import com.intellij.workspaceModel.ide.legacyBridge.findModule
|
||||
import com.jetbrains.python.projectModel.BaseProjectModelService
|
||||
import com.jetbrains.python.projectModel.ProjectModelSettings
|
||||
import com.jetbrains.python.projectModel.ProjectModelSyncListener
|
||||
import com.jetbrains.python.projectModel.PythonProjectModelResolver
|
||||
import java.nio.file.Path
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* Syncs the project model described in pyproject.toml files with the IntelliJ project model.
|
||||
*/
|
||||
object UvProjectModelService : BaseProjectModelService<UvEntitySource, UvProject>() {
|
||||
override val projectModelResolver: PythonProjectModelResolver<UvProject>
|
||||
get() = UvProjectModelResolver
|
||||
|
||||
override val systemName: @NlsSafe String
|
||||
get() = "Uv"
|
||||
|
||||
override fun getSettings(project: Project): ProjectModelSettings = project.service<UvSettings>()
|
||||
|
||||
override fun getSyncListener(project: Project): ProjectModelSyncListener = project.messageBus.syncPublisher(UvSyncListener.TOPIC)
|
||||
|
||||
override fun createEntitySource(project: Project, singleProjectRoot: Path): EntitySource {
|
||||
val fileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
|
||||
return UvEntitySource(singleProjectRoot.toVirtualFileUrl(fileUrlManager))
|
||||
}
|
||||
|
||||
override fun getEntitySourceClass(): KClass<out EntitySource> = UvEntitySource::class
|
||||
|
||||
fun findWorkspace(module: Module): UvWorkspace<Module>? {
|
||||
val wsmSnapshot = module.project.workspaceModel.currentSnapshot
|
||||
val moduleEntity = wsmSnapshot.resolve(ModuleId(module.name))!!
|
||||
val workspace = findWorkspace(module.project, moduleEntity)
|
||||
if (workspace == null) {
|
||||
return null
|
||||
}
|
||||
return UvWorkspace(
|
||||
root = workspace.root.findModule(wsmSnapshot)!!,
|
||||
members = workspace.members.mapNotNull { it.findModule(wsmSnapshot) }.toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
fun findWorkspace(project: Project, module: ModuleEntity): UvWorkspace<ModuleEntity>? {
|
||||
val fullName = module.exModuleOptions?.linkedProjectId
|
||||
if (fullName == null) return null
|
||||
val workspaceName = fullName.split(":")[0]
|
||||
val currentSnapshot = project.workspaceModel.currentSnapshot
|
||||
val rootModule = currentSnapshot.resolve(ModuleId(workspaceName))
|
||||
return UvWorkspace(
|
||||
root = rootModule!!,
|
||||
members = currentSnapshot.entitiesBySource { it is UvEntitySource }
|
||||
.filterIsInstance<ModuleEntity>()
|
||||
.filter {
|
||||
val externalId = it.exModuleOptions?.linkedProjectId
|
||||
externalId != null && externalId.startsWith("$workspaceName:")
|
||||
}
|
||||
.toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
data class UvWorkspace<T>(val root: T, val members: Set<T>)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.uv
|
||||
|
||||
import com.jetbrains.python.PyBundle
|
||||
import com.jetbrains.python.icons.PythonIcons
|
||||
import com.jetbrains.python.projectModel.PyProjectTomlOpenProcessorBase
|
||||
import org.jetbrains.annotations.Nls
|
||||
import javax.swing.Icon
|
||||
|
||||
internal class UvProjectOpenProcessor : PyProjectTomlOpenProcessorBase() {
|
||||
override val importProvider = UvProjectOpenProvider()
|
||||
override val name: @Nls String = PyBundle.message("python.project.model.uv")
|
||||
override val icon: Icon = PythonIcons.UV
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.uv
|
||||
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.externalSystem.importing.AbstractOpenProjectProvider
|
||||
import com.intellij.openapi.externalSystem.model.ProjectSystemId
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.openapi.vfs.readText
|
||||
import com.intellij.openapi.vfs.toNioPathOrNull
|
||||
import com.intellij.python.pyproject.PY_PROJECT_TOML
|
||||
import java.nio.file.Path
|
||||
|
||||
internal class UvProjectOpenProvider() : AbstractOpenProjectProvider() {
|
||||
override val systemId: ProjectSystemId = UvConstants.SYSTEM_ID
|
||||
|
||||
override fun isProjectFile(file: VirtualFile): Boolean {
|
||||
return file.name == UvConstants.UV_LOCK || isUvSpecificPyProjectToml(file)
|
||||
}
|
||||
|
||||
private fun isUvSpecificPyProjectToml(file: VirtualFile): Boolean {
|
||||
return file.name == PY_PROJECT_TOML && UV_TOOL_TABLE_HEADER.find(file.readText()) != null
|
||||
}
|
||||
|
||||
override suspend fun linkProject(projectFile: VirtualFile, project: Project) {
|
||||
val projectDirectory = getProjectDirectory(projectFile)
|
||||
val projectRootPath = projectDirectory.toNioPathOrNull() ?: Path.of(projectDirectory.path)
|
||||
project.service<UvSettings>().addLinkedProject(projectRootPath)
|
||||
UvProjectModelService.syncProjectModelRoot(project, projectRootPath)
|
||||
}
|
||||
|
||||
suspend fun unlinkProject(project: Project, externalProjectPath: String) {
|
||||
UvProjectModelService.forgetProjectModelRoot(project, Path.of(externalProjectPath))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val UV_TOOL_TABLE_HEADER: Regex = """\[tool\.uv[.\w-]*]""".toRegex()
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.uv
|
||||
|
||||
import com.intellij.openapi.components.*
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.jetbrains.python.projectModel.ProjectModelSettings
|
||||
import java.net.URI
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.toPath
|
||||
|
||||
// TODO SerializablePersistentStateComponent
|
||||
@Service(Service.Level.PROJECT)
|
||||
@State(name = "UvSettings", storages = [Storage("uv.xml")])
|
||||
class UvSettings(private val project: Project) :
|
||||
SimplePersistentStateComponent<UvSettings.State>(State()), ProjectModelSettings {
|
||||
|
||||
class State() : BaseState() {
|
||||
var linkedProjects: MutableList<String> by list()
|
||||
}
|
||||
|
||||
override fun setLinkedProjects(projects: List<Path>) {
|
||||
val oldLinkedProjects = getLinkedProjects()
|
||||
val removedLinkedProjects = oldLinkedProjects - projects
|
||||
val addedLinkedProjects = projects - oldLinkedProjects
|
||||
val listener = project.messageBus.syncPublisher(UvSettingsListener.Companion.TOPIC)
|
||||
removedLinkedProjects.forEach { listener.onLinkedProjectRemoved(it) }
|
||||
addedLinkedProjects.forEach { listener.onLinkedProjectAdded(it) }
|
||||
|
||||
state.linkedProjects = projects.map { it.toUri().toString() }.toMutableList()
|
||||
}
|
||||
|
||||
override fun getLinkedProjects(): List<Path> {
|
||||
return state.linkedProjects.map { URI(it).toPath() }
|
||||
}
|
||||
|
||||
override fun addLinkedProject(projectRoot: Path) {
|
||||
val existing = getLinkedProjects()
|
||||
if (projectRoot !in existing) {
|
||||
setLinkedProjects(existing + listOf(projectRoot))
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeLinkedProject(projectRoot: Path) {
|
||||
val existing = getLinkedProjects()
|
||||
if (projectRoot in existing) {
|
||||
setLinkedProjects(existing - listOf(projectRoot))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.uv
|
||||
|
||||
import com.intellij.util.messages.Topic
|
||||
import java.nio.file.Path
|
||||
|
||||
// TODO Actions for linking/unlinking pyproject.toml files
|
||||
internal interface UvSettingsListener {
|
||||
companion object {
|
||||
@Topic.ProjectLevel
|
||||
val TOPIC: Topic<UvSettingsListener> = Topic(UvSettingsListener::class.java, Topic.BroadcastDirection.NONE)
|
||||
}
|
||||
|
||||
fun onLinkedProjectAdded(projectRoot: Path): Unit = Unit
|
||||
fun onLinkedProjectRemoved(projectRoot: Path): Unit = Unit
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.uv
|
||||
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.platform.backend.observation.ActivityKey
|
||||
import com.intellij.platform.backend.observation.launchTracked
|
||||
import com.intellij.platform.backend.observation.trackActivityBlocking
|
||||
import com.jetbrains.python.PyBundle
|
||||
import com.jetbrains.python.projectModel.enablePyProjectToml
|
||||
import com.jetbrains.python.projectModel.uv.UvSyncAction.CoroutineScopeService.Companion.coroutineScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.jetbrains.annotations.Nls
|
||||
|
||||
/**
|
||||
* Forcibly syncs all *already linked* uv projects, overriding their workspace models.
|
||||
*/
|
||||
internal class UvSyncAction : AnAction() {
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val project = e.project ?: return
|
||||
project.trackActivityBlocking(UvActivityKey) {
|
||||
project.coroutineScope.launchTracked {
|
||||
UvProjectModelService.syncAllProjectModelRoots(project = project)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
e.presentation.isEnabledAndVisible = enablePyProjectToml
|
||||
}
|
||||
|
||||
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
|
||||
|
||||
object UvActivityKey : ActivityKey {
|
||||
override val presentableName: @Nls String
|
||||
get() = PyBundle.message("python.project.model.activity.key.uv.sync")
|
||||
}
|
||||
|
||||
@Service(Service.Level.PROJECT)
|
||||
private class CoroutineScopeService(private val coroutineScope: CoroutineScope) {
|
||||
companion object {
|
||||
val Project.coroutineScope: CoroutineScope
|
||||
get() = service<CoroutineScopeService>().coroutineScope
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.uv
|
||||
|
||||
import com.intellij.util.messages.Topic
|
||||
import com.jetbrains.python.projectModel.ProjectModelSyncListener
|
||||
import java.nio.file.Path
|
||||
|
||||
internal interface UvSyncListener : ProjectModelSyncListener {
|
||||
companion object {
|
||||
@Topic.ProjectLevel
|
||||
val TOPIC: Topic<UvSyncListener> = Topic(UvSyncListener::class.java, Topic.BroadcastDirection.NONE)
|
||||
}
|
||||
|
||||
// Add onFailure
|
||||
// Add onCancel
|
||||
override fun onStart(projectRoot: Path): Unit = Unit
|
||||
override fun onFinish(projectRoot: Path): Unit = Unit
|
||||
}
|
||||
134
python/src/com/jetbrains/python/projectModel/uv/UvTool.kt
Normal file
134
python/src/com/jetbrains/python/projectModel/uv/UvTool.kt
Normal file
@@ -0,0 +1,134 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.uv
|
||||
|
||||
import com.intellij.openapi.diagnostic.fileLogger
|
||||
import com.intellij.openapi.util.NlsSafe
|
||||
import com.intellij.openapi.util.getPathMatcher
|
||||
import com.intellij.python.pyproject.PY_PROJECT_TOML_BUILD_SYSTEM
|
||||
import com.intellij.python.pyproject.model.spi.*
|
||||
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
|
||||
import com.jetbrains.python.venvReader.Directory
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.tuweni.toml.TomlArray
|
||||
import org.apache.tuweni.toml.TomlTable
|
||||
import java.nio.file.InvalidPathException
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.PathMatcher
|
||||
import kotlin.io.path.relativeTo
|
||||
|
||||
internal class UvTool : Tool {
|
||||
|
||||
override val id: ToolId = ToolId("uv")
|
||||
|
||||
override suspend fun getProjectName(projectToml: TomlTable): @NlsSafe String? = null
|
||||
|
||||
override suspend fun getSrcRoots(toml: TomlTable, projectRoot: Directory): Set<Directory> = withContext(Dispatchers.Default) {
|
||||
if (toml.getString("${PY_PROJECT_TOML_BUILD_SYSTEM}.build-backend") == "uv_build") {
|
||||
setOf(projectRoot.resolve("src"))
|
||||
}
|
||||
else {
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getProjectStructure(entries: Map<ProjectName, PyProjectTomlProject>, rootIndex: Map<Directory, ProjectName>): ProjectStructureInfo = withContext(Dispatchers.Default) {
|
||||
val workspaces = entries.mapNotNull { (name, entry) ->
|
||||
val matchers = getWorkspaceMembers(entry.pyProjectToml.toml) ?: return@mapNotNull null
|
||||
Pair(entry.root, Pair(matchers, name))
|
||||
}.toMap()
|
||||
|
||||
val dirToProjectName = rootIndex.entries.toList()
|
||||
val workspaceToMembers = HashMap<ProjectName, MutableSet<ProjectName>>()
|
||||
val memberToWorkspace = HashMap<ProjectName, MutableSet<ProjectName>>()
|
||||
for ((workspaceRoot, matchersAndName) in workspaces) {
|
||||
val (matchers, workspaceName) = matchersAndName
|
||||
for ((memberRoot, memberName) in dirToProjectName) {
|
||||
if (!memberRoot.startsWith(workspaceRoot)) continue
|
||||
|
||||
if (matchers.match(memberRoot.relativeTo(workspaceRoot).normalize())) {
|
||||
workspaceToMembers.getOrPut(workspaceName) { HashSet() }.add(memberName)
|
||||
memberToWorkspace.getOrPut(memberName) { HashSet() }.add(workspaceName)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
val dependencies = HashMap<ProjectName, Set<ProjectName>>()
|
||||
|
||||
for ((name, projectToml) in entries) {
|
||||
val siblings = memberToWorkspace[name]?.mapNotNull { workspaceToMembers[it] }?.flatten()?.toSet() ?: continue
|
||||
val (workspaceDeps, pathDeps) = getUvDependencies(projectToml) ?: continue
|
||||
val brokenDeps = workspaceDeps - siblings
|
||||
if (brokenDeps.isNotEmpty()) {
|
||||
logger.info("Deps are broken: ${brokenDeps.joinToString(", ")}")
|
||||
}
|
||||
val pathDepsWithName = pathDeps.mapNotNull {
|
||||
val name = rootIndex[it]
|
||||
if (name == null) {
|
||||
logger.info("No module at ${it}")
|
||||
}
|
||||
name
|
||||
}
|
||||
dependencies[name] = (workspaceDeps intersect siblings) + pathDepsWithName
|
||||
|
||||
}
|
||||
return@withContext ProjectStructureInfo(
|
||||
dependencies = dependencies,
|
||||
membersToWorkspace = memberToWorkspace.map { (member, workspaces) ->
|
||||
val workspaceCount = workspaces.size
|
||||
assert(workspaceCount != 0) { "Workspace can't be empty for $member" }
|
||||
if (workspaceCount > 1) {
|
||||
logger.warn("more than one workspace for member $member, will use the first one")
|
||||
}
|
||||
Pair(member, workspaces.first())
|
||||
}.toMap()
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@RequiresBackgroundThread
|
||||
private fun getWorkspaceMembers(toml: TomlTable): WorkspaceInfo? {
|
||||
val workspace = toml.getTable("tool.uv.workspace") ?: return null
|
||||
val members = workspace.getArrayOrEmpty("members").asMatchers
|
||||
val exclude = workspace.getArrayOrEmpty("exclude").asMatchers
|
||||
if (members.isEmpty()) return null
|
||||
return WorkspaceInfo(members = members, exclude = exclude)
|
||||
}
|
||||
|
||||
@RequiresBackgroundThread
|
||||
private fun getUvDependencies(pyProject: PyProjectTomlProject): DependencyInfo? {
|
||||
val sources = pyProject.pyProjectToml.toml.getTable("tool.uv.sources") ?: return null
|
||||
val deps = pyProject.pyProjectToml.project?.dependencies?.project?.toSet() ?: return null
|
||||
val workspaceDeps = mutableListOf<ProjectName>()
|
||||
val pathDeps = hashSetOf<Path>()
|
||||
for ((depName, depTable) in sources.toMap().entries) {
|
||||
if (depName !in deps) continue
|
||||
val table = depTable as? TomlTable ?: continue
|
||||
|
||||
if (table.getBoolean("workspace") == true) {
|
||||
workspaceDeps.add(ProjectName(depName))
|
||||
}
|
||||
else {
|
||||
val path = table.getString("path") ?: continue
|
||||
try {
|
||||
pathDeps.add(pyProject.root.resolve(path).normalize())
|
||||
}
|
||||
catch (e: InvalidPathException) {
|
||||
logger.info("Can't resolve $path against ${pyProject.root}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return DependencyInfo(workspaceDeps = workspaceDeps.toSet(), pathDeps = pathDeps)
|
||||
}
|
||||
}
|
||||
|
||||
private data class WorkspaceInfo(val members: List<PathMatcher>, val exclude: List<PathMatcher>) {
|
||||
fun match(path: Path): Boolean =
|
||||
members.any { it.matches(path) } && exclude.none { it.matches(path) }
|
||||
}
|
||||
|
||||
private val TomlArray.asMatchers: List<PathMatcher> get() = toList().filterIsInstance<String>().map { getPathMatcher(it) }
|
||||
private val logger = fileLogger()
|
||||
|
||||
private data class DependencyInfo(val workspaceDeps: Set<ProjectName>, val pathDeps: Set<Directory>)
|
||||
@@ -1,43 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.uv
|
||||
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.externalSystem.autolink.ExternalSystemProjectLinkListener
|
||||
import com.intellij.openapi.externalSystem.autolink.ExternalSystemUnlinkedProjectAware
|
||||
import com.intellij.openapi.externalSystem.model.ProjectSystemId
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.io.toCanonicalPath
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.jetbrains.python.projectModel.enablePyProjectToml
|
||||
import java.nio.file.Path
|
||||
|
||||
internal class UvUnlinkedProjectAware : ExternalSystemUnlinkedProjectAware {
|
||||
private val openProvider = UvProjectOpenProvider()
|
||||
|
||||
override val systemId: ProjectSystemId = UvConstants.SYSTEM_ID
|
||||
|
||||
override fun isBuildFile(project: Project, buildFile: VirtualFile): Boolean {
|
||||
return enablePyProjectToml && openProvider.canOpenProject(buildFile)
|
||||
}
|
||||
|
||||
override fun isLinkedProject(project: Project, externalProjectPath: String): Boolean {
|
||||
val projectPath = Path.of(externalProjectPath)
|
||||
return project.service<UvSettings>().getLinkedProjects().any { it == projectPath }
|
||||
}
|
||||
|
||||
override fun subscribe(project: Project, listener: ExternalSystemProjectLinkListener, parentDisposable: Disposable) {
|
||||
project.messageBus.connect(parentDisposable).subscribe(UvSettingsListener.Companion.TOPIC, object : UvSettingsListener {
|
||||
override fun onLinkedProjectAdded(projectRoot: Path) = listener.onProjectLinked(projectRoot.toCanonicalPath())
|
||||
override fun onLinkedProjectRemoved(projectRoot: Path) = listener.onProjectUnlinked(projectRoot.toCanonicalPath())
|
||||
})
|
||||
}
|
||||
|
||||
override suspend fun linkAndLoadProjectAsync(project: Project, externalProjectPath: String) {
|
||||
openProvider.linkToExistingProjectAsync(externalProjectPath, project)
|
||||
}
|
||||
|
||||
override suspend fun unlinkProject(project: Project, externalProjectPath: String) {
|
||||
openProvider.unlinkProject(project, externalProjectPath)
|
||||
}
|
||||
}
|
||||
@@ -20,14 +20,11 @@ import com.jetbrains.python.sdk.impl.PySdkBundle
|
||||
import com.jetbrains.python.PythonPluginDisposable
|
||||
import com.jetbrains.python.errorProcessing.PyResult
|
||||
import com.jetbrains.python.packaging.utils.PyPackageCoroutine
|
||||
import com.jetbrains.python.projectModel.enablePyProjectToml
|
||||
import com.jetbrains.python.projectModel.uv.UvProjectModelService
|
||||
import com.jetbrains.python.sdk.PySdkPopupFactory
|
||||
import com.jetbrains.python.sdk.configuration.suppressors.PyInterpreterInspectionSuppressor
|
||||
import com.jetbrains.python.sdk.configuration.suppressors.PyPackageRequirementsInspectionSuppressor
|
||||
import com.jetbrains.python.sdk.configuration.suppressors.TipOfTheDaySuppressor
|
||||
import com.jetbrains.python.sdk.configurePythonSdk
|
||||
import com.jetbrains.python.sdk.uv.isUv
|
||||
import com.jetbrains.python.statistics.ConfiguredPythonInterpreterIdsHolder.Companion.SDK_HAS_BEEN_CONFIGURED_AS_THE_PROJECT_INTERPRETER
|
||||
import com.jetbrains.python.util.ShowingMessageErrorSync
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -61,15 +58,6 @@ object PyProjectSdkConfiguration {
|
||||
} ?: return false
|
||||
|
||||
// TODO Move this to PyUvSdkConfiguration, show better notification
|
||||
if (sdk.isUv && enablePyProjectToml) {
|
||||
val ws = UvProjectModelService.findWorkspace(module)
|
||||
if (ws != null) {
|
||||
for (wsModule in ws.members + ws.root) {
|
||||
setReadyToUseSdk(wsModule.project, wsModule, sdk)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
setReadyToUseSdk(module.project, module, sdk)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.poetry
|
||||
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.externalSystem.testFramework.fixtures.multiProjectFixture
|
||||
import com.intellij.platform.testFramework.assertion.collectionAssertion.CollectionAssertions
|
||||
import com.intellij.platform.testFramework.assertion.moduleAssertion.DependencyAssertions
|
||||
import com.intellij.platform.testFramework.assertion.moduleAssertion.ModuleAssertions
|
||||
import com.intellij.python.pyproject.PY_PROJECT_TOML
|
||||
import com.intellij.testFramework.common.timeoutRunBlocking
|
||||
import com.intellij.testFramework.junit5.RegistryKey
|
||||
import com.intellij.testFramework.junit5.TestApplication
|
||||
import com.intellij.testFramework.junit5.fixture.tempPathFixture
|
||||
import com.intellij.testFramework.useProjectAsync
|
||||
import com.intellij.testFramework.utils.io.createFile
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.io.path.writeText
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@RegistryKey("python.pyproject.model", "true")
|
||||
@TestApplication
|
||||
class PoetryProjectOpenIntegrationTest {
|
||||
private val testRoot by tempPathFixture()
|
||||
private val multiprojectFixture by multiProjectFixture()
|
||||
|
||||
@Test
|
||||
fun `project with top-level legacy pyproject-toml is automatically linked`() = timeoutRunBlocking(timeout = 20.seconds) {
|
||||
val projectPath = testRoot.resolve("project")
|
||||
|
||||
projectPath.createFile(PY_PROJECT_TOML).writeText("""
|
||||
[tool.poetry]
|
||||
name = "project"
|
||||
""".trimIndent())
|
||||
|
||||
multiprojectFixture.openProject(projectPath).useProjectAsync { project ->
|
||||
ModuleAssertions.assertModules(project, "project")
|
||||
CollectionAssertions.assertEqualsUnordered(listOf(projectPath),
|
||||
project.service<PoetrySettings>().getLinkedProjects())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `project with top-level PEP-621 pyproject-toml without tool-poetry table is not automatically linked`() = timeoutRunBlocking(timeout = 20.seconds) {
|
||||
val projectPath = testRoot.resolve("project")
|
||||
projectPath.createFile(PY_PROJECT_TOML).writeText("""
|
||||
[project]
|
||||
name = "project"
|
||||
""".trimIndent())
|
||||
|
||||
multiprojectFixture.openProject(projectPath).useProjectAsync { project ->
|
||||
ModuleAssertions.assertModules(project, "project")
|
||||
CollectionAssertions.assertEqualsUnordered(emptyList(), project.service<PoetrySettings>().getLinkedProjects())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun `project with top-level PEP-621 pyproject-toml containing tool-poetry table is automatically linked`() = timeoutRunBlocking(timeout = 20.seconds) {
|
||||
val projectPath = testRoot.resolve("project")
|
||||
|
||||
projectPath.createFile(PY_PROJECT_TOML).writeText("""
|
||||
[project]
|
||||
name = "project"
|
||||
|
||||
[tool.poetry]
|
||||
requires-poetry = ">=2.0"
|
||||
""".trimIndent())
|
||||
|
||||
multiprojectFixture.openProject(projectPath).useProjectAsync { project ->
|
||||
ModuleAssertions.assertModules(project, "project")
|
||||
CollectionAssertions.assertEqualsUnordered(listOf(projectPath),
|
||||
project.service<PoetrySettings>().getLinkedProjects())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `project with top-level poetry-lock is automatically linked`() = timeoutRunBlocking(timeout = 20.seconds) {
|
||||
val projectPath = testRoot.resolve("project")
|
||||
|
||||
projectPath.createFile("poetry.lock").writeText("""""")
|
||||
|
||||
projectPath.createFile(PY_PROJECT_TOML).writeText("""
|
||||
[project]
|
||||
name = "project"
|
||||
""".trimIndent())
|
||||
|
||||
multiprojectFixture.openProject(projectPath).useProjectAsync { project ->
|
||||
ModuleAssertions.assertModules(project, "project")
|
||||
CollectionAssertions.assertEqualsUnordered(listOf(projectPath),
|
||||
project.service<PoetrySettings>().getLinkedProjects())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test monorepo without top-level pyproject-toml and with sibling path dependency`() = timeoutRunBlocking(timeout = 20.seconds) {
|
||||
val projectPath = testRoot.resolve("project")
|
||||
projectPath.createFile("libs/project1/pyproject.toml").writeText("""
|
||||
[tool.poetry]
|
||||
name = "project1"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
project2 = { path = "../project2", develop = true }
|
||||
""".trimIndent())
|
||||
|
||||
projectPath.createFile("libs/project2/pyproject.toml").writeText("""
|
||||
[tool.poetry]
|
||||
name = "project2"
|
||||
""".trimIndent())
|
||||
|
||||
multiprojectFixture.openProject(projectPath).useProjectAsync { project ->
|
||||
val poetrySettings = project.service<PoetrySettings>()
|
||||
// Such projects without pyproject.toml or poetry.lock in the root cannot be recognized automatically
|
||||
CollectionAssertions.assertEmpty(poetrySettings.getLinkedProjects())
|
||||
ModuleAssertions.assertModules(project, "project")
|
||||
|
||||
multiprojectFixture.awaitProjectConfiguration(project) {
|
||||
PoetryProjectModelService.linkAllProjectModelRoots(project, project.basePath!!)
|
||||
PoetryProjectModelService.syncAllProjectModelRoots(project)
|
||||
}
|
||||
|
||||
ModuleAssertions.assertModules(project, "project", "project1", "project2")
|
||||
CollectionAssertions.assertEqualsUnordered(
|
||||
listOf(projectPath.resolve("libs")),
|
||||
poetrySettings.getLinkedProjects()
|
||||
)
|
||||
|
||||
ModuleAssertions.assertModuleEntity(project, "project1") { module ->
|
||||
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE, "project2")
|
||||
}
|
||||
|
||||
ModuleAssertions.assertModuleEntity(project, "project2") { module ->
|
||||
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.poetry
|
||||
|
||||
import com.intellij.openapi.externalSystem.testFramework.fixtures.multiProjectFixture
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.platform.backend.workspace.workspaceModel
|
||||
import com.intellij.platform.testFramework.assertion.moduleAssertion.ContentRootAssertions
|
||||
import com.intellij.platform.testFramework.assertion.moduleAssertion.DependencyAssertions
|
||||
import com.intellij.platform.testFramework.assertion.moduleAssertion.DependencyAssertions.INHERITED_SDK
|
||||
import com.intellij.platform.testFramework.assertion.moduleAssertion.DependencyAssertions.MODULE_SOURCE
|
||||
import com.intellij.platform.testFramework.assertion.moduleAssertion.ModuleAssertions
|
||||
import com.intellij.platform.testFramework.assertion.moduleAssertion.SourceRootAssertions
|
||||
import com.intellij.python.pyproject.PY_PROJECT_TOML
|
||||
import com.intellij.testFramework.common.timeoutRunBlocking
|
||||
import com.intellij.testFramework.junit5.RegistryKey
|
||||
import com.intellij.testFramework.junit5.TestApplication
|
||||
import com.intellij.testFramework.junit5.fixture.projectFixture
|
||||
import com.intellij.testFramework.junit5.fixture.tempPathFixture
|
||||
import com.intellij.testFramework.utils.io.createFile
|
||||
import com.jetbrains.python.projectModel.BaseProjectModelService.Companion.PYTHON_SOURCE_ROOT_TYPE
|
||||
import com.jetbrains.python.projectModel.uv.UvConstants
|
||||
import org.junit.jupiter.api.Assertions
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.io.path.writeText
|
||||
|
||||
@RegistryKey("python.pyproject.model", "true")
|
||||
@TestApplication
|
||||
class PoetryProjectSyncIntegrationTest {
|
||||
private val testRootFixture = tempPathFixture()
|
||||
val testRoot by testRootFixture
|
||||
|
||||
private val project by projectFixture(testRootFixture, openAfterCreation = true)
|
||||
private val multiprojectFixture by multiProjectFixture()
|
||||
|
||||
@Test
|
||||
fun `src directory is mapped to module source root`() = timeoutRunBlocking {
|
||||
testRoot.createFile(PY_PROJECT_TOML).writeText("""
|
||||
[project]
|
||||
name = "main"
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
[tool.poetry]
|
||||
packages = [{include = "main", from = "src"}]
|
||||
""".trimIndent())
|
||||
|
||||
testRoot.createFile("src/main/__init__.py")
|
||||
|
||||
multiprojectFixture.linkProject(project, testRoot, UvConstants.SYSTEM_ID)
|
||||
syncAllProjects(project)
|
||||
|
||||
ModuleAssertions.assertModules(project, "main")
|
||||
SourceRootAssertions.assertSourceRoots(project, "main", { it.rootTypeId == PYTHON_SOURCE_ROOT_TYPE }, testRoot.resolve("src"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `project with new-style PEP-621 path dependencies`() = timeoutRunBlocking {
|
||||
testRoot.createFile(PY_PROJECT_TOML).writeText("""
|
||||
[project]
|
||||
name = "main"
|
||||
dependencies = [
|
||||
"lib @ ${testRoot.toUri()}/lib"
|
||||
]
|
||||
""".trimIndent())
|
||||
|
||||
testRoot.createFile("lib/pyproject.toml").writeText("""
|
||||
[project]
|
||||
name = "lib"
|
||||
dependencies = [
|
||||
]
|
||||
""".trimIndent())
|
||||
|
||||
multiprojectFixture.linkProject(project, testRoot, PoetryConstants.SYSTEM_ID)
|
||||
syncAllProjects(project)
|
||||
|
||||
val virtualFileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
|
||||
ModuleAssertions.assertModules(project, "main", "lib")
|
||||
ModuleAssertions.assertModuleEntity(project, "main") { module ->
|
||||
ContentRootAssertions.assertContentRoots(virtualFileUrlManager, module, testRoot)
|
||||
DependencyAssertions.assertDependencies(module, INHERITED_SDK, MODULE_SOURCE, "lib")
|
||||
DependencyAssertions.assertModuleDependency(module, "lib") { dependency ->
|
||||
Assertions.assertTrue(dependency.exported)
|
||||
}
|
||||
}
|
||||
|
||||
ModuleAssertions.assertModuleEntity(project, "lib") { module ->
|
||||
ContentRootAssertions.assertContentRoots(virtualFileUrlManager, module, testRoot.resolve("lib"))
|
||||
DependencyAssertions.assertDependencies(module, INHERITED_SDK, MODULE_SOURCE)
|
||||
}
|
||||
}
|
||||
|
||||
// This format of path dependencies was used before Poetry 2.0
|
||||
// https://python-poetry.org/history/#added-2
|
||||
@Test
|
||||
fun `project with old-style path dependencies`() = timeoutRunBlocking {
|
||||
testRoot.createFile(
|
||||
PY_PROJECT_TOML).writeText("""
|
||||
[tool.poetry]
|
||||
name = "main"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
lib = {path = "./lib", develop = true}
|
||||
""".trimIndent())
|
||||
|
||||
testRoot.createFile("lib/pyproject.toml").writeText("""
|
||||
[tool.poetry]
|
||||
name = "lib"
|
||||
""".trimIndent())
|
||||
|
||||
multiprojectFixture.linkProject(project, testRoot, PoetryConstants.SYSTEM_ID)
|
||||
syncAllProjects(project)
|
||||
|
||||
val virtualFileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
|
||||
ModuleAssertions.assertModules(project, "main", "lib")
|
||||
ModuleAssertions.assertModuleEntity(project, "main") { module ->
|
||||
ContentRootAssertions.assertContentRoots(virtualFileUrlManager, module, testRoot)
|
||||
DependencyAssertions.assertDependencies(module, INHERITED_SDK, MODULE_SOURCE, "lib")
|
||||
DependencyAssertions.assertModuleDependency(module, "lib") { dependency ->
|
||||
Assertions.assertTrue(dependency.exported)
|
||||
}
|
||||
}
|
||||
|
||||
ModuleAssertions.assertModuleEntity(project, "lib") { module ->
|
||||
ContentRootAssertions.assertContentRoots(virtualFileUrlManager, module, testRoot.resolve("lib"))
|
||||
DependencyAssertions.assertDependencies(module, INHERITED_SDK, MODULE_SOURCE)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun syncAllProjects(project: Project) {
|
||||
multiprojectFixture.awaitProjectConfiguration(project) {
|
||||
PoetryProjectModelService.syncAllProjectModelRoots(project)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.uv
|
||||
|
||||
import com.intellij.testFramework.junit5.TestApplication
|
||||
import com.intellij.testFramework.junit5.fixture.tempPathFixture
|
||||
import com.intellij.util.io.copyRecursively
|
||||
import com.jetbrains.python.PythonTestUtil.getTestDataPath
|
||||
import com.jetbrains.python.projectModel.ExternalProjectGraph
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInfo
|
||||
import org.junit.jupiter.api.assertNotNull
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths.get
|
||||
import kotlin.io.path.div
|
||||
|
||||
@TestApplication
|
||||
class UvProjectModelResolverTest {
|
||||
private val testRoot by tempPathFixture()
|
||||
|
||||
private lateinit var testInfo: TestInfo
|
||||
|
||||
@BeforeEach
|
||||
fun setUp(testInfo: TestInfo) {
|
||||
this.testInfo = testInfo
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nestedWorkspaces() {
|
||||
val ijProjectRoot = testRoot / "project"
|
||||
testDataDir.copyRecursively(ijProjectRoot)
|
||||
val graph = UvProjectModelResolver.discoverProjectRootSubgraph(ijProjectRoot / "root-ws")
|
||||
assertNotNull(graph)
|
||||
assertEquals(4, graph.projects.size)
|
||||
|
||||
val rootWs = graph.project("root-ws")
|
||||
assertNotNull(rootWs)
|
||||
assertTrue(rootWs.isWorkspace)
|
||||
assertEquals(ijProjectRoot / "root-ws", rootWs.root)
|
||||
|
||||
val rootWsMember = graph.project("root-ws-member")
|
||||
assertNotNull(rootWsMember)
|
||||
assertFalse(rootWsMember.isWorkspace)
|
||||
assertEquals(ijProjectRoot / "root-ws" / "root-ws-member", rootWsMember.root)
|
||||
assertEquals(rootWs, rootWsMember.parentWorkspace)
|
||||
|
||||
val nestedWs = graph.project("nested-ws")
|
||||
assertNotNull(nestedWs)
|
||||
assertTrue(nestedWs.isWorkspace)
|
||||
assertEquals(ijProjectRoot / "root-ws" / "nested-ws", nestedWs.root)
|
||||
|
||||
val nestedWsMember = graph.project("nested-ws-member")
|
||||
assertNotNull(nestedWsMember)
|
||||
assertFalse(nestedWsMember.isWorkspace)
|
||||
assertEquals(ijProjectRoot / "root-ws" / "nested-ws" / "nested-ws-member", nestedWsMember.root)
|
||||
assertEquals(nestedWs, nestedWsMember.parentWorkspace)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun workspaceInsideStandalone() {
|
||||
val ijProjectRoot = testRoot / "project"
|
||||
testDataDir.copyRecursively(ijProjectRoot)
|
||||
val graph = UvProjectModelResolver.discoverProjectRootSubgraph(ijProjectRoot / "project")
|
||||
|
||||
assertNotNull(graph)
|
||||
assertEquals(3, graph.projects.size)
|
||||
|
||||
val standaloneProject = graph.project("project")
|
||||
assertNotNull(standaloneProject)
|
||||
assertFalse(standaloneProject.isWorkspace)
|
||||
assertEquals(ijProjectRoot / "project" , standaloneProject.root)
|
||||
|
||||
val wsRoot = graph.project("ws-root")
|
||||
assertNotNull(wsRoot)
|
||||
assertTrue(wsRoot.isWorkspace)
|
||||
assertEquals(ijProjectRoot / "project" / "ws-root", wsRoot.root)
|
||||
|
||||
val wsMember = graph.project("ws-member")
|
||||
assertNotNull(wsMember)
|
||||
assertFalse(wsMember.isWorkspace)
|
||||
assertEquals(ijProjectRoot / "project" / "ws-root" / "ws-member", wsMember.root)
|
||||
assertEquals(wsRoot, wsMember.parentWorkspace)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun intermediateNonWorkspaceProjects() {
|
||||
val ijProjectRoot = testRoot / "project"
|
||||
testDataDir.copyRecursively(ijProjectRoot)
|
||||
val graph = UvProjectModelResolver.discoverProjectRootSubgraph(ijProjectRoot / "root-ws")
|
||||
|
||||
assertNotNull(graph)
|
||||
assertEquals(4, graph.projects.size)
|
||||
|
||||
val wsRoot = graph.project("root-ws")
|
||||
assertNotNull(wsRoot)
|
||||
assertTrue(wsRoot.isWorkspace)
|
||||
assertEquals(ijProjectRoot / "root-ws", wsRoot.root)
|
||||
|
||||
val intermediateNonWsProject = graph.project("intermediate-non-ws")
|
||||
assertNotNull(intermediateNonWsProject)
|
||||
assertFalse(intermediateNonWsProject.isWorkspace)
|
||||
assertNull(intermediateNonWsProject.parentWorkspace)
|
||||
assertEquals(ijProjectRoot / "root-ws" / "intermediate-non-ws", intermediateNonWsProject.root)
|
||||
|
||||
val nestedWsMember = graph.project("root-ws-member")
|
||||
assertNotNull(nestedWsMember)
|
||||
assertFalse(nestedWsMember.isWorkspace)
|
||||
assertEquals(wsRoot, nestedWsMember.parentWorkspace)
|
||||
assertEquals(ijProjectRoot / "root-ws" / "intermediate-non-ws" / "root-ws-member", nestedWsMember.root)
|
||||
|
||||
val directWsMember = graph.project("root-ws-direct-member")
|
||||
assertNotNull(directWsMember)
|
||||
assertFalse(directWsMember.isWorkspace)
|
||||
assertEquals(wsRoot, directWsMember.parentWorkspace)
|
||||
assertEquals(ijProjectRoot / "root-ws" / "root-ws-direct-member", directWsMember.root)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun intermediateNonProjectDirs() {
|
||||
val ijProjectRoot = testRoot / "project"
|
||||
testDataDir.copyRecursively(ijProjectRoot)
|
||||
val graph = UvProjectModelResolver.discoverProjectRootSubgraph(ijProjectRoot / "ws-root")
|
||||
|
||||
assertNotNull(graph)
|
||||
assertEquals(2, graph.projects.size)
|
||||
|
||||
val wsRoot = graph.project("ws-root")
|
||||
assertNotNull(wsRoot)
|
||||
assertTrue(wsRoot.isWorkspace)
|
||||
assertEquals(ijProjectRoot / "ws-root", wsRoot.root)
|
||||
|
||||
val wsMember = graph.project("root-ws-member")
|
||||
assertNotNull(wsMember)
|
||||
assertFalse(wsMember.isWorkspace)
|
||||
assertEquals(wsRoot, wsMember.parentWorkspace)
|
||||
assertEquals(ijProjectRoot / "ws-root" / "dir" / "subdir" / "root-ws-member", wsMember.root)
|
||||
}
|
||||
|
||||
|
||||
private val testDataDir: Path
|
||||
get() = get(getTestDataPath()) / "projectModel" / testInfo.testMethod.get().name
|
||||
|
||||
private fun ExternalProjectGraph<UvProject>.project(name: String): UvProject? = projects.firstOrNull { it.name == name }
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.uv
|
||||
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.externalSystem.testFramework.fixtures.multiProjectFixture
|
||||
import com.intellij.platform.testFramework.assertion.collectionAssertion.CollectionAssertions
|
||||
import com.intellij.platform.testFramework.assertion.moduleAssertion.ContentRootAssertions
|
||||
import com.intellij.platform.testFramework.assertion.moduleAssertion.DependencyAssertions
|
||||
import com.intellij.platform.testFramework.assertion.moduleAssertion.ModuleAssertions
|
||||
import com.intellij.python.pyproject.PY_PROJECT_TOML
|
||||
import com.intellij.testFramework.common.timeoutRunBlocking
|
||||
import com.intellij.testFramework.junit5.RegistryKey
|
||||
import com.intellij.testFramework.junit5.TestApplication
|
||||
import com.intellij.testFramework.junit5.fixture.tempPathFixture
|
||||
import com.intellij.testFramework.useProjectAsync
|
||||
import com.intellij.testFramework.utils.io.createFile
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.io.path.writeText
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@RegistryKey("python.pyproject.model", "true")
|
||||
@TestApplication
|
||||
class UvProjectOpenIntegrationTest {
|
||||
private val testRoot by tempPathFixture()
|
||||
private val multiprojectFixture by multiProjectFixture()
|
||||
|
||||
@Test
|
||||
fun `project with top-level uv-lock is automatically linked`() = timeoutRunBlocking(timeout = 20.seconds) {
|
||||
val projectPath = testRoot.resolve("project")
|
||||
|
||||
projectPath.createFile("uv.lock").writeText("""
|
||||
# This file is automatically generated by uv.
|
||||
""".trimIndent())
|
||||
|
||||
projectPath.createFile(PY_PROJECT_TOML).writeText("""
|
||||
[project]
|
||||
name = "project"
|
||||
""".trimIndent())
|
||||
|
||||
multiprojectFixture.openProject(projectPath).useProjectAsync {
|
||||
ModuleAssertions.assertModules(it, "project")
|
||||
CollectionAssertions.assertEqualsUnordered(listOf(projectPath), it.service<UvSettings>().getLinkedProjects())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `project with top-level pyproject-toml containing uv-tool table is automatically linked`() = timeoutRunBlocking(timeout = 20.seconds) {
|
||||
val projectPath = testRoot.resolve("project")
|
||||
|
||||
projectPath.createFile(PY_PROJECT_TOML).writeText("""
|
||||
[project]
|
||||
name = "project"
|
||||
|
||||
[tool.uv.workspace]
|
||||
members = ["packages/*"]
|
||||
""".trimIndent())
|
||||
|
||||
multiprojectFixture.openProject(projectPath).useProjectAsync { project ->
|
||||
ModuleAssertions.assertModules(project, "project")
|
||||
CollectionAssertions.assertEqualsUnordered(listOf(projectPath), project.service<UvSettings>().getLinkedProjects())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `project with top-level pyproject-toml without uv-tool table is not automatically linked`() = timeoutRunBlocking(timeout = 20.seconds) {
|
||||
val projectPath = testRoot.resolve("project")
|
||||
|
||||
projectPath.createFile(PY_PROJECT_TOML).writeText("""
|
||||
[project]
|
||||
name = "project"
|
||||
""".trimIndent())
|
||||
|
||||
multiprojectFixture.openProject(projectPath).useProjectAsync { project ->
|
||||
ModuleAssertions.assertModules(project, "project")
|
||||
CollectionAssertions.assertEqualsUnordered(emptyList(), project.service<UvSettings>().getLinkedProjects())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manually syncing a not recognized project removes its fake top-level module`() = timeoutRunBlocking(timeout = 20.seconds) {
|
||||
val projectPath = testRoot.resolve("project")
|
||||
|
||||
projectPath.createFile(PY_PROJECT_TOML).writeText("""
|
||||
[project]
|
||||
name = "myproject"
|
||||
""".trimIndent())
|
||||
|
||||
multiprojectFixture.openProject(projectPath).useProjectAsync { project ->
|
||||
// Nothing was linked automatically.
|
||||
// There is only the module created by com.intellij.openapi.project.impl.CreateModuleKt.getOrInitializeModule
|
||||
ModuleAssertions.assertModules(project, "project")
|
||||
ContentRootAssertions.assertContentRoots(project, "project", listOf(projectPath))
|
||||
CollectionAssertions.assertEqualsUnordered(emptyList(), project.service<UvSettings>().getLinkedProjects())
|
||||
|
||||
multiprojectFixture.awaitProjectConfiguration(project) {
|
||||
UvProjectModelService.linkAllProjectModelRoots(project, project.basePath!!)
|
||||
UvProjectModelService.syncAllProjectModelRoots(project)
|
||||
}
|
||||
|
||||
// Now there is only the module owned by Uv
|
||||
ModuleAssertions.assertModules(project, "myproject")
|
||||
CollectionAssertions.assertEqualsUnordered(listOf(projectPath), project.service<UvSettings>().getLinkedProjects())
|
||||
ContentRootAssertions.assertContentRoots(project, "myproject", listOf(projectPath))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test monorepo without top-level pyproject-toml and with sibling path dependency`() = timeoutRunBlocking(timeout = 20.seconds) {
|
||||
val projectPath = testRoot.resolve("project")
|
||||
|
||||
projectPath.createFile("libs/project1/pyproject.toml").writeText("""
|
||||
[project]
|
||||
name = "project1"
|
||||
|
||||
[tool.uv.sources]
|
||||
project2 = { path = "../project2", editable = true }
|
||||
""".trimIndent())
|
||||
|
||||
projectPath.createFile("libs/project2/pyproject.toml").writeText("""
|
||||
[project]
|
||||
name = "project2"
|
||||
""".trimIndent())
|
||||
|
||||
multiprojectFixture.openProject(projectPath).useProjectAsync { project ->
|
||||
val uvSettings = project.service<UvSettings>()
|
||||
// Such projects without uv.lock or pyproject.toml with tool.uv in the root cannot be recognized automatically
|
||||
CollectionAssertions.assertEmpty(uvSettings.getLinkedProjects())
|
||||
ModuleAssertions.assertModules(project, "project")
|
||||
|
||||
multiprojectFixture.awaitProjectConfiguration(project) {
|
||||
UvProjectModelService.linkAllProjectModelRoots(project, project.basePath!!)
|
||||
UvProjectModelService.syncAllProjectModelRoots(project)
|
||||
}
|
||||
|
||||
ModuleAssertions.assertModules(project, "project", "project1", "project2")
|
||||
CollectionAssertions.assertEqualsUnordered(
|
||||
listOf(projectPath.resolve("libs/")),
|
||||
uvSettings.getLinkedProjects()
|
||||
)
|
||||
|
||||
ModuleAssertions.assertModuleEntity(project, "project1") { module ->
|
||||
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE, "project2")
|
||||
}
|
||||
|
||||
ModuleAssertions.assertModuleEntity(project, "project2") { module ->
|
||||
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test monorepo with two independent workspaces`() = timeoutRunBlocking(timeout = 20.seconds) {
|
||||
val projectPath = testRoot.resolve("project")
|
||||
|
||||
projectPath.createFile("workspace1/pyproject.toml").writeText("""
|
||||
[project]
|
||||
name = "workspace1"
|
||||
|
||||
[tool.uv.workspace]
|
||||
members = [
|
||||
"lib1",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
lib1 = { workspace = true }
|
||||
""".trimIndent())
|
||||
|
||||
projectPath.createFile("workspace1/lib1/pyproject.toml").writeText("""
|
||||
[project]
|
||||
name = "lib1"
|
||||
""".trimIndent())
|
||||
|
||||
projectPath.createFile("workspace2/pyproject.toml").writeText("""
|
||||
[project]
|
||||
name = "workspace2"
|
||||
|
||||
[tool.uv.workspace]
|
||||
members = [
|
||||
"lib2",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
lib2 = { workspace = true }
|
||||
""".trimIndent())
|
||||
|
||||
projectPath.createFile("workspace2/lib2/pyproject.toml").writeText("""
|
||||
[project]
|
||||
name = "lib2"
|
||||
""".trimIndent())
|
||||
|
||||
multiprojectFixture.openProject(projectPath).useProjectAsync { project ->
|
||||
val uvSettings = project.service<UvSettings>()
|
||||
// Such projects without uv.lock or pyproject.toml with tool.uv in the root cannot be recognized automatically
|
||||
CollectionAssertions.assertEmpty(uvSettings.getLinkedProjects())
|
||||
ModuleAssertions.assertModules(project, "project")
|
||||
|
||||
multiprojectFixture.awaitProjectConfiguration(project) {
|
||||
UvProjectModelService.linkAllProjectModelRoots(project, project.basePath!!)
|
||||
UvProjectModelService.syncAllProjectModelRoots(project)
|
||||
}
|
||||
|
||||
ModuleAssertions.assertModules(project, "project", "workspace1", "workspace2", "lib1", "lib2")
|
||||
CollectionAssertions.assertEqualsUnordered(
|
||||
listOf(
|
||||
projectPath.resolve("workspace1"),
|
||||
projectPath.resolve("workspace2")
|
||||
),
|
||||
uvSettings.getLinkedProjects()
|
||||
)
|
||||
|
||||
ModuleAssertions.assertModuleEntity(project, "workspace1") { module ->
|
||||
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE, "lib1")
|
||||
}
|
||||
|
||||
ModuleAssertions.assertModuleEntity(project, "workspace2") { module ->
|
||||
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE, "lib2")
|
||||
}
|
||||
|
||||
ModuleAssertions.assertModuleEntity(project, "lib1") { module ->
|
||||
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE)
|
||||
}
|
||||
|
||||
ModuleAssertions.assertModuleEntity(project, "lib2") { module ->
|
||||
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.projectModel.uv
|
||||
|
||||
import com.intellij.openapi.externalSystem.testFramework.fixtures.multiProjectFixture
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.platform.backend.workspace.workspaceModel
|
||||
import com.intellij.platform.testFramework.assertion.collectionAssertion.CollectionAssertions.assertEqualsUnordered
|
||||
import com.intellij.platform.testFramework.assertion.moduleAssertion.ContentRootAssertions
|
||||
import com.intellij.platform.testFramework.assertion.moduleAssertion.DependencyAssertions
|
||||
import com.intellij.platform.testFramework.assertion.moduleAssertion.ModuleAssertions
|
||||
import com.intellij.platform.testFramework.assertion.moduleAssertion.SourceRootAssertions
|
||||
import com.intellij.platform.workspace.jps.entities.ModuleEntity
|
||||
import com.intellij.platform.workspace.jps.entities.ModuleId
|
||||
import com.intellij.platform.workspace.jps.entities.exModuleOptions
|
||||
import com.intellij.platform.workspace.storage.impl.url.toVirtualFileUrl
|
||||
import com.intellij.python.pyproject.PY_PROJECT_TOML
|
||||
import com.intellij.testFramework.common.timeoutRunBlocking
|
||||
import com.intellij.testFramework.junit5.RegistryKey
|
||||
import com.intellij.testFramework.junit5.TestApplication
|
||||
import com.intellij.testFramework.junit5.fixture.projectFixture
|
||||
import com.intellij.testFramework.junit5.fixture.tempPathFixture
|
||||
import com.intellij.testFramework.utils.io.createFile
|
||||
import com.jetbrains.python.projectModel.BaseProjectModelService.Companion.PYTHON_SOURCE_ROOT_TYPE
|
||||
import org.junit.jupiter.api.Assertions
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.writeText
|
||||
|
||||
@RegistryKey("python.pyproject.model", "true")
|
||||
@TestApplication
|
||||
class UvProjectSyncIntegrationTest {
|
||||
private val testRootFixture = tempPathFixture()
|
||||
val testRoot by testRootFixture
|
||||
|
||||
private val project by projectFixture(testRootFixture, openAfterCreation = true)
|
||||
private val multiprojectFixture by multiProjectFixture()
|
||||
|
||||
@Test
|
||||
fun `src directory is mapped to module source root`() = timeoutRunBlocking {
|
||||
testRoot.createFile(PY_PROJECT_TOML).writeText("""
|
||||
[project]
|
||||
name = "main"
|
||||
dependencies = []
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
""".trimIndent())
|
||||
|
||||
testRoot.createFile("src/main/__init__.py")
|
||||
|
||||
multiprojectFixture.linkProject(project, testRoot, UvConstants.SYSTEM_ID)
|
||||
syncAllProjects(project)
|
||||
|
||||
ModuleAssertions.assertModules(project, "main")
|
||||
SourceRootAssertions.assertSourceRoots(project, "main", { it.rootTypeId == PYTHON_SOURCE_ROOT_TYPE }, testRoot.resolve("src"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `root dot venv directory is automatically excluded`() = timeoutRunBlocking {
|
||||
testRoot.createFile(PY_PROJECT_TOML).writeText("""
|
||||
[project]
|
||||
name = "main"
|
||||
dependencies = []
|
||||
|
||||
[tool.uv.workspace]
|
||||
members = [
|
||||
"lib",
|
||||
]
|
||||
""".trimIndent())
|
||||
testRoot.createFile(".venv/pyvenv.cfg")
|
||||
|
||||
testRoot.createFile("lib/pyproject.toml").writeText("""
|
||||
[project]
|
||||
name = "lib"
|
||||
dependencies = []
|
||||
""".trimIndent())
|
||||
testRoot.createFile("lib/.venv/pyvenv.cfg")
|
||||
|
||||
multiprojectFixture.linkProject(project, testRoot, UvConstants.SYSTEM_ID)
|
||||
syncAllProjects(project)
|
||||
|
||||
ModuleAssertions.assertModules(project, "main", "lib")
|
||||
assertExcludedRoots(project, "main", listOf(testRoot.resolve(".venv")))
|
||||
assertExcludedRoots(project, "lib", listOf(testRoot.resolve("lib/.venv")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `projects inside dot venv are skipped`() = timeoutRunBlocking {
|
||||
testRoot.createFile(PY_PROJECT_TOML).writeText("""
|
||||
[project]
|
||||
name = "main"
|
||||
""".trimIndent())
|
||||
|
||||
testRoot.createFile(".venv/lib/python3.13/site-packages/pandas/pyproject.toml").writeText("""
|
||||
[project]
|
||||
name = 'pandas'
|
||||
""".trimIndent())
|
||||
|
||||
multiprojectFixture.linkProject(project, testRoot, UvConstants.SYSTEM_ID)
|
||||
syncAllProjects(project)
|
||||
|
||||
ModuleAssertions.assertModules(project, "main")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `workspace project with a path dependency`() = timeoutRunBlocking {
|
||||
testRoot.createFile(PY_PROJECT_TOML).writeText("""
|
||||
[project]
|
||||
name = "main"
|
||||
dependencies = [
|
||||
"lib1",
|
||||
"lib2",
|
||||
]
|
||||
|
||||
[tool.uv.workspace]
|
||||
members = ["lib/lib1", "lib/lib2"]
|
||||
|
||||
[tool.uv.sources]
|
||||
lib1 = { workspace = true }
|
||||
lib2 = { workspace = true }
|
||||
""".trimIndent())
|
||||
|
||||
testRoot.createFile("lib/lib1/pyproject.toml").writeText("""
|
||||
[project]
|
||||
name = "lib1"
|
||||
dependencies = [
|
||||
"pkg",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
pkg = { path = "../../packages/pkg", editable = true }
|
||||
""".trimIndent())
|
||||
|
||||
testRoot.createFile("lib/lib2/pyproject.toml").writeText("""
|
||||
[project]
|
||||
name = "lib2"
|
||||
dependencies = []
|
||||
""".trimIndent())
|
||||
|
||||
testRoot.createFile("packages/pkg/pyproject.toml").writeText("""
|
||||
[project]
|
||||
name = "pkg"
|
||||
dependencies = []
|
||||
""".trimIndent())
|
||||
|
||||
multiprojectFixture.linkProject(project, testRoot, UvConstants.SYSTEM_ID)
|
||||
syncAllProjects(project)
|
||||
|
||||
val virtualFileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
|
||||
ModuleAssertions.assertModules(project, "main", "lib1", "lib2", "pkg")
|
||||
val workspace = UvProjectModelService.UvWorkspace<ModuleEntity>(
|
||||
root = project.findModule("main")!!,
|
||||
members = setOf(
|
||||
project.findModule("lib1")!!,
|
||||
project.findModule("lib2")!!,
|
||||
)
|
||||
)
|
||||
|
||||
ModuleAssertions.assertModuleEntity(project, "main") { module ->
|
||||
ContentRootAssertions.assertContentRoots(virtualFileUrlManager, module, testRoot)
|
||||
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE, "lib1", "lib2")
|
||||
DependencyAssertions.assertModuleDependency(module, "lib1") { dependency ->
|
||||
Assertions.assertTrue(dependency.exported)
|
||||
}
|
||||
DependencyAssertions.assertModuleDependency(module, "lib2") { dependency ->
|
||||
Assertions.assertTrue(dependency.exported)
|
||||
}
|
||||
Assertions.assertEquals("main", module.exModuleOptions?.linkedProjectId)
|
||||
Assertions.assertEquals(workspace, UvProjectModelService.findWorkspace(project, module)
|
||||
)
|
||||
}
|
||||
|
||||
ModuleAssertions.assertModuleEntity(project, "lib1") { module ->
|
||||
ContentRootAssertions.assertContentRoots(virtualFileUrlManager, module, testRoot.resolve("lib/lib1"))
|
||||
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE, "pkg")
|
||||
DependencyAssertions.assertModuleDependency(module, "pkg") { dependency ->
|
||||
Assertions.assertTrue(dependency.exported)
|
||||
}
|
||||
Assertions.assertEquals("main:lib1", module.exModuleOptions?.linkedProjectId)
|
||||
Assertions.assertEquals(workspace, UvProjectModelService.findWorkspace(project, module)
|
||||
)
|
||||
}
|
||||
|
||||
ModuleAssertions.assertModuleEntity(project, "lib2") { module ->
|
||||
ContentRootAssertions.assertContentRoots(virtualFileUrlManager, module, testRoot.resolve("lib/lib2"))
|
||||
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE)
|
||||
Assertions.assertEquals("main:lib2", module.exModuleOptions?.linkedProjectId)
|
||||
Assertions.assertEquals(workspace, UvProjectModelService.findWorkspace(project, module)
|
||||
)
|
||||
}
|
||||
|
||||
ModuleAssertions.assertModuleEntity(project, "pkg") { module ->
|
||||
ContentRootAssertions.assertContentRoots(virtualFileUrlManager, module, testRoot.resolve("packages/pkg"))
|
||||
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE)
|
||||
Assertions.assertEquals("pkg", module.exModuleOptions?.linkedProjectId)
|
||||
Assertions.assertEquals(
|
||||
UvProjectModelService.UvWorkspace(module, emptySet()),
|
||||
UvProjectModelService.findWorkspace(project, module)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertExcludedRoots(project: Project, moduleName: String, expectedRoots: List<Path>) {
|
||||
val virtualFileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
|
||||
val expectedUrls = expectedRoots.map { it.normalize().toVirtualFileUrl(virtualFileUrlManager) }
|
||||
ModuleAssertions.assertModuleEntity(project, moduleName) { moduleEntity ->
|
||||
val actualRoots = moduleEntity.contentRoots
|
||||
.flatMap { it.excludedUrls }
|
||||
.map { it.url }
|
||||
assertEqualsUnordered(expectedUrls, actualRoots)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Project.findModule(name: String): ModuleEntity? {
|
||||
return workspaceModel.currentSnapshot.resolve(ModuleId(name))
|
||||
}
|
||||
|
||||
private suspend fun syncAllProjects(project: Project) {
|
||||
multiprojectFixture.awaitProjectConfiguration(project) {
|
||||
UvProjectModelService.syncAllProjectModelRoots(project)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user