bpo-44569: Decouple frame formatting in traceback.py (GH-27038)

diff --git a/Doc/library/traceback.rst b/Doc/library/traceback.rst
index be1f43e..83d5c8c 100644
--- a/Doc/library/traceback.rst
+++ b/Doc/library/traceback.rst
@@ -353,6 +353,14 @@
       .. versionchanged:: 3.6
          Long sequences of repeated frames are now abbreviated.
 
+   .. method:: format_frame(frame)
+
+      Returns a string for printing one of the frames involved in the stack.
+      This method gets called for each frame object to be printed in the
+      :class:`StackSummary`.
+
+      .. versionadded:: 3.11
+
 
 :class:`FrameSummary` Objects
 -----------------------------
diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py
index 402f773..4742eb1 100644
--- a/Lib/test/test_traceback.py
+++ b/Lib/test/test_traceback.py
@@ -1429,6 +1429,21 @@ def some_inner(k, v):
              '    v = 4\n' % (__file__, some_inner.__code__.co_firstlineno + 3)
             ], s.format())
 
+    def test_custom_format_frame(self):
+        class CustomStackSummary(traceback.StackSummary):
+            def format_frame(self, frame):
+                return f'{frame.filename}:{frame.lineno}'
+
+        def some_inner():
+            return CustomStackSummary.extract(
+                traceback.walk_stack(None), limit=1)
+
+        s = some_inner()
+        self.assertEqual(
+            s.format(),
+            [f'{__file__}:{some_inner.__code__.co_firstlineno + 1}'])
+
+
 class TestTracebackException(unittest.TestCase):
 
     def test_smoke(self):
diff --git a/Lib/traceback.py b/Lib/traceback.py
index 40d736a..ae5775d 100644
--- a/Lib/traceback.py
+++ b/Lib/traceback.py
@@ -449,6 +449,48 @@ def from_list(klass, a_list):
                 result.append(FrameSummary(filename, lineno, name, line=line))
         return result
 
+    def format_frame(self, frame):
+        """Format the lines for a single frame.
+
+        Returns a string representing one frame involved in the stack. This
+        gets called for every frame to be printed in the stack summary.
+        """
+        row = []
+        row.append('  File "{}", line {}, in {}\n'.format(
+            frame.filename, frame.lineno, frame.name))
+        if frame.line:
+            row.append('    {}\n'.format(frame.line.strip()))
+
+            stripped_characters = len(frame._original_line) - len(frame.line.lstrip())
+            if frame.end_lineno == frame.lineno and frame.end_colno != 0:
+                colno = _byte_offset_to_character_offset(frame._original_line, frame.colno)
+                end_colno = _byte_offset_to_character_offset(frame._original_line, frame.end_colno)
+
+                try:
+                    anchors = _extract_caret_anchors_from_line_segment(
+                        frame._original_line[colno - 1:end_colno - 1]
+                    )
+                except Exception:
+                    anchors = None
+
+                row.append('    ')
+                row.append(' ' * (colno - stripped_characters))
+
+                if anchors:
+                    row.append(anchors.primary_char * (anchors.left_end_offset))
+                    row.append(anchors.secondary_char * (anchors.right_start_offset - anchors.left_end_offset))
+                    row.append(anchors.primary_char * (end_colno - colno - anchors.right_start_offset))
+                else:
+                    row.append('^' * (end_colno - colno))
+
+                row.append('\n')
+
+        if frame.locals:
+            for name, value in sorted(frame.locals.items()):
+                row.append('    {name} = {value}\n'.format(name=name, value=value))
+
+        return ''.join(row)
+
     def format(self):
         """Format the stack ready for printing.
 
@@ -483,40 +525,8 @@ def format(self):
             count += 1
             if count > _RECURSIVE_CUTOFF:
                 continue
-            row = []
-            row.append('  File "{}", line {}, in {}\n'.format(
-                frame.filename, frame.lineno, frame.name))
-            if frame.line:
-                row.append('    {}\n'.format(frame.line.strip()))
+            result.append(self.format_frame(frame))
 
-                stripped_characters = len(frame._original_line) - len(frame.line.lstrip())
-                if frame.end_lineno == frame.lineno and frame.end_colno != 0:
-                    colno = _byte_offset_to_character_offset(frame._original_line, frame.colno)
-                    end_colno = _byte_offset_to_character_offset(frame._original_line, frame.end_colno)
-
-                    try:
-                        anchors = _extract_caret_anchors_from_line_segment(
-                            frame._original_line[colno - 1:end_colno - 1]
-                        )
-                    except Exception:
-                        anchors = None
-
-                    row.append('    ')
-                    row.append(' ' * (colno - stripped_characters))
-
-                    if anchors:
-                        row.append(anchors.primary_char * (anchors.left_end_offset))
-                        row.append(anchors.secondary_char * (anchors.right_start_offset - anchors.left_end_offset))
-                        row.append(anchors.primary_char * (end_colno - colno - anchors.right_start_offset))
-                    else:
-                        row.append('^' * (end_colno - colno))
-
-                    row.append('\n')
-
-            if frame.locals:
-                for name, value in sorted(frame.locals.items()):
-                    row.append('    {name} = {value}\n'.format(name=name, value=value))
-            result.append(''.join(row))
         if count > _RECURSIVE_CUTOFF:
             count -= _RECURSIVE_CUTOFF
             result.append(
diff --git a/Misc/NEWS.d/next/Library/2021-07-08-12-22-54.bpo-44569.KZ02v9.rst b/Misc/NEWS.d/next/Library/2021-07-08-12-22-54.bpo-44569.KZ02v9.rst
new file mode 100644
index 0000000..5f693b2
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2021-07-08-12-22-54.bpo-44569.KZ02v9.rst
@@ -0,0 +1,3 @@
+Added the :func:`StackSummary.format_frame` function in :mod:`traceback`.
+This allows users to customize the way individual lines are formatted in
+tracebacks without re-implementing logic to handle recursive tracebacks.