44
55import asyncio
66import glob
7+ import itertools
78import logging
89import os
910import signal
3839from libcst .metadata import ParentNodeProvider , PositionProvider
3940
4041from . import DEFAULT_EXCLUDE , __version__
42+ from .const import SECTION_CHARS
4143from .debug import dump_node
4244from .docstrfmt import Manager
4345from .exceptions import InvalidRstErrors
@@ -62,12 +64,17 @@ def _format_file(
6264 mode : Mode ,
6365 docstring_trailing_line : bool ,
6466 format_python_code_blocks : bool ,
67+ section_adornments : list [tuple [str , bool ]] | None ,
6568 raw_output : bool ,
6669 lock : Lock ,
6770):
6871 error_count = 0
6972 manager = Manager (
70- reporter , mode , docstring_trailing_line , format_python_code_blocks
73+ reporter ,
74+ mode ,
75+ docstring_trailing_line ,
76+ format_python_code_blocks ,
77+ section_adornments ,
7178 )
7279 if file .name == "-" :
7380 raw_output = True
@@ -290,13 +297,32 @@ def _resolve_length(context: click.Context, _: click.Parameter, value: int | Non
290297 return value or pyproject_line_length
291298
292299
300+ def _validate_adornments (
301+ context : click .Context , _ : click .Parameter , value : str | None
302+ ) -> list [tuple [str , bool ]] | None :
303+ actual_value = value or context .params ["section_adornments" ]
304+
305+ if len (actual_value ) != len (set (actual_value )):
306+ msg = "Section adornments must be unique"
307+ raise click .BadParameter (msg )
308+
309+ if "|" in actual_value :
310+ with_overline , without_overline = actual_value .split ("|" , 1 )
311+ return list (zip (with_overline , itertools .repeat (True ))) + list (
312+ zip (without_overline , itertools .repeat (False ))
313+ )
314+
315+ return list (zip (actual_value , itertools .repeat (False )))
316+
317+
293318async def _run_formatter (
294319 check : bool ,
295320 file_type : str ,
296321 files : list [str ],
297322 include_txt : bool ,
298323 docstring_trailing_line : bool ,
299324 format_python_code_blocks : bool ,
325+ section_adornments : list [tuple [str , bool ]] | None ,
300326 mode : Mode ,
301327 line_length : int ,
302328 raw_output : bool ,
@@ -324,6 +350,7 @@ async def _run_formatter(
324350 mode ,
325351 docstring_trailing_line ,
326352 format_python_code_blocks ,
353+ section_adornments ,
327354 raw_output ,
328355 lock ,
329356 )
@@ -484,7 +511,25 @@ def leave_FunctionDef(
484511 self ._object_names .pop (- 1 )
485512 return updated_node
486513
487- def leave_SimpleString (
514+ def _escape_quoting (self , node : SimpleString ) -> SimpleString :
515+ """Escapes quotes in a docstring when necessary."""
516+ # handles quoting escaping once
517+ for quote in ('"' , "'" ):
518+ quoting = quote * 3
519+ if node .value .startswith (quoting ) and node .value .endswith (quoting ):
520+ inner_value = node .value [len (quoting ) : - len (quoting )]
521+ if quoting in inner_value :
522+ node = node .with_changes (
523+ value = quoting
524+ + inner_value .replace (quoting , f"\\ { quoting } " ).replace (
525+ quoting + quote , f"{ quoting } \\ { quote } "
526+ )
527+ + quoting
528+ )
529+ break
530+ return node
531+
532+ def leave_SimpleString ( # noqa: N802
488533 self , original_node : SimpleString , updated_node : SimpleString
489534 ) -> SimpleString :
490535 """Format the docstring."""
@@ -551,7 +596,7 @@ def leave_SimpleString(
551596 self ._last_assign = None
552597 self ._object_names .pop (- 1 )
553598 self ._object_type = old_object_type
554- return updated_node
599+ return self . _escape_quoting ( updated_node )
555600
556601 def visit_AssignTarget_target (self , node : AssignTarget ) -> None :
557602 """Set the last assign node."""
@@ -584,7 +629,7 @@ def visit_Module(self, node: Module) -> bool | None:
584629 is_flag = True ,
585630 help = (
586631 "Check files and returns a non-zero code if files are not formatted correctly."
587- " Useful for linting. Ignored if raw-input, raw-output, stdin is used."
632+ " Useful for linting. Ignored if -- raw-input, -- raw-output, or stdin is used."
588633 ),
589634)
590635@click .option (
@@ -650,6 +695,12 @@ def visit_Module(self, node: Module) -> bool | None:
650695 ),
651696 callback = _resolve_length ,
652697)
698+ @click .option (
699+ "-pA" ,
700+ "--preserve-adornments" ,
701+ help = "Preserve existing section adornments." ,
702+ is_flag = True ,
703+ )
653704@click .option (
654705 "-p" ,
655706 "--pyproject-config" ,
@@ -685,7 +736,26 @@ def visit_Module(self, node: Module) -> bool | None:
685736 type = str ,
686737)
687738@click .option (
688- "-o" , "--raw-output" , is_flag = True , help = "Output the formatted text to stdout."
739+ "-o" ,
740+ "--raw-output" ,
741+ help = "Output the formatted text to stdout." ,
742+ is_flag = True ,
743+ )
744+ @click .option (
745+ "-s" ,
746+ "--section-adornments" ,
747+ type = str ,
748+ default = SECTION_CHARS ,
749+ show_default = True ,
750+ help = (
751+ "Define adornments for part/chapter/section headers. It defines a sequence of"
752+ " adornments to use for each individual section depth. The list must be"
753+ " composed of at least N **distinct** characters for documents with N section"
754+ " depths. Provide more if unsure. If the special character `|` (pipe) is"
755+ " used, then it defines sections (left portion) that will have overlines"
756+ " besides underlines only (right portion). Overrides --preserve-adornments."
757+ ),
758+ callback = _validate_adornments ,
689759)
690760@click .option (
691761 "-v" ,
@@ -710,10 +780,12 @@ def main(
710780 ignore_cache : bool ,
711781 include_txt : bool ,
712782 line_length : int ,
783+ preserve_adornments : bool ,
713784 mode : Mode ,
714785 quiet : bool ,
715786 raw_input : str ,
716787 raw_output : bool ,
788+ section_adornments : list [tuple [str , bool ]],
717789 verbose : int ,
718790 files : list [str ],
719791) -> None :
@@ -732,9 +804,17 @@ def main(
732804 else :
733805 line_length = DEFAULT_LINE_LENGTH
734806 error_count = 0
807+
808+ if preserve_adornments :
809+ section_adornments = None
810+
735811 if raw_input :
736812 manager = Manager (
737- reporter , mode , docstring_trailing_line , format_python_code_blocks
813+ reporter ,
814+ mode ,
815+ docstring_trailing_line ,
816+ format_python_code_blocks ,
817+ section_adornments ,
738818 )
739819 file = "<raw_input>"
740820 check = False
@@ -769,6 +849,7 @@ def main(
769849 mode ,
770850 docstring_trailing_line ,
771851 format_python_code_blocks ,
852+ section_adornments ,
772853 raw_output ,
773854 None ,
774855 )
@@ -800,6 +881,7 @@ def main(
800881 include_txt ,
801882 docstring_trailing_line ,
802883 format_python_code_blocks ,
884+ section_adornments ,
803885 mode ,
804886 line_length ,
805887 raw_output ,
0 commit comments