docstrfmt 2.0.2__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docstrfmt
3
- Version: 2.0.2
3
+ Version: 2.1.0
4
4
  Summary: docstrfmt: A formatter for Sphinx flavored reStructuredText.
5
5
  Keywords: black,docutils,autoformatter,formatter,lint,restructuredtext,rst,sphinx
6
6
  Author-email: Joel Payne <lilspazjoekp@gmail.com>
@@ -32,9 +32,9 @@ Requires-Dist: libcst>=1
32
32
  Requires-Dist: platformdirs>=4
33
33
  Requires-Dist: roman
34
34
  Requires-Dist: sphinx>=7
35
- Requires-Dist: tabulate>=0.9
35
+ Requires-Dist: tabulate>=0.10.0
36
36
  Requires-Dist: tomli>=0.10;python_version<'3.11'
37
- Requires-Dist: types-docutils==0.22.3.20251115
37
+ Requires-Dist: types-docutils==0.22.3.20260518
38
38
  Requires-Dist: aiohttp ; extra == "d"
39
39
  Project-URL: Issue Tracker, https://github.com/LilSpazJoekp/docstrfmt/issues
40
40
  Project-URL: Source Code, https://github.com/LilSpazJoekp/docstrfmt
@@ -9,4 +9,4 @@ from .const import (
9
9
  )
10
10
  from .docstrfmt import Manager
11
11
 
12
- __version__ = "2.0.2"
12
+ __version__ = "2.1.0"
@@ -25,6 +25,7 @@ from docutils import nodes, utils
25
25
  from docutils.frontend import OptionParser
26
26
  from docutils.parsers import rst
27
27
  from docutils.parsers.rst import Directive, roles
28
+ from docutils.statemachine import StringList
28
29
  from docutils.transforms import Transform
29
30
  from docutils.utils import new_document, unescape
30
31
 
@@ -167,7 +168,7 @@ class FormatContext:
167
168
  self.manager = manager
168
169
  self.black_config = black_config
169
170
  self.starting_width = width
170
- self.bullet: str = ""
171
+ self.bullet: str = "-"
171
172
  self.column_widths = []
172
173
  self.current_ordinal = 0
173
174
  self.first_line_len: int = 0
@@ -338,6 +339,7 @@ class CodeFormatters:
338
339
 
339
340
  """
340
341
  manager = self.context.manager
342
+ manager.original_text = self.code
341
343
  try:
342
344
  document = manager.parse_string(
343
345
  self.code, line_offset=manager.get_code_line(self.code) - 1
@@ -364,6 +366,8 @@ class Manager:
364
366
  *,
365
367
  current_file: Path | str,
366
368
  black_config: Mode | None = None,
369
+ center_section_titles: bool = True,
370
+ bullet_list_marker: str = "-",
367
371
  docstring_trailing_line: bool = True,
368
372
  format_python_code_blocks: bool = True,
369
373
  reporter: Reporter | utils.Reporter | logging.Logger,
@@ -374,6 +378,9 @@ class Manager:
374
378
  :param current_file: The current file being processed.
375
379
  :param reporter: utils.Reporter instance for logging.
376
380
  :param black_config: Black formatting configuration.
381
+ :param center_section_titles: Whether to center section titles with overlines
382
+ by adding a leading space.
383
+ :param bullet_list_marker: Bullet character to use for unordered lists.
377
384
  :param docstring_trailing_line: Whether to add trailing line to docstrings.
378
385
  :param format_python_code_blocks: Whether to format Python code blocks.
379
386
  :param section_adornments: Section adornment configuration.
@@ -382,6 +389,8 @@ class Manager:
382
389
  rst_extras.register()
383
390
  self.current_file = current_file
384
391
  self.black_config = black_config
392
+ self.center_section_titles = center_section_titles
393
+ self.bullet_list_marker = bullet_list_marker
385
394
  self.current_offset = 0
386
395
  self.error_count = 0
387
396
  self.reporter = reporter
@@ -901,7 +910,9 @@ class Formatters:
901
910
  - Third item
902
911
 
903
912
  """
