diff --git a/python/resources/intentionDescriptions/PyConvertToFStringIntention/after.py.template b/python/resources/intentionDescriptions/PyConvertToFStringIntention/after.py.template
new file mode 100644
index 000000000000..01caf319426d
--- /dev/null
+++ b/python/resources/intentionDescriptions/PyConvertToFStringIntention/after.py.template
@@ -0,0 +1,2 @@
+f"Pi is {math.pi:03.5}"
+f'{"eggs"}'
\ No newline at end of file
diff --git a/python/resources/intentionDescriptions/PyConvertToFStringIntention/before.py.template b/python/resources/intentionDescriptions/PyConvertToFStringIntention/before.py.template
new file mode 100644
index 000000000000..3815d98cac7f
--- /dev/null
+++ b/python/resources/intentionDescriptions/PyConvertToFStringIntention/before.py.template
@@ -0,0 +1,2 @@
+"Pi is {:03.5}".format(math.pi)
+'%(spam)s' % {'spam': 'eggs'}
\ No newline at end of file
diff --git a/python/resources/intentionDescriptions/PyConvertToFStringIntention/description.html b/python/resources/intentionDescriptions/PyConvertToFStringIntention/description.html
new file mode 100644
index 000000000000..e4f408edb7ee
--- /dev/null
+++ b/python/resources/intentionDescriptions/PyConvertToFStringIntention/description.html
@@ -0,0 +1,7 @@
+
+
+This intention converts string formatting via format() method and format
+operator to Python 3.6 f-string literals.
+
+
+
\ No newline at end of file
diff --git a/python/src/META-INF/python-core-common.xml b/python/src/META-INF/python-core-common.xml
index 939affe7f49e..f1da49fc8607 100644
--- a/python/src/META-INF/python-core-common.xml
+++ b/python/src/META-INF/python-core-common.xml
@@ -310,6 +310,11 @@
com.jetbrains.python.codeInsight.intentions.PyYieldFromIntention
Python
+
+
+ com.jetbrains.python.codeInsight.intentions.PyConvertToFStringIntention
+ Python
+
com.jetbrains.python.codeInsight.intentions.PyConvertTypeCommentToVariableAnnotationIntention
diff --git a/python/src/com/jetbrains/python/PyBundle.properties b/python/src/com/jetbrains/python/PyBundle.properties
index 2053d60c3710..7fa68f7a9f64 100644
--- a/python/src/com/jetbrains/python/PyBundle.properties
+++ b/python/src/com/jetbrains/python/PyBundle.properties
@@ -219,7 +219,11 @@ INTN.string.concatenation.to.format=Replace string concatenation with format ope
INTN.replace.plus.with.format.operator=Replace + with string formatting operator
INTN.replace.plus.with.str.format=Replace + with str.format method call
+# ConvertFormatOperatorToMethodIntention
INTN.format.operator.to.method=Convert format operator usage to str.format method call
+
+# PyConvertToFStringIntention
+INTN.convert.to.fstring.literal=Convert to f-string literal
INTN.replace.with.method=Replace with str.format method call
INTN.flip.comparison=Flip comparison
diff --git a/python/src/com/jetbrains/python/codeInsight/PythonFormattedStringReferenceProvider.java b/python/src/com/jetbrains/python/codeInsight/PythonFormattedStringReferenceProvider.java
index 7d1992225355..dfbfb2c7dd1e 100644
--- a/python/src/com/jetbrains/python/codeInsight/PythonFormattedStringReferenceProvider.java
+++ b/python/src/com/jetbrains/python/codeInsight/PythonFormattedStringReferenceProvider.java
@@ -37,23 +37,23 @@ public class PythonFormattedStringReferenceProvider extends PsiReferenceProvider
}
}
- private static PsiReference[] getReferencesFromFormatString(@NotNull final PyStringLiteralExpression element) {
+ private static PySubstitutionChunkReference[] getReferencesFromFormatString(@NotNull final PyStringLiteralExpression element) {
final List chunks = PyStringFormatParser.filterSubstitutions(
PyStringFormatParser.parseNewStyleFormat(element.getStringValue()));
return getReferencesFromChunks(element, chunks, false);
}
- private static PsiReference[] getReferencesFromPercentString(@NotNull final PyStringLiteralExpression element) {
+ private static PySubstitutionChunkReference[] getReferencesFromPercentString(@NotNull final PyStringLiteralExpression element) {
final List
chunks = PyStringFormatParser.filterSubstitutions(PyStringFormatParser.parsePercentFormat(element.getStringValue()));
return getReferencesFromChunks(element, chunks, true);
}
@NotNull
- private static PsiReference[] getReferencesFromChunks(@NotNull final PyStringLiteralExpression element,
- @NotNull final List chunks,
- boolean isPercent) {
- final PsiReference[] result = new PsiReference[chunks.size()];
+ public static PySubstitutionChunkReference[] getReferencesFromChunks(@NotNull final PyStringLiteralExpression element,
+ @NotNull final List chunks,
+ boolean isPercent) {
+ final PySubstitutionChunkReference[] result = new PySubstitutionChunkReference[chunks.size()];
for (int i = 0; i < chunks.size(); i++) {
final PyStringFormatParser.SubstitutionChunk chunk = chunks.get(i);
result[i] = new PySubstitutionChunkReference(element, chunk, i, isPercent);
diff --git a/python/src/com/jetbrains/python/codeInsight/intentions/ConvertFormatOperatorToMethodIntention.java b/python/src/com/jetbrains/python/codeInsight/intentions/ConvertFormatOperatorToMethodIntention.java
index dc67148d6fe0..dbb88b323980 100644
--- a/python/src/com/jetbrains/python/codeInsight/intentions/ConvertFormatOperatorToMethodIntention.java
+++ b/python/src/com/jetbrains/python/codeInsight/intentions/ConvertFormatOperatorToMethodIntention.java
@@ -36,6 +36,7 @@ import com.jetbrains.python.psi.impl.PyPsiUtils;
import com.jetbrains.python.psi.impl.PyStringLiteralExpressionImpl;
import com.jetbrains.python.psi.types.*;
import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
@@ -148,17 +149,7 @@ public class ConvertFormatOperatorToMethodIntention extends PyBaseIntentionActio
// don't convert %s -> !s, for %s is the normal way to output the default representation
out.append(":");
if (f_modifier != null) {
- // in strict order
- if (has(f_modifier, '-')) out.append("<"); // left align
- else if ("s".equals(fConversion) && !StringUtil.isEmptyOrSpaces(f_width)) {
- // "%20s" aligns right, "{0:20s}" aligns left; to preserve align, make it explicit
- out.append(">");
- }
- if (has(f_modifier, '+')) out.append("+"); // signed
- else if (has(f_modifier, ' ')) out.append(" "); // default-signed
- if (has(f_modifier, '#')) out.append("#"); // alt numbers
- if (has(f_modifier, '0')) out.append("0"); // padding
- // anything else can't be here
+ out.append(convertFormatSpec(f_modifier, f_width, fConversion));
}
if (f_width != null) {
out.append(f_width);
@@ -209,6 +200,29 @@ public class ConvertFormatOperatorToMethodIntention extends PyBaseIntentionActio
return new Pair<>(result, usesNamedFormat);
}
+ @NotNull
+ public static String convertFormatSpec(@NotNull String modifier,
+ @Nullable String widthAndPrecision,
+ @Nullable String conversionChar) {
+ final StringBuilder result = new StringBuilder();
+ // in strict order
+ if (has(modifier, '-')) {
+ result.append("<"); // left align
+ }
+ else if ("s".equals(conversionChar) && !StringUtil.isEmptyOrSpaces(widthAndPrecision)) {
+ // "%20s" aligns right, "{0:20s}" aligns left; to preserve align, make it explicit
+ result.append(">");
+ }
+ if (has(modifier, '+')) {
+ result.append("+"); // signed
+ }
+ else if (has(modifier, ' ')) result.append(" "); // default-signed
+ if (has(modifier, '#')) result.append("#"); // alt numbers
+ if (has(modifier, '0')) result.append("0"); // padding
+ // anything else can't be here
+ return result.toString();
+ }
+
private static boolean has(String where, char what) {
return where.indexOf(what) >= 0;
}
diff --git a/python/src/com/jetbrains/python/codeInsight/intentions/PyConvertToFStringIntention.java b/python/src/com/jetbrains/python/codeInsight/intentions/PyConvertToFStringIntention.java
new file mode 100644
index 000000000000..ef20e165cd6a
--- /dev/null
+++ b/python/src/com/jetbrains/python/codeInsight/intentions/PyConvertToFStringIntention.java
@@ -0,0 +1,303 @@
+/*
+ * Copyright 2000-2016 JetBrains s.r.o.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.jetbrains.python.codeInsight.intentions;
+
+import com.intellij.lang.ASTNode;
+import com.intellij.openapi.editor.Editor;
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.util.Pair;
+import com.intellij.openapi.util.TextRange;
+import com.intellij.openapi.util.text.StringUtil;
+import com.intellij.psi.PsiElement;
+import com.intellij.psi.PsiFile;
+import com.intellij.psi.util.PsiTreeUtil;
+import com.intellij.util.IncorrectOperationException;
+import com.jetbrains.python.PyBundle;
+import com.jetbrains.python.PyTokenTypes;
+import com.jetbrains.python.codeInsight.PySubstitutionChunkReference;
+import com.jetbrains.python.codeInsight.PythonFormattedStringReferenceProvider;
+import com.jetbrains.python.inspections.PyStringFormatParser;
+import com.jetbrains.python.inspections.PyStringFormatParser.ConstantChunk;
+import com.jetbrains.python.inspections.PyStringFormatParser.FormatStringChunk;
+import com.jetbrains.python.inspections.PyStringFormatParser.SubstitutionChunk;
+import com.jetbrains.python.psi.*;
+import com.jetbrains.python.psi.PyUtil.StringNodeInfo;
+import com.jetbrains.python.psi.impl.PyStringLiteralExpressionImpl;
+import org.jetbrains.annotations.Nls;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+
+import static com.jetbrains.python.psi.PyUtil.as;
+
+/**
+ * @author Mikhail Golubev
+ */
+public class PyConvertToFStringIntention extends PyBaseIntentionAction {
+ @Nls
+ @NotNull
+ @Override
+ public String getFamilyName() {
+ return PyBundle.message("INTN.convert.to.fstring.literal");
+ }
+
+ @NotNull
+ @Override
+ public String getText() {
+ return getFamilyName();
+ }
+
+ @Override
+ public boolean isAvailable(@NotNull Project project, Editor editor, PsiFile file) {
+ if (!(file instanceof PyFile) || !LanguageLevel.forElement(file).isAtLeast(LanguageLevel.PYTHON36)) return false;
+
+ final Pair pair = findTargetStringUnderCaret(editor, file);
+ if (pair == null) return false;
+
+ final PyStringLiteralExpression pyString = pair.getFirst();
+ final boolean percentOperator = pair.getSecond();
+
+ // TODO handle "glued" literals
+ if (pyString != null && pyString.getStringNodes().size() == 1) {
+ final String stringText = pyString.getText();
+ final String prefix = PyStringLiteralUtil.getPrefix(stringText);
+ if (PyStringLiteralUtil.isBytesPrefix(prefix) || PyStringLiteralUtil.isFormattedPrefix(prefix)) {
+ return false;
+ }
+
+ final List chunks = percentOperator ? PyStringFormatParser.parsePercentFormat(stringText)
+ : PyStringFormatParser.parseNewStyleFormat(stringText);
+ final List substitutions = PyStringFormatParser.filterSubstitutions(chunks);
+
+ // TODO handle dynamic format spec in both formatting styles
+ final boolean hasDynamicFormatting;
+ if (percentOperator) {
+ hasDynamicFormatting = substitutions.stream().anyMatch(s -> "*".equals(s.getWidth()) || "*".equals(s.getPrecision()));
+ }
+ else {
+ hasDynamicFormatting = false;
+ }
+ if (hasDynamicFormatting) return false;
+
+ final PySubstitutionChunkReference[] references =
+ PythonFormattedStringReferenceProvider.getReferencesFromChunks(pyString, substitutions, percentOperator);
+
+ final PsiElement valuesSource;
+ if (percentOperator) {
+ final PyBinaryExpression binaryExpression = as(pyString.getParent(), PyBinaryExpression.class);
+ assert binaryExpression != null;
+ valuesSource = binaryExpression.getRightExpression();
+ }
+ else {
+ final PyCallExpression callExpression = PsiTreeUtil.getParentOfType(pyString, PyCallExpression.class);
+ assert callExpression != null;
+ valuesSource = callExpression.getArgumentList();
+ }
+
+ return Arrays.stream(references)
+ .map(PyConvertToFStringIntention::getActualReplacementExpression)
+ .allMatch(element -> element != null &&
+ !(element instanceof PyStarExpression) &&
+ !(element instanceof PyStarArgument) &&
+ PsiTreeUtil.isAncestor(valuesSource, element, false) &&
+ expressionCanBeInlined(pyString, element));
+ }
+ return false;
+ }
+
+ private static boolean expressionCanBeInlined(@NotNull PyStringLiteralExpression host, @NotNull PyExpression target) {
+ // Cannot inline multi-line expressions or expressions that contains backslashes (yet)
+ if (target.textContains('\\') || target.textContains('\n')) return false;
+ return adjustQuotesInside((PyExpression)target.copy(), host) != null;
+ }
+
+ @Nullable
+ private static PsiElement adjustQuotesInside(@NotNull PyExpression element, @NotNull PyStringLiteralExpression host) {
+ final StringNodeInfo hostInfo = new StringNodeInfo(host.getStringNodes().get(0));
+ final char hostQuote = hostInfo.getSingleQuote();
+ final PyElementGenerator generator = PyElementGenerator.getInstance(host.getProject());
+
+ final Collection innerStrings = PsiTreeUtil.collectElementsOfType(element, PyStringLiteralExpression.class);
+ for (PyStringLiteralExpression literal : innerStrings) {
+ final List nodes = literal.getStringNodes();
+ // TODO figure out what to do with those
+ if (nodes.size() > 1) {
+ return null;
+ }
+ final StringNodeInfo info = new StringNodeInfo(nodes.get(0));
+ // Nest string contain the same type of quote as host string inside, and we cannot escape inside f-string -- retreat
+ final String content = info.getContent();
+ if (content.indexOf(hostQuote) >= 0) {
+ return null;
+ }
+ if (!info.isTerminated()) {
+ return null;
+ }
+ if (info.getSingleQuote() == hostQuote) {
+ final char targetSingleQuote = hostQuote == '"' ? '\'' : '"';
+ final String targetQuote = info.getQuote().replace(hostQuote, targetSingleQuote);
+ final String stringWithSwappedQuotes = info.getPrefix() + targetQuote + content + targetQuote;
+ final PsiElement replaced = literal.replace(generator.createStringLiteralAlreadyEscaped(stringWithSwappedQuotes));
+ if (literal == element) {
+ return replaced;
+ }
+ }
+
+ }
+ return element;
+ }
+
+ @Nullable
+ private static Pair findTargetStringUnderCaret(@NotNull Editor editor, @NotNull PsiFile file) {
+ final PsiElement anchor = file.findElementAt(editor.getCaretModel().getOffset());
+ if (anchor == null) return null;
+
+ final PyBinaryExpression binaryExpr = PsiTreeUtil.getParentOfType(anchor, PyBinaryExpression.class);
+ if (binaryExpr != null && binaryExpr.getOperator() == PyTokenTypes.PERC) {
+ final PyStringLiteralExpression pyString = as(binaryExpr.getLeftExpression(), PyStringLiteralExpression.class);
+ if (pyString != null) {
+ return Pair.create(pyString, true);
+ }
+ }
+ final PyCallExpression callExpr = PsiTreeUtil.getParentOfType(anchor, PyCallExpression.class);
+ if (callExpr != null) {
+ final PyReferenceExpression callee = as(callExpr.getCallee(), PyReferenceExpression.class);
+ if (callee != null) {
+ final PyStringLiteralExpression pyString = as(callee.getQualifier(), PyStringLiteralExpression.class);
+ if (pyString != null) {
+ return Pair.create(pyString, false);
+ }
+ }
+ }
+ return null;
+
+ }
+
+ @Override
+ public void doInvoke(@NotNull Project project, Editor editor, PsiFile file) throws IncorrectOperationException {
+ final Pair pair = findTargetStringUnderCaret(editor, file);
+ assert pair != null;
+ final Boolean percentOperator = pair.getSecond();
+ if (percentOperator) {
+ convertPercentOperatorFormatting(pair.getFirst());
+ }
+ else {
+ convertFormatMethodFormatting(pair.getFirst());
+ }
+ }
+
+ private static void convertPercentOperatorFormatting(@NotNull PyStringLiteralExpression pyString) {
+ final String stringText = pyString.getText();
+ final Pair quotes = PyStringLiteralUtil.getQuotes(stringText);
+ assert quotes != null;
+ final StringBuilder result = new StringBuilder();
+ result.append("f");
+ result.append(quotes.getFirst().replaceAll("[uU]", ""));
+ final List chunks = PyStringFormatParser.parsePercentFormat(stringText);
+ final TextRange contentRange = PyStringLiteralExpressionImpl.getNodeTextRange(stringText);
+ int subsChunkPosition = 0;
+ for (FormatStringChunk chunk : chunks) {
+ if (chunk instanceof ConstantChunk) {
+ final TextRange rangeWithoutQuotes = chunk.getTextRange().intersection(contentRange);
+ assert rangeWithoutQuotes != null;
+ result.append(rangeWithoutQuotes.substring(stringText));
+ }
+ else {
+ final SubstitutionChunk subsChunk = (SubstitutionChunk)chunk;
+ final char conversionChar = subsChunk.getConversionType();
+ final String widthAndPrecision;
+ if (StringUtil.isNotEmpty(subsChunk.getWidth()) || StringUtil.isNotEmpty(subsChunk.getPrecision())) {
+ widthAndPrecision = StringUtil.notNullize(subsChunk.getWidth()) + "." + StringUtil.notNullize(subsChunk.getPrecision());
+ }
+ else {
+ widthAndPrecision = "";
+ }
+ final String conversionFlags = subsChunk.getConversionFlags();
+
+ result.append("{");
+ final PySubstitutionChunkReference reference = new PySubstitutionChunkReference(pyString, subsChunk, subsChunkPosition, true);
+ final PyExpression resolveResult = getActualReplacementExpression(reference);
+ assert resolveResult != null;
+
+ final PsiElement adjusted = adjustQuotesInside(resolveResult, pyString);
+ if (adjusted == null) return;
+
+ result.append(adjusted.getText());
+
+ // TODO mostly duplicates the logic of ConvertFormatOperatorToMethodIntention
+ if (conversionChar == 'r') {
+ result.append("!r");
+ }
+
+ if ((conversionChar != 'r' && conversionChar != 's')
+ || StringUtil.isNotEmpty(conversionFlags)
+ || StringUtil.isNotEmpty(widthAndPrecision)) {
+ result.append(":");
+ }
+
+ if (StringUtil.isNotEmpty(conversionFlags)) {
+ final String conversionStr = String.valueOf(conversionChar);
+ result.append(ConvertFormatOperatorToMethodIntention.convertFormatSpec(conversionFlags, widthAndPrecision, conversionStr));
+ }
+
+ if (StringUtil.isNotEmpty(widthAndPrecision)) {
+ result.append(widthAndPrecision);
+ }
+
+ if ('i' == conversionChar || 'u' == conversionChar) {
+ result.append("d");
+ }
+ else if ('s' != conversionChar && 'r' != conversionChar) {
+ result.append(conversionChar);
+ }
+ result.append("}");
+ subsChunkPosition++;
+ }
+ }
+ result.append(quotes.getSecond());
+
+ final PyBinaryExpression expressionToReplace = PsiTreeUtil.getParentOfType(pyString, PyBinaryExpression.class);
+ assert expressionToReplace != null;
+
+ final PyElementGenerator generator = PyElementGenerator.getInstance(pyString.getProject());
+ final PyExpression fString = generator.createExpressionFromText(LanguageLevel.PYTHON36, result.toString());
+ expressionToReplace.replace(fString);
+ }
+
+ @Nullable
+ private static PyExpression getActualReplacementExpression(@NotNull PySubstitutionChunkReference reference) {
+ final PsiElement resolveResult = reference.resolve();
+ if (resolveResult == null) {
+ return null;
+ }
+ final PyKeywordArgument argument = as(resolveResult, PyKeywordArgument.class);
+ if (argument != null) {
+ return argument.getValueExpression();
+ }
+ final PyKeyValueExpression parent = as(resolveResult.getParent(), PyKeyValueExpression.class);
+ if (parent != null && parent.getKey() == resolveResult) {
+ return parent.getValue();
+ }
+ return as(resolveResult, PyExpression.class);
+ }
+
+ private void convertFormatMethodFormatting(@NotNull PyStringLiteralExpression pyString) {
+
+ }
+}
diff --git a/python/testData/intentions/PyConvertToFStringIntentionTest/formatMethodByteString.py b/python/testData/intentions/PyConvertToFStringIntentionTest/formatMethodByteString.py
new file mode 100644
index 000000000000..ab8d6cbe96df
--- /dev/null
+++ b/python/testData/intentions/PyConvertToFStringIntentionTest/formatMethodByteString.py
@@ -0,0 +1 @@
+b'{}'.format(42)
\ No newline at end of file
diff --git a/python/testData/intentions/PyConvertToFStringIntentionTest/formatMethodGluedString.py b/python/testData/intentions/PyConvertToFStringIntentionTest/formatMethodGluedString.py
new file mode 100644
index 000000000000..1879e62673ff
--- /dev/null
+++ b/python/testData/intentions/PyConvertToFStringIntentionTest/formatMethodGluedString.py
@@ -0,0 +1 @@
+'{}' '{}'.format('foo', 42)
diff --git a/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorDynamicPrecision.py b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorDynamicPrecision.py
new file mode 100644
index 000000000000..8360174be68f
--- /dev/null
+++ b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorDynamicPrecision.py
@@ -0,0 +1 @@
+"%.*s" % (5, 'spam')
diff --git a/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorDynamicWidth.py b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorDynamicWidth.py
new file mode 100644
index 000000000000..c7faa7a440a5
--- /dev/null
+++ b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorDynamicWidth.py
@@ -0,0 +1 @@
+"%*s" % (5, 'spam')
diff --git a/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorExpressionContainBadQuotes.py b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorExpressionContainBadQuotes.py
new file mode 100644
index 000000000000..ada4c8c33ac2
--- /dev/null
+++ b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorExpressionContainBadQuotes.py
@@ -0,0 +1 @@
+'%s' % "'"
\ No newline at end of file
diff --git a/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorExpressionWithBackslash.py b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorExpressionWithBackslash.py
new file mode 100644
index 000000000000..53ee7d65cdff
--- /dev/null
+++ b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorExpressionWithBackslash.py
@@ -0,0 +1 @@
+'%s' % '\n'
\ No newline at end of file
diff --git a/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorMultilineExpression.py b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorMultilineExpression.py
new file mode 100644
index 000000000000..67290fc03f38
--- /dev/null
+++ b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorMultilineExpression.py
@@ -0,0 +1,2 @@
+'%s' % func(1,
+ 2)
\ No newline at end of file
diff --git a/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorQuotesInsideInlinedExpressions.py b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorQuotesInsideInlinedExpressions.py
new file mode 100644
index 000000000000..b621e8a40bd4
--- /dev/null
+++ b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorQuotesInsideInlinedExpressions.py
@@ -0,0 +1,5 @@
+'%s %s %s' % (
+ d[r'foo'][b'bar']['baz'],
+ u'''[]''',
+ f('foo', g(rf'bar'))
+)
\ No newline at end of file
diff --git a/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorQuotesInsideInlinedExpressions_after.py b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorQuotesInsideInlinedExpressions_after.py
new file mode 100644
index 000000000000..bb2d34b45147
--- /dev/null
+++ b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorQuotesInsideInlinedExpressions_after.py
@@ -0,0 +1 @@
+f'{d[r"foo"][b"bar"]["baz"]} {u"""[]"""} {f("foo", g(rf"bar"))}'
\ No newline at end of file
diff --git a/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSimpleDictConstructorCall.py b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSimpleDictConstructorCall.py
new file mode 100644
index 000000000000..0179da4dfb1f
--- /dev/null
+++ b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSimpleDictConstructorCall.py
@@ -0,0 +1 @@
+'%(foo)d %(bar)s' % dict(foo=.25, bar='spam'.upper())
diff --git a/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSimpleDictConstructorCall_after.py b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSimpleDictConstructorCall_after.py
new file mode 100644
index 000000000000..6c5ab7eed49c
--- /dev/null
+++ b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSimpleDictConstructorCall_after.py
@@ -0,0 +1 @@
+f'{.25:d} {"spam".upper()}'
diff --git a/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSimpleDictLiteral.py b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSimpleDictLiteral.py
new file mode 100644
index 000000000000..efd21af8cd3a
--- /dev/null
+++ b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSimpleDictLiteral.py
@@ -0,0 +1 @@
+"%(foo)s %(bar)r" % {'foo': 'bar', 'bar': [1, 2, 3]}
diff --git a/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSimpleDictLiteral_after.py b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSimpleDictLiteral_after.py
new file mode 100644
index 000000000000..cee61a163f0d
--- /dev/null
+++ b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSimpleDictLiteral_after.py
@@ -0,0 +1 @@
+f"{'bar'} {[1, 2, 3]!r}"
diff --git a/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSimpleTuple.py b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSimpleTuple.py
new file mode 100644
index 000000000000..7b80ae7fbbb3
--- /dev/null
+++ b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSimpleTuple.py
@@ -0,0 +1 @@
+u'%s %d %f' % ("foo", 42, 2.5)
diff --git a/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSimpleTuple_after.py b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSimpleTuple_after.py
new file mode 100644
index 000000000000..ffaf8ac9b1bf
--- /dev/null
+++ b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSimpleTuple_after.py
@@ -0,0 +1 @@
+f'{"foo"} {42:d} {2.5:f}'
diff --git a/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSingleExpression.py b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSingleExpression.py
new file mode 100644
index 000000000000..f6061634c60d
--- /dev/null
+++ b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSingleExpression.py
@@ -0,0 +1 @@
+u'%04.5r' % 42
diff --git a/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSingleExpression_after.py b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSingleExpression_after.py
new file mode 100644
index 000000000000..48c6db99c125
--- /dev/null
+++ b/python/testData/intentions/PyConvertToFStringIntentionTest/percentOperatorSingleExpression_after.py
@@ -0,0 +1 @@
+f'{42!r:04.5}'
diff --git a/python/testSrc/com/jetbrains/python/intentions/PyConvertToFStringIntentionTest.java b/python/testSrc/com/jetbrains/python/intentions/PyConvertToFStringIntentionTest.java
new file mode 100644
index 000000000000..11e3946ca1ad
--- /dev/null
+++ b/python/testSrc/com/jetbrains/python/intentions/PyConvertToFStringIntentionTest.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2000-2016 JetBrains s.r.o.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.jetbrains.python.intentions;
+
+import com.jetbrains.python.PyBundle;
+import com.jetbrains.python.psi.LanguageLevel;
+
+/**
+ * @author Mikhail Golubev
+ */
+public class PyConvertToFStringIntentionTest extends PyIntentionTestCase {
+
+ private void doTest() {
+ doTest(PyBundle.message("INTN.convert.to.fstring.literal"), LanguageLevel.PYTHON36);
+ }
+
+ private void doNegativeTest() {
+ runWithLanguageLevel(LanguageLevel.PYTHON36, () -> doNegativeTest(PyBundle.message("INTN.convert.to.fstring.literal")));
+ }
+
+ public void testFormatMethodByteString() {
+ doNegativeTest();
+ }
+
+ public void testFormatMethodGluedString() {
+ doNegativeTest();
+ }
+
+ public void testPercentOperatorDynamicWidth() {
+ doNegativeTest();
+ }
+
+ public void testPercentOperatorDynamicPrecision() {
+ doNegativeTest();
+ }
+
+ public void testPercentOperatorSingleExpression() {
+ doTest();
+ }
+
+ public void testPercentOperatorSimpleTuple() {
+ doTest();
+ }
+
+ public void testPercentOperatorSimpleDictLiteral() {
+ doTest();
+ }
+
+ public void testPercentOperatorSimpleDictConstructorCall() {
+ doTest();
+ }
+
+ public void testPercentOperatorQuotesInsideInlinedExpressions() {
+ doTest();
+ }
+
+ public void testPercentOperatorMultilineExpression() {
+ doNegativeTest();
+ }
+
+ public void testPercentOperatorExpressionWithBackslash() {
+ doNegativeTest();
+ }
+
+ public void testPercentOperatorExpressionContainBadQuotes() {
+ doNegativeTest();
+ }
+}