diff --git a/python/pluginCore/resources/META-INF/plugin.xml b/python/pluginCore/resources/META-INF/plugin.xml
index afd89a6cc3a0..ecd8090be627 100644
--- a/python/pluginCore/resources/META-INF/plugin.xml
+++ b/python/pluginCore/resources/META-INF/plugin.xml
@@ -269,6 +269,8 @@ The Python plug-in provides smart editing for Python scripts. The feature set of
key="INSP.pandas.series.values.replace.with.tolist" enabledByDefault="true" level="WARNING"
implementationClass="com.jetbrains.python.inspections.PyPandasSeriesToListInspection"/>
+
+
diff --git a/python/src/com/jetbrains/python/codeInsight/editorActions/PyListSplitJoinContext.kt b/python/src/com/jetbrains/python/codeInsight/editorActions/PyListSplitJoinContext.kt
new file mode 100644
index 000000000000..78f357f44a9a
--- /dev/null
+++ b/python/src/com/jetbrains/python/codeInsight/editorActions/PyListSplitJoinContext.kt
@@ -0,0 +1,67 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.codeInsight.editorActions
+
+import com.intellij.openapi.editor.actions.lists.CommaListSplitJoinContext
+import com.intellij.openapi.editor.actions.lists.JoinOrSplit
+import com.intellij.openapi.editor.actions.lists.ListWithElements
+import com.intellij.openapi.util.TextRange
+import com.intellij.psi.PsiElement
+import com.intellij.psi.PsiFile
+import com.intellij.psi.codeStyle.CodeStyleManager
+import com.intellij.psi.util.PsiTreeUtil
+import com.jetbrains.python.psi.*
+
+class PyListSplitJoinContext : CommaListSplitJoinContext() {
+ override fun extractData(context: PsiElement): ListWithElements? {
+ val parent = PsiTreeUtil.findFirstParent(context) {
+ // This is needed only for intention to work before the first and after the last element of a tuple
+ (it is PyParenthesizedExpression && it.containedExpression is PyTupleExpression)
+ || it is PySequenceExpression || it is PyParameterList || it is PyArgumentList
+ }.let { if (it is PyParenthesizedExpression) it.containedExpression else it }
+
+ return when (parent) {
+ is PySequenceExpression -> ListWithElements(parent, parent.elements.toList())
+ is PyParameterList -> ListWithElements(parent, parent.parameters.toList())
+ is PyArgumentList -> ListWithElements(parent, parent.arguments.toList())
+ else -> null
+ }
+ }
+
+ override fun needTailBreak(data: ListWithElements, lastElement: PsiElement, mode: JoinOrSplit): Boolean {
+ // if there is a trailing comma, add tail break
+ return if (mode == JoinOrSplit.SPLIT) hasSeparatorAfter(data, lastElement) else super.needTailBreak(data, lastElement, mode)
+ }
+
+ override fun needHeadBreak(data: ListWithElements, firstElement: PsiElement, mode: JoinOrSplit): Boolean {
+ // if there is a trailing comma, also add head break
+ return needTailBreak(data, data.elements.last(), mode)
+ }
+
+ override fun nextBreak(data: ListWithElements, element: PsiElement): PsiElement? {
+ // in PyTupleExpression spaces for first/last elements are stored in parent psi
+ val nextBreak = super.nextBreak(data, element)
+ if (nextBreak != null || data.list !is PyTupleExpression || data.elements.last() != element) {
+ return nextBreak
+ }
+ return super.nextBreak(data, data.list)
+ }
+
+ override fun prevBreak(data: ListWithElements, element: PsiElement): PsiElement? {
+ // in PyTupleExpression spaces for first/last elements are stored in parent psi
+ val prevBreak = super.prevBreak(data, element)
+ if (prevBreak != null || data.list !is PyTupleExpression || data.elements.first() != element) {
+ return prevBreak
+ }
+ return super.prevBreak(data, data.list)
+ }
+
+ override fun reformatRange(file: PsiFile, rangeToAdjust: TextRange, split: JoinOrSplit) {
+ val tupleExpression = PsiTreeUtil.findElementOfClassAtRange(file, rangeToAdjust.startOffset, rangeToAdjust.endOffset, PyTupleExpression::class.java)
+ if (tupleExpression != null && tupleExpression.parent is PyParenthesizedExpression) {
+ CodeStyleManager.getInstance(file.project).reformat(tupleExpression.parent)
+ return
+ }
+
+ CodeStyleManager.getInstance(file.project).reformatText(file, rangeToAdjust.startOffset, rangeToAdjust.endOffset)
+ }
+}
\ No newline at end of file
diff --git a/python/testData/intentions/joinTupleToSingleLine.py b/python/testData/intentions/joinTupleToSingleLine.py
new file mode 100644
index 000000000000..72e596319bcb
--- /dev/null
+++ b/python/testData/intentions/joinTupleToSingleLine.py
@@ -0,0 +1,5 @@
+a = (
+ 1,
+ 2,
+ 3,
+)
\ No newline at end of file
diff --git a/python/testData/intentions/joinTupleToSingleLine_after.py b/python/testData/intentions/joinTupleToSingleLine_after.py
new file mode 100644
index 000000000000..8459a66d24c8
--- /dev/null
+++ b/python/testData/intentions/joinTupleToSingleLine_after.py
@@ -0,0 +1 @@
+a = (1, 2, 3,)
diff --git a/python/testData/intentions/splitTupleToMultiLine.py b/python/testData/intentions/splitTupleToMultiLine.py
new file mode 100644
index 000000000000..de6c480082dd
--- /dev/null
+++ b/python/testData/intentions/splitTupleToMultiLine.py
@@ -0,0 +1 @@
+a = (1, 2, 3, )
\ No newline at end of file
diff --git a/python/testData/intentions/splitTupleToMultiLine_after.py b/python/testData/intentions/splitTupleToMultiLine_after.py
new file mode 100644
index 000000000000..649f1d2fa15f
--- /dev/null
+++ b/python/testData/intentions/splitTupleToMultiLine_after.py
@@ -0,0 +1,5 @@
+a = (
+ 1,
+ 2,
+ 3,
+)
diff --git a/python/testSrc/com/jetbrains/python/intentions/PyIntentionTest.java b/python/testSrc/com/jetbrains/python/intentions/PyIntentionTest.java
index e85a15399ae3..db30a3a644df 100644
--- a/python/testSrc/com/jetbrains/python/intentions/PyIntentionTest.java
+++ b/python/testSrc/com/jetbrains/python/intentions/PyIntentionTest.java
@@ -1,6 +1,7 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.intentions;
+import com.intellij.codeInsight.CodeInsightBundle;
import com.intellij.codeInsight.CodeInsightSettings;
import com.intellij.codeInsight.intention.IntentionAction;
import com.intellij.openapi.application.impl.NonBlockingReadActionImpl;
@@ -94,6 +95,15 @@ public class PyIntentionTest extends PyTestCase {
runWithLanguageLevel(LanguageLevel.PYTHON35, () -> doTest(PyPsiBundle.message("QFIX.remove.string.prefix", "F")));
}
+ // PY-45729
+ public void testJoinTupleToSingleLine() {
+ doTest(CodeInsightBundle.message("intention.name.join.comma.values"));
+ }
+
+ public void testSplitTupleToMultiLine() {
+ doTest(CodeInsightBundle.message("intention.name.split.comma.values"));
+ }
+
// PY-18972
public void testRemoveTrailingL() {
doTest(PyPsiBundle.message("QFIX.remove.trailing.suffix"));