diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..b508dca8 --- /dev/null +++ b/.flake8 @@ -0,0 +1,8 @@ +[flake8] +exclude = + tests, + docs, + dist +max-complexity = 31 +statistics = True +show-source = True \ No newline at end of file diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 67eff95a..555e5dc2 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -1,4 +1,4 @@ -# This workflow will install Python dependencies, run tests and lint using uv +# This workflow will install Python dependencies, run tests and lint with a single version of Python # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Python application @@ -8,56 +8,41 @@ on: branches: - master pull_request: + branches: [ master ] schedule: - cron: '0 12 * * *' jobs: test: - name: Run tests on Python ${{ matrix.python-version }} - runs-on: ${{ matrix.os }} + name: Run tests on ${{ matrix.py }} + runs-on: ubuntu-latest strategy: - fail-fast: false matrix: - include: - - python-version: "3.10" - os: ubuntu-latest - - python-version: "3.11" - os: ubuntu-latest - - python-version: "3.12" - os: ubuntu-latest - - python-version: "3.13" - os: ubuntu-latest - - python-version: "3.14" - os: ubuntu-latest - # Test on additional platforms for Python 3.11 - - python-version: "3.11" - os: macos-latest - - python-version: "3.11" - os: windows-latest + py: + #- "3.13-dev" + - "3.12" + - "3.11" + - "3.10" + - "3.9" + - "3.8" + - "pypy-3.10" + - "pypy-3.9" + - "pypy-3.8" steps: - uses: actions/checkout@v4 - - - name: Setup uv - uses: astral-sh/setup-uv@v5 + - name: Set up Python ${{ matrix.py }} + uses: actions/setup-python@v5 with: - enable-cache: true - python-version: ${{ matrix.python-version }} - - - name: Install dependencies and run tests - run: uv run --group dev pytest tests/ - - - name: Run lint (Python 3.11 only) - if: matrix.python-version == '3.11' && matrix.os == 'ubuntu-latest' - run: uv run --group dev ruff check sqlparse/ - - - name: Generate coverage report (Python 3.11 only) - if: matrix.python-version == '3.11' && matrix.os == 'ubuntu-latest' + python-version: ${{ matrix.py }} + allow-prereleases: true + check-latest: true + - name: Install dependencies run: | - uv run --group dev coverage run -m pytest tests/ - uv run --group dev coverage combine - uv run --group dev coverage xml - + python -m pip install --upgrade pip hatch + - name: Lint with flake8 + run: hatch run flake8 + - name: Test with pytest and coverage + run: hatch run cov - name: Publish to codecov - if: matrix.python-version == '3.11' && matrix.os == 'ubuntu-latest' uses: codecov/codecov-action@v4 diff --git a/README.rst b/README.rst new file mode 100644 index 00000000..3eaf0efb --- /dev/null +++ b/README.rst @@ -0,0 +1,78 @@ +python-sqlparse - Parse SQL statements +====================================== + +|buildstatus|_ +|coverage|_ +|docs|_ +|packageversion|_ + +.. docincludebegin + +sqlparse is a non-validating SQL parser for Python. +It provides support for parsing, splitting and formatting SQL statements. + +The module is compatible with Python 3.8+ and released under the terms of the +`New BSD license `_. + +Visit the project page at https://github.com/andialbrecht/sqlparse for +further information about this project. + + +Quick Start +----------- + +.. code-block:: sh + + $ pip install sqlparse + +.. code-block:: python + + >>> import sqlparse + + >>> # Split a string containing two SQL statements: + >>> raw = 'select * from foo; select * from bar;' + >>> statements = sqlparse.split(raw) + >>> statements + ['select * from foo;', 'select * from bar;'] + + >>> # Format the first statement and print it out: + >>> first = statements[0] + >>> print(sqlparse.format(first, reindent=True, keyword_case='upper')) + SELECT * + FROM foo; + + >>> # Parsing a SQL statement: + >>> parsed = sqlparse.parse('select * from foo')[0] + >>> parsed.tokens + [, , >> + +Links +----- + +Project page + https://github.com/andialbrecht/sqlparse + +Bug tracker + https://github.com/andialbrecht/sqlparse/issues + +Documentation + https://sqlparse.readthedocs.io/ + +Online Demo + https://sqlformat.org/ + + +sqlparse is licensed under the BSD license. + +Parts of the code are based on pygments written by Georg Brandl and others. +pygments-Homepage: http://pygments.org/ + +.. |buildstatus| image:: https://github.com/andialbrecht/sqlparse/actions/workflows/python-app.yml/badge.svg +.. _buildstatus: https://github.com/andialbrecht/sqlparse/actions/workflows/python-app.yml +.. |coverage| image:: https://codecov.io/gh/andialbrecht/sqlparse/branch/master/graph/badge.svg +.. _coverage: https://codecov.io/gh/andialbrecht/sqlparse +.. |docs| image:: https://readthedocs.org/projects/sqlparse/badge/?version=latest +.. _docs: https://sqlparse.readthedocs.io/en/latest/?badge=latest +.. |packageversion| image:: https://img.shields.io/pypi/v/sqlparse?color=%2334D058&label=pypi%20package +.. _packageversion: https://pypi.org/project/sqlparse diff --git a/sqlparse/filters/aligned_indent.py b/sqlparse/filters/aligned_indent.py index 6ac99d62..c4dcc33c 100644 --- a/sqlparse/filters/aligned_indent.py +++ b/sqlparse/filters/aligned_indent.py @@ -90,11 +90,12 @@ def _process_case(self, tlist): def _next_token(self, tlist, idx=-1): split_words = T.Keyword, self.split_words, True tidx, token = tlist.token_next_by(m=split_words, idx=idx) - # treat "BETWEEN x and y" as a single statement - if token and token.normalized == 'BETWEEN': - tidx, token = self._next_token(tlist, tidx) + # Skip chained BETWEEN ... AND pairs iteratively to avoid + # RecursionError on inputs with many BETWEEN clauses. + while token and token.normalized == 'BETWEEN': + tidx, token = tlist.token_next_by(m=split_words, idx=tidx) if token and token.normalized == 'AND': - tidx, token = self._next_token(tlist, tidx) + tidx, token = tlist.token_next_by(m=split_words, idx=tidx) return tidx, token def _split_kwds(self, tlist): diff --git a/sqlparse/filters/reindent.py b/sqlparse/filters/reindent.py index ea41ac6b..8c0d6d1c 100644 --- a/sqlparse/filters/reindent.py +++ b/sqlparse/filters/reindent.py @@ -59,11 +59,12 @@ def _next_token(self, tlist, idx=-1): m_split = T.Keyword, split_words, True tidx, token = tlist.token_next_by(m=m_split, idx=idx) - if token and token.normalized == 'BETWEEN': - tidx, token = self._next_token(tlist, tidx) - + # Skip chained BETWEEN ... AND pairs iteratively to avoid + # RecursionError on inputs with many BETWEEN clauses. + while token and token.normalized == 'BETWEEN': + tidx, token = tlist.token_next_by(m=m_split, idx=tidx) if token and token.normalized == 'AND': - tidx, token = self._next_token(tlist, tidx) + tidx, token = tlist.token_next_by(m=m_split, idx=tidx) return tidx, token diff --git a/tests/test_format.py b/tests/test_format.py index 0cdbcf88..7bbc9bc6 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -784,3 +784,24 @@ def test_strip_ws_removes_trailing_ws_in_groups(): # issue782 strip_whitespace=True) expected = '(where foo = bar) from' assert formatted == expected + + +def test_format_chained_between_and(): + """Chained BETWEEN...AND pairs must not cause RecursionError. + + _next_token in ReindentFilter and AlignedIndentFilter previously + used recursion to skip BETWEEN...AND pairs. With ~1000 pairs + (8000 tokens, under MAX_GROUPING_TOKENS) this exceeded Python's + recursion limit. + """ + parts = ["x"] + for i in range(1000): + parts.append(f"BETWEEN {i} AND {i + 1}") + sql = "SELECT * FROM t WHERE " + " ".join(parts) + + result = sqlparse.format(sql, reindent=True) + assert "SELECT" in result + assert "BETWEEN" in result + + result_aligned = sqlparse.format(sql, reindent_aligned=True) + assert "SELECT" in result_aligned