configgle 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.
configgle/pprinting.py ADDED
@@ -0,0 +1,615 @@
1
+ """Pretty printing utilities for Fig config objects."""
2
+
3
+ from collections.abc import Callable
4
+ from pprint import PrettyPrinter as _PrettyPrinter
5
+ from typing import IO, Protocol, TypeVar
6
+
7
+ import copy
8
+ import dataclasses
9
+ import io
10
+ import re
11
+ import warnings
12
+
13
+ from typing_extensions import override
14
+
15
+ from configgle.custom_types import Configurable
16
+
17
+
18
+ __all__ = [
19
+ "FigPrinter",
20
+ "pformat",
21
+ "pprint",
22
+ ]
23
+
24
+ _T = TypeVar("_T")
25
+ _T_contra = TypeVar("_T_contra", contravariant=True)
26
+
27
+
28
+ class SupportsWrite(Protocol[_T_contra]):
29
+ """Protocol for objects that support write method."""
30
+
31
+ def write(self, s: _T_contra, /) -> object: ...
32
+
33
+
34
+ # Default threshold for continuation pipes (lines)
35
+ _DEFAULT_CONTINUATION_PIPE_THRESHOLD = 50
36
+
37
+ # Maximum width for sequences to always stay on one line
38
+ _SHORT_SEQUENCE_MAX_WIDTH = 40
39
+
40
+
41
+ def pformat(
42
+ obj: object,
43
+ indent: int = 8,
44
+ width: int = 80,
45
+ depth: int | None = None,
46
+ *,
47
+ compact: bool = False,
48
+ # The following differ from the Python standard lib.
49
+ sort_dicts: bool = False,
50
+ underscore_numbers: bool = True,
51
+ finalize: bool = True,
52
+ scrub_memory_address: bool = True,
53
+ extra_compact: bool = True,
54
+ continuation_pipe: int = _DEFAULT_CONTINUATION_PIPE_THRESHOLD,
55
+ hide_default_values: bool = True,
56
+ short_sequence_max_width: int = _SHORT_SEQUENCE_MAX_WIDTH,
57
+ ) -> str:
58
+ """Format object as a string with Fig-aware pretty printing.
59
+
60
+ Args:
61
+ obj: Object to format.
62
+ indent: Spaces per indent level.
63
+ width: Maximum line width.
64
+ depth: Maximum nesting depth (None for unlimited).
65
+ compact: Use compact format for sequences.
66
+ sort_dicts: Sort dictionary keys.
67
+ underscore_numbers: Use underscores in large numbers.
68
+ finalize: Auto-finalize unfinalized configs before printing.
69
+ scrub_memory_address: Replace memory addresses with placeholder.
70
+ extra_compact: Use extra compact formatting.
71
+ continuation_pipe: Lines threshold for continuation pipes (0=always, -1=never).
72
+ hide_default_values: Omit fields with default values.
73
+ short_sequence_max_width: Max width for single-line sequences.
74
+
75
+ Returns:
76
+ formatted: Pretty-printed string representation.
77
+
78
+ """
79
+ printer = FigPrinter(
80
+ indent=indent,
81
+ width=width,
82
+ depth=depth,
83
+ compact=compact,
84
+ sort_dicts=sort_dicts,
85
+ underscore_numbers=underscore_numbers,
86
+ finalize=finalize,
87
+ scrub_memory_address=scrub_memory_address,
88
+ extra_compact=extra_compact,
89
+ continuation_pipe=continuation_pipe,
90
+ hide_default_values=hide_default_values,
91
+ short_sequence_max_width=short_sequence_max_width,
92
+ )
93
+ return printer.pformat(obj)
94
+
95
+
96
+ def pprint(
97
+ obj: object,
98
+ stream: IO[str] | None = None,
99
+ indent: int = 8,
100
+ width: int = 80,
101
+ depth: int | None = None,
102
+ *,
103
+ compact: bool = False,
104
+ # The following differ from the Python standard lib.
105
+ sort_dicts: bool = False,
106
+ underscore_numbers: bool = True,
107
+ finalize: bool = True,
108
+ scrub_memory_address: bool = True,
109
+ extra_compact: bool = True,
110
+ continuation_pipe: int = _DEFAULT_CONTINUATION_PIPE_THRESHOLD,
111
+ hide_default_values: bool = True,
112
+ short_sequence_max_width: int = _SHORT_SEQUENCE_MAX_WIDTH,
113
+ ) -> None:
114
+ """Pretty-print object with Fig-aware formatting.
115
+
116
+ Args:
117
+ obj: Object to print.
118
+ stream: Output stream (defaults to sys.stdout).
119
+ indent: Spaces per indent level.
120
+ width: Maximum line width.
121
+ depth: Maximum nesting depth (None for unlimited).
122
+ compact: Use compact format for sequences.
123
+ sort_dicts: Sort dictionary keys.
124
+ underscore_numbers: Use underscores in large numbers.
125
+ finalize: Auto-finalize unfinalized configs before printing.
126
+ scrub_memory_address: Replace memory addresses with placeholder.
127
+ extra_compact: Use extra compact formatting.
128
+ continuation_pipe: Lines threshold for continuation pipes (0=always, -1=never).
129
+ hide_default_values: Omit fields with default values.
130
+ short_sequence_max_width: Max width for single-line sequences.
131
+
132
+ """
133
+ printer = FigPrinter(
134
+ stream=stream,
135
+ indent=indent,
136
+ width=width,
137
+ depth=depth,
138
+ compact=compact,
139
+ sort_dicts=sort_dicts,
140
+ underscore_numbers=underscore_numbers,
141
+ finalize=finalize,
142
+ scrub_memory_address=scrub_memory_address,
143
+ extra_compact=extra_compact,
144
+ continuation_pipe=continuation_pipe,
145
+ hide_default_values=hide_default_values,
146
+ short_sequence_max_width=short_sequence_max_width,
147
+ )
148
+ return printer.pprint(obj)
149
+
150
+
151
+ class FigPrinter(_PrettyPrinter):
152
+ """PrettyPrinter subclass with Fig-specific formatting enhancements."""
153
+
154
+ def __init__(
155
+ self,
156
+ stream: IO[str] | None = None,
157
+ indent: int = 8,
158
+ width: int = 80,
159
+ depth: int | None = None,
160
+ *,
161
+ compact: bool = False,
162
+ # The following differ from the Python standard lib.
163
+ sort_dicts: bool = False,
164
+ underscore_numbers: bool = True,
165
+ finalize: bool = True,
166
+ scrub_memory_address: bool = True,
167
+ extra_compact: bool = True,
168
+ continuation_pipe: int = _DEFAULT_CONTINUATION_PIPE_THRESHOLD,
169
+ hide_default_values: bool = True,
170
+ short_sequence_max_width: int = _SHORT_SEQUENCE_MAX_WIDTH,
171
+ ):
172
+ super().__init__(
173
+ indent=indent,
174
+ width=width,
175
+ depth=depth,
176
+ stream=stream,
177
+ compact=compact,
178
+ sort_dicts=sort_dicts,
179
+ underscore_numbers=underscore_numbers,
180
+ )
181
+ self._finalize = finalize
182
+ self._scrub_memory_address = (
183
+ _SCRUB_MEMORY_ADDRESS_FN if scrub_memory_address else None
184
+ )
185
+ self._extra_compact = extra_compact
186
+ self._continuation_pipe = continuation_pipe
187
+ self._hide_default_values = hide_default_values
188
+ self._short_sequence_max_width = short_sequence_max_width
189
+
190
+ @override
191
+ def pprint(self, object: object) -> None:
192
+ return super().pprint(self._try_to_finalize(object))
193
+
194
+ @override
195
+ def pformat(self, object: object) -> str:
196
+ return super().pformat(self._try_to_finalize(object))
197
+
198
+ @override
199
+ def format(
200
+ self,
201
+ object: object,
202
+ context: dict[int, int],
203
+ maxlevels: int,
204
+ level: int,
205
+ ) -> tuple[str, bool, bool]:
206
+ if (
207
+ self._finalize
208
+ and callable(getattr(object, "setup", None))
209
+ and callable(getattr(object, "finalize", None))
210
+ and not getattr(object, "_finalized", False)
211
+ ):
212
+ warnings.warn(
213
+ f"Found potentially unfinalized dataclass: {object}.",
214
+ stacklevel=2,
215
+ )
216
+ repr_, readable, recursive = super().format(
217
+ object,
218
+ context,
219
+ maxlevels,
220
+ level,
221
+ )
222
+ if self._scrub_memory_address is not None:
223
+ repr_ = self._scrub_memory_address(repr_)
224
+ return repr_, readable, recursive
225
+
226
+ def _try_to_finalize(self, obj: _T) -> _T:
227
+ if (
228
+ self._finalize
229
+ and isinstance(obj, Configurable)
230
+ and not getattr(obj, "_finalized", False)
231
+ ):
232
+ try:
233
+ obj = copy.deepcopy(obj)
234
+ obj = obj.finalize()
235
+ except Exception as e: # noqa: BLE001
236
+ warnings.warn(str(e), stacklevel=2)
237
+ return obj
238
+
239
+ def _pprint_dataclass(
240
+ self,
241
+ obj: object,
242
+ stream: SupportsWrite[str],
243
+ indent: int,
244
+ allowance: int,
245
+ context: dict[int, int],
246
+ level: int,
247
+ ) -> None:
248
+ """Override to filter default values if requested."""
249
+ cls_name = obj.__class__.__qualname__
250
+ indent += len(cls_name) + 1
251
+ items = [
252
+ (f.name, getattr(obj, f.name))
253
+ for f in dataclasses.fields(obj) # pyright: ignore[reportArgumentType]
254
+ if f.repr
255
+ ]
256
+
257
+ # Filter out default values if requested
258
+ if self._hide_default_values:
259
+ items = _filter_non_default_items(obj, items)
260
+
261
+ stream.write(cls_name + "(")
262
+ self._format_namespace_items(items, stream, indent, allowance, context, level)
263
+ stream.write(")")
264
+
265
+ def _format_namespace_items(
266
+ self,
267
+ items: list[tuple[str, object]],
268
+ stream: SupportsWrite[str],
269
+ indent: int,
270
+ allowance: int,
271
+ context: dict[int, int],
272
+ level: int,
273
+ ) -> None:
274
+ """Override to use fixed indent and put each parameter on its own line."""
275
+ if not self._extra_compact:
276
+ # PrettyPrinter private method
277
+ super()._format_namespace_items( # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType]
278
+ items,
279
+ stream,
280
+ indent,
281
+ allowance,
282
+ context,
283
+ level,
284
+ )
285
+ return
286
+
287
+ if not items:
288
+ return
289
+
290
+ write = stream.write
291
+ write("\n")
292
+
293
+ item_indent, base_indent_val = _get_level_indents(
294
+ level,
295
+ # PrettyPrinter private attribute
296
+ self._indent_per_level, # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType,reportUnknownArgumentType]
297
+ )
298
+ base_indent = " " * base_indent_val
299
+
300
+ for i, (key, ent) in enumerate(items):
301
+ last = i == len(items) - 1
302
+
303
+ write(" " * item_indent)
304
+ write(key)
305
+ write("=")
306
+
307
+ if id(ent) in context:
308
+ write("...")
309
+ else:
310
+ formatted_value = self._format_namespace_value(
311
+ ent,
312
+ context,
313
+ level,
314
+ item_indent,
315
+ allowance if last else 1,
316
+ len(items),
317
+ )
318
+ write(formatted_value)
319
+
320
+ if not last:
321
+ write(",\n")
322
+
323
+ write("\n")
324
+ write(base_indent)
325
+
326
+ def _format_namespace_value(
327
+ self,
328
+ value: object,
329
+ context: dict[int, int],
330
+ level: int,
331
+ item_indent: int,
332
+ allowance: int,
333
+ num_items: int,
334
+ ) -> str:
335
+ """Format a namespace value with collapsing and continuation pipes."""
336
+ # Format value to string
337
+ temp_stream = io.StringIO()
338
+ self._format(value, temp_stream, item_indent, allowance, context, level)
339
+ formatted_value = temp_stream.getvalue()
340
+
341
+ # Try to collapse short multiline values onto one line
342
+ formatted_value = _collapse_multiline_value(
343
+ formatted_value,
344
+ self._short_sequence_max_width,
345
+ )
346
+
347
+ # Add continuation pipes if needed
348
+ if _should_add_continuation_pipes(
349
+ formatted_value,
350
+ num_items,
351
+ self._continuation_pipe,
352
+ ):
353
+ formatted_value = "\n".join(
354
+ _add_pipes_to_lines(formatted_value.split("\n"), item_indent),
355
+ )
356
+
357
+ return formatted_value
358
+
359
+ @override
360
+ def _pprint_list(
361
+ self,
362
+ object: list[object],
363
+ stream: SupportsWrite[str],
364
+ indent: int,
365
+ allowance: int,
366
+ context: dict[int, int],
367
+ level: int,
368
+ ) -> None:
369
+ """Override to use level-based indent."""
370
+ if not self._extra_compact:
371
+ super()._pprint_list(object, stream, indent, allowance, context, level)
372
+ return
373
+
374
+ write = stream.write
375
+ write("[")
376
+ if object:
377
+ self._format_items(object, stream, indent, allowance + 1, context, level)
378
+ write("]")
379
+
380
+ @override
381
+ def _format_items(
382
+ self,
383
+ items: list[object],
384
+ stream: SupportsWrite[str],
385
+ indent: int,
386
+ allowance: int,
387
+ context: dict[int, int],
388
+ level: int,
389
+ ) -> None:
390
+ """Override to use level-based indent instead of accumulated indent."""
391
+ if not self._extra_compact:
392
+ super()._format_items(items, stream, indent, allowance, context, level)
393
+ return
394
+
395
+ one_line_str = self._try_format_items_on_one_line(items, context, level)
396
+ content_width = len(one_line_str) + 2 # Add 2 for surrounding brackets/parens
397
+
398
+ if self._should_format_on_one_line(content_width, indent, allowance):
399
+ stream.write(one_line_str)
400
+ else:
401
+ self._format_items_multiline(items, stream, context, level)
402
+
403
+ def _try_format_items_on_one_line(
404
+ self,
405
+ items: list[object],
406
+ context: dict[int, int],
407
+ level: int,
408
+ ) -> str:
409
+ """Try to format items on a single line."""
410
+ one_line = io.StringIO()
411
+ delim = ""
412
+ for item in items:
413
+ one_line.write(delim)
414
+ self._format(item, one_line, 0, 0, context, level)
415
+ delim = ", "
416
+ return one_line.getvalue()
417
+
418
+ def _should_format_on_one_line(
419
+ self,
420
+ content_width: int,
421
+ indent: int,
422
+ allowance: int,
423
+ ) -> bool:
424
+ """Determine if items should be formatted on one line."""
425
+ # Keep short sequences on one line regardless of nesting depth
426
+ # (content_width doesn't include indent, so short tuples stay compact even when deeply nested)
427
+ # For longer sequences, check if they fit within the available width
428
+ # PrettyPrinter private attribute _width has unknown type
429
+ return ( # pyright: ignore[reportUnknownVariableType]
430
+ content_width < self._short_sequence_max_width
431
+ or indent + content_width + allowance <= self._width # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType]
432
+ )
433
+
434
+ def _format_items_multiline(
435
+ self,
436
+ items: list[object],
437
+ stream: SupportsWrite[str],
438
+ context: dict[int, int],
439
+ level: int,
440
+ ) -> None:
441
+ """Format items across multiple lines with level-based indent."""
442
+ write = stream.write
443
+ write("\n")
444
+
445
+ item_indent, base_indent_val = _get_level_indents(
446
+ level,
447
+ # PrettyPrinter private attribute
448
+ self._indent_per_level, # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType,reportUnknownArgumentType]
449
+ )
450
+ indent_str = " " * item_indent
451
+
452
+ for i, ent in enumerate(items):
453
+ last = i == len(items) - 1
454
+ write(indent_str)
455
+
456
+ if id(ent) in context:
457
+ write("...")
458
+ else:
459
+ formatted_value = self._format_and_collapse_item(
460
+ ent,
461
+ context,
462
+ level,
463
+ item_indent,
464
+ )
465
+ stream.write(formatted_value)
466
+
467
+ if not last:
468
+ write(",\n")
469
+
470
+ write("\n")
471
+ write(" " * base_indent_val)
472
+
473
+ def _format_and_collapse_item(
474
+ self,
475
+ item: object,
476
+ context: dict[int, int],
477
+ level: int,
478
+ item_indent: int,
479
+ ) -> str:
480
+ """Format an item to a string and collapse if short enough."""
481
+ temp_stream = io.StringIO()
482
+ self._format(item, temp_stream, item_indent, 1, context, level)
483
+ formatted_value = temp_stream.getvalue()
484
+ return _collapse_multiline_value(
485
+ formatted_value,
486
+ self._short_sequence_max_width,
487
+ )
488
+
489
+
490
+ def _get_level_indents(level: int, indent_per_level: int) -> tuple[int, int]:
491
+ """Calculate item and base indent for a given level.
492
+
493
+ Args:
494
+ level: Current nesting level.
495
+ indent_per_level: Spaces per indent level.
496
+
497
+ Returns:
498
+ item_indent: Indent for items.
499
+ base_indent: Indent for base closing delimiter.
500
+
501
+ """
502
+ item_indent = indent_per_level * (level + 1)
503
+ base_indent = item_indent - indent_per_level
504
+ return item_indent, base_indent
505
+
506
+
507
+ def _collapse_multiline_value(formatted_value: str, max_width: int) -> str:
508
+ """Collapse a multiline formatted value to a single line if short enough.
509
+
510
+ Args:
511
+ formatted_value: The formatted string (possibly multiline).
512
+ max_width: Maximum width for collapsing.
513
+
514
+ Returns:
515
+ collapsed_value: Either the original or collapsed version.
516
+
517
+ """
518
+ if "\n" not in formatted_value:
519
+ return formatted_value
520
+
521
+ # Remove newlines and collapse whitespace
522
+ oneline = re.sub(r"\s+", " ", formatted_value.replace("\n", ""))
523
+ # Clean up spaces around parentheses
524
+ oneline = oneline.replace("( ", "(").replace(" )", ")")
525
+
526
+ # Use collapsed version if short enough
527
+ if len(oneline) <= max_width:
528
+ return oneline
529
+ return formatted_value
530
+
531
+
532
+ def _replace_char_at_column(line: str, column: int, char: str) -> str:
533
+ """Replace character at column position if it's whitespace."""
534
+ if len(line) > column and line[column].isspace():
535
+ return line[:column] + char + line[column + 1 :]
536
+ return line
537
+
538
+
539
+ def _add_pipes_to_lines(lines: list[str], pipe_column: int) -> list[str]:
540
+ """Add continuation pipes to lines at the given column."""
541
+ if not lines:
542
+ return lines
543
+
544
+ result = [lines[0]] # First line unchanged
545
+ for i, line in enumerate(lines[1:], 1):
546
+ is_last = i == len(lines) - 1
547
+ pipe_char = " " if is_last else "│"
548
+ result.append(_replace_char_at_column(line, pipe_column, pipe_char))
549
+
550
+ return result
551
+
552
+
553
+ def _should_add_continuation_pipes(
554
+ formatted_value: str,
555
+ num_items: int,
556
+ continuation_pipe_threshold: int,
557
+ ) -> bool:
558
+ """Determine if continuation pipes should be added to formatted value."""
559
+ if continuation_pipe_threshold < 0:
560
+ return False
561
+ if num_items <= 1:
562
+ return False
563
+ if "\n" not in formatted_value:
564
+ return False
565
+
566
+ num_lines = formatted_value.count("\n") + 1
567
+ return continuation_pipe_threshold == 0 or num_lines >= continuation_pipe_threshold
568
+
569
+
570
+ def _filter_non_default_items(
571
+ obj: object,
572
+ items: list[tuple[str, object]],
573
+ ) -> list[tuple[str, object]]:
574
+ """Filter out items that have default values.
575
+
576
+ Args:
577
+ obj: The dataclass instance being formatted.
578
+ items: List of (name, value) tuples for all fields.
579
+
580
+ Returns:
581
+ filtered_items: List of (name, value) tuples with non-default values only.
582
+
583
+ """
584
+ try:
585
+ # Get the class and instantiate a default instance
586
+ cls = type(obj)
587
+ default_obj = cls()
588
+
589
+ # Filter items - keep only non-default values
590
+ filtered: list[tuple[str, object]] = []
591
+ for name, value in items:
592
+ default_value = getattr(default_obj, name)
593
+ if value != default_value:
594
+ filtered.append((name, value))
595
+
596
+ return filtered
597
+ except Exception: # noqa: BLE001
598
+ # If we can't create defaults (e.g., required args), return all items
599
+ return items
600
+
601
+
602
+ def _make_scrub() -> Callable[[str], str]:
603
+ n = len(str(lambda: None)[:-1].split(" at 0x")[-1])
604
+ pattern = re.compile(rf"0x[a-f0-9]{{{n}}}")
605
+ # Fun fact: 0x0defaced is a prime number.
606
+ replace = "0x0defaced0defaced"
607
+ replace = replace[: min(len(replace), 2 + n)]
608
+
609
+ def scrub_memory_address(x: str) -> str:
610
+ return pattern.sub(replace, x)
611
+
612
+ return scrub_memory_address
613
+
614
+
615
+ _SCRUB_MEMORY_ADDRESS_FN = _make_scrub()