format-docstring 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,5 @@
1
+ """A Python formatter to wrap/adjust docstring lines."""
2
+
3
+ import importlib.metadata
4
+
5
+ __version__ = importlib.metadata.version('format-docstring')
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+
8
+ class BaseFixer:
9
+ """Base class for fixing code formatting issues."""
10
+
11
+ def __init__(
12
+ self,
13
+ path: str,
14
+ exclude_pattern: str = r'\.git|\.tox|\.pytest_cache',
15
+ ) -> None:
16
+ """Initialize the fixer with a path and optional exclude pattern."""
17
+ self.path = path
18
+ self.exclude_pattern = exclude_pattern
19
+
20
+ def _get_files_to_process(
21
+ self, directory: Path, pattern: str
22
+ ) -> list[Path]:
23
+ """Get list of files to process, filtered by exclude pattern."""
24
+ all_files = sorted(directory.rglob(pattern))
25
+ return [
26
+ f
27
+ for f in all_files
28
+ if not should_exclude_file(f, self.exclude_pattern)
29
+ ]
30
+
31
+ def fix_one_directory_or_one_file(self) -> int:
32
+ """
33
+ Fix formatting in a single file or all Python files in a directory.
34
+ """
35
+ path_obj = Path(self.path)
36
+
37
+ if path_obj.is_file():
38
+ if should_exclude_file(path_obj, self.exclude_pattern):
39
+ return 0
40
+
41
+ return self.fix_one_file(path_obj.as_posix())
42
+
43
+ # Is a directory
44
+ filenames = self._get_files_to_process(path_obj, '*.py')
45
+ all_status = set()
46
+ for filename in filenames:
47
+ status = self.fix_one_file(filename.as_posix())
48
+ all_status.add(status)
49
+
50
+ return 0 if not all_status or all_status == {0} else 1
51
+
52
+ def fix_one_file(self, *varargs: Any, **kwargs: Any) -> int:
53
+ """Fix formatting in a single file."""
54
+ raise NotImplementedError('Please implement this method')
55
+
56
+
57
+ def should_exclude_file(file_path: Path, exclude_pattern: str) -> bool:
58
+ """Return True if `file_path` matches the provided exclude regex.
59
+
60
+ If `exclude_pattern` is empty or invalid, no files are excluded.
61
+ """
62
+ if not exclude_pattern:
63
+ return False
64
+
65
+ try:
66
+ exclude_regex = re.compile(exclude_pattern)
67
+ except re.error:
68
+ return False
69
+
70
+ return bool(exclude_regex.search(file_path.as_posix()))
@@ -0,0 +1,211 @@
1
+ """Configuration file parsing for format-docstring."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import click
10
+
11
+ if sys.version_info >= (3, 11):
12
+ import tomllib
13
+ else:
14
+ import tomli as tomllib
15
+
16
+
17
+ def find_config_file(paths: list[str] | tuple[str, ...] | None) -> Path | None:
18
+ """
19
+ Find pyproject.toml by walking up from the target path(s).
20
+
21
+ Parameters
22
+ ----------
23
+ paths : list[str] | tuple[str, ...] | None
24
+ The paths to search from. If None or empty, searches from cwd.
25
+
26
+ Returns
27
+ -------
28
+ Path | None
29
+ The path to pyproject.toml if found, None otherwise.
30
+ """
31
+ if not paths:
32
+ search_path = Path.cwd()
33
+ elif len(paths) == 1:
34
+ search_path = Path(paths[0])
35
+ if search_path.is_file():
36
+ search_path = search_path.parent
37
+ else:
38
+ # Find common parent folder
39
+ search_path = _find_common_parent(paths)
40
+
41
+ # Walk up the directory tree looking for pyproject.toml
42
+ current = search_path.resolve()
43
+ while True:
44
+ config_file = current / 'pyproject.toml'
45
+ if config_file.exists():
46
+ return config_file
47
+
48
+ parent = current.parent
49
+ if parent == current: # Reached root
50
+ break
51
+
52
+ current = parent
53
+
54
+ return None
55
+
56
+
57
+ def _find_common_parent(paths: list[str] | tuple[str, ...]) -> Path:
58
+ """
59
+ Find the common parent folder of the given paths.
60
+
61
+ Parameters
62
+ ----------
63
+ paths : list[str] | tuple[str, ...]
64
+ The paths to find the common parent for.
65
+
66
+ Returns
67
+ -------
68
+ Path
69
+ The common parent folder.
70
+ """
71
+ path_objs = [Path(p) for p in paths]
72
+
73
+ # For single path, return its parent if it looks like a file
74
+ if len(path_objs) == 1:
75
+ path = path_objs[0]
76
+ # If it has a file extension, treat as file
77
+ if path.suffix:
78
+ return path.parent
79
+ # If it exists and is a file, return parent
80
+ if path.exists() and path.is_file():
81
+ return path.parent
82
+ # Otherwise treat as directory
83
+ return path
84
+
85
+ # For multiple paths, find common parent by comparing parts
86
+ # Convert all file paths to their parent directories
87
+ dir_paths = []
88
+ for path in path_objs:
89
+ if path.suffix or (path.exists() and path.is_file()):
90
+ dir_paths.append(path.parent)
91
+ else:
92
+ dir_paths.append(path)
93
+
94
+ # Start with the first directory
95
+ common = dir_paths[0]
96
+
97
+ # Find common parent by comparing parts
98
+ for path in dir_paths[1:]:
99
+ # Find common parts
100
+ common_parts = []
101
+ for p1, p2 in zip(common.parts, path.parts, strict=False):
102
+ if p1 == p2:
103
+ common_parts.append(p1)
104
+ else:
105
+ break
106
+
107
+ if common_parts:
108
+ common = Path(*common_parts)
109
+ else:
110
+ # No common parent, use cwd
111
+ common = Path.cwd()
112
+ break
113
+
114
+ return common
115
+
116
+
117
+ def load_config_from_file(config_file: Path) -> dict[str, Any]:
118
+ """
119
+ Load configuration from a pyproject.toml file.
120
+
121
+ Parameters
122
+ ----------
123
+ config_file : Path
124
+ Path to the configuration file.
125
+
126
+ Returns
127
+ -------
128
+ dict[str, Any]
129
+ Configuration dictionary with normalized keys (underscores).
130
+ """
131
+ if not config_file.exists():
132
+ return {}
133
+
134
+ try:
135
+ with open(config_file, 'rb') as fp:
136
+ raw_config = tomllib.load(fp)
137
+
138
+ # Extract [tool.format_docstring] section
139
+ format_docstring_section = raw_config.get('tool', {}).get(
140
+ 'format_docstring', {}
141
+ )
142
+
143
+ # Normalize keys: replace hyphens with underscores
144
+ return {
145
+ k.replace('-', '_'): v for k, v in format_docstring_section.items()
146
+ }
147
+ except Exception:
148
+ # If there's any error reading/parsing the file, return empty config
149
+ return {}
150
+
151
+
152
+ def update_click_context(
153
+ ctx: click.Context,
154
+ config: dict[str, Any],
155
+ ) -> None:
156
+ """
157
+ Update the Click context's default_map with configuration values.
158
+
159
+ Parameters
160
+ ----------
161
+ ctx : click.Context
162
+ The Click context to update.
163
+ config : dict[str, Any]
164
+ Configuration dictionary to merge into the context.
165
+ """
166
+ if ctx.default_map is None:
167
+ ctx.default_map = {}
168
+
169
+ ctx.default_map.update(config)
170
+
171
+
172
+ def inject_config_from_file(
173
+ ctx: click.Context,
174
+ param: click.Parameter, # noqa: ARG001 (required by Click callback signature)
175
+ value: str | None,
176
+ ) -> str | None:
177
+ """
178
+ Click callback to inject configuration from a config file.
179
+
180
+ This is used as a callback for the --config option.
181
+
182
+ Parameters
183
+ ----------
184
+ ctx : click.Context
185
+ The Click context.
186
+ param : click.Parameter
187
+ The Click parameter (unused, required by Click callback signature).
188
+ value : str | None
189
+ The path to the config file, or None to auto-discover.
190
+
191
+ Returns
192
+ -------
193
+ str | None
194
+ The config file path if found/specified, None otherwise.
195
+ """
196
+ config_file: Path | None
197
+
198
+ if value:
199
+ # User specified a config file
200
+ config_file = Path(value)
201
+ else:
202
+ # Auto-discover config file from paths
203
+ paths = ctx.params.get('paths')
204
+ config_file = find_config_file(paths)
205
+
206
+ if config_file and config_file.exists():
207
+ config = load_config_from_file(config_file)
208
+ update_click_context(ctx, config)
209
+ return str(config_file)
210
+
211
+ return None
@@ -0,0 +1,314 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+
5
+ from format_docstring.line_wrap_google import wrap_docstring_google
6
+ from format_docstring.line_wrap_numpy import (
7
+ handle_single_line_docstring_that_is_a_bit_too_long,
8
+ wrap_docstring_numpy,
9
+ )
10
+
11
+ ModuleClassOrFunc = (
12
+ ast.Module | ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef
13
+ )
14
+
15
+
16
+ def fix_src(
17
+ source_code: str,
18
+ *,
19
+ line_length: int = 79,
20
+ docstring_style: str = 'numpy',
21
+ ) -> str:
22
+ """Return code with only docstrings updated to wrapped content.
23
+
24
+ Parameters
25
+ ----------
26
+ source_code : str
27
+ The full Python source code to process.
28
+ line_length : int, default=79
29
+ Target maximum line length for wrapping logic.
30
+
31
+ docstring_style : str, default='numpy'
32
+ The docstring style to target ('numpy' or 'google').
33
+
34
+ Returns
35
+ -------
36
+ str
37
+ The updated source code. Only docstring literals are changed; all
38
+ other formatting is preserved.
39
+
40
+ Notes
41
+ -----
42
+ This function avoids ``ast.unparse`` and instead replaces docstring
43
+ literal spans directly in the original text to preserve non-docstring
44
+ formatting and comments.
45
+
46
+ """
47
+ tree: ast.Module = ast.parse(source_code)
48
+ line_starts: list[int] = calc_line_starts(source_code)
49
+
50
+ replacements: list[tuple[int, int, str]] = []
51
+
52
+ # Module-level docstring
53
+ rep = build_replacement_docstring(
54
+ tree, source_code, line_starts, line_length, docstring_style
55
+ )
56
+ if rep is not None:
57
+ replacements.append(rep)
58
+
59
+ # Class/function-level docstrings
60
+ for node in ast.walk(tree):
61
+ if isinstance(
62
+ node, ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef
63
+ ):
64
+ rep = build_replacement_docstring(
65
+ node, source_code, line_starts, line_length, docstring_style
66
+ )
67
+ if rep is not None:
68
+ replacements.append(rep)
69
+
70
+ # Apply replacements from the end to avoid shifting offsets
71
+ if not replacements:
72
+ return source_code
73
+
74
+ replacements.sort(key=lambda x: x[0], reverse=True)
75
+ new_src = source_code
76
+ for start, end, text in replacements:
77
+ new_src = new_src[:start] + text + new_src[end:]
78
+
79
+ return new_src
80
+
81
+
82
+ def calc_line_starts(source_code: str) -> list[int]:
83
+ """Return starting offsets for each line in the source string.
84
+
85
+ Parameters
86
+ ----------
87
+ source_code : str
88
+ The source text to analyze.
89
+
90
+ Returns
91
+ -------
92
+ list[int]
93
+ A list of absolute indices for the start of each line.
94
+
95
+ """
96
+ starts: list[int] = [0]
97
+ for i, ch in enumerate(source_code):
98
+ if ch == '\n':
99
+ starts.append(i + 1)
100
+
101
+ return starts
102
+
103
+
104
+ def build_replacement_docstring(
105
+ node: ModuleClassOrFunc,
106
+ source_code: str,
107
+ line_starts: list[int],
108
+ line_length: int,
109
+ docstring_style: str = 'numpy',
110
+ ) -> tuple[int, int, str] | None:
111
+ """Compute a single docstring replacement for the given node.
112
+
113
+ Parameters
114
+ ----------
115
+ node : ModuleClassOrFunc
116
+ The AST node owning the docstring.
117
+ source_code : str
118
+ The original source text.
119
+ line_starts : list[int]
120
+ Line start offsets from :func:`_line_starts`.
121
+ line_length : int
122
+ Target maximum line length for wrapping logic.
123
+
124
+ docstring_style : str, default='numpy'
125
+ The docstring style to target ('numpy' or 'google').
126
+
127
+ Returns
128
+ -------
129
+ tuple[int, int, str] or None
130
+ A tuple ``(start, end, new_literal)`` indicating the replacement
131
+ range and text, or ``None`` if no change is needed.
132
+
133
+ """
134
+ docstring_obj: ast.Expr | None = find_docstring(node)
135
+ if docstring_obj is None:
136
+ return None
137
+
138
+ val: ast.Constant = docstring_obj.value # type: ignore[assignment]
139
+ if not hasattr(val, 'lineno') or not hasattr(val, 'end_lineno'):
140
+ return None
141
+
142
+ start: int = calc_abs_pos(line_starts, val.lineno, val.col_offset)
143
+ end: int = calc_abs_pos(line_starts, val.end_lineno, val.end_col_offset) # type: ignore[arg-type] # noqa: LN002
144
+ original_literal = source_code[start:end]
145
+
146
+ doc: str | None = ast.get_docstring(node, clean=False)
147
+ if doc is None:
148
+ return None
149
+
150
+ # Use the docstring literal's column offset as the indentation level for
151
+ # formatting. This lets the wrapper ensure leading/trailing newlines plus
152
+ # matching spaces are present so closing quotes align with the parent's
153
+ # indentation.
154
+ leading_indent: int = getattr(val, 'col_offset', 0)
155
+
156
+ # Only enforce leading/trailing newline+indent for multi-line docstrings
157
+ # or when wrapping will occur. Keep short single-line docstrings unchanged.
158
+ leading_indent_: int | None = (
159
+ leading_indent if ('\n' in doc or len(doc) > line_length) else None
160
+ )
161
+
162
+ wrapped: str = wrap_docstring(
163
+ doc,
164
+ line_length=line_length,
165
+ docstring_style=docstring_style,
166
+ leading_indent=leading_indent_, # type: ignore[arg-type]
167
+ )
168
+
169
+ new_literal: str | None = rebuild_literal(original_literal, wrapped)
170
+
171
+ new_literal = handle_single_line_docstring_that_is_a_bit_too_long(
172
+ whole_docstring_literal=new_literal,
173
+ docstring_content=wrapped,
174
+ docstring_starting_col=val.col_offset,
175
+ docstring_ending_col=val.end_col_offset, # type: ignore[arg-type]
176
+ line_length=line_length,
177
+ )
178
+
179
+ if new_literal is None or new_literal == original_literal:
180
+ return None
181
+
182
+ return start, end, new_literal
183
+
184
+
185
+ def find_docstring(node: ModuleClassOrFunc) -> ast.Expr | None:
186
+ """Return the first statement if it is a string-literal docstring.
187
+
188
+ Parameters
189
+ ----------
190
+ node : ModuleClassOrFunc
191
+ An ``ast.Module``, ``ast.ClassDef``, ``ast.FunctionDef``, or
192
+ ``ast.AsyncFunctionDef`` node.
193
+
194
+ Returns
195
+ -------
196
+ ast.Expr or None
197
+ The ``ast.Expr`` node that holds the docstring literal, if present;
198
+ otherwise ``None``.
199
+
200
+ """
201
+ body: list[ast.stmt] | None = getattr(node, 'body', None)
202
+ if not body:
203
+ return None
204
+
205
+ first = body[0]
206
+ if not isinstance(first, ast.Expr):
207
+ return None
208
+
209
+ val = first.value
210
+ if isinstance(val, ast.Constant) and isinstance(val.value, str):
211
+ return first
212
+
213
+ return None
214
+
215
+
216
+ def calc_abs_pos(line_starts: list[int], lineno: int, col: int) -> int:
217
+ """Convert a (lineno, col) pair to an absolute index.
218
+
219
+ Parameters
220
+ ----------
221
+ line_starts : list[int]
222
+ Precomputed start offsets for each line, from :func:`_line_starts`.
223
+ lineno : int
224
+ 1-based line number.
225
+ col : int
226
+ 0-based column offset.
227
+
228
+ Returns
229
+ -------
230
+ int
231
+ The absolute character index into the source string.
232
+
233
+ """
234
+ return line_starts[lineno - 1] + col
235
+
236
+
237
+ def rebuild_literal(original_literal: str, content: str) -> str | None:
238
+ """Rebuild a string literal preserving prefix and quote style.
239
+
240
+ Parameters
241
+ ----------
242
+ original_literal : str
243
+ The exact text of the original string literal including any prefix
244
+ and surrounding quotes.
245
+ content : str
246
+ The new inner content (without surrounding quotes).
247
+
248
+ Returns
249
+ -------
250
+ str or None
251
+ A new literal string with the same prefix and quotes and the new
252
+ content. Returns ``None`` if the original cannot be parsed.
253
+
254
+ """
255
+ i = 0
256
+ n = len(original_literal)
257
+ while i < n and original_literal[i] in 'rRuUbBfF':
258
+ i += 1
259
+
260
+ prefix = original_literal[:i]
261
+
262
+ delim = ''
263
+ if original_literal[i : i + 3] in ('"""', "'''"):
264
+ delim = original_literal[i : i + 3]
265
+ i += 3
266
+ elif i < n and original_literal[i] in ('"', "'"):
267
+ delim = original_literal[i]
268
+ i += 1
269
+ else:
270
+ return None
271
+
272
+ return f'{prefix}{delim}{content}{delim}'
273
+
274
+
275
+ def wrap_docstring(
276
+ docstring: str,
277
+ line_length: int = 79,
278
+ docstring_style: str = 'numpy',
279
+ leading_indent: int = 0,
280
+ ) -> str:
281
+ """Wrap a docstring to the given line length (stub).
282
+
283
+ Parameters
284
+ ----------
285
+ docstring : str
286
+ The original docstring contents without quotes.
287
+ line_length : int, default=79
288
+ Target maximum line length for wrapping logic.
289
+ docstring_style : str, default="numpy"
290
+ The docstring style to target ('numpy' or 'google').
291
+ leading_indent : int, default=0
292
+ The number of indentation spaces of this docstring.
293
+
294
+ Returns
295
+ -------
296
+ str
297
+ The transformed docstring contents.
298
+
299
+ Notes
300
+ -----
301
+ This function dispatches to style-specific implementations:
302
+ - 'numpy' -> wrap_docstring_numpy
303
+ - 'google' -> wrap_docstring_google
304
+
305
+ """
306
+ style = (docstring_style or '').strip().lower()
307
+ if style == 'google':
308
+ return wrap_docstring_google(
309
+ docstring, line_length, leading_indent=leading_indent
310
+ )
311
+ # Default to NumPy-style for unknown/unspecified styles to be permissive.
312
+ return wrap_docstring_numpy(
313
+ docstring, line_length, leading_indent=leading_indent
314
+ )
@@ -0,0 +1,7 @@
1
+ def wrap_docstring_google(
2
+ docstring: str,
3
+ line_length: int,
4
+ leading_indent: int | None = None,
5
+ ) -> str:
6
+ """A placeholder for now.""" # noqa: D401
7
+ return ''