From 208443e3d9883a7f445924bdcfeaafa345a91eeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 31 Mar 2026 18:14:41 -0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(sdk):=20add=20environment=20de?= =?UTF-8?q?tection=20and=20dynamic=20icons=20for=20PyCharm=202026.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PyCharm 2026.1 removed the generic Virtualenv icon and now requires proper SDK flavor data to display environment-specific icons (UV, Poetry, Hatch, Conda, Pipenv). Without this, all environments showed broken icon references and package managers couldn't properly detect their tooling. The plugin now detects environment types by checking filesystem markers: pyvenv.cfg for UV, conda-meta directories for Conda, .gitignore markers for Hatch, and standard cache locations (with environment variable overrides) for Poetry and Pipenv. Each detected type gets the appropriate SDK flavor data so PyCharm's package managers work correctly and display the right icons. SDK creation now properly handles PyCharm's threading model by separating name generation (which runs Python) from write-locked modifications. Only in-project virtual environments get associated with the project to avoid polluting other projects' interpreter lists. Signed-off-by: Bernรกt Gรกbor --- .github/workflows/check.yaml | 2 +- CHANGELOG.md | 80 +++-- README.md | 26 +- build.gradle.kts | 16 +- gradle.properties | 4 +- gradle/wrapper/gradle-wrapper.properties | 2 +- .../VenvProjectViewNodeDecorator.kt | 36 +- .../actions/ConfigurePythonActionAbstract.kt | 70 ++-- .../pyvenvmanage/sdk/EnvironmentDetector.kt | 248 ++++++++++++++ .../com/github/pyvenvmanage/sdk/SdkFactory.kt | 137 ++++++++ .../META-INF/pyvenvmanage-python.xml | 2 - .../kotlin/com/github/pyvenvmanage/UITest.kt | 28 +- .../VenvProjectViewNodeDecoratorTest.kt | 20 ++ .../ConfigurePythonActionAbstractTest.kt | 135 ++++---- .../github/pyvenvmanage/pages/IdeaFrame.kt | 32 +- .../github/pyvenvmanage/pages/WelcomeFrame.kt | 28 -- .../sdk/EnvironmentDetectorTest.kt | 197 +++++++++++ .../github/pyvenvmanage/sdk/SdkFactoryTest.kt | 321 ++++++++++++++++++ .../settings/PyVenvManageConfigurableTest.kt | 2 + 19 files changed, 1183 insertions(+), 203 deletions(-) create mode 100644 src/main/kotlin/com/github/pyvenvmanage/sdk/EnvironmentDetector.kt create mode 100644 src/main/kotlin/com/github/pyvenvmanage/sdk/SdkFactory.kt delete mode 100644 src/test/kotlin/com/github/pyvenvmanage/pages/WelcomeFrame.kt create mode 100644 src/test/kotlin/com/github/pyvenvmanage/sdk/EnvironmentDetectorTest.kt create mode 100644 src/test/kotlin/com/github/pyvenvmanage/sdk/SdkFactoryTest.kt 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)