reprobate 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.
reprobate/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """reprobate: Budget-controlled repr for Python objects."""
2
+
3
+ from .core import Policy, render, render_attrs, render_child
4
+ from .registry import register
5
+
6
+ __all__ = [
7
+ "Policy",
8
+ "register",
9
+ "render",
10
+ "render_attrs",
11
+ "render_child",
12
+ ]
13
+
14
+ # Auto-register optional type renderers when their deps are available.
15
+ from . import (
16
+ ext_arrow, # noqa: F401
17
+ ext_numpy, # noqa: F401
18
+ ext_pandas, # noqa: F401
19
+ ext_pil, # noqa: F401
20
+ ext_polars, # noqa: F401
21
+ ext_pydantic, # noqa: F401
22
+ )
reprobate/core.py ADDED
@@ -0,0 +1,458 @@
1
+ """Core rendering engine: recursive budget-allocated repr."""
2
+
3
+ import collections
4
+ import contextvars
5
+ import dataclasses
6
+ from typing import Literal
7
+
8
+ from .registry import get_renderer
9
+
10
+ MIN_BUDGET = 5
11
+ MIN_CHILD_BUDGET = 15
12
+
13
+ PROTOCOL_METHOD = "__budget_repr__"
14
+
15
+ Policy = Literal["greedy", "even"]
16
+ _policy: contextvars.ContextVar[Policy] = contextvars.ContextVar(
17
+ "budget_policy", default="greedy"
18
+ )
19
+ _seen: contextvars.ContextVar[set[int]] = contextvars.ContextVar("seen")
20
+
21
+ CIRCULAR = "<...>"
22
+
23
+
24
+ def render(obj: object, budget: int = 200, policy: Policy = "greedy") -> str:
25
+ """Render a Python object within a character budget.
26
+
27
+ Priority order:
28
+ 1. ``__budget_repr__`` protocol method on the object
29
+ 2. Type-specific renderer from the registry
30
+ 3. Generic fallback (primitives, containers, attribute decomposition)
31
+
32
+ Args:
33
+ obj: Any Python object.
34
+ budget: Maximum characters in the output.
35
+ policy: Budget allocation for object attributes.
36
+ ``"greedy"`` (default) gives each attr as much budget as
37
+ remains — first attrs get the most detail.
38
+ ``"even"`` divides budget equally across attrs so all
39
+ fields get comparable detail.
40
+
41
+ Returns:
42
+ A string of length <= budget representing the object.
43
+ """
44
+ policy_token = _policy.set(policy)
45
+ seen_token = _seen.set(set())
46
+ try:
47
+ return render_child(obj, budget)
48
+ finally:
49
+ _seen.reset(seen_token)
50
+ _policy.reset(policy_token)
51
+
52
+
53
+ def render_child(obj: object, budget: int) -> str:
54
+ """Render a child object within an ongoing render call.
55
+
56
+ Use this instead of ``render()`` inside custom renderers to avoid
57
+ resetting the policy and cycle-detection state.
58
+ """
59
+ if budget < MIN_BUDGET:
60
+ return "..."[:budget]
61
+
62
+ # Cycle detection for mutable containers/objects.
63
+ # Primitives (None, bool, int, float, str, bytes) are immutable
64
+ # and can't form cycles, so we skip them.
65
+ if not isinstance(obj, (type(None), bool, int, float, str, bytes)):
66
+ seen = _seen.get()
67
+ obj_id = id(obj)
68
+ if obj_id in seen:
69
+ return CIRCULAR[:budget]
70
+ seen.add(obj_id)
71
+
72
+ # 1. Protocol method
73
+ method = getattr(type(obj), PROTOCOL_METHOD, None)
74
+ if method is not None:
75
+ return method(obj, budget)[:budget]
76
+
77
+ # 2. Registry
78
+ renderer = get_renderer(type(obj))
79
+ if renderer is not None:
80
+ return renderer(obj, budget)[:budget]
81
+
82
+ # 3. Generic fallback
83
+ return _render_generic(obj, budget)
84
+
85
+
86
+ def _render_generic(obj: object, budget: int) -> str:
87
+ """Fallback renderer for unregistered types."""
88
+ if obj is None or isinstance(obj, (bool, int, float)):
89
+ return _render_primitive(obj, budget)
90
+
91
+ if isinstance(obj, str):
92
+ return _render_str(obj, budget)
93
+
94
+ if isinstance(obj, bytes):
95
+ return _render_bytes(obj, budget)
96
+
97
+ if isinstance(obj, collections.defaultdict):
98
+ return _render_defaultdict(obj, budget)
99
+
100
+ if isinstance(obj, collections.Counter):
101
+ return _render_counter(obj, budget)
102
+
103
+ if isinstance(obj, dict):
104
+ return _render_dict(obj, budget)
105
+
106
+ # namedtuple before plain tuple
107
+ if isinstance(obj, tuple) and hasattr(type(obj), "_fields"):
108
+ return _render_namedtuple(obj, budget)
109
+
110
+ if isinstance(obj, (list, tuple)):
111
+ return _render_sequence(obj, budget)
112
+
113
+ if isinstance(obj, (set, frozenset)):
114
+ return _render_set(obj, budget)
115
+
116
+ if isinstance(obj, collections.deque):
117
+ return _render_deque(obj, budget)
118
+
119
+ return _render_object(obj, budget)
120
+
121
+
122
+ def _render_primitive(obj: object, budget: int) -> str:
123
+ r = repr(obj)
124
+ if len(r) <= budget:
125
+ return r
126
+ return r[: budget - 1] + "\u2026"
127
+
128
+
129
+ def _render_str(obj: str, budget: int) -> str:
130
+ r = repr(obj)
131
+ if len(r) <= budget:
132
+ return r
133
+ # budget must fit: opening quote + chars + ... + closing quote = 1 + inner + 3 + 1
134
+ if budget < 6:
135
+ return r[:budget]
136
+ quote = r[0]
137
+ inner = budget - 5 # quote(1) + chars(inner) + ...(3) + quote(1)
138
+ return f"{quote}{obj[:inner]}...{quote}"
139
+
140
+
141
+ def _render_bytes(obj: bytes, budget: int) -> str:
142
+ r = repr(obj)
143
+ if len(r) <= budget:
144
+ return r
145
+ if budget < 6:
146
+ return r[:budget]
147
+ inner = budget - 5 # b' + chars + ... + '
148
+ return f"b'{obj[:inner].decode('ascii', errors='replace')}...'"
149
+
150
+
151
+ def _render_dict(obj: dict, budget: int) -> str:
152
+ if not obj:
153
+ return "{}"
154
+ n = len(obj)
155
+ tag = f"{{...{n} items}}"
156
+ if budget < len(tag):
157
+ return ("{...}"[:budget]) if budget >= 2 else "{"[:budget]
158
+ if budget <= len(tag):
159
+ return tag
160
+
161
+ remaining = budget - 2 # { and }
162
+ parts: list[str] = []
163
+ shown = 0
164
+
165
+ for key, value in obj.items():
166
+ sep_cost = 2 if parts else 0 # ", "
167
+ omitted = n - shown
168
+ reserve = len(f", ...{omitted - 1} more") if omitted > 1 else 0
169
+ available = remaining - sep_cost - reserve
170
+
171
+ key_r = render_child(key, min(available // 2, 40))
172
+ prefix = f"{key_r}: "
173
+ val_budget = available - len(prefix)
174
+
175
+ if val_budget < MIN_CHILD_BUDGET:
176
+ break
177
+
178
+ val_r = render_child(value, val_budget)
179
+ part = f"{prefix}{val_r}"
180
+ parts.append(part)
181
+ remaining -= len(part) + sep_cost
182
+ shown += 1
183
+
184
+ omitted = n - shown
185
+ if omitted > 0:
186
+ parts.append(f"...{omitted} more")
187
+
188
+ return "{" + ", ".join(parts) + "}"
189
+
190
+
191
+ def _render_sequence(obj: list | tuple | collections.deque, budget: int) -> str:
192
+ open_b, close_b = ("(", ")") if isinstance(obj, tuple) else ("[", "]")
193
+ if not obj:
194
+ return open_b + close_b
195
+ n = len(obj)
196
+
197
+ tag = f"{open_b}...{n} items{close_b}"
198
+ if budget <= len(tag):
199
+ return tag[:budget]
200
+
201
+ remaining = budget - 2 # brackets
202
+ head_parts: list[str] = []
203
+ tail_parts: list[str] = []
204
+
205
+ # Head items
206
+ head_idx = 0
207
+ while head_idx < n:
208
+ sep_cost = 2 if head_parts else 0
209
+ omitted = n - head_idx - len(tail_parts)
210
+ reserve = len(f", ...{omitted - 1} more") if omitted > 1 else 0
211
+ available = remaining - sep_cost - reserve
212
+
213
+ if available < MIN_CHILD_BUDGET:
214
+ break
215
+
216
+ part = render_child(obj[head_idx], available)
217
+ head_parts.append(part)
218
+ remaining -= len(part) + sep_cost
219
+ head_idx += 1
220
+
221
+ # Tail item if budget allows and we skipped items
222
+ omitted = n - head_idx
223
+ if omitted > 1 and remaining > MIN_CHILD_BUDGET + 15:
224
+ tail_budget = min(remaining - len(f", ...{omitted - 1} more, "), remaining // 3)
225
+ if tail_budget >= MIN_CHILD_BUDGET:
226
+ tail_r = render_child(obj[-1], tail_budget)
227
+ tail_parts.append(tail_r)
228
+ remaining -= len(tail_r) + 2
229
+ omitted -= 1
230
+
231
+ mid = f"...{omitted} more" if omitted > 0 else None
232
+ all_parts = head_parts + ([mid] if mid else []) + tail_parts
233
+ return open_b + ", ".join(all_parts) + close_b
234
+
235
+
236
+ def _render_set(obj: set | frozenset, budget: int) -> str:
237
+ prefix = "frozenset(" if isinstance(obj, frozenset) else ""
238
+ suffix = ")" if isinstance(obj, frozenset) else ""
239
+ open_b = prefix + "{"
240
+ close_b = "}" + suffix
241
+
242
+ if not obj:
243
+ if isinstance(obj, frozenset):
244
+ return ("frozenset()")[:budget]
245
+ return "set()"[:budget]
246
+
247
+ n = len(obj)
248
+ tag = f"{open_b}...{n} items{close_b}"
249
+ if budget <= len(tag):
250
+ return tag[:budget]
251
+
252
+ remaining = budget - len(open_b) - len(close_b)
253
+ parts: list[str] = []
254
+ shown = 0
255
+
256
+ for item in obj:
257
+ sep_cost = 2 if parts else 0
258
+ omitted = n - shown
259
+ reserve = len(f", ...{omitted - 1} more") if omitted > 1 else 0
260
+ available = remaining - sep_cost - reserve
261
+
262
+ if available < MIN_CHILD_BUDGET:
263
+ break
264
+
265
+ part = render_child(item, available)
266
+ parts.append(part)
267
+ remaining -= len(part) + sep_cost
268
+ shown += 1
269
+
270
+ omitted = n - shown
271
+ if omitted > 0:
272
+ parts.append(f"...{omitted} more")
273
+
274
+ return open_b + ", ".join(parts) + close_b
275
+
276
+
277
+ def _type_stub(val: object) -> str:
278
+ """Minimal type representation: <type> or <type(len)> for sized types."""
279
+ type_name = type(val).__name__
280
+ if hasattr(val, "__len__"):
281
+ try:
282
+ return f"<{type_name}({len(val)})>"
283
+ except Exception:
284
+ pass
285
+ return f"<{type_name}>"
286
+
287
+
288
+ def render_attrs(attrs: dict[str, object], type_name: str, budget: int) -> str:
289
+ """Render as TypeName(key=val, ...) with three-phase budget degradation.
290
+
291
+ Phase 1: Full render of values (greedy).
292
+ Phase 2: Stub remaining attrs as name=<type> or name=<type(len)>.
293
+ Phase 3: Count any remaining as ...N more.
294
+ """
295
+ tag = f"<{type_name}>"
296
+ n = len(attrs)
297
+
298
+ if not attrs:
299
+ return tag[:budget]
300
+
301
+ shell = f"{type_name}()"
302
+ if budget <= len(shell):
303
+ return tag[:budget]
304
+
305
+ remaining = budget - len(type_name) - 2 # Name( and )
306
+ parts: list[str] = []
307
+ attr_items = list(attrs.items())
308
+ idx = 0
309
+ even = _policy.get() == "even"
310
+
311
+ # Phase 1: Full render
312
+ while idx < n:
313
+ sep_cost = 2 if parts else 0
314
+ rest = n - idx
315
+ reserve = len(f", ...{rest - 1} more") if rest > 1 else 0
316
+ available = remaining - sep_cost - reserve
317
+
318
+ if even and rest > 1:
319
+ # Equal share of remaining budget per unrendered attr,
320
+ # accounting for separators between them.
321
+ per_attr = (remaining - (rest - 1) * 2) // rest
322
+ available = min(available, per_attr)
323
+
324
+ prefix = f"{attr_items[idx][0]}="
325
+ val_budget = available - len(prefix)
326
+
327
+ if val_budget < MIN_CHILD_BUDGET:
328
+ break
329
+
330
+ val_r = render_child(attr_items[idx][1], val_budget)
331
+ part = f"{prefix}{val_r}"
332
+ parts.append(part)
333
+ remaining -= len(part) + sep_cost
334
+ idx += 1
335
+
336
+ # Phase 2: Stub remaining with type info
337
+ while idx < n:
338
+ attr_name, attr_val = attr_items[idx]
339
+ stub = _type_stub(attr_val)
340
+ part = f"{attr_name}={stub}"
341
+ sep_cost = 2 if parts else 0
342
+ rest = n - idx - 1
343
+ reserve = len(f", ...{rest} more") if rest > 0 else 0
344
+
345
+ if len(part) + sep_cost + reserve > remaining:
346
+ break
347
+
348
+ parts.append(part)
349
+ remaining -= len(part) + sep_cost
350
+ idx += 1
351
+
352
+ # Phase 3: Count remaining
353
+ omitted = n - idx
354
+ if omitted > 0:
355
+ parts.append(f"...{omitted} more")
356
+
357
+ result = f"{type_name}(" + ", ".join(parts) + ")"
358
+ if len(result) > budget:
359
+ return tag[:budget]
360
+ return result
361
+
362
+
363
+ def _render_namedtuple(obj: tuple, budget: int) -> str:
364
+ type_name = type(obj).__name__
365
+ fields = type(obj)._fields
366
+ attrs = dict(zip(fields, obj))
367
+ return render_attrs(attrs, type_name, budget)
368
+
369
+
370
+ def _render_defaultdict(obj: collections.defaultdict, budget: int) -> str:
371
+ factory = obj.default_factory
372
+ factory_name = factory.__name__ if factory is not None else "None"
373
+ prefix = f"defaultdict({factory_name}, "
374
+ suffix = ")"
375
+
376
+ shell = f"defaultdict({factory_name})"
377
+ if budget <= len(shell):
378
+ return shell[:budget]
379
+
380
+ inner_budget = budget - len(prefix) - len(suffix)
381
+ if inner_budget < MIN_BUDGET:
382
+ return shell[:budget]
383
+
384
+ inner = _render_dict(dict(obj), inner_budget)
385
+ return prefix + inner + suffix
386
+
387
+
388
+ def _render_counter(obj: collections.Counter, budget: int) -> str:
389
+ prefix = "Counter("
390
+ suffix = ")"
391
+
392
+ if not obj:
393
+ return "Counter()"[:budget]
394
+
395
+ shell = "Counter()"
396
+ if budget <= len(shell):
397
+ return shell[:budget]
398
+
399
+ inner_budget = budget - len(prefix) - len(suffix)
400
+ if inner_budget < MIN_BUDGET:
401
+ return shell[:budget]
402
+
403
+ inner = _render_dict(dict(obj.most_common()), inner_budget)
404
+ return prefix + inner + suffix
405
+
406
+
407
+ def _render_deque(obj: collections.deque, budget: int) -> str:
408
+ if not obj:
409
+ return "deque()"[:budget]
410
+
411
+ prefix = "deque("
412
+ suffix = ")"
413
+ shell = prefix + suffix
414
+
415
+ if budget <= len(shell):
416
+ return shell[:budget]
417
+
418
+ inner_budget = budget - len(prefix) - len(suffix)
419
+ if inner_budget < MIN_BUDGET:
420
+ return shell[:budget]
421
+
422
+ inner = _render_sequence(obj, inner_budget)
423
+ return prefix + inner + suffix
424
+
425
+
426
+ def _render_object(obj: object, budget: int) -> str:
427
+ """Generic object renderer via attribute decomposition."""
428
+ type_name = type(obj).__name__
429
+
430
+ # Try custom __repr__ first — but only if it fits entirely.
431
+ # Skip default "<Foo object at 0x...>" and dataclasses (decomposition
432
+ # is always better since it budgets each field individually).
433
+ if not dataclasses.is_dataclass(obj):
434
+ try:
435
+ r = repr(obj)
436
+ is_default = r.startswith("<") and " object at 0x" in r
437
+ if not is_default and len(r) <= budget:
438
+ return r
439
+ except Exception:
440
+ pass
441
+
442
+ # Discover attributes
443
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
444
+ attrs = {
445
+ f.name: getattr(obj, f.name) for f in dataclasses.fields(obj) if f.repr
446
+ }
447
+ elif hasattr(obj, "__dict__"):
448
+ attrs = {k: v for k, v in vars(obj).items() if not k.startswith("_")}
449
+ elif hasattr(obj, "__slots__"):
450
+ attrs = {
451
+ s: getattr(obj, s)
452
+ for s in obj.__slots__
453
+ if not s.startswith("_") and hasattr(obj, s)
454
+ }
455
+ else:
456
+ attrs = {}
457
+
458
+ return render_attrs(attrs, type_name, budget)
reprobate/ext_arrow.py ADDED
@@ -0,0 +1,104 @@
1
+ """Optional renderer for PyArrow objects."""
2
+
3
+ try:
4
+ import pyarrow as pa
5
+ except ImportError:
6
+ pa = None
7
+
8
+ from .core import render_child
9
+ from .registry import register
10
+
11
+ if pa is not None:
12
+
13
+ @register(pa.Table)
14
+ def render_table(obj: "pa.Table", budget: int) -> str:
15
+ try:
16
+ r = repr(obj)
17
+ if len(r) <= budget:
18
+ return r
19
+ except Exception:
20
+ pass
21
+
22
+ rows, cols = obj.shape
23
+ col_names = obj.column_names
24
+ header = f"Table({rows}x{cols}"
25
+
26
+ if budget <= len(header) + 1:
27
+ return f"Table({rows}x{cols})"[:budget]
28
+
29
+ remaining = budget - len(header) - 5 # ", [" before cols, "])" at end
30
+ if remaining < 5:
31
+ return (header + ")")[:budget]
32
+
33
+ # Show column names
34
+ col_parts: list[str] = []
35
+ used = 0
36
+
37
+ for i, name in enumerate(col_names):
38
+ sep = 2 if col_parts else 0
39
+ omitted = len(col_names) - i - 1
40
+ reserve = len(f", ...{omitted} more") if omitted > 0 else 0
41
+ avail = remaining - used - sep - reserve
42
+
43
+ r = render_child(name, avail)
44
+ if len(r) > avail or avail < 3:
45
+ break
46
+ col_parts.append(r)
47
+ used += len(r) + sep
48
+
49
+ omitted = len(col_names) - len(col_parts)
50
+ if omitted > 0:
51
+ col_parts.append(f"...{omitted} more")
52
+
53
+ return header + ", [" + ", ".join(col_parts) + "])"
54
+
55
+ @register(pa.ChunkedArray)
56
+ def render_chunked_array(obj: "pa.ChunkedArray", budget: int) -> str:
57
+ try:
58
+ r = repr(obj)
59
+ if len(r) <= budget:
60
+ return r
61
+ except Exception:
62
+ pass
63
+
64
+ dtype = str(obj.type)
65
+ n = len(obj)
66
+ header = f"ChunkedArray({n}, {dtype})"
67
+ return header[:budget]
68
+
69
+ @register(pa.Array)
70
+ def render_array(obj: "pa.Array", budget: int) -> str:
71
+ dtype = str(obj.type)
72
+ n = len(obj)
73
+ header = f"Array({n}, {dtype}"
74
+
75
+ if budget <= len(header) + 1:
76
+ return f"Array({n}, {dtype})"[:budget]
77
+
78
+ remaining = budget - len(header) - 5 # ", [" before data, "])" at end
79
+ if remaining < 5:
80
+ return (header + ")")[:budget]
81
+
82
+ # Peek at values
83
+ peek_parts: list[str] = []
84
+ used = 0
85
+ limit = min(n, 20)
86
+
87
+ for i in range(limit):
88
+ sep = 2 if peek_parts else 0
89
+ omitted = n - i - 1
90
+ reserve = len(f", ...{omitted} more") if omitted > 0 else 0
91
+ avail = remaining - used - sep - reserve
92
+
93
+ val = obj[i].as_py()
94
+ r = render_child(val, avail)
95
+ if len(r) > avail or avail < 5:
96
+ break
97
+ peek_parts.append(r)
98
+ used += len(r) + sep
99
+
100
+ omitted = n - len(peek_parts)
101
+ if omitted > 0:
102
+ peek_parts.append(f"...{omitted} more")
103
+
104
+ return header + ", [" + ", ".join(peek_parts) + "])"
reprobate/ext_numpy.py ADDED
@@ -0,0 +1,50 @@
1
+ """Optional renderer for numpy arrays."""
2
+
3
+ try:
4
+ import numpy as np
5
+ except ImportError:
6
+ np = None
7
+
8
+ from .core import render_child
9
+ from .registry import register
10
+
11
+ if np is not None:
12
+
13
+ @register(np.ndarray)
14
+ def render_ndarray(obj: "np.ndarray", budget: int) -> str:
15
+ shape = "x".join(str(d) for d in obj.shape)
16
+ dtype = str(obj.dtype)
17
+ header = f"ndarray({shape}, {dtype}"
18
+
19
+ # header + ")" is the minimum useful output
20
+ if budget <= len(header) + 1:
21
+ return f"ndarray({shape})"[:budget]
22
+
23
+ remaining = budget - len(header) - 5 # ", [" before data, "])" at end
24
+ if remaining < 5:
25
+ return (header + ")")[:budget]
26
+
27
+ # Peek at flattened values
28
+ flat = obj.flat
29
+ n = obj.size
30
+ peek_parts: list[str] = []
31
+ used = 0
32
+
33
+ limit = min(n, 20)
34
+ for i in range(limit):
35
+ sep = 2 if peek_parts else 0 # ", "
36
+ omitted = n - i - 1
37
+ reserve = len(f", ...{omitted} more") if omitted > 0 else 0
38
+ avail = remaining - used - sep - reserve
39
+
40
+ r = render_child(flat[i], avail)
41
+ if len(r) > avail or avail < 5:
42
+ break
43
+ peek_parts.append(r)
44
+ used += len(r) + sep
45
+
46
+ omitted = n - len(peek_parts)
47
+ if omitted > 0:
48
+ peek_parts.append(f"...{omitted} more")
49
+
50
+ return header + ", [" + ", ".join(peek_parts) + "])"
@@ -0,0 +1,70 @@
1
+ """Optional renderer for pandas objects."""
2
+
3
+ try:
4
+ import pandas as pd
5
+ except ImportError:
6
+ pd = None
7
+
8
+ from .core import render_child
9
+ from .registry import register
10
+
11
+ if pd is not None:
12
+
13
+ @register(pd.DataFrame)
14
+ def render_dataframe(obj: "pd.DataFrame", budget: int) -> str:
15
+ # Let pandas repr handle it if it fits
16
+ try:
17
+ r = repr(obj)
18
+ if len(r) <= budget:
19
+ return r
20
+ except Exception:
21
+ pass
22
+
23
+ rows, cols = obj.shape
24
+ col_names = list(obj.columns)
25
+ header = f"DataFrame({rows}x{cols}"
26
+
27
+ if budget <= len(header) + 1:
28
+ return f"DataFrame({rows}x{cols})"[:budget]
29
+
30
+ remaining = budget - len(header) - 5 # ", [" before cols, "])" at end
31
+ if remaining < 5:
32
+ return (header + ")")[:budget]
33
+
34
+ # Show column names
35
+ col_parts: list[str] = []
36
+ used = 0
37
+
38
+ for i, name in enumerate(col_names):
39
+ sep = 2 if col_parts else 0
40
+ omitted = len(col_names) - i - 1
41
+ reserve = len(f", ...{omitted} more") if omitted > 0 else 0
42
+ avail = remaining - used - sep - reserve
43
+
44
+ r = render_child(name, avail)
45
+ if len(r) > avail or avail < 3:
46
+ break
47
+ col_parts.append(r)
48
+ used += len(r) + sep
49
+
50
+ omitted = len(col_names) - len(col_parts)
51
+ if omitted > 0:
52
+ col_parts.append(f"...{omitted} more")
53
+
54
+ return header + ", [" + ", ".join(col_parts) + "])"
55
+
56
+ @register(pd.Series)
57
+ def render_series(obj: "pd.Series", budget: int) -> str:
58
+ try:
59
+ r = repr(obj)
60
+ if len(r) <= budget:
61
+ return r
62
+ except Exception:
63
+ pass
64
+
65
+ dtype = str(obj.dtype)
66
+ n = len(obj)
67
+ name_part = f", name={obj.name!r}" if obj.name is not None else ""
68
+ header = f"Series({n}, {dtype}{name_part})"
69
+
70
+ return header[:budget]
reprobate/ext_pil.py ADDED
@@ -0,0 +1,20 @@
1
+ """Optional renderer for PIL/Pillow images."""
2
+
3
+ try:
4
+ from PIL import Image
5
+ except ImportError:
6
+ Image = None
7
+
8
+ from .registry import register
9
+
10
+ if Image is not None:
11
+
12
+ @register(Image.Image)
13
+ def render_image(obj: "Image.Image", budget: int) -> str:
14
+ w, h = obj.size
15
+ mode = obj.mode
16
+ fmt = obj.format
17
+ fmt_part = f", format={fmt}" if fmt else ""
18
+ header = f"Image({w}x{h}, {mode}{fmt_part})"
19
+
20
+ return header[:budget]
@@ -0,0 +1,69 @@
1
+ """Optional renderer for polars objects."""
2
+
3
+ try:
4
+ import polars as pl
5
+ except ImportError:
6
+ pl = None
7
+
8
+ from .core import render_child
9
+ from .registry import register
10
+
11
+ if pl is not None:
12
+
13
+ @register(pl.DataFrame)
14
+ def render_dataframe(obj: "pl.DataFrame", budget: int) -> str:
15
+ try:
16
+ r = repr(obj)
17
+ if len(r) <= budget:
18
+ return r
19
+ except Exception:
20
+ pass
21
+
22
+ rows, cols = obj.shape
23
+ col_names = obj.columns
24
+ header = f"DataFrame({rows}x{cols}"
25
+
26
+ if budget <= len(header) + 1:
27
+ return f"DataFrame({rows}x{cols})"[:budget]
28
+
29
+ remaining = budget - len(header) - 5 # ", [" before cols, "])" at end
30
+ if remaining < 5:
31
+ return (header + ")")[:budget]
32
+
33
+ # Show column names
34
+ col_parts: list[str] = []
35
+ used = 0
36
+
37
+ for i, name in enumerate(col_names):
38
+ sep = 2 if col_parts else 0
39
+ omitted = len(col_names) - i - 1
40
+ reserve = len(f", ...{omitted} more") if omitted > 0 else 0
41
+ avail = remaining - used - sep - reserve
42
+
43
+ r = render_child(name, avail)
44
+ if len(r) > avail or avail < 3:
45
+ break
46
+ col_parts.append(r)
47
+ used += len(r) + sep
48
+
49
+ omitted = len(col_names) - len(col_parts)
50
+ if omitted > 0:
51
+ col_parts.append(f"...{omitted} more")
52
+
53
+ return header + ", [" + ", ".join(col_parts) + "])"
54
+
55
+ @register(pl.Series)
56
+ def render_series(obj: "pl.Series", budget: int) -> str:
57
+ try:
58
+ r = repr(obj)
59
+ if len(r) <= budget:
60
+ return r
61
+ except Exception:
62
+ pass
63
+
64
+ dtype = str(obj.dtype)
65
+ n = len(obj)
66
+ name_part = f", name={obj.name!r}" if obj.name else ""
67
+ header = f"Series({n}, {dtype}{name_part})"
68
+
69
+ return header[:budget]
@@ -0,0 +1,17 @@
1
+ """Optional renderer for pydantic models."""
2
+
3
+ try:
4
+ import pydantic
5
+ except ImportError:
6
+ pydantic = None
7
+
8
+ from .core import render_attrs
9
+ from .registry import register
10
+
11
+ if pydantic is not None:
12
+
13
+ @register(pydantic.BaseModel)
14
+ def render_basemodel(obj: "pydantic.BaseModel", budget: int) -> str:
15
+ type_name = type(obj).__name__
16
+ attrs = {name: getattr(obj, name) for name in type(obj).model_fields}
17
+ return render_attrs(attrs, type_name, budget)
reprobate/py.typed ADDED
File without changes
reprobate/registry.py ADDED
@@ -0,0 +1,32 @@
1
+ """Type-specific renderer registry."""
2
+
3
+ from typing import Any, Callable
4
+
5
+ Renderer = Callable[[Any, int], str]
6
+
7
+ _registry: dict[type, Renderer] = {}
8
+
9
+
10
+ def register(cls: type) -> Callable[[Renderer], Renderer]:
11
+ """Register a budget renderer for a type.
12
+
13
+ Usage::
14
+
15
+ @register(MyClass)
16
+ def render_my_class(obj: MyClass, budget: int) -> str:
17
+ ...
18
+ """
19
+
20
+ def decorator(fn: Renderer) -> Renderer:
21
+ _registry[cls] = fn
22
+ return fn
23
+
24
+ return decorator
25
+
26
+
27
+ def get_renderer(cls: type) -> Renderer | None:
28
+ """Look up a renderer for a type, checking MRO."""
29
+ for klass in cls.__mro__:
30
+ if klass in _registry:
31
+ return _registry[klass]
32
+ return None
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.4
2
+ Name: reprobate
3
+ Version: 0.1.0
4
+ Summary: Budget-controlled repr for Python objects.
5
+ Author: ashenfad
6
+ License-Expression: MIT
7
+ Keywords: repr,pretty-print,budget,truncation,llm
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Provides-Extra: dev
21
+ Requires-Dist: ruff; extra == "dev"
22
+ Requires-Dist: pre-commit; extra == "dev"
23
+ Requires-Dist: pytest; extra == "dev"
24
+ Requires-Dist: pytest-timeout; extra == "dev"
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest; extra == "test"
27
+ Requires-Dist: pytest-timeout; extra == "test"
28
+ Requires-Dist: numpy; extra == "test"
29
+ Requires-Dist: pandas; extra == "test"
30
+ Requires-Dist: Pillow; extra == "test"
31
+ Requires-Dist: polars; extra == "test"
32
+ Requires-Dist: pyarrow; extra == "test"
33
+ Requires-Dist: pydantic; extra == "test"
34
+ Dynamic: license-file
35
+
36
+ # reprobate 🖨️
37
+
38
+ Budget-controlled repr for Python objects.
39
+
40
+ Renders any Python object into a string that fits within a character budget. Nested structures degrade gracefully: full values, then type stubs, then counts. Zero dependencies. Pluggable via a type registry and a `__budget_repr__` protocol.
41
+
42
+ ## Features
43
+
44
+ - **Hard budget guarantee** -- output is always `<= budget` characters
45
+ - **Three-phase degradation** -- full render, then `name=<type(len)>` stubs, then `...N more` counts
46
+ - **Greedy and even policies** -- prioritize depth (first fields in detail) or breadth (all fields equally)
47
+ - **Cycle detection** -- circular references render as `<...>` instead of stack overflows
48
+ - **Type registry** -- `@register(MyType)` for custom budget-aware renderers
49
+ - **Protocol method** -- `__budget_repr__(self, budget)` on any class
50
+ - **Optional extensions** -- arrow, numpy, pandas, pil, polars, pydantic renderers (guarded imports, zero cost if absent)
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install reprobate
56
+ ```
57
+
58
+ Zero dependencies. Optional renderers activate automatically when their libraries are already installed (numpy, pandas, polars, pyarrow, Pillow, pydantic).
59
+
60
+ ## Quick example
61
+
62
+ ```python
63
+ import reprobate
64
+
65
+ reprobate.render({"name": "alice", "scores": [98, 87, 95, 72, 88]}, 60)
66
+ # "{'name': 'alice', 'scores': [98, 87, 95, 72, 88]}"
67
+
68
+ reprobate.render({"name": "alice", "scores": [98, 87, 95, 72, 88]}, 30)
69
+ # "{'name': 'alice', ...1 more}"
70
+
71
+ reprobate.render(list(range(1000)), 40)
72
+ # "[0, 1, 2, 3, ...996 more]"
73
+ ```
74
+
75
+ ## Policies
76
+
77
+ ```python
78
+ from dataclasses import dataclass
79
+
80
+ @dataclass
81
+ class Agent:
82
+ desc: str = "A very long description that eats the budget"
83
+ important_note: str = "critical info here"
84
+ status: str = "running"
85
+ config: dict = None
86
+ history: list = None
87
+
88
+ # Greedy: first fields get full detail
89
+ reprobate.render(agent, 100, policy="greedy")
90
+ # "Agent(desc='A very long description that eats the budget', important_note=<str(18)>, ...3 more)"
91
+
92
+ # Even: all fields get comparable detail
93
+ reprobate.render(agent, 100, policy="even")
94
+ # "Agent(desc='A very long...', important_note='critical info...', status='running', ...2 more)"
95
+ ```
96
+
97
+ ## Custom renderers
98
+
99
+ Register a renderer for any type:
100
+
101
+ ```python
102
+ @reprobate.register(MyType)
103
+ def render_my_type(obj: MyType, budget: int) -> str:
104
+ return f"MyType({obj.key})"[:budget]
105
+ ```
106
+
107
+ Or implement the protocol directly:
108
+
109
+ ```python
110
+ class MyType:
111
+ def __budget_repr__(self, budget: int) -> str:
112
+ return f"MyType({self.key})"[:budget]
113
+ ```
114
+
115
+ For renderers that recurse into child objects, use `render_child` (inherits policy and cycle detection) and `render_attrs` (standard `TypeName(key=val, ...)` pattern):
116
+
117
+ ```python
118
+ from reprobate import register, render_child, render_attrs
119
+
120
+ @register(MyContainer)
121
+ def render_my_container(obj: MyContainer, budget: int) -> str:
122
+ # render_child for recursive rendering
123
+ inner = render_child(obj.value, budget - 10)
124
+ return f"MyContainer({inner})"
125
+
126
+ @register(MyModel)
127
+ def render_my_model(obj: MyModel, budget: int) -> str:
128
+ # render_attrs for the standard object pattern
129
+ attrs = {"name": obj.name, "data": obj.data}
130
+ return render_attrs(attrs, "MyModel", budget)
131
+ ```
132
+
133
+ ## Supported types
134
+
135
+ | Category | Types | Behavior |
136
+ |----------|-------|----------|
137
+ | Primitives | `None`, `bool`, `int`, `float` | `repr()`, truncated with `...` if needed |
138
+ | Strings | `str`, `bytes` | Quoted, truncated with `...` preserving quotes |
139
+ | Containers | `list`, `tuple`, `set`, `frozenset` | Head items + `...N more`, tail peek when budget allows |
140
+ | Dicts | `dict` | Key-value pairs + `...N more` |
141
+ | Collections | `deque`, `defaultdict`, `Counter` | Type-aware wrappers (factory name, most-common order) |
142
+ | Structured | `dataclass`, `namedtuple` | Field-aware decomposition, respects `repr=False` |
143
+ | Objects | anything with `__dict__` | Attribute decomposition, public attrs only |
144
+ | Optional | numpy, pandas, polars, pyarrow, Pillow, pydantic | Shape/dtype/columns summary (auto-activates when lib is installed) |
145
+
146
+ ## Development
147
+
148
+ ```bash
149
+ uv sync --extra dev
150
+ uv run pytest
151
+ ```
@@ -0,0 +1,15 @@
1
+ reprobate/__init__.py,sha256=3WQY1A9TaZ_i_KlKsAN7RX4d-lEJS2c4jcNqalz89GM,519
2
+ reprobate/core.py,sha256=6iR3P8s1nZ4j3b4zgpUNQB4M_q5DUox8on7r0LifsZM,13526
3
+ reprobate/ext_arrow.py,sha256=rSyigOPlhv7I0tbsF0aqUh0y_Aed9zE5Y-NA3poL1WA,2971
4
+ reprobate/ext_numpy.py,sha256=WMQRCadF8b5VRDFFrEVmdRbAPUWNQq35qwiQywY1fa4,1447
5
+ reprobate/ext_pandas.py,sha256=gPlkrFyGUOQnCC-SZskcLlhnf9ZWdd4JFNUSs49iMHA,1968
6
+ reprobate/ext_pil.py,sha256=YZlhasGSfZLkhe1fj_0-EtC6nxbq3QFKUDfMSAZxoUE,471
7
+ reprobate/ext_polars.py,sha256=l5Zk7qge4Jti-KH2faL5ahk2rUbMfgLibowjqh9dKAA,1903
8
+ reprobate/ext_pydantic.py,sha256=ls9WoqyJQQC_86qg-NEjTaCAtHvJrIwHr0_6c_8LCUo,479
9
+ reprobate/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ reprobate/registry.py,sha256=IeiAXy7PKAGWK_-tYH9VKT6LBjNc30A3XHushLqdQMc,716
11
+ reprobate-0.1.0.dist-info/licenses/LICENSE,sha256=mkczgS_OETNm2hMlJwDscbMGpBgyLheu2UhM2-ERWHo,1073
12
+ reprobate-0.1.0.dist-info/METADATA,sha256=c661-KBncceSKUOFL8iW_-domyBk_i-iFUvLd1dkLfI,5389
13
+ reprobate-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
14
+ reprobate-0.1.0.dist-info/top_level.txt,sha256=AIoXDTC_2KQz-cXvSnBSYWOaTKCATTYlbKrsolvDSjM,10
15
+ reprobate-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adam Ashenfelter
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ reprobate