[Python-checkins] bpo-36390: IDLE: Combine region formatting methods. (GH-12481)

Terry Jan Reedy webhook-mailer at python.org
Wed Jul 17 09:44:59 EDT 2019


https://github.com/python/cpython/commit/82494aa6d947c4a320c09c58fe0f100cdcf7af0b
commit: 82494aa6d947c4a320c09c58fe0f100cdcf7af0b
branch: master
author: Cheryl Sabella <cheryl.sabella at gmail.com>
committer: Terry Jan Reedy <tjreedy at udel.edu>
date: 2019-07-17T09:44:44-04:00
summary:

bpo-36390: IDLE: Combine region formatting methods. (GH-12481)

Rename paragraph.py to format.py and add region formatting methods
from editor.py.  Add tests for the latter.

files:
A Lib/idlelib/format.py
A Lib/idlelib/idle_test/test_format.py
A Misc/NEWS.d/next/IDLE/2019-03-21-08-35-00.bpo-36390.OdDCGk.rst
D Lib/idlelib/idle_test/test_paragraph.py
D Lib/idlelib/paragraph.py
M Lib/idlelib/configdialog.py
M Lib/idlelib/editor.py
M Lib/idlelib/mainmenu.py

diff --git a/Lib/idlelib/configdialog.py b/Lib/idlelib/configdialog.py
index 807ff60413d1..6ddbc7fc8e4d 100644
--- a/Lib/idlelib/configdialog.py
+++ b/Lib/idlelib/configdialog.py
@@ -29,7 +29,7 @@
 from idlelib.autocomplete import AutoComplete
 from idlelib.codecontext import CodeContext
 from idlelib.parenmatch import ParenMatch
-from idlelib.paragraph import FormatParagraph
+from idlelib.format import FormatParagraph
 from idlelib.squeezer import Squeezer
 
 changes = ConfigChanges()
diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py
index b972e3db8461..f02498da3521 100644
--- a/Lib/idlelib/editor.py
+++ b/Lib/idlelib/editor.py
@@ -53,7 +53,7 @@ class EditorWindow(object):
     from idlelib.autoexpand import AutoExpand
     from idlelib.calltip import Calltip
     from idlelib.codecontext import CodeContext
-    from idlelib.paragraph import FormatParagraph
+    from idlelib.format import FormatParagraph, FormatRegion
     from idlelib.parenmatch import ParenMatch
     from idlelib.rstrip import Rstrip
     from idlelib.squeezer import Squeezer
@@ -172,13 +172,14 @@ def __init__(self, flist=None, filename=None, key=None, root=None):
         text.bind("<<smart-backspace>>",self.smart_backspace_event)
         text.bind("<<newline-and-indent>>",self.newline_and_indent_event)
         text.bind("<<smart-indent>>",self.smart_indent_event)
-        text.bind("<<indent-region>>",self.indent_region_event)
-        text.bind("<<dedent-region>>",self.dedent_region_event)
-        text.bind("<<comment-region>>",self.comment_region_event)
-        text.bind("<<uncomment-region>>",self.uncomment_region_event)
-        text.bind("<<tabify-region>>",self.tabify_region_event)
-        text.bind("<<untabify-region>>",self.untabify_region_event)
-        text.bind("<<toggle-tabs>>",self.toggle_tabs_event)
+        self.fregion = fregion = self.FormatRegion(self)
+        text.bind("<<indent-region>>", fregion.indent_region_event)
+        text.bind("<<dedent-region>>", fregion.dedent_region_event)
+        text.bind("<<comment-region>>", fregion.comment_region_event)
+        text.bind("<<uncomment-region>>", fregion.uncomment_region_event)
+        text.bind("<<tabify-region>>", fregion.tabify_region_event)
+        text.bind("<<untabify-region>>", fregion.untabify_region_event)
+        text.bind("<<toggle-tabs>>", self.toggle_tabs_event)
         text.bind("<<change-indentwidth>>",self.change_indentwidth_event)
         text.bind("<Left>", self.move_at_edge_if_selection(0))
         text.bind("<Right>", self.move_at_edge_if_selection(1))
@@ -1290,7 +1291,7 @@ def smart_indent_event(self, event):
         try:
             if first and last:
                 if index2line(first) != index2line(last):
-                    return self.indent_region_event(event)
+                    return self.fregion.indent_region_event(event)
                 text.delete(first, last)
                 text.mark_set("insert", first)
             prefix = text.get("insert linestart", "insert")
