Repository: neogeny/TatSu

Issue:
positive join emits invalid code
Using the MRE below, positive joins are broken in tatsu 5.12.1

__grammar.ebnf__
```ebnf
@@grammar::numbers

start
    = expression $
    ;

expression
    = ~ ( "," )%{ digit }+
    ;

digit = /\d+/ ;
```

__code.py__
```python
from numbers import numbersParser

p = numbersParser()

res = p.parse("""2,3""")
```

generating __numbers.py__
```
tatsu --generate-parser --name numbers --outfile numbers.py grammar.bnf
```

sep0 would work.
```
python code.py 

Traceback (most recent call last):
… line 82, in _expression_
    self._positive_join(block1, sep1)
                                ^^^^
NameError: name 'sep1' is not defined. Did you mean: 'sep0'?
```

code in question:
```python
    @tatsumasu()
    def _expression_(self):
        self._cut()

        def sep0():
            with self._group():
                self._token(',')

        def block1():
            self._digit_()
        self._positive_join(block1, sep1)
```

Discussion:
@commonism: https://github.com/neogeny/TatSu/blob/fa2df3889c4ced83d39ea60eacf0d48f17edb699/tatsu/ngcodegen/python.py#L245-L268

problem is re-using n for the index.@apalala: Wow! This is unexpected, and it looks difficult!

A pull request would be appreciated much!
@apalala: If not a solution, a unit test would be great!


Solution patch:
diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml
index fc0f0fc5..bd9cf741 100644
--- a/.github/workflows/default.yml
+++ b/.github/workflows/default.yml
@@ -13,8 +13,8 @@ jobs:
       fail-fast: false
       matrix:
         python-version: [
+          '3.13',
           '3.12',
-          '3.11',
         ]
 
     steps:
diff --git a/README.rst b/README.rst
index ee5ab7dd..73ad6eae 100644
--- a/README.rst
+++ b/README.rst
@@ -32,7 +32,7 @@ input, much like the `re`_ module does with regular expressions, or it can gener
 |TatSu| supports `left-recursive`_  rules in PEG_ grammars using the
 algorithm_ by *Laurent* and *Mens*. The generated AST_ has the expected left associativity.
 
-|TatSu| requires a maintained version of Python (3.11+ at the moment).  While no code
+|TatSu| requires a maintained version of Python (3.13+ at the moment).  While no code
 in |TatSu| yet depends on new language or standard library features,
 the authors don't want to be constrained by Python version compatibility considerations
 when developing features that will be part of future releases.
diff --git a/pyproject.toml b/pyproject.toml
index df45d2cc..ec70f886 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,7 +10,7 @@ authors = [
 ]
 description = "TatSu takes a grammar in a variation of EBNF as input, and outputs a memoizing PEG/Packrat parser in Python."
 readme = "README.rst"
