Skip to content

Commit bd12d34

Browse files
anjosLilSpazJoekp
andauthored
Implement configurable section header adornments (#120)
Implement configurable section header adornments. Co-authored-by: LilSpazJoekp <15524072+LilSpazJoekp@users.noreply.github.com>
1 parent 7fba2d0 commit bd12d34

File tree

6 files changed

+721
-54
lines changed

6 files changed

+721
-54
lines changed

CHANGES.rst

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,17 @@ Unreleased
88

99
- Added support for Python 3.14.
1010
- Added ability to skip formatting Python code blocks with the
11-
``--format-python-code-blocks/--no-format-python-code-blocks`` or ``-N`` flag.
11+
``--format-python-code-blocks/--no-format-python-code-blocks`` or ``-N`` command-line
12+
argument.
13+
- Added command-line option ``--section-adornments`` or ``-s`` to control section header
14+
adornments.
15+
- Added command-line argument ``--preserve-adornments`` or ``-pA`` to preserve
16+
user-defined section adornments.
17+
18+
**Changed**
19+
20+
- Changed default section heading adornments to match the ones described at
21+
https://devguide.python.org/documentation/markup/#sections.
1222

1323
**Fixed**
1424

docstrfmt/const.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818
"**/build",
1919
"**/dist",
2020
]
21-
SECTION_CHARS = "=-~+.'\"`^_*:#"
21+
# Part/Chapter/Section adornment characters. The special `|` character separated
22+
# sections without overlines. If that is not present, then we consider all sections to
23+
# only contain underlines. From:
24+
# https://devguide.python.org/documentation/markup/#sections
25+
SECTION_CHARS = "#*|=-^\"'~+.`_:"
2226
ROLE_ALIASES = {
2327
"pep": "PEP",
2428
"pep-reference": "PEP",

docstrfmt/docstrfmt.py

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import black
2121
import docutils
22+
import docutils.nodes
2223
from black import Mode
2324
from blib2to3.pgen2.tokenize import TokenError
2425
from docutils import nodes
@@ -29,7 +30,6 @@
2930
from docutils.utils import Reporter, new_document, unescape
3031

3132
from . import rst_extras
32-
from .const import SECTION_CHARS
3333
from .exceptions import InvalidRstError, InvalidRstErrors
3434
from .rst_extras import _add_directive, generic_role
3535
from .util import make_enumerator
@@ -151,6 +151,7 @@ def __init__(
151151
self.ordinal_format = "arabic"
152152
self.section_depth = 0
153153
self.subsequent_indent = 0
154+
self.use_adornments = None
154155
for key, value in kwargs.items():
155156
setattr(self, key, value)
156157

@@ -243,6 +244,7 @@ def __init__(
243244
black_config: Mode | None = None,
244245
docstring_trailing_line: bool = True,
245246
format_python_code_blocks: bool = True,
247+
section_adornments: list[tuple[str, bool]] | None = None,
246248
):
247249
"""Initialize the manager."""
248250
rst_extras.register()
@@ -262,6 +264,7 @@ def __init__(
262264
self.docstring_trailing_line = docstring_trailing_line
263265
self.format_python_code_blocks = format_python_code_blocks
264266
self._in_docstring = False # for resolving line numbers in code blocks
267+
self.section_adornments = section_adornments
265268

266269
def _patch_unknown_directives(self, text: str) -> None:
267270
"""Patch unknown directives and roles into the parser."""
@@ -365,6 +368,46 @@ def format_node(
365368
)
366369
return f"{formatted_node}\n"
367370

371+
def _register_adornments(
372+
self, input_lines: list[str], document: nodes.document
373+
) -> None:
374+
"""Register adornments from source text on all individual sections.
375+
376+
This method will parse the document tree and original text to-be-formatted, and
377+
will register, at the document tree, the current document configuration
378+
representing the adornments for parts, chapters and sections on each level of
379+
the document. In particular, it will install an attribute called
380+
``adornment-character`` with the character used for underline or overlining the
381+
section, and ``adornment-overline``, if the section should be overlined or not.
382+
383+
input_lines
384+
The lines of input (splitted by newline), that we must format.
385+
386+
document
387+
The pre-parsed document tree, that will be modified with new section
388+
attributes as described above.
389+
390+
"""
391+
for section in document.traverse(nodes.section):
392+
title_node = section.next_node(nodes.title)
393+
if (
394+
title_node
395+
and hasattr(title_node, "line")
396+
and title_node.line is not None
397+
):
398+
underline = input_lines[title_node.line - 1].strip()[0]
399+
overline_lineno = title_node.line - 3
400+
overline = False
401+
402+
if overline_lineno >= 0:
403+
candidate_overline = input_lines[overline_lineno].strip()
404+
if candidate_overline and candidate_overline[0] == underline:
405+
overline = True
406+
407+
# Store this information in the document tree
408+
section["adornment-character"] = underline
409+
section["adornment-overline"] = overline
410+
368411
def get_code_line(self, code: str, strict: bool = False) -> int:
369412
"""Get the line number of the code in the file."""
370413
lines = self.original_text.splitlines()
@@ -407,7 +450,9 @@ def parse_string(
407450
"", self.settings.report_level, self.settings.halt_level
408451
)
409452
parser.parse(text, doc)
410-
self._pre_process(doc, line_offset, len(text.splitlines()))
453+
input_lines = text.splitlines()
454+
self._pre_process(doc, line_offset, len(input_lines))
455+
self._register_adornments(input_lines, doc)
411456
return doc
412457

413458
def perform_format(
@@ -1643,9 +1688,31 @@ def title(
16431688
None, chain(self._format_children(node, context)), context, node.line
16441689
)
16451690
)
1646-
char = SECTION_CHARS[context.section_depth - 1]
1647-
yield text
1648-
yield char * len(text)
1691+
char: str = node.parent["adornment-character"]
1692+
overline: bool = node.parent["adornment-overline"]
1693+
if context.manager.section_adornments is not None:
1694+
try:
1695+
char, overline = context.manager.section_adornments[
1696+
context.section_depth - 1
1697+
]
1698+
except IndexError:
1699+
context.manager.reporter.error(
1700+
f"Section at line {node.line} is at depth "
1701+
f"{context.section_depth}, however there are only "
1702+
f"{len(context.manager.section_adornments)} adornments to pick "
1703+
"from. You must review your inputs or change settings."
1704+
)
1705+
raise
1706+
1707+
if overline:
1708+
# section headings with overline are centered
1709+
yield char * (2 + len(text))
1710+
yield " " + text
1711+
yield char * (2 + len(text))
1712+
else:
1713+
# sections headings without overline are justified
1714+
yield text
1715+
yield char * len(text)
16491716

16501717
def title_reference(
16511718
self,

docstrfmt/main.py

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import asyncio
66
import glob
7+
import itertools
78
import logging
89
import os
910
import signal
@@ -38,6 +39,7 @@
3839
from libcst.metadata import ParentNodeProvider, PositionProvider
3940

4041
from . import DEFAULT_EXCLUDE, __version__
42+
from .const import SECTION_CHARS
4143
from .debug import dump_node
4244
from .docstrfmt import Manager
4345
from .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+
293318
async 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

Comments
 (0)