ast-pattern-engine 1.0.0__tar.gz → 1.0.2__tar.gz

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.
Files changed (44) hide show
  1. ast_pattern_engine-1.0.2/.github/workflows/ci.yml +86 -0
  2. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/PKG-INFO +8 -1
  3. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/README.md +7 -0
  4. ast_pattern_engine-1.0.2/docs/guide.md +57 -0
  5. ast_pattern_engine-1.0.2/docs/index.md +46 -0
  6. ast_pattern_engine-1.0.2/docs/reference/core.md +11 -0
  7. ast_pattern_engine-1.0.2/docs/reference/nodes.md +19 -0
  8. ast_pattern_engine-1.0.2/docs/reference/templates.md +7 -0
  9. ast_pattern_engine-1.0.2/docs/reference/visitors.md +9 -0
  10. ast_pattern_engine-1.0.2/docs/stylesheets/extra.css +26 -0
  11. ast_pattern_engine-1.0.2/mkdocs.yaml +50 -0
  12. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/pyproject.toml +7 -1
  13. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/src/ast_pattern_engine/core.py +10 -2
  14. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/src/ast_pattern_engine/engine.py +18 -6
  15. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/src/ast_pattern_engine/nodes/basic.py +117 -23
  16. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/src/ast_pattern_engine/nodes/sequences.py +32 -22
  17. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/src/ast_pattern_engine/visitors.py +22 -7
  18. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/tests/patterns/test_any_of.py +8 -18
  19. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/tests/patterns/test_collect.py +1 -0
  20. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/tests/patterns/test_filter.py +4 -3
  21. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/tests/patterns/test_one_of.py +4 -3
  22. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/tests/patterns/test_pattern_group.py +1 -0
  23. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/tests/patterns/test_repetition.py +1 -0
  24. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/tests/test_engine.py +1 -0
  25. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/tests/visitors/test_bottom_up_pattern_transformer.py +2 -1
  26. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/tests/visitors/test_pattern_finder.py +4 -1
  27. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/tests/visitors/test_pattern_transformer.py +14 -10
  28. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/tests/visitors/test_single_occurrence_finder.py +3 -3
  29. ast_pattern_engine-1.0.0/.github/workflows/ci.yml +0 -44
  30. ast_pattern_engine-1.0.0/src/ast_pattern_engine/plumbing.py +0 -2
  31. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/.gitignore +0 -0
  32. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/LICENSE +0 -0
  33. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/examples/dict_get_rewrite.py +0 -0
  34. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/src/ast_pattern_engine/__init__.py +0 -0
  35. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/src/ast_pattern_engine/nodes/__init__.py +0 -0
  36. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/src/ast_pattern_engine/py.typed +0 -0
  37. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/src/ast_pattern_engine/templates.py +0 -0
  38. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/tests/__init__.py +0 -0
  39. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/tests/patterns/test_all_of.py +0 -0
  40. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/tests/patterns/test_bind.py +0 -0
  41. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/tests/patterns/test_contains.py +0 -0
  42. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/tests/patterns/test_not.py +0 -0
  43. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/tests/patterns/test_optional.py +0 -0
  44. {ast_pattern_engine-1.0.0 → ast_pattern_engine-1.0.2}/tests/patterns/test_templates.py +0 -0
@@ -0,0 +1,86 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [ "main", "dev" ]
6
+ pull_request:
7
+ branches: [ "main", "dev" ]
8
+
9
+ jobs:
10
+ format-and-lint:
11
+ runs-on: ubuntu-latest
12
+ if: github.event_name == 'push'
13
+ permissions:
14
+ contents: write
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - name: Install uv
19
+ uses: astral-sh/setup-uv@v5
20
+ with:
21
+ enable-cache: true
22
+
23
+ - name: Auto-format with Ruff
24
+ run: uv run ruff format
25
+
26
+ - name: Lint with Ruff
27
+ run: uv run ruff check --exit-zero
28
+
29
+ - name: Commit formatting changes
30
+ uses: stefanzweifel/git-auto-commit-action@v5
31
+ with:
32
+ commit_message: "style: auto-format with ruff"
33
+
34
+ test:
35
+ runs-on: ubuntu-latest
36
+ needs: format-and-lint
37
+ if: always()
38
+ strategy:
39
+ matrix:
40
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
41
+
42
+ steps:
43
+ - uses: actions/checkout@v4
44
+ with:
45
+ ref: ${{ github.ref }}
46
+
47
+ - name: Set up Python ${{ matrix.python-version }}
48
+ uses: actions/setup-python@v5
49
+ with:
50
+ python-version: ${{ matrix.python-version }}
51
+
52
+ - name: Install uv
53
+ uses: astral-sh/setup-uv@v5
54
+ with:
55
+ enable-cache: true
56
+
57
+ - name: Install dependencies
58
+ run: uv sync --all-extras --all-groups
59
+
60
+ - name: Run tests with pytest
61
+ run: uv run pytest --cov=src --cov-report=xml
62
+
63
+ - name: Upload coverage reports to Codecov
64
+ uses: codecov/codecov-action@v5
65
+ env:
66
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
67
+
68
+ deploy-docs:
69
+ runs-on: ubuntu-latest
70
+ needs: test
71
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
72
+ permissions:
73
+ contents: write
74
+ steps:
75
+ - uses: actions/checkout@v4
76
+
77
+ - name: Install uv
78
+ uses: astral-sh/setup-uv@v5
79
+ with:
80
+ enable-cache: true
81
+
82
+ - name: Install Docs Dependencies
83
+ run: uv sync --only-group docs
84
+
85
+ - name: Deploy Docs
86
+ run: uv run mkdocs gh-deploy --force
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ast-pattern-engine
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: A library for regex-inspired fine-grained AST pattern matching and replacing
5
5
  Project-URL: Homepage, https://github.com/80sVectorz/ast_pattern_engine
6
6
  Project-URL: Repository, https://github.com/80sVectorz/ast_pattern_engine
@@ -21,6 +21,13 @@ Provides-Extra: dev
21
21
  Requires-Dist: pytest; extra == 'dev'
22
22
  Description-Content-Type: text/markdown
23
23
 
