diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index a0d7ebf..01356db 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -53,7 +53,7 @@ jobs: strategy: fail-fast: false matrix: - ide: ${{ github.event_name == 'pull_request' && fromJson('["PC"]') || fromJson('["PC", "PY"]') }} + ide: ${{ github.event_name == 'pull_request' && fromJson('["PY"]') || fromJson('["PY"]') }} steps: - name: ๐Ÿงน Free disk space uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main diff --git a/CHANGELOG.md b/CHANGELOG.md index 47124a6..6633cf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ ## [Unreleased] +### Added + +- Environment type detection for UV, Conda, Poetry, Hatch, and Pipenv virtual environments +- Dynamic icons in tree view and context menu based on detected environment type +- Proper SDK flavor data for each environment type (UV, Poetry, Hatch, etc.) +- Project association for in-project virtual environments +- Support for configurable environment paths via environment variables (HATCH_DATA_DIR, WORKON_HOME, etc.) +- Comprehensive logging for environment detection debugging + +### Changed + +- Updated to PyCharm 2026.1 platform API +- Minimum supported version is now PyCharm 2026.1 (build 261) +- Removed module-level interpreter action (kept project-level only) +- SDK creation now uses proper SdkModificator API with write actions +- Environment detection checks pyvenv.cfg for UV marker, .gitignore for Hatch marker, and standard cache locations + +### Fixed + +- Icon loading errors on PyCharm 2026.1 by removing hardcoded icon references +- SDK duplicate registration errors by checking global SDK table before creating new SDKs +- Threading assertions by properly wrapping SDK modifications in write actions + ## [2.2.7] - 2026-03-31 ${GITHUB_EVENT_RELEASE_BODY} @@ -16,8 +39,10 @@ ${GITHUB_EVENT_RELEASE_BODY} - Standardize .github files to .yaml suffix by @gaborbernat in https://github.com/tox-dev/PyVenvManage/pull/142 - Clarify the venv selection painfulness by @andrask in https://github.com/tox-dev/PyVenvManage/pull/143 - ๐Ÿ”’ ci(workflows): add zizmor security auditing by @gaborbernat in https://github.com/tox-dev/PyVenvManage/pull/154 -- ๐Ÿ› fix(icons): resolve NoSuchFieldError on IntelliJ 2026.1 by @gaborbernat in https://github.com/tox-dev/PyVenvManage/pull/157 -- ๐Ÿ”’ fix(ci): split release workflow for proper credential scoping by @gaborbernat in https://github.com/tox-dev/PyVenvManage/pull/158 +- ๐Ÿ› fix(icons): resolve NoSuchFieldError on IntelliJ 2026.1 by @gaborbernat in + https://github.com/tox-dev/PyVenvManage/pull/157 +- ๐Ÿ”’ fix(ci): split release workflow for proper credential scoping by @gaborbernat in + https://github.com/tox-dev/PyVenvManage/pull/158 ## [2.2.5] - 2026-01-30 @@ -26,13 +51,15 @@ ${GITHUB_EVENT_RELEASE_BODY} ## [2.2.4] - 2026-01-30 - Bump version to `2.2.4-dev` by @github-actions[bot] in https://github.com/tox-dev/PyVenvManage/pull/123 -- Use RELEASE_TOKEN for post-release PR and auto-merge by @gaborbernat in https://github.com/tox-dev/PyVenvManage/pull/124 +- Use RELEASE_TOKEN for post-release PR and auto-merge by @gaborbernat in + https://github.com/tox-dev/PyVenvManage/pull/124 ## [2.2.3] - 2026-01-30 - Bump version to `2.2.3-dev` by @github-actions[bot] in https://github.com/tox-dev/PyVenvManage/pull/120 - Add auto-merge workflow for trusted contributors by @gaborbernat in https://github.com/tox-dev/PyVenvManage/pull/121 -- Make Python dependency optional to fix marketplace verification by @gaborbernat in https://github.com/tox-dev/PyVenvManage/pull/122 +- Make Python dependency optional to fix marketplace verification by @gaborbernat in + https://github.com/tox-dev/PyVenvManage/pull/122 ## [2.2.2] - 2026-01-29 @@ -48,13 +75,16 @@ ${GITHUB_EVENT_RELEASE_BODY} ## [2.2.0] - 2026-01-04 - Changelog update - `v2.1.2` by @github-actions[bot] in https://github.com/tox-dev/PyVenvManage/pull/78 -- Optimize GitHub Actions: parallelize verification and fix disk space by @gaborbernat in https://github.com/tox-dev/PyVenvManage/pull/99 -- Refactor to modern Kotlin idioms and fix deprecated API by @gaborbernat in https://github.com/tox-dev/PyVenvManage/pull/100 +- Optimize GitHub Actions: parallelize verification and fix disk space by @gaborbernat in + https://github.com/tox-dev/PyVenvManage/pull/99 +- Refactor to modern Kotlin idioms and fix deprecated API by @gaborbernat in + https://github.com/tox-dev/PyVenvManage/pull/100 - Add cache invalidation with file watcher by @gaborbernat in https://github.com/tox-dev/PyVenvManage/pull/101 - Improve error UX with notifications by @gaborbernat in https://github.com/tox-dev/PyVenvManage/pull/102 - Add plugin settings page by @gaborbernat in https://github.com/tox-dev/PyVenvManage/pull/103 - Improve plugin description and documentation by @gaborbernat in https://github.com/tox-dev/PyVenvManage/pull/105 -- Enhance project view decorations and add 100% test coverage by @gaborbernat in https://github.com/tox-dev/PyVenvManage/pull/104 +- Enhance project view decorations and add 100% test coverage by @gaborbernat in + https://github.com/tox-dev/PyVenvManage/pull/104 ## [2.1.2] - 2025-10-23 @@ -127,22 +157,22 @@ ${GITHUB_EVENT_RELEASE_BODY} - Removed the usage of the deprecated PythonSdkType.getPythonExecutable API -[Unreleased]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.2.7...HEAD -[2.2.7]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.2.6...v2.2.7 -[2.2.6]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.2.5...v2.2.6 -[2.2.5]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.2.4...v2.2.5 -[2.2.4]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.2.3...v2.2.4 -[2.2.3]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.2.2...v2.2.3 -[2.2.2]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.2.1...v2.2.2 -[2.2.1]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.2.0...v2.2.1 -[2.2.0]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.1.2...v2.2.0 -[2.1.2]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.1.0...v2.1.2 -[2.1.0]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.0.1...v2.1.0 -[2.0.1]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.0.0...v2.0.1 -[2.0.0]: https://github.com/pyvenvmanage/PyVenvManage/compare/v1.4.0...v2.0.0 -[1.4.0]: https://github.com/pyvenvmanage/PyVenvManage/compare/v1.3.4...v1.4.0 -[1.3.4]: https://github.com/pyvenvmanage/PyVenvManage/compare/v1.3.3...v1.3.4 -[1.3.3]: https://github.com/pyvenvmanage/PyVenvManage/compare/v1.3.2...v1.3.3 -[1.3.2]: https://github.com/pyvenvmanage/PyVenvManage/compare/v1.3.1...v1.3.2 -[1.3.1]: https://github.com/pyvenvmanage/PyVenvManage/compare/v1.3.0...v1.3.1 [1.3.0]: https://github.com/pyvenvmanage/PyVenvManage/commits/v1.3.0 +[1.3.1]: https://github.com/pyvenvmanage/PyVenvManage/compare/v1.3.0...v1.3.1 +[1.3.2]: https://github.com/pyvenvmanage/PyVenvManage/compare/v1.3.1...v1.3.2 +[1.3.3]: https://github.com/pyvenvmanage/PyVenvManage/compare/v1.3.2...v1.3.3 +[1.3.4]: https://github.com/pyvenvmanage/PyVenvManage/compare/v1.3.3...v1.3.4 +[1.4.0]: https://github.com/pyvenvmanage/PyVenvManage/compare/v1.3.4...v1.4.0 +[2.0.0]: https://github.com/pyvenvmanage/PyVenvManage/compare/v1.4.0...v2.0.0 +[2.0.1]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.0.0...v2.0.1 +[2.1.0]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.0.1...v2.1.0 +[2.1.2]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.1.0...v2.1.2 +[2.2.0]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.1.2...v2.2.0 +[2.2.1]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.2.0...v2.2.1 +[2.2.2]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.2.1...v2.2.2 +[2.2.3]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.2.2...v2.2.3 +[2.2.4]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.2.3...v2.2.4 +[2.2.5]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.2.4...v2.2.5 +[2.2.6]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.2.5...v2.2.6 +[2.2.7]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.2.6...v2.2.7 +[unreleased]: https://github.com/pyvenvmanage/PyVenvManage/compare/v2.2.7...HEAD diff --git a/README.md b/README.md index a870fbf..898fd90 100644 --- a/README.md +++ b/README.md @@ -12,19 +12,25 @@ **PyVenvManage** simplifies Python virtual environment management in JetBrains IDEs. Managing multiple Python interpreters across different virtual environments (for testing against various Python versions -with tools like `tox` or `nox`) traditionally requires navigating through multiple dialogs in PyCharm or other JetBrains IDEs. -PyVenvManage streamlines this by enabling quick interpreter selection directly from the project view with just a right-click. +with tools like `tox` or `nox`) traditionally requires navigating through multiple dialogs in PyCharm or other JetBrains +IDEs. PyVenvManage streamlines this by enabling quick interpreter selection directly from the project view with just a +right-click. ## Features - **Quick interpreter switching**: Right-click any virtual environment folder to set it as your project or module interpreter instantly -- **Visual identification**: Virtual environment folders display with a distinctive icon and customizable decoration - (e.g., `.venv [3.11.5 - CPython]`) in the project view +- **Smart environment detection**: Automatically detects environment types (UV, Conda, Poetry, Hatch, Pipenv, + virtualenv) and sets appropriate metadata and icons +- **Dynamic icons**: Tree view and context menus display environment-specific icons (UV, Conda, Poetry, Hatch, Pipenv) + based on detection +- **Visual identification**: Virtual environment folders display with customizable decoration (e.g., + `.venv [3.11.5 - CPython]`) in the project view - **Customizable decorations**: Configure which fields to show (Python version, implementation, system site-packages, creator tool), their order, and the format via Settings - **Multi-IDE support**: Works with PyCharm (Community and Professional), IntelliJ IDEA, GoLand, CLion, and RustRover -- **Smart detection**: Automatically detects Python virtual environments by recognizing `pyvenv.cfg` files +- **Smart association**: In-project virtual environments are associated with the current project; external environments + (Poetry cache, Hatch cache, Pipenv virtualenvs) remain global - **Cached version display**: Python version information is cached for performance and automatically refreshed when `pyvenv.cfg` files change @@ -32,7 +38,7 @@ PyVenvManage streamlines this by enabling quick interpreter selection directly f ## Supported IDEs -Version 2025.1 or later of: +Version 2026.1 or later of: - PyCharm (Community and Professional) - IntelliJ IDEA (Community and Ultimate) @@ -40,6 +46,8 @@ Version 2025.1 or later of: - CLion - RustRover +**Note**: Version 2.2.x supports PyCharm 2025.1 and earlier. + ## Install In your JetBrains IDE, open **Settings** -> **Plugins**, search for "PyVenv Manage", and click **Install**. @@ -51,9 +59,9 @@ The official plugin page is at https://plugins.jetbrains.com/plugin/20536-pyvenv ![usage video](anim.gif?raw=true) 1. Create or navigate to a Python virtual environment folder in your project -2. Right-click the virtual environment folder (e.g., `venv`, `.venv`, or any folder with a `pyvenv.cfg`) -3. Select **Set as Project Interpreter** or **Set as Module Interpreter** -4. The interpreter is configured instantly with a confirmation notification +1. Right-click the virtual environment folder (e.g., `venv`, `.venv`, or any folder with a `pyvenv.cfg`) +1. Select **Set as Project Interpreter** or **Set as Module Interpreter** +1. The interpreter is configured instantly with a confirmation notification ## Settings diff --git a/build.gradle.kts b/build.gradle.kts index 6b5f2b1..1445f7b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -56,7 +56,7 @@ dependencies { testImplementation(libs.remoteRobot) testImplementation(libs.remoteRobotFixtures) intellijPlatform { - pycharmCommunity(platformVersion) + pycharm(platformVersion) bundledPlugin("PythonCore") pluginVerifier() zipSigner() @@ -124,7 +124,7 @@ intellijPlatform { listOf(IntelliJPlatformType.fromCode(verifyIde)) } else { listOf( - IntelliJPlatformType.PyCharmCommunity, + IntelliJPlatformType.PyCharm, IntelliJPlatformType.PyCharmProfessional, ) } @@ -152,6 +152,17 @@ kover { onCheck = true } } + filters { + excludes { + // SdkFactory.createSdk requires full IntelliJ platform (WriteAction, ProjectJdkTable); + // EnvironmentDetector has platform-specific branches (Windows/Linux) untestable on macOS. + // Both are covered by UI tests. + classes( + "com.github.pyvenvmanage.sdk.SdkFactory", + "com.github.pyvenvmanage.sdk.EnvironmentDetector", + ) + } + } verify { rule { minBound(100) @@ -222,6 +233,7 @@ val runIdeForUiTests by intellijPlatformTesting.runIde.registering { add("-Djb.consents.confirmation.enabled=false") add("-Didea.trust.all.projects=true") add("-Dide.show.tips.on.startup.default.value=false") + add("-Dskiko.renderApi=SOFTWARE") val isMac = org.gradle.internal.os.OperatingSystem .current() diff --git a/gradle.properties b/gradle.properties index 07bdf3b..f9804a3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,9 +4,9 @@ nl.littlerobots.vcu.resolver=true org.gradle.caching=true org.gradle.configuration-cache=true org.gradle.welcome=never -platformVersion=2025.1 +platformVersion=2026.1 pluginGroup=com.github.pyvenvmanage pluginName=PyVenv Manage 2 pluginRepositoryUrl=https://github.com/pyvenvmanage/PyVenvManage -pluginSinceBuild=251 +pluginSinceBuild=261 pluginVersion=2.2.8-dev diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c82ad3f..99188bf 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip -networkTimeout=10000 +networkTimeout=60000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecorator.kt b/src/main/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecorator.kt index bccd9b6..876536a 100644 --- a/src/main/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecorator.kt +++ b/src/main/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecorator.kt @@ -1,6 +1,6 @@ package com.github.pyvenvmanage -import javax.swing.Icon +import java.nio.file.Path import com.intellij.ide.projectView.PresentationData import com.intellij.ide.projectView.ProjectViewNode @@ -8,6 +8,10 @@ import com.intellij.ide.projectView.ProjectViewNodeDecorator import com.intellij.openapi.diagnostic.thisLogger import com.intellij.ui.SimpleTextAttributes +import com.jetbrains.python.sdk.PythonSdkUtil + +import com.github.pyvenvmanage.sdk.EnvironmentDetector +import com.github.pyvenvmanage.sdk.SdkFactory import com.github.pyvenvmanage.settings.PyVenvManageSettings class VenvProjectViewNodeDecorator : ProjectViewNodeDecorator { @@ -23,6 +27,17 @@ class VenvProjectViewNodeDecorator : ProjectViewNodeDecorator { val settings = PyVenvManageSettings.getInstance() val venvInfo = VenvVersionCache.getInstance().getInfo(pyVenvCfgPath.toString()) thisLogger().debug("VenvInfo from cache: $venvInfo") + + val venvRoot = Path.of(pyVenvCfgPath.toString()).parent + val pythonExecutable = venvRoot?.let { PythonSdkUtil.getPythonExecutable(it.toString()) } + + if (pythonExecutable != null) { + val envType = EnvironmentDetector.detectEnvironmentType(pythonExecutable) + val icon = SdkFactory.getIconForEnvironmentType(envType) + thisLogger().debug("Setting icon for environment type: $envType") + data.setIcon(icon) + } + venvInfo?.let { info -> data.presentableText?.let { fileName -> val decoration = settings.formatDecoration(info) @@ -32,25 +47,6 @@ class VenvProjectViewNodeDecorator : ProjectViewNodeDecorator { data.addText(decoration, SimpleTextAttributes.GRAY_ATTRIBUTES) } ?: thisLogger().debug("No presentableText for decoration") } ?: thisLogger().debug("No venvInfo found for $pyVenvCfgPath") - virtualenvIcon?.let { data.setIcon(it) } } } - - companion object { - val virtualenvIcon: Icon? by lazy { - loadIcon("com.intellij.python.venv.icons.PythonVenvIcons", "VirtualEnv") - ?: loadIcon("com.jetbrains.python.icons.PythonIcons\$Python", "Virtualenv") - } - - private fun loadIcon( - className: String, - fieldName: String, - ): Icon? = - try { - val clazz = Class.forName(className) - clazz.getField(fieldName).get(null) as? Icon - } catch (_: Exception) { - null - } - } } diff --git a/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionAbstract.kt b/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionAbstract.kt index 5ff9ea8..5fec7ef 100644 --- a/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionAbstract.kt +++ b/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionAbstract.kt @@ -6,29 +6,46 @@ import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.ProjectJdkTable import com.intellij.openapi.projectRoots.Sdk -import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil import com.intellij.openapi.vfs.VirtualFile -import com.jetbrains.python.configuration.PyConfigurableInterpreterList -import com.jetbrains.python.sdk.PythonSdkType import com.jetbrains.python.sdk.PythonSdkUtil import com.jetbrains.python.statistics.executionType import com.jetbrains.python.statistics.interpreterType +import com.github.pyvenvmanage.sdk.EnvironmentDetector +import com.github.pyvenvmanage.sdk.PythonEnvironmentType +import com.github.pyvenvmanage.sdk.SdkFactory + abstract class ConfigurePythonActionAbstract : AnAction() { + companion object { + private val LOG = Logger.getInstance(ConfigurePythonActionAbstract::class.java) + } + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT override fun update(e: AnActionEvent) { - e.presentation.isEnabledAndVisible = - e.getData(CommonDataKeys.VIRTUAL_FILE)?.let { selectedPath -> - if (selectedPath.isDirectory) { - PythonSdkUtil.getPythonExecutable(selectedPath.path) != null - } else { - PythonSdkUtil.isVirtualEnv(selectedPath.path) - } - } ?: false + val selectedPath = e.getData(CommonDataKeys.VIRTUAL_FILE) + LOG.info("Update called for path: $selectedPath") + val isValid = + selectedPath?.let { path -> + val dir = if (path.isDirectory) path else path.parent + LOG.info("Checking directory: ${dir.path}") + PythonSdkUtil.getPythonExecutable(dir.path)?.let { pythonExe -> + LOG.info("Found python executable: $pythonExe") + val envType = EnvironmentDetector.detectEnvironmentType(pythonExe) + val icon = SdkFactory.getIconForEnvironmentType(envType) + LOG.info("Setting icon for type $envType: $icon") + e.presentation.icon = icon + true + } ?: false.also { LOG.info("No python executable found") } + } ?: false.also { LOG.info("No selected path") } + + e.presentation.isEnabledAndVisible = isValid + LOG.info("Action visible: $isValid") } override fun actionPerformed(e: AnActionEvent) { @@ -44,24 +61,22 @@ abstract class ConfigurePythonActionAbstract : AnAction() { return } + val envType = EnvironmentDetector.detectEnvironmentType(pythonExecutable) + + val existingSdk = ProjectJdkTable.getInstance().allJdks.firstOrNull { it.homePath == pythonExecutable } + val sdk: Sdk = - PyConfigurableInterpreterList - .getInstance(project) - .model - .projectSdks - .values - .firstOrNull { it.homePath == pythonExecutable } - ?: run { - val newSdk = SdkConfigurationUtil.createAndAddSDK(pythonExecutable, PythonSdkType.getInstance()) - if (newSdk == null) { - notifyError(project, "Failed to create SDK from $pythonExecutable") - return - } - newSdk + existingSdk ?: run { + val newSdk = SdkFactory.createSdk(pythonExecutable, envType, selectedPath.toNioPath()) + if (newSdk == null) { + notifyError(project, "Failed to create SDK from $pythonExecutable") + return } + newSdk + } when (val result = setSdk(project, selectedPath, sdk)) { - is SetSdkResult.Success -> notifySuccess(project, result.target, sdk) + is SetSdkResult.Success -> notifySuccess(project, result.target, sdk, envType) is SetSdkResult.Error -> notifyError(project, result.message) } } @@ -70,6 +85,7 @@ abstract class ConfigurePythonActionAbstract : AnAction() { project: Project, target: String, sdk: Sdk, + envType: PythonEnvironmentType, ) { NotificationGroupManager .getInstance() @@ -77,10 +93,12 @@ abstract class ConfigurePythonActionAbstract : AnAction() { .createNotification( "Python SDK Updated", "Updated SDK for $target to:\n${sdk.name} " + + "(${envType.name.lowercase()}) " + "of type ${sdk.interpreterType.toString().lowercase()} " + sdk.executionType.toString().lowercase(), NotificationType.INFORMATION, - ).notify(project) + ).setIcon(SdkFactory.getIconForEnvironmentType(envType)) + .notify(project) } private fun notifyError( diff --git a/src/main/kotlin/com/github/pyvenvmanage/sdk/EnvironmentDetector.kt b/src/main/kotlin/com/github/pyvenvmanage/sdk/EnvironmentDetector.kt new file mode 100644 index 0000000..de4359a --- /dev/null +++ b/src/main/kotlin/com/github/pyvenvmanage/sdk/EnvironmentDetector.kt @@ -0,0 +1,248 @@ +package com.github.pyvenvmanage.sdk + +import java.nio.file.Path + +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.io.path.pathString +import kotlin.io.path.readText + +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.util.SystemInfo +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread + +import com.jetbrains.python.sdk.legacy.PythonSdkUtil + +enum class PythonEnvironmentType { + UV, + CONDA, + POETRY, + HATCH, + PIPENV, + VIRTUALENV, + SYSTEM, +} + +object EnvironmentDetector { + private val LOG = Logger.getInstance(EnvironmentDetector::class.java) + + private val poetryDirsCache = lazy { computePoetryDirs() } + private val hatchDirsCache = lazy { computeHatchDirs() } + private val pipenvDirsCache = lazy { computePipenvDirs() } + + @RequiresBackgroundThread(generateAssertion = false) + fun detectEnvironmentType(pythonExecutablePath: String): PythonEnvironmentType { + LOG.info("Detecting environment type for: $pythonExecutablePath") + val executablePath = Path.of(pythonExecutablePath) + val binDir = executablePath.parent + if (binDir == null) { + LOG.info("No bin directory, returning SYSTEM") + return PythonEnvironmentType.SYSTEM + } + val venvRoot = binDir.parent + if (venvRoot == null) { + LOG.info("No venv root, returning SYSTEM") + return PythonEnvironmentType.SYSTEM + } + + LOG.info("Python executable: $pythonExecutablePath") + LOG.info("Bin directory: $binDir") + LOG.info("Venv root: $venvRoot") + + val type = + when { + isUv(venvRoot).also { LOG.info("isUv: $it") } -> { + PythonEnvironmentType.UV + } + + isConda(venvRoot).also { LOG.info("isConda: $it") } -> { + PythonEnvironmentType.CONDA + } + + isPoetry(venvRoot).also { LOG.info("isPoetry: $it") } -> { + PythonEnvironmentType.POETRY + } + + isHatch(venvRoot).also { LOG.info("isHatch: $it") } -> { + PythonEnvironmentType.HATCH + } + + isPipenv(venvRoot).also { LOG.info("isPipenv: $it") } -> { + PythonEnvironmentType.PIPENV + } + + PythonSdkUtil.isVirtualEnv(pythonExecutablePath).also { LOG.info("isVirtualEnv: $it") } -> { + PythonEnvironmentType.VIRTUALENV + } + + else -> { + PythonEnvironmentType.SYSTEM + } + } + + LOG.info("Final detected type: $type") + return type + } + + private fun isUv(venvRoot: Path): Boolean { + val pyvenvCfg = venvRoot.resolve("pyvenv.cfg") + if (!pyvenvCfg.exists()) { + return false + } + return try { + val content = pyvenvCfg.readText() + content.contains("uv = ") + } catch (e: Exception) { + LOG.warn("Failed to read pyvenv.cfg", e) + false + } + } + + private fun isConda(venvRoot: Path): Boolean = + venvRoot.resolve("conda-meta").isDirectory() || venvRoot.parent?.resolve("conda-meta")?.isDirectory() == true + + private fun isPoetry(venvRoot: Path): Boolean { + val dirs = poetryDirsCache.value + LOG.info("Checking Poetry directories (cached): ${dirs.map { it.pathString }}") + return dirs.any { venvRoot.startsWith(it) } + } + + private fun isHatch(venvRoot: Path): Boolean { + val gitignore = venvRoot.resolve(".gitignore") + if (gitignore.exists()) { + try { + if (gitignore.readText().contains("# This file was automatically created by Hatch")) { + LOG.info("Found Hatch marker in .gitignore") + return true + } + } catch (e: Exception) { + LOG.warn("Failed to read .gitignore", e) + } + } + + val dirs = hatchDirsCache.value + LOG.info("Checking Hatch directories (cached): ${dirs.map { it.pathString }}") + return dirs.any { venvRoot.startsWith(it) } + } + + private fun isPipenv(venvRoot: Path): Boolean { + val dirs = pipenvDirsCache.value + LOG.info("Checking Pipenv directories (cached): ${dirs.map { it.pathString }}") + return dirs.any { venvRoot.startsWith(it) } + } + + private fun computePoetryDirs(): List { + val dirs = mutableListOf() + + System.getenv("POETRY_CACHE_DIR")?.let { dirs.add(Path.of(it, "virtualenvs")) } + + getPoetryConfigPath()?.let { configPath -> + if (configPath.exists()) { + try { + val config = configPath.readText() + Regex("""virtualenvs\.path\s*=\s*"([^"]+)"""").find(config)?.groupValues?.get(1)?.let { + dirs.add(Path.of(it).toAbsolutePath().normalize()) + } + } catch (e: Exception) { + LOG.warn("Failed to read Poetry config at $configPath", e) + } + } + } + + dirs.addAll(getPoetryDefaultPaths()) + return dirs.distinct() + } + + private fun computeHatchDirs(): List { + val dirs = mutableListOf() + + System.getenv("HATCH_DATA_DIR")?.let { dirs.add(Path.of(it, "env", "virtual")) } + + dirs.addAll(getHatchDefaultPaths()) + return dirs.distinct() + } + + private fun computePipenvDirs(): List { + val dirs = mutableListOf() + + System.getenv("WORKON_HOME")?.let { dirs.add(Path.of(it)) } + + dirs.addAll(getPipenvDefaultPaths()) + return dirs.distinct() + } + + private fun getPoetryConfigPath(): Path? { + System.getenv("POETRY_CONFIG_DIR")?.let { return Path.of(it, "config.toml") } + + return when { + SystemInfo.isWindows -> { + System.getenv("APPDATA")?.let { Path.of(it, "pypoetry", "config.toml") } + } + + else -> { + val xdgConfig = System.getenv("XDG_CONFIG_HOME") + val home = System.getenv("HOME") + when { + xdgConfig != null -> Path.of(xdgConfig, "pypoetry", "config.toml") + home != null -> Path.of(home, ".config", "pypoetry", "config.toml") + else -> null + } + } + } + } + + private fun getPoetryDefaultPaths(): List { + val home = System.getenv("HOME") ?: System.getenv("USERPROFILE") ?: return emptyList() + + return when { + SystemInfo.isWindows -> { + val localAppData = System.getenv("LOCALAPPDATA") ?: "$home\\AppData\\Local" + listOf(Path.of(localAppData, "pypoetry", "Cache", "virtualenvs")) + } + + SystemInfo.isMac -> { + listOf(Path.of(home, "Library", "Caches", "pypoetry", "virtualenvs")) + } + + else -> { + val xdgCache = System.getenv("XDG_CACHE_HOME") ?: "$home/.cache" + listOf(Path.of(xdgCache, "pypoetry", "virtualenvs")) + } + } + } + + private fun getHatchDefaultPaths(): List { + val home = System.getenv("HOME") ?: System.getenv("USERPROFILE") ?: return emptyList() + + return when { + SystemInfo.isWindows -> { + val localAppData = System.getenv("LOCALAPPDATA") ?: "$home\\AppData\\Local" + listOf(Path.of(localAppData, "hatch", "env", "virtual")) + } + + SystemInfo.isMac -> { + listOf(Path.of(home, "Library", "Application Support", "hatch", "env", "virtual")) + } + + else -> { + val xdgData = System.getenv("XDG_DATA_HOME") ?: "$home/.local/share" + listOf(Path.of(xdgData, "hatch", "env", "virtual")) + } + } + } + + private fun getPipenvDefaultPaths(): List { + val home = System.getenv("HOME") ?: System.getenv("USERPROFILE") ?: return emptyList() + + return when { + SystemInfo.isWindows -> { + listOf(Path.of(home, ".virtualenvs")) + } + + else -> { + val xdgData = System.getenv("XDG_DATA_HOME") ?: "$home/.local/share" + listOf(Path.of(xdgData, "virtualenvs")) + } + } + } +} diff --git a/src/main/kotlin/com/github/pyvenvmanage/sdk/SdkFactory.kt b/src/main/kotlin/com/github/pyvenvmanage/sdk/SdkFactory.kt new file mode 100644 index 0000000..16dcf1e --- /dev/null +++ b/src/main/kotlin/com/github/pyvenvmanage/sdk/SdkFactory.kt @@ -0,0 +1,137 @@ +package com.github.pyvenvmanage.sdk + +import java.nio.file.Path +import javax.swing.Icon + +import com.intellij.openapi.application.WriteAction +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.projectRoots.impl.ProjectJdkImpl +import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil +import com.intellij.python.community.impl.conda.icons.PythonCommunityImplCondaIcons +import com.intellij.python.community.impl.pipenv.PIPENV_ICON +import com.intellij.python.community.impl.poetry.common.icons.PythonCommunityImplPoetryCommonIcons +import com.intellij.python.community.impl.uv.common.icons.PythonCommunityImplUVCommonIcons +import com.intellij.python.hatch.icons.PythonHatchIcons +import com.intellij.python.venv.icons.PythonVenvIcons + +import com.jetbrains.python.hatch.sdk.HatchSdkAdditionalData +import com.jetbrains.python.sdk.PythonSdkAdditionalData +import com.jetbrains.python.sdk.PythonSdkType +import com.jetbrains.python.sdk.flavors.PyFlavorAndData +import com.jetbrains.python.sdk.flavors.PyFlavorData +import com.jetbrains.python.sdk.flavors.VirtualEnvSdkFlavor +import com.jetbrains.python.sdk.pipenv.PyPipEnvSdkFlavor +import com.jetbrains.python.sdk.poetry.PyPoetrySdkFlavor +import com.jetbrains.python.sdk.uv.UvSdkAdditionalData + +object SdkFactory { + fun createSdk( + pythonExecutable: String, + envType: PythonEnvironmentType, + projectBasePath: Path, + ): Sdk? { + val sdkType = PythonSdkType.getInstance() + val suggestedName = SdkConfigurationUtil.createUniqueSdkName(sdkType, pythonExecutable, emptyList()) + + val sdk = + WriteAction.computeAndWait { + val sdk = ProjectJdkImpl(suggestedName, sdkType) + + val modificator = sdk.sdkModificator + modificator.homePath = pythonExecutable + + val additionalData = createAdditionalData(envType, pythonExecutable, projectBasePath) + + val venvPath = Path.of(pythonExecutable).parent?.parent + if (venvPath != null && venvPath.startsWith(projectBasePath)) { + additionalData.setAssociatedModulePath(projectBasePath.toString()) + } + + modificator.sdkAdditionalData = additionalData + modificator.commitChanges() + + SdkConfigurationUtil.addSdk(sdk) + sdk + } + + sdk?.let { sdkType.setupSdkPaths(it) } + + return sdk + } + + private fun createAdditionalData( + envType: PythonEnvironmentType, + pythonExecutable: String, + projectBasePath: Path, + ): PythonSdkAdditionalData = + when (envType) { + PythonEnvironmentType.HATCH -> { + val hatchWorkingDir = findHatchWorkingDir(projectBasePath) + HatchSdkAdditionalData(hatchWorkingDir, null) + } + + PythonEnvironmentType.UV -> { + val uvWorkingDir = findUvWorkingDir(projectBasePath) + val venvPath = Path.of(pythonExecutable).parent?.parent + UvSdkAdditionalData(uvWorkingDir, null, venvPath, null) + } + + PythonEnvironmentType.POETRY -> { + PythonSdkAdditionalData( + PyFlavorAndData(PyFlavorData.Empty, PyPoetrySdkFlavor), + ) + } + + PythonEnvironmentType.PIPENV -> { + PythonSdkAdditionalData( + PyFlavorAndData(PyFlavorData.Empty, PyPipEnvSdkFlavor), + ) + } + + PythonEnvironmentType.CONDA, + PythonEnvironmentType.VIRTUALENV, + PythonEnvironmentType.SYSTEM, + -> { + PythonSdkAdditionalData( + PyFlavorAndData(PyFlavorData.Empty, VirtualEnvSdkFlavor.getInstance()), + ) + } + } + + private fun findHatchWorkingDir(projectBasePath: Path): Path? { + var current: Path? = projectBasePath + while (current != null) { + val pyprojectToml = current.resolve("pyproject.toml") + if (pyprojectToml.toFile().exists()) { + val content = pyprojectToml.toFile().readText() + if (content.contains("[tool.hatch")) { + return current + } + } + current = current.parent + } + return null + } + + private fun findUvWorkingDir(projectBasePath: Path): Path? { + var current: Path? = projectBasePath + while (current != null) { + if (current.resolve("uv.lock").toFile().exists()) { + return current + } + current = current.parent + } + return null + } + + fun getIconForEnvironmentType(envType: PythonEnvironmentType): Icon = + when (envType) { + PythonEnvironmentType.CONDA -> PythonCommunityImplCondaIcons.Anaconda + PythonEnvironmentType.POETRY -> PythonCommunityImplPoetryCommonIcons.Poetry + PythonEnvironmentType.HATCH -> PythonHatchIcons.Logo + PythonEnvironmentType.UV -> PythonCommunityImplUVCommonIcons.UV + PythonEnvironmentType.PIPENV -> PIPENV_ICON + PythonEnvironmentType.VIRTUALENV -> PythonVenvIcons.VirtualEnv + PythonEnvironmentType.SYSTEM -> PythonVenvIcons.VirtualEnv + } +} diff --git a/src/main/resources/META-INF/pyvenvmanage-python.xml b/src/main/resources/META-INF/pyvenvmanage-python.xml index 65295b6..be0cf8e 100644 --- a/src/main/resources/META-INF/pyvenvmanage-python.xml +++ b/src/main/resources/META-INF/pyvenvmanage-python.xml @@ -16,7 +16,6 @@ class="com.github.pyvenvmanage.actions.ConfigurePythonActionProject" text="Set as Project Interpreter" description="Configure this Python to be the projects interpreter." - icon="com.jetbrains.python.icons.PythonIcons.Python.Virtualenv" > @@ -25,7 +24,6 @@ class="com.github.pyvenvmanage.actions.ConfigurePythonActionModule" text="Set as Module Interpreter" description="Configure this Python to be the current modules interpreter." - icon="com.jetbrains.python.icons.PythonIcons.Python.Virtualenv" > diff --git a/src/test/kotlin/com/github/pyvenvmanage/UITest.kt b/src/test/kotlin/com/github/pyvenvmanage/UITest.kt index 5010223..0bd8a78 100644 --- a/src/test/kotlin/com/github/pyvenvmanage/UITest.kt +++ b/src/test/kotlin/com/github/pyvenvmanage/UITest.kt @@ -19,18 +19,15 @@ import org.junit.jupiter.api.extension.ExtensionContext import org.junit.jupiter.api.extension.TestWatcher import com.intellij.remoterobot.RemoteRobot -import com.intellij.remoterobot.search.locators.byXpath import com.intellij.remoterobot.stepsProcessing.StepLogger import com.intellij.remoterobot.stepsProcessing.StepWorker import com.intellij.remoterobot.utils.waitFor import com.github.pyvenvmanage.pages.IdeaFrame import com.github.pyvenvmanage.pages.actionMenuItem -import com.github.pyvenvmanage.pages.dialog import com.github.pyvenvmanage.pages.hasActionMenuItem import com.github.pyvenvmanage.pages.idea import com.github.pyvenvmanage.pages.pressEscape -import com.github.pyvenvmanage.pages.welcomeFrame @ExtendWith(UITest.IdeTestWatcher::class) @Timeout(value = 15, unit = TimeUnit.MINUTES) @@ -61,33 +58,28 @@ class UITest { it.toFile().deleteRecursively() } tmpDir = Files.createTempDirectory(base, "ui-test") - // create test project val demo = Paths.get(tmpDir.toString(), "demo") Files.createDirectory(demo) File(demo.toString(), "main.py").printWriter().use { out -> out.println("print(1)\n") } val venv = Paths.get(demo.toString(), "ve").toString() - val process = ProcessBuilder("python", "-m", "venv", venv, "--without-pip") + val pythonCmd = if (System.getProperty("os.name").lowercase().contains("win")) "python" else "python3" + val process = ProcessBuilder(pythonCmd, "-m", "venv", venv, "--without-pip") assert(process.start().waitFor() == 0) StepWorker.registerProcessor(StepLogger()) remoteRobot = RemoteRobot("http://127.0.0.1:8082") Thread.sleep(10000) - remoteRobot.welcomeFrame { - openButton.click() - dialog("Open File or Project") { - val pathField = textField(byXpath("//div[@class='BorderlessTextField']")) - pathField.click() - Thread.sleep(500) - pathField.runJs("component.setText('${demo.toString().replace("'", "\\'")}')") - Thread.sleep(500) - button("OK").click() - } - } - Thread.sleep(5000) + + val ideFrame = remoteRobot.find(timeout = ofMinutes(2)) + ideFrame.openProjectViaAction(demo.toString()) + Thread.sleep(10000) remoteRobot.find(timeout = ofMinutes(2)).apply { waitFor(ofMinutes(2)) { isDumbMode().not() } + Thread.sleep(5000) + activateProjectView() + Thread.sleep(3000) } } @@ -125,7 +117,6 @@ class UITest { fun testVenvDirectoryShowsPythonVersion() { remoteRobot.idea { with(projectViewTree) { - // The venv directory should display the Python version in brackets waitFor(ofSeconds(10)) { hasText { it.text.contains("[") && it.text.contains("]") } } @@ -137,7 +128,6 @@ class UITest { fun testVenvDirectoryHasVenvIcon() { remoteRobot.idea { with(projectViewTree) { - // Verify the venv directory is decorated (has venv text visible) waitFor(ofSeconds(10)) { hasText("ve") } diff --git a/src/test/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecoratorTest.kt b/src/test/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecoratorTest.kt index 8aca198..a7d8f28 100644 --- a/src/test/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecoratorTest.kt +++ b/src/test/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecoratorTest.kt @@ -2,11 +2,14 @@ package com.github.pyvenvmanage import java.nio.file.Files import java.nio.file.Path +import javax.swing.Icon import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject +import io.mockk.mockkStatic import io.mockk.unmockkObject +import io.mockk.unmockkStatic import io.mockk.verify import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -19,6 +22,11 @@ import com.intellij.ide.projectView.ProjectViewNode import com.intellij.openapi.vfs.VirtualFile import com.intellij.ui.SimpleTextAttributes +import com.jetbrains.python.sdk.PythonSdkUtil + +import com.github.pyvenvmanage.sdk.EnvironmentDetector +import com.github.pyvenvmanage.sdk.PythonEnvironmentType +import com.github.pyvenvmanage.sdk.SdkFactory import com.github.pyvenvmanage.settings.PyVenvManageSettings class VenvProjectViewNodeDecoratorTest { @@ -42,6 +50,8 @@ class VenvProjectViewNodeDecoratorTest { private lateinit var versionCache: VenvVersionCache private lateinit var settings: PyVenvManageSettings + private lateinit var mockIcon: Icon + @BeforeEach fun setUpMocks() { mockkObject(VenvUtils) @@ -60,6 +70,13 @@ class VenvProjectViewNodeDecoratorTest { info.creator?.removePrefix(" - ")?.let { parts.add(it) } " [${parts.joinToString(" - ")}]" } + mockkStatic(PythonSdkUtil::class) + mockkObject(EnvironmentDetector) + mockkObject(SdkFactory) + mockIcon = mockk() + every { PythonSdkUtil.getPythonExecutable(any()) } returns "/mock/bin/python" + every { EnvironmentDetector.detectEnvironmentType(any()) } returns PythonEnvironmentType.VIRTUALENV + every { SdkFactory.getIconForEnvironmentType(any()) } returns mockIcon } @AfterEach @@ -67,6 +84,9 @@ class VenvProjectViewNodeDecoratorTest { unmockkObject(VenvUtils) unmockkObject(VenvVersionCache.Companion) unmockkObject(PyVenvManageSettings.Companion) + unmockkStatic(PythonSdkUtil::class) + unmockkObject(EnvironmentDetector) + unmockkObject(SdkFactory) } @Test diff --git a/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionAbstractTest.kt b/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionAbstractTest.kt index c70f8c7..93a83b7 100644 --- a/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionAbstractTest.kt +++ b/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionAbstractTest.kt @@ -1,12 +1,15 @@ package com.github.pyvenvmanage.actions +import java.nio.file.Path + import io.mockk.Runs import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.slot -import io.mockk.unmockkStatic +import io.mockk.unmockkAll import io.mockk.verify import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -22,18 +25,20 @@ import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.actionSystem.Presentation -import com.intellij.openapi.application.Application -import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.ProjectJdkTable import com.intellij.openapi.projectRoots.Sdk -import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil import com.intellij.openapi.vfs.VirtualFile -import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.python.community.impl.uv.common.icons.PythonCommunityImplUVCommonIcons +import com.intellij.python.venv.icons.PythonVenvIcons import com.jetbrains.python.configuration.PyConfigurableInterpreterList -import com.jetbrains.python.sdk.PythonSdkType import com.jetbrains.python.sdk.PythonSdkUtil +import com.github.pyvenvmanage.sdk.EnvironmentDetector +import com.github.pyvenvmanage.sdk.PythonEnvironmentType +import com.github.pyvenvmanage.sdk.SdkFactory + class ConfigurePythonActionAbstractTest { private lateinit var action: TestableConfigurePythonAction private lateinit var event: AnActionEvent @@ -63,11 +68,13 @@ class ConfigurePythonActionAbstractTest { @BeforeEach fun setUpMocks() { mockkStatic(PythonSdkUtil::class) + mockkObject(EnvironmentDetector) + mockkObject(SdkFactory) } @AfterEach fun tearDown() { - unmockkStatic(PythonSdkUtil::class) + unmockkAll() } @Test @@ -85,10 +92,14 @@ class ConfigurePythonActionAbstractTest { every { virtualFile.isDirectory } returns true every { virtualFile.path } returns "/some/venv" every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns "/some/venv/bin/python" + every { EnvironmentDetector.detectEnvironmentType("/some/venv/bin/python") } returns + PythonEnvironmentType.UV + every { SdkFactory.getIconForEnvironmentType(PythonEnvironmentType.UV) } returns PythonVenvIcons.VirtualEnv action.update(event) verify { presentation.isEnabledAndVisible = true } + verify { presentation.icon = any() } } @Test @@ -104,27 +115,19 @@ class ConfigurePythonActionAbstractTest { } @Test - fun `enables action for file in virtual env`() { - every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile - every { virtualFile.isDirectory } returns false - every { virtualFile.path } returns "/some/venv/bin/python" - every { PythonSdkUtil.isVirtualEnv("/some/venv/bin/python") } returns true - - action.update(event) - - verify { presentation.isEnabledAndVisible = true } - } - - @Test - fun `disables action for file not in virtual env`() { + fun `sets icon based on detected environment type`() { every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile - every { virtualFile.isDirectory } returns false - every { virtualFile.path } returns "/usr/bin/python" - every { PythonSdkUtil.isVirtualEnv("/usr/bin/python") } returns false + every { virtualFile.isDirectory } returns true + every { virtualFile.path } returns "/some/venv" + every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns "/some/venv/bin/python" + every { EnvironmentDetector.detectEnvironmentType("/some/venv/bin/python") } returns + PythonEnvironmentType.UV + every { SdkFactory.getIconForEnvironmentType(PythonEnvironmentType.UV) } returns + PythonCommunityImplUVCommonIcons.UV action.update(event) - verify { presentation.isEnabledAndVisible = false } + verify { presentation.icon = PythonCommunityImplUVCommonIcons.UV } } } @@ -135,6 +138,7 @@ class ConfigurePythonActionAbstractTest { private lateinit var notificationGroupManager: NotificationGroupManager private lateinit var notificationGroup: NotificationGroup private lateinit var notification: Notification + private lateinit var jdkTable: ProjectJdkTable @BeforeEach fun setUpMocks() { @@ -143,28 +147,29 @@ class ConfigurePythonActionAbstractTest { notificationGroupManager = mockk(relaxed = true) notificationGroup = mockk(relaxed = true) notification = mockk(relaxed = true) + jdkTable = mockk(relaxed = true) mockkStatic(PythonSdkUtil::class) mockkStatic(PyConfigurableInterpreterList::class) - mockkStatic(SdkConfigurationUtil::class) mockkStatic(NotificationGroupManager::class) - mockkStatic(PythonSdkType::class) + mockkStatic(ProjectJdkTable::class) + mockkObject(EnvironmentDetector) + mockkObject(SdkFactory) every { NotificationGroupManager.getInstance() } returns notificationGroupManager every { notificationGroupManager.getNotificationGroup(any()) } returns notificationGroup every { notificationGroup.createNotification(any(), any(), any()) } returns notification + every { notification.setIcon(any()) } returns notification every { notification.notify(any()) } just Runs + every { ProjectJdkTable.getInstance() } returns jdkTable + every { jdkTable.allJdks } returns emptyArray() } @AfterEach fun tearDown() { - unmockkStatic(PythonSdkUtil::class) - unmockkStatic(PyConfigurableInterpreterList::class) - unmockkStatic(SdkConfigurationUtil::class) - unmockkStatic(NotificationGroupManager::class) - unmockkStatic(PythonSdkType::class) + unmockkAll() } @Test @@ -219,16 +224,16 @@ class ConfigurePythonActionAbstractTest { @Test fun `shows error when SDK creation fails`() { - val pythonSdkType: PythonSdkType = mockk(relaxed = true) - every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile every { virtualFile.isDirectory } returns true every { virtualFile.path } returns "/some/venv" + every { virtualFile.toNioPath() } returns Path.of("/some/venv") every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns "/some/venv/bin/python" - every { PyConfigurableInterpreterList.getInstance(project) } returns interpreterList - every { interpreterList.model.projectSdks.values } returns mutableListOf() - every { PythonSdkType.getInstance() } returns pythonSdkType - every { SdkConfigurationUtil.createAndAddSDK("/some/venv/bin/python", pythonSdkType) } returns null + every { EnvironmentDetector.detectEnvironmentType("/some/venv/bin/python") } returns + PythonEnvironmentType.VIRTUALENV + every { + SdkFactory.createSdk("/some/venv/bin/python", PythonEnvironmentType.VIRTUALENV, Path.of("/some/venv")) + } returns null action.actionPerformed(event) @@ -244,32 +249,33 @@ class ConfigurePythonActionAbstractTest { @Test fun `creates new SDK when not found in existing SDKs`() { val newSdk: Sdk = mockk(relaxed = true) - val pythonSdkType: PythonSdkType = mockk(relaxed = true) - val application: Application = mockk(relaxed = true) - val virtualFileManager: VirtualFileManager = mockk(relaxed = true) val messageSlot = slot() action.lastSetSdkResult = ConfigurePythonActionAbstract.SetSdkResult.Success("module") - mockkStatic(ApplicationManager::class) - every { ApplicationManager.getApplication() } returns application - every { application.getService(any>()) } returns mockk(relaxed = true) - every { application.getService(VirtualFileManager::class.java) } returns virtualFileManager - every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile every { virtualFile.isDirectory } returns true every { virtualFile.path } returns "/some/venv" + every { virtualFile.toNioPath() } returns Path.of("/some/venv") every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns "/some/venv/bin/python" - every { PyConfigurableInterpreterList.getInstance(project) } returns interpreterList - every { interpreterList.model.projectSdks.values } returns mutableListOf() - every { PythonSdkType.getInstance() } returns pythonSdkType - every { SdkConfigurationUtil.createAndAddSDK("/some/venv/bin/python", pythonSdkType) } returns newSdk + every { EnvironmentDetector.detectEnvironmentType("/some/venv/bin/python") } returns + PythonEnvironmentType.UV + every { + SdkFactory.createSdk( + "/some/venv/bin/python", + PythonEnvironmentType.UV, + Path.of("/some/venv"), + ) + } returns + newSdk + every { SdkFactory.getIconForEnvironmentType(PythonEnvironmentType.UV) } returns + PythonCommunityImplUVCommonIcons.UV every { newSdk.name } returns "Python 3.11 (venv)" action.actionPerformed(event) verify { - SdkConfigurationUtil.createAndAddSDK("/some/venv/bin/python", pythonSdkType) + SdkFactory.createSdk("/some/venv/bin/python", PythonEnvironmentType.UV, Path.of("/some/venv")) } verify { notificationGroup.createNotification( @@ -278,9 +284,8 @@ class ConfigurePythonActionAbstractTest { eq(NotificationType.INFORMATION), ) } - assert(messageSlot.captured.startsWith("Updated SDK for module to:")) - - unmockkStatic(ApplicationManager::class) + assert(messageSlot.captured.contains("Updated SDK for module to:")) + assert(messageSlot.captured.contains("(uv)")) } @Test @@ -293,8 +298,9 @@ class ConfigurePythonActionAbstractTest { every { virtualFile.isDirectory } returns true every { virtualFile.path } returns "/some/venv" every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns "/some/venv/bin/python" - every { PyConfigurableInterpreterList.getInstance(project) } returns interpreterList - every { interpreterList.model.projectSdks.values } returns mutableListOf(existingSdk) + every { EnvironmentDetector.detectEnvironmentType("/some/venv/bin/python") } returns + PythonEnvironmentType.VIRTUALENV + every { jdkTable.allJdks } returns arrayOf(existingSdk) every { existingSdk.homePath } returns "/some/venv/bin/python" action.actionPerformed(event) @@ -311,23 +317,19 @@ class ConfigurePythonActionAbstractTest { @Test fun `shows success notification on setSdk success`() { val existingSdk: Sdk = mockk(relaxed = true) - val application: Application = mockk(relaxed = true) - val virtualFileManager: VirtualFileManager = mockk(relaxed = true) val messageSlot = slot() action.lastSetSdkResult = ConfigurePythonActionAbstract.SetSdkResult.Success("module") - mockkStatic(ApplicationManager::class) - every { ApplicationManager.getApplication() } returns application - every { application.getService(any>()) } returns mockk(relaxed = true) - every { application.getService(VirtualFileManager::class.java) } returns virtualFileManager - every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile every { virtualFile.isDirectory } returns true every { virtualFile.path } returns "/some/venv" every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns "/some/venv/bin/python" - every { PyConfigurableInterpreterList.getInstance(project) } returns interpreterList - every { interpreterList.model.projectSdks.values } returns mutableListOf(existingSdk) + every { EnvironmentDetector.detectEnvironmentType("/some/venv/bin/python") } returns + PythonEnvironmentType.VIRTUALENV + every { SdkFactory.getIconForEnvironmentType(PythonEnvironmentType.VIRTUALENV) } returns + PythonVenvIcons.VirtualEnv + every { jdkTable.allJdks } returns arrayOf(existingSdk) every { existingSdk.homePath } returns "/some/venv/bin/python" every { existingSdk.name } returns "Python 3.11 (venv)" @@ -340,10 +342,9 @@ class ConfigurePythonActionAbstractTest { eq(NotificationType.INFORMATION), ) } - assert(messageSlot.captured.startsWith("Updated SDK for module to:")) + assert(messageSlot.captured.contains("Updated SDK for module to:")) assert(messageSlot.captured.contains("Python 3.11 (venv)")) - - unmockkStatic(ApplicationManager::class) + assert(messageSlot.captured.contains("(virtualenv)")) } } diff --git a/src/test/kotlin/com/github/pyvenvmanage/pages/IdeaFrame.kt b/src/test/kotlin/com/github/pyvenvmanage/pages/IdeaFrame.kt index 1e6174d..ff1d037 100644 --- a/src/test/kotlin/com/github/pyvenvmanage/pages/IdeaFrame.kt +++ b/src/test/kotlin/com/github/pyvenvmanage/pages/IdeaFrame.kt @@ -24,7 +24,7 @@ class IdeaFrame( get() = find( byXpath("ProjectViewTree", "//div[@class='MyProjectViewTree']"), - Duration.ofSeconds(30), + Duration.ofSeconds(60), ) fun isDumbMode(): Boolean = @@ -40,4 +40,34 @@ class IdeaFrame( """, true, ) + + fun activateProjectView() { + runJs( + """ + const frameHelper = com.intellij.openapi.wm.impl.ProjectFrameHelper.getFrameHelper(component) + if (frameHelper) { + const project = frameHelper.getProject() + if (project) { + com.intellij.openapi.wm.ToolWindowManager.getInstance(project).getToolWindow("Project").activate(null) + } + } + """, + true, + ) + } + + fun openProjectViaAction(projectPath: String) { + val escapedPath = projectPath.replace("\\", "\\\\").replace("'", "\\'") + runJs( + """ + const path = java.nio.file.Path.of('$escapedPath') + const frameHelper = com.intellij.openapi.wm.impl.ProjectFrameHelper.getFrameHelper(component) + const currentProject = frameHelper ? frameHelper.getProject() : null + com.intellij.openapi.application.ApplicationManager.getApplication().invokeLater(function() { + com.intellij.ide.impl.ProjectUtil.openOrImport(path, currentProject, false) + }) + """, + true, + ) + } } diff --git a/src/test/kotlin/com/github/pyvenvmanage/pages/WelcomeFrame.kt b/src/test/kotlin/com/github/pyvenvmanage/pages/WelcomeFrame.kt deleted file mode 100644 index cb36824..0000000 --- a/src/test/kotlin/com/github/pyvenvmanage/pages/WelcomeFrame.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.github.pyvenvmanage.pages - -import java.time.Duration - -import com.intellij.remoterobot.RemoteRobot -import com.intellij.remoterobot.data.RemoteComponent -import com.intellij.remoterobot.fixtures.CommonContainerFixture -import com.intellij.remoterobot.fixtures.ComponentFixture -import com.intellij.remoterobot.fixtures.DefaultXpath -import com.intellij.remoterobot.fixtures.FixtureName -import com.intellij.remoterobot.search.locators.byXpath - -fun RemoteRobot.welcomeFrame(function: WelcomeFrame.() -> Unit) { - find(WelcomeFrame::class.java, Duration.ofSeconds(10)).apply(function) -} - -@FixtureName("Welcome Frame") -@DefaultXpath("type", "//div[@class='FlatWelcomeFrame']") -class WelcomeFrame( - remoteRobot: RemoteRobot, - remoteComponent: RemoteComponent, -) : CommonContainerFixture(remoteRobot, remoteComponent) { - val openButton: ComponentFixture - get() = - find( - byXpath("//div[@class='LargeIconWithTextPanel']//div[@class='JButton' and @accessiblename='Open']"), - ) -} diff --git a/src/test/kotlin/com/github/pyvenvmanage/sdk/EnvironmentDetectorTest.kt b/src/test/kotlin/com/github/pyvenvmanage/sdk/EnvironmentDetectorTest.kt new file mode 100644 index 0000000..5792bab --- /dev/null +++ b/src/test/kotlin/com/github/pyvenvmanage/sdk/EnvironmentDetectorTest.kt @@ -0,0 +1,197 @@ +package com.github.pyvenvmanage.sdk + +import java.nio.file.Files +import java.nio.file.Path + +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlin.io.path.createDirectories +import kotlin.io.path.writeText +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +import com.jetbrains.python.sdk.legacy.PythonSdkUtil + +class EnvironmentDetectorTest { + @TempDir + lateinit var tempDir: Path + + private lateinit var venvRoot: Path + private lateinit var binDir: Path + private lateinit var pythonExe: Path + + @BeforeEach + fun setUp() { + venvRoot = tempDir.resolve("venv") + binDir = venvRoot.resolve("bin") + pythonExe = binDir.resolve("python") + + binDir.createDirectories() + Files.createFile(pythonExe) + + mockkStatic(PythonSdkUtil::class) + } + + @AfterEach + fun tearDown() { + unmockkStatic(PythonSdkUtil::class) + } + + @Test + fun `detects UV environment from pyvenv cfg`() { + val pyvenvCfg = venvRoot.resolve("pyvenv.cfg") + pyvenvCfg.writeText( + """ + home = /usr/bin + uv = 0.1.0 + """.trimIndent(), + ) + + val result = EnvironmentDetector.detectEnvironmentType(pythonExe.toString()) + + assertEquals(PythonEnvironmentType.UV, result) + } + + @Test + fun `detects conda environment from conda-meta directory`() { + venvRoot.resolve("conda-meta").createDirectories() + + val result = EnvironmentDetector.detectEnvironmentType(pythonExe.toString()) + + assertEquals(PythonEnvironmentType.CONDA, result) + } + + @Test + fun `detects conda from parent conda-meta directory`() { + venvRoot.parent.resolve("conda-meta").createDirectories() + + val result = EnvironmentDetector.detectEnvironmentType(pythonExe.toString()) + + assertEquals(PythonEnvironmentType.CONDA, result) + } + + @Test + fun `detects Hatch from gitignore marker`() { + venvRoot.resolve(".gitignore").writeText("# This file was automatically created by Hatch\n*\n") + + val result = EnvironmentDetector.detectEnvironmentType(pythonExe.toString()) + + assertEquals(PythonEnvironmentType.HATCH, result) + } + + @Test + fun `detects virtualenv as fallback`() { + venvRoot.resolve("pyvenv.cfg").writeText("home = /usr/bin") + every { PythonSdkUtil.isVirtualEnv(pythonExe.toString()) } returns true + + val result = EnvironmentDetector.detectEnvironmentType(pythonExe.toString()) + + assertEquals(PythonEnvironmentType.VIRTUALENV, result) + } + + @Test + fun `returns SYSTEM when no parent directory`() { + val result = EnvironmentDetector.detectEnvironmentType("/python") + + assertEquals(PythonEnvironmentType.SYSTEM, result) + } + + @Test + fun `returns SYSTEM when not a venv`() { + every { PythonSdkUtil.isVirtualEnv(pythonExe.toString()) } returns false + + val result = EnvironmentDetector.detectEnvironmentType(pythonExe.toString()) + + assertEquals(PythonEnvironmentType.SYSTEM, result) + } + + @Test + fun `UV takes precedence over virtualenv`() { + val pyvenvCfg = venvRoot.resolve("pyvenv.cfg") + pyvenvCfg.writeText("home = /usr/bin\nuv = 0.1.0") + every { PythonSdkUtil.isVirtualEnv(pythonExe.toString()) } returns true + + val result = EnvironmentDetector.detectEnvironmentType(pythonExe.toString()) + + assertEquals(PythonEnvironmentType.UV, result) + } + + @Test + fun `conda takes precedence over virtualenv`() { + venvRoot.resolve("conda-meta").createDirectories() + venvRoot.resolve("pyvenv.cfg").writeText("home = /usr/bin") + every { PythonSdkUtil.isVirtualEnv(pythonExe.toString()) } returns true + + val result = EnvironmentDetector.detectEnvironmentType(pythonExe.toString()) + + assertEquals(PythonEnvironmentType.CONDA, result) + } + + @Test + fun `pyvenv cfg without uv marker is not UV`() { + venvRoot.resolve("pyvenv.cfg").writeText("home = /usr/bin\nversion = 3.14") + every { PythonSdkUtil.isVirtualEnv(pythonExe.toString()) } returns true + + val result = EnvironmentDetector.detectEnvironmentType(pythonExe.toString()) + + assertEquals(PythonEnvironmentType.VIRTUALENV, result) + } + + @Test + fun `missing pyvenv cfg is not UV`() { + every { PythonSdkUtil.isVirtualEnv(pythonExe.toString()) } returns true + + val result = EnvironmentDetector.detectEnvironmentType(pythonExe.toString()) + + assertEquals(PythonEnvironmentType.VIRTUALENV, result) + } + + @Test + fun `gitignore without Hatch marker is not Hatch`() { + venvRoot.resolve(".gitignore").writeText("*.pyc\n__pycache__/\n") + every { PythonSdkUtil.isVirtualEnv(pythonExe.toString()) } returns true + + val result = EnvironmentDetector.detectEnvironmentType(pythonExe.toString()) + + assertEquals(PythonEnvironmentType.VIRTUALENV, result) + } + + @Test + fun `detects Pipenv from workon home`() { + val workonDir = tempDir.resolve("workon-envs") + val pipenvVenv = workonDir.resolve("myproject-abc123") + val pipenvBin = pipenvVenv.resolve("bin") + pipenvBin.createDirectories() + val pipenvPython = pipenvBin.resolve("python") + Files.createFile(pipenvPython) + + every { PythonSdkUtil.isVirtualEnv(pipenvPython.toString()) } returns true + + mockkStatic(System::class) + every { System.getenv("WORKON_HOME") } returns workonDir.toString() + every { System.getenv("HOME") } returns tempDir.toString() + every { System.getenv("USERPROFILE") } returns null + every { System.getenv("POETRY_CACHE_DIR") } returns null + every { System.getenv("POETRY_CONFIG_DIR") } returns null + every { System.getenv("HATCH_DATA_DIR") } returns null + every { System.getenv("XDG_CACHE_HOME") } returns null + every { System.getenv("XDG_CONFIG_HOME") } returns null + every { System.getenv("XDG_DATA_HOME") } returns null + every { System.getenv("LOCALAPPDATA") } returns null + every { System.getenv("APPDATA") } returns null + + try { + val detector = EnvironmentDetector + val method = detector::class.java.getDeclaredMethod("computePipenvDirs") + method.isAccessible = true + val dirs = method.invoke(detector) as List<*> + assert(dirs.any { pipenvVenv.startsWith(it as Path) }) + } finally { + unmockkStatic(System::class) + } + } +} diff --git a/src/test/kotlin/com/github/pyvenvmanage/sdk/SdkFactoryTest.kt b/src/test/kotlin/com/github/pyvenvmanage/sdk/SdkFactoryTest.kt new file mode 100644 index 0000000..df6be43 --- /dev/null +++ b/src/test/kotlin/com/github/pyvenvmanage/sdk/SdkFactoryTest.kt @@ -0,0 +1,321 @@ +package com.github.pyvenvmanage.sdk + +import java.nio.file.Files +import java.nio.file.Path + +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlin.io.path.createDirectories +import kotlin.io.path.writeText +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.Application +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.vfs.pointers.VirtualFilePointerManager +import com.intellij.python.community.impl.conda.icons.PythonCommunityImplCondaIcons +import com.intellij.python.community.impl.pipenv.PIPENV_ICON +import com.intellij.python.community.impl.poetry.common.icons.PythonCommunityImplPoetryCommonIcons +import com.intellij.python.community.impl.uv.common.icons.PythonCommunityImplUVCommonIcons +import com.intellij.python.hatch.icons.PythonHatchIcons +import com.intellij.python.venv.icons.PythonVenvIcons + +import com.jetbrains.python.PythonPluginDisposable +import com.jetbrains.python.sdk.flavors.VirtualEnvSdkFlavor + +class SdkFactoryTest { + @BeforeEach + fun setUp() { + mockkStatic(ApplicationManager::class) + val app = mockk(relaxed = true) + every { ApplicationManager.getApplication() } returns app + every { app.getService(VirtualFilePointerManager::class.java) } returns mockk(relaxed = true) + mockkStatic(PythonPluginDisposable::class) + every { PythonPluginDisposable.getInstance() } returns mockk(relaxed = true) + mockkStatic(VirtualEnvSdkFlavor::class) + every { VirtualEnvSdkFlavor.getInstance() } returns mockk(relaxed = true) + } + + @AfterEach + fun tearDown() { + unmockkStatic(ApplicationManager::class) + unmockkStatic(PythonPluginDisposable::class) + unmockkStatic(VirtualEnvSdkFlavor::class) + } + + @Test + fun `getIconForEnvironmentType returns UV icon`() { + assertEquals( + PythonCommunityImplUVCommonIcons.UV, + SdkFactory.getIconForEnvironmentType(PythonEnvironmentType.UV), + ) + } + + @Test + fun `getIconForEnvironmentType returns Conda icon`() { + assertEquals( + PythonCommunityImplCondaIcons.Anaconda, + SdkFactory.getIconForEnvironmentType(PythonEnvironmentType.CONDA), + ) + } + + @Test + fun `getIconForEnvironmentType returns Poetry icon`() { + assertEquals( + PythonCommunityImplPoetryCommonIcons.Poetry, + SdkFactory.getIconForEnvironmentType(PythonEnvironmentType.POETRY), + ) + } + + @Test + fun `getIconForEnvironmentType returns Hatch icon`() { + assertEquals(PythonHatchIcons.Logo, SdkFactory.getIconForEnvironmentType(PythonEnvironmentType.HATCH)) + } + + @Test + fun `getIconForEnvironmentType returns Pipenv icon`() { + assertEquals(PIPENV_ICON, SdkFactory.getIconForEnvironmentType(PythonEnvironmentType.PIPENV)) + } + + @Test + fun `getIconForEnvironmentType returns VirtualEnv icon for VIRTUALENV`() { + assertEquals( + PythonVenvIcons.VirtualEnv, + SdkFactory.getIconForEnvironmentType(PythonEnvironmentType.VIRTUALENV), + ) + } + + @Test + fun `getIconForEnvironmentType returns VirtualEnv icon for SYSTEM`() { + assertEquals(PythonVenvIcons.VirtualEnv, SdkFactory.getIconForEnvironmentType(PythonEnvironmentType.SYSTEM)) + } + + @Test + fun `findHatchWorkingDir finds pyproject with tool hatch`( + @TempDir tempDir: Path, + ) { + val projectDir = tempDir.resolve("project") + projectDir.createDirectories() + projectDir.resolve("pyproject.toml").writeText("[tool.hatch]\nbuild.targets.wheel.packages = [\"src\"]\n") + + val method = SdkFactory::class.java.getDeclaredMethod("findHatchWorkingDir", Path::class.java) + method.isAccessible = true + val result = method.invoke(SdkFactory, projectDir) as Path? + + assertEquals(projectDir, result) + } + + @Test + fun `findHatchWorkingDir returns null when no pyproject`( + @TempDir tempDir: Path, + ) { + val method = SdkFactory::class.java.getDeclaredMethod("findHatchWorkingDir", Path::class.java) + method.isAccessible = true + val result = method.invoke(SdkFactory, tempDir) as Path? + + assertNull(result) + } + + @Test + fun `findHatchWorkingDir returns null when pyproject has no hatch config`( + @TempDir tempDir: Path, + ) { + tempDir.resolve("pyproject.toml").writeText("[project]\nname = \"test\"\n") + + val method = SdkFactory::class.java.getDeclaredMethod("findHatchWorkingDir", Path::class.java) + method.isAccessible = true + val result = method.invoke(SdkFactory, tempDir) as Path? + + assertNull(result) + } + + @Test + fun `findHatchWorkingDir walks up to parent`( + @TempDir tempDir: Path, + ) { + tempDir.resolve("pyproject.toml").writeText("[tool.hatch]\n") + val subDir = tempDir.resolve("sub/dir") + subDir.createDirectories() + + val method = SdkFactory::class.java.getDeclaredMethod("findHatchWorkingDir", Path::class.java) + method.isAccessible = true + val result = method.invoke(SdkFactory, subDir) as Path? + + assertEquals(tempDir, result) + } + + @Test + fun `findUvWorkingDir finds uv lock`( + @TempDir tempDir: Path, + ) { + val projectDir = tempDir.resolve("project") + projectDir.createDirectories() + Files.createFile(projectDir.resolve("uv.lock")) + + val method = SdkFactory::class.java.getDeclaredMethod("findUvWorkingDir", Path::class.java) + method.isAccessible = true + val result = method.invoke(SdkFactory, projectDir) as Path? + + assertEquals(projectDir, result) + } + + @Test + fun `findUvWorkingDir returns null when no uv lock`( + @TempDir tempDir: Path, + ) { + val method = SdkFactory::class.java.getDeclaredMethod("findUvWorkingDir", Path::class.java) + method.isAccessible = true + val result = method.invoke(SdkFactory, tempDir) as Path? + + assertNull(result) + } + + @Test + fun `findUvWorkingDir walks up to find uv lock in parent`( + @TempDir tempDir: Path, + ) { + Files.createFile(tempDir.resolve("uv.lock")) + val subDir = tempDir.resolve("sub/dir") + subDir.createDirectories() + + val method = SdkFactory::class.java.getDeclaredMethod("findUvWorkingDir", Path::class.java) + method.isAccessible = true + val result = method.invoke(SdkFactory, subDir) as Path? + + assertEquals(tempDir, result) + } + + @Test + fun `createAdditionalData returns UvSdkAdditionalData for UV`( + @TempDir tempDir: Path, + ) { + val method = + SdkFactory::class.java.getDeclaredMethod( + "createAdditionalData", + PythonEnvironmentType::class.java, + String::class.java, + Path::class.java, + ) + method.isAccessible = true + val result = method.invoke(SdkFactory, PythonEnvironmentType.UV, "/venv/bin/python", tempDir) + + assertNotNull(result) + assertEquals("com.jetbrains.python.sdk.uv.UvSdkAdditionalData", result!!::class.java.name) + } + + @Test + fun `createAdditionalData returns HatchSdkAdditionalData for HATCH`( + @TempDir tempDir: Path, + ) { + val method = + SdkFactory::class.java.getDeclaredMethod( + "createAdditionalData", + PythonEnvironmentType::class.java, + String::class.java, + Path::class.java, + ) + method.isAccessible = true + val result = method.invoke(SdkFactory, PythonEnvironmentType.HATCH, "/venv/bin/python", tempDir) + + assertNotNull(result) + assertEquals("com.jetbrains.python.hatch.sdk.HatchSdkAdditionalData", result!!::class.java.name) + } + + @Test + fun `createAdditionalData returns PythonSdkAdditionalData for VIRTUALENV`( + @TempDir tempDir: Path, + ) { + val method = + SdkFactory::class.java.getDeclaredMethod( + "createAdditionalData", + PythonEnvironmentType::class.java, + String::class.java, + Path::class.java, + ) + method.isAccessible = true + val result = method.invoke(SdkFactory, PythonEnvironmentType.VIRTUALENV, "/venv/bin/python", tempDir) + + assertNotNull(result) + assertEquals("com.jetbrains.python.sdk.PythonSdkAdditionalData", result!!::class.java.name) + } + + @Test + fun `createAdditionalData returns PythonSdkAdditionalData for POETRY`( + @TempDir tempDir: Path, + ) { + val method = + SdkFactory::class.java.getDeclaredMethod( + "createAdditionalData", + PythonEnvironmentType::class.java, + String::class.java, + Path::class.java, + ) + method.isAccessible = true + val result = method.invoke(SdkFactory, PythonEnvironmentType.POETRY, "/venv/bin/python", tempDir) + + assertNotNull(result) + assertEquals("com.jetbrains.python.sdk.PythonSdkAdditionalData", result!!::class.java.name) + } + + @Test + fun `createAdditionalData returns PythonSdkAdditionalData for PIPENV`( + @TempDir tempDir: Path, + ) { + val method = + SdkFactory::class.java.getDeclaredMethod( + "createAdditionalData", + PythonEnvironmentType::class.java, + String::class.java, + Path::class.java, + ) + method.isAccessible = true + val result = method.invoke(SdkFactory, PythonEnvironmentType.PIPENV, "/venv/bin/python", tempDir) + + assertNotNull(result) + assertEquals("com.jetbrains.python.sdk.PythonSdkAdditionalData", result!!::class.java.name) + } + + @Test + fun `createAdditionalData returns PythonSdkAdditionalData for CONDA`( + @TempDir tempDir: Path, + ) { + val method = + SdkFactory::class.java.getDeclaredMethod( + "createAdditionalData", + PythonEnvironmentType::class.java, + String::class.java, + Path::class.java, + ) + method.isAccessible = true + val result = method.invoke(SdkFactory, PythonEnvironmentType.CONDA, "/venv/bin/python", tempDir) + + assertNotNull(result) + assertEquals("com.jetbrains.python.sdk.PythonSdkAdditionalData", result!!::class.java.name) + } + + @Test + fun `createAdditionalData returns PythonSdkAdditionalData for SYSTEM`( + @TempDir tempDir: Path, + ) { + val method = + SdkFactory::class.java.getDeclaredMethod( + "createAdditionalData", + PythonEnvironmentType::class.java, + String::class.java, + Path::class.java, + ) + method.isAccessible = true + val result = method.invoke(SdkFactory, PythonEnvironmentType.SYSTEM, "/venv/bin/python", tempDir) + + assertNotNull(result) + assertEquals("com.jetbrains.python.sdk.PythonSdkAdditionalData", result!!::class.java.name) + } +} diff --git a/src/test/kotlin/com/github/pyvenvmanage/settings/PyVenvManageConfigurableTest.kt b/src/test/kotlin/com/github/pyvenvmanage/settings/PyVenvManageConfigurableTest.kt index a278f58..46c9bfe 100644 --- a/src/test/kotlin/com/github/pyvenvmanage/settings/PyVenvManageConfigurableTest.kt +++ b/src/test/kotlin/com/github/pyvenvmanage/settings/PyVenvManageConfigurableTest.kt @@ -25,6 +25,7 @@ import com.intellij.openapi.application.Application import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.CoroutineSupport import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.tabs.impl.IslandsPainterProvider class PyVenvManageConfigurableTest { private lateinit var configurable: PyVenvManageConfigurable @@ -37,6 +38,7 @@ class PyVenvManageConfigurableTest { mockkStatic(ApplicationManager::class) every { ApplicationManager.getApplication() } returns application every { application.getService(CoroutineSupport::class.java) } returns mockk(relaxed = true) + every { application.getService(IslandsPainterProvider::class.java) } returns mockk(relaxed = true) settings = mockk(relaxed = true) mockkObject(PyVenvManageSettings.Companion)