1212from pyflyby ._importclns import ImportSet , NoSuchImportError
1313from pyflyby ._importdb import ImportDB
1414from pyflyby ._importstmt import (Import , ImportFormatParams ,
15- ImportStatement )
15+ ImportStatement ,
16+ NonImportStatementError )
1617from pyflyby ._log import logger
1718from pyflyby ._parse import PythonBlock , PythonStatement
1819from pyflyby ._util import ImportPathCtx , Inf , NullCtx , memoize
2829_IMPORT_TYPES = (ast .Import , ast .ImportFrom )
2930
3031
31- def _group_consecutive_imports (body : list ) -> list [list ]:
32+ def _group_consecutive_imports (
33+ body : list [ast .stmt ],
34+ ) -> list [list [Union [ast .Import , ast .ImportFrom ]]]:
3235 """Group consecutive import statements from an AST body.
3336
3437 Parameters
@@ -194,6 +197,48 @@ class NoImportBlockError(Exception):
194197class ImportAlreadyExistsError (Exception ):
195198 pass
196199
200+
201+ def _maybe_insert_pass (
202+ lines : list [str ], idx : int , indent : int , lineno : int
203+ ) -> None :
204+ """Insert a ``pass`` statement at *idx* when removing a line would leave a
205+ block-opener (a line ending with ``:``) without a body.
206+
207+ After a line has been deleted at position *idx*, this function walks
208+ backwards to find the nearest non-empty preceding line. If that line ends
209+ with ``:`` (a compound-statement header such as ``def``, ``class``,
210+ ``if``, etc.) and the next non-empty line after *idx* is at an equal or
211+ lower indentation level, the block body is gone and a ``pass`` statement
212+ is inserted at *idx* using *indent* spaces.
213+
214+ :param lines: Source lines of the block, already modified (the import line
215+ has been deleted before this call).
216+ :param idx: Index into *lines* where the deleted line used to be.
217+ :param indent: Column offset (number of leading spaces) of the deleted line,
218+ used to indent the inserted ``pass``.
219+ :param lineno: Absolute line number in the file (used only for logging).
220+ """
221+ prev_idx = idx - 1
222+ while prev_idx >= 0 and lines [prev_idx ].strip () == "" :
223+ prev_idx -= 1
224+ if prev_idx < 0 :
225+ return
226+ prev_line = lines [prev_idx ]
227+ if not prev_line .rstrip ().endswith (":" ):
228+ return
229+ prev_indent = len (prev_line ) - len (prev_line .lstrip ())
230+ next_idx = idx
231+ while next_idx < len (lines ) and lines [next_idx ].strip () == "" :
232+ next_idx += 1
233+ body_gone = (
234+ next_idx >= len (lines )
235+ or (len (lines [next_idx ]) - len (lines [next_idx ].lstrip ())) <= prev_indent
236+ )
237+ if body_gone :
238+ lines .insert (idx , " " * indent + "pass" )
239+ logger .debug ("Inserted 'pass' at line %d to preserve empty block" , lineno )
240+
241+
197242class SourceToSourceFileImportsTransformation (SourceToSourceTransformationBase ):
198243 blocks : list [Union [SourceToSourceImportBlockTransformation , SourceToSourceTransformation ]]
199244 import_blocks : list [Union [SourceToSourceImportBlockTransformation , _LocalImportBlockWrapper ]]
@@ -231,7 +276,11 @@ def preprocess(self) -> None:
231276 logger .debug ("preprocess: extracted %d total import blocks" , len (self .import_blocks ))
232277
233278 def _create_import_block_from_group (
234- self , group : list , lines : list , start_line : int , end_line : int
279+ self ,
280+ group : list [Union [ast .Import , ast .ImportFrom ]],
281+ lines : list [str ],
282+ start_line : int ,
283+ end_line : int ,
235284 ) -> None :
236285 """Create an import block from a group of import statements.
237286
@@ -240,34 +289,49 @@ def _create_import_block_from_group(
240289
241290 Parameters
242291 ----------
243- group : list
244- List of consecutive import AST nodes
245- lines : list
246- Lines of the full source text
292+ group : list[ast.Import | ast.ImportFrom]
293+ Consecutive import AST nodes to extract.
294+ lines : list[str]
295+ All source lines of the file (1-indexed via ``lines[lineno - 1]``).
247296 start_line : int
248- Starting line number of the group
297+ First line number of the group (1-indexed).
249298 end_line : int
250- Ending line number of the group
299+ Last line number of the group, accounting for multiline imports.
251300 """
252301 import_text_parts = []
253302 semicolon_suffixes = {}
254303
255304 for node in group :
256305 lineno = node .lineno
257- line : str = lines [ lineno - 1 ]
306+ end_lineno = getattr ( node , "end_lineno" , lineno )
258307
259308 if hasattr (node , "col_offset" ) and hasattr (node , "end_col_offset" ):
260- # WARNING offsets seem to be in number of bytes!
261- import_stmt = line .encode ()[
262- node .col_offset : node .end_col_offset
263- ].decode ()
264- remaining = line .encode ()[node .end_col_offset :].decode ().lstrip ()
265- if remaining and remaining .startswith (";" ):
309+ # WARNING: col_offset/end_col_offset are in bytes, not characters.
310+ if end_lineno > lineno :
311+ # Multiline import (e.g. "from foo import (\n a,\n b\n)").
312+ # end_col_offset refers to the last line, not the first, so we
313+ # must collect all source lines and trim each end independently.
314+ first = lines [lineno - 1 ].encode ()
315+ last = lines [end_lineno - 1 ].encode ()
316+ node_lines = [first [node .col_offset :].decode ()]
317+ for mid in range (lineno + 1 , end_lineno ):
318+ node_lines .append (lines [mid - 1 ])
319+ node_lines .append (last [: node .end_col_offset ].decode ())
320+ import_stmt = "\n " .join (node_lines )
321+ remaining = last [node .end_col_offset :].decode ().lstrip ()
322+ else :
323+ line : str = lines [lineno - 1 ]
324+ import_stmt = line .encode ()[
325+ node .col_offset : node .end_col_offset
326+ ].decode ()
327+ remaining = line .encode ()[node .end_col_offset :].decode ().lstrip ()
328+
329+ if remaining .startswith (";" ):
266330 suffix = remaining [1 :].lstrip ()
267331 if suffix :
268332 semicolon_suffixes [lineno ] = suffix
269333 else :
270- import_stmt = line
334+ import_stmt = lines [ lineno - 1 ]
271335
272336 import_text_parts .append (import_stmt )
273337
@@ -276,7 +340,7 @@ def _create_import_block_from_group(
276340 try :
277341 import_block = PythonBlock (import_text )
278342 trans = SourceToSourceImportBlockTransformation (import_block )
279- except (SyntaxError , ValueError ) as e :
343+ except (SyntaxError , ValueError , NonImportStatementError ) as e :
280344 logger .debug (
281345 "Failed to create import block for lines %d-%d: %s" ,
282346 start_line ,
@@ -329,7 +393,7 @@ def _extract_imports_from_statement(
329393
330394 for group in import_groups :
331395 start_line = group [0 ].lineno
332- end_line = group [- 1 ].lineno
396+ end_line = getattr ( group [- 1 ], "end_lineno" , group [ - 1 ] .lineno )
333397 self ._create_import_block_from_group (group , lines , start_line , end_line )
334398
335399 # Recursively check nested function/class definitions
@@ -589,6 +653,7 @@ def remove_import(self, imp, lineno):
589653 return imp
590654
591655 def _remove_local_import_from_blocks (self , imp , lineno ):
656+
592657 """
593658 Remove a local import from the actual code blocks.
594659 This modifies the _output of blocks in self.blocks to remove the import line.
@@ -627,8 +692,9 @@ def _remove_local_import_from_blocks(self, imp, lineno):
627692 offset ,
628693 line_to_remove .strip (),
629694 )
630- # Remove this line
695+ removed_indent = len ( line_to_remove ) - len ( line_to_remove . lstrip ())
631696 del lines [relative_lineno ]
697+ _maybe_insert_pass (lines , relative_lineno , removed_indent , lineno )
632698
633699 # Track that we removed a line from this block
634700 self ._removed_lines_per_block [block_idx ] = offset + 1
0 commit comments