-requires-python = ">=3.11"
+requires-python = ">=3.12"
 keywords = []
 license = {file = "LICENSE.TXT"}
 classifiers = [
@@ -21,8 +21,8 @@ classifiers = [
     "Intended Audience :: Science/Research",
     "Environment :: Console",
     "Operating System :: OS Independent",
-    "Programming Language :: Python :: 3.11",
     "Programming Language :: Python :: 3.12",
+    "Programming Language :: Python :: 3.13",
     "Topic :: Software Development :: Code Generators",
     "Topic :: Software Development :: Compilers",
     "Topic :: Software Development :: Interpreters",
diff --git a/requirements-test.txt b/requirements-test.txt
index 9ec3eaea..231f3ed3 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -1,4 +1,4 @@
-astroid>
+astroid
 docutils
 mypy
 pandoc
diff --git a/ruff.toml b/ruff.toml
index abf3684a..d55fd9f7 100644
--- a/ruff.toml
+++ b/ruff.toml
@@ -41,6 +41,7 @@ lint.ignore = [
     "SIM114",  # if-with-same-arms
     "TRY003",  # raise-vanilla-args
     "TRY300",  # try-consider-else
+    "UP031",  # use of % formatting
 ]
 exclude = []
 
diff --git a/tatsu/_version.py b/tatsu/_version.py
index 0218cf33..e9592a00 100644
--- a/tatsu/_version.py
+++ b/tatsu/_version.py
@@ -1 +1 @@
-__version__ = '5.12.2b1'
+__version__ = '5.12.3b1'
diff --git a/tatsu/ast.py b/tatsu/ast.py
index 95db84a1..bfba54c0 100644
--- a/tatsu/ast.py
+++ b/tatsu/ast.py
@@ -7,7 +7,7 @@
 from .util import asjson, is_list
 
 
-class AST(dict):
+class AST(dict):  # noqa: FURB189
     _frozen = False
 
     def __init__(self, *args, **kwargs):
diff --git a/tatsu/collections/orderedset.py b/tatsu/collections/orderedset.py
index 5efc2137..fb924017 100644
--- a/tatsu/collections/orderedset.py
+++ b/tatsu/collections/orderedset.py
@@ -13,7 +13,7 @@
 T = TypeVar('T')
 
 
-class OrderedSet(MutableSet[T], Sequence[T]):  # noqa: PLW1641
+class OrderedSet(MutableSet[T], Sequence[T]):
     def __init__(self, iterable: Iterable[T] | None = None):
         if iterable is not None:
             self._map = dict.fromkeys(iterable)
@@ -21,6 +21,9 @@ def __init__(self, iterable: Iterable[T] | None = None):
             self._map = {}
         self._list_cache: Sequence[T] | None = None
 
+    def __hash__(self):
+        return self._map.__hash__()
+
     def __len__(self):
         return len(self._map)
 
diff --git a/tatsu/contexts.py b/tatsu/contexts.py
index 50d7df30..12873fdb 100644
--- a/tatsu/contexts.py
+++ b/tatsu/contexts.py
@@ -101,7 +101,7 @@ def isname(impl):
     return impl
 
 
-class closure(list):
+class closure(list):  # noqa: FURB189
     def __hash__(self):
         return hash(tuple(self))
 
diff --git a/tatsu/grammars.py b/tatsu/grammars.py
index 726225f1..65def8b9 100644
--- a/tatsu/grammars.py
+++ b/tatsu/grammars.py
@@ -22,7 +22,7 @@
 PRAGMA_RE = r'^\s*#include.*$'
 
 
-class _ref(str):
+class _ref(str):  # noqa: FURB189
     def __repr__(self):
         return f'<{self}>'
 
diff --git a/tatsu/infos.py b/tatsu/infos.py
index 0efb982f..6ec898ad 100644
--- a/tatsu/infos.py
+++ b/tatsu/infos.py
@@ -12,7 +12,7 @@
 from .util.unicode_characters import C_DERIVE
 
 
-class UndefinedStr(str):
+class UndefinedStr(str):  # noqa: FURB189
     pass
 
 
diff --git a/tatsu/ngcodegen/python.py b/tatsu/ngcodegen/python.py
index 30725801..6a83e5c5 100644
--- a/tatsu/ngcodegen/python.py
+++ b/tatsu/ngcodegen/python.py
@@ -243,24 +243,24 @@ def walk_PositiveClosure(self, closure: grammars.PositiveClosure):
         self.print(f'self._positive_closure(block{n})')
 
     def walk_Join(self, join: grammars.Join):
-        n = self._gen_block(join.sep, name='sep')
+        m = self._gen_block(join.sep, name='sep')
         n = self._gen_block(join.exp)
-        self.print(f'self._join(block{n}, sep{n})')
+        self.print(f'self._join(block{n}, sep{m})')
 
     def walk_PositiveJoin(self, join: grammars.PositiveJoin):
-        n = self._gen_block(join.sep, name='sep')
+        m = self._gen_block(join.sep, name='sep')
         n = self._gen_block(join.exp)
-        self.print(f'self._positive_join(block{n}, sep{n})')
+        self.print(f'self._positive_join(block{n}, sep{m})')
 
     def walk_LeftJoin(self, join: grammars.LeftJoin):
-        n = self._gen_block(join.sep, name='sep')
+        m = self._gen_block(join.sep, name='sep')
         n = self._gen_block(join.exp)
-        self.print(f'self._left_join(block{n}, sep{n})')
+        self.print(f'self._left_join(block{n}, sep{m})')
 
     def walk_RightJoin(self, join: grammars.RightJoin):
-        n = self._gen_block(join.sep, name='sep')
+        m = self._gen_block(join.sep, name='sep')
         n = self._gen_block(join.exp)
-        self.print(f'self._right_join(block{n}, sep{n})')
+        self.print(f'self._right_join(block{n}, sep{m})')
 
     def walk_Gather(self, gather: grammars.Gather):
         m = self._gen_block(gather.sep, name='sep')
diff --git a/tatsu/util/parproc.py b/tatsu/util/parproc.py
index 715fafec..9300ff97 100644
--- a/tatsu/util/parproc.py
+++ b/tatsu/util/parproc.py
@@ -122,7 +122,7 @@ def parallel_proc(payloads, process, *args, **kwargs):
             pmap = _pmap if parallel else map
             yield from pmap(process, tasks)
     except KeyboardInterrupt:
-        return []
+        return
 
 
 def _build_progressbar(total):
diff --git a/tox.ini b/tox.ini
index 2432f42c..f4783e4e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = py313, py312, py311
+envlist = py313, py312
 skipsdist = True
 
 [testenv]


Test patch:
diff --git a/test/parser_equivalence_test.py b/test/parser_equivalence_test.py
index 27ed9c4b..02b4367f 100644
--- a/test/parser_equivalence_test.py
+++ b/test/parser_equivalence_test.py
@@ -170,3 +170,22 @@ def test_none_whitespace():
     parser = generate_and_load_parser('W', grammar)
     output = parser.parse(input, parseinfo=False)
     assert output == ('This is a', ' test')
+
+def test_sep_join():
+    grammar = """
+    @@grammar::numbers
+
+    start
+        = expression $
+        ;
+
+    expression
+        = ~ ( "," )%{ digit }+
+        ;
+
+    digit = /\d+/ ;
+    """
+    parser = generate_and_load_parser('W', grammar)
+    ast = parser.parse('1,2,3,4', nameguard=False)
+
+