904
- yield from self._list(node, context.with_bullet("-"))
913
+ yield from self._list(
914
+ node, context.with_bullet(context.manager.bullet_list_marker)
915
+ )
905
916
 
906
917
  def comment(
907
918
  self,
@@ -1008,6 +1019,18 @@ class Formatters:
1008
1019
  directive = attributes["directive"]
1009
1020
  is_code_block = directive.name in ["code", "code-block", "sourcecode"]
1010
1021
  in_substitution = isinstance(node.parent, nodes.substitution_definition)
1022
+ if (
1023
+ directive.name
1024
+ in ["deprecated", "versionadded", "versionchanged", "versionremoved"]
1025
+ and len(directive.arguments) > 1
1026
+ ):
1027
+ # These directives have a required argument that we want to preserve, but
1028
+ # the content is just a normal paragraph that we can format like usual.
1029
+ # If there is more than 1 argument, then those need to be moved to the
1030
+ # content attribute.
1031
+ directive.content = StringList(directive.arguments[1:])
1032
+ directive.arguments = directive.arguments[:1]
1033
+
1011
1034
  parts = [
1012
1035
  f".. {'code-block' if is_code_block else directive.name}::",
1013
1036
  *directive.arguments,
@@ -1574,9 +1597,9 @@ class Formatters:
1574
1597
 
1575
1598
  """
1576
1599
  if not node.children: # pragma: no cover
1577
- yield "-" # no idea why this isn't covered anymore
1600
+ yield context.bullet # no idea why this isn't covered anymore
1578
1601
  return
1579
- if context.current_ordinal and context.bullet not in ["-", "*", "+"]:
1602
+ if context.current_ordinal:
1580
1603
  context.bullet = make_enumerator(
1581
1604
  context.current_ordinal, context.ordinal_format, ("", ".")
1582
1605
  )
@@ -1596,7 +1619,7 @@ class Formatters:
1596
1619
  node: nodes.literal,
1597
1620
  context: FormatContext,
1598
1621
  ) -> inline_iterator:
1599
- """Format a literal node.
1622
+ r"""Format a literal node.
1600
1623
 
1601
1624
  Example:
1602
1625
 
@@ -1604,7 +1627,23 @@ class Formatters:
1604
1627
 
1605
1628
  This is ``literal`` text.
1606
1629
 
1630
+ If the literal needs to end with a space:
1631
+
1632
+ .. code-block:: rst
1633
+
1634
+ This is a :literal:`literal with a trailing space \ ` that is not surrounded
1635
+ with (``).
1636
+
1607
1637
  """
1638
+ if node.rawsource.startswith(":literal:") and node.rawsource.endswith(
1639
+ (r"\ `", "\\\n`")
1640
+ ):
1641
+ # When the node ends with a backslash followed by a space or new line, don't
1642
+ # remove the :literal: role and just return the node untouched. This is a
1643
+ # workaround for specific edge cases in docutils.
1644
+ yield inline_markup(node.rawsource)
1645
+ return
1646
+
1608
1647
  yield inline_markup(
1609
1648
  f"``{''.join(chain(self._format_children(node, context)))}``"
1610
1649
  )
@@ -2203,10 +2242,16 @@ class Formatters:
2203
2242
  raise
2204
2243
 
2205
2244
  if overline:
2206
- # section headings with overline are centered
2207
- yield char * (2 + len(text))
2208
- yield " " + text
2209
- yield char * (2 + len(text))
2245
+ if context.manager.center_section_titles:
2246
+ # section headings with overline are centered
2247
+ yield char * (2 + len(text))
2248
+ yield " " + text
2249
+ yield char * (2 + len(text))
2250
+ else:
2251
+ # section headings with overline are not centered
2252
+ yield char * len(text)
2253
+ yield text
2254
+ yield char * len(text)
2210
2255
  else:
2211
2256
  # sections headings without overline are justified
2212
2257
  yield text
@@ -66,6 +66,8 @@ def _format_file(
66
66
  section_adornments: list[tuple[str, bool]] | None,
67
67
  raw_output: bool,
68
68
  lock: Lock | None,
69
+ bullet_list_marker: str = "-",
70
+ center_section_titles: bool = True,
69
71
  ):
70
72
  """Format a single file with the given parameters.
71
73
 
@@ -80,6 +82,8 @@ def _format_file(
80
82
  :param section_adornments: Section adornment configuration.
81
83
  :param raw_output: Whether to output raw formatted text.
82
84
  :param lock: Lock for thread safety.
85
+ :param bullet_list_marker: Bullet character to use for unordered lists.
86
+ :param center_section_titles: Whether to center section titles with overlines.
83
87
 
84
88
  :returns: A tuple containing a boolean indicating if the file was misformatted and
85
89
  the number of errors.
@@ -89,6 +93,8 @@ def _format_file(
89
93
  manager = Manager(
90
94
  current_file=file.name,
91
95
  black_config=mode,
96
+ bullet_list_marker=bullet_list_marker,
97
+ center_section_titles=center_section_titles,
92
98
  docstring_trailing_line=docstring_trailing_line,
93
99
  format_python_code_blocks=format_python_code_blocks,
94
100
  reporter=reporter,
@@ -181,14 +187,14 @@ def _parse_pyproject_config(
181
187
  config_value = config.get(key)
182
188
  if config_value is not None and not isinstance(config_value, list):
183
189
  raise click.BadOptionUsage(key, f"Config key {key} must be a list")
184
- params = {}
190
+ # Expose config values through the default map only; writing them
191
+ # directly into context.params conflicts with how click >=8.4
192
+ # arbitrates which parameter owns a value slot.
193
+ default_map = {}
185
194
  if context.default_map is not None: # pragma: no cover
186
- params.update(context.default_map)
187
- if context.params is not None:
188
- params.update(context.params)
189
- params.update(config)
190
- context.params = params
191
- context.default_map = params
195
+ default_map.update(context.default_map)
196
+ default_map.update(config)
197
+ context.default_map = default_map
192
198
 
193
199
  black_config = parse_pyproject_toml(value)
194
200
  black_config.pop("exclude", None)
@@ -215,11 +221,21 @@ def _parse_sources(context: click.Context, _: click.Parameter, value: list[str]
215
221
  :returns: List of resolved file paths to format.
216
222
 
217
223
  """
218
- sources = value or context.params.get("files", [])
219
- exclude = list(context.params.get("exclude", DEFAULT_EXCLUDE))
220
- extend_exclude = list(context.params.get("extend_exclude", []))
224
+ default_map = context.default_map or {}
225
+
226
+ def lookup(name: str, fallback: Any) -> Any:
227
+ # Sibling parameters may not be processed yet when this callback runs,
228
+ # so fall back to the default map, which _parse_pyproject_config fills
229
+ # with pyproject.toml values.
230
+ if name in context.params:
231
+ return context.params[name]
232
+ return default_map.get(name, fallback)
233
+
234
+ sources = value or lookup("files", [])
235
+ exclude = list(lookup("exclude", DEFAULT_EXCLUDE))
236
+ extend_exclude = list(lookup("extend_exclude", []))
221
237
  exclude.extend(extend_exclude)
222
- include_txt = context.params.get("include_txt", False)
238
+ include_txt = lookup("include_txt", False)
223
239
  files_to_format = set()
224
240
  extensions = [".py", ".rst"] + ([".txt"] if include_txt else [])
225
241
  for source in sources:
@@ -243,10 +259,7 @@ def _parse_sources(context: click.Context, _: click.Parameter, value: list[str]
243
259
  if file.parent.match(exclusion) or file.match(exclusion):
244
260
  files_to_format.discard(abspath(file))
245
261
  break
246
- sorted_files = sorted(files_to_format)
247
- if context.params.get("files", []):
248
- context.params["files"] = sorted_files
249
- return sorted_files
262
+ return sorted(files_to_format)
250
263
 
251
264
 
252
265
  def _process_python(
@@ -359,20 +372,6 @@ def _process_rst(
359
372
  return misformatted, error_count
360
373
 
361
374
 
362
- def _resolve_length(context: click.Context, _: click.Parameter, value: int | None):
363
- """Resolve line length from command line or pyproject.toml.
364
-
365
- :param context: Click context containing command parameters.
366
- :param _: Unused parameter.
367
- :param value: Line length from command line.
368
-
369
- :returns: Resolved line length value.
370
-
371
- """
372
- pyproject_line_length = context.params.pop("line_length", None)
373
- return value or pyproject_line_length
374
-
375
-
376
375
  def _validate_adornments(
377
376
  context: click.Context, _: click.Parameter, value: str | None
378
377
  ) -> list[tuple[str, bool]] | None:
@@ -380,14 +379,14 @@ def _validate_adornments(
380
379
 
381
380
  :param context: Click context containing command parameters.
382
381
  :param _: Unused parameter.
383
- :param value: Section adornments string from command line.
382
+ :param value: Section adornments string from the command line or pyproject.toml.
384
383
 
385
384
  :returns: List of tuples containing (character, has_overline) for each adornment.
386
385
 
387
386
  :raises click.BadParameter: If adornments are not unique.
388
387
 
389
388
  """
390
- actual_value = context.params.pop("section_adornments", value)
389
+ actual_value = SECTION_CHARS if value is None else value
391
390
 
392
391
  if len(actual_value) != len(set(actual_value)):
393
392
  msg = "Section adornments must be unique"
@@ -418,6 +417,8 @@ async def _run_formatter(
418
417
  cache: FileCache,
419
418
  loop: asyncio.AbstractEventLoop,
420
419
  executor: ProcessPoolExecutor | ThreadPoolExecutor,
420
+ bullet_list_marker: str = "-",
421
+ center_section_titles: bool = True,
421
422
  ):
422
423
  """Run the formatter on multiple files asynchronously.
423
424
 
@@ -434,6 +435,8 @@ async def _run_formatter(
434
435
  :param cache: File cache for tracking changes.
435
436
  :param loop: Event loop for async operations.
436
437
  :param executor: Process or thread pool executor.
438
+ :param bullet_list_marker: Bullet character to use for unordered lists.
439
+ :param center_section_titles: Whether to center section titles with overlines.
437
440
 
438
441
  :returns: Tuple of (misformatted_files, total_error_count).
439
442
 
@@ -461,6 +464,8 @@ async def _run_formatter(
461
464
  section_adornments,
462
465
  raw_output,
463
466
  lock,
467
+ bullet_list_marker,
468
+ center_section_titles,
464
469
  )
465
470
  ): file
466
471
  for file in sorted(todo)
@@ -489,8 +494,13 @@ async def _run_formatter(
489
494
  if misformatted:
490
495
  misformatted_files.add(file)
491
496
  if (
492
- not (misformatted and raw_output) or (check and not misformatted)
493
- ) and errors == 0:
497
+ file.name != "-" # stdin cannot be cached
498
+ and (
499
+ not (misformatted and raw_output)
500
+ or (check and not misformatted)
501
+ )
502
+ and errors == 0
503
+ ):
494
504
  files_to_cache.append(file)
495
505
  if cancelled: # pragma: no cover
496
506
  await asyncio.gather(*cancelled, return_exceptions=True)
@@ -839,6 +849,23 @@ class Visitor(CSTTransformer):
839
849
 
840
850
  # noinspection PyUnusedLocal
841
851
  @click.command(context_settings={"help_option_names": ["-h", "--help"]})
852
+ @click.option(
853
+ "-b",
854
+ "--bullet-list-marker",
855
+ default="-",
856
+ help="Bullet character to use for unordered lists.",
857
+ show_default=True,
858
+ type=click.Choice(["-", "*", "+"], case_sensitive=False),
859
+ )
860
+ @click.option(
861
+ "--center-section-titles/--no-center-section-titles",
862
+ default=True,
863
+ help=(
864
+ "Whether to center section titles with overlines by adding a leading space."
865
+ " When disabled, section titles with overlines will not have a leading space"
866
+ " and the adornment will match the exact length of the title text."
867
+ ),
868
+ )
842
869
  @click.option(
843
870
  "-c",
844
871
  "--check",
@@ -909,7 +936,6 @@ class Visitor(CSTTransformer):
909
936
  " 'line-length' set in pyproject.toml if set. Defaults to the length provided"
910
937
  " to black if not set."
911
938
  ),
912
- callback=_resolve_length,
913
939
  )
914
940
  @click.option(
915
941
  "-pA",
@@ -987,6 +1013,8 @@ class Visitor(CSTTransformer):
987
1013
  @click.pass_context
988
1014
  def main(
989
1015
  context: Context,
1016
+ bullet_list_marker: str,
1017
+ center_section_titles: bool,
990
1018
  check: bool,
991
1019
  docstring_trailing_line: bool,
992
1020
  exclude: list[str],
@@ -1008,6 +1036,8 @@ def main(
1008
1036
  """Format reStructuredText and Python files.
1009
1037
 
1010
1038
  :param context: Click context containing command parameters.
1039
+ :param bullet_list_marker: Bullet character to use for unordered lists.
1040
+ :param center_section_titles: Whether to center section titles with overlines.
1011
1041
  :param check: Whether to check formatting without modifying files.
1012
1042
  :param docstring_trailing_line: Whether to add trailing line to docstrings.
1013
1043
  :param exclude: List of paths to exclude from formatting.
@@ -1050,6 +1080,8 @@ def main(
1050
1080
  manager = Manager(
1051
1081
  current_file=file,
1052
1082
  black_config=mode,
1083
+ bullet_list_marker=bullet_list_marker,
1084
+ center_section_titles=center_section_titles,
1053
1085
  docstring_trailing_line=docstring_trailing_line,
1054
1086
  format_python_code_blocks=format_python_code_blocks,
1055
1087
  reporter=reporter,
@@ -1077,10 +1109,18 @@ def main(
1077
1109
 
1078
1110
  cache = FileCache(context, ignore_cache)
1079
1111
  if len(files) < 2:
1080
- for file in files:
1112
+ if raw_output:
1113
+ # Raw output must always be emitted, even for cached files.
1114
+ todo = {Path(file).resolve() for file in files}
1115
+ else:
1116
+ todo, _already_done = cache.gen_todo_list(files)
1117
+ files_to_cache = []
1118
+ for file in (Path(f) for f in files):
1119
+ if file.resolve() not in todo:
1120
+ continue
1081
1121
  misformatted, error_count = _format_file(
1082
1122
  check,
1083
- Path(file),
1123
+ file,
1084
1124
  file_type,
1085
1125
  include_txt,
1086
1126
  line_length,
@@ -1090,9 +1130,20 @@ def main(
1090
1130
  section_adornments,
1091
1131
  raw_output,
1092
1132
  None,
1133
+ bullet_list_marker,
1134
+ center_section_titles,
1093
1135
  )
1094
1136
  if misformatted:
1095
1137
  misformatted_files.add(file)
1138
+ if (
1139
+ file.name != "-" # stdin cannot be cached
1140
+ and not raw_output # raw output does not modify the file
1141
+ and not (check and misformatted) # the file remains misformatted
1142
+ and error_count == 0
1143
+ ):
1144
+ files_to_cache.append(file)
1145
+ if files_to_cache:
1146
+ cache.write_cache(files_to_cache)
1096
1147
 
1097
1148
  else:
1098
1149
  # This code is heavily based on that of psf/black
@@ -1126,6 +1177,8 @@ def main(
1126
1177
  cache,
1127
1178
  loop,
1128
1179
  executor,
1180
+ bullet_list_marker,
1181
+ center_section_titles,
1129
1182
  )
1130
1183
  )
1131
1184
  finally:
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import hashlib
5
6
  import pickle
6
7
  import tempfile
7
8
  from collections import defaultdict
@@ -59,6 +60,23 @@ def make_enumerator(ordinal: int, sequence: str, fmt: tuple[str, str]) -> str:
59
60
  class FileCache:
60
61
  """A class to manage the cache of files."""
61
62
 
63
+ # Parameters that do not affect how a given file is formatted and therefore
64
+ # must not influence the cache key.
65
+ _NON_FORMATTING_PARAMS = frozenset(
66
+ [
67
+ "check",
68
+ "exclude",
69
+ "extend_exclude",
70
+ "files",
71
+ "ignore_cache",
72
+ "mode",
73
+ "quiet",
74
+ "raw_input",
75
+ "raw_output",
76
+ "verbose",
77
+ ]
78
+ )
79
+
62
80
  @staticmethod
63
81
  def _get_file_info(file: Path) -> tuple[float, int]:
64
82
  """Get the file info.
@@ -88,20 +106,22 @@ class FileCache:
88
106
  def _get_cache_filename(self) -> Path:
89
107
  """Get the cache filename.
90
108
 
109
+ The filename incorporates every parameter that affects formatting output
110
+ (e.g., ``section_adornments``, ``bullet_list_marker``) so that changing any
111
+ of them invalidates previously cached results.
112
+
91
113
  :returns: Path to the cache file.
92
114
 
93
115
  """
94
- docstring_trailing_line = str(self.context.params["docstring_trailing_line"])
95
- format_python_code_blocks = str(
96
- self.context.params["format_python_code_blocks"]
97
- )
98
- line_length = str(self.context.params["line_length"])
99
- mode = self.context.params["mode"].get_cache_key()
100
- include_txt = str(self.context.params["include_txt"])
101
- return (
102
- self.cache_dir
103
- / f"cache.{f'{docstring_trailing_line}_{format_python_code_blocks}_{include_txt}_{line_length}_{mode}'}.pickle"
104
- )
116
+ params = self.context.params
117
+ parts = [
118
+ f"{name}={params[name]!r}"
119
+ for name in sorted(params)
120
+ if name not in self._NON_FORMATTING_PARAMS
121
+ ]
122
+ parts.append(f"mode={params['mode'].get_cache_key()}")
123
+ key = hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
124
+ return self.cache_dir / f"cache.{key}.pickle"
105
125
 
106
126
  def _read_cache(self) -> dict[str, tuple[float, int]]:
107
127
  """Read the cache file.
@@ -134,8 +154,9 @@ class FileCache:
134
154
  todo, done = set(), set()
135
155
  for file in (Path(f).resolve() for f in files):
136
156
  if (
137
- self.cache.get(str(file)) != self._get_file_info(file)
157
+ file.name == "-" # stdin cannot be cached
138
158
  or self.ignore_cache
159
+ or self.cache.get(str(file)) != self._get_file_info(file)
139
160
  ):
140
161
  todo.add(file)
141
162
  else:
@@ -11,7 +11,7 @@ dev = [
11
11
  lint = [
12
12
  "pre-commit",
13
13
  "pyright>=1.1.406",
14
- "ruff>=0.0.292"
14
+ "ruff>=0.15.10"
15
15
  ]
16
16
  test = [
17
17
  "aiohttp",
@@ -52,9 +52,9 @@ dependencies = [
52
52
  "platformdirs>=4",
53
53
  "roman",
54
54
  "sphinx>=7",
55
- "tabulate>=0.9",
55
+ "tabulate>=0.10.0",
56
56
  "tomli>=0.10;python_version<'3.11'",
57
- "types-docutils==0.22.3.20251115"
57
+ "types-docutils==0.22.3.20260518"
58
58
  ]
59
59
  dynamic = ["version", "description"]
60
60
  keywords = ["black", "docutils", "autoformatter", "formatter", "lint", "restructuredtext", "rst", "sphinx"]
File without changes
File without changes
File without changes
File without changes
File without changes