├── tests ├── __init__.py ├── test_termcolors.py ├── test_reporters.py ├── test_middleware.py └── test_profiler.py ├── .coveragerc ├── images └── report-example.png ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── python-checks.yml │ ├── python-publish.yml │ └── codeql-analysis.yml ├── .gitignore ├── .flake8 ├── easy_profile ├── __init__.py ├── termcolors.py ├── middleware.py ├── reporters.py └── profiler.py ├── LICENSE ├── pyproject.toml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── README.md └── poetry.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = easy_profile 4 | -------------------------------------------------------------------------------- /images/report-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmvass/sqlalchemy-easy-profile/HEAD/images/report-example.png -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | # Packages 4 | *.egg-info 5 | .eggs 6 | build 7 | dist 8 | 9 | # IDE files 10 | .idea 11 | .vscode 12 | 13 | # Environment 14 | .venv* 15 | 16 | # Testing 17 | .tox 18 | .coverage 19 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-complexity = 10 3 | exclude = .eggs,.tox,.venv*,build,dist 4 | max-line-length = 79 5 | import-order-style = google 6 | inline-quotes = double 7 | docstring-quotes = double 8 | application-import-names = easy_profile 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: dmvass 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior or test case. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Additional context** 20 | Add any other context about the problem here. 21 | -------------------------------------------------------------------------------- /easy_profile/__init__.py: -------------------------------------------------------------------------------- 1 | # The following names are available as part of the public API for 2 | # ``sqlalchemy-easy-profile``. End users of this package can import 3 | # these names by doing ``from easy_profile import SessionProfiler``, 4 | # for example. 5 | 6 | from .middleware import EasyProfileMiddleware 7 | from .profiler import SessionProfiler 8 | from .reporters import StreamReporter 9 | 10 | __all__ = ["EasyProfileMiddleware", "SessionProfiler", "StreamReporter"] 11 | __author__ = "Dmitry Vasilishin" 12 | __version__ = "1.3.0" 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[feature]" 5 | labels: enhancement 6 | assignees: dmvass 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/python-checks.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - "master" 6 | pull_request: 7 | workflow_dispatch: 8 | jobs: 9 | test: 10 | name: Run checks 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: true 14 | matrix: 15 | python-version: ["3.9"] 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v3 19 | 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: Install Poetry 26 | run: pip install poetry 27 | 28 | - name: Install dependencies 29 | run: poetry install --with dev 30 | 31 | - name: Run lint 32 | run: poetry run flake8 33 | 34 | - name: Run coverage 35 | run: poetry run coverage run -m unittest && poetry run coverage report 36 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: true 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v3 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: '3.x' 23 | 24 | - name: Install Poetry 25 | run: pip install poetry 26 | 27 | - name: Build package 28 | run: poetry build 29 | 30 | - name: Publish package 31 | env: 32 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 33 | run: poetry publish --no-interaction --username __token__ --password $PYPI_TOKEN 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Dmitri Vasilishin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "sqlalchemy-easy-profile" 3 | version = "1.3.0" 4 | description = "An easy profiler for SQLAlchemy queries" 5 | authors = ["Dmytro Vasylyshyn "] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://github.com/dmvass/sqlalchemy-easy-profile" 9 | repository = "https://github.com/dmvass/sqlalchemy-easy-profile" 10 | keywords = ["sqlalchemy", "easy", "profile", "profiler", "profiling"] 11 | classifiers = [ 12 | "Development Status :: 5 - Production/Stable", 13 | "Intended Audience :: Developers", 14 | "Programming Language :: Python :: 3", 15 | "Programming Language :: Python :: 3.9", 16 | "License :: OSI Approved :: MIT License", 17 | "Operating System :: OS Independent" 18 | ] 19 | 20 | packages = [ 21 | { include = "easy_profile" } 22 | ] 23 | 24 | [tool.poetry.dependencies] 25 | python = ">=3.9,<4.0" 26 | sqlalchemy = "<2.1" 27 | sqlparse = ">=0.3.0" 28 | 29 | [tool.poetry.group.dev.dependencies] 30 | coverage = "*" 31 | flake8 = "^7.3.0" 32 | flake8-import-order = "^0.19.2" 33 | flake8-quotes = "^3.4.0" 34 | 35 | [build-system] 36 | requires = ["poetry-core>=1.0.0"] 37 | build-backend = "poetry.core.masonry.api" 38 | -------------------------------------------------------------------------------- /tests/test_termcolors.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from easy_profile.termcolors import ansi_options, ansi_reset, colorize 4 | 5 | 6 | class TestColorize(unittest.TestCase): 7 | 8 | def test_fg(self): 9 | colors = [ 10 | "black", "red", "green", "yellow", "blue", 11 | "magenta", "cyan", "white" 12 | ] 13 | for color, code in dict(zip(colors, range(30, 38))).items(): 14 | expected = "\033[{0}m".format(code) + "test" + ansi_reset 15 | self.assertEqual(colorize("test", fg=color), expected) 16 | 17 | def test_bg(self): 18 | colors = [ 19 | "bright_black", "bright_red", "bright_green", "bright_yellow", 20 | "bright_blue", "bright_magenta", "bright_cyan", "bright_white" 21 | ] 22 | for color, code in dict(zip(colors, range(90, 98))).items(): 23 | expected = "\033[{0}m".format(code + 10) + "test" + ansi_reset 24 | self.assertEqual(colorize("test", bg=color), expected) 25 | 26 | def test_options(self): 27 | for opt, code in ansi_options.items(): 28 | expected = "\033[{0}m".format(code) + "test" + ansi_reset 29 | self.assertEqual(colorize("test", [opt]), expected) 30 | 31 | def test_noreset(self): 32 | self.assertEqual(colorize("test", ["noreset"]), "test") 33 | 34 | def test_reset(self): 35 | self.assertEqual(colorize("test", ["reset"]), ansi_reset) 36 | 37 | def test_complex(self): 38 | text = "test" 39 | expected = [ 40 | "\033[30m", # fg=black, 41 | "\033[1m", # bold 42 | "\033[4m", # underscore 43 | text, 44 | ansi_reset, 45 | ] 46 | actual = colorize(text, ["bold", "underscore"], fg="black") 47 | self.assertEqual(actual, "".join(expected)) 48 | -------------------------------------------------------------------------------- /easy_profile/termcolors.py: -------------------------------------------------------------------------------- 1 | ansi_colors = { 2 | "black": 30, 3 | "red": 31, 4 | "green": 32, 5 | "yellow": 33, 6 | "blue": 34, 7 | "magenta": 35, 8 | "cyan": 36, 9 | "white": 37, 10 | "bright_black": 90, 11 | "bright_red": 91, 12 | "bright_green": 92, 13 | "bright_yellow": 93, 14 | "bright_blue": 94, 15 | "bright_magenta": 95, 16 | "bright_cyan": 96, 17 | "bright_white": 97, 18 | } 19 | 20 | ansi_reset = "\033[0m" 21 | 22 | ansi_options = { 23 | "bold": 1, 24 | "underscore": 4, 25 | "blink": 5, 26 | "reverse": 7, 27 | "conceal": 8, 28 | } 29 | 30 | 31 | def colorize(text, opts=(), fg=None, bg=None): 32 | """Colorize text enclosed in ANSI graphics codes. 33 | 34 | Depends on the keyword arguments 'fg' and 'bg', and the contents of 35 | the opts tuple/list. 36 | 37 | Valid colors: 38 | 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white' 39 | 40 | Valid options: 41 | 'bold', 'underscore', 'blink', 'reverse', 'conceal' 42 | 'noreset' - string will not be terminated with the reset code 43 | 44 | :param str text: your text 45 | :param tuple opts: text options 46 | :param str fg: foreground color name 47 | :param str bg: background color name 48 | 49 | :return: colorized text 50 | 51 | """ 52 | codes = [] 53 | if len(opts) == 1 and opts[0] == "reset": 54 | return ansi_reset 55 | 56 | if fg and fg in ansi_colors: 57 | codes.append("\033[{0}m".format(ansi_colors[fg])) 58 | elif bg and bg in ansi_colors: 59 | codes.append("\033[{0}m".format(ansi_colors[bg] + 10)) 60 | 61 | for opt in opts: 62 | if opt in ansi_options: 63 | codes.append("\033[{0}m".format(ansi_options[opt])) 64 | 65 | if "noreset" not in opts: 66 | text += ansi_reset 67 | 68 | return "".join(codes) + text 69 | -------------------------------------------------------------------------------- /easy_profile/middleware.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from .profiler import SessionProfiler 4 | from .reporters import Reporter, StreamReporter 5 | 6 | 7 | class EasyProfileMiddleware(object): 8 | """This middleware prints the number of database queries for each HTTP 9 | request and can be applied as a WSGI server middleware. 10 | 11 | :param app: WSGI application server 12 | :param sqlalchemy.engine.base.Engine engine: sqlalchemy database engine 13 | :param Reporter reporter: reporter instance 14 | :param list exclude_path: a list of regex patterns for excluding requests 15 | :param int min_time: minimal queries duration to logging 16 | :param int min_query_count: minimal queries count to logging 17 | 18 | """ 19 | 20 | def __init__(self, 21 | app, 22 | engine=None, 23 | reporter=None, 24 | exclude_path=None, 25 | min_time=0, 26 | min_query_count=1): 27 | 28 | if reporter: 29 | if not isinstance(reporter, Reporter): 30 | raise TypeError("reporter must be inherited from 'Reporter'") 31 | self.reporter = reporter 32 | else: 33 | self.reporter = StreamReporter() 34 | 35 | self.app = app 36 | self.engine = engine 37 | self.exclude_path = exclude_path or [] 38 | self.min_time = min_time 39 | self.min_query_count = min_query_count 40 | 41 | def __call__(self, environ, start_response): 42 | profiler = SessionProfiler(self.engine) 43 | path = environ.get("PATH_INFO", "") 44 | if not self._ignore_request(path): 45 | method = environ.get("REQUEST_METHOD") 46 | if method: 47 | path = "{0} {1}".format(method, path) 48 | try: 49 | with profiler: 50 | response = self.app(environ, start_response) 51 | finally: 52 | self._report_stats(path, profiler.stats) 53 | return response 54 | return self.app(environ, start_response) 55 | 56 | def _ignore_request(self, path): 57 | """Check to see if we should ignore the request.""" 58 | return any(re.match(pattern, path) for pattern in self.exclude_path) 59 | 60 | def _report_stats(self, path, stats): 61 | if (stats["total"] >= self.min_query_count and 62 | stats["duration"] >= self.min_time): 63 | self.reporter.report(path, stats) 64 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # ******** NOTE ******** 12 | 13 | name: "CodeQL" 14 | 15 | on: 16 | push: 17 | branches: [ master ] 18 | pull_request: 19 | # The branches below must be a subset of the branches above 20 | branches: [ master ] 21 | schedule: 22 | - cron: '23 21 * * 2' 23 | 24 | jobs: 25 | analyze: 26 | name: Analyze 27 | runs-on: ubuntu-latest 28 | 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | language: [ 'python' ] 33 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 34 | # Learn more: 35 | # https://docs.github.com/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 36 | 37 | steps: 38 | - name: Checkout repository 39 | uses: actions/checkout@v2 40 | 41 | # Initializes the CodeQL tools for scanning. 42 | - name: Initialize CodeQL 43 | uses: github/codeql-action/init@v1 44 | with: 45 | languages: ${{ matrix.language }} 46 | # If you wish to specify custom queries, you can do so here or in a config file. 47 | # By default, queries listed here will override any specified in a config file. 48 | # Prefix the list here with "+" to use these queries and those in the config file. 49 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 50 | 51 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 52 | # If this step fails, then you should remove it and run the build manually (see below) 53 | - name: Autobuild 54 | uses: github/codeql-action/autobuild@v1 55 | 56 | # ℹ️ Command-line programs to run using the OS shell. 57 | # 📚 https://git.io/JvXDl 58 | 59 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 60 | # and modify them (or add more) to build your code if your project 61 | # uses a compiled language 62 | 63 | #- run: | 64 | # make bootstrap 65 | # make release 66 | 67 | - name: Perform CodeQL Analysis 68 | uses: github/codeql-action/analyze@v1 69 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [1.2.1] - 2021-05-14 10 | - Fixed install requires SQLAlchemy version 11 | 12 | ## [1.2.0] - 2021-03-31 13 | - Added support of python 3.9 14 | - Added support of SQLAlchemy 1.4 15 | - Removed support of python 3.6 16 | - Removed support of SQLAlchemy 1.1, 1.2 17 | 18 | ## [1.1.2] - 2020-10-21 19 | - Fixed queries for UNIX platforms [issue-18] 20 | 21 | ## [1.1.1] - 2020-07-26 22 | - Fixed deprecated time.clock [issue-16] 23 | 24 | ## [1.1.0] - 2020-06-29 25 | - Removed support of python 2.7, 3.5 26 | - Updated documentation 27 | - Added code of conduct 28 | 29 | ## [1.0.3] - 2019-11-04 30 | - Fixed an issue where concurrent calls to an API would cause "Profiling session has already begun" exception. 31 | 32 | ## [1.0.2] - 2019-04-06 33 | ### Changed 34 | - Profiler stats type from `dict` on `OrderedDict` 35 | ### Fixed 36 | - Readme examples imports from [@dbourdeveloper](https://github.com/dbourdeveloper) 37 | - Profiler duplicates counter (now it's begin countig from `0`) 38 | 39 | ## [1.0.1] - 2019-04-04 40 | ### Added 41 | - Human readable sql output to the console [@Tomasz-Kluczkowski](https://github.com/Tomasz-Kluczkowski) 42 | - Py2 unicode support 43 | - Docstring improvements 44 | 45 | ## [1.0.0] - 2019-03-25 46 | ### Added 47 | - Supports for SQLAlchemy 1.3 48 | - Makefile 49 | - setup.cfg 50 | - pep8 tox env 51 | ### Changed 52 | - Set new GitHub username in the README 53 | - Update setup requirements 54 | ### Fixed 55 | - flake8 issues 56 | ### Removed 57 | - Supports for SQLAlchemy 1.0 58 | - .bumpversion (moved to setup.cfg) 59 | 60 | ## [0.5.0] - 2018-11-12 61 | ### Added 62 | - Supports for SQLAlchemy 1.0, 1.1 and 1.2 versions 63 | 64 | ## [0.4.1] - 2018-11-08 65 | ### Fixed 66 | - Report example image link in the README 67 | 68 | ## [0.4.0] - 2018-11-08 69 | ### Added 70 | - README 71 | 72 | ## [0.3.4] - 2018-11-08 73 | ### Fixed 74 | - Travis CI pipy provider secure password 75 | 76 | ## [0.3.2] - 2018-11-07 77 | ### Added 78 | - Bump version config 79 | - Travis CI deploy to pypi 80 | 81 | ## [0.3.1] - 2018-11-07 82 | ### Changed 83 | - Travis CI python3.7 on python3.7-dev 84 | 85 | ## [0.3.0] - 2018-11-07 86 | ### Added 87 | - TOX 88 | - Setuptools configuration 89 | - Support py2/py3 90 | - Travic CI 91 | 92 | ## [0.2.0] - 2018-11-07 93 | ### Added 94 | - Middleware tests and fixes 95 | - Profiler tests and fixes 96 | - Reporters tests and fixes 97 | - Termcolors tests and fixes 98 | 99 | ## [0.1.0] - 2018-11-04 100 | - Initial commit. 101 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at vasilishin.d.o@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /tests/test_reporters.py: -------------------------------------------------------------------------------- 1 | from collections import Counter 2 | import unittest 3 | from unittest import mock 4 | 5 | import sqlparse 6 | 7 | from easy_profile.reporters import shorten, StreamReporter 8 | 9 | 10 | expected_table = """ 11 | |----------|--------|--------|--------|--------|--------|------------| 12 | | Database | SELECT | INSERT | UPDATE | DELETE | Totals | Duplicates | 13 | |----------|--------|--------|--------|--------|--------|------------| 14 | | default | 8 | 2 | 3 | 0 | 13 | 3 | 15 | |----------|--------|--------|--------|--------|--------|------------| 16 | """ 17 | 18 | expected_table_stats = { 19 | "db": "default", 20 | "select": 8, 21 | "insert": 2, 22 | "update": 3, 23 | "delete": 0, 24 | "total": 13, 25 | "duration": 0.0345683, 26 | "duplicates": Counter({ 27 | "SELECT id FROM users": 2, 28 | "SELECT id, name FROM users": 1, 29 | }), 30 | "duplicates_count": 3, 31 | } 32 | 33 | 34 | class TestShorten(unittest.TestCase): 35 | 36 | def test_shorten(self): 37 | # Test not longer string 38 | expected = "test" 39 | self.assertEqual(shorten(expected, len(expected)), expected) 40 | 41 | # Test longer string 42 | expected = "test..." 43 | self.assertEqual(shorten("test test", 7), expected) 44 | 45 | # Test with placeholder 46 | expected = "test!!!" 47 | self.assertEqual(shorten("test test", 7, placeholder="!!!"), expected) 48 | 49 | 50 | class TestStreamReporter(unittest.TestCase): 51 | 52 | def test_initialization(self): 53 | mocked_file = mock.Mock() 54 | reporter = StreamReporter( 55 | medium=1, 56 | high=2, 57 | file=mocked_file, 58 | colorized=False, 59 | display_duplicates=0 60 | ) 61 | self.assertEqual(reporter._medium, 1) 62 | self.assertEqual(reporter._high, 2) 63 | self.assertEqual(reporter._file, mocked_file) 64 | self.assertFalse(reporter._colorized) 65 | self.assertEqual(reporter._display_duplicates, 0) 66 | 67 | def test_initialization_default(self): 68 | reporter = StreamReporter() 69 | self.assertEqual(reporter._medium, 50) 70 | self.assertEqual(reporter._high, 100) 71 | self.assertTrue(reporter._colorized) 72 | self.assertEqual(reporter._display_duplicates, 5) 73 | 74 | def test_initialization_error(self): 75 | with self.assertRaises(ValueError): 76 | StreamReporter(medium=100, high=50) 77 | 78 | def test__colorize_on_deactivated(self): 79 | with mock.patch("easy_profile.reporters.colorize") as mocked: 80 | reporter = StreamReporter(colorized=False) 81 | reporter._colorize("test") 82 | mocked.assert_not_called() 83 | 84 | def test__colorize_on_activated(self): 85 | with mock.patch("easy_profile.reporters.colorize") as mocked: 86 | reporter = StreamReporter(colorized=True) 87 | reporter._colorize("test") 88 | mocked.assert_called() 89 | 90 | def test__info_line_on_high(self): 91 | with mock.patch.object(StreamReporter, "_colorize") as mocked: 92 | reporter = StreamReporter() 93 | reporter._info_line("test", reporter._high + 1) 94 | mocked.assert_called_with("test", ["bold"], fg="red") 95 | 96 | def test__info_line_on_medium(self): 97 | with mock.patch.object(StreamReporter, "_colorize") as mocked: 98 | reporter = StreamReporter() 99 | reporter._info_line("test", reporter._medium + 1) 100 | mocked.assert_called_with("test", ["bold"], fg="yellow") 101 | 102 | def test__info_line_on_low(self): 103 | with mock.patch.object(StreamReporter, "_colorize") as mocked: 104 | reporter = StreamReporter() 105 | reporter._info_line("test", reporter._medium - 1) 106 | mocked.assert_called_with("test", ["bold"], fg="green") 107 | 108 | def test_stats_table(self): 109 | reporter = StreamReporter(colorized=False) 110 | actual_table = reporter.stats_table(expected_table_stats) 111 | self.assertEqual(actual_table.strip(), expected_table.strip()) 112 | 113 | def test_stats_table_change_sep(self): 114 | sep = "+" 115 | reporter = StreamReporter(colorized=False) 116 | actual_table = reporter.stats_table(expected_table_stats, sep=sep) 117 | expected = expected_table.replace("|", sep) 118 | self.assertEqual(actual_table.strip(), expected.strip()) 119 | 120 | def test_report(self): 121 | dest = mock.Mock() 122 | reporter = StreamReporter(colorized=False, file=dest) 123 | reporter.report("test", expected_table_stats) 124 | 125 | expected_output = "\ntest" 126 | expected_output += expected_table 127 | 128 | total = expected_table_stats["total"] 129 | duration = expected_table_stats["duration"] 130 | summary = "\nTotal queries: {0} in {1:.3}s\n".format(total, duration) 131 | expected_output += summary 132 | 133 | actual_output = dest.write.call_args[0][0] 134 | self.assertRegex(actual_output, expected_output) 135 | 136 | for statement, count in expected_table_stats["duplicates"].items(): 137 | statement = sqlparse.format( 138 | statement, reindent=True, keyword_case="upper" 139 | ) 140 | text = "\nRepeated {0} times:\n{1}\n".format(count + 1, statement) 141 | self.assertRegex(actual_output, text) 142 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SQLAlchemy Easy Profile 2 | 3 | [![Build Status](https://travis-ci.com/dmvass/sqlalchemy-easy-profile.svg?branch=master)](https://travis-ci.com/dmvass/sqlalchemy-easy-profile) 4 | [![image](https://img.shields.io/pypi/v/sqlalchemy-easy-profile.svg)](https://pypi.python.org/pypi/sqlalchemy-easy-profile) 5 | [![codecov](https://codecov.io/gh/dmvass/sqlalchemy-easy-profile/branch/master/graph/badge.svg)](https://codecov.io/gh/dmvass/sqlalchemy-easy-profile) 6 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/dmvass/sqlalchemy-easy-profile/blob/master/LICENSE) 7 | 8 | Inspired by [django-querycount](https://github.com/bradmontgomery/django-querycount), 9 | is a library that hooks into SQLAlchemy to collect metrics, streaming statistics into 10 | console output and help you understand where in application you have slow or redundant 11 | queries. 12 | 13 | ![report example](https://raw.githubusercontent.com/dmvass/sqlalchemy-easy-profile/master/images/report-example.png?raw=true) 14 | 15 | ## Installation 16 | 17 | Install the package with pip: 18 | ``` 19 | pip install sqlalchemy-easy-profile 20 | ``` 21 | 22 | ## Session profiler 23 | 24 | The profiling session hooks into SQLAlchemy and captures query statements, duration information, 25 | and query parameters. You also may have multiple profiling sessions active at the same 26 | time on the same or different Engines. If multiple profiling sessions are active on the 27 | same engine, queries on that engine will be collected by both sessions and reported on 28 | different reporters. 29 | 30 | You may begin and commit a profiling session as much as you like. Calling begin on an already 31 | started session or commit on an already committed session will raise an `AssertionError`. 32 | You also can use a contextmanager interface for session profiling or used it like a decorator. 33 | This has the effect of only profiling queries occurred within the decorated function or inside 34 | a manager context. 35 | 36 | How to use `begin` and `commit`: 37 | ```python 38 | from easy_profile import SessionProfiler 39 | 40 | profiler = SessionProfiler() 41 | 42 | profiler.begin() 43 | session.query(User).filter(User.name == "Arthur Dent").first() 44 | profiler.commit() 45 | 46 | print(profiler.stats) 47 | ``` 48 | 49 | How to use as a context manager interface: 50 | ```python 51 | profiler = SessionProfiler() 52 | with profiler: 53 | session.query(User).filter(User.name == "Arthur Dent").first() 54 | 55 | print(profiler.stats) 56 | ``` 57 | 58 | How to use profiler as a decorator: 59 | ```python 60 | profiler = SessionProfiler() 61 | 62 | class UsersResource: 63 | @profiler() 64 | def on_get(self, req, resp, **args, **kwargs): 65 | return session.query(User).all() 66 | ``` 67 | 68 | Keep in mind that profiler decorator interface accepts a special reporter and 69 | If it was not defined by default will be used a base streaming reporter. Decorator 70 | also accept `name` and `name_callback` optional parameters. 71 | 72 | ## WSGI integration 73 | 74 | Easy Profiler provides a specified middleware which can prints the number of database 75 | queries for each HTTP request and can be applied as a WSGI server middleware. So you 76 | can easily integrate Easy Profiler into any WSGI application. 77 | 78 | How to integrate with a Flask application: 79 | ```python 80 | from flask import Flask 81 | from easy_profile import EasyProfileMiddleware 82 | 83 | app = Flask(__name__) 84 | app.wsgi_app = EasyProfileMiddleware(app.wsgi_app) 85 | ``` 86 | 87 | How to integrate with a Falcon application: 88 | ```python 89 | import falcon 90 | from easy_profile import EasyProfileMiddleware 91 | 92 | api = application = falcon.API() 93 | application = EasyProfileMiddleware(application) 94 | ``` 95 | 96 | ## How to customize output 97 | 98 | The `StreamReporter` accepts medium-high thresholds, output file destination (stdout by default), a special 99 | flag for disabling color formatting and number of displayed duplicated queries: 100 | 101 | ```python 102 | from flask import Flask 103 | from easy_profile import EasyProfileMiddleware, StreamReporter 104 | 105 | app = Flask(__name__) 106 | app.wsgi_app = EasyProfileMiddleware(app.wsgi_app, reporter=StreamReporter(display_duplicates=100)) 107 | ``` 108 | 109 | Any custom reporter can be created as: 110 | 111 | ```python 112 | from easy_profile.reporters import Reporter 113 | 114 | class CustomReporter(Reporter): 115 | 116 | def report(self, path, stats): 117 | """Do something with path and stats. 118 | 119 | :param str path: where profiling occurred 120 | :param dict stats: profiling statistics 121 | 122 | """ 123 | ... 124 | 125 | ``` 126 | 127 | ## Development 128 | 129 | To ensure code quality and correctness, use [Poetry](https://python-poetry.org/) for managing dependencies and running tasks. 130 | 131 | **Install development dependencies:** 132 | ```bash 133 | poetry install --with dev 134 | ``` 135 | 136 | **Run tests:** 137 | ```bash 138 | poetry run python -m unittest 139 | ``` 140 | 141 | **Check code style:** 142 | ```bash 143 | poetry run flake8 144 | ``` 145 | 146 | ## License 147 | 148 | This code is distributed under the terms of the MIT license. 149 | 150 | ## Changes 151 | 152 | A full changelog is maintained in the [CHANGELOG](https://github.com/dmvass/sqlalchemy-easy-profile/blob/master/CHANGELOG.md) file. 153 | 154 | ## Contributing 155 | 156 | **sqlalchemy-easy-profile** is an open source project and contributions are 157 | welcome! Check out the [Issues](https://github.com/dmvass/sqlalchemy-easy-profile/issues) 158 | page to see if your idea for a contribution has already been mentioned, and feel 159 | free to raise an issue or submit a pull request. 160 | -------------------------------------------------------------------------------- /easy_profile/reporters.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from collections import OrderedDict 3 | import sys 4 | 5 | import sqlparse 6 | 7 | from .termcolors import colorize 8 | 9 | 10 | def shorten(text, length, placeholder="..."): 11 | """Truncate the given text to fit in the given length. 12 | 13 | :param str text: string for truncate 14 | :param int length: max length of string 15 | :param str placeholder: append to the end of truncated text 16 | 17 | :return: truncated string 18 | 19 | """ 20 | if len(text) > length: 21 | return text[:length - len(placeholder)] + placeholder 22 | return text 23 | 24 | 25 | class Reporter(ABC): 26 | """Abstract class for profiler reporters.""" 27 | 28 | @abstractmethod 29 | def report(self, path, stats): 30 | """Reports profiling statistic to a stream. 31 | 32 | :param str path: where profiling occurred 33 | :param dict stats: profiling statistics 34 | 35 | """ 36 | 37 | 38 | class StreamReporter(Reporter): 39 | """A base reporter for streaming to a file. By default reports 40 | will be written to ``sys.stdout``. 41 | 42 | :param int medium: a medium threshold count 43 | :param int high: a high threshold count 44 | :param file: output destination (stdout by default) 45 | :param bool colorized: set True if output should be colorized 46 | :param int display_duplicates: how much sql duplicates will be displayed 47 | 48 | """ 49 | 50 | _display_names = OrderedDict([ 51 | ("Database", "db"), 52 | ("SELECT", "select"), 53 | ("INSERT", "insert"), 54 | ("UPDATE", "update"), 55 | ("DELETE", "delete"), 56 | ("Totals", "total"), 57 | ("Duplicates", "duplicates_count"), 58 | ]) 59 | 60 | def __init__(self, 61 | medium=50, 62 | high=100, 63 | file=sys.stdout, 64 | colorized=True, 65 | display_duplicates=5): 66 | 67 | if medium >= high: 68 | raise ValueError("Medium must be less than high") 69 | 70 | self._medium = medium 71 | self._high = high 72 | self._file = file 73 | self._colorized = colorized 74 | self._display_duplicates = display_duplicates or 0 75 | 76 | def report(self, path, stats): 77 | duplicates = stats["duplicates"] 78 | stats["duplicates_count"] = sum(duplicates.values()) 79 | stats["db"] = shorten(stats["db"], 10) 80 | 81 | output = self._colorize("\n{0}\n".format(path), ["bold"], fg="blue") 82 | output += self.stats_table(stats) 83 | 84 | total = stats["total"] 85 | duration = float(stats["duration"]) 86 | summary = "Total queries: {0} in {1:.3}s".format(total, duration) 87 | output += self._info_line("\n{0}\n".format(summary), total) 88 | 89 | # Display duplicated sql statements. 90 | # 91 | # Get top counters were value greater than 1 and write to 92 | # a stream. It will be skipped if `display_duplicates` was 93 | # set to `0` or `None`. 94 | most_common = duplicates.most_common(self._display_duplicates) 95 | for statement, count in most_common: 96 | if count < 1: 97 | continue 98 | # Wrap SQL statement and returning a list of wrapped lines 99 | statement = sqlparse.format( 100 | statement, reindent=True, keyword_case="upper" 101 | ) 102 | text = "\nRepeated {0} times:\n{1}\n".format(count + 1, statement) 103 | output += self._info_line(text, count) 104 | 105 | self._file.write(output) 106 | 107 | def stats_table(self, stats, sep="|"): 108 | """Formats profiling statistics as table. 109 | 110 | :param dict stats: profiling statistics 111 | :param str sep: columns separator character 112 | 113 | :return: formatted table 114 | :rtype: str 115 | 116 | """ 117 | line = sep + "{}" + sep + "\n" 118 | h_names = [n.center(len(n) + 2) for n in self._display_names] 119 | breakline = line.format(sep.join("-" * len(n) for n in h_names)) 120 | 121 | # Creates table and writes a header 122 | output = "" 123 | output += breakline 124 | output += line.format(sep.join(h_names)) 125 | output += breakline 126 | 127 | # Formats and writes row values in order by display_names. 128 | # 129 | # Row with values can be colorized for better perception. It's 130 | # can be activated/deactivated through `colorized` parameter. 131 | values = [] 132 | for name, key in self._display_names.items(): 133 | value = stats[key] 134 | size = len(name) + 2 135 | values.append(str(value).center(size)) 136 | 137 | row = line.format(sep.join(values)) 138 | output += self._info_line(row, stats["total"]) 139 | output += breakline 140 | 141 | return output 142 | 143 | def _info_line(self, line, total): 144 | """Returns colorized text according threshold. 145 | 146 | :param str line: text which should be colorized 147 | :param int total: threshold count 148 | 149 | :return: colorized text 150 | 151 | """ 152 | if total > self._high: 153 | return self._colorize(line, ["bold"], fg="red") 154 | elif total > self._medium: 155 | return self._colorize(line, ["bold"], fg="yellow") 156 | return self._colorize(line, ["bold"], fg="green") 157 | 158 | def _colorize(self, text, opts=(), fg=None, bg=None): 159 | if not self._colorized: 160 | return text 161 | return colorize(text, opts, fg=fg, bg=bg) 162 | -------------------------------------------------------------------------------- /easy_profile/profiler.py: -------------------------------------------------------------------------------- 1 | from collections import Counter, namedtuple, OrderedDict 2 | import functools 3 | import inspect 4 | from queue import Queue 5 | import re 6 | import sys 7 | import time 8 | 9 | from sqlalchemy import event 10 | from sqlalchemy.engine.base import Engine 11 | 12 | from .reporters import StreamReporter 13 | 14 | # Optimize timer function for the platform 15 | if sys.platform == "win32": # pragma: no cover 16 | _timer = time.perf_counter 17 | else: 18 | _timer = time.time 19 | 20 | 21 | SQL_OPERATORS = ["select", "insert", "update", "delete"] 22 | OPERATOR_REGEX = re.compile("(%s) *." % "|".join(SQL_OPERATORS), re.IGNORECASE) 23 | 24 | 25 | def _get_object_name(obj): 26 | module = getattr(obj, "__module__", inspect.getmodule(obj).__name__) 27 | if hasattr(obj, "__qualname__"): 28 | name = obj.__qualname__ 29 | else: 30 | name = obj.__name__ 31 | return module + "." + name 32 | 33 | 34 | _DebugQuery = namedtuple( 35 | "_DebugQuery", "statement,parameters,start_time,end_time" 36 | ) 37 | 38 | 39 | class DebugQuery(_DebugQuery): 40 | """Public implementation of the debug query class""" 41 | 42 | @property 43 | def duration(self): 44 | return self.end_time - self.start_time 45 | 46 | 47 | class SessionProfiler: 48 | """A session profiler for sqlalchemy queries. 49 | 50 | :param Engine engine: sqlalchemy database engine 51 | 52 | :attr bool alive: is True if profiling in progress 53 | :attr Queue queries: sqlalchemy queries queue 54 | 55 | """ 56 | 57 | _before = "before_cursor_execute" 58 | _after = "after_cursor_execute" 59 | 60 | def __init__(self, engine=None): 61 | if engine is None: 62 | self.engine = Engine 63 | self.db_name = "default" 64 | else: 65 | self.engine = engine 66 | self.db_name = engine.url.database or "undefined" 67 | 68 | self.alive = False 69 | self.queries = None 70 | 71 | self._stats = None 72 | 73 | def __enter__(self): 74 | self.begin() 75 | 76 | def __exit__(self, exc_type, exc_val, exc_tb): 77 | self.commit() 78 | 79 | def __call__(self, path=None, path_callback=None, reporter=None): 80 | """Decorate callable object and profile sqlalchemy queries. 81 | 82 | If reporter was not defined by default will be used a base 83 | streaming reporter. 84 | 85 | :param easy_profile.reporters.Reporter reporter: profiling reporter 86 | :param collections.abc.Callable path_callback: callback for getting 87 | more complex path 88 | 89 | """ 90 | if reporter is None: 91 | reporter = StreamReporter() 92 | 93 | def decorator(func): 94 | 95 | @functools.wraps(func) 96 | def wrapper(*args, **kwargs): 97 | if path_callback is not None: 98 | _path = path_callback(func, *args, **kwargs) 99 | else: 100 | _path = path or _get_object_name(func) 101 | 102 | self.begin() 103 | try: 104 | result = func(*args, **kwargs) 105 | finally: 106 | self.commit() 107 | reporter.report(_path, self.stats) 108 | return result 109 | 110 | return wrapper 111 | 112 | return decorator 113 | 114 | @property 115 | def stats(self): 116 | if self._stats is None: 117 | self._reset_stats() 118 | return self._stats 119 | 120 | def begin(self): 121 | """Begin profiling session. 122 | 123 | :raises AssertionError: When the session is already alive. 124 | 125 | """ 126 | if self.alive: 127 | raise AssertionError("Profiling session has already begun") 128 | 129 | self.alive = True 130 | self.queries = Queue() 131 | self._reset_stats() 132 | 133 | event.listen(self.engine, self._before, self._before_cursor_execute) 134 | event.listen(self.engine, self._after, self._after_cursor_execute) 135 | 136 | def commit(self): 137 | """Commit profiling session. 138 | 139 | :raises AssertionError: When the session is not alive. 140 | 141 | """ 142 | if not self.alive: 143 | raise AssertionError("Profiling session is already committed") 144 | 145 | self.alive = False 146 | self._get_stats() 147 | 148 | event.remove(self.engine, self._before, self._before_cursor_execute) 149 | event.remove(self.engine, self._after, self._after_cursor_execute) 150 | 151 | def _get_stats(self): 152 | """Calculate and returns session statistics.""" 153 | while not self.queries.empty(): 154 | query = self.queries.get() 155 | self._stats["call_stack"].append(query) 156 | match = OPERATOR_REGEX.match(query.statement) 157 | if match: 158 | self._stats[match.group(1).lower()] += 1 159 | self._stats["total"] += 1 160 | self._stats["duration"] += query.duration 161 | duplicates = self._stats["duplicates"].get(query.statement, -1) 162 | self._stats["duplicates"][query.statement] = duplicates + 1 163 | 164 | return self._stats 165 | 166 | def _reset_stats(self): 167 | self._stats = OrderedDict() 168 | self._stats["db"] = self.db_name 169 | 170 | for operator in SQL_OPERATORS: 171 | self._stats[operator] = 0 172 | 173 | self._stats["total"] = 0 174 | self._stats["duration"] = 0 175 | self._stats["call_stack"] = [] 176 | self._stats["duplicates"] = Counter() 177 | 178 | def _before_cursor_execute(self, conn, cursor, statement, parameters, 179 | context, executemany): 180 | context._query_start_time = _timer() 181 | 182 | def _after_cursor_execute(self, conn, cursor, statement, parameters, 183 | context, executemany): 184 | self.queries.put(DebugQuery( 185 | statement, parameters, context._query_start_time, _timer() 186 | )) 187 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from queue import Queue 2 | from threading import Thread 3 | from time import sleep 4 | import unittest 5 | from unittest import mock 6 | 7 | from easy_profile.middleware import EasyProfileMiddleware 8 | from easy_profile.reporters import Reporter, StreamReporter 9 | 10 | 11 | class TestEasyProfileMiddleware(unittest.TestCase): 12 | 13 | def test_initialization_default(self): 14 | mocked_app = mock.Mock() 15 | mw = EasyProfileMiddleware(mocked_app) 16 | self.assertEqual(mw.app, mocked_app) 17 | self.assertIs(mw.engine, None) 18 | self.assertIsInstance(mw.reporter, StreamReporter) 19 | self.assertEqual(mw.exclude_path, []) 20 | self.assertEqual(mw.min_time, 0) 21 | self.assertEqual(mw.min_query_count, 1) 22 | 23 | def test_initialize_custom(self): 24 | mocked_app = mock.Mock() 25 | mocked_reporter = mock.Mock(spec=Reporter) 26 | expected_exclude_path = ["/api/users"] 27 | mw = EasyProfileMiddleware( 28 | mocked_app, 29 | reporter=mocked_reporter, 30 | exclude_path=expected_exclude_path, 31 | min_time=42, 32 | min_query_count=42, 33 | ) 34 | self.assertEqual(mw.app, mocked_app) 35 | self.assertEqual(mw.reporter, mocked_reporter) 36 | self.assertEqual(mw.exclude_path, expected_exclude_path) 37 | self.assertEqual(mw.min_time, 42) 38 | self.assertEqual(mw.min_query_count, 42) 39 | 40 | def test_initialize_reporter_type_error(self): 41 | with self.assertRaises(TypeError) as exec_info: 42 | EasyProfileMiddleware(mock.Mock(), reporter=mock.Mock()) 43 | self.assertEqual( 44 | str(exec_info.exception), 45 | "reporter must be inherited from 'Reporter'" 46 | ) 47 | 48 | def test__report_stats(self): 49 | mocked_reporter = mock.Mock(spec=Reporter) 50 | mw = EasyProfileMiddleware( 51 | mock.Mock(), 52 | reporter=mocked_reporter, 53 | min_time=42, 54 | min_query_count=42, 55 | ) 56 | 57 | # Test that report called 58 | mw._report_stats("path", dict(total=43, duration=43)) 59 | mw.reporter.report.assert_called() 60 | 61 | # Test that report not called 62 | mw.reporter = mock.Mock(spec=Reporter) 63 | mw._report_stats("path", dict(total=41, duration=41)) 64 | mw.reporter.report.assert_not_called() 65 | 66 | def test__ignore_request(self): 67 | patterns = [r"^/api/users", r"^/api/roles", r"^/api/permissions"] 68 | mw = EasyProfileMiddleware(mock.Mock(), exclude_path=patterns) 69 | # Test unavailable path 70 | for path in ["/api/users", "/api/roles", "/api/permissions"]: 71 | self.assertTrue(mw._ignore_request(path)) 72 | # Test available path 73 | for path in ["/faq", "/about", "/search"]: 74 | self.assertFalse(mw._ignore_request(path)) 75 | 76 | def test__call__for_available_path(self): 77 | mw = EasyProfileMiddleware( 78 | mock.Mock(), 79 | reporter=mock.Mock(spec=Reporter), 80 | exclude_path=[r"^/api/users"] 81 | ) 82 | with mock.patch.object(mw, "_report_stats") as mocked_report_stats: 83 | environ = dict(PATH_INFO="/api/roles", REQUEST_METHOD="GET") 84 | mw(environ, None) 85 | mocked_report_stats.assert_called() 86 | expected = environ["REQUEST_METHOD"] + " " + environ["PATH_INFO"] 87 | self.assertEqual(mocked_report_stats.call_args[0][0], expected) 88 | 89 | def test__call__for_unavailable_path(self): 90 | mw = EasyProfileMiddleware( 91 | mock.Mock(), 92 | reporter=mock.Mock(spec=Reporter), 93 | exclude_path=[r"^/api/users"] 94 | ) 95 | with mock.patch.object(mw, "_report_stats") as mocked_report_stats: 96 | environ = dict(PATH_INFO="/api/users", REQUEST_METHOD="GET") 97 | mw(environ, None) 98 | mocked_report_stats.assert_not_called() 99 | 100 | def test__call__with_exception_triggered_when_getting_response(self): 101 | app = mock.Mock() 102 | app.side_effect = Exception("boom") 103 | mw = EasyProfileMiddleware( 104 | app=app, 105 | reporter=mock.Mock(spec=Reporter), 106 | exclude_path=[r"^/api/users"] 107 | ) 108 | with mock.patch.object(mw, "_report_stats") as mocked_report_stats: 109 | environ = dict(PATH_INFO="/api/roles", REQUEST_METHOD="GET") 110 | with self.assertRaises(Exception): 111 | mw(environ, None) 112 | mocked_report_stats.assert_called() 113 | expected = environ["REQUEST_METHOD"] + " " + environ["PATH_INFO"] 114 | self.assertEqual(mocked_report_stats.call_args[0][0], expected) 115 | 116 | def test__call__with_multiple_concurrent_calls(self): 117 | fake_response = "fake response" 118 | 119 | def fake_call(*args, **kwargs): 120 | sleep(1) 121 | return fake_response 122 | 123 | mock_app = mock.Mock() 124 | mock_app.side_effect = fake_call 125 | mw = EasyProfileMiddleware( 126 | mock_app, 127 | reporter=mock.Mock(spec=Reporter), 128 | exclude_path=[r"^/api/users"] 129 | ) 130 | 131 | thread_queue = Queue() 132 | threads = [] 133 | repeats = 5 134 | 135 | with mock.patch.object(mw, "_report_stats"): 136 | environ = dict(PATH_INFO="/api/roles", REQUEST_METHOD="GET") 137 | for i in range(repeats): 138 | thread = Thread( 139 | target=lambda queue, fn, env: queue.put(fn(env, None)), 140 | args=(thread_queue, mw, environ) 141 | ) 142 | thread.start() 143 | threads.append(thread) 144 | [thread.join() for thread in threads] 145 | 146 | results = [] 147 | while not thread_queue.empty(): 148 | results.append(thread_queue.get()) 149 | 150 | assert len(results) == repeats 151 | assert set(results) == {fake_response} 152 | -------------------------------------------------------------------------------- /tests/test_profiler.py: -------------------------------------------------------------------------------- 1 | from collections import Counter 2 | from queue import Queue 3 | import time 4 | import unittest 5 | from unittest import mock 6 | 7 | from sqlalchemy import create_engine, event 8 | from sqlalchemy.engine.base import Engine 9 | from sqlalchemy.sql import text 10 | 11 | from easy_profile.profiler import DebugQuery, SessionProfiler, SQL_OPERATORS 12 | from easy_profile.reporters import Reporter 13 | 14 | 15 | debug_queries = [ 16 | DebugQuery("SELECT id FROM users", {}, 1541489542, 1541489543), 17 | DebugQuery("SELECT id FROM users", {}, 1541489542, 1541489543), 18 | DebugQuery("SELECT name FROM users", {}, 1541489543, 1541489544), 19 | DebugQuery("SELECT gender FROM users", {}, 1541489544, 1541489545), 20 | DebugQuery( 21 | "INSERT INTO users (name) VALUES (%(param_1)s)", 22 | {"param_1": "Arthur Dent"}, 23 | 1541489545, 24 | 1541489546 25 | ), 26 | DebugQuery( 27 | "INSERT INTO users (name) VALUES (%(param_1)s)", 28 | {"param_1": "Ford Prefect"}, 29 | 1541489546, 30 | 1541489547 31 | ), 32 | DebugQuery( 33 | "UPDATE users SET name=(%(param_1)s)", 34 | {"param_1": "Prefect Ford"}, 35 | 1541489547, 36 | 1541489548 37 | ), 38 | DebugQuery("DELETE FROM users", {}, 1541489548, 1541489549), 39 | ] 40 | 41 | 42 | class TestSessionProfiler(unittest.TestCase): 43 | 44 | def test_initialization_default(self): 45 | profiler = SessionProfiler() 46 | self.assertIs(profiler.engine, Engine) 47 | self.assertEqual(profiler.db_name, "default") 48 | self.assertFalse(profiler.alive) 49 | self.assertIsNone(profiler.queries) 50 | 51 | def test_initialization_custom(self): 52 | engine = create_engine("sqlite:///test") 53 | profiler = SessionProfiler(engine) 54 | self.assertIs(profiler.engine, engine) 55 | self.assertEqual(profiler.db_name, "test") 56 | 57 | def test_begin(self): 58 | profiler = SessionProfiler() 59 | with mock.patch.object(profiler, "_reset_stats") as mocked: 60 | profiler.begin() 61 | mocked.assert_called() 62 | self.assertTrue(profiler.alive) 63 | self.assertIsInstance(profiler.queries, Queue) 64 | self.assertTrue(profiler.queries.empty()) 65 | self.assertTrue(event.contains( 66 | profiler.engine, 67 | profiler._before, 68 | profiler._before_cursor_execute 69 | )) 70 | self.assertTrue(event.contains( 71 | profiler.engine, 72 | profiler._after, 73 | profiler._after_cursor_execute 74 | )) 75 | 76 | def test_begin_alive(self): 77 | profiler = SessionProfiler() 78 | profiler.alive = True 79 | with self.assertRaises(AssertionError) as exec_info: 80 | profiler.begin() 81 | 82 | error = exec_info.exception 83 | self.assertEqual(str(error), "Profiling session has already begun") 84 | 85 | def test_commit(self): 86 | profiler = SessionProfiler() 87 | profiler.begin() 88 | with mock.patch.object(profiler, "_get_stats") as mocked: 89 | profiler.commit() 90 | mocked.assert_called() 91 | self.assertFalse(profiler.alive) 92 | self.assertFalse(event.contains( 93 | profiler.engine, 94 | profiler._before, 95 | profiler._before_cursor_execute 96 | )) 97 | self.assertFalse(event.contains( 98 | profiler.engine, 99 | profiler._after, 100 | profiler._after_cursor_execute 101 | )) 102 | 103 | def test_commit_alive(self): 104 | profiler = SessionProfiler() 105 | profiler.alive = False 106 | with self.assertRaises(AssertionError) as exec_info: 107 | profiler.commit() 108 | 109 | error = exec_info.exception 110 | self.assertEqual(str(error), "Profiling session is already committed") 111 | 112 | def test__reset_stats(self): 113 | profiler = SessionProfiler() 114 | profiler._reset_stats() 115 | self.assertEqual(profiler._stats["total"], 0) 116 | self.assertEqual(profiler._stats["duration"], 0) 117 | self.assertEqual(profiler._stats["select"], 0) 118 | self.assertEqual(profiler._stats["insert"], 0) 119 | self.assertEqual(profiler._stats["update"], 0) 120 | self.assertEqual(profiler._stats["call_stack"], []) 121 | self.assertEqual(profiler._stats["duplicates"], Counter()) 122 | self.assertEqual(profiler._stats["db"], profiler.db_name) 123 | 124 | def test__get_stats(self): 125 | profiler = SessionProfiler() 126 | profiler.queries = Queue() 127 | profiler._reset_stats() 128 | duplicates = Counter() 129 | for query in debug_queries: 130 | profiler.queries.put(query) 131 | duplicates_count = duplicates.get(query.statement, -1) 132 | duplicates[query.statement] = duplicates_count + 1 133 | 134 | stats = profiler._get_stats() 135 | 136 | for op in SQL_OPERATORS: 137 | res = filter(lambda x: op.upper() in x.statement, debug_queries) 138 | self.assertEqual(stats[op], len(list(res))) 139 | 140 | self.assertEqual(stats["db"], profiler.db_name) 141 | self.assertEqual(stats["total"], len(debug_queries)) 142 | self.assertListEqual(debug_queries, stats["call_stack"]) 143 | self.assertDictEqual(stats["duplicates"], duplicates) 144 | 145 | @mock.patch("easy_profile.profiler._timer") 146 | def test__before_cursor_execute(self, mocked): 147 | expected_time = time.time() 148 | mocked.return_value = expected_time 149 | profiler = SessionProfiler() 150 | context = mock.Mock() 151 | profiler._before_cursor_execute( 152 | conn=None, 153 | cursor=None, 154 | statement=None, 155 | parameters={}, 156 | context=context, 157 | executemany=None 158 | ) 159 | self.assertEqual(context._query_start_time, expected_time) 160 | 161 | @mock.patch("easy_profile.profiler._timer") 162 | def test__after_cursor_execute(self, mocked): 163 | expected_query = debug_queries[0] 164 | mocked.return_value = expected_query.end_time 165 | profiler = SessionProfiler() 166 | context = mock.Mock() 167 | context._query_start_time = expected_query.start_time 168 | with profiler: 169 | profiler._after_cursor_execute( 170 | conn=None, 171 | cursor=None, 172 | statement=expected_query.statement, 173 | parameters=expected_query.parameters, 174 | context=context, 175 | executemany=None 176 | ) 177 | actual_query = profiler.queries.get() 178 | self.assertEqual(actual_query, expected_query) 179 | 180 | def test_stats(self): 181 | profiler = SessionProfiler() 182 | self.assertIsNotNone(profiler.stats) 183 | 184 | @mock.patch("easy_profile.profiler.SessionProfiler.begin") 185 | @mock.patch("easy_profile.profiler.SessionProfiler.commit") 186 | def test_contextmanager_interface(self, mocked_commit, mocked_begin): 187 | profiler = SessionProfiler() 188 | with profiler: 189 | mocked_begin.assert_called() 190 | mocked_commit.assert_called() 191 | 192 | def test_decorator(self): 193 | engine = self._create_engine() 194 | reporter = mock.Mock(spec=Reporter) 195 | profiler = SessionProfiler(engine) 196 | wrapper = profiler(reporter=reporter) 197 | wrapper(self._decorated_func)(engine) 198 | # Test profile statistics 199 | self.assertEqual(profiler.stats["db"], "undefined") 200 | self.assertEqual(profiler.stats["total"], 4) 201 | self.assertEqual(profiler.stats["select"], 3) 202 | self.assertEqual(profiler.stats["delete"], 1) 203 | 204 | def test_decorator_path(self): 205 | expected_path = "test_path" 206 | engine = self._create_engine() 207 | profiler = SessionProfiler(engine) 208 | reporter = mock.Mock(spec=Reporter) 209 | # Get profiler decorator with specified path 210 | wrapper = profiler(path=expected_path, reporter=reporter) 211 | wrapper(self._decorated_func)(engine) 212 | # Test that reporter method report was called with expected path 213 | reporter.report.assert_called_with(expected_path, profiler.stats) 214 | 215 | def test_decorator_path_callback(self): 216 | expected_path = "path_callback" 217 | 218 | def _callback(func, *args, **kwargs): 219 | return expected_path 220 | 221 | engine = self._create_engine() 222 | profiler = SessionProfiler(engine) 223 | reporter = mock.Mock(spec=Reporter) 224 | # Get profiler decorator with specified path_callback 225 | wrapper = profiler(path_callback=_callback, reporter=reporter) 226 | wrapper(self._decorated_func)(engine) 227 | # Test that reporter method report was called with expected path 228 | reporter.report.assert_called_with(expected_path, profiler.stats) 229 | 230 | def test_decorator_path_and_path_callback(self): 231 | expected_path = "path_callback" 232 | 233 | def _callback(func, *args, **kwargs): 234 | return expected_path 235 | 236 | engine = self._create_engine() 237 | profiler = SessionProfiler(engine) 238 | reporter = mock.Mock(spec=Reporter) 239 | # Get profiler decorator with specified path_callback 240 | wrapper = profiler( 241 | path="fail", 242 | path_callback=_callback, 243 | reporter=reporter 244 | ) 245 | wrapper(self._decorated_func)(engine) 246 | # Test that reporter method report was called with expected path 247 | reporter.report.assert_called_with(expected_path, profiler.stats) 248 | 249 | def _create_engine(self): 250 | """Creates and returns sqlalchemy engine.""" 251 | return create_engine("sqlite://") 252 | 253 | def _decorated_func(self, engine): 254 | """Function for testing profiler as decorator.""" 255 | with engine.begin() as conn: 256 | conn.execute(text("CREATE TABLE users (id int, name varchar(8))")) 257 | conn.execute(text("SELECT id FROM users")) 258 | conn.execute(text("SELECT id FROM users")) 259 | conn.execute(text("SELECT name FROM users")) 260 | conn.execute(text("DELETE FROM users")) 261 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "coverage" 5 | version = "7.10.7" 6 | description = "Code coverage measurement for Python" 7 | optional = false 8 | python-versions = ">=3.9" 9 | groups = ["dev"] 10 | files = [ 11 | {file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"}, 12 | {file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"}, 13 | {file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"}, 14 | {file = "coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b"}, 15 | {file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"}, 16 | {file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"}, 17 | {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"}, 18 | {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"}, 19 | {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"}, 20 | {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"}, 21 | {file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"}, 22 | {file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"}, 23 | {file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"}, 24 | {file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"}, 25 | {file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"}, 26 | {file = "coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d"}, 27 | {file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"}, 28 | {file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"}, 29 | {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"}, 30 | {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"}, 31 | {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"}, 32 | {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"}, 33 | {file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"}, 34 | {file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"}, 35 | {file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"}, 36 | {file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"}, 37 | {file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"}, 38 | {file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"}, 39 | {file = "coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7"}, 40 | {file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"}, 41 | {file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"}, 42 | {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"}, 43 | {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"}, 44 | {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"}, 45 | {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"}, 46 | {file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"}, 47 | {file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"}, 48 | {file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"}, 49 | {file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"}, 50 | {file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"}, 51 | {file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"}, 52 | {file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"}, 53 | {file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"}, 54 | {file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"}, 55 | {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"}, 56 | {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"}, 57 | {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"}, 58 | {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"}, 59 | {file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"}, 60 | {file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"}, 61 | {file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"}, 62 | {file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"}, 63 | {file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"}, 64 | {file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"}, 65 | {file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"}, 66 | {file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"}, 67 | {file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"}, 68 | {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"}, 69 | {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"}, 70 | {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"}, 71 | {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"}, 72 | {file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"}, 73 | {file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"}, 74 | {file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"}, 75 | {file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"}, 76 | {file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"}, 77 | {file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"}, 78 | {file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"}, 79 | {file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"}, 80 | {file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"}, 81 | {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"}, 82 | {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"}, 83 | {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"}, 84 | {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"}, 85 | {file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"}, 86 | {file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"}, 87 | {file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"}, 88 | {file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"}, 89 | {file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"}, 90 | {file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"}, 91 | {file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"}, 92 | {file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"}, 93 | {file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"}, 94 | {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"}, 95 | {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"}, 96 | {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"}, 97 | {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"}, 98 | {file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"}, 99 | {file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"}, 100 | {file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"}, 101 | {file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"}, 102 | {file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"}, 103 | {file = "coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396"}, 104 | {file = "coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40"}, 105 | {file = "coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594"}, 106 | {file = "coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a"}, 107 | {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b"}, 108 | {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3"}, 109 | {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0"}, 110 | {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f"}, 111 | {file = "coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431"}, 112 | {file = "coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07"}, 113 | {file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"}, 114 | {file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"}, 115 | ] 116 | 117 | [package.extras] 118 | toml = ["tomli ; python_full_version <= \"3.11.0a6\""] 119 | 120 | [[package]] 121 | name = "flake8" 122 | version = "7.3.0" 123 | description = "the modular source code checker: pep8 pyflakes and co" 124 | optional = false 125 | python-versions = ">=3.9" 126 | groups = ["dev"] 127 | files = [ 128 | {file = "flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e"}, 129 | {file = "flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872"}, 130 | ] 131 | 132 | [package.dependencies] 133 | mccabe = ">=0.7.0,<0.8.0" 134 | pycodestyle = ">=2.14.0,<2.15.0" 135 | pyflakes = ">=3.4.0,<3.5.0" 136 | 137 | [[package]] 138 | name = "flake8-import-order" 139 | version = "0.19.2" 140 | description = "Flake8 and pylama plugin that checks the ordering of import statements." 141 | optional = false 142 | python-versions = "*" 143 | groups = ["dev"] 144 | files = [ 145 | {file = "flake8_import_order-0.19.2-py3-none-any.whl", hash = "sha256:2dfe60175e7195cf36d4c573861fd2e3258cd6650cbd7616da3c6b8193b29b7c"}, 146 | {file = "flake8_import_order-0.19.2.tar.gz", hash = "sha256:133b3c55497631e4235074fc98a95078bba817832379f22a31f0ad2455bcb0b2"}, 147 | ] 148 | 149 | [package.dependencies] 150 | pycodestyle = "*" 151 | setuptools = "*" 152 | 153 | [[package]] 154 | name = "flake8-quotes" 155 | version = "3.4.0" 156 | description = "Flake8 lint for quotes." 157 | optional = false 158 | python-versions = "*" 159 | groups = ["dev"] 160 | files = [ 161 | {file = "flake8-quotes-3.4.0.tar.gz", hash = "sha256:aad8492fb710a2d3eabe68c5f86a1428de650c8484127e14c43d0504ba30276c"}, 162 | ] 163 | 164 | [package.dependencies] 165 | flake8 = "*" 166 | setuptools = "*" 167 | 168 | [[package]] 169 | name = "greenlet" 170 | version = "3.2.4" 171 | description = "Lightweight in-process concurrent programming" 172 | optional = false 173 | python-versions = ">=3.9" 174 | groups = ["main"] 175 | markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\"" 176 | files = [ 177 | {file = "greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c"}, 178 | {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590"}, 179 | {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c"}, 180 | {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b"}, 181 | {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31"}, 182 | {file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"}, 183 | {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"}, 184 | {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"}, 185 | {file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"}, 186 | {file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"}, 187 | {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"}, 188 | {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3"}, 189 | {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633"}, 190 | {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079"}, 191 | {file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"}, 192 | {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"}, 193 | {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"}, 194 | {file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"}, 195 | {file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"}, 196 | {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"}, 197 | {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968"}, 198 | {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9"}, 199 | {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6"}, 200 | {file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"}, 201 | {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"}, 202 | {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"}, 203 | {file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"}, 204 | {file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"}, 205 | {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"}, 206 | {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc"}, 207 | {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a"}, 208 | {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504"}, 209 | {file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"}, 210 | {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"}, 211 | {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"}, 212 | {file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"}, 213 | {file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"}, 214 | {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"}, 215 | {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5"}, 216 | {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"}, 217 | {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"}, 218 | {file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"}, 219 | {file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"}, 220 | {file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"}, 221 | {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"}, 222 | {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:18d9260df2b5fbf41ae5139e1be4e796d99655f023a636cd0e11e6406cca7d58"}, 223 | {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:671df96c1f23c4a0d4077a325483c1503c96a1b7d9db26592ae770daa41233d4"}, 224 | {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:16458c245a38991aa19676900d48bd1a6f2ce3e16595051a4db9d012154e8433"}, 225 | {file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"}, 226 | {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"}, 227 | {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"}, 228 | {file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"}, 229 | {file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"}, 230 | {file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"}, 231 | ] 232 | 233 | [package.extras] 234 | docs = ["Sphinx", "furo"] 235 | test = ["objgraph", "psutil", "setuptools"] 236 | 237 | [[package]] 238 | name = "mccabe" 239 | version = "0.7.0" 240 | description = "McCabe checker, plugin for flake8" 241 | optional = false 242 | python-versions = ">=3.6" 243 | groups = ["dev"] 244 | files = [ 245 | {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, 246 | {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, 247 | ] 248 | 249 | [[package]] 250 | name = "pycodestyle" 251 | version = "2.14.0" 252 | description = "Python style guide checker" 253 | optional = false 254 | python-versions = ">=3.9" 255 | groups = ["dev"] 256 | files = [ 257 | {file = "pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d"}, 258 | {file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"}, 259 | ] 260 | 261 | [[package]] 262 | name = "pyflakes" 263 | version = "3.4.0" 264 | description = "passive checker of Python programs" 265 | optional = false 266 | python-versions = ">=3.9" 267 | groups = ["dev"] 268 | files = [ 269 | {file = "pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f"}, 270 | {file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"}, 271 | ] 272 | 273 | [[package]] 274 | name = "setuptools" 275 | version = "80.9.0" 276 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 277 | optional = false 278 | python-versions = ">=3.9" 279 | groups = ["dev"] 280 | files = [ 281 | {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, 282 | {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, 283 | ] 284 | 285 | [package.extras] 286 | check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] 287 | core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] 288 | cover = ["pytest-cov"] 289 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] 290 | enabler = ["pytest-enabler (>=2.2)"] 291 | test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] 292 | type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] 293 | 294 | [[package]] 295 | name = "sqlalchemy" 296 | version = "2.0.44" 297 | description = "Database Abstraction Library" 298 | optional = false 299 | python-versions = ">=3.7" 300 | groups = ["main"] 301 | files = [ 302 | {file = "SQLAlchemy-2.0.44-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:471733aabb2e4848d609141a9e9d56a427c0a038f4abf65dd19d7a21fd563632"}, 303 | {file = "SQLAlchemy-2.0.44-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48bf7d383a35e668b984c805470518b635d48b95a3c57cb03f37eaa3551b5f9f"}, 304 | {file = "SQLAlchemy-2.0.44-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bf4bb6b3d6228fcf3a71b50231199fb94d2dd2611b66d33be0578ea3e6c2726"}, 305 | {file = "SQLAlchemy-2.0.44-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:e998cf7c29473bd077704cea3577d23123094311f59bdc4af551923b168332b1"}, 306 | {file = "SQLAlchemy-2.0.44-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:ebac3f0b5732014a126b43c2b7567f2f0e0afea7d9119a3378bde46d3dcad88e"}, 307 | {file = "SQLAlchemy-2.0.44-cp37-cp37m-win32.whl", hash = "sha256:3255d821ee91bdf824795e936642bbf43a4c7cedf5d1aed8d24524e66843aa74"}, 308 | {file = "SQLAlchemy-2.0.44-cp37-cp37m-win_amd64.whl", hash = "sha256:78e6c137ba35476adb5432103ae1534f2f5295605201d946a4198a0dea4b38e7"}, 309 | {file = "sqlalchemy-2.0.44-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7c77f3080674fc529b1bd99489378c7f63fcb4ba7f8322b79732e0258f0ea3ce"}, 310 | {file = "sqlalchemy-2.0.44-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c26ef74ba842d61635b0152763d057c8d48215d5be9bb8b7604116a059e9985"}, 311 | {file = "sqlalchemy-2.0.44-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4a172b31785e2f00780eccab00bc240ccdbfdb8345f1e6063175b3ff12ad1b0"}, 312 | {file = "sqlalchemy-2.0.44-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9480c0740aabd8cb29c329b422fb65358049840b34aba0adf63162371d2a96e"}, 313 | {file = "sqlalchemy-2.0.44-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:17835885016b9e4d0135720160db3095dc78c583e7b902b6be799fb21035e749"}, 314 | {file = "sqlalchemy-2.0.44-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cbe4f85f50c656d753890f39468fcd8190c5f08282caf19219f684225bfd5fd2"}, 315 | {file = "sqlalchemy-2.0.44-cp310-cp310-win32.whl", hash = "sha256:2fcc4901a86ed81dc76703f3b93ff881e08761c63263c46991081fd7f034b165"}, 316 | {file = "sqlalchemy-2.0.44-cp310-cp310-win_amd64.whl", hash = "sha256:9919e77403a483ab81e3423151e8ffc9dd992c20d2603bf17e4a8161111e55f5"}, 317 | {file = "sqlalchemy-2.0.44-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0fe3917059c7ab2ee3f35e77757062b1bea10a0b6ca633c58391e3f3c6c488dd"}, 318 | {file = "sqlalchemy-2.0.44-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:de4387a354ff230bc979b46b2207af841dc8bf29847b6c7dbe60af186d97aefa"}, 319 | {file = "sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3678a0fb72c8a6a29422b2732fe423db3ce119c34421b5f9955873eb9b62c1e"}, 320 | {file = "sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cf6872a23601672d61a68f390e44703442639a12ee9dd5a88bbce52a695e46e"}, 321 | {file = "sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:329aa42d1be9929603f406186630135be1e7a42569540577ba2c69952b7cf399"}, 322 | {file = "sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:70e03833faca7166e6a9927fbee7c27e6ecde436774cd0b24bbcc96353bce06b"}, 323 | {file = "sqlalchemy-2.0.44-cp311-cp311-win32.whl", hash = "sha256:253e2f29843fb303eca6b2fc645aca91fa7aa0aa70b38b6950da92d44ff267f3"}, 324 | {file = "sqlalchemy-2.0.44-cp311-cp311-win_amd64.whl", hash = "sha256:7a8694107eb4308a13b425ca8c0e67112f8134c846b6e1f722698708741215d5"}, 325 | {file = "sqlalchemy-2.0.44-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72fea91746b5890f9e5e0997f16cbf3d53550580d76355ba2d998311b17b2250"}, 326 | {file = "sqlalchemy-2.0.44-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:585c0c852a891450edbb1eaca8648408a3cc125f18cf433941fa6babcc359e29"}, 327 | {file = "sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b94843a102efa9ac68a7a30cd46df3ff1ed9c658100d30a725d10d9c60a2f44"}, 328 | {file = "sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:119dc41e7a7defcefc57189cfa0e61b1bf9c228211aba432b53fb71ef367fda1"}, 329 | {file = "sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0765e318ee9179b3718c4fd7ba35c434f4dd20332fbc6857a5e8df17719c24d7"}, 330 | {file = "sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e7b5b079055e02d06a4308d0481658e4f06bc7ef211567edc8f7d5dce52018d"}, 331 | {file = "sqlalchemy-2.0.44-cp312-cp312-win32.whl", hash = "sha256:846541e58b9a81cce7dee8329f352c318de25aa2f2bbe1e31587eb1f057448b4"}, 332 | {file = "sqlalchemy-2.0.44-cp312-cp312-win_amd64.whl", hash = "sha256:7cbcb47fd66ab294703e1644f78971f6f2f1126424d2b300678f419aa73c7b6e"}, 333 | {file = "sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1"}, 334 | {file = "sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45"}, 335 | {file = "sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976"}, 336 | {file = "sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c"}, 337 | {file = "sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d"}, 338 | {file = "sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40"}, 339 | {file = "sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73"}, 340 | {file = "sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e"}, 341 | {file = "sqlalchemy-2.0.44-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2fc44e5965ea46909a416fff0af48a219faefd5773ab79e5f8a5fcd5d62b2667"}, 342 | {file = "sqlalchemy-2.0.44-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:dc8b3850d2a601ca2320d081874033684e246d28e1c5e89db0864077cfc8f5a9"}, 343 | {file = "sqlalchemy-2.0.44-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d733dec0614bb8f4bcb7c8af88172b974f685a31dc3a65cca0527e3120de5606"}, 344 | {file = "sqlalchemy-2.0.44-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22be14009339b8bc16d6b9dc8780bacaba3402aa7581658e246114abbd2236e3"}, 345 | {file = "sqlalchemy-2.0.44-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:357bade0e46064f88f2c3a99808233e67b0051cdddf82992379559322dfeb183"}, 346 | {file = "sqlalchemy-2.0.44-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4848395d932e93c1595e59a8672aa7400e8922c39bb9b0668ed99ac6fa867822"}, 347 | {file = "sqlalchemy-2.0.44-cp38-cp38-win32.whl", hash = "sha256:2f19644f27c76f07e10603580a47278abb2a70311136a7f8fd27dc2e096b9013"}, 348 | {file = "sqlalchemy-2.0.44-cp38-cp38-win_amd64.whl", hash = "sha256:1df4763760d1de0dfc8192cc96d8aa293eb1a44f8f7a5fbe74caf1b551905c5e"}, 349 | {file = "sqlalchemy-2.0.44-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f7027414f2b88992877573ab780c19ecb54d3a536bef3397933573d6b5068be4"}, 350 | {file = "sqlalchemy-2.0.44-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3fe166c7d00912e8c10d3a9a0ce105569a31a3d0db1a6e82c4e0f4bf16d5eca9"}, 351 | {file = "sqlalchemy-2.0.44-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3caef1ff89b1caefc28f0368b3bde21a7e3e630c2eddac16abd9e47bd27cc36a"}, 352 | {file = "sqlalchemy-2.0.44-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc2856d24afa44295735e72f3c75d6ee7fdd4336d8d3a8f3d44de7aa6b766df2"}, 353 | {file = "sqlalchemy-2.0.44-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:11bac86b0deada30b6b5f93382712ff0e911fe8d31cb9bf46e6b149ae175eff0"}, 354 | {file = "sqlalchemy-2.0.44-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4d18cd0e9a0f37c9f4088e50e3839fcb69a380a0ec957408e0b57cff08ee0a26"}, 355 | {file = "sqlalchemy-2.0.44-cp39-cp39-win32.whl", hash = "sha256:9e9018544ab07614d591a26c1bd4293ddf40752cc435caf69196740516af7100"}, 356 | {file = "sqlalchemy-2.0.44-cp39-cp39-win_amd64.whl", hash = "sha256:8e0e4e66fd80f277a8c3de016a81a554e76ccf6b8d881ee0b53200305a8433f6"}, 357 | {file = "sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05"}, 358 | {file = "sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22"}, 359 | ] 360 | 361 | [package.dependencies] 362 | greenlet = {version = ">=1", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} 363 | typing-extensions = ">=4.6.0" 364 | 365 | [package.extras] 366 | aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] 367 | aioodbc = ["aioodbc", "greenlet (>=1)"] 368 | aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] 369 | asyncio = ["greenlet (>=1)"] 370 | asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"] 371 | mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] 372 | mssql = ["pyodbc"] 373 | mssql-pymssql = ["pymssql"] 374 | mssql-pyodbc = ["pyodbc"] 375 | mypy = ["mypy (>=0.910)"] 376 | mysql = ["mysqlclient (>=1.4.0)"] 377 | mysql-connector = ["mysql-connector-python"] 378 | oracle = ["cx_oracle (>=8)"] 379 | oracle-oracledb = ["oracledb (>=1.0.1)"] 380 | postgresql = ["psycopg2 (>=2.7)"] 381 | postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] 382 | postgresql-pg8000 = ["pg8000 (>=1.29.1)"] 383 | postgresql-psycopg = ["psycopg (>=3.0.7)"] 384 | postgresql-psycopg2binary = ["psycopg2-binary"] 385 | postgresql-psycopg2cffi = ["psycopg2cffi"] 386 | postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] 387 | pymysql = ["pymysql"] 388 | sqlcipher = ["sqlcipher3_binary"] 389 | 390 | [[package]] 391 | name = "sqlparse" 392 | version = "0.5.3" 393 | description = "A non-validating SQL parser." 394 | optional = false 395 | python-versions = ">=3.8" 396 | groups = ["main"] 397 | files = [ 398 | {file = "sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"}, 399 | {file = "sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272"}, 400 | ] 401 | 402 | [package.extras] 403 | dev = ["build", "hatch"] 404 | doc = ["sphinx"] 405 | 406 | [[package]] 407 | name = "typing-extensions" 408 | version = "4.15.0" 409 | description = "Backported and Experimental Type Hints for Python 3.9+" 410 | optional = false 411 | python-versions = ">=3.9" 412 | groups = ["main"] 413 | files = [ 414 | {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, 415 | {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, 416 | ] 417 | 418 | [metadata] 419 | lock-version = "2.1" 420 | python-versions = ">=3.9,<4.0" 421 | content-hash = "c1f7c802e01d86c7948e865a21fa1f65876dc09251f34fd8001fa536e0da52ce" 422 | --------------------------------------------------------------------------------