24
+ ![PyPI - Version](https://img.shields.io/pypi/v/ast-pattern-engine)
25
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/ast-pattern-engine)
26
+ ![Pytest](https://img.shields.io/badge/pytest-tested-orange)
27
+ ![MIT License](https://img.shields.io/badge/License-MIT-blue)
28
+ ![Codecov](https://codecov.io/gh/80sVectorz/ast_pattern_engine/branch/main/graph/badge.svg)
29
+ ![GitHub Workflow Status](https://github.com/80sVectorz/ast_pattern_engine/actions/workflows/ci.yml/badge.svg)
30
+
24
31
  # AST Pattern Engine
25
32
 
26
33
  A powerful, programmatic, regex-inspired AST pattern matching and manipulation library for Python.
@@ -1,3 +1,10 @@
1
+ ![PyPI - Version](https://img.shields.io/pypi/v/ast-pattern-engine)
2
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/ast-pattern-engine)
3
+ ![Pytest](https://img.shields.io/badge/pytest-tested-orange)
4
+ ![MIT License](https://img.shields.io/badge/License-MIT-blue)
5
+ ![Codecov](https://codecov.io/gh/80sVectorz/ast_pattern_engine/branch/main/graph/badge.svg)
6
+ ![GitHub Workflow Status](https://github.com/80sVectorz/ast_pattern_engine/actions/workflows/ci.yml/badge.svg)
7
+
1
8
  # AST Pattern Engine
2
9
 
3
10
  A powerful, programmatic, regex-inspired AST pattern matching and manipulation library for Python.
@@ -0,0 +1,57 @@
1
+ # Guide & Philosophy
2
+
3
+ ## Philosophy & Design
4
+
5
+ Instead of having to rely on fragile direct source-code manipulation or slightly better magic string-expression-based AST manipulation engines, `ast_pattern_engine` provides an internal DSL for building explicit, structural patterns.
6
+
7
+ It explicitly avoids string-based expressions because they simply don't scale well. For robust code-analysis you must have a grasp of the underlying AST structure.
8
+
9
+ The package started as a component for another project and was later spun off into it's own clean package.
10
+ Early experiences made it clear that large expressions aren't the way to go for AST manipulation.
11
+ A staged approach is much more robust and easier to reason about.
12
+
13
+ The package is intentionally kept somewhat limited to encourage using custom logic for more advanced filtering and analysis. Rather than building extremely nested gigantic expressions, it's encouraged to build small, focused patterns and visitors.
14
+ Like casting a wide net and progressively filtering down in stages.
15
+
16
+ ## Primitives
17
+
18
+ The library provides several primitives to build robust sequences:
19
+
20
+ - `NodePattern`: Match specific AST node types and assert on their fields.
21
+ - `Collect` / `Bind`: Extract sub-trees out of a matched pattern to use in your handlers. `Bind` assigns a name to a matched node so that it can be processed during transformation.
22
+ - `OneOf`: Match one of several possible patterns (similar to regex `|`).
23
+ - `Repetition`: Match a pattern sequentially 1 or more times (similar to regex `*` and `+`).
24
+ - `Optional`: Match a pattern 0 or 1 times (similar to regex `?`).
25
+ - `Filter`: Apply arbitrary Python lambdas to check node states during matching.
26
+
27
+ ## Building Patterns
28
+
29
+ A typical pattern is a sequence of `Pattern` objects. For instance, finding a sequence of assignments:
30
+
31
+ ```python
32
+ from ast_pattern_engine import NodePattern, Bind
33
+ import ast
34
+
35
+ # Matches an assignment of any value to "x"
36
+ assign_to_x = NodePattern(
37
+ ast.Assign,
38
+ targets=[NodePattern(ast.Name, id="x")],
39
+ value=Bind("x_value")
40
+ )
41
+ ```
42
+
43
+ ## Transformers and Finders
44
+
45
+ Once a pattern is matched, you often want to act on it.
46
+
47
+ - **Transformers** (`PatternTransformer`, `BottomUpPatternTransformer`): These visitors replace matched subtrees with new nodes generated by your handlers. A handler receives the bound variables (E.G `Bind("x")`, `Collect()) and returns the replacement nodes.
48
+ - **Finders** (`PatternFinder`, `SingleOccurrenceFinder`): These visitors just locate where patterns occur in the tree without modifying it. Useful for analysis or linting.
49
+
50
+ ## Templates
51
+
52
+ To reduce boilerplate when building patterns, the library includes a `templates` module with helpers for common operations:
53
+ - `match_call(func_name, **kwargs)`
54
+ - `match_assign(target_name, value)`
55
+ - `match_in_expr(pattern)`
56
+
57
+ Using templates allows you to write dense, readable matching rules quickly.
@@ -0,0 +1,46 @@
1
+ # AST Pattern Engine
2
+
3
+ A powerful, programmatic, regex-inspired AST pattern matching and manipulation library for Python.
4
+
5
+ ## Quick Start
6
+
7
+ Here is a simple pipeline that rewrites `dict.get("key")` calls into direct subscript access `dict["key"]`:
8
+
9
+ ```python
10
+ from typing import Any
11
+ import ast
12
+ from ast_pattern_engine import BottomUpPatternTransformer, Bind, NodePattern
13
+
14
+ source = "value = my_dict.get(other_dict.get('foo'))"
15
+ tree = ast.parse(source)
16
+
17
+ # 1. Build the explicit structural pattern
18
+ # Matches: <obj>.get(<key>)
19
+ pattern = [
20
+ NodePattern(
21
+ ast.Call,
22
+ func=NodePattern(ast.Attribute, attr="get", value=Bind("obj")),
23
+ args=Bind("key"),
24
+ )
25
+ ]
26
+
27
+ # 2. Define the rewrite logic
28
+ def rewrite_dict_get(bindings: dict[str, Any]) -> list[ast.AST]:
29
+ obj = bindings["obj"]
30
+ key = bindings["key"][0] # args is a list
31
+
32
+ # Return the new node to replace the matched node
33
+ new_node = ast.Subscript(value=obj, slice=key, ctx=ast.Load())
34
+ return [new_node]
35
+
36
+ # 3. Apply the transformer
37
+ # We use BottomUpPatternTransformer so nested `.get()` calls
38
+ # are safely transformed from the inside-out.
39
+ transformer = BottomUpPatternTransformer(pattern, {"key": rewrite_dict_get})
40
+ transformer.visit(tree)
41
+
42
+ print(ast.unparse(tree))
43
+ # Output: value = my_dict[other_dict['foo']]
44
+ ```
45
+
46
+ For more in-depth examples, refer to the [Guide](guide.md) or the [API Reference](reference/core.md).
@@ -0,0 +1,11 @@
1
+ # Core & Engine
2
+
3
+ This page documents the core base classes and the engine used to match sequences.
4
+
5
+ ::: ast_pattern_engine.core
6
+ options:
7
+ show_root_heading: true
8
+
9
+ ::: ast_pattern_engine.engine
10
+ options:
11
+ show_root_heading: true
@@ -0,0 +1,19 @@
1
+ # Pattern Nodes
2
+
3
+ Pattern nodes are the basic building blocks to construct matching logic over an AST.
4
+
5
+ ## Basic Nodes
6
+
7
+ Basic nodes allow matching structural properties, capturing values, and simple boolean combinations.
8
+
9
+ ::: ast_pattern_engine.nodes.basic
10
+ options:
11
+ show_root_heading: true
12
+
13
+ ## Sequence Nodes
14
+
15
+ Sequence nodes allow matching groups of adjacent nodes, similar to regex patterns.
16
+
17
+ ::: ast_pattern_engine.nodes.sequences
18
+ options:
19
+ show_root_heading: true
@@ -0,0 +1,7 @@
1
+ ## Templates
2
+
3
+ Templates provide convenient helper functions for generating common patterns.
4
+
5
+ ::: ast_pattern_engine.templates
6
+ options:
7
+ show_root_heading: true
@@ -0,0 +1,9 @@
1
+ # AST Visitors
2
+
3
+ Visitors use the pattern matching engine to find or transform abstract syntax trees.
4
+
5
+ ## Finders and Transformers
6
+
7
+ ::: ast_pattern_engine.visitors
8
+ options:
9
+ show_root_heading: true
@@ -0,0 +1,26 @@
1
+ /* Add a simple visual divider above each mkdocstrings API object */
2
+ .doc-object {
3
+ border-top: 1px solid var(--md-default-fg-color--lightest);
4
+ margin-top: 3rem;
5
+ padding-top: 1rem;
6
+ }
7
+
8
+ /* Remove the divider from the very first item on the page */
9
+ .doc-object:first-child {
10
+ border-top: none;
11
+ margin-top: 0;
12
+ padding-top: 0;
13
+ }
14
+
15
+ /* Ensure the heading sits neatly below the divider */
16
+ .doc-heading,
17
+ .doc-symbol-heading {
18
+ margin-top: 0 !important;
19
+ }
20
+
21
+ /* Indent the contents (docstrings, parameters, methods) of classes and functions */
22
+ .doc-contents {
23
+ margin-left: 1.5rem;
24
+ padding-left: 1rem;
25
+ border-left: 2px solid var(--md-default-fg-color--lightest);
26
+ }
@@ -0,0 +1,50 @@
1
+ site_name: AST Pattern Engine
2
+ site_url: https://80svectorz.github.io/ast_pattern_engine/
3
+ repo_url: https://github.com/80sVectorz/ast_pattern_engine
4
+
5
+ theme:
6
+ name: material
7
+ palette:
8
+ - scheme: slate
9
+ primary: teal
10
+ accent: teal
11
+ features:
12
+ - navigation.tabs
13
+ - content.code.copy
14
+ - toc.follow
15
+
16
+ plugins:
17
+ - search
18
+ - mkdocstrings:
19
+ default_handler: python
20
+ handlers:
21
+ python:
22
+ options:
23
+ docstring_style: google
24
+ show_root_heading: true
25
+ show_source: true
26
+ separate_signature: true
27
+ show_signature_annotations: true
28
+ show_symbol_type_heading: true
29
+ show_symbol_type_toc: true
30
+
31
+ markdown_extensions:
32
+ - pymdownx.highlight:
33
+ anchor_linenums: true
34
+ line_spans: __span
35
+ pygments_style: material
36
+ - pymdownx.inlinehilite
37
+ - pymdownx.snippets
38
+ - pymdownx.superfences
39
+
40
+ extra_css:
41
+ - stylesheets/extra.css
42
+
43
+ nav:
44
+ - Home: index.md
45
+ - Guide & Philosophy: guide.md
46
+ - API Reference:
47
+ - Core & Engine: reference/core.md
48
+ - Pattern Nodes: reference/nodes.md
49
+ - AST Visitors: reference/visitors.md
50
+ - QoL Templates: reference/templates.md
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ast-pattern-engine"
3
- version = "1.0.0"
3
+ version = "1.0.2"
4
4
  description = "A library for regex-inspired fine-grained AST pattern matching and replacing"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -40,4 +40,10 @@ pythonpath = ["src"]
40
40
  [dependency-groups]
41
41
  dev = [
42
42
  "pytest-cov>=7.1.0",
43
+ "ruff>=0.11.0",
43
44
  ]
45
+ docs = [
46
+ "mkdocs>=1.6.1",
47
+ "mkdocs-material>=9.7.6",
48
+ "mkdocstrings[python]>=1.0.4",
49
+ ]
@@ -8,7 +8,11 @@ class Pattern(ast.AST):
8
8
 
9
9
  # public API
10
10
  def match_node(
11
- self, node: object, bindings: dict[str, object] | None = None, *, _force_list: bool = False
11
+ self,
12
+ node: object,
13
+ bindings: dict[str, object] | None = None,
14
+ *,
15
+ _force_list: bool = False,
12
16
  ):
13
17
  """Match *node* and return updated *bindings* or *None*."""
14
18
  raise NotImplementedError
@@ -21,7 +25,11 @@ class Pattern(ast.AST):
21
25
 
22
26
  class SequencePattern(Pattern):
23
27
  def match_node(
24
- self, node: object, bindings: dict[str, object] | None = None, *, _force_list: bool = False
28
+ self,
29
+ node: object,
30
+ bindings: dict[str, object] | None = None,
31
+ *,
32
+ _force_list: bool = False,
25
33
  ):
26
34
  # Matching is handled by engine._match_sequence
27
35
  raise NotImplementedError(
@@ -26,7 +26,9 @@ def _match_patterns(
26
26
 
27
27
  match first:
28
28
  case PatternGroup(pattern=sub_pattern, key=key):
29
- res = _match_patterns(sub_pattern, nodes, pos, dict(bindings), _force_list=_force_list)
29
+ res = _match_patterns(
30
+ sub_pattern, nodes, pos, dict(bindings), _force_list=_force_list
31
+ )
30
32
  if res:
31
33
  new_bindings = res[-1][0]
32
34
  if key is not None:
@@ -40,7 +42,9 @@ def _match_patterns(
40
42
  new_bindings = dict(bindings)
41
43
  n_reps = 0
42
44
  while n_reps < (max_matches or len(nodes)) and pos < len(nodes):
43
- res = _match_patterns([sub_pattern], nodes, pos, dict(new_bindings), _force_list=True)
45
+ res = _match_patterns(
46
+ [sub_pattern], nodes, pos, dict(new_bindings), _force_list=True
47
+ )
44
48
  if not res:
45
49
  break
46
50
  new_bindings, pos = res[-1]
@@ -54,7 +58,9 @@ def _match_patterns(
54
58
  new_pos = pos
55
59
  n_matches = 0
56
60
  for pattern in sub_patterns:
57
- res = _match_patterns([pattern], nodes, pos, dict(bindings), _force_list=_force_list)
61
+ res = _match_patterns(
62
+ [pattern], nodes, pos, dict(bindings), _force_list=_force_list
63
+ )
58
64
  if res:
59
65
  n_matches += 1
60
66
  if n_matches == 1:
@@ -74,7 +80,9 @@ def _match_patterns(
74
80
  out.append((new_bindings, pos))
75
81
 
76
82
  case Optional(pattern=sub_pattern, key=key):
77
- res = _match_patterns([sub_pattern], nodes, pos, dict(bindings), _force_list=_force_list)
83
+ res = _match_patterns(
84
+ [sub_pattern], nodes, pos, dict(bindings), _force_list=_force_list
85
+ )
78
86
  if res:
79
87
  new_bindings, new_pos = res[-1]
80
88
  if key is not None:
@@ -88,13 +96,17 @@ def _match_patterns(
88
96
  case _:
89
97
  # single node pattern
90
98
  if pos < len(nodes):
91
- res = first.match_node(nodes[pos], dict(bindings), _force_list=_force_list)
99
+ res = first.match_node(
100
+ nodes[pos], dict(bindings), _force_list=_force_list
101
+ )
92
102
  if res is not None:
93
103
  out.append((res, pos + 1))
94
104
 
95
105
  # Match remaining patterns
96
106
  if out and remaining:
97
- rem_res = _match_patterns(remaining, nodes, out[-1][1], out[-1][0], _force_list=_force_list)
107
+ rem_res = _match_patterns(
108
+ remaining, nodes, out[-1][1], out[-1][0], _force_list=_force_list
109
+ )
98
110
  if not rem_res:
99
111
  return []
100
112
  out.extend(rem_res)
@@ -11,10 +11,10 @@ from ast_pattern_engine.visitors import SingleOccurrenceFinder
11
11
 
12
12
 
13
13
  class Bind(Pattern):
14
- """Bind the current node to `name`.
14
+ """Bind the current node to `key`.
15
15
 
16
16
  This is syntactic sugar for:
17
- >>> Collect(WildCard(), "x")
17
+ >>> Collect(WildCard(), "x")
18
18
 
19
19
  Args:
20
20
  key: The key to bind the node(s) or value(s) to.
@@ -23,9 +23,20 @@ class Bind(Pattern):
23
23
  key: str
24
24
 
25
25
  def __init__(self, key: str):
26
+ """Bind node.
27
+
28
+ Args:
29
+ key: The key to bind the node(s) or value(s) to.
30
+ """
26
31
  self.key = key
27
32
 
28
- def match_node(self, node: Any, bindings: dict[str, Any] | None = None, *, _force_list: bool = False):
33
+ def match_node(
34
+ self,
35
+ node: Any,
36
+ bindings: dict[str, Any] | None = None,
37
+ *,
38
+ _force_list: bool = False,
39
+ ):
29
40
  bindings = bindings or {}
30
41
  if self.key in bindings:
31
42
  if not _force_list:
@@ -41,7 +52,13 @@ class WildCard(Pattern):
41
52
 
42
53
  def __init__(self): ...
43
54
 
44
- def match_node(self, node: Any, bindings: dict[str, Any] | None = None, *, _force_list: bool = False):
55
+ def match_node(
56
+ self,
57
+ node: Any,
58
+ bindings: dict[str, Any] | None = None,
59
+ *,
60
+ _force_list: bool = False,
61
+ ):
45
62
  bindings = bindings or {}
46
63
  return bindings
47
64
 
@@ -54,11 +71,26 @@ class NodePattern(Pattern):
54
71
  **field_patterns: Patterns or exact values to match against the node's fields.
55
72
  """
56
73
 
74
+ node_type: type[ast.AST]
75
+ field_patterns: dict[str, Pattern | Any]
76
+
57
77
  def __init__(self, node_type: type[ast.AST], **field_patterns: Pattern | Any):
78
+ """NodePattern node.
79
+
80
+ Args:
81
+ node_type: The AST node class to match (e.g., ast.Assign).
82
+ **field_patterns: Patterns or exact values to match against the node's fields.
83
+ """
58
84
  self.node_type = node_type
59
85
  self.field_patterns = field_patterns
60
86
 
61
- def match_node(self, node: Any, bindings: dict[str, Any] | None = None, *, _force_list: bool = False):
87
+ def match_node(
88
+ self,
89
+ node: Any,
90
+ bindings: dict[str, Any] | None = None,
91
+ *,
92
+ _force_list: bool = False,
93
+ ):
62
94
  bindings = bindings or {}
63
95
  if not isinstance(node, self.node_type):
64
96
  return None
@@ -85,9 +117,7 @@ class NodePattern(Pattern):
85
117
  return None
86
118
  merged[k] = self._to_list(merged[k]) + self._to_list(v)
87
119
  else:
88
- merged[k] = (
89
- self._to_list(v) if _force_list else v
90
- )
120
+ merged[k] = self._to_list(v) if _force_list else v
91
121
  else:
92
122
  if val != pat:
93
123
  return None
@@ -103,17 +133,24 @@ class Collect(Pattern):
103
133
  """
104
134
 
105
135
  pattern: Pattern
106
- """The pattern to match."""
107
-
108
136
  key: str
109
- """The key to bind the pattern result to."""
110
137
 
111
138
  def __init__(self, pattern: Pattern, key: str):
139
+ """Collect node.
140
+
141
+ Args:
142
+ pattern: The pattern to match.
143
+ key: The key to bind the pattern result to.
144
+ """
112
145
  self.pattern = pattern
113
146
  self.key = key
114
147
 
115
148
  def match_node(
116
- self, node: Any, bindings: dict[str, Any] | None = None, *, _force_list: bool = False
149
+ self,
150
+ node: Any,
151
+ bindings: dict[str, Any] | None = None,
152
+ *,
153
+ _force_list: bool = False,
117
154
  ) -> None | dict[str, Any]:
118
155
  bindings = bindings or {}
119
156
  # Collect is a binding boundary — inner patterns always see _force_list=False
@@ -158,16 +195,25 @@ class Filter(Pattern):
158
195
  """
159
196
 
160
197
  predicate: Callable[[Any], bool]
161
- """The predicate to match."""
162
-
163
198
  key: str | None
164
- """The key to bind the node to."""
165
199
 
166
200
  def __init__(self, predicate: Callable[[Any], bool], key: str | None = None):
201
+ """Filter node.
202
+
203
+ Args:
204
+ predicate: A callable that returns True if the node matches.
205
+ key: Optional key to bind the matched node to.
206
+ """
167
207
  self.predicate = predicate
168
208
  self.key = key
169
209
 
170
- def match_node(self, node: Any, bindings: dict[str, Any] | None = None, *, _force_list: bool = False):
210
+ def match_node(
211
+ self,
212
+ node: Any,
213
+ bindings: dict[str, Any] | None = None,
214
+ *,
215
+ _force_list: bool = False,
216
+ ):
171
217
  bindings = bindings or {}
172
218
  if not self.predicate(node):
173
219
  return None
@@ -189,10 +235,23 @@ class Not(Pattern):
189
235
  pattern: The pattern that must fail for this to match.
190
236
  """
191
237
 
238
+ pattern: Pattern
239
+
192
240
  def __init__(self, pattern: Pattern):
241
+ """Not node.
242
+
243
+ Args:
244
+ pattern: The pattern that must fail for this to match.
245
+ """
193
246
  self.pattern = pattern
194
247
 
195
- def match_node(self, node: Any, bindings: dict[str, Any] | None = None, *, _force_list: bool = False):
248
+ def match_node(
249
+ self,
250
+ node: Any,
251
+ bindings: dict[str, Any] | None = None,
252
+ *,
253
+ _force_list: bool = False,
254
+ ):
196
255
  bindings = bindings or {}
197
256
 
198
257
  # Use _match_patterns so that SequencePatterns (like OneOf) don't
@@ -211,10 +270,23 @@ class Contains(Pattern):
211
270
  pattern: The pattern or sequence of patterns to search for in the sub-tree.
212
271
  """
213
272
 
273
+ pattern: Sequence[Pattern]
274
+
214
275
  def __init__(self, pattern: Sequence[Pattern]):
276
+ """Contains node.
277
+
278
+ Args:
279
+ pattern: The pattern or sequence of patterns to search for in the sub-tree.
280
+ """
215
281
  self.pattern = list(pattern)
216
282
 
217
- def match_node(self, node: Any, bindings: dict[str, Any] | None = None, *, _force_list: bool = False):
283
+ def match_node(
284
+ self,
285
+ node: Any,
286
+ bindings: dict[str, Any] | None = None,
287
+ *,
288
+ _force_list: bool = False,
289
+ ):
218
290
  bindings = bindings or {}
219
291
  finder = SingleOccurrenceFinder(self.pattern)
220
292
  finder.visit(node)
@@ -240,17 +312,29 @@ class AllOf(Pattern):
240
312
  """
241
313
 
242
314
  patterns: Sequence[Pattern]
243
- """The patterns to match."""
244
315
 
245
316
  def __init__(self, patterns: Sequence[Pattern]):
317
+ """AllOf node.
318
+
319
+ Args:
320
+ patterns: Sequence of patterns that must all match the node.
321
+ """
246
322
  self.patterns = list(patterns)
247
323
 
248
- def match_node(self, node: Any, bindings: dict[str, Any] | None = None, *, _force_list: bool = False):
324
+ def match_node(
325
+ self,
326
+ node: Any,
327
+ bindings: dict[str, Any] | None = None,
328
+ *,
329
+ _force_list: bool = False,
330
+ ):
249
331
  bindings = bindings or {}
250
332
  new_bindings = dict(bindings)
251
333
 
252
334
  for pattern in self.patterns:
253
- new_bindings = pattern.match_node(node, new_bindings, _force_list=_force_list)
335
+ new_bindings = pattern.match_node(
336
+ node, new_bindings, _force_list=_force_list
337
+ )
254
338
  if new_bindings is None:
255
339
  return None
256
340
  return new_bindings
@@ -264,12 +348,22 @@ class AnyOf(Pattern):
264
348
  """
265
349
 
266
350
  patterns: list[Pattern]
267
- """The patterns to match."""
268
351
 
269
352
  def __init__(self, patterns: Sequence[Pattern]):
353
+ """AnyOf node.
354
+
355
+ Args:
356
+ patterns: Sequence of patterns where at least one must match.
357
+ """
270
358
  self.patterns = list(patterns)
271
359
 
272
- def match_node(self, node: Any, bindings: dict[str, Any] | None = None, *, _force_list: bool = False):
360
+ def match_node(
361
+ self,
362
+ node: Any,
363
+ bindings: dict[str, Any] | None = None,
364
+ *,
365
+ _force_list: bool = False,
366
+ ):
273
367
  bindings = bindings or {}
274
368
  merged = dict(bindings)
275
369
  matched_any = False
@@ -6,7 +6,11 @@ from ast_pattern_engine.core import Pattern
6
6
 
7
7
  class SequencePattern(Pattern):
8
8
  def match_node(
9
- self, node: object, bindings: dict[str, object] | None = None, *, _force_list: bool = False
9
+ self,
10
+ node: object,
11
+ bindings: dict[str, object] | None = None,
12
+ *,
13
+ _force_list: bool = False,
10
14
  ):
11
15
  # Matching is handled by engine._match_sequence
12
16
  raise NotImplementedError(
@@ -18,16 +22,16 @@ class Repetition(SequencePattern):
18
22
  """Matches a single pattern zero or more times.
19
23
 
20
24
  Also supports specifying min and max match count thresholds
25
+
26
+ Args:
27
+ pattern: The pattern to match.
28
+ min_matches: Minimum number of matches required. Default is 1.
29
+ max_matches: Maximum number of allowed matches. Defaults to None.
21
30
  """
22
31
 
23
32
  pattern: Pattern
24
- """The pattern to match."""
25
-
26
33
  min_matches: int
27
- """Minimum number of matches required. Defaults to 1."""
28
-
29
34
  max_matches: int | None
30
- """Maximum number of allowed matches. Defaults to None."""
31
35
 
32
36
  def __init__(
33
37
  self,
@@ -38,9 +42,9 @@ class Repetition(SequencePattern):
38
42
  """Repetition node.
39
43
 
40
44
  Args:
41
- pattern: The AST pattern.
42
- min_matches: Min number of matches required. Default is 1.
43
- max_matches: Max number of allowed matches. Defaults to None.
45
+ pattern: The pattern to match.
46
+ min_matches: Minimum number of matches required. Default is 1.
47
+ max_matches: Maximum number of allowed matches. Defaults to None.
44
48
  """
45
49
  self.pattern = pattern
46
50
  self.min_matches = min_matches
@@ -48,13 +52,15 @@ class Repetition(SequencePattern):
48
52
 
49
53
 
50
54
  class PatternGroup(SequencePattern):
51
- """Matches a compound pattern/pattern group to an AST node sequence."""
55
+ """Matches a compound pattern/pattern group to an AST node sequence.
52
56
 
53
- pattern: Sequence[Pattern]
54
- """The compound pattern to match."""
57
+ Args:
58
+ pattern: The compound pattern to match.
59
+ key: Optional key to bind the matched pattern to.
60
+ """
55
61
 
62
+ pattern: Sequence[Pattern]
56
63
  key: str | None
57
- """Optional key to bind the matched pattern to."""
58
64
 
59
65
  def __init__(self, pattern: Sequence[Pattern], key: str | None = None) -> None:
60
66
  """PatternGroup node.
@@ -80,30 +86,34 @@ class OneOf(SequencePattern):
80
86
  """
81
87
 
82
88
  patterns: list[Pattern]
83
- """The patterns to match."""
84
-
85
89
  strict: bool
86
- """Whether to be strict and only match if *exactly* one pattern matches."""
87
-
88
90
  key: str | None
89
- """Optional key to bind the matched pattern to."""
90
91
 
91
92
  def __init__(
92
93
  self, patterns: Sequence[Pattern], strict: bool = False, key: str | None = None
93
94
  ) -> None:
95
+ """OneOf node.
96
+
97
+ Args:
98
+ patterns: The patterns to match
99
+ strict: Whether to be strict and only match if *exactly* one pattern matches
100
+ key: Optional key to bind the matched pattern to
101
+ """
94
102
  self.patterns = list(patterns)
95
103
  self.strict = strict
96
104
  self.key = key
97
105
 
98
106
 
99
107
  class Optional(SequencePattern):
100
- """Matches a pattern zero or one times."""
108
+ """Matches a pattern zero or one times.
101
109
 
102
- pattern: Pattern
103
- """The pattern to match."""
110
+ Args:
111
+ pattern: The pattern to match.
112
+ key: Optional key to bind the matched pattern to.
113
+ """
104
114
 
115
+ pattern: Pattern
105
116
  key: str | None
106
- """Optional key to bind the matched pattern to."""
107
117
 
108
118
  def __init__(self, pattern: Pattern, key: str | None = None) -> None:
109
119
  """Optional node.
@@ -1,3 +1,4 @@
1
+ from typing import TypeAlias
1
2
  import ast
2
3
  from typing import Any
3
4
  from collections.abc import Sequence, Callable
@@ -5,7 +6,7 @@ from collections.abc import Sequence, Callable
5
6
  from ast_pattern_engine.core import Pattern
6
7
  from ast_pattern_engine.engine import _match_patterns
7
8
 
8
- type ReplaceResult = ast.AST | list[ast.AST] | None
9
+ ReplaceResult: TypeAlias = ast.AST | list[ast.AST] | None
9
10
 
10
11
 
11
12
  class PatternTransformer(ast.NodeTransformer):
@@ -28,6 +29,9 @@ class PatternTransformer(ast.NodeTransformer):
28
29
  Args:
29
30
  pattern: Sequence of pattern nodes matched against each candidate span.
30
31
  actions: Mapping from collect keys to replacement handlers or None.
32
+
33
+ Attributes:
34
+ matches: List of bindings for each match.
31
35
  """
32
36
 
33
37
  def __init__(
@@ -358,6 +362,9 @@ class BottomUpPatternTransformer(ast.NodeTransformer):
358
362
  Args:
359
363
  pattern: Sequence of pattern nodes matched against each candidate span.
360
364
  actions: Mapping from collect keys to replacement handlers or None.
365
+
366
+ Attributes:
367
+ matches: List of bindings for each match.
361
368
  """
362
369
 
363
370
  def __init__(
@@ -441,6 +448,10 @@ class PatternFinder(ast.NodeVisitor):
441
448
 
442
449
  Args:
443
450
  pattern: Sequence of pattern nodes to search for.
451
+
452
+ Attributes:
453
+ matches: List of bindings for each match.
454
+ visited: Set of visited node IDs.
444
455
  """
445
456
 
446
457
  def __init__(self, pattern: Sequence[Pattern]):
@@ -486,6 +497,10 @@ class SingleOccurrenceFinder(ast.NodeVisitor):
486
497
 
487
498
  Args:
488
499
  pattern: Sequence of pattern nodes to search for.
500
+
501
+ Attributes:
502
+ match_node: The first match node, if any.
503
+ match_bindings: The bindings for the first match, if any.
489
504
  """
490
505
 
491
506
  match_node: ast.AST | None
@@ -496,15 +511,15 @@ class SingleOccurrenceFinder(ast.NodeVisitor):
496
511
  self.match_node = None
497
512
  self.match_bindings = {}
498
513
  self.pattern = pattern
499
- self.found = False
514
+ self._found_match = False
500
515
 
501
516
  def visit(self, node: ast.AST):
502
- if self.found:
517
+ if self._found_match:
503
518
  return # short-circuit: we've already found a match
504
519
 
505
520
  res = _match_patterns(self.pattern, [node], 0, {})
506
521
  if res:
507
- self.found = True
522
+ self._found_match = True
508
523
  self.match_node = node
509
524
  self.match_bindings = res[0][0]
510
525
  return
@@ -515,12 +530,12 @@ class SingleOccurrenceFinder(ast.NodeVisitor):
515
530
  for item in val:
516
531
  if isinstance(item, ast.AST):
517
532
  self.visit(item)
518
- if self.found:
533
+ if self._found_match:
519
534
  return
520
535
  elif isinstance(val, ast.AST):
521
536
  self.visit(val)
522
- if self.found:
537
+ if self._found_match:
523
538
  return
524
539
 
525
540
  def found_match(self) -> bool:
526
- return self.found
541
+ return self._found_match
@@ -1,32 +1,22 @@
1
1
  import ast
2
2
  from ast_pattern_engine.nodes.basic import AnyOf, NodePattern, Collect
3
3
 
4
+
4
5
  def test_any_of_matches_and_conflicts():
5
6
  node = ast.parse("1").body[0].value
6
-
7
+
7
8
  # First matches
8
- pattern1 = AnyOf([
9
- NodePattern(ast.Constant),
10
- NodePattern(ast.Name)
11
- ])
9
+ pattern1 = AnyOf([NodePattern(ast.Constant), NodePattern(ast.Name)])
12
10
  assert pattern1.match_node(node, {"existing": 1}) == {"existing": 1}
13
-
11
+
14
12
  # Second matches
15
- pattern2 = AnyOf([
16
- NodePattern(ast.Name),
17
- NodePattern(ast.Constant)
18
- ])
13
+ pattern2 = AnyOf([NodePattern(ast.Name), NodePattern(ast.Constant)])
19
14
  assert pattern2.match_node(node, {}) == {}
20
-
15
+
21
16
  # None matches
22
- pattern3 = AnyOf([
23
- NodePattern(ast.Name),
24
- NodePattern(ast.Assign)
25
- ])
17
+ pattern3 = AnyOf([NodePattern(ast.Name), NodePattern(ast.Assign)])
26
18
  assert pattern3.match_node(node, {}) is None
27
19
 
28
20
  # Match produces conflicting key
29
- pattern4 = AnyOf([
30
- Collect(NodePattern(ast.Constant), "c")
31
- ])
21
+ pattern4 = AnyOf([Collect(NodePattern(ast.Constant), "c")])
32
22
  assert pattern4.match_node(node, {"c": "conflict"}) is None
@@ -1,6 +1,7 @@
1
1
  import ast
2
2
  from ast_pattern_engine.nodes.basic import Collect, NodePattern
3
3
 
4
+
4
5
  def test_collect_match_single_constant():
5
6
  node = ast.parse("1").body[0]
6
7
  assert isinstance(node, ast.Expr)
@@ -3,6 +3,7 @@ from ast_pattern_engine.nodes.basic import Filter
3
3
  from ast_pattern_engine.nodes.sequences import Repetition
4
4
  from ast_pattern_engine.engine import match_sequence
5
5
 
6
+
6
7
  def test_filter_no_key():
7
8
  node = ast.parse("1").body[0].value
8
9
  # Matches, no key bound
@@ -17,14 +18,14 @@ def test_filter_no_key():
17
18
  def test_filter_with_key_and_conflicts():
18
19
  node = ast.parse("1").body[0].value
19
20
  pattern = Filter(lambda x: isinstance(x, ast.Constant), key="my_filter")
20
-
21
+
21
22
  # Matches and binds
22
23
  res = pattern.match_node(node, {})
23
24
  assert res == {"my_filter": node}
24
-
25
+
25
26
  # Conflict on duplicate key without force
26
27
  assert pattern.match_node(node, {"my_filter": "existing"}) is None
27
-
28
+
28
29
  # When inside a Repetition, ancestor forces list
29
30
  rep_pattern = Repetition(pattern)
30
31
  # Match node directly against the filter while simulating the Repetition context
@@ -3,6 +3,7 @@ from ast_pattern_engine.nodes.basic import Collect, WildCard, NodePattern
3
3
  from ast_pattern_engine.nodes.sequences import OneOf
4
4
  from ast_pattern_engine.engine import _match_patterns, match_sequence
5
5
 
6
+
6
7
  def test_one_of_non_strict_returns_first_match():
7
8
  nodes = [ast.parse(src).body[0] for src in ("1", "2")]
8
9
  pattern = [
@@ -49,9 +50,9 @@ def test_one_of_strict_matches_exactly_one_pattern():
49
50
  ]
50
51
 
51
52
  result = _match_patterns(pattern, nodes, 0, {})
52
- assert (
53
- len(result) == 0
54
- ), "Expected strict OneOf not to match because multiple sub patterns match"
53
+ assert len(result) == 0, (
54
+ "Expected strict OneOf not to match because multiple sub patterns match"
55
+ )
55
56
 
56
57
  # Section B: Test strict mode with exactly one matching pattern for each line
57
58
  nodes = [ast.parse(src).body[0] for src in ("1", "x=2")]
@@ -3,6 +3,7 @@ from ast_pattern_engine.nodes.basic import Collect, WildCard, NodePattern
3
3
  from ast_pattern_engine.nodes.sequences import PatternGroup
4
4
  from ast_pattern_engine.engine import _match_patterns, match_sequence
5
5
 
6
+
6
7
  def test_pattern_group_collects_inner_bindings_under_key():
7
8
  nodes = [ast.parse(src).body[0].value for src in ("1", "2")] # type: ignore
8
9
  pattern = [
@@ -3,6 +3,7 @@ from ast_pattern_engine.nodes.basic import Collect, WildCard
3
3
  from ast_pattern_engine.nodes.sequences import Repetition
4
4
  from ast_pattern_engine.engine import _match_patterns
5
5
 
6
+
6
7
  def test_collect_inside_one_or_more_accumulates_nodes():
7
8
  nodes = [ast.parse(str(i)).body[0].value for i in range(3)] # type: ignore
8
9
  pattern = [Repetition(Collect(WildCard(), "item"))]
@@ -2,6 +2,7 @@ import ast
2
2
  from ast_pattern_engine.nodes.basic import Collect, WildCard
3
3
  from ast_pattern_engine.engine import match_sequence
4
4
 
5
+
5
6
  def test_match_sequence_returns_non_overlapping_bindings():
6
7
  nodes = [ast.parse(text).body[0] for text in ("a = 1", "b = 2", "c = 3")]
7
8
  pattern = [Collect(WildCard(), "assign")]
@@ -14,7 +14,7 @@ def _constant(value, template):
14
14
 
15
15
 
16
16
  def test_bottom_up_pattern_transformer_collapses_children_before_parent():
17
- source = "def foo():\n" " return (1 + 2) + (3 + 4)\n"
17
+ source = "def foo():\n return (1 + 2) + (3 + 4)\n"
18
18
  tree = ast.parse(source)
19
19
  pattern = [Collect(NodePattern(ast.BinOp), "expr")]
20
20
 
@@ -60,6 +60,7 @@ def test_bu_transformer_list_manipulation():
60
60
  # Return multiple nodes (expands)
61
61
  def expand(_):
62
62
  return [_parse_stmt("pass"), _parse_stmt("pass")]
63
+
63
64
  transformer3 = BottomUpPatternTransformer(pattern, {"a": expand})
64
65
  res3 = transformer3.visit(ast.parse("x = 1"))
65
66
  assert len(res3.body) == 2
@@ -17,7 +17,10 @@ def test_pattern_finder_collects_node_matches():
17
17
  def test_pattern_finder_scan_list():
18
18
  tree = ast.parse("a = 1\nb = 2\nc = 3")
19
19
  # A sequence of length 2 to trigger `_scan_list` len(self.pattern) > 1 branch
20
- pattern = [Collect(NodePattern(ast.Assign), "a"), Collect(NodePattern(ast.Assign), "b")]
20
+ pattern = [
21
+ Collect(NodePattern(ast.Assign), "a"),
22
+ Collect(NodePattern(ast.Assign), "b"),
23
+ ]
21
24
  finder = PatternFinder(pattern)
22
25
  finder.visit(tree)
23
26
  # Print the matches to debug
@@ -123,7 +123,7 @@ def test_pt_nonlist_replace_errors():
123
123
 
124
124
  # Error: handler returns non-list
125
125
  def bad_handler(_):
126
- return _constant_val(2) # type: ignore
126
+ return _constant_val(2) # type: ignore
127
127
 
128
128
  transformer2 = PatternTransformer(pattern, {"c": bad_handler})
129
129
  with pytest.raises(TypeError, match="Handler must return list"):
@@ -133,7 +133,7 @@ def test_pt_nonlist_replace_errors():
133
133
  def test_pt_plan_errors():
134
134
  tree = ast.parse("a = 1\nb = 2")
135
135
  pattern = [Collect(NodePattern(ast.Assign), "a")]
136
-
136
+
137
137
  # key not in bindings (should silently continue)
138
138
  transformer1 = PatternTransformer(pattern, {"missing_key": lambda b: []})
139
139
  transformer1.visit(ast.parse("a = 1"))
@@ -142,11 +142,12 @@ def test_pt_plan_errors():
142
142
  transformer2 = PatternTransformer(pattern, {"a": lambda b: []})
143
143
  # override matching to return empty list for testing
144
144
  b = {"a": []}
145
- transformer2.matches.append(b)
146
-
145
+ transformer2.matches.append(b)
146
+
147
147
  # handler returns non-list
148
148
  def bad_plan_handler(_):
149
149
  return "not a list"
150
+
150
151
  transformer3 = PatternTransformer(pattern, {"a": bad_plan_handler})
151
152
  with pytest.raises(TypeError, match="must return `list"):
152
153
  transformer3.visit(ast.parse("a = 1"))
@@ -166,6 +167,7 @@ def test_pt_nested_replace_and_delete():
166
167
  # Replace nested nodes
167
168
  def replace_with_99(_):
168
169
  return [_constant_val(99)]
170
+
169
171
  transformer2 = PatternTransformer(pattern, {"c": replace_with_99})
170
172
  res2 = transformer2.visit(ast.parse("x = [1, 2, 3]"))
171
173
  assert all(elt.value == 99 for elt in res2.body[0].value.elts)
@@ -174,18 +176,20 @@ def test_pt_nested_replace_and_delete():
174
176
  def test_pt_dict_as_nodes():
175
177
  tree = ast.parse("x = 1")
176
178
  pattern = [Collect(NodePattern(ast.Assign), "a")]
177
-
179
+
178
180
  # We force the binding to be a dict to trigger dict handling in _as_nodes
179
181
  class DictTransformer(PatternTransformer):
180
182
  def _plan(self, seq):
181
183
  # Intercept and mutate bindings
182
184
  res = super()._plan(seq)
183
185
  return res
184
-
186
+
185
187
  transformer = DictTransformer(pattern, {"a": lambda b: [_parse_stmt("x = 2")]})
186
188
  # Override match manually
187
- mtch = transformer._match_patterns = lambda p, s, i, b: [({"a": {"nested": s[i]}}, i+1)] if i < len(s) else []
188
-
189
+ mtch = transformer._match_patterns = lambda p, s, i, b: (
190
+ [({"a": {"nested": s[i]}}, i + 1)] if i < len(s) else []
191
+ )
192
+
189
193
  res = transformer.visit(tree)
190
194
  assert isinstance(res.body[0], ast.Assign)
191
195
  assert res.body[0].value.value == 2
@@ -195,14 +199,14 @@ def test_pt_generic_visit_list_field_error():
195
199
  tree = ast.parse("a = 1")
196
200
  pattern = [Collect(NodePattern(ast.Assign), "a")]
197
201
  transformer = PatternTransformer(pattern, {})
198
-
202
+
199
203
  # Force a child to not be an AST node
200
204
  class BadTransformer(PatternTransformer):
201
205
  def visit(self, node):
202
206
  if isinstance(node, ast.Assign):
203
207
  return "Not an AST node"
204
208
  return super().visit(node)
205
-
209
+
206
210
  bad_transformer = BadTransformer(pattern, {})
207
211
  with pytest.raises(TypeError, match="must contain AST nodes"):
208
212
  bad_transformer.visit(ast.parse("a = 1"))
@@ -29,8 +29,8 @@ def test_single_occurrence_finder_early_exit():
29
29
  tree = ast.parse("a = 1\nb = 2")
30
30
  pattern = [NodePattern(ast.Assign)]
31
31
  finder = SingleOccurrenceFinder(pattern)
32
-
32
+
33
33
  # We manually set found to True to test early exit in visit
34
- finder.found = True
34
+ finder._found_match = True
35
35
  finder.visit(tree)
36
- assert finder.match_node is None # Didn't actually match because it early exited
36
+ assert finder.match_node is None # Didn't actually match because it early exited
@@ -1,44 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [ "main" ]
6
- pull_request:
7
- branches: [ "main" ]
8
-
9
- jobs:
10
- test:
11
- runs-on: ubuntu-latest
12
- strategy:
13
- matrix:
14
- python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
15
-
16
- steps:
17
- - uses: actions/checkout@v4
18
-
19
- - name: Set up Python ${{ matrix.python-version }}
20
- uses: actions/setup-python@v5
21
- with:
22
- python-version: ${{ matrix.python-version }}
23
-
24
- - name: Install uv
25
- uses: astral-sh/setup-uv@v5
26
- with:
27
- enable-cache: true
28
-
29
- - name: Install dependencies
30
- run: uv sync --all-extras
31
-
32
- - name: Check formatting with Ruff
33
- run: uv run ruff format --check
34
-
35
- - name: Lint with Ruff
36
- run: uv run ruff check
37
-
38
- - name: Run tests with pytest
39
- run: uv run pytest --cov=src --cov-report=xml
40
-
41
- - name: Upload coverage reports to Codecov
42
- uses: codecov/codecov-action@v4
43
- env:
44
- CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
@@ -1,2 +0,0 @@
1
- # This module previously contained the Message/AnnounceBinding plumbing.
2
- # That system has been replaced by stateless _force_list context passing.