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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ build
html
.coverage
CLAUDE.md
AGENTS.md
11 changes: 11 additions & 0 deletions printo/describe.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from keyword import iskeyword
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union

from printo.reprs import superrepr
Expand Down Expand Up @@ -63,6 +64,16 @@ def describe_call( # noqa: PLR0913
) -> str:
from sigmatch import PossibleCallMatcher # noqa: PLC0415

if not isinstance(class_name, str):
raise TypeError(f'class_name must be a string, got {type(class_name).__name__}.')

for part in class_name.split('.'):
identifier = part[1:-1] if part.startswith('<') and part.endswith('>') else part
if not identifier.isidentifier():
raise ValueError(f'class_name must be a valid Python identifier, a valid Python identifier wrapped in angle brackets, or a dot-separated series of those, got {class_name!r}.')
if iskeyword(identifier):
raise ValueError(f'class_name contains Python keyword {identifier!r}, which is not allowed: {class_name!r}.')

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

if item_limit is not None and item_limit < 0:
Expand Down
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.27'
version = '0.0.28'
authors = [
{ name='Evgeniy Blinov', email='zheni-b@yandex.ru' },
]
Expand Down
5 changes: 3 additions & 2 deletions tests/typing/test_describe_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ def test_describe_call_with_total_limit():

@pytest.mark.mypy_testing
def test_describe_call_invalid_class_name_type():
"""mypy rejects int as class_name — expected str."""
describe_call(42, [], {}) # E: [arg-type]
"""mypy and runtime validation both reject non-string class_name values."""
with pytest.raises(TypeError, match=match('class_name must be a string, got int.')):
describe_call(42, [], {}) # E: [arg-type]


@pytest.mark.mypy_testing
Expand Down
82 changes: 82 additions & 0 deletions tests/units/test_describe.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Any

import pytest
from full_match import match
from sigmatch.errors import SignatureMismatchError

from printo import describe_call, not_none
Expand All @@ -11,6 +12,87 @@ def test_empty_object():
assert describe_call('ClassName', (), {}, serializer=lambda x: 'kek') == 'ClassName()' # noqa: ARG005


@pytest.mark.parametrize(
('class_name', 'type_name'),
[
(42, 'int'),
(None, 'NoneType'),
(object(), 'object'),
],
)
def test_class_name_must_be_string(class_name, type_name):
"""Only strings can be used as the name in the described call."""
with pytest.raises(TypeError, match=match(f'class_name must be a string, got {type_name}.')):
describe_call(class_name, (), {})


@pytest.mark.parametrize(
'class_name',
[
'ClassName',
'_private',
'package.module.ClassName',
'<locals>',
'function.<locals>.SomeClass',
'package.<private>.ClassName',
],
)
def test_class_name_accepts_valid_identifiers_and_wrapped_identifiers(class_name):
"""
Class names can be identifiers or <identifier> chunks.

Multiple valid chunks can be joined with dots.
"""
assert describe_call(class_name, (), {}) == f'{class_name}()'


@pytest.mark.parametrize(
'class_name',
[
'',
'not valid',
'123abc',
'a-b',
'<',
'>',
'<>',
'<123abc>',
'<not valid>',
'<a-b>',
'<locals',
'locals>',
'.',
'.ClassName',
'package.',
'package..ClassName',
'package.123Class',
'function.<>.Class',
'function.<not valid>.Class',
],
)
def test_class_name_rejects_invalid_identifier_shapes(class_name):
"""Malformed names are rejected before any repr string is produced."""
with pytest.raises(ValueError, match=match(f'class_name must be a valid Python identifier, a valid Python identifier wrapped in angle brackets, or a dot-separated series of those, got {class_name!r}.')):
describe_call(class_name, (), {})


@pytest.mark.parametrize(
('class_name', 'keyword'),
[
('class', 'class'),
('None', 'None'),
('package.class', 'class'),
('<class>', 'class'),
('<None>', 'None'),
('function.<class>.SomeClass', 'class'),
],
)
def test_class_name_rejects_python_keywords(class_name, keyword):
"""Python keywords are rejected even when their identifier shape is valid."""
with pytest.raises(ValueError, match=match(f'class_name contains Python keyword {keyword!r}, which is not allowed: {class_name!r}.')):
describe_call(class_name, (), {})


@pytest.mark.parametrize(
'args_converter',
[
Expand Down
Loading