From f16a1c3fe1728f43dace29109ead0a9ef3f9a8e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Thu, 2 Jul 2026 17:12:26 +0300 Subject: [PATCH 1/8] Add a lot of docstrings --- tests/test_changer.py | 189 ++++++++++++++++++++- tests/test_collector.py | 50 ++++++ tests/test_wrapper.py | 5 + tests/visitors/test_comments_aggregator.py | 15 ++ 4 files changed, 257 insertions(+), 2 deletions(-) diff --git a/tests/test_changer.py b/tests/test_changer.py index e8cdf4c..57f46c5 100644 --- a/tests/test_changer.py +++ b/tests/test_changer.py @@ -25,6 +25,11 @@ ], ) def test_just_iterate_add_coordinates(file, with_context, unfold): + """ + Iterating coordinates for raw source reports only matching Add operators. + + It returns the two Add coordinates in source order, with no file path and the expected class name and start positions. + """ changer = Changer(file) if with_context: @@ -66,6 +71,11 @@ def name_changer(node: Add): ], ) def test_apply_one_change(file, with_context, unfold): + """ + Applying one Changer-registered converter rewrites the single matching operation in the returned full source. + + Using the only Add coordinate, the plus becomes a minus and surrounding whitespace is preserved across callback signatures and decorator forms. + """ changer = Changer(file) if with_context: @@ -101,6 +111,11 @@ def change_add_to_sub(node: Add): ], ) def test_apply_two_changes_at_same_line(file, with_context, unfold): + """ + Applying a selected change to one of several same-line additions changes only that operator. + + Coordinates distinguish additions on the same line, so each candidate is applied independently from the original source while preserving the other additions and spacing. + """ changer = Changer(file) if with_context: @@ -140,6 +155,11 @@ def change_add_to_sub(node: Add): ], ) def test_to_different_changers_to_same_line(file, with_context, unfold): + """ + Converters targeting different operator types on the same source line remain independently addressable. + + Applying the Add coordinate changes only the plus, and applying the Subtract coordinate changes only the minus; the neighboring operator is left unchanged in each result. + """ changer = Changer(file) if with_context: @@ -192,6 +212,11 @@ def change_sub_to_add(node: Subtract): ], ) def test_changing_function_with_wrong_number_of_parameters(file, unfold): + """ + Reject converter callbacks that cannot be called with a node or with a node and context. + + Zero-argument and three-argument converters are rejected during registration, with the error type and message treated as part of the contract. + """ changer = Changer(file) with pytest.raises(SignatureMismatchError, match=match('A function that takes a CST node and a context is expected.')): @@ -222,6 +247,11 @@ def changing_function_2(): ], ) def test_read_comments(file, expected_comment, unfold): + """ + Context exposes the same-line comment for the node being converted. + + A missing comment is None. Otherwise, the value is the raw comment text after removing only the leading #, preserving whitespace after it and later # characters. + """ changer = Changer(file) comments_containers = [] @@ -250,6 +280,11 @@ def change_something(node: Add, context: Context): ], ) def test_read_metacodes_from_comment(file, expected_metacodes, unfold): + """ + Context.get_metacodes('key') returns only matching same-line metacodes. + + Inputs without a `key: action` metacode return an empty list, while matching comments return one parsed entry even when adjacent to code or followed by extra comment text. + """ changer = Changer(file) metacodes_containers = [] @@ -274,6 +309,11 @@ def change_something(node: Add, context: Context): ], ) def test_filter_any_on(file, with_context, unfold): + """ + An Any-annotated filter that returns True permits matching Changer conversions. + + The test checks that the catch-all filter allows the single Add-to-Subtract change to be discovered and applied for both node-only and context-aware callables, through both decorator styles. + """ changer = Changer(file) if with_context: @@ -318,6 +358,11 @@ def filter_something(node: Any) -> bool: ], ) def test_filter_any_off(file, with_context, unfold): + """ + A filter annotated with Any rejects an otherwise eligible change when it returns False. + + This checks that the broad filter blocks an Add converter that would replace a plus operator, leaving no results across the supported callback signatures and decorator forms. + """ changer = Changer(file) if with_context: @@ -361,6 +406,11 @@ def filter_something(node: Any) -> bool: ], ) def test_filter_cstnode_on(file, with_context, unfold): + """ + A truthy broad CSTNode filter allows a concrete Add conversion to remain available. + + The test uses one plus expression and an Add converter that changes it to subtraction, then checks that one coordinate is emitted and applying it changes only that plus operator. The behavior is covered for callbacks with or without context and for both decorator forms. + """ changer = Changer(file) if with_context: @@ -405,6 +455,11 @@ def filter_something(node: CSTNode) -> bool: ], ) def test_filter_cstnode_off(file, with_context, unfold): + """ + A broad CSTNode filter returning False suppresses otherwise matching converter changes. + + Even when a converter targets a specific Add node, the rejecting CSTNode filter applies to that candidate, so no changes are emitted. The same behavior is expected for callbacks with or without Context and for both supported decorator registration styles. + """ changer = Changer(file) if with_context: @@ -448,6 +503,11 @@ def filter_something(node: CSTNode) -> bool: ], ) def test_filter_node_on(file, with_context, unfold): + """ + An Add-annotated filter returning True permits the Add conversion. + + The single Add-to-Subtract change is emitted and applied across node-only/context-aware callbacks and both decorator forms, leaving the existing Subtract operator unchanged. + """ changer = Changer(file) if with_context: @@ -492,6 +552,11 @@ def filter_something(node: Add) -> bool: ], ) def test_filter_node_off(file, with_context, unfold): + """ + Rejects an Add conversion when a matching Add filter returns false. + + The otherwise valid plus-to-minus change produces no results because the concrete node filter vetoes the candidate before any coordinate is applied. The expectation holds for both node-only and context-aware callbacks, and for both decorator invocation styles. + """ changer = Changer(file) if with_context: @@ -535,6 +600,11 @@ def filter_something(node: Add) -> bool: ], ) def test_filter_other_node_on(file, with_context, unfold): + """ + A true filter for a different concrete node type coexists with an Add conversion. + + The source contains one Add and one existing Subtract, but only the Add converter yields a change. Applying that change replaces the plus with a minus while leaving the existing minus intact, across context-aware and context-free callbacks and both decorator styles. + """ changer = Changer(file) if with_context: @@ -579,6 +649,11 @@ def filter_something(node: Subtract) -> bool: ], ) def test_filter_other_node_off(file, with_context, unfold): + """ + A false filter for a different concrete node type does not block an eligible conversion. + + An Add converter still yields one change even when a Subtract filter returns False, because that filter is unrelated to the Add candidate. Applying the change rewrites only the plus operator to a minus, and the behavior is the same with or without Context and for both decorator invocation styles. + """ changer = Changer(file) if with_context: @@ -615,6 +690,11 @@ def filter_something(node: Subtract) -> bool: def test_converter_with_no_annotation(with_context, unfold): + """ + Accept an unannotated converter parameter as a catch-all for CST nodes. + + The identity converter should produce applicable coordinates for the minimal source and work both with and without context, regardless of decorator invocation form. + """ changer = Changer('1') if with_context: @@ -630,6 +710,11 @@ def converter_func(node): def test_converter_with_any_annotation(with_context, unfold): + """ + Accepts typing.Any as a universal converter annotation. + + An Any-annotated identity converter for source '1' should discover at least one coordinate and apply successfully across supported converter signatures and decorator forms. + """ changer = Changer('1') if with_context: @@ -645,6 +730,11 @@ def converter_func(node: Any): def test_converter_with_cstnode_annotation_restriction(with_context, unfold): + """ + Accepts libcst.CSTNode as a broad converter annotation. + + The converter should apply to at least one coordinate for a minimal source, whether it accepts only the node or also accepts context, and whether the decorator is used bare or called. + """ changer = Changer('1') if with_context: @@ -660,6 +750,11 @@ def converter_func(node: CSTNode): def test_convert_str(with_context, unfold): + """ + Dispatches the builtin str annotation shortcut to string literal CST nodes. + + Verifies that a converter registered for source containing one string literal is called exactly once and receives a libcst.SimpleString node, across the supported converter signature and decorator forms. + """ changer = Changer('a = "kek"') nodes = [] @@ -683,6 +778,11 @@ def converter_func(node: str): def test_convert_float(with_context, unfold): + """ + Converts a float literal selected by the built-in `float` shortcut. + + A `float` converter finds the single `5.0` in `a = 5.0` and returns `a = 6.0`; a prior discarded application must not affect the later result. This holds with or without context and for both decorator forms. + """ changer = Changer('a = 5.0') if with_context: @@ -701,6 +801,11 @@ def converter_func(node: float): def test_filter_with_wrong_number_of_parameters(unfold): + """ + Reject filters with unsupported callback arity. + + Covers both direct and called decorator forms, asserting that zero-argument and three-argument filter callbacks fail registration with SignatureMismatchError. + """ changer = Changer('a = 5') with pytest.raises(SignatureMismatchError, match=match('A function that takes a CST node and a context is expected.')): @@ -715,6 +820,11 @@ def filter_func(): def test_filter_with_invalid_annotation(unfold): + """ + Rejects filters whose node parameter is annotated with a non-CSTNode class. + + The callback otherwise has a valid node-and-context signature, so either filter decorator form should raise TypeError for the invalid annotation instead of treating it as a signature mismatch. + """ changer = Changer('a = 5') class SomeClass: @@ -727,6 +837,11 @@ def filter_func(node: SomeClass, context: Context): def test_two_converters_for_same_node(with_context, unfold): + """ + Multiple converters for the same Add node produce separate choices. + + For `5 + 5`, the two Add converters yield independent results, `5 - 5` and `5 * 5`, across node-only/context-aware callbacks and both decorator forms. + """ changer = Changer('5 + 5') if with_context: @@ -763,6 +878,11 @@ def converter2(node: Add): def test_use_collector_for_converter(with_context, unfold): + """ + Collected converters participate in Changer coordinate discovery and application. + + A converter registered on a Collector and supplied to Changer should rewrite the single Add operator in the source to Subtract, for both supported converter signatures and both Collector.converter decorator forms. + """ collector = Collector() if with_context: @@ -786,6 +906,11 @@ def some_converter(node: Add): def test_use_collector_for_converter_and_filter(with_context, unfold): + """ + Collected filters gate collected converters. + + With the filter returning False, the Collector-backed Changer emits no changes; after the filter state flips to True, the same source yields `a = 5 - 5` across callback signatures and decorator forms. + """ collector = Collector() filters_value = False @@ -824,6 +949,11 @@ def some_filter(node: Add): def test_union_with_csts(with_context, unfold): + """ + Union[Add, Subtract] converters produce one rewrite per matching operator. + + For `5 - 5 + 5`, coordinates are emitted in source order, and each application replaces only the selected Add or Subtract operator with Multiply across callback signatures. + """ changer = Changer('5 - 5 + 5') if with_context: @@ -845,6 +975,11 @@ def some_converter(node: Union[Add, Subtract]): def test_union_with_union_with_csts(with_context, unfold): + """ + Nested Union annotations expand to the matching operator types present in the source. + + For `Union[Add, Union[Multiply, Subtract]]` on `5 - 5 + 5`, only Add and Subtract coordinates are emitted, yielding the two single-operator rewrites. + """ changer = Changer('5 - 5 + 5') if with_context: @@ -866,6 +1001,11 @@ def some_converter(node: Union[Add, Union[Multiply, Subtract]]): def test_convert_plus_one(with_context, unfold): + """ + Treat converters annotated with int as applying to integer literals. + + For `5 - 5 + 5`, each integer occurrence can be changed independently to its plus-one value, across node-only and context-aware converter signatures and both decorator call styles. + """ changer = Changer('5 - 5 + 5') if with_context: @@ -881,6 +1021,11 @@ def convert_ints(node: int): def test_converter_for_any(with_context, unfold): + """ + An Any-annotated converter is considered for many CST nodes. + + Applying every coordinate in `Changer('5 - 5 + 5')` invokes the identity converter more than ten times across node-only/context-aware callbacks and both decorator forms. + """ changer = Changer('5 - 5 + 5') nodes = [] @@ -901,6 +1046,11 @@ def do_something(node: Any): def test_if_node_is_not_exist_nothing_changed(with_context, unfold): + """ + Converters targeting float literals produce no changes when the source has no float literals. + + A float-annotated converter should produce no applied changes for `5 - 5 + 5`, whose numeric literals are all integers, across supported context and decorator variants. + """ changer = Changer('5 - 5 + 5') if with_context: @@ -916,6 +1066,11 @@ def do_something(node: float): def test_get_function_id_from_itself(unfold): + """ + Registered converter and filter wrappers report deterministic function IDs. + + The IDs include the wrapped callable's module, function name, and first source line for both bare-decorator and called-decorator registration forms. + """ changer = Changer('5 - 5 + 5') @unfold(changer.converter) @@ -929,11 +1084,16 @@ def filter_something(node: float, context): converter = list(changer.converters_by_types.values())[0][0] # noqa: RUF015 filter = list(changer.filters_by_types.values())[0][0] # noqa: RUF015, A001 - assert converter.get_function_id() == 'tests.test_changer:do_something:921' - assert filter.get_function_id() == 'tests.test_changer:filter_something:925' + assert converter.get_function_id() == 'tests.test_changer:do_something:1076' + assert filter.get_function_id() == 'tests.test_changer:filter_something:1080' def test_wrong_converter_and_wrong_filter(unfold): + """ + Reject converter and filter callbacks with invalid arity. + + Both decorators should raise SignatureMismatchError when registering callbacks that take no parameters or three parameters, whether used directly or as called decorator factories. + """ changer = Changer('5 - 5 + 5') with pytest.raises(SignatureMismatchError, match=match('A function that takes a CST node and a context is expected.')): @@ -958,6 +1118,11 @@ def filter_something_2(a, b, c): def test_pass_meta_dict_to_converter(): + """ + Direct Changer converters receive copied decorator metadata in their Context. + + Applying the Add coordinate from `5 - 5 + 5` triggers a two-argument converter and verifies that `context.meta` equals the supplied meta dictionary while remaining a distinct top-level dict. + """ bread_crumbs = [] changer = Changer('5 - 5 + 5') meta = {'key': 123} @@ -977,6 +1142,11 @@ def some_converter(node: Add, context: Context): def test_pass_meta_dict_to_filter(unfold): + """ + Passing a metadata dict to a Changer filter exposes an equal but distinct copy through Context.meta. + + The filter is evaluated while discovering a matching Add candidate, so the test focuses on filter metadata delivery rather than source transformation. + """ bread_crumbs = [] changer = Changer('5 - 5 + 5') meta = {'key': 123} @@ -1000,6 +1170,11 @@ def filter_something(node: Add, context: Context): def test_pass_meta_dict_to_converter_throw_collector(): + """ + Collector-registered converter meta reaches the converter context as an equal copy. + + When a Changer built from the Collector applies the Add converter, the converter records one Context.meta value equal to {'key': 123} and distinct from the original decorator dict. + """ bread_crumbs = [] collector = Collector() meta = {'key': 123} @@ -1020,6 +1195,11 @@ def some_converter(node: Add, context: Context): def test_pass_meta_dict_to_filter_throw_collector(unfold): + """ + Collector-registered filter metadata is copied into Context during coordinate discovery. + + A Changer built from the Collector has a collected Add converter only to make the Add node eligible. The collected Add filter returns False, and the assertions focus on context.meta matching the filter decorator's dict without aliasing it. + """ bread_crumbs = [] collector = Collector() meta = {'key': 123} @@ -1044,6 +1224,11 @@ def filter_something(node: Add, context: Context): def test_it_passes_2_arguments_if_possible(unfold): + """ + Passes Context as the second argument when a converter can accept it. + + This covers the ambiguous valid case where the converter's second positional parameter is optional, defaults to None, and is not annotated as Context. The converter should receive a real Context object instead of falling back to its default. + """ changer = Changer('5 + 4') contexts = [] diff --git a/tests/test_collector.py b/tests/test_collector.py index e0c72d9..71d23d0 100644 --- a/tests/test_collector.py +++ b/tests/test_collector.py @@ -5,6 +5,11 @@ def test_collections_in_different_collectors_are_not_same(): + """ + A new Collector starts with empty, independent registration lists. + + Separate Collector instances have distinct `_filters` and `_converters` lists, so registrations cannot leak through shared mutable defaults. + """ first_collector = Collector() second_collector = Collector() @@ -19,6 +24,11 @@ def test_collections_in_different_collectors_are_not_same(): def test_collect_some_filter(): + """ + Registering a collector filter stores the original function in the filter collection. + + The decorated name remains bound to the same callable, while the collector stores a wrapper whose function is that callable. + """ collector = Collector() @collector.filter @@ -29,6 +39,11 @@ def some_filter(node, context): # noqa: ARG001 def test_collect_some_converter(): + """ + Collects a converter registered with the bare collector decorator without replacing it. + + The decorated function remains the original callable, and the collector records that callable as its converter for later use. + """ collector = Collector() @collector.converter @@ -39,6 +54,11 @@ def some_converter(node, context): # noqa: ARG001 def test_add_two_collectors_with_converters(): + """ + Adding two collectors combines converter registrations in left-to-right order. + + Before addition, each original collector contains only its own converter. The combined collector lists the left converter before the right converter. + """ collector_1 = Collector() collector_2 = Collector() @@ -59,6 +79,11 @@ def some_converter_2(node, context): # noqa: ARG001 def test_add_two_collectors_with_filters(): + """ + Adding two collectors combines their registered filters in left-to-right order. + + Before addition, each original collector contains only its own filter. The combined collector lists the left callback before the right callback. + """ collector_1 = Collector() collector_2 = Collector() @@ -79,6 +104,11 @@ def some_filter_2(node, context): # noqa: ARG001 def test_add_wrong_things_to_collector(): + """ + Reject non-Collector operands when adding to a Collector. + + Integer and string operands should both raise TypeError with the Collector-only diagnostic. + """ with pytest.raises(TypeError, match=match('Collector objects can only be added to other collector objects.')): Collector() + 1 @@ -87,11 +117,21 @@ def test_add_wrong_things_to_collector(): def test_repr(): + """ + Pin Collector repr output for default and non-empty constructor metadata. + + A default collector renders as `Collector()`, while a collector with metadata includes it as the `meta` keyword using the dictionary repr. + """ assert repr(Collector()) == 'Collector()' assert repr(Collector(meta={'lol': 'kek'})) == "Collector(meta={'lol': 'kek'})" def test_meta_for_collector_but_not_for_converter_or_filter(): + """ + Bare converter and filter registrations inherit collector-level metadata. + + Each collected wrapper receives metadata equal to the Collector constructor dictionary while holding its own top-level copy, so neither wrapper reuses the caller's original dictionary. + """ meta = {'lol': 'kek'} collector = Collector(meta=meta) @@ -111,6 +151,11 @@ def some_filter(node, context): # noqa: ARG001 def test_meta_for_converter_or_filter_but_not_for_collector(): + """ + Stores decorator-level metadata on collected converter and filter wrappers. + + A collector created without constructor metadata should attach equal but top-level copied meta dictionaries to wrappers registered with converter- and filter-level meta. + """ meta = {'lol': 'kek'} collector = Collector() @@ -130,6 +175,11 @@ def some_filter(node, context): # noqa: ARG001 def test_meta_for_for_converter_or_filter_and_for_collector(): + """ + Registers merged collector and decorator metadata for converters and filters. + + Decorator metadata overrides collector metadata for duplicate keys, and each wrapper receives a copied top-level metadata dictionary rather than either original input dictionary. + """ meta_1 = {'lol_1': 'kek_1', 'lol_2': 'kek_2'} meta_2 = {'lol_2': 'kek_2-2', 'lol_3': 'kek_3'} diff --git a/tests/test_wrapper.py b/tests/test_wrapper.py index f9d3050..a9099a7 100644 --- a/tests/test_wrapper.py +++ b/tests/test_wrapper.py @@ -2,6 +2,11 @@ def test_repr(): + """ + Pin CallableWrapper repr as a constructor-like string using positional values. + + The repr omits default meta, renders the wrapped function by its bare name, and includes explicit meta as a second positional argument. + """ def function(a, b): ... diff --git a/tests/visitors/test_comments_aggregator.py b/tests/visitors/test_comments_aggregator.py index 2dbec78..684597f 100644 --- a/tests/visitors/test_comments_aggregator.py +++ b/tests/visitors/test_comments_aggregator.py @@ -14,6 +14,11 @@ ], ) def test_code_without_comments(file): + """ + A module with ordinary code but no comments leaves the comments map empty. + + Lines without comments are omitted rather than recorded with placeholder values. + """ wrapper = metadata.MetadataWrapper(parse_module(file)) aggregator = CommentsAggregator() wrapper.visit(aggregator) @@ -31,6 +36,11 @@ def test_code_without_comments(file): ], ) def test_code_with_one_comment(file): + """ + Record one trailing inline comment under its 1-based source line. + + The collected mapping is {2: 'lol'}, showing that the leading '#' is removed from the comment after code while the remaining comment text is kept unchanged. + """ wrapper = metadata.MetadataWrapper(parse_module(file)) aggregator = CommentsAggregator() wrapper.visit(aggregator) @@ -49,6 +59,11 @@ def test_code_with_one_comment(file): ], ) def test_code_with_two_comments(file): + """ + Accumulates separate inline comments by their 1-based source line numbers. + + The expected mapping shows that multiple comments are retained together and that only the leading `#` is removed, preserving the remaining text, including a leading space. + """ wrapper = metadata.MetadataWrapper(parse_module(file)) aggregator = CommentsAggregator() wrapper.visit(aggregator) From 57733a779af6627adad54ca5ab054f5ed3701911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Thu, 2 Jul 2026 17:17:22 +0300 Subject: [PATCH 2/8] Bump version to 0.0.8 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6d299f0..70a76c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "cstvis" -version = "0.0.7" +version = "0.0.8" authors = [{ name = "Evgeniy Blinov", email = "zheni-b@yandex.ru" }] description = 'Incremental change of CST' readme = "README.md" From 7e465ca07bcb6ec1ef2facec8513f7d08a277617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Thu, 2 Jul 2026 17:20:47 +0300 Subject: [PATCH 3/8] Update printo dependency to >=0.0.29 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 70a76c5..943bc61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ description = 'Incremental change of CST' readme = "README.md" requires-python = ">=3.8" dependencies = [ - 'printo>=0.0.22', + 'printo>=0.0.29', 'sigmatch>=0.0.9', 'metacode>=0.0.6', "libcst>=1.1.0 ; python_version == '3.8'", From 738d400f7bf79048cc16500d599b00b433ab3a3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Thu, 2 Jul 2026 19:11:27 +0300 Subject: [PATCH 4/8] Make some tests more powerful --- tests/test_changer.py | 48 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/tests/test_changer.py b/tests/test_changer.py index 57f46c5..c0343d6 100644 --- a/tests/test_changer.py +++ b/tests/test_changer.py @@ -49,15 +49,50 @@ def name_changer(node: Add): assert coordinates[0].class_name == 'Add' assert coordinates[0].start_line == 3 assert coordinates[0].start_column == 7 - assert coordinates[0].start_line == 3 - assert coordinates[0].start_column == 7 + assert coordinates[0].end_line == 3 + assert coordinates[0].end_column == 8 assert coordinates[1].file is None assert coordinates[1].class_name == 'Add' assert coordinates[1].start_line == 6 assert coordinates[1].start_column == 6 - assert coordinates[1].start_line == 6 - assert coordinates[1].start_column == 6 + assert coordinates[1].end_line == 6 + assert coordinates[1].end_column == 7 + + +@pytest.mark.parametrize( + ['strings'], + [ + ([ + 'a = 1 + 2', + 'b = 3 + 4', + ],), + ], +) +def test_iterate_coordinates_filters_matching_add_without_calling_converter(file): + """ + Iterating coordinates applies filters without running converters. + + When a filter rejects one matching Add node, only the accepted coordinate is emitted and the converter callback remains untouched. + """ + changer = Changer(file) + converter_calls = [] + + @changer.converter + def name_changer(node: Add): + converter_calls.append(node) + return node + + @changer.filter + def filter_second_add(node: Add, context: Context) -> bool: + return context.coordinate.start_line == 2 + + coordinates = list(changer.iterate_coordinates()) + + assert converter_calls == [] + assert len(coordinates) == 1 + assert coordinates[0].start_line == 2 + assert coordinates[0].start_column == 6 @pytest.mark.parametrize( @@ -237,6 +272,7 @@ def changing_function_2(): ['strings', 'expected_comment'], [ (['a = 5 + 6- 7'], None), + (['# preceding comment', 'a = 5 + 6- 7'], None), (['a = 5 + 6- 7#'], ''), (['a = 5 + 6- 7# ololo!'], ' ololo!'), (['a = 5 + 6- 7# other_key: action'], ' other_key: action'), @@ -1084,8 +1120,8 @@ def filter_something(node: float, context): converter = list(changer.converters_by_types.values())[0][0] # noqa: RUF015 filter = list(changer.filters_by_types.values())[0][0] # noqa: RUF015, A001 - assert converter.get_function_id() == 'tests.test_changer:do_something:1076' - assert filter.get_function_id() == 'tests.test_changer:filter_something:1080' + assert converter.get_function_id() == 'tests.test_changer:do_something:1112' + assert filter.get_function_id() == 'tests.test_changer:filter_something:1116' def test_wrong_converter_and_wrong_filter(unfold): From d82ffca81bddd743906b8506606eaa5c35d8b310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Thu, 2 Jul 2026 19:16:03 +0300 Subject: [PATCH 5/8] Remove type ignore from repred decorator --- cstvis/wrapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cstvis/wrapper.py b/cstvis/wrapper.py index 4da1621..f44d2a1 100644 --- a/cstvis/wrapper.py +++ b/cstvis/wrapper.py @@ -29,7 +29,7 @@ FilterOrConverterReturnValue = TypeVar('FilterOrConverterReturnValue') -@repred(prefer_positional=True) # type: ignore[call-overload] +@repred(prefer_positional=True) class CallableWrapper(Generic[FilterOrConverterReturnValue]): matcher = PossibleCallMatcher('.') + PossibleCallMatcher('..') From 9c90d8ebcc6c6d4b36da0714f6f739e1b1ce9bc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Thu, 2 Jul 2026 20:27:52 +0300 Subject: [PATCH 6/8] Add "Typing :: Typed" classifier to project metadata --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 943bc61..5191ded 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ classifiers = [ 'License :: OSI Approved :: MIT License', 'Intended Audience :: Developers', 'Topic :: Software Development :: Libraries', + 'Typing :: Typed', ] keywords = ['CST', 'visitor'] From 7bcd8de2faa926b4b51ffdb33674e4b01649400b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Thu, 2 Jul 2026 20:28:33 +0300 Subject: [PATCH 7/8] Update metacode version to 0.0.7 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5191ded..7fe7323 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ requires-python = ">=3.8" dependencies = [ 'printo>=0.0.29', 'sigmatch>=0.0.9', - 'metacode>=0.0.6', + 'metacode>=0.0.7', "libcst>=1.1.0 ; python_version == '3.8'", "libcst>=1.8.6 ; python_version > '3.8'", ] From ad31f0a8f45365b7e0b7b545b4db6f772d9f6115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Thu, 2 Jul 2026 20:28:46 +0300 Subject: [PATCH 8/8] Update sigmatch version to 0.0.10 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7fe7323..be37c83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ readme = "README.md" requires-python = ">=3.8" dependencies = [ 'printo>=0.0.29', - 'sigmatch>=0.0.9', + 'sigmatch>=0.0.10', 'metacode>=0.0.7', "libcst>=1.1.0 ; python_version == '3.8'", "libcst>=1.8.6 ; python_version > '3.8'",