Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Pythonistas follow an implicit convention to create special [`__repr__`](https:/
- [**Filtering**](#filtering)
- [**Custom display of objects**](#custom-display-of-objects)
- [**Placeholders**](#placeholders)
- [**Output limits**](#output-limits)
- [**Auto mode**](#auto-mode)


Expand Down Expand Up @@ -169,6 +170,41 @@ print(
> 🤓 If you set a placeholder for a parameter, the [custom serializer](#custom-display-of-objects) will not be applied to it.


## Output limits

You can limit the length of individual serialized values with `item_limit`, and the total length of the output string with `total_limit`. Both are disabled by default (`None`).

`item_limit` truncates each serialized value to at most `N` characters, appending `...` if the value is longer:

```python
print(
describe_data_object(
'MyClass',
(123456789,),
{'name': 'a very long string'},
item_limit=5,
)
)
#> MyClass(12345..., name='a ver'...)
```

`total_limit` limits the total length of the output. If the output would be too long, whole items are dropped from the right and replaced with `...`:

```python
print(
describe_data_object(
'MyClass',
(),
{'a': 1, 'b': 2, 'c': 3},
total_limit=15,
)
)
#> MyClass(a=1, ...)
```

If `total_limit` is too small to fit even `ClassName(...)`, a `ValueError` is raised. The minimum valid value is `len(class_name) + 5`.


## Auto mode

> ⚠️ Auto mode is currently experimental, so there may be some bugs.
Expand Down Expand Up @@ -197,6 +233,19 @@ print(SomeClass(1, 2, 3, 4, 5, d=lambda x: x))

How does it work? Behind the scenes, the decorator uses AST analysis to generate code. The decorator attempts to determine which arguments passed to `__init__` are stored in which attributes. In other words, it looks for direct assignments of the form `self.a = a` in the `__init__` method.

Conditional (ternary) assignments are also recognized. If you write `self.a = a if a else default`, the decorator understands that parameter `a` is stored in attribute `a`:

```python
@repred
class SomeClass:
def __init__(self, a, b):
self.a = a if a is not None else 0
self.b = b

print(SomeClass(42, 'hello'))
#> SomeClass(a=42, b='hello')
```

If there is no *direct assignment* of a specific argument, an exception will be raised:

```python
Expand Down
1 change: 1 addition & 0 deletions printo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from printo.describe import (
descript_data_object as descript_data_object,
)
from printo.errors import AmbiguousMappingError as AmbiguousMappingError
from printo.errors import CanNotBePositionalError as CanNotBePositionalError
from printo.errors import ParameterMappingNotFoundError as ParameterMappingNotFoundError
from printo.errors import RedefinitionError as RedefinitionError
Expand Down
122 changes: 93 additions & 29 deletions printo/describe.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,117 @@
from printo.reprs import superrepr


def _serialize_item( # noqa: PLR0913
key: Union[str, int],
value: Any,
filters: Dict[Union[str, int], Callable[[Any], bool]],
get_placeholder: Callable[[Union[str, int]], Optional[str]],
serializer: Callable[[Any], str],
item_limit: Optional[int],
) -> Optional[str]:
from sigmatch import PossibleCallMatcher # noqa: PLC0415

decider = filters.get(key, lambda x: True) # noqa: ARG005
PossibleCallMatcher('.').match(decider, raise_exception=True)
if not decider(value):
return None
placeholder = get_placeholder(key)
serialized = placeholder if placeholder is not None else serializer(value)
if item_limit is not None:
if placeholder is not None:
if len(serialized) > item_limit:
serialized = serialized[:item_limit] + '...'
elif value is ...:
pass # Ellipsis is never truncated
elif isinstance(value, (str, bytes)) and serialized == repr(value):
if len(value) > item_limit:
serialized = repr(value[:item_limit]) + '...'
elif len(serialized) > item_limit:
serialized = serialized[:item_limit] + '...'
return serialized


def _serialize_items( # noqa: PLR0913
items: Iterable[Tuple[Union[str, int], Any]],
format_chunk: Callable[[Union[str, int], str], str],
filters: Dict[Union[str, int], Callable[[Any], bool]],
get_placeholder: Callable[[Union[str, int]], Optional[str]],
serializer: Callable[[Any], str],
item_limit: Optional[int],
) -> Tuple[List[str], List[bool]]:
chunks: List[str] = []
pinned: List[bool] = []
for key, value in items:
result = _serialize_item(key, value, filters, get_placeholder, serializer, item_limit)
if result is not None:
chunks.append(format_chunk(key, result))
pinned.append(value is ...)
return chunks, pinned


def describe_data_object( # noqa: PLR0913
class_name: str,
args: Union[Tuple[Any, ...], List[Any]],
kwargs: Dict[str, Any],
serializer: Callable[[Any], str] = superrepr,
filters: Optional[Dict[Union[str, int], Callable[[Any], bool]]] = None,
placeholders: Optional[Dict[Union[str, int], str]] = None,
item_limit: Optional[int] = None,
total_limit: Optional[int] = None,
) -> str:
from sigmatch import PossibleCallMatcher # noqa: PLC0415

PossibleCallMatcher('.').match(serializer, raise_exception=True)

if item_limit is not None and item_limit < 0:
raise ValueError(f'item_limit must be a non-negative integer, got {item_limit}.')

if total_limit is not None:
if total_limit < 0:
raise ValueError(f'total_limit must be a non-negative integer, got {total_limit}.')
minimum = len(class_name) + 2
if total_limit < minimum:
raise ValueError(
f'total_limit ({total_limit}) is too small for class name {class_name!r}. '
f'Minimum is {minimum} (class name length + 2 for parentheses).',
)

real_filters: Dict[Union[str, int], Callable[[Any], bool]] = (
filters if filters is not None else {}
)
get_placeholder: Callable[[Union[str, int]], Optional[str]] = lambda field_name: placeholders.get(field_name) if placeholders is not None else None

def serialize_item(
key: Union[str, int],
value: Any,
) -> Optional[str]:
decider = real_filters.get(key, lambda x: True) # noqa: ARG005
PossibleCallMatcher('.').match(decider, raise_exception=True)
if not decider(value):
return None
placeholder = get_placeholder(key)
return placeholder if placeholder is not None else serializer(value)

def serialize_items(
items: Iterable[Tuple[Union[str, int], Any]],
format_chunk: Callable[[Union[str, int], str], str],
) -> str:
chunks = []
for key, value in items:
result = serialize_item(key, value)
if result is not None:
chunks.append(format_chunk(key, result))
return ', '.join(chunks)

args_description = serialize_items(enumerate(args), lambda _, value: value)
kwargs_description = serialize_items(kwargs.items(), lambda key, value: f'{key}={value}')

breackets_content = ', '.join(
[x for x in (args_description, kwargs_description) if x],
)
args_chunks, args_pinned = _serialize_items(enumerate(args), lambda _, value: value, real_filters, get_placeholder, serializer, item_limit)
kwargs_chunks, kwargs_pinned = _serialize_items(kwargs.items(), lambda key, value: f'{key}={value}', real_filters, get_placeholder, serializer, item_limit)

all_chunks = args_chunks + kwargs_chunks
all_pinned = args_pinned + kwargs_pinned

full_output = f'{class_name}({", ".join(all_chunks)})'

if total_limit is not None and len(full_output) > total_limit:
droppable = [i for i in range(len(all_chunks)) if not all_pinned[i]]
pinned_indices = [i for i in range(len(all_chunks)) if all_pinned[i]]

for num_keep in range(len(droppable) - 1, -1, -1):
kept_indices = sorted(pinned_indices + droppable[:num_keep])
kept_chunks = [all_chunks[i] for i in kept_indices]
if kept_chunks:
content = f'{class_name}({", ".join(kept_chunks)})'
output = f'{class_name}({", ".join(kept_chunks)}, ...)'
else:
content = f'{class_name}()'
output = f'{class_name}(...)'
if len(content) <= total_limit:
return output

# Ellipsis exemption: all droppable dropped, return pinned-only output regardless of limit
pinned_chunks = [all_chunks[i] for i in pinned_indices]
if not droppable:
return full_output
return f'{class_name}({", ".join(pinned_chunks)}, ...)'

return f'{class_name}({breackets_content})'
return full_output


descript_data_object = describe_data_object
4 changes: 4 additions & 0 deletions printo/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ class ParameterMappingNotFoundError(Exception):

class CanNotBePositionalError(Exception):
...


class AmbiguousMappingError(Exception):
...
50 changes: 41 additions & 9 deletions printo/repred.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from ast import Assign, Attribute, Name, parse
from ast import Assign, Attribute, IfExp, Name, parse
from functools import partial
from inspect import Parameter, Signature, getattr_static, isclass, signature
from typing import (
Expand All @@ -8,6 +8,7 @@
Iterable,
List,
Optional,
Tuple,
Type,
TypeVar,
Union,
Expand All @@ -19,18 +20,19 @@

from printo import describe_data_object
from printo.errors import (
AmbiguousMappingError,
CanNotBePositionalError,
ParameterMappingNotFoundError,
RedefinitionError,
)

ClassType = TypeVar('ClassType', bound=Type[Any])

def get_mapping(cls: ClassType) -> Dict[str, str]:
def get_mapping(cls: ClassType) -> Tuple[Dict[str, str], List[Tuple[str, str, str]]]:
try:
source = getclearsource(cls.__init__)
except TypeError:
return {}
return {}, []

tree = parse(source)

Expand All @@ -42,12 +44,30 @@ def get_mapping(cls: ClassType) -> Dict[str, str]:
except IndexError as e:
raise ParameterMappingNotFoundError(f'It seems that the "self" argument was not found for the __init__ method of class {cls.__name__}.') from e

results = {}
results: Dict[str, str] = {}
ambiguities: List[Tuple[str, str, str]] = []
for node in tree.body[0].body: # type: ignore[attr-defined]
if isinstance(node, Assign) and len(node.targets) == 1 and isinstance(node.targets[0], Attribute) and isinstance(node.targets[0].value, Name) and node.targets[0].value.id == self_name and isinstance(node.value, Name):
results[node.value.id] = node.targets[0].attr

return results
if isinstance(node, Assign) and len(node.targets) == 1 and isinstance(node.targets[0], Attribute) and isinstance(node.targets[0].value, Name) and node.targets[0].value.id == self_name:
attr_name = node.targets[0].attr
if isinstance(node.value, Name):
results[node.value.id] = attr_name
elif isinstance(node.value, IfExp):
if isinstance(node.value.body, IfExp) or isinstance(node.value.orelse, IfExp):
pass # Nested ternaries are not supported — skip
else:
body_is_param = isinstance(node.value.body, Name) and node.value.body.id != self_name
orelse_is_param = isinstance(node.value.orelse, Name) and node.value.orelse.id != self_name
if body_is_param and orelse_is_param and node.value.body.id != node.value.orelse.id: # type: ignore[attr-defined]
ambiguities.append((node.value.body.id, node.value.orelse.id, attr_name)) # type: ignore[attr-defined]
elif body_is_param and orelse_is_param:
# Both branches are the same parameter — not ambiguous
results[node.value.body.id] = attr_name # type: ignore[attr-defined]
elif body_is_param:
results[node.value.body.id] = attr_name # type: ignore[attr-defined]
elif orelse_is_param:
results[node.value.orelse.id] = attr_name # type: ignore[attr-defined]

return results, ambiguities


@overload
Expand Down Expand Up @@ -106,7 +126,19 @@ def repred(cls: Optional[ClassType] = None, prefer_positional: bool = False, qua
positionals = []
positionals_to_compare = set(positionals)

names_mapping = get_mapping(cls)
names_mapping, ambiguities = get_mapping(cls)

for param1, param2, attr_name in ambiguities:
unresolved = [
parameter_name for parameter_name in (param1, param2)
if parameter_name not in default_getters and parameter_name not in ignored_parameters
]
if unresolved:
raise AmbiguousMappingError(
f'Ternary expression in assignment to self.{attr_name} uses two different parameters '
f'({param1}, {param2}), making it ambiguous which parameter value will be stored. '
f'Provide custom getters for both parameters to resolve this.',
)

positional_getters = {}
keyword_getters = {}
Expand Down
10 changes: 8 additions & 2 deletions printo/reprs.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def superrepr(value: Any) -> str: # noqa: PLR0911
if result == '<lambda>':
try:
return getclearsource(value)
except UncertaintyWithLambdasError:
except (UncertaintyWithLambdasError, OSError):
return 'λ'

return result
Expand All @@ -30,4 +30,10 @@ def superrepr(value: Any) -> str: # noqa: PLR0911
parts.extend(f'{k}={superrepr(v)}' for k, v in value.keywords.items())
return f'functools.partial({", ".join(parts)})'

return repr(value)
try:
return repr(value)
except Exception: # noqa: BLE001
try:
return f"<{type(value).__name__}'s object>"
except Exception: # noqa: BLE001
return '<unprintable>'
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta'

[project]
name = 'printo'
version = '0.0.23'
version = '0.0.24'
authors = [
{ name='Evgeniy Blinov', email='zheni-b@yandex.ru' },
]
Expand Down
1 change: 1 addition & 0 deletions requirements_dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ mypy==1.14.1
ruff==0.14.6
mutmut==3.2.3
full_match==0.0.3
suby==0.0.9
30 changes: 30 additions & 0 deletions tests/documentation/test_readme.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,33 @@ def test_placeholders():
'variable_name': '***',
},
) == "MySuperClass(1, ***, 'lol', variable_name=***, second_variable_name='kek')"


def test_item_limit():
assert describe_data_object(
'MyClass',
(123456789,),
{'name': 'a very long string'},
item_limit=5,
) == "MyClass(12345..., name='a ver'...)"


def test_total_limit():
assert describe_data_object(
'MyClass',
(),
{'a': 1, 'b': 2, 'c': 3},
total_limit=15,
) == 'MyClass(a=1, ...)'


def test_repred_conditional_expression():
from printo import repred # noqa: PLC0415

@repred
class SomeClass:
def __init__(self, a: int, b: str) -> None:
self.a = a if a is not None else 0
self.b = b

assert repr(SomeClass(42, 'hello')) == "SomeClass(a=42, b='hello')"
Loading
Loading