@@ -1423,72 +1424,6 @@ def inner(offset, _startindex=startindex,
             return _icis(_startindex + "+%dc" % offset)
         return inner
 
-    def indent_region_event(self, event):
-        head, tail, chars, lines = self.get_region()
-        for pos in range(len(lines)):
-            line = lines[pos]
-            if line:
-                raw, effective = get_line_indent(line, self.tabwidth)
-                effective = effective + self.indentwidth
-                lines[pos] = self._make_blanks(effective) + line[raw:]
-        self.set_region(head, tail, chars, lines)
-        return "break"
-
-    def dedent_region_event(self, event):
-        head, tail, chars, lines = self.get_region()
-        for pos in range(len(lines)):
-            line = lines[pos]
-            if line:
-                raw, effective = get_line_indent(line, self.tabwidth)
-                effective = max(effective - self.indentwidth, 0)
-                lines[pos] = self._make_blanks(effective) + line[raw:]
-        self.set_region(head, tail, chars, lines)
-        return "break"
-
-    def comment_region_event(self, event):
-        head, tail, chars, lines = self.get_region()
-        for pos in range(len(lines) - 1):
-            line = lines[pos]
-            lines[pos] = '##' + line
-        self.set_region(head, tail, chars, lines)
-        return "break"
-
-    def uncomment_region_event(self, event):
-        head, tail, chars, lines = self.get_region()
-        for pos in range(len(lines)):
-            line = lines[pos]
-            if not line:
-                continue
-            if line[:2] == '##':
-                line = line[2:]
-            elif line[:1] == '#':
-                line = line[1:]
-            lines[pos] = line
-        self.set_region(head, tail, chars, lines)
-        return "break"
-
-    def tabify_region_event(self, event):
-        head, tail, chars, lines = self.get_region()
-        tabwidth = self._asktabwidth()
-        if tabwidth is None: return
-        for pos in range(len(lines)):
-            line = lines[pos]
-            if line:
-                raw, effective = get_line_indent(line, tabwidth)
-                ntabs, nspaces = divmod(effective, tabwidth)
-                lines[pos] = '\t' * ntabs + ' ' * nspaces + line[raw:]
-        self.set_region(head, tail, chars, lines)
-        return "break"
-
-    def untabify_region_event(self, event):
-        head, tail, chars, lines = self.get_region()
-        tabwidth = self._asktabwidth()
-        if tabwidth is None: return
-        for pos in range(len(lines)):
-            lines[pos] = lines[pos].expandtabs(tabwidth)
-        self.set_region(head, tail, chars, lines)
-        return "break"
-
     def toggle_tabs_event(self, event):
         if self.askyesno(
               "Toggle tabs",
@@ -1523,33 +1458,6 @@ def change_indentwidth_event(self, event):
             self.indentwidth = new
         return "break"
 
-    def get_region(self):
-        text = self.text
-        first, last = self.get_selection_indices()
-        if first and last:
-            head = text.index(first + " linestart")
-            tail = text.index(last + "-1c lineend +1c")
-        else:
-            head = text.index("insert linestart")
-            tail = text.index("insert lineend +1c")
-        chars = text.get(head, tail)
-        lines = chars.split("\n")
-        return head, tail, chars, lines
-
-    def set_region(self, head, tail, chars, lines):
-        text = self.text
-        newchars = "\n".join(lines)
-        if newchars == chars:
-            text.bell()
-            return
-        text.tag_remove("sel", "1.0", "end")
-        text.mark_set("insert", head)
-        text.undo_block_start()
-        text.delete(head, tail)
-        text.insert(head, newchars)
-        text.undo_block_stop()
-        text.tag_add("sel", head, "insert")
-
     # Make string that displays as n leading blanks.
 
     def _make_blanks(self, n):
@@ -1571,15 +1479,6 @@ def reindent_to(self, column):
             text.insert("insert", self._make_blanks(column))
         text.undo_block_stop()
 
-    def _asktabwidth(self):
-        return self.askinteger(
-            "Tab width",
-            "Columns per tab? (2-16)",
-            parent=self.text,
-            initialvalue=self.indentwidth,
-            minvalue=2,
-            maxvalue=16)
-
     # Guess indentwidth from text content.
     # Return guessed indentwidth.  This should not be believed unless
     # it's in a reasonable range (e.g., it will be 0 if no indented
diff --git a/Lib/idlelib/format.py b/Lib/idlelib/format.py
new file mode 100644
index 000000000000..e11ca3a9d26f
--- /dev/null
+++ b/Lib/idlelib/format.py
@@ -0,0 +1,357 @@
+"""Format all or a selected region (line slice) of text.
+
+Region formatting options: paragraph, comment block, indent, deindent,
+comment, uncomment, tabify, and untabify.
+
+File renamed from paragraph.py with functions added from editor.py.
+"""
+import re
+from tkinter.simpledialog import askinteger
+from idlelib.config import idleConf
+
+
+class FormatParagraph:
+    """Format a paragraph, comment block, or selection to a max width.
+
+    Does basic, standard text formatting, and also understands Python
+    comment blocks. Thus, for editing Python source code, this
+    extension is really only suitable for reformatting these comment
+    blocks or triple-quoted strings.
+
+    Known problems with comment reformatting:
+    * If there is a selection marked, and the first line of the
+      selection is not complete, the block will probably not be detected
+      as comments, and will have the normal "text formatting" rules
+      applied.
+    * If a comment block has leading whitespace that mixes tabs and
+      spaces, they will not be considered part of the same block.
+    * Fancy comments, like this bulleted list, aren't handled :-)
+    """
+    def __init__(self, editwin):
+        self.editwin = editwin
+
+    @classmethod
+    def reload(cls):
+        cls.max_width = idleConf.GetOption('extensions', 'FormatParagraph',
+                                           'max-width', type='int', default=72)
+
+    def close(self):
+        self.editwin = None
+
+    def format_paragraph_event(self, event, limit=None):
+        """Formats paragraph to a max width specified in idleConf.
+
+        If text is selected, format_paragraph_event will start breaking lines
+        at the max width, starting from the beginning selection.
+
+        If no text is selected, format_paragraph_event uses the current
+        cursor location to determine the paragraph (lines of text surrounded
+        by blank lines) and formats it.
+
+        The length limit parameter is for testing with a known value.
+        """
+        limit = self.max_width if limit is None else limit
+        text = self.editwin.text
+        first, last = self.editwin.get_selection_indices()
+        if first and last:
+            data = text.get(first, last)
+            comment_header = get_comment_header(data)
+        else:
+            first, last, comment_header, data = \
+                    find_paragraph(text, text.index("insert"))
+        if comment_header:
+            newdata = reformat_comment(data, limit, comment_header)
+        else:
+            newdata = reformat_paragraph(data, limit)
+        text.tag_remove("sel", "1.0", "end")
+
+        if newdata != data:
+            text.mark_set("insert", first)
+            text.undo_block_start()
+            text.delete(first, last)
+            text.insert(first, newdata)
+            text.undo_block_stop()
+        else:
+            text.mark_set("insert", last)
+        text.see("insert")
+        return "break"
+
+
+FormatParagraph.reload()
+
+def find_paragraph(text, mark):
+    """Returns the start/stop indices enclosing the paragraph that mark is in.
+
+    Also returns the comment format string, if any, and paragraph of text
+    between the start/stop indices.
+    """
+    lineno, col = map(int, mark.split("."))
+    line = text.get("%d.0" % lineno, "%d.end" % lineno)
+
+    # Look for start of next paragraph if the index passed in is a blank line
+    while text.compare("%d.0" % lineno, "<", "end") and is_all_white(line):
+        lineno = lineno + 1
+        line = text.get("%d.0" % lineno, "%d.end" % lineno)
+    first_lineno = lineno
+    comment_header = get_comment_header(line)
+    comment_header_len = len(comment_header)
+
+    # Once start line found, search for end of paragraph (a blank line)
+    while get_comment_header(line)==comment_header and \
+              not is_all_white(line[comment_header_len:]):
+        lineno = lineno + 1
+        line = text.get("%d.0" % lineno, "%d.end" % lineno)
+    last = "%d.0" % lineno
+
+    # Search back to beginning of paragraph (first blank line before)
+    lineno = first_lineno - 1
+    line = text.get("%d.0" % lineno, "%d.end" % lineno)
+    while lineno > 0 and \
+              get_comment_header(line)==comment_header and \
+              not is_all_white(line[comment_header_len:]):
+        lineno = lineno - 1
+        line = text.get("%d.0" % lineno, "%d.end" % lineno)
+    first = "%d.0" % (lineno+1)
+
+    return first, last, comment_header, text.get(first, last)
+
+# This should perhaps be replaced with textwrap.wrap
+def reformat_paragraph(data, limit):
+    """Return data reformatted to specified width (limit)."""
+    lines = data.split("\n")
+    i = 0
+    n = len(lines)
+    while i < n and is_all_white(lines[i]):
+        i = i+1
+    if i >= n:
+        return data
+    indent1 = get_indent(lines[i])
+    if i+1 < n and not is_all_white(lines[i+1]):
+        indent2 = get_indent(lines[i+1])
+    else:
+        indent2 = indent1
+    new = lines[:i]
+    partial = indent1
+    while i < n and not is_all_white(lines[i]):
+        # XXX Should take double space after period (etc.) into account
+        words = re.split(r"(\s+)", lines[i])
+        for j in range(0, len(words), 2):
+            word = words[j]
+            if not word:
+                continue # Can happen when line ends in whitespace
+            if len((partial + word).expandtabs()) > limit and \
+                   partial != indent1:
+                new.append(partial.rstrip())
+                partial = indent2
+            partial = partial + word + " "
+            if j+1 < len(words) and words[j+1] != " ":
+                partial = partial + " "
+        i = i+1
+    new.append(partial.rstrip())
+    # XXX Should reformat remaining paragraphs as well
+    new.extend(lines[i:])
+    return "\n".join(new)
+
+def reformat_comment(data, limit, comment_header):
+    """Return data reformatted to specified width with comment header."""
+
+    # Remove header from the comment lines
+    lc = len(comment_header)
+    data = "\n".join(line[lc:] for line in data.split("\n"))
+    # Reformat to maxformatwidth chars or a 20 char width,
+    # whichever is greater.
+    format_width = max(limit - len(comment_header), 20)
+    newdata = reformat_paragraph(data, format_width)
+    # re-split and re-insert the comment header.
+    newdata = newdata.split("\n")
+    # If the block ends in a \n, we don't want the comment prefix
+    # inserted after it. (Im not sure it makes sense to reformat a
+    # comment block that is not made of complete lines, but whatever!)
+    # Can't think of a clean solution, so we hack away
+    block_suffix = ""
+    if not newdata[-1]:
+        block_suffix = "\n"
+        newdata = newdata[:-1]
+    return '\n'.join(comment_header+line for line in newdata) + block_suffix
+
+def is_all_white(line):
+    """Return True if line is empty or all whitespace."""
+
+    return re.match(r"^\s*$", line) is not None
+
+def get_indent(line):
+    """Return the initial space or tab indent of line."""
+    return re.match(r"^([ \t]*)", line).group()
+
+def get_comment_header(line):
+    """Return string with leading whitespace and '#' from line or ''.
+
+    A null return indicates that the line is not a comment line. A non-
+    null return, such as '    #', will be used to find the other lines of
+    a comment block with the same  indent.
+    """
+    m = re.match(r"^([ \t]*#*)", line)
+    if m is None: return ""
+    return m.group(1)
+
+
+# Copy from editor.py; importing it would cause an import cycle.
+_line_indent_re = re.compile(r'[ \t]*')
+
+def get_line_indent(line, tabwidth):
+    """Return a line's indentation as (# chars, effective # of spaces).
+
+    The effective # of spaces is the length after properly "expanding"
+    the tabs into spaces, as done by str.expandtabs(tabwidth).
+    """
+    m = _line_indent_re.match(line)
+    return m.end(), len(m.group().expandtabs(tabwidth))
+
+
+class FormatRegion:
+    "Format selected text."
+
+    def __init__(self, editwin):
+        self.editwin = editwin
+
+    def get_region(self):
+        """Return line information about the selected text region.
+
+        If text is selected, the first and last indices will be
+        for the selection.  If there is no text selected, the
+        indices will be the current cursor location.
+
+        Return a tuple containing (first index, last index,
+            string representation of text, list of text lines).
+        """
+        text = self.editwin.text
+        first, last = self.editwin.get_selection_indices()
+        if first and last:
+            head = text.index(first + " linestart")
+            tail = text.index(last + "-1c lineend +1c")
+        else:
+            head = text.index("insert linestart")
+            tail = text.index("insert lineend +1c")
+        chars = text.get(head, tail)
+        lines = chars.split("\n")
+        return head, tail, chars, lines
+
+    def set_region(self, head, tail, chars, lines):
+        """Replace the text between the given indices.
+
+        Args:
+            head: Starting index of text to replace.
+            tail: Ending index of text to replace.
+            chars: Expected to be string of current text
+                between head and tail.
+            lines: List of new lines to insert between head
+                and tail.
+        """
+        text = self.editwin.text
+        newchars = "\n".join(lines)
+        if newchars == chars:
+            text.bell()
+            return
+        text.tag_remove("sel", "1.0", "end")
+        text.mark_set("insert", head)
+        text.undo_block_start()
+        text.delete(head, tail)
+        text.insert(head, newchars)
+        text.undo_block_stop()
+        text.tag_add("sel", head, "insert")
+
+    def indent_region_event(self, event=None):
+        "Indent region by indentwidth spaces."
+        head, tail, chars, lines = self.get_region()
+        for pos in range(len(lines)):
+            line = lines[pos]
+            if line:
+                raw, effective = get_line_indent(line, self.editwin.tabwidth)
+                effective = effective + self.editwin.indentwidth
+                lines[pos] = self.editwin._make_blanks(effective) + line[raw:]
+        self.set_region(head, tail, chars, lines)
+        return "break"
+
+    def dedent_region_event(self, event=None):
+        "Dedent region by indentwidth spaces."
+        head, tail, chars, lines = self.get_region()
+        for pos in range(len(lines)):
+            line = lines[pos]
+            if line:
+                raw, effective = get_line_indent(line, self.editwin.tabwidth)
+                effective = max(effective - self.editwin.indentwidth, 0)
+                lines[pos] = self.editwin._make_blanks(effective) + line[raw:]
+        self.set_region(head, tail, chars, lines)
+        return "break"
+
+    def comment_region_event(self, event=None):
+        """Comment out each line in region.
+
+        ## is appended to the beginning of each line to comment it out.
+        """
+        head, tail, chars, lines = self.get_region()
+        for pos in range(len(lines) - 1):
+            line = lines[pos]
+            lines[pos] = '##' + line
+        self.set_region(head, tail, chars, lines)
+        return "break"
+
+    def uncomment_region_event(self, event=None):
+        """Uncomment each line in region.
+
+        Remove ## or # in the first positions of a line.  If the comment
+        is not in the beginning position, this command will have no effect.
+        """
+        head, tail, chars, lines = self.get_region()
+        for pos in range(len(lines)):
+            line = lines[pos]
+            if not line:
+                continue
+            if line[:2] == '##':
+                line = line[2:]
+            elif line[:1] == '#':
+                line = line[1:]
+            lines[pos] = line
+        self.set_region(head, tail, chars, lines)
+        return "break"
+
+    def tabify_region_event(self, event=None):
+        "Convert leading spaces to tabs for each line in selected region."
+        head, tail, chars, lines = self.get_region()
+        tabwidth = self._asktabwidth()
+        if tabwidth is None:
+            return
+        for pos in range(len(lines)):
+            line = lines[pos]
+            if line:
+                raw, effective = get_line_indent(line, tabwidth)
+                ntabs, nspaces = divmod(effective, tabwidth)
+                lines[pos] = '\t' * ntabs + ' ' * nspaces + line[raw:]
+        self.set_region(head, tail, chars, lines)
+        return "break"
+
+    def untabify_region_event(self, event=None):
+        "Expand tabs to spaces for each line in region."
+        head, tail, chars, lines = self.get_region()
+        tabwidth = self._asktabwidth()
+        if tabwidth is None:
+            return
+        for pos in range(len(lines)):
+            lines[pos] = lines[pos].expandtabs(tabwidth)
+        self.set_region(head, tail, chars, lines)
+        return "break"
+
+    def _asktabwidth(self):
+        "Return value for tab width."
+        return askinteger(
+            "Tab width",
+            "Columns per tab? (2-16)",
+            parent=self.editwin.text,
+            initialvalue=self.editwin.indentwidth,
+            minvalue=2,
+            maxvalue=16)
+
+
+if __name__ == "__main__":
+    from unittest import main
+    main('idlelib.idle_test.test_format', verbosity=2, exit=False)
diff --git a/Lib/idlelib/idle_test/test_paragraph.py b/Lib/idlelib/idle_test/test_format.py
similarity index 64%
rename from Lib/idlelib/idle_test/test_paragraph.py
rename to Lib/idlelib/idle_test/test_format.py
index 0cb966fb96ca..a2d27ed69dd1 100644
--- a/Lib/idlelib/idle_test/test_paragraph.py
+++ b/Lib/idlelib/idle_test/test_format.py
@@ -1,7 +1,8 @@
-"Test paragraph, coverage 76%."
+"Test format, coverage 99%."
 
-from idlelib import paragraph as pg
+from idlelib import format as ft
 import unittest
+from unittest import mock
 from test.support import requires
 from tkinter import Tk, Text
 from idlelib.editor import EditorWindow
@@ -16,26 +17,26 @@ class Is_Get_Test(unittest.TestCase):
     leadingws_nocomment = '    This is not a comment'
 
     def test_is_all_white(self):
-        self.assertTrue(pg.is_all_white(''))
-        self.assertTrue(pg.is_all_white('\t\n\r\f\v'))
-        self.assertFalse(pg.is_all_white(self.test_comment))
+        self.assertTrue(ft.is_all_white(''))
+        self.assertTrue(ft.is_all_white('\t\n\r\f\v'))
+        self.assertFalse(ft.is_all_white(self.test_comment))
 
     def test_get_indent(self):
         Equal = self.assertEqual
-        Equal(pg.get_indent(self.test_comment), '')
-        Equal(pg.get_indent(self.trailingws_comment), '')
-        Equal(pg.get_indent(self.leadingws_comment), '    ')
-        Equal(pg.get_indent(self.leadingws_nocomment), '    ')
+        Equal(ft.get_indent(self.test_comment), '')
+        Equal(ft.get_indent(self.trailingws_comment), '')
+        Equal(ft.get_indent(self.leadingws_comment), '    ')
+        Equal(ft.get_indent(self.leadingws_nocomment), '    ')
 
     def test_get_comment_header(self):
         Equal = self.assertEqual
         # Test comment strings
-        Equal(pg.get_comment_header(self.test_comment), '#')
-        Equal(pg.get_comment_header(self.trailingws_comment), '#')
-        Equal(pg.get_comment_header(self.leadingws_comment), '    #')
+        Equal(ft.get_comment_header(self.test_comment), '#')
+        Equal(ft.get_comment_header(self.trailingws_comment), '#')
+        Equal(ft.get_comment_header(self.leadingws_comment), '    #')
         # Test non-comment strings
-        Equal(pg.get_comment_header(self.leadingws_nocomment), '    ')
-        Equal(pg.get_comment_header(self.test_nocomment), '')
+        Equal(ft.get_comment_header(self.leadingws_nocomment), '    ')
+        Equal(ft.get_comment_header(self.test_nocomment), '')
 
 
 class FindTest(unittest.TestCase):
@@ -63,7 +64,7 @@ def runcase(self, inserttext, stopline, expected):
             linelength = int(text.index("%d.end" % line).split('.')[1])
             for col in (0, linelength//2, linelength):
                 tempindex = "%d.%d" % (line, col)
-                self.assertEqual(pg.find_paragraph(text, tempindex), expected)
+                self.assertEqual(ft.find_paragraph(text, tempindex), expected)
         text.delete('1.0', 'end')
 
     def test_find_comment(self):
@@ -162,7 +163,7 @@ class ReformatFunctionTest(unittest.TestCase):
 
     def test_reformat_paragraph(self):
         Equal = self.assertEqual
-        reform = pg.reformat_paragraph
+        reform = ft.reformat_paragraph
         hw = "O hello world"
         Equal(reform(' ', 1), ' ')
         Equal(reform("Hello    world", 20), "Hello  world")
@@ -193,7 +194,7 @@ def test_reformat_comment(self):
         test_string = (
             "    \"\"\"this is a test of a reformat for a triple quoted string"
             " will it reformat to less than 70 characters for me?\"\"\"")
-        result = pg.reformat_comment(test_string, 70, "    ")
+        result = ft.reformat_comment(test_string, 70, "    ")
         expected = (
             "    \"\"\"this is a test of a reformat for a triple quoted string will it\n"
             "    reformat to less than 70 characters for me?\"\"\"")
@@ -202,7 +203,7 @@ def test_reformat_comment(self):
         test_comment = (
             "# this is a test of a reformat for a triple quoted string will "
             "it reformat to less than 70 characters for me?")
-        result = pg.reformat_comment(test_comment, 70, "#")
+        result = ft.reformat_comment(test_comment, 70, "#")
         expected = (
             "# this is a test of a reformat for a triple quoted string will it\n"
             "# reformat to less than 70 characters for me?")
@@ -211,7 +212,7 @@ def test_reformat_comment(self):
 
 class FormatClassTest(unittest.TestCase):
     def test_init_close(self):
-        instance = pg.FormatParagraph('editor')
+        instance = ft.FormatParagraph('editor')
         self.assertEqual(instance.editwin, 'editor')
         instance.close()
         self.assertEqual(instance.editwin, None)
@@ -273,7 +274,7 @@ def setUpClass(cls):
         cls.root.withdraw()
         editor = Editor(root=cls.root)
         cls.text = editor.text.text  # Test code does not need the wrapper.
-        cls.formatter = pg.FormatParagraph(editor).format_paragraph_event
+        cls.formatter = ft.FormatParagraph(editor).format_paragraph_event
         # Sets the insert mark just after the re-wrapped and inserted  text.
 
     @classmethod
@@ -375,5 +376,202 @@ def test_comment_block(self):
 ##        text.delete('1.0', 'end')
 
 
+class DummyEditwin:
+    def __init__(self, root, text):
+        self.root = root
+        self.text = text
+        self.indentwidth = 4
+        self.tabwidth = 4
+        self.usetabs = False
+        self.context_use_ps1 = True
+
+    _make_blanks = EditorWindow._make_blanks
+    get_selection_indices = EditorWindow.get_selection_indices
+
+
+class FormatRegionTest(unittest.TestCase):
+
+    @classmethod
+    def setUpClass(cls):
+        requires('gui')
+        cls.root = Tk()
+        cls.root.withdraw()
+        cls.text = Text(cls.root)
+        cls.text.undo_block_start = mock.Mock()
+        cls.text.undo_block_stop = mock.Mock()
+        cls.editor = DummyEditwin(cls.root, cls.text)
+        cls.formatter = ft.FormatRegion(cls.editor)
+
+    @classmethod
+    def tearDownClass(cls):
+        del cls.text, cls.formatter, cls.editor
+        cls.root.update_idletasks()
+        cls.root.destroy()
+        del cls.root
+
+    def setUp(self):
+        self.text.insert('1.0', self.code_sample)
+
+    def tearDown(self):
+        self.text.delete('1.0', 'end')
+
+    code_sample = """\
+
+class C1():
+    # Class comment.
+    def __init__(self, a, b):
+        self.a = a
+        self.b = b
+
+    def compare(self):
+        if a > b:
+            return a
+        elif a < b:
+            return b
+        else:
+            return None
+"""
+
+    def test_get_region(self):
+        get = self.formatter.get_region
+        text = self.text
+        eq = self.assertEqual
+
+        # Add selection.
+        text.tag_add('sel', '7.0', '10.0')
+        expected_lines = ['',
+                          '    def compare(self):',
+                          '        if a > b:',
+                          '']
+        eq(get(), ('7.0', '10.0', '\n'.join(expected_lines), expected_lines))
+
+        # Remove selection.
+        text.tag_remove('sel', '1.0', 'end')
+        eq(get(), ('15.0', '16.0', '\n', ['', '']))
+
+    def test_set_region(self):
+        set_ = self.formatter.set_region
+        text = self.text
+        eq = self.assertEqual
+
+        save_bell = text.bell
+        text.bell = mock.Mock()
+        line6 = self.code_sample.splitlines()[5]
+        line10 = self.code_sample.splitlines()[9]
+
+        text.tag_add('sel', '6.0', '11.0')
+        head, tail, chars, lines = self.formatter.get_region()
+
+        # No changes.
+        set_(head, tail, chars, lines)
+        text.bell.assert_called_once()
+        eq(text.get('6.0', '11.0'), chars)
+        eq(text.get('sel.first', 'sel.last'), chars)
+        text.tag_remove('sel', '1.0', 'end')
+
+        # Alter selected lines by changing lines and adding a newline.
+        newstring = 'added line 1\n\n\n\n'
+        newlines = newstring.split('\n')
+        set_('7.0', '10.0', chars, newlines)
+        # Selection changed.
+        eq(text.get('sel.first', 'sel.last'), newstring)
+        # Additional line added, so last index is changed.
+        eq(text.get('7.0', '11.0'), newstring)
+        # Before and after lines unchanged.
+        eq(text.get('6.0', '7.0-1c'), line6)
+        eq(text.get('11.0', '12.0-1c'), line10)
+        text.tag_remove('sel', '1.0', 'end')
+
+        text.bell = save_bell
+
+    def test_indent_region_event(self):
+        indent = self.formatter.indent_region_event
+        text = self.text
+        eq = self.assertEqual
+
+        text.tag_add('sel', '7.0', '10.0')
+        indent()
+        # Blank lines aren't affected by indent.
+        eq(text.get('7.0', '10.0'), ('\n        def compare(self):\n            if a > b:\n'))
+
+    def test_dedent_region_event(self):
+        dedent = self.formatter.dedent_region_event
+        text = self.text
+        eq = self.assertEqual
+
+        text.tag_add('sel', '7.0', '10.0')
+        dedent()
+        # Blank lines aren't affected by dedent.
+        eq(text.get('7.0', '10.0'), ('\ndef compare(self):\n    if a > b:\n'))
+
+    def test_comment_region_event(self):
+        comment = self.formatter.comment_region_event
+        text = self.text
+        eq = self.assertEqual
+
+        text.tag_add('sel', '7.0', '10.0')
+        comment()
+        eq(text.get('7.0', '10.0'), ('##\n##    def compare(self):\n##        if a > b:\n'))
+
+    def test_uncomment_region_event(self):
+        comment = self.formatter.comment_region_event
+        uncomment = self.formatter.uncomment_region_event
+        text = self.text
+        eq = self.assertEqual
+
+        text.tag_add('sel', '7.0', '10.0')
+        comment()
+        uncomment()
+        eq(text.get('7.0', '10.0'), ('\n    def compare(self):\n        if a > b:\n'))
+
+        # Only remove comments at the beginning of a line.
+        text.tag_remove('sel', '1.0', 'end')
+        text.tag_add('sel', '3.0', '4.0')
+        uncomment()
+        eq(text.get('3.0', '3.end'), ('    # Class comment.'))
+
+        self.formatter.set_region('3.0', '4.0', '', ['# Class comment.', ''])
+        uncomment()
+        eq(text.get('3.0', '3.end'), (' Class comment.'))
+
+    @mock.patch.object(ft.FormatRegion, "_asktabwidth")
+    def test_tabify_region_event(self, _asktabwidth):
+        tabify = self.formatter.tabify_region_event
+        text = self.text
+        eq = self.assertEqual
+
+        text.tag_add('sel', '7.0', '10.0')
+        # No tabwidth selected.
+        _asktabwidth.return_value = None
+        self.assertIsNone(tabify())
+
+        _asktabwidth.return_value = 3
+        self.assertIsNotNone(tabify())
+        eq(text.get('7.0', '10.0'), ('\n\t def compare(self):\n\t\t  if a > b:\n'))
+
+    @mock.patch.object(ft.FormatRegion, "_asktabwidth")
+    def test_untabify_region_event(self, _asktabwidth):
+        untabify = self.formatter.untabify_region_event
+        text = self.text
+        eq = self.assertEqual
+
+        text.tag_add('sel', '7.0', '10.0')
+        # No tabwidth selected.
+        _asktabwidth.return_value = None
+        self.assertIsNone(untabify())
+
+        _asktabwidth.return_value = 2
+        self.formatter.tabify_region_event()
+        _asktabwidth.return_value = 3
+        self.assertIsNotNone(untabify())
+        eq(text.get('7.0', '10.0'), ('\n      def compare(self):\n            if a > b:\n'))
+
+    @mock.patch.object(ft, "askinteger")
+    def test_ask_tabwidth(self, askinteger):
+        ask = self.formatter._asktabwidth
+        askinteger.return_value = 10
+        self.assertEqual(ask(), 10)
+
+
 if __name__ == '__main__':
     unittest.main(verbosity=2, exit=2)
diff --git a/Lib/idlelib/mainmenu.py b/Lib/idlelib/mainmenu.py
index 1b8dc475650d..b0c85cf505c7 100644
--- a/Lib/idlelib/mainmenu.py
+++ b/Lib/idlelib/mainmenu.py
@@ -60,6 +60,7 @@
    ]),
 
  ('format', [
+   ('F_ormat Paragraph', '<<format-paragraph>>'),
    ('_Indent Region', '<<indent-region>>'),
    ('_Dedent Region', '<<dedent-region>>'),
    ('Comment _Out Region', '<<comment-region>>'),
@@ -68,7 +69,6 @@
    ('Untabify Region', '<<untabify-region>>'),
    ('Toggle Tabs', '<<toggle-tabs>>'),
    ('New Indent Width', '<<change-indentwidth>>'),
-   ('F_ormat Paragraph', '<<format-paragraph>>'),
    ('S_trip Trailing Whitespace', '<<do-rstrip>>'),
    ]),
 
diff --git a/Lib/idlelib/paragraph.py b/Lib/idlelib/paragraph.py
deleted file mode 100644
index 81422571fa32..000000000000
--- a/Lib/idlelib/paragraph.py
+++ /dev/null
@@ -1,194 +0,0 @@
-"""Format a paragraph, comment block, or selection to a max width.
-
-Does basic, standard text formatting, and also understands Python
-comment blocks. Thus, for editing Python source code, this
-extension is really only suitable for reformatting these comment
-blocks or triple-quoted strings.
-
-Known problems with comment reformatting:
-* If there is a selection marked, and the first line of the
-  selection is not complete, the block will probably not be detected
-  as comments, and will have the normal "text formatting" rules
-  applied.
-* If a comment block has leading whitespace that mixes tabs and
-  spaces, they will not be considered part of the same block.
-* Fancy comments, like this bulleted list, aren't handled :-)
-"""
-import re
-
-from idlelib.config import idleConf
-
-
-class FormatParagraph:
-
-    def __init__(self, editwin):
-        self.editwin = editwin
-
-    @classmethod
-    def reload(cls):
-        cls.max_width = idleConf.GetOption('extensions', 'FormatParagraph',
-                                           'max-width', type='int', default=72)
-
-    def close(self):
-        self.editwin = None
-
-    def format_paragraph_event(self, event, limit=None):
-        """Formats paragraph to a max width specified in idleConf.
-
-        If text is selected, format_paragraph_event will start breaking lines
-        at the max width, starting from the beginning selection.
-
-        If no text is selected, format_paragraph_event uses the current
-        cursor location to determine the paragraph (lines of text surrounded
-        by blank lines) and formats it.
-
-        The length limit parameter is for testing with a known value.
-        """
-        limit = self.max_width if limit is None else limit
-        text = self.editwin.text
-        first, last = self.editwin.get_selection_indices()
-        if first and last:
-            data = text.get(first, last)
-            comment_header = get_comment_header(data)
-        else:
-            first, last, comment_header, data = \
-                    find_paragraph(text, text.index("insert"))
-        if comment_header:
-            newdata = reformat_comment(data, limit, comment_header)
-        else:
-            newdata = reformat_paragraph(data, limit)
-        text.tag_remove("sel", "1.0", "end")
-
-        if newdata != data:
-            text.mark_set("insert", first)
-            text.undo_block_start()
-            text.delete(first, last)
-            text.insert(first, newdata)
-            text.undo_block_stop()
-        else:
-            text.mark_set("insert", last)
-        text.see("insert")
-        return "break"
-
-
-FormatParagraph.reload()
-
-def find_paragraph(text, mark):
-    """Returns the start/stop indices enclosing the paragraph that mark is in.
-
-    Also returns the comment format string, if any, and paragraph of text
-    between the start/stop indices.
-    """
-    lineno, col = map(int, mark.split("."))
-    line = text.get("%d.0" % lineno, "%d.end" % lineno)
-
-    # Look for start of next paragraph if the index passed in is a blank line
-    while text.compare("%d.0" % lineno, "<", "end") and is_all_white(line):
-        lineno = lineno + 1
-        line = text.get("%d.0" % lineno, "%d.end" % lineno)
-    first_lineno = lineno
-    comment_header = get_comment_header(line)
-    comment_header_len = len(comment_header)
-
-    # Once start line found, search for end of paragraph (a blank line)
-    while get_comment_header(line)==comment_header and \
-              not is_all_white(line[comment_header_len:]):
-        lineno = lineno + 1
-        line = text.get("%d.0" % lineno, "%d.end" % lineno)
-    last = "%d.0" % lineno
-
-    # Search back to beginning of paragraph (first blank line before)
-    lineno = first_lineno - 1
-    line = text.get("%d.0" % lineno, "%d.end" % lineno)
-    while lineno > 0 and \
-              get_comment_header(line)==comment_header and \
-              not is_all_white(line[comment_header_len:]):
-        lineno = lineno - 1
-        line = text.get("%d.0" % lineno, "%d.end" % lineno)
-    first = "%d.0" % (lineno+1)
-
-    return first, last, comment_header, text.get(first, last)
-
-# This should perhaps be replaced with textwrap.wrap
-def reformat_paragraph(data, limit):
-    """Return data reformatted to specified width (limit)."""
-    lines = data.split("\n")
-    i = 0
-    n = len(lines)
-    while i < n and is_all_white(lines[i]):
-        i = i+1
-    if i >= n:
-        return data
-    indent1 = get_indent(lines[i])
-    if i+1 < n and not is_all_white(lines[i+1]):
-        indent2 = get_indent(lines[i+1])
-    else:
-        indent2 = indent1
-    new = lines[:i]
-    partial = indent1
-    while i < n and not is_all_white(lines[i]):
-        # XXX Should take double space after period (etc.) into account
-        words = re.split(r"(\s+)", lines[i])
-        for j in range(0, len(words), 2):
-            word = words[j]
-            if not word:
-                continue # Can happen when line ends in whitespace
-            if len((partial + word).expandtabs()) > limit and \
-                   partial != indent1:
-                new.append(partial.rstrip())
-                partial = indent2
-            partial = partial + word + " "
-            if j+1 < len(words) and words[j+1] != " ":
-                partial = partial + " "
-        i = i+1
-    new.append(partial.rstrip())
-    # XXX Should reformat remaining paragraphs as well
-    new.extend(lines[i:])
-    return "\n".join(new)
-
-def reformat_comment(data, limit, comment_header):
-    """Return data reformatted to specified width with comment header."""
-
-    # Remove header from the comment lines
-    lc = len(comment_header)
-    data = "\n".join(line[lc:] for line in data.split("\n"))
-    # Reformat to maxformatwidth chars or a 20 char width,
-    # whichever is greater.
-    format_width = max(limit - len(comment_header), 20)
-    newdata = reformat_paragraph(data, format_width)
-    # re-split and re-insert the comment header.
-    newdata = newdata.split("\n")
-    # If the block ends in a \n, we don't want the comment prefix
-    # inserted after it. (Im not sure it makes sense to reformat a
-    # comment block that is not made of complete lines, but whatever!)
-    # Can't think of a clean solution, so we hack away
-    block_suffix = ""
-    if not newdata[-1]:
-        block_suffix = "\n"
-        newdata = newdata[:-1]
-    return '\n'.join(comment_header+line for line in newdata) + block_suffix
-
-def is_all_white(line):
-    """Return True if line is empty or all whitespace."""
-
-    return re.match(r"^\s*$", line) is not None
-
-def get_indent(line):
-    """Return the initial space or tab indent of line."""
-    return re.match(r"^([ \t]*)", line).group()
-
-def get_comment_header(line):
-    """Return string with leading whitespace and '#' from line or ''.
-
-    A null return indicates that the line is not a comment line. A non-
-    null return, such as '    #', will be used to find the other lines of
-    a comment block with the same  indent.
-    """
-    m = re.match(r"^([ \t]*#*)", line)
-    if m is None: return ""
-    return m.group(1)
-
-
-if __name__ == "__main__":
-    from unittest import main
-    main('idlelib.idle_test.test_paragraph', verbosity=2, exit=False)
diff --git a/Misc/NEWS.d/next/IDLE/2019-03-21-08-35-00.bpo-36390.OdDCGk.rst b/Misc/NEWS.d/next/IDLE/2019-03-21-08-35-00.bpo-36390.OdDCGk.rst
new file mode 100644
index 000000000000..fabc75fd1c6e
--- /dev/null
+++ b/Misc/NEWS.d/next/IDLE/2019-03-21-08-35-00.bpo-36390.OdDCGk.rst
@@ -0,0 +1,2 @@
+Rename paragraph.py to format.py and add region formatting methods
+from editor.py.  Add tests for the latter.



More information about the Python-checkins mailing list