diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index ca1e615a..00000000 --- a/.editorconfig +++ /dev/null @@ -1,20 +0,0 @@ -# http://editorconfig.org - -root = true - -[*] -indent_style = space -indent_size = 4 -end_of_line = lf -charset = utf-8 -insert_final_newline = true -trim_trailing_whitespace = true - -[*.{py,ini,yaml,yml,rst}] -indent_style = space -indent_size = 4 -continuation_indent_size = 4 -trim_trailing_whitespace = true - -[{Makefile,*.bat}] -indent_style = tab diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..b1ee021c --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,7 @@ +# Be nice to each other + +Everyone participating in the _sqlparse_ project and especially in the +issue tracker, discussion forums, pull requests, is expected to treat +other people with respect and more generally to follow the guidelines +articulated in the +[Python Community Code of Conduct](https://www.python.org/psf/codeofconduct/). \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..22844bfc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,26 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: 'bug,needs-triage' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior. +Please give code examples or concete SQL statements. Take care of not posting any sensitive information when pasting SQL statements! +What's the concrete error / traceback. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Versions (please complete the following information):** + - Python: [e.g. 3.11.2] + - sqlparse: [e.g. 0.4.1] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..03f62715 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Discussions, Questions? + url: https://github.com/andialbrecht/sqlparse/discussions + about: Please ask questions and start more general discussions here \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..bbcbbe7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..9252c97c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,19 @@ +# Thanks for contributing! + +Before submitting your pull request please have a look at the +following checklist: + +- [ ] ran the tests (`pytest`) +- [ ] all style issues addressed (`ruff`) +- [ ] your changes are covered by tests +- [ ] your changes are documented, if needed + +In addition, please take care to provide a proper description +on what your change does, fixes or achieves when submitting the +pull request. + +--- + +**Note:** This repository has automated AI code reviews enabled to help catch +potential issues early and provide suggestions. This is an experimental +feature to support maintainers and contributors – your feedback is welcome! \ No newline at end of file diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 00000000..b560fd65 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,71 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master ] + schedule: + - cron: '25 5 * * 1' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + # Learn more: + # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 00000000..67eff95a --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,63 @@ +# This workflow will install Python dependencies, run tests and lint using uv +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Python application + +on: + push: + branches: + - master + pull_request: + schedule: + - cron: '0 12 * * *' + +jobs: + test: + name: Run tests on Python ${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + 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 + + steps: + - uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@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' + run: | + uv run --group dev coverage run -m pytest tests/ + uv run --group dev coverage combine + uv run --group dev coverage xml + + - name: Publish to codecov + if: matrix.python-version == '3.11' && matrix.os == 'ubuntu-latest' + uses: codecov/codecov-action@v4 diff --git a/.gitignore b/.gitignore index e5953853..dd37cb6f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,11 @@ -# PyCharm -.idea/ - *.py[co] docs/build dist/ build/ MANIFEST .coverage -.tox/ .cache/ *.egg-info/ htmlcov/ -extras/appengine/sqlparse -extras/appengine/lib/ -extras/py3k/sqlparse -extras/py3k/tests -extras/py3k/sqlparse.diff -extras/py3k/tests.diff -coverage.xml -*.class -.pytest_cache \ No newline at end of file +.pytest_cache +.venv/ diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 00000000..5d908288 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,8 @@ +- id: sqlformat + name: sqlformat + description: Format SQL files using sqlparse + entry: sqlformat + language: python + types: [sql] + args: [--in-place, --reindent] + minimum_pre_commit_version: '2.9.0' diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..6dffd85a --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,35 @@ +# Read the Docs configuration file for Sphinx projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.12" + # You can also specify other tool versions: + # nodejs: "20" + # rust: "1.70" + # golang: "1.20" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/source/conf.py + # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs + # builder: "dirhtml" + # Fail on all warnings to avoid broken references + # fail_on_warning: true + +# Optionally build your docs in additional formats such as PDF and ePub +# formats: +# - pdf +# - epub + +# Optional but recommended, declare the Python requirements required +# to build your documentation +# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +# python: +# install: +# - requirements: docs/requirements.txt \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index cbf7bf9d..00000000 --- a/.travis.yml +++ /dev/null @@ -1,31 +0,0 @@ -language: python -python: - - "2.7" - - "3.4" - - "3.5" - - "3.6" - - "3.7" - - "3.8" - - "nightly" - - "pypy" - - "pypy3" - -matrix: - include: - - stage: codecheck - python: 3.7 - install: - - pip install flake8 - script: - - flake8 sqlparse - after_success: skip - -install: - - pip install -Uq pytest pytest-cov codecov - - pytest --version - -script: - - pytest --cov=sqlparse - -after_success: - - codecov diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..f490bb63 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,119 @@ +# AGENTS.md + +This file provides guidance to Agents when working with code in this repository. + +## Project Overview + +sqlparse is a non-validating SQL parser for Python that provides support for parsing, splitting, and formatting SQL statements. It's compatible with Python 3.10+ and supports multiple SQL dialects (Oracle, MySQL, PostgreSQL/PL/pgSQL, HQL, MS Access, Snowflake, BigQuery). + +## Development Commands + +This project uses `uv` for dependency and environment management. Common commands: + +### Testing +- Run all tests across Python versions: `make test` +- Run tests for specific Python version: `uv run --group dev --python 3.11 pytest tests/` +- Run single test file: `uv run --group dev --python 3.11 pytest tests/test_format.py` +- Run specific test: `uv run --group dev --python 3.11 pytest tests/test_format.py::test_name` +- Using Makefile: `make test` + +### Linting +- `uv run --group dev ruff check sqlparse/` or `make lint` + +### Coverage +- `make coverage` (runs tests with coverage and shows report) +- `make coverage-xml` (generates XML coverage report) + +### Building +- `python -m build` (builds distribution packages) + +## Architecture + +### Core Processing Pipeline + +The parsing and formatting workflow follows this sequence: + +1. **Lexing** (`sqlparse/lexer.py`): Tokenizes SQL text into `(token_type, value)` pairs using regex-based pattern matching +2. **Filtering** (`sqlparse/engine/filter_stack.py`): Processes token stream through a `FilterStack` with three stages: + - `preprocess`: Token-level filters + - `stmtprocess`: Statement-level filters + - `postprocess`: Final output filters +3. **Statement Splitting** (`sqlparse/engine/statement_splitter.py`): Splits token stream into individual SQL statements +4. **Grouping** (`sqlparse/engine/grouping.py`): Groups tokens into higher-level syntactic structures (parentheses, functions, identifiers, etc.) +5. **Formatting** (`sqlparse/formatter.py` + `sqlparse/filters/`): Applies formatting filters based on options + +### Token Hierarchy + +The token system is defined in `sqlparse/sql.py`: + +- `Token`: Base class with `value`, `ttype` (token type), and `parent` attributes +- `TokenList`: Group of tokens, base for all syntactic structures + - `Statement`: Top-level SQL statement + - `Identifier`: Table/column names, possibly with aliases + - `IdentifierList`: Comma-separated identifiers + - `Function`: Function calls with parameters + - `Parenthesis`, `SquareBrackets`: Bracketed expressions + - `Case`, `If`, `For`, `Begin`: Control structures + - `Where`, `Having`, `Over`: SQL clauses + - `Comparison`, `Operation`: Expressions + +All tokens maintain parent-child relationships for tree traversal. + +### Token Types + +Token types are defined in `sqlparse/tokens.py` and used for classification during lexing (e.g., `T.Keyword.DML`, `T.Name`, `T.Punctuation`). + +### Keywords and Lexer Configuration + +`sqlparse/keywords.py` contains: +- `SQL_REGEX`: List of regex patterns for tokenization +- Multiple `KEYWORDS_*` dictionaries for different SQL dialects +- The `Lexer` class uses a singleton pattern (`Lexer.get_default_instance()`) that can be configured with different keyword sets + +### Grouping Algorithm + +`sqlparse/engine/grouping.py` contains the grouping logic that transforms flat token lists into nested tree structures. Key functions: + +- `_group_matching()`: Groups tokens with matching open/close markers (parentheses, CASE/END, etc.) +- Various `group_*()` functions for specific constructs (identifiers, functions, comparisons, etc.) +- Includes DoS protection via `MAX_GROUPING_DEPTH` and `MAX_GROUPING_TOKENS` limits + +### Formatting Filters + +`sqlparse/filters/` contains various formatting filters: +- `reindent.py`: Indentation logic +- `aligned_indent.py`: Aligned indentation style +- `right_margin.py`: Line wrapping +- `tokens.py`: Token-level transformations (keyword case, etc.) +- `output.py`: Output format serialization (SQL, Python, PHP) +- `others.py`: Miscellaneous filters (strip comments, whitespace, etc.) + +## Public API + +The main entry points in `sqlparse/__init__.py`: + +- `parse(sql, encoding=None)`: Parse SQL into tuple of `Statement` objects +- `format(sql, encoding=None, **options)`: Format SQL with options (reindent, keyword_case, etc.) +- `split(sql, encoding=None, strip_semicolon=False)`: Split SQL into individual statement strings +- `parsestream(stream, encoding=None)`: Generator version of parse for file-like objects + +## Important Patterns + +### Token Traversal +- `token.flatten()`: Recursively yields all leaf tokens (ungrouped) +- `token_first()`, `token_next()`, `token_prev()`: Navigate token lists +- `token_next_by(i=, m=, t=)`: Find next token by instance type, match criteria, or token type +- `token.match(ttype, values, regex=False)`: Check if token matches criteria + +### Adding Keyword Support +Use `Lexer.add_keywords()` to extend the parser with new keywords for different SQL dialects. + +### DoS Prevention +Be aware of recursion limits and token count limits in grouping operations when handling untrusted SQL input. + +## Testing Conventions + +- Tests are in `tests/` directory +- Test files follow pattern `test_*.py` +- Uses pytest framework +- Test data often includes SQL strings with expected parsing/formatting results diff --git a/AUTHORS b/AUTHORS index 24f3fda1..872c7007 100644 --- a/AUTHORS +++ b/AUTHORS @@ -8,26 +8,40 @@ project: https://bitbucket.org/gutworth/six. Alphabetical list of contributors: * Adam Greenhall +* Adam Johnson +* Aki Ariga * Alexander Beedie * Alexey Malyshev +* alhudz +* ali-tny * andrew deryabin * Andrew Tipton * atronah * casey * Cauê Beloni +* Christian Clauss * circld * Corey Zumar * Cristian Orellana * Dag Wieers +* Daniel Harding * Darik Gamble * Demetrio92 * Dennis Taylor * Dvořák Václav +* Erik Cederstrand * Florian Bauer * Fredy Wijaya * Gavin Wahl +* Georg Traar +* griff <70294474+griffatrasgo@users.noreply.github.com> +* Hugo van Kemenade +* hurcy * Ian Robertson +* Igor Khrol * JacekPliszka +* JavierPan +* Jean-Martin Archer * Jesús Leganés Combarro "Piranna" * Johannes Hoff * John Bodley @@ -36,11 +50,13 @@ Alphabetical list of contributors: * Kevin Jing Qiu * koljonen * Likai Liu +* Long Le Xich * mathilde.oustlant * Michael Schuller * Mike Amy * mulos * Oleg Broytman +* osmnv <80402144+osmnv@users.noreply.github.com> * Patrick Schemitz * Pi Delport * Prudhvi Vatala @@ -51,17 +67,24 @@ Alphabetical list of contributors: * Rowan Seymour * Ryan Wooden * saaj +* Sergei Stropysh * Shen Longxing +* Simon Heisterkamp * Sjoerd Job Postmus +* skryzh * Soloman Weng * spigwitmer +* Stefan Warnat * Tao Wang * Tenghuan * Tim Graham +* tonghuaroot * Victor Hahn * Victor Uriarte * Ville Skyttä * vthriller * wayne.wuw +* Will Jones * William Ivanski * Yago Riveiro +* Zi-Xuan Fu diff --git a/CHANGELOG b/CHANGELOG index 0382f9af..edfedb4f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,7 +1,270 @@ Development Version ------------------- -Nothing yet. ¯\_(ツ)_/¯ +Notable Changes + +* Drop support for Python 3.8 and 3.9. Python 3.10+ is now required. +* Migrate project dependencies and environment management from `pixi` to `uv`. +* Replace `flake8` with `ruff` for code checking and linting. + +Security Fixes + +* Fix uncontrolled CPU consumption in ``TokenList`` grouping (CWE-1333, + patches by alhudz and tonghuaroot, pr848). Two independent + quadratic behaviours were fixed: + + - ``TokenList.__init__`` called ``str(self)`` which recursively flattened + the entire token subtree on every group construction. Deeply nested SQL + (parentheses, CASE WHEN, subqueries) therefore costs O(n·depth) CPU + before the depth cap fires — a ~2 KB payload could pin a worker for 10+ + seconds. Fix: join children's already-cached ``value`` fields directly, + reducing per-node cost to O(len(tokens)). + + - ``group_tokens()`` recomputed ``grp.value = str(start)`` on every extend + step, re-scanning the entire growing group each time. Wide flat queries + (e.g. SELECT with thousands of columns) therefore cost O(N²) in the + number of columns. Fix: append only the newly added tokens' values. + +Enhancements + +* Modernize type annotations in top-level API functions using PEP 585 and PEP 604 syntax. + +Bug Fixes + +* Fix statement splitting (issue845). +* Fix a late-binding closure bug in `TokenList.token_not_matching`. + + +Release 0.5.5 (Dec 19, 2025) +---------------------------- + +Bug Fixes + +* Fix DoS protection to raise SQLParseError instead of silently returning None + when grouping limits are exceeded (issue827). +* Fix splitting of BEGIN TRANSACTION statements (issue826). + + +Release 0.5.4 (Nov 28, 2025) +---------------------------- + +Enhancements + +* Add support for Python 3.14. +* Add type annotations to top-level API functions and include py.typed marker + for PEP 561 compliance, enabling type checking with mypy and other tools + (issue756). +* Add pre-commit hook support. sqlparse can now be used as a pre-commit hook + to automatically format SQL files. The CLI now supports multiple files and + an `--in-place` flag for in-place editing (issue537). +* Add `ATTACH` and `DETACH` to PostgreSQL keywords (pr808). +* Add `INTERSECT` to close keywords in WHERE clause (pr820). +* Support `REGEXP BINARY` comparison operator (pr817). + +Bug Fixes + +* Add additional protection against denial of service attacks when parsing + very large lists of tuples. This enhances the existing recursion protections + with configurable limits for token processing to prevent DoS through + algorithmic complexity attacks. The new limits (MAX_GROUPING_DEPTH=100, + MAX_GROUPING_TOKENS=10000) can be adjusted or disabled (by setting to None) + if needed for legitimate large SQL statements. +* Remove shebang from cli.py and remove executable flag (pr818). +* Fix strip_comments not removing all comments when input contains only + comments (issue801, pr803 by stropysh). +* Fix splitting statements with IF EXISTS/IF NOT EXISTS inside BEGIN...END + blocks (issue812). +* Fix splitting on semicolons inside BEGIN...END blocks (issue809). + + +Release 0.5.3 (Dez 10, 2024) +---------------------------- + +Bug Fixes + +* This version introduces a more generalized handling of potential denial of + service attack (DOS) due to recursion errors for deeply nested statements. + Brought up and fixed by @living180. Thanks a lot! + + +Release 0.5.2 (Nov 14, 2024) +---------------------------- + +Bug Fixes + +* EXTENSION is now recognized as a keyword (issue785). +* SQL hints are not removed when removing comments (issue262, by skryzh). + + +Release 0.5.1 (Jul 15, 2024) +---------------------------- + +Enhancements + +* New "compact" option for formatter. If set, the formatter tries to produce + a more compact output by avoiding some line breaks (issue783). + +Bug Fixes + +* The strip comments filter was a bit greedy and removed too much + whitespace (issue772). + Note: In some cases you might want to add `strip_whitespace=True` where you + previously used just `strip_comments=True`. `strip_comments` did some of the + work that `strip_whitespace` should do. +* Fix error when splitting statements that contain multiple CASE clauses + within a BEGIN block (issue784). +* Fix whitespace removal with nested expressions (issue782). +* Fix parsing and formatting of ORDER clauses containing NULLS FIRST or + NULLS LAST (issue532). + + +Release 0.5.0 (Apr 13, 2024) +---------------------------- + +Notable Changes + +* Drop support for Python 3.5, 3.6, and 3.7. +* Python 3.12 is now supported (pr725, by hugovk). +* IMPORTANT: Fixes a potential denial of service attack (DOS) due to recursion + error for deeply nested statements. Instead of recursion error a generic + SQLParseError is raised. See the security advisory for details: + https://github.com/andialbrecht/sqlparse/security/advisories/GHSA-2m57-hf25-phgg + The vulnerability was discovered by @uriyay-jfrog. Thanks for reporting! + +Enhancements + +* Splitting statements now allows to remove the semicolon at the end. + Some database backends love statements without semicolon (issue742). +* Support TypedLiterals in get_parameters (pr749, by Khrol). +* Improve splitting of Transact SQL when using GO keyword (issue762). +* Support for some JSON operators (issue682). +* Improve formatting of statements containing JSON operators (issue542). +* Support for BigQuery and Snowflake keywords (pr699, by griffatrasgo). +* Support parsing of OVER clause (issue701, pr768 by r33s3n6). + +Bug Fixes + +* Ignore dunder attributes when creating Tokens (issue672). +* Allow operators to precede dollar-quoted strings (issue763). +* Fix parsing of nested order clauses (issue745, pr746 by john-bodley). +* Thread-safe initialization of Lexer class (issue730). +* Classify TRUNCATE as DDL and GRANT/REVOKE as DCL keywords (based on pr719 + by josuc1, thanks for bringing this up!). +* Fix parsing of PRIMARY KEY (issue740). + +Other + +* Optimize performance of matching function (pr799, by admachainz). + + +Release 0.4.4 (Apr 18, 2023) +---------------------------- + +Notable Changes + +* IMPORTANT: This release fixes a security vulnerability in the + parser where a regular expression vulnerable to ReDOS (Regular + Expression Denial of Service) was used. See the security advisory + for details: https://github.com/andialbrecht/sqlparse/security/advisories/GHSA-rrm6-wvj7-cwh2 + The vulnerability was discovered by @erik-krogh from GitHub + Security Lab (GHSL). Thanks for reporting! + +Bug Fixes + +* Revert a change from 0.4.0 that changed IN to be a comparison (issue694). + The primary expectation is that IN is treated as a keyword and not as a + comparison operator. That also follows the definition of reserved keywords + for the major SQL syntax definitions. +* Fix regular expressions for string parsing. + +Other + +* sqlparse now uses pyproject.toml instead of setup.cfg (issue685). + + +Release 0.4.3 (Sep 23, 2022) +---------------------------- + +Enhancements + +* Add support for DIV operator (pr664, by chezou). +* Add support for additional SPARK keywords (pr643, by mrmasterplan). +* Avoid tokens copy (pr622, by living180). +* Add REGEXP as a comparision (pr647, by PeterSandwich). +* Add DISTINCTROW keyword for MS Access (issue677). +* Improve parsing of CREATE TABLE AS SELECT (pr662, by chezou). + +Bug Fixes + +* Fix spelling of INDICATOR keyword (pr653, by ptld). +* Fix formatting error in EXTRACT function (issue562, issue670, pr676, by ecederstrand). +* Fix bad parsing of create table statements that use lower case (issue217, pr642, by mrmasterplan). +* Handle backtick as valid quote char (issue628, pr629, by codenamelxl). +* Allow any unicode character as valid identifier name (issue641). + +Other + +* Update github actions to test on Python 3.10 as well (pr661, by cclaus). + + +Release 0.4.2 (Sep 10, 2021) +---------------------------- + +Notable Changes + +* IMPORTANT: This release fixes a security vulnerability in the + strip comments filter. In this filter a regular expression that was + vulnerable to ReDOS (Regular Expression Denial of Service) was + used. See the security advisory for details: https://github.com/andialbrecht/sqlparse/security/advisories/GHSA-p5w8-wqhj-9hhf + The vulnerability was discovered by @erik-krogh and @yoff from + GitHub Security Lab (GHSL). Thanks for reporting! + +Enhancements + +* Add ELSIF as keyword (issue584). +* Add CONFLICT and ON_ERROR_STOP keywords (pr595, by j-martin). + +Bug Fixes + +* Fix parsing of backticks (issue588). +* Fix parsing of scientific number (issue399). + + +Release 0.4.1 (Oct 08, 2020) +---------------------------- + +Bug Fixes + +* Just removed a debug print statement, sorry... + + +Release 0.4.0 (Oct 07, 2020) +---------------------------- + +Notable Changes + +* Remove support for end-of-life Python 2.7 and 3.4. Python 3.5+ is now + required. +* Remaining strings that only consist of whitespaces are not treated as + statements anymore. Code that ignored the last element from + sqlparse.split() should be updated accordingly since that function + now doesn't return an empty string as the last element in some + cases (issue496). + +Enhancements + +* Add WINDOW keyword (pr579 by ali-tny). +* Add RLIKE keyword (pr582 by wjones1). + +Bug Fixes + +* Improved parsing of IN(...) statements (issue566, pr567 by hurcy). +* Preserve line breaks when removing comments (issue484). +* Fix parsing error when using square bracket notation (issue583). +* Fix splitting when using DECLARE ... HANDLER (issue581). +* Fix splitting of statements using CASE ... WHEN (issue580). +* Improve formatting of type casts in parentheses. +* Stabilize formatting of invalid SQL statements. Release 0.3.1 (Feb 29, 2020) @@ -24,7 +287,7 @@ Bug Fixes * Remove support for parsing double slash comments introduced in 0.3.0 (issue456) as it had some side-effects with other dialects and doesn't seem to be widely used (issue476). -* Restrict detection of alias names to objects that acutally could +* Restrict detection of alias names to objects that actually could have an alias (issue455, adopted some parts of pr509 by john-bodley). * Fix parsing of date/time literals (issue438, by vashek). * Fix initialization of TokenList (issue499, pr505 by john-bodley). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..3db0f110 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,11 @@ +# Contributing to `sqlparse` + +Thanks for your interest in contributing to the `sqlparse` project! + +All contributors are expected to follow the +[Python Community Code of Conduct](https://www.python.org/psf/codeofconduct/). + +Head over to the +[Discussions Page](https://github.com/andialbrecht/sqlparse/discussions) if +you have any questions. We're still working on a more elaborate +developer guide. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 8043b359..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,11 +0,0 @@ -recursive-include docs source/* -include docs/sqlformat.1 -include docs/Makefile -recursive-include tests *.py *.sql -include LICENSE -include TODO -include AUTHORS -include CHANGELOG -include Makefile -include setup.cfg -include tox.ini diff --git a/Makefile b/Makefile index ee35e546..0d3db3aa 100644 --- a/Makefile +++ b/Makefile @@ -10,10 +10,24 @@ help: @sed -n '/^[a-zA-Z0-9_.]*:/s/:.*//p' = 3.4) -and released under the terms of the `New BSD license -`_. - -.. note:: - - Support for Python<3.4 (including 2.x) will be dropped soon. +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. @@ -50,6 +47,34 @@ Quick Start [, , >> +Pre-commit Hook +--------------- + +sqlparse can be used as a `pre-commit `_ hook +to automatically format SQL files before committing: + +.. code-block:: yaml + + repos: + - repo: https://github.com/andialbrecht/sqlparse + rev: 0.5.4 # Use the latest version + hooks: + - id: sqlformat + # Optional: Add more formatting options + # IMPORTANT: --in-place is required, already included by default + args: [--in-place, --reindent, --keywords, upper] + +Then install the hook: + +.. code-block:: sh + + $ pre-commit install + +Your SQL files will now be automatically formatted on each commit. + +**Note**: The hook uses ``--in-place --reindent`` by default. If you override +the ``args``, you **must** include ``--in-place`` for the hook to work. + Links ----- @@ -63,7 +88,7 @@ Documentation https://sqlparse.readthedocs.io/ Online Demo - https://sqlformat.org/ + https://sqlformat.org/ sqlparse is licensed under the BSD license. @@ -71,7 +96,11 @@ 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://secure.travis-ci.org/andialbrecht/sqlparse.png?branch=master -.. _buildstatus: https://travis-ci.org/#!/andialbrecht/sqlparse -.. |coverage| image:: https://coveralls.io/repos/andialbrecht/sqlparse/badge.svg?branch=master&service=github -.. _coverage: https://coveralls.io/github/andialbrecht/sqlparse?branch=master +.. |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/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..81c01543 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +## Supported Versions + +For now `sqlparse` uses very defensive version numbers. There's no major version yet. +In turn there's only one supported version and this is the latest. + +## Reporting a Vulnerability + +To report a vulnerability head over to the [Security Advisories](https://github.com/andialbrecht/sqlparse/security/advisories) +page and click on "New draft security advisory". + +Feel free to contact me at albrecht.andi@gmail.com if you have any questions or want to discuss things +beforehand. diff --git a/benchmarks/bench_grouping.py b/benchmarks/bench_grouping.py new file mode 100644 index 00000000..245ea0e2 --- /dev/null +++ b/benchmarks/bench_grouping.py @@ -0,0 +1,59 @@ +"""Grouping performance benchmarks. + +Measures parse time for SQL patterns that stress the grouping engine: +- Deeply nested parentheses +- Deeply nested CASE WHEN expressions +- Wide column lists (tests O(N) identifier grouping, fixed in PR848) + +Run with: python benchmarks/bench_grouping.py +""" + +import signal +import time + +import sqlparse + + +def _alarm_handler(signum, frame): + raise TimeoutError() + + +signal.signal(signal.SIGALRM, _alarm_handler) + + +def measure(label, sql, fn): + signal.alarm(30) + t0 = time.perf_counter() + status = 'OK' + try: + fn(sql) + except sqlparse.exceptions.SQLParseError: + status = 'CAP' + except TimeoutError: + status = 'TIMEOUT' + finally: + signal.alarm(0) + dt = (time.perf_counter() - t0) * 1000 + print(f' {status:8} {dt:8.1f} ms {label} ({len(sql)} B)') + + +# Vector 1: deeply nested parentheses +print('Nested parentheses:') +for n in (200, 500, 1000, 2000): + sql = 'SELECT ' + '(' * n + '1' + ')' * n + measure(f'nested-paren n={n}', sql, sqlparse.parse) + +# Vector 2: deeply nested CASE WHEN +print('Nested CASE WHEN:') +for n in (100, 200, 400): + case = '1' + for i in range(n): + case = f'CASE WHEN x={i} THEN {case} ELSE NULL END' + measure(f'CASE-nested n={n}', f'SELECT {case} FROM t', sqlparse.parse) + +# Vector 3: wide column lists (O(N) grouping, regression fixed in PR848) +print('Wide column lists:') +for n in (500, 1000, 2000, 4000): + cols = ', '.join(f'col_{i}' for i in range(n)) + sql = f'SELECT {cols} FROM t' + measure(f'wide-select n={n}', sql, sqlparse.parse) diff --git a/docs/source/api.rst b/docs/source/api.rst index 40193d0b..e3458930 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -62,6 +62,9 @@ The :meth:`~sqlparse.format` function accepts the following keyword arguments. The column limit (in characters) for wrapping comma-separated lists. If unspecified, it puts every item in the list on its own line. +``compact`` + If ``True`` the formatter tries to produce more compact output. + ``output_format`` If given the output is additionally formatted to be used as a variable in a programming language. Allowed values are "python" and "php". @@ -69,4 +72,40 @@ The :meth:`~sqlparse.format` function accepts the following keyword arguments. ``comma_first`` If ``True`` comma-first notation for column names is used. - + +Security and Performance Considerations +--------------------------------------- + +For developers working with very large SQL statements or in security-sensitive +environments, sqlparse includes built-in protections against potential denial +of service (DoS) attacks: + +**Grouping Limits** + The parser includes configurable limits to prevent excessive resource + consumption when processing very large or deeply nested SQL structures: + + - ``MAX_GROUPING_DEPTH`` (default: 100) - Limits recursion depth during token grouping + - ``MAX_GROUPING_TOKENS`` (default: 10,000) - Limits the number of tokens processed in a single grouping operation + + These limits can be modified by changing the constants in ``sqlparse.engine.grouping`` + if your application requires processing larger SQL statements. Set a limit to ``None`` + to completely disable it. However, increasing these values or disabling limits may + expose your application to DoS vulnerabilities when processing untrusted SQL input. + + Example of modifying limits:: + + import sqlparse.engine.grouping + + # Increase limits (use with caution) + sqlparse.engine.grouping.MAX_GROUPING_DEPTH = 200 + sqlparse.engine.grouping.MAX_GROUPING_TOKENS = 50000 + + # Disable limits completely (use with extreme caution) + sqlparse.engine.grouping.MAX_GROUPING_DEPTH = None + sqlparse.engine.grouping.MAX_GROUPING_TOKENS = None + +.. warning:: + Increasing the grouping limits or disabling them completely may make your + application vulnerable to DoS attacks when processing untrusted SQL input. + Only modify these values if you are certain about the source and size of + your SQL statements. diff --git a/docs/source/conf.py b/docs/source/conf.py index 70bd69a5..a5be6409 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # python-sqlparse documentation build configuration file, created by # sphinx-quickstart on Thu Feb 26 08:19:28 2009. # diff --git a/docs/source/extending.rst b/docs/source/extending.rst new file mode 100644 index 00000000..866303b7 --- /dev/null +++ b/docs/source/extending.rst @@ -0,0 +1,76 @@ +Extending :mod:`sqlparse` +========================= + +.. module:: sqlparse + :synopsis: Extending parsing capability of sqlparse. + +The :mod:`sqlparse` module uses a sql grammar that was tuned through usage and numerous +PR to fit a broad range of SQL syntaxes, but it cannot cater to every given case since +some SQL dialects have adopted conflicting meanings of certain keywords. Sqlparse +therefore exposes a mechanism to configure the fundamental keywords and regular +expressions that parse the language as described below. + +If you find an adaptation that works for your specific use-case. Please consider +contributing it back to the community by opening a PR on +`GitHub `_. + +Configuring the Lexer +--------------------- + +The lexer is a singleton class that breaks down the stream of characters into language +tokens. It does this by using a sequence of regular expressions and keywords that are +listed in the file ``sqlparse.keywords``. Instead of applying these fixed grammar +definitions directly, the lexer is default initialized in its method called +``default_initialization()``. As an api user, you can adapt the Lexer configuration by +applying your own configuration logic. To do so, start out by clearing previous +configurations with ``.clear()``, then apply the SQL list with +``.set_SQL_REGEX(SQL_REGEX)``, and apply keyword lists with ``.add_keywords(KEYWORDS)``. + +You can do so by re-using the expressions in ``sqlparse.keywords`` (see example below), +leaving parts out, or by making up your own master list. + +See the expected types of the arguments by inspecting their structure in +``sqlparse.keywords``. +(For compatibility with python 3.4, this library does not use type-hints.) + +The following example adds support for the expression ``ZORDER BY``, and adds ``BAR`` as +a keyword to the lexer: + +.. code-block:: python + + import re + + import sqlparse + from sqlparse import keywords + from sqlparse.lexer import Lexer + + # get the lexer singleton object to configure it + lex = Lexer.get_default_instance() + + # Clear the default configurations. + # After this call, reg-exps and keyword dictionaries need to be loaded + # to make the lexer functional again. + lex.clear() + + my_regex = (r"ZORDER\s+BY\b", sqlparse.tokens.Keyword) + + # slice the default SQL_REGEX to inject the custom object + lex.set_SQL_REGEX( + keywords.SQL_REGEX[:38] + + [my_regex] + + keywords.SQL_REGEX[38:] + ) + + # add the default keyword dictionaries + lex.add_keywords(keywords.KEYWORDS_COMMON) + lex.add_keywords(keywords.KEYWORDS_ORACLE) + lex.add_keywords(keywords.KEYWORDS_PLPGSQL) + lex.add_keywords(keywords.KEYWORDS_HQL) + lex.add_keywords(keywords.KEYWORDS_MSACCESS) + lex.add_keywords(keywords.KEYWORDS) + + # add a custom keyword dictionary + lex.add_keywords({'BAR': sqlparse.tokens.Keyword}) + + # no configuration is passed here. The lexer is used as a singleton. + sqlparse.parse("select * from foo zorder by bar;") diff --git a/docs/source/index.rst b/docs/source/index.rst index cba33141..e18d2b3c 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -20,6 +20,7 @@ Contents api analyzing ui + extending changes license indices diff --git a/docs/source/intro.rst b/docs/source/intro.rst index 330184a0..1d3c9498 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -120,7 +120,7 @@ To check out the latest sources of this module run to check out the latest sources from the repository. -:mod:`sqlparse` is currently tested under Python 2.7, >=3.4 and pypy. Tests are +:mod:`sqlparse` is currently tested under Python 3.5+ and PyPy. Tests are automatically run on each commit and for each pull request on Travis: https://travis-ci.org/andialbrecht/sqlparse diff --git a/docs/source/ui.rst b/docs/source/ui.rst index dcaeba13..e794f36d 100644 --- a/docs/source/ui.rst +++ b/docs/source/ui.rst @@ -2,10 +2,68 @@ User Interfaces =============== ``sqlformat`` - The ``sqlformat`` command line script ist distributed with the module. + The ``sqlformat`` command line script is distributed with the module. Run :command:`sqlformat --help` to list available options and for usage hints. +Pre-commit Hook +^^^^^^^^^^^^^^^^ + +``sqlparse`` can be integrated with `pre-commit `_ +to automatically format SQL files before committing them to version control. + +To use it, add the following to your ``.pre-commit-config.yaml``: + +.. code-block:: yaml + + repos: + - repo: https://github.com/andialbrecht/sqlparse + rev: 0.5.4 # Replace with the version you want to use + hooks: + - id: sqlformat + +The hook will format your SQL files with basic indentation (``--reindent``) by default. + +To customize formatting options, override the ``args`` parameter: + +.. code-block:: yaml + + repos: + - repo: https://github.com/andialbrecht/sqlparse + rev: 0.5.4 + hooks: + - id: sqlformat + args: [--in-place, --reindent, --keywords, upper, --identifiers, lower] + +.. important:: + + When overriding ``args``, you **must include** ``--in-place`` as the first + argument, otherwise the hook will not modify your files. + +Common formatting options include: + +* ``--in-place``: Required - modify files in-place (always include this!) +* ``--reindent`` or ``-r``: Reindent statements +* ``--keywords upper`` or ``-k upper``: Convert keywords to uppercase +* ``--identifiers lower`` or ``-i lower``: Convert identifiers to lowercase +* ``--indent_width 4``: Set indentation width to 4 spaces +* ``--strip-comments``: Remove comments from SQL + +Run ``sqlformat --help`` for a complete list of formatting options. + +After adding the configuration, install the pre-commit hooks: + +.. code-block:: bash + + pre-commit install + +The hook will now run automatically before each commit. You can also run +it manually on all files: + +.. code-block:: bash + + pre-commit run sqlformat --all-files + ``sqlformat.appspot.com`` An example `Google App Engine `_ application that exposes the formatting features using a web front-end. diff --git a/examples/column_defs_lowlevel.py b/examples/column_defs_lowlevel.py index 6bd5072f..5b43945c 100644 --- a/examples/column_defs_lowlevel.py +++ b/examples/column_defs_lowlevel.py @@ -1,7 +1,6 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This example is part of python-sqlparse and is released under @@ -28,7 +27,7 @@ def extract_definitions(token_list): if par_level == 0: break else: - par_level += 1 + par_level -= 1 elif token.match(sqlparse.tokens.Punctuation, ','): if tmp: definitions.append(tmp) diff --git a/examples/extract_table_names.py b/examples/extract_table_names.py index 8f0065e1..9837806f 100644 --- a/examples/extract_table_names.py +++ b/examples/extract_table_names.py @@ -1,7 +1,6 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This example is part of python-sqlparse and is released under @@ -32,8 +31,7 @@ def extract_from_part(parsed): for item in parsed.tokens: if from_seen: if is_subselect(item): - for x in extract_from_part(item): - yield x + yield from extract_from_part(item) elif item.ttype is Keyword: return else: @@ -67,4 +65,4 @@ def extract_tables(sql): """ tables = ', '.join(extract_tables(sql)) - print('Tables: {0}'.format(tables)) + print('Tables: {}'.format(tables)) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..ba298599 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,114 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "sqlparse" +description = "A non-validating SQL parser." +authors = [{name = "Andi Albrecht", email = "albrecht.andi@gmail.com"}] +readme = "README.rst" +dynamic = ["version"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Database", + "Topic :: Software Development", + ] +requires-python = ">=3.10" + +[project.urls] +Home = "https://github.com/andialbrecht/sqlparse" +Documentation = "https://sqlparse.readthedocs.io/" +"Release Notes" = "https://sqlparse.readthedocs.io/en/latest/changes.html" +Source = "https://github.com/andialbrecht/sqlparse" +Tracker = "https://github.com/andialbrecht/sqlparse/issues" + +[project.scripts] +sqlformat = "sqlparse.__main__:main" + +[project.optional-dependencies] +dev = [ + "build", +] +doc = [ + "sphinx", +] + +[tool.hatch.version] +path = "sqlparse/__init__.py" + +[tool.coverage.run] +source_pkgs = ["sqlparse", "tests"] +branch = true +parallel = true +omit = [ + "sqlparse/__main__.py", +] + +[tool.coverage.paths] +sqlparse = ["sqlparse"] +tests = ["tests"] + +[tool.coverage.report] +exclude_lines = [ + "no cov", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] +[dependency-groups] +dev = [ + "pytest", + "coverage", + "ruff", + "build", + "sphinx", +] + +[tool.ruff] +target-version = "py310" +exclude = [ + "dist", + "docs", + "tests", +] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # Pyflakes + "W", # pycodestyle warnings + "C90", # mccabe complexity + "B", # flake8-bugbear (bugs/design issues) + "I", # isort (import sorting) + "UP", # pyupgrade (modern python syntax) + "SIM", # flake8-simplify (simpler code) + "RUF", # Ruff-specific rules +] +ignore = [ + "RUF001", # Ambiguous unicode character (acute accent is used intentionally for quoting) + "RUF012", # Mutable default value for class attribute (used for SQL matching patterns) + "B904", # raise ... from ... (not required for simple custom exceptions) + "E501", # Line too long (managed by formatting choices rather than strict rules) + "SIM108", # Use ternary operator instead of if-else block (traditional if-else is preferred for readability) + "RUF005", # Consider iterable unpacking instead of concatenation + "RUF059", # Unused unpacked variables (retains standard unpacking signatures) + "SIM102", # Nested if statements (sometimes preferred for logic separation) + "SIM115", # Use a context manager for opening files (CLI streams are opened dynamically) +] + +[tool.ruff.lint.mccabe] +max-complexity = 31 + + diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index c4dee1df..00000000 --- a/setup.cfg +++ /dev/null @@ -1,20 +0,0 @@ -[bdist_wheel] -universal = 1 - -[metadata] -license_file = LICENSE - -[tool:pytest] -xfail_strict = True - -[flake8] -exclude = - sqlparse/compat.py -ignore = - W503, - E731 - -[coverage:run] -branch = False -omit = - sqlparse/__main__.py diff --git a/setup.py b/setup.py deleted file mode 100644 index 3de94e7b..00000000 --- a/setup.py +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# -# Copyright (C) 2009-2018 the sqlparse authors and contributors -# -# -# This setup script is part of python-sqlparse and is released under -# the BSD License: https://opensource.org/licenses/BSD-3-Clause - -import re - -from setuptools import setup, find_packages - - -def get_version(): - """Parse __init__.py for version number instead of importing the file.""" - VERSIONFILE = 'sqlparse/__init__.py' - VSRE = r'^__version__ = [\'"]([^\'"]*)[\'"]' - with open(VERSIONFILE) as f: - verstrline = f.read() - mo = re.search(VSRE, verstrline, re.M) - if mo: - return mo.group(1) - raise RuntimeError('Unable to find version in {fn}'.format(fn=VERSIONFILE)) - - -LONG_DESCRIPTION = """ -``sqlparse`` is a non-validating SQL parser module. -It provides support for parsing, splitting and formatting SQL statements. - -Visit the `project page `_ for -additional information and documentation. - -**Example Usage** - - -Splitting SQL statements:: - - >>> import sqlparse - >>> sqlparse.split('select * from foo; select * from bar;') - [u'select * from foo; ', u'select * from bar;'] - - -Formatting statements:: - - >>> sql = 'select * from foo where id in (select id from bar);' - >>> print(sqlparse.format(sql, reindent=True, keyword_case='upper')) - SELECT * - FROM foo - WHERE id IN - (SELECT id - FROM bar); - - -Parsing:: - - >>> sql = 'select * from someschema.mytable where id = 1' - >>> res = sqlparse.parse(sql) - >>> res - (,) - >>> stmt = res[0] - >>> str(stmt) # converting it back to unicode - 'select * from someschema.mytable where id = 1' - >>> # This is how the internal representation looks like: - >>> stmt.tokens - (, - , - , - , - , - , - , - , - ) - -""" - -setup( - name='sqlparse', - version=get_version(), - author='Andi Albrecht', - author_email='albrecht.andi@gmail.com', - url='https://github.com/andialbrecht/sqlparse', - description='Non-validating SQL parser', - long_description=LONG_DESCRIPTION, - license='BSD', - python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Topic :: Database', - 'Topic :: Software Development', - ], - packages=find_packages(exclude=('tests',)), - entry_points={ - 'console_scripts': [ - 'sqlformat = sqlparse.__main__:main', - ] - }, -) diff --git a/sqlparse/__init__.py b/sqlparse/__init__.py index b4750618..3615924b 100644 --- a/sqlparse/__init__.py +++ b/sqlparse/__init__.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This module is part of python-sqlparse and is released under @@ -9,20 +8,18 @@ """Parse SQL statements.""" # Setup namespace -from sqlparse import sql -from sqlparse import cli -from sqlparse import engine -from sqlparse import tokens -from sqlparse import filters -from sqlparse import formatter +from collections.abc import Generator +from typing import IO, Any -from sqlparse.compat import text_type +from sqlparse import cli, engine, filters, formatter, sql, tokens -__version__ = '0.3.2.dev0' -__all__ = ['engine', 'filters', 'formatter', 'sql', 'tokens', 'cli'] +__version__ = "0.5.6.dev0" +__all__ = ["cli", "engine", "filters", "formatter", "sql", "tokens"] -def parse(sql, encoding=None): +def parse( + sql: str, encoding: str | None = None +) -> tuple[sql.Statement, ...]: """Parse sql and return a list of statements. :param sql: A string containing one or more SQL statements. @@ -32,7 +29,9 @@ def parse(sql, encoding=None): return tuple(parsestream(sql, encoding)) -def parsestream(stream, encoding=None): +def parsestream( + stream: str | IO[str], encoding: str | None = None +) -> Generator[sql.Statement, None, None]: """Parses sql statements from file-like object. :param stream: A file-like object. @@ -44,7 +43,7 @@ def parsestream(stream, encoding=None): return stack.run(stream, encoding) -def format(sql, encoding=None, **options): +def format(sql: str, encoding: str | None = None, **options: Any) -> str: """Format *sql* according to *options*. Available options are documented in :ref:`formatting`. @@ -58,15 +57,19 @@ def format(sql, encoding=None, **options): options = formatter.validate_options(options) stack = formatter.build_filter_stack(stack, options) stack.postprocess.append(filters.SerializerUnicode()) - return u''.join(stack.run(sql, encoding)) + return "".join(stack.run(sql, encoding)) -def split(sql, encoding=None): +def split( + sql: str, encoding: str | None = None, strip_semicolon: bool = False +) -> list[str]: """Split *sql* into single statements. :param sql: A string containing one or more SQL statements. :param encoding: The encoding of the statement (optional). + :param strip_semicolon: If True, remove trailing semicolons + (default: False). :returns: A list of strings. """ - stack = engine.FilterStack() - return [text_type(stmt).strip() for stmt in stack.run(sql, encoding)] + stack = engine.FilterStack(strip_semicolon=strip_semicolon) + return [str(stmt).strip() for stmt in stack.run(sql, encoding)] diff --git a/sqlparse/__main__.py b/sqlparse/__main__.py index 867d75d5..2bf25132 100644 --- a/sqlparse/__main__.py +++ b/sqlparse/__main__.py @@ -1,7 +1,6 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This module is part of python-sqlparse and is released under diff --git a/sqlparse/cli.py b/sqlparse/cli.py old mode 100755 new mode 100644 index 25555a59..03ee86e3 --- a/sqlparse/cli.py +++ b/sqlparse/cli.py @@ -1,7 +1,4 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This module is part of python-sqlparse and is released under @@ -23,10 +20,8 @@ import argparse import sys from io import TextIOWrapper -from codecs import open, getreader import sqlparse -from sqlparse.compat import PY2 from sqlparse.exceptions import SQLParseError @@ -39,10 +34,13 @@ def create_parser(): prog='sqlformat', description='Format FILE according to OPTIONS. Use "-" as FILE ' 'to read from stdin.', - usage='%(prog)s [OPTIONS] FILE, ...', + usage='%(prog)s [OPTIONS] FILE [FILE ...]', ) - parser.add_argument('filename') + parser.add_argument( + 'filename', + nargs='+', + help='file(s) to format (use "-" for stdin)') parser.add_argument( '-o', '--outfile', @@ -50,6 +48,13 @@ def create_parser(): metavar='FILE', help='write output to FILE (defaults to stdout)') + parser.add_argument( + '--in-place', + dest='inplace', + action='store_true', + default=False, + help='format files in-place (overwrite existing files)') + parser.add_argument( '--version', action='version', @@ -62,16 +67,16 @@ def create_parser(): metavar='CHOICE', dest='keyword_case', choices=_CASE_CHOICES, - help='change case of keywords, CHOICE is one of {0}'.format( - ', '.join('"{0}"'.format(x) for x in _CASE_CHOICES))) + help='change case of keywords, CHOICE is one of {}'.format( + ', '.join(f'"{x}"' for x in _CASE_CHOICES))) group.add_argument( '-i', '--identifiers', metavar='CHOICE', dest='identifier_case', choices=_CASE_CHOICES, - help='change case of identifiers, CHOICE is one of {0}'.format( - ', '.join('"{0}"'.format(x) for x in _CASE_CHOICES))) + help='change case of identifiers, CHOICE is one of {}'.format( + ', '.join(f'"{x}"' for x in _CASE_CHOICES))) group.add_argument( '-l', '--language', @@ -142,6 +147,13 @@ def create_parser(): type=bool, help='Insert linebreak before comma (default False)') + group.add_argument( + '--compact', + dest='compact', + default=False, + type=bool, + help='Try to produce more compact output (default False)') + group.add_argument( '--encoding', dest='encoding', @@ -153,46 +165,56 @@ def create_parser(): def _error(msg): """Print msg and optionally exit with return code exit_.""" - sys.stderr.write(u'[ERROR] {0}\n'.format(msg)) + sys.stderr.write(f'[ERROR] {msg}\n') return 1 -def main(args=None): - parser = create_parser() - args = parser.parse_args(args) +def _process_file(filename, args): + """Process a single file with the given formatting options. + + Returns 0 on success, 1 on error. + """ + # Check for incompatible option combinations first + if filename == '-' and args.inplace: + return _error('Cannot use --in-place with stdin') - if args.filename == '-': # read from stdin - if PY2: - data = getreader(args.encoding)(sys.stdin).read() - else: - wrapper = TextIOWrapper(sys.stdin.buffer, encoding=args.encoding) - try: - data = wrapper.read() - finally: - wrapper.detach() + # Read input + if filename == '-': # read from stdin + wrapper = TextIOWrapper(sys.stdin.buffer, encoding=args.encoding) + try: + data = wrapper.read() + finally: + wrapper.detach() else: try: - with open(args.filename, 'r', args.encoding) as f: + with open(filename, encoding=args.encoding) as f: data = ''.join(f.readlines()) - except IOError as e: - return _error( - u'Failed to read {0}: {1}'.format(args.filename, e)) + except OSError as e: + return _error(f'Failed to read {filename}: {e}') + # Determine output destination close_stream = False - if args.outfile: + if args.inplace: try: - stream = open(args.outfile, 'w', args.encoding) + stream = open(filename, 'w', encoding=args.encoding) close_stream = True - except IOError as e: - return _error(u'Failed to open {0}: {1}'.format(args.outfile, e)) + except OSError as e: + return _error(f'Failed to open {filename}: {e}') + elif args.outfile: + try: + stream = open(args.outfile, 'w', encoding=args.encoding) + close_stream = True + except OSError as e: + return _error(f'Failed to open {args.outfile}: {e}') else: stream = sys.stdout + # Format the SQL formatter_opts = vars(args) try: formatter_opts = sqlparse.formatter.validate_options(formatter_opts) except SQLParseError as e: - return _error(u'Invalid options: {0}'.format(e)) + return _error(f'Invalid options: {e}') s = sqlparse.format(data, **formatter_opts) stream.write(s) @@ -200,3 +222,25 @@ def main(args=None): if close_stream: stream.close() return 0 + + +def main(args=None): + parser = create_parser() + args = parser.parse_args(args) + + # Validate argument combinations + if len(args.filename) > 1: + if args.outfile: + return _error('Cannot use -o/--outfile with multiple files') + if not args.inplace: + return _error('Multiple files require --in-place flag') + + # Process all files + exit_code = 0 + for filename in args.filename: + result = _process_file(filename, args) + if result != 0: + exit_code = result + # Continue processing remaining files even if one fails + + return exit_code diff --git a/sqlparse/compat.py b/sqlparse/compat.py deleted file mode 100644 index d2214bed..00000000 --- a/sqlparse/compat.py +++ /dev/null @@ -1,45 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright (C) 2009-2018 the sqlparse authors and contributors -# -# -# This module is part of python-sqlparse and is released under -# the BSD License: https://opensource.org/licenses/BSD-3-Clause - -"""Python 2/3 compatibility. - -This module only exists to avoid a dependency on six -for very trivial stuff. We only need to take care of -string types, buffers and metaclasses. - -Parts of the code is copied directly from six: -https://bitbucket.org/gutworth/six -""" - -import sys -from io import TextIOBase - -PY2 = sys.version_info[0] == 2 -PY3 = sys.version_info[0] == 3 - - -if PY3: - def unicode_compatible(cls): - return cls - - text_type = str - string_types = (str,) - from io import StringIO - file_types = (StringIO, TextIOBase) - - -elif PY2: - def unicode_compatible(cls): - cls.__unicode__ = cls.__str__ - cls.__str__ = lambda x: x.__unicode__().encode('utf-8') - return cls - - text_type = unicode - string_types = (str, unicode,) - from StringIO import StringIO - file_types = (file, StringIO, TextIOBase) diff --git a/sqlparse/engine/__init__.py b/sqlparse/engine/__init__.py index 0b3f3eb5..5b6c56da 100644 --- a/sqlparse/engine/__init__.py +++ b/sqlparse/engine/__init__.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This module is part of python-sqlparse and is released under @@ -11,7 +10,7 @@ from sqlparse.engine.statement_splitter import StatementSplitter __all__ = [ - 'grouping', 'FilterStack', 'StatementSplitter', + 'grouping', ] diff --git a/sqlparse/engine/filter_stack.py b/sqlparse/engine/filter_stack.py index fc77fd64..415d3fc9 100644 --- a/sqlparse/engine/filter_stack.py +++ b/sqlparse/engine/filter_stack.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This module is part of python-sqlparse and is released under @@ -11,35 +10,42 @@ from sqlparse import lexer from sqlparse.engine import grouping from sqlparse.engine.statement_splitter import StatementSplitter +from sqlparse.exceptions import SQLParseError +from sqlparse.filters import StripTrailingSemicolonFilter -class FilterStack(object): - def __init__(self): +class FilterStack: + def __init__(self, strip_semicolon=False): self.preprocess = [] self.stmtprocess = [] self.postprocess = [] self._grouping = False + if strip_semicolon: + self.stmtprocess.append(StripTrailingSemicolonFilter()) def enable_grouping(self): self._grouping = True def run(self, sql, encoding=None): - stream = lexer.tokenize(sql, encoding) - # Process token stream - for filter_ in self.preprocess: - stream = filter_.process(stream) + try: + stream = lexer.tokenize(sql, encoding) + # Process token stream + for filter_ in self.preprocess: + stream = filter_.process(stream) - stream = StatementSplitter().process(stream) + stream = StatementSplitter().process(stream) - # Output: Stream processed Statements - for stmt in stream: - if self._grouping: - stmt = grouping.group(stmt) + # Output: Stream processed Statements + for stmt in stream: + if self._grouping: + stmt = grouping.group(stmt) - for filter_ in self.stmtprocess: - filter_.process(stmt) + for filter_ in self.stmtprocess: + filter_.process(stmt) - for filter_ in self.postprocess: - stmt = filter_.process(stmt) + for filter_ in self.postprocess: + stmt = filter_.process(stmt) - yield stmt + yield stmt + except RecursionError as err: + raise SQLParseError('Maximum recursion depth exceeded') from err diff --git a/sqlparse/engine/grouping.py b/sqlparse/engine/grouping.py index e7a42118..e04c2a9e 100644 --- a/sqlparse/engine/grouping.py +++ b/sqlparse/engine/grouping.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This module is part of python-sqlparse and is released under @@ -8,18 +7,42 @@ from sqlparse import sql from sqlparse import tokens as T -from sqlparse.utils import recurse, imt +from sqlparse.exceptions import SQLParseError +from sqlparse.utils import imt, recurse + +# Maximum recursion depth for grouping operations to prevent DoS attacks +# Set to None to disable limit (not recommended for untrusted input) +MAX_GROUPING_DEPTH = 100 + +# Maximum number of tokens to process in one grouping operation to prevent +# DoS attacks. +# Set to None to disable limit (not recommended for untrusted input) +MAX_GROUPING_TOKENS = 10000 T_NUMERICAL = (T.Number, T.Number.Integer, T.Number.Float) T_STRING = (T.String, T.String.Single, T.String.Symbol) T_NAME = (T.Name, T.Name.Placeholder) -def _group_matching(tlist, cls): +def _group_matching(tlist, cls, depth=0): """Groups Tokens that have beginning and end.""" + if MAX_GROUPING_DEPTH is not None and depth > MAX_GROUPING_DEPTH: + raise SQLParseError( + f"Maximum grouping depth exceeded ({MAX_GROUPING_DEPTH})." + ) + + # Limit the number of tokens to prevent DoS attacks + if MAX_GROUPING_TOKENS is not None \ + and len(tlist.tokens) > MAX_GROUPING_TOKENS: + raise SQLParseError( + f"Maximum number of tokens exceeded ({MAX_GROUPING_TOKENS})." + ) + opens = [] tidx_offset = 0 - for idx, token in enumerate(list(tlist)): + token_list = list(tlist) + + for idx, token in enumerate(token_list): tidx = idx - tidx_offset if token.is_whitespace: @@ -32,7 +55,7 @@ def _group_matching(tlist, cls): # Check inside previously grouped (i.e. parenthesis) if group # of different type is inside (i.e., case). though ideally should # should check for all open/close tokens at once to avoid recursion - _group_matching(token, cls) + _group_matching(token, cls, depth + 1) continue if token.match(*cls.M_OPEN): @@ -92,13 +115,20 @@ def group_tzcasts(tlist): def match(token): return token.ttype == T.Keyword.TZCast - def valid(token): + def valid_prev(token): return token is not None + def valid_next(token): + return token is not None and ( + token.is_whitespace + or token.match(T.Keyword, 'AS') + or token.match(*sql.TypedLiteral.M_CLOSE) + ) + def post(tlist, pidx, tidx, nidx): return pidx, nidx - _group(tlist, sql.Identifier, match, valid, valid, post) + _group(tlist, sql.Identifier, match, valid_prev, valid_next, post) def group_typed_literal(tlist): @@ -133,7 +163,12 @@ def post(tlist, pidx, tidx, nidx): def group_period(tlist): def match(token): - return token.match(T.Punctuation, '.') + for ttype, value in ((T.Punctuation, '.'), + (T.Operator, '->'), + (T.Operator, '->>')): + if token.match(ttype, value): + return True + return False def valid_prev(token): sqlcls = sql.SquareBrackets, sql.Identifier @@ -147,7 +182,7 @@ def valid_next(token): def post(tlist, pidx, tidx, nidx): # next_ validation is being performed here. issue261 sqlcls = sql.SquareBrackets, sql.Function - ttypes = T.Name, T.String.Symbol, T.Wildcard + ttypes = T.Name, T.String.Symbol, T.Wildcard, T.String.Single next_ = tlist[nidx] if nidx is not None else None valid_next = imt(next_, i=sqlcls, t=ttypes) @@ -178,7 +213,7 @@ def match(token): return token.match(T.Assignment, ':=') def valid(token): - return token is not None and token.ttype not in (T.Keyword) + return token is not None and token.ttype not in (T.Keyword,) def post(tlist, pidx, tidx, nidx): m_semicolon = T.Punctuation, ';' @@ -199,12 +234,7 @@ def match(token): return token.ttype == T.Operator.Comparison def valid(token): - if imt(token, t=ttypes, i=sqlcls): - return True - elif token and token.is_keyword and token.normalized == 'NULL': - return True - else: - return False + return bool(imt(token, t=ttypes, i=sqlcls) or (token and token.is_keyword and token.normalized == 'NULL')) def post(tlist, pidx, tidx, nidx): return pidx, nidx @@ -224,6 +254,16 @@ def group_identifier(tlist): tidx, token = tlist.token_next_by(t=ttypes, idx=tidx) +@recurse(sql.Over) +def group_over(tlist): + tidx, token = tlist.token_next_by(m=sql.Over.M_OPEN) + while token: + nidx, next_ = tlist.token_next(tidx) + if imt(next_, i=sql.Parenthesis, t=T.Name): + tlist.group_tokens(sql.Over, tidx, nidx) + tidx, token = tlist.token_next_by(m=sql.Over.M_OPEN, idx=tidx) + + def group_arrays(tlist): sqlcls = sql.SquareBrackets, sql.Identifier, sql.Function ttypes = T.Name, T.String.Symbol @@ -293,7 +333,7 @@ def group_comments(tlist): tidx, token = tlist.token_next_by(t=T.Comment) while token: eidx, end = tlist.token_not_matching( - lambda tk: imt(tk, t=T.Comment) or tk.is_whitespace, idx=tidx) + lambda tk: imt(tk, t=T.Comment) or tk.is_newline, idx=tidx) if end is not None: eidx, end = tlist.token_prev(eidx, skip_ws=False) tlist.group_tokens(sql.Comment, tidx, eidx) @@ -335,22 +375,31 @@ def group_aliased(tlist): def group_functions(tlist): has_create = False has_table = False + has_as = False for tmp_token in tlist.tokens: - if tmp_token.value == 'CREATE': + if tmp_token.value.upper() == 'CREATE': has_create = True - if tmp_token.value == 'TABLE': + if tmp_token.value.upper() == 'TABLE': has_table = True - if has_create and has_table: + if tmp_token.value == 'AS': + has_as = True + if has_create and has_table and not has_as: return tidx, token = tlist.token_next_by(t=T.Name) while token: nidx, next_ = tlist.token_next(tidx) if isinstance(next_, sql.Parenthesis): - tlist.group_tokens(sql.Function, tidx, nidx) + over_idx, over = tlist.token_next(nidx) + if over and isinstance(over, sql.Over): + eidx = over_idx + else: + eidx = nidx + tlist.group_tokens(sql.Function, tidx, eidx) tidx, token = tlist.token_next_by(t=T.Name, idx=tidx) +@recurse(sql.Identifier) def group_order(tlist): """Group together Identifier and Asc/Desc token""" tidx, token = tlist.token_next_by(t=T.Keyword.Order) @@ -397,6 +446,7 @@ def group(stmt): group_for, group_begin, + group_over, group_functions, group_where, group_period, @@ -425,20 +475,37 @@ def _group(tlist, cls, match, valid_next=lambda t: True, post=None, extend=True, - recurse=True + recurse=True, + depth=0 ): """Groups together tokens that are joined by a middle token. i.e. x < y""" + if MAX_GROUPING_DEPTH is not None and depth > MAX_GROUPING_DEPTH: + raise SQLParseError( + f"Maximum grouping depth exceeded ({MAX_GROUPING_DEPTH})." + ) + + # Limit the number of tokens to prevent DoS attacks + if MAX_GROUPING_TOKENS is not None \ + and len(tlist.tokens) > MAX_GROUPING_TOKENS: + raise SQLParseError( + f"Maximum number of tokens exceeded ({MAX_GROUPING_TOKENS})." + ) tidx_offset = 0 pidx, prev_ = None, None - for idx, token in enumerate(list(tlist)): + token_list = list(tlist) + + for idx, token in enumerate(token_list): tidx = idx - tidx_offset + if tidx < 0: # tidx shouldn't get negative + continue if token.is_whitespace: continue if recurse and token.is_group and not isinstance(token, cls): - _group(token, cls, match, valid_prev, valid_next, post, extend) + _group(token, cls, match, valid_prev, valid_next, + post, extend, True, depth + 1) if match(token): nidx, next_ = tlist.token_next(tidx) diff --git a/sqlparse/engine/statement_splitter.py b/sqlparse/engine/statement_splitter.py index 1e9af3cf..bc57d170 100644 --- a/sqlparse/engine/statement_splitter.py +++ b/sqlparse/engine/statement_splitter.py @@ -1,15 +1,15 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This module is part of python-sqlparse and is released under # the BSD License: https://opensource.org/licenses/BSD-3-Clause -from sqlparse import sql, tokens as T +from sqlparse import sql +from sqlparse import tokens as T -class StatementSplitter(object): +class StatementSplitter: """Filter that split stream at individual statements""" def __init__(self): @@ -17,65 +17,136 @@ def __init__(self): def _reset(self): """Set the filter attributes to its default values""" - self._in_declare = False + self._block_stack = [] + self._parenthesis_level = 0 + self._unconfirmed_start = None self._is_create = False - self._begin_depth = 0 + self._seen_begin = False self.consume_ws = False self.tokens = [] self.level = 0 + def _handle_nested_block(self, unified): + """Check for nested loop or control structures inside a block""" + if unified == 'FOR': + self._unconfirmed_start = 'FOR' + return 0 + if unified == 'WHILE': + self._unconfirmed_start = 'WHILE' + return 0 + if unified in ('LOOP', 'DO'): + if self._unconfirmed_start in ('FOR', 'WHILE'): + self._block_stack.append(self._unconfirmed_start) + self._unconfirmed_start = None + return 1 + if unified == 'LOOP': + self._block_stack.append('LOOP') + return 1 + if unified in ('IF', 'CASE'): + self._block_stack.append(unified) + return 1 + return None + + def _handle_closing_keyword(self, unified): + """Handle closing keywords for blocks""" + if unified == 'END IF': + if self._block_stack and self._block_stack[-1] == 'IF': + self._block_stack.pop() + return -1 + elif unified == 'END FOR': + if self._block_stack and self._block_stack[-1] == 'FOR': + self._block_stack.pop() + return -1 + elif unified == 'END WHILE': + if self._block_stack and self._block_stack[-1] == 'WHILE': + self._block_stack.pop() + return -1 + elif unified == 'END LOOP': + if (self._block_stack and + self._block_stack[-1] in ('LOOP', 'FOR', 'WHILE')): + self._block_stack.pop() + return -1 + elif unified == 'END CASE': + if self._block_stack and self._block_stack[-1] == 'CASE': + self._block_stack.pop() + return -1 + elif unified == 'END': + if self._block_stack: + self._block_stack.pop() + return -1 + return 0 + def _change_splitlevel(self, ttype, value): """Get the new split level (increase, decrease or remain equal)""" + # Semicolon resets unconfirmed loop starters + # and handles standalone BEGIN; + if ttype is T.Punctuation and value == ';': + self._unconfirmed_start = None + if self._seen_begin: + self._seen_begin = False + if self._block_stack and self._block_stack[-1] == 'BEGIN': + self._block_stack.pop() + return -1 + return 0 + # parenthesis increase/decrease a level if ttype is T.Punctuation and value == '(': + self._parenthesis_level += 1 return 1 elif ttype is T.Punctuation and value == ')': + self._parenthesis_level = max(0, self._parenthesis_level - 1) return -1 elif ttype not in T.Keyword: # if normal token return return 0 # Everything after here is ttype = T.Keyword - # Also to note, once entered an If statement you are done and basically - # returning unified = value.upper() - # three keywords begin with CREATE, but only one of them is DDL # DDL Create though can contain more words such as "or replace" if ttype is T.Keyword.DDL and unified.startswith('CREATE'): self._is_create = True return 0 - # can have nested declare inside of being... - if unified == 'DECLARE' and self._is_create and self._begin_depth == 0: - self._in_declare = True + # Handle DECLARE block start (only for CREATE statements) + if unified == 'DECLARE' and self._is_create and not self._block_stack: + self._block_stack.append('DECLARE') return 1 + # Handle BEGIN block start if unified == 'BEGIN': - self._begin_depth += 1 - if self._is_create: - # FIXME(andi): This makes no sense. + self._seen_begin = True + # Transition DECLARE to BEGIN if present + if self._block_stack and self._block_stack[-1] == 'DECLARE': + self._block_stack.pop() + self._block_stack.append('BEGIN') + return 0 + else: + self._block_stack.append('BEGIN') return 1 - return 0 - - # Should this respect a preceding BEGIN? - # In CASE ... WHEN ... END this results in a split level -1. - # Would having multiple CASE WHEN END and a Assignment Operator - # cause the statement to cut off prematurely? - if unified == 'END': - self._begin_depth = max(0, self._begin_depth - 1) - return -1 - if (unified in ('IF', 'FOR', 'WHILE') - and self._is_create and self._begin_depth > 0): - return 1 + # Issue826: If we see a transaction keyword after BEGIN, + # it's a transaction statement, not a block. + if self._seen_begin and \ + (ttype is T.Keyword or ttype is T.Name) and \ + unified in ('TRANSACTION', 'WORK', 'TRAN', + 'DISTRIBUTED', 'DEFERRED', + 'IMMEDIATE', 'EXCLUSIVE'): + self._seen_begin = False + if self._block_stack and self._block_stack[-1] == 'BEGIN': + self._block_stack.pop() + return -1 + return 0 - if unified in ('END IF', 'END FOR', 'END WHILE'): - return -1 + # Inside a block, check for nested loop or control structures + if 'BEGIN' in self._block_stack: + res = self._handle_nested_block(unified) + if res is not None: + return res - # Default - return 0 + # Handle closing keywords + return self._handle_closing_keyword(unified) def process(self, stream): """Process the stream""" @@ -100,9 +171,26 @@ def process(self, stream): self.tokens.append(sql.Token(ttype, value)) # Check if we get the end of a statement - if self.level <= 0 and ttype is T.Punctuation and value == ';': + # Issue762: Allow GO (or "GO 2") as statement splitter. + # When implementing a language toggle, it's not only to add + # keywords it's also to change some rules, like this splitting + # rule. + # Issue809: Ignore semicolons inside BEGIN...END blocks, but handle + # standalone BEGIN; as a transaction statement + if ttype is T.Punctuation and value == ';': + self._seen_begin = False + # Split on semicolon if not inside a BEGIN...END block + if self.level <= 0 and 'BEGIN' not in self._block_stack: + self.consume_ws = True + elif ttype is T.Keyword and value.split()[0] == 'GO': self.consume_ws = True + elif (ttype not in (T.Whitespace, T.Newline, T.Comment.Single, + T.Comment.Multiline) + and not (ttype is T.Keyword and value.upper() == 'BEGIN')): + # Reset _seen_begin if we see a non-whitespace, non-comment + # token but not for BEGIN itself (which just set the flag) + self._seen_begin = False # Yield pending statement (if any) - if self.tokens: + if self.tokens and not all(t.is_whitespace for t in self.tokens): yield sql.Statement(self.tokens) diff --git a/sqlparse/exceptions.py b/sqlparse/exceptions.py index 01e60f7b..11285da0 100644 --- a/sqlparse/exceptions.py +++ b/sqlparse/exceptions.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This module is part of python-sqlparse and is released under diff --git a/sqlparse/filters/__init__.py b/sqlparse/filters/__init__.py index c60d84d7..d9d13396 100644 --- a/sqlparse/filters/__init__.py +++ b/sqlparse/filters/__init__.py @@ -1,41 +1,39 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This module is part of python-sqlparse and is released under # the BSD License: https://opensource.org/licenses/BSD-3-Clause -from sqlparse.filters.others import SerializerUnicode -from sqlparse.filters.others import StripCommentsFilter -from sqlparse.filters.others import StripWhitespaceFilter -from sqlparse.filters.others import SpacesAroundOperatorsFilter - -from sqlparse.filters.output import OutputPHPFilter -from sqlparse.filters.output import OutputPythonFilter - -from sqlparse.filters.tokens import KeywordCaseFilter -from sqlparse.filters.tokens import IdentifierCaseFilter -from sqlparse.filters.tokens import TruncateStringFilter - +from sqlparse.filters.aligned_indent import AlignedIndentFilter +from sqlparse.filters.others import ( + SerializerUnicode, + SpacesAroundOperatorsFilter, + StripCommentsFilter, + StripTrailingSemicolonFilter, + StripWhitespaceFilter, +) +from sqlparse.filters.output import OutputPHPFilter, OutputPythonFilter from sqlparse.filters.reindent import ReindentFilter from sqlparse.filters.right_margin import RightMarginFilter -from sqlparse.filters.aligned_indent import AlignedIndentFilter +from sqlparse.filters.tokens import ( + IdentifierCaseFilter, + KeywordCaseFilter, + TruncateStringFilter, +) __all__ = [ - 'SerializerUnicode', - 'StripCommentsFilter', - 'StripWhitespaceFilter', - 'SpacesAroundOperatorsFilter', - + 'AlignedIndentFilter', + 'IdentifierCaseFilter', + 'KeywordCaseFilter', 'OutputPHPFilter', 'OutputPythonFilter', - - 'KeywordCaseFilter', - 'IdentifierCaseFilter', - 'TruncateStringFilter', - 'ReindentFilter', 'RightMarginFilter', - 'AlignedIndentFilter', + 'SerializerUnicode', + 'SpacesAroundOperatorsFilter', + 'StripCommentsFilter', + 'StripTrailingSemicolonFilter', + 'StripWhitespaceFilter', + 'TruncateStringFilter', ] diff --git a/sqlparse/filters/aligned_indent.py b/sqlparse/filters/aligned_indent.py index 85b11e58..6ac99d62 100644 --- a/sqlparse/filters/aligned_indent.py +++ b/sqlparse/filters/aligned_indent.py @@ -1,17 +1,16 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This module is part of python-sqlparse and is released under # the BSD License: https://opensource.org/licenses/BSD-3-Clause -from sqlparse import sql, tokens as T -from sqlparse.compat import text_type -from sqlparse.utils import offset, indent +from sqlparse import sql +from sqlparse import tokens as T +from sqlparse.utils import indent, offset -class AlignedIndentFilter(object): +class AlignedIndentFilter: join_words = (r'((LEFT\s+|RIGHT\s+|FULL\s+)?' r'(INNER\s+|OUTER\s+|STRAIGHT\s+)?|' r'(CROSS\s+|NATURAL\s+)?)?JOIN\b') @@ -73,7 +72,7 @@ def _process_case(self, tlist): end_token = tlist.token_next_by(m=(T.Keyword, 'END'))[1] cases.append((None, [end_token])) - condition_width = [len(' '.join(map(text_type, cond))) if cond else 0 + condition_width = [len(' '.join(map(str, cond))) if cond else 0 for cond, _ in cases] max_cond_width = max(condition_width) @@ -82,8 +81,7 @@ def _process_case(self, tlist): stmt = cond[0] if cond else value[0] if i > 0: - tlist.insert_before(stmt, self.nl( - offset_ - len(text_type(stmt)))) + tlist.insert_before(stmt, self.nl(offset_ - len(str(stmt)))) if cond: ws = sql.Token(T.Whitespace, self.char * ( max_cond_width - condition_width[i])) @@ -110,7 +108,7 @@ def _split_kwds(self, tlist): ): token_indent = token.value.split()[0] else: - token_indent = text_type(token) + token_indent = str(token) tlist.insert_before(token, self.nl(token_indent)) tidx += 1 tidx, token = self._next_token(tlist, tidx) @@ -129,7 +127,7 @@ def _process_default(self, tlist): self._process(sgroup) def _process(self, tlist): - func_name = '_process_{cls}'.format(cls=type(tlist).__name__) + func_name = f'_process_{type(tlist).__name__}' func = getattr(self, func_name.lower(), self._process_default) func(tlist) diff --git a/sqlparse/filters/others.py b/sqlparse/filters/others.py index 52b86170..95bc436c 100644 --- a/sqlparse/filters/others.py +++ b/sqlparse/filters/others.py @@ -1,43 +1,79 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This module is part of python-sqlparse and is released under # the BSD License: https://opensource.org/licenses/BSD-3-Clause -from sqlparse import sql, tokens as T +import re + +from sqlparse import sql +from sqlparse import tokens as T from sqlparse.utils import split_unquoted_newlines -class StripCommentsFilter(object): +class StripCommentsFilter: + @staticmethod def _process(tlist): - def get_next_comment(): + def get_next_comment(idx=-1): # TODO(andi) Comment types should be unified, see related issue38 - return tlist.token_next_by(i=sql.Comment, t=T.Comment) + return tlist.token_next_by(i=sql.Comment, t=T.Comment, idx=idx) + + def _get_insert_token(token): + """Returns either a whitespace or the line breaks from token.""" + # See issue484 why line breaks should be preserved. + # Note: The actual value for a line break is replaced by \n + # in SerializerUnicode which will be executed in the + # postprocessing state. + m = re.search(r'([\r\n]+) *$', token.value) + if m is not None: + return sql.Token(T.Whitespace.Newline, m.groups()[0]) + else: + return sql.Token(T.Whitespace, ' ') + sql_hints = (T.Comment.Multiline.Hint, T.Comment.Single.Hint) tidx, token = get_next_comment() while token: + # skipping token remove if token is a SQL-Hint. issue262 + is_sql_hint = False + if token.ttype in sql_hints: + is_sql_hint = True + elif isinstance(token, sql.Comment): + comment_tokens = token.tokens + if len(comment_tokens) > 0: + if comment_tokens[0].ttype in sql_hints: + is_sql_hint = True + + if is_sql_hint: + # using current index as start index to search next token for + # preventing infinite loop in cases when token type is a + # "SQL-Hint" and has to be skipped + tidx, token = get_next_comment(idx=tidx) + continue + pidx, prev_ = tlist.token_prev(tidx, skip_ws=False) nidx, next_ = tlist.token_next(tidx, skip_ws=False) # Replace by whitespace if prev and next exist and if they're not # whitespaces. This doesn't apply if prev or next is a parenthesis. - if (prev_ is None or next_ is None - or prev_.is_whitespace or prev_.match(T.Punctuation, '(') - or next_.is_whitespace or next_.match(T.Punctuation, ')')): + if ( + prev_ is None or next_ is None + or prev_.is_whitespace or prev_.match(T.Punctuation, '(') + or next_.is_whitespace or next_.match(T.Punctuation, ')') + ): # Insert a whitespace to ensure the following SQL produces - # a valid SQL (see #425). For example: - # - # Before: select a--comment\nfrom foo - # After: select a from foo - if prev_ is not None and next_ is None: - tlist.tokens.insert(tidx, sql.Token(T.Whitespace, ' ')) + # a valid SQL (see #425). + if prev_ is not None and not prev_.match(T.Punctuation, '('): + tlist.tokens.insert(tidx, _get_insert_token(token)) tlist.tokens.remove(token) + tidx -= 1 else: - tlist.tokens[tidx] = sql.Token(T.Whitespace, ' ') + tlist.tokens[tidx] = _get_insert_token(token) - tidx, token = get_next_comment() + # using current index as start index to search next token for + # preventing infinite loop in cases when token type is a + # "SQL-Hint" and has to be skipped + tidx, token = get_next_comment(idx=tidx) def process(self, stmt): [self.process(sgroup) for sgroup in stmt.get_sublists()] @@ -45,9 +81,9 @@ def process(self, stmt): return stmt -class StripWhitespaceFilter(object): +class StripWhitespaceFilter: def _stripws(self, tlist): - func_name = '_stripws_{cls}'.format(cls=type(tlist).__name__) + func_name = f'_stripws_{type(tlist).__name__}' func = getattr(self, func_name.lower(), self._stripws_default) func(tlist) @@ -80,6 +116,10 @@ def _stripws_parenthesis(self, tlist): tlist.tokens.pop(1) while tlist.tokens[-2].is_whitespace: tlist.tokens.pop(-2) + if tlist.tokens[-2].is_group: + # save to remove the last whitespace + while tlist.tokens[-2].tokens[-1].is_whitespace: + tlist.tokens[-2].tokens.pop(-1) self._stripws_default(tlist) def process(self, stmt, depth=0): @@ -90,7 +130,7 @@ def process(self, stmt, depth=0): return stmt -class SpacesAroundOperatorsFilter(object): +class SpacesAroundOperatorsFilter: @staticmethod def _process(tlist): @@ -115,10 +155,19 @@ def process(self, stmt): return stmt +class StripTrailingSemicolonFilter: + + def process(self, stmt): + while stmt.tokens and (stmt.tokens[-1].is_whitespace + or stmt.tokens[-1].value == ';'): + stmt.tokens.pop() + return stmt + + # --------------------------- # postprocess -class SerializerUnicode(object): +class SerializerUnicode: @staticmethod def process(stmt): lines = split_unquoted_newlines(stmt) diff --git a/sqlparse/filters/output.py b/sqlparse/filters/output.py index 3fbc46d1..235db540 100644 --- a/sqlparse/filters/output.py +++ b/sqlparse/filters/output.py @@ -1,16 +1,15 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This module is part of python-sqlparse and is released under # the BSD License: https://opensource.org/licenses/BSD-3-Clause -from sqlparse import sql, tokens as T -from sqlparse.compat import text_type +from sqlparse import sql +from sqlparse import tokens as T -class OutputFilter(object): +class OutputFilter: varname_prefix = '' def __init__(self, varname='sql'): @@ -23,11 +22,11 @@ def _process(self, stream, varname, has_nl): def process(self, stmt): self.count += 1 if self.count > 1: - varname = u'{f.varname}{f.count}'.format(f=self) + varname = f'{self.varname}{self.count}' else: varname = self.varname - has_nl = len(text_type(stmt).strip().splitlines()) > 1 + has_nl = len(str(stmt).strip().splitlines()) > 1 stmt.tokens = self._process(stmt.tokens, varname, has_nl) return stmt diff --git a/sqlparse/filters/reindent.py b/sqlparse/filters/reindent.py index acec8ca4..ea41ac6b 100644 --- a/sqlparse/filters/reindent.py +++ b/sqlparse/filters/reindent.py @@ -1,20 +1,19 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This module is part of python-sqlparse and is released under # the BSD License: https://opensource.org/licenses/BSD-3-Clause -from sqlparse import sql, tokens as T -from sqlparse.compat import text_type -from sqlparse.utils import offset, indent +from sqlparse import sql +from sqlparse import tokens as T +from sqlparse.utils import indent, offset -class ReindentFilter(object): +class ReindentFilter: def __init__(self, width=2, char=' ', wrap_after=0, n='\n', comma_first=False, indent_after_first=False, - indent_columns=False): + indent_columns=False, compact=False): self.n = n self.width = width self.char = char @@ -23,6 +22,7 @@ def __init__(self, width=2, char=' ', wrap_after=0, n='\n', self.wrap_after = wrap_after self.comma_first = comma_first self.indent_columns = indent_columns + self.compact = compact self._curr_stmt = None self._last_stmt = None self._last_func = None @@ -42,7 +42,7 @@ def leading_ws(self): return self.offset + self.indent * self.width def _get_offset(self, token): - raw = u''.join(map(text_type, self._flatten_up_to_token(token))) + raw = ''.join(map(str, self._flatten_up_to_token(token))) line = (raw or '\n').splitlines()[-1] # Now take current offset into account and return relative offset. return len(line) - len(self.char * self.leading_ws) @@ -71,7 +71,7 @@ def _split_kwds(self, tlist): tidx, token = self._next_token(tlist) while token: pidx, prev_ = tlist.token_prev(tidx, skip_ws=False) - uprev = text_type(prev_) + uprev = str(prev_) if prev_ and prev_.is_whitespace: del tlist.tokens[pidx] @@ -98,15 +98,16 @@ def _split_statements(self, tlist): tidx, token = tlist.token_next_by(t=ttypes, idx=tidx) def _process(self, tlist): - func_name = '_process_{cls}'.format(cls=type(tlist).__name__) + func_name = f'_process_{type(tlist).__name__}' func = getattr(self, func_name.lower(), self._process_default) func(tlist) def _process_where(self, tlist): tidx, token = tlist.token_next_by(m=(T.Keyword, 'WHERE')) + if not token: + return # issue121, errors in statement fixed?? tlist.insert_before(tidx, self.nl()) - with indent(self): self._process_default(tlist) @@ -114,6 +115,8 @@ def _process_parenthesis(self, tlist): ttypes = T.Keyword.DML, T.Keyword.DDL _, is_dml_dll = tlist.token_next_by(t=ttypes) fidx, first = tlist.token_next_by(m=sql.Parenthesis.M_OPEN) + if first is None: + return with indent(self, 1 if is_dml_dll else 0): tlist.tokens.insert(0, self.nl()) if is_dml_dll else None @@ -195,15 +198,19 @@ def _process_case(self, tlist): with offset(self, self._get_offset(tlist[0])): with offset(self, self._get_offset(first)): for cond, value in iterable: - token = value[0] if cond is None else cond[0] - tlist.insert_before(token, self.nl()) + str_cond = ''.join(str(x) for x in cond or []) + str_value = ''.join(str(x) for x in value) + end_pos = self.offset + 1 + len(str_cond) + len(str_value) + if (not self.compact and end_pos > self.wrap_after): + token = value[0] if cond is None else cond[0] + tlist.insert_before(token, self.nl()) # Line breaks on group level are done. let's add an offset of # len "when ", "then ", "else " with offset(self, len("WHEN ")): self._process_default(tlist) end_idx, end = tlist.token_next_by(m=sql.Case.M_CLOSE) - if end_idx is not None: + if end_idx is not None and not self.compact: tlist.insert_before(end_idx, self.nl()) def _process_values(self, tlist): @@ -234,7 +241,7 @@ def process(self, stmt): self._process(stmt) if self._last_stmt is not None: - nl = '\n' if text_type(self._last_stmt).endswith('\n') else '\n\n' + nl = '\n' if str(self._last_stmt).endswith('\n') else '\n\n' stmt.tokens.insert(0, sql.Token(T.Whitespace, nl)) self._last_stmt = stmt diff --git a/sqlparse/filters/right_margin.py b/sqlparse/filters/right_margin.py index 16581389..52331950 100644 --- a/sqlparse/filters/right_margin.py +++ b/sqlparse/filters/right_margin.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This module is part of python-sqlparse and is released under @@ -8,12 +7,12 @@ import re -from sqlparse import sql, tokens as T -from sqlparse.compat import text_type +from sqlparse import sql +from sqlparse import tokens as T # FIXME: Doesn't work -class RightMarginFilter(object): +class RightMarginFilter: keep_together = ( # sql.TypeCast, sql.Identifier, sql.Alias, ) @@ -32,14 +31,14 @@ def _process(self, group, stream): elif token.is_group and type(token) not in self.keep_together: token.tokens = self._process(token, token.tokens) else: - val = text_type(token) + val = str(token) if len(self.line) + len(val) > self.width: match = re.search(r'^ +', self.line) if match is not None: indent = match.group() else: indent = '' - yield sql.Token(T.Whitespace, '\n{0}'.format(indent)) + yield sql.Token(T.Whitespace, f'\n{indent}') self.line = indent self.line += val yield token diff --git a/sqlparse/filters/tokens.py b/sqlparse/filters/tokens.py index 93182b19..cc00a844 100644 --- a/sqlparse/filters/tokens.py +++ b/sqlparse/filters/tokens.py @@ -1,21 +1,19 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This module is part of python-sqlparse and is released under # the BSD License: https://opensource.org/licenses/BSD-3-Clause from sqlparse import tokens as T -from sqlparse.compat import text_type -class _CaseFilter(object): +class _CaseFilter: ttype = None def __init__(self, case=None): case = case or 'upper' - self.convert = getattr(text_type, case) + self.convert = getattr(str, case) def process(self, stream): for ttype, value in stream: @@ -38,7 +36,7 @@ def process(self, stream): yield ttype, value -class TruncateStringFilter(object): +class TruncateStringFilter: def __init__(self, width, char): self.width = width self.char = char diff --git a/sqlparse/formatter.py b/sqlparse/formatter.py index 89627596..1fba2466 100644 --- a/sqlparse/formatter.py +++ b/sqlparse/formatter.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This module is part of python-sqlparse and is released under @@ -17,32 +16,32 @@ def validate_options(options): kwcase = options.get('keyword_case') if kwcase not in [None, 'upper', 'lower', 'capitalize']: raise SQLParseError('Invalid value for keyword_case: ' - '{0!r}'.format(kwcase)) + f'{kwcase!r}') idcase = options.get('identifier_case') if idcase not in [None, 'upper', 'lower', 'capitalize']: raise SQLParseError('Invalid value for identifier_case: ' - '{0!r}'.format(idcase)) + f'{idcase!r}') ofrmt = options.get('output_format') if ofrmt not in [None, 'sql', 'python', 'php']: raise SQLParseError('Unknown output format: ' - '{0!r}'.format(ofrmt)) + f'{ofrmt!r}') strip_comments = options.get('strip_comments', False) if strip_comments not in [True, False]: raise SQLParseError('Invalid value for strip_comments: ' - '{0!r}'.format(strip_comments)) + f'{strip_comments!r}') space_around_operators = options.get('use_space_around_operators', False) if space_around_operators not in [True, False]: raise SQLParseError('Invalid value for use_space_around_operators: ' - '{0!r}'.format(space_around_operators)) + f'{space_around_operators!r}') strip_ws = options.get('strip_whitespace', False) if strip_ws not in [True, False]: raise SQLParseError('Invalid value for strip_whitespace: ' - '{0!r}'.format(strip_ws)) + f'{strip_ws!r}') truncate_strings = options.get('truncate_strings') if truncate_strings is not None: @@ -50,17 +49,17 @@ def validate_options(options): truncate_strings = int(truncate_strings) except (ValueError, TypeError): raise SQLParseError('Invalid value for truncate_strings: ' - '{0!r}'.format(truncate_strings)) + f'{truncate_strings!r}') if truncate_strings <= 1: raise SQLParseError('Invalid value for truncate_strings: ' - '{0!r}'.format(truncate_strings)) + f'{truncate_strings!r}') options['truncate_strings'] = truncate_strings options['truncate_char'] = options.get('truncate_char', '[...]') indent_columns = options.get('indent_columns', False) if indent_columns not in [True, False]: raise SQLParseError('Invalid value for indent_columns: ' - '{0!r}'.format(indent_columns)) + f'{indent_columns!r}') elif indent_columns: options['reindent'] = True # enforce reindent options['indent_columns'] = indent_columns @@ -68,27 +67,27 @@ def validate_options(options): reindent = options.get('reindent', False) if reindent not in [True, False]: raise SQLParseError('Invalid value for reindent: ' - '{0!r}'.format(reindent)) + f'{reindent!r}') elif reindent: options['strip_whitespace'] = True reindent_aligned = options.get('reindent_aligned', False) if reindent_aligned not in [True, False]: raise SQLParseError('Invalid value for reindent_aligned: ' - '{0!r}'.format(reindent)) + f'{reindent!r}') elif reindent_aligned: options['strip_whitespace'] = True indent_after_first = options.get('indent_after_first', False) if indent_after_first not in [True, False]: raise SQLParseError('Invalid value for indent_after_first: ' - '{0!r}'.format(indent_after_first)) + f'{indent_after_first!r}') options['indent_after_first'] = indent_after_first indent_tabs = options.get('indent_tabs', False) if indent_tabs not in [True, False]: raise SQLParseError('Invalid value for indent_tabs: ' - '{0!r}'.format(indent_tabs)) + f'{indent_tabs!r}') elif indent_tabs: options['indent_char'] = '\t' else: @@ -117,6 +116,11 @@ def validate_options(options): raise SQLParseError('comma_first requires a boolean value') options['comma_first'] = comma_first + compact = options.get('compact', False) + if compact not in [True, False]: + raise SQLParseError('compact requires a boolean value') + options['compact'] = compact + right_margin = options.get('right_margin') if right_margin is not None: try: @@ -172,7 +176,8 @@ def build_filter_stack(stack, options): indent_after_first=options['indent_after_first'], indent_columns=options['indent_columns'], wrap_after=options['wrap_after'], - comma_first=options['comma_first'])) + comma_first=options['comma_first'], + compact=options['compact'],)) if options.get('reindent_aligned', False): stack.enable_grouping() diff --git a/sqlparse/keywords.py b/sqlparse/keywords.py index 9c37e502..4bafcda1 100644 --- a/sqlparse/keywords.py +++ b/sqlparse/keywords.py @@ -1,103 +1,103 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This module is part of python-sqlparse and is released under # the BSD License: https://opensource.org/licenses/BSD-3-Clause -import re - from sqlparse import tokens - -def is_keyword(value): - val = value.upper() - return (KEYWORDS_COMMON.get(val) - or KEYWORDS_ORACLE.get(val) - or KEYWORDS_PLPGSQL.get(val) - or KEYWORDS_HQL.get(val) - or KEYWORDS.get(val, tokens.Name)), value - - -SQL_REGEX = { - 'root': [ - (r'(--|# )\+.*?(\r\n|\r|\n|$)', tokens.Comment.Single.Hint), - (r'/\*\+[\s\S]*?\*/', tokens.Comment.Multiline.Hint), - - (r'(--|# ).*?(\r\n|\r|\n|$)', tokens.Comment.Single), - (r'/\*[\s\S]*?\*/', tokens.Comment.Multiline), - - (r'(\r\n|\r|\n)', tokens.Newline), - (r'\s+?', tokens.Whitespace), - - (r':=', tokens.Assignment), - (r'::', tokens.Punctuation), - - (r'\*', tokens.Wildcard), - - (r"`(``|[^`])*`", tokens.Name), - (r"´(´´|[^´])*´", tokens.Name), - (r'((?=~!]+', tokens.Operator.Comparison), - (r'[+/@#%^&|`?^-]+', tokens.Operator), - ]} - -FLAGS = re.IGNORECASE | re.UNICODE -SQL_REGEX = [(re.compile(rx, FLAGS).match, tt) for rx, tt in SQL_REGEX['root']] +# object() only supports "is" and is useful as a marker +# use this marker to specify that the given regex in SQL_REGEX +# shall be processed further through a lookup in the KEYWORDS dictionaries +PROCESS_AS_KEYWORD = object() + + +SQL_REGEX = [ + (r'(--|# )\+.*?(\r\n|\r|\n|$)', tokens.Comment.Single.Hint), + (r'/\*\+[\s\S]*?\*/', tokens.Comment.Multiline.Hint), + + (r'(--|# ).*?(\r\n|\r|\n|$)', tokens.Comment.Single), + (r'/\*[\s\S]*?\*/', tokens.Comment.Multiline), + + (r'(\r\n|\r|\n)', tokens.Newline), + (r'\s+?', tokens.Whitespace), + + (r':=', tokens.Assignment), + (r'::', tokens.Punctuation), + + (r'\*', tokens.Wildcard), + + (r"`(``|[^`])*`", tokens.Name), + (r"´(´´|[^´])*´", tokens.Name), + (r'((?>?|#>>?|@>|<@|\?\|?|\?&|\-|#\-)', tokens.Operator), + (r'[<>=~!]+', tokens.Operator.Comparison), + (r'[+/@#%^&|^-]+', tokens.Operator), +] KEYWORDS = { 'ABORT': tokens.Keyword, @@ -117,7 +117,6 @@ def is_keyword(value): 'ANY': tokens.Keyword, 'ARRAYLEN': tokens.Keyword, 'ARE': tokens.Keyword, - 'ASC': tokens.Keyword.Order, 'ASENSITIVE': tokens.Keyword, 'ASSERTION': tokens.Keyword, 'ASSIGNMENT': tokens.Keyword, @@ -230,7 +229,6 @@ def is_keyword(value): 'DELIMITER': tokens.Keyword, 'DELIMITERS': tokens.Keyword, 'DEREF': tokens.Keyword, - 'DESC': tokens.Keyword.Order, 'DESCRIBE': tokens.Keyword, 'DESCRIPTOR': tokens.Keyword, 'DESTROY': tokens.Keyword, @@ -241,6 +239,7 @@ def is_keyword(value): 'DISABLE': tokens.Keyword, 'DISCONNECT': tokens.Keyword, 'DISPATCH': tokens.Keyword, + 'DIV': tokens.Operator, 'DO': tokens.Keyword, 'DOMAIN': tokens.Keyword, 'DYNAMIC': tokens.Keyword, @@ -291,11 +290,9 @@ def is_keyword(value): 'GLOBAL': tokens.Keyword, 'GO': tokens.Keyword, 'GOTO': tokens.Keyword, - 'GRANT': tokens.Keyword, 'GRANTED': tokens.Keyword, 'GROUPING': tokens.Keyword, - 'HANDLER': tokens.Keyword, 'HAVING': tokens.Keyword, 'HIERARCHY': tokens.Keyword, 'HOLD': tokens.Keyword, @@ -315,7 +312,7 @@ def is_keyword(value): 'INCREMENT': tokens.Keyword, 'INDEX': tokens.Keyword, - 'INDITCATOR': tokens.Keyword, + 'INDICATOR': tokens.Keyword, 'INFIX': tokens.Keyword, 'INHERITS': tokens.Keyword, 'INITIAL': tokens.Keyword, @@ -481,7 +478,6 @@ def is_keyword(value): 'RETURNED_SQLSTATE': tokens.Keyword, 'RETURNING': tokens.Keyword, 'RETURNS': tokens.Keyword, - 'REVOKE': tokens.Keyword, 'RIGHT': tokens.Keyword, 'ROLE': tokens.Keyword, 'ROLLBACK': tokens.Keyword.DML, @@ -490,7 +486,6 @@ def is_keyword(value): 'ROUTINE_CATALOG': tokens.Keyword, 'ROUTINE_NAME': tokens.Keyword, 'ROUTINE_SCHEMA': tokens.Keyword, - 'ROW': tokens.Keyword, 'ROWS': tokens.Keyword, 'ROW_COUNT': tokens.Keyword, 'RULE': tokens.Keyword, @@ -582,7 +577,6 @@ def is_keyword(value): 'TRIGGER_SCHEMA': tokens.Keyword, 'TRIM': tokens.Keyword, 'TRUE': tokens.Keyword, - 'TRUNCATE': tokens.Keyword, 'TRUSTED': tokens.Keyword, 'TYPE': tokens.Keyword, @@ -689,6 +683,9 @@ def is_keyword(value): 'DROP': tokens.Keyword.DDL, 'CREATE': tokens.Keyword.DDL, 'ALTER': tokens.Keyword.DDL, + 'TRUNCATE': tokens.Keyword.DDL, + 'GRANT': tokens.Keyword.DCL, + 'REVOKE': tokens.Keyword.DCL, 'WHERE': tokens.Keyword, 'FROM': tokens.Keyword, @@ -746,6 +743,7 @@ def is_keyword(value): 'DOUBLE': tokens.Keyword, 'DUMP': tokens.Keyword, + 'ELSIF': tokens.Keyword, 'EVENTS': tokens.Keyword, 'EXCEPTIONS': tokens.Keyword, 'EXPLAIN': tokens.Keyword, @@ -832,15 +830,26 @@ def is_keyword(value): 'UNLOCK': tokens.Keyword, } +# MySQL +KEYWORDS_MYSQL = { + 'ROW': tokens.Keyword, +} + # PostgreSQL Syntax KEYWORDS_PLPGSQL = { + 'CONFLICT': tokens.Keyword, + 'WINDOW': tokens.Keyword, 'PARTITION': tokens.Keyword, + 'ATTACH': tokens.Keyword, + 'DETACH': tokens.Keyword, 'OVER': tokens.Keyword, 'PERFORM': tokens.Keyword, 'NOTICE': tokens.Keyword, 'PLPGSQL': tokens.Keyword, 'INHERIT': tokens.Keyword, 'INDEXES': tokens.Keyword, + 'ON_ERROR_STOP': tokens.Keyword, + 'EXTENSION': tokens.Keyword, 'BYTEA': tokens.Keyword, 'BIGSERIAL': tokens.Keyword, @@ -904,6 +913,7 @@ def is_keyword(value): 'INLINE': tokens.Keyword, 'INSTR': tokens.Keyword, 'LEN': tokens.Keyword, + 'MAP': tokens.Name.Builtin, 'MAXELEMENT': tokens.Keyword, 'MAXINDEX': tokens.Keyword, 'MAX_PART_DATE': tokens.Keyword, @@ -935,9 +945,12 @@ def is_keyword(value): 'SQRT': tokens.Keyword, 'STACK': tokens.Keyword, 'STR': tokens.Keyword, + 'STRING': tokens.Name.Builtin, + 'STRUCT': tokens.Name.Builtin, 'SUBSTR': tokens.Keyword, 'SUMMARY': tokens.Keyword, 'TBLPROPERTIES': tokens.Keyword, + 'TIMESTAMP': tokens.Name.Builtin, 'TIMESTAMP_ISO': tokens.Keyword, 'TO_CHAR': tokens.Keyword, 'TO_DATE': tokens.Keyword, @@ -953,3 +966,40 @@ def is_keyword(value): 'BREAK': tokens.Keyword, 'LEAVE': tokens.Keyword, } + + +KEYWORDS_MSACCESS = { + 'DISTINCTROW': tokens.Keyword, +} + + +KEYWORDS_SNOWFLAKE = { + 'ACCOUNT': tokens.Keyword, + 'GSCLUSTER': tokens.Keyword, + 'ISSUE': tokens.Keyword, + 'ORGANIZATION': tokens.Keyword, + 'PIVOT': tokens.Keyword, + 'QUALIFY': tokens.Keyword, + 'REGEXP': tokens.Keyword, + 'RLIKE': tokens.Keyword, + 'SAMPLE': tokens.Keyword, + 'TRY_CAST': tokens.Keyword, + 'UNPIVOT': tokens.Keyword, + + 'VARIANT': tokens.Name.Builtin, +} + + +KEYWORDS_BIGQUERY = { + 'ASSERT_ROWS_MODIFIED': tokens.Keyword, + 'DEFINE': tokens.Keyword, + 'ENUM': tokens.Keyword, + 'HASH': tokens.Keyword, + 'LOOKUP': tokens.Keyword, + 'PRECEDING': tokens.Keyword, + 'PROTO': tokens.Keyword, + 'RESPECT': tokens.Keyword, + 'TABLESAMPLE': tokens.Keyword, + + 'BIGNUMERIC': tokens.Name.Builtin, +} diff --git a/sqlparse/lexer.py b/sqlparse/lexer.py index fd007a4c..966cdb2c 100644 --- a/sqlparse/lexer.py +++ b/sqlparse/lexer.py @@ -1,31 +1,109 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This module is part of python-sqlparse and is released under # the BSD License: https://opensource.org/licenses/BSD-3-Clause """SQL Lexer""" +import re # This code is based on the SqlLexer in pygments. # http://pygments.org/ # It's separated from the rest of pygments to increase performance # and to allow some customizations. +from io import TextIOBase +from threading import Lock -from sqlparse import tokens -from sqlparse.keywords import SQL_REGEX -from sqlparse.compat import text_type, file_types +from sqlparse import keywords, tokens from sqlparse.utils import consume -class Lexer(object): - """Lexer - Empty class. Leaving for backwards-compatibility - """ +class Lexer: + """The Lexer supports configurable syntax. + To add support for additional keywords, use the `add_keywords` method.""" + + _default_instance = None + _lock = Lock() + + # Development notes: + # - This class is prepared to be able to support additional SQL dialects + # in the future by adding additional functions that take the place of + # the function default_initialization(). + # - The lexer class uses an explicit singleton behavior with the + # instance-getter method get_default_instance(). This mechanism has + # the advantage that the call signature of the entry-points to the + # sqlparse library are not affected. Also, usage of sqlparse in third + # party code does not need to be adapted. On the other hand, the current + # implementation does not easily allow for multiple SQL dialects to be + # parsed in the same process. + # Such behavior can be supported in the future by passing a + # suitably initialized lexer object as an additional parameter to the + # entry-point functions (such as `parse`). Code will need to be written + # to pass down and utilize such an object. The current implementation + # is prepared to support this thread safe approach without the + # default_instance part needing to change interface. + + @classmethod + def get_default_instance(cls): + """Returns the lexer instance used internally + by the sqlparse core functions.""" + with cls._lock: + if cls._default_instance is None: + cls._default_instance = cls() + cls._default_instance.default_initialization() + return cls._default_instance + + def default_initialization(self): + """Initialize the lexer with default dictionaries. + Useful if you need to revert custom syntax settings.""" + self.clear() + self.set_SQL_REGEX(keywords.SQL_REGEX) + self.add_keywords(keywords.KEYWORDS_COMMON) + self.add_keywords(keywords.KEYWORDS_ORACLE) + self.add_keywords(keywords.KEYWORDS_MYSQL) + self.add_keywords(keywords.KEYWORDS_PLPGSQL) + self.add_keywords(keywords.KEYWORDS_HQL) + self.add_keywords(keywords.KEYWORDS_MSACCESS) + self.add_keywords(keywords.KEYWORDS_SNOWFLAKE) + self.add_keywords(keywords.KEYWORDS_BIGQUERY) + self.add_keywords(keywords.KEYWORDS) + + def clear(self): + """Clear all syntax configurations. + Useful if you want to load a reduced set of syntax configurations. + After this call, regexps and keyword dictionaries need to be loaded + to make the lexer functional again.""" + self._SQL_REGEX = [] + self._keywords = [] + + def set_SQL_REGEX(self, SQL_REGEX): + """Set the list of regex that will parse the SQL.""" + FLAGS = re.IGNORECASE | re.UNICODE + self._SQL_REGEX = [ + (re.compile(rx, FLAGS).match, tt) + for rx, tt in SQL_REGEX + ] + + def add_keywords(self, keywords): + """Add keyword dictionaries. Keywords are looked up in the same order + that dictionaries were added.""" + self._keywords.append(keywords) + + def is_keyword(self, value): + """Checks for a keyword. + + If the given value is in one of the KEYWORDS_* dictionary + it's considered a keyword. Otherwise, tokens.Name is returned. + """ + val = value.upper() + for kwdict in self._keywords: + if val in kwdict: + return kwdict[val], value + else: + return tokens.Name, value - @staticmethod - def get_tokens(text, encoding=None): + def get_tokens(self, text, encoding=None): """ Return an iterable of (tokentype, value) pairs generated from `text`. If `unfiltered` is set to `True`, the filtering mechanism @@ -38,10 +116,10 @@ def get_tokens(text, encoding=None): ``stack`` is the initial stack (default: ``['root']``) """ - if isinstance(text, file_types): + if isinstance(text, TextIOBase): text = text.read() - if isinstance(text, text_type): + if isinstance(text, str): pass elif isinstance(text, bytes): if encoding: @@ -52,20 +130,19 @@ def get_tokens(text, encoding=None): except UnicodeDecodeError: text = text.decode('unicode-escape') else: - raise TypeError(u"Expected text or file-like object, got {!r}". - format(type(text))) + raise TypeError(f"Expected text or file-like object, got {type(text)!r}") iterable = enumerate(text) for pos, char in iterable: - for rexmatch, action in SQL_REGEX: + for rexmatch, action in self._SQL_REGEX: m = rexmatch(text, pos) if not m: continue elif isinstance(action, tokens._TokenType): yield action, m.group() - elif callable(action): - yield action(m.group()) + elif action is keywords.PROCESS_AS_KEYWORD: + yield self.is_keyword(m.group()) consume(iterable, m.end() - pos - 1) break @@ -79,4 +156,4 @@ def tokenize(sql, encoding=None): Tokenize *sql* using the :class:`Lexer` and return a 2-tuple stream of ``(token type, value)`` items. """ - return Lexer().get_tokens(sql, encoding) + return Lexer.get_default_instance().get_tokens(sql, encoding) diff --git a/sqlparse/py.typed b/sqlparse/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/sqlparse/sql.py b/sqlparse/sql.py index a942bcd6..5489f055 100644 --- a/sqlparse/sql.py +++ b/sqlparse/sql.py @@ -1,18 +1,15 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This module is part of python-sqlparse and is released under # the BSD License: https://opensource.org/licenses/BSD-3-Clause """This module contains classes representing syntactical elements of SQL.""" -from __future__ import print_function import re from sqlparse import tokens as T -from sqlparse.compat import string_types, text_type, unicode_compatible from sqlparse.utils import imt, remove_quotes @@ -39,8 +36,7 @@ def get_alias(self): return self._get_first_name(reverse=True) -@unicode_compatible -class Token(object): +class Token: """Base class for all other classes in this module. It represents a single token and has two instance attributes: @@ -48,17 +44,26 @@ class Token(object): the type of the token. """ - __slots__ = ('value', 'ttype', 'parent', 'normalized', 'is_keyword', - 'is_group', 'is_whitespace') + __slots__ = ( + 'is_group', + 'is_keyword', + 'is_newline', + 'is_whitespace', + 'normalized', + 'parent', + 'ttype', + 'value', + ) def __init__(self, ttype, value): - value = text_type(value) + value = str(value) self.value = value self.ttype = ttype self.parent = None self.is_group = False self.is_keyword = ttype in T.Keyword self.is_whitespace = self.ttype in T.Whitespace + self.is_newline = self.ttype in T.Newline self.normalized = value.upper() if self.is_keyword else value def __str__(self): @@ -72,15 +77,15 @@ def __repr__(self): cls = self._get_repr_name() value = self._get_repr_value() - q = u'"' if value.startswith("'") and value.endswith("'") else u"'" - return u"<{cls} {q}{value}{q} at 0x{id:2X}>".format( + q = '"' if value.startswith("'") and value.endswith("'") else "'" + return "<{cls} {q}{value}{q} at 0x{id:2X}>".format( id=id(self), **locals()) def _get_repr_name(self): return str(self.ttype).split('.')[-1] def _get_repr_value(self): - raw = text_type(self) + raw = str(self) if len(raw) > 7: raw = raw[:6] + '...' return re.sub(r'\s+', ' ', raw) @@ -92,31 +97,28 @@ def flatten(self): def match(self, ttype, values, regex=False): """Checks whether the token matches the given arguments. - *ttype* is a token type. If this token doesn't match the given token - type. - *values* is a list of possible values for this token. The values - are OR'ed together so if only one of the values matches ``True`` - is returned. Except for keyword tokens the comparison is - case-sensitive. For convenience it's OK to pass in a single string. - If *regex* is ``True`` (default is ``False``) the given values are - treated as regular expressions. + *ttype* is a token type as defined in `sqlparse.tokens`. If it does + not match, ``False`` is returned. + *values* is a list of possible values for this token. For match to be + considered valid, the token value needs to be in this list. For tokens + of type ``Keyword`` the comparison is case-insensitive. For + convenience, a single value can be given passed as a string. + If *regex* is ``True``, the given values are treated as regular + expressions. Partial matches are allowed. Defaults to ``False``. """ type_matched = self.ttype is ttype if not type_matched or values is None: return type_matched - if isinstance(values, string_types): + if isinstance(values, str): values = (values,) if regex: - # TODO: Add test for regex with is_keyboard = false + # TODO: Add test for regex with is_keyword = false flag = re.IGNORECASE if self.is_keyword else 0 values = (re.compile(v, flag) for v in values) - for pattern in values: - if pattern.search(self.normalized): - return True - return False + return any(pattern.search(self.normalized) for pattern in values) if self.is_keyword: values = (v.upper() for v in values) @@ -150,7 +152,6 @@ def has_ancestor(self, other): return False -@unicode_compatible class TokenList(Token): """A group of tokens. @@ -163,11 +164,11 @@ class TokenList(Token): def __init__(self, tokens=None): self.tokens = tokens or [] [setattr(token, 'parent', self) for token in self.tokens] - super(TokenList, self).__init__(None, text_type(self)) + super().__init__(None, ''.join(token.value for token in self.tokens)) self.is_group = True def __str__(self): - return u''.join(token.value for token in self.flatten()) + return ''.join(token.value for token in self.flatten()) # weird bug # def __len__(self): @@ -190,14 +191,13 @@ def _pprint_tree(self, max_depth=None, depth=0, f=None, _pre=''): value = token._get_repr_value() last = idx == (token_count - 1) - pre = u'`- ' if last else u'|- ' + pre = '`- ' if last else '|- ' - q = u'"' if value.startswith("'") and value.endswith("'") else u"'" - print(u"{_pre}{pre}{idx} {cls} {q}{value}{q}" - .format(**locals()), file=f) + q = '"' if value.startswith("'") and value.endswith("'") else "'" + print(f"{_pre}{pre}{idx} {cls} {q}{value}{q}", file=f) if token.is_group and (max_depth is None or depth < max_depth): - parent_pre = u' ' if last else u'| ' + parent_pre = ' ' if last else '| ' token._pprint_tree(max_depth, depth + 1, f, _pre + parent_pre) def get_token_at_offset(self, offset): @@ -216,8 +216,7 @@ def flatten(self): """ for token in self.tokens: if token.is_group: - for item in token.flatten(): - yield item + yield from token.flatten() else: yield token @@ -240,16 +239,16 @@ def _token_matching(self, funcs, start=0, end=None, reverse=False): if reverse: assert end is None - for idx in range(start - 2, -1, -1): - token = self.tokens[idx] - for func in funcs: - if func(token): - return idx, token + indexes = range(start - 2, -1, -1) else: - for idx, token in enumerate(self.tokens[start:end], start=start): - for func in funcs: - if func(token): - return idx, token + if end is None: + end = len(self.tokens) + indexes = range(start, end) + for idx in indexes: + token = self.tokens[idx] + for func in funcs: + if func(token): + return idx, token return None, None def token_first(self, skip_ws=True, skip_cm=False): @@ -273,7 +272,7 @@ def token_next_by(self, i=None, m=None, t=None, idx=-1, end=None): def token_not_matching(self, funcs, idx): funcs = (funcs,) if not isinstance(funcs, (list, tuple)) else funcs - funcs = [lambda tk: not func(tk) for func in funcs] + funcs = [lambda tk, func=func: not func(tk) for func in funcs] return self._token_matching(funcs, idx) def token_matching(self, funcs, idx): @@ -328,7 +327,7 @@ def group_tokens(self, grp_cls, start, end, include_end=True, grp = start grp.tokens.extend(subtokens) del self.tokens[start_idx + 1:end_idx] - grp.value = text_type(start) + grp.value += ''.join(token.value for token in subtokens) else: subtokens = self.tokens[start_idx:end_idx] grp = grp_cls(subtokens) @@ -419,27 +418,28 @@ def get_type(self): Whitespaces and comments at the beginning of the statement are ignored. """ - first_token = self.token_first(skip_cm=True) - if first_token is None: + token = self.token_first(skip_cm=True) + if token is None: # An "empty" statement that either has not tokens at all # or only whitespace tokens. return 'UNKNOWN' - elif first_token.ttype in (T.Keyword.DML, T.Keyword.DDL): - return first_token.normalized + elif token.ttype in (T.Keyword.DML, T.Keyword.DDL): + return token.normalized - elif first_token.ttype == T.Keyword.CTE: + elif token.ttype == T.Keyword.CTE: # The WITH keyword should be followed by either an Identifier or # an IdentifierList containing the CTE definitions; the actual # DML keyword (e.g. SELECT, INSERT) will follow next. - fidx = self.token_index(first_token) - tidx, token = self.token_next(fidx, skip_ws=True) - if isinstance(token, (Identifier, IdentifierList)): - _, dml_keyword = self.token_next(tidx, skip_ws=True) + tidx = self.token_index(token) + while tidx is not None: + tidx, token = self.token_next(tidx, skip_ws=True) + if isinstance(token, (Identifier, IdentifierList)): + tidx, token = self.token_next(tidx, skip_ws=True) - if dml_keyword is not None \ - and dml_keyword.ttype == T.Keyword.DML: - return dml_keyword.normalized + if token is not None \ + and token.ttype == T.Keyword.DML: + return token.normalized # Hmm, probably invalid syntax, so return unknown. return 'UNKNOWN' @@ -556,7 +556,12 @@ class Where(TokenList): M_OPEN = T.Keyword, 'WHERE' M_CLOSE = T.Keyword, ( 'ORDER BY', 'GROUP BY', 'LIMIT', 'UNION', 'UNION ALL', 'EXCEPT', - 'HAVING', 'RETURNING', 'INTO') + 'INTERSECT', 'HAVING', 'RETURNING', 'INTO') + + +class Over(TokenList): + """An OVER clause.""" + M_OPEN = T.Keyword, 'OVER' class Having(TokenList): @@ -583,10 +588,7 @@ def get_cases(self, skip_ws=False): for token in self.tokens: # Set mode from the current statement - if token.match(T.Keyword, 'CASE'): - continue - - elif skip_ws and token.ttype in T.Whitespace: + if token.match(T.Keyword, 'CASE') or (skip_ws and token.ttype in T.Whitespace): continue elif token.match(T.Keyword, 'WHEN'): @@ -623,13 +625,22 @@ class Function(NameAliasMixin, TokenList): def get_parameters(self): """Return a list of parameters.""" - parenthesis = self.tokens[-1] + parenthesis = self.token_next_by(i=Parenthesis)[1] + result = [] for token in parenthesis.tokens: if isinstance(token, IdentifierList): return token.get_identifiers() - elif imt(token, i=(Function, Identifier), t=T.Literal): - return [token, ] - return [] + elif imt(token, i=(Function, Identifier, TypedLiteral), + t=T.Literal): + result.append(token) + return result + + def get_window(self): + """Return the window if it exists.""" + over_clause = self.token_next_by(i=Over) + if not over_clause: + return None + return over_clause[1].tokens[-1] class Begin(TokenList): diff --git a/sqlparse/tokens.py b/sqlparse/tokens.py index eefc0b49..143f66b4 100644 --- a/sqlparse/tokens.py +++ b/sqlparse/tokens.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This module is part of python-sqlparse and is released under @@ -20,6 +19,9 @@ def __contains__(self, item): return item is not None and (self is item or item[:len(self)] == self) def __getattr__(self, name): + # don't mess with dunder + if name.startswith('__'): + return super().__getattr__(self, name) new = _TokenType(self + (name,)) setattr(self, name, new) new.parent = self diff --git a/sqlparse/utils.py b/sqlparse/utils.py index 3283274d..58c0245a 100644 --- a/sqlparse/utils.py +++ b/sqlparse/utils.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2009-2018 the sqlparse authors and contributors +# Copyright (C) 2009-2020 the sqlparse authors and contributors # # # This module is part of python-sqlparse and is released under @@ -10,7 +9,6 @@ import re from collections import deque from contextlib import contextmanager -from sqlparse.compat import text_type # This regular expression replaces the home-cooked parser that was here before. # It is much faster, but requires an extra post-processing step to get the @@ -40,7 +38,7 @@ def split_unquoted_newlines(stmt): Unlike str.splitlines(), this will ignore CR/LF/CR+LF if the requisite character is inside of a string.""" - text = text_type(stmt) + text = str(stmt) lines = SPLIT_REGEX.split(text) outputlines = [''] for line in lines: @@ -57,7 +55,7 @@ def remove_quotes(val): """Helper that removes surrounding quotes from strings.""" if val is None: return - if val[0] in ('"', "'") and val[0] == val[-1]: + if val[0] in ('"', "'", '`') and val[0] == val[-1]: val = val[1:-1] return val @@ -88,20 +86,23 @@ def imt(token, i=None, m=None, t=None): :param t: TokenType or Tuple/List of TokenTypes :return: bool """ - clss = i - types = [t, ] if t and not isinstance(t, list) else t - mpatterns = [m, ] if m and not isinstance(m, list) else m - if token is None: return False - elif clss and isinstance(token, clss): - return True - elif mpatterns and any(token.match(*pattern) for pattern in mpatterns): + if i and isinstance(token, i): return True - elif types and any(token.ttype in ttype for ttype in types): - return True - else: - return False + if m: + if isinstance(m, list): + if any(token.match(*pattern) for pattern in m): + return True + elif token.match(*m): + return True + if t: + if isinstance(t, list): + if any(token.ttype in ttype for ttype in t): + return True + elif token.ttype in t: + return True + return False def consume(iterator, n): diff --git a/tests/conftest.py b/tests/conftest.py index f2473a43..939c481d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """Helpers for testing.""" import io @@ -35,7 +33,7 @@ def make_load_file(filename, encoding='utf-8'): # https://stackoverflow.com/questions/18011902/py-test-pass-a-parameter-to-a-fixture-function/33879151#33879151 # Syntax is noisy and requires specific variable names # And seems to be limited to only 1 argument. - with io.open(filepath(filename), encoding=encoding) as f: + with open(filepath(filename), encoding=encoding) as f: return f.read().strip() return make_load_file @@ -44,6 +42,6 @@ def make_load_file(filename, encoding='utf-8'): @pytest.fixture() def get_stream(filepath): def make_stream(filename, encoding='utf-8'): - return io.open(filepath(filename), encoding=encoding) + return open(filepath(filename), encoding=encoding) return make_stream diff --git a/tests/files/casewhen_procedure.sql b/tests/files/casewhen_procedure.sql new file mode 100644 index 00000000..e590d49b --- /dev/null +++ b/tests/files/casewhen_procedure.sql @@ -0,0 +1,8 @@ +create procedure procName() +begin + select case when column = 'value' then column else 0 end; +end; +create procedure procName() +begin + select 1; +end; diff --git a/tests/files/multiple_case_in_begin.sql b/tests/files/multiple_case_in_begin.sql new file mode 100644 index 00000000..6cbb3864 --- /dev/null +++ b/tests/files/multiple_case_in_begin.sql @@ -0,0 +1,8 @@ +CREATE TRIGGER mytrig +AFTER UPDATE OF vvv ON mytable +BEGIN + UPDATE aa + SET mycola = (CASE WHEN (A=1) THEN 2 END); + UPDATE bb + SET mycolb = (CASE WHEN (B=1) THEN 5 END); +END; \ No newline at end of file diff --git a/tests/files/mysql_handler.sql b/tests/files/mysql_handler.sql new file mode 100644 index 00000000..702374ef --- /dev/null +++ b/tests/files/mysql_handler.sql @@ -0,0 +1,10 @@ +create procedure proc1() +begin + declare handler for foo begin end; + select 1; +end; + +create procedure proc2() +begin + select 1; +end; diff --git a/tests/test_cli.py b/tests/test_cli.py index 5f1ea0e5..4aec44d3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import subprocess import sys @@ -71,75 +69,138 @@ def test_stdout(filepath, load_file, capsys): def test_script(): # Call with the --help option as a basic sanity check. - cmd = "{0:s} -m sqlparse.cli --help".format(sys.executable) - assert subprocess.call(cmd.split()) == 0 - - -def test_encoding_utf8_stdout(filepath, load_file, capfd): - path = filepath('encoding_utf8.sql') - expected = load_file('encoding_utf8.sql', 'utf-8') - sys.stdout.encoding = 'utf-8' - sqlparse.cli.main([path]) - out, _ = capfd.readouterr() - assert out == expected - - -def test_encoding_utf8_output_file(filepath, load_file, tmpdir): - in_path = filepath('encoding_utf8.sql') - expected = load_file('encoding_utf8.sql', 'utf-8') - out_path = tmpdir.dirname + '/encoding_utf8.out.sql' - sqlparse.cli.main([in_path, '-o', out_path]) - out = load_file(out_path, 'utf-8') - assert out == expected - - -def test_encoding_gbk_stdout(filepath, load_file, capfd): - path = filepath('encoding_gbk.sql') - expected = load_file('encoding_gbk.sql', 'gbk') - sys.stdout.encoding = 'gbk' - sqlparse.cli.main([path, '--encoding', 'gbk']) + cmd = [sys.executable, '-m', 'sqlparse.cli', '--help'] + assert subprocess.call(cmd) == 0 + + +@pytest.mark.parametrize('fpath, encoding', ( + ('encoding_utf8.sql', 'utf-8'), + ('encoding_gbk.sql', 'gbk'), +)) +def test_encoding_stdout(fpath, encoding, filepath, load_file, capfd): + path = filepath(fpath) + expected = load_file(fpath, encoding) + sqlparse.cli.main([path, '--encoding', encoding]) out, _ = capfd.readouterr() assert out == expected -def test_encoding_gbk_output_file(filepath, load_file, tmpdir): - in_path = filepath('encoding_gbk.sql') - expected = load_file('encoding_gbk.sql', 'gbk') - out_path = tmpdir.dirname + '/encoding_gbk.out.sql' - sqlparse.cli.main([in_path, '--encoding', 'gbk', '-o', out_path]) - out = load_file(out_path, 'gbk') +@pytest.mark.parametrize('fpath, encoding', ( + ('encoding_utf8.sql', 'utf-8'), + ('encoding_gbk.sql', 'gbk'), +)) +def test_encoding_output_file(fpath, encoding, filepath, load_file, tmpdir): + in_path = filepath(fpath) + expected = load_file(fpath, encoding) + out_path = tmpdir.dirname + '/encoding_out.sql' + sqlparse.cli.main([in_path, '--encoding', encoding, '-o', out_path]) + out = load_file(out_path, encoding) assert out == expected -def test_encoding_stdin_utf8(filepath, load_file, capfd): - path = filepath('encoding_utf8.sql') - expected = load_file('encoding_utf8.sql', 'utf-8') +@pytest.mark.parametrize('fpath, encoding', ( + ('encoding_utf8.sql', 'utf-8'), + ('encoding_gbk.sql', 'gbk'), +)) +def test_encoding_stdin(fpath, encoding, filepath, load_file, capfd): + path = filepath(fpath) + expected = load_file(fpath, encoding) old_stdin = sys.stdin - with open(path, 'r') as f: + with open(path) as f: sys.stdin = f - sys.stdout.encoding = 'utf-8' - sqlparse.cli.main(['-']) + sqlparse.cli.main(['-', '--encoding', encoding]) sys.stdin = old_stdin out, _ = capfd.readouterr() assert out == expected -def test_encoding_stdin_gbk(filepath, load_file, capfd): - path = filepath('encoding_gbk.sql') - expected = load_file('encoding_gbk.sql', 'gbk') - old_stdin = sys.stdin - with open(path, 'r') as stream: - sys.stdin = stream - sys.stdout.encoding = 'gbk' - sqlparse.cli.main(['-', '--encoding', 'gbk']) - sys.stdin = old_stdin - out, _ = capfd.readouterr() - assert out == expected - - def test_encoding(filepath, capsys): path = filepath('test_cp1251.sql') - expected = u'insert into foo values (1); -- Песня про надежду\n' + expected = 'insert into foo values (1); -- Песня про надежду\n' sqlparse.cli.main([path, '--encoding=cp1251']) out, _ = capsys.readouterr() assert out == expected + + +def test_cli_multiple_files_with_inplace(tmpdir): + """Test CLI with multiple files and --in-place flag.""" + # Create test files + file1 = tmpdir.join("test1.sql") + file1.write("select * from foo") + file2 = tmpdir.join("test2.sql") + file2.write("select * from bar") + + # Run sqlformat with --in-place + result = sqlparse.cli.main([str(file1), str(file2), '--in-place', '--reindent']) + + assert result == 0 + # Files should be modified in-place + assert "select" in file1.read() + assert "select" in file2.read() + + +def test_cli_multiple_files_without_inplace_fails(tmpdir, capsys): + """Test that multiple files require --in-place flag.""" + file1 = tmpdir.join("test1.sql") + file1.write("select * from foo") + file2 = tmpdir.join("test2.sql") + file2.write("select * from bar") + + result = sqlparse.cli.main([str(file1), str(file2)]) + + assert result != 0 # Should fail + _, err = capsys.readouterr() + assert "Multiple files require --in-place flag" in err + + +def test_cli_inplace_with_stdin_fails(capsys): + """Test that --in-place flag cannot be used with stdin.""" + result = sqlparse.cli.main(['-', '--in-place']) + assert result != 0 # Should fail + _, err = capsys.readouterr() + assert "Cannot use --in-place with stdin" in err + + +def test_cli_outfile_with_multiple_files_fails(tmpdir, capsys): + """Test that -o cannot be used with multiple files.""" + file1 = tmpdir.join("test1.sql") + file1.write("select * from foo") + file2 = tmpdir.join("test2.sql") + file2.write("select * from bar") + out = tmpdir.join("out.sql") + + result = sqlparse.cli.main([str(file1), str(file2), '-o', str(out)]) + assert result != 0 # Should fail + _, err = capsys.readouterr() + assert "Cannot use -o/--outfile with multiple files" in err + + +def test_cli_single_file_inplace(tmpdir): + """Test --in-place flag with a single file.""" + test_file = tmpdir.join("test.sql") + test_file.write("select * from foo") + + result = sqlparse.cli.main([str(test_file), '--in-place', '--keywords', 'upper']) + + assert result == 0 + content = test_file.read() + assert "SELECT" in content + + +def test_cli_error_handling_continues(tmpdir, capsys): + """Test that errors in one file don't stop processing of others.""" + file1 = tmpdir.join("test1.sql") + file1.write("select * from foo") + # file2 doesn't exist - it will cause an error + file3 = tmpdir.join("test3.sql") + file3.write("select * from baz") + + result = sqlparse.cli.main([str(file1), str(tmpdir.join("nonexistent.sql")), + str(file3), '--in-place']) + + # Should return error code but still process valid files + assert result != 0 + assert "select * from foo" in file1.read() + assert "select * from baz" in file3.read() + _, err = capsys.readouterr() + assert "Failed to read" in err diff --git a/tests/test_dos_prevention.py b/tests/test_dos_prevention.py new file mode 100644 index 00000000..1753c05b --- /dev/null +++ b/tests/test_dos_prevention.py @@ -0,0 +1,119 @@ +"""Tests for DoS prevention mechanisms in sqlparse.""" + +import pytest +import sqlparse +from sqlparse.exceptions import SQLParseError +import time + + +class TestDoSPrevention: + """Test cases to ensure sqlparse is protected against DoS attacks.""" + + def test_large_tuple_list_performance(self): + """Test that parsing a large list of tuples raises SQLParseError.""" + # Generate SQL with many tuples (like Django composite primary key queries) + sql = ''' + SELECT "composite_pk_comment"."tenant_id", "composite_pk_comment"."comment_id" + FROM "composite_pk_comment" + WHERE ("composite_pk_comment"."tenant_id", "composite_pk_comment"."comment_id") IN (''' + + # Generate 5000 tuples - this should trigger MAX_GROUPING_TOKENS + tuples = [] + for i in range(1, 5001): + tuples.append(f"(1, {i})") + + sql += ", ".join(tuples) + ")" + + # Should raise SQLParseError due to token limit + with pytest.raises(SQLParseError, match="Maximum number of tokens exceeded"): + sqlparse.format(sql, reindent=True, keyword_case="upper") + + def test_deeply_nested_groups_limited(self): + """Test that deeply nested groups raise SQLParseError.""" + # Create deeply nested parentheses + sql = "SELECT " + "(" * 200 + "1" + ")" * 200 + + # Should raise SQLParseError due to depth limit + with pytest.raises(SQLParseError, match="Maximum grouping depth exceeded"): + sqlparse.format(sql, reindent=True) + + def test_very_large_token_list_limited(self): + """Test that very large token lists raise SQLParseError.""" + # Create a SQL with many identifiers + identifiers = [] + for i in range(15000): # More than MAX_GROUPING_TOKENS + identifiers.append(f"col{i}") + + sql = f"SELECT {', '.join(identifiers)} FROM table1" + + # Should raise SQLParseError due to token limit + with pytest.raises(SQLParseError, match="Maximum number of tokens exceeded"): + sqlparse.format(sql, reindent=True) + + def test_nested_paren_within_cap_under_1s(self): + """Reaching MAX_GROUPING_DEPTH must not require multi-second CPU. + + Before the TokenList.__init__ fix, a 1 KB payload of 500 nested + parens took ~1.3 s and a 2 KB payload of 1000 nested parens took + ~11 s before the depth cap raised SQLParseError, because each + TokenList materialised its ``value`` via ``str(self)`` which + recursed over the full subtree (O(n * depth)). + """ + sql = 'SELECT ' + '(' * 1000 + '1' + ')' * 1000 + t0 = time.perf_counter() + with pytest.raises(SQLParseError, match='Maximum grouping depth'): + sqlparse.parse(sql) + dt = time.perf_counter() - t0 + assert dt < 1.0, f'parse took {dt:.2f}s, expected sub-second' + + def test_nested_case_within_cap_under_1s(self): + """Same invariant as nested parentheses, exercised via CASE WHEN.""" + case = '1' + for i in range(400): + case = f'CASE WHEN x={i} THEN {case} ELSE NULL END' + sql = f'SELECT {case} FROM t' + t0 = time.perf_counter() + with pytest.raises(SQLParseError, match='Maximum grouping depth'): + sqlparse.parse(sql) + dt = time.perf_counter() - t0 + assert dt < 1.0, f'parse took {dt:.2f}s, expected sub-second' + + def test_normal_sql_still_works(self): + """Test that normal SQL still works correctly after DoS protections.""" + sql = """ + SELECT u.id, u.name, p.title + FROM users u + JOIN posts p ON u.id = p.user_id + WHERE u.active = 1 + AND p.published_at > '2023-01-01' + ORDER BY p.published_at DESC + """ + + result = sqlparse.format(sql, reindent=True, keyword_case="upper") + + assert "SELECT" in result + assert "FROM" in result + assert "JOIN" in result + assert "WHERE" in result + assert "ORDER BY" in result + + def test_reasonable_tuple_list_works(self): + """Test that reasonable-sized tuple lists still work correctly.""" + sql = ''' + SELECT id FROM table1 + WHERE (col1, col2) IN (''' + + # 100 tuples should work fine + tuples = [] + for i in range(1, 101): + tuples.append(f"({i}, {i * 2})") + + sql += ", ".join(tuples) + ")" + + result = sqlparse.format(sql, reindent=True, keyword_case="upper") + + assert "SELECT" in result + assert "WHERE" in result + assert "IN" in result + assert "1," in result # First tuple should be there + assert "200" in result # Last tuple should be there diff --git a/tests/test_format.py b/tests/test_format.py index 811e0833..0cdbcf88 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -1,12 +1,10 @@ -# -*- coding: utf-8 -*- - import pytest import sqlparse from sqlparse.exceptions import SQLParseError -class TestFormat(object): +class TestFormat: def test_keywordcase(self): sql = 'select * from bar; -- select foo\n' res = sqlparse.format(sql, keyword_case='upper') @@ -43,26 +41,32 @@ def test_identifiercase_quotes(self): def test_strip_comments_single(self): sql = 'select *-- statement starts here\nfrom foo' res = sqlparse.format(sql, strip_comments=True) - assert res == 'select * from foo' + assert res == 'select *\nfrom foo' sql = 'select * -- statement starts here\nfrom foo' res = sqlparse.format(sql, strip_comments=True) - assert res == 'select * from foo' + assert res == 'select *\nfrom foo' sql = 'select-- foo\nfrom -- bar\nwhere' res = sqlparse.format(sql, strip_comments=True) - assert res == 'select from where' + assert res == 'select\nfrom\nwhere' sql = 'select *-- statement starts here\n\nfrom foo' res = sqlparse.format(sql, strip_comments=True) - assert res == 'select * from foo' + assert res == 'select *\n\nfrom foo' sql = 'select * from foo-- statement starts here\nwhere' res = sqlparse.format(sql, strip_comments=True) - assert res == 'select * from foo where' + assert res == 'select * from foo\nwhere' sql = 'select a-- statement starts here\nfrom foo' res = sqlparse.format(sql, strip_comments=True) - assert res == 'select a from foo' + assert res == 'select a\nfrom foo' sql = '--comment\nselect a-- statement starts here\n' \ 'from foo--comment\nf' res = sqlparse.format(sql, strip_comments=True) - assert res == 'select a from foo f' + assert res == 'select a\nfrom foo\nf' + sql = '--A;--B;' + res = '' + assert res == sqlparse.format(sql, strip_comments=True) + sql = '--A;\n--B;' + res = '' + assert res == sqlparse.format(sql, strip_comments=True) def test_strip_comments_invalid_option(self): sql = 'select-- foo\nfrom -- bar\nwhere' @@ -75,17 +79,65 @@ def test_strip_comments_multi(self): assert res == 'select' sql = '/* sql starts here */ select' res = sqlparse.format(sql, strip_comments=True) - assert res == 'select' + assert res == ' select' # note whitespace is preserved, see issue 772 sql = '/*\n * sql starts here\n */\nselect' res = sqlparse.format(sql, strip_comments=True) assert res == 'select' - sql = 'select (/* sql starts here */ select 2)' + sql = '/* sql starts here */' res = sqlparse.format(sql, strip_comments=True) + assert res == '' + sql = '/* sql starts here */\n/* or here */' + res = sqlparse.format(sql, strip_comments=True, strip_whitespace=True) + assert res == '' + sql = 'select (/* sql starts here */ select 2)' + res = sqlparse.format(sql, strip_comments=True, strip_whitespace=True) assert res == 'select (select 2)' sql = 'select (/* sql /* starts here */ select 2)' - res = sqlparse.format(sql, strip_comments=True) + res = sqlparse.format(sql, strip_comments=True, strip_whitespace=True) assert res == 'select (select 2)' + def test_strip_comments_preserves_linebreak(self): + sql = 'select * -- a comment\r\nfrom foo' + res = sqlparse.format(sql, strip_comments=True) + assert res == 'select *\nfrom foo' + sql = 'select * -- a comment\nfrom foo' + res = sqlparse.format(sql, strip_comments=True) + assert res == 'select *\nfrom foo' + sql = 'select * -- a comment\rfrom foo' + res = sqlparse.format(sql, strip_comments=True) + assert res == 'select *\nfrom foo' + sql = 'select * -- a comment\r\n\r\nfrom foo' + res = sqlparse.format(sql, strip_comments=True) + assert res == 'select *\n\nfrom foo' + sql = 'select * -- a comment\n\nfrom foo' + res = sqlparse.format(sql, strip_comments=True) + assert res == 'select *\n\nfrom foo' + + def test_strip_comments_preserves_whitespace(self): + sql = 'SELECT 1/*bar*/ AS foo' # see issue772 + res = sqlparse.format(sql, strip_comments=True) + assert res == 'SELECT 1 AS foo' + + def test_strip_comments_preserves_hint(self): + sql = 'select --+full(u)' + res = sqlparse.format(sql, strip_comments=True) + assert res == sql + sql = '#+ hint\nselect * from foo' + res = sqlparse.format(sql, strip_comments=True) + assert res == sql + sql = 'select --+full(u)\n--comment simple' + res = sqlparse.format(sql, strip_comments=True) + assert res == 'select --+full(u)\n' + sql = '#+ hint\nselect * from foo\n# comment simple' + res = sqlparse.format(sql, strip_comments=True) + assert res == '#+ hint\nselect * from foo\n' + sql = 'SELECT /*+cluster(T)*/* FROM T_EEE T where A >:1' + res = sqlparse.format(sql, strip_comments=True) + assert res == sql + sql = 'insert /*+ DIRECT */ into sch.table_name as select * from foo' + res = sqlparse.format(sql, strip_comments=True) + assert res == sql + def test_strip_ws(self): f = lambda sql: sqlparse.format(sql, strip_whitespace=True) s = 'select\n* from foo\n\twhere ( 1 = 2 )\n' @@ -123,7 +175,7 @@ def test_notransform_of_quoted_crlf(self): == "SELECT some_column LIKE 'value\\\\\\'\r' WHERE id = 1\n") -class TestFormatReindentAligned(object): +class TestFormatReindentAligned: @staticmethod def formatter(sql): return sqlparse.format(sql, reindent_aligned=True) @@ -294,7 +346,7 @@ def test_window_functions(self): ' from table']) -class TestSpacesAroundOperators(object): +class TestSpacesAroundOperators: @staticmethod def formatter(sql): return sqlparse.format(sql, use_space_around_operators=True) @@ -321,7 +373,7 @@ def test_wildcard_vs_mult(self): assert self.formatter(sql) == 'select a * b - c from table' -class TestFormatReindent(object): +class TestFormatReindent: def test_option(self): with pytest.raises(SQLParseError): sqlparse.format('foo', reindent=2) @@ -598,7 +650,7 @@ def test_insert_values(self): ' , (5, 6)']) -class TestOutputFormat(object): +class TestOutputFormat: def test_python(self): sql = 'select * from foo;' f = lambda sql: sqlparse.format(sql, output_format='python') @@ -663,7 +715,7 @@ def test_format_column_ordering(): def test_truncate_strings(): - sql = "update foo set value = '{0}';".format('x' * 1000) + sql = "update foo set value = '{}';".format('x' * 1000) formatted = sqlparse.format(sql, truncate_strings=10) assert formatted == "update foo set value = 'xxxxxxxxxx[...]';" formatted = sqlparse.format(sql, truncate_strings=3, truncate_char='YYY') @@ -707,3 +759,28 @@ def test_format_right_margin_invalid_option(right_margin): def test_format_right_margin(): # TODO: Needs better test, only raises exception right now sqlparse.format('foo', right_margin="79") + + +def test_format_json_ops(): # issue542 + formatted = sqlparse.format( + "select foo->'bar', foo->'bar';", reindent=True) + expected = "select foo->'bar',\n foo->'bar';" + assert formatted == expected + + +@pytest.mark.parametrize('sql, expected_normal, expected_compact', [ + ('case when foo then 1 else bar end', + 'case\n when foo then 1\n else bar\nend', + 'case when foo then 1 else bar end')]) +def test_compact(sql, expected_normal, expected_compact): # issue783 + formatted_normal = sqlparse.format(sql, reindent=True) + formatted_compact = sqlparse.format(sql, reindent=True, compact=True) + assert formatted_normal == expected_normal + assert formatted_compact == expected_compact + + +def test_strip_ws_removes_trailing_ws_in_groups(): # issue782 + formatted = sqlparse.format('( where foo = bar ) from', + strip_whitespace=True) + expected = '(where foo = bar) from' + assert formatted == expected diff --git a/tests/test_grouping.py b/tests/test_grouping.py index 87dcf117..bf278817 100644 --- a/tests/test_grouping.py +++ b/tests/test_grouping.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import pytest import sqlparse @@ -19,13 +17,6 @@ def test_grouping_parenthesis(): assert len(parsed.tokens[2].tokens[3].tokens) == 3 -def test_grouping_comments(): - s = '/*\n * foo\n */ \n bar' - parsed = sqlparse.parse(s)[0] - assert str(parsed) == s - assert len(parsed.tokens) == 2 - - @pytest.mark.parametrize('s', ['foo := 1;', 'foo := 1']) def test_grouping_assignment(s): parsed = sqlparse.parse(s)[0] @@ -187,6 +178,20 @@ def test_grouping_identifier_function(): assert isinstance(p.tokens[0], sql.Identifier) assert isinstance(p.tokens[0].tokens[0], sql.Operation) assert isinstance(p.tokens[0].tokens[0].tokens[0], sql.Function) + p = sqlparse.parse('foo(c1) over win1 as bar')[0] + assert isinstance(p.tokens[0], sql.Identifier) + assert isinstance(p.tokens[0].tokens[0], sql.Function) + assert len(p.tokens[0].tokens[0].tokens) == 4 + assert isinstance(p.tokens[0].tokens[0].tokens[3], sql.Over) + assert isinstance(p.tokens[0].tokens[0].tokens[3].tokens[2], + sql.Identifier) + p = sqlparse.parse('foo(c1) over (partition by c2 order by c3) as bar')[0] + assert isinstance(p.tokens[0], sql.Identifier) + assert isinstance(p.tokens[0].tokens[0], sql.Function) + assert len(p.tokens[0].tokens[0].tokens) == 4 + assert isinstance(p.tokens[0].tokens[0].tokens[3], sql.Over) + assert isinstance(p.tokens[0].tokens[0].tokens[3].tokens[2], + sql.Parenthesis) @pytest.mark.parametrize('s', ['foo+100', 'foo + 100', 'foo*100']) @@ -249,6 +254,14 @@ def test_grouping_identifier_list_with_order(): assert str(p.tokens[0].tokens[3]) == '2 desc' +def test_grouping_nested_identifier_with_order(): + # issue745 + p = sqlparse.parse('(a desc)')[0] + assert isinstance(p.tokens[0], sql.Parenthesis) + assert isinstance(p.tokens[0].tokens[1], sql.Identifier) + assert str(p.tokens[0].tokens[1]) == 'a desc' + + def test_grouping_where(): s = 'select * from foo where bar = 1 order by id desc' p = sqlparse.parse(s)[0] @@ -326,6 +339,11 @@ def test_grouping_alias_case(): assert p.tokens[0].get_alias() == 'foo' +def test_grouping_alias_ctas(): + p = sqlparse.parse('CREATE TABLE tbl1 AS SELECT coalesce(t1.col1, 0) AS col1 FROM t1')[0] + assert p.tokens[10].get_alias() == 'col1' + assert isinstance(p.tokens[10].tokens[0], sql.Function) + def test_grouping_subquery_no_parens(): # Not totally sure if this is the right approach... # When a THEN clause contains a subquery w/o parenthesis around it *and* @@ -367,6 +385,14 @@ def test_grouping_function(): p = sqlparse.parse('foo(null, bar)')[0] assert isinstance(p.tokens[0], sql.Function) assert len(list(p.tokens[0].get_parameters())) == 2 + p = sqlparse.parse('foo(5) over win1')[0] + assert isinstance(p.tokens[0], sql.Function) + assert len(list(p.tokens[0].get_parameters())) == 1 + assert isinstance(p.tokens[0].get_window(), sql.Identifier) + p = sqlparse.parse('foo(5) over (PARTITION BY c1)')[0] + assert isinstance(p.tokens[0], sql.Function) + assert len(list(p.tokens[0].get_parameters())) == 1 + assert isinstance(p.tokens[0].get_window(), sql.Parenthesis) def test_grouping_function_not_in(): @@ -391,10 +417,6 @@ def f(sql): assert f(' update foo').get_type() == 'UPDATE' assert f('\nupdate foo').get_type() == 'UPDATE' assert f('foo').get_type() == 'UNKNOWN' - # Statements that have a whitespace after the closing semicolon - # are parsed as two statements where later only consists of the - # trailing whitespace. - assert f('\n').get_type() == 'UNKNOWN' def test_identifier_with_operators(): @@ -484,7 +506,7 @@ def test_comparison_with_parenthesis(): )) def test_comparison_with_strings(operator): # issue148 - p = sqlparse.parse("foo {0} 'bar'".format(operator))[0] + p = sqlparse.parse(f"foo {operator} 'bar'")[0] assert len(p.tokens) == 1 assert isinstance(p.tokens[0], sql.Comparison) assert p.tokens[0].right.value == "'bar'" @@ -563,7 +585,7 @@ def test_comparison_with_typed_literal(): @pytest.mark.parametrize('start', ['FOR', 'FOREACH']) def test_forloops(start): - p = sqlparse.parse('{0} foo in bar LOOP foobar END LOOP'.format(start))[0] + p = sqlparse.parse(f'{start} foo in bar LOOP foobar END LOOP')[0] assert (len(p.tokens)) == 1 assert isinstance(p.tokens[0], sql.For) @@ -651,3 +673,7 @@ def test_grouping_as_cte(): assert p[0].get_alias() is None assert p[2].value == 'AS' assert p[4].value == 'WITH' + +def test_grouping_create_table(): + p = sqlparse.parse("create table db.tbl (a string)")[0].tokens + assert p[4].value == "db.tbl" diff --git a/tests/test_keywords.py b/tests/test_keywords.py index c197f367..b26e9b45 100644 --- a/tests/test_keywords.py +++ b/tests/test_keywords.py @@ -1,8 +1,7 @@ -# -*- coding: utf-8 -*- import pytest from sqlparse import tokens -from sqlparse.keywords import SQL_REGEX +from sqlparse.lexer import Lexer class TestSQLREGEX: @@ -10,5 +9,5 @@ class TestSQLREGEX: '1.', '-1.', '.1', '-.1']) def test_float_numbers(self, number): - ttype = next(tt for action, tt in SQL_REGEX if action(number)) + ttype = next(tt for action, tt in Lexer.get_default_instance()._SQL_REGEX if action(number)) assert tokens.Number.Float == ttype diff --git a/tests/test_parse.py b/tests/test_parse.py index c28cb06e..34800cb7 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -1,12 +1,11 @@ -# -*- coding: utf-8 -*- - """Tests sqlparse.parse().""" +from io import StringIO import pytest import sqlparse -from sqlparse import sql, tokens as T -from sqlparse.compat import StringIO, text_type +from sqlparse import sql, tokens as T, keywords +from sqlparse.lexer import Lexer def test_parse_tokenize(): @@ -102,6 +101,12 @@ def test_parse_square_brackets_notation_isnt_too_greedy(): assert t[0].tokens[-1].get_real_name() == '[bar]' +def test_parse_square_brackets_notation_isnt_too_greedy2(): + # see issue583 + t = sqlparse.parse('[(foo[i])]')[0].tokens + assert isinstance(t[0], sql.SquareBrackets) # not Identifier! + + def test_parse_keyword_like_identifier(): # see issue47 t = sqlparse.parse('foo.key')[0].tokens @@ -128,6 +133,17 @@ def test_parse_nested_function(): assert type(t[0]) is sql.Function +def test_parse_casted_params(): + t = sqlparse.parse("foo(DATE '2023-11-14', TIMESTAMP '2023-11-15')")[0].tokens[0].get_parameters() + assert len(t) == 2 + + +def test_parse_div_operator(): + p = sqlparse.parse('col1 DIV 5 AS div_col1')[0].tokens + assert p[0].tokens[0].tokens[2].ttype is T.Operator + assert p[0].get_alias() == 'div_col1' + + def test_quoted_identifier(): t = sqlparse.parse('select x.y as "z" from foo')[0].tokens assert isinstance(t[2], sql.Identifier) @@ -138,6 +154,7 @@ def test_quoted_identifier(): @pytest.mark.parametrize('name', [ 'foo', '_foo', # issue175 '1_data', # valid MySQL table name, see issue337 + '業者名稱', # valid at least for SQLite3, see issue641 ]) def test_valid_identifier_names(name): t = sqlparse.parse(name)[0].tokens @@ -168,6 +185,14 @@ def test_psql_quotation_marks(): $PROC_2$ LANGUAGE plpgsql;""") assert len(t) == 2 + # operators are valid infront of dollar quoted strings + t = sqlparse.split("""UPDATE SET foo =$$bar;SELECT bar$$""") + assert len(t) == 1 + + # identifiers must be separated by whitespace + t = sqlparse.split("""UPDATE SET foo TO$$bar;SELECT bar$$""") + assert len(t) == 2 + def test_double_precision_is_builtin(): s = 'DOUBLE PRECISION' @@ -184,11 +209,16 @@ def test_placeholder(ph): assert p[0].ttype is T.Name.Placeholder -@pytest.mark.parametrize('num', ['6.67428E-8', '1.988e33', '1e-12']) -def test_scientific_numbers(num): +@pytest.mark.parametrize('num, expected', [ + ('6.67428E-8', T.Number.Float), + ('1.988e33', T.Number.Float), + ('1e-12', T.Number.Float), + ('e1', None), +]) +def test_scientific_numbers(num, expected): p = sqlparse.parse(num)[0].tokens assert len(p) == 1 - assert p[0].ttype is T.Number.Float + assert p[0].ttype is expected def test_single_quotes_are_strings(): @@ -332,7 +362,8 @@ def test_pprint(): "| | `- 0 Name 'd0'", "| |- 10 Punctuation ','", "| |- 11 Whitespace ' '", - "| `- 12 Float 'e0'", + "| `- 12 Identifier 'e0'", + "| `- 0 Name 'e0'", "|- 3 Whitespace ' '", "|- 4 Keyword 'from'", "|- 5 Whitespace ' '", @@ -409,26 +440,26 @@ def test_dbldollar_as_literal(sql, is_literal): def test_non_ascii(): - _test_non_ascii = u"insert into test (id, name) values (1, 'тест');" + _test_non_ascii = "insert into test (id, name) values (1, 'тест');" s = _test_non_ascii stmts = sqlparse.parse(s) assert len(stmts) == 1 statement = stmts[0] - assert text_type(statement) == s + assert str(statement) == s assert statement._pprint_tree() is None s = _test_non_ascii.encode('utf-8') stmts = sqlparse.parse(s, 'utf-8') assert len(stmts) == 1 statement = stmts[0] - assert text_type(statement) == _test_non_ascii + assert str(statement) == _test_non_ascii assert statement._pprint_tree() is None def test_get_real_name(): # issue 369 - s = u"update a t set t.b=1" + s = "update a t set t.b=1" stmts = sqlparse.parse(s) assert len(stmts) == 1 assert 'a' == stmts[0].tokens[2].get_real_name() @@ -437,14 +468,14 @@ def test_get_real_name(): def test_from_subquery(): # issue 446 - s = u'from(select 1)' + s = 'from(select 1)' stmts = sqlparse.parse(s) assert len(stmts) == 1 assert len(stmts[0].tokens) == 2 assert stmts[0].tokens[0].value == 'from' assert stmts[0].tokens[0].ttype == T.Keyword - s = u'from (select 1)' + s = 'from (select 1)' stmts = sqlparse.parse(s) assert len(stmts) == 1 assert len(stmts[0].tokens) == 3 @@ -472,3 +503,123 @@ def test_parenthesis(): T.Newline, T.Newline, T.Punctuation] + + +def test_configurable_keywords(): + sql = """select * from foo BACON SPAM EGGS;""" + tokens = sqlparse.parse(sql)[0] + + assert list( + (t.ttype, t.value) + for t in tokens + if t.ttype not in sqlparse.tokens.Whitespace + ) == [ + (sqlparse.tokens.Keyword.DML, "select"), + (sqlparse.tokens.Wildcard, "*"), + (sqlparse.tokens.Keyword, "from"), + (None, "foo BACON"), + (None, "SPAM EGGS"), + (sqlparse.tokens.Punctuation, ";"), + ] + + Lexer.get_default_instance().add_keywords( + { + "BACON": sqlparse.tokens.Name.Builtin, + "SPAM": sqlparse.tokens.Keyword, + "EGGS": sqlparse.tokens.Keyword, + } + ) + + tokens = sqlparse.parse(sql)[0] + + # reset the syntax for later tests. + Lexer.get_default_instance().default_initialization() + + assert list( + (t.ttype, t.value) + for t in tokens + if t.ttype not in sqlparse.tokens.Whitespace + ) == [ + (sqlparse.tokens.Keyword.DML, "select"), + (sqlparse.tokens.Wildcard, "*"), + (sqlparse.tokens.Keyword, "from"), + (None, "foo"), + (sqlparse.tokens.Name.Builtin, "BACON"), + (sqlparse.tokens.Keyword, "SPAM"), + (sqlparse.tokens.Keyword, "EGGS"), + (sqlparse.tokens.Punctuation, ";"), + ] + + +def test_regexp(): + s = "column REGEXP '.+static.+'" + stmts = sqlparse.parse(s) + + assert len(stmts) == 1 + assert stmts[0].tokens[0].ttype == T.Keyword + assert stmts[0].tokens[0].value == "column" + + assert stmts[0].tokens[2].ttype == T.Comparison + assert stmts[0].tokens[2].value == "REGEXP" + + assert stmts[0].tokens[4].ttype == T.Literal.String.Single + assert stmts[0].tokens[4].value == "'.+static.+'" + + +def test_regexp_binary(): + s = "column REGEXP BINARY '.+static.+'" + stmts = sqlparse.parse(s) + + assert len(stmts) == 1 + assert stmts[0].tokens[0].ttype == T.Keyword + assert stmts[0].tokens[0].value == "column" + + assert stmts[0].tokens[2].ttype == T.Comparison + assert stmts[0].tokens[2].value == "REGEXP BINARY" + + assert stmts[0].tokens[4].ttype == T.Literal.String.Single + assert stmts[0].tokens[4].value == "'.+static.+'" + + +def test_configurable_regex(): + lex = Lexer.get_default_instance() + lex.clear() + + my_regex = (r"ZORDER\s+BY\b", sqlparse.tokens.Keyword) + + lex.set_SQL_REGEX( + keywords.SQL_REGEX[:38] + + [my_regex] + + keywords.SQL_REGEX[38:] + ) + lex.add_keywords(keywords.KEYWORDS_COMMON) + lex.add_keywords(keywords.KEYWORDS_ORACLE) + lex.add_keywords(keywords.KEYWORDS_PLPGSQL) + lex.add_keywords(keywords.KEYWORDS_HQL) + lex.add_keywords(keywords.KEYWORDS_MSACCESS) + lex.add_keywords(keywords.KEYWORDS) + + tokens = sqlparse.parse("select * from foo zorder by bar;")[0] + + # reset the syntax for later tests. + Lexer.get_default_instance().default_initialization() + + assert list( + (t.ttype, t.value) + for t in tokens + if t.ttype not in sqlparse.tokens.Whitespace + )[4] == (sqlparse.tokens.Keyword, "zorder by") + + +@pytest.mark.parametrize('sql', [ + '->', '->>', '#>', '#>>', + '@>', '<@', + # leaving ? out for now, they're somehow ambiguous as placeholders + # '?', '?|', '?&', + '||', '-', '#-' +]) +def test_json_operators(sql): + p = sqlparse.parse(sql) + assert len(p) == 1 + assert len(p[0].tokens) == 1 + assert p[0].tokens[0].ttype == sqlparse.tokens.Operator diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 2ed0ff32..15ac9ee9 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -1,10 +1,11 @@ -# -*- coding: utf-8 -*- +import copy +import sys import pytest import sqlparse from sqlparse import sql, tokens as T -from sqlparse.compat import PY2 +from sqlparse.exceptions import SQLParseError def test_issue9(): @@ -20,9 +21,9 @@ def test_issue9(): def test_issue13(): - parsed = sqlparse.parse(("select 'one';\n" - "select 'two\\'';\n" - "select 'three';")) + parsed = sqlparse.parse("select 'one';\n" + "select 'two\\'';\n" + "select 'three';") assert len(parsed) == 3 assert str(parsed[1]).strip() == "select 'two\\'';" @@ -73,8 +74,8 @@ def test_issue39(): def test_issue40(): # make sure identifier lists in subselects are grouped - p = sqlparse.parse(('SELECT id, name FROM ' - '(SELECT id, name FROM bar) as foo'))[0] + p = sqlparse.parse('SELECT id, name FROM ' + '(SELECT id, name FROM bar) as foo')[0] assert len(p.tokens) == 7 assert p.tokens[2].__class__ == sql.IdentifierList assert p.tokens[-1].__class__ == sql.Identifier @@ -149,7 +150,7 @@ def test_issue83(): def test_comment_encoding_when_reindent(): # There was an UnicodeEncodeError in the reindent filter that # casted every comment followed by a keyword to str. - sql = u'select foo -- Comment containing Ümläuts\nfrom bar' + sql = 'select foo -- Comment containing Ümläuts\nfrom bar' formatted = sqlparse.format(sql, reindent=True) assert formatted == sql @@ -158,11 +159,9 @@ def test_parse_sql_with_binary(): # See https://github.com/andialbrecht/sqlparse/pull/88 # digest = '‚|ËêŠplL4¡h‘øN{' digest = '\x82|\xcb\x0e\xea\x8aplL4\xa1h\x91\xf8N{' - sql = "select * from foo where bar = '{0}'".format(digest) + sql = f"select * from foo where bar = '{digest}'" formatted = sqlparse.format(sql, reindent=True) - tformatted = "select *\nfrom foo\nwhere bar = '{0}'".format(digest) - if PY2: - tformatted = tformatted.decode('unicode-escape') + tformatted = f"select *\nfrom foo\nwhere bar = '{digest}'" assert formatted == tformatted @@ -180,7 +179,7 @@ def test_format_accepts_encoding(load_file): # issue20 sql = load_file('test_cp1251.sql', 'cp1251') formatted = sqlparse.format(sql, reindent=True, encoding='cp1251') - tformatted = u'insert into foo\nvalues (1); -- Песня про надежду' + tformatted = 'insert into foo\nvalues (1); -- Песня про надежду' assert formatted == tformatted @@ -275,7 +274,7 @@ def test_issue186_get_type(): def test_issue212_py2unicode(): - t1 = sql.Token(T.String, u'schöner ') + t1 = sql.Token(T.String, 'schöner ') t2 = sql.Token(T.String, 'bug') token_list = sql.TokenList([t1, t2]) assert str(token_list) == 'schöner bug' @@ -337,11 +336,9 @@ def test_issue315_utf8_by_default(): '\x9b\xb2.' '\xec\x82\xac\xeb\x9e\x91\xed\x95\xb4\xec\x9a\x94' ) - sql = "select * from foo where bar = '{0}'".format(digest) + sql = f"select * from foo where bar = '{digest}'" formatted = sqlparse.format(sql, reindent=True) - tformatted = "select *\nfrom foo\nwhere bar = '{0}'".format(digest) - if PY2: - tformatted = tformatted.decode('utf-8') + tformatted = f"select *\nfrom foo\nwhere bar = '{digest}'" assert formatted == tformatted @@ -406,3 +403,65 @@ def test_issue489_tzcasts(): p = sqlparse.parse('select bar at time zone \'UTC\' as foo')[0] assert p.tokens[-1].has_alias() is True assert p.tokens[-1].get_alias() == 'foo' + + +def test_issue562_tzcasts(): + # Test that whitespace between 'from' and 'bar' is retained + formatted = sqlparse.format( + 'SELECT f(HOUR from bar AT TIME ZONE \'UTC\') from foo', reindent=True + ) + assert formatted == \ + 'SELECT f(HOUR\n from bar AT TIME ZONE \'UTC\')\nfrom foo' + + +def test_as_in_parentheses_indents(): + # did raise NoneType has no attribute is_group in _process_parentheses + formatted = sqlparse.format('(as foo)', reindent=True) + assert formatted == '(as foo)' + + +def test_format_invalid_where_clause(): + # did raise ValueError + formatted = sqlparse.format('where, foo', reindent=True) + assert formatted == 'where, foo' + + +def test_splitting_at_and_backticks_issue588(): + splitted = sqlparse.split( + 'grant foo to user1@`myhost`; grant bar to user1@`myhost`;') + assert len(splitted) == 2 + assert splitted[-1] == 'grant bar to user1@`myhost`;' + + +def test_comment_between_cte_clauses_issue632(): + p, = sqlparse.parse(""" + WITH foo AS (), + -- A comment before baz subquery + baz AS () + SELECT * FROM baz;""") + assert p.get_type() == "SELECT" + + +def test_copy_issue672(): + p = sqlparse.parse('select * from foo')[0] + copied = copy.deepcopy(p) + assert str(p) == str(copied) + + +def test_primary_key_issue740(): + p = sqlparse.parse('PRIMARY KEY')[0] + assert len(p.tokens) == 1 + assert p.tokens[0].ttype == T.Keyword + + +@pytest.fixture +def limit_recursion(): + curr_limit = sys.getrecursionlimit() + sys.setrecursionlimit(100) + yield + sys.setrecursionlimit(curr_limit) + + +def test_max_recursion(limit_recursion): + with pytest.raises(SQLParseError): + sqlparse.parse('[' * 1000 + ']' * 1000) diff --git a/tests/test_split.py b/tests/test_split.py index a93e3d40..92c3fefe 100644 --- a/tests/test_split.py +++ b/tests/test_split.py @@ -1,13 +1,11 @@ -# -*- coding: utf-8 -*- - # Tests splitting functions. import types +from io import StringIO import pytest import sqlparse -from sqlparse.compat import StringIO, text_type def test_split_semicolon(): @@ -20,8 +18,8 @@ def test_split_semicolon(): def test_split_backslash(): - stmts = sqlparse.parse(r"select '\\'; select '\''; select '\\\'';") - assert len(stmts) == 3 + stmts = sqlparse.parse("select '\'; select '\'';") + assert len(stmts) == 2 @pytest.mark.parametrize('fn', ['function.sql', @@ -33,7 +31,7 @@ def test_split_create_function(load_file, fn): sql = load_file(fn) stmts = sqlparse.parse(sql) assert len(stmts) == 1 - assert text_type(stmts[0]) == sql + assert str(stmts[0]) == sql def test_split_dashcomments(load_file): @@ -74,12 +72,12 @@ def test_split_dropif(): def test_split_comment_with_umlaut(): - sql = (u'select * from foo;\n' - u'-- Testing an umlaut: ä\n' - u'select * from bar;') + sql = ('select * from foo;\n' + '-- Testing an umlaut: ä\n' + 'select * from bar;') stmts = sqlparse.parse(sql) assert len(stmts) == 2 - assert ''.join(text_type(q) for q in stmts) == sql + assert ''.join(str(q) for q in stmts) == sql def test_split_comment_end_of_line(): @@ -99,6 +97,12 @@ def test_split_casewhen(): assert len(stmts) == 2 +def test_split_casewhen_procedure(load_file): + # see issue580 + stmts = sqlparse.split(load_file('casewhen_procedure.sql')) + assert len(stmts) == 2 + + def test_split_cursor_declare(): sql = ('DECLARE CURSOR "foo" AS SELECT 1;\n' 'SELECT 2;') @@ -125,11 +129,11 @@ def test_split_stream(): def test_split_encoding_parsestream(): stream = StringIO("SELECT 1; SELECT 2;") stmts = list(sqlparse.parsestream(stream)) - assert isinstance(stmts[0].tokens[0].value, text_type) + assert isinstance(stmts[0].tokens[0].value, str) def test_split_unicode_parsestream(): - stream = StringIO(u'SELECT ö') + stream = StringIO('SELECT ö') stmts = list(sqlparse.parsestream(stream)) assert str(stmts[0]) == 'SELECT ö' @@ -141,6 +145,13 @@ def test_split_simple(): assert stmts[1] == 'select * from bar;' +def test_split_ignores_empty_newlines(): + stmts = sqlparse.split('select foo;\nselect bar;\n') + assert len(stmts) == 2 + assert stmts[0] == 'select foo;' + assert stmts[1] == 'select bar;' + + def test_split_quotes_with_new_line(): stmts = sqlparse.split('select "foo\nbar"') assert len(stmts) == 1 @@ -149,3 +160,217 @@ def test_split_quotes_with_new_line(): stmts = sqlparse.split("select 'foo\n\bar'") assert len(stmts) == 1 assert stmts[0] == "select 'foo\n\bar'" + + +def test_split_mysql_handler_for(load_file): + # see issue581 + stmts = sqlparse.split(load_file('mysql_handler.sql')) + assert len(stmts) == 2 + + +@pytest.mark.parametrize('sql, expected', [ + ('select * from foo;', ['select * from foo']), + ('select * from foo', ['select * from foo']), + ('select * from foo; select * from bar;', [ + 'select * from foo', + 'select * from bar', + ]), + (' select * from foo;\n\nselect * from bar;\n\n\n\n', [ + 'select * from foo', + 'select * from bar', + ]), + ('select * from foo\n\n; bar', ['select * from foo', 'bar']), +]) +def test_split_strip_semicolon(sql, expected): + stmts = sqlparse.split(sql, strip_semicolon=True) + assert len(stmts) == len(expected) + for idx, expectation in enumerate(expected): + assert stmts[idx] == expectation + + +def test_split_strip_semicolon_procedure(load_file): + stmts = sqlparse.split(load_file('mysql_handler.sql'), + strip_semicolon=True) + assert len(stmts) == 2 + assert stmts[0].endswith('end') + assert stmts[1].endswith('end') + +@pytest.mark.parametrize('sql, num', [ + ('USE foo;\nGO\nSELECT 1;\nGO', 4), + ('SELECT * FROM foo;\nGO', 2), + ('USE foo;\nGO 2\nSELECT 1;', 3) +]) +def test_split_go(sql, num): # issue762 + stmts = sqlparse.split(sql) + assert len(stmts) == num + + +def test_split_multiple_case_in_begin(load_file): # issue784 + stmts = sqlparse.split(load_file('multiple_case_in_begin.sql')) + assert len(stmts) == 1 + + +def test_split_if_exists_in_begin_end(): # issue812 + # IF EXISTS should not be confused with control flow IF + sql = """CREATE TASK t1 AS +BEGIN + CREATE OR REPLACE TABLE temp1; + DROP TABLE IF EXISTS temp1; +END; +EXECUTE TASK t1;""" + stmts = sqlparse.split(sql) + assert len(stmts) == 2 + assert 'CREATE TASK' in stmts[0] + assert 'EXECUTE TASK' in stmts[1] + + +def test_split_begin_end_semicolons(): # issue809 + # Semicolons inside BEGIN...END blocks should not split statements + sql = """WITH +FUNCTION meaning_of_life() + RETURNS tinyint + BEGIN + DECLARE a tinyint DEFAULT CAST(6 as tinyint); + DECLARE b tinyint DEFAULT CAST(7 as tinyint); + RETURN a * b; + END +SELECT meaning_of_life();""" + stmts = sqlparse.split(sql) + assert len(stmts) == 1 + assert 'WITH' in stmts[0] + assert 'SELECT meaning_of_life()' in stmts[0] + + +def test_split_begin_end_procedure(): # issue809 + # Test with CREATE PROCEDURE (BigQuery style) + sql = """CREATE OR REPLACE PROCEDURE mydataset.create_customer() +BEGIN + DECLARE id STRING; + SET id = GENERATE_UUID(); + INSERT INTO mydataset.customers (customer_id) + VALUES(id); + SELECT FORMAT("Created customer %s", id); +END;""" + stmts = sqlparse.split(sql) + assert len(stmts) == 1 + assert 'CREATE OR REPLACE PROCEDURE' in stmts[0] + + +def test_split_begin_transaction(): # issue826 + # BEGIN TRANSACTION should not be treated as a block start + sql = """BEGIN TRANSACTION; +DELETE FROM "schema"."table_a" USING "table_a_temp" WHERE "schema"."table_a"."id" = "table_a_temp"."id"; +INSERT INTO "schema"."table_a" SELECT * FROM "table_a_temp"; +END TRANSACTION;""" + stmts = sqlparse.split(sql) + assert len(stmts) == 4 + assert stmts[0] == 'BEGIN TRANSACTION;' + assert stmts[1].startswith('DELETE') + assert stmts[2].startswith('INSERT') + assert stmts[3] == 'END TRANSACTION;' + + +def test_split_begin_transaction_formatted(): # issue826 + # Test with formatted SQL (newlines between BEGIN and TRANSACTION) + sql = """BEGIN +TRANSACTION; +DELETE FROM "schema"."table_a" USING "table_a_temp" WHERE "schema"."table_a"."id" = "table_a_temp"."id"; +INSERT INTO "schema"."table_a" SELECT * FROM "table_a_temp"; +END +TRANSACTION;""" + stmts = sqlparse.split(sql) + assert len(stmts) == 4 + assert stmts[0] == 'BEGIN\nTRANSACTION;' + assert stmts[1].startswith('DELETE') + assert stmts[2].startswith('INSERT') + assert stmts[3] == 'END\nTRANSACTION;' + + +def test_split_anonymous_begin_end_for(): # issue845 Case 1 + sql = """ +BEGIN + SELECT 1; + FOR R DO + SELECT 1; + END FOR; +END; +""" + stmts = sqlparse.split(sql) + assert len(stmts) == 1 + assert "END FOR;" in stmts[0] + + +def test_split_anonymous_begin_end_case_inline(): # issue845 Case 2 + sql = """ +BEGIN + SELECT 1; + IF 1 THEN + SELECT CASE WHEN 1 THEN 2 ELSE 3 END AS COUNT; + ELSE + SELECT 2; + END IF; +END; +""" + stmts = sqlparse.split(sql) + assert len(stmts) == 1 + assert "END AS COUNT;" in stmts[0] + + +def test_split_for_update_in_begin_end(): + # Verify that FOR UPDATE / FOR SHARE inside a BEGIN ... END block do not break level balancing + sql = """ +BEGIN + SELECT * FROM foo FOR UPDATE; + SELECT * FROM bar FOR SHARE; +END; +SELECT 3; +""" + stmts = sqlparse.split(sql) + assert len(stmts) == 2 + assert "SELECT 3;" in stmts[1] + + +def test_split_multiple_for_loops_in_begin_end(): + # Verify that multiple sequential loops inside a BEGIN ... END block balance correctly + sql = """ +BEGIN + FOR x IN select_query LOOP + SELECT 1; + END LOOP; + FOR y IN select_query LOOP + SELECT 2; + END LOOP; +END; +SELECT 3; +""" + stmts = sqlparse.split(sql) + assert len(stmts) == 2 + assert "SELECT 3;" in stmts[1] + + +def test_split_procedural_case_end_case(): + # Verify that CASE closed by END CASE inside a BEGIN block balances correctly + sql = """ +BEGIN + CASE val + WHEN 1 THEN SELECT 'one'; + WHEN 2 THEN SELECT 'two'; + ELSE SELECT 'other'; + END CASE; +END; +SELECT 3; +""" + stmts = sqlparse.split(sql) + assert len(stmts) == 2 + assert "SELECT 3;" in stmts[1] + + +def test_split_standalone_for_update(): + # Verify that standalone FOR UPDATE statements split correctly + sql = "SELECT * FROM foo FOR UPDATE; SELECT 3;" + stmts = sqlparse.split(sql) + assert len(stmts) == 2 + assert stmts[0] == "SELECT * FROM foo FOR UPDATE;" + assert stmts[1] == "SELECT 3;" + + diff --git a/tests/test_tokenize.py b/tests/test_tokenize.py index 3e8831b5..e368e83e 100644 --- a/tests/test_tokenize.py +++ b/tests/test_tokenize.py @@ -1,13 +1,11 @@ -# -*- coding: utf-8 -*- - import types +from io import StringIO import pytest import sqlparse from sqlparse import lexer from sqlparse import sql, tokens as T -from sqlparse.compat import StringIO def test_tokenize_simple(): @@ -152,7 +150,7 @@ def test_stream_error(): 'INNER JOIN', 'LEFT INNER JOIN']) def test_parse_join(expr): - p = sqlparse.parse('{0} foo'.format(expr))[0] + p = sqlparse.parse(f'{expr} foo')[0] assert len(p.tokens) == 3 assert p.tokens[0].ttype is T.Keyword @@ -171,11 +169,16 @@ def test_parse_endifloop(s): assert p.tokens[0].ttype is T.Keyword -@pytest.mark.parametrize('s', ['NULLS FIRST', 'NULLS LAST']) -def test_parse_nulls(s): # issue487 +@pytest.mark.parametrize('s', [ + 'ASC', 'DESC', + 'NULLS FIRST', 'NULLS LAST', + 'ASC NULLS FIRST', 'ASC NULLS LAST', + 'DESC NULLS FIRST', 'DESC NULLS LAST', +]) +def test_parse_order(s): # issue487 p = sqlparse.parse(s)[0] assert len(p.tokens) == 1 - assert p.tokens[0].ttype is T.Keyword + assert p.tokens[0].ttype is T.Keyword.Order @pytest.mark.parametrize('s', [ @@ -204,6 +207,12 @@ def test_parse_order_by(): assert p.tokens[0].ttype is T.Keyword +def test_parse_window_as(): + p = sqlparse.parse('WINDOW w AS')[0] + assert len(p.tokens) == 5 + assert p.tokens[0].ttype is T.Keyword + + @pytest.mark.parametrize('s', ( "LIKE", "ILIKE", "NOT LIKE", "NOT ILIKE", "NOT LIKE", "NOT ILIKE", diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..d020f3fa --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,12 @@ +import pytest + +from sqlparse import utils + + +@pytest.mark.parametrize('value, expected', ( + [None, None], + ['\'foo\'', 'foo'], + ['"foo"', 'foo'], + ['`foo`', 'foo'])) +def test_remove_quotes(value, expected): + assert utils.remove_quotes(value) == expected diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 2e5010a8..00000000 --- a/tox.ini +++ /dev/null @@ -1,28 +0,0 @@ -[tox] -skip_missing_interpreters = True -envlist = - py27 - py34 - py35 - py36 - py37 - py38 - pypy_54 - flake8 - -[testenv] -deps = - pytest - pytest-cov - pytest-travis-fold -passenv = - TRAVIS -commands = - sqlformat --version - pytest --cov=sqlparse {posargs} - -[testenv:flake8] -deps = - flake8 -commands = - flake8 sqlparse tests setup.py diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..60facd8d --- /dev/null +++ b/uv.lock @@ -0,0 +1,832 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "build" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10.2'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/e0/df5e171f685f82f37b12e1f208064e24244911079d7b767447d1af7e0d70/build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647", size = 89796, upload-time = "2026-04-30T03:18:25.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018, upload-time = "2026-04-30T03:18:23.644Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/9d/7c83ef51c3eb495f10010094e661833588b7709946da634c8b66520b97c7/coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075", size = 219668, upload-time = "2026-05-10T17:59:23.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/34/898546aefbd28f0af131201d0dc852c9e976f817bd7d5bfb8dc4e02863bb/coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82", size = 220192, upload-time = "2026-05-10T17:59:26.095Z" }, + { url = "https://files.pythonhosted.org/packages/df/4a/b457c88aca72b0df13a98167ebd5d947135ccd9881ea88ce6a570e13aa9b/coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c", size = 246932, upload-time = "2026-05-10T17:59:27.806Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d9/92600e89486fd074c50f0117422b2c9592c3e144e2f25bd5ac0bc62bc7a0/coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893", size = 248762, upload-time = "2026-05-10T17:59:29.479Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e1/9ea1eb9c311da7f15853559dc1d9d82bef88ecd3e59fbeb51f16bc2ffa91/coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20", size = 250625, upload-time = "2026-05-10T17:59:31.33Z" }, + { url = "https://files.pythonhosted.org/packages/a5/03/57afca1b8106f8549a5329139315041fe166d6099bd9381346b9430dfbd1/coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec", size = 252539, upload-time = "2026-05-10T17:59:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/57/5e/2e9fc63c9928119c1dbae02222be51407d3e7ebac5811ebbda4af3557795/coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757", size = 247636, upload-time = "2026-05-10T17:59:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e2/0b7898cda21041cc67546e19b80ba66cbbb47cbece52a76a5904de6a3aaf/coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a", size = 248666, upload-time = "2026-05-10T17:59:36.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/d33662a2fdaef23229c15921f39c84ec38441f3069ba26e134ed402c833b/coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea", size = 246670, upload-time = "2026-05-10T17:59:38.029Z" }, + { url = "https://files.pythonhosted.org/packages/99/b2/533942c3bfbf6770b5c32d7f2ff029fe013dba31f3fe8b45cabbb250365e/coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb", size = 250484, upload-time = "2026-05-10T17:59:39.974Z" }, + { url = "https://files.pythonhosted.org/packages/d8/00/15acbad83a96de13c73831486c7627bfed73dfaec53b04e4a6315edf3fd8/coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218", size = 246942, upload-time = "2026-05-10T17:59:41.659Z" }, + { url = "https://files.pythonhosted.org/packages/70/db/cef0228de493f2c740c760a9057a61d00c6849480073b70a75b87c7d4bab/coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85", size = 247544, upload-time = "2026-05-10T17:59:43.471Z" }, + { url = "https://files.pythonhosted.org/packages/77/a0/d9ef8e148f3025c2ae8401d77cda1502b6d2a4d8102603a8af31460aedb6/coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323", size = 222285, upload-time = "2026-05-10T17:59:44.908Z" }, + { url = "https://files.pythonhosted.org/packages/85/c0/30c454c7d3cf47b2805d4e06f12443f5eece8a5d030d3b0350e7b74ecb49/coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a", size = 223215, upload-time = "2026-05-10T17:59:46.779Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e4/649c8d4f7f1709b6dbfc474358aa1bba02f67bcd52e2fec291a5014006cd/coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480", size = 219795, upload-time = "2026-05-10T17:59:48.198Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8d/46692d24b3f395d4cbf17bfcc57136b4f2f9c0c0df864b0bddfc1d71a014/coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4", size = 220299, upload-time = "2026-05-10T17:59:49.683Z" }, + { url = "https://files.pythonhosted.org/packages/12/c2/a40f5cb295bbcbb697a76947a56081c494c61950366294ee426ffe261099/coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7", size = 250721, upload-time = "2026-05-10T17:59:51.494Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/202235eb5c3c14c212462cd91d61b7386bf8fc44bc7a77f4742d2a69174b/coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed", size = 252633, upload-time = "2026-05-10T17:59:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/bb/80/5f596e8995785124ee191c42535664c5e62c65995b66f4ca21e28ae04c81/coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980", size = 254743, upload-time = "2026-05-10T17:59:55.021Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6d/0d178825be2350f0adb27984d0aa7cf84bbdab201f6fb926b535d23a8f5f/coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0", size = 256700, upload-time = "2026-05-10T17:59:56.511Z" }, + { url = "https://files.pythonhosted.org/packages/19/5b/9e549c2f6e9dfea472adadba06c294e64735dabc2dd19015fac082095013/coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742", size = 250854, upload-time = "2026-05-10T17:59:57.94Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1c/b94f9f5f36396021ee2f62c5834b12e6a3d31f0bed5d6fc6d1c3caec087c/coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5", size = 252433, upload-time = "2026-05-10T17:59:59.688Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cb/d192cd8e1345eccabc32016f2d39072ecd10cb4f4b983ed8d0ebdeaf00dc/coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327", size = 250494, upload-time = "2026-05-10T18:00:01.953Z" }, + { url = "https://files.pythonhosted.org/packages/53/c5/aac9f460a41d835dbddef1d377f105f6ac2311d0f3c1588e9f51046d8813/coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d", size = 254261, upload-time = "2026-05-10T18:00:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/23/aa/7af7c0081980a9cb3d289c5a435a4b7657dcecbd128e25c580e6a50389b5/coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20", size = 250216, upload-time = "2026-05-10T18:00:05.262Z" }, + { url = "https://files.pythonhosted.org/packages/35/60/a4257538ce2f6b978aeb51870d6c4208c510928a03db7e0339bb625dccb7/coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c", size = 251125, upload-time = "2026-05-10T18:00:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ab/f91af47642ec1aa53490e835a95847168d9c77fc39aa58527604c051e145/coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3", size = 222300, upload-time = "2026-05-10T18:00:08.608Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f0/a71ddbd874431e7a7cd96071f0c331cfbbad07704833c765d24ffbab8a67/coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1", size = 223241, upload-time = "2026-05-10T18:00:10.746Z" }, + { url = "https://files.pythonhosted.org/packages/d8/6e/d9d312a5151a96cd110efee32efc3fc97b01ebd86203fe618ccb29cf4c92/coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627", size = 221908, upload-time = "2026-05-10T18:00:12.242Z" }, + { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" }, + { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" }, + { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" }, + { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" }, + { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" }, + { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" }, + { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" }, + { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" }, + { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" }, + { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" }, + { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" }, + { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" }, + { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" }, + { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, + { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, + { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, + { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, + { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, + { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, + { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "9.0.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version == '3.11.*'" }, + { name = "babel", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imagesize", marker = "python_full_version == '3.11.*'" }, + { name = "jinja2", marker = "python_full_version == '3.11.*'" }, + { name = "packaging", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "requests", marker = "python_full_version == '3.11.*'" }, + { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "imagesize", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "sqlparse" +source = { editable = "." } + +[package.optional-dependencies] +dev = [ + { name = "build" }, +] +doc = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "build" }, + { name = "coverage" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] + +[package.metadata] +requires-dist = [ + { name = "build", marker = "extra == 'dev'" }, + { name = "sphinx", marker = "extra == 'doc'" }, +] +provides-extras = ["dev", "doc"] + +[package.metadata.requires-dev] +dev = [ + { name = "build" }, + { name = "coverage" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "sphinx" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +]