Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

PyCharm: Run `black -S` on region

Tags:

We are not ready to automatically format the whole source code with black.

But from time to time I would like to execute black -S on a region via PyCharm.

There is a hint in the docs how to run black (or black -S (what I like)) on the whole file. But ...

How to run black only on a selected region?

like image 960
guettli Avatar asked Jan 29 '21 08:01

guettli


1 Answers

Using Python Black on a code region in the PyCharm IDE can be done by implementing it as an external tool. Currently Black has two main options to choose the code to format

  1. Run Black on the whole module specifying it on the CLI as the [SRC]...
  2. Passing the code region as a string on the CLI using the -c, --code TEXT option.

The following implementation shows how to do this using the 2nd option. The reason is that applying Black to the whole module is likely to change the number of lines thus making the job of selecting the code region by choosing start and end line numbers more complicated.

Implementing the 1st option can be done, but it would require mapping the initial code region to the final code region after Black formats the entire module.

Lets take as example the following code that has a number of obvious PEP-8 violations (missing white-spaces and empty lines):

"""
long multi-line
comment
"""
def foo(token:int=None)->None:
  a=token+1

class bar:
  foo:int=None
  
def the_simple_test():
    """the_simple_test"""
    pass

Step 1.

Using Black as an external tool in the IDE can be configured by going to File > Tools > External Tools and clicking the Add or Edit icons.

What is of interesst is passing the right Macros - (see point 3 "Parameter with macros") from the PyCharm IDE to the custom script that calls Black and does the necessary processing. Namely you'll need the Macros

  • FilePath - File Path
  • SelectionStartLine - Selected text start line number
  • SelectionEndLine - Select text end line number
  • PyInterpreterDirectory - The directory containing the Python interpreter selected for the project

But from time to time I would like to execute black -S on a region via PyCharm.

Any additional Black CLI options you want to pass as arguments are best placed at the end of the parameter list.

Since you may have Black installed on a specific venv, the example also uses the PyInterpreterDirectory macro.

The screenshot illustrates the above:

enter image description here

Step 2.

You'll need to implement a script to call Black and interface with the IDE. The following is a working example. It should be noted:

  1. Four lines are OS/shell specific as commented (it should be trivial to adapt them to your environment).
  2. Some details could be further tweaked, for purpose of example the implementation makes simplistic choices.
import os
import pathlib
import tempfile
import subprocess
import sys

def region_to_str(file_path: pathlib.Path, start_line: int, end_line: int) -> str:

    file = open(file_path)
    str_build = list()

    for line_number, line in enumerate(file, start=1):
        if line_number > end_line:
            break
        elif line_number < start_line:
            continue
        else:
            str_build.append(line)

    return "".join(str_build)

def black_to_clipboard(py_interpeter, black_cli_options, code_region_str):

    py_interpreter_path = pathlib.Path(py_interpeter) / "python.exe"  # OS specific, .exe for Windows.

    proc = subprocess.Popen([py_interpreter_path, "-m", "black", *black_cli_options,
                             "-c", code_region_str], stdout=subprocess.PIPE)

    try:
        outs, errs = proc.communicate(timeout=15)
    except TimeoutExpired:
        proc.kill()
        outs, errs = proc.communicate()

    # By default Black outputs binary, decodes to default Python module utf-8 encoding.
    result = outs.decode('utf-8').replace('\r','')  # OS specific, remove \r from \n\r Windows new-line.

    tmp_dir_name = tempfile.gettempdir()
    tmp_file = tempfile.gettempdir() + "\\__run_black_tmp.txt"  # OS specific, escaped path separator.

    with open(tmp_file, mode='w+', encoding='utf-8', errors='strict') as out_file:
        out_file.write(result + '\n')

    command = 'clip < ' + str(tmp_file)  # OS specific, send result to clipboard for copy-paste.
    os.system(command)

def main(argv: list[str] = sys.argv[1:]) -> int:
    """External tool script to run black on a code region.

    Args:
        argv[0] (str): Path to module containing code region.
        argv[1] (str): Code region start line.
        argv[2] (str): Code region end line.
        argv[3] (str): Path to venv /Scripts directory.
        argv[4:] (str): Black CLI options.
    """
    # print(argv)
    lines_as_str = region_to_str(argv[0], int(argv[1]), int(argv[2]))
    black_to_clipboard(argv[3], argv[4:], lines_as_str)

if __name__ == "__main__":
    main(sys.argv[1:])

Step 3.

The hard part is done. Lets use the new functionality.

Normally select the lines you want as your code region in the editor. This has to be emphasized because the previous SelectionStartLine and SelectionEndLine macros need the selection to work. (See the next screenshot).

Step 4.

Run the external tool previously implemented. This can be done by right clicking in the editor and choosing External Tools > the_name_of_your_external_tool.

enter image description here

Step 5.

Simply paste (the screenshot shows the result after running the external tool and pressing Ctrl + v). The implementation in Step 2 copies Black's output to your OS's clipboard, this seemed like the preferable solution since this way you change the file inside the editor thus Undo Ctrl + z will also work. Changing the file by overwrite it programmatically outside the editor would be less seamless and might require refreshing it inside the editor.

enter image description here

Step 6.

You can record a macro of the previous steps and associate it with a keyboard shortcut to have the above functionality in one keystroke (similar to copy-paste Ctrl + c + Ctrl + v).

enter image description here

End Notes.

  1. If you need to debug the functionality in Step 2 a Run Configuration can also be configured using the same macros the external tool configuration did.

  2. It's important to notice when using the clipboard that character encodings can change across the layers. I decided to use clip and read into it directly from a temporary file, this was to avoid passing the code string to Black on the command line because the CMD Windows encoding is not UTF-8 by default. (For Linux users this should be simpler but can depend on your system settings.)

  3. One important note is that you can choose a code region without the broader context of its indentation level. Meaning, for example, if you only choose 2 methods inside a class they will be passed to Black and formatted with the indentation level of module level functions. This shouldn't be a problem if you are careful to select code regions with their proper scope. This could also easily be solved by passing the additional macro SelectionStartColumn - Select text start column number from Step 1 and prepending that number of whitespaces to each line in the Step 2 script. (Ideally such functionality would be implemented by Black as a CLI option.) In any case, if needed, using Tab to put the region in its proper indentation level is easy enough.

  4. The main topic of the question is how to integrating Black with the PyCharm IDE for a code region, so demonstrating the 2nd option should be enough to address the problem because the 1st option would, for the most part, only add implementation specific complexity. (The answer is long enough as it is. The specifics of implementing the 1st option would make a good Feature/Pull Request for the Black project.)

like image 63
bad_coder Avatar answered Sep 30 '22 16:09

bad_coder