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 +22 -0
- reprobate/core.py +458 -0
- reprobate/ext_arrow.py +104 -0
- reprobate/ext_numpy.py +50 -0
- reprobate/ext_pandas.py +70 -0
- reprobate/ext_pil.py +20 -0
- reprobate/ext_polars.py +69 -0
- reprobate/ext_pydantic.py +17 -0
- reprobate/py.typed +0 -0
- reprobate/registry.py +32 -0
- reprobate-0.1.0.dist-info/METADATA +151 -0
- reprobate-0.1.0.dist-info/RECORD +15 -0
- reprobate-0.1.0.dist-info/WHEEL +5 -0
- reprobate-0.1.0.dist-info/licenses/LICENSE +21 -0
- reprobate-0.1.0.dist-info/top_level.txt +1 -0
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) + "])"
|
reprobate/ext_pandas.py
ADDED
|
@@ -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]
|
reprobate/ext_polars.py
ADDED
|
@@ -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,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
|